diff --git a/dnssec-trigger-script.in b/dnssec-trigger-script.in
index b572dd1..830baa9 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,40 @@ 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,
+ "use_private_address_ranges": TRUE,
+ "set_search_domains": False,
+ "keep_positive_answers": False,
+ }
def __init__(self):
try:
@@ -37,35 +68,44 @@ 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 "<Config validate_connection_provided_zones={validate_connection_provided_zones} add_wifi_provided_zones={add_wifi_provided_zones}>".format(**vars(self))
+ def __getattr__(self, option):
+ return self.bool_options[option]
+
+ def __str__(self):
+ return "<Config {}>".format(self.bool_options)
+
+ @property
+ def flush_command(self):
+ return "flush_negative" if self.keep_positive_answers else "flush_zone"
+
+config = Config()
+if config.debug:
+ log.setLevel(logging.DEBUG);
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 "<ConnectionList(only_default={only_default}, skip_wifi={skip_wifi}, connections={})>".format(list(self), **vars(self))
+ return "<ConnectionList(only_default={only_default}, only_vpn={only_vpn}, skip_wifi={skip_wifi}, connections={})>".format(list(self), **vars(self))
def __iter__(self):
for item in self.nm_connections:
@@ -82,6 +122,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 +232,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):
@@ -216,28 +258,33 @@ class UnboundZoneConfig:
self._commit(zone, None, None)
def _commit(self, name, servers, secure):
- # Check the list of servers.
+ # FIXME: Older versions of unbound don't print +i for insecure zones
+ # and thus we cannot see whether the zone has changed or not. Therefore
+ # we have no other chance than to re-add existing zones as well.
#
- # Older versions of unbound don't print +i and so we can't distinguish
- # secure and insecure zones properly. Therefore we need to ignore the
- # insecure flag which leads to not being able to switch the zone
- # between secure and insecure unless it's removed or its servers change.
- if self.cache.get(name, [None])[0] == servers:
- log.debug("Connection provided zone '{}' already set to {} ({})".format(name, servers, 'secure' if servers else 'insecure'))
- return
+ # old_servers, old_secure = self.cache.get(name, [None, None])
+ # if servers, secure == old_servers, old_secure:
+ # log.debug("Connection provided zone '{}' already set to {} ({})".format(name, servers, 'secure' if old_secure else 'insecure'))
+ # return
if servers:
self.cache[name] = servers, secure
self._control(["forward_add"] + ([] if secure else ["+i"]) + [name] + list(servers))
+ # Unbound doesn't switch an insecure zone to a secure zone when "+i" is
+ # specified and there is no "-i" to add a secure zone explicitly.
+ if secure:
+ self._control(["insecure_remove", name])
else:
del self.cache[name]
self._control(["forward_remove", name])
- self._control(["flush_zone", name])
+ self._control([config.flush_command, name])
self._control(["flush_requestlist"])
log.debug(self)
- def _control(self, args):
+ @staticmethod
+ def _control(args):
+ log.debug("unbound-control: {}".format(args))
subprocess.check_call(["unbound-control"] + args, stdout=DEVNULL, stderr=DEVNULL)
class Store:
@@ -255,7 +302,7 @@ class Store:
line = line.strip()
if line:
self.cache.add(line)
- except FileNotFoundError:
+ except IOError:
pass
log.debug(self)
@@ -277,10 +324,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 +362,29 @@ 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"
+
+ rfc1918_reverse_zones = [
+ "c.f.ip6.arpa",
+ "d.f.ip6.arpa",
+ "168.192.in-addr.arpa",
+ ] + ["{}.172.in-addr.arpa".format(octet) for octet in range(16, 32)] + [
+ "10.in-addr.arpa",
+ ] if config.use_private_address_ranges else []
+
def __init__(self, argv):
if len(argv) > 1 and argv[1] == '--debug':
argv.pop(1)
@@ -327,108 +399,246 @@ class Application:
self.method = getattr(self, "run_" + argv[1][2:].replace('-', '_'))
except AttributeError:
self.usage()
- self.config = Config()
+
+ 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.info("Rewriting {!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.
+ """
+
+ if config.add_search_domains:
+ zones = set(sum((connection.zones for connection in ConnectionList(self.client)), []))
+ log.info("Search domains: " + ' '.join(zones))
+ self.resolvconf_localhost_contents = self.__class__.resolvconf_localhost_contents
+ self.resolvconf_localhost_contents += "search {}\n".format(' '.join(zones))
+
+ self._install_resolv_conf(self.resolvconf_trigger, self.resolvconf_trigger_tmp, False)
+ self._install_resolv_conf(self.resolvconf, self.resolvconf_tmp, config.use_resolv_conf_symlink)
+ self._install_resolv_conf(self.resolvconf_secure, self.resolvconf_secure_tmp, 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.exists(self.resolvconf_networkmanager):
+ os.symlink(self.resolvconf_networkmanager, self.resolvconf)
+ elif os.path.exists("/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()
+ stored_servers.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"])
+ @property
+ def global_forwarders(self):
+ connections = None
+ if config.use_vpn_global_forwarders:
+ connections = list(ConnectionList(self.client, only_vpn=True))
+ if not connections:
+ connections = list(ConnectionList(self.client, only_default=True))
+
+ return sum((connection.servers for connection in connections), [])
def run_update(self):
+ """Update unbound and dnssec-trigger configuration."""
+
self.run_update_global_forwarders()
self.run_update_connection_zones()
+ @staticmethod
+ def dnssec_trigger_control(args):
+ log.debug("dnssec-trigger-control: {}".format(args))
+ subprocess.check_call(["dnssec-trigger-control"] + args, stdout=DEVNULL, stderr=DEVNULL)
+
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():
+ self.dnssec_trigger_control(["status"])
- 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(self.global_forwarders):
+ UnboundZoneConfig._control([config.flush_command, "."])
+ self.dnssec_trigger_control(["submit"] + list(servers))
+ servers.commit()
+ log.info("Global forwarders: {}".format(' '.join(servers)))
+ else:
+ log.info("Global forwarders: {} (unchanged)".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 config.add_wifi_provided_zones).get_zone_connection_mapping()
+ unbound_zones = UnboundZoneConfig()
+ stored_zones = Store('zones')
+
+ # Remove any zones managed by dnssec-trigger that are no longer
+ # valid.
+ log.debug("removing zones that are no longer valid")
+ for zone in stored_zones:
+ # Remove all zones that are not in connections except those for
+ # reverse name resolution of private addresses.
+ if zone not in connections and zone not in self.rfc1918_reverse_zones:
+ if zone in unbound_zones:
+ unbound_zones.remove(zone)
+ stored_zones.remove(zone)
+
+ # Install all zones coming from connections except those installed
+ # by other means than dnssec-trigger-script.
+ log.debug("installing connection provided zones")
+ for zone in connections:
+ # Reinstall a known zone or install a new zone.
+ if zone in stored_zones or zone not in unbound_zones:
+ unbound_zones.add(zone, connections[zone].servers, secure=config.validate_connection_provided_zones)
+ stored_zones.add(zone)
+
+ # Install zones for reverse name resolution of private addresses
+ # except those already provided by connections and those installed
+ # by other means than dnssec-trigger-script.
+ if self.rfc1918_reverse_zones:
+ log.debug("adding RFC 1918 private zones not present in unbound or connections")
+ global_forwarders = self.global_forwarders
+ for zone in self.rfc1918_reverse_zones:
+ # Ignore a connection provided zone as it's been already
+ # processed.
+ if zone in connections:
+ continue
+ # Reinstall a known zone or install a new zone.
+ if zone in stored_zones or zone not in unbound_zones:
+ unbound_zones.add(zone, global_forwarders, secure=False)
+ stored_zones.add(zone)
+
+ stored_zones.commit()
if __name__ == "__main__":
try: