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