]>
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 ###############################################################################
27 import logging
.handlers
34 import inotify
.adapters
38 def setup_logging(loglevel
=logging
.INFO
):
39 log
= logging
.getLogger("dhcp")
40 log
.setLevel(loglevel
)
42 handler
= logging
.handlers
.SysLogHandler(address
="/dev/log", facility
="daemon")
43 handler
.setLevel(loglevel
)
45 formatter
= logging
.Formatter("%(name)s[%(process)d]: %(message)s")
46 handler
.setFormatter(formatter
)
48 log
.addHandler(handler
)
52 log
= logging
.getLogger("dhcp")
54 def ip_address_to_reverse_pointer(address
):
55 parts
= address
.split(".")
58 return "%s.in-addr.arpa" % ".".join(parts
)
60 def reverse_pointer_to_ip_address(rr
):
63 # Only take IP address part
64 parts
= reversed(parts
[0:4])
66 return ".".join(parts
)
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
74 self
.unbound
= UnboundConfigWriter(unbound_leases_file
)
78 log
.info("Unbound DHCP Leases Bridge started on %s" % self
.leases_file
)
82 self
.hosts
= self
.read_static_hosts()
83 self
.update_dhcp_leases()
85 i
= inotify
.adapters
.Inotify([
91 for event
in i
.event_gen():
92 # End if we are requested to terminate
99 header
, type_names
, watch_path
, filename
= event
101 # Update leases after leases file has been modified
102 if "IN_MODIFY" in type_names
:
104 if watch_path
== self
.hosts_file
:
105 self
.hosts
= self
.read_static_hosts()
107 self
.update_dhcp_leases()
109 # If the file is deleted, we re-add the watcher
110 if "IN_IGNORED" in type_names
:
111 i
.add_watch(watch_path
)
113 log
.info("Unbound DHCP Leases Bridge terminated")
115 def update_dhcp_leases(self
):
118 for lease
in DHCPLeases(self
.leases_file
):
119 # Don't bother with any leases that don't have a hostname
125 for lease
in FixLeases(self
.fix_leases_file
):
128 # Skip any leases that also are a static host
129 leases
= [l
for l
in leases
if not l
.fqdn
in self
.hosts
]
131 # Remove any inactive or expired leases
132 leases
= [l
for l
in leases
if l
.active
and not l
.expired
]
136 log
.debug("DHCP 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
)
143 log
.debug(" Expired")
145 self
.unbound
.update_dhcp_leases(leases
)
147 def read_static_hosts(self
):
148 log
.info("Reading static hosts from %s" % self
.hosts_file
)
151 with
open(self
.hosts_file
) as f
:
152 for line
in f
.readlines():
156 enabled
, ipaddr
, hostname
, domainname
= line
.split(",")
158 log
.warning("Could not parse line: %s" % line
)
161 # Skip any disabled entries
162 if not enabled
== "on":
165 if hostname
and domainname
:
166 fqdn
= "%s.%s" % (hostname
, domainname
)
173 hosts
[fqdn
].append(ipaddr
)
176 hosts
[fqdn
] = [ipaddr
,]
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
)))
189 class DHCPLeases(object):
190 regex_leaseblock
= re
.compile(r
"lease (?P<ipaddr>\d+\.\d+\.\d+\.\d+) {(?P<config>[\s\S]+?)\n}")
192 def __init__(self
, path
):
195 self
._leases
= self
._parse
()
198 return iter(self
._leases
)
201 log
.info("Reading DHCP leases from %s" % self
.path
)
205 with
open(self
.path
) as f
:
206 # Read entire leases file
209 for match
in self
.regex_leaseblock
.finditer(data
):
210 block
= match
.groupdict()
212 ipaddr
= block
.get("ipaddr")
213 config
= block
.get("config")
215 properties
= self
._parse
_block
(config
)
217 # Skip any abandoned leases
218 if not "hardware" in properties
:
221 lease
= Lease(ipaddr
, properties
)
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
)
236 def _parse_block(self
, block
):
239 for line
in block
.splitlines():
243 # Remove trailing ; from line
244 if line
.endswith(";"):
247 # Invalid line if it doesn't end with ;
251 # Remove any leading whitespace
254 # We skip all options and sets
255 if line
.startswith("option") or line
.startswith("set"):
258 # Split by first space
259 key
, val
= line
.split(" ", 1)
260 properties
[key
] = val
265 class FixLeases(object):
268 def __init__(self
, path
):
271 self
._leases
= self
.cache
[self
.path
] = self
._parse
()
274 return iter(self
._leases
)
277 log
.info("Reading fix leases from %s" % self
.path
)
280 now
= datetime
.datetime
.utcnow()
282 with
open(self
.path
) as f
:
283 for line
in f
.readlines():
287 hwaddr
, ipaddr
, enabled
, a
, b
, c
, hostname
= line
.split(",")
289 log
.warning("Could not parse line: %s" % line
)
292 # Skip any disabled leases
293 if not enabled
== "on":
297 "binding" : "state active",
298 "client-hostname" : hostname
,
299 "hardware" : "ethernet %s" % hwaddr
,
300 "starts" : now
.strftime("%w %Y/%m/%d %H:%M:%S"),
305 # Try finding any deleted leases
306 for lease
in self
.cache
.get(self
.path
, []):
310 # Free the deleted lease
318 def __init__(self
, ipaddr
, properties
):
320 self
._properties
= properties
323 return "<%s %s for %s (%s)>" % (self
.__class
__.__name
__,
324 self
.ipaddr
, self
.hwaddr
, self
.hostname
)
326 def __eq__(self
, other
):
327 return self
.ipaddr
== other
.ipaddr
and self
.hwaddr
== other
.hwaddr
329 def __gt__(self
, other
):
330 if not self
.ipaddr
== other
.ipaddr
:
333 if not self
.hwaddr
== other
.hwaddr
:
336 return self
.time_starts
> other
.time_starts
339 def binding_state(self
):
340 state
= self
._properties
.get("binding")
343 state
= state
.split(" ", 1)
347 self
._properties
.update({
348 "binding" : "state free",
353 return self
.binding_state
== "active"
357 hardware
= self
._properties
.get("hardware")
362 ethernet
, address
= hardware
.split(" ", 1)
368 hostname
= self
._properties
.get("client-hostname")
374 hostname
= hostname
.replace("\"", "")
376 # Only return valid hostnames
377 m
= re
.match(r
"^[A-Z0-9\-]{1,63}$", hostname
, re
.I
)
383 # Load ethernet settings
384 ethernet_settings
= self
.read_settings("/var/ipfire/ethernet/settings")
387 dhcp_settings
= self
.read_settings("/var/ipfire/dhcp/settings")
390 for zone
in ("GREEN", "BLUE"):
391 if not dhcp_settings
.get("ENABLE_%s" % zone
) == "on":
394 netaddr
= ethernet_settings
.get("%s_NETADDRESS" % zone
)
395 submask
= ethernet_settings
.get("%s_NETMASK" % zone
)
397 subnet
= ipaddress
.ip_network("%s/%s" % (netaddr
, submask
))
398 domain
= dhcp_settings
.get("DOMAIN_NAME_%s" % zone
)
400 subnets
[subnet
] = domain
402 address
= ipaddress
.ip_address(self
.ipaddr
)
404 for subnet
, domain
in subnets
.items():
405 if address
in subnet
:
408 # Fall back to localdomain if no match could be found
412 def read_settings(filename
):
415 with
open(filename
) as f
:
416 for line
in f
.readlines():
420 k
, v
= line
.split("=", 1)
428 return "%s.%s" % (self
.hostname
, self
.domain
)
432 return datetime
.datetime
.strptime(s
, "%w %Y/%m/%d %H:%M:%S")
435 def time_starts(self
):
436 starts
= self
._properties
.get("starts")
439 return self
._parse
_time
(starts
)
443 ends
= self
._properties
.get("ends")
445 if not ends
or ends
== "never":
448 return self
._parse
_time
(ends
)
452 if not self
.time_ends
:
453 return self
.time_starts
> datetime
.datetime
.utcnow()
455 return self
.time_starts
> datetime
.datetime
.utcnow() > self
.time_ends
459 # If the lease does not have a valid FQDN, we cannot create any RRs
460 if self
.fqdn
is None:
465 (self
.fqdn
, "%s" % LOCAL_TTL
, "IN A", self
.ipaddr
),
468 (ip_address_to_reverse_pointer(self
.ipaddr
), "%s" % LOCAL_TTL
,
469 "IN PTR", self
.fqdn
),
473 class UnboundConfigWriter(object):
474 def __init__(self
, path
):
477 self
._cached
_leases
= []
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
]
484 # Find any leases that have been added
485 new_leases
= [l
for l
in leases
if l
not in self
._cached
_leases
]
487 # End here if nothing has changed
488 if not new_leases
and not removed_leases
:
491 # Write out all leases
492 self
.write_dhcp_leases(leases
)
494 # Update unbound about changes
495 for l
in removed_leases
:
497 for name
, ttl
, type, content
in l
.rrset
:
498 log
.debug("Removing records for %s" % name
)
499 self
._control
("local_data_remove", name
)
501 # If the lease cannot be removed we will try the next one
505 # If the removal was successful, we will remove it from the cache
507 self
._cached
_leases
.remove(l
)
512 log
.debug("Adding new record %s" % " ".join(rr
))
513 self
._control
("local_data", *rr
)
515 # If the lease cannot be added we will try the next one
519 # Add lease to cache when successfully added
521 self
._cached
_leases
.append(l
)
523 def write_dhcp_leases(self
, leases
):
524 with tempfile
.NamedTemporaryFile(mode
="w", delete
=False) as f
:
529 f
.write("local-data: \"%s\"\n" % " ".join(rr
))
531 os
.rename(filename
, self
.path
)
533 def _control(self
, *args
):
534 command
= ["unbound-control"]
538 subprocess
.check_output(command
)
541 except subprocess
.CalledProcessError
as e
:
542 log
.critical("Could not run %s, error code: %s: %s" % (
543 " ".join(command
), e
.returncode
, e
.output
))
548 if __name__
== "__main__":
549 parser
= argparse
.ArgumentParser(description
="Bridge for DHCP Leases and Unbound DNS")
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")
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")
566 # Parse command line arguments
567 args
= parser
.parse_args()
570 if args
.verbose
== 1:
571 loglevel
= logging
.INFO
572 elif args
.verbose
>= 2:
573 loglevel
= logging
.DEBUG
575 loglevel
= logging
.WARN
577 setup_logging(loglevel
)
579 bridge
= UnboundDHCPLeasesBridge(args
.dhcp_leases
, args
.fix_leases
,
580 args
.unbound_leases
, args
.hosts
)
582 ctx
= daemon
.DaemonContext(detach_process
=args
.daemon
)
584 signal
.SIGHUP
: bridge
.update_dhcp_leases
,
585 signal
.SIGTERM
: bridge
.terminate
,