]> git.ipfire.org Git - people/pmueller/ipfire-2.x.git/blame - config/unbound/unbound-dhcp-leases-bridge
core125: Ship JSON-C
[people/pmueller/ipfire-2.x.git] / config / unbound / unbound-dhcp-leases-bridge
CommitLineData
0fbd7c3c
MT
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
22import argparse
23import datetime
24import daemon
74a5ab67 25import ipaddress
0fbd7c3c
MT
26import logging
27import logging.handlers
28import re
29import signal
30import subprocess
31
32import inotify.adapters
33
077ea717
MT
34LOCAL_TTL = 60
35
0fbd7c3c
MT
36def 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
50log = logging.getLogger("dhcp")
51
e22bcd38
MT
52def ip_address_to_reverse_pointer(address):
53 parts = address.split(".")
54 parts.reverse()
55
56 return "%s.in-addr.arpa" % ".".join(parts)
57
58def 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
0fbd7c3c 66class UnboundDHCPLeasesBridge(object):
7354d294 67 def __init__(self, dhcp_leases_file, fix_leases_file, unbound_leases_file, hosts_file):
0fbd7c3c 68 self.leases_file = dhcp_leases_file
86c9deb2 69 self.fix_leases_file = fix_leases_file
7354d294 70 self.hosts_file = hosts_file
0fbd7c3c
MT
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
7354d294
MT
79 # Initial setup
80 self.hosts = self.read_static_hosts()
0fbd7c3c
MT
81 self.update_dhcp_leases()
82
7354d294
MT
83 i = inotify.adapters.Inotify([
84 self.leases_file,
85 self.fix_leases_file,
86 self.hosts_file,
87 ])
0fbd7c3c
MT
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:
7354d294
MT
101 # Reload hosts
102 if watch_path == self.hosts_file:
103 self.hosts = self.read_static_hosts()
104
0fbd7c3c
MT
105 self.update_dhcp_leases()
106
86c9deb2
MT
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
0fbd7c3c
MT
111 log.info("Unbound DHCP Leases Bridge terminated")
112
113 def update_dhcp_leases(self):
86c9deb2
MT
114 leases = []
115
116 for lease in DHCPLeases(self.leases_file):
5d4f3a42
MT
117 # Don't bother with any leases that don't have a hostname
118 if not lease.fqdn:
119 continue
120
86c9deb2
MT
121 leases.append(lease)
122
123 for lease in FixLeases(self.fix_leases_file):
124 leases.append(lease)
0fbd7c3c 125
c7b83f9b
MT
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
0fbd7c3c
MT
143 self.unbound.update_dhcp_leases(leases)
144
7354d294
MT
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
0fbd7c3c
MT
183 def terminate(self):
184 self.running = False
185
186
187class 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):
86c9deb2
MT
199 log.info("Reading DHCP leases from %s" % self.path)
200
0fbd7c3c
MT
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):
8b1eb795 225 if l.ipaddr == lease.ipaddr:
0fbd7c3c
MT
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
86c9deb2
MT
263class 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
0fbd7c3c
MT
315class 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
86c9deb2
MT
344 def free(self):
345 self._properties.update({
346 "binding" : "state free",
347 })
348
0fbd7c3c
MT
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
998e880b
MT
368 if hostname is None:
369 return
370
0fbd7c3c 371 # Remove any ""
998e880b 372 hostname = hostname.replace("\"", "")
0fbd7c3c 373
998e880b
MT
374 # Only return valid hostnames
375 m = re.match(r"^[A-Z0-9\-]{1,63}$", hostname, re.I)
376 if m:
377 return hostname
0fbd7c3c
MT
378
379 @property
380 def domain(self):
74a5ab67
MT
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
0fbd7c3c
MT
422
423 @property
424 def fqdn(self):
998e880b
MT
425 if self.hostname:
426 return "%s.%s" % (self.hostname, self.domain)
0fbd7c3c
MT
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):
998e880b
MT
457 # If the lease does not have a valid FQDN, we cannot create any RRs
458 if self.fqdn is None:
459 return []
460
0fbd7c3c
MT
461 return [
462 # Forward record
b8dd42b9 463 (self.fqdn, "%s" % LOCAL_TTL, "IN A", self.ipaddr),
0fbd7c3c
MT
464
465 # Reverse record
e22bcd38
MT
466 (ip_address_to_reverse_pointer(self.ipaddr), "%s" % LOCAL_TTL,
467 "IN PTR", self.fqdn),
0fbd7c3c
MT
468 ]
469
470
471class UnboundConfigWriter(object):
472 def __init__(self, path):
473 self.path = path
474
c7b83f9b 475 self._cached_leases = []
0fbd7c3c
MT
476
477 def update_dhcp_leases(self, leases):
d20ef9d7
MT
478 # Find any leases that have expired or do not exist any more
479 # but are still in the unbound local data
c7b83f9b 480 removed_leases = [l for l in self._cached_leases if not l in leases]
d20ef9d7 481
0fbd7c3c 482 # Find any leases that have been added
c7b83f9b 483 new_leases = [l for l in leases if l not in self._cached_leases]
0fbd7c3c
MT
484
485 # End here if nothing has changed
486 if not new_leases and not removed_leases:
487 return
488
0fbd7c3c
MT
489 # Write out all leases
490 self.write_dhcp_leases(leases)
491
492 # Update unbound about changes
c7b83f9b 493 for l in removed_leases:
3ec5ba50
MT
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)
0fbd7c3c
MT
506
507 for l in new_leases:
3ec5ba50
MT
508 try:
509 for rr in l.rrset:
510 log.debug("Adding new record %s" % " ".join(rr))
511 self._control("local_data", *rr)
0fbd7c3c 512
3ec5ba50
MT
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)
0fbd7c3c
MT
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):
b8dd42b9 528 command = ["unbound-control"]
0fbd7c3c
MT
529 command.extend(args)
530
531 try:
3ec5ba50 532 subprocess.check_output(command)
0fbd7c3c
MT
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
3ec5ba50
MT
539 raise
540
0fbd7c3c
MT
541
542if __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")
86c9deb2
MT
555 parser.add_argument("--fix-leases", default="/var/ipfire/dhcp/fixleases",
556 metavar="PATH", help="Path to the fix leases file")
7354d294
MT
557 parser.add_argument("--hosts", default="/var/ipfire/main/hosts",
558 metavar="PATH", help="Path to static hosts file")
0fbd7c3c
MT
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
7354d294
MT
573 bridge = UnboundDHCPLeasesBridge(args.dhcp_leases, args.fix_leases,
574 args.unbound_leases, args.hosts)
0fbd7c3c
MT
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()