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