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