unbound-dhcp-leases-bridge: Replace leases file atomically
[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 ipaddress
26 import logging
27 import logging.handlers
28 import os
29 import re
30 import signal
31 import subprocess
32 import tempfile
33
34 import inotify.adapters
35
36 LOCAL_TTL = 60
37
38 def setup_logging(loglevel=logging.INFO):
39         log = logging.getLogger("dhcp")
40         log.setLevel(loglevel)
41
42         handler = logging.handlers.SysLogHandler(address="/dev/log", facility="daemon")
43         handler.setLevel(loglevel)
44
45         formatter = logging.Formatter("%(name)s[%(process)d]: %(message)s")
46         handler.setFormatter(formatter)
47
48         log.addHandler(handler)
49
50         return log
51
52 log = logging.getLogger("dhcp")
53
54 def ip_address_to_reverse_pointer(address):
55         parts = address.split(".")
56         parts.reverse()
57
58         return "%s.in-addr.arpa" % ".".join(parts)
59
60 def reverse_pointer_to_ip_address(rr):
61         parts = rr.split(".")
62
63         # Only take IP address part
64         parts = reversed(parts[0:4])
65
66         return ".".join(parts)
67
68 class UnboundDHCPLeasesBridge(object):
69         def __init__(self, dhcp_leases_file, fix_leases_file, unbound_leases_file, hosts_file):
70                 self.leases_file = dhcp_leases_file
71                 self.fix_leases_file = fix_leases_file
72                 self.hosts_file = hosts_file
73
74                 self.unbound = UnboundConfigWriter(unbound_leases_file)
75                 self.running = False
76
77         def run(self):
78                 log.info("Unbound DHCP Leases Bridge started on %s" % self.leases_file)
79                 self.running = True
80
81                 # Initial setup
82                 self.hosts = self.read_static_hosts()
83                 self.update_dhcp_leases()
84
85                 i = inotify.adapters.Inotify([
86                         self.leases_file,
87                         self.fix_leases_file,
88                         self.hosts_file,
89                 ])
90
91                 for event in i.event_gen():
92                         # End if we are requested to terminate
93                         if not self.running:
94                                 break
95
96                         if event is None:
97                                 continue
98
99                         header, type_names, watch_path, filename = event
100
101                         # Update leases after leases file has been modified
102                         if "IN_MODIFY" in type_names:
103                                 # Reload hosts
104                                 if watch_path == self.hosts_file:
105                                         self.hosts = self.read_static_hosts()
106
107                                 self.update_dhcp_leases()
108
109                         # If the file is deleted, we re-add the watcher
110                         if "IN_IGNORED" in type_names:
111                                 i.add_watch(watch_path)
112
113                 log.info("Unbound DHCP Leases Bridge terminated")
114
115         def update_dhcp_leases(self):
116                 leases = []
117
118                 for lease in DHCPLeases(self.leases_file):
119                         # Don't bother with any leases that don't have a hostname
120                         if not lease.fqdn:
121                                 continue
122
123                         leases.append(lease)
124
125                 for lease in FixLeases(self.fix_leases_file):
126                         leases.append(lease)
127
128                 # Skip any leases that also are a static host
129                 leases = [l for l in leases if not l.fqdn in self.hosts]
130
131                 # Remove any inactive or expired leases
132                 leases = [l for l in leases if l.active and not l.expired]
133
134                 # Dump leases
135                 if leases:
136                         log.debug("DHCP Leases:")
137                         for lease in leases:
138                                 log.debug("  %s:" % lease.fqdn)
139                                 log.debug("    State: %s" % lease.binding_state)
140                                 log.debug("    Start: %s" % lease.time_starts)
141                                 log.debug("    End  : %s" % lease.time_ends)
142                                 if lease.expired:
143                                         log.debug("    Expired")
144
145                 self.unbound.update_dhcp_leases(leases)
146
147         def read_static_hosts(self):
148                 log.info("Reading static hosts from %s" % self.hosts_file)
149
150                 hosts = {}
151                 with open(self.hosts_file) as f:
152                         for line in f.readlines():
153                                 line = line.rstrip()
154
155                                 try:
156                                         enabled, ipaddr, hostname, domainname = line.split(",")
157                                 except:
158                                         log.warning("Could not parse line: %s" % line)
159                                         continue
160
161                                 # Skip any disabled entries
162                                 if not enabled == "on":
163                                         continue
164
165                                 if hostname and domainname:
166                                         fqdn = "%s.%s" % (hostname, domainname)
167                                 elif hostname:
168                                         fqdn = hostname
169                                 elif domainname:
170                                         fqdn = domainname
171
172                                 try:
173                                         hosts[fqdn].append(ipaddr)
174                                         hosts[fqdn].sort()
175                                 except KeyError:
176                                         hosts[fqdn] = [ipaddr,]
177
178                 # Dump everything in the logs
179                 log.debug("Static hosts:")
180                 for hostname, addresses in hosts.items():
181                         log.debug("  %-20s : %s" % (hostname, ", ".join(addresses)))
182
183                 return hosts
184
185         def terminate(self):
186                 self.running = False
187
188
189 class DHCPLeases(object):
190         regex_leaseblock = re.compile(r"lease (?P<ipaddr>\d+\.\d+\.\d+\.\d+) {(?P<config>[\s\S]+?)\n}")
191
192         def __init__(self, path):
193                 self.path = path
194
195                 self._leases = self._parse()
196
197         def __iter__(self):
198                 return iter(self._leases)
199
200         def _parse(self):
201                 log.info("Reading DHCP leases from %s" % self.path)
202
203                 leases = []
204
205                 with open(self.path) as f:
206                         # Read entire leases file
207                         data = f.read()
208
209                         for match in self.regex_leaseblock.finditer(data):
210                                 block = match.groupdict()
211
212                                 ipaddr = block.get("ipaddr")
213                                 config = block.get("config")
214
215                                 properties = self._parse_block(config)
216
217                                 # Skip any abandoned leases
218                                 if not "hardware" in properties:
219                                         continue
220
221                                 lease = Lease(ipaddr, properties)
222
223                                 # Check if a lease for this Ethernet address already
224                                 # exists in the list of known leases. If so replace
225                                 # if with the most recent lease
226                                 for i, l in enumerate(leases):
227                                         if l.ipaddr == lease.ipaddr:
228                                                 leases[i] = max(lease, l)
229                                                 break
230
231                                 else:
232                                         leases.append(lease)
233
234                 return leases
235
236         def _parse_block(self, block):
237                 properties = {}
238
239                 for line in block.splitlines():
240                         if not line:
241                                 continue
242
243                         # Remove trailing ; from line
244                         if line.endswith(";"):
245                                 line = line[:-1]
246
247                         # Invalid line if it doesn't end with ;
248                         else:
249                                 continue
250
251                         # Remove any leading whitespace
252                         line = line.lstrip()
253
254                         # We skip all options and sets
255                         if line.startswith("option") or line.startswith("set"):
256                                 continue
257
258                         # Split by first space
259                         key, val = line.split(" ", 1)
260                         properties[key] = val
261
262                 return properties
263
264
265 class FixLeases(object):
266         cache = {}
267
268         def __init__(self, path):
269                 self.path = path
270
271                 self._leases = self.cache[self.path] = self._parse()
272
273         def __iter__(self):
274                 return iter(self._leases)
275
276         def _parse(self):
277                 log.info("Reading fix leases from %s" % self.path)
278
279                 leases = []
280                 now = datetime.datetime.utcnow()
281
282                 with open(self.path) as f:
283                         for line in f.readlines():
284                                 line = line.rstrip()
285
286                                 try:
287                                         hwaddr, ipaddr, enabled, a, b, c, hostname = line.split(",")
288                                 except ValueError:
289                                         log.warning("Could not parse line: %s" % line)
290                                         continue
291
292                                 # Skip any disabled leases
293                                 if not enabled == "on":
294                                         continue
295
296                                 l = Lease(ipaddr, {
297                                         "binding"         : "state active",
298                                         "client-hostname" : hostname,
299                                         "hardware"        : "ethernet %s" % hwaddr,
300                                         "starts"          : now.strftime("%w %Y/%m/%d %H:%M:%S"),
301                                         "ends"            : "never",
302                                 })
303                                 leases.append(l)
304
305                 # Try finding any deleted leases
306                 for lease in self.cache.get(self.path, []):
307                         if lease in leases:
308                                 continue
309
310                         # Free the deleted lease
311                         lease.free()
312                         leases.append(lease)
313
314                 return leases
315
316
317 class Lease(object):
318         def __init__(self, ipaddr, properties):
319                 self.ipaddr = ipaddr
320                 self._properties = properties
321
322         def __repr__(self):
323                 return "<%s %s for %s (%s)>" % (self.__class__.__name__,
324                         self.ipaddr, self.hwaddr, self.hostname)
325
326         def __eq__(self, other):
327                 return self.ipaddr == other.ipaddr and self.hwaddr == other.hwaddr
328
329         def __gt__(self, other):
330                 if not self.ipaddr == other.ipaddr:
331                         return
332
333                 if not self.hwaddr == other.hwaddr:
334                         return
335
336                 return self.time_starts > other.time_starts
337
338         @property
339         def binding_state(self):
340                 state = self._properties.get("binding")
341
342                 if state:
343                         state = state.split(" ", 1)
344                         return state[1]
345
346         def free(self):
347                 self._properties.update({
348                         "binding" : "state free",
349                 })
350
351         @property
352         def active(self):
353                 return self.binding_state == "active"
354
355         @property
356         def hwaddr(self):
357                 hardware = self._properties.get("hardware")
358
359                 if not hardware:
360                         return
361
362                 ethernet, address = hardware.split(" ", 1)
363
364                 return address
365
366         @property
367         def hostname(self):
368                 hostname = self._properties.get("client-hostname")
369
370                 if hostname is None:
371                         return
372
373                 # Remove any ""
374                 hostname = hostname.replace("\"", "")
375
376                 # Only return valid hostnames
377                 m = re.match(r"^[A-Z0-9\-]{1,63}$", hostname, re.I)
378                 if m:
379                         return hostname
380
381         @property
382         def domain(self):
383                 # Load ethernet settings
384                 ethernet_settings = self.read_settings("/var/ipfire/ethernet/settings")
385
386                 # Load DHCP settings
387                 dhcp_settings = self.read_settings("/var/ipfire/dhcp/settings")
388
389                 subnets = {}
390                 for zone in ("GREEN", "BLUE"):
391                         if not dhcp_settings.get("ENABLE_%s" % zone) == "on":
392                                 continue
393
394                         netaddr = ethernet_settings.get("%s_NETADDRESS" % zone)
395                         submask = ethernet_settings.get("%s_NETMASK" % zone)
396
397                         subnet = ipaddress.ip_network("%s/%s" % (netaddr, submask))
398                         domain = dhcp_settings.get("DOMAIN_NAME_%s" % zone)
399
400                         subnets[subnet] = domain
401
402                 address = ipaddress.ip_address(self.ipaddr)
403
404                 for subnet, domain in subnets.items():
405                         if address in subnet:
406                                 return domain
407
408                 # Fall back to localdomain if no match could be found
409                 return "localdomain"
410
411         @staticmethod
412         def read_settings(filename):
413                 settings = {}
414
415                 with open(filename) as f:
416                         for line in f.readlines():
417                                 # Remove line-breaks
418                                 line = line.rstrip()
419
420                                 k, v = line.split("=", 1)
421                                 settings[k] = v
422
423                 return settings
424
425         @property
426         def fqdn(self):
427                 if self.hostname:
428                         return "%s.%s" % (self.hostname, self.domain)
429
430         @staticmethod
431         def _parse_time(s):
432                 return datetime.datetime.strptime(s, "%w %Y/%m/%d %H:%M:%S")
433
434         @property
435         def time_starts(self):
436                 starts = self._properties.get("starts")
437
438                 if starts:
439                         return self._parse_time(starts)
440
441         @property
442         def time_ends(self):
443                 ends = self._properties.get("ends")
444
445                 if not ends or ends == "never":
446                         return
447
448                 return self._parse_time(ends)
449
450         @property
451         def expired(self):
452                 if not self.time_ends:
453                         return self.time_starts > datetime.datetime.utcnow()
454
455                 return self.time_starts > datetime.datetime.utcnow() > self.time_ends
456
457         @property
458         def rrset(self):
459                 # If the lease does not have a valid FQDN, we cannot create any RRs
460                 if self.fqdn is None:
461                         return []
462
463                 return [
464                         # Forward record
465                         (self.fqdn, "%s" % LOCAL_TTL, "IN A", self.ipaddr),
466
467                         # Reverse record
468                         (ip_address_to_reverse_pointer(self.ipaddr), "%s" % LOCAL_TTL,
469                                 "IN PTR", self.fqdn),
470                 ]
471
472
473 class UnboundConfigWriter(object):
474         def __init__(self, path):
475                 self.path = path
476
477                 self._cached_leases = []
478
479         def update_dhcp_leases(self, leases):
480                 # Find any leases that have expired or do not exist any more
481                 # but are still in the unbound local data
482                 removed_leases = [l for l in self._cached_leases if not l in leases]
483
484                 # Find any leases that have been added
485                 new_leases = [l for l in leases if l not in self._cached_leases]
486
487                 # End here if nothing has changed
488                 if not new_leases and not removed_leases:
489                         return
490
491                 # Write out all leases
492                 self.write_dhcp_leases(leases)
493
494                 # Update unbound about changes
495                 for l in removed_leases:
496                         try:
497                                 for name, ttl, type, content in l.rrset:
498                                         log.debug("Removing records for %s" % name)
499                                         self._control("local_data_remove", name)
500
501                         # If the lease cannot be removed we will try the next one
502                         except:
503                                 continue
504
505                         # If the removal was successful, we will remove it from the cache
506                         else:
507                                 self._cached_leases.remove(l)
508
509                 for l in new_leases:
510                         try:
511                                 for rr in l.rrset:
512                                         log.debug("Adding new record %s" % " ".join(rr))
513                                         self._control("local_data", *rr)
514
515                         # If the lease cannot be added we will try the next one
516                         except:
517                                 continue
518
519                         # Add lease to cache when successfully added
520                         else:
521                                 self._cached_leases.append(l)
522
523         def write_dhcp_leases(self, leases):
524                 with tempfile.NamedTemporaryFile(mode="w", delete=False) as f:
525                         filename = f.name
526
527                         for l in leases:
528                                 for rr in l.rrset:
529                                         f.write("local-data: \"%s\"\n" % " ".join(rr))
530
531                 os.rename(filename, self.path)
532
533         def _control(self, *args):
534                 command = ["unbound-control"]
535                 command.extend(args)
536
537                 try:
538                         subprocess.check_output(command)
539
540                 # Log any errors
541                 except subprocess.CalledProcessError as e:
542                         log.critical("Could not run %s, error code: %s: %s" % (
543                                 " ".join(command), e.returncode, e.output))
544
545                         raise
546
547
548 if __name__ == "__main__":
549         parser = argparse.ArgumentParser(description="Bridge for DHCP Leases and Unbound DNS")
550
551         # Daemon Stuff
552         parser.add_argument("--daemon", "-d", action="store_true",
553                 help="Launch as daemon in background")
554         parser.add_argument("--verbose", "-v", action="count", help="Be more verbose")
555
556         # Paths
557         parser.add_argument("--dhcp-leases", default="/var/state/dhcp/dhcpd.leases",
558                 metavar="PATH", help="Path to the DHCPd leases file")
559         parser.add_argument("--unbound-leases", default="/etc/unbound/dhcp-leases.conf",
560                 metavar="PATH", help="Path to the unbound configuration file")
561         parser.add_argument("--fix-leases", default="/var/ipfire/dhcp/fixleases",
562                 metavar="PATH", help="Path to the fix leases file")
563         parser.add_argument("--hosts", default="/var/ipfire/main/hosts",
564                 metavar="PATH", help="Path to static hosts file")
565
566         # Parse command line arguments
567         args = parser.parse_args()
568
569         # Setup logging
570         if args.verbose == 1:
571                 loglevel = logging.INFO
572         elif args.verbose >= 2:
573                 loglevel = logging.DEBUG
574         else:
575                 loglevel = logging.WARN
576
577         setup_logging(loglevel)
578
579         bridge = UnboundDHCPLeasesBridge(args.dhcp_leases, args.fix_leases,
580                 args.unbound_leases, args.hosts)
581
582         ctx = daemon.DaemonContext(detach_process=args.daemon)
583         ctx.signal_map = {
584                 signal.SIGHUP  : bridge.update_dhcp_leases,
585                 signal.SIGTERM : bridge.terminate,
586         }
587
588         with ctx:
589                 bridge.run()