diff --git a/dnssec-trigger-script.in b/dnssec-trigger-script.in index b572dd1..b25afc9 100644 --- a/dnssec-trigger-script.in +++ b/dnssec-trigger-script.in @@ -6,17 +6,20 @@ """ from gi.repository import NMClient -import os, sys, shutil, subprocess +import os, sys, fcntl, shutil, glob, subprocess import logging, logging.handlers import socket, struct +# Python compatibility stuff +if not hasattr(os, "O_CLOEXEC"): + os.O_CLOEXEC = 0x80000 + DEVNULL = open("/dev/null", "wb") log = logging.getLogger() log.setLevel(logging.INFO) log.addHandler(logging.handlers.SysLogHandler()) -if sys.stderr.isatty(): - log.addHandler(logging.StreamHandler()) +log.addHandler(logging.StreamHandler()) # NetworkManager reportedly doesn't pass the PATH environment variable. os.environ['PATH'] = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" @@ -24,12 +27,37 @@ os.environ['PATH'] = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/b class UserError(Exception): pass +class Lock: + """Lock used to serialize the script""" + + path = "/var/run/dnssec-trigger/lock" + + def __init__(self): + # We don't use os.makedirs(..., exist_ok=True) to ensure Python 2 compatibility + dirname = os.path.dirname(self.path) + if not os.path.exists(dirname): + os.makedirs(dirname) + self.lock = os.open(self.path, os.O_WRONLY | os.O_CREAT | os.O_CLOEXEC, 0o600) + + def __enter__(self): + fcntl.lockf(self.lock, fcntl.LOCK_EX) + + def __exit__(self, t, v, tb): + fcntl.lockf(self.lock, fcntl.LOCK_UN) + class Config: """Global configuration options""" path = "/etc/dnssec.conf" - validate_connection_provided_zones = True - add_wifi_provided_zones = False + + bool_options = { + "debug": False, + "validate_connection_provided_zones": True, + "add_wifi_provided_zones": False, + "use_vpn_global_forwarders": False, + "use_resolv_conf_symlink": False, + "use_resolv_secure_conf_symlink": False, + } def __init__(self): try: @@ -37,35 +65,36 @@ class Config: for line in config_file: if '=' in line: option, value = [part.strip() for part in line.split("=", 1)] - if option == "validate_connection_provided_zones": - self.validate_connection_provided_zones = (value == "yes") - elif option == "add_wifi_provided_zones": - self.add_wifi_provided_zones = (value == "yes") + if option in self.bool_options: + self.bool_options[option] = (value == "yes") except IOError: pass log.debug(self) - def __repr__(self): - return "".format(**vars(self)) + def __getattr__(self, option): + return self.bool_options[option] + + def __str__(self): + return "".format(self.bool_options) class ConnectionList: """List of NetworkManager active connections""" nm_connections = None - def __init__(self, only_default=False, skip_wifi=False): + def __init__(self, client, only_default=False, only_vpn=False, skip_wifi=False): # Cache the active connection list in the class + if not client.get_manager_running(): + raise UserError("NetworkManager is not running.") if self.nm_connections is None: - self.__class__.client = NMClient.Client() - self.__class__.nm_connections = self.client.get_active_connections() + self.__class__.nm_connections = client.get_active_connections() self.skip_wifi = skip_wifi self.only_default = only_default + self.only_vpn = only_vpn log.debug(self) def __repr__(self): - if not list(self): - raise Exception("!!!") - return "".format(list(self), **vars(self)) + return "".format(list(self), **vars(self)) def __iter__(self): for item in self.nm_connections: @@ -82,6 +111,8 @@ class ConnectionList: # Skip non-default connections if appropriate if self.only_default and not connection.is_default: continue + if self.only_vpn and not connection.is_vpn: + continue yield connection def get_zone_connection_mapping(self): @@ -190,10 +221,10 @@ class UnboundZoneConfig: if fields.pop(0) in ('forward', 'forward:'): fields.pop(0) secure = False - if fields[0] == '+i': + if fields and fields[0] == '+i': secure = True fields.pop(0) - self.cache[name] = set(fields[3:]), secure + self.cache[name] = set(fields), secure log.debug(self) def __repr__(self): @@ -255,7 +286,7 @@ class Store: line = line.strip() if line: self.cache.add(line) - except FileNotFoundError: + except IOError: pass log.debug(self) @@ -277,10 +308,16 @@ class Store: log.debug(self) def update(self, zones): - """Commit a new zone list.""" + """Commit a new set of items and return True when it differs""" - self.cache = set(zones) - log.debug(self) + zones = set(zones) + + if zones != self.cache: + self.cache = set(zones) + log.debug(self) + return True + + return False def remove(self, zone): """Remove zone from the cache.""" @@ -309,10 +346,21 @@ class GlobalForwarders: line = line.strip() if line: self.cache.add(line) - except FileNotFoundError: + except IOError: pass class Application: + resolvconf = "/etc/resolv.conf" + resolvconf_tmp = "/etc/.resolv.conf.dnssec-trigger" + resolvconf_secure = "/etc/resolv-secure.conf" + resolvconf_secure_tmp = "/etc/.resolv-secure.conf.dnssec-trigger" + resolvconf_backup = "/var/run/dnssec-trigger/resolv.conf.backup" + resolvconf_trigger = "/var/run/dnssec-trigger/resolv.conf" + resolvconf_trigger_tmp = resolvconf_trigger + ".tmp" + resolvconf_networkmanager = "/var/run/NetworkManager/resolv.conf" + + resolvconf_localhost_contents = "# Generated by dnssec-trigger-script\nnameserver 127.0.0.1\n" + def __init__(self, argv): if len(argv) > 1 and argv[1] == '--debug': argv.pop(1) @@ -327,108 +375,222 @@ class Application: self.method = getattr(self, "run_" + argv[1][2:].replace('-', '_')) except AttributeError: self.usage() + self.config = Config() + if self.config.debug: + log.setLevel(logging.DEBUG); + + self.client = NMClient.Client() def nm_handles_resolv_conf(self): - if subprocess.call(["pidof", "NetworkManager"], stdout=DEVNULL, stderr=DEVNULL) != 0: + if not self.client.get_manager_running(): + log.debug("NetworkManager is not running") return False try: with open("/etc/NetworkManager/NetworkManager.conf") as nm_config_file: for line in nm_config_file: - if line.strip == "dns=none": + if line.strip() in ("dns=none", "dns=unbound"): + log.debug("NetworkManager doesn't handle resolv.conf") return False except IOError: pass + log.debug("NetworkManager handles resolv.conf") return True def usage(self): - raise UserError("Usage: dnssec-trigger-script [--debug] [--async] --prepare|--update|--update-global-forwarders|--update-connection-zones|--cleanup") + raise UserError("Usage: dnssec-trigger-script [--debug] [--async] --prepare|--setup|--update|--update-global-forwarders|--update-connection-zones|--cleanup") def run(self): log.debug("Running: {}".format(self.method.__name__)) self.method() + def _check_resolv_conf(self, path): + try: + with open(path) as source: + if source.read() != self.resolvconf_localhost_contents: + log.warning("Detected incorrect contents of {!r}!".format(path)) + return False; + return True + except IOError: + return False + + def _write_resolv_conf(self, path): + self._try_remove(path) + with open(path, "w") as target: + target.write(self.resolvconf_localhost_contents) + + def _install_resolv_conf(self, path, path_tmp, symlink=False): + if symlink: + self._try_remove(path_tmp) + os.symlink(self.resolvconf_trigger, path_tmp) + self._try_set_mutable(path) + os.rename(path_tmp, path) + elif not self._check_resolv_conf(path): + self._write_resolv_conf(path_tmp) + self._try_set_mutable(path) + os.rename(path_tmp, path) + self._try_set_immutable(path) + + def _try_remove(self, path): + self._try_set_mutable(path) + try: + os.remove(path) + except OSError: + pass + + def _try_set_immutable(self, path): + subprocess.call(["chattr", "+i", path]) + + def _try_set_mutable(self, path): + if os.path.exists(path) and not os.path.islink(path): + subprocess.call(["chattr", "-i", path]) + def run_prepare(self): - """Prepare for dnssec-trigger.""" + """Prepare for starting dnssec-trigger + + Called by the service manager before starting dnssec-trigger daemon. + """ + # Backup resolv.conf when appropriate if not self.nm_handles_resolv_conf(): - log.info("Backing up /etc/resolv.conf") - shutil.copy("/etc/resolv.conf", "/var/run/dnssec-trigger/resolv.conf.bak") + try: + log.info("Backing up {} as {}...".format(self.resolvconf, self.resolvconf_backup)) + shutil.move(self.resolvconf, self.resolvconf_backup) + except IOError as error: + log.warning("Cannot back up {!r} as {!r}: {}".format(self.resolvconf, self.resolvconf_backup, error.strerror)) + + # Make sure dnssec-trigger daemon doesn't get confused by existing files. + self._try_remove(self.resolvconf) + self._try_remove(self.resolvconf_secure) + self._try_remove(self.resolvconf_trigger) + + def run_setup(self): + """Set up resolv.conf with localhost nameserver + + Called by dnssec-trigger. + """ + + self._install_resolv_conf(self.resolvconf_trigger, self.resolvconf_trigger_tmp, False) + self._install_resolv_conf(self.resolvconf, self.resolvconf_tmp, self.config.use_resolv_conf_symlink) + self._install_resolv_conf(self.resolvconf_secure, self.resolvconf_secure_tmp, self.config.use_resolv_secure_conf_symlink) + + def run_restore(self): + """Restore resolv.conf with original data + + Called by dnssec-trigger or internally as part of other actions. + """ + + self._try_remove(self.resolvconf) + self._try_remove(self.resolvconf_secure) + self._try_remove(self.resolvconf_trigger) + + log.info("Recovering {}...".format(self.resolvconf)) + if self.nm_handles_resolv_conf(): + if os.path.isfile(self.resolvconf_networkmanager): + os.symlink(self.resolvconf_networkmanager, self.resolvconf) + elif os.path.isfile("/sys/fs/cgroup/systemd"): + subprocess.check_call(["systemctl", "--ignore-dependencies", "try-restart", "NetworkManager.service"]) + else: + subprocess.check_call(["/etc/init.d/NetworkManager", "restart"]) + else: + try: + shutil.move(self.resolvconf_backup, self.resolvconf) + except IOError as error: + log.warning("Cannot restore {!r} from {!r}: {}".format(self.resolvconf, self.resolvconf_backup, error.strerror)) def run_cleanup(self): - """Clean up after dnssec-trigger.""" + """Clean up after dnssec-trigger daemon + + Called by the service manager after stopping dnssec-trigger daemon. + """ + + self.run_restore() stored_zones = Store('zones') + stored_servers = Store('servers') unbound_zones = UnboundZoneConfig() + # provide upgrade path for previous versions + old_zones = glob.glob("/var/run/dnssec-trigger/????????-????-????-????-????????????") + if old_zones: + log.info("Reading zones from the legacy zone store") + with open("/var/run/dnssec-trigger/zones", "a") as target: + for filename in old_zones: + with open(filename) as source: + log.debug("Reading zones from {}".format(filename)) + for line in source: + stored_zones.add(line.strip()) + os.remove(filename) + log.debug("clearing unbound configuration") for zone in stored_zones: unbound_zones.remove(zone) stored_zones.remove(zone) + for server in stored_servers: + stored_servers.remove(server) stored_zones.commit() - - log.debug("recovering /etc/resolv.conf") - subprocess.check_call(["chattr", "-i", "/etc/resolv.conf"]) - if not self.nm_handles_resolv_conf(): - shutil.copy("/var/run/dnssec-trigger/resolv.conf.bak", "/etc/resolv.conf") - # NetworkManager currently doesn't support explicit /etc/resolv.conf - # write out. For now we simply restart the daemon. - elif os.path.exists("/sys/fs/cgroup/systemd"): - subprocess.check_call(["systemctl", "try-restart", "NetworkManager.service"]) - else: - subprocess.check_call(["/etc/init.d/NetworkManager", "restart"]) + stored_servers.commit() def run_update(self): + """Update unbound and dnssec-trigger configuration.""" + self.run_update_global_forwarders() self.run_update_connection_zones() def run_update_global_forwarders(self): """Configure global forwarders using dnssec-trigger-control.""" - subprocess.check_call(["dnssec-trigger-control", "status"], stdout=DEVNULL, stderr=DEVNULL) + with Lock(): + subprocess.check_call(["dnssec-trigger-control", "status"], stdout=DEVNULL, stderr=DEVNULL) + + connections = None + if self.config.use_vpn_global_forwarders: + connections = list(ConnectionList(self.client, only_vpn=True)) + if not connections: + connections = list(ConnectionList(self.client, only_default=True)) - default_connections = ConnectionList(only_default=True) - servers = Store('servers') + servers = Store('servers') - if servers.update(sum((connection.servers for connection in default_connections), [])): - subprocess.check_call(["unbound-control", "flush_zone", "."]) - subprocess.check_call(["dnssec-trigger-control", "submit"] + list(servers)) - servers.commit() - log.info("Global forwarders: {}".format(' '.join(servers))) + if servers.update(sum((connection.servers for connection in connections), [])): + subprocess.check_call(["unbound-control", "flush_zone", "."]) + subprocess.check_call(["dnssec-trigger-control", "submit"] + list(servers)) + servers.commit() + log.info("Global forwarders: {}".format(' '.join(servers))) def run_update_connection_zones(self): """Configures forward zones in the unbound using unbound-control.""" - connections = ConnectionList(skip_wifi=not self.config.add_wifi_provided_zones).get_zone_connection_mapping() - unbound_zones = UnboundZoneConfig() - stored_zones = Store('zones') - - # The purpose of the zone store is to keep the list of Unbound zones - # that are managed by dnssec-trigger-script. We don't want to track - # zones accoss Unbound restarts. We want to clear any Unbound zones - # that are no longer active in NetworkManager. - log.debug("removing stored zones not present in both unbound and an active connection") - for zone in stored_zones: - if zone not in unbound_zones: - stored_zones.remove(zone) - elif zone not in connections: - unbound_zones.remove(zone) - stored_zones.remove(zone) - - # We need to install zones that are not yet in Unbound. We also need to - # reinstall zones that are already managed by dnssec-trigger in case their - # list of nameservers was changed. - # - # TODO: In some cases, we don't seem to flush Unbound cache properly, - # even when Unbound is restarted (and dnssec-trigger as well, because - # of dependency). - log.debug("installing connection provided zones") - for zone in connections: - if zone in stored_zones or zone not in unbound_zones: - unbound_zones.add(zone, connections[zone].servers, secure=self.config.validate_connection_provided_zones) - stored_zones.add(zone) - - stored_zones.commit() + with Lock(): + connections = ConnectionList(self.client, skip_wifi=not self.config.add_wifi_provided_zones).get_zone_connection_mapping() + unbound_zones = UnboundZoneConfig() + stored_zones = Store('zones') + + # The purpose of the zone store is to keep the list of Unbound zones + # that are managed by dnssec-trigger-script. We don't want to track + # zones accoss Unbound restarts. We want to clear any Unbound zones + # that are no longer active in NetworkManager. + log.debug("removing stored zones not present in both unbound and an active connection") + for zone in stored_zones: + if zone not in unbound_zones: + stored_zones.remove(zone) + elif zone not in connections: + unbound_zones.remove(zone) + stored_zones.remove(zone) + + # We need to install zones that are not yet in Unbound. We also need to + # reinstall zones that are already managed by dnssec-trigger in case their + # list of nameservers was changed. + # + # TODO: In some cases, we don't seem to flush Unbound cache properly, + # even when Unbound is restarted (and dnssec-trigger as well, because + # of dependency). + log.debug("installing connection provided zones") + for zone in connections: + if zone in stored_zones or zone not in unbound_zones: + unbound_zones.add(zone, connections[zone].servers, secure=self.config.validate_connection_provided_zones) + stored_zones.add(zone) + + stored_zones.commit() if __name__ == "__main__": try: