]>
git.ipfire.org Git - ipfire-2.x.git/blob - config/unbound/unbound-dhcp-leases-bridge
4a6f9587f84f8e78a633df9cc2a53a5e8fc01db9
2 ###############################################################################
4 # IPFire.org - A linux based firewall #
5 # Copyright (C) 2016 Michael Tremer #
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. #
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. #
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/>. #
20 ###############################################################################
29 import logging
.handlers
43 log
= logging
.getLogger("dhcp")
44 log
.setLevel(logging
.DEBUG
)
46 def setup_logging(daemon
=True, loglevel
=logging
.INFO
):
47 log
.setLevel(loglevel
)
49 # Log to syslog by default
50 handler
= logging
.handlers
.SysLogHandler(address
="/dev/log", facility
="daemon")
51 log
.addHandler(handler
)
54 formatter
= logging
.Formatter("%(name)s[%(process)d]: %(message)s")
55 handler
.setFormatter(formatter
)
57 handler
.setLevel(loglevel
)
59 # If we are running in foreground, we should write everything to the console, too
61 handler
= logging
.StreamHandler()
62 log
.addHandler(handler
)
64 handler
.setLevel(loglevel
)
68 class UnboundDHCPLeasesBridge(object):
69 def __init__(self
, dhcp_leases_file
, fix_leases_file
, unbound_leases_file
, hosts_file
, socket_path
):
70 self
.leases_file
= dhcp_leases_file
71 self
.fix_leases_file
= fix_leases_file
72 self
.hosts_file
= hosts_file
73 self
.socket_path
= socket_path
77 # Store all known leases
80 # Create a queue for all received events
81 self
.queue
= queue
.Queue()
83 # Initialize the worker
84 self
.worker
= Worker(self
.queue
, callback
=self
._handle
_message
)
86 # Initialize the watcher
87 self
.watcher
= Watcher(reload=self
.reload)
89 self
.unbound
= UnboundConfigWriter(unbound_leases_file
)
92 log
.info("Unbound DHCP Leases Bridge started on %s" % self
.leases_file
)
100 # Open the server socket
101 self
.socket
= self
._open
_socket
(self
.socket_path
)
104 # Accept any incoming connections
106 conn
, peer
= self
.socket
.accept()
111 # Receive what the client is sending
112 data
, ancillary_data
, flags
, address
= conn
.recvmsg(4096)
114 # Log that we have received some data
115 log
.debug("Received message of %s byte(s)" % len(data
))
118 message
= self
._decode
_message
(data
)
120 # Add the message to the queue
121 self
.queue
.put(message
)
125 # Send ERROR to the client if something went wrong
126 except Exception as e
:
127 log
.error("Could not handle message: %s" % e
)
129 conn
.send(b
"ERROR\n")
132 # Close the connection
136 # Terminate the worker
139 # Terminate the watcher
140 self
.watcher
.terminate()
142 # Wait for the worker and watcher to finish
146 log
.info("Unbound DHCP Leases Bridge terminated")
148 def _open_socket(self
, path
):
149 # Allocate a new socket
150 s
= socket
.socket(family
=socket
.AF_UNIX
, type=socket
.SOCK_STREAM
)
152 # Unlink any old sockets
155 except FileNotFoundError
as e
:
160 s
.bind(self
.socket_path
)
162 log
.error("Could not open socket at %s: %s" % (path
, e
))
164 raise SystemExit(1) from e
171 def _decode_message(self
, data
):
174 for line
in data
.splitlines():
179 # Try to decode the line
182 except UnicodeError as e
:
183 log
.error("Could not decode %r: %s" % (line
, e
))
188 key
, _
, value
= line
.partition("=")
190 # Skip the line if it does not have a value
192 raise ValueError("No value given")
194 # Store the attributes
199 def _handle_message(self
, message
):
200 log
.debug("Handling message:")
202 log
.debug(" %-20s = %s" % (key
, message
[key
]))
204 # Extract the event type
205 event
= message
.get("EVENT")
207 # Check if event is set
209 raise ValueError("The message does not have EVENT set")
212 elif event
== "commit":
213 address
= message
.get("ADDRESS")
214 name
= message
.get("NAME")
217 old_lease
= self
._find
_lease
(address
)
219 # Don't update fixed leases as they might clear the hostname
220 if old_lease
and old_lease
.fixed
:
221 log
.debug("Won't update fixed lease %s" % old_lease
)
225 lease
= Lease(address
, {
226 "client-hostname" : name
,
228 self
._add
_lease
(lease
)
230 # Can we skip the update?
232 if lease
.rrset
== old_lease
.rrset
:
233 log
.debug("Won't update %s as nothing has changed" % lease
)
236 # Remove the old lease first
237 self
.unbound
.remove_lease(old_lease
)
238 self
._remove
_lease
(old_lease
)
241 self
.unbound
.apply_lease(lease
)
244 elif event
in ("release", "expiry"):
245 address
= message
.get("ADDRESS")
248 lease
= self
._find
_lease
(address
)
251 log
.warning("Could not find lease for %s" % address
)
255 self
.unbound
.remove_lease(lease
)
256 self
._remove
_lease
(lease
)
258 # Raise an error if the event is not supported
260 raise ValueError("Unsupported event: %s" % event
)
262 def update_dhcp_leases(self
):
263 # Drop all known leases
266 # Add all dynamic leases
267 for lease
in DHCPLeases(self
.leases_file
):
268 self
._add
_lease
(lease
)
270 # Add all static leases
271 for lease
in FixLeases(self
.fix_leases_file
):
272 self
._add
_lease
(lease
)
276 log
.debug("DHCP Leases:")
277 for lease
in self
.leases
:
278 log
.debug(" %s:" % lease
.fqdn
)
279 log
.debug(" Start: %s" % lease
.time_starts
)
280 log
.debug(" End : %s" % lease
.time_ends
)
281 if lease
.has_expired():
282 log
.debug(" Expired")
284 self
.unbound
.update_dhcp_leases([l
for l
in self
.leases
if not l
.has_expired()])
286 def _add_lease(self
, lease
):
287 # Skip leases without a FQDN
289 log
.debug("Skipping lease without a FQDN: %s" % lease
)
292 # Skip any leases that also are a static host
293 elif lease
.fqdn
in self
.hosts
:
294 log
.debug("Skipping lease for which a static host exists: %s" % lease
)
297 # Don't add expired leases
298 elif lease
.has_expired():
299 log
.debug("Skipping expired lease: %s" % lease
)
302 # Remove any previous leases
303 self
._remove
_lease
(lease
)
306 self
.leases
.add(lease
)
308 def _find_lease(self
, ipaddr
):
310 Returns the lease with the specified IP address
312 if not isinstance(ipaddr
, ipaddress
.IPv4Address
):
313 ipaddr
= ipaddress
.IPv4Address(ipaddr
)
315 for lease
in self
.leases
:
316 if lease
.ipaddr
== ipaddr
:
319 def _remove_lease(self
, lease
):
321 self
.leases
.remove(lease
)
325 def read_static_hosts(self
):
326 log
.info("Reading static hosts from %s" % self
.hosts_file
)
329 with open(self
.hosts_file
) as f
:
330 for line
in f
.readlines():
334 enabled
, ipaddr
, hostname
, domainname
, generateptr
= line
.split(",")
336 log
.warning("Could not parse line: %s" % line
)
339 # Skip any disabled entries
340 if not enabled
== "on":
343 if hostname
and domainname
:
344 fqdn
= "%s.%s" % (hostname
, domainname
)
351 hosts
[fqdn
].append(ipaddr
)
354 hosts
[fqdn
] = [ipaddr
,]
356 # Dump everything in the logs
357 log
.debug("Static hosts:")
359 log
.debug(" %-20s : %s" % (name
, ", ".join(hosts
[name
])))
363 def reload(self
, *args
, **kwargs
):
364 # Read all static hosts
365 self
.hosts
= self
.read_static_hosts()
367 # Unconditionally update all leases and reload Unbound
368 self
.update_dhcp_leases()
370 def terminate(self
, *args
, **kwargs
):
376 class Watcher(threading
.Thread
):
378 Watches if Unbound is still running.
380 def __init__(self
, reload, *args
, **kwargs
):
381 super().__init
__(*args
, **kwargs
)
385 # Set to true if this thread should be terminated
386 self
._terminated
= threading
.Event()
389 log
.debug("Watcher launched")
394 # One iteration takes 30 seconds unless we don't know the process
395 # when we try to find it once a second.
396 if self
._terminated
.wait(30 if pidfd
else 1):
399 # Fetch a PIDFD for Unbound
401 pidfd
= self
._get
_pidfd
()
403 # If we could not acquire a PIDFD, we will try again soon...
405 log
.warning("Cannot find Unbound...")
408 # Since Unbound has been restarted, we need to reload it all...
411 log
.debug("Checking if Unbound is still alive...")
413 # Send the process a signal
415 signal
.pidfd_send_signal(pidfd
, signal
.SIG_DFL
)
417 # If the process has died, we land here and will have to wait until Unbound
418 # has come back and reload it...
419 except ProcessLookupError
as e
:
420 log
.error("Unbound has died")
426 log
.debug("Unbound is alive")
428 log
.debug("Watcher terminated")
432 Called to signal this thread to terminate
434 self
._terminated
.set()
436 def _get_pidfd(self
):
438 Returns a PIDFD for unbound if it is running, otherwise None.
440 # Try to find the PID
441 pid
= pidof("unbound")
444 log
.debug("Unbound is running as PID %s" % pid
)
447 pidfd
= os
.pidfd_open(pid
)
449 log
.debug("Acquired PIDFD %s for PID %s" % (pidfd
, pid
))
454 class Worker(threading
.Thread
):
456 The worker is launched in a separate thread
457 which allows us to perform some tasks asynchronously.
459 def __init__(self
, queue
, callback
):
463 self
.callback
= callback
466 log
.debug("Worker %s launched" % self
.native_id
)
469 message
= self
.queue
.get()
471 # If the message is None, we have to quit
477 self
.callback(message
)
478 except Exception as e
:
479 log
.error("Callback failed: %s" % e
, exc_info
=True)
481 log
.debug("Worker %s terminated" % self
.native_id
)
484 class DHCPLeases(object):
485 regex_leaseblock
= re
.compile(r
"lease (?P<ipaddr>\d+\.\d+\.\d+\.\d+) {(?P<config>[\s\S]+?)\n}")
487 def __init__(self
, path
):
490 self
._leases
= self
._parse
()
493 return iter(self
._leases
)
496 log
.info("Reading DHCP leases from %s" % self
.path
)
500 with open(self
.path
) as f
:
501 # Read entire leases file
504 for match
in self
.regex_leaseblock
.finditer(data
):
505 block
= match
.groupdict()
507 ipaddr
= block
.get("ipaddr")
508 config
= block
.get("config")
510 properties
= self
._parse
_block
(config
)
512 # Skip any abandoned leases
513 if not "hardware" in properties
:
516 # Skip inactive leases
517 elif not properties
.get("binding", "state active"):
520 lease
= Lease(ipaddr
, properties
)
525 def _parse_block(self
, block
):
528 for line
in block
.splitlines():
532 # Remove trailing ; from line
533 if line
.endswith(";"):
536 # Invalid line if it doesn't end with ;
540 # Remove any leading whitespace
543 # We skip all options and sets
544 if line
.startswith("option") or line
.startswith("set"):
547 # Split by first space
548 key
, val
= line
.split(" ", 1)
549 properties
[key
] = val
554 class FixLeases(object):
555 def __init__(self
, path
):
558 self
._leases
= self
._parse
()
561 return iter(self
._leases
)
564 log
.info("Reading fix leases from %s" % self
.path
)
566 now
= datetime
.datetime
.utcnow()
570 with open(self
.path
) as f
:
571 for line
in f
.readlines():
575 hwaddr
, ipaddr
, enabled
, a
, b
, c
, hostname
= line
.split(",")
577 log
.warning("Could not parse line: %s" % line
)
580 # Skip any disabled leases
581 if not enabled
== "on":
585 "binding" : "state active",
586 "client-hostname" : hostname
,
587 "starts" : now
.strftime("%w %Y/%m/%d %H:%M:%S"),
596 def __init__(self
, ipaddr
, properties
, fixed
=False):
597 if not isinstance(ipaddr
, ipaddress
.IPv4Address
):
598 ipaddr
= ipaddress
.IPv4Address(ipaddr
)
601 self
._properties
= properties
605 return "<%s for %s (%s)>" % (self
.__class
__.__name
__, self
.ipaddr
, self
.hostname
)
607 def __eq__(self
, other
):
608 if isinstance(other
, self
.__class
__):
609 return self
.ipaddr
== other
.ipaddr
611 return NotImplemented
613 def __gt__(self
, other
):
614 if isinstance(other
, self
.__class
__):
615 if not self
.ipaddr
== other
.ipaddr
:
616 return NotImplemented
618 return self
.time_starts
> other
.time_starts
620 return NotImplemented
623 return hash(self
.ipaddr
)
627 hostname
= self
._properties
.get("client-hostname")
633 hostname
= hostname
.replace("\"", "")
635 # Only return valid hostnames
636 m
= re
.match(r
"^[A-Z0-9\-]{1,63}$", hostname
, re
.I
)
642 # Load ethernet settings
643 ethernet_settings
= self
.read_settings("/var/ipfire/ethernet/settings")
646 dhcp_settings
= self
.read_settings("/var/ipfire/dhcp/settings")
649 for zone
in ("GREEN", "BLUE"):
650 if not dhcp_settings
.get("ENABLE_%s" % zone
) == "on":
653 netaddr
= ethernet_settings
.get("%s_NETADDRESS" % zone
)
654 submask
= ethernet_settings
.get("%s_NETMASK" % zone
)
656 subnet
= ipaddress
.ip_network("%s/%s" % (netaddr
, submask
))
657 domain
= dhcp_settings
.get("DOMAIN_NAME_%s" % zone
)
659 subnets
[subnet
] = domain
661 address
= ipaddress
.ip_address(self
.ipaddr
)
663 for subnet
in subnets
:
664 if address
in subnet
:
665 return subnets
[subnet
]
668 settings
= self
.read_settings("/var/ipfire/main/settings")
670 # Fall back to the host domain if no match could be found
671 return settings
.get("DOMAINNAME", "localdomain")
675 def read_settings(filename
):
678 with open(filename
) as f
:
679 for line
in f
.readlines():
683 k
, v
= line
.split("=", 1)
691 return "%s.%s" % (self
.hostname
, self
.domain
)
695 return datetime
.datetime
.strptime(s
, "%w %Y/%m/%d %H:%M:%S")
698 def time_starts(self
):
699 starts
= self
._properties
.get("starts")
702 return self
._parse
_time
(starts
)
706 ends
= self
._properties
.get("ends")
708 if not ends
or ends
== "never":
711 return self
._parse
_time
(ends
)
713 def has_expired(self
):
714 if not self
.time_starts
:
717 if not self
.time_ends
:
718 return self
.time_starts
> datetime
.datetime
.utcnow()
720 return not self
.time_starts
< datetime
.datetime
.utcnow() < self
.time_ends
724 # If the lease does not have a valid FQDN, we cannot create any RRs
725 if self
.fqdn
is None:
730 (self
.fqdn
, "%s" % LOCAL_TTL
, "IN A", "%s" % self
.ipaddr
),
733 (self
.ipaddr
.reverse_pointer
, "%s" % LOCAL_TTL
,
734 "IN PTR", self
.fqdn
),
738 class UnboundConfigWriter(object):
739 def __init__(self
, path
):
742 def update_dhcp_leases(self
, leases
):
743 # Write out all leases
744 if self
.write_dhcp_leases(leases
):
745 log
.debug("Reloading Unbound...")
747 # Reload the configuration without dropping the cache
748 self
._control
("reload_keep_cache")
750 def write_dhcp_leases(self
, leases
):
751 log
.debug("Writing DHCP leases...")
753 with tempfile
.NamedTemporaryFile(mode
="w") as f
:
754 for l
in sorted(leases
, key
=lambda x
: x
.ipaddr
):
756 f
.write("local-data: \"%s\"\n" % " ".join(rr
))
761 # Compare if the new leases file has changed from the previous version
763 if filecmp
.cmp(f
.name
, self
.path
, shallow
=False):
764 log
.debug("The generated leases file has not changed")
768 # Remove the old file
771 # If the previous file did not exist, just keep falling through
772 except FileNotFoundError
:
775 # Make file readable for everyone
776 os
.fchmod(f
.fileno(), stat
.S_IRUSR|stat
.S_IWUSR|stat
.S_IRGRP|stat
.S_IROTH
)
778 # Move the file to its destination
779 os
.link(f
.name
, self
.path
)
783 def _control(self
, *args
):
784 command
= ["unbound-control"]
787 # Log what we are doing
788 log
.debug("Running %s" % " ".join(command
))
791 subprocess
.check_output(command
)
794 except subprocess
.CalledProcessError
as e
:
795 log
.critical("Could not run %s, error code: %s: %s" % (
796 " ".join(command
), e
.returncode
, e
.output
))
800 def apply_lease(self
, lease
):
802 This method takes a lease and updates Unbound at runtime.
804 log
.debug("Applying lease %s" % lease
)
806 for rr
in lease
.rrset
:
807 log
.debug("Adding new record %s" % " ".join(rr
))
809 self
._control
("local_data", *rr
)
811 def remove_lease(self
, lease
):
813 This method takes a lease and removes it from Unbound at runtime.
815 log
.debug("Removing lease %s" % lease
)
817 for name
, ttl
, type, content
in lease
.rrset
:
818 log
.debug("Removing records for %s" % name
)
820 self
._control
("local_data_remove", name
)
825 Returns the first PID of the given program.
828 output
= subprocess
.check_output(["pidof", program
])
829 except subprocess
.CalledProcessError
as e
:
833 output
= output
.decode()
835 # Return the first PID
836 for pid
in output
.split():
845 if __name__
== "__main__":
846 parser
= argparse
.ArgumentParser(description
="Bridge for DHCP Leases and Unbound DNS")
849 parser
.add_argument("--daemon", "-d", action
="store_true",
850 help="Launch as daemon in background")
851 parser
.add_argument("--verbose", "-v", action
="count", help="Be more verbose")
854 parser
.add_argument("--dhcp-leases", default
="/var/state/dhcp/dhcpd.leases",
855 metavar
="PATH", help="Path to the DHCPd leases file")
856 parser
.add_argument("--unbound-leases", default
="/etc/unbound/dhcp-leases.conf",
857 metavar
="PATH", help="Path to the unbound configuration file")
858 parser
.add_argument("--fix-leases", default
="/var/ipfire/dhcp/fixleases",
859 metavar
="PATH", help="Path to the fix leases file")
860 parser
.add_argument("--hosts", default
="/var/ipfire/main/hosts",
861 metavar
="PATH", help="Path to static hosts file")
862 parser
.add_argument("--socket-path", default
="/var/run/unbound-dhcp-leases-bridge.sock",
863 metavar
="PATH", help="Socket Path",
866 # Parse command line arguments
867 args
= parser
.parse_args()
870 loglevel
= logging
.WARN
873 if args
.verbose
== 1:
874 loglevel
= logging
.INFO
875 elif args
.verbose
>= 2:
876 loglevel
= logging
.DEBUG
878 bridge
= UnboundDHCPLeasesBridge(args
.dhcp_leases
, args
.fix_leases
,
879 args
.unbound_leases
, args
.hosts
, socket_path
=args
.socket_path
)
881 with daemon
.DaemonContext(
882 detach_process
=args
.daemon
,
883 stderr
=None if args
.daemon
else sys
.stderr
,
885 signal
.SIGHUP
: bridge
.reload,
886 signal
.SIGINT
: bridge
.terminate
,
887 signal
.SIGTERM
: bridge
.terminate
,
890 setup_logging(daemon
=args
.daemon
, loglevel
=loglevel
)