]> git.ipfire.org Git - ipfire-2.x.git/blame - config/unbound/unbound-dhcp-leases-bridge
netpbm: Bump release version to 2
[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):
86c9deb2 67 def __init__(self, dhcp_leases_file, fix_leases_file, unbound_leases_file):
0fbd7c3c 68 self.leases_file = dhcp_leases_file
86c9deb2 69 self.fix_leases_file = fix_leases_file
0fbd7c3c
MT
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
86c9deb2 81 i = inotify.adapters.Inotify([self.leases_file, self.fix_leases_file])
0fbd7c3c
MT
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
86c9deb2
MT
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
0fbd7c3c
MT
101 log.info("Unbound DHCP Leases Bridge terminated")
102
103 def update_dhcp_leases(self):
86c9deb2
MT
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)
0fbd7c3c 111
0fbd7c3c
MT
112 self.unbound.update_dhcp_leases(leases)
113
114 def terminate(self):
115 self.running = False
116
117
118class 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):
86c9deb2
MT
130 log.info("Reading DHCP leases from %s" % self.path)
131
0fbd7c3c
MT
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
86c9deb2
MT
194class 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
0fbd7c3c
MT
246class 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
86c9deb2
MT
275 def free(self):
276 self._properties.update({
277 "binding" : "state free",
278 })
279
0fbd7c3c
MT
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
998e880b
MT
299 if hostname is None:
300 return
301
0fbd7c3c 302 # Remove any ""
998e880b 303 hostname = hostname.replace("\"", "")
0fbd7c3c 304
998e880b
MT
305 # Only return valid hostnames
306 m = re.match(r"^[A-Z0-9\-]{1,63}$", hostname, re.I)
307 if m:
308 return hostname
0fbd7c3c
MT
309
310 @property
311 def domain(self):
74a5ab67
MT
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
0fbd7c3c
MT
353
354 @property
355 def fqdn(self):
998e880b
MT
356 if self.hostname:
357 return "%s.%s" % (self.hostname, self.domain)
0fbd7c3c
MT
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):
998e880b
MT
388 # If the lease does not have a valid FQDN, we cannot create any RRs
389 if self.fqdn is None:
390 return []
391
0fbd7c3c
MT
392 return [
393 # Forward record
b8dd42b9 394 (self.fqdn, "%s" % LOCAL_TTL, "IN A", self.ipaddr),
0fbd7c3c
MT
395
396 # Reverse record
e22bcd38
MT
397 (ip_address_to_reverse_pointer(self.ipaddr), "%s" % LOCAL_TTL,
398 "IN PTR", self.fqdn),
0fbd7c3c
MT
399 ]
400
401
402class UnboundConfigWriter(object):
403 def __init__(self, path):
404 self.path = path
405
b8dd42b9
MT
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":
e22bcd38 430 ret[content] = reverse_pointer_to_ip_address(hostname)
b8dd42b9
MT
431
432 return ret
0fbd7c3c
MT
433
434 def update_dhcp_leases(self, leases):
d20ef9d7
MT
435 # Cache all expired or inactive leases
436 expired_leases = [l for l in leases if l.expired or not l.active]
0fbd7c3c 437
d20ef9d7
MT
438 # Find any leases that have expired or do not exist any more
439 # but are still in the unbound local data
b8dd42b9
MT
440 removed_leases = []
441 for fqdn, address in self.existing_leases.items():
d20ef9d7 442 if fqdn in (l.fqdn for l in expired_leases):
b8dd42b9 443 removed_leases += [fqdn, address]
0fbd7c3c 444
d20ef9d7
MT
445 # Strip all non-active or expired leases
446 leases = [l for l in leases if l.active and not l.expired]
447
0fbd7c3c 448 # Find any leases that have been added
b8dd42b9
MT
449 new_leases = [l for l in leases
450 if l.fqdn not in self.existing_leases]
0fbd7c3c
MT
451
452 # End here if nothing has changed
453 if not new_leases and not removed_leases:
454 return
455
0fbd7c3c
MT
456 # Write out all leases
457 self.write_dhcp_leases(leases)
458
459 # Update unbound about changes
b8dd42b9
MT
460 for hostname in removed_leases:
461 log.debug("Removing all records for %s" % hostname)
462 self._control("local_data_remove", hostname)
0fbd7c3c
MT
463
464 for l in new_leases:
465 for rr in l.rrset:
b8dd42b9 466 log.debug("Adding new record %s" % " ".join(rr))
0fbd7c3c
MT
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):
b8dd42b9 477 command = ["unbound-control"]
0fbd7c3c
MT
478 command.extend(args)
479
480 try:
b8dd42b9 481 return subprocess.check_output(command)
0fbd7c3c
MT
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
489if __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")
86c9deb2
MT
502 parser.add_argument("--fix-leases", default="/var/ipfire/dhcp/fixleases",
503 metavar="PATH", help="Path to the fix leases file")
0fbd7c3c
MT
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
86c9deb2 518 bridge = UnboundDHCPLeasesBridge(args.dhcp_leases, args.fix_leases, args.unbound_leases)
0fbd7c3c
MT
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()