]>
git.ipfire.org Git - ipfire-2.x.git/blob - config/unbound/unbound-dhcp-leases-bridge
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 ###############################################################################
28 import logging
.handlers
38 import inotify
.adapters
42 log
= logging
.getLogger("dhcp")
43 log
.setLevel(logging
.DEBUG
)
45 def setup_logging(daemon
=True, loglevel
=logging
.INFO
):
46 log
.setLevel(loglevel
)
48 # Log to syslog by default
49 handler
= logging
.handlers
.SysLogHandler(address
="/dev/log", facility
="daemon")
50 log
.addHandler(handler
)
53 formatter
= logging
.Formatter("%(name)s[%(process)d]: %(message)s")
54 handler
.setFormatter(formatter
)
56 handler
.setLevel(loglevel
)
58 # If we are running in foreground, we should write everything to the console, too
60 handler
= logging
.StreamHandler()
61 log
.addHandler(handler
)
63 handler
.setLevel(loglevel
)
67 def ip_address_to_reverse_pointer(address
):
68 parts
= address
.split(".")
71 return "%s.in-addr.arpa" % ".".join(parts
)
73 def reverse_pointer_to_ip_address(rr
):
76 # Only take IP address part
77 parts
= reversed(parts
[0:4])
79 return ".".join(parts
)
81 class UnboundDHCPLeasesBridge(object):
82 def __init__(self
, dhcp_leases_file
, fix_leases_file
, unbound_leases_file
, hosts_file
):
83 self
.leases_file
= dhcp_leases_file
84 self
.fix_leases_file
= fix_leases_file
85 self
.hosts_file
= hosts_file
88 self
.leases_file
: inotify
.constants
.IN_MODIFY
,
89 self
.fix_leases_file
: 0,
93 self
.unbound
= UnboundConfigWriter(unbound_leases_file
)
97 log
.info("Unbound DHCP Leases Bridge started on %s" % self
.leases_file
)
100 i
= inotify
.adapters
.Inotify()
102 # Add watches for the directories of every relevant file
103 for f
, mask
in self
.watches
.items():
106 mask | inotify
.constants
.IN_CLOSE_WRITE | inotify
.constants
.IN_MOVED_TO
,
109 # Enabled so that we update hosts and leases on startup
110 update_hosts
= update_leases
= True
113 log
.debug("Wakeup of main loop")
115 # Process the entire inotify queue and identify what we need to do
116 for event
in i
.event_gen():
122 header
, type_names
, path
, filename
= event
124 file = os
.path
.join(path
, filename
)
126 log
.debug("inotify event received for %s: %s", file, " ".join(type_names
))
128 # Did the hosts file change?
129 if self
.hosts_file
== file:
132 # We will need to update the leases on any change
135 # Update hosts (if needed)
137 self
.hosts
= self
.read_static_hosts()
139 # Update leases (if needed)
141 self
.update_dhcp_leases()
144 update_hosts
= update_leases
= False
146 # Wait a moment before we start the next iteration
149 log
.info("Unbound DHCP Leases Bridge terminated")
151 def update_dhcp_leases(self
):
154 for lease
in DHCPLeases(self
.leases_file
):
155 # Don't bother with any leases that don't have a hostname
161 for lease
in FixLeases(self
.fix_leases_file
):
164 # Skip any leases that also are a static host
165 leases
= [l
for l
in leases
if not l
.fqdn
in self
.hosts
]
167 # Remove any inactive or expired leases
168 leases
= [l
for l
in leases
if l
.active
and not l
.expired
]
172 log
.debug("DHCP Leases:")
174 log
.debug(" %s:" % lease
.fqdn
)
175 log
.debug(" State: %s" % lease
.binding_state
)
176 log
.debug(" Start: %s" % lease
.time_starts
)
177 log
.debug(" End : %s" % lease
.time_ends
)
179 log
.debug(" Expired")
181 self
.unbound
.update_dhcp_leases(leases
)
183 def read_static_hosts(self
):
184 log
.info("Reading static hosts from %s" % self
.hosts_file
)
187 with
open(self
.hosts_file
) as f
:
188 for line
in f
.readlines():
192 enabled
, ipaddr
, hostname
, domainname
, generateptr
= line
.split(",")
194 log
.warning("Could not parse line: %s" % line
)
197 # Skip any disabled entries
198 if not enabled
== "on":
201 if hostname
and domainname
:
202 fqdn
= "%s.%s" % (hostname
, domainname
)
209 hosts
[fqdn
].append(ipaddr
)
212 hosts
[fqdn
] = [ipaddr
,]
214 # Dump everything in the logs
215 log
.debug("Static hosts:")
217 log
.debug(" %-20s : %s" % (name
, ", ".join(hosts
[name
])))
225 class DHCPLeases(object):
226 regex_leaseblock
= re
.compile(r
"lease (?P<ipaddr>\d+\.\d+\.\d+\.\d+) {(?P<config>[\s\S]+?)\n}")
228 def __init__(self
, path
):
231 self
._leases
= self
._parse
()
234 return iter(self
._leases
)
237 log
.info("Reading DHCP leases from %s" % self
.path
)
241 with
open(self
.path
) as f
:
242 # Read entire leases file
245 for match
in self
.regex_leaseblock
.finditer(data
):
246 block
= match
.groupdict()
248 ipaddr
= block
.get("ipaddr")
249 config
= block
.get("config")
251 properties
= self
._parse
_block
(config
)
253 # Skip any abandoned leases
254 if not "hardware" in properties
:
257 lease
= Lease(ipaddr
, properties
)
259 # Check if a lease for this Ethernet address already
260 # exists in the list of known leases. If so replace
261 # if with the most recent lease
262 for i
, l
in enumerate(leases
):
263 if l
.ipaddr
== lease
.ipaddr
:
264 leases
[i
] = max(lease
, l
)
272 def _parse_block(self
, block
):
275 for line
in block
.splitlines():
279 # Remove trailing ; from line
280 if line
.endswith(";"):
283 # Invalid line if it doesn't end with ;
287 # Remove any leading whitespace
290 # We skip all options and sets
291 if line
.startswith("option") or line
.startswith("set"):
294 # Split by first space
295 key
, val
= line
.split(" ", 1)
296 properties
[key
] = val
301 class FixLeases(object):
304 def __init__(self
, path
):
307 self
._leases
= self
.cache
[self
.path
] = self
._parse
()
310 return iter(self
._leases
)
313 log
.info("Reading fix leases from %s" % self
.path
)
316 now
= datetime
.datetime
.utcnow()
318 with
open(self
.path
) as f
:
319 for line
in f
.readlines():
323 hwaddr
, ipaddr
, enabled
, a
, b
, c
, hostname
= line
.split(",")
325 log
.warning("Could not parse line: %s" % line
)
328 # Skip any disabled leases
329 if not enabled
== "on":
333 "binding" : "state active",
334 "client-hostname" : hostname
,
335 "hardware" : "ethernet %s" % hwaddr
,
336 "starts" : now
.strftime("%w %Y/%m/%d %H:%M:%S"),
341 # Try finding any deleted leases
342 for lease
in self
.cache
.get(self
.path
, []):
346 # Free the deleted lease
354 def __init__(self
, ipaddr
, properties
):
356 self
._properties
= properties
359 return "<%s %s for %s (%s)>" % (self
.__class
__.__name
__,
360 self
.ipaddr
, self
.hwaddr
, self
.hostname
)
362 def __eq__(self
, other
):
363 return self
.ipaddr
== other
.ipaddr
and self
.hwaddr
== other
.hwaddr
365 def __gt__(self
, other
):
366 if not self
.ipaddr
== other
.ipaddr
:
369 if not self
.hwaddr
== other
.hwaddr
:
372 return self
.time_starts
> other
.time_starts
375 def binding_state(self
):
376 state
= self
._properties
.get("binding")
379 state
= state
.split(" ", 1)
383 self
._properties
.update({
384 "binding" : "state free",
389 return self
.binding_state
== "active"
393 hardware
= self
._properties
.get("hardware")
398 ethernet
, address
= hardware
.split(" ", 1)
404 hostname
= self
._properties
.get("client-hostname")
410 hostname
= hostname
.replace("\"", "")
412 # Only return valid hostnames
413 m
= re
.match(r
"^[A-Z0-9\-]{1,63}$", hostname
, re
.I
)
419 # Load ethernet settings
420 ethernet_settings
= self
.read_settings("/var/ipfire/ethernet/settings")
423 dhcp_settings
= self
.read_settings("/var/ipfire/dhcp/settings")
426 for zone
in ("GREEN", "BLUE"):
427 if not dhcp_settings
.get("ENABLE_%s" % zone
) == "on":
430 netaddr
= ethernet_settings
.get("%s_NETADDRESS" % zone
)
431 submask
= ethernet_settings
.get("%s_NETMASK" % zone
)
433 subnet
= ipaddress
.ip_network("%s/%s" % (netaddr
, submask
))
434 domain
= dhcp_settings
.get("DOMAIN_NAME_%s" % zone
)
436 subnets
[subnet
] = domain
438 address
= ipaddress
.ip_address(self
.ipaddr
)
440 for subnet
in subnets
:
441 if address
in subnet
:
442 return subnets
[subnet
]
445 settings
= self
.read_settings("/var/ipfire/main/settings")
447 # Fall back to the host domain if no match could be found
448 return settings
.get("DOMAINNAME", "localdomain")
452 def read_settings(filename
):
455 with
open(filename
) as f
:
456 for line
in f
.readlines():
460 k
, v
= line
.split("=", 1)
468 return "%s.%s" % (self
.hostname
, self
.domain
)
472 return datetime
.datetime
.strptime(s
, "%w %Y/%m/%d %H:%M:%S")
475 def time_starts(self
):
476 starts
= self
._properties
.get("starts")
479 return self
._parse
_time
(starts
)
483 ends
= self
._properties
.get("ends")
485 if not ends
or ends
== "never":
488 return self
._parse
_time
(ends
)
492 if not self
.time_ends
:
493 return self
.time_starts
> datetime
.datetime
.utcnow()
495 return self
.time_starts
> datetime
.datetime
.utcnow() > self
.time_ends
499 # If the lease does not have a valid FQDN, we cannot create any RRs
500 if self
.fqdn
is None:
505 (self
.fqdn
, "%s" % LOCAL_TTL
, "IN A", self
.ipaddr
),
508 (ip_address_to_reverse_pointer(self
.ipaddr
), "%s" % LOCAL_TTL
,
509 "IN PTR", self
.fqdn
),
513 class UnboundConfigWriter(object):
514 def __init__(self
, path
):
517 def update_dhcp_leases(self
, leases
):
518 # Write out all leases
519 self
.write_dhcp_leases(leases
)
521 log
.debug("Reloading Unbound...")
523 # Reload the configuration without dropping the cache
524 self
._control
("reload_keep_cache")
526 def write_dhcp_leases(self
, leases
):
527 log
.debug("Writing DHCP leases...")
529 with tempfile
.NamedTemporaryFile(mode
="w", delete
=False) as f
:
532 f
.write("local-data: \"%s\"\n" % " ".join(rr
))
534 # Make file readable for everyone
535 os
.fchmod(f
.fileno(), stat
.S_IRUSR|stat
.S_IWUSR|stat
.S_IRGRP|stat
.S_IROTH
)
537 # Move the file to its destination
538 os
.rename(f
.name
, self
.path
)
540 def _control(self
, *args
):
541 command
= ["unbound-control"]
545 subprocess
.check_output(command
)
548 except subprocess
.CalledProcessError
as e
:
549 log
.critical("Could not run %s, error code: %s: %s" % (
550 " ".join(command
), e
.returncode
, e
.output
))
555 if __name__
== "__main__":
556 parser
= argparse
.ArgumentParser(description
="Bridge for DHCP Leases and Unbound DNS")
559 parser
.add_argument("--daemon", "-d", action
="store_true",
560 help="Launch as daemon in background")
561 parser
.add_argument("--verbose", "-v", action
="count", help="Be more verbose")
564 parser
.add_argument("--dhcp-leases", default
="/var/state/dhcp/dhcpd.leases",
565 metavar
="PATH", help="Path to the DHCPd leases file")
566 parser
.add_argument("--unbound-leases", default
="/etc/unbound/dhcp-leases.conf",
567 metavar
="PATH", help="Path to the unbound configuration file")
568 parser
.add_argument("--fix-leases", default
="/var/ipfire/dhcp/fixleases",
569 metavar
="PATH", help="Path to the fix leases file")
570 parser
.add_argument("--hosts", default
="/var/ipfire/main/hosts",
571 metavar
="PATH", help="Path to static hosts file")
573 # Parse command line arguments
574 args
= parser
.parse_args()
577 loglevel
= logging
.WARN
580 if args
.verbose
== 1:
581 loglevel
= logging
.INFO
582 elif args
.verbose
>= 2:
583 loglevel
= logging
.DEBUG
585 bridge
= UnboundDHCPLeasesBridge(args
.dhcp_leases
, args
.fix_leases
,
586 args
.unbound_leases
, args
.hosts
)
588 with daemon
.DaemonContext(
589 detach_process
=args
.daemon
,
590 stderr
=None if args
.daemon
else sys
.stderr
,
592 signal
.SIGHUP
: bridge
.update_dhcp_leases
,
593 signal
.SIGTERM
: bridge
.terminate
,
596 setup_logging(daemon
=args
.daemon
, loglevel
=loglevel
)