]>
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
32 import inotify
.adapters
36 def setup_logging(loglevel
=logging
.INFO
):
37 log
= logging
.getLogger("dhcp")
38 log
.setLevel(loglevel
)
40 handler
= logging
.handlers
.SysLogHandler(address
="/dev/log", facility
="daemon")
41 handler
.setLevel(loglevel
)
43 formatter
= logging
.Formatter("%(name)s[%(process)d]: %(message)s")
44 handler
.setFormatter(formatter
)
46 log
.addHandler(handler
)
50 log
= logging
.getLogger("dhcp")
52 def ip_address_to_reverse_pointer(address
):
53 parts
= address
.split(".")
56 return "%s.in-addr.arpa" % ".".join(parts
)
58 def reverse_pointer_to_ip_address(rr
):
61 # Only take IP address part
62 parts
= reversed(parts
[0:4])
64 return ".".join(parts
)
66 class UnboundDHCPLeasesBridge(object):
67 def __init__(self
, dhcp_leases_file
, unbound_leases_file
):
68 self
.leases_file
= dhcp_leases_file
70 self
.unbound
= UnboundConfigWriter(unbound_leases_file
)
74 log
.info("Unbound DHCP Leases Bridge started on %s" % self
.leases_file
)
77 # Initially read leases file
78 self
.update_dhcp_leases()
80 i
= inotify
.adapters
.Inotify([self
.leases_file
])
82 for event
in i
.event_gen():
83 # End if we are requested to terminate
90 header
, type_names
, watch_path
, filename
= event
92 # Update leases after leases file has been modified
93 if "IN_MODIFY" in type_names
:
94 self
.update_dhcp_leases()
96 log
.info("Unbound DHCP Leases Bridge terminated")
98 def update_dhcp_leases(self
):
99 log
.info("Reading DHCP leases from %s" % self
.leases_file
)
101 leases
= DHCPLeases(self
.leases_file
)
102 self
.unbound
.update_dhcp_leases(leases
)
108 class DHCPLeases(object):
109 regex_leaseblock
= re
.compile(r
"lease (?P<ipaddr>\d+\.\d+\.\d+\.\d+) {(?P<config>[\s\S]+?)\n}")
111 def __init__(self
, path
):
114 self
._leases
= self
._parse
()
117 return iter(self
._leases
)
122 with
open(self
.path
) as f
:
123 # Read entire leases file
126 for match
in self
.regex_leaseblock
.finditer(data
):
127 block
= match
.groupdict()
129 ipaddr
= block
.get("ipaddr")
130 config
= block
.get("config")
132 properties
= self
._parse
_block
(config
)
134 # Skip any abandoned leases
135 if not "hardware" in properties
:
138 lease
= Lease(ipaddr
, properties
)
140 # Check if a lease for this Ethernet address already
141 # exists in the list of known leases. If so replace
142 # if with the most recent lease
143 for i
, l
in enumerate(leases
):
144 if l
.hwaddr
== lease
.hwaddr
:
145 leases
[i
] = max(lease
, l
)
153 def _parse_block(self
, block
):
156 for line
in block
.splitlines():
160 # Remove trailing ; from line
161 if line
.endswith(";"):
164 # Invalid line if it doesn't end with ;
168 # Remove any leading whitespace
171 # We skip all options and sets
172 if line
.startswith("option") or line
.startswith("set"):
175 # Split by first space
176 key
, val
= line
.split(" ", 1)
177 properties
[key
] = val
183 def __init__(self
, ipaddr
, properties
):
185 self
._properties
= properties
188 return "<%s %s for %s (%s)>" % (self
.__class
__.__name
__,
189 self
.ipaddr
, self
.hwaddr
, self
.hostname
)
191 def __eq__(self
, other
):
192 return self
.ipaddr
== other
.ipaddr
and self
.hwaddr
== other
.hwaddr
194 def __gt__(self
, other
):
195 if not self
.ipaddr
== other
.ipaddr
:
198 if not self
.hwaddr
== other
.hwaddr
:
201 return self
.time_starts
> other
.time_starts
204 def binding_state(self
):
205 state
= self
._properties
.get("binding")
208 state
= state
.split(" ", 1)
213 return self
.binding_state
== "active"
217 hardware
= self
._properties
.get("hardware")
222 ethernet
, address
= hardware
.split(" ", 1)
228 hostname
= self
._properties
.get("client-hostname")
232 hostname
= hostname
.replace("\"", "")
238 # Load ethernet settings
239 ethernet_settings
= self
.read_settings("/var/ipfire/ethernet/settings")
242 dhcp_settings
= self
.read_settings("/var/ipfire/dhcp/settings")
245 for zone
in ("GREEN", "BLUE"):
246 if not dhcp_settings
.get("ENABLE_%s" % zone
) == "on":
249 netaddr
= ethernet_settings
.get("%s_NETADDRESS" % zone
)
250 submask
= ethernet_settings
.get("%s_NETMASK" % zone
)
252 subnet
= ipaddress
.ip_network("%s/%s" % (netaddr
, submask
))
253 domain
= dhcp_settings
.get("DOMAIN_NAME_%s" % zone
)
255 subnets
[subnet
] = domain
257 address
= ipaddress
.ip_address(self
.ipaddr
)
259 for subnet
, domain
in subnets
.items():
260 if address
in subnet
:
263 # Fall back to localdomain if no match could be found
267 def read_settings(filename
):
270 with
open(filename
) as f
:
271 for line
in f
.readlines():
275 k
, v
= line
.split("=", 1)
282 return "%s.%s" % (self
.hostname
, self
.domain
)
286 return datetime
.datetime
.strptime(s
, "%w %Y/%m/%d %H:%M:%S")
289 def time_starts(self
):
290 starts
= self
._properties
.get("starts")
293 return self
._parse
_time
(starts
)
297 ends
= self
._properties
.get("ends")
299 if not ends
or ends
== "never":
302 return self
._parse
_time
(ends
)
306 if not self
.time_ends
:
307 return self
.time_starts
> datetime
.datetime
.utcnow()
309 return self
.time_starts
> datetime
.datetime
.utcnow() > self
.time_ends
315 (self
.fqdn
, "%s" % LOCAL_TTL
, "IN A", self
.ipaddr
),
318 (ip_address_to_reverse_pointer(self
.ipaddr
), "%s" % LOCAL_TTL
,
319 "IN PTR", self
.fqdn
),
323 class UnboundConfigWriter(object):
324 def __init__(self
, path
):
328 def existing_leases(self
):
329 local_data
= self
._control
("list_local_data")
332 for line
in local_data
.splitlines():
334 hostname
, ttl
, x
, record_type
, content
= line
.split("\t")
338 # Ignore everything that is not A or PTR
339 if not record_type
in ("A", "PTR"):
342 if hostname
.endswith("."):
343 hostname
= hostname
[:-1]
345 if content
.endswith("."):
346 content
= content
[:-1]
348 if record_type
== "A":
349 ret
[hostname
] = content
350 elif record_type
== "PTR":
351 ret
[content
] = reverse_pointer_to_ip_address(hostname
)
355 def update_dhcp_leases(self
, leases
):
356 # Cache all expired or inactive leases
357 expired_leases
= [l
for l
in leases
if l
.expired
or not l
.active
]
359 # Find any leases that have expired or do not exist any more
360 # but are still in the unbound local data
362 for fqdn
, address
in self
.existing_leases
.items():
363 if fqdn
in (l
.fqdn
for l
in expired_leases
):
364 removed_leases
+= [fqdn
, address
]
366 # Strip all non-active or expired leases
367 leases
= [l
for l
in leases
if l
.active
and not l
.expired
]
369 # Find any leases that have been added
370 new_leases
= [l
for l
in leases
371 if l
.fqdn
not in self
.existing_leases
]
373 # End here if nothing has changed
374 if not new_leases
and not removed_leases
:
377 # Write out all leases
378 self
.write_dhcp_leases(leases
)
380 # Update unbound about changes
381 for hostname
in removed_leases
:
382 log
.debug("Removing all records for %s" % hostname
)
383 self
._control
("local_data_remove", hostname
)
387 log
.debug("Adding new record %s" % " ".join(rr
))
388 self
._control
("local_data", *rr
)
391 def write_dhcp_leases(self
, leases
):
392 with
open(self
.path
, "w") as f
:
395 f
.write("local-data: \"%s\"\n" % " ".join(rr
))
397 def _control(self
, *args
):
398 command
= ["unbound-control"]
402 return subprocess
.check_output(command
)
405 except subprocess
.CalledProcessError
as e
:
406 log
.critical("Could not run %s, error code: %s: %s" % (
407 " ".join(command
), e
.returncode
, e
.output
))
410 if __name__
== "__main__":
411 parser
= argparse
.ArgumentParser(description
="Bridge for DHCP Leases and Unbound DNS")
414 parser
.add_argument("--daemon", "-d", action
="store_true",
415 help="Launch as daemon in background")
416 parser
.add_argument("--verbose", "-v", action
="count", help="Be more verbose")
419 parser
.add_argument("--dhcp-leases", default
="/var/state/dhcp/dhcpd.leases",
420 metavar
="PATH", help="Path to the DHCPd leases file")
421 parser
.add_argument("--unbound-leases", default
="/etc/unbound/dhcp-leases.conf",
422 metavar
="PATH", help="Path to the unbound configuration file")
424 # Parse command line arguments
425 args
= parser
.parse_args()
428 if args
.verbose
== 1:
429 loglevel
= logging
.INFO
430 elif args
.verbose
>= 2:
431 loglevel
= logging
.DEBUG
433 loglevel
= logging
.WARN
435 setup_logging(loglevel
)
437 bridge
= UnboundDHCPLeasesBridge(args
.dhcp_leases
, args
.unbound_leases
)
439 ctx
= daemon
.DaemonContext(detach_process
=args
.daemon
)
441 signal
.SIGHUP
: bridge
.update_dhcp_leases
,
442 signal
.SIGTERM
: bridge
.terminate
,