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