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