]> git.ipfire.org Git - ipfire-2.x.git/blob - config/unbound/unbound-dhcp-leases-bridge
e9f022affa98a2483ca1f41d267fe443b0f048cf
[ipfire-2.x.git] / config / unbound / unbound-dhcp-leases-bridge
1 #!/usr/bin/python3
2 ###############################################################################
3 # #
4 # IPFire.org - A linux based firewall #
5 # Copyright (C) 2016 Michael Tremer #
6 # #
7 # This program is free software: you can redistribute it and/or modify #
8 # it under the terms of the GNU General Public License as published by #
9 # the Free Software Foundation, either version 3 of the License, or #
10 # (at your option) any later version. #
11 # #
12 # This program is distributed in the hope that it will be useful, #
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of #
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
15 # GNU General Public License for more details. #
16 # #
17 # You should have received a copy of the GNU General Public License #
18 # along with this program. If not, see <http://www.gnu.org/licenses/>. #
19 # #
20 ###############################################################################
21
22 import argparse
23 import datetime
24 import daemon
25 import functools
26 import ipaddress
27 import logging
28 import logging.handlers
29 import os
30 import re
31 import signal
32 import stat
33 import subprocess
34 import sys
35 import tempfile
36 import time
37
38 import inotify.adapters
39
40 LOCAL_TTL = 60
41
42 log = logging.getLogger("dhcp")
43 log.setLevel(logging.DEBUG)
44
45 def setup_logging(daemon=True, loglevel=logging.INFO):
46 log.setLevel(loglevel)
47
48 # Log to syslog by default
49 handler = logging.handlers.SysLogHandler(address="/dev/log", facility="daemon")
50 log.addHandler(handler)
51
52 # Format everything
53 formatter = logging.Formatter("%(name)s[%(process)d]: %(message)s")
54 handler.setFormatter(formatter)
55
56 handler.setLevel(loglevel)
57
58 # If we are running in foreground, we should write everything to the console, too
59 if not daemon:
60 handler = logging.StreamHandler()
61 log.addHandler(handler)
62
63 handler.setLevel(loglevel)
64
65 return log
66
67 def ip_address_to_reverse_pointer(address):
68 parts = address.split(".")
69 parts.reverse()
70
71 return "%s.in-addr.arpa" % ".".join(parts)
72
73 def reverse_pointer_to_ip_address(rr):
74 parts = rr.split(".")
75
76 # Only take IP address part
77 parts = reversed(parts[0:4])
78
79 return ".".join(parts)
80
81 class UnboundDHCPLeasesBridge(object):
82 def __init__(self, dhcp_leases_file, fix_leases_file, unbound_leases_file, hosts_file):
83 self.leases_file = dhcp_leases_file
84 self.fix_leases_file = fix_leases_file
85 self.hosts_file = hosts_file
86
87 self.watches = {
88 self.leases_file : inotify.constants.IN_MODIFY,
89 self.fix_leases_file : 0,
90 self.hosts_file : 0,
91 }
92
93 self.unbound = UnboundConfigWriter(unbound_leases_file)
94 self.running = False
95
96 def run(self):
97 log.info("Unbound DHCP Leases Bridge started on %s" % self.leases_file)
98 self.running = True
99
100 i = inotify.adapters.Inotify()
101
102 # Add watches for the directories of every relevant file
103 for f, mask in self.watches.items():
104 i.add_watch(
105 os.path.dirname(f),
106 mask | inotify.constants.IN_CLOSE_WRITE | inotify.constants.IN_MOVED_TO,
107 )
108
109 # Enabled so that we update hosts and leases on startup
110 update_hosts = update_leases = True
111
112 while self.running:
113 log.debug("Wakeup of main loop")
114
115 # Process the entire inotify queue and identify what we need to do
116 for event in i.event_gen():
117 # Nothing to do
118 if event is None:
119 break
120
121 # Decode the event
122 header, type_names, path, filename = event
123
124 file = os.path.join(path, filename)
125
126 log.debug("inotify event received for %s: %s", file, " ".join(type_names))
127
128 # Did the hosts file change?
129 if self.hosts_file == file:
130 update_hosts = True
131
132 # We will need to update the leases on any change
133 update_leases = True
134
135 # Update hosts (if needed)
136 if update_hosts:
137 self.hosts = self.read_static_hosts()
138
139 # Update leases (if needed)
140 if update_leases:
141 self.update_dhcp_leases()
142
143 # Reset
144 update_hosts = update_leases = False
145
146 # Wait a moment before we start the next iteration
147 time.sleep(5)
148
149 log.info("Unbound DHCP Leases Bridge terminated")
150
151 def update_dhcp_leases(self):
152 leases = []
153
154 for lease in DHCPLeases(self.leases_file):
155 # Don't bother with any leases that don't have a hostname
156 if not lease.fqdn:
157 continue
158
159 leases.append(lease)
160
161 for lease in FixLeases(self.fix_leases_file):
162 leases.append(lease)
163
164 # Skip any leases that also are a static host
165 leases = [l for l in leases if not l.fqdn in self.hosts]
166
167 # Remove any inactive or expired leases
168 leases = [l for l in leases if l.active and not l.expired]
169
170 # Dump leases
171 if leases:
172 log.debug("DHCP Leases:")
173 for lease in leases:
174 log.debug(" %s:" % lease.fqdn)
175 log.debug(" State: %s" % lease.binding_state)
176 log.debug(" Start: %s" % lease.time_starts)
177 log.debug(" End : %s" % lease.time_ends)
178 if lease.expired:
179 log.debug(" Expired")
180
181 self.unbound.update_dhcp_leases(leases)
182
183 def read_static_hosts(self):
184 log.info("Reading static hosts from %s" % self.hosts_file)
185
186 hosts = {}
187 with open(self.hosts_file) as f:
188 for line in f.readlines():
189 line = line.rstrip()
190
191 try:
192 enabled, ipaddr, hostname, domainname, generateptr = line.split(",")
193 except:
194 log.warning("Could not parse line: %s" % line)
195 continue
196
197 # Skip any disabled entries
198 if not enabled == "on":
199 continue
200
201 if hostname and domainname:
202 fqdn = "%s.%s" % (hostname, domainname)
203 elif hostname:
204 fqdn = hostname
205 elif domainname:
206 fqdn = domainname
207
208 try:
209 hosts[fqdn].append(ipaddr)
210 hosts[fqdn].sort()
211 except KeyError:
212 hosts[fqdn] = [ipaddr,]
213
214 # Dump everything in the logs
215 log.debug("Static hosts:")
216 for name in hosts:
217 log.debug(" %-20s : %s" % (name, ", ".join(hosts[name])))
218
219 return hosts
220
221 def terminate(self):
222 self.running = False
223
224
225 class DHCPLeases(object):
226 regex_leaseblock = re.compile(r"lease (?P<ipaddr>\d+\.\d+\.\d+\.\d+) {(?P<config>[\s\S]+?)\n}")
227
228 def __init__(self, path):
229 self.path = path
230
231 self._leases = self._parse()
232
233 def __iter__(self):
234 return iter(self._leases)
235
236 def _parse(self):
237 log.info("Reading DHCP leases from %s" % self.path)
238
239 leases = []
240
241 with open(self.path) as f:
242 # Read entire leases file
243 data = f.read()
244
245 for match in self.regex_leaseblock.finditer(data):
246 block = match.groupdict()
247
248 ipaddr = block.get("ipaddr")
249 config = block.get("config")
250
251 properties = self._parse_block(config)
252
253 # Skip any abandoned leases
254 if not "hardware" in properties:
255 continue
256
257 lease = Lease(ipaddr, properties)
258
259 # Check if a lease for this Ethernet address already
260 # exists in the list of known leases. If so replace
261 # if with the most recent lease
262 for i, l in enumerate(leases):
263 if l.ipaddr == lease.ipaddr:
264 leases[i] = max(lease, l)
265 break
266
267 else:
268 leases.append(lease)
269
270 return leases
271
272 def _parse_block(self, block):
273 properties = {}
274
275 for line in block.splitlines():
276 if not line:
277 continue
278
279 # Remove trailing ; from line
280 if line.endswith(";"):
281 line = line[:-1]
282
283 # Invalid line if it doesn't end with ;
284 else:
285 continue
286
287 # Remove any leading whitespace
288 line = line.lstrip()
289
290 # We skip all options and sets
291 if line.startswith("option") or line.startswith("set"):
292 continue
293
294 # Split by first space
295 key, val = line.split(" ", 1)
296 properties[key] = val
297
298 return properties
299
300
301 class FixLeases(object):
302 cache = {}
303
304 def __init__(self, path):
305 self.path = path
306
307 self._leases = self.cache[self.path] = self._parse()
308
309 def __iter__(self):
310 return iter(self._leases)
311
312 def _parse(self):
313 log.info("Reading fix leases from %s" % self.path)
314
315 leases = []
316 now = datetime.datetime.utcnow()
317
318 with open(self.path) as f:
319 for line in f.readlines():
320 line = line.rstrip()
321
322 try:
323 hwaddr, ipaddr, enabled, a, b, c, hostname = line.split(",")
324 except ValueError:
325 log.warning("Could not parse line: %s" % line)
326 continue
327
328 # Skip any disabled leases
329 if not enabled == "on":
330 continue
331
332 l = Lease(ipaddr, {
333 "binding" : "state active",
334 "client-hostname" : hostname,
335 "hardware" : "ethernet %s" % hwaddr,
336 "starts" : now.strftime("%w %Y/%m/%d %H:%M:%S"),
337 "ends" : "never",
338 })
339 leases.append(l)
340
341 # Try finding any deleted leases
342 for lease in self.cache.get(self.path, []):
343 if lease in leases:
344 continue
345
346 # Free the deleted lease
347 lease.free()
348 leases.append(lease)
349
350 return leases
351
352
353 class Lease(object):
354 def __init__(self, ipaddr, properties):
355 self.ipaddr = ipaddr
356 self._properties = properties
357
358 def __repr__(self):
359 return "<%s %s for %s (%s)>" % (self.__class__.__name__,
360 self.ipaddr, self.hwaddr, self.hostname)
361
362 def __eq__(self, other):
363 return self.ipaddr == other.ipaddr and self.hwaddr == other.hwaddr
364
365 def __gt__(self, other):
366 if not self.ipaddr == other.ipaddr:
367 return
368
369 if not self.hwaddr == other.hwaddr:
370 return
371
372 return self.time_starts > other.time_starts
373
374 @property
375 def binding_state(self):
376 state = self._properties.get("binding")
377
378 if state:
379 state = state.split(" ", 1)
380 return state[1]
381
382 def free(self):
383 self._properties.update({
384 "binding" : "state free",
385 })
386
387 @property
388 def active(self):
389 return self.binding_state == "active"
390
391 @property
392 def hwaddr(self):
393 hardware = self._properties.get("hardware")
394
395 if not hardware:
396 return
397
398 ethernet, address = hardware.split(" ", 1)
399
400 return address
401
402 @property
403 def hostname(self):
404 hostname = self._properties.get("client-hostname")
405
406 if hostname is None:
407 return
408
409 # Remove any ""
410 hostname = hostname.replace("\"", "")
411
412 # Only return valid hostnames
413 m = re.match(r"^[A-Z0-9\-]{1,63}$", hostname, re.I)
414 if m:
415 return hostname
416
417 @property
418 def domain(self):
419 # Load ethernet settings
420 ethernet_settings = self.read_settings("/var/ipfire/ethernet/settings")
421
422 # Load DHCP settings
423 dhcp_settings = self.read_settings("/var/ipfire/dhcp/settings")
424
425 subnets = {}
426 for zone in ("GREEN", "BLUE"):
427 if not dhcp_settings.get("ENABLE_%s" % zone) == "on":
428 continue
429
430 netaddr = ethernet_settings.get("%s_NETADDRESS" % zone)
431 submask = ethernet_settings.get("%s_NETMASK" % zone)
432
433 subnet = ipaddress.ip_network("%s/%s" % (netaddr, submask))
434 domain = dhcp_settings.get("DOMAIN_NAME_%s" % zone)
435
436 subnets[subnet] = domain
437
438 address = ipaddress.ip_address(self.ipaddr)
439
440 for subnet in subnets:
441 if address in subnet:
442 return subnets[subnet]
443
444 # Load main settings
445 settings = self.read_settings("/var/ipfire/main/settings")
446
447 # Fall back to the host domain if no match could be found
448 return settings.get("DOMAINNAME", "localdomain")
449
450 @staticmethod
451 @functools.cache
452 def read_settings(filename):
453 settings = {}
454
455 with open(filename) as f:
456 for line in f.readlines():
457 # Remove line-breaks
458 line = line.rstrip()
459
460 k, v = line.split("=", 1)
461 settings[k] = v
462
463 return settings
464
465 @property
466 def fqdn(self):
467 if self.hostname:
468 return "%s.%s" % (self.hostname, self.domain)
469
470 @staticmethod
471 def _parse_time(s):
472 return datetime.datetime.strptime(s, "%w %Y/%m/%d %H:%M:%S")
473
474 @property
475 def time_starts(self):
476 starts = self._properties.get("starts")
477
478 if starts:
479 return self._parse_time(starts)
480
481 @property
482 def time_ends(self):
483 ends = self._properties.get("ends")
484
485 if not ends or ends == "never":
486 return
487
488 return self._parse_time(ends)
489
490 @property
491 def expired(self):
492 if not self.time_ends:
493 return self.time_starts > datetime.datetime.utcnow()
494
495 return self.time_starts > datetime.datetime.utcnow() > self.time_ends
496
497 @property
498 def rrset(self):
499 # If the lease does not have a valid FQDN, we cannot create any RRs
500 if self.fqdn is None:
501 return []
502
503 return [
504 # Forward record
505 (self.fqdn, "%s" % LOCAL_TTL, "IN A", self.ipaddr),
506
507 # Reverse record
508 (ip_address_to_reverse_pointer(self.ipaddr), "%s" % LOCAL_TTL,
509 "IN PTR", self.fqdn),
510 ]
511
512
513 class UnboundConfigWriter(object):
514 def __init__(self, path):
515 self.path = path
516
517 def update_dhcp_leases(self, leases):
518 # Write out all leases
519 self.write_dhcp_leases(leases)
520
521 log.debug("Reloading Unbound...")
522
523 # Reload the configuration without dropping the cache
524 self._control("reload_keep_cache")
525
526 def write_dhcp_leases(self, leases):
527 log.debug("Writing DHCP leases...")
528
529 with tempfile.NamedTemporaryFile(mode="w", delete=False) as f:
530 for l in leases:
531 for rr in l.rrset:
532 f.write("local-data: \"%s\"\n" % " ".join(rr))
533
534 # Make file readable for everyone
535 os.fchmod(f.fileno(), stat.S_IRUSR|stat.S_IWUSR|stat.S_IRGRP|stat.S_IROTH)
536
537 # Move the file to its destination
538 os.rename(f.name, self.path)
539
540 def _control(self, *args):
541 command = ["unbound-control"]
542 command.extend(args)
543
544 try:
545 subprocess.check_output(command)
546
547 # Log any errors
548 except subprocess.CalledProcessError as e:
549 log.critical("Could not run %s, error code: %s: %s" % (
550 " ".join(command), e.returncode, e.output))
551
552 raise e
553
554
555 if __name__ == "__main__":
556 parser = argparse.ArgumentParser(description="Bridge for DHCP Leases and Unbound DNS")
557
558 # Daemon Stuff
559 parser.add_argument("--daemon", "-d", action="store_true",
560 help="Launch as daemon in background")
561 parser.add_argument("--verbose", "-v", action="count", help="Be more verbose")
562
563 # Paths
564 parser.add_argument("--dhcp-leases", default="/var/state/dhcp/dhcpd.leases",
565 metavar="PATH", help="Path to the DHCPd leases file")
566 parser.add_argument("--unbound-leases", default="/etc/unbound/dhcp-leases.conf",
567 metavar="PATH", help="Path to the unbound configuration file")
568 parser.add_argument("--fix-leases", default="/var/ipfire/dhcp/fixleases",
569 metavar="PATH", help="Path to the fix leases file")
570 parser.add_argument("--hosts", default="/var/ipfire/main/hosts",
571 metavar="PATH", help="Path to static hosts file")
572
573 # Parse command line arguments
574 args = parser.parse_args()
575
576 # Setup logging
577 loglevel = logging.WARN
578
579 if args.verbose:
580 if args.verbose == 1:
581 loglevel = logging.INFO
582 elif args.verbose >= 2:
583 loglevel = logging.DEBUG
584
585 bridge = UnboundDHCPLeasesBridge(args.dhcp_leases, args.fix_leases,
586 args.unbound_leases, args.hosts)
587
588 with daemon.DaemonContext(
589 detach_process=args.daemon,
590 stderr=None if args.daemon else sys.stderr,
591 signal_map = {
592 signal.SIGHUP : bridge.update_dhcp_leases,
593 signal.SIGTERM : bridge.terminate,
594 },
595 ) as daemon:
596 setup_logging(daemon=args.daemon, loglevel=loglevel)
597
598 bridge.run()