1#   Copyright 2016 - The Android Open Source Project
2#
3#   Licensed under the Apache License, Version 2.0 (the "License");
4#   you may not use this file except in compliance with the License.
5#   You may obtain a copy of the License at
6#
7#       http://www.apache.org/licenses/LICENSE-2.0
8#
9#   Unless required by applicable law or agreed to in writing, software
10#   distributed under the License is distributed on an "AS IS" BASIS,
11#   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12#   See the License for the specific language governing permissions and
13#   limitations under the License.
14
15import collections
16import itertools
17import logging
18import re
19import time
20from typing import Set
21
22from acts.controllers.ap_lib import hostapd_config
23from acts.controllers.ap_lib import hostapd_constants
24from acts.controllers.ap_lib.extended_capabilities import ExtendedCapabilities
25from acts.controllers.ap_lib.wireless_network_management import BssTransitionManagementRequest
26from acts.controllers.utils_lib.commands import shell
27from acts.libs.proc.job import Result
28
29
30class Error(Exception):
31    """An error caused by hostapd."""
32
33
34class Hostapd(object):
35    """Manages the hostapd program.
36
37    Attributes:
38        config: The hostapd configuration that is being used.
39    """
40
41    PROGRAM_FILE = '/usr/sbin/hostapd'
42    CLI_PROGRAM_FILE = '/usr/bin/hostapd_cli'
43
44    def __init__(self, runner, interface, working_dir='/tmp'):
45        """
46        Args:
47            runner: Object that has run_async and run methods for executing
48                    shell commands (e.g. connection.SshConnection)
49            interface: string, The name of the interface to use (eg. wlan0).
50            working_dir: The directory to work out of.
51        """
52        self._runner = runner
53        self._interface = interface
54        self._working_dir = working_dir
55        self.config = None
56        self._shell = shell.ShellCommand(runner, working_dir)
57        self._log_file = 'hostapd-%s.log' % self._interface
58        self._ctrl_file = 'hostapd-%s.ctrl' % self._interface
59        self._config_file = 'hostapd-%s.conf' % self._interface
60        self._identifier = '%s.*%s' % (self.PROGRAM_FILE, self._config_file)
61
62    def start(self, config, timeout=60, additional_parameters=None):
63        """Starts hostapd
64
65        Starts the hostapd daemon and runs it in the background.
66
67        Args:
68            config: Configs to start the hostapd with.
69            timeout: Time to wait for DHCP server to come up.
70            additional_parameters: A dictionary of parameters that can sent
71                                   directly into the hostapd config file.  This
72                                   can be used for debugging and or adding one
73                                   off parameters into the config.
74
75        Returns:
76            True if the daemon could be started. Note that the daemon can still
77            start and not work. Invalid configurations can take a long amount
78            of time to be produced, and because the daemon runs indefinitely
79            it's impossible to wait on. If you need to check if configs are ok
80            then periodic checks to is_running and logs should be used.
81        """
82        if self.is_alive():
83            self.stop()
84
85        self.config = config
86
87        self._shell.delete_file(self._ctrl_file)
88        self._shell.delete_file(self._log_file)
89        self._shell.delete_file(self._config_file)
90        self._write_configs(additional_parameters=additional_parameters)
91
92        hostapd_command = '%s -dd -t "%s"' % (self.PROGRAM_FILE,
93                                              self._config_file)
94        base_command = 'cd "%s"; %s' % (self._working_dir, hostapd_command)
95        job_str = 'rfkill unblock all; %s > "%s" 2>&1' %\
96                  (base_command, self._log_file)
97        self._runner.run_async(job_str)
98
99        try:
100            self._wait_for_process(timeout=timeout)
101            self._wait_for_interface(timeout=timeout)
102        except:
103            self.stop()
104            raise
105
106    def stop(self):
107        """Kills the daemon if it is running."""
108        if self.is_alive():
109            self._shell.kill(self._identifier)
110
111    def channel_switch(self, channel_num):
112        """Switches to the given channel.
113
114        Returns:
115            acts.libs.proc.job.Result containing the results of the command.
116        Raises: See _run_hostapd_cli_cmd
117        """
118        try:
119            channel_freq = hostapd_constants.FREQUENCY_MAP[channel_num]
120        except KeyError:
121            raise ValueError('Invalid channel number {}'.format(channel_num))
122        csa_beacon_count = 10
123        channel_switch_cmd = 'chan_switch {} {}'.format(
124            csa_beacon_count, channel_freq)
125        result = self._run_hostapd_cli_cmd(channel_switch_cmd)
126
127    def get_current_channel(self):
128        """Returns the current channel number.
129
130        Raises: See _run_hostapd_cli_cmd
131        """
132        status_cmd = 'status'
133        result = self._run_hostapd_cli_cmd(status_cmd)
134        match = re.search(r'^channel=(\d+)$', result.stdout, re.MULTILINE)
135        if not match:
136            raise Error('Current channel could not be determined')
137        try:
138            channel = int(match.group(1))
139        except ValueError:
140            raise Error('Internal error: current channel could not be parsed')
141        return channel
142
143    def _list_sta(self) -> Result:
144        """List all associated STA MAC addresses.
145
146        Returns:
147            acts.libs.proc.job.Result containing the results of the command.
148        Raises: See _run_hostapd_cli_cmd
149        """
150        list_sta_cmd = 'list_sta'
151        return self._run_hostapd_cli_cmd(list_sta_cmd)
152
153    def get_stas(self) -> Set[str]:
154        """Return MAC addresses of all associated STAs."""
155        list_sta_result = self._list_sta()
156        stas = set()
157        for line in list_sta_result.stdout.splitlines():
158            # Each line must be a valid MAC address. Capture it.
159            m = re.match(r'((?:[0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2})', line)
160            if m:
161                stas.add(m.group(1))
162        return stas
163
164    def _sta(self, sta_mac: str) -> Result:
165        """Return hostapd's detailed info about an associated STA.
166
167        Returns:
168            acts.libs.proc.job.Result containing the results of the command.
169        Raises: See _run_hostapd_cli_cmd
170        """
171        sta_cmd = 'sta {}'.format(sta_mac)
172        return self._run_hostapd_cli_cmd(sta_cmd)
173
174    def get_sta_extended_capabilities(self,
175                                      sta_mac: str) -> ExtendedCapabilities:
176        """Get extended capabilities for the given STA, as seen by the AP.
177
178        Args:
179            sta_mac: MAC address of the STA in question.
180        Returns:
181            Extended capabilities of the given STA.
182        Raises:
183            Error if extended capabilities for the STA cannot be obtained.
184        """
185        sta_result = self._sta(sta_mac)
186        # hostapd ext_capab field is a hex encoded string representation of the
187        # 802.11 extended capabilities structure, each byte represented by two
188        # chars (each byte having format %02x).
189        m = re.search(r'ext_capab=([0-9A-Faf]+)', sta_result.stdout,
190                      re.MULTILINE)
191        if not m:
192            raise Error('Failed to get ext_capab from STA details')
193        raw_ext_capab = m.group(1)
194        try:
195            return ExtendedCapabilities(bytearray.fromhex(raw_ext_capab))
196        except ValueError:
197            raise Error(
198                f'ext_capab contains invalid hex string repr {raw_ext_capab}')
199
200    def _bss_tm_req(self, client_mac: str,
201                    request: BssTransitionManagementRequest) -> Result:
202        """Send a hostapd BSS Transition Management request command to a STA.
203
204        Args:
205            client_mac: MAC address that will receive the request.
206            request: BSS Transition Management request that will be sent.
207        Returns:
208            acts.libs.proc.job.Result containing the results of the command.
209        Raises: See _run_hostapd_cli_cmd
210        """
211        bss_tm_req_cmd = f'bss_tm_req {client_mac}'
212
213        if request.abridged:
214            bss_tm_req_cmd += ' abridged=1'
215        if request.bss_termination_included and request.bss_termination_duration:
216            bss_tm_req_cmd += f' bss_term={request.bss_termination_duration.duration}'
217        if request.disassociation_imminent:
218            bss_tm_req_cmd += ' disassoc_imminent=1'
219        if request.disassociation_timer is not None:
220            bss_tm_req_cmd += f' disassoc_timer={request.disassociation_timer}'
221        if request.preferred_candidate_list_included:
222            bss_tm_req_cmd += ' pref=1'
223        if request.session_information_url:
224            bss_tm_req_cmd += f' url={request.session_information_url}'
225        if request.validity_interval:
226            bss_tm_req_cmd += f' valid_int={request.validity_interval}'
227
228        # neighbor= can appear multiple times, so it requires special handling.
229        for neighbor in request.candidate_list:
230            bssid = neighbor.bssid
231            bssid_info = hex(neighbor.bssid_information)
232            op_class = neighbor.operating_class
233            chan_num = neighbor.channel_number
234            phy_type = int(neighbor.phy_type)
235            bss_tm_req_cmd += f' neighbor={bssid},{bssid_info},{op_class},{chan_num},{phy_type}'
236
237        return self._run_hostapd_cli_cmd(bss_tm_req_cmd)
238
239    def send_bss_transition_management_req(
240            self, sta_mac: str,
241            request: BssTransitionManagementRequest) -> Result:
242        """Send a BSS Transition Management request to an associated STA.
243
244        Args:
245            sta_mac: MAC address of the STA in question.
246            request: BSS Transition Management request that will be sent.
247        Returns:
248            acts.libs.proc.job.Result containing the results of the command.
249        Raises: See _run_hostapd_cli_cmd
250        """
251        return self._bss_tm_req(sta_mac, request)
252
253    def is_alive(self):
254        """
255        Returns:
256            True if the daemon is running.
257        """
258        return self._shell.is_alive(self._identifier)
259
260    def pull_logs(self):
261        """Pulls the log files from where hostapd is running.
262
263        Returns:
264            A string of the hostapd logs.
265        """
266        # TODO: Auto pulling of logs when stop is called.
267        return self._shell.read_file(self._log_file)
268
269    def _run_hostapd_cli_cmd(self, cmd):
270        """Run the given hostapd_cli command.
271
272        Runs the command, waits for the output (up to default timeout), and
273            returns the result.
274
275        Returns:
276            acts.libs.proc.job.Result containing the results of the ssh command.
277
278        Raises:
279            acts.lib.proc.job.TimeoutError: When the remote command took too
280                long to execute.
281            acts.controllers.utils_lib.ssh.connection.Error: When the ssh
282                connection failed to be created.
283            acts.controllers.utils_lib.ssh.connection.CommandError: Ssh worked,
284                but the command had an error executing.
285        """
286        hostapd_cli_job = 'cd {}; {} -p {} {}'.format(self._working_dir,
287                                                      self.CLI_PROGRAM_FILE,
288                                                      self._ctrl_file, cmd)
289        return self._runner.run(hostapd_cli_job)
290
291    def _wait_for_process(self, timeout=60):
292        """Waits for the process to come up.
293
294        Waits until the hostapd process is found running, or there is
295        a timeout. If the program never comes up then the log file
296        will be scanned for errors.
297
298        Raises: See _scan_for_errors
299        """
300        start_time = time.time()
301        while time.time() - start_time < timeout and not self.is_alive():
302            self._scan_for_errors(False)
303            time.sleep(0.1)
304
305    def _wait_for_interface(self, timeout=60):
306        """Waits for hostapd to report that the interface is up.
307
308        Waits until hostapd says the interface has been brought up or an
309        error occurs.
310
311        Raises: see _scan_for_errors
312        """
313        start_time = time.time()
314        while time.time() - start_time < timeout:
315            time.sleep(0.1)
316            success = self._shell.search_file('Setup of interface done',
317                                              self._log_file)
318            if success:
319                return
320            self._scan_for_errors(False)
321
322        self._scan_for_errors(True)
323
324    def _scan_for_errors(self, should_be_up):
325        """Scans the hostapd log for any errors.
326
327        Args:
328            should_be_up: If true then hostapd program is expected to be alive.
329                          If it is found not alive while this is true an error
330                          is thrown.
331
332        Raises:
333            Error: Raised when a hostapd error is found.
334        """
335        # Store this so that all other errors have priority.
336        is_dead = not self.is_alive()
337
338        bad_config = self._shell.search_file('Interface initialization failed',
339                                             self._log_file)
340        if bad_config:
341            raise Error('Interface failed to start', self)
342
343        bad_config = self._shell.search_file(
344            "Interface %s wasn't started" % self._interface, self._log_file)
345        if bad_config:
346            raise Error('Interface failed to start', self)
347
348        if should_be_up and is_dead:
349            raise Error('Hostapd failed to start', self)
350
351    def _write_configs(self, additional_parameters=None):
352        """Writes the configs to the hostapd config file."""
353        self._shell.delete_file(self._config_file)
354
355        interface_configs = collections.OrderedDict()
356        interface_configs['interface'] = self._interface
357        interface_configs['ctrl_interface'] = self._ctrl_file
358        pairs = ('%s=%s' % (k, v) for k, v in interface_configs.items())
359
360        packaged_configs = self.config.package_configs()
361        if additional_parameters:
362            packaged_configs.append(additional_parameters)
363        for packaged_config in packaged_configs:
364            config_pairs = ('%s=%s' % (k, v)
365                            for k, v in packaged_config.items()
366                            if v is not None)
367            pairs = itertools.chain(pairs, config_pairs)
368
369        hostapd_conf = '\n'.join(pairs)
370
371        logging.info('Writing %s' % self._config_file)
372        logging.debug('******************Start*******************')
373        logging.debug('\n%s' % hostapd_conf)
374        logging.debug('*******************End********************')
375
376        self._shell.write_file(self._config_file, hostapd_conf)
377