1#!/usr/bin/env python3
2#
3#   Copyright 2018 - 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
17from acts import logger
18from acts.controllers.ap_lib.hostapd_constants import AP_DEFAULT_CHANNEL_2G
19from acts.controllers.ap_lib.hostapd_constants import AP_DEFAULT_CHANNEL_5G
20from acts.controllers.ap_lib.hostapd_constants import CHANNEL_MAP
21from acts.controllers.ap_lib.hostapd_constants import FREQUENCY_MAP
22from acts.controllers.ap_lib.hostapd_constants import CENTER_CHANNEL_MAP
23from acts.controllers.ap_lib.hostapd_constants import VHT_CHANNEL
24from acts.controllers.utils_lib.ssh import connection
25from acts.controllers.utils_lib.ssh import formatter
26from acts.controllers.utils_lib.ssh import settings
27from acts.libs.logging import log_stream
28from acts.libs.proc.process import Process
29from acts import asserts
30
31import logging
32import os
33import threading
34import time
35
36ACTS_CONTROLLER_CONFIG_NAME = 'PacketCapture'
37ACTS_CONTROLLER_REFERENCE_NAME = 'packet_capture'
38BSS = 'BSS'
39BSSID = 'BSSID'
40FREQ = 'freq'
41FREQUENCY = 'frequency'
42LEVEL = 'level'
43MON_2G = 'mon0'
44MON_5G = 'mon1'
45BAND_IFACE = {'2G': MON_2G, '5G': MON_5G}
46SCAN_IFACE = 'wlan2'
47SCAN_TIMEOUT = 60
48SEP = ':'
49SIGNAL = 'signal'
50SSID = 'SSID'
51
52
53def create(configs):
54    return [PacketCapture(c) for c in configs]
55
56
57def destroy(pcaps):
58    for pcap in pcaps:
59        pcap.close()
60
61
62def get_info(pcaps):
63    return [pcap.ssh_settings.hostname for pcap in pcaps]
64
65
66class PcapProperties(object):
67    """Class to maintain packet capture properties after starting tcpdump.
68
69    Attributes:
70        proc: Process object of tcpdump
71        pcap_fname: File name of the tcpdump output file
72        pcap_file: File object for the tcpdump output file
73    """
74    def __init__(self, proc, pcap_fname, pcap_file):
75        """Initialize object."""
76        self.proc = proc
77        self.pcap_fname = pcap_fname
78        self.pcap_file = pcap_file
79
80
81class PacketCaptureError(Exception):
82    """Error related to Packet capture."""
83
84
85class PacketCapture(object):
86    """Class representing packet capturer.
87
88    An instance of this class creates and configures two interfaces for monitor
89    mode; 'mon0' for 2G and 'mon1' for 5G and one interface for scanning for
90    wifi networks; 'wlan2' which is a dual band interface.
91
92    Attributes:
93        pcap_properties: dict that specifies packet capture properties for a
94            band.
95    """
96    def __init__(self, configs):
97        """Initialize objects.
98
99        Args:
100            configs: config for the packet capture.
101        """
102        self.ssh_settings = settings.from_config(configs['ssh_config'])
103        self.ssh = connection.SshConnection(self.ssh_settings)
104        self.log = logger.create_logger(lambda msg: '[%s|%s] %s' % (
105            ACTS_CONTROLLER_CONFIG_NAME, self.ssh_settings.hostname, msg))
106
107        self._create_interface(MON_2G, 'monitor')
108        self._create_interface(MON_5G, 'monitor')
109        self.managed_mode = True
110        result = self.ssh.run('ifconfig -a', ignore_status=True)
111        if result.stderr or SCAN_IFACE not in result.stdout:
112            self.managed_mode = False
113        if self.managed_mode:
114            self._create_interface(SCAN_IFACE, 'managed')
115
116        self.pcap_properties = dict()
117        self._pcap_stop_lock = threading.Lock()
118
119    def _create_interface(self, iface, mode):
120        """Create interface of monitor/managed mode.
121
122        Create mon0/mon1 for 2G/5G monitor mode and wlan2 for managed mode.
123        """
124        if mode == 'monitor':
125            self.ssh.run('ifconfig wlan%s down' % iface[-1], ignore_status=True)
126        self.ssh.run('iw dev %s del' % iface, ignore_status=True)
127        self.ssh.run('iw phy%s interface add %s type %s'
128                     % (iface[-1], iface, mode), ignore_status=True)
129        self.ssh.run('ip link set %s up' % iface, ignore_status=True)
130        result = self.ssh.run('iw dev %s info' % iface, ignore_status=True)
131        if result.stderr or iface not in result.stdout:
132            raise PacketCaptureError('Failed to configure interface %s' % iface)
133
134    def _cleanup_interface(self, iface):
135        """Clean up monitor mode interfaces."""
136        self.ssh.run('iw dev %s del' % iface, ignore_status=True)
137        result = self.ssh.run('iw dev %s info' % iface, ignore_status=True)
138        if not result.stderr or 'No such device' not in result.stderr:
139            raise PacketCaptureError('Failed to cleanup monitor mode for %s'
140                                     % iface)
141
142    def _parse_scan_results(self, scan_result):
143        """Parses the scan dump output and returns list of dictionaries.
144
145        Args:
146            scan_result: scan dump output from scan on mon interface.
147
148        Returns:
149            Dictionary of found network in the scan.
150            The attributes returned are
151                a.) SSID - SSID of the network.
152                b.) LEVEL - signal level.
153                c.) FREQUENCY - WiFi band the network is on.
154                d.) BSSID - BSSID of the network.
155        """
156        scan_networks = []
157        network = {}
158        for line in scan_result.splitlines():
159            if SEP not in line:
160                continue
161            if BSS in line:
162                network[BSSID] = line.split('(')[0].split()[-1]
163            field, value = line.lstrip().rstrip().split(SEP)[0:2]
164            value = value.lstrip()
165            if SIGNAL in line:
166                network[LEVEL] = int(float(value.split()[0]))
167            elif FREQ in line:
168                network[FREQUENCY] = int(value)
169            elif SSID in line:
170                network[SSID] = value
171                scan_networks.append(network)
172                network = {}
173        return scan_networks
174
175    def get_wifi_scan_results(self):
176        """Starts a wifi scan on wlan2 interface.
177
178        Returns:
179            List of dictionaries each representing a found network.
180        """
181        if not self.managed_mode:
182            raise PacketCaptureError('Managed mode not setup')
183        result = self.ssh.run('iw dev %s scan' % SCAN_IFACE)
184        if result.stderr:
185            raise PacketCaptureError('Failed to get scan dump')
186        if not result.stdout:
187            return []
188        return self._parse_scan_results(result.stdout)
189
190    def start_scan_and_find_network(self, ssid):
191        """Start a wifi scan on wlan2 interface and find network.
192
193        Args:
194            ssid: SSID of the network.
195
196        Returns:
197            True/False if the network if found or not.
198        """
199        curr_time = time.time()
200        while time.time() < curr_time + SCAN_TIMEOUT:
201            found_networks = self.get_wifi_scan_results()
202            for network in found_networks:
203                if network[SSID] == ssid:
204                    return True
205            time.sleep(3)  # sleep before next scan
206        return False
207
208    def configure_monitor_mode(self, band, channel, bandwidth=20):
209        """Configure monitor mode.
210
211        Args:
212            band: band to configure monitor mode for.
213            channel: channel to set for the interface.
214            bandwidth : bandwidth for VHT channel as 40,80,160
215
216        Returns:
217            True if configure successful.
218            False if not successful.
219        """
220
221        band = band.upper()
222        if band not in BAND_IFACE:
223            self.log.error('Invalid band. Must be 2g/2G or 5g/5G')
224            return False
225
226        iface = BAND_IFACE[band]
227        if bandwidth == 20:
228            self.ssh.run('iw dev %s set channel %s' %
229                         (iface, channel), ignore_status=True)
230        else:
231            center_freq = None
232            for i, j in CENTER_CHANNEL_MAP[VHT_CHANNEL[bandwidth]]["channels"]:
233                if channel in range(i, j + 1):
234                    center_freq = (FREQUENCY_MAP[i] + FREQUENCY_MAP[j]) / 2
235                    break
236            asserts.assert_true(center_freq,
237                                "No match channel in VHT channel list.")
238            self.ssh.run('iw dev %s set freq %s %s %s' %
239                (iface, FREQUENCY_MAP[channel],
240                bandwidth, center_freq), ignore_status=True)
241
242        result = self.ssh.run('iw dev %s info' % iface, ignore_status=True)
243        if result.stderr or 'channel %s' % channel not in result.stdout:
244            self.log.error("Failed to configure monitor mode for %s" % band)
245            return False
246        return True
247
248    def start_packet_capture(self, band, log_path, pcap_fname):
249        """Start packet capture for band.
250
251        band = 2G starts tcpdump on 'mon0' interface.
252        band = 5G starts tcpdump on 'mon1' interface.
253
254        Args:
255            band: '2g' or '2G' and '5g' or '5G'.
256            log_path: test log path to save the pcap file.
257            pcap_fname: name of the pcap file.
258
259        Returns:
260            pcap_proc: Process object of the tcpdump.
261        """
262        band = band.upper()
263        if band not in BAND_IFACE.keys() or band in self.pcap_properties:
264            self.log.error("Invalid band or packet capture already running")
265            return None
266
267        pcap_name = '%s_%s.pcap' % (pcap_fname, band)
268        pcap_fname = os.path.join(log_path, pcap_name)
269        pcap_file = open(pcap_fname, 'w+b')
270
271        tcpdump_cmd = 'tcpdump -i %s -w - -U 2>/dev/null' % (BAND_IFACE[band])
272        cmd = formatter.SshFormatter().format_command(
273            tcpdump_cmd, None, self.ssh_settings, extra_flags={'-q': None})
274        pcap_proc = Process(cmd)
275        pcap_proc.set_on_output_callback(
276            lambda msg: pcap_file.write(msg), binary=True)
277        pcap_proc.start()
278
279        self.pcap_properties[band] = PcapProperties(pcap_proc, pcap_fname,
280                                                    pcap_file)
281        return pcap_proc
282
283    def stop_packet_capture(self, proc):
284        """Stop the packet capture.
285
286        Args:
287            proc: Process object of tcpdump to kill.
288        """
289        for key, val in self.pcap_properties.items():
290            if val.proc is proc:
291                break
292        else:
293            self.log.error("Failed to stop tcpdump. Invalid process.")
294            return
295
296        proc.stop()
297        with self._pcap_stop_lock:
298            self.pcap_properties[key].pcap_file.close()
299            del self.pcap_properties[key]
300
301    def close(self):
302        """Cleanup.
303
304        Cleans up all the monitor mode interfaces and closes ssh connections.
305        """
306        self._cleanup_interface(MON_2G)
307        self._cleanup_interface(MON_5G)
308        self.ssh.close()
309