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