blob: fb01acf140fafe383bde2a43c02c790c973c187e [file] [log] [blame]
#!/usr/bin/python
"""Manages a device's wired and wireless connections to the LAN."""
import collections
import errno
import glob
import json
import logging
import os
import random
import re
import socket
import subprocess
import time
# This is in site-packages on the device, but not when running tests, and so
# raises lint errors.
# pylint: disable=g-bad-import-order
import pyinotify
import cycler
import experiment
import interface
import iw
import ratchet
import status
try:
import monotime # pylint: disable=unused-import,g-import-not-at-top
except ImportError:
pass
try:
_gettime = time.monotonic
except AttributeError:
_gettime = time.time
HOSTNAME = socket.gethostname()
TMP_HOSTS = '/tmp/hosts'
CWMP_PATH = '/tmp/cwmp'
experiment.register('WifiNo2GClient')
class FileChangeHandler(pyinotify.ProcessEvent):
"""Connects pyinotify events to ConnectionManager."""
def __init__(self, connection_manager, *args, **kwargs):
self._connection_manager = connection_manager
super(FileChangeHandler, self).__init__(*args, **kwargs)
def process_IN_DELETE(self, event):
self._connection_manager.handle_event(event.path, event.name, True)
def process_IN_CLOSE_WRITE(self, event):
self._connection_manager.handle_event(event.path, event.name, False)
def process_IN_MOVED_TO(self, event):
self.process_IN_CLOSE_WRITE(event)
def process_IN_MOVED_FROM(self, event):
self.process_IN_DELETE(event)
def check_events(self, *args, **kwargs):
result = super(FileChangeHandler, self).check_events(*args, **kwargs)
return result
class WLANConfiguration(object):
"""Represents a WLAN configuration from cwmpd."""
WIFI_STOPAP = ['wifi', 'stopap', '--persist']
WIFI_SETCLIENT = ['wifi', 'setclient', '--persist']
WIFI_STOPCLIENT = ['wifi', 'stopclient', '--persist']
def __init__(self, band, wifi, command_lines, wpa_control_interface):
self.band = band
self.wifi = wifi
self.command = command_lines.splitlines()
self.access_point_up = False
self.ssid = None
self.passphrase = None
self.interface_suffix = None
self.access_point = None
self._wpa_control_interface = wpa_control_interface
binwifi_option_attrs = {
'-s': 'ssid',
'--ssid': 'ssid',
'-S': 'interface_suffix',
'--interface-suffix': 'interface_suffix',
}
attr = None
for line in self.command:
if attr is not None:
setattr(self, attr, line)
attr = None
continue
attr = binwifi_option_attrs.get(line, None)
if line.startswith('WIFI_PSK='):
self.passphrase = line[len('WIFI_PSK='):]
if self.ssid is None:
raise ValueError('Command file does not specify SSID')
if self.wifi.initial_ssid == self.ssid:
logging.info('Connected to WLAN at startup')
@property
def client_up(self):
wpa_status = self.wifi.wpa_status()
return (wpa_status.get('wpa_state') == 'COMPLETED'
# NONE indicates we're on a provisioning network; anything else
# suggests we're already on the WLAN.
and wpa_status.get('key_mgmt') != 'NONE')
def start_access_point(self):
"""Start an access point."""
if not self.access_point or self.access_point_up:
return
# Since we only run an access point if we have a wired connection anyway, we
# don't want to constrain the AP's channel to the channel of the AP we are
# connected to. This line should go away when we have wireless repeaters.
self.stop_client()
try:
subprocess.check_output(self.command, stderr=subprocess.STDOUT)
self.access_point_up = True
logging.info('Started %s GHz AP', self.band)
except subprocess.CalledProcessError as e:
logging.error('Failed to start access point: %s', e.output)
def stop_access_point(self):
if not self.access_point_up:
return
command = self.WIFI_STOPAP + ['--band', self.band]
if self.interface_suffix:
command += ['--interface_suffix', self.interface_suffix]
try:
subprocess.check_output(command, stderr=subprocess.STDOUT)
self.access_point_up = False
logging.info('Stopped %s GHz AP', self.band)
except subprocess.CalledProcessError as e:
logging.error('Failed to stop access point: %s', e.output)
return
def start_client(self):
"""Join the WLAN as a client."""
if experiment.enabled('WifiNo2GClient') and self.band == '2.4':
logging.info('WifiNo2GClient enabled; not starting 2.4 GHz client.')
return
up = self.client_up
if up:
logging.debug('Wifi client already started on %s GHz', self.band)
return
if self._actually_start_client():
self._post_start_client()
def _actually_start_client(self):
"""Actually run wifi setclient.
Returns:
Whether the command succeeded.
"""
self.wifi.set_gateway_ip(None)
self.wifi.set_subnet(None)
command = self.WIFI_SETCLIENT + ['--ssid', self.ssid, '--band', self.band]
env = dict(os.environ)
if self.passphrase:
env['WIFI_CLIENT_PSK'] = self.passphrase
try:
self.wifi.status.trying_wlan = True
subprocess.check_output(command, stderr=subprocess.STDOUT, env=env)
except subprocess.CalledProcessError as e:
logging.error('Failed to start wifi client: %s', e.output)
self.wifi.status.wlan_failed = True
return False
return True
def _post_start_client(self):
self.wifi.handle_wpa_events()
self.wifi.status.connected_to_wlan = True
logging.info('Started wifi client on %s GHz', self.band)
self.wifi.attach_wpa_control(self._wpa_control_interface)
def stop_client(self):
if not self.client_up:
logging.debug('Wifi client already stopped on %s GHz', self.band)
return
self.wifi.detach_wpa_control()
try:
subprocess.check_output(self.WIFI_STOPCLIENT + ['-b', self.band],
stderr=subprocess.STDOUT)
# TODO(rofrankel): Make this work for dual-radio devices.
self.wifi.status.connected_to_wlan = False
logging.info('Stopped wifi client on %s GHz', self.band)
self.wifi.handle_wpa_events()
except subprocess.CalledProcessError as e:
logging.error('Failed to stop wifi client: %s', e.output)
class ConnectionManager(object):
"""Manages wired and wireless connections to the LAN."""
# pylint: disable=invalid-name
Bridge = interface.Bridge
Wifi = interface.Wifi
FrenzyWifi = interface.FrenzyWifi
WLANConfiguration = WLANConfiguration
ETHERNET_STATUS_FILE = 'eth0'
WLAN_FILE_REGEXP_FMT = r'^%s((?P<interface_suffix>.*)\.)?(?P<band>2\.4|5)$'
COMMAND_FILE_PREFIX = 'command.'
ACCESS_POINT_FILE_PREFIX = 'access_point.'
COMMAND_FILE_REGEXP = WLAN_FILE_REGEXP_FMT % COMMAND_FILE_PREFIX
ACCESS_POINT_FILE_REGEXP = WLAN_FILE_REGEXP_FMT % ACCESS_POINT_FILE_PREFIX
GATEWAY_FILE_PREFIX = 'gateway.'
SUBNET_FILE_PREFIX = 'subnet.'
MOCA_NODE_FILE_PREFIX = 'node'
WIFI_SETCLIENT = ['wifi', 'setclient']
IFUP = ['ifup']
IP_LINK = ['ip', 'link']
IFPLUGD_ACTION = ['/etc/ifplugd/ifplugd.action']
BINWIFI = ['wifi']
UPLOAD_LOGS_AND_WAIT = ['timeout', '60', 'upload-logs-and-wait']
CWMP_WAKEUP = ['cwmp', 'wakeup']
def __init__(self,
bridge_interface='br0',
tmp_dir='/tmp/conman',
config_dir='/config/conman',
moca_tmp_dir='/tmp/cwmp/monitoring/moca2',
wpa_control_interface='/var/run/wpa_supplicant',
run_duration_s=1, interface_update_period=5,
wifi_scan_period_s=120, wlan_retry_s=120, associate_wait_s=15,
dhcp_wait_s=10, acs_start_wait_s=20, acs_finish_wait_s=120,
bssid_cycle_length_s=30):
self._tmp_dir = tmp_dir
self._config_dir = config_dir
self._interface_status_dir = os.path.join(tmp_dir, 'interfaces')
self._status_dir = os.path.join(tmp_dir, 'status')
self._moca_tmp_dir = moca_tmp_dir
self._wpa_control_interface = wpa_control_interface
self._run_duration_s = run_duration_s
self._interface_update_period = interface_update_period
self._wifi_scan_period_s = wifi_scan_period_s
self._wlan_retry_s = wlan_retry_s
self._associate_wait_s = associate_wait_s
self._dhcp_wait_s = dhcp_wait_s
self._acs_start_wait_s = acs_start_wait_s
self._acs_finish_wait_s = acs_finish_wait_s
self._bssid_cycle_length_s = bssid_cycle_length_s
self._wlan_configuration = {}
self._try_to_upload_logs = False
# Make sure all necessary directories exist.
for directory in (self._tmp_dir, self._config_dir, self._moca_tmp_dir,
self._interface_status_dir, self._moca_tmp_dir):
if not os.path.exists(directory):
os.makedirs(directory)
logging.info('Created monitored directory: %s', directory)
acs_autoprov_filepath = os.path.join(self._tmp_dir,
'acs_autoprovisioning')
self.bridge = self.Bridge(
bridge_interface, '10',
acs_autoprovisioning_filepath=acs_autoprov_filepath)
self.create_wifi_interfaces()
for ifc in self.interfaces():
status_dir = os.path.join(self._status_dir, ifc.name)
if not os.path.exists(status_dir):
os.makedirs(status_dir)
ifc.status = status.Status(status_dir)
self._status = status.CompositeStatus(self._status_dir,
[i.status for i in self.interfaces()])
wm = pyinotify.WatchManager()
wm.add_watch(self._config_dir,
pyinotify.IN_CLOSE_WRITE | pyinotify.IN_MOVED_TO |
pyinotify.IN_DELETE | pyinotify.IN_MOVED_FROM)
wm.add_watch(self._tmp_dir,
pyinotify.IN_CLOSE_WRITE | pyinotify.IN_MOVED_TO)
wm.add_watch(self._interface_status_dir,
pyinotify.IN_CLOSE_WRITE | pyinotify.IN_MOVED_TO)
wm.add_watch(self._moca_tmp_dir,
pyinotify.IN_CLOSE_WRITE | pyinotify.IN_MOVED_TO)
self.notifier = pyinotify.Notifier(wm, FileChangeHandler(self), timeout=0)
# If the ethernet file doesn't exist for any reason when conman starts,
# check explicitly and run ifplugd.action to create the file.
if not os.path.exists(os.path.join(self._interface_status_dir, 'eth0')):
ethernet_up = self.is_interface_up('eth0')
self.ifplugd_action('eth0', ethernet_up)
self.bridge.ethernet = ethernet_up
# Do the same for wifi interfaces , but rather than explicitly setting that
# the wpa_supplicant link is up, attempt to attach to the wpa_supplicant
# control interface.
for wifi in self.wifi:
wifi_up = self.is_interface_up(wifi.name)
if not os.path.exists(
os.path.join(self._interface_status_dir, wifi.name)):
self.ifplugd_action(wifi.name, wifi_up)
if wifi_up:
wifi.status.attached_to_wpa_supplicant = wifi.attach_wpa_control(
self._wpa_control_interface)
for path, prefix in ((self._tmp_dir, self.GATEWAY_FILE_PREFIX),
(self._tmp_dir, self.SUBNET_FILE_PREFIX),
(self._interface_status_dir, ''),
(self._moca_tmp_dir, self.MOCA_NODE_FILE_PREFIX),
(self._config_dir, self.COMMAND_FILE_PREFIX)):
for filepath in glob.glob(os.path.join(path, prefix + '*')):
self._process_file(path, os.path.split(filepath)[-1])
# Now that we've read any existing state, it's okay to let interfaces touch
# the routing table.
for ifc in self.interfaces():
ifc.initialize()
logging.info('%s initialized', ifc.name)
# Make sure no unwanted APs or clients are running.
for wifi in self.wifi:
for band in wifi.bands:
config = self._wlan_configuration.get(band, None)
if config:
if config.access_point and self.bridge.internet():
# If we have a config and want an AP, we don't want a client.
logging.info('Stopping pre-existing %s client on %s',
band, wifi.name)
self._stop_wifi(band, False, True)
else:
# If we have a config but don't want an AP, make sure we aren't
# running one.
logging.info('Stopping pre-existing %s AP on %s', band, wifi.name)
self._stop_wifi(band, True, False)
break
else:
# If we have no config for this radio, neither a client nor an AP should
# be running.
logging.info('Stopping pre-existing %s AP and clienton %s',
band, wifi.name)
self._stop_wifi(wifi.bands[0], True, True)
self._interface_update_counter = 0
self._try_wlan_after = {'5': 0, '2.4': 0}
for wifi in self.wifi:
ratchet_name = '%s provisioning' % wifi.name
wifi.provisioning_ratchet = ratchet.Ratchet(ratchet_name, [
ratchet.Condition('trying_open', wifi.connected_to_open,
self._associate_wait_s,
callback=wifi.expire_connection_status_cache),
ratchet.Condition('waiting_for_dhcp', wifi.gateway, self._dhcp_wait_s,
callback=self.cwmp_wakeup),
ratchet.FileTouchedCondition('waiting_for_cwmp_wakeup',
os.path.join(CWMP_PATH, 'acscontact'),
self._acs_start_wait_s),
ratchet.FileTouchedCondition('waiting_for_acs_session',
os.path.join(CWMP_PATH, 'acsconnected'),
self._acs_finish_wait_s),
], wifi.status)
def interfaces(self):
return [self.bridge] + self.wifi
def create_wifi_interfaces(self):
"""Create Wifi interfaces."""
# If we have multiple client interfaces, 5 GHz-only < both < 2.4 GHz-only.
def metric_for_bands(bands):
if '5' in bands:
if '2.4' in bands:
return interface.METRIC_24GHZ_5GHZ
return interface.METRIC_5GHZ
return interface.METRIC_24GHZ
def wifi_class(attrs):
return self.FrenzyWifi if 'frenzy' in attrs else self.Wifi
self.wifi = sorted([
wifi_class(attrs)(interface_name,
metric_for_bands(attrs['bands']),
# Prioritize 5 GHz over 2.4.
bands=sorted(attrs['bands'], reverse=True))
for interface_name, attrs
in get_client_interfaces().iteritems()
], key=lambda w: w.metric)
for wifi in self.wifi:
wifi.last_wifi_scan_time = -self._wifi_scan_period_s
def is_interface_up(self, interface_name):
"""Explicitly check whether an interface is up.
Only used on startup, and only if ifplugd file is missing.
Args:
interface_name: The name of the interface to check.
Returns:
Whether the interface is up.
"""
try:
lines = subprocess.check_output(self.IP_LINK).splitlines()
except subprocess.CalledProcessError as e:
raise EnvironmentError('Failed to call "ip link": %r', e.message)
for line in lines:
if interface_name in line and 'LOWER_UP' in line:
return True
return False
def run(self):
"""Run the main loop, and clean up when done."""
try:
while True:
self.run_once()
finally:
for wifi in self.wifi:
wifi.detach_wpa_control()
self.notifier.stop()
def run_once(self):
"""Run one iteration of the main loop.
This includes the following:
1. Process any changes in watched files.
2. Check interfaces for changed connectivity, if
update_interfaces_and_routes is true.
3. Start, stop, or restart access points as appropriate. If running an
access point, skip all remaining wifi steps for that band.
3. Handle any wpa_supplicant events.
4. Periodically, perform a wifi scan.
5. If not connected to the WLAN or to the ACS, try to connect to something.
6. If connected to the ACS but not the WLAN, and enough time has passed
since connecting that we should expect a current WLAN configuration, try
to join the WLAN again.
7. Sleep for the rest of the duration of _run_duration_s.
"""
start_time = _gettime()
self.notifier.process_events()
while self.notifier.check_events():
self.notifier.read_events()
self.notifier.process_events()
self._interface_update_counter += 1
if self._interface_update_counter == self._interface_update_period:
self._interface_update_counter = 0
self._update_interfaces_and_routes()
for wifi in self.wifi:
continue_wifi = False
provisioning_failed = self.provisioning_failed(wifi)
if self.currently_provisioning(wifi):
continue
provisioning_failed = self.provisioning_failed(wifi)
# Only one wlan_configuration per interface will have access_point ==
# True. Try 5 GHz first, then 2.4 GHz. If both bands are supported by
# the same radio, and the enabled band is changed, then hostapd will be
# updated by the wifi command, but we should tell any other
# wlan_configurations that their APs have been disabled.
for band in wifi.bands:
wlan_configuration = self._wlan_configuration.get(band, None)
if continue_wifi:
if wlan_configuration:
wlan_configuration.access_point_up = False
continue
# Make sure access point is running iff it should be.
if wlan_configuration:
self._update_access_point(wlan_configuration)
# If we are running an AP on this interface, we don't want to make any
# client connections on it.
if wlan_configuration.access_point_up:
continue_wifi = True
if not wifi.attached():
logging.debug('Attempting to attach to wpa control interface for %s',
wifi.name)
wifi.status.attached_to_wpa_supplicant = wifi.attach_wpa_control(
self._wpa_control_interface)
wifi.handle_wpa_events()
if continue_wifi:
logging.debug('Running AP on %s, nothing else to do.', wifi.name)
continue
# If this interface is connected to the user's WLAN, there is nothing else
# to do.
if self._connected_to_wlan(wifi):
wifi.status.connected_to_wlan = True
logging.debug('Connected to WLAN on %s, nothing else to do.', wifi.name)
continue
# This interface is not connected to the WLAN, so scan for potential
# routes to the ACS for provisioning.
if ((not self.acs() or provisioning_failed) and
not getattr(wifi, 'last_successful_bss_info', None) and
_gettime() > wifi.last_wifi_scan_time + self._wifi_scan_period_s):
logging.debug('Performing scan on %s.', wifi.name)
self._wifi_scan(wifi)
# Periodically retry rejoining the WLAN. If the WLAN configuration is
# updated then _update_wlan_configuration will do this immediately, but
# maybe the credentials didn't change and we were just waiting for the AP
# to restart or something. Try both bands, so we can fall back to 2.4 in
# case 5 is unavailable for some reason.
for band in wifi.bands:
wlan_configuration = self._wlan_configuration.get(band, None)
if wlan_configuration and _gettime() >= self._try_wlan_after[band]:
logging.info('Trying to join WLAN on %s.', wifi.name)
wlan_configuration.start_client()
if self._connected_to_wlan(wifi):
logging.info('Joined WLAN on %s.', wifi.name)
wifi.status.connected_to_wlan = True
self._try_wlan_after[band] = 0
break
else:
logging.error('Failed to connect to WLAN on %s.', wifi.name)
wifi.status.connected_to_wlan = False
self._try_wlan_after[band] = _gettime() + self._wlan_retry_s
else:
# If we are aren't on the WLAN, can ping the ACS, and haven't gotten a
# new WLAN configuration yet, there are two possibilities:
#
# 1) The configuration didn't change, and we should retry connecting.
# 2) cwmpd isn't writing a configuration, possibly because the device
# isn't registered to any accounts.
logging.debug('Unable to join WLAN on %s', wifi.name)
wifi.status.connected_to_wlan = False
if self.acs():
logging.debug('Connected to ACS')
if self._try_to_upload_logs:
self._try_upload_logs()
self._try_to_upload_logs = False
if wifi.acs():
wifi.last_successful_bss_info = getattr(wifi,
'last_attempted_bss_info',
None)
if provisioning_failed:
wifi.last_successful_bss_info = None
now = _gettime()
if self._wlan_configuration:
logging.info('ACS has not updated WLAN configuration; will retry '
' with old config.')
for w in self.wifi:
for b in w.bands:
self._try_wlan_after[b] = now
continue
# We don't want to want to log this spammily, so do exponential
# backoff.
elif (hasattr(wifi, 'complain_about_acs_at')
and now >= wifi.complain_about_acs_at):
wait = wifi.complain_about_acs_at - self.provisioning_since(wifi)
logging.info('Can ping ACS, but no WLAN configuration for %ds.',
wait)
wifi.complain_about_acs_at += wait
# If we didn't manage to join the WLAN, and we don't have an ACS
# connection or the ACS session failed, we should try another open AP.
if not self.acs() or provisioning_failed:
now = _gettime()
if self._connected_to_open(wifi) and not provisioning_failed:
logging.debug('Waiting for provisioning for %ds.',
now - self.provisioning_since(wifi))
else:
logging.debug('Not connected to ACS or provisioning failed')
self._try_next_bssid(wifi)
time.sleep(max(0, self._run_duration_s - (_gettime() - start_time)))
def acs(self):
result = False
for ifc in self.interfaces():
acs = ifc.acs()
ifc.status.can_reach_acs = acs
result |= acs
return result
def internet(self):
result = False
for ifc in self.interfaces():
internet = ifc.internet()
ifc.status.can_reach_internet = internet
result |= internet
return result
def _update_interfaces_and_routes(self):
"""Touch each interface via update_routes."""
self.bridge.update_routes()
for wifi in self.wifi:
wifi.update_routes()
# If wifi is connected to something that's not the WLAN, it must be a
# provisioning attempt, and in particular that attempt must be via
# last_attempted_bss_info. If that is the same as the
# last_successful_bss_info (i.e. the last attempt was successful) and we
# aren't connected to the ACS after calling update_routes (which expires
# the connection status cache), then this BSS is no longer successful.
if (wifi.wpa_supplicant and
not self._connected_to_wlan(wifi) and
(getattr(wifi, 'last_successful_bss_info', None) ==
getattr(wifi, 'last_attempted_bss_info', None)) and
not wifi.acs()):
wifi.last_successful_bss_info = None
# Make sure these get called semi-regularly so that exported status is up-
# to-date.
self.acs()
self.internet()
# Update /etc/hosts (depends on routing table)
self._update_tmp_hosts()
def _update_tmp_hosts(self):
"""Update the contents of /tmp/hosts."""
lowest_metric_interface = None
for ifc in self.interfaces():
route = ifc.current_routes().get('default', None)
if route:
metric = route.get('metric', 0)
candidate = (metric, ifc)
if (lowest_metric_interface is None or
candidate < lowest_metric_interface):
lowest_metric_interface = candidate
ip_line = ''
if lowest_metric_interface:
ip = lowest_metric_interface[1].get_ip_address()
ip_line = '%s %s\n' % (ip, HOSTNAME) if ip else ''
logging.info('Lowest metric default route is on dev %r',
lowest_metric_interface[1].name)
new_tmp_hosts = '%s127.0.0.1 localhost' % ip_line
if not os.path.exists(TMP_HOSTS) or open(TMP_HOSTS).read() != new_tmp_hosts:
tmp_hosts_tmp_filename = TMP_HOSTS + '.tmp'
tmp_hosts_tmp = open(tmp_hosts_tmp_filename, 'w')
tmp_hosts_tmp.write(new_tmp_hosts)
os.rename(tmp_hosts_tmp_filename, TMP_HOSTS)
def handle_event(self, path, filename, deleted):
if deleted:
self._handle_deleted_file(path, filename)
else:
self._process_file(path, filename)
def _handle_deleted_file(self, path, filename):
if path == self._config_dir:
match = re.match(self.COMMAND_FILE_REGEXP, filename)
if match:
band = match.group('band')
if band in self._wlan_configuration:
self._remove_wlan_configuration(band)
return
match = re.match(self.ACCESS_POINT_FILE_REGEXP, filename)
if match:
band = match.group('band')
if band in self._wlan_configuration:
self._wlan_configuration[band].access_point = False
return
def _remove_wlan_configuration(self, band):
config = self._wlan_configuration[band]
config.stop_client()
config.stop_access_point()
del self._wlan_configuration[band]
if not self._wlan_configuration:
self.wifi_for_band(band).status.have_config = False
def _process_file(self, path, filename):
"""Process or ignore an updated file in a watched directory."""
filepath = os.path.join(path, filename)
try:
contents = open(filepath).read().strip()
except IOError as e:
if e.errno == errno.ENOENT:
# Logging about failing to open .tmp files results in spammy logs.
if not filename.endswith('.tmp'):
logging.error('Not a file: %s', filepath)
return
else:
raise
if path == self._interface_status_dir:
if filename == self.ETHERNET_STATUS_FILE:
try:
self.bridge.ethernet = bool(int(contents))
logging.info('Ethernet %s', 'up' if self.bridge.ethernet else 'down')
except ValueError:
logging.error('Status file contents should be 0 or 1, not %s',
contents)
return
elif path == self._config_dir:
if filename.startswith(self.COMMAND_FILE_PREFIX):
match = re.match(self.COMMAND_FILE_REGEXP, filename)
if match:
band = match.group('band')
wifi = self.wifi_for_band(band)
if wifi:
self._update_wlan_configuration(
self.WLANConfiguration(band, wifi, contents,
self._wpa_control_interface))
elif filename.startswith(self.ACCESS_POINT_FILE_PREFIX):
match = re.match(self.ACCESS_POINT_FILE_REGEXP, filename)
if match:
band = match.group('band')
wifi = self.wifi_for_band(band)
if wifi and band in self._wlan_configuration:
self._wlan_configuration[band].access_point = True
logging.info('AP enabled for %s GHz', band)
elif path == self._tmp_dir:
if filename.startswith(self.GATEWAY_FILE_PREFIX):
interface_name = filename.split(self.GATEWAY_FILE_PREFIX)[-1]
# If we get a new gateway file, we probably have a new subnet file, and
# we need the subnet in order to add the gateway route. So try to
# process the subnet file before doing anything further with this file.
self._process_file(path, self.SUBNET_FILE_PREFIX + interface_name)
ifc = self.interface_by_name(interface_name)
if ifc:
ifc.set_gateway_ip(contents)
logging.info('Received gateway %r for interface %s', contents,
ifc.name)
if filename.startswith(self.SUBNET_FILE_PREFIX):
interface_name = filename.split(self.SUBNET_FILE_PREFIX)[-1]
ifc = self.interface_by_name(interface_name)
if ifc:
ifc.set_subnet(contents)
logging.info('Received subnet %r for interface %s', contents,
ifc.name)
elif path == self._moca_tmp_dir:
match = re.match(r'^%s\d+$' % self.MOCA_NODE_FILE_PREFIX, filename)
if match:
try:
json_contents = json.loads(contents)
except ValueError:
logging.error('Cannot parse %s as JSON.', filepath)
return
node = json_contents['NodeId']
had_moca = self.bridge.moca
if json_contents.get('RxNBAS', 0) == 0:
self.bridge.remove_moca_station(node)
else:
self.bridge.add_moca_station(node)
has_moca = self.bridge.moca
if had_moca != has_moca:
self.ifplugd_action('moca0', has_moca)
def interface_by_name(self, interface_name):
for ifc in self.interfaces():
if ifc.name == interface_name:
return ifc
def wifi_for_band(self, band):
for wifi in self.wifi:
if band in wifi.bands:
return wifi
logging.error('No wifi interface for %s GHz. wlan interfaces:\n%s',
band, '\n'.join('%s: %r' %
(w.name, w.bands) for w in self.wifi))
def ifplugd_action(self, interface_name, up):
subprocess.call(self.IFPLUGD_ACTION + [interface_name,
'up' if up else 'down'])
def _wifi_scan(self, wifi):
"""Perform a wifi scan and update wifi.cycler."""
logging.info('Scanning on %s...', wifi.name)
wifi.last_wifi_scan_time = _gettime()
subprocess.call(self.IFUP + [wifi.name])
# /bin/wifi takes a --band option but then finds the right interface for it,
# so it's okay to just pick the first band here.
items = self._find_bssids(wifi.bands[0])
logging.info('Done scanning on %s', wifi.name)
if not hasattr(wifi, 'cycler'):
wifi.cycler = cycler.AgingPriorityCycler(
cycle_length_s=self._bssid_cycle_length_s)
# Shuffle items to undefined determinism in scan results + dict
# implementation unfairly biasing BSSID order.
random.shuffle(items)
wifi.cycler.update(items)
def _find_bssids(self, band):
"""Wrapper used as a unit testing seam."""
return iw.find_bssids(band, False)
def _try_next_bssid(self, wifi):
"""Attempt to connect to the next BSSID in wifi's BSSID cycler.
Args:
wifi: The wifi interface to use.
Returns:
Whether connecting to the network succeeded.
"""
if not hasattr(wifi, 'cycler'):
return False
last_successful_bss_info = getattr(wifi, 'last_successful_bss_info', None)
bss_info = last_successful_bss_info or wifi.cycler.next()
if bss_info is not None:
logging.info('Attempting to connect to SSID %s (%s) for provisioning',
bss_info.ssid, bss_info.bssid)
self.start_provisioning(wifi)
connected = self._try_bssid(wifi, bss_info)
if connected:
wifi.attach_wpa_control(self._wpa_control_interface)
wifi.handle_wpa_events()
wifi.status.connected_to_open = True
now = _gettime()
wifi.complain_about_acs_at = now + 5
logging.info('Attempting to provision via SSID %s', bss_info.ssid)
self._try_to_upload_logs = True
# If we can no longer connect to this, it's no longer successful.
else:
wifi.status.connected_to_open = False
if bss_info == last_successful_bss_info:
wifi.last_successful_bss_info = None
return connected
else:
# TODO(rofrankel): There are probably more cases in which this should be
# true, e.g. if we keep trying the same few unsuccessful BSSIDs.
# Relatedly, once we find ACS access on an open network we may want to
# save that SSID/BSSID and that first in future. If we do that then we
# can declare that provisioning has failed much more aggressively.
logging.info('Ran out of BSSIDs to try on %s', wifi.name)
wifi.status.provisioning_failed = True
return False
def _try_bssid(self, wifi, bss_info):
wifi.last_attempted_bss_info = bss_info
return subprocess.call(self.WIFI_SETCLIENT +
['--ssid', bss_info.ssid,
'--band', wifi.bands[0],
'--bssid', bss_info.bssid]) == 0
def _connected_to_wlan(self, wifi):
return (wifi.wpa_supplicant and
any(config.client_up for band, config
in self._wlan_configuration.iteritems()
if band in wifi.bands))
def _connected_to_open(self, wifi):
result = wifi.connected_to_open()
wifi.status.connected_to_open = result
return result
def _update_wlan_configuration(self, wlan_configuration):
band = wlan_configuration.band
current = self._wlan_configuration.get(band, None)
if current is None or wlan_configuration.command != current.command:
if current is not None:
wlan_configuration.access_point = current.access_point
else:
ap_file = os.path.join(
self._config_dir, self.ACCESS_POINT_FILE_PREFIX +
(('%s.' % wlan_configuration.interface_suffix)
if wlan_configuration.interface_suffix else '') + band)
wlan_configuration.access_point = os.path.exists(ap_file)
self._wlan_configuration[band] = wlan_configuration
self.wifi_for_band(band).status.have_config = True
logging.info('Updated WLAN configuration for %s GHz', band)
self._update_access_point(wlan_configuration)
def _update_access_point(self, wlan_configuration):
if self.bridge.internet() and wlan_configuration.access_point:
wlan_configuration.start_access_point()
else:
wlan_configuration.stop_access_point()
wlan_configuration.start_client()
def _stop_wifi(self, band, stopap, stopclient):
"""Stop running wifi processes.
At least one of [stopap, stopclient] must be True.
Args:
band: The band on which to stop wifi.
stopap: Whether to stop access points.
stopclient: Whether to stop wifi clients.
Raises:
ValueError: If neither stopap nor stopclient is True.
"""
if stopap and stopclient:
command = 'stop'
elif stopap:
command = 'stopap'
elif stopclient:
command = 'stopclient'
else:
raise ValueError('Called _stop_wifi without specifying AP or client.')
full_command = [command, '--band', band, '--persist']
try:
self._binwifi(*full_command)
except subprocess.CalledProcessError as e:
logging.error('wifi %s failed: "%s"', ' '.join(full_command), e.output)
def _binwifi(self, *command):
"""Test seam for calls to /bin/wifi.
Only used by _stop_wifi, and probably shouldn't be used by anything else.
Args:
*command: A command for /bin/wifi
Raises:
subprocess.CalledProcessError: If the command fails. Deliberately not
handled here to make future authors think twice before using this.
"""
subprocess.check_output(self.BINWIFI + list(command),
stderr=subprocess.STDOUT)
def _try_upload_logs(self):
logging.info('Attempting to upload logs')
if subprocess.call(self.UPLOAD_LOGS_AND_WAIT) != 0:
logging.error('Failed to upload logs')
def cwmp_wakeup(self):
if subprocess.call(self.CWMP_WAKEUP) != 0:
logging.error('cwmp wakeup failed')
def start_provisioning(self, wifi):
wifi.set_gateway_ip(None)
wifi.set_subnet(None)
wifi.provisioning_ratchet.start()
def provisioning_failed(self, wifi):
try:
wifi.provisioning_ratchet.check()
if wifi.provisioning_ratchet.done_after:
wifi.status.provisioning_completed = True
logging.info('%s successfully provisioned', wifi.name)
return False
except ratchet.TimeoutException:
wifi.status.provisioning_failed = True
logging.info('%s failed to provision: %s', wifi.name,
wifi.provisioning_ratchet.current_step().name)
return True
def provisioning_completed(self, wifi):
return bool(wifi.provisioning_ratchet.done_after)
def currently_provisioning(self, wifi):
return (self._connected_to_open(wifi) and
(not (self.provisioning_failed(wifi) or
self.provisioning_completed(wifi))))
def provisioning_since(self, wifi):
return wifi.provisioning_ratchet.t0
def _wifi_show():
try:
return subprocess.check_output(['wifi', 'show'])
except subprocess.CalledProcessError as e:
logging.error('Failed to call "wifi show": %s', e)
return ''
def _get_quantenna_interfaces():
try:
return subprocess.check_output(['get-quantenna-interfaces']).split()
except subprocess.CalledProcessError:
logging.fatal('Failed to call get-quantenna-interfaces')
raise
def get_client_interfaces():
"""Find all client interfaces on the device.
Returns:
A dict mapping wireless client interfaces to their supported bands.
"""
# TODO(mikemu): Use wifi_files instead of "wifi show".
current_band = None
result = collections.defaultdict(lambda: collections.defaultdict(set))
for line in _wifi_show().splitlines():
if line.startswith('Band:'):
current_band = line.split()[1]
elif line.startswith('Client Interface:'):
result[line.split()[2]]['bands'].add(current_band)
for quantenna_interface in _get_quantenna_interfaces():
if quantenna_interface.startswith('wcli'):
result[quantenna_interface]['bands'].add('5')
result[quantenna_interface]['frenzy'] = True
return result