xref: /aosp_15_r20/external/autotest/server/cros/network/packet_capturer.py (revision 9c5db1993ded3edbeafc8092d69fe5de2ee02df7)
1# Lint as: python2, python3
2# Copyright (c) 2013 The Chromium OS Authors. All rights reserved.
3# Use of this source code is governed by a BSD-style license that can be
4# found in the LICENSE file.
5
6import collections
7import logging
8import os.path
9import time
10import uuid
11
12from autotest_lib.client.bin import utils
13from autotest_lib.client.common_lib import error
14from autotest_lib.client.common_lib.cros import path_utils
15from autotest_lib.client.common_lib.cros.network import iw_runner
16
17
18class PacketCapturesDisabledError(Exception):
19    """Signifies that this remote host does not support packet captures."""
20    pass
21
22
23# local_pcap_path refers to the path of the result on the local host.
24# local_log_path refers to the tcpdump log file path on the local host.
25CaptureResult = collections.namedtuple('CaptureResult',
26                                       ['local_pcap_path', 'local_log_path'])
27
28# The number of bytes needed for a probe request is hard to define,
29# because the frame contents are variable (e.g. radiotap header may
30# contain different fields, maybe SSID isn't the first tagged
31# parameter?). The value here is 2x the largest frame size observed in
32# a quick sample.
33SNAPLEN_WIFI_PROBE_REQUEST = 600
34
35TCPDUMP_START_TIMEOUT_SECONDS = 5
36TCPDUMP_START_POLL_SECONDS = 0.1
37
38# These are WidthType objects from iw_runner
39WIDTH_HT20 = iw_runner.WIDTH_HT20
40WIDTH_HT40_PLUS = iw_runner.WIDTH_HT40_PLUS
41WIDTH_HT40_MINUS = iw_runner.WIDTH_HT40_MINUS
42WIDTH_VHT80 = iw_runner.WIDTH_VHT80
43WIDTH_VHT160 = iw_runner.WIDTH_VHT160
44WIDTH_VHT80_80 = iw_runner.WIDTH_VHT80_80
45
46_WIDTH_STRINGS = {
47    WIDTH_HT20: 'HT20',
48    WIDTH_HT40_PLUS: 'HT40+',
49    WIDTH_HT40_MINUS: 'HT40-',
50    WIDTH_VHT80: '80',
51    WIDTH_VHT160: '160',
52    WIDTH_VHT80_80: '80+80',
53}
54
55def _get_width_string(width):
56    """Returns a valid width parameter for "iw dev ${DEV} set freq".
57
58    @param width object, one of WIDTH_*
59    @return string iw readable width, or empty string
60
61    """
62    return _WIDTH_STRINGS.get(width, '')
63
64
65def _get_center_freq_80(frequency):
66    """Find the center frequency of a 80MHz channel.
67
68    Raises an error upon an invalid frequency.
69
70    @param frequency int Control frequency of the channel.
71    @return center_freq int Center frequency of the channel.
72
73    """
74    vht80 = [ 5180, 5260, 5500, 5580, 5660, 5745 ]
75    for f in vht80:
76        if frequency >= f and frequency < f + 80:
77            return f + 30
78    raise error.TestError(
79            'Frequency %s is not part of a 80MHz channel', frequency)
80
81
82def _get_center_freq_160(frequency):
83    """Find the center frequency of a 160MHz channel.
84
85    Raises an error upon an invalid frequency.
86
87    @param frequency int Control frequency of the channel.
88    @return center_freq int Center frequency of the channel.
89
90    """
91    if (frequency >= 5180 and frequency <= 5320):
92        return 5250
93    if (frequency >= 5500 and frequency <= 5640):
94        return 5570
95    raise error.TestError(
96            'Frequency %s is not part of a 160MHz channel', frequency)
97
98
99def get_packet_capturer(host, host_description=None, cmd_ip=None, cmd_iw=None,
100                        cmd_netdump=None, ignore_failures=False, logdir=None):
101    cmd_iw = cmd_iw or path_utils.get_install_path('iw', host=host)
102    cmd_ip = cmd_ip or path_utils.get_install_path('ip', host=host)
103    cmd_netdump = (cmd_netdump or
104                   path_utils.get_install_path('tcpdump', host=host))
105    host_description = host_description or 'cap_%s' % uuid.uuid4().hex
106    if None in [cmd_iw, cmd_ip, cmd_netdump, host_description, logdir]:
107        if ignore_failures:
108            logging.warning('Creating a disabled packet capturer for %s.',
109                            host_description)
110            return DisabledPacketCapturer()
111        else:
112            raise error.TestFail('Missing commands needed for '
113                                 'capturing packets')
114
115    return PacketCapturer(host, host_description, cmd_ip, cmd_iw, cmd_netdump,
116                          logdir=logdir)
117
118
119class DisabledPacketCapturer(object):
120    """Delegate meant to look like it could take packet captures."""
121
122    @property
123    def capture_running(self):
124        """@return False"""
125        return False
126
127
128    def __init__(self):
129        pass
130
131
132    def  __enter__(self):
133        return self
134
135
136    def __exit__(self):
137        pass
138
139
140    def close(self):
141        """No-op"""
142
143
144    def create_raw_monitor(self, phy, frequency, width_type=None,
145                           monitor_device=None):
146        """Appears to fail while creating a raw monitor device.
147
148        @param phy string ignored.
149        @param frequency int ignored.
150        @param width_type string ignored.
151        @param monitor_device string ignored.
152        @return None.
153
154        """
155        return None
156
157
158    def configure_raw_monitor(self, monitor_device, frequency, width_type=None):
159        """Fails to configure a raw monitor.
160
161        @param monitor_device string ignored.
162        @param frequency int ignored.
163        @param width_type string ignored.
164
165        """
166
167
168    def create_managed_monitor(self, existing_dev, monitor_device=None):
169        """Fails to create a managed monitor device.
170
171        @param existing_device string ignored.
172        @param monitor_device string ignored.
173        @return None
174
175        """
176        return None
177
178
179    def start_capture(self, interface, local_save_dir,
180                      remote_file=None, snaplen=None):
181        """Fails to start a packet capture.
182
183        @param interface string ignored.
184        @param local_save_dir string ignored.
185        @param remote_file string ignored.
186        @param snaplen int ignored.
187
188        @raises PacketCapturesDisabledError.
189
190        """
191        raise PacketCapturesDisabledError()
192
193
194    def stop_capture(self, capture_pid=None):
195        """Stops all ongoing packet captures.
196
197        @param capture_pid int ignored.
198
199        """
200
201
202class PacketCapturer(object):
203    """Delegate with capability to initiate packet captures on a remote host."""
204
205    LIBPCAP_POLL_FREQ_SECS = 1
206
207    @property
208    def capture_running(self):
209        """@return True iff we have at least one ongoing packet capture."""
210        if self._ongoing_captures:
211            return True
212
213        return False
214
215
216    def __init__(self, host, host_description, cmd_ip, cmd_iw, cmd_netdump,
217                 logdir, disable_captures=False):
218        self._cmd_netdump = cmd_netdump
219        self._cmd_iw = cmd_iw
220        self._cmd_ip = cmd_ip
221        self._host = host
222        self._ongoing_captures = {}
223        self._cap_num = 0
224        self._if_num = 0
225        self._created_managed_devices = []
226        self._created_raw_devices = []
227        self._host_description = host_description
228        self._logdir = logdir
229
230
231    def __enter__(self):
232        return self
233
234
235    def __exit__(self):
236        self.close()
237
238
239    def close(self):
240        """Stop ongoing captures and destroy all created devices."""
241        self.stop_capture()
242        for device in self._created_managed_devices:
243            self._host.run("%s dev %s del" % (self._cmd_iw, device))
244        self._created_managed_devices = []
245        for device in self._created_raw_devices:
246            self._host.run("%s link set %s down" % (self._cmd_ip, device))
247            self._host.run("%s dev %s del" % (self._cmd_iw, device))
248        self._created_raw_devices = []
249
250
251    def create_raw_monitor(self, phy, frequency, width_type=None,
252                           monitor_device=None):
253        """Create and configure a monitor type WiFi interface on a phy.
254
255        If a device called |monitor_device| already exists, it is first removed.
256
257        @param phy string phy name for created monitor (e.g. phy0).
258        @param frequency int frequency for created monitor to watch.
259        @param width_type object optional HT or VHT type, one of the keys in
260                self.WIDTH_STRINGS.
261        @param monitor_device string name of monitor interface to create.
262        @return string monitor device name created or None on failure.
263
264        """
265        if not monitor_device:
266            monitor_device = 'mon%d' % self._if_num
267            self._if_num += 1
268
269        self._host.run('%s dev %s del' % (self._cmd_iw, monitor_device),
270                       ignore_status=True)
271        result = self._host.run('%s phy %s interface add %s type monitor' %
272                                (self._cmd_iw,
273                                 phy,
274                                 monitor_device),
275                                ignore_status=True)
276        if result.exit_status:
277            logging.error('Failed creating raw monitor.')
278            return None
279
280        self.configure_raw_monitor(monitor_device, frequency, width_type)
281        self._created_raw_devices.append(monitor_device)
282        return monitor_device
283
284
285    def configure_raw_monitor(self, monitor_device, frequency, width_type=None):
286        """Configure a raw monitor with frequency and HT params.
287
288        Note that this will stomp on earlier device settings.
289
290        @param monitor_device string name of device to configure.
291        @param frequency int WiFi frequency to dwell on.
292        @param width_type object width_type, one of the WIDTH_* objects.
293
294        """
295        channel_args = str(frequency)
296
297        if width_type:
298            width_string = _get_width_string(width_type)
299            if not width_string:
300                raise error.TestError('Invalid width type: %r' % width_type)
301            if width_type == WIDTH_VHT80_80:
302                raise error.TestError('VHT80+80 packet capture not supported')
303            if width_type == WIDTH_VHT80:
304                width_string = '%s %d' % (width_string,
305                                          _get_center_freq_80(frequency))
306            elif width_type == WIDTH_VHT160:
307                width_string = '%s %d' % (width_string,
308                                          _get_center_freq_160(frequency))
309            channel_args = '%s %s' % (channel_args, width_string)
310
311        self._host.run("%s link set %s up" % (self._cmd_ip, monitor_device))
312        self._host.run("%s dev %s set freq %s" % (self._cmd_iw,
313                                                  monitor_device,
314                                                  channel_args))
315
316
317    def create_managed_monitor(self, existing_dev, monitor_device=None):
318        """Create a monitor type WiFi interface next to a managed interface.
319
320        If a device called |monitor_device| already exists, it is first removed.
321
322        @param existing_device string existing interface (e.g. mlan0).
323        @param monitor_device string name of monitor interface to create.
324        @return string monitor device name created or None on failure.
325
326        """
327        if not monitor_device:
328            monitor_device = 'mon%d' % self._if_num
329            self._if_num += 1
330        self._host.run('%s dev %s del' % (self._cmd_iw, monitor_device),
331                       ignore_status=True)
332        result = self._host.run('%s dev %s interface add %s type monitor' %
333                                (self._cmd_iw,
334                                 existing_dev,
335                                 monitor_device),
336                                ignore_status=True)
337        if result.exit_status:
338            logging.warning('Failed creating monitor.')
339            return None
340
341        self._host.run('%s link set %s up' % (self._cmd_ip, monitor_device))
342        self._created_managed_devices.append(monitor_device)
343        return monitor_device
344
345
346    def _is_capture_active(self, remote_log_file):
347        """Check if a packet capture has completed initialization.
348
349        @param remote_log_file string path to the capture's log file
350        @return True iff log file indicates that tcpdump is listening.
351        """
352        return self._host.run(
353            'grep "listening on" "%s"' % remote_log_file, ignore_status=True
354            ).exit_status == 0
355
356
357    def start_capture(self, interface, local_save_dir,
358                      remote_file=None, snaplen=None):
359        """Start a packet capture on an existing interface.
360
361        @param interface string existing interface to capture on.
362        @param local_save_dir string directory on local machine to hold results.
363        @param remote_file string full path on remote host to hold the capture.
364        @param snaplen int maximum captured frame length.
365        @return int pid of started packet capture.
366
367        """
368        remote_file = (remote_file or
369                       '%s/%s.%d.pcap' % (self._logdir, self._host_description,
370                                            self._cap_num))
371        self._cap_num += 1
372        remote_log_file = '%s.log' % remote_file
373        # Redirect output because SSH refuses to return until the child file
374        # descriptors are closed.
375        cmd = '%s -U -i %s -w %s -s %d >%s 2>&1 & echo $!' % (
376            self._cmd_netdump,
377            interface,
378            remote_file,
379            snaplen or 0,
380            remote_log_file)
381        logging.debug('Starting managed packet capture')
382        pid = int(self._host.run(cmd).stdout)
383        self._ongoing_captures[pid] = (remote_file,
384                                       remote_log_file,
385                                       local_save_dir)
386        is_capture_active = lambda: self._is_capture_active(remote_log_file)
387        utils.poll_for_condition(
388            is_capture_active,
389            timeout=TCPDUMP_START_TIMEOUT_SECONDS,
390            sleep_interval=TCPDUMP_START_POLL_SECONDS,
391            desc='Timeout waiting for tcpdump to start.')
392        return pid
393
394
395    def stop_capture(self, capture_pid=None, local_save_dir=None,
396                     local_pcap_filename=None):
397        """Stop an ongoing packet capture, or all ongoing packet captures.
398
399        If |capture_pid| is given, stops that capture, otherwise stops all
400        ongoing captures.
401
402        This method may sleep for a small amount of time, to ensure that
403        libpcap has completed its last poll(). The caller must ensure that
404        no unwanted traffic is received during this time.
405
406        @param capture_pid int pid of ongoing packet capture or None.
407        @param local_save_dir path to directory to save pcap file in locally.
408        @param local_pcap_filename name of file to store pcap in
409                (basename only).
410        @return list of RemoteCaptureResult tuples
411
412        """
413        if capture_pid:
414            pids_to_kill = [capture_pid]
415        else:
416            pids_to_kill = list(self._ongoing_captures.keys())
417
418        if pids_to_kill:
419            time.sleep(self.LIBPCAP_POLL_FREQ_SECS * 2)
420
421        results = []
422        for pid in pids_to_kill:
423            self._host.run('kill -INT %d' % pid, ignore_status=True)
424            remote_pcap, remote_pcap_log, save_dir = self._ongoing_captures[pid]
425            pcap_filename = os.path.basename(remote_pcap)
426            pcap_log_filename = os.path.basename(remote_pcap_log)
427            if local_pcap_filename:
428                pcap_filename = os.path.join(local_save_dir or save_dir,
429                                             local_pcap_filename)
430                pcap_log_filename = os.path.join(local_save_dir or save_dir,
431                                                 '%s.log' % local_pcap_filename)
432            pairs = [(remote_pcap, pcap_filename),
433                     (remote_pcap_log, pcap_log_filename)]
434
435            for remote_file, local_file in pairs:
436                self._host.get_file(remote_file, local_file)
437                self._host.run('rm -f %s' % remote_file)
438
439            self._ongoing_captures.pop(pid)
440            results.append(CaptureResult(pcap_filename,
441                                         pcap_log_filename))
442        return results
443