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