]> git.ipfire.org Git - ipfire-2.x.git/blob - config/unbound/unbound-dhcp-leases-bridge
unbound+DHCP: Set TTL for local leases to 1m
[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 logging
26 import logging.handlers
27 import re
28 import signal
29 import subprocess
30
31 import inotify.adapters
32
33 LOCAL_TTL = 60
34
35 def setup_logging(loglevel=logging.INFO):
36 log = logging.getLogger("dhcp")
37 log.setLevel(loglevel)
38
39 handler = logging.handlers.SysLogHandler(address="/dev/log", facility="daemon")
40 handler.setLevel(loglevel)
41
42 formatter = logging.Formatter("%(name)s[%(process)d]: %(message)s")
43 handler.setFormatter(formatter)
44
45 log.addHandler(handler)
46
47 return log
48
49 log = logging.getLogger("dhcp")
50
51 class UnboundDHCPLeasesBridge(object):
52 def __init__(self, dhcp_leases_file, unbound_leases_file):
53 self.leases_file = dhcp_leases_file
54
55 self.unbound = UnboundConfigWriter(unbound_leases_file)
56 self.running = False
57
58 def run(self):
59 log.info("Unbound DHCP Leases Bridge started on %s" % self.leases_file)
60 self.running = True
61
62 # Initially read leases file
63 self.update_dhcp_leases()
64
65 i = inotify.adapters.Inotify([self.leases_file])
66
67 for event in i.event_gen():
68 # End if we are requested to terminate
69 if not self.running:
70 break
71
72 if event is None:
73 continue
74
75 header, type_names, watch_path, filename = event
76
77 # Update leases after leases file has been modified
78 if "IN_MODIFY" in type_names:
79 self.update_dhcp_leases()
80
81 log.info("Unbound DHCP Leases Bridge terminated")
82
83 def update_dhcp_leases(self):
84 log.info("Reading DHCP leases from %s" % self.leases_file)
85
86 leases = DHCPLeases(self.leases_file)
87 self.unbound.update_dhcp_leases(leases)
88
89 def terminate(self):
90 self.running = False
91
92
93 class DHCPLeases(object):
94 regex_leaseblock = re.compile(r"lease (?P<ipaddr>\d+\.\d+\.\d+\.\d+) {(?P<config>[\s\S]+?)\n}")
95
96 def __init__(self, path):
97 self.path = path
98
99 self._leases = self._parse()
100
101 def __iter__(self):
102 return iter(self._leases)
103
104 def _parse(self):
105 leases = []
106
107 with open(self.path) as f:
108 # Read entire leases file
109 data = f.read()
110
111 for match in self.regex_leaseblock.finditer(data):
112 block = match.groupdict()
113
114 ipaddr = block.get("ipaddr")
115 config = block.get("config")
116
117 properties = self._parse_block(config)
118
119 # Skip any abandoned leases
120 if not "hardware" in properties:
121 continue
122
123 lease = Lease(ipaddr, properties)
124
125 # Check if a lease for this Ethernet address already
126 # exists in the list of known leases. If so replace
127 # if with the most recent lease
128 for i, l in enumerate(leases):
129 if l.hwaddr == lease.hwaddr:
130 leases[i] = max(lease, l)
131 break
132
133 else:
134 leases.append(lease)
135
136 return leases
137
138 def _parse_block(self, block):
139 properties = {}
140
141 for line in block.splitlines():
142 if not line:
143 continue
144
145 # Remove trailing ; from line
146 if line.endswith(";"):
147 line = line[:-1]
148
149 # Invalid line if it doesn't end with ;
150 else:
151 continue
152
153 # Remove any leading whitespace
154 line = line.lstrip()
155
156 # We skip all options and sets
157 if line.startswith("option") or line.startswith("set"):
158 continue
159
160 # Split by first space
161 key, val = line.split(" ", 1)
162 properties[key] = val
163
164 return properties
165
166
167 class Lease(object):
168 def __init__(self, ipaddr, properties):
169 self.ipaddr = ipaddr
170 self._properties = properties
171
172 def __repr__(self):
173 return "<%s %s for %s (%s)>" % (self.__class__.__name__,
174 self.ipaddr, self.hwaddr, self.hostname)
175
176 def __eq__(self, other):
177 return self.ipaddr == other.ipaddr and self.hwaddr == other.hwaddr
178
179 def __gt__(self, other):
180 if not self.ipaddr == other.ipaddr:
181 return
182
183 if not self.hwaddr == other.hwaddr:
184 return
185
186 return self.time_starts > other.time_starts
187
188 @property
189 def binding_state(self):
190 state = self._properties.get("binding")
191
192 if state:
193 state = state.split(" ", 1)
194 return state[1]
195
196 @property
197 def active(self):
198 return self.binding_state == "active"
199
200 @property
201 def hwaddr(self):
202 hardware = self._properties.get("hardware")
203
204 if not hardware:
205 return
206
207 ethernet, address = hardware.split(" ", 1)
208
209 return address
210
211 @property
212 def hostname(self):
213 hostname = self._properties.get("client-hostname")
214
215 # Remove any ""
216 if hostname:
217 hostname = hostname.replace("\"", "")
218
219 return hostname
220
221 @property
222 def domain(self):
223 return "local" # XXX
224
225 @property
226 def fqdn(self):
227 return "%s.%s" % (self.hostname, self.domain)
228
229 @staticmethod
230 def _parse_time(s):
231 return datetime.datetime.strptime(s, "%w %Y/%m/%d %H:%M:%S")
232
233 @property
234 def time_starts(self):
235 starts = self._properties.get("starts")
236
237 if starts:
238 return self._parse_time(starts)
239
240 @property
241 def time_ends(self):
242 ends = self._properties.get("ends")
243
244 if not ends or ends == "never":
245 return
246
247 return self._parse_time(ends)
248
249 @property
250 def expired(self):
251 if not self.time_ends:
252 return self.time_starts > datetime.datetime.utcnow()
253
254 return self.time_starts > datetime.datetime.utcnow() > self.time_ends
255
256 @property
257 def rrset(self):
258 return [
259 # Forward record
260 (self.fqdn, LOCAL_TTL, "IN A", self.ipaddr),
261
262 # Reverse record
263 (self.ipaddr, LOCAL_TTL, "IN PTR", self.fqdn),
264 ]
265
266
267 class UnboundConfigWriter(object):
268 def __init__(self, path):
269 self.path = path
270
271 self._cached_leases = []
272
273 def update_dhcp_leases(self, leases):
274 # Strip all non-active or expired leases
275 leases = [l for l in leases if l.active and not l.expired]
276
277 # Find any leases that have expired or do not exist any more
278 removed_leases = [l for l in self._cached_leases if l.expired or l not in leases]
279
280 # Find any leases that have been added
281 new_leases = [l for l in leases if l not in self._cached_leases]
282
283 # End here if nothing has changed
284 if not new_leases and not removed_leases:
285 return
286
287 self._cached_leases = leases
288
289 # Write out all leases
290 self.write_dhcp_leases(leases)
291
292 # Update unbound about changes
293 for l in removed_leases:
294 self._control("local_data_remove", l.fqdn)
295
296 for l in new_leases:
297 for rr in l.rrset:
298 self._control("local_data", *rr)
299
300
301 def write_dhcp_leases(self, leases):
302 with open(self.path, "w") as f:
303 for l in leases:
304 for rr in l.rrset:
305 f.write("local-data: \"%s\"\n" % " ".join(rr))
306
307 def _control(self, *args):
308 command = ["unbound-control", "-q"]
309 command.extend(args)
310
311 try:
312 subprocess.check_call(command)
313
314 # Log any errors
315 except subprocess.CalledProcessError as e:
316 log.critical("Could not run %s, error code: %s: %s" % (
317 " ".join(command), e.returncode, e.output))
318
319
320 if __name__ == "__main__":
321 parser = argparse.ArgumentParser(description="Bridge for DHCP Leases and Unbound DNS")
322
323 # Daemon Stuff
324 parser.add_argument("--daemon", "-d", action="store_true",
325 help="Launch as daemon in background")
326 parser.add_argument("--verbose", "-v", action="count", help="Be more verbose")
327
328 # Paths
329 parser.add_argument("--dhcp-leases", default="/var/state/dhcp/dhcpd.leases",
330 metavar="PATH", help="Path to the DHCPd leases file")
331 parser.add_argument("--unbound-leases", default="/etc/unbound/dhcp-leases.conf",
332 metavar="PATH", help="Path to the unbound configuration file")
333
334 # Parse command line arguments
335 args = parser.parse_args()
336
337 # Setup logging
338 if args.verbose == 1:
339 loglevel = logging.INFO
340 elif args.verbose >= 2:
341 loglevel = logging.DEBUG
342 else:
343 loglevel = logging.WARN
344
345 setup_logging(loglevel)
346
347 bridge = UnboundDHCPLeasesBridge(args.dhcp_leases, args.unbound_leases)
348
349 ctx = daemon.DaemonContext(detach_process=args.daemon)
350 ctx.signal_map = {
351 signal.SIGHUP : bridge.update_dhcp_leases,
352 signal.SIGTERM : bridge.terminate,
353 }
354
355 with ctx:
356 bridge.run()