]> git.ipfire.org Git - ipfire-2.x.git/blob - config/unbound/unbound-dhcp-leases-bridge
core130: update pakfire database after version change
[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 os
29 import re
30 import signal
31 import subprocess
32 import tempfile
33
34 import inotify.adapters
35
36 LOCAL_TTL = 60
37
38 def 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
52 log = logging.getLogger("dhcp")
53
54 def ip_address_to_reverse_pointer(address):
55 parts = address.split(".")
56 parts.reverse()
57
58 return "%s.in-addr.arpa" % ".".join(parts)
59
60 def 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
68 class UnboundDHCPLeasesBridge(object):
69 def __init__(self, dhcp_leases_file, fix_leases_file, unbound_leases_file, hosts_file):
70 self.leases_file = dhcp_leases_file
71 self.fix_leases_file = fix_leases_file
72 self.hosts_file = hosts_file
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
81 # Initial setup
82 self.hosts = self.read_static_hosts()
83 self.update_dhcp_leases()
84
85 i = inotify.adapters.Inotify([
86 self.leases_file,
87 self.fix_leases_file,
88 self.hosts_file,
89 ])
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:
103 # Reload hosts
104 if watch_path == self.hosts_file:
105 self.hosts = self.read_static_hosts()
106
107 self.update_dhcp_leases()
108
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
113 log.info("Unbound DHCP Leases Bridge terminated")
114
115 def update_dhcp_leases(self):
116 leases = []
117
118 for lease in DHCPLeases(self.leases_file):
119 # Don't bother with any leases that don't have a hostname
120 if not lease.fqdn:
121 continue
122
123 leases.append(lease)
124
125 for lease in FixLeases(self.fix_leases_file):
126 leases.append(lease)
127
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
145 self.unbound.update_dhcp_leases(leases)
146
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
185 def terminate(self):
186 self.running = False
187
188
189 class 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):
201 log.info("Reading DHCP leases from %s" % self.path)
202
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):
227 if l.ipaddr == lease.ipaddr:
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
265 class 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
317 class 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
346 def free(self):
347 self._properties.update({
348 "binding" : "state free",
349 })
350
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
370 if hostname is None:
371 return
372
373 # Remove any ""
374 hostname = hostname.replace("\"", "")
375
376 # Only return valid hostnames
377 m = re.match(r"^[A-Z0-9\-]{1,63}$", hostname, re.I)
378 if m:
379 return hostname
380
381 @property
382 def domain(self):
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
424
425 @property
426 def fqdn(self):
427 if self.hostname:
428 return "%s.%s" % (self.hostname, self.domain)
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):
459 # If the lease does not have a valid FQDN, we cannot create any RRs
460 if self.fqdn is None:
461 return []
462
463 return [
464 # Forward record
465 (self.fqdn, "%s" % LOCAL_TTL, "IN A", self.ipaddr),
466
467 # Reverse record
468 (ip_address_to_reverse_pointer(self.ipaddr), "%s" % LOCAL_TTL,
469 "IN PTR", self.fqdn),
470 ]
471
472
473 class UnboundConfigWriter(object):
474 def __init__(self, path):
475 self.path = path
476
477 self._cached_leases = []
478
479 def update_dhcp_leases(self, leases):
480 # Find any leases that have expired or do not exist any more
481 # but are still in the unbound local data
482 removed_leases = [l for l in self._cached_leases if not l in leases]
483
484 # Find any leases that have been added
485 new_leases = [l for l in leases if l not in self._cached_leases]
486
487 # End here if nothing has changed
488 if not new_leases and not removed_leases:
489 return
490
491 # Write out all leases
492 self.write_dhcp_leases(leases)
493
494 # Update unbound about changes
495 for l in removed_leases:
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)
508
509 for l in new_leases:
510 try:
511 for rr in l.rrset:
512 log.debug("Adding new record %s" % " ".join(rr))
513 self._control("local_data", *rr)
514
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)
522
523 def write_dhcp_leases(self, leases):
524 with tempfile.NamedTemporaryFile(mode="w", delete=False) as f:
525 filename = f.name
526
527 for l in leases:
528 for rr in l.rrset:
529 f.write("local-data: \"%s\"\n" % " ".join(rr))
530
531 os.rename(filename, self.path)
532
533 def _control(self, *args):
534 command = ["unbound-control"]
535 command.extend(args)
536
537 try:
538 subprocess.check_output(command)
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
545 raise
546
547
548 if __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")
561 parser.add_argument("--fix-leases", default="/var/ipfire/dhcp/fixleases",
562 metavar="PATH", help="Path to the fix leases file")
563 parser.add_argument("--hosts", default="/var/ipfire/main/hosts",
564 metavar="PATH", help="Path to static hosts file")
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
579 bridge = UnboundDHCPLeasesBridge(args.dhcp_leases, args.fix_leases,
580 args.unbound_leases, args.hosts)
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()