Home | History | Annotate | Download | only in controllers
      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 
     17 import collections
     18 import ipaddress
     19 import logging
     20 import time
     21 
     22 from acts import logger
     23 from acts.controllers.ap_lib import ap_get_interface
     24 from acts.controllers.ap_lib import bridge_interface
     25 from acts.controllers.ap_lib import dhcp_config
     26 from acts.controllers.ap_lib import dhcp_server
     27 from acts.controllers.ap_lib import hostapd
     28 from acts.controllers.ap_lib import hostapd_config
     29 from acts.controllers.utils_lib.commands import ip
     30 from acts.controllers.utils_lib.commands import route
     31 from acts.controllers.utils_lib.commands import shell
     32 from acts.controllers.utils_lib.ssh import connection
     33 from acts.controllers.utils_lib.ssh import settings
     34 from acts.libs.proc import job
     35 
     36 ACTS_CONTROLLER_CONFIG_NAME = 'AccessPoint'
     37 ACTS_CONTROLLER_REFERENCE_NAME = 'access_points'
     38 _BRCTL = 'brctl'
     39 
     40 
     41 def 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 
     57 def 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 
     67 def 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 
     79 class 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
     92 BRIDGE_IP_LAST = '100'
     93 
     94 
     95 class 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