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 "<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)
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 +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: