]>
Commit | Line | Data |
---|---|---|
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 | ||
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 | ||
077ea717 MT |
33 | LOCAL_TTL = 60 |
34 | ||
0fbd7c3c MT |
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 | |
077ea717 | 260 | (self.fqdn, LOCAL_TTL, "IN A", self.ipaddr), |
0fbd7c3c MT |
261 | |
262 | # Reverse record | |
077ea717 | 263 | (self.ipaddr, LOCAL_TTL, "IN PTR", self.fqdn), |
0fbd7c3c MT |
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() |