Development

This commit is contained in:
Matthew Grotke 2026-06-01 01:25:16 -04:00
parent 705c69abc4
commit 470cc39356
5 changed files with 244 additions and 245 deletions

View file

@ -317,54 +317,6 @@
"ntp_servers": ""
}
},
"reservations": [
{
"enabled": true,
"description": "UniFi Switch",
"hostname": "unifi-switch",
"mac": "aa:bb:cc:dd:ee:01",
"ip": "192.168.1.2",
"radius_client": true
},
{
"enabled": true,
"description": "UniFi AP (Kitchen)",
"hostname": "unifi-ap-kitchen",
"mac": "aa:bb:cc:dd:ee:02",
"ip": "192.168.1.3",
"radius_client": true
},
{
"enabled": true,
"description": "UniFi AP (Lounge)",
"hostname": "unifi-ap-lounge",
"mac": "aa:bb:cc:dd:ee:03",
"ip": "192.168.1.4",
"radius_client": true
},
{
"enabled": true,
"description": "UniFi AP (Upstairs)",
"hostname": "unifi-ap-upstairs",
"mac": "aa:bb:cc:dd:ee:04",
"ip": "192.168.1.5",
"radius_client": true
},
{
"enabled": true,
"description": "Home Server",
"hostname": "homeserver",
"mac": "aa:bb:cc:dd:ee:05",
"ip": "192.168.1.20"
},
{
"enabled": true,
"description": "Desktop PC",
"hostname": "desktop-pc",
"mac": "aa:bb:cc:dd:ee:06",
"ip": "192.168.1.50"
}
],
"port_wrangling": [
{
"description": "DNS wrangling - redirect Trusted DNS to local resolver",
@ -412,64 +364,6 @@
"ntp_servers": ""
}
},
"reservations": [
{
"enabled": true,
"description": "Network Printer",
"hostname": "printer",
"mac": "aa:bb:cc:dd:ee:10",
"ip": "192.168.10.2"
},
{
"enabled": true,
"description": "Smart TV",
"hostname": "smart-tv",
"mac": "aa:bb:cc:dd:ee:11",
"ip": "192.168.10.3"
},
{
"enabled": true,
"description": "Streaming Box (Eth)",
"hostname": "streaming-box-eth",
"mac": "aa:bb:cc:dd:ee:12",
"ip": "192.168.10.4"
},
{
"enabled": true,
"description": "Streaming Box (Wifi)",
"hostname": "streaming-box-wifi",
"mac": "aa:bb:cc:dd:ee:13",
"ip": "192.168.10.4"
},
{
"enabled": true,
"description": "Raspberry Pi",
"hostname": "rpi",
"mac": "aa:bb:cc:dd:ee:14",
"ip": "192.168.10.12"
},
{
"enabled": true,
"description": "NAS",
"hostname": "nas",
"mac": "aa:bb:cc:dd:ee:15",
"ip": "192.168.10.14"
},
{
"enabled": true,
"description": "Doorbell Camera",
"hostname": "doorbell-camera",
"mac": "aa:bb:cc:dd:ee:16",
"ip": "dynamic"
},
{
"enabled": true,
"description": "Smart Speaker",
"hostname": "smart-speaker",
"mac": "aa:bb:cc:dd:ee:17",
"ip": "dynamic"
}
],
"port_wrangling": [
{
"description": "DNS wrangling - redirect IoT DNS to local resolver",
@ -517,22 +411,6 @@
"ntp_servers": ""
}
},
"reservations": [
{
"enabled": true,
"description": "Family Member Phone 1",
"hostname": "phone-1",
"mac": "aa:bb:cc:dd:ee:20",
"ip": "dynamic"
},
{
"enabled": true,
"description": "Family Member Phone 2",
"hostname": "phone-2",
"mac": "aa:bb:cc:dd:ee:21",
"ip": "dynamic"
}
],
"port_wrangling": [
{
"description": "DNS wrangling - redirect Guest DNS to local resolver",
@ -581,36 +459,6 @@
"ntp_servers": ""
}
},
"reservations": [
{
"enabled": true,
"description": "Child 1 Laptop",
"hostname": "child1-laptop",
"mac": "aa:bb:cc:dd:ee:30",
"ip": "dynamic"
},
{
"enabled": true,
"description": "Child 2 Laptop",
"hostname": "child2-laptop",
"mac": "aa:bb:cc:dd:ee:31",
"ip": "dynamic"
},
{
"enabled": true,
"description": "Child 3 Laptop",
"hostname": "child3-laptop",
"mac": "aa:bb:cc:dd:ee:32",
"ip": "dynamic"
},
{
"enabled": true,
"description": "Child Tablet",
"hostname": "child-tablet",
"mac": "aa:bb:cc:dd:ee:33",
"ip": "dynamic"
}
],
"port_wrangling": [
{
"description": "DNS wrangling - redirect Kids DNS to local resolver",
@ -812,5 +660,171 @@
"format": "dnsmasq"
}
]
}
}
},
"dhcp_reservations": [
{
"enabled": true,
"description": "UniFi Switch",
"hostname": "unifi-switch",
"mac": "aa:bb:cc:dd:ee:01",
"ip": "192.168.1.2",
"radius_client": true,
"vlan": "trusted"
},
{
"enabled": true,
"description": "UniFi AP (Kitchen)",
"hostname": "unifi-ap-kitchen",
"mac": "aa:bb:cc:dd:ee:02",
"ip": "192.168.1.3",
"radius_client": true,
"vlan": "trusted"
},
{
"enabled": true,
"description": "UniFi AP (Lounge)",
"hostname": "unifi-ap-lounge",
"mac": "aa:bb:cc:dd:ee:03",
"ip": "192.168.1.4",
"radius_client": true,
"vlan": "trusted"
},
{
"enabled": true,
"description": "UniFi AP (Upstairs)",
"hostname": "unifi-ap-upstairs",
"mac": "aa:bb:cc:dd:ee:04",
"ip": "192.168.1.5",
"radius_client": true,
"vlan": "trusted"
},
{
"enabled": true,
"description": "Home Server",
"hostname": "homeserver",
"mac": "aa:bb:cc:dd:ee:05",
"ip": "192.168.1.20",
"vlan": "trusted"
},
{
"enabled": true,
"description": "Desktop PC",
"hostname": "desktop-pc",
"mac": "aa:bb:cc:dd:ee:06",
"ip": "192.168.1.50",
"vlan": "trusted"
},
{
"enabled": true,
"description": "Network Printer",
"hostname": "printer",
"mac": "aa:bb:cc:dd:ee:10",
"ip": "192.168.10.2",
"vlan": "iot"
},
{
"enabled": true,
"description": "Smart TV",
"hostname": "smart-tv",
"mac": "aa:bb:cc:dd:ee:11",
"ip": "192.168.10.3",
"vlan": "iot"
},
{
"enabled": true,
"description": "Streaming Box (Eth)",
"hostname": "streaming-box-eth",
"mac": "aa:bb:cc:dd:ee:12",
"ip": "192.168.10.4",
"vlan": "iot"
},
{
"enabled": true,
"description": "Streaming Box (Wifi)",
"hostname": "streaming-box-wifi",
"mac": "aa:bb:cc:dd:ee:13",
"ip": "192.168.10.4",
"vlan": "iot"
},
{
"enabled": true,
"description": "Raspberry Pi",
"hostname": "rpi",
"mac": "aa:bb:cc:dd:ee:14",
"ip": "192.168.10.12",
"vlan": "iot"
},
{
"enabled": true,
"description": "NAS",
"hostname": "nas",
"mac": "aa:bb:cc:dd:ee:15",
"ip": "192.168.10.14",
"vlan": "iot"
},
{
"enabled": true,
"description": "Doorbell Camera",
"hostname": "doorbell-camera",
"mac": "aa:bb:cc:dd:ee:16",
"ip": "dynamic",
"vlan": "iot"
},
{
"enabled": true,
"description": "Smart Speaker",
"hostname": "smart-speaker",
"mac": "aa:bb:cc:dd:ee:17",
"ip": "dynamic",
"vlan": "iot"
},
{
"enabled": true,
"description": "Family Member Phone 1",
"hostname": "phone-1",
"mac": "aa:bb:cc:dd:ee:20",
"ip": "dynamic",
"vlan": "guest"
},
{
"enabled": true,
"description": "Family Member Phone 2",
"hostname": "phone-2",
"mac": "aa:bb:cc:dd:ee:21",
"ip": "dynamic",
"vlan": "guest"
},
{
"enabled": true,
"description": "Child 1 Laptop",
"hostname": "child1-laptop",
"mac": "aa:bb:cc:dd:ee:30",
"ip": "dynamic",
"vlan": "kids"
},
{
"enabled": true,
"description": "Child 2 Laptop",
"hostname": "child2-laptop",
"mac": "aa:bb:cc:dd:ee:31",
"ip": "dynamic",
"vlan": "kids"
},
{
"enabled": true,
"description": "Child 3 Laptop",
"hostname": "child3-laptop",
"mac": "aa:bb:cc:dd:ee:32",
"ip": "dynamic",
"vlan": "kids"
},
{
"enabled": true,
"description": "Child Tablet",
"hostname": "child-tablet",
"mac": "aa:bb:cc:dd:ee:33",
"ip": "dynamic",
"vlan": "kids"
}
]
}

View file

@ -492,8 +492,9 @@ def build_vlan_dnsmasq_conf(vlan, data, iface):
line(f"dhcp-host={s['ip']},{s['hostname']}")
line()
active_res = [r for r in vlan.get("reservations", []) if r.get("enabled") is True]
inactive_res = [r for r in vlan.get("reservations", []) if r.get("enabled") is not True]
vlan_res = [r for r in data.get("dhcp_reservations", []) if r.get("vlan") == name]
active_res = [r for r in vlan_res if r.get("enabled") is True]
inactive_res = [r for r in vlan_res if r.get("enabled") is not True]
if active_res:
line("# -- Reservations -----------------------------------------------")
@ -1821,12 +1822,12 @@ RADIUS_USERS_FILE = Path("/etc/freeradius/3.0/users")
def radius_clients(data):
"""Return list of (reservation, vlan) tuples where radius_client is True."""
result = []
for vlan in data["vlans"]:
for r in vlan.get("reservations", []):
if r.get("radius_client") is True:
result.append((r, vlan))
return result
vlan_by_name = {v["name"]: v for v in data.get("vlans", [])}
return [
(r, vlan_by_name[r["vlan"]])
for r in data.get("dhcp_reservations", [])
if r.get("radius_client") is True and r.get("vlan") in vlan_by_name
]
def radius_enabled(data):
"""Return True if any reservation has radius_client: true."""
@ -1889,22 +1890,25 @@ def build_radius_users(data):
"",
]
for vlan in data["vlans"]:
vlan_by_name = {v["name"]: v for v in data.get("vlans", [])}
for r in data.get("dhcp_reservations", []):
if r.get("enabled") is not True:
continue
mac = r.get("mac", "").replace(":", "").lower()
if not mac:
continue
vlan = vlan_by_name.get(r.get("vlan", ""))
if not vlan:
continue
vlan_id = vlan.get('vlan_id')
for r in vlan.get("reservations", []):
if r.get("enabled") is not True:
continue
mac = r.get("mac", "").replace(":", "").lower()
if not mac:
continue
lines += [
f"# {r['description']} -> VLAN {vlan_id} ({vlan['name']})",
f"{mac} Cleartext-Password := \"{mac}\"",
f" Tunnel-Type = VLAN,",
f" Tunnel-Medium-Type = IEEE-802,",
f" Tunnel-Private-Group-Id = \"{vlan_id}\"",
"",
]
lines += [
f"# {r['description']} -> VLAN {vlan_id} ({vlan['name']})",
f"{mac} Cleartext-Password := \"{mac}\"",
f" Tunnel-Type = VLAN,",
f" Tunnel-Medium-Type = IEEE-802,",
f" Tunnel-Private-Group-Id = \"{vlan_id}\"",
"",
]
default_id = default_vlan.get('vlan_id')
lines += [
@ -2143,13 +2147,13 @@ def reset_leases(data, vlan_name=None):
def show_leases(data):
# Build MAC -> reservation lookup across all VLANs
vlan_by_name = {v["name"]: v for v in data.get("vlans", [])}
res_by_mac = {}
for vlan in data["vlans"]:
for r in vlan.get("reservations", []):
if r.get("enabled") is True:
mac = r.get("mac", "").lower().strip()
if mac:
res_by_mac[mac] = (r, vlan)
for r in data.get("dhcp_reservations", []):
if r.get("enabled") is True:
mac = r.get("mac", "").lower().strip()
if mac:
res_by_mac[mac] = (r, vlan_by_name.get(r.get("vlan", ""), {}))
now = int(datetime.now().timestamp())
any_leases = False
@ -2922,10 +2926,7 @@ def cmd_apply(data, dry_run=False):
print("RADIUS (dry-run) ====================================================")
num_clients = len(radius_clients(data))
default_vlan = next((v for v in data["vlans"] if v.get("radius_default") is True), None)
total_macs = sum(
len([r for r in v.get("reservations", []) if r.get("enabled") is True])
for v in data["vlans"]
)
total_macs = len([r for r in data.get("dhcp_reservations", []) if r.get("enabled") is True])
print(f" Would write: {RADIUS_CLIENTS_CONF}")
print(f" {num_clients} RADIUS client(s)")
print(f" Would write: {RADIUS_USERS_FILE}")
@ -2944,14 +2945,10 @@ def cmd_apply(data, dry_run=False):
check_root()
total_enabled = sum(
len([r for r in v.get("reservations", []) if r.get("enabled") is True])
for v in data["vlans"] if not is_wg(v)
)
total_disabled = sum(
len([r for r in v.get("reservations", []) if r.get("enabled") is not True])
for v in data["vlans"] if not is_wg(v)
)
wg_names = {v["name"] for v in data["vlans"] if is_wg(v)}
non_wg_res = [r for r in data.get("dhcp_reservations", []) if r.get("vlan") not in wg_names]
total_enabled = len([r for r in non_wg_res if r.get("enabled") is True])
total_disabled = len([r for r in non_wg_res if r.get("enabled") is not True])
total_wg_peers = sum(len(v.get("peers", [])) for v in data["vlans"] if is_wg(v))
wg_part = f", {total_wg_peers} WG peer(s)" if total_wg_peers else ""
print(f"Applying config: {len(data['vlans'])} VLAN(s), "

View file

@ -741,7 +741,8 @@ def validate_config(data):
seen_res_ips = {}
seen_res_macs = {}
for r in vlan.get("reservations", []):
vlan_name_key = vlan.get("name", "")
for r in [r for r in data.get("dhcp_reservations", []) if r.get("vlan") == vlan_name_key]:
rdesc = r.get("description", "?")
rmac = r.get("mac", "").lower().strip()
@ -876,10 +877,11 @@ def validate_config(data):
# RADIUS requires multiple VLANs ================================
non_wg_vlans = [v for v in data.get("vlans", []) if not is_wg(v)]
non_wg_names = {v.get("name") for v in non_wg_vlans}
has_radius_clients = any(
r.get("radius_client")
for v in non_wg_vlans
for r in v.get("reservations", [])
for r in data.get("dhcp_reservations", [])
if r.get("vlan") in non_wg_names
)
if has_radius_clients and len(non_wg_vlans) < 2:
errors.append(