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
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
# 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
return iter(self._leases)
def _parse(self):
+ log.info("Reading DHCP leases from %s" % self.path)
+
leases = []
with open(self.path) as f:
# 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
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
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"
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):
@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):
@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
- (self.ipaddr, "%s" % LOCAL_TTL, "IN PTR", self.fqdn),
+ (ip_address_to_reverse_pointer(self.ipaddr), "%s" % LOCAL_TTL,
+ "IN PTR", self.fqdn),
]
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] = hostname
-
- return ret
+ self._cached_leases = []
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]
+ 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.fqdn not in self.existing_leases]
+ new_leases = [l for l in leases if l not in self._cached_leases]
# End here if nothing has changed
if not new_leases and not removed_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 removed_leases:
+ 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:
- log.debug("Adding new record %s" % " ".join(rr))
- 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:
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
+
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Bridge for DHCP Leases and Unbound DNS")
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(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 = {