1#!/usr/bin/env python3 2# 3# Copyright 2016 - Google, Inc. 4# 5# Licensed under the Apache License, Version 2.0 (the "License"); 6# you may not use this file except in compliance with the License. 7# You may obtain a copy of the License at 8# 9# http://www.apache.org/licenses/LICENSE-2.0 10# 11# Unless required by applicable law or agreed to in writing, software 12# distributed under the License is distributed on an "AS IS" BASIS, 13# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14# See the License for the specific language governing permissions and 15# limitations under the License. 16 17import collections 18import ipaddress 19import logging 20import time 21 22from acts import logger 23from acts.controllers.ap_lib import ap_get_interface 24from acts.controllers.ap_lib import bridge_interface 25from acts.controllers.ap_lib import dhcp_config 26from acts.controllers.ap_lib import dhcp_server 27from acts.controllers.ap_lib import hostapd 28from acts.controllers.ap_lib import hostapd_config 29from acts.controllers.utils_lib.commands import ip 30from acts.controllers.utils_lib.commands import route 31from acts.controllers.utils_lib.commands import shell 32from acts.controllers.utils_lib.ssh import connection 33from acts.controllers.utils_lib.ssh import settings 34from acts.libs.proc import job 35 36ACTS_CONTROLLER_CONFIG_NAME = 'AccessPoint' 37ACTS_CONTROLLER_REFERENCE_NAME = 'access_points' 38_BRCTL = 'brctl' 39 40 41def create(configs): 42 """Creates ap controllers from a json config. 43 44 Creates an ap controller from either a list, or a single 45 element. The element can either be just the hostname or a dictionary 46 containing the hostname and username of the ap to connect to over ssh. 47 48 Args: 49 The json configs that represent this controller. 50 51 Returns: 52 A new AccessPoint. 53 """ 54 return [AccessPoint(c) for c in configs] 55 56 57def destroy(aps): 58 """Destroys a list of access points. 59 60 Args: 61 aps: The list of access points to destroy. 62 """ 63 for ap in aps: 64 ap.close() 65 66 67def get_info(aps): 68 """Get information on a list of access points. 69 70 Args: 71 aps: A list of AccessPoints. 72 73 Returns: 74 A list of all aps hostname. 75 """ 76 return [ap.ssh_settings.hostname for ap in aps] 77 78 79class Error(Exception): 80 """Error raised when there is a problem with the access point.""" 81 82 83_ApInstance = collections.namedtuple('_ApInstance', ['hostapd', 'subnet']) 84 85# These ranges were split this way since each physical radio can have up 86# to 8 SSIDs so for the 2GHz radio the DHCP range will be 87# 192.168.1 - 8 and the 5Ghz radio will be 192.168.9 - 16 88_AP_2GHZ_SUBNET_STR_DEFAULT = '192.168.1.0/24' 89_AP_5GHZ_SUBNET_STR_DEFAULT = '192.168.9.0/24' 90 91# The last digit of the ip for the bridge interface 92BRIDGE_IP_LAST = '100' 93 94 95class AccessPoint(object): 96 """An access point controller. 97 98 Attributes: 99 ssh: The ssh connection to this ap. 100 ssh_settings: The ssh settings being used by the ssh connection. 101 dhcp_settings: The dhcp server settings being used. 102 """ 103 104 def __init__(self, configs): 105 """ 106 Args: 107 configs: configs for the access point from config file. 108 """ 109 self.ssh_settings = settings.from_config(configs['ssh_config']) 110 self.log = logger.create_logger(lambda msg: '[Access Point|%s] %s' % ( 111 self.ssh_settings.hostname, msg)) 112 113 if 'ap_subnet' in configs: 114 self._AP_2G_SUBNET_STR = configs['ap_subnet']['2g'] 115 self._AP_5G_SUBNET_STR = configs['ap_subnet']['5g'] 116 else: 117 self._AP_2G_SUBNET_STR = _AP_2GHZ_SUBNET_STR_DEFAULT 118 self._AP_5G_SUBNET_STR = _AP_5GHZ_SUBNET_STR_DEFAULT 119 120 self._AP_2G_SUBNET = dhcp_config.Subnet( 121 ipaddress.ip_network(self._AP_2G_SUBNET_STR)) 122 self._AP_5G_SUBNET = dhcp_config.Subnet( 123 ipaddress.ip_network(self._AP_5G_SUBNET_STR)) 124 125 self.ssh = connection.SshConnection(self.ssh_settings) 126 127 # Singleton utilities for running various commands. 128 self._ip_cmd = ip.LinuxIpCommand(self.ssh) 129 self._route_cmd = route.LinuxRouteCommand(self.ssh) 130 131 # A map from network interface name to _ApInstance objects representing 132 # the hostapd instance running against the interface. 133 self._aps = dict() 134 self.bridge = bridge_interface.BridgeInterface(self) 135 self.interfaces = ap_get_interface.ApInterfaces(self) 136 137 # Get needed interface names and initialize the unneccessary ones. 138 self.wan = self.interfaces.get_wan_interface() 139 self.wlan = self.interfaces.get_wlan_interface() 140 self.wlan_2g = self.wlan[0] 141 self.wlan_5g = self.wlan[1] 142 self.lan = self.interfaces.get_lan_interface() 143 self.__initial_ap() 144 145 def __initial_ap(self): 146 """Initial AP interfaces. 147 148 Bring down hostapd if instance is running, bring down all bridge 149 interfaces. 150 """ 151 try: 152 # This is necessary for Gale/Whirlwind flashed with dev channel image 153 # Unused interfaces such as existing hostapd daemon, guest, mesh 154 # interfaces need to be brought down as part of the AP initialization 155 # process, otherwise test would fail. 156 try: 157 self.ssh.run('stop hostapd') 158 except job.Error: 159 self.log.debug('No hostapd running') 160 # Bring down all wireless interfaces 161 for iface in self.wlan: 162 WLAN_DOWN = 'ifconfig {} down'.format(iface) 163 self.ssh.run(WLAN_DOWN) 164 # Bring down all bridge interfaces 165 bridge_interfaces = self.interfaces.get_bridge_interface() 166 if bridge_interfaces: 167 for iface in bridge_interfaces: 168 BRIDGE_DOWN = 'ifconfig {} down'.format(iface) 169 BRIDGE_DEL = 'brctl delbr {}'.format(iface) 170 self.ssh.run(BRIDGE_DOWN) 171 self.ssh.run(BRIDGE_DEL) 172 except Exception: 173 # TODO(b/76101464): APs may not clean up properly from previous 174 # runs. Rebooting the AP can put them back into the correct state. 175 self.log.exception('Unable to bring down hostapd. Rebooting.') 176 # Reboot the AP. 177 try: 178 self.ssh.run('reboot') 179 # This sleep ensures the device had time to go down. 180 time.sleep(10) 181 self.ssh.run('echo connected', timeout=300) 182 except Exception as e: 183 self.log.exception("Error in rebooting AP: %s", e) 184 raise 185 186 def start_ap(self, hostapd_config, additional_parameters=None): 187 """Starts as an ap using a set of configurations. 188 189 This will start an ap on this host. To start an ap the controller 190 selects a network interface to use based on the configs given. It then 191 will start up hostapd on that interface. Next a subnet is created for 192 the network interface and dhcp server is refreshed to give out ips 193 for that subnet for any device that connects through that interface. 194 195 Args: 196 hostapd_config: hostapd_config.HostapdConfig, The configurations 197 to use when starting up the ap. 198 additional_parameters: A dictionary of parameters that can sent 199 directly into the hostapd config file. This 200 can be used for debugging and or adding one 201 off parameters into the config. 202 203 Returns: 204 An identifier for the ap being run. This identifier can be used 205 later by this controller to control the ap. 206 207 Raises: 208 Error: When the ap can't be brought up. 209 """ 210 211 if hostapd_config.frequency < 5000: 212 interface = self.wlan_2g 213 subnet = self._AP_2G_SUBNET 214 else: 215 interface = self.wlan_5g 216 subnet = self._AP_5G_SUBNET 217 218 # In order to handle dhcp servers on any interface, the initiation of 219 # the dhcp server must be done after the wlan interfaces are figured 220 # out as opposed to being in __init__ 221 self._dhcp = dhcp_server.DhcpServer(self.ssh, interface=interface) 222 223 # For multi bssid configurations the mac address 224 # of the wireless interface needs to have enough space to mask out 225 # up to 8 different mac addresses. The easiest way to do this 226 # is to set the last byte to 0. While technically this could 227 # cause a duplicate mac address it is unlikely and will allow for 228 # one radio to have up to 8 APs on the interface. 229 interface_mac_orig = None 230 cmd = "ifconfig %s|grep ether|awk -F' ' '{print $2}'" % interface 231 interface_mac_orig = self.ssh.run(cmd) 232 hostapd_config.bssid = interface_mac_orig.stdout[:-1] + '0' 233 234 if interface in self._aps: 235 raise ValueError('No WiFi interface available for AP on ' 236 'channel %d' % hostapd_config.channel) 237 238 apd = hostapd.Hostapd(self.ssh, interface) 239 new_instance = _ApInstance(hostapd=apd, subnet=subnet) 240 self._aps[interface] = new_instance 241 242 # Turn off the DHCP server, we're going to change its settings. 243 self._dhcp.stop() 244 # Clear all routes to prevent old routes from interfering. 245 self._route_cmd.clear_routes(net_interface=interface) 246 247 if hostapd_config.bss_lookup: 248 # The dhcp_bss dictionary is created to hold the key/value 249 # pair of the interface name and the ip scope that will be 250 # used for the particular interface. The a, b, c, d 251 # variables below are the octets for the ip address. The 252 # third octet is then incremented for each interface that 253 # is requested. This part is designed to bring up the 254 # hostapd interfaces and not the DHCP servers for each 255 # interface. 256 dhcp_bss = {} 257 counter = 1 258 for bss in hostapd_config.bss_lookup: 259 if interface_mac_orig: 260 hostapd_config.bss_lookup[ 261 bss].bssid = interface_mac_orig.stdout[:-1] + str( 262 counter) 263 self._route_cmd.clear_routes(net_interface=str(bss)) 264 if interface is self.wlan_2g: 265 starting_ip_range = self._AP_2G_SUBNET_STR 266 else: 267 starting_ip_range = self._AP_5G_SUBNET_STR 268 a, b, c, d = starting_ip_range.split('.') 269 dhcp_bss[bss] = dhcp_config.Subnet( 270 ipaddress.ip_network('%s.%s.%s.%s' % 271 (a, b, str(int(c) + counter), d))) 272 counter = counter + 1 273 274 apd.start(hostapd_config, additional_parameters=additional_parameters) 275 276 # The DHCP serer requires interfaces to have ips and routes before 277 # the server will come up. 278 interface_ip = ipaddress.ip_interface( 279 '%s/%s' % (subnet.router, subnet.network.netmask)) 280 self._ip_cmd.set_ipv4_address(interface, interface_ip) 281 if hostapd_config.bss_lookup: 282 # This loop goes through each interface that was setup for 283 # hostapd and assigns the DHCP scopes that were defined but 284 # not used during the hostapd loop above. The k and v 285 # variables represent the interface name, k, and dhcp info, v. 286 for k, v in dhcp_bss.items(): 287 bss_interface_ip = ipaddress.ip_interface( 288 '%s/%s' % (dhcp_bss[k].router, 289 dhcp_bss[k].network.netmask)) 290 self._ip_cmd.set_ipv4_address(str(k), bss_interface_ip) 291 292 # Restart the DHCP server with our updated list of subnets. 293 configured_subnets = [x.subnet for x in self._aps.values()] 294 if hostapd_config.bss_lookup: 295 for k, v in dhcp_bss.items(): 296 configured_subnets.append(v) 297 298 self._dhcp.start(config=dhcp_config.DhcpConfig(configured_subnets)) 299 300 # The following three commands are needed to enable bridging between 301 # the WAN and LAN/WLAN ports. This means anyone connecting to the 302 # WLAN/LAN ports will be able to access the internet if the WAN port 303 # is connected to the internet. 304 self.ssh.run('iptables -t nat -F') 305 self.ssh.run( 306 'iptables -t nat -A POSTROUTING -o %s -j MASQUERADE' % self.wan) 307 self.ssh.run('echo 1 > /proc/sys/net/ipv4/ip_forward') 308 309 return interface 310 311 def get_bssid_from_ssid(self, ssid): 312 """Gets the BSSID from a provided SSID 313 314 Args: 315 ssid: An SSID string 316 Returns: The BSSID if on the AP or None if SSID could not be found. 317 """ 318 319 interfaces = [self.wlan_2g, self.wlan_5g, ssid] 320 # Get the interface name associated with the given ssid. 321 for interface in interfaces: 322 cmd = "iw dev %s info|grep ssid|awk -F' ' '{print $2}'" % ( 323 str(interface)) 324 iw_output = self.ssh.run(cmd) 325 if 'command failed: No such device' in iw_output.stderr: 326 continue 327 else: 328 # If the configured ssid is equal to the given ssid, we found 329 # the right interface. 330 if iw_output.stdout == ssid: 331 cmd = "iw dev %s info|grep addr|awk -F' ' '{print $2}'" % ( 332 str(interface)) 333 iw_output = self.ssh.run(cmd) 334 return iw_output.stdout 335 return None 336 337 def stop_ap(self, identifier): 338 """Stops a running ap on this controller. 339 340 Args: 341 identifier: The identify of the ap that should be taken down. 342 """ 343 344 if identifier not in list(self._aps.keys()): 345 raise ValueError('Invalid identifier %s given' % identifier) 346 347 instance = self._aps.get(identifier) 348 349 instance.hostapd.stop() 350 self._dhcp.stop() 351 self._ip_cmd.clear_ipv4_addresses(identifier) 352 353 # DHCP server needs to refresh in order to tear down the subnet no 354 # longer being used. In the event that all interfaces are torn down 355 # then an exception gets thrown. We need to catch this exception and 356 # check that all interfaces should actually be down. 357 configured_subnets = [x.subnet for x in self._aps.values()] 358 del self._aps[identifier] 359 if configured_subnets: 360 self._dhcp.start(dhcp_config.DhcpConfig(configured_subnets)) 361 362 def stop_all_aps(self): 363 """Stops all running aps on this device.""" 364 365 for ap in list(self._aps.keys()): 366 try: 367 self.stop_ap(ap) 368 except dhcp_server.NoInterfaceError as e: 369 pass 370 371 def close(self): 372 """Called to take down the entire access point. 373 374 When called will stop all aps running on this host, shutdown the dhcp 375 server, and stop the ssh connection. 376 """ 377 378 if self._aps: 379 self.stop_all_aps() 380 self.ssh.close() 381 382 def generate_bridge_configs(self, channel): 383 """Generate a list of configs for a bridge between LAN and WLAN. 384 385 Args: 386 channel: the channel WLAN interface is brought up on 387 iface_lan: the LAN interface to bridge 388 Returns: 389 configs: tuple containing iface_wlan, iface_lan and bridge_ip 390 """ 391 392 if channel < 15: 393 iface_wlan = self.wlan_2g 394 subnet_str = self._AP_2G_SUBNET_STR 395 else: 396 iface_wlan = self.wlan_5g 397 subnet_str = self._AP_5G_SUBNET_STR 398 399 iface_lan = self.lan 400 401 a, b, c, d = subnet_str.strip('/24').split('.') 402 bridge_ip = "%s.%s.%s.%s" % (a, b, c, BRIDGE_IP_LAST) 403 404 configs = (iface_wlan, iface_lan, bridge_ip) 405 406 return configs 407