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