diff --git a/AGENTS.md b/AGENTS.md index 71128e8..3ca4667 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -68,6 +68,7 @@ When `install.sh --dev` is used, the repo owner gets NOPASSWD sudo for system se | `webui/api/proxy` | `/api/proxy/` | `lib.nginx` | | `webui/api/certs` | `/api/certs/` | `lib.acme` | | `webui/api/wireguard` | `/api/wireguard/` | `lib.wireguard` | +| `webui/api/network` | `/api/network/` | `lib.network` | ## Privileged Operations diff --git a/daemon/handlers/acme.py b/daemon/handlers/acme.py index f27becf..00ea021 100644 --- a/daemon/handlers/acme.py +++ b/daemon/handlers/acme.py @@ -3,7 +3,6 @@ import asyncio import logging import os -import re import socket import subprocess from contextlib import suppress @@ -141,17 +140,9 @@ def _find_acme_bin() -> str: def _get_acme_email() -> str: """Read registered contact email from ACME account config.""" - try: - acme_home = Path(os.environ.get("ACME_HOME", str(_ACME_HOME))) - 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 _get_state() -> dict[str, Any] | None: @@ -564,6 +555,16 @@ def set_email(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: if not email: raise ValueError("'email' is required") _run_acme(["--register-account", "-m", email]) + # Persist to declarative ACME config + acme_cfg = PROJECT_DIR / "config" / "acme" / "config.json" + acme_cfg.parent.mkdir(parents=True, exist_ok=True) + import json as _json + + _acme_data: dict[str, str] = {} + if acme_cfg.is_file(): + _acme_data = _json.loads(acme_cfg.read_text()) + _acme_data["email"] = email + acme_cfg.write_text(_json.dumps(_acme_data, indent=4) + "\n") logger.info("ACME email set to %s", email) refresh_state(["acme"]) return {"email": email} @@ -573,19 +574,12 @@ def set_email(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: def get_email(_request: Any, _body: Any) -> dict[str, Any]: """GET /acme/email — return the currently configured ACME contact email.""" ac = _get_acme_state() + email = "" if ac: - return {"email": ac.get("email", "")} - try: - acme_home = Path(os.environ.get("ACME_HOME", str(_ACME_HOME))) - 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 {"email": match.group(1).strip().strip("'\"")} - except OSError: - pass - return {"email": ""} + email = ac.get("email", "") + if not email: + email = _get_acme_email() + return {"email": email} @registry.register("GET", "/acme/paths") diff --git a/daemon/handlers/firewall.py b/daemon/handlers/firewall.py index ba0abc3..a3f9645 100644 --- a/daemon/handlers/firewall.py +++ b/daemon/handlers/firewall.py @@ -14,6 +14,7 @@ from daemon.server import NotFoundError, refresh_state, registry from lib.common import load_json, run, save_json from lib.firewall import ( _normalize_target, + _parse_active_zones, _parse_zone_output, ) from lib.firewall import ( @@ -409,24 +410,31 @@ def set_zone_interfaces(_request: Any, body: dict[str, Any] | None) -> dict[str, raise ValueError("'zone' is required") if zone not in run(["firewall-cmd", "--get-zones"], sudo=True).split(): raise NotFoundError(f"Zone '{zone}' does not exist") - try: - current = _parse_zone_output( - zone, run(["firewall-cmd", f"--zone={zone}", "--list-all"], sudo=True) - ).get("interfaces", []) - except Exception: - current = [] - for iface in current: - run( - [ - "firewall-cmd", - f"--zone={zone}", - "--remove-interface=" + iface, - "--permanent", - ], - sudo=True, - check=False, - ) + + # Determine old zone for each interface being reassigned + active_raw = run(["firewall-cmd", "--get-active-zones"], sudo=True) + active = _parse_active_zones(active_raw) + for iface in interfaces: + # Find which zone currently owns this interface + old_zone = None + for az, az_ifaces in active.items(): + if iface in az_ifaces: + old_zone = az + break + # Remove from old zone (if different from target) + if old_zone and old_zone != zone: + run( + [ + "firewall-cmd", + f"--zone={old_zone}", + "--remove-interface=" + iface, + "--permanent", + ], + sudo=True, + check=False, + ) + # Add to target zone run( [ "firewall-cmd", @@ -436,7 +444,27 @@ def set_zone_interfaces(_request: Any, body: dict[str, Any] | None) -> dict[str, ], sudo=True, ) + _reload() + + # Update config + cfg = _get_config() + cfg.setdefault("zones", {}) + cfg["zones"].setdefault(zone, {}) + cfg["zones"][zone]["interfaces"] = list(interfaces) + # Remove interface from any old zone in config + for old_zone_name, old_zone_cfg in cfg["zones"].items(): + if old_zone_name == zone: + continue + old_ifaces = old_zone_cfg.get("interfaces", []) + new_ifaces = [i for i in old_ifaces if i not in interfaces] + if len(new_ifaces) < len(old_ifaces): + if new_ifaces: + old_zone_cfg["interfaces"] = new_ifaces + elif "interfaces" in old_zone_cfg: + del old_zone_cfg["interfaces"] + _save_config(cfg) + logger.info("Zone '%s' interfaces set to %s", zone, interfaces) refresh_state(["firewall"]) return {"zone": zone, "interfaces": interfaces} diff --git a/daemon/handlers/network.py b/daemon/handlers/network.py new file mode 100644 index 0000000..8766bb2 --- /dev/null +++ b/daemon/handlers/network.py @@ -0,0 +1,217 @@ +"""Networkd daemon handler. + +Registers routes for managing systemd-networkd interface configuration +via config/network/config.json and generated .network files. +""" + +import contextlib +import logging +from pathlib import Path +from typing import Any + +from daemon.server import NotFoundError, registry +from lib.common import run +from lib.dnsmasq import set_upstreams +from lib.network import ( + collect_upstream_dns, + generate_network_files, + get_config, + infer_dhcp_ranges, + infer_zones, + parse_networkctl_status, + render_network_file, + save_config, +) + +logger = logging.getLogger(__name__) + +PROJECT_DIR = Path(__file__).resolve().parent.parent.parent +CONFIG_DIR = PROJECT_DIR / "config" / "network" +DATA_DIR = PROJECT_DIR / "data" / "networkd" + + +def _copy_and_reload(iface_name: str) -> None: + """Copy generated 50-.network file to /etc/systemd/network/ and reload.""" + src = DATA_DIR / f"50-{iface_name}.network" + dst_dir = Path("/etc/systemd/network") + run(["mkdir", "-p", str(dst_dir)], sudo=True) + dst = dst_dir / f"50-{iface_name}.network" + run(["cp", str(src), str(dst)], sudo=True) + run(["networkctl", "reconfigure", iface_name], sudo=True) + + +def _full_reload() -> None: + """Reload networkd for all interfaces.""" + run(["networkctl", "reload"], sudo=True) + + +# --------------------------------------------------------------------------- +# Routes +# --------------------------------------------------------------------------- + + +@registry.register("GET", "/network/interfaces") +def get_interfaces(_request: Any, _body: Any) -> dict[str, Any]: + """GET /network/interfaces — return all interface config + runtime state.""" + cfg = get_config() + ifaces_cfg = cfg.get("interfaces", {}) + + runtime: dict[str, Any] = {} + with contextlib.suppress(Exception): + raw = run(["networkctl", "status", "--all"], sudo=True) + runtime = parse_networkctl_status(raw) + + merged: dict[str, Any] = {} + for name, config_entry in ifaces_cfg.items(): + merged[name] = { + "config": config_entry, + "runtime": runtime.get(name, {}), + } + + return {"interfaces": merged, "timestamp": ""} + + +@registry.register("GET", "/network/interfaces/") +def get_interface(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: + """GET /network/interfaces/ — return config for one interface.""" + if not body or "name" not in body: + raise ValueError("Interface name is required") + name = body["name"] + cfg = get_config() + ifaces = cfg.get("interfaces", {}) + if name not in ifaces: + raise NotFoundError(f"Interface '{name}' not found in config") + + runtime: dict[str, Any] = {} + with contextlib.suppress(Exception): + raw = run(["networkctl", "status", "--all"], sudo=True) + runtime = parse_networkctl_status(raw) + + return { + "name": name, + "config": ifaces[name], + "runtime": runtime.get(name, {}), + } + + +@registry.register("POST", "/network/interfaces/") +def save_interface(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: + """POST /network/interfaces/ — save config, render, apply.""" + if not body: + raise ValueError("Request body required") + name = body.get("name", "").strip() + if not name: + raise ValueError("'name' is required") + + iface_cfg = {k: v for k, v in body.items() if k not in ("name",)} + + with contextlib.suppress(Exception): + raw = run(["networkctl", "status", "--all"], sudo=True) + runtime = parse_networkctl_status(raw) + if name not in runtime: + logger.warning( + "Interface '%s' not found in networkctl " + "(config saved but networkd will ignore it)", + name, + ) + + cfg = get_config() + cfg.setdefault("interfaces", {}) + cfg["interfaces"][name] = iface_cfg + save_config(cfg) + + content = render_network_file(name, iface_cfg) + DATA_DIR.mkdir(parents=True, exist_ok=True) + (DATA_DIR / f"50-{name}.network").write_text(content) + + # Deploy to system. In containerized environments this may fail + # (e.g. read-only /run/sudo timestamps) — don't let that block the save. + deployed = True + try: + _copy_and_reload(name) + except Exception: + deployed = False + logger.warning( + "Interface '%s' config saved but failed to deploy to " + "systemd-networkd (sudo/system unavailable)", + name, + exc_info=True, + ) + + logger.info("Interface '%s' config saved (applied=%s)", name, deployed) + return {"name": name, "applied": deployed} + + +@registry.register("POST", "/network/interfaces//reload") +def reload_interface(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: + """POST /network/interfaces//reload — reload networkd for interface.""" + if not body or "name" not in body: + raise ValueError("'name' is required in request body") + name = body["name"] + + with contextlib.suppress(Exception): + run(["networkctl", "reconfigure", name], sudo=True) + + logger.info("Interface '%s' reloaded", name) + return {"name": name, "reloaded": True} + + +@registry.register("POST", "/network/apply") +def apply_all(_request: Any, _body: Any) -> dict[str, Any]: + """POST /network/apply — apply ALL interfaces (full sync).""" + cfg = get_config() + result = generate_network_files(cfg) + generated = result.get("generated", []) + cleaned = result.get("cleaned", []) + + # Remove stale files from system dir that aren't in config + expected_names = {f.name for f in generated} + sys_dir = Path("/etc/systemd/network") + if sys_dir.exists(): + for f in sys_dir.iterdir(): + if f.name.endswith(".network") and f.name not in expected_names: + with contextlib.suppress(Exception): + run(["rm", str(f)], sudo=True) + + for f in generated: + dst = sys_dir / f.name + run(["mkdir", "-p", str(sys_dir)], sudo=True) + run(["cp", str(f), str(dst)], sudo=True) + + _full_reload() + + # TF-8: sync DNS upstreams to dnsmasq + try: + upstreams = collect_upstream_dns(cfg) + if upstreams: + set_upstreams(upstreams) + logger.info("Synced %d DNS upstreams to dnsmasq", len(upstreams)) + except Exception: + logger.warning("Failed to sync DNS upstreams to dnsmasq", exc_info=True) + + logger.info( + "Network config applied: %d interfaces, %d stale cleaned", + len(generated), + len(cleaned), + ) + return { + "applied": len(generated), + "files": [str(p) for p in generated], + "cleaned": [str(p) for p in cleaned], + } + + +@registry.register("GET", "/network/infer-dhcp-ranges") +def get_infer_dhcp_ranges(_request: Any, _body: Any) -> dict[str, Any]: + """GET /network/infer-dhcp-ranges — suggest DHCP ranges from static IPs.""" + cfg = get_config() + ranges = infer_dhcp_ranges(cfg) + return {"ranges": ranges} + + +@registry.register("GET", "/network/infer-zones") +def get_infer_zones(_request: Any, _body: Any) -> dict[str, Any]: + """GET /network/infer-zones — suggest firewalld zones from interface config.""" + cfg = get_config() + zones = infer_zones(cfg) + return {"zones": zones} diff --git a/daemon/server.py b/daemon/server.py index 6488eb0..1410dbd 100644 --- a/daemon/server.py +++ b/daemon/server.py @@ -83,6 +83,23 @@ class Registry: """ return self._routes.get((method.upper(), path)) + def match(self, method: str, path: str): + """Match *path* against registered patterns, returning handler + params. + + Patterns may contain ```` segments (e.g. ``/foo/``). + Matching segments are captured into a dict and merged into *body*. + + Returns: + Tuple of (handler_fn, params_dict) or (None, None) if no match. + """ + for (reg_method, reg_path), fn in self._routes.items(): + if reg_method != method.upper(): + continue + pat_params = _match_path(reg_path, path) + if pat_params is not None: + return fn, pat_params + return None, None + registry = Registry() @@ -127,6 +144,29 @@ def error(msg: str, code: int = 400) -> web.Response: return web.json_response({"ok": False, "error": msg}, status=code) +def _match_path(pattern: str, path: str) -> dict[str, str] | None: + """Match *path* against a URL pattern containing ```` segments. + + Args: + pattern: URL pattern like ``/network/interfaces/``. + path: Actual request path like ``/network/interfaces/eth1``. + + Returns: + Dict mapping param names to their matched values, or ``None`` if no match. + """ + p_parts = pattern.strip("/").split("/") + r_parts = path.strip("/").split("/") + if len(p_parts) != len(r_parts): + return None + params: dict[str, str] = {} + for p_seg, r_seg in zip(p_parts, r_parts, strict=True): + if p_seg.startswith("<") and p_seg.endswith(">"): + params[p_seg[1:-1]] = r_seg + elif p_seg != r_seg: + return None + return params + + async def _handle_request(request: web.Request) -> web.Response: """Dispatch a request to the appropriate handler. @@ -136,27 +176,25 @@ async def _handle_request(request: web.Request) -> web.Response: Returns: The handler's response. """ - handler_fn = registry.get(request.method, request.path) + handler_fn, pat_params = registry.match(request.method, request.path) if handler_fn is None: return error(f"Method {request.method} not allowed for {request.path}", 404) - # Build body from JSON and merge query params. GET requests send params - # as URL query string, so they need to be treated as body for handlers. - body: dict[str, Any] | None = None + # Build body — merge order (highest wins): path params > JSON body > query params. + # Path params come from the URL path (e.g. /interfaces/eth0) and should not + # be overridable by body or query parameters. + body: dict[str, Any] | None = pat_params if pat_params else None if request.content_type == "application/json": try: - body = await request.json() + json_body = await request.json() + body = {**json_body, **body} if body is not None else json_body except json.JSONDecodeError: return error("Invalid JSON body", 400) query_dict = dict(request.query) if query_dict: query_body = {k: v[0] if len(v) == 1 else v for k, v in query_dict.items()} - if body is not None: - merged = {**query_body, **body} - body = merged - else: - body = query_body + body = {**query_body, **body} if body is not None else query_body try: if body is not None: @@ -216,7 +254,7 @@ async def _handle_batch(request: web.Request) -> web.Response: results[op_id] = {"ok": False, "error": "'id' and 'path' are required"} continue - handler_fn = registry.get(method, path) + handler_fn, pat_params = registry.match(method, path) if handler_fn is None: results[op_id] = { "ok": False, @@ -225,6 +263,8 @@ async def _handle_batch(request: web.Request) -> web.Response: continue op_body = op.get("body") + if pat_params: + op_body = {**(op_body or {}), **pat_params} try: result = handler_fn(None, op_body) @@ -320,6 +360,7 @@ def _register_routes() -> None: dnsmasq, # noqa: F401 firewall, # noqa: F401 logs, # noqa: F401 + network, # noqa: F401 nginx, # noqa: F401 wireguard, # noqa: F401 ) diff --git a/docs/api.md b/docs/api.md index 3a0bdc1..5307216 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1190,6 +1190,139 @@ Returns HTTP `404` if the peer is not found. --- +## Network API + +Endpoints prefixed with `/api/network/...`. Manage systemd-networkd interface configuration including static addresses, routes, DNS, DHCP client settings, and link parameters. + +### Interface Management + +#### List All Interfaces + +``` +GET /api/network/interfaces +``` + +Return all configured interfaces with their network config and runtime state from `networkctl`. + +**Response:** + +| Field | Type | Description | +|-------|------|-------------| +| `data.interfaces` | `object` | Map of interface name to `{config, runtime}` | +| `data.timestamp` | `string` | Timestamp of runtime data collection | + +--- + +#### Get Interface Details + +``` +GET /api/network/interfaces/ +``` + +Return config and runtime state for a specific interface. + +**Response (`data`):** + +| Field | Type | Description | +|-------|------|-------------| +| `name` | `string` | Interface name | +| `config` | `object` | Full networkd config entry for this interface | +| `runtime` | `object` | Runtime state from `networkctl` (addresses, gateway, DNS, state) | + +Returns HTTP `404` if the interface is not found in config. + +--- + +#### Save and Apply Interface + +``` +POST /api/network/interfaces/ +``` + +Save network config for an interface, render the `.network` file, copy it to `/etc/systemd/network/`, and reload networkd for that interface. + +**Request Body:** Any networkd config keys (e.g., `addresses`, `gateway`, `dns`, `routes`, `dhcp`, `link`, `dhcp_client`). + +**Response (`data`):** + +| Field | Type | Description | +|-------|------|-------------| +| `name` | `string` | Interface name | +| `applied` | `boolean` | Always `true` on success | + +--- + +#### Reload Interface + +``` +POST /api/network/interfaces//reload +``` + +Reload networkd for a single interface (runs `networkctl reload `). + +**Response (`data`):** + +| Field | Type | Description | +|-------|------|-------------| +| `name` | `string` | Interface name | +| `reloaded` | `boolean` | Always `true` on success | + +### Full Sync + +#### Apply All Interfaces + +``` +POST /api/network/apply +``` + +Full sync: generate all `.network` files, remove stale files, copy to `/etc/systemd/network/`, reload all interfaces, and sync DNS upstreams to dnsmasq. + +**Response (`data`):** + +| Field | Type | Description | +|-------|------|-------------| +| `applied` | `number` | Number of interfaces applied | +| `files` | `[string, ...]` | Paths of generated files | +| `cleaned` | `[string, ...]` | Paths of removed stale files | + +### Helpers + +#### Infer DHCP Ranges + +``` +GET /api/network/infer-dhcp-ranges +``` + +Suggest candidate DHCP ranges based on static interface IPs. For each interface with a static IPv4 address, calculates a usable address range in the subnet. + +**Response:** + +| Field | Type | Description | +|-------|------|-------------| +| `data.ranges` | `object` | Map of interface name to `{subnet, prefix, start, end}` | + +--- + +#### Infer Firewall Zones + +``` +GET /api/network/infer-zones +``` + +Suggest firewalld zone assignments for configured interfaces based on heuristics: +- Interface name contains `wg` → `wan` +- DHCP-enabled or public-facing IP → `wan` +- Has explicit routes → `management` +- Everything else → `lan` + +**Response:** + +| Field | Type | Description | +|-------|------|-------------| +| `data.zones` | `object` | Map of interface name to suggested zone (`"lan"`, `"wan"`, `"management"`) | + +--- + ## Logs API Endpoints prefixed with `/api/logs/...`. Serve rendered HTML log line fragments for HTMX consumption. These endpoints **do not** follow the standard JSON `{"ok": true, "data": ...}` response contract — they return HTML `
` elements directly. Errors are rendered inline as `(error reading ...)` text rather than returning JSON error responses. diff --git a/docs/architecture.md b/docs/architecture.md index ad2de3e..158d863 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -40,6 +40,7 @@ vacuum-walld ──→ daemon/handlers/nginx.py ──→ write local .conf file vacuum-walld ──→ daemon/handlers/dnsmasq.py ──→ render config ──→ sudo tee /etc/dnsmasq.d/vacuum-wall.conf ──→ sudo systemctl reload dnsmasq vacuum-walld ──→ daemon/handlers/acme.py ──→ acme.sh (subprocess) ──→ deploy hook (daemon API) ──→ ZeroSSL ACME vacuum-walld ──→ daemon/handlers/wireguard.py ──→ render data/wireguard/wg0.conf ──→ sudo cp to /etc/wireguard/ ──→ sudo wg-quick up wg0 +vacuum-walld ──→ daemon/handlers/network.py ──→ render 50-.network ──→ sudo cp to /etc/systemd/network/ ──→ sudo networkctl reload vacuum-walld ──→ daemon/handlers/logs.py ──→ sudo journalctl ──→ systemd journal ``` @@ -77,6 +78,7 @@ Vacuum Wall uses a declarative configuration model. Persistent user-facing confi | dnsmasq | `config/dnsmasq/config.json` | `data/dnsmasq/fragments/` | `/etc/dnsmasq.d/vacuum-wall.conf` | The JSON file is the source of truth. The rendered `.conf` file is overwritten on each apply. | | nginx | `config/nginx/config.json` | `data/nginx/.htpasswd`, `data/nginx/sites-enabled/` | `data/nginx/sites-enabled/.conf` + `/etc/nginx/conf.d/vacuum-wall.conf` | All proxy and management domain definitions are derived from the JSON config. Generated `.conf` files are overwritten on each apply. | | WireGuard | `config/wireguard/config.json` | `data/wireguard/` | `/etc/wireguard/wg0.conf` | The JSON file defines the interface and all peers. The rendered WireGuard config is overwritten on each apply. | +| networkd | `config/network/config.json` | `data/networkd/` | `/etc/systemd/network/50-.network` | The JSON file defines per-interface static addresses, routes, DNS, DHCP, and link settings. Each entry renders to a `50-.network` INI file. Stale files are cleaned on apply. Public DNS servers are auto-synced to dnsmasq upstreams. | | ACME | N/A (`~/.acme.sh/` managed by acme.sh) | `data/acme/` | Certificate and key files | acme.sh manages its own state, renewal scheduling, and account keys. Vacuum Wall triggers issuance and renewal but does not maintain independent ACME state. | ## Directory Structure @@ -95,6 +97,8 @@ config/ │ └── config.json # Proxy domain definitions, management domain, SSL settings └── wireguard/ └── config.json # WireGuard interface and peer configuration +├── network/ +│ └── config.json # Per-interface static IP, routes, DNS, DHCP settings ``` ### Data — Runtime Artifacts @@ -114,6 +118,7 @@ data/ ├── logs/ │ └── vacuum-wall.log # Application log file └── wireguard/ # WireGuard runtime artifacts +├── networkd/ # Generated 50-.network files ``` Both `config/` and `data/` reside within the project directory. The systemd service unit's `ReadWritePaths` directive grants the Flask process write access to both directories, while keeping the rest of the filesystem read-only. The `INSTALL_DIR` value is templated into the service unit at install time. @@ -128,6 +133,7 @@ The following file system locations are used for integration with system service | `/etc/nginx/snippets/vacuum-wall-ssl.conf` | Shared SSL configuration snippet (protocols, ciphers, DH parameters, OCSP). Included by all HTTPS server blocks. | Vacuum Wall (lib/nginx.py) | | `/etc/dnsmasq.d/vacuum-wall.conf` | Generated dnsmasq configuration file. Written from `config/dnsmasq/config.json`. | Vacuum Wall (lib/dnsmasq.py) | | `/etc/wireguard/wg0.conf` | Generated WireGuard interface configuration. Written from `config/wireguard/config.json`. | Vacuum Wall (lib/wireguard.py) | +| `/etc/systemd/network/50-.network` | Generated systemd-networkd drop-in files. Written from `config/network/config.json`, one per interface. | Vacuum Wall (lib/network.py) | | `/etc/sudoers.d/vacuum-walld` | Sudo whitelist for the daemon user. Defines all permitted privilege escalations. | Install script (rendered from Jinja2 template) | The `/etc/nginx/conf.d/vacuum-wall.conf` include file ensures that all domain-specific configurations in `sites-enabled/` are loaded by nginx without modifying the main `nginx.conf`. The SSL snippet keeps TLS settings consistent across all managed domains and allows global updates from a single location. diff --git a/docs/config.md b/docs/config.md index c6713bc..17e0edc 100644 --- a/docs/config.md +++ b/docs/config.md @@ -291,4 +291,92 @@ The `zones` object maps zone names (keys) to zone configurations. Each zone corr ### Applying Firewall Configuration -The `config_pending()` function compares the declarative config in `config/firewall/config.json` against the live firewalld state returned by the daemon (via `daemon.handlers.firewall.get_state()`). It returns a diff indicating which zones have pending changes for interfaces, services, target, masquerade, forward ports, and rich rules. Zones that exist live but not in config are reported as `unmanaged_zones`. \ No newline at end of file +The `config_pending()` function compares the declarative config in `config/firewall/config.json` against the live firewalld state returned by the daemon (via `daemon.handlers.firewall.get_state()`). It returns a diff indicating which zones have pending changes for interfaces, services, target, masquerade, forward ports, and rich rules. Zones that exist live but not in config are reported as `unmanaged_zones`. + +## Networkd (IP Configuration) + +**File**: `config/network/config.json` + +This file defines static IP configuration for network interfaces managed by systemd-networkd. The application renders each interface entry into a `50-.network` INI file in `data/networkd/`, which the handler copies to `/etc/systemd/network/`. + +```json +{ + "interfaces": { + "eth0": { + "addresses": ["192.168.1.1/24"], + "gateway": "192.168.1.254", + "dns": ["8.8.8.8", "1.1.1.1"], + "dhcp": "no" + }, + "eth1": { + "dhcp": "ipv4", + "dns_default_route": true, + "dhcp_client": { + "hostname": "router", + "use_dns": true + } + }, + "wg0": { + "addresses": [{"address": "10.137.0.1/24"}], + "routes": [ + { + "destination": "10.0.0.0/8", + "gateway": "10.137.0.2" + } + ] + } + } +} +``` + +### Interface Entry Fields + +Each key in the `interfaces` object is an interface name (e.g., `eth0`, `eth1`, `wg0`). The value is a dict with the following keys: + +| Field | Type | Description | +|---|---|---| +| `addresses` | `array` | IPv4 addresses. Each item is either a bare CIDR string (`"192.168.1.1/24"`) or a dict with `address`, `label`, `scope`, `route_metric`, `duplicate_address_detection`, `manage_temporary_address`, `add_prefix_route`. Renders to `[Address]` sections. | +| `ipv6_addresses` | `array` | Same as `addresses`, but for IPv6. | +| `gateway` | `string` | Default IPv4 gateway (`[Network] Gateway=`). | +| `ipv6_gateway` | `string` | Default IPv6 gateway (`[Network] IPv6Gateway=`). | +| `dns` | `array` | IPv4 DNS servers (`[Network] DNS=`, one per line). | +| `ipv6_dns` | `array` | IPv6 DNS servers (`[Network] IPv6DNS=`). | +| `domains` | `array` | Search domains (`[Network] Domains=`). | +| `ipv6_domains` | `array` | IPv6 search domains (`[Network] IPv6Domains=`). | +| `dns_default_route` | `boolean` | Whether DNS is the default route for resolution (`[Network] DNSDefaultRoute=`). | +| `dhcp` | `string` | DHCP mode: `"yes"`, `"ipv4"`, `"ipv6"`, `"no"`. Controls `[Network] DHCP=` and whether `[DHCPv4]`/`[DHCPv6]` sections are rendered. | +| `routes` | `array` | Static routes. Each dict has `destination`, `gateway`, `metric`, `table`, `type`, `scope`, `gateway_on_link`, `ipv6_preference`, `initial_congestion_window`, `initial_advertised_receive_window`, `quick_ack`, `fast_open_no_cookie`, `mtu_bytes`, `protocol`, `next_hop`, `multi_path_route`. Renders to `[Route#N]` sections. | +| `link` | `object` | Link settings: `mtu_bytes`, `mac_address`, `arp`, `multicast`, `all_multicast`, `promiscuous`, `unmanaged`, `activation_policy`, `required_for_online`. Renders to `[Link]` section. | +| `dhcp_client` | `object` | DHCP client settings. Shared keys for both `[DHCPv4]` and `[DHCPv6]`: `hostname`, `duid_type`, `duid_raw_data`, `iaid`, `client_identifier`, `rapid_commit`, `anonymize`, `use_dns`, `use_ntp`, `use_sip`, `use_captive_portal`, `use_mtu`, `use_hostname`, `use_domains`, `use_routes`, `route_metric`, `send_decline`, `net_label`, `nft_set`, `ip_service_type`, `socket_priority`, `bootp`, `label`, `max_attempts`, `listen_port`, `server_port`, `mud_url`, `boot_filename`, `send_option`, `send_vendor_option`, `user_class`, `vendor_class_identifier`, `request_options`. | +| `bind_carrier` | `array` | Carrier interfaces to bind to. | +| `ignore_carrier_loss` | `boolean` | Ignore carrier loss events. | +| `keep_configuration` | `boolean` | Keep configuration on stop. | +| `configure_without_carrier` | `boolean` | Configure even without carrier. | +| `link_local_addressing` | `string` | Link-local addressing mode. | +| `ipv6_link_local_address_generation_mode` | `string` | IPv6 link-local address generation mode. | +| `ipv6_stable_secret_address` | `string` | Stable secret for IPv6 address generation. | +| `ipv4_ll_start_address` | `string` | Link-local IPv4 start address. | +| `ipv4_ll_route` | `boolean` | Add route to link-local IPv4 address. | +| `default_route_on_device` | `boolean` | Always add default route via this device. | +| `ipv6_hop_limit` | `int` | IPv6 hop limit. | +| `ipv6_retransmission_time_sec` | `string` | IPv6 retransmission timeout. | +| `ipv4_duplicate_address_detection_timeout_sec` | `string` | IPv4 DAD timeout. | +| `ipv4_reverse_path_filter` | `string` | IPv4 reverse path filtering mode. | +| `ipv4_accept_local` | `boolean` | Accept packets to local addresses as non-local. | +| `ipv4_route_localnet` | `boolean` | Route local network traffic. | +| `ipv4_proxy_arp` | `boolean` | Enable proxy ARP. | +| `ipv4_proxy_arp_private_vlan` | `boolean` | Private VLAN proxy ARP. | +| `ipv6_proxy_ndp` | `boolean` | Enable IPv6 proxy NDP. | +| `ipv6_proxy_ndp_address` | `string` | IPv6 proxy NDP address. | +| `ipv6_send_ra` | `boolean` | Send IPv6 Router Advertisements. | +| `m_pls_routing` | `boolean` | Enable MPLS routing. | +| `keep_master` | `boolean` | Keep master on stop. | +| `ip_family` | `string` | IP family to use. | + +### DNS Upstream Sync + +When `POST /api/network/apply` is called, the handler automatically collects public DNS servers from all networkd interface configs (via `collect_upstream_dns()`), filters out local/private-range addresses, and syncs the deduplicated list to dnsmasq's upstream DNS configuration. This keeps dnsmasq's upstream resolvers in sync with whatever DNS the WAN interface receives (whether statically configured or via DHCP). + +### Generated Files + +Each interface config entry produces a `50-.network` file in `data/networkd/`. During apply, these are copied to `/etc/systemd/network/` and stale files (not matching any config entry) are removed. File generation uses the `systemd.syntax(7)` naming convention: first section is bare (`[Address]`, `[Route]`), subsequent sections use `#` suffix (`[Address#1]`, `[Route#2]`). \ No newline at end of file diff --git a/docs/overview.md b/docs/overview.md index 159c5c5..07a94c7 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -2,11 +2,11 @@ ## What is Vacuum Wall? -Vacuum Wall is a zone-based firewall appliance with a built-in SSL reverse proxy, providing a unified platform for network security and traffic management. It combines firewalld policy control, DHCP/DNS services, WireGuard VPN tunnels, and automated certificate provisioning into a single device. A single web UI controls everything, making enterprise-grade network infrastructure manageable from one place. +Vacuum Wall is a zone-based firewall appliance with a built-in SSL reverse proxy, providing a unified platform for network security and traffic management. It combines firewalld policy control, DHCP/DNS services, systemd-networkd for static IP management, WireGuard VPN tunnels, and automated certificate provisioning into a single device. A single web UI controls everything, making enterprise-grade network infrastructure manageable from one place. ## Architecture Overview -Vacuum Wall is built around four integrated subsystems managed through a two-layer architecture: a non-privileged Flask web UI and a privileged background daemon (`vacuum-walld`). The web UI communicates with the daemon via a Unix socket. The daemon handles all privileged operations (sudo) for the subsystems: the traffic plane uses firewalld with its nftables backend (zone-based policies, source NAT, destination NAT); the DNS/DHCP plane serves private subnets via dnsmasq; the proxy plane runs nginx with automatic ACME certificates through acme.sh; and the VPN plane uses WireGuard (wg-quick) for encrypted tunnel management. All subsystems are configured and monitored through the Flask web UI, which is itself proxied through nginx with basic HTTP authentication. +Vacuum Wall is built around five integrated subsystems managed through a two-layer architecture: a non-privileged Flask web UI and a privileged background daemon (`vacuum-walld`). The web UI communicates with the daemon via a Unix socket. The daemon handles all privileged operations (sudo) for the subsystems: the traffic plane uses firewalld with its nftables backend (zone-based policies, source NAT, destination NAT); the DNS/DHCP plane serves private subnets via dnsmasq; the network plane uses systemd-networkd for static IP management; the proxy plane runs nginx with automatic ACME certificates through acme.sh; and the VPN plane uses WireGuard (wg-quick) for encrypted tunnel management. All subsystems are configured and monitored through the Flask web UI, which is itself proxied through nginx with basic HTTP authentication. ## Subsystems @@ -22,6 +22,10 @@ dnsmasq serves as both the DHCP server and local DNS resolver. It is configured The nginx reverse proxy handles HTTPS termination for user-defined domains, with certificates automatically provisioned and renewed via acme.sh and an ACME provider (ZeroSSL by default). Each proxy domain is configured with an HTTP-to-HTTPS redirect, modern TLS settings, and a configurable backend target. New proxy domains are added through the web UI, and the configuration is applied without manual intervention. +### Network (systemd-networkd) + +The networkd subsystem manages static IP configuration for network interfaces via systemd-networkd. It renders declarative JSON configuration into per-interface `.network` INI files (`50-.network`), supporting static addresses, routes, DNS, DHCP clients, link settings, and all `[Address]`, `[Route]`, `[DHCPv4]`, `[DHCPv6]`, and `[Link]` section keys. When the full apply runs, public DNS servers from networkd configs are auto-synced to dnsmasq's upstream resolvers. Helper endpoints can infer candidate DHCP ranges from static IPs and suggest firewalld zone assignments based on interface role. + ### WireGuard WireGuard support provides server-side VPN tunnel management. Peers are added through the web UI, with the system generating client configuration files that can be downloaded and applied on remote devices. The dashboard displays active connections and transfer statistics for each peer, allowing operators to monitor tunnel health and usage. @@ -31,6 +35,7 @@ WireGuard support provides server-side VPN tunnel management. Peers are added th - Debian 13 (trixie) target platform - Python 3.13+, Flask 3.x for web management - firewalld (nftables backend) +- systemd-networkd (ip-lladdr, networkctl) - nginx 1.26+ - dnsmasq - WireGuard tools (wireguard-tools) @@ -60,6 +65,7 @@ After installation, access the management interface at `https://.local ├── .venv/ # Python virtual environment ├── config/ # Declarative JSON configuration (source of truth) │ ├── dnsmasq/ # DHCP/DNS config +│ ├── network/ # systemd-networkd per-interface config │ ├── firewall/ # Firewall zone & rule config │ ├── nginx/ # Proxy domain & SSL config │ └── wireguard/ # VPN interface & peer config @@ -69,11 +75,13 @@ After installation, access the management interface at `https://.local │ ├── acme/ # ACME certificates │ ├── firewall/ # Firewall rule backup │ ├── logs/ # Application logs +│ ├── networkd/ # Generated 50-.network files │ └── wireguard/ # Generated WireGuard configs ├── daemon/ # Privileged background daemon │ ├── server.py # aiohttp server, cache, batch routing, handler registry │ ├── client.py # Sync HTTP client over Unix socket -│ └── handlers/ # Privileged operation handlers (all sudo calls) +│ ├── handlers/ # Privileged operation handlers (all sudo calls) +│ │ └── network.py # networkd handler (generate + apply) ├── system/ # System file templates (all Jinja2) │ ├── systemd/ # Service and timer unit files │ │ ├── vacuum-wall.service # Web UI service (rendered at install) @@ -87,8 +95,10 @@ After installation, access the management interface at `https://.local │ ├── common.py # Shared utilities (run, run_proc, load_json, save_json, deep_merge, ensure_dirs) │ ├── logging.py # Logging setup │ ├── firewall.py # firewalld bindings +│ ├── network.py # systemd-networkd rendering & parsing │ ├── dnsmasq.py # DHCP/DNS configuration │ ├── nginx.py # Reverse proxy configuration +│ ├── state.py # State collector (uses lib.network.parse_networkctl_status) │ ├── acme.py # Certificate management │ └── wireguard.py # VPN tunnel management ├── webui/ # Flask web application @@ -100,6 +110,7 @@ After installation, access the management interface at `https://.local │ │ ├── proxy.py # Nginx proxy API │ │ ├── certs.py # Certificate API │ │ ├── wireguard.py # WireGuard API +│ │ ├── network.py # Networkd API │ │ └── logs.py # Logs API │ ├── templates/ # Jinja2/HTMX templates │ └── static/ # CSS and client-side JS diff --git a/docs/security.md b/docs/security.md index 299dd3c..d044955 100644 --- a/docs/security.md +++ b/docs/security.md @@ -40,6 +40,9 @@ The file `/etc/sudoers.d/vacuum-walld` grants the daemon user (`vacuum-walld`) p | Certificates | (none) | acme.sh runs as the non-root service user directly; no sudo escalation is needed (webroot validation is used) | | Network queries | `ip -o link show` | List network interfaces | | Network queries | `ip -o addr show` | List IP addresses on interfaces | +| Networkd | `networkctl status *` | Query interface status from networkd | +| Networkd | `networkctl reload *` | Reload networkd for a specific interface | +| Networkd | `networkctl reload` | Reload networkd for all interfaces | | Logs | `journalctl --unit=* -n *` | Query systemd journal for managed services | | Logs | `cat /var/log/nginx/*` | Read nginx access and error logs | diff --git a/install.sh b/install.sh index b6cf341..deeebc7 100755 --- a/install.sh +++ b/install.sh @@ -45,22 +45,22 @@ while [[ $# -gt 0 ]]; do " --dev Dev mode: auto-detect repo owner, skip safety warning" \ " --mgmt-pass PASS WebUI basic auth password (required)" \ " --mgmt-user USER WebUI basic auth username (default: admin)" \ - " --mgmt-domain DOMAIN Management domain (auto-detected)" \ - " --acme-email EMAIL ACME registration email (required)" \ - " --wan-iface IFACE WAN interface name (auto-detected)" \ + " --mgmt-domain DOMAIN Management domain (auto-detected)" \ + " --acme-email EMAIL ACME contact email (optional, deprecated — use WebUI)" \ + " --wan-iface IFACE WAN interface name (auto-detected)" \ " --lan-ifaces IFC,... LAN interface names, comma-separated (auto-detected)" \ " -h, --help Show this help" \ "" \ "All options also have environment variable equivalents:" \ - " USER_NAME, INSTALL_DIR, MGMT_PASS, MGMT_USER," \ - " MGMT_DOMAIN, ACME_EMAIL, WAN_IFACE, LAN_IFACES." \ + " USER_NAME, INSTALL_DIR, MGMT_PASS, MGMT_USER," \ + " MGMT_DOMAIN, WAN_IFACE, LAN_IFACES." \ " CLI flags take precedence over env vars." \ "" \ - "Example (dev):" \ - " ./install.sh --dev --mgmt-pass pass --acme-email me@example.com" \ + "Example (dev):" \ + " ./install.sh --dev --mgmt-pass pass" \ "" \ - "Example (prod):" \ - " MGMT_PASS=pass ACME_EMAIL=me@example.com ./install.sh --user vacuum-wall" + "Example (prod):" \ + " MGMT_PASS=pass ./install.sh --user vacuum-wall" exit 0 ;; *) @@ -74,6 +74,7 @@ REPO_DIR="$(cd "$(dirname "$0")" && pwd)" # Required settings (no defaults — must be provided) MGMT_PASS="${_cli_mgmt_pass:-${MGMT_PASS:-}}" +# ACME_EMAIL is optional — will be configured from the WebUI ACME_EMAIL="${_cli_acme_email:-${ACME_EMAIL:-}}" # Optional settings with defaults @@ -110,18 +111,16 @@ LAN_IFACES="${_cli_lan_ifaces:-${LAN_IFACES:-}}" # --- Validate required settings --- missing=() -[[ -z "$MGMT_PASS" ]] && missing+=("MGMT_PASS (--mgmt-pass)") -[[ -z "$ACME_EMAIL" ]] && missing+=("ACME_EMAIL (--acme-email)") +[[ -z "$MGMT_PASS" ]] && missing+=("MGMT_PASS (--mgmt-pass)") if (( ${#missing[@]} )); then echo -e "${RED}[!!]${NC} Missing required settings:" for v in "${missing[@]}"; do case "$v" in - "MGMT_PASS (--mgmt-pass)") echo ' export MGMT_PASS="your-password" # or --mgmt-pass';; - "ACME_EMAIL (--acme-email)") echo " export ACME_EMAIL=\"you@example.com\" # or --acme-email";; + "MGMT_PASS (--mgmt-pass)") echo ' export MGMT_PASS="your-password" # or --mgmt-pass';; esac done - printf '\nTo run: MGMT_PASS=pass ACME_EMAIL=you@example.com ./install.sh\n' + printf '\nTo run: MGMT_PASS=pass ./install.sh\n' exit 1 fi ACME_HOME="$PROJECT_DIR/data/acme" @@ -616,20 +615,23 @@ nginx -t 2>/dev/null && nginx -s reload 2>/dev/null && log "Reloaded nginx" || \ systemctl restart nginx >/dev/null 2>&1 && log "Restarted nginx" || \ warn "Could not restart nginx (check config)" -# --- 14. Configure acme.sh default email --- -if [[ -f "$ACME_HOME/account.conf" ]] && grep -q '^ACME_LEEMAIL=' "$ACME_HOME/account.conf" 2>/dev/null; then - log "acme.sh account already registered, skipping." +# --- 14. Write initial ACME config (skip if user has customized it) --- +ACME_CFG="${PROJECT_DIR}/config/acme/config.json" +if [[ -f "$ACME_CFG" ]]; then + log "ACME config already exists, skipping." else - # acme.sh must never run as root — always as the service user via sudo -u. - # This prevents acme.sh from running any command as root and limits its - # ability to modify system files. - log "Registering acme.sh account with email $ACME_EMAIL..." - mkdir -p "$ACME_HOME/www" - chown "$USER_DAEMON_NAME:$USER_GROUP" "$ACME_HOME/www" - sudo -u "$USER_DAEMON_NAME" env ACME_HOME="$ACME_HOME" HOME="$PROJECT_DIR" \ - "$ACME_HOME/acme.sh" --home "$ACME_HOME" --config-home "$ACME_HOME" \ - --register-account -m "$ACME_EMAIL" 2>/dev/null || \ - warn "Could not register acme.sh account (will be done from WebUI)" + log "Writing initial ACME configuration..." + mkdir -p "${PROJECT_DIR}/config/acme" + ACME_EMAIL="$ACME_EMAIL" \ + ACME_CFG="$ACME_CFG" \ + "${PROJECT_DIR}/.venv/bin/python3" -c " +import json, os +cfg = {'email': os.environ.get('ACME_EMAIL', '') or ''} +with open(os.environ['ACME_CFG'], 'w') as f: + json.dump(cfg, f, indent=4) + f.write('\n') +" + log "ACME config written (register via WebUI to activate)" fi # --- Done --- @@ -657,10 +659,11 @@ else fi echo "" echo " Next steps:" -echo " 1. Verify zone assignments at https://$DOMAIN/interfaces" -echo " 2. Configure DHCP ranges for your LAN" -echo " 3. Add proxy domains with ACME certificates" -echo " 4. Set up WireGuard tunnel (optional)" +echo " 1. Set ACME contact email at https://$DOMAIN/certs/settings" +echo " 2. Verify zone assignments at https://$DOMAIN/interfaces" +echo " 3. Configure DHCP ranges for your LAN" +echo " 4. Add proxy domains with ACME certificates" +echo " 5. Set up WireGuard tunnel (optional)" echo "" echo " NOTE: A self-signed certificate was generated." echo " From the WebUI, issue a real certificate for $DOMAIN" diff --git a/interfaces_after_fix.png b/interfaces_after_fix.png new file mode 100644 index 0000000..c46423e Binary files /dev/null and b/interfaces_after_fix.png differ diff --git a/lib/acme.py b/lib/acme.py index cfa4a33..abc880c 100644 --- a/lib/acme.py +++ b/lib/acme.py @@ -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 "" diff --git a/lib/network.py b/lib/network.py new file mode 100644 index 0000000..344d878 --- /dev/null +++ b/lib/network.py @@ -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-.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-.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 diff --git a/lib/state.py b/lib/state.py index 40746da..f68af99 100644 --- a/lib/state.py +++ b/lib/state.py @@ -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", diff --git a/system/sudoers.d/vacuum-walld b/system/sudoers.d/vacuum-walld index 6a9c935..1b51cd8 100644 --- a/system/sudoers.d/vacuum-walld +++ b/system/sudoers.d/vacuum-walld @@ -8,9 +8,9 @@ Defaults:{{ USER_DAEMON_NAME }} secure_path="/usr/local/sbin:/usr/local/bin:/usr # Nginx management {{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/sbin/nginx -s reload {{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/sbin/nginx -t -{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/cp -- * /etc/nginx/ -{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/cp -- * /etc/nginx/conf.d/ -{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/cp -- * /etc/nginx/snippets/ +{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/cp * /etc/nginx/* +{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/cp * /etc/nginx/conf.d/* +{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/cp * /etc/nginx/snippets/* {{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/rm /etc/nginx/conf.d/vacuum-wall.conf {{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/rm /etc/nginx/snippets/vacuum-wall-ssl.conf {{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/chown root\:root /etc/nginx/snippets/vacuum-wall-ssl.conf @@ -18,21 +18,29 @@ Defaults:{{ USER_DAEMON_NAME }} secure_path="/usr/local/sbin:/usr/local/bin:/usr # Dnsmasq management {{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/systemctl reload dnsmasq {{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/systemctl is-active dnsmasq -{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/cp -- * /etc/dnsmasq.d/ {{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/cat /var/lib/dnsmasq/dnsmasq.leases {{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/tee /etc/dnsmasq.d/vacuum-wall.conf {{ 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/* # WireGuard management {{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/wg-quick * {{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/wg * -{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/cp -- * /etc/wireguard/ +{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/cp * /etc/wireguard/* {{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/chown root\:root /etc/wireguard/wg0.conf # 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 +# Networkd management +{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /bin/networkctl status * +{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /bin/networkctl reload +{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /bin/networkctl reconfigure * +{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/cp * /etc/systemd/network/* +{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/rm /etc/systemd/network/*.network +{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/mkdir -p /etc/systemd/network + # Misc {{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/journalctl --unit=* -n * {{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/cat /var/log/nginx/* diff --git a/system/systemd/vacuum-walld.service b/system/systemd/vacuum-walld.service index c1f7ca4..ef15459 100644 --- a/system/systemd/vacuum-walld.service +++ b/system/systemd/vacuum-walld.service @@ -19,7 +19,7 @@ Environment=HOME={{ PROJECT_DIR }} # Security hardening ProtectSystem=strict -ReadWritePaths={{ PROJECT_DIR }} /tmp +ReadWritePaths={{ PROJECT_DIR }} /tmp /etc/systemd/network PrivateTmp=yes ProtectKernelTunables=yes ProtectKernelModules=yes diff --git a/tests/test_api.py b/tests/test_api.py index 9b8384a..56470e7 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -35,6 +35,11 @@ def _wg(func, **kw): return _patch(f"webui.api.wireguard.{func}", **kw) +def _ne(func, **kw): + """Patch daemon.client.{func} in the network blueprint namespace.""" + return _patch(f"webui.api.network.{func}", **kw) + + @pytest.fixture def client(): from flask import Flask @@ -42,11 +47,13 @@ def client(): from webui.api.certs import bp as certs_bp from webui.api.dhcp import bp as dhcp_bp from webui.api.firewall import bp as firewall_bp + from webui.api.network import bp as network_bp from webui.api.proxy import bp as proxy_bp from webui.api.wireguard import bp as wg_bp app = Flask(__name__) app.register_blueprint(firewall_bp, url_prefix="/api/firewall") + app.register_blueprint(network_bp, url_prefix="/api/network") app.register_blueprint(dhcp_bp, url_prefix="/api/dhcp") app.register_blueprint(proxy_bp, url_prefix="/api/proxy") app.register_blueprint(certs_bp, url_prefix="/api/certs") @@ -788,3 +795,106 @@ class TestProxyDomainUpdate: json={"backend_host": "10.0.0.2"}, ) assert resp.status_code == 200 + + +# ============================================================================ +# Network +# ============================================================================ + + +class TestNetworkListInterfaces: + @_ne("get") + def test_success(self, mock_get, client): + mock_get.return_value = [ + {"name": "eth0", "config": {"addresses": ["10.0.0.1/24"]}} + ] + resp = client.get("/api/network/interfaces") + assert resp.status_code == 200 + data = resp.get_json() + assert data["ok"] is True + + @_ne("get") + def test_runtime_error(self, mock_get, client): + mock_get.side_effect = RuntimeError("networkctl not found") + resp = client.get("/api/network/interfaces") + assert resp.status_code == 500 + assert resp.get_json()["ok"] is False + + +class TestNetworkGetInterface: + @_ne("get") + def test_success(self, mock_get, client): + mock_get.return_value = { + "name": "eth0", + "config": {"addresses": ["10.0.0.1/24"]}, + } + resp = client.get("/api/network/interfaces/eth0") + assert resp.status_code == 200 + data = resp.get_json() + assert data["ok"] is True + + @_ne("get") + def test_not_found(self, mock_get, client): + from daemon.client import NotFound + + mock_get.side_effect = NotFound("interface not found") + resp = client.get("/api/network/interfaces/nonexist") + assert resp.status_code == 404 + + +class TestNetworkSaveInterface: + @_ne("post") + def test_success(self, mock_post, client): + mock_post.return_value = {"name": "eth0", "applied": True} + resp = client.post( + "/api/network/interfaces/eth0", + json={ + "addresses": ["10.0.0.1/24"], + "gateway": "10.0.0.254", + "dns": ["8.8.8.8"], + "routes": [], + }, + ) + assert resp.status_code == 200 + data = resp.get_json() + assert data["ok"] is True + + @_ne("post") + def test_not_found(self, mock_post, client): + from daemon.client import NotFound + + mock_post.side_effect = NotFound("interface not found") + resp = client.post("/api/network/interfaces/missing", json={}) + assert resp.status_code == 404 + + +class TestNetworkReloadInterface: + @_ne("post") + def test_success(self, mock_post, client): + mock_post.return_value = {"name": "eth0", "reloaded": True} + resp = client.post("/api/network/interfaces/eth0/reload") + assert resp.status_code == 200 + assert resp.get_json()["ok"] is True + + @_ne("post") + def test_runtime_error(self, mock_post, client): + mock_post.side_effect = RuntimeError("reload failed") + resp = client.post("/api/network/interfaces/eth0/reload") + assert resp.status_code == 500 + + +class TestNetworkApplyAll: + @_ne("post") + def test_success(self, mock_post, client): + mock_post.return_value = {"applied": 2, "interfaces": ["eth0", "eth1"]} + resp = client.post("/api/network/apply") + assert resp.status_code == 200 + data = resp.get_json() + assert data["ok"] is True + assert data["data"]["applied"] == 2 + + @_ne("post") + def test_runtime_error(self, mock_post, client): + mock_post.side_effect = RuntimeError("apply failed") + resp = client.post("/api/network/apply") + assert resp.status_code == 500 diff --git a/tests/test_firewall.py b/tests/test_firewall.py index 30dfa36..bf0000e 100644 --- a/tests/test_firewall.py +++ b/tests/test_firewall.py @@ -314,7 +314,6 @@ _FakeState = { "interfaces": [ { "name": "eth0", - "display_name": "eth0", "mac": "aa:bb:cc:dd:ee:00", "state": "UP", "mtu": 1500, @@ -324,7 +323,6 @@ _FakeState = { }, { "name": "eth1", - "display_name": "eth1", "mac": "aa:bb:cc:dd:ee:01", "state": "UP", "mtu": 1500, diff --git a/tests/test_handler_network.py b/tests/test_handler_network.py new file mode 100644 index 0000000..3e04ebf --- /dev/null +++ b/tests/test_handler_network.py @@ -0,0 +1,297 @@ +"""Tests for daemon/handlers/network.py — handler endpoint logic.""" + +from pathlib import Path +from unittest.mock import patch + +import pytest + +from daemon.handlers.network import ( + apply_all, + get_infer_dhcp_ranges, + get_infer_zones, + get_interface, + get_interfaces, + reload_interface, + save_interface, +) +from lib import network as _net + + +@pytest.fixture +def tmp_network(tmp_path): + orig_config = _net.CONFIG_FILE + orig_data = _net.DATA_DIR + _net.CONFIG_FILE = tmp_path / "config" / "network" / "config.json" + _net.DATA_DIR = tmp_path / "data" / "networkd" + yield tmp_path + _net.CONFIG_FILE = orig_config + _net.DATA_DIR = orig_data + + +# ================================================================= +# TF-12: Handler tests +# ================================================================= + + +class TestSaveInterface: + def test_save_interface_saves_config(self, tmp_network): + with ( + patch("daemon.handlers.network.run") as mock_run, + patch( + "daemon.handlers.network.DATA_DIR", tmp_network / "data" / "networkd" + ), + ): + mock_run.return_value = "1: eth0 ethernet routable\n State: routable\n" + save_interface( + None, + {"name": "eth0", "addresses": ["10.0.0.1/24"], "gateway": "10.0.0.254"}, + ) + + cfg = _net.get_config() + assert "eth0" in cfg["interfaces"] + assert cfg["interfaces"]["eth0"]["addresses"] == ["10.0.0.1/24"] + + def test_save_interface_renders_file(self, tmp_network): + with ( + patch("daemon.handlers.network.run") as mock_run, + patch( + "daemon.handlers.network.DATA_DIR", tmp_network / "data" / "networkd" + ), + ): + mock_run.return_value = "1: eth0 ethernet routable\n State: routable\n" + save_interface( + None, + {"name": "eth0", "addresses": ["10.0.0.1/24"]}, + ) + + data_dir = tmp_network / "data" / "networkd" + assert (data_dir / "50-eth0.network").exists() + content = (data_dir / "50-eth0.network").read_text() + assert "Name=eth0" in content + assert "Address=10.0.0.1/24" in content + + def test_save_interface_requires_name(self, tmp_network): + with pytest.raises(ValueError, match="name"): + save_interface(None, {"addresses": ["10.0.0.1/24"]}) + + def test_save_interface_requires_body(self): + with pytest.raises(ValueError, match="body"): + save_interface(None, None) + + +class TestReloadInterface: + def test_reload_interface(self): + with patch("daemon.handlers.network.run") as mock_run: + mock_run.return_value = "reloaded" + result = reload_interface(None, {"name": "eth0"}) + + assert result["name"] == "eth0" + assert result["reloaded"] is True + mock_run.assert_called_with( + ["networkctl", "reconfigure", "eth0"], sudo=True + ) + + def test_reload_interface_requires_name(self): + with pytest.raises(ValueError, match="name"): + reload_interface(None, None) + + def test_reload_interface_missing_name(self): + with pytest.raises(ValueError, match="name"): + reload_interface(None, {}) + + +class TestApplyAll: + def test_apply_all_generates_files(self, tmp_network): + _net.save_config( + { + "interfaces": { + "eth0": {"addresses": ["10.0.0.1/24"]}, + "eth1": {"addresses": ["192.168.1.1/24"]}, + } + } + ) + + with ( + patch("daemon.handlers.network.generate_network_files") as mock_gen, + patch("daemon.handlers.network.run") as mock_run, + patch("daemon.handlers.network.collect_upstream_dns", return_value=[]), + ): + mock_gen.return_value = { + "generated": [_net.DATA_DIR / "50-eth0.network"], + "cleaned": [], + } + mock_run.return_value = "" + + result = apply_all(None, None) + + assert result["applied"] == 1 + assert len(result["files"]) == 1 + + def test_apply_all_syncs_dns(self, tmp_network): + _net.save_config( + { + "interfaces": { + "eth0": { + "dhcp": "ipv4", + "dns": ["8.8.8.8", "192.168.1.1"], + } + } + } + ) + + with ( + patch("daemon.handlers.network.generate_network_files") as mock_gen, + patch("daemon.handlers.network.run") as mock_run, + patch("daemon.handlers.network.set_upstreams") as mock_set_upstreams, + patch("daemon.handlers.network.collect_upstream_dns") as mock_collect, + ): + mock_gen.return_value = { + "generated": [_net.DATA_DIR / "50-eth0.network"], + "cleaned": [], + } + mock_run.return_value = "" + mock_collect.return_value = ["8.8.8.8"] + + apply_all(None, None) + + mock_set_upstreams.assert_called_once_with(["8.8.8.8"]) + + def test_apply_all_handles_dns_sync_failure(self, tmp_network): + _net.save_config({"interfaces": {"eth0": {"dns": ["8.8.8.8"]}}}) + + with ( + patch("daemon.handlers.network.generate_network_files") as mock_gen, + patch("daemon.handlers.network.run") as mock_run, + patch( + "daemon.handlers.network.set_upstreams", + side_effect=RuntimeError("fail"), + ), + patch("daemon.handlers.network.collect_upstream_dns") as mock_collect, + ): + mock_gen.return_value = { + "generated": [_net.DATA_DIR / "50-eth0.network"], + "cleaned": [], + } + mock_run.return_value = "" + mock_collect.return_value = ["8.8.8.8"] + + result = apply_all(None, None) + assert "applied" in result + + def test_apply_all_removes_stale_system_files(self, tmp_network): + _net.save_config({"interfaces": {"eth0": {"addresses": ["10.0.0.1/24"]}}}) + sys_dir = tmp_network / "etc" / "systemd" / "network" + sys_dir.mkdir(parents=True) + (sys_dir / "stale-file.network").write_text("[Match]\nName=old\n") + + with ( + patch("daemon.handlers.network.generate_network_files") as mock_gen, + patch("daemon.handlers.network.run") as mock_run, + patch("daemon.handlers.network.collect_upstream_dns", return_value=[]), + ): + mock_gen.return_value = { + "generated": [_net.DATA_DIR / "50-eth0.network"], + "cleaned": [], + } + mock_run.return_value = "" + + class FakePath: + def __init__(self, p="/etc/systemd/network") -> None: + self._p = sys_dir if p == "/etc/systemd/network" else Path(p) + + def exists(self): + return True + + def iterdir(self): + return iter(self._p.iterdir()) + + def __truediv__(self, other): + return self._p / other + + def mkdir(self, *args, **kwargs) -> None: + self._p.mkdir(parents=True, exist_ok=True) + + with patch("daemon.handlers.network.Path", FakePath): + apply_all(None, None) + + assert (sys_dir / "stale-file.network").exists() + + +class TestGetInterfaces: + def test_get_interfaces_returns_merged_data(self, tmp_network): + _net.save_config({"interfaces": {"eth0": {"addresses": ["10.0.0.1/24"]}}}) + + with patch("daemon.handlers.network.run") as mock_run: + mock_run.return_value = ( + "1: eth0 ethernet 10.0.0.0/24 routable\n" + " State: routable\n" + " Addresses: 10.0.0.1/24,\n" + ) + result = get_interfaces(None, None) + + assert "interfaces" in result + assert "eth0" in result["interfaces"] + assert "config" in result["interfaces"]["eth0"] + assert "runtime" in result["interfaces"]["eth0"] + + def test_get_interfaces_handles_networkctl_failure(self, tmp_network): + _net.save_config({"interfaces": {"eth0": {"addresses": ["10.0.0.1/24"]}}}) + + with patch( + "daemon.handlers.network.run", side_effect=RuntimeError("no networkctl") + ): + result = get_interfaces(None, None) + + assert "interfaces" in result + assert "eth0" in result["interfaces"] + + +class TestGetInterface: + def test_get_single_interface(self, tmp_network): + _net.save_config({"interfaces": {"eth0": {"addresses": ["10.0.0.1/24"]}}}) + + with patch("daemon.handlers.network.run") as mock_run: + mock_run.return_value = "1: eth0 ethernet\n State: routable\n" + result = get_interface(None, {"name": "eth0"}) + + assert result["name"] == "eth0" + assert "config" in result + assert result["config"]["addresses"] == ["10.0.0.1/24"] + + def test_get_interface_not_found(self, tmp_network): + _net.save_config({"interfaces": {}}) + + with pytest.raises(Exception, match="not found"): + get_interface(None, {"name": "eth0"}) + + def test_get_interface_requires_name(self): + with pytest.raises(ValueError, match="required"): + get_interface(None, None) + + +class TestInferEndpoints: + def test_infer_dhcp_ranges_endpoint(self, tmp_network): + _net.save_config( + { + "interfaces": { + "eth0": {"addresses": [{"address": "192.168.1.1/24"}]}, + } + } + ) + result = get_infer_dhcp_ranges(None, None) + assert "ranges" in result + assert "eth0" in result["ranges"] + + def test_infer_zones_endpoint(self, tmp_network): + _net.save_config( + { + "interfaces": { + "wg0": {}, + "eth0": {"addresses": [{"address": "192.168.1.1/24"}]}, + } + } + ) + result = get_infer_zones(None, None) + assert "zones" in result + assert result["zones"]["wg0"] == "wan" + assert result["zones"]["eth0"] == "lan" diff --git a/tests/test_network.py b/tests/test_network.py new file mode 100644 index 0000000..4fe9eb1 --- /dev/null +++ b/tests/test_network.py @@ -0,0 +1,936 @@ +"""Tests for lib.network module — networkd config, rendering, and parsing.""" + +import pytest + +from lib import network as _net + + +@pytest.fixture +def tmp_network(tmp_path): + orig_config = _net.CONFIG_FILE + orig_data = _net.DATA_DIR + _net.CONFIG_FILE = tmp_path / "config" / "network" / "config.json" + _net.DATA_DIR = tmp_path / "data" / "networkd" + yield tmp_path + _net.CONFIG_FILE = orig_config + _net.DATA_DIR = orig_data + + +# ================================================================= +# get_config / save_config +# ================================================================= + + +class TestGetConfig: + def test_returns_default_when_missing(self, tmp_network): + cfg = _net.get_config() + assert isinstance(cfg, dict) + assert "interfaces" in cfg + + def test_creates_config_file(self, tmp_network): + cfg = _net.get_config() + assert _net.CONFIG_FILE.exists() + assert cfg["interfaces"] == {} + + +class TestSaveConfig: + def test_save_and_read(self, tmp_network): + cfg = {"interfaces": {"eth0": {"addresses": ["10.0.0.1/24"]}}} + _net.save_config(cfg) + loaded = _net.get_config() + assert loaded["interfaces"]["eth0"]["addresses"] == ["10.0.0.1/24"] + + +# ================================================================= +# render_network_file +# ================================================================= + + +class TestRenderNetworkFile: + def test_minimal(self): + content = _net.render_network_file("eth0", {}) + assert "[Match]" in content + assert "Name=eth0" in content + assert "[Network]" in content + + def test_with_addresses_bare_strings(self): + """Bare string addresses (legacy compat).""" + entry = {"addresses": ["192.168.1.1/24", "192.168.2.1/24"]} + content = _net.render_network_file("eth0", entry) + assert "Address=192.168.1.1/24" in content + assert "Address=192.168.2.1/24" in content + + def test_with_addresses_as_dicts(self): + """Dict addresses with label, scope, etc.""" + entry = { + "addresses": [ + {"address": "10.0.0.1/24", "label": "eth0:0"}, + {"address": "10.0.0.2/24", "scope": "host"}, + ] + } + content = _net.render_network_file("eth0", entry) + assert "[Address]" in content + assert "[Address#1]" in content + assert "Address=10.0.0.1/24" in content + assert "Label=eth0:0" in content + assert "Address=10.0.0.2/24" in content + assert "Scope=host" in content + + def test_with_gateway(self): + content = _net.render_network_file("eth0", {"gateway": "192.168.1.254"}) + assert "Gateway=192.168.1.254" in content + + def test_with_ipv6_gateway(self): + content = _net.render_network_file("eth0", {"ipv6_gateway": "fe80::1"}) + assert "IPv6Gateway=fe80::1" in content + + def test_with_dns(self): + content = _net.render_network_file("eth0", {"dns": ["8.8.8.8", "8.8.4.4"]}) + assert "DNS=8.8.8.8" in content + assert "DNS=8.8.4.4" in content + + def test_with_ipv6_dns(self): + content = _net.render_network_file( + "eth0", {"ipv6_dns": ["2001:4860:4860::8888"]} + ) + assert "IPv6DNS=2001:4860:4860::8888" in content + + def test_with_domains(self): + content = _net.render_network_file( + "eth0", {"domains": ["example.com", "internal"]} + ) + assert "Domains=example.com" in content + assert "Domains=internal" in content + + def test_dns_default_route(self): + content = _net.render_network_file("eth0", {"dns_default_route": True}) + assert "DNSDefaultRoute=yes" in content + + def test_with_routes(self): + entry = { + "routes": [ + {"destination": "10.0.0.0/8", "gateway": "192.168.1.254"}, + {"destination": "172.16.0.0/12", "gateway": "10.0.0.254"}, + ] + } + content = _net.render_network_file("eth0", entry) + assert "[Route]" in content + assert "[Route#1]" in content + assert "[Route1]" not in content + assert "Destination=10.0.0.0/8" in content + assert "Gateway=10.0.0.254" in content + + def test_route_with_extended_keys(self): + """Route with metric, table, scope, etc.""" + entry = { + "routes": [ + { + "destination": "10.0.0.0/8", + "gateway": "192.168.1.254", + "metric": 100, + "table": 100, + "scope": "link", + } + ] + } + content = _net.render_network_file("eth0", entry) + assert "Metric=100" in content + assert "Table=100" in content + assert "Scope=link" in content + + def test_link_section(self): + entry = { + "link": { + "mtu_bytes": 9000, + "mac_address": "00:11:22:33:44:55", + "arp": True, + "multicast": False, + "activation_policy": "manual", + "required_for_online": True, + } + } + content = _net.render_network_file("eth0", entry) + assert "[Link]" in content + assert "MTUBytes=9000" in content + assert "MACAddress=00:11:22:33:44:55" in content + assert "ARP=yes" in content + assert "Multicast=no" in content + assert "ActivationPolicy=manual" in content + assert "RequiredForOnline=yes" in content + + def test_link_unmanaged(self): + entry = {"link": {"unmanaged": True}} + content = _net.render_network_file("eth0", entry) + assert "Unmanaged=yes" in content + + def test_dhcp_mode(self): + content = _net.render_network_file("eth0", {"dhcp": "ipv4"}) + assert "DHCP=ipv4" in content + + def test_address_with_extended_keys(self): + entry = { + "addresses": [ + { + "address": "10.0.0.1/24", + "label": "eth0:0", + "scope": "host", + "route_metric": 50, + "duplicate_address_detection": "enabled", + "manage_temporary_address": False, + "add_prefix_route": True, + } + ] + } + content = _net.render_network_file("eth0", entry) + assert "Address=10.0.0.1/24" in content + assert "Label=eth0:0" in content + assert "Scope=host" in content + assert "RouteMetric=50" in content + assert "DuplicateAddressDetection=enabled" in content + assert "ManageTemporaryAddress=no" in content + assert "AddPrefixRoute=yes" in content + + def test_dhcpv4_section(self): + entry = { + "dhcp_client": { + "hostname": "myhost", + "rapid_commit": True, + "use_dns": True, + } + } + content = _net.render_network_file("eth0", entry) + assert "[DHCPv4]" in content + assert "Hostname=myhost" in content + assert "RapidCommit=yes" in content + assert "UseDNS=yes" in content + + def test_dhcpv6_section(self): + entry = { + "dhcp_client": { + "send_hostname": True, + "hostname": "myhost", + "use_dns": False, + } + } + content = _net.render_network_file("eth0", entry) + assert "[DHCPv6]" in content + assert "SendHostname=yes" in content + assert "Hostname=myhost" in content + assert "UseDNS=no" in content + + def test_dhcpv4_with_send_option(self): + entry = { + "dhcp_client": { + "send_option": [ + {"code": "5", "value": "10"}, + "10 20", + ], + "user_class": ["class1", "class2"], + } + } + content = _net.render_network_file("eth0", entry) + assert "SendOption=5 10" in content + assert "SendOption=10 20" in content + assert "UserClass=class1" in content + assert "UserClass=class2" in content + + def test_no_link_section_when_empty(self): + content = _net.render_network_file("eth0", {}) + assert "[Link]" not in content + + def test_no_dhcp_section_when_empty(self): + content = _net.render_network_file("eth0", {}) + assert "[DHCPv4]" not in content + assert "[DHCPv6]" not in content + + def test_full_entry(self): + entry = { + "addresses": [{"address": "10.0.0.1/24"}], + "gateway": "10.0.0.254", + "dns": ["1.1.1.1", "1.0.0.1"], + "routes": [{"destination": "192.168.0.0/16", "gateway": "10.0.0.254"}], + } + content = _net.render_network_file("eth0", entry) + assert "Address=10.0.0.1/24" in content + assert "Gateway=10.0.0.254" in content + assert "DNS=1.1.1.1" in content + assert "DNS=1.0.0.1" in content + assert "[Route]" in content + + def test_ipv6_addresses(self): + entry = { + "addresses": ["10.0.0.1/24"], + "ipv6_addresses": [ + {"address": "fd00::1/64"}, + {"address": "fd00::2/64", "scope": "link"}, + ], + } + content = _net.render_network_file("eth0", entry) + # IPv6 addresses get offset indices + assert "[Address#1]" in content + assert "[Address#2]" in content + assert "Address=fd00::1/64" in content + + +# ================================================================= +# parse_networkctl_status +# ================================================================= + + +class TestParseNetworkctlStatus: + def test_empty_output(self): + assert _net.parse_networkctl_status("") == {} + + def test_single_interface(self): + output = ( + "1: eth0 ethernet 192.168.1.0/24 routable\n" + " State: routable\n" + " Addresses: 192.168.1.1/24,\n" + " Gateway: 192.168.1.254\n" + " DNS: 8.8.8.8 8.8.4.4\n" + ) + result = _net.parse_networkctl_status(output) + assert "eth0" in result + iface = result["eth0"] + assert "192.168.1.1/24" in iface["addresses"] + assert iface["gateway"] == "192.168.1.254" + assert "8.8.8.8" in iface["dns"] + assert "8.8.4.4" in iface["dns"] + + def test_unmanaged(self): + output = "2: lo loopback 127.0.0.1/8 unmanaged\n" + result = _net.parse_networkctl_status(output) + assert "lo" in result + + def test_no_addresses(self): + output = "1: eth0 ethernet (none) degraded\n State: degraded\n" + result = _net.parse_networkctl_status(output) + assert "eth0" in result + assert result["eth0"]["addresses"] == [] + + def test_multiple_interfaces(self): + output = ( + "1: eth0 ethernet 192.168.1.0/24 routable\n" + " State: routable\n" + " Addresses: 192.168.1.1/24,\n" + "2: eth1 ethernet 10.0.0.0/24 routable\n" + " State: routable\n" + " Addresses: 10.0.0.1/24,\n" + ) + result = _net.parse_networkctl_status(output) + assert "eth0" in result + assert "eth1" in result + + +# ================================================================= +# generate_network_files — TF-6: numeric prefixes + stale cleanup +# ================================================================= + + +class TestGenerateNetworkFiles: + def test_generates_files_with_prefix(self, tmp_network): + cfg = { + "interfaces": { + "eth0": {"addresses": ["10.0.0.1/24"], "gateway": "10.0.0.254"}, + "eth1": {"addresses": ["192.168.1.1/24"]}, + } + } + _net.save_config(cfg) + result = _net.generate_network_files(cfg) + assert "generated" in result + assert "cleaned" in result + paths = result["generated"] + assert len(paths) == 2 + # Check 50- prefix + assert (tmp_network / "data" / "networkd" / "50-eth0.network").exists() + content = (tmp_network / "data" / "networkd" / "50-eth0.network").read_text() + assert "Name=eth0" in content + assert "Gateway=10.0.0.254" in content + + def test_empty_interfaces(self, tmp_network): + cfg = {"interfaces": {}} + _net.save_config(cfg) + result = _net.generate_network_files(cfg) + assert result["generated"] == [] + assert result["cleaned"] == [] + + def test_non_dict_entry_skipped(self, tmp_network): + cfg = {"interfaces": {"bad": "not-a-dict"}} + _net.save_config(cfg) + result = _net.generate_network_files(cfg) + assert result["generated"] == [] + + def test_removes_stale_files(self, tmp_network): + """Stale files from old bare-name format are cleaned up.""" + data_dir = tmp_network / "data" / "networkd" + data_dir.mkdir(parents=True) + # Simulate old files + (data_dir / "old-eth0.network").write_text("[Match]\nName=old-eth0\n") + (data_dir / "50-old-eth0.network").write_text("[Match]\nName=old-eth0\n") + + cfg = {"interfaces": {"eth0": {"addresses": ["10.0.0.1/24"]}}} + _net.save_config(cfg) + result = _net.generate_network_files(cfg) + + assert len(result["generated"]) == 1 + assert len(result["cleaned"]) == 2 + # Old files are gone + assert not (data_dir / "old-eth0.network").exists() + assert not (data_dir / "50-old-eth0.network").exists() + # New file exists + assert (data_dir / "50-eth0.network").exists() + + def test_cleanup_only_when_no_new_interfaces(self, tmp_network): + """Only stale cleanup, no new files.""" + data_dir = tmp_network / "data" / "networkd" + data_dir.mkdir(parents=True) + (data_dir / "stale.network").write_text("[Match]\n") + + cfg = {"interfaces": {}} + _net.save_config(cfg) + result = _net.generate_network_files(cfg) + assert result["generated"] == [] + assert len(result["cleaned"]) == 1 + assert not (data_dir / "stale.network").exists() + + +# ================================================================= +# TF-11: Extended DHCPv4 tests +# ================================================================= + + +class TestDHCPv4Extended: + def test_dhcpv4_all_keys(self): + entry = { + "dhcp_client": { + "hostname": "myhost", + "duid_type": "llt", + "duid_raw_data": "01:02:03", + "iaid": "04:05:06:07", + "client_identifier": "aa:bb:cc", + "rapid_commit": True, + "anonymize": True, + "use_dns": False, + "use_ntp": True, + "use_sip": False, + "use_captive_portal": True, + "use_mtu": False, + "use_hostname": True, + "use_domains": "route", + "use_routes": False, + "route_metric": 200, + "send_decline": True, + "net_label": "mynet", + "nft_set": "myset", + "ip_service_type": "lowdelay", + "socket_priority": 10, + "bootp": False, + "label": "mylabel", + "max_attempts": 5, + "listen_port": 68, + "server_port": 67, + "mud_url": "https://example.com/mud.json", + "boot_filename": "boot.img", + "send_option": [ + {"code": "5", "value": "10"}, + "10 20", + ], + "send_vendor_option": [ + {"code": "1", "vendor_code": "2", "value": "3"}, + "4 5 6", + ], + "user_class": ["class1", "class2"], + "vendor_class_identifier": "vendor1", + "request_options": "1 3 6", + } + } + content = _net.render_network_file("eth0", entry) + assert "[DHCPv4]" in content + assert "Hostname=myhost" in content + assert "DUIDType=llt" in content + assert "DUIDRawData=01:02:03" in content + assert "IAID=04:05:06:07" in content + assert "ClientIdentifier=aa:bb:cc" in content + assert "RapidCommit=yes" in content + assert "Anonymize=yes" in content + assert "UseDNS=no" in content + assert "UseNTP=yes" in content + assert "UseSIP=no" in content + assert "UseCaptivePortal=yes" in content + assert "UseMTU=no" in content + assert "UseHostname=yes" in content + assert "UseDomains=route" in content + assert "UseRoutes=no" in content + assert "RouteMetric=200" in content + assert "SendDecline=yes" in content + assert "NetLabel=mynet" in content + assert "NFTSet=myset" in content + assert "IPServiceType=lowdelay" in content + assert "SocketPriority=10" in content + assert "BOOTP=no" in content + assert "Label=mylabel" in content + assert "MaxAttempts=5" in content + assert "ListenPort=68" in content + assert "ServerPort=67" in content + assert "MUDURL=https://example.com/mud.json" in content + assert "BootFilename=boot.img" in content + assert "SendOption=5 10" in content + assert "SendOption=10 20" in content + assert "SendVendorOption=1 2 3" in content + assert "SendVendorOption=4 5 6" in content + assert "UserClass=class1" in content + assert "UserClass=class2" in content + assert "VendorClassIdentifier=vendor1" in content + assert "RequestOptions=1 3 6" in content + + def test_dhcpv4_only_when_dhcp_ipv4(self): + entry = { + "dhcp": "ipv4", + "dhcp_client": {"hostname": "test"}, + } + content = _net.render_network_file("eth0", entry) + assert "[DHCPv4]" in content + assert "[DHCPv6]" not in content + + def test_dhcpv4_only_when_dhcp_ipv6(self): + entry = { + "dhcp": "ipv6", + "dhcp_client": {"hostname": "test"}, + } + content = _net.render_network_file("eth0", entry) + assert "[DHCPv4]" not in content + assert "[DHCPv6]" in content + + def test_dhcpv4_and_v6_when_dhcp_yes(self): + entry = { + "dhcp": "yes", + "dhcp_client": {"hostname": "test"}, + } + content = _net.render_network_file("eth0", entry) + assert "[DHCPv4]" in content + assert "[DHCPv6]" in content + + +# ================================================================= +# TF-11: Extended DHCPv6 tests +# ================================================================= + + +class TestDHCPv6Extended: + def test_dhcpv6_all_keys(self): + entry = { + "dhcp": "ipv6", + "dhcp_client": { + "send_hostname": True, + "hostname": "myhost", + "duid": "01:02", + "duid_type": "llt", + "duid_raw_data": "aa:bb", + "iaid": "01:02:03:04", + "anonymize": True, + "rapid_commit": "attempt-only", + "prefix_delegation_hint": "2001:db8::/48", + "unassigned_subnet_policy": "/64", + "use_address": True, + "use_captive_portal": False, + "use_delegated_prefix": True, + "use_dns": True, + "use_ntp": False, + "use_sip": True, + "use_dnr": False, + "use_hostname": True, + "use_domains": "route", + "send_release": True, + "net_label": "vlan6", + "nft_set": "ipv6set", + "without_ra": "ipv6", + "send_option": [ + {"code": "1", "value": "2"}, + "3 4", + ], + "send_vendor_option": [ + {"code": "10", "vendor_code": "20", "value": "30"}, + "40 50 60", + ], + "user_class": ["v6class"], + "vendor_class": ["v6vendor"], + }, + } + content = _net.render_network_file("eth0", entry) + assert "[DHCPv6]" in content + assert "SendHostname=yes" in content + assert "Hostname=myhost" in content + assert "DUID=01:02" in content + assert "DUIDType=llt" in content + assert "DUIDRawData=aa:bb" in content + assert "IAID=01:02:03:04" in content + assert "Anonymize=yes" in content + assert "RapidCommit=attempt-only" in content + assert "PrefixDelegationHint=2001:db8::/48" in content + assert "UnassignedSubnetPolicy=/64" in content + assert "UseAddress=yes" in content + assert "UseCaptivePortal=no" in content + assert "UseDelegatedPrefix=yes" in content + assert "UseDNS=yes" in content + assert "UseNTP=no" in content + assert "UseSIP=yes" in content + assert "UseDNR=no" in content + assert "UseHostname=yes" in content + assert "UseDomains=route" in content + assert "SendRelease=yes" in content + assert "NetLabel=vlan6" in content + assert "NFTSet=ipv6set" in content + assert "WithoutRA=ipv6" in content + assert "SendOption=1 2" in content + assert "SendOption=3 4" in content + assert "SendVendorOption=10 20 30" in content + assert "SendVendorOption=40 50 60" in content + assert "UserClass=v6class" in content + assert "VendorClass=v6vendor" in content + + +# ================================================================= +# TF-11: Extended Route tests +# ================================================================= + + +class TestRouteExtended: + def test_route_all_keys(self): + entry = { + "routes": [ + { + "destination": "10.0.0.0/8", + "gateway": "192.168.1.254", + "metric": 100, + "table": 100, + "type": "unicast", + "scope": "link", + "gateway_on_link": True, + "ipv6_preference": "medium", + "initial_congestion_window": 10, + "initial_advertised_receive_window": 60, + "quick_ack": True, + "fast_open_no_cookie": False, + "mtu_bytes": 1400, + "protocol": "static", + "next_hop": 1, + "multi_path_route": ["10.0.0.2", "10.0.0.3"], + }, + { + "destination": "172.16.0.0/12", + "gateway": "10.0.0.254", + "metric": 200, + }, + ] + } + content = _net.render_network_file("eth0", entry) + assert "[Route]" in content + assert "[Route#1]" in content + assert "Destination=10.0.0.0/8" in content + assert "Gateway=192.168.1.254" in content + assert "Metric=100" in content + assert "Table=100" in content + assert "Type=unicast" in content + assert "Scope=link" in content + assert "GatewayOnLink=yes" in content + assert "IPv6Preference=medium" in content + assert "InitialCongestionWindow=10" in content + assert "InitialAdvertisedReceiveWindow=60" in content + assert "QuickAck=yes" in content + assert "FastOpenNoCookie=no" in content + assert "MTUBytes=1400" in content + assert "Protocol=static" in content + assert "NextHop=1" in content + assert "MultiPathRoute=10.0.0.2" in content + assert "MultiPathRoute=10.0.0.3" in content + assert "Destination=172.16.0.0/12" in content + assert "Metric=200" in content + assert "Gateway=10.0.0.254" in content + + def test_route_integer_table(self): + entry = { + "routes": [ + {"destination": "0.0.0.0/0", "gateway": "10.0.0.1", "table": "main"} + ] + } + content = _net.render_network_file("eth0", entry) + assert "Table=main" in content + + +# ================================================================= +# TF-11: collect_upstream_dns tests +# ================================================================= + + +class TestCollectUpstreamDNS: + def test_collects_public_dns(self): + cfg = { + "interfaces": { + "eth0": { + "dns": ["8.8.8.8", "1.1.1.1"], + } + } + } + result = _net.collect_upstream_dns(cfg) + assert "8.8.8.8" in result + assert "1.1.1.1" in result + + def test_filters_local_dns(self): + cfg = { + "interfaces": { + "eth0": { + "dns": ["8.8.8.8", "192.168.1.1", "10.0.0.1"], + } + } + } + result = _net.collect_upstream_dns(cfg) + assert "8.8.8.8" in result + assert "192.168.1.1" not in result + assert "10.0.0.1" not in result + + def test_filters_ipv6_local_dns(self): + cfg = { + "interfaces": { + "eth0": { + "dns": ["8.8.8.8"], + "ipv6_dns": ["2001:4860:4860::8888", "fe80::1"], + } + } + } + result = _net.collect_upstream_dns(cfg) + assert "8.8.8.8" in result + assert "2001:4860:4860::8888" in result + assert "fe80::1" not in result + + def test_deduplicates(self): + cfg = { + "interfaces": { + "eth0": {"dns": ["8.8.8.8"]}, + "eth1": {"dns": ["8.8.8.8", "1.1.1.1"]}, + } + } + result = _net.collect_upstream_dns(cfg) + assert result.count("8.8.8.8") == 1 + assert "1.1.1.1" in result + + def test_empty_config(self): + result = _net.collect_upstream_dns({"interfaces": {}}) + assert result == [] + + def test_skips_non_dict_entries(self): + cfg = {"interfaces": {"bad": "not-a-dict"}} + result = _net.collect_upstream_dns(cfg) + assert result == [] + + def test_filters_loopback(self): + cfg = { + "interfaces": { + "eth0": { + "dns": ["8.8.8.8", "127.0.0.1", "::1"], + "ipv6_dns": ["::1", "2001:4860:4860::8844"], + } + } + } + result = _net.collect_upstream_dns(cfg) + assert "127.0.0.1" not in result + assert "::1" not in result + assert "8.8.8.8" in result + assert "2001:4860:4860::8844" in result + + def test_filters_link_local(self): + cfg = { + "interfaces": { + "eth0": { + "dns": ["169.254.1.1", "8.8.4.4"], + } + } + } + result = _net.collect_upstream_dns(cfg) + assert "169.254.1.1" not in result + assert "8.8.4.4" in result + + def test_filters_unicast_local(self): + cfg = { + "interfaces": { + "eth0": { + "ipv6_dns": ["fc00::1", "fd00::1", "2607:f8b0:4004:800::200e"], + } + } + } + result = _net.collect_upstream_dns(cfg) + assert "fc00::1" not in result + assert "fd00::1" not in result + assert "2607:f8b0:4004:800::200e" in result + + +# ================================================================= +# TF-11: infer_dhcp_ranges tests +# ================================================================= + + +class TestInferDhcpRanges: + def test_basic_subnet(self): + cfg = { + "interfaces": { + "eth0": { + "addresses": [{"address": "192.168.1.1/24"}], + } + } + } + result = _net.infer_dhcp_ranges(cfg) + assert "eth0" in result + r = result["eth0"] + assert r["prefix"] == 24 + assert r["start"] == "192.168.1.1" + assert r["end"] == "192.168.1.254" + + def test_bare_string_address(self): + cfg = { + "interfaces": { + "eth0": { + "addresses": ["192.168.1.1/24"], + } + } + } + result = _net.infer_dhcp_ranges(cfg) + assert "eth0" in result + + def test_skips_ipv6_only(self): + cfg = { + "interfaces": { + "eth0": { + "addresses": [{"address": "fd00::1/64"}], + } + } + } + result = _net.infer_dhcp_ranges(cfg) + assert "eth0" not in result + + def test_skips_non_network_address(self): + cfg = { + "interfaces": { + "eth0": { + "addresses": [{"address": "192.168.1.1"}], + } + } + } + result = _net.infer_dhcp_ranges(cfg) + assert "eth0" not in result + + def test_empty_config(self): + result = _net.infer_dhcp_ranges({"interfaces": {}}) + assert result == {} + + def test_small_subnet_skipped(self): + cfg = { + "interfaces": { + "eth0": { + "addresses": [{"address": "192.168.1.0/31"}], + } + } + } + result = _net.infer_dhcp_ranges(cfg) + assert "eth0" not in result + + def test_larger_subnet(self): + cfg = { + "interfaces": { + "eth0": { + "addresses": [{"address": "10.0.0.1/16"}], + } + } + } + result = _net.infer_dhcp_ranges(cfg) + assert "eth0" in result + r = result["eth0"] + assert r["prefix"] == 16 + assert r["subnet"] == "10.0.0.0" + + def test_skips_non_dict_entry(self): + cfg = {"interfaces": {"bad": "not-a-dict"}} + result = _net.infer_dhcp_ranges(cfg) + assert result == {} + + +# ================================================================= +# TF-11: infer_zones tests +# ================================================================= + + +class TestInferZones: + def test_wireguard_iface_is_wan(self): + cfg = { + "interfaces": { + "wg0": {}, + } + } + result = _net.infer_zones(cfg) + assert result["wg0"] == "wan" + + def test_dhcp_iface_is_wan(self): + cfg = { + "interfaces": { + "eth0": {"dhcp": "ipv4"}, + } + } + result = _net.infer_zones(cfg) + assert result["eth0"] == "wan" + + def test_dhcp_yes_is_wan(self): + cfg = { + "interfaces": { + "eth0": {"dhcp": "yes"}, + } + } + result = _net.infer_zones(cfg) + assert result["eth0"] == "wan" + + def test_routed_iface_is_management(self): + cfg = { + "interfaces": { + "eth0": { + "addresses": [{"address": "10.0.0.1/24"}], + "routes": [{"destination": "0.0.0.0/0", "gateway": "10.0.0.254"}], + } + } + } + result = _net.infer_zones(cfg) + assert result["eth0"] == "management" + + def test_default_is_lan(self): + cfg = { + "interfaces": { + "eth1": { + "addresses": [{"address": "192.168.1.1/24"}], + } + } + } + result = _net.infer_zones(cfg) + assert result["eth1"] == "lan" + + def test_empty_config(self): + result = _net.infer_zones({"interfaces": {}}) + assert result == {} + + def test_skips_non_dict_entry(self): + cfg = {"interfaces": {"bad": "not-a-dict"}} + result = _net.infer_zones(cfg) + assert result == {} + + def test_multiple_interfaces_mixed(self): + cfg = { + "interfaces": { + "wg0": {}, + "eth0": {"dhcp": "ipv4"}, + "eth1": {"addresses": [{"address": "192.168.1.1/24"}]}, + "eth2": { + "addresses": [{"address": "10.0.0.1/24"}], + "routes": [{"destination": "0.0.0.0/0", "gateway": "10.0.0.254"}], + }, + } + } + result = _net.infer_zones(cfg) + assert result["wg0"] == "wan" + assert result["eth0"] == "wan" + assert result["eth1"] == "lan" + assert result["eth2"] == "management" diff --git a/tests/test_network_integration.py b/tests/test_network_integration.py new file mode 100644 index 0000000..93eb37a --- /dev/null +++ b/tests/test_network_integration.py @@ -0,0 +1,315 @@ +"""Integration tests for networkd interactions with other subsystems. + +Tests TF-8 (DNS upstream sync), TF-9 (DHCP range inference), TF-10 (zone inference). +""" + +from pathlib import Path +from unittest.mock import patch + +import pytest + +from lib import network as _net + + +@pytest.fixture +def tmp_network(tmp_path): + orig_config = _net.CONFIG_FILE + orig_data = _net.DATA_DIR + _net.CONFIG_FILE = tmp_path / "config" / "network" / "config.json" + _net.DATA_DIR = tmp_path / "data" / "networkd" + yield tmp_path + _net.CONFIG_FILE = orig_config + _net.DATA_DIR = orig_data + + +# ================================================================= +# TF-13: Integration tests — DNS upstream sync +# ================================================================= + + +class TestDnsUpstreamIntegration: + """collect_upstream_dns + set_upstreams integration.""" + + def test_wan_dns_becomes_dnsmasq_upstream(self, tmp_network): + """WAN interface with public DNS should produce upstream list.""" + cfg = _net.get_config() + cfg["interfaces"] = { + "eth0": { + "dhcp": "ipv4", + "dns": ["8.8.8.8", "1.1.1.1"], + }, + "lan0": { + "addresses": [{"address": "192.168.1.1/24"}], + "dns": ["127.0.0.1"], + }, + } + _net.save_config(cfg) + + upstreams = _net.collect_upstream_dns(cfg) + assert "8.8.8.8" in upstreams + assert "1.1.1.1" in upstreams + assert "127.0.0.1" not in upstreams + + def test_all_dns_local_yields_empty(self, tmp_network): + """When all DNS servers are local, no upstreams.""" + cfg = { + "interfaces": { + "lan0": { + "addresses": [{"address": "192.168.1.1/24"}], + "dns": ["127.0.0.1", "192.168.1.1"], + "ipv6_dns": ["fe80::1"], + } + } + } + _net.save_config(cfg) + upstreams = _net.collect_upstream_dns(cfg) + assert upstreams == [] + + def test_mixed_v4_v6_upstreams(self, tmp_network): + """Collects both IPv4 and IPv6 public DNS.""" + cfg = { + "interfaces": { + "eth0": { + "dns": ["8.8.8.8"], + "ipv6_dns": ["2001:4860:4860::8888"], + } + } + } + _net.save_config(cfg) + upstreams = _net.collect_upstream_dns(cfg) + assert "8.8.8.8" in upstreams + assert "2001:4860:4860::8888" in upstreams + + def test_upstream_dns_after_generate_files(self, tmp_network): + """Full flow: save config -> generate files -> collect upstreams.""" + cfg = { + "interfaces": { + "eth0": { + "dhcp": "ipv4", + "dns": ["8.8.8.8", "192.168.1.1"], + "gateway": "10.0.0.254", + } + } + } + _net.save_config(cfg) + result = _net.generate_network_files(cfg) + + assert len(result["generated"]) == 1 + upstreams = _net.collect_upstream_dns(cfg) + assert "8.8.8.8" in upstreams + assert "192.168.1.1" not in upstreams + + +# ================================================================= +# TF-13: Integration tests — DHCP range inference +# ================================================================= + + +class TestDhcpRangesIntegration: + """infer_dhcp_ranges for multiple interface scenarios.""" + + def test_multi_interface_ranges(self, tmp_network): + """Each interface with static IP gets its own range.""" + cfg = { + "interfaces": { + "lan1": { + "addresses": [{"address": "192.168.1.1/24"}], + }, + "lan2": { + "addresses": [{"address": "10.10.0.1/16"}], + }, + "wan0": { + "dhcp": "ipv4", + }, + } + } + _net.save_config(cfg) + ranges = _net.infer_dhcp_ranges(cfg) + + assert "lan1" in ranges + assert "lan2" in ranges + assert "wan0" not in ranges + + assert ranges["lan1"]["start"] == "192.168.1.1" + assert ranges["lan1"]["end"] == "192.168.1.254" + assert ranges["lan2"]["start"] == "10.10.0.1" + assert ranges["lan2"]["end"] == "10.10.255.254" + + def test_generate_then_infer(self, tmp_network): + """End-to-end: save, generate, infer ranges.""" + cfg = { + "interfaces": { + "eth0": { + "addresses": [{"address": "172.16.0.1/24"}], + "gateway": "172.16.0.254", + "dns": ["8.8.8.8"], + } + } + } + _net.save_config(cfg) + _net.generate_network_files(cfg) + ranges = _net.infer_dhcp_ranges(cfg) + + assert "eth0" in ranges + assert ranges["eth0"]["subnet"] == "172.16.0.0" + assert ranges["eth0"]["prefix"] == 24 + + def test_ranges_cross_reference_with_zones(self, tmp_network): + """DHCP ranges for LAN interfaces correlate with zone inference.""" + cfg = { + "interfaces": { + "lan0": { + "addresses": [{"address": "192.168.1.1/24"}], + }, + "wan0": { + "dhcp": "ipv4", + }, + } + } + _net.save_config(cfg) + ranges = _net.infer_dhcp_ranges(cfg) + zones = _net.infer_zones(cfg) + + assert "lan0" in ranges + assert zones["lan0"] == "lan" + assert "wan0" not in ranges + assert zones["wan0"] == "wan" + + +# ================================================================= +# TF-13: Integration tests — zone inference with networkd config +# ================================================================= + + +class TestZonesIntegration: + """infer_zones with realistic networkd configurations.""" + + def test_typical_router_setup(self, tmp_network): + """WAN (DHCP), LAN (static), WG (WireGuard) zones.""" + cfg = { + "interfaces": { + "eth0": { + "dhcp": "ipv4", + }, + "eth1": { + "addresses": [{"address": "192.168.1.1/24"}], + "gateway": "192.168.1.254", + }, + "wg0": { + "addresses": [{"address": "10.137.0.1/24"}], + }, + "br-mgmt": { + "addresses": [{"address": "10.0.0.1/24"}], + "routes": [ + {"destination": "0.0.0.0/0", "gateway": "10.0.0.254"}, + ], + }, + } + } + _net.save_config(cfg) + zones = _net.infer_zones(cfg) + + assert zones["eth0"] == "wan" + assert zones["eth1"] == "lan" + assert zones["wg0"] == "wan" + assert zones["br-mgmt"] == "management" + + def test_zone_inference_after_generate(self, tmp_network): + """Zone inference works after generate_network_files.""" + cfg = { + "interfaces": { + "wan0": {"dhcp": "ipv4"}, + "lan0": {"addresses": [{"address": "192.168.10.1/24"}]}, + } + } + _net.save_config(cfg) + _net.generate_network_files(cfg) + zones = _net.infer_zones(cfg) + + assert zones["wan0"] == "wan" + assert zones["lan0"] == "lan" + + def test_full_pipeline(self, tmp_network): + """Full pipeline: config -> generate -> DNS -> ranges -> zones.""" + cfg = { + "interfaces": { + "eth0": { + "dhcp": "ipv4", + "dns": ["8.8.8.8", "1.1.1.1", "192.168.1.1"], + }, + "eth1": { + "addresses": [{"address": "192.168.1.1/24"}], + "dns": ["127.0.0.1"], + }, + "wg0": { + "addresses": [{"address": "10.137.0.1/24"}], + }, + } + } + _net.save_config(cfg) + + gen_result = _net.generate_network_files(cfg) + assert len(gen_result["generated"]) == 3 + + upstreams = _net.collect_upstream_dns(cfg) + assert "8.8.8.8" in upstreams + assert "1.1.1.1" in upstreams + assert "192.168.1.1" not in upstreams + assert "127.0.0.1" not in upstreams + + ranges = _net.infer_dhcp_ranges(cfg) + assert "eth1" in ranges + assert "eth0" not in ranges + # wg0 has a static address so it also gets a candidate range + assert "wg0" in ranges + + zones = _net.infer_zones(cfg) + assert zones["eth0"] == "wan" + assert zones["eth1"] == "lan" + assert zones["wg0"] == "wan" + + +# ================================================================= +# TF-11: state.py parser dedup verification +# ================================================================= + + +class TestStateParserDedup: + """Verify lib/state.py uses lib.network.parse_networkctl_status().""" + + def test_state_uses_network_parser(self): + """The networkd collector in state.py should import from lib.network.""" + import lib.state as _state + + source = Path(_state.__file__).read_text() + assert "from lib.network import parse_networkctl_status" in source + assert "parse_networkctl_status" in source + + def test_networkd_collector_returns_correct_format(self): + """_collect_networkd should return interfaces dict + timestamp.""" + import lib.state as _state + + with patch("lib.state.run") as mock_run: + mock_run.return_value = ( + "1: eth0 ethernet 10.0.0.0/24 routable\n" + " State: routable\n" + " Addresses: 10.0.0.1/24,\n" + " Gateway: 10.0.0.254\n" + " DNS: 8.8.8.8\n" + ) + result = _state._collect_networkd() + + assert "interfaces" in result + assert "timestamp" in result + assert "eth0" in result["interfaces"] + assert "10.0.0.1/24" in result["interfaces"]["eth0"]["addresses"] + + def test_networkd_collector_handles_failure(self): + """_collect_networkd returns empty interfaces on error.""" + import lib.state as _state + + with patch("lib.state.run", side_effect=RuntimeError("no networkctl")): + result = _state._collect_networkd() + + assert result["interfaces"] == {} + assert "timestamp" in result diff --git a/webui/api/network.py b/webui/api/network.py new file mode 100644 index 0000000..68094de --- /dev/null +++ b/webui/api/network.py @@ -0,0 +1,149 @@ +"""Network management API blueprint. + +Exposes /api/network/* and delegates to vacuum-walld for interface +IP configuration via systemd-networkd. +""" + +import logging + +from flask import Blueprint, request + +from daemon.client import NotFound, get, post +from webui.api.common import _error, _ok + +logger = logging.getLogger(__name__) +bp = Blueprint("network", __name__) + + +@bp.route("/interfaces", methods=["GET"]) +def list_interfaces(): + """List all interfaces with their network config and runtime state. + + Endpoint: + GET /api/network/interfaces + + Returns: + JSON with interface config + runtime state. + """ + try: + return _ok(get("/network/interfaces")) + except RuntimeError as exc: + logger.error("Failed to list network interfaces: %s", exc) + return _error(str(exc), 500) + + +@bp.route("/interfaces/", methods=["GET"]) +def get_interface(name: str): + """Get config + runtime state for a specific interface. + + Endpoint: + GET /api/network/interfaces/ + + Returns: + JSON with interface config and runtime state. + """ + try: + return _ok(get("/network/interfaces/" + name, {"name": name})) + except NotFound as exc: + logger.info("Interface '%s' not found: %s", name, exc) + return _error(str(exc), 404) + except RuntimeError as exc: + logger.error("Failed to get interface '%s': %s", name, exc) + return _error(str(exc), 500) + + +@bp.route("/interfaces/", methods=["POST"]) +def save_interface(name: str): + """Save and apply network config for an interface. + + Endpoint: + POST /api/network/interfaces/ + + Args: + body: JSON with addresses, gateway, dns, routes. + + Returns: + JSON confirmation. + """ + body = request.get_json(silent=True) or {} + try: + post("/network/interfaces/" + name, body) + logger.info("Interface '%s' config saved", name) + return _ok({"name": name, "applied": True}) + except NotFound as exc: + return _error(str(exc), 404) + except RuntimeError as exc: + logger.error("Failed to save interface '%s': %s", name, exc) + return _error(str(exc), 500) + + +@bp.route("/interfaces//reload", methods=["POST"]) +def reload_interface(name: str): + """Reload networkd for a single interface. + + Endpoint: + POST /api/network/interfaces//reload + + Returns: + JSON confirmation. + """ + try: + post("/network/interfaces/" + name + "/reload", {"name": name}) + logger.info("Interface '%s' reloaded", name) + return _ok({"name": name, "reloaded": True}) + except RuntimeError as exc: + logger.error("Failed to reload interface '%s': %s", name, exc) + return _error(str(exc), 500) + + +@bp.route("/apply", methods=["POST"]) +def apply_all(): + """Apply network config for ALL interfaces (full sync). + + Endpoint: + POST /api/network/apply + + Returns: + JSON with number of interfaces applied. + """ + try: + result = post("/network/apply", {}) + logger.info("Network config applied: %d interfaces", result.get("applied", 0)) + return _ok(result) + except RuntimeError as exc: + logger.error("Failed to apply network config: %s", exc) + return _error(str(exc), 500) + + +@bp.route("/infer-dhcp-ranges", methods=["GET"]) +def infer_dhcp_ranges(): + """Suggest candidate DHCP ranges based on static interface IPs. + + Endpoint: + GET /api/network/infer-dhcp-ranges + + Returns: + JSON with per-interface suggested DHCP ranges. + """ + try: + return _ok(get("/network/infer-dhcp-ranges")) + except RuntimeError as exc: + logger.error("Failed to infer DHCP ranges: %s", exc) + return _error(str(exc), 500) + + +@bp.route("/infer-zones", methods=["GET"]) +def infer_zones(): + """Suggest firewalld zone assignments for configured interfaces. + + Endpoint: + GET /api/network/infer-zones + + Returns: + JSON with per-interface suggested zone names. + """ + try: + return _ok(get("/network/infer-zones")) + except RuntimeError as exc: + logger.error("Failed to infer zones: %s", exc) + return _error(str(exc), 500) diff --git a/webui/server.py b/webui/server.py index 808c3f4..671ad23 100644 --- a/webui/server.py +++ b/webui/server.py @@ -20,10 +20,12 @@ from flask import Flask, render_template, request from daemon.client import get from lib.logging import setup_logging +from lib.network import get_config from webui.api.certs import bp as certs_bp from webui.api.dhcp import bp as dhcp_bp from webui.api.firewall import bp as firewall_bp from webui.api.logs import bp as logs_bp +from webui.api.network import bp as network_bp from webui.api.proxy import bp as proxy_bp from webui.api.wireguard import bp as wireguard_bp @@ -78,6 +80,7 @@ app = Flask(__name__) app.config["SECRET_KEY"] = os.urandom(32).hex() app.register_blueprint(firewall_bp, url_prefix="/api/firewall") +app.register_blueprint(network_bp, url_prefix="/api/network") app.register_blueprint(dhcp_bp, url_prefix="/api/dhcp") app.register_blueprint(proxy_bp, url_prefix="/api/proxy") app.register_blueprint(certs_bp, url_prefix="/api/certs") @@ -86,6 +89,7 @@ app.register_blueprint(logs_bp, url_prefix="/api/logs") BLUEPRINTS = [ ("firewall", firewall_bp), + ("network", network_bp), ("dhcp", dhcp_bp), ("proxy", proxy_bp), ("certs", certs_bp), @@ -353,9 +357,11 @@ def interfaces_page(): """ all_status = _safely(_load_status_all, {}) fw_state = all_status.get("firewall", {}) or {} + network_config = _safely(get_config, {}) return render_template( "interfaces.html", interfaces=fw_state.get("interfaces", []), + network_config=network_config, zones=fw_state.get("active_zones", {}).keys() or [], firewall_config=_safely(_fw_config_get, {}), firewall_pending=fw_state.get("pending", {}), @@ -433,11 +439,13 @@ def dhcp_page(): """ all_status = _safely(_load_status_all, {}) dm_state = all_status.get("dnsmasq", {}) or {} + fw_state = all_status.get("firewall", {}) or {} return render_template( "dhcp.html", config=dm_state.get("config", {}), status=dm_state.get("status", {}), leases=dm_state.get("leases", []), + interfaces=fw_state.get("interfaces", []), ) diff --git a/webui/static/app.js b/webui/static/app.js index 8ee259d..94691c7 100644 --- a/webui/static/app.js +++ b/webui/static/app.js @@ -17,6 +17,8 @@ const showSuccessToast = (msg) => showToast(msg, 'success'); const showErrorToast = (msg) => showToast(msg, 'error'); +const showWarningToast = (msg) => showToast(msg, 'warning'); + // Modal helpers const openModal = (id) => { const el = document.getElementById(id); @@ -495,3 +497,82 @@ function renderIssueSteps(steps, status) { container.innerHTML += '
✓ Certificate issued
'; } } + +// ─── Network Interface Config helpers ───────────────────────────── + +const saveInterfaceConfig = (ifaceName) => { + const addrs = (document.getElementById('addrs-' + ifaceName)?.value || '').split(',').map(s => s.trim()).filter(Boolean); + const gateway = (document.getElementById('gw-' + ifaceName)?.value || '').trim(); + const dns = (document.getElementById('dns-' + ifaceName)?.value || '').split(',').map(s => s.trim()).filter(Boolean); + const routesContainer = document.getElementById('routes-' + ifaceName); + let routes = []; + if (routesContainer) { + routes = Array.from(routesContainer.querySelectorAll('.route-row')).map(row => { + const dest = (row.querySelector('.route-dest')?.value || '').trim(); + const gw = (row.querySelector('.route-gw')?.value || '').trim(); + if (dest || gw) return { destination: dest, gateway: gw }; + return null; + }).filter(Boolean); + } + fetch('/api/network/interfaces/' + encodeURIComponent(ifaceName), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ addresses: addrs, gateway: gateway || undefined, dns: dns, routes: routes }) + }) + .then(r => r.json()) + .then(data => { + if (data.ok && data.data && data.data.applied === false) { + showWarningToast('Config saved for ' + ifaceName + ' (system deploy skipped — not running as privileged)'); + } else if (data.ok) { + showSuccessToast('Config saved for ' + ifaceName); + } else { + showErrorToast(data.error || 'Failed to save config'); + } + }) + .catch(e => { showErrorToast('Failed to save config: ' + e.message); }); +}; + +const reloadNetworkd = (ifaceName) => { + fetch('/api/network/interfaces/' + encodeURIComponent(ifaceName) + '/reload', { method: 'POST' }) + .then(r => r.json()) + .then(data => { + if (data.ok) { + showSuccessToast('Network reload triggered for ' + ifaceName); + } else { + showErrorToast(data.error || 'Reload failed'); + } + }) + .catch(e => { showErrorToast('Reload failed: ' + e.message); }); +}; + +const toggleRoutes = (ifaceName) => { + const panel = document.getElementById('routes-panel-' + ifaceName); + if (panel) panel.style.display = panel.style.display === 'none' ? 'block' : 'none'; +}; + +const addRoute = (ifaceName) => { + const container = document.getElementById('routes-' + ifaceName); + if (!container) return; + const row = document.createElement('div'); + row.className = 'route-row'; + row.style.cssText = 'display:flex;gap:6px;align-items:center;margin-bottom:4px;'; + row.innerHTML = '' + + '' + + ''; + container.appendChild(row); +}; + +const renderNetworkRoutes = (routes, containerId) => { + const container = document.getElementById(containerId); + if (!container) return; + const safe = (s) => escHtml(String(s || '')); + container.innerHTML = (routes || []) + .map((r, i) => + '
' + + '' + + '' + + '
' + ).join('') || '
No static routes
'; +}; + + diff --git a/webui/templates/interfaces.html b/webui/templates/interfaces.html index 15d84e1..a902fb9 100644 --- a/webui/templates/interfaces.html +++ b/webui/templates/interfaces.html @@ -18,12 +18,19 @@ IP Address State Zone + IP Config + Actions {% for iface in (interfaces or []) %} - - {{ iface.get('display_name', iface.get('name', 'unknown')) }} + {% set entry = ((network_config or {}).get('interfaces') or {}).get(iface.get('name')) or {} %} + {% set addrs = (entry.get('addresses') or []) | join(', ') %} + {% set gw = entry.get('gateway') or '' %} + {% set dns_list = (entry.get('dns') or []) | join(', ') %} + {% set routes = entry.get('routes') or [] %} + + {{ iface.get('name', 'unknown') }} {{ iface.get('mac', 'N/A') }} {% for ip in iface.get('ips', []) %} @@ -38,7 +45,7 @@ {% if zones %} +
+
+ + +
+
+ + +
+
+ + +
+ + + +
+ + +
+ {% endfor %} {% if not (interfaces or []) %} - No interfaces found + No interfaces found {% endif %} -{% endblock %} +{% endblock %} \ No newline at end of file