]> git.ipfire.org Git - ipfire-2.x.git/blame - config/unbound/unbound-dhcp-leases-bridge
Core Update 168: Ship fcrontab and rebuild it from scratch
[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
091cb62a 25import functools
74a5ab67 26import ipaddress
0fbd7c3c
MT
27import logging
28import logging.handlers
b666975e 29import os
0fbd7c3c
MT
30import re
31import signal
7be4822f 32import stat
0fbd7c3c 33import subprocess
29a36036 34import sys
b666975e 35import tempfile
6d6e7949 36import time
0fbd7c3c
MT
37
38import inotify.adapters
39
077ea717
MT
40LOCAL_TTL = 60
41
29a36036
MT
42log = logging.getLogger("dhcp")
43log.setLevel(logging.DEBUG)
44
45def setup_logging(daemon=True, loglevel=logging.INFO):
0fbd7c3c
MT
46 log.setLevel(loglevel)
47
29a36036 48 # Log to syslog by default
0fbd7c3c 49 handler = logging.handlers.SysLogHandler(address="/dev/log", facility="daemon")
29a36036 50 log.addHandler(handler)
0fbd7c3c 51
29a36036 52 # Format everything
0fbd7c3c
MT
53 formatter = logging.Formatter("%(name)s[%(process)d]: %(message)s")
54 handler.setFormatter(formatter)
55
29a36036 56 handler.setLevel(loglevel)
0fbd7c3c 57
29a36036
MT
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)
0fbd7c3c 62
29a36036
MT
63 handler.setLevel(loglevel)
64
65 return log
0fbd7c3c 66
e22bcd38
MT
67def ip_address_to_reverse_pointer(address):
68 parts = address.split(".")
69 parts.reverse()
70
71 return "%s.in-addr.arpa" % ".".join(parts)
72
73def 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
0fbd7c3c 81class UnboundDHCPLeasesBridge(object):
7354d294 82 def __init__(self, dhcp_leases_file, fix_leases_file, unbound_leases_file, hosts_file):
0fbd7c3c 83 self.leases_file = dhcp_leases_file
86c9deb2 84 self.fix_leases_file = fix_leases_file
7354d294 85 self.hosts_file = hosts_file
0fbd7c3c 86
6d6e7949
MT
87 self.watches = {
88 self.leases_file : inotify.constants.IN_MODIFY,
89 self.fix_leases_file : 0,
90 self.hosts_file : 0,
91 }
92
0fbd7c3c
MT
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
6d6e7949
MT
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")
0fbd7c3c 114
6d6e7949
MT
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
0fbd7c3c 120
6d6e7949
MT
121 # Decode the event
122 header, type_names, path, filename = event
0fbd7c3c 123
6d6e7949 124 file = os.path.join(path, filename)
0fbd7c3c 125
6d6e7949 126 log.debug("inotify event received for %s: %s", file, " ".join(type_names))
0fbd7c3c 127
6d6e7949
MT
128 # Did the hosts file change?
129 if self.hosts_file == file:
130 update_hosts = True
7354d294 131
6d6e7949
MT
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:
0fbd7c3c
MT
141 self.update_dhcp_leases()
142
6d6e7949
MT
143 # Reset
144 update_hosts = update_leases = False
145
146 # Wait a moment before we start the next iteration
147 time.sleep(5)
86c9deb2 148
0fbd7c3c
MT
149 log.info("Unbound DHCP Leases Bridge terminated")
150
151 def update_dhcp_leases(self):
86c9deb2
MT
152 leases = []
153
154 for lease in DHCPLeases(self.leases_file):
5d4f3a42
MT
155 # Don't bother with any leases that don't have a hostname
156 if not lease.fqdn:
157 continue
158
86c9deb2
MT
159 leases.append(lease)
160
161 for lease in FixLeases(self.fix_leases_file):
162 leases.append(lease)
0fbd7c3c 163
c7b83f9b
MT
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
0fbd7c3c
MT
181 self.unbound.update_dhcp_leases(leases)
182
7354d294
MT
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:
4a465756 192 enabled, ipaddr, hostname, domainname, generateptr = line.split(",")
7354d294
MT
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:")
96c86cf6
MT
216 for name in hosts:
217 log.debug(" %-20s : %s" % (name, ", ".join(hosts[name])))
7354d294
MT
218
219 return hosts
220
0fbd7c3c
MT
221 def terminate(self):
222 self.running = False
223
224
225class 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):
86c9deb2
MT
237 log.info("Reading DHCP leases from %s" % self.path)
238
0fbd7c3c
MT
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):
8b1eb795 263 if l.ipaddr == lease.ipaddr:
0fbd7c3c
MT
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
86c9deb2
MT
301class 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
0fbd7c3c
MT
353class 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
86c9deb2
MT
382 def free(self):
383 self._properties.update({
384 "binding" : "state free",
385 })
386
0fbd7c3c
MT
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
998e880b
MT
406 if hostname is None:
407 return
408
0fbd7c3c 409 # Remove any ""
998e880b 410 hostname = hostname.replace("\"", "")
0fbd7c3c 411
998e880b
MT
412 # Only return valid hostnames
413 m = re.match(r"^[A-Z0-9\-]{1,63}$", hostname, re.I)
414 if m:
415 return hostname
0fbd7c3c
MT
416
417 @property
418 def domain(self):
74a5ab67
MT
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
96c86cf6 440 for subnet in subnets:
74a5ab67 441 if address in subnet:
96c86cf6 442 return subnets[subnet]
74a5ab67
MT
443
444 # Fall back to localdomain if no match could be found
445 return "localdomain"
446
447 @staticmethod
091cb62a 448 @functools.cache
74a5ab67
MT
449 def read_settings(filename):
450 settings = {}
451
452 with open(filename) as f:
453 for line in f.readlines():
454 # Remove line-breaks
455 line = line.rstrip()
456
457 k, v = line.split("=", 1)
458 settings[k] = v
459
460 return settings
0fbd7c3c
MT
461
462 @property
463 def fqdn(self):
998e880b
MT
464 if self.hostname:
465 return "%s.%s" % (self.hostname, self.domain)
0fbd7c3c
MT
466
467 @staticmethod
468 def _parse_time(s):
469 return datetime.datetime.strptime(s, "%w %Y/%m/%d %H:%M:%S")
470
471 @property
472 def time_starts(self):
473 starts = self._properties.get("starts")
474
475 if starts:
476 return self._parse_time(starts)
477
478 @property
479 def time_ends(self):
480 ends = self._properties.get("ends")
481
482 if not ends or ends == "never":
483 return
484
485 return self._parse_time(ends)
486
487 @property
488 def expired(self):
489 if not self.time_ends:
490 return self.time_starts > datetime.datetime.utcnow()
491
492 return self.time_starts > datetime.datetime.utcnow() > self.time_ends
493
494 @property
495 def rrset(self):
998e880b
MT
496 # If the lease does not have a valid FQDN, we cannot create any RRs
497 if self.fqdn is None:
498 return []
499
0fbd7c3c
MT
500 return [
501 # Forward record
b8dd42b9 502 (self.fqdn, "%s" % LOCAL_TTL, "IN A", self.ipaddr),
0fbd7c3c
MT
503
504 # Reverse record
e22bcd38
MT
505 (ip_address_to_reverse_pointer(self.ipaddr), "%s" % LOCAL_TTL,
506 "IN PTR", self.fqdn),
0fbd7c3c
MT
507 ]
508
509
510class UnboundConfigWriter(object):
511 def __init__(self, path):
512 self.path = path
513
c7b83f9b 514 self._cached_leases = []
0fbd7c3c
MT
515
516 def update_dhcp_leases(self, leases):
d20ef9d7
MT
517 # Find any leases that have expired or do not exist any more
518 # but are still in the unbound local data
c7b83f9b 519 removed_leases = [l for l in self._cached_leases if not l in leases]
d20ef9d7 520
0fbd7c3c 521 # Find any leases that have been added
c7b83f9b 522 new_leases = [l for l in leases if l not in self._cached_leases]
0fbd7c3c
MT
523
524 # End here if nothing has changed
525 if not new_leases and not removed_leases:
526 return
527
0fbd7c3c
MT
528 # Write out all leases
529 self.write_dhcp_leases(leases)
530
531 # Update unbound about changes
c7b83f9b 532 for l in removed_leases:
3ec5ba50
MT
533 try:
534 for name, ttl, type, content in l.rrset:
535 log.debug("Removing records for %s" % name)
536 self._control("local_data_remove", name)
537
538 # If the lease cannot be removed we will try the next one
539 except:
540 continue
541
542 # If the removal was successful, we will remove it from the cache
543 else:
544 self._cached_leases.remove(l)
0fbd7c3c
MT
545
546 for l in new_leases:
3ec5ba50
MT
547 try:
548 for rr in l.rrset:
549 log.debug("Adding new record %s" % " ".join(rr))
550 self._control("local_data", *rr)
0fbd7c3c 551
3ec5ba50
MT
552 # If the lease cannot be added we will try the next one
553 except:
554 continue
555
556 # Add lease to cache when successfully added
557 else:
558 self._cached_leases.append(l)
0fbd7c3c
MT
559
560 def write_dhcp_leases(self, leases):
b666975e
MT
561 with tempfile.NamedTemporaryFile(mode="w", delete=False) as f:
562 filename = f.name
563
0fbd7c3c
MT
564 for l in leases:
565 for rr in l.rrset:
566 f.write("local-data: \"%s\"\n" % " ".join(rr))
567
7be4822f
MT
568 # Make file readable for everyone
569 os.fchmod(f.fileno(), stat.S_IRUSR|stat.S_IWUSR|stat.S_IRGRP|stat.S_IROTH)
570
b666975e
MT
571 os.rename(filename, self.path)
572
0fbd7c3c 573 def _control(self, *args):
b8dd42b9 574 command = ["unbound-control"]
0fbd7c3c
MT
575 command.extend(args)
576
577 try:
3ec5ba50 578 subprocess.check_output(command)
0fbd7c3c
MT
579
580 # Log any errors
581 except subprocess.CalledProcessError as e:
582 log.critical("Could not run %s, error code: %s: %s" % (
583 " ".join(command), e.returncode, e.output))
584
3ec5ba50
MT
585 raise
586
0fbd7c3c
MT
587
588if __name__ == "__main__":
589 parser = argparse.ArgumentParser(description="Bridge for DHCP Leases and Unbound DNS")
590
591 # Daemon Stuff
592 parser.add_argument("--daemon", "-d", action="store_true",
593 help="Launch as daemon in background")
594 parser.add_argument("--verbose", "-v", action="count", help="Be more verbose")
595
596 # Paths
597 parser.add_argument("--dhcp-leases", default="/var/state/dhcp/dhcpd.leases",
598 metavar="PATH", help="Path to the DHCPd leases file")
599 parser.add_argument("--unbound-leases", default="/etc/unbound/dhcp-leases.conf",
600 metavar="PATH", help="Path to the unbound configuration file")
86c9deb2
MT
601 parser.add_argument("--fix-leases", default="/var/ipfire/dhcp/fixleases",
602 metavar="PATH", help="Path to the fix leases file")
7354d294
MT
603 parser.add_argument("--hosts", default="/var/ipfire/main/hosts",
604 metavar="PATH", help="Path to static hosts file")
0fbd7c3c
MT
605
606 # Parse command line arguments
607 args = parser.parse_args()
608
609 # Setup logging
83e5f672
MT
610 loglevel = logging.WARN
611
612 if args.verbose:
613 if args.verbose == 1:
614 loglevel = logging.INFO
615 elif args.verbose >= 2:
616 loglevel = logging.DEBUG
0fbd7c3c 617
7354d294
MT
618 bridge = UnboundDHCPLeasesBridge(args.dhcp_leases, args.fix_leases,
619 args.unbound_leases, args.hosts)
0fbd7c3c 620
1918174d
MT
621 with daemon.DaemonContext(
622 detach_process=args.daemon,
623 stderr=None if args.daemon else sys.stderr,
624 signal_map = {
625 signal.SIGHUP : bridge.update_dhcp_leases,
626 signal.SIGTERM : bridge.terminate,
627 },
628 ) as daemon:
f9611355
MT
629 setup_logging(daemon=args.daemon, loglevel=loglevel)
630
0fbd7c3c 631 bridge.run()