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 os 20 21from acts import logger 22from acts.controllers.ap_lib import ap_get_interface 23from acts.controllers.ap_lib import ap_iwconfig 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_constants 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 40LIFETIME = 180 41PROC_NET_SNMP6 = '/proc/net/snmp6' 42SCAPY_INSTALL_COMMAND = 'sudo python setup.py install' 43RA_MULTICAST_ADDR = '33:33:00:00:00:01' 44RA_SCRIPT = 'sendra.py' 45 46 47def create(configs): 48 """Creates ap controllers from a json config. 49 50 Creates an ap controller from either a list, or a single 51 element. The element can either be just the hostname or a dictionary 52 containing the hostname and username of the ap to connect to over ssh. 53 54 Args: 55 The json configs that represent this controller. 56 57 Returns: 58 A new AccessPoint. 59 """ 60 return [AccessPoint(c) for c in configs] 61 62 63def destroy(aps): 64 """Destroys a list of access points. 65 66 Args: 67 aps: The list of access points to destroy. 68 """ 69 for ap in aps: 70 ap.close() 71 72 73def get_info(aps): 74 """Get information on a list of access points. 75 76 Args: 77 aps: A list of AccessPoints. 78 79 Returns: 80 A list of all aps hostname. 81 """ 82 return [ap.ssh_settings.hostname for ap in aps] 83 84 85class Error(Exception): 86 """Error raised when there is a problem with the access point.""" 87 88 89_ApInstance = collections.namedtuple('_ApInstance', ['hostapd', 'subnet']) 90 91# These ranges were split this way since each physical radio can have up 92# to 8 SSIDs so for the 2GHz radio the DHCP range will be 93# 192.168.1 - 8 and the 5Ghz radio will be 192.168.9 - 16 94_AP_2GHZ_SUBNET_STR_DEFAULT = '192.168.1.0/24' 95_AP_5GHZ_SUBNET_STR_DEFAULT = '192.168.9.0/24' 96 97# The last digit of the ip for the bridge interface 98BRIDGE_IP_LAST = '100' 99 100 101class AccessPoint(object): 102 """An access point controller. 103 104 Attributes: 105 ssh: The ssh connection to this ap. 106 ssh_settings: The ssh settings being used by the ssh connection. 107 dhcp_settings: The dhcp server settings being used. 108 """ 109 def __init__(self, configs): 110 """ 111 Args: 112 configs: configs for the access point from config file. 113 """ 114 self.ssh_settings = settings.from_config(configs['ssh_config']) 115 self.log = logger.create_logger(lambda msg: '[Access Point|%s] %s' % 116 (self.ssh_settings.hostname, msg)) 117 118 if 'ap_subnet' in configs: 119 self._AP_2G_SUBNET_STR = configs['ap_subnet']['2g'] 120 self._AP_5G_SUBNET_STR = configs['ap_subnet']['5g'] 121 else: 122 self._AP_2G_SUBNET_STR = _AP_2GHZ_SUBNET_STR_DEFAULT 123 self._AP_5G_SUBNET_STR = _AP_5GHZ_SUBNET_STR_DEFAULT 124 125 self._AP_2G_SUBNET = dhcp_config.Subnet( 126 ipaddress.ip_network(self._AP_2G_SUBNET_STR)) 127 self._AP_5G_SUBNET = dhcp_config.Subnet( 128 ipaddress.ip_network(self._AP_5G_SUBNET_STR)) 129 130 self.ssh = connection.SshConnection(self.ssh_settings) 131 132 # Singleton utilities for running various commands. 133 self._ip_cmd = ip.LinuxIpCommand(self.ssh) 134 self._route_cmd = route.LinuxRouteCommand(self.ssh) 135 136 # A map from network interface name to _ApInstance objects representing 137 # the hostapd instance running against the interface. 138 self._aps = dict() 139 self._dhcp = None 140 self._dhcp_bss = dict() 141 self.bridge = bridge_interface.BridgeInterface(self) 142 self.interfaces = ap_get_interface.ApInterfaces(self) 143 self.iwconfig = ap_iwconfig.ApIwconfig(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 self.setup_bridge = False 154 155 def __initial_ap(self): 156 """Initial AP interfaces. 157 158 Bring down hostapd if instance is running, bring down all bridge 159 interfaces. 160 """ 161 # This is necessary for Gale/Whirlwind flashed with dev channel image 162 # Unused interfaces such as existing hostapd daemon, guest, mesh 163 # interfaces need to be brought down as part of the AP initialization 164 # process, otherwise test would fail. 165 try: 166 self.ssh.run('stop wpasupplicant') 167 self.ssh.run('stop hostapd') 168 except job.Error: 169 self.log.debug('No hostapd running') 170 # Bring down all wireless interfaces 171 for iface in self.wlan: 172 WLAN_DOWN = 'ifconfig {} down'.format(iface) 173 self.ssh.run(WLAN_DOWN) 174 # Bring down all bridge interfaces 175 bridge_interfaces = self.interfaces.get_bridge_interface() 176 if bridge_interfaces: 177 for iface in bridge_interfaces: 178 BRIDGE_DOWN = 'ifconfig {} down'.format(iface) 179 BRIDGE_DEL = 'brctl delbr {}'.format(iface) 180 self.ssh.run(BRIDGE_DOWN) 181 self.ssh.run(BRIDGE_DEL) 182 183 def start_ap(self, 184 hostapd_config, 185 setup_bridge=False, 186 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 setup_bridge: Whether to bridge the LAN interface WLAN interface. 199 Only one WLAN interface can be bridged with the LAN interface 200 and none of the guest networks can be bridged. 201 additional_parameters: A dictionary of parameters that can sent 202 directly into the hostapd config file. This can be used for 203 debugging and or adding one off parameters into the config. 204 205 Returns: 206 An identifier for each ssid being started. These identifiers can be 207 used later by this controller to control the ap. 208 209 Raises: 210 Error: When the ap can't be brought up. 211 """ 212 if hostapd_config.frequency < 5000: 213 interface = self.wlan_2g 214 subnet = self._AP_2G_SUBNET 215 else: 216 interface = self.wlan_5g 217 subnet = self._AP_5G_SUBNET 218 219 # In order to handle dhcp servers on any interface, the initiation of 220 # the dhcp server must be done after the wlan interfaces are figured 221 # out as opposed to being in __init__ 222 self._dhcp = dhcp_server.DhcpServer(self.ssh, interface=interface) 223 224 # For multi bssid configurations the mac address 225 # of the wireless interface needs to have enough space to mask out 226 # up to 8 different mac addresses. So in for one interface the range is 227 # hex 0-7 and for the other the range is hex 8-f. 228 interface_mac_orig = None 229 cmd = "ifconfig %s|grep ether|awk -F' ' '{print $2}'" % interface 230 interface_mac_orig = self.ssh.run(cmd) 231 if interface == self.wlan_5g: 232 hostapd_config.bssid = interface_mac_orig.stdout[:-1] + '0' 233 last_octet = 1 234 if interface == self.wlan_2g: 235 hostapd_config.bssid = interface_mac_orig.stdout[:-1] + '8' 236 last_octet = 9 237 if interface in self._aps: 238 raise ValueError('No WiFi interface available for AP on ' 239 'channel %d' % hostapd_config.channel) 240 241 apd = hostapd.Hostapd(self.ssh, interface) 242 new_instance = _ApInstance(hostapd=apd, subnet=subnet) 243 self._aps[interface] = new_instance 244 245 # Turn off the DHCP server, we're going to change its settings. 246 self.stop_dhcp() 247 # Clear all routes to prevent old routes from interfering. 248 self._route_cmd.clear_routes(net_interface=interface) 249 250 if hostapd_config.bss_lookup: 251 # The self._dhcp_bss dictionary is created to hold the key/value 252 # pair of the interface name and the ip scope that will be 253 # used for the particular interface. The a, b, c, d 254 # variables below are the octets for the ip address. The 255 # third octet is then incremented for each interface that 256 # is requested. This part is designed to bring up the 257 # hostapd interfaces and not the DHCP servers for each 258 # interface. 259 self._dhcp_bss = dict() 260 counter = 1 261 for bss in hostapd_config.bss_lookup: 262 if interface_mac_orig: 263 hostapd_config.bss_lookup[bss].bssid = ( 264 interface_mac_orig.stdout[:-1] + hex(last_octet)[-1:]) 265 self._route_cmd.clear_routes(net_interface=str(bss)) 266 if interface is self.wlan_2g: 267 starting_ip_range = self._AP_2G_SUBNET_STR 268 else: 269 starting_ip_range = self._AP_5G_SUBNET_STR 270 a, b, c, d = starting_ip_range.split('.') 271 self._dhcp_bss[bss] = dhcp_config.Subnet( 272 ipaddress.ip_network('%s.%s.%s.%s' % 273 (a, b, str(int(c) + counter), d))) 274 counter = counter + 1 275 last_octet = last_octet + 1 276 277 apd.start(hostapd_config, additional_parameters=additional_parameters) 278 279 # The DHCP serer requires interfaces to have ips and routes before 280 # the server will come up. 281 interface_ip = ipaddress.ip_interface( 282 '%s/%s' % (subnet.router, subnet.network.netmask)) 283 if setup_bridge is True: 284 bridge_interface_name = 'br_lan' 285 self.create_bridge(bridge_interface_name, [interface, self.lan]) 286 self._ip_cmd.set_ipv4_address(bridge_interface_name, interface_ip) 287 else: 288 self._ip_cmd.set_ipv4_address(interface, interface_ip) 289 if hostapd_config.bss_lookup: 290 # This loop goes through each interface that was setup for 291 # hostapd and assigns the DHCP scopes that were defined but 292 # not used during the hostapd loop above. The k and v 293 # variables represent the interface name, k, and dhcp info, v. 294 for k, v in self._dhcp_bss.items(): 295 bss_interface_ip = ipaddress.ip_interface( 296 '%s/%s' % (self._dhcp_bss[k].router, 297 self._dhcp_bss[k].network.netmask)) 298 self._ip_cmd.set_ipv4_address(str(k), bss_interface_ip) 299 300 # Restart the DHCP server with our updated list of subnets. 301 configured_subnets = [x.subnet for x in self._aps.values()] 302 if hostapd_config.bss_lookup: 303 for k, v in self._dhcp_bss.items(): 304 configured_subnets.append(v) 305 306 self.start_dhcp(subnets=configured_subnets) 307 self.start_nat() 308 309 bss_interfaces = [bss for bss in hostapd_config.bss_lookup] 310 bss_interfaces.append(interface) 311 312 return bss_interfaces 313 314 def start_dhcp(self, subnets): 315 """Start a DHCP server for the specified subnets. 316 317 This allows consumers of the access point objects to control DHCP. 318 319 Args: 320 subnets: A list of Subnets. 321 """ 322 return self._dhcp.start(config=dhcp_config.DhcpConfig(subnets)) 323 324 def stop_dhcp(self): 325 """Stop DHCP for this AP object. 326 327 This allows consumers of the access point objects to control DHCP. 328 """ 329 return self._dhcp.stop() 330 331 def start_nat(self): 332 """Start NAT on the AP. 333 334 This allows consumers of the access point objects to enable NAT 335 on the AP. 336 337 Note that this is currently a global setting, since we don't 338 have per-interface masquerade rules. 339 """ 340 # The following three commands are needed to enable NAT between 341 # the WAN and LAN/WLAN ports. This means anyone connecting to the 342 # WLAN/LAN ports will be able to access the internet if the WAN port 343 # is connected to the internet. 344 self.ssh.run('iptables -t nat -F') 345 self.ssh.run('iptables -t nat -A POSTROUTING -o %s -j MASQUERADE' % 346 self.wan) 347 self.ssh.run('echo 1 > /proc/sys/net/ipv4/ip_forward') 348 self.ssh.run('echo 1 > /proc/sys/net/ipv6/conf/all/forwarding') 349 350 def stop_nat(self): 351 """Stop NAT on the AP. 352 353 This allows consumers of the access point objects to disable NAT on the 354 AP. 355 356 Note that this is currently a global setting, since we don't have 357 per-interface masquerade rules. 358 """ 359 self.ssh.run('iptables -t nat -F') 360 self.ssh.run('echo 0 > /proc/sys/net/ipv4/ip_forward') 361 self.ssh.run('echo 0 > /proc/sys/net/ipv6/conf/all/forwarding') 362 363 def create_bridge(self, bridge_name, interfaces): 364 """Create the specified bridge and bridge the specified interfaces. 365 366 Args: 367 bridge_name: The name of the bridge to create. 368 interfaces: A list of interfaces to add to the bridge. 369 """ 370 371 # Create the bridge interface 372 self.ssh.run( 373 'brctl addbr {bridge_name}'.format(bridge_name=bridge_name)) 374 375 for interface in interfaces: 376 self.ssh.run('brctl addif {bridge_name} {interface}'.format( 377 bridge_name=bridge_name, interface=interface)) 378 379 def remove_bridge(self, bridge_name): 380 """Removes the specified bridge 381 382 Args: 383 bridge_name: The name of the bridge to remove. 384 """ 385 # Check if the bridge exists. 386 # 387 # Cases where it may not are if we failed to initialize properly 388 # 389 # Or if we're doing 2.4Ghz and 5Ghz SSIDs and we've already torn 390 # down the bridge once, but we got called for each band. 391 result = self.ssh.run( 392 'brctl show {bridge_name}'.format(bridge_name=bridge_name), 393 ignore_status=True) 394 395 # If the bridge exists, we'll get an exit_status of 0, indicating 396 # success, so we can continue and remove the bridge. 397 if result.exit_status == 0: 398 self.ssh.run('ip link set {bridge_name} down'.format( 399 bridge_name=bridge_name)) 400 self.ssh.run( 401 'brctl delbr {bridge_name}'.format(bridge_name=bridge_name)) 402 403 def get_bssid_from_ssid(self, ssid, band): 404 """Gets the BSSID from a provided SSID 405 406 Args: 407 ssid: An SSID string. 408 band: 2G or 5G Wifi band. 409 Returns: The BSSID if on the AP or None if SSID could not be found. 410 """ 411 if band == hostapd_constants.BAND_2G: 412 interfaces = [self.wlan_2g, ssid] 413 else: 414 interfaces = [self.wlan_5g, ssid] 415 416 # Get the interface name associated with the given ssid. 417 for interface in interfaces: 418 cmd = "iw dev %s info|grep ssid|awk -F' ' '{print $2}'" % ( 419 str(interface)) 420 iw_output = self.ssh.run(cmd) 421 if 'command failed: No such device' in iw_output.stderr: 422 continue 423 else: 424 # If the configured ssid is equal to the given ssid, we found 425 # the right interface. 426 if iw_output.stdout == ssid: 427 cmd = "iw dev %s info|grep addr|awk -F' ' '{print $2}'" % ( 428 str(interface)) 429 iw_output = self.ssh.run(cmd) 430 return iw_output.stdout 431 return None 432 433 def stop_ap(self, identifier): 434 """Stops a running ap on this controller. 435 436 Args: 437 identifier: The identify of the ap that should be taken down. 438 """ 439 440 if identifier not in list(self._aps.keys()): 441 raise ValueError('Invalid identifier %s given' % identifier) 442 443 instance = self._aps.get(identifier) 444 445 instance.hostapd.stop() 446 self.stop_dhcp() 447 self._ip_cmd.clear_ipv4_addresses(identifier) 448 449 # DHCP server needs to refresh in order to tear down the subnet no 450 # longer being used. In the event that all interfaces are torn down 451 # then an exception gets thrown. We need to catch this exception and 452 # check that all interfaces should actually be down. 453 configured_subnets = [x.subnet for x in self._aps.values()] 454 del self._aps[identifier] 455 if configured_subnets: 456 self.start_dhcp(subnets=configured_subnets) 457 bridge_interfaces = self.interfaces.get_bridge_interface() 458 if bridge_interfaces: 459 for iface in bridge_interfaces: 460 BRIDGE_DOWN = 'ifconfig {} down'.format(iface) 461 BRIDGE_DEL = 'brctl delbr {}'.format(iface) 462 self.ssh.run(BRIDGE_DOWN) 463 self.ssh.run(BRIDGE_DEL) 464 465 def stop_all_aps(self): 466 """Stops all running aps on this device.""" 467 468 for ap in list(self._aps.keys()): 469 try: 470 self.stop_ap(ap) 471 except dhcp_server.NoInterfaceError: 472 pass 473 474 def close(self): 475 """Called to take down the entire access point. 476 477 When called will stop all aps running on this host, shutdown the dhcp 478 server, and stop the ssh connection. 479 """ 480 481 if self._aps: 482 self.stop_all_aps() 483 self.ssh.close() 484 485 def generate_bridge_configs(self, channel): 486 """Generate a list of configs for a bridge between LAN and WLAN. 487 488 Args: 489 channel: the channel WLAN interface is brought up on 490 iface_lan: the LAN interface to bridge 491 Returns: 492 configs: tuple containing iface_wlan, iface_lan and bridge_ip 493 """ 494 495 if channel < 15: 496 iface_wlan = self.wlan_2g 497 subnet_str = self._AP_2G_SUBNET_STR 498 else: 499 iface_wlan = self.wlan_5g 500 subnet_str = self._AP_5G_SUBNET_STR 501 502 iface_lan = self.lan 503 504 a, b, c, _ = subnet_str.strip('/24').split('.') 505 bridge_ip = "%s.%s.%s.%s" % (a, b, c, BRIDGE_IP_LAST) 506 507 configs = (iface_wlan, iface_lan, bridge_ip) 508 509 return configs 510 511 def install_scapy(self, scapy_path, send_ra_path): 512 """Install scapy 513 514 Args: 515 scapy_path: path where scapy tar file is located on server 516 send_ra_path: path where sendra path is located on server 517 """ 518 self.scapy_install_path = self.ssh.run('mktemp -d').stdout.rstrip() 519 self.log.info("Scapy install path: %s" % self.scapy_install_path) 520 self.ssh.send_file(scapy_path, self.scapy_install_path) 521 self.ssh.send_file(send_ra_path, self.scapy_install_path) 522 523 scapy = os.path.join(self.scapy_install_path, 524 scapy_path.split('/')[-1]) 525 526 untar_res = self.ssh.run('tar -xvf %s -C %s' % 527 (scapy, self.scapy_install_path)) 528 529 instl_res = self.ssh.run( 530 'cd %s; %s' % (self.scapy_install_path, SCAPY_INSTALL_COMMAND)) 531 532 def cleanup_scapy(self): 533 """ Cleanup scapy """ 534 if self.scapy_install_path: 535 cmd = 'rm -rf %s' % self.scapy_install_path 536 self.log.info("Cleaning up scapy %s" % cmd) 537 output = self.ssh.run(cmd) 538 self.scapy_install_path = None 539 540 def send_ra(self, 541 iface, 542 mac=RA_MULTICAST_ADDR, 543 interval=1, 544 count=None, 545 lifetime=LIFETIME, 546 rtt=0): 547 """Invoke scapy and send RA to the device. 548 549 Args: 550 iface: string of the WiFi interface to use for sending packets. 551 mac: string HWAddr/MAC address to send the packets to. 552 interval: int Time to sleep between consecutive packets. 553 count: int Number of packets to be sent. 554 lifetime: int original RA's router lifetime in seconds. 555 rtt: retrans timer of the RA packet 556 """ 557 scapy_command = os.path.join(self.scapy_install_path, RA_SCRIPT) 558 options = ' -m %s -i %d -c %d -l %d -in %s -rtt %s' % ( 559 mac, interval, count, lifetime, iface, rtt) 560 self.log.info("Scapy cmd: %s" % scapy_command + options) 561 res = self.ssh.run(scapy_command + options) 562 563 def get_icmp6intype134(self): 564 """Read the value of Icmp6InType134 and return integer. 565 566 Returns: 567 Integer value >0 if grep is successful; 0 otherwise. 568 """ 569 ra_count_str = self.ssh.run('grep Icmp6InType134 %s || true' % 570 PROC_NET_SNMP6).stdout 571 if ra_count_str: 572 return int(ra_count_str.split()[1]) 573