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
This commit is contained in:
2026-07-09 00:58:09 +00:00
parent 5135de0921
commit 803258cf18
7 changed files with 143 additions and 35 deletions
+22 -6
View File
@@ -30,6 +30,7 @@ from lib.common import (
config_hash, config_hash,
deep_merge, deep_merge,
ensure_dirs, ensure_dirs,
get_interface_ip,
load_json, load_json,
run, run,
save_json, save_json,
@@ -44,7 +45,7 @@ DATA_DIR = PROJECT_DIR / "data" / "dnsmasq"
CONFIG_PATH = CONFIG_DIR / "config.json" CONFIG_PATH = CONFIG_DIR / "config.json"
FRAGMENTS_DIR = DATA_DIR / "fragments" FRAGMENTS_DIR = DATA_DIR / "fragments"
DNSMASQ_CONF = "/etc/dnsmasq.d/vacuum-wall.conf" DNSMASQ_CONF = "/etc/dnsmasq.d/vacuum-wall.conf"
LEASE_FILE = "/var/lib/dnsmasq/dnsmasq.leases" LEASE_FILE = "/var/lib/misc/dnsmasq.leases"
ENV = Environment( ENV = Environment(
loader=FileSystemLoader(str(PROJECT_DIR / "system")), 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 Endpoint: POST /dnsmasq/ranges/add
Add or update a DHCP pool range by interface. Raises ValueError on invalid input. 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: if not body:
raise ValueError("Request body required") raise ValueError("Request body required")
iface = body.get("interface", "").strip() or "" iface = (body.get("interface") or "").strip()
start = body.get("start", "").strip() start = body.get("start", "").strip()
end = body.get("end", "").strip() end = body.get("end", "").strip()
lease_time = body.get("lease_time", "12h") lease_time = body.get("lease_time", "12h")
if not start or not end: if not start or not end:
raise ValueError("'start' and 'end' are required") 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() cfg = _get_config()
ranges = cfg["dhcp"]["ranges"] ranges = cfg["dhcp"]["ranges"]
found = False found = False
@@ -245,8 +261,8 @@ def set_dhcp_range(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]
"end": end, "end": end,
"lease_time": lease_time, "lease_time": lease_time,
} }
if body.get("gateway"): if gateway:
ranges[i]["gateway"] = body["gateway"] ranges[i]["gateway"] = gateway
if body.get("dns"): if body.get("dns"):
ranges[i]["dns"] = body["dns"] ranges[i]["dns"] = body["dns"]
found = True found = True
@@ -258,8 +274,8 @@ def set_dhcp_range(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]
"end": end, "end": end,
"lease_time": lease_time, "lease_time": lease_time,
} }
if body.get("gateway"): if gateway:
entry["gateway"] = body["gateway"] entry["gateway"] = gateway
if body.get("dns"): if body.get("dns"):
entry["dns"] = body["dns"] entry["dns"] = body["dns"]
ranges.append(entry) ranges.append(entry)
+20
View File
@@ -169,11 +169,31 @@ def ensure_dirs(*dirs: Path) -> None:
d.mkdir(parents=True, exist_ok=True) 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__ = [ __all__ = [
"_APPLY_HASH_KEY", "_APPLY_HASH_KEY",
"config_hash", "config_hash",
"deep_merge", "deep_merge",
"ensure_dirs", "ensure_dirs",
"get_interface_ip",
"load_json", "load_json",
"run", "run",
"run_proc", "run_proc",
+2 -2
View File
@@ -523,7 +523,7 @@ def _collect_dnsmasq() -> dict[str, Any]:
CONFIG_DIR = PROJECT_DIR / "config" / "dnsmasq" CONFIG_DIR = PROJECT_DIR / "config" / "dnsmasq"
CONFIG_PATH = CONFIG_DIR / "config.json" CONFIG_PATH = CONFIG_DIR / "config.json"
DNSMASQ_CONF = "/etc/dnsmasq.d/vacuum-wall.conf" 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] = { DEFAULT_CFG: dict[str, Any] = {
"dhcp": {"ranges": [], "static_leases": []}, "dhcp": {"ranges": [], "static_leases": []},
@@ -575,7 +575,7 @@ def _collect_dnsmasq() -> dict[str, Any]:
ts = None ts = None
leases.append( leases.append(
{ {
"expires_at": ts, "expires": ts.isoformat() if ts else "",
"mac": parts[1], "mac": parts[1],
"ip": parts[2], "ip": parts[2],
"hostname": parts[3] if len(parts) > 3 else "", "hostname": parts[3] if len(parts) > 3 else "",
+95 -22
View File
@@ -10,6 +10,8 @@ from collections.abc import Callable
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any from typing import Any
from lib.common import get_interface_ip
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -235,7 +237,13 @@ def _safe_name(obj: object) -> str:
class DnsToFirewallSync: 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 @classmethod
def on_dnsmasq_config_saved(cls, event: SyncEvent) -> SyncResult | None: def on_dnsmasq_config_saved(cls, event: SyncEvent) -> SyncResult | None:
@@ -243,7 +251,12 @@ class DnsToFirewallSync:
return None return None
try: 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 get_config as _get_fw_cfg
from lib.firewall import save_config as _save_fw_cfg from lib.firewall import save_config as _save_fw_cfg
@@ -280,9 +293,10 @@ class DnsToFirewallSync:
continue continue
zone_ifaces[zname] = set(zdata.get("interfaces", [])) 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(): for zname, _ifaces in active_zones.items():
zdata = zones.get(zname, {}) zdata = zones.get(zname, {})
if not isinstance(zdata, dict): if not isinstance(zdata, dict):
@@ -295,15 +309,14 @@ class DnsToFirewallSync:
added.append(svc) added.append(svc)
zdata["services"] = services zdata["services"] = services
if added: if added:
changes.append( firewall_changes.append(
f"Added {', '.join(added)} service(s) to zone '{zname}'" f"Added {', '.join(added)} service(s) to zone '{zname}'"
) )
if not zdata.get("masquerade"): # Back-propagate: ensure DHCP ranges have gateway set
zdata["masquerade"] = True cls._ensure_gateways(dnsmasq_cfg, zname, _ifaces, dnsmasq_changes)
changes.append(f"Enabled masquerade on zone '{zname}'")
# For inactive zones: remove dhcp/dns services, disable masquerade # For inactive zones: remove dhcp/dns services
for zname, zdata in zones.items(): for zname, zdata in zones.items():
if not isinstance(zdata, dict): if not isinstance(zdata, dict):
continue continue
@@ -320,26 +333,52 @@ class DnsToFirewallSync:
removed.append(svc) removed.append(svc)
zdata["services"] = services zdata["services"] = services
if removed: if removed:
changes.append( firewall_changes.append(
f"Removed {', '.join(removed)} service(s) from zone '{zname}'" f"Removed {', '.join(removed)} service(s) from zone '{zname}'"
) )
if zdata.get("masquerade"): result = SyncResult(changes=firewall_changes + dnsmasq_changes)
zdata["masquerade"] = False
changes.append(f"Disabled masquerade on zone '{zname}'")
if changes: # Save firewall config if changed
if firewall_changes:
fw_cfg["zones"] = zones fw_cfg["zones"] = zones
_save_fw_cfg(fw_cfg) _save_fw_cfg(fw_cfg)
return SyncResult( result.affected_subsystems.append("firewall")
affected_subsystems=["firewall"],
changes=changes, # Save dnsmasq config if gateways were set
) if dnsmasq_changes:
return SyncResult(changes=changes) _save_dnsmasq_cfg(dnsmasq_cfg)
result.affected_subsystems.append("dnsmasq")
return result
except Exception: except Exception:
logger.exception("DnsToFirewallSync failed") logger.exception("DnsToFirewallSync failed")
return SyncResult() 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: class WgToFirewallSync:
"""Sync subscriber: wireguard config_saved → update firewall config.""" """Sync subscriber: wireguard config_saved → update firewall config."""
@@ -510,8 +549,9 @@ class FirewallToDhcpSync:
"""Sync subscriber: firewall config_saved → sync dnsmasq DHCP ranges. """Sync subscriber: firewall config_saved → sync dnsmasq DHCP ranges.
Removes DHCP ranges whose interface no longer belongs to any firewall Removes DHCP ranges whose interface no longer belongs to any firewall
zone. Also logs warnings for zones with dhcp service but no range zone. When masquerade is enabled on a zone, ensures DHCP ranges on
(cannot auto-create a range without knowing IP addresses). that zone's interfaces carry the gateway (interface IP).
Logs warnings for zones with dhcp service but no range.
""" """
@classmethod @classmethod
@@ -570,6 +610,39 @@ class FirewallToDhcpSync:
) )
changes.append(f"Removed stale DHCP range on interface '{iface}'") 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) # Zones with dhcp service but no matching range (warn only)
for iface in sorted(dhcp_service_ifaces - range_ifaces): for iface in sorted(dhcp_service_ifaces - range_ifaces):
logger.info( logger.info(
@@ -579,7 +652,7 @@ class FirewallToDhcpSync:
changes.append(f"Zone has dhcp service on '{iface}' but no DHCP range") changes.append(f"Zone has dhcp service on '{iface}' but no DHCP range")
return SyncResult( return SyncResult(
affected_subsystems=["dnsmasq"] if stale_ifaces else [], affected_subsystems=["dnsmasq"] if changed or stale_ifaces else [],
changes=changes, changes=changes,
) )
except Exception: except Exception:
+2 -1
View File
@@ -20,7 +20,7 @@ Defaults:{{ USER_DAEMON_NAME }} secure_path="/usr/local/sbin:/usr/local/bin:/usr
# Dnsmasq management # Dnsmasq management
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/systemctl restart dnsmasq {{ 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/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/mkdir -p /etc/dnsmasq.d
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/cp * /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 # 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 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
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/sbin/ip -o addr show *
# Networkd management # Networkd management
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /bin/networkctl status * {{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /bin/networkctl status *
+1 -3
View File
@@ -229,7 +229,7 @@ class TestDnsToFirewallSync:
@patch("lib.firewall.save_config") @patch("lib.firewall.save_config")
@patch("lib.firewall.get_config") @patch("lib.firewall.get_config")
@patch("lib.dnsmasq.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 = { mock_dm_get.return_value = {
"dhcp": { "dhcp": {
"ranges": [ "ranges": [
@@ -262,7 +262,6 @@ class TestDnsToFirewallSync:
saved_cfg = mock_fw_save.call_args[0][0] saved_cfg = mock_fw_save.call_args[0][0]
assert "dhcp" in saved_cfg["zones"]["internal"]["services"] assert "dhcp" in saved_cfg["zones"]["internal"]["services"]
assert "dns" 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.save_config")
@patch("lib.firewall.get_config") @patch("lib.firewall.get_config")
@@ -288,7 +287,6 @@ class TestDnsToFirewallSync:
saved_cfg = mock_fw_save.call_args[0][0] saved_cfg = mock_fw_save.call_args[0][0]
assert "dhcp" not in saved_cfg["zones"]["internal"]["services"] assert "dhcp" not in saved_cfg["zones"]["internal"]["services"]
assert "dns" 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.save_config")
@patch("lib.firewall.get_config") @patch("lib.firewall.get_config")
+1 -1
View File
@@ -63,7 +63,7 @@ export default definePage({
onClick=${() => MultiSelectModal({ onClick=${() => MultiSelectModal({
title: 'Interfaces: ' + name, title: 'Interfaces: ' + name,
url: '/api/firewall/zones/' + enc(name) + '/interfaces', url: '/api/firewall/zones/' + enc(name) + '/interfaces',
options: state.firewall.data?.interfaces || [], options: (state.firewall.data?.interfaces || []).map(i => i.name),
selected: ifacesArr, selected: ifacesArr,
fieldKey: 'interfaces', fieldKey: 'interfaces',
successMsg: 'Interfaces updated', successMsg: 'Interfaces updated',