/
usr
/
bin
/
Upload File
HOME
#! /usr/bin/python3 # networkd-dispatcher # Dispatcher service for systemd-networkd # Copyright(c) 2016 by wave++ "Yuri D'Elia" <wavexx@thregr.org> # Distributed under GPLv3+ (see COPYING) WITHOUT ANY WARRANTY. # Copyright(c) 2018 by craftyguy "Clayton Craft" <clayton@craftyguy.net> # Distributed under GPLv3+ (see COPYING) WITHOUT ANY WARRANTY. from __future__ import print_function, division, generators, unicode_literals import argparse import collections import errno import json import logging import os import pathlib import socket import subprocess import sys from gi.repository import GLib as glib import dbus import dbus.mainloop.glib logger = logging.getLogger('networkd-dispatcher') # Detect up-front which commands we use exist def resolve_path(cmdname): for dirname in os.environ['PATH'].split(':'): path = os.path.join(dirname, cmdname) if os.path.exists(path): return path logger.warning('No valid path found for %s', cmdname) return None # Constants NETWORKCTL = resolve_path('networkctl') DEFAULT_SCRIPT_DIR = '/etc/networkd-dispatcher:/usr/lib/networkd-dispatcher' # Supported wireless tools IWCONFIG = resolve_path('iwconfig') IW = resolve_path('iw') LOG_FORMAT = '%(levelname)s:%(message)s' STATE_IGN = {'carrier', 'degraded', None} SINGLETONS = {'Type', 'ESSID', 'OperationalState'} # taken from https://www.freedesktop.org/software/systemd/man/networkctl.html ADMIN_STATES = ['configured', 'configuring', 'failed', 'pending', 'unmanaged', 'linger', 'initialized'] OPER_STATES = ['carrier', 'degraded', 'degraded-carrier', 'dormant', 'enslaved', 'missing', 'no-carrier', 'off', 'routable'] AddressList = collections.namedtuple('AddressList', ['ipv4', 'ipv6']) NetworkctlListState = collections.namedtuple('NetworkctlListState', ['idx', 'name', 'type', 'operational', 'administrative']) class UnknownState(Exception): pass def unquote(buf, char='\\'): """Remove escape characters from iwconfig ESSID output""" idx = 0 while True: idx = buf.find(char, idx) if idx < 0: break buf = buf[:idx] + buf[idx+1:] idx += 1 return buf def get_networkctl_list(): """Update the mapping from interface index numbers to state""" try: out = subprocess.check_output([NETWORKCTL, 'list', '--no-pager', '--no-legend']) except subprocess.CalledProcessError as e: logger.error('networkctl list failed: %s', e) return [] result = [] for line in out.split(b'\n')[:-1]: fields = line.decode('utf-8', errors='replace').split() idx_s = fields.pop(0) result.append(NetworkctlListState(int(idx_s), *fields)) return result def get_networkctl_status(iface_name): """Return a dictionary mapping keys to lists (or strings if in SINGLETONS)""" data = collections.defaultdict(list) try: out = subprocess.check_output([NETWORKCTL, 'status', '--no-pager', '--no-legend', '--', iface_name]) except subprocess.CalledProcessError as e: logger.error('Failed to get interface "%s" status: %s', iface_name, e) return data oldk = None for line in out.split(b'\n')[1:-1]: line = line.decode('utf-8', errors='replace') k = line[:16].strip() or oldk oldk = k v = line[18:].strip() if k in SINGLETONS: data[k] = v else: data[k].append(v) return data def get_wlan_essid(iface_name): """Given an interface name, return its ESSID""" if IWCONFIG is None: if IW is None: logger.error('Unable to retrieve ESSID for wireless interface %s: ' 'no supported wireless tool installed' % iface_name) return '' return iw_get_ssid(iface_name) return iwconfig_get_ssid(iface_name) def iw_get_ssid(iface_name): out = subprocess.check_output([IW, iface_name, 'link']) lines = out.decode('utf-8', errors='replace').split('\n') line = [s for s in lines if 'SSID' in s] if len(line) == 0: logger.warning('Unable to retrieve ESSID for wireless interface %s.' % iface_name) return '' essid = line[0].rsplit(" ")[1] return unquote(essid) def iwconfig_get_ssid(iface_name): out = subprocess.check_output([IWCONFIG, '--', iface_name]) line = out.split(b'\n')[0].decode('utf-8', errors='replace') essid = line[line.find('ESSID:')+7:-3] return unquote(essid) def check_perms(path, mode=0o755, uid=0, gid=0): """ Check that the given file or dir @ path has the given mode set, and is owned by the given uid/gid. Symlinks are *not* followed. Raises FileNotFoundError if path doesn't exist.""" if not os.path.exists(path): raise FileNotFoundError st = os.stat(path, follow_symlinks=False) st_mode = st.st_mode & 0x00FFF if st.st_uid == uid and st.st_gid == gid and st_mode == mode: return True logger.error("invalid permissions on %s. expected mode=%s, uid=%d, " "gid=%d; got mode=%s, uid=%d, gid=%d", path, oct(mode), uid, gid, oct(st_mode), st.st_uid, st.st_gid) return False def scripts_in_path(path, subdir): """Given directory names in PATH notation (separated by :), and a subdirectory name, return a sorted list of executables contained in that subdirectory, such that executables in earlier path components override those with the same name in later path components.""" script_list = [] base_filenames = set() for one_path in path.split(":"): one_path = os.path.join(one_path, subdir) if not os.path.exists(one_path): logger.debug("Path %r does not exist; skipping", one_path) continue base_filenames.update(os.listdir(one_path)) for filename in sorted(base_filenames): for one_path in path.split(":"): pathname = os.path.join(one_path, subdir, filename) if os.path.isfile(pathname): try: realpath = pathlib.Path(pathname).resolve() # Make sure that the file's parent dir has the correct # perms, without following any symlinks if not check_perms(os.path.dirname(pathname), 0o755, 0, 0): continue # Make sure file has correct perms, after following any # symlink(s) if not check_perms(realpath, 0o755, 0, 0): continue except FileNotFoundError: continue script_list.append(pathname) break return script_list def parse_address_strings(addrs): """Given a list of addresses, discard uninteresting ones, and sort the rest into IPv4 vs IPv6""" ip4addrs = [] ip6addrs = [] for addr in addrs: if addr.startswith('127.') or \ addr.startswith('fe80:'): continue if ':' in addr: ip6addrs.append(addr) elif '.' in addr: ip4addrs.append(addr) return AddressList(ip4addrs, ip6addrs) def get_interface_data(iface, state): """Return JSON-serializable data representing all state needed to run hooks for the given interface""" data = {'Type': iface.type, 'OperationalState': iface.operational, 'AdministrativeState': iface.administrative} # Always collect what data we can. data.update(get_networkctl_status(iface.name)) # The returned state may be different than what was read from # 'networkctl list', so construct state based on th iface data. # See Issue #24. data['State'] = (data.get('OperationalState', '') + " (" + data.get('AdministrativeState', '') + ")") if data.get('Type') == 'wlan': data['ESSID'] = get_wlan_essid(iface.name) return data class Dispatcher(object): def __init__(self, script_dir=DEFAULT_SCRIPT_DIR): self.iface_names_by_idx = {} # only changed on rescan self.ifaces_by_name = {} # updated on every state change self.script_dir = script_dir self._interface_scan() def __repr__(self): return '<Dispatcher(%r)>' % (self.__dict__,) def _interface_scan(self): iface_list = get_networkctl_list() # Append new interfaces, keeping old ones around to avoid hotplug race # condition (issue #20) for i in iface_list: if i not in self.iface_names_by_idx: self.iface_names_by_idx[i.idx] = i.name self.ifaces_by_name[i.name] = i logger.debug('Performed interface scan; state: %r', self) def register(self, bus=None): """Register this dispatcher to handle events from the given bus""" if bus is None: bus = dbus.SystemBus() bus.add_signal_receiver(self._receive_signal, bus_name='org.freedesktop.network1', signal_name='PropertiesChanged', path_keyword='path') def trigger_all(self): """Immediately invoke all scripts for the last known (or initial) states for each interface""" logger.info('Triggering scripts for last-known state for all' 'interfaces') for iface_name, iface in self.ifaces_by_name.items(): logger.debug('Running immediate triggers for %r', iface) try: self.handle_state(iface_name, administrative_state=iface.administrative, operational_state=iface.operational, force=True) except UnknownState as e: logger.exception("Unknown state for interface %s: %s", iface, str(e)) except Exception: # pylint: disable=broad-except logger.exception("Error handling initial state for " "interface %r", iface) def get_scripts_list(self, state): """Return scripts for the given state""" return scripts_in_path(self.script_dir, state + ".d") def _handle_one_state(self, iface_name, state, state_type, force=False): """Process a single state change""" try: if state is None: return prior_iface = self.ifaces_by_name.get(iface_name) if prior_iface is None: logger.error('Attempting to handle state for unknown interface' '%r', iface_name) return prior_state = getattr(prior_iface, state_type) if force is False and state == prior_state: logger.debug('No change represented by %s state %r for ' 'interface %r', state_type, state, iface_name) return new_iface = prior_iface._replace(**{state_type: state}) self.ifaces_by_name[new_iface.name] = new_iface if state in STATE_IGN: logger.debug('Ignored state %r seen for interface %r, ' 'skipping', state, iface_name) return self.run_hooks_for_state(new_iface, state) # pylint: disable=broad-except except Exception: logger.exception('Error handling notification for interface %r ' 'entering %s state %s', iface_name, state_type, state) def handle_state(self, iface_name, administrative_state=None, operational_state=None, force=False): if (administrative_state and administrative_state.lower() not in ADMIN_STATES): raise UnknownState(administrative_state) if (operational_state and operational_state.lower() not in OPER_STATES): raise UnknownState(operational_state) self._handle_one_state(iface_name, administrative_state, 'administrative', force=force) self._handle_one_state(iface_name, operational_state, 'operational', force=force) def run_hooks_for_state(self, iface, state): """Run all hooks associated with a given state""" # No actions to take? Do nothing. script_list = self.get_scripts_list(state) if not script_list: logger.debug('Ignoring notification for interface %r entering ' 'state %r: no triggers', iface, state) return # Collect data data = get_interface_data(iface, state) (v4addrs, v6addrs) = parse_address_strings(data.get('Address', ())) # Set script env. variables script_env = dict(os.environ) script_env.update({ 'ADDR': (data.get('Address', ['']) + [''])[0], 'ESSID': data.get('ESSID', ''), 'IP_ADDRS': ' '.join(v4addrs), 'IP6_ADDRS': ' '.join(v6addrs), 'IFACE': iface.name, 'STATE': str(state), 'AdministrativeState': data.get('AdministrativeState', ''), 'OperationalState': data.get('OperationalState', ''), 'json': json.dumps(data), }) # run all valid scripts in the list logger.debug('Running triggers for interface %r entering state %r ' 'with environment %r', iface, state, script_env) for script in script_list: logger.info('Invoking %r for interface %s', script, iface.name) ret = subprocess.Popen(script, env=script_env).wait() if ret != 0: logger.warning('Exit status %r from script %r invoked with ' 'environment %r', ret, script, script_env) def _receive_signal(self, typ, data, _, path): logger.debug('Signal: typ=%r, data=%r, path=%r', typ, data, path) if typ != 'org.freedesktop.network1.Link': logger.debug('Ignoring signal received with unexpected typ %r', typ) return if not path.startswith('/org/freedesktop/network1/link/_'): logger.warning('Ignoring signal received with unexpected path %r', path) return # Detect necessity of reloading map *before* filtering ignored states # http://thread.gmane.org/gmane.comp.sysutils.systemd.devel/36460 idx = path[32:] idx = int(chr(int(idx[:2], 16)) + idx[2:]) if idx not in self.iface_names_by_idx: # Try to reload configuration if even an ignored message is seen logger.warning('Unknown index %r seen, reloading interface list', idx) self._interface_scan() try: iface_name = self.iface_names_by_idx[idx] except KeyError: # Presumptive race condition: We reloaded, but the index is # still invalid logger.error('Unknown interface index %r seen even after reload', idx) return operational_state = data.get('OperationalState', None) administrative_state = data.get('AdministrativeState', None) if ((operational_state is not None) or (administrative_state is not None)): try: self.handle_state(iface_name, administrative_state=str(administrative_state) # noqa if administrative_state else None, operational_state=str(operational_state) if operational_state else None,) except UnknownState as e: logger.exception("Unknown state for interface %s: %s", iface_name, str(e)) # Handle interfaces that have been removed if administrative_state == 'linger': try: self.iface_names_by_idx.pop(idx) self.ifaces_by_name.pop(iface_name) except KeyError: logger.error('Unable to remove interface at index %r.', idx) finally: return def sd_notify(unset_environment=False, **kwargs): """Systemd sd_notify implementation for Python. Note: kwargs should contain the state to send to systemd""" if not kwargs: logger.error("sd_notify called with no state specified!") return -errno.EINVAL sock = None try: # Turn state, a dictionary, into a properly formatted string where # each 'key=val' combo in the dictionary is separated by a \n state_str = '\n'.join(['{0}={1}'.format(key, val) for (key, val) in kwargs.items()]) env = os.environ.get('NOTIFY_SOCKET', None) if not env: # Process was not invoked with systemd return -errno.EINVAL if env[0] not in ('/', '@'): logger.warning("NOTIFY_SOCKET is set, but does not contain a " "legitimate value") return -errno.EINVAL if env[0] == '@': env = '\0' + env[1:] sock = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM) if sock.sendto(bytearray(state_str, 'utf-8'), env) > 0: return 1 # pylint: disable=broad-except except Exception: logger.exception("Ignoring unexpected error during sd_notify()" "invocation") if sock: sock.close() if unset_environment: if 'NOTIFY_SOCKET' in os.environ: del os.environ['NOTIFY_SOCKET'] return 0 def main(): ap = argparse.ArgumentParser(description='networkd dispatcher daemon') ap.add_argument('-S', '--script-dir', action='store', default=DEFAULT_SCRIPT_DIR, help='Location under which to look for scripts [default: ' '%(default)s]') ap.add_argument('-T', '--run-startup-triggers', action='store_true', help='Generate events reflecting preexisting state and ' 'behavior on startup [default: %(default)s]') ap.add_argument('-v', '--verbose', action='count', default=0, help='Increment verbosity level once per call') ap.add_argument('-q', '--quiet', action='count', default=0, help='Decrement verbosity level once per call') args = ap.parse_args() verbosity_num = (args.verbose - args.quiet) if verbosity_num <= -2: log_level = logging.CRITICAL elif verbosity_num <= -1: log_level = logging.ERROR elif verbosity_num == 0: log_level = logging.WARNING elif verbosity_num == 1: log_level = logging.INFO else: log_level = logging.DEBUG logging.basicConfig(level=log_level, format=LOG_FORMAT) dbus.mainloop.glib.DBusGMainLoop(set_as_default=True) if NETWORKCTL is None: logger.critical('Unable to find networkctl command; cannot continue') sd_notify(ERRNO=errno.ENOENT) sys.exit(1) dispatcher = Dispatcher(script_dir=args.script_dir) dispatcher.register() # After configuring the receiver, run initial operations if args.run_startup_triggers: dispatcher.trigger_all() # main loop mainloop = glib.MainLoop() # Signal to systemd that service is runnning sd_notify(READY=1) logger.info('Startup complete') mainloop.run() if __name__ == '__main__': main() # vim: ai et sts=4 sw=4 ts=4