1#!/usr/bin/env python3
2#
3#   Copyright 2019 - The Android Open Source Project
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 csv
18import os
19import posixpath
20import time
21import acts_contrib.test_utils.wifi.wifi_test_utils as wutils
22
23from acts import context
24from acts import logger
25from acts import utils
26from acts.controllers.utils_lib import ssh
27
28WifiEnums = wutils.WifiEnums
29SNIFFER_TIMEOUT = 6
30
31
32def create(configs):
33    """Factory method for sniffer.
34    Args:
35        configs: list of dicts with sniffer settings.
36        Settings must contain the following : ssh_settings, type, OS, interface.
37
38    Returns:
39        objs: list of sniffer class objects.
40    """
41    objs = []
42    for config in configs:
43        try:
44            if config['type'] == 'tshark':
45                if config['os'] == 'unix':
46                    objs.append(TsharkSnifferOnUnix(config))
47                elif config['os'] == 'linux':
48                    objs.append(TsharkSnifferOnLinux(config))
49                else:
50                    raise RuntimeError('Wrong sniffer config')
51
52            elif config['type'] == 'mock':
53                objs.append(MockSniffer(config))
54        except KeyError:
55            raise KeyError('Invalid sniffer configurations')
56        return objs
57
58
59def destroy(objs):
60    return
61
62
63class OtaSnifferBase(object):
64    """Base class defining common sniffers functions."""
65
66    _log_file_counter = 0
67
68    @property
69    def started(self):
70        raise NotImplementedError('started must be specified.')
71
72    def start_capture(self, network, duration=30):
73        """Starts the sniffer Capture.
74
75        Args:
76            network: dict containing network information such as SSID, etc.
77            duration: duration of sniffer capture in seconds.
78        """
79        raise NotImplementedError('start_capture must be specified.')
80
81    def stop_capture(self, tag=''):
82        """Stops the sniffer Capture.
83
84        Args:
85            tag: string to tag sniffer capture file name with.
86        """
87        raise NotImplementedError('stop_capture must be specified.')
88
89    def _get_remote_dump_path(self):
90        """Returns name of the sniffer dump file."""
91        remote_file_name = 'sniffer_dump.{}'.format(
92            self.sniffer_output_file_type)
93        remote_dump_path = posixpath.join(posixpath.sep, 'tmp',
94                                          remote_file_name)
95        return remote_dump_path
96
97    def _get_full_file_path(self, tag=None):
98        """Returns the full file path for the sniffer capture dump file.
99
100        Returns the full file path (on test machine) for the sniffer capture
101        dump file.
102
103        Args:
104            tag: The tag appended to the sniffer capture dump file .
105        """
106        tags = [tag, 'count', OtaSnifferBase._log_file_counter]
107        out_file_name = 'Sniffer_Capture_%s.%s' % ('_'.join([
108            str(x) for x in tags if x != '' and x is not None
109        ]), self.sniffer_output_file_type)
110        OtaSnifferBase._log_file_counter += 1
111
112        file_path = os.path.join(self.log_path, out_file_name)
113        return file_path
114
115    @property
116    def log_path(self):
117        current_context = context.get_current_context()
118        full_out_dir = os.path.join(current_context.get_full_output_path(),
119                                    'sniffer_captures')
120
121        # Ensure the directory exists.
122        os.makedirs(full_out_dir, exist_ok=True)
123
124        return full_out_dir
125
126
127class MockSniffer(OtaSnifferBase):
128    """Class that implements mock sniffer for test development and debug."""
129    def __init__(self, config):
130        self.log = logger.create_tagged_trace_logger('Mock Sniffer')
131
132    def start_capture(self, network, duration=30):
133        """Starts sniffer capture on the specified machine.
134
135        Args:
136            network: dict of network credentials.
137            duration: duration of the sniff.
138        """
139        self.log.info('Starting sniffer.')
140
141    def stop_capture(self):
142        """Stops the sniffer.
143
144        Returns:
145            log_file: name of processed sniffer.
146        """
147
148        self.log.info('Stopping sniffer.')
149        log_file = self._get_full_file_path()
150        with open(log_file, 'w') as file:
151            file.write('this is a sniffer dump.')
152        return log_file
153
154
155class TsharkSnifferBase(OtaSnifferBase):
156    """Class that implements Tshark based sniffer controller. """
157
158    TYPE_SUBTYPE_DICT = {
159        '0': 'Association Requests',
160        '1': 'Association Responses',
161        '2': 'Reassociation Requests',
162        '3': 'Resssociation Responses',
163        '4': 'Probe Requests',
164        '5': 'Probe Responses',
165        '8': 'Beacon',
166        '9': 'ATIM',
167        '10': 'Disassociations',
168        '11': 'Authentications',
169        '12': 'Deauthentications',
170        '13': 'Actions',
171        '24': 'Block ACK Requests',
172        '25': 'Block ACKs',
173        '26': 'PS-Polls',
174        '27': 'RTS',
175        '28': 'CTS',
176        '29': 'ACK',
177        '30': 'CF-Ends',
178        '31': 'CF-Ends/CF-Acks',
179        '32': 'Data',
180        '33': 'Data+CF-Ack',
181        '34': 'Data+CF-Poll',
182        '35': 'Data+CF-Ack+CF-Poll',
183        '36': 'Null',
184        '37': 'CF-Ack',
185        '38': 'CF-Poll',
186        '39': 'CF-Ack+CF-Poll',
187        '40': 'QoS Data',
188        '41': 'QoS Data+CF-Ack',
189        '42': 'QoS Data+CF-Poll',
190        '43': 'QoS Data+CF-Ack+CF-Poll',
191        '44': 'QoS Null',
192        '46': 'QoS CF-Poll (Null)',
193        '47': 'QoS CF-Ack+CF-Poll (Null)'
194    }
195
196    TSHARK_COLUMNS = [
197        'frame_number', 'frame_time_relative', 'mactime', 'frame_len', 'rssi',
198        'channel', 'ta', 'ra', 'bssid', 'type', 'subtype', 'duration', 'seq',
199        'retry', 'pwrmgmt', 'moredata', 'ds', 'phy', 'radio_datarate',
200        'vht_datarate', 'radiotap_mcs_index', 'vht_mcs', 'wlan_data_rate',
201        '11n_mcs_index', '11ac_mcs', '11n_bw', '11ac_bw', 'vht_nss', 'mcs_gi',
202        'vht_gi', 'vht_coding', 'ba_bm', 'fc_status', 'bf_report'
203    ]
204
205    TSHARK_OUTPUT_COLUMNS = [
206        'frame_number', 'frame_time_relative', 'mactime', 'ta', 'ra', 'bssid',
207        'rssi', 'channel', 'frame_len', 'Info', 'radio_datarate',
208        'radiotap_mcs_index', 'pwrmgmt', 'phy', 'vht_nss', 'vht_mcs',
209        'vht_datarate', '11ac_mcs', '11ac_bw', 'vht_gi', 'vht_coding',
210        'wlan_data_rate', '11n_mcs_index', '11n_bw', 'mcs_gi', 'type',
211        'subtype', 'duration', 'seq', 'retry', 'moredata', 'ds', 'ba_bm',
212        'fc_status', 'bf_report'
213    ]
214
215    TSHARK_FIELDS_LIST = [
216        'frame.number', 'frame.time_relative', 'radiotap.mactime', 'frame.len',
217        'radiotap.dbm_antsignal', 'wlan_radio.channel', 'wlan.ta', 'wlan.ra',
218        'wlan.bssid', 'wlan.fc.type', 'wlan.fc.type_subtype', 'wlan.duration',
219        'wlan.seq', 'wlan.fc.retry', 'wlan.fc.pwrmgt', 'wlan.fc.moredata',
220        'wlan.fc.ds', 'wlan_radio.phy', 'radiotap.datarate',
221        'radiotap.vht.datarate.0', 'radiotap.mcs.index', 'radiotap.vht.mcs.0',
222        'wlan_radio.data_rate', 'wlan_radio.11n.mcs_index',
223        'wlan_radio.11ac.mcs', 'wlan_radio.11n.bandwidth',
224        'wlan_radio.11ac.bandwidth', 'radiotap.vht.nss.0', 'radiotap.mcs.gi',
225        'radiotap.vht.gi', 'radiotap.vht.coding.0', 'wlan.ba.bm',
226        'wlan.fcs.status', 'wlan.vht.compressed_beamforming_report.snr'
227    ]
228
229    def __init__(self, config):
230        self.sniffer_proc_pid = None
231        self.log = logger.create_tagged_trace_logger('Tshark Sniffer')
232        self.ssh_config = config['ssh_config']
233        self.sniffer_os = config['os']
234        self.run_as_sudo = config.get('run_as_sudo', False)
235        self.sniffer_output_file_type = config['output_file_type']
236        self.sniffer_snap_length = config['snap_length']
237        self.sniffer_interface = config['interface']
238
239        #Logging into sniffer
240        self.log.info('Logging into sniffer.')
241        self._sniffer_server = ssh.connection.SshConnection(
242            ssh.settings.from_config(self.ssh_config))
243        # Get tshark params
244        self.tshark_fields = self._generate_tshark_fields(
245            self.TSHARK_FIELDS_LIST)
246        self.tshark_path = self._sniffer_server.run('which tshark').stdout
247
248    @property
249    def _started(self):
250        return self.sniffer_proc_pid is not None
251
252    def _scan_for_networks(self):
253        """Scans for wireless networks on the sniffer."""
254        raise NotImplementedError
255
256    def _get_tshark_command(self, duration):
257        """Frames the appropriate tshark command.
258
259        Args:
260            duration: duration to sniff for.
261
262        Returns:
263            tshark_command : appropriate tshark command.
264        """
265        tshark_command = '{} -l -i {} -I -t u -a duration:{}'.format(
266            self.tshark_path, self.sniffer_interface, int(duration))
267        if self.run_as_sudo:
268            tshark_command = 'sudo {}'.format(tshark_command)
269
270        return tshark_command
271
272    def _get_sniffer_command(self, tshark_command):
273        """
274        Frames the appropriate sniffer command.
275
276        Args:
277            tshark_command: framed tshark command
278
279        Returns:
280            sniffer_command: appropriate sniffer command
281        """
282        if self.sniffer_output_file_type in ['pcap', 'pcapng']:
283            sniffer_command = ' {tshark} -s {snaplength} -w {log_file} '.format(
284                tshark=tshark_command,
285                snaplength=self.sniffer_snap_length,
286                log_file=self._get_remote_dump_path())
287
288        elif self.sniffer_output_file_type == 'csv':
289            sniffer_command = '{tshark} {fields} > {log_file}'.format(
290                tshark=tshark_command,
291                fields=self.tshark_fields,
292                log_file=self._get_remote_dump_path())
293
294        else:
295            raise KeyError('Sniffer output file type not configured correctly')
296
297        return sniffer_command
298
299    def _generate_tshark_fields(self, fields):
300        """Generates tshark fields to be appended to the tshark command.
301
302        Args:
303            fields: list of tshark fields to be appended to the tshark command.
304
305        Returns:
306            tshark_fields: string of tshark fields to be appended
307            to the tshark command.
308        """
309        tshark_fields = "-T fields -y IEEE802_11_RADIO -E separator='^'"
310        for field in fields:
311            tshark_fields = tshark_fields + ' -e {}'.format(field)
312        return tshark_fields
313
314    def _configure_sniffer(self, network, chan, bw):
315        """ Connects to a wireless network using networksetup utility.
316
317        Args:
318            network: dictionary of network credentials; SSID and password.
319        """
320        raise NotImplementedError
321
322    def _run_tshark(self, sniffer_command):
323        """Starts the sniffer.
324
325        Args:
326            sniffer_command: sniffer command to execute.
327        """
328        self.log.info('Starting sniffer.')
329        sniffer_job = self._sniffer_server.run_async(sniffer_command)
330        self.sniffer_proc_pid = sniffer_job.stdout
331
332    def _stop_tshark(self):
333        """ Stops the sniffer."""
334        self.log.info('Stopping sniffer')
335
336        # while loop to kill the sniffer process
337        stop_time = time.time() + SNIFFER_TIMEOUT
338        while time.time() < stop_time:
339            # Wait before sending more kill signals
340            time.sleep(0.1)
341            try:
342                # Returns 1 if process was killed
343                self._sniffer_server.run(
344                    'ps aux| grep {} | grep -v grep'.format(
345                        self.sniffer_proc_pid))
346            except:
347                return
348            try:
349                # Returns error if process was killed already
350                self._sniffer_server.run('sudo kill -15 {}'.format(
351                    str(self.sniffer_proc_pid)))
352            except:
353                # Except is hit when tshark is already dead but we will break
354                # out of the loop when confirming process is dead using ps aux
355                pass
356        self.log.warning('Could not stop sniffer. Trying with SIGKILL.')
357        try:
358            self.log.debug('Killing sniffer with SIGKILL.')
359            self._sniffer_server.run('sudo kill -9 {}'.format(
360                str(self.sniffer_proc_pid)))
361        except:
362            self.log.debug('Sniffer process may have stopped succesfully.')
363
364    def _process_tshark_dump(self, log_file):
365        """ Process tshark dump for better readability.
366
367        Processes tshark dump for better readability and saves it to a file.
368        Adds an info column at the end of each row. Format of the info columns:
369        subtype of the frame, sequence no and retry status.
370
371        Args:
372            log_file : unprocessed sniffer output
373        Returns:
374            log_file : processed sniffer output
375        """
376        temp_dump_file = os.path.join(self.log_path, 'sniffer_temp_dump.csv')
377        utils.exe_cmd('cp {} {}'.format(log_file, temp_dump_file))
378
379        with open(temp_dump_file, 'r') as input_csv, open(log_file,
380                                                          'w') as output_csv:
381            reader = csv.DictReader(input_csv,
382                                    fieldnames=self.TSHARK_COLUMNS,
383                                    delimiter='^')
384            writer = csv.DictWriter(output_csv,
385                                    fieldnames=self.TSHARK_OUTPUT_COLUMNS,
386                                    delimiter='\t')
387            writer.writeheader()
388            for row in reader:
389                if row['subtype'] in self.TYPE_SUBTYPE_DICT:
390                    row['Info'] = '{sub} S={seq} retry={retry_status}'.format(
391                        sub=self.TYPE_SUBTYPE_DICT[row['subtype']],
392                        seq=row['seq'],
393                        retry_status=row['retry'])
394                else:
395                    row['Info'] = '{} S={} retry={}\n'.format(
396                        row['subtype'], row['seq'], row['retry'])
397                writer.writerow(row)
398
399        utils.exe_cmd('rm -f {}'.format(temp_dump_file))
400        return log_file
401
402    def start_capture(self, network, chan, bw, duration=60):
403        """Starts sniffer capture on the specified machine.
404
405        Args:
406            network: dict describing network to sniff on.
407            duration: duration of sniff.
408        """
409        # Checking for existing sniffer processes
410        if self._started:
411            self.log.info('Sniffer already running')
412            return
413
414        # Configure sniffer
415        self._configure_sniffer(network, chan, bw)
416        tshark_command = self._get_tshark_command(duration)
417        sniffer_command = self._get_sniffer_command(tshark_command)
418
419        # Starting sniffer capture by executing tshark command
420        self._run_tshark(sniffer_command)
421
422    def stop_capture(self, tag=''):
423        """Stops the sniffer.
424
425        Args:
426            tag: tag to be appended to the sniffer output file.
427        Returns:
428            log_file: path to sniffer dump.
429        """
430        # Checking if there is an ongoing sniffer capture
431        if not self._started:
432            self.log.error('No sniffer process running')
433            return
434        # Killing sniffer process
435        self._stop_tshark()
436
437        # Processing writing capture output to file
438        log_file = self._get_full_file_path(tag)
439        self._sniffer_server.run('sudo chmod 777 {}'.format(
440            self._get_remote_dump_path()))
441        self._sniffer_server.pull_file(log_file, self._get_remote_dump_path())
442
443        if self.sniffer_output_file_type == 'csv':
444            log_file = self._process_tshark_dump(log_file)
445
446        self.sniffer_proc_pid = None
447        return log_file
448
449
450class TsharkSnifferOnUnix(TsharkSnifferBase):
451    """Class that implements Tshark based sniffer controller on Unix systems."""
452    def _scan_for_networks(self):
453        """Scans the wireless networks on the sniffer.
454
455        Returns:
456            scan_results : output of the scan command.
457        """
458        scan_command = '/usr/local/bin/airport -s'
459        scan_result = self._sniffer_server.run(scan_command).stdout
460
461        return scan_result
462
463    def _configure_sniffer(self, network, chan, bw):
464        """Connects to a wireless network using networksetup utility.
465
466        Args:
467            network: dictionary of network credentials; SSID and password.
468        """
469
470        self.log.debug('Connecting to network {}'.format(network['SSID']))
471
472        if 'password' not in network:
473            network['password'] = ''
474
475        connect_command = 'networksetup -setairportnetwork en0 {} {}'.format(
476            network['SSID'], network['password'])
477        self._sniffer_server.run(connect_command)
478
479
480class TsharkSnifferOnLinux(TsharkSnifferBase):
481    """Class that implements Tshark based sniffer controller on Linux."""
482    def __init__(self, config):
483        super().__init__(config)
484        self._init_sniffer()
485        self.channel = None
486        self.bandwidth = None
487
488    def _init_sniffer(self):
489        """Function to configure interface for the first time"""
490        self._sniffer_server.run('sudo modprobe -r iwlwifi')
491        self._sniffer_server.run('sudo dmesg -C')
492        self._sniffer_server.run('cat /dev/null | sudo tee /var/log/syslog')
493        self._sniffer_server.run('sudo modprobe iwlwifi debug=0x1')
494        # Wait for wifi config changes before trying to further configuration
495        # e.g. setting monitor mode (which will fail if above is not complete)
496        time.sleep(1)
497
498    def set_monitor_mode(self, chan, bw):
499        """Function to configure interface to monitor mode
500
501        Brings up the sniffer wireless interface in monitor mode and
502        tunes it to the appropriate channel and bandwidth
503
504        Args:
505            chan: primary channel (int) to tune the sniffer to
506            bw: bandwidth (int) to tune the sniffer to
507        """
508        if chan == self.channel and bw == self.bandwidth:
509            return
510
511        self.channel = chan
512        self.bandwidth = bw
513
514        channel_map = {
515            80: {
516                tuple(range(36, 50, 2)): 42,
517                tuple(range(52, 66, 2)): 58,
518                tuple(range(100, 114, 2)): 106,
519                tuple(range(116, 130, 2)): 122,
520                tuple(range(132, 146, 2)): 138,
521                tuple(range(149, 163, 2)): 155
522            },
523            40: {
524                (36, 38, 40): 38,
525                (44, 46, 48): 46,
526                (52, 54, 56): 54,
527                (60, 62, 64): 62,
528                (100, 102, 104): 102,
529                (108, 110, 112): 108,
530                (116, 118, 120): 118,
531                (124, 126, 128): 126,
532                (132, 134, 136): 134,
533                (140, 142, 144): 142,
534                (149, 151, 153): 151,
535                (157, 159, 161): 159
536            },
537            160: {
538                (36, 38, 40): 50
539            }
540        }
541
542        if chan <= 13:
543            primary_freq = WifiEnums.channel_2G_to_freq[chan]
544        else:
545            primary_freq = WifiEnums.channel_5G_to_freq[chan]
546
547        self._sniffer_server.run('sudo ifconfig {} down'.format(
548            self.sniffer_interface))
549        self._sniffer_server.run('sudo iwconfig {} mode monitor'.format(
550            self.sniffer_interface))
551        self._sniffer_server.run('sudo ifconfig {} up'.format(
552            self.sniffer_interface))
553
554        if bw in channel_map:
555            for tuple_chan in channel_map[bw]:
556                if chan in tuple_chan:
557                    center_freq = WifiEnums.channel_5G_to_freq[channel_map[bw]
558                                                               [tuple_chan]]
559                    self._sniffer_server.run(
560                        'sudo iw dev {} set freq {} {} {}'.format(
561                            self.sniffer_interface, primary_freq, bw,
562                            center_freq))
563
564        else:
565            self._sniffer_server.run('sudo iw dev {} set freq {}'.format(
566                self.sniffer_interface, primary_freq))
567
568    def _configure_sniffer(self, network, chan, bw):
569        """ Connects to a wireless network using networksetup utility.
570
571        Args:
572            network: dictionary of network credentials; SSID and password.
573        """
574
575        self.log.debug('Setting monitor mode on Ch {}, bw {}'.format(chan, bw))
576        self.set_monitor_mode(chan, bw)
577