]> git.ipfire.org Git - ipfire-2.x.git/blame - config/unbound/unbound-dhcp-leases-bridge
Merge branch 'next'
[ipfire-2.x.git] / config / unbound / unbound-dhcp-leases-bridge
CommitLineData
a8b1843b 1#!/usr/bin/python3
0fbd7c3c
MT
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
b666975e 28import os
0fbd7c3c
MT
29import re
30import signal
7be4822f 31import stat
0fbd7c3c 32import subprocess
b666975e 33import tempfile
0fbd7c3c
MT
34
35import inotify.adapters
36
077ea717
MT
37LOCAL_TTL = 60
38
0fbd7c3c
MT
39def 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
53log = logging.getLogger("dhcp")
54
e22bcd38
MT
55def ip_address_to_reverse_pointer(address):
56 parts = address.split(".")
57 parts.reverse()
58
59 return "%s.in-addr.arpa" % ".".join(parts)
60
61def 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
0fbd7c3c 69class UnboundDHCPLeasesBridge(object):
7354d294 70 def __init__(self, dhcp_leases_file, fix_leases_file, unbound_leases_file, hosts_file):
0fbd7c3c 71 self.leases_file = dhcp_leases_file
86c9deb2 72 self.fix_leases_file = fix_leases_file
7354d294 73 self.hosts_file = hosts_file
0fbd7c3c
MT
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
7354d294
MT
82 # Initial setup
83 self.hosts = self.read_static_hosts()
0fbd7c3c
MT
84 self.update_dhcp_leases()
85
7354d294
MT
86 i = inotify.adapters.Inotify([
87 self.leases_file,
88 self.fix_leases_file,
89 self.hosts_file,
90 ])
0fbd7c3c
MT
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:
7354d294
MT
104 # Reload hosts
105 if watch_path == self.hosts_file:
106 self.hosts = self.read_static_hosts()
107
0fbd7c3c
MT
108 self.update_dhcp_leases()
109
86c9deb2
MT
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
0fbd7c3c
MT
114 log.info("Unbound DHCP Leases Bridge terminated")
115
116 def update_dhcp_leases(self):
86c9deb2
MT
117 leases = []
118
119 for lease in DHCPLeases(self.leases_file):
5d4f3a42
MT
120 # Don't bother with any leases that don't have a hostname
121 if not lease.fqdn:
122 continue
123
86c9deb2
MT
124 leases.append(lease)
125
126 for lease in FixLeases(self.fix_leases_file):
127 leases.append(lease)
0fbd7c3c 128
c7b83f9b
MT
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
0fbd7c3c
MT
146 self.unbound.update_dhcp_leases(leases)
147
7354d294
MT
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:
4a465756 157 enabled, ipaddr, hostname, domainname, generateptr = line.split(",")
7354d294
MT
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:")
96c86cf6
MT
181 for name in hosts:
182 log.debug(" %-20s : %s" % (name, ", ".join(hosts[name])))
7354d294
MT
183
184 return hosts
185
0fbd7c3c
MT
186 def terminate(self):
187 self.running = False
188
189
190class 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):
86c9deb2
MT
202 log.info("Reading DHCP leases from %s" % self.path)
203
0fbd7c3c
MT
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):
8b1eb795 228 if l.ipaddr == lease.ipaddr:
0fbd7c3c
MT
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
86c9deb2
MT
266class 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
0fbd7c3c
MT
318class 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
86c9deb2
MT
347 def free(self):
348 self._properties.update({
349 "binding" : "state free",
350 })
351
0fbd7c3c
MT
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
998e880b
MT
371 if hostname is None:
372 return
373
0fbd7c3c 374 # Remove any ""
998e880b 375 hostname = hostname.replace("\"", "")
0fbd7c3c 376
998e880b
MT
377 # Only return valid hostnames
378 m = re.match(r"^[A-Z0-9\-]{1,63}$", hostname, re.I)
379 if m:
380 return hostname
0fbd7c3c
MT
381
382 @property
383 def domain(self):
74a5ab67
MT
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
96c86cf6 405 for subnet in subnets:
74a5ab67 406 if address in subnet:
96c86cf6 407 return subnets[subnet]
74a5ab67
MT
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
0fbd7c3c
MT
425
426 @property
427 def fqdn(self):
998e880b
MT
428 if self.hostname:
429 return "%s.%s" % (self.hostname, self.domain)
0fbd7c3c
MT
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):
998e880b
MT
460 # If the lease does not have a valid FQDN, we cannot create any RRs
461 if self.fqdn is None:
462 return []
463
0fbd7c3c
MT
464 return [
465 # Forward record
b8dd42b9 466 (self.fqdn, "%s" % LOCAL_TTL, "IN A", self.ipaddr),
0fbd7c3c
MT
467
468 # Reverse record
e22bcd38
MT
469 (ip_address_to_reverse_pointer(self.ipaddr), "%s" % LOCAL_TTL,
470 "IN PTR", self.fqdn),
0fbd7c3c
MT
471 ]
472
473
474class UnboundConfigWriter(object):
475 def __init__(self, path):
476 self.path = path
477
c7b83f9b 478 self._cached_leases = []
0fbd7c3c
MT
479
480 def update_dhcp_leases(self, leases):
d20ef9d7
MT
481 # Find any leases that have expired or do not exist any more
482 # but are still in the unbound local data
c7b83f9b 483 removed_leases = [l for l in self._cached_leases if not l in leases]
d20ef9d7 484
0fbd7c3c 485 # Find any leases that have been added
c7b83f9b 486 new_leases = [l for l in leases if l not in self._cached_leases]
0fbd7c3c
MT
487
488 # End here if nothing has changed
489 if not new_leases and not removed_leases:
490 return
491
0fbd7c3c
MT
492 # Write out all leases
493 self.write_dhcp_leases(leases)
494
495 # Update unbound about changes
c7b83f9b 496 for l in removed_leases:
3ec5ba50
MT
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)
0fbd7c3c
MT
509
510 for l in new_leases:
3ec5ba50
MT
511 try:
512 for rr in l.rrset:
513 log.debug("Adding new record %s" % " ".join(rr))
514 self._control("local_data", *rr)
0fbd7c3c 515
3ec5ba50
MT
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)
0fbd7c3c
MT
523
524 def write_dhcp_leases(self, leases):
b666975e
MT
525 with tempfile.NamedTemporaryFile(mode="w", delete=False) as f:
526 filename = f.name
527
0fbd7c3c
MT
528 for l in leases:
529 for rr in l.rrset:
530 f.write("local-data: \"%s\"\n" % " ".join(rr))
531
7be4822f
MT
532 # Make file readable for everyone
533 os.fchmod(f.fileno(), stat.S_IRUSR|stat.S_IWUSR|stat.S_IRGRP|stat.S_IROTH)
534
b666975e
MT
535 os.rename(filename, self.path)
536
0fbd7c3c 537 def _control(self, *args):
b8dd42b9 538 command = ["unbound-control"]
0fbd7c3c
MT
539 command.extend(args)
540
541 try:
3ec5ba50 542 subprocess.check_output(command)
0fbd7c3c
MT
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
3ec5ba50
MT
549 raise
550
0fbd7c3c
MT
551
552if __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")
86c9deb2
MT
565 parser.add_argument("--fix-leases", default="/var/ipfire/dhcp/fixleases",
566 metavar="PATH", help="Path to the fix leases file")
7354d294
MT
567 parser.add_argument("--hosts", default="/var/ipfire/main/hosts",
568 metavar="PATH", help="Path to static hosts file")
0fbd7c3c
MT
569
570 # Parse command line arguments
571 args = parser.parse_args()
572
573 # Setup logging
83e5f672
MT
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
0fbd7c3c
MT
581
582 setup_logging(loglevel)
583
7354d294
MT
584 bridge = UnboundDHCPLeasesBridge(args.dhcp_leases, args.fix_leases,
585 args.unbound_leases, args.hosts)
0fbd7c3c
MT
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()