]> git.ipfire.org Git - ipfire-2.x.git/blobdiff - config/unbound/unbound-dhcp-leases-bridge
mympd: remove create config start
[ipfire-2.x.git] / config / unbound / unbound-dhcp-leases-bridge
index 862b5814f03f073c83178fb25f73192507a22477..7f89f620a1400c5542e2d9585ffa4b658d2a115e 100644 (file)
@@ -1,4 +1,4 @@
-#!/usr/bin/python
+#!/usr/bin/python3
 ###############################################################################
 #                                                                             #
 # IPFire.org - A linux based firewall                                         #
 import argparse
 import datetime
 import daemon
+import filecmp
+import functools
 import ipaddress
 import logging
 import logging.handlers
+import os
 import re
 import signal
+import stat
 import subprocess
+import sys
+import tempfile
+import time
 
 import inotify.adapters
 
 LOCAL_TTL = 60
 
-def setup_logging(loglevel=logging.INFO):
-       log = logging.getLogger("dhcp")
+log = logging.getLogger("dhcp")
+log.setLevel(logging.DEBUG)
+
+def setup_logging(daemon=True, loglevel=logging.INFO):
        log.setLevel(loglevel)
 
+       # Log to syslog by default
        handler = logging.handlers.SysLogHandler(address="/dev/log", facility="daemon")
-       handler.setLevel(loglevel)
+       log.addHandler(handler)
 
+       # Format everything
        formatter = logging.Formatter("%(name)s[%(process)d]: %(message)s")
        handler.setFormatter(formatter)
 
-       log.addHandler(handler)
+       handler.setLevel(loglevel)
 
-       return log
+       # If we are running in foreground, we should write everything to the console, too
+       if not daemon:
+               handler = logging.StreamHandler()
+               log.addHandler(handler)
 
-log = logging.getLogger("dhcp")
+               handler.setLevel(loglevel)
+
+       return log
 
 def ip_address_to_reverse_pointer(address):
        parts = address.split(".")
@@ -64,8 +80,16 @@ def reverse_pointer_to_ip_address(rr):
        return ".".join(parts)
 
 class UnboundDHCPLeasesBridge(object):
-       def __init__(self, dhcp_leases_file, unbound_leases_file):
+       def __init__(self, dhcp_leases_file, fix_leases_file, unbound_leases_file, hosts_file):
                self.leases_file = dhcp_leases_file
+               self.fix_leases_file = fix_leases_file
+               self.hosts_file = hosts_file
+
+               self.watches = {
+                       self.leases_file     : inotify.constants.IN_MODIFY,
+                       self.fix_leases_file : 0,
+                       self.hosts_file      : 0,
+               }
 
                self.unbound = UnboundConfigWriter(unbound_leases_file)
                self.running = False
@@ -74,33 +98,127 @@ class UnboundDHCPLeasesBridge(object):
                log.info("Unbound DHCP Leases Bridge started on %s" % self.leases_file)
                self.running = True
 
-               # Initially read leases file
-               self.update_dhcp_leases()
+               i = inotify.adapters.Inotify()
 
-               i = inotify.adapters.Inotify([self.leases_file])
+               # Add watches for the directories of every relevant file
+               for f, mask in self.watches.items():
+                       i.add_watch(
+                               os.path.dirname(f),
+                               mask | inotify.constants.IN_CLOSE_WRITE | inotify.constants.IN_MOVED_TO,
+                       )
 
-               for event in i.event_gen():
-                       # End if we are requested to terminate
-                       if not self.running:
-                               break
+               # Enabled so that we update hosts and leases on startup
+               update_hosts = update_leases = True
 
-                       if event is None:
-                               continue
+               while self.running:
+                       log.debug("Wakeup of main loop")
 
-                       header, type_names, watch_path, filename = event
+                       # Process the entire inotify queue and identify what we need to do
+                       for event in i.event_gen():
+                               # Nothing to do
+                               if event is None:
+                                       break
 
-                       # Update leases after leases file has been modified
-                       if "IN_MODIFY" in type_names:
+                               # Decode the event
+                               header, type_names, path, filename = event
+
+                               file = os.path.join(path, filename)
+
+                               log.debug("inotify event received for %s: %s", file, " ".join(type_names))
+
+                               # Did the hosts file change?
+                               if self.hosts_file == file:
+                                       update_hosts = True
+
+                               # We will need to update the leases on any change
+                               update_leases = True
+
+                       # Update hosts (if needed)
+                       if update_hosts:
+                               self.hosts = self.read_static_hosts()
+
+                       # Update leases (if needed)
+                       if update_leases:
                                self.update_dhcp_leases()
 
+                       # Reset
+                       update_hosts = update_leases = False
+
+                       # Wait a moment before we start the next iteration
+                       time.sleep(5)
+
                log.info("Unbound DHCP Leases Bridge terminated")
 
        def update_dhcp_leases(self):
-               log.info("Reading DHCP leases from %s" % self.leases_file)
+               leases = []
+
+               for lease in DHCPLeases(self.leases_file):
+                       # Don't bother with any leases that don't have a hostname
+                       if not lease.fqdn:
+                               continue
+
+                       leases.append(lease)
+
+               for lease in FixLeases(self.fix_leases_file):
+                       leases.append(lease)
+
+               # Skip any leases that also are a static host
+               leases = [l for l in leases if not l.fqdn in self.hosts]
+
+               # Remove any inactive or expired leases
+               leases = [l for l in leases if l.active and not l.expired]
+
+               # Dump leases
+               if leases:
+                       log.debug("DHCP Leases:")
+                       for lease in leases:
+                               log.debug("  %s:" % lease.fqdn)
+                               log.debug("    State: %s" % lease.binding_state)
+                               log.debug("    Start: %s" % lease.time_starts)
+                               log.debug("    End  : %s" % lease.time_ends)
+                               if lease.expired:
+                                       log.debug("    Expired")
 
-               leases = DHCPLeases(self.leases_file)
                self.unbound.update_dhcp_leases(leases)
 
+       def read_static_hosts(self):
+               log.info("Reading static hosts from %s" % self.hosts_file)
+
+               hosts = {}
+               with open(self.hosts_file) as f:
+                       for line in f.readlines():
+                               line = line.rstrip()
+
+                               try:
+                                       enabled, ipaddr, hostname, domainname, generateptr = line.split(",")
+                               except:
+                                       log.warning("Could not parse line: %s" % line)
+                                       continue
+
+                               # Skip any disabled entries
+                               if not enabled == "on":
+                                       continue
+
+                               if hostname and domainname:
+                                       fqdn = "%s.%s" % (hostname, domainname)
+                               elif hostname:
+                                       fqdn = hostname
+                               elif domainname:
+                                       fqdn = domainname
+
+                               try:
+                                       hosts[fqdn].append(ipaddr)
+                                       hosts[fqdn].sort()
+                               except KeyError:
+                                       hosts[fqdn] = [ipaddr,]
+
+               # Dump everything in the logs
+               log.debug("Static hosts:")
+               for name in hosts:
+                       log.debug("  %-20s : %s" % (name, ", ".join(hosts[name])))
+
+               return hosts
+
        def terminate(self):
                self.running = False
 
@@ -117,6 +235,8 @@ class DHCPLeases(object):
                return iter(self._leases)
 
        def _parse(self):
+               log.info("Reading DHCP leases from %s" % self.path)
+
                leases = []
 
                with open(self.path) as f:
@@ -141,7 +261,7 @@ class DHCPLeases(object):
                                # exists in the list of known leases. If so replace
                                # if with the most recent lease
                                for i, l in enumerate(leases):
-                                       if l.hwaddr == lease.hwaddr:
+                                       if l.ipaddr == lease.ipaddr:
                                                leases[i] = max(lease, l)
                                                break
 
@@ -179,6 +299,58 @@ class DHCPLeases(object):
                return properties
 
 
+class FixLeases(object):
+       cache = {}
+
+       def __init__(self, path):
+               self.path = path
+
+               self._leases = self.cache[self.path] = self._parse()
+
+       def __iter__(self):
+               return iter(self._leases)
+
+       def _parse(self):
+               log.info("Reading fix leases from %s" % self.path)
+
+               leases = []
+               now = datetime.datetime.utcnow()
+
+               with open(self.path) as f:
+                       for line in f.readlines():
+                               line = line.rstrip()
+
+                               try:
+                                       hwaddr, ipaddr, enabled, a, b, c, hostname = line.split(",")
+                               except ValueError:
+                                       log.warning("Could not parse line: %s" % line)
+                                       continue
+
+                               # Skip any disabled leases
+                               if not enabled == "on":
+                                       continue
+
+                               l = Lease(ipaddr, {
+                                       "binding"         : "state active",
+                                       "client-hostname" : hostname,
+                                       "hardware"        : "ethernet %s" % hwaddr,
+                                       "starts"          : now.strftime("%w %Y/%m/%d %H:%M:%S"),
+                                       "ends"            : "never",
+                               })
+                               leases.append(l)
+
+               # Try finding any deleted leases
+               for lease in self.cache.get(self.path, []):
+                       if lease in leases:
+                               continue
+
+                       # Free the deleted lease
+                       lease.free()
+                       leases.append(lease)
+
+               return leases
+
+
 class Lease(object):
        def __init__(self, ipaddr, properties):
                self.ipaddr = ipaddr
@@ -208,6 +380,11 @@ class Lease(object):
                        state = state.split(" ", 1)
                        return state[1]
 
+       def free(self):
+               self._properties.update({
+                       "binding" : "state free",
+               })
+
        @property
        def active(self):
                return self.binding_state == "active"
@@ -261,14 +438,18 @@ class Lease(object):
 
                address = ipaddress.ip_address(self.ipaddr)
 
-               for subnet, domain in subnets.items():
+               for subnet in subnets:
                        if address in subnet:
-                               return domain
+                               return subnets[subnet]
 
-               # Fall back to localdomain if no match could be found
-               return "localdomain"
+               # Load main settings
+               settings = self.read_settings("/var/ipfire/main/settings")
+
+               # Fall back to the host domain if no match could be found
+               return settings.get("DOMAINNAME", "localdomain")
 
        @staticmethod
+       @functools.cache
        def read_settings(filename):
                settings = {}
 
@@ -334,88 +515,61 @@ class UnboundConfigWriter(object):
        def __init__(self, path):
                self.path = path
 
-       @property
-       def existing_leases(self):
-               local_data = self._control("list_local_data")
-               ret = {}
-
-               for line in local_data.splitlines():
-                       try:
-                               hostname, ttl, x, record_type, content = line.split("\t")
-                       except ValueError:
-                               continue
-
-                       # Ignore everything that is not A or PTR
-                       if not record_type in ("A", "PTR"):
-                               continue
-
-                       if hostname.endswith("."):
-                               hostname = hostname[:-1]
-
-                       if content.endswith("."):
-                               content = content[:-1]
-
-                       if record_type == "A":
-                               ret[hostname] = content
-                       elif record_type == "PTR":
-                               ret[content] = reverse_pointer_to_ip_address(hostname)
+       def update_dhcp_leases(self, leases):
+               # Write out all leases
+               if self.write_dhcp_leases(leases):
+                       log.debug("Reloading Unbound...")
 
-               return ret
+                       # Reload the configuration without dropping the cache
+                       self._control("reload_keep_cache")
 
-       def update_dhcp_leases(self, leases):
-               # Cache all expired or inactive leases
-               expired_leases = [l for l in leases if l.expired or not l.active]
+       def write_dhcp_leases(self, leases):
+               log.debug("Writing DHCP leases...")
 
-               # Find any leases that have expired or do not exist any more
-               # but are still in the unbound local data
-               removed_leases = []
-               for fqdn, address in self.existing_leases.items():
-                       if fqdn in (l.fqdn for l in expired_leases):
-                               removed_leases += [fqdn, address]
+               with tempfile.NamedTemporaryFile(mode="w") as f:
+                       for l in sorted(leases, key=lambda x: x.ipaddr):
+                               for rr in l.rrset:
+                                       f.write("local-data: \"%s\"\n" % " ".join(rr))
 
-               # Strip all non-active or expired leases
-               leases = [l for l in leases if l.active and not l.expired]
+                       # Flush the file
+                       f.flush()
 
-               # Find any leases that have been added
-               new_leases = [l for l in leases
-                       if l.fqdn not in self.existing_leases]
+                       # Compare if the new leases file has changed from the previous version
+                       try:
+                               if filecmp.cmp(f.name, self.path, shallow=False):
+                                       log.debug("The generated leases file has not changed")
 
-               # End here if nothing has changed
-               if not new_leases and not removed_leases:
-                       return
+                                       return False
 
-               # Write out all leases
-               self.write_dhcp_leases(leases)
+                               # Remove the old file
+                               os.unlink(self.path)
 
-               # Update unbound about changes
-               for hostname in removed_leases:
-                       log.debug("Removing all records for %s" % hostname)
-                       self._control("local_data_remove", hostname)
+                       # If the previous file did not exist, just keep falling through
+                       except FileNotFoundError:
+                               pass
 
-               for l in new_leases:
-                       for rr in l.rrset:
-                               log.debug("Adding new record %s" % " ".join(rr))
-                               self._control("local_data", *rr)
+                       # Make file readable for everyone
+                       os.fchmod(f.fileno(), stat.S_IRUSR|stat.S_IWUSR|stat.S_IRGRP|stat.S_IROTH)
 
+                       # Move the file to its destination
+                       os.link(f.name, self.path)
 
-       def write_dhcp_leases(self, leases):
-               with open(self.path, "w") as f:
-                       for l in leases:
-                               for rr in l.rrset:
-                                       f.write("local-data: \"%s\"\n" % " ".join(rr))
+               return True
 
        def _control(self, *args):
                command = ["unbound-control"]
                command.extend(args)
 
                try:
-                       return subprocess.check_output(command)
+                       subprocess.check_output(command)
 
                # Log any errors
                except subprocess.CalledProcessError as e:
                        log.critical("Could not run %s, error code: %s: %s" % (
                                " ".join(command), e.returncode, e.output))
 
+                       raise e
+
 
 if __name__ == "__main__":
        parser = argparse.ArgumentParser(description="Bridge for DHCP Leases and Unbound DNS")
@@ -430,27 +584,34 @@ if __name__ == "__main__":
                metavar="PATH", help="Path to the DHCPd leases file")
        parser.add_argument("--unbound-leases", default="/etc/unbound/dhcp-leases.conf",
                metavar="PATH", help="Path to the unbound configuration file")
+       parser.add_argument("--fix-leases", default="/var/ipfire/dhcp/fixleases",
+               metavar="PATH", help="Path to the fix leases file")
+       parser.add_argument("--hosts", default="/var/ipfire/main/hosts",
+               metavar="PATH", help="Path to static hosts file")
 
        # Parse command line arguments
        args = parser.parse_args()
 
        # Setup logging
-       if args.verbose == 1:
-               loglevel = logging.INFO
-       elif args.verbose >= 2:
-               loglevel = logging.DEBUG
-       else:
-               loglevel = logging.WARN
-
-       setup_logging(loglevel)
-
-       bridge = UnboundDHCPLeasesBridge(args.dhcp_leases, args.unbound_leases)
-
-       ctx = daemon.DaemonContext(detach_process=args.daemon)
-       ctx.signal_map = {
-               signal.SIGHUP  : bridge.update_dhcp_leases,
-               signal.SIGTERM : bridge.terminate,
-       }
+       loglevel = logging.WARN
+
+       if args.verbose:
+               if args.verbose == 1:
+                       loglevel = logging.INFO
+               elif args.verbose >= 2:
+                       loglevel = logging.DEBUG
+
+       bridge = UnboundDHCPLeasesBridge(args.dhcp_leases, args.fix_leases,
+               args.unbound_leases, args.hosts)
+
+       with daemon.DaemonContext(
+               detach_process=args.daemon,
+               stderr=None if args.daemon else sys.stderr,
+               signal_map = {
+                       signal.SIGHUP  : bridge.update_dhcp_leases,
+                       signal.SIGTERM : bridge.terminate,
+               },
+       ) as daemon:
+               setup_logging(daemon=args.daemon, loglevel=loglevel)
 
-       with ctx:
                bridge.run()