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