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 os 21import time 22 23from acts import logger 24from acts.controllers.ap_lib import ap_get_interface 25from acts.controllers.ap_lib import bridge_interface 26from acts.controllers.ap_lib import dhcp_config 27from acts.controllers.ap_lib import dhcp_server 28from acts.controllers.ap_lib import hostapd 29from acts.controllers.ap_lib import hostapd_config 30from acts.controllers.ap_lib import hostapd_constants 31from acts.controllers.utils_lib.commands import ip 32from acts.controllers.utils_lib.commands import route 33from acts.controllers.utils_lib.commands import shell 34from acts.controllers.utils_lib.ssh import connection 35from acts.controllers.utils_lib.ssh import settings 36from acts.libs.proc import job 37 38ACTS_CONTROLLER_CONFIG_NAME = 'AccessPoint' 39ACTS_CONTROLLER_REFERENCE_NAME = 'access_points' 40_BRCTL = 'brctl' 41 42LIFETIME = 180 43PROC_NET_SNMP6 = '/proc/net/snmp6' 44SCAPY_INSTALL_COMMAND = 'sudo python setup.py install' 45RA_MULTICAST_ADDR = '33:33:00:00:00:01' 46RA_SCRIPT = 'sendra.py' 47 48 49def create(configs): 50 """Creates ap controllers from a json config. 51 52 Creates an ap controller from either a list, or a single 53 element. The element can either be just the hostname or a dictionary 54 containing the hostname and username of the ap to connect to over ssh. 55 56 Args: 57 The json configs that represent this controller. 58 59 Returns: 60 A new AccessPoint. 61 """ 62 return [AccessPoint(c) for c in configs] 63 64 65def destroy(aps): 66 """Destroys a list of access points. 67 68 Args: 69 aps: The list of access points to destroy. 70 """ 71 for ap in aps: 72 ap.close() 73 74 75def get_info(aps): 76 """Get information on a list of access points. 77 78 Args: 79 aps: A list of AccessPoints. 80 81 Returns: 82 A list of all aps hostname. 83 """ 84 return [ap.ssh_settings.hostname for ap in aps] 85 86 87class Error(Exception): 88 """Error raised when there is a problem with the access point.""" 89 90 91_ApInstance = collections.namedtuple('_ApInstance', ['hostapd', 'subnet']) 92 93# These ranges were split this way since each physical radio can have up 94# to 8 SSIDs so for the 2GHz radio the DHCP range will be 95# 192.168.1 - 8 and the 5Ghz radio will be 192.168.9 - 16 96_AP_2GHZ_SUBNET_STR_DEFAULT = '192.168.1.0/24' 97_AP_5GHZ_SUBNET_STR_DEFAULT = '192.168.9.0/24' 98 99# The last digit of the ip for the bridge interface 100BRIDGE_IP_LAST = '100' 101 102 103class AccessPoint(object): 104 """An access point controller. 105 106 Attributes: 107 ssh: The ssh connection to this ap. 108 ssh_settings: The ssh settings being used by the ssh connection. 109 dhcp_settings: The dhcp server settings being used. 110 """ 111 112 def __init__(self, configs): 113 """ 114 Args: 115 configs: configs for the access point from config file. 116 """ 117 self.ssh_settings = settings.from_config(configs['ssh_config']) 118 self.log = logger.create_logger(lambda msg: '[Access Point|%s] %s' % ( 119 self.ssh_settings.hostname, msg)) 120 121 if 'ap_subnet' in configs: 122 self._AP_2G_SUBNET_STR = configs['ap_subnet']['2g'] 123 self._AP_5G_SUBNET_STR = configs['ap_subnet']['5g'] 124 else: 125 self._AP_2G_SUBNET_STR = _AP_2GHZ_SUBNET_STR_DEFAULT 126 self._AP_5G_SUBNET_STR = _AP_5GHZ_SUBNET_STR_DEFAULT 127 128 self._AP_2G_SUBNET = dhcp_config.Subnet( 129 ipaddress.ip_network(self._AP_2G_SUBNET_STR)) 130 self._AP_5G_SUBNET = dhcp_config.Subnet( 131 ipaddress.ip_network(self._AP_5G_SUBNET_STR)) 132 133 self.ssh = connection.SshConnection(self.ssh_settings) 134 135 # Singleton utilities for running various commands. 136 self._ip_cmd = ip.LinuxIpCommand(self.ssh) 137 self._route_cmd = route.LinuxRouteCommand(self.ssh) 138 139 # A map from network interface name to _ApInstance objects representing 140 # the hostapd instance running against the interface. 141 self._aps = dict() 142 self.bridge = bridge_interface.BridgeInterface(self) 143 self.interfaces = ap_get_interface.ApInterfaces(self) 144 145 # Get needed interface names and initialize the unneccessary ones. 146 self.wan = self.interfaces.get_wan_interface() 147 self.wlan = self.interfaces.get_wlan_interface() 148 self.wlan_2g = self.wlan[0] 149 self.wlan_5g = self.wlan[1] 150 self.lan = self.interfaces.get_lan_interface() 151 self.__initial_ap() 152 self.scapy_install_path = None 153 154 def __initial_ap(self): 155 """Initial AP interfaces. 156 157 Bring down hostapd if instance is running, bring down all bridge 158 interfaces. 159 """ 160 # This is necessary for Gale/Whirlwind flashed with dev channel image 161 # Unused interfaces such as existing hostapd daemon, guest, mesh 162 # interfaces need to be brought down as part of the AP initialization 163 # process, otherwise test would fail. 164 try: 165 self.ssh.run('stop wpasupplicant') 166 self.ssh.run('stop hostapd') 167 except job.Error: 168 self.log.debug('No hostapd running') 169 # Bring down all wireless interfaces 170 for iface in self.wlan: 171 WLAN_DOWN = 'ifconfig {} down'.format(iface) 172 self.ssh.run(WLAN_DOWN) 173 # Bring down all bridge interfaces 174 bridge_interfaces = self.interfaces.get_bridge_interface() 175 if bridge_interfaces: 176 for iface in bridge_interfaces: 177 BRIDGE_DOWN = 'ifconfig {} down'.format(iface) 178 BRIDGE_DEL = 'brctl delbr {}'.format(iface) 179 self.ssh.run(BRIDGE_DOWN) 180 self.ssh.run(BRIDGE_DEL) 181 182 def start_ap(self, hostapd_config, additional_parameters=None): 183 """Starts as an ap using a set of configurations. 184 185 This will start an ap on this host. To start an ap the controller 186 selects a network interface to use based on the configs given. It then 187 will start up hostapd on that interface. Next a subnet is created for 188 the network interface and dhcp server is refreshed to give out ips 189 for that subnet for any device that connects through that interface. 190 191 Args: 192 hostapd_config: hostapd_config.HostapdConfig, The configurations 193 to use when starting up the ap. 194 additional_parameters: A dictionary of parameters that can sent 195 directly into the hostapd config file. This 196 can be used for debugging and or adding one 197 off parameters into the config. 198 199 Returns: 200 An identifier for the ap being run. This identifier can be used 201 later by this controller to control the ap. 202 203 Raises: 204 Error: When the ap can't be brought up. 205 """ 206 207 if hostapd_config.frequency < 5000: 208 interface = self.wlan_2g 209 subnet = self._AP_2G_SUBNET 210 else: 211 interface = self.wlan_5g 212 subnet = self._AP_5G_SUBNET 213 214 # In order to handle dhcp servers on any interface, the initiation of 215 # the dhcp server must be done after the wlan interfaces are figured 216 # out as opposed to being in __init__ 217 self._dhcp = dhcp_server.DhcpServer(self.ssh, interface=interface) 218 219 # For multi bssid configurations the mac address 220 # of the wireless interface needs to have enough space to mask out 221 # up to 8 different mac addresses. The easiest way to do this 222 # is to set the last byte to 0. While technically this could 223 # cause a duplicate mac address it is unlikely and will allow for 224 # one radio to have up to 8 APs on the interface. 225 interface_mac_orig = None 226 cmd = "ifconfig %s|grep ether|awk -F' ' '{print $2}'" % interface 227 interface_mac_orig = self.ssh.run(cmd) 228 hostapd_config.bssid = interface_mac_orig.stdout[:-1] + '0' 229 230 if interface in self._aps: 231 raise ValueError('No WiFi interface available for AP on ' 232 'channel %d' % hostapd_config.channel) 233 234 apd = hostapd.Hostapd(self.ssh, interface) 235 new_instance = _ApInstance(hostapd=apd, subnet=subnet) 236 self._aps[interface] = new_instance 237 238 # Turn off the DHCP server, we're going to change its settings. 239 self._dhcp.stop() 240 # Clear all routes to prevent old routes from interfering. 241 self._route_cmd.clear_routes(net_interface=interface) 242 243 if hostapd_config.bss_lookup: 244 # The dhcp_bss dictionary is created to hold the key/value 245 # pair of the interface name and the ip scope that will be 246 # used for the particular interface. The a, b, c, d 247 # variables below are the octets for the ip address. The 248 # third octet is then incremented for each interface that 249 # is requested. This part is designed to bring up the 250 # hostapd interfaces and not the DHCP servers for each 251 # interface. 252 dhcp_bss = {} 253 counter = 1 254 for bss in hostapd_config.bss_lookup: 255 if interface_mac_orig: 256 hostapd_config.bss_lookup[ 257 bss].bssid = interface_mac_orig.stdout[:-1] + str( 258 counter) 259 self._route_cmd.clear_routes(net_interface=str(bss)) 260 if interface is self.wlan_2g: 261 starting_ip_range = self._AP_2G_SUBNET_STR 262 else: 263 starting_ip_range = self._AP_5G_SUBNET_STR 264 a, b, c, d = starting_ip_range.split('.') 265 dhcp_bss[bss] = dhcp_config.Subnet( 266 ipaddress.ip_network('%s.%s.%s.%s' % 267 (a, b, str(int(c) + counter), d))) 268 counter = counter + 1 269 270 apd.start(hostapd_config, additional_parameters=additional_parameters) 271 272 # The DHCP serer requires interfaces to have ips and routes before 273 # the server will come up. 274 interface_ip = ipaddress.ip_interface( 275 '%s/%s' % (subnet.router, subnet.network.netmask)) 276 self._ip_cmd.set_ipv4_address(interface, interface_ip) 277 if hostapd_config.bss_lookup: 278 # This loop goes through each interface that was setup for 279 # hostapd and assigns the DHCP scopes that were defined but 280 # not used during the hostapd loop above. The k and v 281 # variables represent the interface name, k, and dhcp info, v. 282 for k, v in dhcp_bss.items(): 283 bss_interface_ip = ipaddress.ip_interface( 284 '%s/%s' % (dhcp_bss[k].router, 285 dhcp_bss[k].network.netmask)) 286 self._ip_cmd.set_ipv4_address(str(k), bss_interface_ip) 287 288 # Restart the DHCP server with our updated list of subnets. 289 configured_subnets = [x.subnet for x in self._aps.values()] 290 if hostapd_config.bss_lookup: 291 for k, v in dhcp_bss.items(): 292 configured_subnets.append(v) 293 294 self._dhcp.start(config=dhcp_config.DhcpConfig(configured_subnets)) 295 296 # The following three commands are needed to enable bridging between 297 # the WAN and LAN/WLAN ports. This means anyone connecting to the 298 # WLAN/LAN ports will be able to access the internet if the WAN port 299 # is connected to the internet. 300 self.ssh.run('iptables -t nat -F') 301 self.ssh.run( 302 'iptables -t nat -A POSTROUTING -o %s -j MASQUERADE' % self.wan) 303 self.ssh.run('echo 1 > /proc/sys/net/ipv4/ip_forward') 304 305 return interface 306 307 def get_bssid_from_ssid(self, ssid, band): 308 """Gets the BSSID from a provided SSID 309 310 Args: 311 ssid: An SSID string. 312 band: 2G or 5G Wifi band. 313 Returns: The BSSID if on the AP or None if SSID could not be found. 314 """ 315 if band == hostapd_constants.BAND_2G: 316 interfaces = [self.wlan_2g, ssid] 317 else: 318 interfaces = [self.wlan_5g, ssid] 319 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 408 def install_scapy(self, scapy_path, send_ra_path): 409 """Install scapy 410 411 Args: 412 scapy_path: path where scapy tar file is located on server 413 send_ra_path: path where sendra path is located on server 414 """ 415 self.scapy_install_path = self.ssh.run('mktemp -d').stdout.rstrip() 416 self.log.info("Scapy install path: %s" % self.scapy_install_path) 417 self.ssh.send_file(scapy_path, self.scapy_install_path) 418 self.ssh.send_file(send_ra_path, self.scapy_install_path) 419 420 scapy = os.path.join(self.scapy_install_path, scapy_path.split('/')[-1]) 421 422 untar_res = self.ssh.run( 423 'tar -xvf %s -C %s' % (scapy, self.scapy_install_path)) 424 425 instl_res = self.ssh.run( 426 'cd %s; %s' % (self.scapy_install_path, SCAPY_INSTALL_COMMAND)) 427 428 def cleanup_scapy(self): 429 """ Cleanup scapy """ 430 if self.scapy_install_path: 431 cmd = 'rm -rf %s' % self.scapy_install_path 432 self.log.info("Cleaning up scapy %s" % cmd) 433 output = self.ssh.run(cmd) 434 self.scapy_install_path = None 435 436 def send_ra(self, iface, mac=RA_MULTICAST_ADDR, interval=1, count=None, 437 lifetime=LIFETIME, rtt=0): 438 """Invoke scapy and send RA to the device. 439 440 Args: 441 iface: string of the WiFi interface to use for sending packets. 442 mac: string HWAddr/MAC address to send the packets to. 443 interval: int Time to sleep between consecutive packets. 444 count: int Number of packets to be sent. 445 lifetime: int original RA's router lifetime in seconds. 446 rtt: retrans timer of the RA packet 447 """ 448 scapy_command = os.path.join(self.scapy_install_path, RA_SCRIPT) 449 options = ' -m %s -i %d -c %d -l %d -in %s -rtt %s' % ( 450 mac, interval, count, lifetime, iface, rtt) 451 self.log.info("Scapy cmd: %s" % scapy_command + options) 452 res = self.ssh.run(scapy_command + options) 453 454 def get_icmp6intype134(self): 455 """Read the value of Icmp6InType134 and return integer. 456 457 Returns: 458 Integer value >0 if grep is successful; 0 otherwise. 459 """ 460 ra_count_str = self.ssh.run('grep Icmp6InType134 %s || true' % 461 PROC_NET_SNMP6).stdout 462 if ra_count_str: 463 return int(ra_count_str.split()[1]) 464