From 5a3a18d5b07643c8571430a7d63ab1d0accb01a0 Mon Sep 17 00:00:00 2001 From: Matthew Grotke Date: Wed, 3 Jun 2026 02:24:21 -0400 Subject: [PATCH] Development --- docker/routlin-dash/app/config_utils.py | 1 + .../app/pages/dhcpleases/content.json | 5 ++ .../routlin-dash/app/pages/dhcpleases/view.py | 88 ++++++++++++++++--- .../app/pages/dhcpreservations/action.py | 12 ++- routlin/config.json | 16 ++-- routlin/core.py | 19 ++-- routlin/validation.py | 15 +++- 7 files changed, 123 insertions(+), 33 deletions(-) diff --git a/docker/routlin-dash/app/config_utils.py b/docker/routlin-dash/app/config_utils.py index 63430db..e34c37d 100644 --- a/docker/routlin-dash/app/config_utils.py +++ b/docker/routlin-dash/app/config_utils.py @@ -735,6 +735,7 @@ def config_datasource(name): for res in cfg.get('dhcp_reservations', []): row = dict(res) row['vlan_name'] = res.get('vlan', '-') + row['ip'] = res.get('ip') or 'dynamic' rows.append(row) return rows diff --git a/docker/routlin-dash/app/pages/dhcpleases/content.json b/docker/routlin-dash/app/pages/dhcpleases/content.json index af3f308..916c762 100644 --- a/docker/routlin-dash/app/pages/dhcpleases/content.json +++ b/docker/routlin-dash/app/pages/dhcpleases/content.json @@ -54,6 +54,11 @@ "label": "VLAN", "field": "vlan_name" }, + { + "label": "Status", + "field": "status", + "render": "raw_html" + }, { "label": "Last Renewal", "field": "last_active" diff --git a/docker/routlin-dash/app/pages/dhcpleases/view.py b/docker/routlin-dash/app/pages/dhcpleases/view.py index 7e4869f..823af77 100644 --- a/docker/routlin-dash/app/pages/dhcpleases/view.py +++ b/docker/routlin-dash/app/pages/dhcpleases/view.py @@ -1,5 +1,7 @@ +import ipaddress import os import glob +import subprocess from datetime import datetime, timezone from config_utils import collect_layout_tokens, load_config, relative_time from factory import ( @@ -44,6 +46,52 @@ def _vendor_cell(vendor): return e(display) +def _get_arp_table(): + """Return {mac_lower: state} from `ip neigh`. Excludes FAILED/PERMANENT/INCOMPLETE.""" + try: + result = subprocess.run(['ip', 'neigh'], capture_output=True, text=True, timeout=5) + entries = {} + for line in result.stdout.splitlines(): + parts = line.split() + if 'lladdr' not in parts: + continue + state = parts[-1] + if state in ('FAILED', 'PERMANENT', 'NOARP', 'INCOMPLETE'): + continue + idx = parts.index('lladdr') + mac = parts[idx + 1].lower() + ip = parts[0] + iface = parts[2] if len(parts) > 2 else '' + entries[mac] = {'ip': ip, 'iface': iface, 'state': state} + return entries + except Exception: + return {} + + +def _status_badge(state): + if state == 'REACHABLE': + return 'Online' + return 'Offline' + + +def _vlan_for_ip(ip_str, vlans): + """Return VLAN name whose subnet contains ip_str, or '-'.""" + try: + addr = ipaddress.IPv4Address(ip_str) + except ValueError: + return '-' + for v in vlans: + subnet = v.get('subnet', '') + mask = v.get('subnet_mask', '') + if subnet and mask: + try: + if addr in ipaddress.IPv4Network(f'{subnet}/{mask}', strict=False): + return v.get('name', '-') + except ValueError: + pass + return '-' + + def _parse_lease_secs(s): s = str(s).strip().lower() try: @@ -56,10 +104,13 @@ def _parse_lease_secs(s): def live_dhcp_leases(): - rows = [] - now = int(datetime.now(tz=timezone.utc).timestamp()) - cfg = load_config() - vlans = cfg.get('vlans', []) + rows = [] + now = int(datetime.now(tz=timezone.utc).timestamp()) + cfg = load_config() + vlans = cfg.get('vlans', []) + arp_table = _get_arp_table() + lease_macs = set() + vlan_lease_secs = { v['name']: _parse_lease_secs(v.get('dhcp_information', {}).get('lease_time', '')) for v in vlans if v.get('name') @@ -101,15 +152,14 @@ def live_dhcp_leases(): device_h = parts[3] if parts[3] != '*' else None res_h = mac_to_res.get(mac_norm) desc = mac_to_desc.get(mac_norm) - desc_attr = f' data-hostname-desc="{e(desc)}"' if desc else '' - if res_h and device_h and device_h.lower() != res_h.lower(): - hostname_html = f'{e(res_h)}
({e(device_h)})' if desc_attr else f'{e(res_h)}
({e(device_h)})' - elif res_h: - hostname_html = f'{e(res_h)}' if desc_attr else e(res_h) - elif device_h: - hostname_html = f'{e(device_h)}' if desc_attr else e(device_h) + name = res_h or device_h + if name: + desc_attr = f' data-hostname-desc="{e(desc)}"' if desc else '' + hostname_html = f'{e(name)}' if desc_attr else e(name) else: hostname_html = '-' + arp_entry = arp_table.get(mac_norm, {}) + lease_macs.add(mac_norm) rows.append({ 'hostname': hostname_html, 'ip_address': parts[2], @@ -118,9 +168,25 @@ def live_dhcp_leases(): 'vlan_name': vlan_name, 'last_active': last_active, 'renews': 'in ' + relative_time(renews_ts or expiry, now, short=True), + 'status': _status_badge(arp_entry.get('state', '')), }) except Exception: pass + + for mac, arp in arp_table.items(): + if mac in lease_macs: + continue + rows.append({ + 'hostname': '-', + 'ip_address': arp['ip'], + 'mac_address': mac, + 'vendor': _vendor_cell(_get_vendor(mac)), + 'vlan_name': _vlan_for_ip(arp['ip'], vlans), + 'last_active': '', + 'renews': '', + 'status': _status_badge(arp['state']), + }) + return rows diff --git a/docker/routlin-dash/app/pages/dhcpreservations/action.py b/docker/routlin-dash/app/pages/dhcpreservations/action.py index 3c0a71c..3e1d1e7 100644 --- a/docker/routlin-dash/app/pages/dhcpreservations/action.py +++ b/docker/routlin-dash/app/pages/dhcpreservations/action.py @@ -29,7 +29,7 @@ def _hash_ok(): def _parse_ip(): raw = request.form.get('ip', '').strip() if not raw: - return 'dynamic' + return '' ip = validate.ip(raw) if not ip: flash(f'The configuration has not been saved because "{raw}" is not a valid IP address.', 'error') @@ -38,7 +38,7 @@ def _parse_ip(): def _check_ip_in_vlan_subnet(ip, vlan): - if not ip or ip == 'dynamic': + if not ip: return None subnet = vlan.get('subnet') prefix = vlan.get('subnet_mask') @@ -98,13 +98,14 @@ def addreservation_add(): entry = { 'description': description, - 'hostname': hostname, 'mac': mac, 'ip': ip, 'radius_client': radius_client, 'enabled': True, 'vlan': vlan_name, } + if hostname: + entry['hostname'] = hostname cfg.setdefault('dhcp_reservations', []).append(entry) errors = validate.validate_config(cfg) if errors: @@ -192,12 +193,15 @@ def reservations_edit(): before = copy.deepcopy(res) res.update({ 'description': description, - 'hostname': hostname, 'mac': mac, 'ip': ip, 'radius_client': radius_client, 'enabled': 'enabled' in request.form, }) + if hostname: + res['hostname'] = hostname + else: + res.pop('hostname', None) errors = validate.validate_config(cfg) if errors: for msg in errors: diff --git a/routlin/config.json b/routlin/config.json index c2f01d3..45512de 100644 --- a/routlin/config.json +++ b/routlin/config.json @@ -687,7 +687,7 @@ "description": "Doorbell Camera", "hostname": "doorbell-camera", "mac": "aa:bb:cc:dd:ee:16", - "ip": "dynamic", + "ip": "", "vlan": "iot" }, { @@ -695,7 +695,7 @@ "description": "Smart Speaker", "hostname": "smart-speaker", "mac": "aa:bb:cc:dd:ee:17", - "ip": "dynamic", + "ip": "", "vlan": "iot" }, { @@ -703,7 +703,7 @@ "description": "Family Member Phone 1", "hostname": "phone-1", "mac": "aa:bb:cc:dd:ee:20", - "ip": "dynamic", + "ip": "", "vlan": "guest" }, { @@ -711,7 +711,7 @@ "description": "Family Member Phone 2", "hostname": "phone-2", "mac": "aa:bb:cc:dd:ee:21", - "ip": "dynamic", + "ip": "", "vlan": "guest" }, { @@ -719,7 +719,7 @@ "description": "Child 1 Laptop", "hostname": "child1-laptop", "mac": "aa:bb:cc:dd:ee:30", - "ip": "dynamic", + "ip": "", "vlan": "kids" }, { @@ -727,7 +727,7 @@ "description": "Child 2 Laptop", "hostname": "child2-laptop", "mac": "aa:bb:cc:dd:ee:31", - "ip": "dynamic", + "ip": "", "vlan": "kids" }, { @@ -735,7 +735,7 @@ "description": "Child 3 Laptop", "hostname": "child3-laptop", "mac": "aa:bb:cc:dd:ee:32", - "ip": "dynamic", + "ip": "", "vlan": "kids" }, { @@ -743,7 +743,7 @@ "description": "Child Tablet", "hostname": "child-tablet", "mac": "aa:bb:cc:dd:ee:33", - "ip": "dynamic", + "ip": "", "vlan": "kids" } ], diff --git a/routlin/core.py b/routlin/core.py index bad8abb..705c695 100644 --- a/routlin/core.py +++ b/routlin/core.py @@ -518,20 +518,23 @@ def build_vlan_dnsmasq_conf(vlan, data, iface): for group in ordered: if len(group) == 1: r = group[0] + h = r.get('hostname', '') line(f"# {r['description']}") if is_dynamic_ip(r): - line(f"dhcp-host=set:{name},{r['mac']},{r['hostname']},{d['lease_time']}") + line(f"dhcp-host=set:{name},{r['mac']},{h},{d['lease_time']}" if h else + f"dhcp-host=set:{name},{r['mac']},{d['lease_time']}") else: - line(f"dhcp-host=set:{name},{r['mac']},{r['ip']},{r['hostname']},{d['lease_time']}") + line(f"dhcp-host=set:{name},{r['mac']},{r['ip']},{h},{d['lease_time']}" if h else + f"dhcp-host=set:{name},{r['mac']},{r['ip']},{d['lease_time']}") else: # Multiple MACs share the same IP -- combine into one dhcp-host line - descs = ", ".join(r['description'] for r in group) - macs = ",".join(r['mac'] for r in group) - ip = group[0]['ip'] - # Use first entry's hostname; all share the same IP anyway - hostname = group[0]['hostname'] + descs = ", ".join(r['description'] for r in group) + macs = ",".join(r['mac'] for r in group) + ip = group[0]['ip'] + hostname = group[0].get('hostname', '') line(f"# {descs}") - line(f"dhcp-host=set:{name},{macs},{ip},{hostname},{d['lease_time']}") + line(f"dhcp-host=set:{name},{macs},{ip},{hostname},{d['lease_time']}" if hostname else + f"dhcp-host=set:{name},{macs},{ip},{d['lease_time']}") line() if inactive_res: diff --git a/routlin/validation.py b/routlin/validation.py index 05662d9..e9460aa 100644 --- a/routlin/validation.py +++ b/routlin/validation.py @@ -739,8 +739,9 @@ def validate_config(data): f"pool ({pool_start} - {pool_end})." ) - seen_res_ips = {} - seen_res_macs = {} + seen_res_ips = {} + seen_res_macs = {} + seen_res_hostnames = {} 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", "?") @@ -784,6 +785,16 @@ def validate_config(data): else: seen_res_macs[rmac] = rdesc + rhost = r.get("hostname", "").strip().lower() + if rhost: + if rhost in seen_res_hostnames: + errors.append( + f"{label}: reservation '{rdesc}' hostname '{rhost}' duplicates " + f"'{seen_res_hostnames[rhost]}'." + ) + else: + seen_res_hostnames[rhost] = rdesc + for bl_name in vlan.get("use_blocklists", []): if bl_name not in blocklists_by_name: errors.append(f"{label}: use_blocklists references unknown blocklist '{bl_name}'.")