]> git.ipfire.org Git - ipfire-2.x.git/blame - config/unbound/unbound-dhcp-leases-bridge
unbound-dhcp-leases-bridge: Fix inotify handling
[ipfire-2.x.git] / config / unbound / unbound-dhcp-leases-bridge
CommitLineData
a8b1843b 1#!/usr/bin/python3
0fbd7c3c
MT
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
22import argparse
23import datetime
24import daemon
74a5ab67 25import ipaddress
0fbd7c3c
MT
26import logging
27import logging.handlers
b666975e 28import os
0fbd7c3c
MT
29import re
30import signal
7be4822f 31import stat
0fbd7c3c 32import subprocess
29a36036 33import sys
b666975e 34import tempfile
6d6e7949 35import time
0fbd7c3c
MT
36
37import inotify.adapters
38
077ea717
MT
39LOCAL_TTL = 60
40
29a36036
MT
41log = logging.getLogger("dhcp")
42log.setLevel(logging.DEBUG)
43
44def setup_logging(daemon=True, loglevel=logging.INFO):
0fbd7c3c
MT
45 log.setLevel(loglevel)
46
29a36036 47 # Log to syslog by default
0fbd7c3c 48 handler = logging.handlers.SysLogHandler(address="/dev/log", facility="daemon")
29a36036 49 log.addHandler(handler)
0fbd7c3c 50
29a36036 51 # Format everything
0fbd7c3c
MT
52 formatter = logging.Formatter("%(name)s[%(process)d]: %(message)s")
53 handler.setFormatter(formatter)
54
29a36036 55 handler.setLevel(loglevel)
0fbd7c3c 56
29a36036
MT
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)
0fbd7c3c 61
29a36036
MT
62 handler.setLevel(loglevel)
63
64 return log
0fbd7c3c 65
e22bcd38
MT
66def ip_address_to_reverse_pointer(address):
67 parts = address.split(".")
68 parts.reverse()
69
70 return "%s.in-addr.arpa" % ".".join(parts)
71
72def 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
0fbd7c3c 80class UnboundDHCPLeasesBridge(object):
7354d294 81 def __init__(self, dhcp_leases_file, fix_leases_file, unbound_leases_file, hosts_file):
0fbd7c3c 82 self.leases_file = dhcp_leases_file
86c9deb2 83 self.fix_leases_file = fix_leases_file
7354d294 84 self.hosts_file = hosts_file
0fbd7c3c 85
6d6e7949
MT
86 self.watches = {
87 self.leases_file : inotify.constants.IN_MODIFY,
88 self.fix_leases_file : 0,
89 self.hosts_file : 0,
90 }
91
0fbd7c3c
MT
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
6d6e7949
MT
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")
0fbd7c3c 113
6d6e7949
MT
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
0fbd7c3c 119
6d6e7949
MT
120 # Decode the event
121 header, type_names, path, filename = event
0fbd7c3c 122
6d6e7949 123 file = os.path.join(path, filename)
0fbd7c3c 124
6d6e7949 125 log.debug("inotify event received for %s: %s", file, " ".join(type_names))
0fbd7c3c 126
6d6e7949
MT
127 # Did the hosts file change?
128 if self.hosts_file == file:
129 update_hosts = True
7354d294 130
6d6e7949
MT
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:
0fbd7c3c
MT
140 self.update_dhcp_leases()
141
6d6e7949
MT
142 # Reset
143 update_hosts = update_leases = False
144
145 # Wait a moment before we start the next iteration
146 time.sleep(5)
86c9deb2 147
0fbd7c3c
MT
148 log.info("Unbound DHCP Leases Bridge terminated")
149
150 def update_dhcp_leases(self):
86c9deb2
MT
151 leases = []
152
153 for lease in DHCPLeases(self.leases_file):
5d4f3a42
MT
154 # Don't bother with any leases that don't have a hostname
155 if not lease.fqdn:
156 continue
157
86c9deb2
MT
158 leases.append(lease)
159
160 for lease in FixLeases(self.fix_leases_file):
161 leases.append(lease)
0fbd7c3c 162
c7b83f9b
MT
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
0fbd7c3c
MT
180 self.unbound.update_dhcp_leases(leases)
181
7354d294
MT
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:
4a465756 191 enabled, ipaddr, hostname, domainname, generateptr = line.split(",")
7354d294
MT
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:")
96c86cf6
MT
215 for name in hosts:
216 log.debug(" %-20s : %s" % (name, ", ".join(hosts[name])))
7354d294
MT
217
218 return hosts
219
0fbd7c3c
MT
220 def terminate(self):
221 self.running = False
222
223
224class 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):
86c9deb2
MT
236 log.info("Reading DHCP leases from %s" % self.path)
237
0fbd7c3c
MT
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):
8b1eb795 262 if l.ipaddr == lease.ipaddr:
0fbd7c3c
MT
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
86c9deb2
MT
300class 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
0fbd7c3c
MT
352class 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
86c9deb2
MT
381 def free(self):
382 self._properties.update({
383 "binding" : "state free",
384 })
385
0fbd7c3c
MT
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
998e880b
MT
405 if hostname is None:
406 return
407
0fbd7c3c 408 # Remove any ""
998e880b 409 hostname = hostname.replace("\"", "")
0fbd7c3c 410
998e880b
MT
411 # Only return valid hostnames
412 m = re.match(r"^[A-Z0-9\-]{1,63}$", hostname, re.I)
413 if m:
414 return hostname
0fbd7c3c
MT
415
416 @property
417 def domain(self):
74a5ab67
MT
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
96c86cf6 439 for subnet in subnets:
74a5ab67 440 if address in subnet:
96c86cf6 441 return subnets[subnet]
74a5ab67
MT
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
0fbd7c3c
MT
459
460 @property
461 def fqdn(self):
998e880b
MT
462 if self.hostname:
463 return "%s.%s" % (self.hostname, self.domain)
0fbd7c3c
MT
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):
998e880b
MT
494 # If the lease does not have a valid FQDN, we cannot create any RRs
495 if self.fqdn is None:
496 return []
497
0fbd7c3c
MT
498 return [
499 # Forward record
b8dd42b9 500 (self.fqdn, "%s" % LOCAL_TTL, "IN A", self.ipaddr),
0fbd7c3c
MT
501
502 # Reverse record
e22bcd38
MT
503 (ip_address_to_reverse_pointer(self.ipaddr), "%s" % LOCAL_TTL,
504 "IN PTR", self.fqdn),
0fbd7c3c
MT
505 ]
506
507
508class UnboundConfigWriter(object):
509 def __init__(self, path):
510 self.path = path
511
c7b83f9b 512 self._cached_leases = []
0fbd7c3c
MT
513
514 def update_dhcp_leases(self, leases):
d20ef9d7
MT
515 # Find any leases that have expired or do not exist any more
516 # but are still in the unbound local data
c7b83f9b 517 removed_leases = [l for l in self._cached_leases if not l in leases]
d20ef9d7 518
0fbd7c3c 519 # Find any leases that have been added
c7b83f9b 520 new_leases = [l for l in leases if l not in self._cached_leases]
0fbd7c3c
MT
521
522 # End here if nothing has changed
523 if not new_leases and not removed_leases:
524 return
525
0fbd7c3c
MT
526 # Write out all leases
527 self.write_dhcp_leases(leases)
528
529 # Update unbound about changes
c7b83f9b 530 for l in removed_leases:
3ec5ba50
MT
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)
0fbd7c3c
MT
543
544 for l in new_leases:
3ec5ba50
MT
545 try:
546 for rr in l.rrset:
547 log.debug("Adding new record %s" % " ".join(rr))
548 self._control("local_data", *rr)
0fbd7c3c 549
3ec5ba50
MT
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)
0fbd7c3c
MT
557
558 def write_dhcp_leases(self, leases):
b666975e
MT
559 with tempfile.NamedTemporaryFile(mode="w", delete=False) as f:
560 filename = f.name
561
0fbd7c3c
MT
562 for l in leases:
563 for rr in l.rrset:
564 f.write("local-data: \"%s\"\n" % " ".join(rr))
565
7be4822f
MT
566 # Make file readable for everyone
567 os.fchmod(f.fileno(), stat.S_IRUSR|stat.S_IWUSR|stat.S_IRGRP|stat.S_IROTH)
568
b666975e
MT
569 os.rename(filename, self.path)
570
0fbd7c3c 571 def _control(self, *args):
b8dd42b9 572 command = ["unbound-control"]
0fbd7c3c
MT
573 command.extend(args)
574
575 try:
3ec5ba50 576 subprocess.check_output(command)
0fbd7c3c
MT
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
3ec5ba50
MT
583 raise
584
0fbd7c3c
MT
585
586if __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")
86c9deb2
MT
599 parser.add_argument("--fix-leases", default="/var/ipfire/dhcp/fixleases",
600 metavar="PATH", help="Path to the fix leases file")
7354d294
MT
601 parser.add_argument("--hosts", default="/var/ipfire/main/hosts",
602 metavar="PATH", help="Path to static hosts file")
0fbd7c3c
MT
603
604 # Parse command line arguments
605 args = parser.parse_args()
606
607 # Setup logging
83e5f672
MT
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
0fbd7c3c 615
29a36036 616 setup_logging(daemon=args.daemon, loglevel=loglevel)
0fbd7c3c 617
7354d294
MT
618 bridge = UnboundDHCPLeasesBridge(args.dhcp_leases, args.fix_leases,
619 args.unbound_leases, args.hosts)
0fbd7c3c 620
29a36036 621 ctx = daemon.DaemonContext(detach_process=args.daemon, stderr=sys.stderr)
0fbd7c3c
MT
622 ctx.signal_map = {
623 signal.SIGHUP : bridge.update_dhcp_leases,
624 signal.SIGTERM : bridge.terminate,
625 }
626
627 with ctx:
628 bridge.run()