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