]> git.ipfire.org Git - people/pmueller/ipfire-2.x.git/blame - config/unbound/unbound-dhcp-leases-bridge
suricata: Change midstream policy to "pass-flow"
[people/pmueller/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 443
ca4de263
MT
444 # Load main settings
445 settings = self.read_settings("/var/ipfire/main/settings")
446
447 # Fall back to the host domain if no match could be found
448 return settings.get("DOMAINNAME", "localdomain")
74a5ab67
MT
449
450 @staticmethod
091cb62a 451 @functools.cache
74a5ab67
MT
452 def read_settings(filename):
453 settings = {}
454
455 with open(filename) as f:
456 for line in f.readlines():
457 # Remove line-breaks
458 line = line.rstrip()
459
460 k, v = line.split("=", 1)
461 settings[k] = v
462
463 return settings
0fbd7c3c
MT
464
465 @property
466 def fqdn(self):
998e880b
MT
467 if self.hostname:
468 return "%s.%s" % (self.hostname, self.domain)
0fbd7c3c
MT
469
470 @staticmethod
471 def _parse_time(s):
472 return datetime.datetime.strptime(s, "%w %Y/%m/%d %H:%M:%S")
473
474 @property
475 def time_starts(self):
476 starts = self._properties.get("starts")
477
478 if starts:
479 return self._parse_time(starts)
480
481 @property
482 def time_ends(self):
483 ends = self._properties.get("ends")
484
485 if not ends or ends == "never":
486 return
487
488 return self._parse_time(ends)
489
490 @property
491 def expired(self):
492 if not self.time_ends:
493 return self.time_starts > datetime.datetime.utcnow()
494
495 return self.time_starts > datetime.datetime.utcnow() > self.time_ends
496
497 @property
498 def rrset(self):
998e880b
MT
499 # If the lease does not have a valid FQDN, we cannot create any RRs
500 if self.fqdn is None:
501 return []
502
0fbd7c3c
MT
503 return [
504 # Forward record
b8dd42b9 505 (self.fqdn, "%s" % LOCAL_TTL, "IN A", self.ipaddr),
0fbd7c3c
MT
506
507 # Reverse record
e22bcd38
MT
508 (ip_address_to_reverse_pointer(self.ipaddr), "%s" % LOCAL_TTL,
509 "IN PTR", self.fqdn),
0fbd7c3c
MT
510 ]
511
512
513class UnboundConfigWriter(object):
514 def __init__(self, path):
515 self.path = path
516
0fbd7c3c 517 def update_dhcp_leases(self, leases):
0fbd7c3c
MT
518 # Write out all leases
519 self.write_dhcp_leases(leases)
520
f20ca78e 521 log.debug("Reloading Unbound...")
3ec5ba50 522
f20ca78e
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
f20ca78e 529 with tempfile.NamedTemporaryFile(mode="w", delete=False) as f:
0fbd7c3c
MT
530 for l in leases:
531 for rr in l.rrset:
532 f.write("local-data: \"%s\"\n" % " ".join(rr))
533
7be4822f
MT
534 # Make file readable for everyone
535 os.fchmod(f.fileno(), stat.S_IRUSR|stat.S_IWUSR|stat.S_IRGRP|stat.S_IROTH)
536
f20ca78e
MT
537 # Move the file to its destination
538 os.rename(f.name, self.path)
b666975e 539
0fbd7c3c 540 def _control(self, *args):
b8dd42b9 541 command = ["unbound-control"]
0fbd7c3c
MT
542 command.extend(args)
543
544 try:
3ec5ba50 545 subprocess.check_output(command)
0fbd7c3c
MT
546
547 # Log any errors
548 except subprocess.CalledProcessError as e:
549 log.critical("Could not run %s, error code: %s: %s" % (
550 " ".join(command), e.returncode, e.output))
551
f20ca78e 552 raise e
3ec5ba50 553
0fbd7c3c
MT
554
555if __name__ == "__main__":
556 parser = argparse.ArgumentParser(description="Bridge for DHCP Leases and Unbound DNS")
557
558 # Daemon Stuff
559 parser.add_argument("--daemon", "-d", action="store_true",
560 help="Launch as daemon in background")
561 parser.add_argument("--verbose", "-v", action="count", help="Be more verbose")
562
563 # Paths
564 parser.add_argument("--dhcp-leases", default="/var/state/dhcp/dhcpd.leases",
565 metavar="PATH", help="Path to the DHCPd leases file")
566 parser.add_argument("--unbound-leases", default="/etc/unbound/dhcp-leases.conf",
567 metavar="PATH", help="Path to the unbound configuration file")
86c9deb2
MT
568 parser.add_argument("--fix-leases", default="/var/ipfire/dhcp/fixleases",
569 metavar="PATH", help="Path to the fix leases file")
7354d294
MT
570 parser.add_argument("--hosts", default="/var/ipfire/main/hosts",
571 metavar="PATH", help="Path to static hosts file")
0fbd7c3c
MT
572
573 # Parse command line arguments
574 args = parser.parse_args()
575
576 # Setup logging
83e5f672
MT
577 loglevel = logging.WARN
578
579 if args.verbose:
580 if args.verbose == 1:
581 loglevel = logging.INFO
582 elif args.verbose >= 2:
583 loglevel = logging.DEBUG
0fbd7c3c 584
7354d294
MT
585 bridge = UnboundDHCPLeasesBridge(args.dhcp_leases, args.fix_leases,
586 args.unbound_leases, args.hosts)
0fbd7c3c 587
1918174d
MT
588 with daemon.DaemonContext(
589 detach_process=args.daemon,
590 stderr=None if args.daemon else sys.stderr,
591 signal_map = {
592 signal.SIGHUP : bridge.update_dhcp_leases,
593 signal.SIGTERM : bridge.terminate,
594 },
595 ) as daemon:
f9611355
MT
596 setup_logging(daemon=args.daemon, loglevel=loglevel)
597
0fbd7c3c 598 bridge.run()