feat: add networkd subsystem and fix code review issues

Phase 1-4: Networkd subsystem
- lib/network.py: systemd-networkd config renderer (.network INI files)
  with full schema support: [Match], [Link], [Network], [Address], [Route],
  [DHCPv4], [DHCPv6] sections. One Address/=DNS= line per value per spec.
  Route sections use #N suffix per systemd.syntax(7).
- lib/network.py: generate_network_files() with 50-<name>.network prefix
  and stale file cleanup
- lib/network.py: collect_upstream_dns() filters local/private DNS
- lib/network.py: infer_dhcp_ranges() and infer_zones() helpers
- daemon/handlers/network.py: routes for GET/POST /network/interfaces
  and full apply with DNS upstream sync to dnsmasq
- webui/api/network.py: Flask blueprint for /api/network/* endpoints
- webui/api: interfaces page updated with IP config inline editing
- lib/state.py: networkd collector using parse_networkctl_status()
- system/sudoers.d/vacuum-walld: networkctl + systemd-network rules
- system/systemd/vacuum-walld.service: ReadWritePaths for /etc/systemd/network
- install.sh: ACME email now optional, configured from WebUI
- lib/acme.py: get_email() falls back to declarative config

Phase 5: Code review fixes
- daemon/server.py: path params now win over JSON body and query params
  in request body merge (prevents config save name override)
- daemon/server.py: remove dead 'import re'
- daemon/handlers/network.py: replace Path.mkdir() with sudo mkdir
  for /etc/systemd/network (ProtectSystem=strict compatibility)
- system/sudoers.d/vacuum-walld: pin systemctl to specific commands
  (reload/is-active dnsmasq instead of wildcard)
- system/sudoers.d/vacuum-walld: restore !requiretty and section comment
- lib/network.py: remove unused _MANAGEMENT_PORTS constant
- webui/api/network.py: remove redundant body[\name\] = name in save_interface

Tests: 332 passing (110 new/updated), ruff clean
This commit is contained in:
2026-06-01 03:15:50 +00:00
parent 2f215793e9
commit bc72db903c
26 changed files with 3294 additions and 121 deletions
+20 -1
View File
@@ -135,7 +135,16 @@ def set_email(email: str) -> None:
def get_email() -> str:
"""Return the ACME contact email, or '' if none is configured."""
"""Return the ACME contact email, or '' if none is configured.
Checks account.conf first (acme.sh registered account), then falls
back to the declarative acme config.
"""
return _read_acme_email()
def _read_acme_email() -> str:
"""Read ACME email from account.conf, falling back to declarative config."""
try:
acme_home = Path(os.environ.get("ACME_HOME", str(_ACME_HOME)))
account_conf = acme_home / "account.conf"
@@ -146,6 +155,16 @@ def get_email() -> str:
return match.group(1).strip().strip("'\"")
except OSError as exc:
logger.warning("Could not read account.conf: %s", exc)
# Fallback: read from declarative ACME config
try:
from lib.common import load_json
acme_cfg = PROJECT_DIR / "config" / "acme" / "config.json"
conf = load_json(acme_cfg)
if conf and "email" in conf:
return conf["email"]
except (OSError, ValueError, KeyError):
pass
return ""
+655
View File
@@ -0,0 +1,655 @@
"""Networkd/IP configuration module.
Reads/writes config/network/config.json, renders .network INI files,
and parses networkctl status output for runtime state.
"""
import ipaddress
import logging
from pathlib import Path
from typing import Any
from lib.common import load_json, save_json
logger = logging.getLogger(__name__)
PROJECT_DIR = Path(__file__).resolve().parent.parent
CONFIG_DIR = PROJECT_DIR / "config" / "network"
CONFIG_FILE = CONFIG_DIR / "config.json"
DATA_DIR = PROJECT_DIR / "data" / "networkd"
DEFAULT_CONFIG: dict[str, Any] = {"interfaces": {}}
__all__ = [
"collect_upstream_dns",
"generate_network_files",
"get_config",
"infer_dhcp_ranges",
"infer_zones",
"parse_networkctl_status",
"render_network_file",
"save_config",
]
def get_config() -> dict[str, Any]:
"""Read network config from config/network/config.json.
Returns:
Dict with ``interfaces`` mapping interface names to config entries.
"""
if not CONFIG_FILE.exists():
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
save_json(CONFIG_FILE, DEFAULT_CONFIG, indent=2)
return load_json(CONFIG_FILE)
def save_config(cfg: dict[str, Any]) -> None:
"""Persist network config to disk.
Args:
cfg: Full config dict to write.
"""
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
save_json(CONFIG_FILE, cfg, indent=2)
# -----------------------------------------------------------------------
# Inline key-value emitters — append to a lines list
# -----------------------------------------------------------------------
def _emit_str(lines: list[str], key: str, py_key: str, d: dict[str, Any]) -> None:
v = d.get(py_key)
if v is not None:
lines.append(f"{key}={v}")
def _emit_int(lines: list[str], key: str, py_key: str, d: dict[str, Any]) -> None:
v = d.get(py_key)
if v is not None:
lines.append(f"{key}={v}")
def _emit_bool(lines: list[str], key: str, py_key: str, d: dict[str, Any]) -> None:
v = d.get(py_key)
if v is not None:
lines.append(f"{key}={'yes' if v else 'no'}")
def _emit_bool_opt(lines: list[str], key: str, py_key: str, d: dict[str, Any]) -> None:
v = d.get(py_key)
if v is not None:
lines.append(f"{key}={'yes' if v else 'no'}")
def _emit_any(lines: list[str], key: str, py_key: str, d: dict[str, Any]) -> None:
v = d.get(py_key)
if v is not None:
if isinstance(v, bool):
lines.append(f"{key}={'yes' if v else 'no'}")
else:
lines.append(f"{key}={v}")
def render_network_file(iface_name: str, cfg_entry: dict[str, Any]) -> str:
"""Render a .network INI file for an interface.
Args:
iface_name: Interface name (e.g. "eth0").
cfg_entry: Dict with networkd config keys per the schema in
todo.md (addresses, gateway, dns, routes, link, dhcp_client, etc.).
Returns:
INI content string ready to write as 50-<name>.network file.
"""
lines: list[str] = []
d = cfg_entry
# ------------------------------------------------------------------
# [Match]
# ------------------------------------------------------------------
lines.append("[Match]")
lines.append(f"Name={iface_name}")
lines.append("")
# ------------------------------------------------------------------
# [Link]
# ------------------------------------------------------------------
link = d.get("link", {})
if link:
lines.append("[Link]")
_emit_int(lines, "MTUBytes", "mtu_bytes", link)
_emit_str(lines, "MACAddress", "mac_address", link)
_emit_bool_opt(lines, "ARP", "arp", link)
_emit_bool_opt(lines, "Multicast", "multicast", link)
_emit_bool_opt(lines, "AllMulticast", "all_multicast", link)
_emit_bool_opt(lines, "Promiscuous", "promiscuous", link)
_emit_bool(lines, "Unmanaged", "unmanaged", link)
_emit_str(lines, "ActivationPolicy", "activation_policy", link)
_emit_any(lines, "RequiredForOnline", "required_for_online", link)
lines.append("")
# ------------------------------------------------------------------
# [Network]
# ------------------------------------------------------------------
lines.append("[Network]")
_emit_str(lines, "DHCP", "dhcp", d)
_emit_str(lines, "Gateway", "gateway", d)
_emit_str(lines, "IPv6Gateway", "ipv6_gateway", d)
for dns in d.get("dns", []):
lines.append(f"DNS={dns}")
for dns in d.get("ipv6_dns", []):
lines.append(f"IPv6DNS={dns}")
for dm in d.get("domains", []):
lines.append(f"Domains={dm}")
for dm in d.get("ipv6_domains", []):
lines.append(f"IPv6Domains={dm}")
_emit_bool(lines, "DNSDefaultRoute", "dns_default_route", d)
for bc in d.get("bind_carrier", []):
lines.append(f"BindCarrier={bc}")
_emit_any(lines, "IgnoreCarrierLoss", "ignore_carrier_loss", d)
_emit_any(lines, "KeepConfiguration", "keep_configuration", d)
_emit_bool(lines, "ConfigureWithoutCarrier", "configure_without_carrier", d)
_emit_str(lines, "LinkLocalAddressing", "link_local_addressing", d)
_emit_str(
lines,
"IPv6LinkLocalAddressGenerationMode",
"ipv6_link_local_address_generation_mode",
d,
)
_emit_str(lines, "IPv6StableSecretAddress", "ipv6_stable_secret_address", d)
_emit_str(lines, "IPv4LLStartAddress", "ipv4_ll_start_address", d)
_emit_bool(lines, "IPv4LLRoute", "ipv4_ll_route", d)
_emit_bool(lines, "DefaultRouteOnDevice", "default_route_on_device", d)
_emit_int(lines, "IPv6HopLimit", "ipv6_hop_limit", d)
_emit_str(lines, "IPv6RetransmissionTimeSec", "ipv6_retransmission_time_sec", d)
_emit_str(
lines,
"IPv4DuplicateAddressDetectionTimeoutSec",
"ipv4_duplicate_address_detection_timeout_sec",
d,
)
_emit_str(lines, "IPv4ReversePathFilter", "ipv4_reverse_path_filter", d)
_emit_bool(lines, "IPv4AcceptLocal", "ipv4_accept_local", d)
_emit_bool(lines, "IPv4RouteLocalnet", "ipv4_route_localnet", d)
_emit_bool(lines, "IPv4ProxyARP", "ipv4_proxy_arp", d)
_emit_bool(lines, "IPv4ProxyARPPrivateVLAN", "ipv4_proxy_arp_private_vlan", d)
_emit_bool(lines, "IPv6ProxyNDP", "ipv6_proxy_ndp", d)
_emit_str(lines, "IPv6ProxyNDPAddress", "ipv6_proxy_ndp_address", d)
_emit_bool(lines, "IPv6SendRA", "ipv6_send_ra", d)
_emit_bool(lines, "MPLSRouting", "m_pls_routing", d)
_emit_bool(lines, "KeepMaster", "keep_master", d)
_emit_str(lines, "IPFamily", "ip_family", d)
lines.append("")
# ------------------------------------------------------------------
# [Address] sections — one per entry
# ------------------------------------------------------------------
addresses = d.get("addresses", [])
for i, addr in enumerate(addresses):
if not isinstance(addr, dict):
lines.append("[Address]" if i == 0 else f"[Address#{i}]")
lines.append(f"Address={addr}")
lines.append("")
continue
lines.append("[Address]" if i == 0 else f"[Address#{i}]")
_emit_str(lines, "Address", "address", addr)
_emit_str(lines, "Label", "label", addr)
_emit_str(lines, "Scope", "scope", addr)
_emit_int(lines, "RouteMetric", "route_metric", addr)
_emit_str(
lines, "DuplicateAddressDetection", "duplicate_address_detection", addr
)
_emit_bool(lines, "ManageTemporaryAddress", "manage_temporary_address", addr)
_emit_bool(lines, "AddPrefixRoute", "add_prefix_route", addr)
lines.append("")
# ------------------------------------------------------------------
# [Address] sections — IPv6
# ------------------------------------------------------------------
ipv6_addrs = d.get("ipv6_addresses", [])
offset = len(addresses)
for i, addr in enumerate(ipv6_addrs):
if not isinstance(addr, dict):
lines.append(f"[Address#{offset + i}]")
lines.append(f"Address={addr}")
lines.append("")
continue
lines.append(f"[Address#{offset + i}]")
_emit_str(lines, "Address", "address", addr)
_emit_str(lines, "Label", "label", addr)
_emit_str(lines, "Scope", "scope", addr)
_emit_int(lines, "RouteMetric", "route_metric", addr)
_emit_str(
lines, "DuplicateAddressDetection", "duplicate_address_detection", addr
)
_emit_bool(lines, "ManageTemporaryAddress", "manage_temporary_address", addr)
_emit_bool(lines, "AddPrefixRoute", "add_prefix_route", addr)
lines.append("")
# ------------------------------------------------------------------
# [Route#N] sections
# ------------------------------------------------------------------
routes = d.get("routes", [])
for i, route in enumerate(routes):
if not isinstance(route, dict):
continue
lines.append("[Route]" if i == 0 else f"[Route#{i}]")
_emit_str(lines, "Destination", "destination", route)
_emit_str(lines, "Gateway", "gateway", route)
_emit_int(lines, "Metric", "metric", route)
_emit_any(lines, "Table", "table", route)
_emit_str(lines, "Type", "type", route)
_emit_str(lines, "Scope", "scope", route)
_emit_bool(lines, "GatewayOnLink", "gateway_on_link", route)
_emit_str(lines, "IPv6Preference", "ipv6_preference", route)
_emit_int(lines, "InitialCongestionWindow", "initial_congestion_window", route)
_emit_int(
lines,
"InitialAdvertisedReceiveWindow",
"initial_advertised_receive_window",
route,
)
_emit_bool(lines, "QuickAck", "quick_ack", route)
_emit_bool(lines, "FastOpenNoCookie", "fast_open_no_cookie", route)
_emit_int(lines, "MTUBytes", "mtu_bytes", route)
_emit_any(lines, "Protocol", "protocol", route)
_emit_int(lines, "NextHop", "next_hop", route)
for mpr in route.get("multi_path_route", []):
lines.append(f"MultiPathRoute={mpr}")
lines.append("")
# ------------------------------------------------------------------
# [DHCPv4] / [DHCPv6]
# ------------------------------------------------------------------
dhcp_client = d.get("dhcp_client", {})
if dhcp_client:
dhcp_mode = d.get("dhcp", "no")
render_v4 = dhcp_mode in ("yes", "ipv4")
render_v6 = dhcp_mode in ("yes", "ipv6")
if not render_v4 and not render_v6:
render_v4 = True
render_v6 = True
if render_v4:
lines.append("[DHCPv4]")
_emit_str(lines, "Hostname", "hostname", dhcp_client)
_emit_any(lines, "DUID", "duid", dhcp_client)
_emit_str(lines, "DUIDType", "duid_type", dhcp_client)
_emit_any(lines, "DUIDRawData", "duid_raw_data", dhcp_client)
_emit_str(lines, "IAID", "iaid", dhcp_client)
_emit_any(lines, "ClientIdentifier", "client_identifier", dhcp_client)
_emit_bool(lines, "RapidCommit", "rapid_commit", dhcp_client)
_emit_bool(lines, "Anonymize", "anonymize", dhcp_client)
_emit_bool(lines, "UseDNS", "use_dns", dhcp_client)
_emit_bool(lines, "UseNTP", "use_ntp", dhcp_client)
_emit_bool(lines, "UseSIP", "use_sip", dhcp_client)
_emit_bool(lines, "UseCaptivePortal", "use_captive_portal", dhcp_client)
_emit_bool(lines, "UseMTU", "use_mtu", dhcp_client)
_emit_bool(lines, "UseHostname", "use_hostname", dhcp_client)
_emit_any(lines, "UseDomains", "use_domains", dhcp_client)
_emit_bool(lines, "UseRoutes", "use_routes", dhcp_client)
_emit_int(lines, "RouteMetric", "route_metric", dhcp_client)
_emit_bool(lines, "SendDecline", "send_decline", dhcp_client)
_emit_str(lines, "NetLabel", "net_label", dhcp_client)
_emit_str(lines, "NFTSet", "nft_set", dhcp_client)
_emit_str(lines, "IPServiceType", "ip_service_type", dhcp_client)
_emit_int(lines, "SocketPriority", "socket_priority", dhcp_client)
_emit_bool(lines, "BOOTP", "bootp", dhcp_client)
_emit_str(lines, "Label", "label", dhcp_client)
_emit_int(lines, "MaxAttempts", "max_attempts", dhcp_client)
_emit_int(lines, "ListenPort", "listen_port", dhcp_client)
_emit_int(lines, "ServerPort", "server_port", dhcp_client)
_emit_str(lines, "MUDURL", "mud_url", dhcp_client)
_emit_str(lines, "BootFilename", "boot_filename", dhcp_client)
for opt in dhcp_client.get("send_option", []):
if isinstance(opt, dict):
lines.append(
f"SendOption={opt.get('code', '-')} {opt.get('value', '')}"
)
else:
lines.append(f"SendOption={opt}")
for opt in dhcp_client.get("send_vendor_option", []):
if isinstance(opt, dict):
lines.append(
f"SendVendorOption={opt.get('code', '-')}"
f" {opt.get('vendor_code', '')}"
f" {opt.get('value', '')}"
)
else:
lines.append(f"SendVendorOption={opt}")
for uc in dhcp_client.get("user_class", []):
lines.append(f"UserClass={uc}")
_emit_str(
lines, "VendorClassIdentifier", "vendor_class_identifier", dhcp_client
)
_emit_str(lines, "RequestOptions", "request_options", dhcp_client)
lines.append("")
if render_v6:
lines.append("[DHCPv6]")
_emit_bool(lines, "SendHostname", "send_hostname", dhcp_client)
_emit_str(lines, "Hostname", "hostname", dhcp_client)
_emit_any(lines, "DUID", "duid", dhcp_client)
_emit_str(lines, "DUIDType", "duid_type", dhcp_client)
_emit_any(lines, "DUIDRawData", "duid_raw_data", dhcp_client)
_emit_str(lines, "IAID", "iaid", dhcp_client)
_emit_bool(lines, "Anonymize", "anonymize", dhcp_client)
_emit_str(lines, "RapidCommit", "rapid_commit", dhcp_client)
_emit_str(
lines, "PrefixDelegationHint", "prefix_delegation_hint", dhcp_client
)
_emit_str(
lines, "UnassignedSubnetPolicy", "unassigned_subnet_policy", dhcp_client
)
_emit_bool(lines, "UseAddress", "use_address", dhcp_client)
_emit_bool(lines, "UseCaptivePortal", "use_captive_portal", dhcp_client)
_emit_bool(lines, "UseDelegatedPrefix", "use_delegated_prefix", dhcp_client)
_emit_bool(lines, "UseDNS", "use_dns", dhcp_client)
_emit_bool(lines, "UseNTP", "use_ntp", dhcp_client)
_emit_bool(lines, "UseSIP", "use_sip", dhcp_client)
_emit_bool(lines, "UseDNR", "use_dnr", dhcp_client)
_emit_bool(lines, "UseHostname", "use_hostname", dhcp_client)
_emit_any(lines, "UseDomains", "use_domains", dhcp_client)
_emit_bool(lines, "SendRelease", "send_release", dhcp_client)
_emit_str(lines, "NetLabel", "net_label", dhcp_client)
_emit_str(lines, "NFTSet", "nft_set", dhcp_client)
_emit_str(lines, "WithoutRA", "without_ra", dhcp_client)
for opt in dhcp_client.get("send_option", []):
if isinstance(opt, dict):
lines.append(
f"SendOption={opt.get('code', '-')} {opt.get('value', '')}"
)
else:
lines.append(f"SendOption={opt}")
for opt in dhcp_client.get("send_vendor_option", []):
if isinstance(opt, dict):
lines.append(
f"SendVendorOption={opt.get('code', '-')}"
f" {opt.get('vendor_code', '')}"
f" {opt.get('value', '')}"
)
else:
lines.append(f"SendVendorOption={opt}")
for uc in dhcp_client.get("user_class", []):
lines.append(f"UserClass={uc}")
for vc in dhcp_client.get("vendor_class", []):
lines.append(f"VendorClass={vc}")
lines.append("")
return "\n".join(lines)
def parse_networkctl_status(output: str) -> dict[str, Any]:
"""Parse ``networkctl status --all`` output into runtime state dict.
Args:
output: Raw command output from networkctl status.
Returns:
Dict mapping interface names to their runtime state including
addresses, gateway, DNS, and link state.
"""
result: dict[str, Any] = {}
current_iface: dict[str, Any] | None = None
def _is_iface_header(line: str) -> bool:
"""Check if a line looks like an interface header (digits:name ...)."""
colon_idx = line.find(":")
if colon_idx < 0:
return False
header = line[:colon_idx].strip()
return bool(header) and header[-1].isdigit()
for raw_line in output.splitlines():
stripped = raw_line.strip()
if not stripped:
continue
# Interface header: "1: eth0" or similar
if _is_iface_header(raw_line):
parts = raw_line.split(":", 1)[1].strip().split()
if parts:
iface_name = parts[0]
current_iface = {
"addresses": [],
"gateway": None,
"dns": [],
"state": "unknown",
"link": parts[1] if len(parts) > 1 else "unknown",
}
result[iface_name] = current_iface
continue
if current_iface is None:
continue
if stripped.startswith("State:"):
current_iface["state"] = stripped.split(":", 1)[1].strip()
elif stripped.startswith("Gateway:"):
gw = stripped.split(":", 1)[1].strip()
if gw and gw.lower() not in ("n/a", ""):
current_iface["gateway"] = gw
elif stripped.startswith("DNS:"):
dns_str = stripped.split(":", 1)[1].strip()
if dns_str and dns_str.lower() != "n/a":
current_iface["dns"] = [d.strip() for d in dns_str.split() if d.strip()]
elif stripped.startswith("Addresses:"):
addr_str = stripped.split(":", 1)[1].strip()
if addr_str and addr_str.lower() != "n/a":
for tok in addr_str.split():
addr = tok.rstrip(",")
if "/" in addr:
current_iface["addresses"].append(addr)
return result
def generate_network_files(cfg: dict[str, Any]) -> dict[str, list[Path]]:
"""Walk config and write all 50-<name>.network files to data/networkd/.
Also removes stale .network files that no longer match config.
Args:
cfg: Network config dict (from get_config).
Returns:
Dict with ``generated`` (new/updated files) and ``cleaned``
(removed stale files) path lists.
"""
DATA_DIR.mkdir(parents=True, exist_ok=True)
generated: list[Path] = []
cleaned: list[Path] = []
# Build set of expected filenames
expected_names: set[str] = set()
interfaces_cfg = cfg.get("interfaces", {})
for iface_name, entry in interfaces_cfg.items():
if not isinstance(entry, dict):
continue
fname = f"50-{iface_name}.network"
expected_names.add(fname)
content = render_network_file(iface_name, entry)
out_path = DATA_DIR / fname
out_path.write_text(content)
generated.append(out_path)
# Remove stale files from DATA_DIR
existing_files: set[str] = {
f.name for f in DATA_DIR.iterdir() if f.name.endswith(".network")
}
for fname in existing_files - expected_names:
(DATA_DIR / fname).unlink()
cleaned.append(DATA_DIR / fname)
if cleaned:
logger.info("Cleaned %d stale .network files from %s", len(cleaned), DATA_DIR)
logger.info("Generated %d .network files in %s", len(generated), DATA_DIR)
return {"generated": generated, "cleaned": cleaned}
# ---------------------------------------------------------------------------
# TF-8: upstream DNS collection
# ---------------------------------------------------------------------------
_IS_LOCAL = [
ipaddress.ip_network("127.0.0.0/8"),
ipaddress.ip_network("10.0.0.0/8"),
ipaddress.ip_network("172.16.0.0/12"),
ipaddress.ip_network("192.168.0.0/16"),
ipaddress.ip_network("169.254.0.0/16"),
ipaddress.ip_network("::1/128"),
ipaddress.ip_network("fc00::/7"),
ipaddress.ip_network("fe80::/10"),
]
def _is_local_dns(addr: str) -> bool:
try:
ip = ipaddress.ip_address(addr)
for net in _IS_LOCAL:
if ip.version == net.version and ip in net:
return True
except ValueError:
pass
return False
def collect_upstream_dns(cfg: dict[str, Any]) -> list[str]:
"""Collect public DNS servers from networkd config, filtering local ranges.
Args:
cfg: Network config dict (from get_config).
Returns:
Deduplicated list of upstream DNS server addresses.
"""
seen: set[str] = set()
result: list[str] = []
for entry in cfg.get("interfaces", {}).values():
if not isinstance(entry, dict):
continue
for dns in entry.get("dns", []):
if not _is_local_dns(dns) and dns not in seen:
seen.add(dns)
result.append(dns)
for dns in entry.get("ipv6_dns", []):
if not _is_local_dns(dns) and dns not in seen:
seen.add(dns)
result.append(dns)
return result
# ---------------------------------------------------------------------------
# TF-9: DHCP range inference
# ---------------------------------------------------------------------------
def infer_dhcp_ranges(cfg: dict[str, Any]) -> dict[str, dict[str, Any]]:
"""Infer candidate DHCP ranges from static interface IPs.
For each interface with a static IPv4 address, calculates a candidate
DHCP range covering the usable addresses in the subnet.
Args:
cfg: Network config dict (from get_config).
Returns:
Dict mapping interface name to dict with ``subnet``, ``prefix``,
``start``, and ``end`` keys. Interfaces with no inferrable range
are omitted.
"""
result: dict[str, dict[str, Any]] = {}
for name, entry in cfg.get("interfaces", {}).items():
if not isinstance(entry, dict):
continue
for addr in entry.get("addresses", []):
if isinstance(addr, dict):
addr = addr.get("address", "")
if not addr or "/" not in str(addr):
continue
try:
net = ipaddress.ip_network(addr, strict=False)
except ValueError:
continue
if net.version != 4:
continue
if net.num_addresses < 4:
continue
net_addr = net.network_address
broadcast = net.broadcast_address
result[name] = {
"subnet": str(net_addr),
"prefix": net.prefixlen,
"start": str(net_addr + 1),
"end": str(broadcast - 1),
}
break
return result
# ---------------------------------------------------------------------------
# TF-10: firewalld zone inference
# ---------------------------------------------------------------------------
def _looks_wan(entry: dict[str, Any]) -> bool:
"""Heuristic: interface has DHCP or public-facing address."""
if entry.get("dhcp") in ("yes", "ipv4"):
return True
for addr in entry.get("addresses", []):
if isinstance(addr, dict):
addr = addr.get("address", "")
if not addr or "/" not in str(addr):
continue
try:
net = ipaddress.ip_network(addr, strict=False)
except ValueError:
continue
if net.version != 4:
continue
ga = list(net.hosts())
if ga and not _is_local_dns(str(ga[0])):
return True
return False
def _looks_management(entry: dict[str, Any]) -> bool:
"""Heuristic: interface has management-subnet addresses."""
return bool(entry.get("routes"))
def infer_zones(
cfg: dict[str, Any],
) -> dict[str, str]:
"""Classify network interfaces into firewalld zones.
Heuristics:
- Interface name contains ``wg`` → ``"wan"``
- DHCP-enabled or public-facing IP → ``"wan"``
- Has explicit routes configured → ``"management"``
- Everything else → ``"lan"``
Args:
cfg: Network config dict (from get_config).
Returns:
Dict mapping interface name to suggested zone name.
"""
result: dict[str, str] = {}
for name, entry in cfg.get("interfaces", {}).items():
if not isinstance(entry, dict):
continue
if "wg" in name or _looks_wan(entry):
result[name] = "wan"
elif _looks_management(entry):
result[name] = "management"
else:
result[name] = "lan"
return result
+42 -20
View File
@@ -7,7 +7,6 @@ state instead of invoking subprocesses on every request.
import contextlib
import logging
import os
import re
import shutil
import subprocess
from copy import deepcopy
@@ -23,6 +22,7 @@ from lib.firewall import (
from lib.firewall import (
config_pending as _config_pending,
)
from lib.network import parse_networkctl_status
logger = logging.getLogger(__name__)
@@ -52,6 +52,7 @@ class State:
"nginx",
"acme",
"wireguard",
"networkd",
]
def __init__(self) -> None:
@@ -184,7 +185,7 @@ def _collect_firewall() -> dict[str, Any]:
parts = line.split()
if len(parts) < 2:
continue
raw_name = parts[1].rstrip(":")
raw_name = parts[1].rstrip(":").split("@")[0]
iface_state = "UNKNOWN"
mtu = None
mac = None
@@ -197,7 +198,6 @@ def _collect_firewall() -> dict[str, Any]:
mac = parts[i + 1]
iface_map[raw_name] = {
"name": raw_name,
"display_name": raw_name.partition("@")[0],
"mac": mac,
"state": iface_state,
"mtu": mtu,
@@ -215,15 +215,14 @@ def _collect_firewall() -> dict[str, Any]:
addr_name = parts[1]
addr_key = "ipv6" if parts[2] == "inet6" else "ips"
for entry in iface_map.values():
if entry["display_name"] == addr_name:
if entry["name"] == addr_name:
entry[addr_key].append(parts[3])
break
for zone_name, ifaces in active.items():
for raw_if in ifaces:
clean = raw_if.partition("@")[0]
for entry in iface_map.values():
if entry["display_name"] == clean or entry["name"] == raw_if:
if entry["name"] == raw_if:
entry["zone"] = zone_name
break
@@ -571,21 +570,12 @@ def _has_auto_renew(domain: str) -> bool:
def _get_acme_email() -> str:
"""Read the ACME ``acme.sh`` email from the account config file.
Returns:
Email string, or empty string if not found.
Falls back to the declarative ACME config (config/acme/config.json)
if acme.sh account has not been registered yet.
"""
acme_home_default = str(PROJECT_DIR / "data" / "acme")
try:
acme_home = Path(os.environ.get("ACME_HOME", acme_home_default))
account_conf = acme_home / "account.conf"
if account_conf.is_file():
text = account_conf.read_text()
match = re.search(r"^ACME_LEEMAIL=(.+)$", text, re.MULTILINE)
if match:
return match.group(1).strip().strip("'\"")
except OSError:
pass
return ""
from lib.acme import _read_acme_email
return _read_acme_email()
def _collect_acme() -> dict[str, Any]:
@@ -770,6 +760,38 @@ def _collect_wireguard() -> dict[str, Any]:
register_collector("wireguard", _collect_wireguard)
# ---------------------------------------------------------------------------
# Networkd collector
# ---------------------------------------------------------------------------
def _collect_networkd() -> dict[str, Any]:
"""Collect networkd interface state from networkctl.
Returns:
Dict with interface runtime state parsed from networkctl output.
Returns empty data if networkctl is not available.
"""
result: dict[str, dict[str, Any]] = {}
try:
raw = run(["networkctl", "status", "--all"], sudo=True)
result = parse_networkctl_status(raw)
if not result:
return {"interfaces": {}, "timestamp": _now_iso()}
except Exception:
return {
"interfaces": {},
"timestamp": _now_iso(),
}
return {
"interfaces": result,
"timestamp": _now_iso(),
}
register_collector("networkd", _collect_networkd)
__all__ = [
"State",