blob: d45f42e719d5ad7c51d6148a74349f293d35e317 [file] [log] [blame]
#!/usr/bin/python
"""Models wired and wireless interfaces."""
import json
import logging
import os
import re
import subprocess
import wpactrl
METRIC_5GHZ = 20
METRIC_24GHZ_5GHZ = 21
METRIC_24GHZ = 22
METRIC_TEMPORARY_CONNECTION_CHECK = 99
class Interface(object):
"""Represents an interface.
Base class for more specific interface types.
"""
CONNECTION_CHECK = 'connection_check'
IP_ROUTE = ['ip', 'route']
def __init__(self, name, metric):
self.name = name
# Currently connected links for this interface, e.g. ethernet.
self.links = set()
# Whether [ACS, internet] access is currently available via this interface.
self._has_acs = None
self._has_internet = None
# The gateway IP for this interface.
self._gateway_ip = None
self.metric = metric
# Until this is set True, the routing table will not be touched.
self._initialized = False
def _connection_check(self, check_acs):
"""Check this interface's connection status.
Args:
check_acs: If true, check for ACS access rather than internet access.
Returns:
Whether the connection is working.
"""
# Until initialized, we want to act as if the interface is down.
if not self._initialized:
logging.debug('%s not initialized; not running connection_check%s',
self.name, ' (ACS)' if check_acs else '')
return None
if not self.links:
logging.debug('Connection check for %s failed due to no links', self.name)
return False
logging.debug('Gateway IP for %s is %s', self.name, self._gateway_ip)
if self._gateway_ip is None:
logging.debug('Connection check for %s failed due to no gateway IP',
self.name)
return False
# Temporarily add a route to make sure the connection check can be run.
# Give it a high metric so that it won't interfere with normal default
# routes.
added_temporary_route = False
if not self.current_route():
logging.debug('Adding temporary connection check route for dev %s',
self.name)
self._ip_route('add', 'default',
'via', self._gateway_ip,
'dev', self.name,
'metric', str(METRIC_TEMPORARY_CONNECTION_CHECK))
added_temporary_route = True
cmd = [self.CONNECTION_CHECK, '-I', self.name]
if check_acs:
cmd.append('-a')
with open(os.devnull, 'w') as devnull:
result = subprocess.call(cmd, stdout=devnull, stderr=devnull) == 0
logging.debug('Connection check%s for %s %s',
' (ACS)' if check_acs else '',
self.name,
'passed' if result else 'failed')
# Delete the temporary route.
if added_temporary_route:
logging.debug('Deleting temporary connection check route for dev %s',
self.name)
self._ip_route('del', 'default',
'dev', self.name,
'metric', str(METRIC_TEMPORARY_CONNECTION_CHECK))
return result
def acs(self):
if self._has_acs is None:
self._has_acs = self._connection_check(check_acs=True)
return self._has_acs
def internet(self):
if self._has_internet is None:
self._has_internet = self._connection_check(check_acs=False)
return self._has_internet
def add_route(self):
"""Adds a default route for this interface.
First, checks whether an equivalent route already exists, and if so,
returns.
"""
if self.metric is None:
logging.info('Cannot add route for %s without a metric.', self.name)
return
if self._gateway_ip is None:
logging.info('Cannot add route for %s without a gateway IP.', self.name)
return
# If the current default route is the same, there is nothing to do. If it
# exists but is different, delete it before adding an updated one.
current = self.current_route()
if current:
if (current.get('via', None) == self._gateway_ip and
current.get('metric', None) == str(self.metric)):
return
else:
self.delete_route()
logging.debug('Adding default route for dev %s', self.name)
self._ip_route('add', 'default',
'via', self._gateway_ip,
'dev', self.name,
'metric', str(self.metric))
def delete_route(self):
while self.current_route():
logging.debug('Deleting default route for dev %s', self.name)
self._ip_route('del', 'default',
'dev', self.name)
def current_route(self):
"""Read the current default route for this interface.
Returns:
A dict containing the gateway [and metric] of the route, or an empty dict
if there is currently no default route for this interface.
"""
result = {}
for line in self._ip_route().splitlines():
if line.startswith('default') and 'dev %s' % self.name in line:
key = None
for token in line.split():
if token in ['via', 'metric']:
key = token
elif key:
result[key] = token
key = None
return result
def _ip_route(self, *args):
if not self._initialized:
logging.debug('Not initialized, not running %s %s',
' '.join(self.IP_ROUTE), ' '.join(args))
return ''
return self._really_ip_route(*args)
def _really_ip_route(self, *args):
try:
logging.debug('%s calling ip route %s', self.name, ' '.join(args))
return subprocess.check_output(self.IP_ROUTE + list(args))
except subprocess.CalledProcessError as e:
logging.error('Failed to call "ip route" with args %r: %s', args,
e.message)
return ''
def set_gateway_ip(self, gateway_ip):
logging.debug('New gateway IP %s for %s', gateway_ip, self.name)
self._gateway_ip = gateway_ip
self.update_routes()
def _set_link_status(self, link, is_up):
"""Set whether a link is up or not."""
if is_up == (link in self.links):
return
had_links = bool(self.links)
if is_up:
logging.debug('%s gained link %s', self.name, link)
self.links.add(link)
else:
logging.debug('%s lost link %s', self.name, link)
self.links.remove(link)
# If a link goes away, we may have lost access to something but not gained
# it, and vice versa.
if is_up != self._has_acs:
self._has_acs = None
if is_up != self._has_internet:
self._has_internet = None
if had_links != bool(self.links):
self.update_routes(expire_cache=False)
def expire_connection_status_cache(self):
logging.debug('Expiring connection status cache for %s', self.name)
self._has_internet = self._has_acs = None
def update_routes(self, expire_cache=True):
"""Update this interface's routes.
If the interface has gained ACS or internet access, add a route. If it had
either and now has neither, delete the route.
Args:
expire_cache: If true, force a recheck of connection status before
deciding whether to add or remove routes.
"""
logging.debug('Updating routes for %s', self.name)
maybe_had_acs = self._has_acs
maybe_had_internet = self._has_internet
if expire_cache:
self.expire_connection_status_cache()
has_acs = self.acs()
has_internet = self.internet()
# This is a little confusing: We want to try adding a route if we _may_
# have gone from no access to some access, and we want to try deleting the
# route if we _may_ have lost *all* access. So the first condition checks
# for truthiness but the elif checks for explicit Falsity (i.e. excluding
# the None/unknown case).
had_access = maybe_had_acs or maybe_had_internet
# pylint: disable=g-explicit-bool-comparison
maybe_had_access = maybe_had_acs != False or maybe_had_internet != False
has_access = has_acs or has_internet
if not had_access and has_access:
self.add_route()
elif maybe_had_access and not has_access:
self.delete_route()
def initialize(self):
"""Tell the interface it has its initial state.
Until this is called, the interface won't run connection checks or touch the
routing table.
"""
self._initialized = True
self.update_routes()
class Bridge(Interface):
"""Represents the wired bridge."""
def __init__(self, *args, **kwargs):
self._acs_autoprovisioning_filepath = kwargs.pop(
'acs_autoprovisioning_filepath')
super(Bridge, self).__init__(*args, **kwargs)
self._moca_stations = set()
@property
def moca(self):
return bool(self._moca_stations)
@moca.setter
def moca(self, is_up):
self._set_link_status('moca', is_up)
@property
def ethernet(self):
return 'ethernet' in self.links
@ethernet.setter
def ethernet(self, is_up):
self._set_link_status('ethernet', is_up)
def add_moca_station(self, node_id):
if node_id not in self._moca_stations:
self._moca_stations.add(node_id)
self.moca = True
def remove_moca_station(self, node_id):
if node_id in self._moca_stations:
self._moca_stations.remove(node_id)
self.moca = bool(self._moca_stations)
def add_route(self):
"""We only want ACS autoprovisioning when we're using a wired route."""
super(Bridge, self).add_route()
open(self._acs_autoprovisioning_filepath, 'w')
def delete_route(self):
"""We only want ACS autoprovisioning when we're using a wired route."""
if os.path.exists(self._acs_autoprovisioning_filepath):
os.unlink(self._acs_autoprovisioning_filepath)
super(Bridge, self).delete_route()
class Wifi(Interface):
"""Represents a wireless interface."""
WPA_EVENT_RE = re.compile(r'<\d+>CTRL-EVENT-(?P<event>[A-Z\-]+).*')
# pylint: disable=invalid-name
WPACtrl = wpactrl.WPACtrl
def __init__(self, *args, **kwargs):
self.bands = kwargs.pop('bands', [])
super(Wifi, self).__init__(*args, **kwargs)
self._wpa_control = None
self.initial_ssid = None
@property
def wpa_supplicant(self):
return 'wpa_supplicant' in self.links
@wpa_supplicant.setter
def wpa_supplicant(self, is_up):
self._set_link_status('wpa_supplicant', is_up)
def attached(self):
return self._wpa_control and self._wpa_control.attached
def attach_wpa_control(self, path):
"""Attach to the wpa_supplicant control interface.
Args:
path: The path containing the wpa_supplicant control interface socket.
Returns:
Whether attaching was successful.
"""
if self.attached():
return True
socket = os.path.join(path, self.name)
try:
self._wpa_control = self.get_wpa_control(socket)
self._wpa_control.attach()
except wpactrl.error as e:
logging.error('Error attaching to wpa_supplicant: %s', e)
return False
status = self.wpa_cli_status()
self.wpa_supplicant = status.get('wpa_state') == 'COMPLETED'
if not self._initialized:
self.initial_ssid = status.get('ssid')
return True
def wpa_cli_status(self):
"""Parse the STATUS response from the wpa_supplicant CLI.
Returns:
A dict containing the parsed results, where key and value are separated by
'=' on each line.
"""
status = {}
if self._wpa_control:
lines = self._wpa_control.request('STATUS').splitlines()
for line in lines:
if '=' not in line:
continue
k, v = line.strip().split('=', 1)
status[k] = v
return status
def get_wpa_control(self, socket):
return self.WPACtrl(socket)
def detach_wpa_control(self):
if self.attached():
try:
self._wpa_control.detach()
except wpactrl.error:
logging.error('Failed to detach from wpa_supplicant interface. This '
'may mean something else killed wpa_supplicant.')
self._wpa_control = None
self.wpa_supplicant = False
def handle_wpa_events(self):
if not self.attached():
self.wpa_supplicant = False
return
while self._wpa_control.pending():
match = self.WPA_EVENT_RE.match(self._wpa_control.recv())
if match:
event = match.group('event')
if event == 'CONNECTED':
self.wpa_supplicant = True
elif event in ('DISCONNECTED', 'TERMINATING', 'ASSOC-REJECT',
'AUTH-REJECT'):
self.wpa_supplicant = False
if event == 'TERMINATING':
self.detach_wpa_control()
break
self.update_routes()
def initialize(self):
"""Unset self.initial_ssid, which is only relevant during initialization."""
self.initial_ssid = None
super(Wifi, self).initialize()
class FrenzyWPACtrl(object):
"""A WPACtrl for Frenzy devices.
Implements the same functions used on the normal WPACtrl, using a combination
of the QCSAPI and wifi_files. Keeps state in order to generate events by
diffing saved state with current system state.
"""
WIFIINFO_PATH = '/tmp/wifi/wifiinfo'
def __init__(self, socket):
self._interface = os.path.split(socket)[-1]
# State from QCSAPI and wifi_files.
self._client_mode = False
self._ssid = None
self._status = None
self._events = []
def _qcsapi(self, *command):
try:
return subprocess.check_output(['qcsapi'] + list(command)).strip()
except subprocess.CalledProcessError:
return None
def _wifiinfo_filename(self):
return os.path.join(self.WIFIINFO_PATH, self._interface)
def _get_wifiinfo(self):
try:
return json.load(open(self._wifiinfo_filename()))
except IOError:
return None
def _get_ssid(self):
wifiinfo = self._get_wifiinfo()
if wifiinfo:
return wifiinfo.get('SSID')
def _check_client_mode(self):
return self._qcsapi('get_mode', 'wifi0') == 'Station'
def attach(self):
self._update()
@property
def attached(self):
return self._client_mode
def detach(self):
raise wpactrl.error('Real WPACtrl always raises this when detaching.')
def pending(self):
self._update()
return bool(self._events)
def _update(self):
"""Generate and cache events, update state."""
client_mode = self._check_client_mode()
ssid = self._get_ssid()
status = self._qcsapi('get_status', 'wifi0')
# If we have an SSID and are in client mode, and at least one of those is
# new, then we have just connected.
if client_mode and ssid and (not self._client_mode or ssid != self._ssid):
self._events.append('<2>CTRL-EVENT-CONNECTED')
# If we are in client mode but lost SSID, we disconnected.
if client_mode and self._ssid and not ssid:
self._events.append('<2>CTRL-EVENT-DISCONNECTED')
# If there is an auth/assoc failure, then status (above) is 'Error'. We
# really want the converse of this implication (i.e. that 'Error' implies an
# auth/assoc failure), but due to limited documentation this will have to
# do. It should be good enough: if something else causes get_status to
# return 'Error', we are probably not connected, and we don't do anything
# special with auth/assoc failures specifically.
if client_mode and status == 'Error' and self._status != 'Error':
self._events.append('<2>CTRL-EVENT-AUTH-REJECT')
# If we left client mode, wpa_supplicant has terminated.
if self._client_mode and not client_mode:
self._events.append('<2>CTRL-EVENT-TERMINATING')
self._client_mode = client_mode
self._ssid = ssid
self._status = status
def recv(self):
return self._events.pop(0)
def request(self, request_type):
"""Partial implementation of WPACtrl.request."""
if request_type != 'STATUS':
return ''
self._update()
if not self._client_mode or not self._ssid:
return ''
return 'wpa_state=COMPLETED\nssid=%s' % self._ssid
class FrenzyWifi(Wifi):
"""Represents a Frenzy wireless interface."""
# pylint: disable=invalid-name
WPACtrl = FrenzyWPACtrl