]>
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 | |
28 | import re | |
29 | import signal | |
30 | import subprocess | |
31 | ||
32 | import inotify.adapters | |
33 | ||
077ea717 MT |
34 | LOCAL_TTL = 60 |
35 | ||
0fbd7c3c MT |
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 | ||
e22bcd38 MT |
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 | ||
0fbd7c3c | 66 | class 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 | ||
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): | |
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 |
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 | ||
0fbd7c3c MT |
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 | ||
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 | ||
402 | class 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 | ||
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") | |
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() |