| #!/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 |