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