]> git.ipfire.org Git - ipfire-2.x.git/blobdiff - config/unbound/unbound-dhcp-leases-bridge
unbound-dhcp-leases-bridge: Replace leases file atomically
[ipfire-2.x.git] / config / unbound / unbound-dhcp-leases-bridge
index 06bff2eda266beafbde3305c09937f70c4767b6f..a8cd837bbfbf17b08cf03cd77eb5c11f7b56233d 100644 (file)
 import argparse
 import datetime
 import daemon
+import ipaddress
 import logging
 import logging.handlers
+import os
 import re
 import signal
 import subprocess
+import tempfile
 
 import inotify.adapters
 
@@ -48,9 +51,25 @@ def setup_logging(loglevel=logging.INFO):
 
 log = logging.getLogger("dhcp")
 
+def ip_address_to_reverse_pointer(address):
+       parts = address.split(".")
+       parts.reverse()
+
+       return "%s.in-addr.arpa" % ".".join(parts)
+
+def reverse_pointer_to_ip_address(rr):
+       parts = rr.split(".")
+
+       # Only take IP address part
+       parts = reversed(parts[0:4])
+
+       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.unbound = UnboundConfigWriter(unbound_leases_file)
                self.running = False
@@ -59,10 +78,15 @@ class UnboundDHCPLeasesBridge(object):
                log.info("Unbound DHCP Leases Bridge started on %s" % self.leases_file)
                self.running = True
 
-               # Initially read leases file
+               # Initial setup
+               self.hosts = self.read_static_hosts()
                self.update_dhcp_leases()
 
-               i = inotify.adapters.Inotify([self.leases_file])
+               i = inotify.adapters.Inotify([
+                       self.leases_file,
+                       self.fix_leases_file,
+                       self.hosts_file,
+               ])
 
                for event in i.event_gen():
                        # End if we are requested to terminate
@@ -76,16 +100,88 @@ class UnboundDHCPLeasesBridge(object):
 
                        # Update leases after leases file has been modified
                        if "IN_MODIFY" in type_names:
+                               # Reload hosts
+                               if watch_path == self.hosts_file:
+                                       self.hosts = self.read_static_hosts()
+
                                self.update_dhcp_leases()
 
+                       # If the file is deleted, we re-add the watcher
+                       if "IN_IGNORED" in type_names:
+                               i.add_watch(watch_path)
+
                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 = 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 hostname, addresses in hosts.items():
+                       log.debug("  %-20s : %s" % (hostname, ", ".join(addresses)))
+
+               return hosts
+
        def terminate(self):
                self.running = False
 
@@ -102,6 +198,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:
@@ -126,7 +224,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
 
@@ -164,6 +262,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
@@ -193,6 +343,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"
@@ -212,19 +367,65 @@ class Lease(object):
        def hostname(self):
                hostname = self._properties.get("client-hostname")
 
+               if hostname is None:
+                       return
+
                # Remove any ""
-               if hostname:
-                       hostname = hostname.replace("\"", "")
+               hostname = hostname.replace("\"", "")
 
-               return hostname
+               # Only return valid hostnames
+               m = re.match(r"^[A-Z0-9\-]{1,63}$", hostname, re.I)
+               if m:
+                       return hostname
 
        @property
        def domain(self):
-               return "local" # XXX
+               # Load ethernet settings
+               ethernet_settings = self.read_settings("/var/ipfire/ethernet/settings")
+
+               # Load DHCP settings
+               dhcp_settings = self.read_settings("/var/ipfire/dhcp/settings")
+
+               subnets = {}
+               for zone in ("GREEN", "BLUE"):
+                       if not dhcp_settings.get("ENABLE_%s" % zone) == "on":
+                               continue
+
+                       netaddr = ethernet_settings.get("%s_NETADDRESS" % zone)
+                       submask = ethernet_settings.get("%s_NETMASK" % zone)
+
+                       subnet = ipaddress.ip_network("%s/%s" % (netaddr, submask))
+                       domain = dhcp_settings.get("DOMAIN_NAME_%s" % zone)
+
+                       subnets[subnet] = domain
+
+               address = ipaddress.ip_address(self.ipaddr)
+
+               for subnet, domain in subnets.items():
+                       if address in subnet:
+                               return domain
+
+               # Fall back to localdomain if no match could be found
+               return "localdomain"
+
+       @staticmethod
+       def read_settings(filename):
+               settings = {}
+
+               with open(filename) as f:
+                       for line in f.readlines():
+                               # Remove line-breaks
+                               line = line.rstrip()
+
+                               k, v = line.split("=", 1)
+                               settings[k] = v
+
+               return settings
 
        @property
        def fqdn(self):
-               return "%s.%s" % (self.hostname, self.domain)
+               if self.hostname:
+                       return "%s.%s" % (self.hostname, self.domain)
 
        @staticmethod
        def _parse_time(s):
@@ -255,12 +456,17 @@ class Lease(object):
 
        @property
        def rrset(self):
+               # If the lease does not have a valid FQDN, we cannot create any RRs
+               if self.fqdn is None:
+                       return []
+
                return [
                        # Forward record
-                       (self.fqdn, LOCAL_TTL, "IN A", self.ipaddr),
+                       (self.fqdn, "%s" % LOCAL_TTL, "IN A", self.ipaddr),
 
                        # Reverse record
-                       (self.ipaddr, LOCAL_TTL, "IN PTR", self.fqdn),
+                       (ip_address_to_reverse_pointer(self.ipaddr), "%s" % LOCAL_TTL,
+                               "IN PTR", self.fqdn),
                ]
 
 
@@ -271,11 +477,9 @@ class UnboundConfigWriter(object):
                self._cached_leases = []
 
        def update_dhcp_leases(self, leases):
-               # Strip all non-active or expired leases
-               leases = [l for l in leases if l.active and not l.expired]
-
-               # Find any leases that have expired or do not exist any more 
-               removed_leases = [l for l in self._cached_leases if l.expired or l not in leases]
+               # Find any leases that have expired or do not exist any more
+               # but are still in the unbound local data
+               removed_leases = [l for l in self._cached_leases if not l in leases]
 
                # Find any leases that have been added
                new_leases = [l for l in leases if l not in self._cached_leases]
@@ -284,38 +488,62 @@ class UnboundConfigWriter(object):
                if not new_leases and not removed_leases:
                        return
 
-               self._cached_leases = leases
-
                # Write out all leases
                self.write_dhcp_leases(leases)
 
                # Update unbound about changes
                for l in removed_leases:
-                       self._control("local_data_remove", l.fqdn)
+                       try:
+                               for name, ttl, type, content in l.rrset:
+                                       log.debug("Removing records for %s" % name)
+                                       self._control("local_data_remove", name)
+
+                       # If the lease cannot be removed we will try the next one
+                       except:
+                               continue
+
+                       # If the removal was successful, we will remove it from the cache
+                       else:
+                               self._cached_leases.remove(l)
 
                for l in new_leases:
-                       for rr in l.rrset:
-                               self._control("local_data", *rr)
+                       try:
+                               for rr in l.rrset:
+                                       log.debug("Adding new record %s" % " ".join(rr))
+                                       self._control("local_data", *rr)
+
+                       # If the lease cannot be added we will try the next one
+                       except:
+                               continue
 
+                       # Add lease to cache when successfully added
+                       else:
+                               self._cached_leases.append(l)
 
        def write_dhcp_leases(self, leases):
-               with open(self.path, "w") as f:
+               with tempfile.NamedTemporaryFile(mode="w", delete=False) as f:
+                       filename = f.name
+
                        for l in leases:
                                for rr in l.rrset:
                                        f.write("local-data: \"%s\"\n" % " ".join(rr))
 
+               os.rename(filename, self.path)
+
        def _control(self, *args):
-               command = ["unbound-control", "-q"]
+               command = ["unbound-control"]
                command.extend(args)
 
                try:
-                       subprocess.check_call(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
+
 
 if __name__ == "__main__":
        parser = argparse.ArgumentParser(description="Bridge for DHCP Leases and Unbound DNS")
@@ -330,6 +558,10 @@ 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()
@@ -344,7 +576,8 @@ if __name__ == "__main__":
 
        setup_logging(loglevel)
 
-       bridge = UnboundDHCPLeasesBridge(args.dhcp_leases, args.unbound_leases)
+       bridge = UnboundDHCPLeasesBridge(args.dhcp_leases, args.fix_leases,
+               args.unbound_leases, args.hosts)
 
        ctx = daemon.DaemonContext(detach_process=args.daemon)
        ctx.signal_map = {