• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1# Copyright (c) 2020 The Chromium OS Authors. All rights reserved.
2# Use of this source code is governed by a BSD-style license that can be
3# found in the LICENSE file.
4
5from collections import namedtuple
6import os
7import re
8import time
9
10class WpaMon(object):
11    """wpa_supplicant event monitor."""
12
13    WPAS_CTRL_DIR = '/var/run/wpa_supplicant/'
14    LOCAL_CTRL = 'local_ctrl'
15    REQUEST_PIPE = 'request_pipe'
16    WPAS_EVENT_LOG = 'wpa_event.log'
17
18    CTRL_EVENT_DO_ROAM = 'CTRL-EVENT-DO-ROAM'
19    CTRL_EVENT_SKIP_ROAM = 'CTRL-EVENT-SKIP-ROAM'
20    CTRL_EVENT_DISCONNECTED = 'CTRL-EVENT-DISCONNECTED'
21    CTRL_EVENT_SCAN_RESULTS = 'CTRL-EVENT-SCAN-RESULTS'
22    CTRL_EVENT_BSS_ADDED = 'CTRL-EVENT-BSS-ADDED'
23
24    ROAM_MATCH = ' cur_bssid=([\da-fA-F:]+) cur_freq=(\d+) ' \
25                 'cur_level=([\d-]+) cur_est=(\d+) ' \
26                 'sel_bssid=([\da-fA-F:]+) sel_freq=(\d+) ' \
27                 'sel_level=([\d-]+) sel_est=(\d+)'
28    DISCONNECT_MATCH = ' bssid=([\da-fA-F:]+) reason=(\d+)' \
29                       '(?: locally_generated=(1))?'
30    SCAN_RESULTS_MATCH = '()'
31    BSS_ADDED_MATCH = ' ([\d]+) ([\da-fA-F:]+)'
32
33    Roam = namedtuple('Roam',
34                      ['cur_bssid', 'cur_freq', 'cur_level', 'cur_est',
35                       'sel_bssid', 'sel_freq', 'sel_level', 'sel_est'])
36    Disconnect = namedtuple('Disconnect', ['bssid', 'reason',
37                                           'locally_generated'])
38    ScanResults = namedtuple('ScanResults', [])
39    Bss = namedtuple('Bss', ['id', 'bssid'])
40
41    MatchFields = namedtuple('MatchFields', ['match_str', 'obj'])
42
43    EVENT_MATCH_DICT = \
44        {CTRL_EVENT_DO_ROAM: MatchFields(ROAM_MATCH, Roam),
45         CTRL_EVENT_SKIP_ROAM: MatchFields(ROAM_MATCH, Roam),
46         CTRL_EVENT_DISCONNECTED: MatchFields(DISCONNECT_MATCH, Disconnect),
47         CTRL_EVENT_SCAN_RESULTS: MatchFields(SCAN_RESULTS_MATCH, ScanResults),
48         CTRL_EVENT_BSS_ADDED: MatchFields(BSS_ADDED_MATCH, Bss),
49        }
50
51    def __init__(self, host, wifi_if):
52        self._host = host
53        self._dest = os.path.join(self.WPAS_CTRL_DIR, wifi_if)
54        self._pgid = None
55        self._started = False
56
57    def __enter__(self):
58        """Connect to wpa_supplicant control interface."""
59        tmp_dir = self._host.get_tmp_dir()
60        tmp_dir = self._host.get_tmp_dir(parent=tmp_dir)
61        # Relax permissions for self._tmp_dir so that socat (run as wpa user)
62        # can create files in this directory.
63        self._host.run('chmod 777 %s' % tmp_dir)
64        local = os.path.join(tmp_dir, self.LOCAL_CTRL)
65        self._pipe = os.path.join(tmp_dir, self.REQUEST_PIPE)
66        self._log_path = os.path.join(tmp_dir, self.WPAS_EVENT_LOG)
67        # Run socat as wpa user so that the socket we bind to can be written to
68        # by wpa_supplicant. We use a `tail -f` on a named pipe to send requests
69        # to wpa_supplicant because `tail -f` continues to read even after it
70        # encounters an EOF. Using `cat` or the PIPE address type would close
71        # the input stream after the first write, instructing socat to tear
72        # everything else down.
73        command = "nohup sudo -u wpa -g wpa socat SYSTEM:'mkfifo %s; " \
74                  "tail -f %s'\!\!STDOUT UNIX-CONNECT:%s,type=2,bind=%s " \
75                  "</dev/null >%s 2>&1 & echo $!" % \
76                  (self._pipe, self._pipe, self._dest, local, self._log_path)
77        out_lines = self._host.run(command).stdout.splitlines()
78        pid = int(out_lines[0])
79        self._pgid = \
80            int(self._host.run('ps -p %d -o pgid=' % pid).stdout.strip())
81        self._capture_index = 0
82        self._start()
83        return self
84
85    def __exit__(self, exception, value, traceback):
86        """Disconnect from wpa_supplicant control interface."""
87        self._stop()
88        # socat spawns a subprocess with the SYSTEM address type, so we must
89        # kill the process group in order to properly clean up.
90        self._host.run('kill -- -%d' % self._pgid)
91        self._pgid = None
92        self._capture_index = 0
93
94    def _start(self):
95        """
96        Attach to the wpa_supplicant control interface to start subscribing to
97        events.
98
99        @return False if already attached, True otherwise.
100        """
101        if self._started:
102            return False
103        self._request('ATTACH')
104        self._started = True
105        return True
106
107    def _stop(self):
108        """
109        Detach from the wpa_supplicant control interface to no longer receive
110        events.
111
112        @return False if not currently attached, True otherwise.
113        """
114        if not self._started:
115            return False
116        self._request('DETACH')
117        self._started = False
118        return True
119
120    def _request(self, cmd):
121        """
122        Send a request to the control interface by writing to the named pipe.
123
124        We use the -n option because wpa_supplicant expects there to be no
125        newline character after the command.
126
127        @param cmd string: command to run
128        """
129        self._host.run('echo -n "%s" > %s' % (cmd, self._pipe))
130
131    def get_log_entries(self):
132        """
133        Get all event log entries and command replies.
134
135        @return string event log
136        """
137        return self._host.run('cat %s' % self._log_path).stdout.rstrip()
138
139    def start_event_capture(self):
140        """
141        Set _capture_index to mark the point in the logs at which an event
142        capture was started.
143        """
144        self._capture_index = len(self.get_log_entries())
145
146    def wait_for_event(self, event, timeout=10, sleep_interval=1.0, attrs={}):
147        """
148        Wait for a wpa_supplicant event. start_event_capture should be called
149        before this.
150
151        @param event string: the wpa_supplicant event to wait for.
152        @param timeout int: timeout in seconds.
153        @param sleep_interval float: sleep interval in seconds.
154        @return list of strings of all event occurrences.
155        """
156        start_time = time.time()
157        while True:
158            objs = self.get_events(event, True, attrs)
159            if objs:
160                return objs
161            if time.time() + sleep_interval - start_time > timeout:
162                return []
163            time.sleep(sleep_interval)
164        return []
165
166    def get_events(self, event, captured_events=False, attrs={}):
167        """
168        Get all wpa_supplicant events of type |event|.
169
170        @param event string: the wpa_supplicant event to get.
171        @param captured_events boolean: True to get events starting from the
172        last start_event_capture call, False to get all events.
173        @return list of namedtuples corresponding to the event.
174        """
175        wpa_log = self.get_log_entries()
176        if captured_events:
177            wpa_log = wpa_log[self._capture_index:]
178        match_str = event + self.EVENT_MATCH_DICT[event].match_str
179        matches = re.findall(match_str, wpa_log)
180        objs = []
181        for match in matches:
182            obj = self.EVENT_MATCH_DICT[event].obj(*match)
183            does_match = True
184            for attr, val in attrs.items():
185                if getattr(obj, attr) != val:
186                    does_match = False
187                    break
188            if does_match:
189                objs.append(obj)
190        return objs
191