]> git.ipfire.org Git - ipfire-2.x.git/blame - config/unbound/unbound-dhcp-leases-bridge
unbound: Start service after network has been brought up
[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
25import logging
26import logging.handlers
27import re
28import signal
29import subprocess
30
31import inotify.adapters
32
33def 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
47log = logging.getLogger("dhcp")
48
49class 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
91class 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
165class 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
265class 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
318if __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()