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