Add support for DHCP server/relay

This patch introduces a new yang yang model for DHCP server and relay,
using dnsmasq to provide the functionality on a per-interface basis.

Available DHCP options will be queried by confd on startup and can
then be configured in a flexible manner.

The DHCP option 82 feature has been realized by patching dnsmasq
(see patches/dnsmasq/2.90/0000-relay-agent-info.patch).

Signed-off-by: Stefan Schlosser <sgs@grmmbl.org>
This commit is contained in:
Stefan Schlosser
2025-01-31 13:55:35 +01:00
committed by Joachim Wiberg
parent 455c384164
commit 60f459687c
19 changed files with 1345 additions and 159 deletions
+55
View File
@@ -57,6 +57,12 @@ class PadSoftware:
state = 10
version = 23
class PadDhcpServer:
iface = 7
ip = 17
mac = 19
host = 21
exp = 7
class PadUsbPort:
title = 30
@@ -350,6 +356,34 @@ class STPPortID:
)
return f"{prio:1x}.{pid:03x}"
class DhcpServer:
def __init__(self, data):
self.data = data
self.iface = get_json_data('', self.data, 'if-name')
self.leases = []
now = datetime.now(timezone.utc)
for lease in get_json_data([], self.data, 'server', 'lease', 'host'):
dt = datetime.strptime(lease["expires"], "%Y-%m-%dT%H:%M:%S%z")
exp = (dt - now).total_seconds()
self.leases.append({
"ip": lease["ip-address"],
"mac": lease["hardware-address"],
"host": lease["hostname"],
"exp": int(exp)
})
def print(self):
for lease in self.leases:
ip = lease["ip"]
mac = lease["mac"]
exp = "%ds" % lease["exp"]
host = lease["host"][:20]
row = f"{self.iface:<{PadDhcpServer.iface}}"
row += f"{ip:<{PadDhcpServer.ip}}"
row += f"{mac:<{PadDhcpServer.mac}}"
row += f"{host:<{PadDhcpServer.host}}"
row += f"{exp:<{PadDhcpServer.exp}}"
print(row)
class Iface:
def __init__(self, data):
@@ -1038,6 +1072,23 @@ def show_ntp(json):
row += f"{source['poll']:>{PadNtpSource.poll}}"
print(row)
def show_dhcp_server(json):
if not json.get("infix-dhcp-server:dhcp-server"):
print("DHCP server not enabled.")
return
hdr = (f"{'IFACE':<{PadDhcpServer.iface}}"
f"{'IP':<{PadDhcpServer.ip}}"
f"{'MAC':<{PadDhcpServer.mac}}"
f"{'HOSTNAME':<{PadDhcpServer.host}}"
f"{'EXPIRES':<{PadDhcpServer.exp}}")
print(Decore.invert(hdr))
servers = get_json_data({}, json, "infix-dhcp-server:dhcp-server", "server-if")
for s in servers:
server = DhcpServer(s)
server.print()
def main():
global UNIT_TEST
@@ -1074,6 +1125,8 @@ def main():
parser_show_boot_order = subparsers.add_parser('show-boot-order', help='Show NTP sources')
parser_show_routing_table = subparsers.add_parser('show-dhcp-server', help='Show DHCP server')
args = parser.parse_args()
UNIT_TEST = args.test
@@ -1091,6 +1144,8 @@ def main():
show_hardware(json_data)
elif args.command == "show-ntp":
show_ntp(json_data)
elif args.command == "show-dhcp-server":
show_dhcp_server(json_data)
else:
print(f"Error, unknown command '{args.command}'")
sys.exit(1)
@@ -0,0 +1,4 @@
from .dhcp_server_status import main
if __name__ == "__main__":
main()
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/python3
#
# This script is used to query dnsmasq daemons via dbus and
# fills/cleans the infix-dhcp-server packet statistics.
#
import argparse
import json
import dbus
import re
DBUS_NAME = "org.freedesktop.DBus"
DBUS_OBJECT = "/org/freedesktop/DBus"
DBUS_IFACE = "org.freedesktop.DBus"
DNSMASQ_NAME = "uk.org.thekelleys.dnsmasq"
DNSMASQ_IFACE = "uk.org.thekelleys.dnsmasq"
DNSMASQ_OBJECT = "/uk/org/thekelleys/dnsmasq"
def get_servers(bus):
try:
remote_object = bus.get_object(DBUS_NAME, DBUS_OBJECT)
iface = dbus.Interface(remote_object, DBUS_IFACE)
except dbus.DBusException:
return []
servers = []
for name in iface.ListNames():
r = re.search(r"%s.(\w+)$" % DNSMASQ_NAME, name)
if r:
server = {
"ifc": r.group(1),
"name": str(name)
}
servers.append(server)
return servers
def get_iface(bus, name):
try:
remote_object = bus.get_object(name, DNSMASQ_OBJECT)
iface = dbus.Interface(remote_object, DNSMASQ_IFACE)
except dbus.DBusException:
return None
finally:
return iface
def main():
bus = dbus.SystemBus()
servers = get_servers(bus)
parser = argparse.ArgumentParser(prog='dhcp-server-status')
parser.add_argument("-c", "--clean", help="DHCP server interface")
args = parser.parse_args()
if args.clean:
for server in servers:
if server["ifc"] != args.clean:
continue
iface = get_iface(bus, server["name"])
if not iface:
continue
print("Cleaning metrics for DHCP server on %s" % server["ifc"])
iface.ClearMetrics()
else:
data = []
for server in servers:
iface = get_iface(bus, server["name"])
if not iface:
continue
data.append({
"if-name": server["ifc"],
"metrics": iface.GetMetrics()
})
print(json.dumps(data))
if __name__ == "__main__":
main()
+3 -1
View File
@@ -6,7 +6,8 @@ license = "MIT"
packages = [
{ include = "yanger" },
{ include = "cli_pretty" },
{ include = "ospf_status" }
{ include = "ospf_status" },
{ include = "dhcp_server_status" }
]
authors = [
"KernelKit developers"
@@ -21,3 +22,4 @@ build-backend = "poetry.core.masonry.api"
yanger = "yanger.__main__:main"
cli-pretty = "cli_pretty:main"
ospf-status = "ospf_status:main"
dhcp-server-status = "dhcp_server_status:main"
+3
View File
@@ -70,6 +70,9 @@ def main():
elif args.model == 'infix-containers':
from . import infix_containers
yang_data = infix_containers.operational()
elif args.model == 'infix-dhcp-server':
from . import infix_dhcp_server
yang_data = infix_dhcp_server.operational()
elif args.model == 'ietf-system':
from . import ietf_system
yang_data = ietf_system.operational()
@@ -0,0 +1,79 @@
from datetime import datetime
from .host import HOST
def lease_info(ifname):
"""Populate DHCP leases table"""
leases = f"/var/run/dnsmasq-{ifname}.leases"
hosts = []
try:
with open(leases, 'r', encoding='utf-8') as fd:
for line in fd:
tokens = line.strip().split(" ")
if len(tokens) != 5:
continue
host = {
"ip-address": tokens[2],
"hardware-address": tokens[1],
}
dt = datetime.utcfromtimestamp(int(tokens[0]))
host["expires"] = dt.isoformat() + "+00:00"
if tokens[3] != '*':
host["hostname"] = tokens[3]
if tokens[4] != '*':
host["client-identifier"] = tokens[4]
hosts.append(host)
except (IOError, OSError, ValueError):
pass
return {
"host-count": len(hosts),
"host": hosts
}
def status(servers):
"""Populate DHCP server status"""
data = HOST.run_json(['/usr/libexec/statd/dhcp-server-status'], default=[])
if data == []:
return
for entry in data:
metrics = entry["metrics"]
servers.append({
"if-name": entry["if-name"],
"packet-statistics": {
"sent": {
"offer-count": metrics["dhcp_offer"],
"ack-count": metrics["dhcp_ack"],
"nak-count": metrics["dhcp_nak"]
},
"received": {
"decline-count": metrics["dhcp_decline"],
"discover-count": metrics["dhcp_discover"],
"request-count": metrics["dhcp_request"],
"release-count": metrics["dhcp_release"],
"inform-count": metrics["dhcp_inform"]
}
},
"server": {
"lease": lease_info(entry["if-name"])
}
})
def operational():
"""Return operational status for DHCP server"""
out = {
"infix-dhcp-server:dhcp-server": {
"server-if": []
}
}
status(out['infix-dhcp-server:dhcp-server']['server-if'])
return out