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