Files
vacuum-wall/lib/network.py
T
mteehan faa076370d refactor: daemon collectors, thin webui proxies, pure config reads
- move state collectors from lib/state.py to daemon/collectors/ (7
  modules, registration side-effect; daemon/server.py imports the
  package before the first populate())
- webui/api: new daemon_route() decorator factory in common.py
  collapses the try/except daemon-proxy boilerplate in all 8
  blueprints (rules/params/body/transform keep responses identical)
- firewall: interface-coverage invariant — config is the source of
  truth for zone interfaces (absent key = empty, no hands-off
  zones); pure validate_coverage() enforced at save (400) and apply
  (409, force: true overrides), top-level `unmanaged` exemption
- lib: get_config() reads are now pure (no dir creation or writes);
  new lib/bootstrap.py creates runtime dirs and persists the
  one-shot nginx legacy migration at daemon start, after
  system_import (lib.nginx.migrate_config_file)
- lib/common: compute_pending() apply-bookkeeping helper
- daemon: emit_and_refresh() handler helper; refresh_state(bump=) so
  /status/refresh no longer bumps versions (poll/mutation only)
- acme: move --log last so acme.sh never treats a real arg as the
  log-file argument
- docs: AGENTS.md, config.md, state-model.md, api.md updated;
  HARDEN.md dropped (plan implemented); apply-confirm force wording

Tests: 917 passed; ruff check + format clean.
2026-09-03 00:40:56 +00:00

780 lines
28 KiB
Python

"""Networkd/IP configuration module.
Reads/writes config/network/config.json, renders .network INI files,
and parses networkctl JSON output for runtime state.
"""
import contextlib
import ipaddress
import json
import logging
from copy import deepcopy
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": {}}
KNOWN_INTERFACE_KEYS: set[str] = {
"addresses",
"ipv6_addresses",
"gateway",
"ipv6_gateway",
"dns",
"ipv6_dns",
"domains",
"ipv6_domains",
"dns_default_route",
"dhcp",
"routes",
"bind_carrier",
"ignore_carrier_loss",
"keep_configuration",
"configure_without_carrier",
"link_local_addressing",
"ipv6_link_local_address_generation_mode",
"ipv6_stable_secret_address",
"ipv4_ll_start_address",
"ipv4_ll_route",
"default_route_on_device",
"ipv6_hop_limit",
"ipv6_retransmission_time_sec",
"ipv4_duplicate_address_detection_timeout_sec",
"ipv4_reverse_path_filter",
"ipv4_accept_local",
"ipv4_route_localnet",
"ipv4_proxy_arp",
"ipv4_proxy_arp_private_vlan",
"ipv6_proxy_ndp",
"ipv6_proxy_ndp_address",
"ipv6_send_ra",
"m_pls_routing",
"keep_master",
"ip_family",
"link",
"dhcp_client",
}
__all__ = [
"KNOWN_INTERFACE_KEYS",
"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.
Pure read — never writes. Returns the in-memory default when the file
is missing; the file is materialized on the first ``save_config``.
Returns:
Dict with ``interfaces`` mapping interface names to config entries.
"""
raw = load_json(CONFIG_FILE)
if not raw:
return deepcopy(DEFAULT_CONFIG)
return raw
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:
"""Append a ``Key=Value`` line to *lines* if *py_key* has a non-None value in *d*.
Args:
lines: Target list to append rendered line to.
key: INI key name for the output line.
py_key: Python dict key to look up in *d*.
d: Config entry dict to extract value from.
"""
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:
"""Append a ``Key=Value`` line to *lines* for integer values.
Args:
lines: Target list to append rendered line to.
key: INI key name for the output line.
py_key: Python dict key to look up in *d*.
d: Config entry dict to extract value from.
"""
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:
"""Append a ``Key=yes/no`` line to *lines* if *py_key* has a non-None value in *d*.
Args:
lines: Target list to append rendered line to.
key: INI key name for the output line.
py_key: Python dict key to look up in *d*.
d: Config entry dict to extract value from.
"""
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:
"""Identical to :func:`_emit_bool` — kept for API compatibility.
Args:
lines: Target list to append rendered line to.
key: INI key name for the output line.
py_key: Python dict key to look up in *d*.
d: Config entry dict to extract value from.
"""
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:
"""Append a ``Key=Value`` line handling both bool and non-bool types.
Boolean values are rendered as ``yes``/``no``; all other types are
stringified directly.
Args:
lines: Target list to append rendered line to.
key: INI key name for the output line.
py_key: Python dict key to look up in *d*.
d: Config entry dict to extract value from.
"""
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 _bytes_to_ip(addr_bytes: list[int], family: int) -> str:
"""Convert networkctl JSON address byte array to string."""
if family == 2:
return str(ipaddress.ip_address(bytes(addr_bytes)))
return str(ipaddress.IPv6Address(bytes(addr_bytes)))
def parse_networkctl_status(output: str) -> dict[str, Any]:
"""Parse ``networkctl status --json=short --all`` JSON output into runtime state dict.
Args:
output: JSON command output from networkctl status.
Returns:
Dict mapping interface names to their runtime state including
addresses, gateway, DNS, and link state.
"""
try:
data = json.loads(output)
except (json.JSONDecodeError, TypeError):
return {}
result: dict[str, Any] = {}
for iface in data.get("Interfaces", []):
name = iface.get("Name")
if not name:
continue
# Addresses
addresses = []
for a in iface.get("Addresses", []):
try:
ip = _bytes_to_ip(a["Address"], a["Family"])
addresses.append(f"{ip}/{a['PrefixLength']}")
except (KeyError, ValueError, TypeError):
continue
# Gateway — find default route (Destination 0.0.0.0/0)
gateway = None
for route in iface.get("Routes", []):
if route.get("Family") != 2:
continue
dest = route.get("Destination", [])
prefix = route.get("DestinationPrefixLength", 32)
if len(dest) == 4 and all(d == 0 for d in dest) and prefix == 0:
gw_bytes = route.get("Gateway")
if gw_bytes:
with contextlib.suppress(ValueError, TypeError):
gateway = _bytes_to_ip(gw_bytes, 2)
break
# DNS
dns = []
for d in iface.get("DNS", []):
try:
dns.append(_bytes_to_ip(d["Address"], d["Family"]))
except (KeyError, ValueError, TypeError):
continue
# MAC
mac = None
hw = iface.get("HardwareAddress")
if hw:
mac = ":".join(f"{b:02x}" for b in hw)
# State
state = iface.get("OperationalState") or "unknown"
result[name] = {
"addresses": addresses,
"gateway": gateway,
"dns": dns,
"mac": mac,
"state": state,
"link": iface.get("Type", "unknown"),
}
return result
def generate_network_files(cfg: dict[str, Any]) -> dict[str, list[Path]]:
"""Walk config and write all 99-<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"99-{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:
"""Return ``True`` if *addr* falls within a local/private IP range.
Checks loopback, RFC 1918 (10/8, 172.16/12, 192.168/16), link-local
(169.254/16), and their IPv6 equivalents (fc00::/7, fe80::/10).
Args:
addr: IP address string to test.
Returns:
``True`` if the address is local/private, ``False`` otherwise.
Invalid addresses are treated as non-local.
"""
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
_start = net_addr + 100
_end = net_addr + 200
_start = min(_start, broadcast - 1)
_end = min(_end, broadcast - 1)
if _start > _end:
continue
result[name] = {
"subnet": str(net_addr),
"prefix": net.prefixlen,
"start": str(_start),
"end": str(_end),
}
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