#!/usr/bin/python
###############################################################################
# #
# 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 . #
# #
###############################################################################
import argparse
import datetime
import daemon
import ipaddress
import logging
import logging.handlers
import re
import signal
import subprocess
import inotify.adapters
LOCAL_TTL = 60
def setup_logging(loglevel=logging.INFO):
log = logging.getLogger("dhcp")
log.setLevel(loglevel)
handler = logging.handlers.SysLogHandler(address="/dev/log", facility="daemon")
handler.setLevel(loglevel)
formatter = logging.Formatter("%(name)s[%(process)d]: %(message)s")
handler.setFormatter(formatter)
log.addHandler(handler)
return log
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, fix_leases_file, unbound_leases_file):
self.leases_file = dhcp_leases_file
self.fix_leases_file = fix_leases_file
self.unbound = UnboundConfigWriter(unbound_leases_file)
self.running = False
def run(self):
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([self.leases_file, self.fix_leases_file])
for event in i.event_gen():
# End if we are requested to terminate
if not self.running:
break
if event is None:
continue
header, type_names, watch_path, filename = event
# Update leases after leases file has been modified
if "IN_MODIFY" in type_names:
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):
leases = []
for lease in DHCPLeases(self.leases_file):
leases.append(lease)
for lease in FixLeases(self.fix_leases_file):
leases.append(lease)
self.unbound.update_dhcp_leases(leases)
def terminate(self):
self.running = False
class DHCPLeases(object):
regex_leaseblock = re.compile(r"lease (?P\d+\.\d+\.\d+\.\d+) {(?P[\s\S]+?)\n}")
def __init__(self, path):
self.path = path
self._leases = self._parse()
def __iter__(self):
return iter(self._leases)
def _parse(self):
log.info("Reading DHCP leases from %s" % self.path)
leases = []
with open(self.path) as f:
# Read entire leases file
data = f.read()
for match in self.regex_leaseblock.finditer(data):
block = match.groupdict()
ipaddr = block.get("ipaddr")
config = block.get("config")
properties = self._parse_block(config)
# Skip any abandoned leases
if not "hardware" in properties:
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.hwaddr == lease.hwaddr:
leases[i] = max(lease, l)
break
else:
leases.append(lease)
return leases
def _parse_block(self, block):
properties = {}
for line in block.splitlines():
if not line:
continue
# Remove trailing ; from line
if line.endswith(";"):
line = line[:-1]
# Invalid line if it doesn't end with ;
else:
continue
# Remove any leading whitespace
line = line.lstrip()
# We skip all options and sets
if line.startswith("option") or line.startswith("set"):
continue
# Split by first space
key, val = line.split(" ", 1)
properties[key] = val
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
self._properties = properties
def __repr__(self):
return "<%s %s for %s (%s)>" % (self.__class__.__name__,
self.ipaddr, self.hwaddr, self.hostname)
def __eq__(self, other):
return self.ipaddr == other.ipaddr and self.hwaddr == other.hwaddr
def __gt__(self, other):
if not self.ipaddr == other.ipaddr:
return
if not self.hwaddr == other.hwaddr:
return
return self.time_starts > other.time_starts
@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
@property
def hostname(self):
hostname = self._properties.get("client-hostname")
if hostname is None:
return
# Remove any ""
hostname = hostname.replace("\"", "")
# Only return valid hostnames
m = re.match(r"^[A-Z0-9\-]{1,63}$", hostname, re.I)
if m:
return hostname
@property
def domain(self):
# 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):
if self.hostname:
return "%s.%s" % (self.hostname, self.domain)
@staticmethod
def _parse_time(s):
return datetime.datetime.strptime(s, "%w %Y/%m/%d %H:%M:%S")
@property
def time_starts(self):
starts = self._properties.get("starts")
if starts:
return self._parse_time(starts)
@property
def time_ends(self):
ends = self._properties.get("ends")
if not ends or ends == "never":
return
return self._parse_time(ends)
@property
def expired(self):
if not self.time_ends:
return self.time_starts > datetime.datetime.utcnow()
return self.time_starts > datetime.datetime.utcnow() > self.time_ends
@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, "%s" % LOCAL_TTL, "IN A", self.ipaddr),
# Reverse record
(ip_address_to_reverse_pointer(self.ipaddr), "%s" % LOCAL_TTL,
"IN PTR", self.fqdn),
]
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)
return ret
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]
# 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]
# 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 been added
new_leases = [l for l in leases
if l.fqdn not in self.existing_leases]
# End here if nothing has changed
if not new_leases and not removed_leases:
return
# Write out all leases
self.write_dhcp_leases(leases)
# Update unbound about changes
for hostname in removed_leases:
log.debug("Removing all records for %s" % hostname)
self._control("local_data_remove", hostname)
for l in new_leases:
for rr in l.rrset:
log.debug("Adding new record %s" % " ".join(rr))
self._control("local_data", *rr)
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))
def _control(self, *args):
command = ["unbound-control"]
command.extend(args)
try:
return 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))
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Bridge for DHCP Leases and Unbound DNS")
# Daemon Stuff
parser.add_argument("--daemon", "-d", action="store_true",
help="Launch as daemon in background")
parser.add_argument("--verbose", "-v", action="count", help="Be more verbose")
# Paths
parser.add_argument("--dhcp-leases", default="/var/state/dhcp/dhcpd.leases",
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")
# 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.fix_leases, args.unbound_leases)
ctx = daemon.DaemonContext(detach_process=args.daemon)
ctx.signal_map = {
signal.SIGHUP : bridge.update_dhcp_leases,
signal.SIGTERM : bridge.terminate,
}
with ctx:
bridge.run()