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