From 803258cf18fb630ebfb87af416e8ec55867bd4c1 Mon Sep 17 00:00:00 2001 From: Mike Teehan Date: Thu, 9 Jul 2026 00:58:09 +0000 Subject: [PATCH] dhcp: auto-populate gateway from interface IP for DHCP ranges Add get_interface_ip() helper to resolve an interface's IPv4 address via 'ip -o addr show'. Use it to back-propagate gateway into DHCP ranges so clients receive their default route. - set_dhcp_range() resolves gateway: explicit > existing range > iface IP - DnsToFirewallSync and FirewallToDhcpSync sync ensure gateways are set - Remove automatic masquerade toggle from DnsToFirewallSync - Fix dnsmasq lease file path to /var/lib/misc/dnsmasq.leases - Rename lease state field expires_at -> expires (ISO string) - Add 'ip -o addr show' to sudo whitelist --- daemon/handlers/dnsmasq.py | 28 ++++++-- lib/common.py | 20 ++++++ lib/state.py | 4 +- lib/sync.py | 117 +++++++++++++++++++++++++++------- system/sudoers.d/vacuum-walld | 3 +- tests/test_sync.py | 4 +- webui/static/pages/zones.js | 2 +- 7 files changed, 143 insertions(+), 35 deletions(-) diff --git a/daemon/handlers/dnsmasq.py b/daemon/handlers/dnsmasq.py index bf818e3..d2665c1 100644 --- a/daemon/handlers/dnsmasq.py +++ b/daemon/handlers/dnsmasq.py @@ -30,6 +30,7 @@ from lib.common import ( config_hash, deep_merge, ensure_dirs, + get_interface_ip, load_json, run, save_json, @@ -44,7 +45,7 @@ DATA_DIR = PROJECT_DIR / "data" / "dnsmasq" CONFIG_PATH = CONFIG_DIR / "config.json" FRAGMENTS_DIR = DATA_DIR / "fragments" DNSMASQ_CONF = "/etc/dnsmasq.d/vacuum-wall.conf" -LEASE_FILE = "/var/lib/dnsmasq/dnsmasq.leases" +LEASE_FILE = "/var/lib/misc/dnsmasq.leases" ENV = Environment( loader=FileSystemLoader(str(PROJECT_DIR / "system")), @@ -225,15 +226,30 @@ def set_dhcp_range(_request: Any, body: dict[str, Any] | None) -> dict[str, Any] Endpoint: POST /dnsmasq/ranges/add Add or update a DHCP pool range by interface. Raises ValueError on invalid input. + Auto-populates ``gateway`` from the interface's IPv4 address when not provided. """ if not body: raise ValueError("Request body required") - iface = body.get("interface", "").strip() or "" + iface = (body.get("interface") or "").strip() start = body.get("start", "").strip() end = body.get("end", "").strip() lease_time = body.get("lease_time", "12h") if not start or not end: raise ValueError("'start' and 'end' are required") + + # Resolve gateway: explicit value > existing range value > interface IP + gateway = body.get("gateway") + if not gateway: + # Check existing range for same interface + cfg_tmp = _get_config() + for r in cfg_tmp["dhcp"]["ranges"]: + if r.get("interface") == iface: + gateway = r.get("gateway") + break + # Fall back to interface's own IP + if not gateway and iface: + gateway = get_interface_ip(iface) + cfg = _get_config() ranges = cfg["dhcp"]["ranges"] found = False @@ -245,8 +261,8 @@ def set_dhcp_range(_request: Any, body: dict[str, Any] | None) -> dict[str, Any] "end": end, "lease_time": lease_time, } - if body.get("gateway"): - ranges[i]["gateway"] = body["gateway"] + if gateway: + ranges[i]["gateway"] = gateway if body.get("dns"): ranges[i]["dns"] = body["dns"] found = True @@ -258,8 +274,8 @@ def set_dhcp_range(_request: Any, body: dict[str, Any] | None) -> dict[str, Any] "end": end, "lease_time": lease_time, } - if body.get("gateway"): - entry["gateway"] = body["gateway"] + if gateway: + entry["gateway"] = gateway if body.get("dns"): entry["dns"] = body["dns"] ranges.append(entry) diff --git a/lib/common.py b/lib/common.py index 5425be3..185a194 100644 --- a/lib/common.py +++ b/lib/common.py @@ -169,11 +169,31 @@ def ensure_dirs(*dirs: Path) -> None: d.mkdir(parents=True, exist_ok=True) +def get_interface_ip(iface: str) -> str | None: + """Return the primary IPv4 address of *iface* (without CIDR), or ``None``. + + Uses ``ip -o addr show`` which is in the daemon sudo whitelist. + """ + if not iface: + return None + try: + raw = run(["ip", "-o", "addr", "show", iface], sudo=True) + for line in raw.splitlines(): + parts = line.split() + # -o format: "NUM: IFACE inet/6 ADDR/MASK ..." + if len(parts) >= 4 and parts[2] == "inet": + return parts[3].split("/", 1)[0] + except Exception: + pass + return None + + __all__ = [ "_APPLY_HASH_KEY", "config_hash", "deep_merge", "ensure_dirs", + "get_interface_ip", "load_json", "run", "run_proc", diff --git a/lib/state.py b/lib/state.py index 96f9768..9054910 100644 --- a/lib/state.py +++ b/lib/state.py @@ -523,7 +523,7 @@ def _collect_dnsmasq() -> dict[str, Any]: CONFIG_DIR = PROJECT_DIR / "config" / "dnsmasq" CONFIG_PATH = CONFIG_DIR / "config.json" DNSMASQ_CONF = "/etc/dnsmasq.d/vacuum-wall.conf" - LEASE_FILE = "/var/lib/dnsmasq/dnsmasq.leases" + LEASE_FILE = "/var/lib/misc/dnsmasq.leases" DEFAULT_CFG: dict[str, Any] = { "dhcp": {"ranges": [], "static_leases": []}, @@ -575,7 +575,7 @@ def _collect_dnsmasq() -> dict[str, Any]: ts = None leases.append( { - "expires_at": ts, + "expires": ts.isoformat() if ts else "", "mac": parts[1], "ip": parts[2], "hostname": parts[3] if len(parts) > 3 else "", diff --git a/lib/sync.py b/lib/sync.py index 659911b..b5acebf 100644 --- a/lib/sync.py +++ b/lib/sync.py @@ -10,6 +10,8 @@ from collections.abc import Callable from dataclasses import dataclass, field from typing import Any +from lib.common import get_interface_ip + logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- @@ -235,7 +237,13 @@ def _safe_name(obj: object) -> str: class DnsToFirewallSync: - """Sync subscriber: dnsmasq config_saved → update firewall config.""" + """Sync subscriber: dnsmasq config_saved → update firewall config. + + Adds/removes dhcp and dns services on firewall zones based on + which interfaces have DHCP ranges. Back-propagates: ensures + DHCP ranges carry the gateway (interface IP) so clients get + their default route. + """ @classmethod def on_dnsmasq_config_saved(cls, event: SyncEvent) -> SyncResult | None: @@ -243,7 +251,12 @@ class DnsToFirewallSync: return None try: - from lib.dnsmasq import get_config as _get_dnsmasq_cfg + from lib.dnsmasq import ( + get_config as _get_dnsmasq_cfg, + ) + from lib.dnsmasq import ( + save_config as _save_dnsmasq_cfg, + ) from lib.firewall import get_config as _get_fw_cfg from lib.firewall import save_config as _save_fw_cfg @@ -280,9 +293,10 @@ class DnsToFirewallSync: continue zone_ifaces[zname] = set(zdata.get("interfaces", [])) - changes: list[str] = [] + firewall_changes: list[str] = [] + dnsmasq_changes: list[str] = [] - # For active zones: ensure dhcp/dns services and masquerade + # For active zones: ensure dhcp/dns services for zname, _ifaces in active_zones.items(): zdata = zones.get(zname, {}) if not isinstance(zdata, dict): @@ -295,15 +309,14 @@ class DnsToFirewallSync: added.append(svc) zdata["services"] = services if added: - changes.append( + firewall_changes.append( f"Added {', '.join(added)} service(s) to zone '{zname}'" ) - if not zdata.get("masquerade"): - zdata["masquerade"] = True - changes.append(f"Enabled masquerade on zone '{zname}'") + # Back-propagate: ensure DHCP ranges have gateway set + cls._ensure_gateways(dnsmasq_cfg, zname, _ifaces, dnsmasq_changes) - # For inactive zones: remove dhcp/dns services, disable masquerade + # For inactive zones: remove dhcp/dns services for zname, zdata in zones.items(): if not isinstance(zdata, dict): continue @@ -320,26 +333,52 @@ class DnsToFirewallSync: removed.append(svc) zdata["services"] = services if removed: - changes.append( + firewall_changes.append( f"Removed {', '.join(removed)} service(s) from zone '{zname}'" ) - if zdata.get("masquerade"): - zdata["masquerade"] = False - changes.append(f"Disabled masquerade on zone '{zname}'") + result = SyncResult(changes=firewall_changes + dnsmasq_changes) - if changes: + # Save firewall config if changed + if firewall_changes: fw_cfg["zones"] = zones _save_fw_cfg(fw_cfg) - return SyncResult( - affected_subsystems=["firewall"], - changes=changes, - ) - return SyncResult(changes=changes) + result.affected_subsystems.append("firewall") + + # Save dnsmasq config if gateways were set + if dnsmasq_changes: + _save_dnsmasq_cfg(dnsmasq_cfg) + result.affected_subsystems.append("dnsmasq") + + return result except Exception: logger.exception("DnsToFirewallSync failed") return SyncResult() + @staticmethod + def _ensure_gateways( + dnsmasq_cfg: dict[str, Any], + zone_name: str, + zone_ifaces: set[str], + changes: list[str], + ) -> None: + """Ensure DHCP ranges for *zone_ifaces* carry the gateway (interface IP).""" + from lib.common import get_interface_ip + + ranges = dnsmasq_cfg.get("dhcp", {}).get("ranges", []) + for r in ranges: + iface = r.get("interface", "") + if iface not in zone_ifaces: + continue + if r.get("gateway"): + continue + ip = get_interface_ip(iface) + if ip: + r["gateway"] = ip + changes.append( + f"Set DHCP gateway {ip} for range on '{iface}' (zone '{zone_name}')" + ) + class WgToFirewallSync: """Sync subscriber: wireguard config_saved → update firewall config.""" @@ -510,8 +549,9 @@ class FirewallToDhcpSync: """Sync subscriber: firewall config_saved → sync dnsmasq DHCP ranges. Removes DHCP ranges whose interface no longer belongs to any firewall - zone. Also logs warnings for zones with dhcp service but no range - (cannot auto-create a range without knowing IP addresses). + zone. When masquerade is enabled on a zone, ensures DHCP ranges on + that zone's interfaces carry the gateway (interface IP). + Logs warnings for zones with dhcp service but no range. """ @classmethod @@ -570,6 +610,39 @@ class FirewallToDhcpSync: ) changes.append(f"Removed stale DHCP range on interface '{iface}'") + # When masquerade is enabled on a zone, ensure DHCP ranges have gateway + changed = False + for zname, zdata in zones.items(): + if not isinstance(zdata, dict): + continue + if not zdata.get("masquerade"): + continue + # Find DHCP ranges on this zone's interfaces + z_ifaces = set(zdata.get("interfaces", [])) + ranges = dnsmasq_cfg.get("dhcp", {}).get("ranges", []) + for r in ranges: + iface = r.get("interface", "") + if not iface or iface not in z_ifaces: + continue + if r.get("gateway"): + continue + ip = get_interface_ip(iface) + if ip: + r["gateway"] = ip + changed = True + logger.info( + "Set DHCP gateway %s on '%s' (masquerade on zone '%s')", + ip, + iface, + zname, + ) + changes.append( + f"Set DHCP gateway {ip} for range on '{iface}' (zone '{zname}')" + ) + + if changed: + _save_dnsmasq_cfg(dnsmasq_cfg) + # Zones with dhcp service but no matching range (warn only) for iface in sorted(dhcp_service_ifaces - range_ifaces): logger.info( @@ -579,7 +652,7 @@ class FirewallToDhcpSync: changes.append(f"Zone has dhcp service on '{iface}' but no DHCP range") return SyncResult( - affected_subsystems=["dnsmasq"] if stale_ifaces else [], + affected_subsystems=["dnsmasq"] if changed or stale_ifaces else [], changes=changes, ) except Exception: diff --git a/system/sudoers.d/vacuum-walld b/system/sudoers.d/vacuum-walld index 47941a2..91414a6 100644 --- a/system/sudoers.d/vacuum-walld +++ b/system/sudoers.d/vacuum-walld @@ -20,7 +20,7 @@ Defaults:{{ USER_DAEMON_NAME }} secure_path="/usr/local/sbin:/usr/local/bin:/usr # Dnsmasq management {{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/systemctl restart dnsmasq {{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/systemctl is-active dnsmasq -{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/cat /var/lib/dnsmasq/dnsmasq.leases +{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/cat /var/lib/misc/dnsmasq.leases {{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/mkdir -p /etc/dnsmasq.d {{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/cp * /etc/dnsmasq.d/* @@ -33,6 +33,7 @@ Defaults:{{ USER_DAEMON_NAME }} secure_path="/usr/local/sbin:/usr/local/bin:/usr # Network interface queries {{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/sbin/ip -o link show {{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/sbin/ip -o addr show +{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/sbin/ip -o addr show * # Networkd management {{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /bin/networkctl status * diff --git a/tests/test_sync.py b/tests/test_sync.py index 3c60ae9..23c4fb5 100644 --- a/tests/test_sync.py +++ b/tests/test_sync.py @@ -229,7 +229,7 @@ class TestDnsToFirewallSync: @patch("lib.firewall.save_config") @patch("lib.firewall.get_config") @patch("lib.dnsmasq.get_config") - def test_adds_dhcp_dns_masquerade(self, mock_dm_get, mock_fw_get, mock_fw_save): + def test_adds_dhcp_dns(self, mock_dm_get, mock_fw_get, mock_fw_save): mock_dm_get.return_value = { "dhcp": { "ranges": [ @@ -262,7 +262,6 @@ class TestDnsToFirewallSync: saved_cfg = mock_fw_save.call_args[0][0] assert "dhcp" in saved_cfg["zones"]["internal"]["services"] assert "dns" in saved_cfg["zones"]["internal"]["services"] - assert saved_cfg["zones"]["internal"]["masquerade"] is True @patch("lib.firewall.save_config") @patch("lib.firewall.get_config") @@ -288,7 +287,6 @@ class TestDnsToFirewallSync: saved_cfg = mock_fw_save.call_args[0][0] assert "dhcp" not in saved_cfg["zones"]["internal"]["services"] assert "dns" not in saved_cfg["zones"]["internal"]["services"] - assert saved_cfg["zones"]["internal"]["masquerade"] is False @patch("lib.firewall.save_config") @patch("lib.firewall.get_config") diff --git a/webui/static/pages/zones.js b/webui/static/pages/zones.js index 8626d49..a1d021f 100644 --- a/webui/static/pages/zones.js +++ b/webui/static/pages/zones.js @@ -63,7 +63,7 @@ export default definePage({ onClick=${() => MultiSelectModal({ title: 'Interfaces: ' + name, url: '/api/firewall/zones/' + enc(name) + '/interfaces', - options: state.firewall.data?.interfaces || [], + options: (state.firewall.data?.interfaces || []).map(i => i.name), selected: ifacesArr, fieldKey: 'interfaces', successMsg: 'Interfaces updated',