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