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