]> git.ipfire.org Git - ipfire-2.x.git/blob - config/unbound/unbound-dhcp-leases-bridge
unbound: Skip invalid hostnames
[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 if hostname is None:
231 return
232
233 # Remove any ""
234 hostname = hostname.replace("\"", "")
235
236 # Only return valid hostnames
237 m = re.match(r"^[A-Z0-9\-]{1,63}$", hostname, re.I)
238 if m:
239 return hostname
240
241 @property
242 def domain(self):
243 # Load ethernet settings
244 ethernet_settings = self.read_settings("/var/ipfire/ethernet/settings")
245
246 # Load DHCP settings
247 dhcp_settings = self.read_settings("/var/ipfire/dhcp/settings")
248
249 subnets = {}
250 for zone in ("GREEN", "BLUE"):
251 if not dhcp_settings.get("ENABLE_%s" % zone) == "on":
252 continue
253
254 netaddr = ethernet_settings.get("%s_NETADDRESS" % zone)
255 submask = ethernet_settings.get("%s_NETMASK" % zone)
256
257 subnet = ipaddress.ip_network("%s/%s" % (netaddr, submask))
258 domain = dhcp_settings.get("DOMAIN_NAME_%s" % zone)
259
260 subnets[subnet] = domain
261
262 address = ipaddress.ip_address(self.ipaddr)
263
264 for subnet, domain in subnets.items():
265 if address in subnet:
266 return domain
267
268 # Fall back to localdomain if no match could be found
269 return "localdomain"
270
271 @staticmethod
272 def read_settings(filename):
273 settings = {}
274
275 with open(filename) as f:
276 for line in f.readlines():
277 # Remove line-breaks
278 line = line.rstrip()
279
280 k, v = line.split("=", 1)
281 settings[k] = v
282
283 return settings
284
285 @property
286 def fqdn(self):
287 if self.hostname:
288 return "%s.%s" % (self.hostname, self.domain)
289
290 @staticmethod
291 def _parse_time(s):
292 return datetime.datetime.strptime(s, "%w %Y/%m/%d %H:%M:%S")
293
294 @property
295 def time_starts(self):
296 starts = self._properties.get("starts")
297
298 if starts:
299 return self._parse_time(starts)
300
301 @property
302 def time_ends(self):
303 ends = self._properties.get("ends")
304
305 if not ends or ends == "never":
306 return
307
308 return self._parse_time(ends)
309
310 @property
311 def expired(self):
312 if not self.time_ends:
313 return self.time_starts > datetime.datetime.utcnow()
314
315 return self.time_starts > datetime.datetime.utcnow() > self.time_ends
316
317 @property
318 def rrset(self):
319 # If the lease does not have a valid FQDN, we cannot create any RRs
320 if self.fqdn is None:
321 return []
322
323 return [
324 # Forward record
325 (self.fqdn, "%s" % LOCAL_TTL, "IN A", self.ipaddr),
326
327 # Reverse record
328 (ip_address_to_reverse_pointer(self.ipaddr), "%s" % LOCAL_TTL,
329 "IN PTR", self.fqdn),
330 ]
331
332
333 class UnboundConfigWriter(object):
334 def __init__(self, path):
335 self.path = path
336
337 @property
338 def existing_leases(self):
339 local_data = self._control("list_local_data")
340 ret = {}
341
342 for line in local_data.splitlines():
343 try:
344 hostname, ttl, x, record_type, content = line.split("\t")
345 except ValueError:
346 continue
347
348 # Ignore everything that is not A or PTR
349 if not record_type in ("A", "PTR"):
350 continue
351
352 if hostname.endswith("."):
353 hostname = hostname[:-1]
354
355 if content.endswith("."):
356 content = content[:-1]
357
358 if record_type == "A":
359 ret[hostname] = content
360 elif record_type == "PTR":
361 ret[content] = reverse_pointer_to_ip_address(hostname)
362
363 return ret
364
365 def update_dhcp_leases(self, leases):
366 # Cache all expired or inactive leases
367 expired_leases = [l for l in leases if l.expired or not l.active]
368
369 # Find any leases that have expired or do not exist any more
370 # but are still in the unbound local data
371 removed_leases = []
372 for fqdn, address in self.existing_leases.items():
373 if fqdn in (l.fqdn for l in expired_leases):
374 removed_leases += [fqdn, address]
375
376 # Strip all non-active or expired leases
377 leases = [l for l in leases if l.active and not l.expired]
378
379 # Find any leases that have been added
380 new_leases = [l for l in leases
381 if l.fqdn not in self.existing_leases]
382
383 # End here if nothing has changed
384 if not new_leases and not removed_leases:
385 return
386
387 # Write out all leases
388 self.write_dhcp_leases(leases)
389
390 # Update unbound about changes
391 for hostname in removed_leases:
392 log.debug("Removing all records for %s" % hostname)
393 self._control("local_data_remove", hostname)
394
395 for l in new_leases:
396 for rr in l.rrset:
397 log.debug("Adding new record %s" % " ".join(rr))
398 self._control("local_data", *rr)
399
400
401 def write_dhcp_leases(self, leases):
402 with open(self.path, "w") as f:
403 for l in leases:
404 for rr in l.rrset:
405 f.write("local-data: \"%s\"\n" % " ".join(rr))
406
407 def _control(self, *args):
408 command = ["unbound-control"]
409 command.extend(args)
410
411 try:
412 return subprocess.check_output(command)
413
414 # Log any errors
415 except subprocess.CalledProcessError as e:
416 log.critical("Could not run %s, error code: %s: %s" % (
417 " ".join(command), e.returncode, e.output))
418
419
420 if __name__ == "__main__":
421 parser = argparse.ArgumentParser(description="Bridge for DHCP Leases and Unbound DNS")
422
423 # Daemon Stuff
424 parser.add_argument("--daemon", "-d", action="store_true",
425 help="Launch as daemon in background")
426 parser.add_argument("--verbose", "-v", action="count", help="Be more verbose")
427
428 # Paths
429 parser.add_argument("--dhcp-leases", default="/var/state/dhcp/dhcpd.leases",
430 metavar="PATH", help="Path to the DHCPd leases file")
431 parser.add_argument("--unbound-leases", default="/etc/unbound/dhcp-leases.conf",
432 metavar="PATH", help="Path to the unbound configuration file")
433
434 # Parse command line arguments
435 args = parser.parse_args()
436
437 # Setup logging
438 if args.verbose == 1:
439 loglevel = logging.INFO
440 elif args.verbose >= 2:
441 loglevel = logging.DEBUG
442 else:
443 loglevel = logging.WARN
444
445 setup_logging(loglevel)
446
447 bridge = UnboundDHCPLeasesBridge(args.dhcp_leases, args.unbound_leases)
448
449 ctx = daemon.DaemonContext(detach_process=args.daemon)
450 ctx.signal_map = {
451 signal.SIGHUP : bridge.update_dhcp_leases,
452 signal.SIGTERM : bridge.terminate,
453 }
454
455 with ctx:
456 bridge.run()