X-Git-Url: http://git.ipfire.org/?p=ipfire-2.x.git;a=blobdiff_plain;f=config%2Funbound%2Funbound-dhcp-leases-bridge;h=a8cd837bbfbf17b08cf03cd77eb5c11f7b56233d;hp=06bff2eda266beafbde3305c09937f70c4767b6f;hb=b666975ec292fec239aa6023dc79abf5538c9d95;hpb=077ea717e035aa5fa37ce670957d3312fcaabcf1 diff --git a/config/unbound/unbound-dhcp-leases-bridge b/config/unbound/unbound-dhcp-leases-bridge index 06bff2eda2..a8cd837bbf 100644 --- a/config/unbound/unbound-dhcp-leases-bridge +++ b/config/unbound/unbound-dhcp-leases-bridge @@ -22,11 +22,14 @@ 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 = {