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