]> git.ipfire.org Git - ipfire-2.x.git/blame - config/unbound/unbound-dhcp-leases-bridge
unbound+DHCP: Read correct DHCP domain name for lease
[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
52class 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
94class 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
168class 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):
74a5ab67
MT
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
0fbd7c3c
MT
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
b8dd42b9 301 (self.fqdn, "%s" % LOCAL_TTL, "IN A", self.ipaddr),
0fbd7c3c
MT
302
303 # Reverse record
b8dd42b9 304 (self.ipaddr, "%s" % LOCAL_TTL, "IN PTR", self.fqdn),
0fbd7c3c
MT
305 ]
306
307
308class UnboundConfigWriter(object):
309 def __init__(self, path):
310 self.path = path
311
b8dd42b9
MT
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
0fbd7c3c
MT
339
340 def update_dhcp_leases(self, leases):
341 # Strip all non-active or expired leases
342 leases = [l for l in leases if l.active and not l.expired]
343
344 # Find any leases that have expired or do not exist any more
b8dd42b9
MT
345 removed_leases = []
346 for fqdn, address in self.existing_leases.items():
347 if not fqdn in (l.fqdn for l in leases):
348 removed_leases += [fqdn, address]
0fbd7c3c
MT
349
350 # Find any leases that have been added
b8dd42b9
MT
351 new_leases = [l for l in leases
352 if l.fqdn not in self.existing_leases]
0fbd7c3c
MT
353
354 # End here if nothing has changed
355 if not new_leases and not removed_leases:
356 return
357
0fbd7c3c
MT
358 # Write out all leases
359 self.write_dhcp_leases(leases)
360
361 # Update unbound about changes
b8dd42b9
MT
362 for hostname in removed_leases:
363 log.debug("Removing all records for %s" % hostname)
364 self._control("local_data_remove", hostname)
0fbd7c3c
MT
365
366 for l in new_leases:
367 for rr in l.rrset:
b8dd42b9 368 log.debug("Adding new record %s" % " ".join(rr))
0fbd7c3c
MT
369 self._control("local_data", *rr)
370
371
372 def write_dhcp_leases(self, leases):
373 with open(self.path, "w") as f:
374 for l in leases:
375 for rr in l.rrset:
376 f.write("local-data: \"%s\"\n" % " ".join(rr))
377
378 def _control(self, *args):
b8dd42b9 379 command = ["unbound-control"]
0fbd7c3c
MT
380 command.extend(args)
381
382 try:
b8dd42b9 383 return subprocess.check_output(command)
0fbd7c3c
MT
384
385 # Log any errors
386 except subprocess.CalledProcessError as e:
387 log.critical("Could not run %s, error code: %s: %s" % (
388 " ".join(command), e.returncode, e.output))
389
390
391if __name__ == "__main__":
392 parser = argparse.ArgumentParser(description="Bridge for DHCP Leases and Unbound DNS")
393
394 # Daemon Stuff
395 parser.add_argument("--daemon", "-d", action="store_true",
396 help="Launch as daemon in background")
397 parser.add_argument("--verbose", "-v", action="count", help="Be more verbose")
398
399 # Paths
400 parser.add_argument("--dhcp-leases", default="/var/state/dhcp/dhcpd.leases",
401 metavar="PATH", help="Path to the DHCPd leases file")
402 parser.add_argument("--unbound-leases", default="/etc/unbound/dhcp-leases.conf",
403 metavar="PATH", help="Path to the unbound configuration file")
404
405 # Parse command line arguments
406 args = parser.parse_args()
407
408 # Setup logging
409 if args.verbose == 1:
410 loglevel = logging.INFO
411 elif args.verbose >= 2:
412 loglevel = logging.DEBUG
413 else:
414 loglevel = logging.WARN
415
416 setup_logging(loglevel)
417
418 bridge = UnboundDHCPLeasesBridge(args.dhcp_leases, args.unbound_leases)
419
420 ctx = daemon.DaemonContext(detach_process=args.daemon)
421 ctx.signal_map = {
422 signal.SIGHUP : bridge.update_dhcp_leases,
423 signal.SIGTERM : bridge.terminate,
424 }
425
426 with ctx:
427 bridge.run()