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
+20
View File
@@ -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",
+2 -2
View File
@@ -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 "",
+95 -22
View File
@@ -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: