]> git.ipfire.org Git - ipfire-2.x.git/blame - config/unbound/unbound-dhcp-leases-bridge
unbound: Skip invalid hostnames
[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
MT
66class 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
108class 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
182class 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
998e880b
MT
230 if hostname is None:
231 return
232
0fbd7c3c 233 # Remove any ""
998e880b 234 hostname = hostname.replace("\"", "")
0fbd7c3c 235
998e880b
MT
236 # Only return valid hostnames
237 m = re.match(r"^[A-Z0-9\-]{1,63}$", hostname, re.I)
238 if m:
239 return hostname
0fbd7c3c
MT
240
241 @property
242 def domain(self):
74a5ab67
MT
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
0fbd7c3c
MT
284
285 @property
286 def fqdn(self):
998e880b
MT
287 if self.hostname:
288 return "%s.%s" % (self.hostname, self.domain)
0fbd7c3c
MT
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):
998e880b
MT
319 # If the lease does not have a valid FQDN, we cannot create any RRs
320 if self.fqdn is None:
321 return []
322
0fbd7c3c
MT
323 return [
324 # Forward record
b8dd42b9 325 (self.fqdn, "%s" % LOCAL_TTL, "IN A", self.ipaddr),
0fbd7c3c
MT
326
327 # Reverse record
e22bcd38
MT
328 (ip_address_to_reverse_pointer(self.ipaddr), "%s" % LOCAL_TTL,
329 "IN PTR", self.fqdn),
0fbd7c3c
MT
330 ]
331
332
333class UnboundConfigWriter(object):
334 def __init__(self, path):
335 self.path = path
336
b8dd42b9
MT
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":
e22bcd38 361 ret[content] = reverse_pointer_to_ip_address(hostname)
b8dd42b9
MT
362
363 return ret
0fbd7c3c
MT
364
365 def update_dhcp_leases(self, leases):
d20ef9d7
MT
366 # Cache all expired or inactive leases
367 expired_leases = [l for l in leases if l.expired or not l.active]
0fbd7c3c 368
d20ef9d7
MT
369 # Find any leases that have expired or do not exist any more
370 # but are still in the unbound local data
b8dd42b9
MT
371 removed_leases = []
372 for fqdn, address in self.existing_leases.items():
d20ef9d7 373 if fqdn in (l.fqdn for l in expired_leases):
b8dd42b9 374 removed_leases += [fqdn, address]
0fbd7c3c 375
d20ef9d7
MT
376 # Strip all non-active or expired leases
377 leases = [l for l in leases if l.active and not l.expired]
378
0fbd7c3c 379 # Find any leases that have been added
b8dd42b9
MT
380 new_leases = [l for l in leases
381 if l.fqdn not in self.existing_leases]
0fbd7c3c
MT
382
383 # End here if nothing has changed
384 if not new_leases and not removed_leases:
385 return
386
0fbd7c3c
MT
387 # Write out all leases
388 self.write_dhcp_leases(leases)
389
390 # Update unbound about changes
b8dd42b9
MT
391 for hostname in removed_leases:
392 log.debug("Removing all records for %s" % hostname)
393 self._control("local_data_remove", hostname)
0fbd7c3c
MT
394
395 for l in new_leases:
396 for rr in l.rrset:
b8dd42b9 397 log.debug("Adding new record %s" % " ".join(rr))
0fbd7c3c
MT
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):
b8dd42b9 408 command = ["unbound-control"]
0fbd7c3c
MT
409 command.extend(args)
410
411 try:
b8dd42b9 412 return subprocess.check_output(command)
0fbd7c3c
MT
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
420if __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()