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