From 470cc3935643d5cf85ea729515bd89009a8f784d Mon Sep 17 00:00:00 2001 From: Matthew Grotke Date: Mon, 1 Jun 2026 01:25:16 -0400 Subject: [PATCH] Development --- docker/routlin-dash/app/pages/dhcp/action.py | 64 ++-- docker/routlin-dash/app/view_page.py | 16 +- routlin/config.json | 322 ++++++++++--------- routlin/core.py | 79 +++-- routlin/validation.py | 8 +- 5 files changed, 244 insertions(+), 245 deletions(-) diff --git a/docker/routlin-dash/app/pages/dhcp/action.py b/docker/routlin-dash/app/pages/dhcp/action.py index 8c2f1aa..d3f8c1d 100644 --- a/docker/routlin-dash/app/pages/dhcp/action.py +++ b/docker/routlin-dash/app/pages/dhcp/action.py @@ -25,16 +25,6 @@ def _hash_ok(): return True -def _flat_index_to_vlan_res(vlans, flat_idx): - pos = 0 - for vi, vlan in enumerate(vlans): - for ri in range(len(vlan.get('reservations', []))): - if pos == flat_idx: - return vi, ri - pos += 1 - return None, None - - def _parse_ip(): raw = request.form.get('ip', '').strip() if not raw: @@ -67,7 +57,7 @@ def addreservation_add(): if not _hash_ok(): return redirect(f'/{_PAGE}') - cfg = load_config() + cfg = load_config() vlans = cfg.get('vlans', []) vlan = next((v for v in vlans if v.get('name') == vlan_name), None) if vlan is None: @@ -86,8 +76,9 @@ def addreservation_add(): 'ip': ip, 'radius_client': radius_client, 'enabled': True, + 'vlan': vlan_name, } - vlan.setdefault('reservations', []).append(entry) + cfg.setdefault('dhcp_reservations', []).append(entry) errors = validate.validate_config(cfg) if errors: for msg in errors: @@ -95,7 +86,7 @@ def addreservation_add(): return redirect(f'/{_PAGE}') changes = diff_fields(None, entry) - flash(record_group(cfg, f'vlans[name={vlan_name}].reservations', 'mac', mac, changes, 'core apply'), 'success') + flash(record_group(cfg, 'dhcp_reservations', 'mac', mac, changes, 'core apply'), 'success') return redirect(f'/{_PAGE}') @@ -109,14 +100,13 @@ def reservations_toggle(): if not _hash_ok(): return redirect(f'/{_PAGE}') - cfg = load_config() - vlans = cfg.get('vlans', []) - vi, ri = _flat_index_to_vlan_res(vlans, idx) - if vi is None: + cfg = load_config() + items = cfg.get('dhcp_reservations', []) + if idx < 0 or idx >= len(items): flash('Entry not found.', 'error') return redirect(f'/{_PAGE}') - res = vlans[vi]['reservations'][ri] + res = items[idx] old_enabled = res.get('enabled', True) before = copy.deepcopy(res) res['enabled'] = not old_enabled @@ -126,9 +116,8 @@ def reservations_toggle(): flash(msg, 'error') return redirect(f'/{_PAGE}') - vlan_name = vlans[vi]['name'] changes = diff_fields(before, res) - flash(record_group(cfg, f'vlans[name={vlan_name}].reservations', 'mac', res['mac'], changes, 'core apply'), 'success') + flash(record_group(cfg, 'dhcp_reservations', 'mac', res['mac'], changes, 'core apply'), 'success') return redirect(f'/{_PAGE}') @@ -154,19 +143,21 @@ def reservations_edit(): if not _hash_ok(): return redirect(f'/{_PAGE}') - cfg = load_config() - vlans = cfg.get('vlans', []) - vi, ri = _flat_index_to_vlan_res(vlans, idx) - if vi is None: + cfg = load_config() + items = cfg.get('dhcp_reservations', []) + if idx < 0 or idx >= len(items): flash('Entry not found.', 'error') return redirect(f'/{_PAGE}') - conflict = validate.check_reservation_ip_conflicts(ip, vlans[vi]) - if conflict: - flash(f'The configuration has not been saved because {conflict}', 'error') - return redirect(f'/{_PAGE}') + res = items[idx] + vlan_name = res.get('vlan', '') + vlan = next((v for v in cfg.get('vlans', []) if v.get('name') == vlan_name), None) + if vlan: + conflict = validate.check_reservation_ip_conflicts(ip, vlan) + if conflict: + flash(f'The configuration has not been saved because {conflict}', 'error') + return redirect(f'/{_PAGE}') - res = vlans[vi]['reservations'][ri] before = copy.deepcopy(res) res.update({ 'description': description, @@ -182,9 +173,8 @@ def reservations_edit(): flash(msg, 'error') return redirect(f'/{_PAGE}') - vlan_name = vlans[vi]['name'] changes = diff_fields(before, res) - flash(record_group(cfg, f'vlans[name={vlan_name}].reservations', 'mac', mac, changes, 'core apply'), 'success') + flash(record_group(cfg, 'dhcp_reservations', 'mac', mac, changes, 'core apply'), 'success') return redirect(f'/{_PAGE}') @@ -198,15 +188,13 @@ def reservations_delete(): if not _hash_ok(): return redirect(f'/{_PAGE}') - cfg = load_config() - vlans = cfg.get('vlans', []) - vi, ri = _flat_index_to_vlan_res(vlans, idx) - if vi is None: + cfg = load_config() + items = cfg.get('dhcp_reservations', []) + if idx < 0 or idx >= len(items): flash('Entry not found.', 'error') return redirect(f'/{_PAGE}') - vlan_name = vlans[vi]['name'] - removed = vlans[vi]['reservations'].pop(ri) + removed = items.pop(idx) errors = validate.validate_config(cfg) if errors: for msg in errors: @@ -214,5 +202,5 @@ def reservations_delete(): return redirect(f'/{_PAGE}') changes = diff_fields(removed, None) - flash(record_group(cfg, f'vlans[name={vlan_name}].reservations', 'mac', removed['mac'], changes, 'core apply'), 'success') + flash(record_group(cfg, 'dhcp_reservations', 'mac', removed['mac'], changes, 'core apply'), 'success') return redirect(f'/{_PAGE}') diff --git a/docker/routlin-dash/app/view_page.py b/docker/routlin-dash/app/view_page.py index 110da10..1d50761 100644 --- a/docker/routlin-dash/app/view_page.py +++ b/docker/routlin-dash/app/view_page.py @@ -334,11 +334,10 @@ def config_datasource(name): if name == 'dhcp_reservations': rows = [] - for vlan in vlans: - for res in vlan.get('reservations', []): - row = dict(res) - row['vlan_name'] = vlan.get('name', '-') - rows.append(row) + for res in cfg.get('dhcp_reservations', []): + row = dict(res) + row['vlan_name'] = res.get('vlan', '-') + rows.append(row) return rows if name == 'ddns_providers': @@ -795,10 +794,9 @@ def collect_tokens(): _vn = _v.get('name', '') if not _vn: continue - _res_ips_by_vlan[_vn] = [r['ip'] for r in _v.get('reservations', []) - if r.get('ip') and r['ip'] != 'dynamic'] - _res_hosts_by_vlan[_vn] = [r['hostname'] for r in _v.get('reservations', []) - if r.get('hostname')] + _vlan_res = [r for r in cfg.get('dhcp_reservations', []) if r.get('vlan') == _vn] + _res_ips_by_vlan[_vn] = [r['ip'] for r in _vlan_res if r.get('ip') and r['ip'] != 'dynamic'] + _res_hosts_by_vlan[_vn] = [r['hostname'] for r in _vlan_res if r.get('hostname')] tokens['RESERVATION_IPS_BY_VLAN_JSON'] = json.dumps(_res_ips_by_vlan) tokens['RESERVATION_HOSTNAMES_BY_VLAN_JSON'] = json.dumps(_res_hosts_by_vlan) tokens['EXISTING_VLAN_IDS_JSON'] = json.dumps([v.get('vlan_id') for v in vlans]) diff --git a/routlin/config.json b/routlin/config.json index 6fe9c33..8cb1f57 100644 --- a/routlin/config.json +++ b/routlin/config.json @@ -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" + } + ] +} \ No newline at end of file diff --git a/routlin/core.py b/routlin/core.py index a753443..36cfa15 100644 --- a/routlin/core.py +++ b/routlin/core.py @@ -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), " diff --git a/routlin/validation.py b/routlin/validation.py index 8e92a67..7b9bb9f 100644 --- a/routlin/validation.py +++ b/routlin/validation.py @@ -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(