diff --git a/config/rootfiles/common/unbound b/config/rootfiles/common/unbound index 79612c634..40997f96b 100644 --- a/config/rootfiles/common/unbound +++ b/config/rootfiles/common/unbound @@ -19,6 +19,7 @@ usr/sbin/unbound-checkconf usr/sbin/unbound-control usr/sbin/unbound-control-setup usr/sbin/unbound-dhcp-leases-bridge +usr/sbin/unbound-dhcp-leases-client usr/sbin/unbound-host #usr/share/man/man1/unbound-host.1 #usr/share/man/man3/libunbound.3 diff --git a/config/unbound/unbound-dhcp-leases-bridge b/config/unbound/unbound-dhcp-leases-bridge index 7f89f620a..3972a45c6 100644 --- a/config/unbound/unbound-dhcp-leases-bridge +++ b/config/unbound/unbound-dhcp-leases-bridge @@ -28,15 +28,15 @@ import ipaddress import logging import logging.handlers import os +import queue import re import signal +import socket import stat import subprocess import sys import tempfile -import time - -import inotify.adapters +import threading LOCAL_TTL = 60 @@ -65,121 +65,248 @@ def setup_logging(daemon=True, loglevel=logging.INFO): return log -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, fix_leases_file, unbound_leases_file, hosts_file): + def __init__(self, dhcp_leases_file, fix_leases_file, unbound_leases_file, hosts_file, socket_path): self.leases_file = dhcp_leases_file self.fix_leases_file = fix_leases_file self.hosts_file = hosts_file + self.socket_path = socket_path - self.watches = { - self.leases_file : inotify.constants.IN_MODIFY, - self.fix_leases_file : 0, - self.hosts_file : 0, - } + self.socket = None + + # Store all known leases + self.leases = set() + + # Create a queue for all received events + self.queue = queue.Queue() + + # Initialize the worker + self.worker = Worker(self.queue, callback=self._handle_message) self.unbound = UnboundConfigWriter(unbound_leases_file) - self.running = False + + # Load all required data + self.reload() def run(self): log.info("Unbound DHCP Leases Bridge started on %s" % self.leases_file) - self.running = True - i = inotify.adapters.Inotify() + # Launch the worker + self.worker.start() - # 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, - ) + # Open the server socket + self.socket = self._open_socket(self.socket_path) - # Enabled so that we update hosts and leases on startup - update_hosts = update_leases = True + while True: + # Accept any incoming connections + try: + conn, peer = self.socket.accept() + except OSError as e: + break - while self.running: - log.debug("Wakeup of main loop") + try: + # Receive what the client is sending + data, ancillary_data, flags, address = conn.recvmsg(4096) - # 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 + # Log that we have received some data + log.debug("Received message of %s byte(s)" % len(data)) - # Decode the event - header, type_names, path, filename = event + # Decode the data + message = self._decode_message(data) - file = os.path.join(path, filename) + # Add the message to the queue + self.queue.put(message) - log.debug("inotify event received for %s: %s", file, " ".join(type_names)) + conn.send(b"OK\n") - # Did the hosts file change? - if self.hosts_file == file: - update_hosts = True + # Send ERROR to the client if something went wrong + except Exception as e: + log.error("Could not handle message: %s" % e) - # We will need to update the leases on any change - update_leases = True + conn.send(b"ERROR\n") + continue - # Update hosts (if needed) - if update_hosts: - self.hosts = self.read_static_hosts() + # Close the connection + finally: + conn.close() - # 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) + # Terminate the worker + self.queue.put(None) + self.worker.join() log.info("Unbound DHCP Leases Bridge terminated") - def update_dhcp_leases(self): - leases = [] + def _open_socket(self, path): + # Allocate a new socket + s = socket.socket(family=socket.AF_UNIX, type=socket.SOCK_STREAM) - for lease in DHCPLeases(self.leases_file): - # Don't bother with any leases that don't have a hostname - if not lease.fqdn: + # Unlink any old sockets + try: + os.unlink(path) + except FileNotFoundError as e: + pass + + # Bind the socket + try: + s.bind(self.socket_path) + except OSError as e: + log.error("Could not open socket at %s: %s" % (path, e)) + + raise SystemExit(1) from e + + # Listen + s.listen(128) + + return s + + def _decode_message(self, data): + message = {} + + for line in data.splitlines(): + # Skip empty lines + if not line: continue - leases.append(lease) + # Try to decode the line + try: + line = line.decode() + except UnicodeError as e: + log.error("Could not decode %r: %s" % (line, e)) + raise e + + # Split the line + key, _, value = line.partition("=") + + # Skip the line if it does not have a value + if not _: + raise ValueError("No value given") + + # Store the attributes + message[key] = value + + return message + + def _handle_message(self, message): + log.debug("Handling message:") + for key in message: + log.debug(" %-20s = %s" % (key, message[key])) + + # Extract the event type + event = message.get("EVENT") + + # Check if event is set + if not event: + raise ValueError("The message does not have EVENT set") + + # COMMIT + elif event == "commit": + address = message.get("ADDRESS") + name = message.get("NAME") + + # Find the old lease + old_lease = self._find_lease(address) + + # Create a new lease + lease = Lease(address, { + "client-hostname" : name, + }) + self._add_lease(lease) + + # Can we skip the update? + if old_lease: + if lease.rrset == old_lease.rrset: + log.debug("Won't update %s as nothing has changed" % lease) + return + + # Remove the old lease first + self.unbound.remove_lease(old_lease) + self._remove_lease(old_lease) + + # Apply the lease + self.unbound.apply_lease(lease) + + # RELEASE/EXPIRY + elif event in ("release", "expiry"): + address = message.get("ADDRESS") + + # Find the lease + lease = self._find_lease(address) + + if not lease: + log.warning("Could not find lease for %s" % address) + return + + # Remove the lease + self.unbound.remove_lease(lease) + self._remove_lease(lease) + + # Raise an error if the event is not supported + else: + raise ValueError("Unsupported event: %s" % event) + + def update_dhcp_leases(self): + # Drop all known leases + self.leases.clear() + + # Add all dynamic leases + for lease in DHCPLeases(self.leases_file): + self._add_lease(lease) + + # Add all static leases 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] + self._add_lease(lease) # Dump leases - if leases: + if self.leases: log.debug("DHCP Leases:") - for lease in leases: + for lease in self.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: + if lease.has_expired(): log.debug(" Expired") - self.unbound.update_dhcp_leases(leases) + self.unbound.update_dhcp_leases(self.leases) + + def _add_lease(self, lease): + # Skip leases without an FQDN + if not lease.fqdn: + log.debug("Skipping lease without an FQDN: %s" % lease) + return + + # Skip any leases that also are a static host + elif lease.fqdn in self.hosts: + log.debug("Skipping lease for which a static host exists: %s" % lease) + return + + # Don't add expired leases + elif lease.has_expired(): + log.debug("Skipping expired lease: %s" % lease) + return + + # Remove any previous leases + self._remove_lease(lease) + + # Store the lease + self.leases.add(lease) + + def _find_lease(self, ipaddr): + """ + Returns the lease with the specified IP address + """ + if not isinstance(ipaddr, ipaddress.IPv4Address): + ipaddr = ipaddress.IPv4Address(ipaddr) + + for lease in self.leases: + if lease.ipaddr == ipaddr: + return lease + + def _remove_lease(self, lease): + try: + self.leases.remove(lease) + except KeyError: + pass def read_static_hosts(self): log.info("Reading static hosts from %s" % self.hosts_file) @@ -219,8 +346,47 @@ class UnboundDHCPLeasesBridge(object): return hosts - def terminate(self): - self.running = False + def reload(self, *args, **kwargs): + # Read all static hosts + self.hosts = self.read_static_hosts() + + # Unconditionally update all leases and reload Unbound + self.update_dhcp_leases() + + def terminate(self, *args, **kwargs): + # Close the socket + if self.socket: + self.socket.close() + + +class Worker(threading.Thread): + """ + The worker is launched in a separate thread + which allows us to perform some tasks asynchronously. + """ + def __init__(self, queue, callback): + super().__init__() + + self.queue = queue + self.callback = callback + + def run(self): + log.debug("Worker %s launched" % self.native_id) + + while True: + message = self.queue.get() + + # If the message is None, we have to quit + if message is None: + break + + # Call the callback + try: + self.callback(message) + except Exception as e: + log.error("Callback failed: %s" % e, exc_info=True) + + log.debug("Worker %s terminated" % self.native_id) class DHCPLeases(object): @@ -255,18 +421,12 @@ class DHCPLeases(object): if not "hardware" in properties: continue + # Skip inactive leases + elif not properties.get("binding", "state active"): + continue + lease = Lease(ipaddr, properties) - - # Check if a lease for this Ethernet address already - # exists in the list of known leases. If so replace - # if with the most recent lease - for i, l in enumerate(leases): - if l.ipaddr == lease.ipaddr: - leases[i] = max(lease, l) - break - - else: - leases.append(lease) + leases.append(lease) return leases @@ -300,12 +460,10 @@ class DHCPLeases(object): class FixLeases(object): - cache = {} - def __init__(self, path): self.path = path - self._leases = self.cache[self.path] = self._parse() + self._leases = self._parse() def __iter__(self): return iter(self._leases) @@ -313,9 +471,10 @@ class FixLeases(object): def _parse(self): log.info("Reading fix leases from %s" % self.path) - leases = [] now = datetime.datetime.utcnow() + leases = [] + with open(self.path) as f: for line in f.readlines(): line = line.rstrip() @@ -333,72 +492,42 @@ class FixLeases(object): 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): + if not isinstance(ipaddr, ipaddress.IPv4Address): + ipaddr = ipaddress.IPv4Address(ipaddr) + self.ipaddr = ipaddr self._properties = properties def __repr__(self): - return "<%s %s for %s (%s)>" % (self.__class__.__name__, - self.ipaddr, self.hwaddr, self.hostname) + return "<%s for %s (%s)>" % (self.__class__.__name__, self.ipaddr, self.hostname) def __eq__(self, other): - return self.ipaddr == other.ipaddr and self.hwaddr == other.hwaddr + if isinstance(other, self.__class__): + return self.ipaddr == other.ipaddr + + return NotImplemented def __gt__(self, other): - if not self.ipaddr == other.ipaddr: - return + if isinstance(other, self.__class__): + if not self.ipaddr == other.ipaddr: + return NotImplemented - if not self.hwaddr == other.hwaddr: - return + return self.time_starts > other.time_starts - return self.time_starts > other.time_starts + return NotImplemented - @property - def binding_state(self): - state = self._properties.get("binding") - - if state: - 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" - - @property - def hwaddr(self): - hardware = self._properties.get("hardware") - - if not hardware: - return - - ethernet, address = hardware.split(" ", 1) - - return address + def __hash__(self): + return hash(self.ipaddr) @property def hostname(self): @@ -488,8 +617,10 @@ class Lease(object): return self._parse_time(ends) - @property - def expired(self): + def has_expired(self): + if not self.time_starts: + return + if not self.time_ends: return self.time_starts > datetime.datetime.utcnow() @@ -503,10 +634,10 @@ class Lease(object): return [ # Forward record - (self.fqdn, "%s" % LOCAL_TTL, "IN A", self.ipaddr), + (self.fqdn, "%s" % LOCAL_TTL, "IN A", "%s" % self.ipaddr), # Reverse record - (ip_address_to_reverse_pointer(self.ipaddr), "%s" % LOCAL_TTL, + (self.ipaddr.reverse_pointer, "%s" % LOCAL_TTL, "IN PTR", self.fqdn), ] @@ -560,6 +691,9 @@ class UnboundConfigWriter(object): command = ["unbound-control"] command.extend(args) + # Log what we are doing + log.debug("Running %s" % " ".join(command)) + try: subprocess.check_output(command) @@ -570,6 +704,28 @@ class UnboundConfigWriter(object): raise e + def apply_lease(self, lease): + """ + This method takes a lease and updates Unbound at runtime. + """ + log.debug("Applying lease %s" % lease) + + for rr in lease.rrset: + log.debug("Adding new record %s" % " ".join(rr)) + + self._control("local_data", *rr) + + def remove_lease(self, lease): + """ + This method takes a lease and removes it from Unbound at runtime. + """ + log.debug("Removing lease %s" % lease) + + for name, ttl, type, content in lease.rrset: + log.debug("Removing records for %s" % name) + + self._control("local_data_remove", name) + if __name__ == "__main__": parser = argparse.ArgumentParser(description="Bridge for DHCP Leases and Unbound DNS") @@ -588,6 +744,9 @@ if __name__ == "__main__": 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") + parser.add_argument("--socket-path", default="/var/run/unbound-dhcp-leases-bridge.sock", + metavar="PATH", help="Socket Path", + ) # Parse command line arguments args = parser.parse_args() @@ -602,13 +761,14 @@ if __name__ == "__main__": loglevel = logging.DEBUG bridge = UnboundDHCPLeasesBridge(args.dhcp_leases, args.fix_leases, - args.unbound_leases, args.hosts) + args.unbound_leases, args.hosts, socket_path=args.socket_path) with daemon.DaemonContext( detach_process=args.daemon, stderr=None if args.daemon else sys.stderr, signal_map = { - signal.SIGHUP : bridge.update_dhcp_leases, + signal.SIGHUP : bridge.reload, + signal.SIGINT : bridge.terminate, signal.SIGTERM : bridge.terminate, }, ) as daemon: diff --git a/config/unbound/unbound-dhcp-leases-client b/config/unbound/unbound-dhcp-leases-client new file mode 100644 index 000000000..b1b6291d9 --- /dev/null +++ b/config/unbound/unbound-dhcp-leases-client @@ -0,0 +1,75 @@ +#!/bin/bash +############################################################################### +# # +# IPFire.org - A linux based firewall # +# Copyright (C) 2016 Michael Tremer # +# # +# This program is free software: you can redistribute it and/or modify # +# it under the terms of the GNU General Public License as published by # +# the Free Software Foundation, either version 3 of the License, or # +# (at your option) any later version. # +# # +# This program is distributed in the hope that it will be useful, # +# but WITHOUT ANY WARRANTY; without even the implied warranty of # +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # +# GNU General Public License for more details. # +# # +# You should have received a copy of the GNU General Public License # +# along with this program. If not, see . # +# # +############################################################################### + +SOCKET="/var/run/unbound-dhcp-leases-bridge.sock" + +main() { + local event="${1}" + shift + + # Check if we have received an event + if [ -z "${event}" ]; then + echo "${0}: Missing event" >&2 + return 2 + fi + + # Check if the socket exists + if [ ! -S "${SOCKET}" ]; then + echo "${0}: ${SOCKET} does not exist" >&2 + return 1 + fi + + # Connect to the socket + coproc NC { nc -U "${SOCKET}"; } + + local arg + local response + + # Send the message + { + # Send the event + echo "EVENT=${event}" + + # Send all arguments + for arg in $@; do + echo "${arg}" + done + } >&"${NC[1]}" + + # Close the input part of the connection + exec {NC[1]}>&- + + # Capture the response + read response <&"${NC[0]}" + + case "${response}" in + OK) + return 0 + ;; + + *) + echo "${response}" >&2 + return 1 + ;; + esac +} + +main "$@" || exit $? diff --git a/html/cgi-bin/dhcp.cgi b/html/cgi-bin/dhcp.cgi index be00f199a..83cd80965 100644 --- a/html/cgi-bin/dhcp.cgi +++ b/html/cgi-bin/dhcp.cgi @@ -1374,6 +1374,49 @@ sub buildconf { } } + # Add event handlers + print FILE <