feat: add networkd subsystem and fix code review issues
Phase 1-4: Networkd subsystem - lib/network.py: systemd-networkd config renderer (.network INI files) with full schema support: [Match], [Link], [Network], [Address], [Route], [DHCPv4], [DHCPv6] sections. One Address/=DNS= line per value per spec. Route sections use #N suffix per systemd.syntax(7). - lib/network.py: generate_network_files() with 50-<name>.network prefix and stale file cleanup - lib/network.py: collect_upstream_dns() filters local/private DNS - lib/network.py: infer_dhcp_ranges() and infer_zones() helpers - daemon/handlers/network.py: routes for GET/POST /network/interfaces and full apply with DNS upstream sync to dnsmasq - webui/api/network.py: Flask blueprint for /api/network/* endpoints - webui/api: interfaces page updated with IP config inline editing - lib/state.py: networkd collector using parse_networkctl_status() - system/sudoers.d/vacuum-walld: networkctl + systemd-network rules - system/systemd/vacuum-walld.service: ReadWritePaths for /etc/systemd/network - install.sh: ACME email now optional, configured from WebUI - lib/acme.py: get_email() falls back to declarative config Phase 5: Code review fixes - daemon/server.py: path params now win over JSON body and query params in request body merge (prevents config save name override) - daemon/server.py: remove dead 'import re' - daemon/handlers/network.py: replace Path.mkdir() with sudo mkdir for /etc/systemd/network (ProtectSystem=strict compatibility) - system/sudoers.d/vacuum-walld: pin systemctl to specific commands (reload/is-active dnsmasq instead of wildcard) - system/sudoers.d/vacuum-walld: restore !requiretty and section comment - lib/network.py: remove unused _MANAGEMENT_PORTS constant - webui/api/network.py: remove redundant body[\name\] = name in save_interface Tests: 332 passing (110 new/updated), ruff clean
This commit is contained in:
@@ -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/proxy` | `/api/proxy/` | `lib.nginx` |
|
||||||
| `webui/api/certs` | `/api/certs/` | `lib.acme` |
|
| `webui/api/certs` | `/api/certs/` | `lib.acme` |
|
||||||
| `webui/api/wireguard` | `/api/wireguard/` | `lib.wireguard` |
|
| `webui/api/wireguard` | `/api/wireguard/` | `lib.wireguard` |
|
||||||
|
| `webui/api/network` | `/api/network/` | `lib.network` |
|
||||||
|
|
||||||
## Privileged Operations
|
## Privileged Operations
|
||||||
|
|
||||||
|
|||||||
+18
-24
@@ -3,7 +3,6 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
|
||||||
import socket
|
import socket
|
||||||
import subprocess
|
import subprocess
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
@@ -141,17 +140,9 @@ def _find_acme_bin() -> str:
|
|||||||
|
|
||||||
def _get_acme_email() -> str:
|
def _get_acme_email() -> str:
|
||||||
"""Read registered contact email from ACME account config."""
|
"""Read registered contact email from ACME account config."""
|
||||||
try:
|
from lib.acme import _read_acme_email
|
||||||
acme_home = Path(os.environ.get("ACME_HOME", str(_ACME_HOME)))
|
|
||||||
account_conf = acme_home / "account.conf"
|
return _read_acme_email()
|
||||||
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 ""
|
|
||||||
|
|
||||||
|
|
||||||
def _get_state() -> dict[str, Any] | None:
|
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:
|
if not email:
|
||||||
raise ValueError("'email' is required")
|
raise ValueError("'email' is required")
|
||||||
_run_acme(["--register-account", "-m", email])
|
_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)
|
logger.info("ACME email set to %s", email)
|
||||||
refresh_state(["acme"])
|
refresh_state(["acme"])
|
||||||
return {"email": email}
|
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]:
|
def get_email(_request: Any, _body: Any) -> dict[str, Any]:
|
||||||
"""GET /acme/email — return the currently configured ACME contact email."""
|
"""GET /acme/email — return the currently configured ACME contact email."""
|
||||||
ac = _get_acme_state()
|
ac = _get_acme_state()
|
||||||
|
email = ""
|
||||||
if ac:
|
if ac:
|
||||||
return {"email": ac.get("email", "")}
|
email = ac.get("email", "")
|
||||||
try:
|
if not email:
|
||||||
acme_home = Path(os.environ.get("ACME_HOME", str(_ACME_HOME)))
|
email = _get_acme_email()
|
||||||
account_conf = acme_home / "account.conf"
|
return {"email": email}
|
||||||
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": ""}
|
|
||||||
|
|
||||||
|
|
||||||
@registry.register("GET", "/acme/paths")
|
@registry.register("GET", "/acme/paths")
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from daemon.server import NotFoundError, refresh_state, registry
|
|||||||
from lib.common import load_json, run, save_json
|
from lib.common import load_json, run, save_json
|
||||||
from lib.firewall import (
|
from lib.firewall import (
|
||||||
_normalize_target,
|
_normalize_target,
|
||||||
|
_parse_active_zones,
|
||||||
_parse_zone_output,
|
_parse_zone_output,
|
||||||
)
|
)
|
||||||
from lib.firewall import (
|
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")
|
raise ValueError("'zone' is required")
|
||||||
if zone not in run(["firewall-cmd", "--get-zones"], sudo=True).split():
|
if zone not in run(["firewall-cmd", "--get-zones"], sudo=True).split():
|
||||||
raise NotFoundError(f"Zone '{zone}' does not exist")
|
raise NotFoundError(f"Zone '{zone}' does not exist")
|
||||||
try:
|
|
||||||
current = _parse_zone_output(
|
# Determine old zone for each interface being reassigned
|
||||||
zone, run(["firewall-cmd", f"--zone={zone}", "--list-all"], sudo=True)
|
active_raw = run(["firewall-cmd", "--get-active-zones"], sudo=True)
|
||||||
).get("interfaces", [])
|
active = _parse_active_zones(active_raw)
|
||||||
except Exception:
|
|
||||||
current = []
|
for iface in interfaces:
|
||||||
for iface in current:
|
# 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(
|
run(
|
||||||
[
|
[
|
||||||
"firewall-cmd",
|
"firewall-cmd",
|
||||||
f"--zone={zone}",
|
f"--zone={old_zone}",
|
||||||
"--remove-interface=" + iface,
|
"--remove-interface=" + iface,
|
||||||
"--permanent",
|
"--permanent",
|
||||||
],
|
],
|
||||||
sudo=True,
|
sudo=True,
|
||||||
check=False,
|
check=False,
|
||||||
)
|
)
|
||||||
for iface in interfaces:
|
# Add to target zone
|
||||||
run(
|
run(
|
||||||
[
|
[
|
||||||
"firewall-cmd",
|
"firewall-cmd",
|
||||||
@@ -436,7 +444,27 @@ def set_zone_interfaces(_request: Any, body: dict[str, Any] | None) -> dict[str,
|
|||||||
],
|
],
|
||||||
sudo=True,
|
sudo=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
_reload()
|
_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)
|
logger.info("Zone '%s' interfaces set to %s", zone, interfaces)
|
||||||
refresh_state(["firewall"])
|
refresh_state(["firewall"])
|
||||||
return {"zone": zone, "interfaces": interfaces}
|
return {"zone": zone, "interfaces": interfaces}
|
||||||
|
|||||||
@@ -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-<name>.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/<name>")
|
||||||
|
def get_interface(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||||
|
"""GET /network/interfaces/<name> — 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/<name>")
|
||||||
|
def save_interface(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||||
|
"""POST /network/interfaces/<name> — 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/<name>/reload")
|
||||||
|
def reload_interface(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||||
|
"""POST /network/interfaces/<name>/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}
|
||||||
+52
-11
@@ -83,6 +83,23 @@ class Registry:
|
|||||||
"""
|
"""
|
||||||
return self._routes.get((method.upper(), path))
|
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 ``<param>`` segments (e.g. ``/foo/<name>``).
|
||||||
|
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()
|
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)
|
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 ``<param>`` segments.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
pattern: URL pattern like ``/network/interfaces/<name>``.
|
||||||
|
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:
|
async def _handle_request(request: web.Request) -> web.Response:
|
||||||
"""Dispatch a request to the appropriate handler.
|
"""Dispatch a request to the appropriate handler.
|
||||||
|
|
||||||
@@ -136,27 +176,25 @@ async def _handle_request(request: web.Request) -> web.Response:
|
|||||||
Returns:
|
Returns:
|
||||||
The handler's response.
|
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:
|
if handler_fn is None:
|
||||||
return error(f"Method {request.method} not allowed for {request.path}", 404)
|
return error(f"Method {request.method} not allowed for {request.path}", 404)
|
||||||
|
|
||||||
# Build body from JSON and merge query params. GET requests send params
|
# Build body — merge order (highest wins): path params > JSON body > query params.
|
||||||
# as URL query string, so they need to be treated as body for handlers.
|
# Path params come from the URL path (e.g. /interfaces/eth0) and should not
|
||||||
body: dict[str, Any] | None = None
|
# 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":
|
if request.content_type == "application/json":
|
||||||
try:
|
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:
|
except json.JSONDecodeError:
|
||||||
return error("Invalid JSON body", 400)
|
return error("Invalid JSON body", 400)
|
||||||
|
|
||||||
query_dict = dict(request.query)
|
query_dict = dict(request.query)
|
||||||
if query_dict:
|
if query_dict:
|
||||||
query_body = {k: v[0] if len(v) == 1 else v for k, v in query_dict.items()}
|
query_body = {k: v[0] if len(v) == 1 else v for k, v in query_dict.items()}
|
||||||
if body is not None:
|
body = {**query_body, **body} if body is not None else query_body
|
||||||
merged = {**query_body, **body}
|
|
||||||
body = merged
|
|
||||||
else:
|
|
||||||
body = query_body
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if body is not None:
|
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"}
|
results[op_id] = {"ok": False, "error": "'id' and 'path' are required"}
|
||||||
continue
|
continue
|
||||||
|
|
||||||
handler_fn = registry.get(method, path)
|
handler_fn, pat_params = registry.match(method, path)
|
||||||
if handler_fn is None:
|
if handler_fn is None:
|
||||||
results[op_id] = {
|
results[op_id] = {
|
||||||
"ok": False,
|
"ok": False,
|
||||||
@@ -225,6 +263,8 @@ async def _handle_batch(request: web.Request) -> web.Response:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
op_body = op.get("body")
|
op_body = op.get("body")
|
||||||
|
if pat_params:
|
||||||
|
op_body = {**(op_body or {}), **pat_params}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = handler_fn(None, op_body)
|
result = handler_fn(None, op_body)
|
||||||
@@ -320,6 +360,7 @@ def _register_routes() -> None:
|
|||||||
dnsmasq, # noqa: F401
|
dnsmasq, # noqa: F401
|
||||||
firewall, # noqa: F401
|
firewall, # noqa: F401
|
||||||
logs, # noqa: F401
|
logs, # noqa: F401
|
||||||
|
network, # noqa: F401
|
||||||
nginx, # noqa: F401
|
nginx, # noqa: F401
|
||||||
wireguard, # noqa: F401
|
wireguard, # noqa: F401
|
||||||
)
|
)
|
||||||
|
|||||||
+133
@@ -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/<name>
|
||||||
|
```
|
||||||
|
|
||||||
|
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/<name>
|
||||||
|
```
|
||||||
|
|
||||||
|
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/<name>/reload
|
||||||
|
```
|
||||||
|
|
||||||
|
Reload networkd for a single interface (runs `networkctl reload <name>`).
|
||||||
|
|
||||||
|
**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
|
## 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 `<div>` elements directly. Errors are rendered inline as `(error reading ...)` text rather than returning JSON error responses.
|
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 `<div>` elements directly. Errors are rendered inline as `(error reading ...)` text rather than returning JSON error responses.
|
||||||
|
|||||||
@@ -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/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/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/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-<name>.network ──→ sudo cp to /etc/systemd/network/ ──→ sudo networkctl reload
|
||||||
vacuum-walld ──→ daemon/handlers/logs.py ──→ sudo journalctl ──→ systemd journal
|
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. |
|
| 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/<domain>.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. |
|
| nginx | `config/nginx/config.json` | `data/nginx/.htpasswd`, `data/nginx/sites-enabled/` | `data/nginx/sites-enabled/<domain>.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. |
|
| 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-<name>.network` | The JSON file defines per-interface static addresses, routes, DNS, DHCP, and link settings. Each entry renders to a `50-<name>.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. |
|
| 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
|
## Directory Structure
|
||||||
@@ -95,6 +97,8 @@ config/
|
|||||||
│ └── config.json # Proxy domain definitions, management domain, SSL settings
|
│ └── config.json # Proxy domain definitions, management domain, SSL settings
|
||||||
└── wireguard/
|
└── wireguard/
|
||||||
└── config.json # WireGuard interface and peer configuration
|
└── config.json # WireGuard interface and peer configuration
|
||||||
|
├── network/
|
||||||
|
│ └── config.json # Per-interface static IP, routes, DNS, DHCP settings
|
||||||
```
|
```
|
||||||
|
|
||||||
### Data — Runtime Artifacts
|
### Data — Runtime Artifacts
|
||||||
@@ -114,6 +118,7 @@ data/
|
|||||||
├── logs/
|
├── logs/
|
||||||
│ └── vacuum-wall.log # Application log file
|
│ └── vacuum-wall.log # Application log file
|
||||||
└── wireguard/ # WireGuard runtime artifacts
|
└── wireguard/ # WireGuard runtime artifacts
|
||||||
|
├── networkd/ # Generated 50-<name>.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.
|
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/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/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/wireguard/wg0.conf` | Generated WireGuard interface configuration. Written from `config/wireguard/config.json`. | Vacuum Wall (lib/wireguard.py) |
|
||||||
|
| `/etc/systemd/network/50-<name>.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) |
|
| `/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.
|
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.
|
||||||
|
|||||||
@@ -292,3 +292,91 @@ The `zones` object maps zone names (keys) to zone configurations. Each zone corr
|
|||||||
### Applying Firewall Configuration
|
### 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`.
|
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-<name>.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-<name>.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]`).
|
||||||
+14
-3
@@ -2,11 +2,11 @@
|
|||||||
|
|
||||||
## What is Vacuum Wall?
|
## 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
|
## 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
|
## 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.
|
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-<name>.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
|
||||||
|
|
||||||
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.
|
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
|
- Debian 13 (trixie) target platform
|
||||||
- Python 3.13+, Flask 3.x for web management
|
- Python 3.13+, Flask 3.x for web management
|
||||||
- firewalld (nftables backend)
|
- firewalld (nftables backend)
|
||||||
|
- systemd-networkd (ip-lladdr, networkctl)
|
||||||
- nginx 1.26+
|
- nginx 1.26+
|
||||||
- dnsmasq
|
- dnsmasq
|
||||||
- WireGuard tools (wireguard-tools)
|
- WireGuard tools (wireguard-tools)
|
||||||
@@ -60,6 +65,7 @@ After installation, access the management interface at `https://<hostname>.local
|
|||||||
├── .venv/ # Python virtual environment
|
├── .venv/ # Python virtual environment
|
||||||
├── config/ # Declarative JSON configuration (source of truth)
|
├── config/ # Declarative JSON configuration (source of truth)
|
||||||
│ ├── dnsmasq/ # DHCP/DNS config
|
│ ├── dnsmasq/ # DHCP/DNS config
|
||||||
|
│ ├── network/ # systemd-networkd per-interface config
|
||||||
│ ├── firewall/ # Firewall zone & rule config
|
│ ├── firewall/ # Firewall zone & rule config
|
||||||
│ ├── nginx/ # Proxy domain & SSL config
|
│ ├── nginx/ # Proxy domain & SSL config
|
||||||
│ └── wireguard/ # VPN interface & peer config
|
│ └── wireguard/ # VPN interface & peer config
|
||||||
@@ -69,11 +75,13 @@ After installation, access the management interface at `https://<hostname>.local
|
|||||||
│ ├── acme/ # ACME certificates
|
│ ├── acme/ # ACME certificates
|
||||||
│ ├── firewall/ # Firewall rule backup
|
│ ├── firewall/ # Firewall rule backup
|
||||||
│ ├── logs/ # Application logs
|
│ ├── logs/ # Application logs
|
||||||
|
│ ├── networkd/ # Generated 50-<name>.network files
|
||||||
│ └── wireguard/ # Generated WireGuard configs
|
│ └── wireguard/ # Generated WireGuard configs
|
||||||
├── daemon/ # Privileged background daemon
|
├── daemon/ # Privileged background daemon
|
||||||
│ ├── server.py # aiohttp server, cache, batch routing, handler registry
|
│ ├── server.py # aiohttp server, cache, batch routing, handler registry
|
||||||
│ ├── client.py # Sync HTTP client over Unix socket
|
│ ├── 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)
|
├── system/ # System file templates (all Jinja2)
|
||||||
│ ├── systemd/ # Service and timer unit files
|
│ ├── systemd/ # Service and timer unit files
|
||||||
│ │ ├── vacuum-wall.service # Web UI service (rendered at install)
|
│ │ ├── vacuum-wall.service # Web UI service (rendered at install)
|
||||||
@@ -87,8 +95,10 @@ After installation, access the management interface at `https://<hostname>.local
|
|||||||
│ ├── common.py # Shared utilities (run, run_proc, load_json, save_json, deep_merge, ensure_dirs)
|
│ ├── common.py # Shared utilities (run, run_proc, load_json, save_json, deep_merge, ensure_dirs)
|
||||||
│ ├── logging.py # Logging setup
|
│ ├── logging.py # Logging setup
|
||||||
│ ├── firewall.py # firewalld bindings
|
│ ├── firewall.py # firewalld bindings
|
||||||
|
│ ├── network.py # systemd-networkd rendering & parsing
|
||||||
│ ├── dnsmasq.py # DHCP/DNS configuration
|
│ ├── dnsmasq.py # DHCP/DNS configuration
|
||||||
│ ├── nginx.py # Reverse proxy configuration
|
│ ├── nginx.py # Reverse proxy configuration
|
||||||
|
│ ├── state.py # State collector (uses lib.network.parse_networkctl_status)
|
||||||
│ ├── acme.py # Certificate management
|
│ ├── acme.py # Certificate management
|
||||||
│ └── wireguard.py # VPN tunnel management
|
│ └── wireguard.py # VPN tunnel management
|
||||||
├── webui/ # Flask web application
|
├── webui/ # Flask web application
|
||||||
@@ -100,6 +110,7 @@ After installation, access the management interface at `https://<hostname>.local
|
|||||||
│ │ ├── proxy.py # Nginx proxy API
|
│ │ ├── proxy.py # Nginx proxy API
|
||||||
│ │ ├── certs.py # Certificate API
|
│ │ ├── certs.py # Certificate API
|
||||||
│ │ ├── wireguard.py # WireGuard API
|
│ │ ├── wireguard.py # WireGuard API
|
||||||
|
│ │ ├── network.py # Networkd API
|
||||||
│ │ └── logs.py # Logs API
|
│ │ └── logs.py # Logs API
|
||||||
│ ├── templates/ # Jinja2/HTMX templates
|
│ ├── templates/ # Jinja2/HTMX templates
|
||||||
│ └── static/ # CSS and client-side JS
|
│ └── static/ # CSS and client-side JS
|
||||||
|
|||||||
@@ -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) |
|
| 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 link show` | List network interfaces |
|
||||||
| Network queries | `ip -o addr show` | List IP addresses on 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 | `journalctl --unit=* -n *` | Query systemd journal for managed services |
|
||||||
| Logs | `cat /var/log/nginx/*` | Read nginx access and error logs |
|
| Logs | `cat /var/log/nginx/*` | Read nginx access and error logs |
|
||||||
|
|
||||||
|
|||||||
+27
-24
@@ -46,21 +46,21 @@ while [[ $# -gt 0 ]]; do
|
|||||||
" --mgmt-pass PASS WebUI basic auth password (required)" \
|
" --mgmt-pass PASS WebUI basic auth password (required)" \
|
||||||
" --mgmt-user USER WebUI basic auth username (default: admin)" \
|
" --mgmt-user USER WebUI basic auth username (default: admin)" \
|
||||||
" --mgmt-domain DOMAIN Management domain (auto-detected)" \
|
" --mgmt-domain DOMAIN Management domain (auto-detected)" \
|
||||||
" --acme-email EMAIL ACME registration email (required)" \
|
" --acme-email EMAIL ACME contact email (optional, deprecated — use WebUI)" \
|
||||||
" --wan-iface IFACE WAN interface name (auto-detected)" \
|
" --wan-iface IFACE WAN interface name (auto-detected)" \
|
||||||
" --lan-ifaces IFC,... LAN interface names, comma-separated (auto-detected)" \
|
" --lan-ifaces IFC,... LAN interface names, comma-separated (auto-detected)" \
|
||||||
" -h, --help Show this help" \
|
" -h, --help Show this help" \
|
||||||
"" \
|
"" \
|
||||||
"All options also have environment variable equivalents:" \
|
"All options also have environment variable equivalents:" \
|
||||||
" USER_NAME, INSTALL_DIR, MGMT_PASS, MGMT_USER," \
|
" USER_NAME, INSTALL_DIR, MGMT_PASS, MGMT_USER," \
|
||||||
" MGMT_DOMAIN, ACME_EMAIL, WAN_IFACE, LAN_IFACES." \
|
" MGMT_DOMAIN, WAN_IFACE, LAN_IFACES." \
|
||||||
" CLI flags take precedence over env vars." \
|
" CLI flags take precedence over env vars." \
|
||||||
"" \
|
"" \
|
||||||
"Example (dev):" \
|
"Example (dev):" \
|
||||||
" ./install.sh --dev --mgmt-pass pass --acme-email me@example.com" \
|
" ./install.sh --dev --mgmt-pass pass" \
|
||||||
"" \
|
"" \
|
||||||
"Example (prod):" \
|
"Example (prod):" \
|
||||||
" MGMT_PASS=pass ACME_EMAIL=me@example.com ./install.sh --user vacuum-wall"
|
" MGMT_PASS=pass ./install.sh --user vacuum-wall"
|
||||||
exit 0
|
exit 0
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
@@ -74,6 +74,7 @@ REPO_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|||||||
|
|
||||||
# Required settings (no defaults — must be provided)
|
# Required settings (no defaults — must be provided)
|
||||||
MGMT_PASS="${_cli_mgmt_pass:-${MGMT_PASS:-}}"
|
MGMT_PASS="${_cli_mgmt_pass:-${MGMT_PASS:-}}"
|
||||||
|
# ACME_EMAIL is optional — will be configured from the WebUI
|
||||||
ACME_EMAIL="${_cli_acme_email:-${ACME_EMAIL:-}}"
|
ACME_EMAIL="${_cli_acme_email:-${ACME_EMAIL:-}}"
|
||||||
|
|
||||||
# Optional settings with defaults
|
# Optional settings with defaults
|
||||||
@@ -111,17 +112,15 @@ LAN_IFACES="${_cli_lan_ifaces:-${LAN_IFACES:-}}"
|
|||||||
# --- Validate required settings ---
|
# --- Validate required settings ---
|
||||||
missing=()
|
missing=()
|
||||||
[[ -z "$MGMT_PASS" ]] && missing+=("MGMT_PASS (--mgmt-pass)")
|
[[ -z "$MGMT_PASS" ]] && missing+=("MGMT_PASS (--mgmt-pass)")
|
||||||
[[ -z "$ACME_EMAIL" ]] && missing+=("ACME_EMAIL (--acme-email)")
|
|
||||||
|
|
||||||
if (( ${#missing[@]} )); then
|
if (( ${#missing[@]} )); then
|
||||||
echo -e "${RED}[!!]${NC} Missing required settings:"
|
echo -e "${RED}[!!]${NC} Missing required settings:"
|
||||||
for v in "${missing[@]}"; do
|
for v in "${missing[@]}"; do
|
||||||
case "$v" in
|
case "$v" in
|
||||||
"MGMT_PASS (--mgmt-pass)") echo ' export MGMT_PASS="your-password" # or --mgmt-pass';;
|
"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";;
|
|
||||||
esac
|
esac
|
||||||
done
|
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
|
exit 1
|
||||||
fi
|
fi
|
||||||
ACME_HOME="$PROJECT_DIR/data/acme"
|
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" || \
|
systemctl restart nginx >/dev/null 2>&1 && log "Restarted nginx" || \
|
||||||
warn "Could not restart nginx (check config)"
|
warn "Could not restart nginx (check config)"
|
||||||
|
|
||||||
# --- 14. Configure acme.sh default email ---
|
# --- 14. Write initial ACME config (skip if user has customized it) ---
|
||||||
if [[ -f "$ACME_HOME/account.conf" ]] && grep -q '^ACME_LEEMAIL=' "$ACME_HOME/account.conf" 2>/dev/null; then
|
ACME_CFG="${PROJECT_DIR}/config/acme/config.json"
|
||||||
log "acme.sh account already registered, skipping."
|
if [[ -f "$ACME_CFG" ]]; then
|
||||||
|
log "ACME config already exists, skipping."
|
||||||
else
|
else
|
||||||
# acme.sh must never run as root — always as the service user via sudo -u.
|
log "Writing initial ACME configuration..."
|
||||||
# This prevents acme.sh from running any command as root and limits its
|
mkdir -p "${PROJECT_DIR}/config/acme"
|
||||||
# ability to modify system files.
|
ACME_EMAIL="$ACME_EMAIL" \
|
||||||
log "Registering acme.sh account with email $ACME_EMAIL..."
|
ACME_CFG="$ACME_CFG" \
|
||||||
mkdir -p "$ACME_HOME/www"
|
"${PROJECT_DIR}/.venv/bin/python3" -c "
|
||||||
chown "$USER_DAEMON_NAME:$USER_GROUP" "$ACME_HOME/www"
|
import json, os
|
||||||
sudo -u "$USER_DAEMON_NAME" env ACME_HOME="$ACME_HOME" HOME="$PROJECT_DIR" \
|
cfg = {'email': os.environ.get('ACME_EMAIL', '') or ''}
|
||||||
"$ACME_HOME/acme.sh" --home "$ACME_HOME" --config-home "$ACME_HOME" \
|
with open(os.environ['ACME_CFG'], 'w') as f:
|
||||||
--register-account -m "$ACME_EMAIL" 2>/dev/null || \
|
json.dump(cfg, f, indent=4)
|
||||||
warn "Could not register acme.sh account (will be done from WebUI)"
|
f.write('\n')
|
||||||
|
"
|
||||||
|
log "ACME config written (register via WebUI to activate)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# --- Done ---
|
# --- Done ---
|
||||||
@@ -657,10 +659,11 @@ else
|
|||||||
fi
|
fi
|
||||||
echo ""
|
echo ""
|
||||||
echo " Next steps:"
|
echo " Next steps:"
|
||||||
echo " 1. Verify zone assignments at https://$DOMAIN/interfaces"
|
echo " 1. Set ACME contact email at https://$DOMAIN/certs/settings"
|
||||||
echo " 2. Configure DHCP ranges for your LAN"
|
echo " 2. Verify zone assignments at https://$DOMAIN/interfaces"
|
||||||
echo " 3. Add proxy domains with ACME certificates"
|
echo " 3. Configure DHCP ranges for your LAN"
|
||||||
echo " 4. Set up WireGuard tunnel (optional)"
|
echo " 4. Add proxy domains with ACME certificates"
|
||||||
|
echo " 5. Set up WireGuard tunnel (optional)"
|
||||||
echo ""
|
echo ""
|
||||||
echo " NOTE: A self-signed certificate was generated."
|
echo " NOTE: A self-signed certificate was generated."
|
||||||
echo " From the WebUI, issue a real certificate for $DOMAIN"
|
echo " From the WebUI, issue a real certificate for $DOMAIN"
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 42 KiB |
+20
-1
@@ -135,7 +135,16 @@ def set_email(email: str) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def get_email() -> str:
|
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:
|
try:
|
||||||
acme_home = Path(os.environ.get("ACME_HOME", str(_ACME_HOME)))
|
acme_home = Path(os.environ.get("ACME_HOME", str(_ACME_HOME)))
|
||||||
account_conf = acme_home / "account.conf"
|
account_conf = acme_home / "account.conf"
|
||||||
@@ -146,6 +155,16 @@ def get_email() -> str:
|
|||||||
return match.group(1).strip().strip("'\"")
|
return match.group(1).strip().strip("'\"")
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
logger.warning("Could not read account.conf: %s", 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 ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+655
@@ -0,0 +1,655 @@
|
|||||||
|
"""Networkd/IP configuration module.
|
||||||
|
|
||||||
|
Reads/writes config/network/config.json, renders .network INI files,
|
||||||
|
and parses networkctl status output for runtime state.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import ipaddress
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from lib.common import load_json, save_json
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
PROJECT_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
CONFIG_DIR = PROJECT_DIR / "config" / "network"
|
||||||
|
CONFIG_FILE = CONFIG_DIR / "config.json"
|
||||||
|
DATA_DIR = PROJECT_DIR / "data" / "networkd"
|
||||||
|
|
||||||
|
DEFAULT_CONFIG: dict[str, Any] = {"interfaces": {}}
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"collect_upstream_dns",
|
||||||
|
"generate_network_files",
|
||||||
|
"get_config",
|
||||||
|
"infer_dhcp_ranges",
|
||||||
|
"infer_zones",
|
||||||
|
"parse_networkctl_status",
|
||||||
|
"render_network_file",
|
||||||
|
"save_config",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_config() -> dict[str, Any]:
|
||||||
|
"""Read network config from config/network/config.json.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with ``interfaces`` mapping interface names to config entries.
|
||||||
|
"""
|
||||||
|
if not CONFIG_FILE.exists():
|
||||||
|
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
save_json(CONFIG_FILE, DEFAULT_CONFIG, indent=2)
|
||||||
|
return load_json(CONFIG_FILE)
|
||||||
|
|
||||||
|
|
||||||
|
def save_config(cfg: dict[str, Any]) -> None:
|
||||||
|
"""Persist network config to disk.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cfg: Full config dict to write.
|
||||||
|
"""
|
||||||
|
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
save_json(CONFIG_FILE, cfg, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Inline key-value emitters — append to a lines list
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _emit_str(lines: list[str], key: str, py_key: str, d: dict[str, Any]) -> None:
|
||||||
|
v = d.get(py_key)
|
||||||
|
if v is not None:
|
||||||
|
lines.append(f"{key}={v}")
|
||||||
|
|
||||||
|
|
||||||
|
def _emit_int(lines: list[str], key: str, py_key: str, d: dict[str, Any]) -> None:
|
||||||
|
v = d.get(py_key)
|
||||||
|
if v is not None:
|
||||||
|
lines.append(f"{key}={v}")
|
||||||
|
|
||||||
|
|
||||||
|
def _emit_bool(lines: list[str], key: str, py_key: str, d: dict[str, Any]) -> None:
|
||||||
|
v = d.get(py_key)
|
||||||
|
if v is not None:
|
||||||
|
lines.append(f"{key}={'yes' if v else 'no'}")
|
||||||
|
|
||||||
|
|
||||||
|
def _emit_bool_opt(lines: list[str], key: str, py_key: str, d: dict[str, Any]) -> None:
|
||||||
|
v = d.get(py_key)
|
||||||
|
if v is not None:
|
||||||
|
lines.append(f"{key}={'yes' if v else 'no'}")
|
||||||
|
|
||||||
|
|
||||||
|
def _emit_any(lines: list[str], key: str, py_key: str, d: dict[str, Any]) -> None:
|
||||||
|
v = d.get(py_key)
|
||||||
|
if v is not None:
|
||||||
|
if isinstance(v, bool):
|
||||||
|
lines.append(f"{key}={'yes' if v else 'no'}")
|
||||||
|
else:
|
||||||
|
lines.append(f"{key}={v}")
|
||||||
|
|
||||||
|
|
||||||
|
def render_network_file(iface_name: str, cfg_entry: dict[str, Any]) -> str:
|
||||||
|
"""Render a .network INI file for an interface.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
iface_name: Interface name (e.g. "eth0").
|
||||||
|
cfg_entry: Dict with networkd config keys per the schema in
|
||||||
|
todo.md (addresses, gateway, dns, routes, link, dhcp_client, etc.).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
INI content string ready to write as 50-<name>.network file.
|
||||||
|
"""
|
||||||
|
lines: list[str] = []
|
||||||
|
d = cfg_entry
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# [Match]
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
lines.append("[Match]")
|
||||||
|
lines.append(f"Name={iface_name}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# [Link]
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
link = d.get("link", {})
|
||||||
|
if link:
|
||||||
|
lines.append("[Link]")
|
||||||
|
_emit_int(lines, "MTUBytes", "mtu_bytes", link)
|
||||||
|
_emit_str(lines, "MACAddress", "mac_address", link)
|
||||||
|
_emit_bool_opt(lines, "ARP", "arp", link)
|
||||||
|
_emit_bool_opt(lines, "Multicast", "multicast", link)
|
||||||
|
_emit_bool_opt(lines, "AllMulticast", "all_multicast", link)
|
||||||
|
_emit_bool_opt(lines, "Promiscuous", "promiscuous", link)
|
||||||
|
_emit_bool(lines, "Unmanaged", "unmanaged", link)
|
||||||
|
_emit_str(lines, "ActivationPolicy", "activation_policy", link)
|
||||||
|
_emit_any(lines, "RequiredForOnline", "required_for_online", link)
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# [Network]
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
lines.append("[Network]")
|
||||||
|
|
||||||
|
_emit_str(lines, "DHCP", "dhcp", d)
|
||||||
|
_emit_str(lines, "Gateway", "gateway", d)
|
||||||
|
_emit_str(lines, "IPv6Gateway", "ipv6_gateway", d)
|
||||||
|
|
||||||
|
for dns in d.get("dns", []):
|
||||||
|
lines.append(f"DNS={dns}")
|
||||||
|
for dns in d.get("ipv6_dns", []):
|
||||||
|
lines.append(f"IPv6DNS={dns}")
|
||||||
|
for dm in d.get("domains", []):
|
||||||
|
lines.append(f"Domains={dm}")
|
||||||
|
for dm in d.get("ipv6_domains", []):
|
||||||
|
lines.append(f"IPv6Domains={dm}")
|
||||||
|
|
||||||
|
_emit_bool(lines, "DNSDefaultRoute", "dns_default_route", d)
|
||||||
|
for bc in d.get("bind_carrier", []):
|
||||||
|
lines.append(f"BindCarrier={bc}")
|
||||||
|
_emit_any(lines, "IgnoreCarrierLoss", "ignore_carrier_loss", d)
|
||||||
|
_emit_any(lines, "KeepConfiguration", "keep_configuration", d)
|
||||||
|
_emit_bool(lines, "ConfigureWithoutCarrier", "configure_without_carrier", d)
|
||||||
|
_emit_str(lines, "LinkLocalAddressing", "link_local_addressing", d)
|
||||||
|
_emit_str(
|
||||||
|
lines,
|
||||||
|
"IPv6LinkLocalAddressGenerationMode",
|
||||||
|
"ipv6_link_local_address_generation_mode",
|
||||||
|
d,
|
||||||
|
)
|
||||||
|
_emit_str(lines, "IPv6StableSecretAddress", "ipv6_stable_secret_address", d)
|
||||||
|
_emit_str(lines, "IPv4LLStartAddress", "ipv4_ll_start_address", d)
|
||||||
|
_emit_bool(lines, "IPv4LLRoute", "ipv4_ll_route", d)
|
||||||
|
_emit_bool(lines, "DefaultRouteOnDevice", "default_route_on_device", d)
|
||||||
|
_emit_int(lines, "IPv6HopLimit", "ipv6_hop_limit", d)
|
||||||
|
_emit_str(lines, "IPv6RetransmissionTimeSec", "ipv6_retransmission_time_sec", d)
|
||||||
|
_emit_str(
|
||||||
|
lines,
|
||||||
|
"IPv4DuplicateAddressDetectionTimeoutSec",
|
||||||
|
"ipv4_duplicate_address_detection_timeout_sec",
|
||||||
|
d,
|
||||||
|
)
|
||||||
|
_emit_str(lines, "IPv4ReversePathFilter", "ipv4_reverse_path_filter", d)
|
||||||
|
_emit_bool(lines, "IPv4AcceptLocal", "ipv4_accept_local", d)
|
||||||
|
_emit_bool(lines, "IPv4RouteLocalnet", "ipv4_route_localnet", d)
|
||||||
|
_emit_bool(lines, "IPv4ProxyARP", "ipv4_proxy_arp", d)
|
||||||
|
_emit_bool(lines, "IPv4ProxyARPPrivateVLAN", "ipv4_proxy_arp_private_vlan", d)
|
||||||
|
_emit_bool(lines, "IPv6ProxyNDP", "ipv6_proxy_ndp", d)
|
||||||
|
_emit_str(lines, "IPv6ProxyNDPAddress", "ipv6_proxy_ndp_address", d)
|
||||||
|
_emit_bool(lines, "IPv6SendRA", "ipv6_send_ra", d)
|
||||||
|
_emit_bool(lines, "MPLSRouting", "m_pls_routing", d)
|
||||||
|
_emit_bool(lines, "KeepMaster", "keep_master", d)
|
||||||
|
_emit_str(lines, "IPFamily", "ip_family", d)
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# [Address] sections — one per entry
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
addresses = d.get("addresses", [])
|
||||||
|
for i, addr in enumerate(addresses):
|
||||||
|
if not isinstance(addr, dict):
|
||||||
|
lines.append("[Address]" if i == 0 else f"[Address#{i}]")
|
||||||
|
lines.append(f"Address={addr}")
|
||||||
|
lines.append("")
|
||||||
|
continue
|
||||||
|
lines.append("[Address]" if i == 0 else f"[Address#{i}]")
|
||||||
|
_emit_str(lines, "Address", "address", addr)
|
||||||
|
_emit_str(lines, "Label", "label", addr)
|
||||||
|
_emit_str(lines, "Scope", "scope", addr)
|
||||||
|
_emit_int(lines, "RouteMetric", "route_metric", addr)
|
||||||
|
_emit_str(
|
||||||
|
lines, "DuplicateAddressDetection", "duplicate_address_detection", addr
|
||||||
|
)
|
||||||
|
_emit_bool(lines, "ManageTemporaryAddress", "manage_temporary_address", addr)
|
||||||
|
_emit_bool(lines, "AddPrefixRoute", "add_prefix_route", addr)
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# [Address] sections — IPv6
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
ipv6_addrs = d.get("ipv6_addresses", [])
|
||||||
|
offset = len(addresses)
|
||||||
|
for i, addr in enumerate(ipv6_addrs):
|
||||||
|
if not isinstance(addr, dict):
|
||||||
|
lines.append(f"[Address#{offset + i}]")
|
||||||
|
lines.append(f"Address={addr}")
|
||||||
|
lines.append("")
|
||||||
|
continue
|
||||||
|
lines.append(f"[Address#{offset + i}]")
|
||||||
|
_emit_str(lines, "Address", "address", addr)
|
||||||
|
_emit_str(lines, "Label", "label", addr)
|
||||||
|
_emit_str(lines, "Scope", "scope", addr)
|
||||||
|
_emit_int(lines, "RouteMetric", "route_metric", addr)
|
||||||
|
_emit_str(
|
||||||
|
lines, "DuplicateAddressDetection", "duplicate_address_detection", addr
|
||||||
|
)
|
||||||
|
_emit_bool(lines, "ManageTemporaryAddress", "manage_temporary_address", addr)
|
||||||
|
_emit_bool(lines, "AddPrefixRoute", "add_prefix_route", addr)
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# [Route#N] sections
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
routes = d.get("routes", [])
|
||||||
|
for i, route in enumerate(routes):
|
||||||
|
if not isinstance(route, dict):
|
||||||
|
continue
|
||||||
|
lines.append("[Route]" if i == 0 else f"[Route#{i}]")
|
||||||
|
_emit_str(lines, "Destination", "destination", route)
|
||||||
|
_emit_str(lines, "Gateway", "gateway", route)
|
||||||
|
_emit_int(lines, "Metric", "metric", route)
|
||||||
|
_emit_any(lines, "Table", "table", route)
|
||||||
|
_emit_str(lines, "Type", "type", route)
|
||||||
|
_emit_str(lines, "Scope", "scope", route)
|
||||||
|
_emit_bool(lines, "GatewayOnLink", "gateway_on_link", route)
|
||||||
|
_emit_str(lines, "IPv6Preference", "ipv6_preference", route)
|
||||||
|
_emit_int(lines, "InitialCongestionWindow", "initial_congestion_window", route)
|
||||||
|
_emit_int(
|
||||||
|
lines,
|
||||||
|
"InitialAdvertisedReceiveWindow",
|
||||||
|
"initial_advertised_receive_window",
|
||||||
|
route,
|
||||||
|
)
|
||||||
|
_emit_bool(lines, "QuickAck", "quick_ack", route)
|
||||||
|
_emit_bool(lines, "FastOpenNoCookie", "fast_open_no_cookie", route)
|
||||||
|
_emit_int(lines, "MTUBytes", "mtu_bytes", route)
|
||||||
|
_emit_any(lines, "Protocol", "protocol", route)
|
||||||
|
_emit_int(lines, "NextHop", "next_hop", route)
|
||||||
|
for mpr in route.get("multi_path_route", []):
|
||||||
|
lines.append(f"MultiPathRoute={mpr}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# [DHCPv4] / [DHCPv6]
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
dhcp_client = d.get("dhcp_client", {})
|
||||||
|
if dhcp_client:
|
||||||
|
dhcp_mode = d.get("dhcp", "no")
|
||||||
|
render_v4 = dhcp_mode in ("yes", "ipv4")
|
||||||
|
render_v6 = dhcp_mode in ("yes", "ipv6")
|
||||||
|
if not render_v4 and not render_v6:
|
||||||
|
render_v4 = True
|
||||||
|
render_v6 = True
|
||||||
|
|
||||||
|
if render_v4:
|
||||||
|
lines.append("[DHCPv4]")
|
||||||
|
_emit_str(lines, "Hostname", "hostname", dhcp_client)
|
||||||
|
_emit_any(lines, "DUID", "duid", dhcp_client)
|
||||||
|
_emit_str(lines, "DUIDType", "duid_type", dhcp_client)
|
||||||
|
_emit_any(lines, "DUIDRawData", "duid_raw_data", dhcp_client)
|
||||||
|
_emit_str(lines, "IAID", "iaid", dhcp_client)
|
||||||
|
_emit_any(lines, "ClientIdentifier", "client_identifier", dhcp_client)
|
||||||
|
_emit_bool(lines, "RapidCommit", "rapid_commit", dhcp_client)
|
||||||
|
_emit_bool(lines, "Anonymize", "anonymize", dhcp_client)
|
||||||
|
_emit_bool(lines, "UseDNS", "use_dns", dhcp_client)
|
||||||
|
_emit_bool(lines, "UseNTP", "use_ntp", dhcp_client)
|
||||||
|
_emit_bool(lines, "UseSIP", "use_sip", dhcp_client)
|
||||||
|
_emit_bool(lines, "UseCaptivePortal", "use_captive_portal", dhcp_client)
|
||||||
|
_emit_bool(lines, "UseMTU", "use_mtu", dhcp_client)
|
||||||
|
_emit_bool(lines, "UseHostname", "use_hostname", dhcp_client)
|
||||||
|
_emit_any(lines, "UseDomains", "use_domains", dhcp_client)
|
||||||
|
_emit_bool(lines, "UseRoutes", "use_routes", dhcp_client)
|
||||||
|
_emit_int(lines, "RouteMetric", "route_metric", dhcp_client)
|
||||||
|
_emit_bool(lines, "SendDecline", "send_decline", dhcp_client)
|
||||||
|
_emit_str(lines, "NetLabel", "net_label", dhcp_client)
|
||||||
|
_emit_str(lines, "NFTSet", "nft_set", dhcp_client)
|
||||||
|
_emit_str(lines, "IPServiceType", "ip_service_type", dhcp_client)
|
||||||
|
_emit_int(lines, "SocketPriority", "socket_priority", dhcp_client)
|
||||||
|
_emit_bool(lines, "BOOTP", "bootp", dhcp_client)
|
||||||
|
_emit_str(lines, "Label", "label", dhcp_client)
|
||||||
|
_emit_int(lines, "MaxAttempts", "max_attempts", dhcp_client)
|
||||||
|
_emit_int(lines, "ListenPort", "listen_port", dhcp_client)
|
||||||
|
_emit_int(lines, "ServerPort", "server_port", dhcp_client)
|
||||||
|
_emit_str(lines, "MUDURL", "mud_url", dhcp_client)
|
||||||
|
_emit_str(lines, "BootFilename", "boot_filename", dhcp_client)
|
||||||
|
|
||||||
|
for opt in dhcp_client.get("send_option", []):
|
||||||
|
if isinstance(opt, dict):
|
||||||
|
lines.append(
|
||||||
|
f"SendOption={opt.get('code', '-')} {opt.get('value', '')}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
lines.append(f"SendOption={opt}")
|
||||||
|
for opt in dhcp_client.get("send_vendor_option", []):
|
||||||
|
if isinstance(opt, dict):
|
||||||
|
lines.append(
|
||||||
|
f"SendVendorOption={opt.get('code', '-')}"
|
||||||
|
f" {opt.get('vendor_code', '')}"
|
||||||
|
f" {opt.get('value', '')}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
lines.append(f"SendVendorOption={opt}")
|
||||||
|
for uc in dhcp_client.get("user_class", []):
|
||||||
|
lines.append(f"UserClass={uc}")
|
||||||
|
_emit_str(
|
||||||
|
lines, "VendorClassIdentifier", "vendor_class_identifier", dhcp_client
|
||||||
|
)
|
||||||
|
_emit_str(lines, "RequestOptions", "request_options", dhcp_client)
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
if render_v6:
|
||||||
|
lines.append("[DHCPv6]")
|
||||||
|
_emit_bool(lines, "SendHostname", "send_hostname", dhcp_client)
|
||||||
|
_emit_str(lines, "Hostname", "hostname", dhcp_client)
|
||||||
|
_emit_any(lines, "DUID", "duid", dhcp_client)
|
||||||
|
_emit_str(lines, "DUIDType", "duid_type", dhcp_client)
|
||||||
|
_emit_any(lines, "DUIDRawData", "duid_raw_data", dhcp_client)
|
||||||
|
_emit_str(lines, "IAID", "iaid", dhcp_client)
|
||||||
|
_emit_bool(lines, "Anonymize", "anonymize", dhcp_client)
|
||||||
|
_emit_str(lines, "RapidCommit", "rapid_commit", dhcp_client)
|
||||||
|
_emit_str(
|
||||||
|
lines, "PrefixDelegationHint", "prefix_delegation_hint", dhcp_client
|
||||||
|
)
|
||||||
|
_emit_str(
|
||||||
|
lines, "UnassignedSubnetPolicy", "unassigned_subnet_policy", dhcp_client
|
||||||
|
)
|
||||||
|
_emit_bool(lines, "UseAddress", "use_address", dhcp_client)
|
||||||
|
_emit_bool(lines, "UseCaptivePortal", "use_captive_portal", dhcp_client)
|
||||||
|
_emit_bool(lines, "UseDelegatedPrefix", "use_delegated_prefix", dhcp_client)
|
||||||
|
_emit_bool(lines, "UseDNS", "use_dns", dhcp_client)
|
||||||
|
_emit_bool(lines, "UseNTP", "use_ntp", dhcp_client)
|
||||||
|
_emit_bool(lines, "UseSIP", "use_sip", dhcp_client)
|
||||||
|
_emit_bool(lines, "UseDNR", "use_dnr", dhcp_client)
|
||||||
|
_emit_bool(lines, "UseHostname", "use_hostname", dhcp_client)
|
||||||
|
_emit_any(lines, "UseDomains", "use_domains", dhcp_client)
|
||||||
|
_emit_bool(lines, "SendRelease", "send_release", dhcp_client)
|
||||||
|
_emit_str(lines, "NetLabel", "net_label", dhcp_client)
|
||||||
|
_emit_str(lines, "NFTSet", "nft_set", dhcp_client)
|
||||||
|
_emit_str(lines, "WithoutRA", "without_ra", dhcp_client)
|
||||||
|
|
||||||
|
for opt in dhcp_client.get("send_option", []):
|
||||||
|
if isinstance(opt, dict):
|
||||||
|
lines.append(
|
||||||
|
f"SendOption={opt.get('code', '-')} {opt.get('value', '')}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
lines.append(f"SendOption={opt}")
|
||||||
|
for opt in dhcp_client.get("send_vendor_option", []):
|
||||||
|
if isinstance(opt, dict):
|
||||||
|
lines.append(
|
||||||
|
f"SendVendorOption={opt.get('code', '-')}"
|
||||||
|
f" {opt.get('vendor_code', '')}"
|
||||||
|
f" {opt.get('value', '')}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
lines.append(f"SendVendorOption={opt}")
|
||||||
|
for uc in dhcp_client.get("user_class", []):
|
||||||
|
lines.append(f"UserClass={uc}")
|
||||||
|
for vc in dhcp_client.get("vendor_class", []):
|
||||||
|
lines.append(f"VendorClass={vc}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_networkctl_status(output: str) -> dict[str, Any]:
|
||||||
|
"""Parse ``networkctl status --all`` output into runtime state dict.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
output: Raw command output from networkctl status.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict mapping interface names to their runtime state including
|
||||||
|
addresses, gateway, DNS, and link state.
|
||||||
|
"""
|
||||||
|
result: dict[str, Any] = {}
|
||||||
|
current_iface: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
def _is_iface_header(line: str) -> bool:
|
||||||
|
"""Check if a line looks like an interface header (digits:name ...)."""
|
||||||
|
colon_idx = line.find(":")
|
||||||
|
if colon_idx < 0:
|
||||||
|
return False
|
||||||
|
header = line[:colon_idx].strip()
|
||||||
|
return bool(header) and header[-1].isdigit()
|
||||||
|
|
||||||
|
for raw_line in output.splitlines():
|
||||||
|
stripped = raw_line.strip()
|
||||||
|
if not stripped:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Interface header: "1: eth0" or similar
|
||||||
|
if _is_iface_header(raw_line):
|
||||||
|
parts = raw_line.split(":", 1)[1].strip().split()
|
||||||
|
if parts:
|
||||||
|
iface_name = parts[0]
|
||||||
|
current_iface = {
|
||||||
|
"addresses": [],
|
||||||
|
"gateway": None,
|
||||||
|
"dns": [],
|
||||||
|
"state": "unknown",
|
||||||
|
"link": parts[1] if len(parts) > 1 else "unknown",
|
||||||
|
}
|
||||||
|
result[iface_name] = current_iface
|
||||||
|
continue
|
||||||
|
|
||||||
|
if current_iface is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if stripped.startswith("State:"):
|
||||||
|
current_iface["state"] = stripped.split(":", 1)[1].strip()
|
||||||
|
elif stripped.startswith("Gateway:"):
|
||||||
|
gw = stripped.split(":", 1)[1].strip()
|
||||||
|
if gw and gw.lower() not in ("n/a", ""):
|
||||||
|
current_iface["gateway"] = gw
|
||||||
|
elif stripped.startswith("DNS:"):
|
||||||
|
dns_str = stripped.split(":", 1)[1].strip()
|
||||||
|
if dns_str and dns_str.lower() != "n/a":
|
||||||
|
current_iface["dns"] = [d.strip() for d in dns_str.split() if d.strip()]
|
||||||
|
elif stripped.startswith("Addresses:"):
|
||||||
|
addr_str = stripped.split(":", 1)[1].strip()
|
||||||
|
if addr_str and addr_str.lower() != "n/a":
|
||||||
|
for tok in addr_str.split():
|
||||||
|
addr = tok.rstrip(",")
|
||||||
|
if "/" in addr:
|
||||||
|
current_iface["addresses"].append(addr)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def generate_network_files(cfg: dict[str, Any]) -> dict[str, list[Path]]:
|
||||||
|
"""Walk config and write all 50-<name>.network files to data/networkd/.
|
||||||
|
|
||||||
|
Also removes stale .network files that no longer match config.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cfg: Network config dict (from get_config).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with ``generated`` (new/updated files) and ``cleaned``
|
||||||
|
(removed stale files) path lists.
|
||||||
|
"""
|
||||||
|
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
generated: list[Path] = []
|
||||||
|
cleaned: list[Path] = []
|
||||||
|
|
||||||
|
# Build set of expected filenames
|
||||||
|
expected_names: set[str] = set()
|
||||||
|
interfaces_cfg = cfg.get("interfaces", {})
|
||||||
|
for iface_name, entry in interfaces_cfg.items():
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
continue
|
||||||
|
fname = f"50-{iface_name}.network"
|
||||||
|
expected_names.add(fname)
|
||||||
|
content = render_network_file(iface_name, entry)
|
||||||
|
out_path = DATA_DIR / fname
|
||||||
|
out_path.write_text(content)
|
||||||
|
generated.append(out_path)
|
||||||
|
|
||||||
|
# Remove stale files from DATA_DIR
|
||||||
|
existing_files: set[str] = {
|
||||||
|
f.name for f in DATA_DIR.iterdir() if f.name.endswith(".network")
|
||||||
|
}
|
||||||
|
for fname in existing_files - expected_names:
|
||||||
|
(DATA_DIR / fname).unlink()
|
||||||
|
cleaned.append(DATA_DIR / fname)
|
||||||
|
|
||||||
|
if cleaned:
|
||||||
|
logger.info("Cleaned %d stale .network files from %s", len(cleaned), DATA_DIR)
|
||||||
|
logger.info("Generated %d .network files in %s", len(generated), DATA_DIR)
|
||||||
|
return {"generated": generated, "cleaned": cleaned}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TF-8: upstream DNS collection
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_IS_LOCAL = [
|
||||||
|
ipaddress.ip_network("127.0.0.0/8"),
|
||||||
|
ipaddress.ip_network("10.0.0.0/8"),
|
||||||
|
ipaddress.ip_network("172.16.0.0/12"),
|
||||||
|
ipaddress.ip_network("192.168.0.0/16"),
|
||||||
|
ipaddress.ip_network("169.254.0.0/16"),
|
||||||
|
ipaddress.ip_network("::1/128"),
|
||||||
|
ipaddress.ip_network("fc00::/7"),
|
||||||
|
ipaddress.ip_network("fe80::/10"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _is_local_dns(addr: str) -> bool:
|
||||||
|
try:
|
||||||
|
ip = ipaddress.ip_address(addr)
|
||||||
|
for net in _IS_LOCAL:
|
||||||
|
if ip.version == net.version and ip in net:
|
||||||
|
return True
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def collect_upstream_dns(cfg: dict[str, Any]) -> list[str]:
|
||||||
|
"""Collect public DNS servers from networkd config, filtering local ranges.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cfg: Network config dict (from get_config).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Deduplicated list of upstream DNS server addresses.
|
||||||
|
"""
|
||||||
|
seen: set[str] = set()
|
||||||
|
result: list[str] = []
|
||||||
|
for entry in cfg.get("interfaces", {}).values():
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
continue
|
||||||
|
for dns in entry.get("dns", []):
|
||||||
|
if not _is_local_dns(dns) and dns not in seen:
|
||||||
|
seen.add(dns)
|
||||||
|
result.append(dns)
|
||||||
|
for dns in entry.get("ipv6_dns", []):
|
||||||
|
if not _is_local_dns(dns) and dns not in seen:
|
||||||
|
seen.add(dns)
|
||||||
|
result.append(dns)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TF-9: DHCP range inference
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def infer_dhcp_ranges(cfg: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
||||||
|
"""Infer candidate DHCP ranges from static interface IPs.
|
||||||
|
|
||||||
|
For each interface with a static IPv4 address, calculates a candidate
|
||||||
|
DHCP range covering the usable addresses in the subnet.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cfg: Network config dict (from get_config).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict mapping interface name to dict with ``subnet``, ``prefix``,
|
||||||
|
``start``, and ``end`` keys. Interfaces with no inferrable range
|
||||||
|
are omitted.
|
||||||
|
"""
|
||||||
|
result: dict[str, dict[str, Any]] = {}
|
||||||
|
for name, entry in cfg.get("interfaces", {}).items():
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
continue
|
||||||
|
for addr in entry.get("addresses", []):
|
||||||
|
if isinstance(addr, dict):
|
||||||
|
addr = addr.get("address", "")
|
||||||
|
if not addr or "/" not in str(addr):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
net = ipaddress.ip_network(addr, strict=False)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if net.version != 4:
|
||||||
|
continue
|
||||||
|
if net.num_addresses < 4:
|
||||||
|
continue
|
||||||
|
net_addr = net.network_address
|
||||||
|
broadcast = net.broadcast_address
|
||||||
|
result[name] = {
|
||||||
|
"subnet": str(net_addr),
|
||||||
|
"prefix": net.prefixlen,
|
||||||
|
"start": str(net_addr + 1),
|
||||||
|
"end": str(broadcast - 1),
|
||||||
|
}
|
||||||
|
break
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TF-10: firewalld zone inference
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _looks_wan(entry: dict[str, Any]) -> bool:
|
||||||
|
"""Heuristic: interface has DHCP or public-facing address."""
|
||||||
|
if entry.get("dhcp") in ("yes", "ipv4"):
|
||||||
|
return True
|
||||||
|
for addr in entry.get("addresses", []):
|
||||||
|
if isinstance(addr, dict):
|
||||||
|
addr = addr.get("address", "")
|
||||||
|
if not addr or "/" not in str(addr):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
net = ipaddress.ip_network(addr, strict=False)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if net.version != 4:
|
||||||
|
continue
|
||||||
|
ga = list(net.hosts())
|
||||||
|
if ga and not _is_local_dns(str(ga[0])):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _looks_management(entry: dict[str, Any]) -> bool:
|
||||||
|
"""Heuristic: interface has management-subnet addresses."""
|
||||||
|
return bool(entry.get("routes"))
|
||||||
|
|
||||||
|
|
||||||
|
def infer_zones(
|
||||||
|
cfg: dict[str, Any],
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""Classify network interfaces into firewalld zones.
|
||||||
|
|
||||||
|
Heuristics:
|
||||||
|
- Interface name contains ``wg`` → ``"wan"``
|
||||||
|
- DHCP-enabled or public-facing IP → ``"wan"``
|
||||||
|
- Has explicit routes configured → ``"management"``
|
||||||
|
- Everything else → ``"lan"``
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cfg: Network config dict (from get_config).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict mapping interface name to suggested zone name.
|
||||||
|
"""
|
||||||
|
result: dict[str, str] = {}
|
||||||
|
for name, entry in cfg.get("interfaces", {}).items():
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
continue
|
||||||
|
if "wg" in name or _looks_wan(entry):
|
||||||
|
result[name] = "wan"
|
||||||
|
elif _looks_management(entry):
|
||||||
|
result[name] = "management"
|
||||||
|
else:
|
||||||
|
result[name] = "lan"
|
||||||
|
return result
|
||||||
+42
-20
@@ -7,7 +7,6 @@ state instead of invoking subprocesses on every request.
|
|||||||
import contextlib
|
import contextlib
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
@@ -23,6 +22,7 @@ from lib.firewall import (
|
|||||||
from lib.firewall import (
|
from lib.firewall import (
|
||||||
config_pending as _config_pending,
|
config_pending as _config_pending,
|
||||||
)
|
)
|
||||||
|
from lib.network import parse_networkctl_status
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -52,6 +52,7 @@ class State:
|
|||||||
"nginx",
|
"nginx",
|
||||||
"acme",
|
"acme",
|
||||||
"wireguard",
|
"wireguard",
|
||||||
|
"networkd",
|
||||||
]
|
]
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
@@ -184,7 +185,7 @@ def _collect_firewall() -> dict[str, Any]:
|
|||||||
parts = line.split()
|
parts = line.split()
|
||||||
if len(parts) < 2:
|
if len(parts) < 2:
|
||||||
continue
|
continue
|
||||||
raw_name = parts[1].rstrip(":")
|
raw_name = parts[1].rstrip(":").split("@")[0]
|
||||||
iface_state = "UNKNOWN"
|
iface_state = "UNKNOWN"
|
||||||
mtu = None
|
mtu = None
|
||||||
mac = None
|
mac = None
|
||||||
@@ -197,7 +198,6 @@ def _collect_firewall() -> dict[str, Any]:
|
|||||||
mac = parts[i + 1]
|
mac = parts[i + 1]
|
||||||
iface_map[raw_name] = {
|
iface_map[raw_name] = {
|
||||||
"name": raw_name,
|
"name": raw_name,
|
||||||
"display_name": raw_name.partition("@")[0],
|
|
||||||
"mac": mac,
|
"mac": mac,
|
||||||
"state": iface_state,
|
"state": iface_state,
|
||||||
"mtu": mtu,
|
"mtu": mtu,
|
||||||
@@ -215,15 +215,14 @@ def _collect_firewall() -> dict[str, Any]:
|
|||||||
addr_name = parts[1]
|
addr_name = parts[1]
|
||||||
addr_key = "ipv6" if parts[2] == "inet6" else "ips"
|
addr_key = "ipv6" if parts[2] == "inet6" else "ips"
|
||||||
for entry in iface_map.values():
|
for entry in iface_map.values():
|
||||||
if entry["display_name"] == addr_name:
|
if entry["name"] == addr_name:
|
||||||
entry[addr_key].append(parts[3])
|
entry[addr_key].append(parts[3])
|
||||||
break
|
break
|
||||||
|
|
||||||
for zone_name, ifaces in active.items():
|
for zone_name, ifaces in active.items():
|
||||||
for raw_if in ifaces:
|
for raw_if in ifaces:
|
||||||
clean = raw_if.partition("@")[0]
|
|
||||||
for entry in iface_map.values():
|
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
|
entry["zone"] = zone_name
|
||||||
break
|
break
|
||||||
|
|
||||||
@@ -571,21 +570,12 @@ def _has_auto_renew(domain: str) -> bool:
|
|||||||
def _get_acme_email() -> str:
|
def _get_acme_email() -> str:
|
||||||
"""Read the ACME ``acme.sh`` email from the account config file.
|
"""Read the ACME ``acme.sh`` email from the account config file.
|
||||||
|
|
||||||
Returns:
|
Falls back to the declarative ACME config (config/acme/config.json)
|
||||||
Email string, or empty string if not found.
|
if acme.sh account has not been registered yet.
|
||||||
"""
|
"""
|
||||||
acme_home_default = str(PROJECT_DIR / "data" / "acme")
|
from lib.acme import _read_acme_email
|
||||||
try:
|
|
||||||
acme_home = Path(os.environ.get("ACME_HOME", acme_home_default))
|
return _read_acme_email()
|
||||||
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 ""
|
|
||||||
|
|
||||||
|
|
||||||
def _collect_acme() -> dict[str, Any]:
|
def _collect_acme() -> dict[str, Any]:
|
||||||
@@ -770,6 +760,38 @@ def _collect_wireguard() -> dict[str, Any]:
|
|||||||
|
|
||||||
register_collector("wireguard", _collect_wireguard)
|
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__ = [
|
__all__ = [
|
||||||
"State",
|
"State",
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ Defaults:{{ USER_DAEMON_NAME }} secure_path="/usr/local/sbin:/usr/local/bin:/usr
|
|||||||
# Nginx management
|
# Nginx management
|
||||||
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/sbin/nginx -s reload
|
{{ 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/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/*
|
||||||
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/cp -- * /etc/nginx/conf.d/
|
{{ 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/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/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/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
|
{{ 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
|
# Dnsmasq management
|
||||||
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/systemctl reload dnsmasq
|
{{ 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/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/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/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/mkdir -p /etc/dnsmasq.d
|
||||||
|
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/cp * /etc/dnsmasq.d/*
|
||||||
|
|
||||||
# WireGuard management
|
# WireGuard management
|
||||||
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/wg-quick *
|
{{ 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/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
|
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/chown root\:root /etc/wireguard/wg0.conf
|
||||||
|
|
||||||
# Network interface queries
|
# Network interface queries
|
||||||
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/sbin/ip -o link show
|
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/sbin/ip -o link show
|
||||||
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/sbin/ip -o addr show
|
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/sbin/ip -o addr show
|
||||||
|
|
||||||
|
# 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
|
# Misc
|
||||||
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/journalctl --unit=* -n *
|
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/journalctl --unit=* -n *
|
||||||
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/cat /var/log/nginx/*
|
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/cat /var/log/nginx/*
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ Environment=HOME={{ PROJECT_DIR }}
|
|||||||
|
|
||||||
# Security hardening
|
# Security hardening
|
||||||
ProtectSystem=strict
|
ProtectSystem=strict
|
||||||
ReadWritePaths={{ PROJECT_DIR }} /tmp
|
ReadWritePaths={{ PROJECT_DIR }} /tmp /etc/systemd/network
|
||||||
PrivateTmp=yes
|
PrivateTmp=yes
|
||||||
ProtectKernelTunables=yes
|
ProtectKernelTunables=yes
|
||||||
ProtectKernelModules=yes
|
ProtectKernelModules=yes
|
||||||
|
|||||||
@@ -35,6 +35,11 @@ def _wg(func, **kw):
|
|||||||
return _patch(f"webui.api.wireguard.{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
|
@pytest.fixture
|
||||||
def client():
|
def client():
|
||||||
from flask import Flask
|
from flask import Flask
|
||||||
@@ -42,11 +47,13 @@ def client():
|
|||||||
from webui.api.certs import bp as certs_bp
|
from webui.api.certs import bp as certs_bp
|
||||||
from webui.api.dhcp import bp as dhcp_bp
|
from webui.api.dhcp import bp as dhcp_bp
|
||||||
from webui.api.firewall import bp as firewall_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.proxy import bp as proxy_bp
|
||||||
from webui.api.wireguard import bp as wg_bp
|
from webui.api.wireguard import bp as wg_bp
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
app.register_blueprint(firewall_bp, url_prefix="/api/firewall")
|
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(dhcp_bp, url_prefix="/api/dhcp")
|
||||||
app.register_blueprint(proxy_bp, url_prefix="/api/proxy")
|
app.register_blueprint(proxy_bp, url_prefix="/api/proxy")
|
||||||
app.register_blueprint(certs_bp, url_prefix="/api/certs")
|
app.register_blueprint(certs_bp, url_prefix="/api/certs")
|
||||||
@@ -788,3 +795,106 @@ class TestProxyDomainUpdate:
|
|||||||
json={"backend_host": "10.0.0.2"},
|
json={"backend_host": "10.0.0.2"},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 200
|
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
|
||||||
|
|||||||
@@ -314,7 +314,6 @@ _FakeState = {
|
|||||||
"interfaces": [
|
"interfaces": [
|
||||||
{
|
{
|
||||||
"name": "eth0",
|
"name": "eth0",
|
||||||
"display_name": "eth0",
|
|
||||||
"mac": "aa:bb:cc:dd:ee:00",
|
"mac": "aa:bb:cc:dd:ee:00",
|
||||||
"state": "UP",
|
"state": "UP",
|
||||||
"mtu": 1500,
|
"mtu": 1500,
|
||||||
@@ -324,7 +323,6 @@ _FakeState = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "eth1",
|
"name": "eth1",
|
||||||
"display_name": "eth1",
|
|
||||||
"mac": "aa:bb:cc:dd:ee:01",
|
"mac": "aa:bb:cc:dd:ee:01",
|
||||||
"state": "UP",
|
"state": "UP",
|
||||||
"mtu": 1500,
|
"mtu": 1500,
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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"
|
||||||
@@ -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
|
||||||
@@ -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/<name>", methods=["GET"])
|
||||||
|
def get_interface(name: str):
|
||||||
|
"""Get config + runtime state for a specific interface.
|
||||||
|
|
||||||
|
Endpoint:
|
||||||
|
GET /api/network/interfaces/<name>
|
||||||
|
|
||||||
|
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/<name>", methods=["POST"])
|
||||||
|
def save_interface(name: str):
|
||||||
|
"""Save and apply network config for an interface.
|
||||||
|
|
||||||
|
Endpoint:
|
||||||
|
POST /api/network/interfaces/<name>
|
||||||
|
|
||||||
|
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/<name>/reload", methods=["POST"])
|
||||||
|
def reload_interface(name: str):
|
||||||
|
"""Reload networkd for a single interface.
|
||||||
|
|
||||||
|
Endpoint:
|
||||||
|
POST /api/network/interfaces/<name>/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)
|
||||||
@@ -20,10 +20,12 @@ from flask import Flask, render_template, request
|
|||||||
|
|
||||||
from daemon.client import get
|
from daemon.client import get
|
||||||
from lib.logging import setup_logging
|
from lib.logging import setup_logging
|
||||||
|
from lib.network import get_config
|
||||||
from webui.api.certs import bp as certs_bp
|
from webui.api.certs import bp as certs_bp
|
||||||
from webui.api.dhcp import bp as dhcp_bp
|
from webui.api.dhcp import bp as dhcp_bp
|
||||||
from webui.api.firewall import bp as firewall_bp
|
from webui.api.firewall import bp as firewall_bp
|
||||||
from webui.api.logs import bp as logs_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.proxy import bp as proxy_bp
|
||||||
from webui.api.wireguard import bp as wireguard_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.config["SECRET_KEY"] = os.urandom(32).hex()
|
||||||
|
|
||||||
app.register_blueprint(firewall_bp, url_prefix="/api/firewall")
|
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(dhcp_bp, url_prefix="/api/dhcp")
|
||||||
app.register_blueprint(proxy_bp, url_prefix="/api/proxy")
|
app.register_blueprint(proxy_bp, url_prefix="/api/proxy")
|
||||||
app.register_blueprint(certs_bp, url_prefix="/api/certs")
|
app.register_blueprint(certs_bp, url_prefix="/api/certs")
|
||||||
@@ -86,6 +89,7 @@ app.register_blueprint(logs_bp, url_prefix="/api/logs")
|
|||||||
|
|
||||||
BLUEPRINTS = [
|
BLUEPRINTS = [
|
||||||
("firewall", firewall_bp),
|
("firewall", firewall_bp),
|
||||||
|
("network", network_bp),
|
||||||
("dhcp", dhcp_bp),
|
("dhcp", dhcp_bp),
|
||||||
("proxy", proxy_bp),
|
("proxy", proxy_bp),
|
||||||
("certs", certs_bp),
|
("certs", certs_bp),
|
||||||
@@ -353,9 +357,11 @@ def interfaces_page():
|
|||||||
"""
|
"""
|
||||||
all_status = _safely(_load_status_all, {})
|
all_status = _safely(_load_status_all, {})
|
||||||
fw_state = all_status.get("firewall", {}) or {}
|
fw_state = all_status.get("firewall", {}) or {}
|
||||||
|
network_config = _safely(get_config, {})
|
||||||
return render_template(
|
return render_template(
|
||||||
"interfaces.html",
|
"interfaces.html",
|
||||||
interfaces=fw_state.get("interfaces", []),
|
interfaces=fw_state.get("interfaces", []),
|
||||||
|
network_config=network_config,
|
||||||
zones=fw_state.get("active_zones", {}).keys() or [],
|
zones=fw_state.get("active_zones", {}).keys() or [],
|
||||||
firewall_config=_safely(_fw_config_get, {}),
|
firewall_config=_safely(_fw_config_get, {}),
|
||||||
firewall_pending=fw_state.get("pending", {}),
|
firewall_pending=fw_state.get("pending", {}),
|
||||||
@@ -433,11 +439,13 @@ def dhcp_page():
|
|||||||
"""
|
"""
|
||||||
all_status = _safely(_load_status_all, {})
|
all_status = _safely(_load_status_all, {})
|
||||||
dm_state = all_status.get("dnsmasq", {}) or {}
|
dm_state = all_status.get("dnsmasq", {}) or {}
|
||||||
|
fw_state = all_status.get("firewall", {}) or {}
|
||||||
return render_template(
|
return render_template(
|
||||||
"dhcp.html",
|
"dhcp.html",
|
||||||
config=dm_state.get("config", {}),
|
config=dm_state.get("config", {}),
|
||||||
status=dm_state.get("status", {}),
|
status=dm_state.get("status", {}),
|
||||||
leases=dm_state.get("leases", []),
|
leases=dm_state.get("leases", []),
|
||||||
|
interfaces=fw_state.get("interfaces", []),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ const showSuccessToast = (msg) => showToast(msg, 'success');
|
|||||||
|
|
||||||
const showErrorToast = (msg) => showToast(msg, 'error');
|
const showErrorToast = (msg) => showToast(msg, 'error');
|
||||||
|
|
||||||
|
const showWarningToast = (msg) => showToast(msg, 'warning');
|
||||||
|
|
||||||
// Modal helpers
|
// Modal helpers
|
||||||
const openModal = (id) => {
|
const openModal = (id) => {
|
||||||
const el = document.getElementById(id);
|
const el = document.getElementById(id);
|
||||||
@@ -495,3 +497,82 @@ function renderIssueSteps(steps, status) {
|
|||||||
container.innerHTML += '<div style="margin-top:12px;text-align:center;"><span class="badge badge-success" style="font-size:13px;padding:4px 12px;">✓ Certificate issued</span></div>';
|
container.innerHTML += '<div style="margin-top:12px;text-align:center;"><span class="badge badge-success" style="font-size:13px;padding:4px 12px;">✓ Certificate issued</span></div>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── 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 = '<input type="text" class="route-dest" placeholder="Destination CIDR" style="flex:1;" />' +
|
||||||
|
'<input type="text" class="route-gw" placeholder="Gateway" style="flex:1;" />' +
|
||||||
|
'<button type="button" class="btn btn-sm btn-danger" onclick="this.parentElement.remove();">✕</button>';
|
||||||
|
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) =>
|
||||||
|
'<div class="route-row" style="display:flex;gap:6px;align-items:center;margin-bottom:4px;">' +
|
||||||
|
'<input type="text" class="route-dest" value="' + safe(r.destination) + '" placeholder="Destination CIDR" style="flex:1;" />' +
|
||||||
|
'<input type="text" class="route-gw" value="' + safe(r.gateway) + '" placeholder="Gateway" style="flex:1;" />' +
|
||||||
|
'<button type="button" class="btn btn-sm btn-danger" onclick="this.parentElement.remove();">✕</button></div>'
|
||||||
|
).join('') || '<div class="text-muted text-sm">No static routes</div>';
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -18,12 +18,19 @@
|
|||||||
<th>IP Address</th>
|
<th>IP Address</th>
|
||||||
<th>State</th>
|
<th>State</th>
|
||||||
<th>Zone</th>
|
<th>Zone</th>
|
||||||
|
<th>IP Config</th>
|
||||||
|
<th>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="interface-list">
|
<tbody id="interface-list">
|
||||||
{% for iface in (interfaces or []) %}
|
{% for iface in (interfaces or []) %}
|
||||||
<tr>
|
{% set entry = ((network_config or {}).get('interfaces') or {}).get(iface.get('name')) or {} %}
|
||||||
<td><strong>{{ iface.get('display_name', iface.get('name', 'unknown')) }}</strong></td>
|
{% 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 [] %}
|
||||||
|
<tr data-iface="{{ iface.get('name', '') }}">
|
||||||
|
<td><strong>{{ iface.get('name', 'unknown') }}</strong></td>
|
||||||
<td class="text-muted">{{ iface.get('mac', 'N/A') }}</td>
|
<td class="text-muted">{{ iface.get('mac', 'N/A') }}</td>
|
||||||
<td>
|
<td>
|
||||||
{% for ip in iface.get('ips', []) %}
|
{% for ip in iface.get('ips', []) %}
|
||||||
@@ -38,7 +45,7 @@
|
|||||||
<td>
|
<td>
|
||||||
{% if zones %}
|
{% if zones %}
|
||||||
<select
|
<select
|
||||||
hx-on::change="fetch('/api/firewall/zones/'+encodeURIComponent(this.value)+'/interfaces',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({interfaces:['{{ iface.name }}']})}).then(r=>{if(!r.ok)throw r}).then(r=>r.ok?(showSuccessToast('{{ iface.display_name }} assigned to '+this.value),refreshTable('/api/firewall/interfaces',document.getElementById('interface-list'),renderInterfaces)):r.json().then(j=>{throw new Error(j.error||r.statusText)})).catch(e=>{showErrorToast(e.message);this.selectedIndex=0})"
|
hx-on::change="assignZone('{{ iface.get('name', '') }}', this)"
|
||||||
>
|
>
|
||||||
{% for zname in zones %}
|
{% for zname in zones %}
|
||||||
<option value="{{ zname }}" {% if zname == iface.get('zone') %}selected{% endif %}>{{ zname }}</option>
|
<option value="{{ zname }}" {% if zname == iface.get('zone') %}selected{% endif %}>{{ zname }}</option>
|
||||||
@@ -48,11 +55,54 @@
|
|||||||
<span class="text-muted">No zones configured</span>
|
<span class="text-muted">No zones configured</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
|
<td style="min-width: 280px;">
|
||||||
|
<div style="display:flex;flex-direction:column;gap:6px;">
|
||||||
|
<div style="margin-bottom:4px;">
|
||||||
|
<label style="display:block;font-size:11px;color:var(--text-muted);margin-bottom:3px;">Addresses</label>
|
||||||
|
<input type="text" id="addrs-{{ iface.get('name', '') }}" value="{{ addrs }}" placeholder="192.168.1.1/24" />
|
||||||
|
</div>
|
||||||
|
<div style="margin-bottom:4px;">
|
||||||
|
<label style="display:block;font-size:11px;color:var(--text-muted);margin-bottom:3px;">Gateway</label>
|
||||||
|
<input type="text" id="gw-{{ iface.get('name', '') }}" value="{{ gw }}" placeholder="e.g. 192.168.1.254" />
|
||||||
|
</div>
|
||||||
|
<div style="margin-bottom:4px;">
|
||||||
|
<label style="display:block;font-size:11px;color:var(--text-muted);margin-bottom:3px;">DNS</label>
|
||||||
|
<input type="text" id="dns-{{ iface.get('name', '') }}" value="{{ dns_list }}" placeholder="1.1.1.1, 8.8.8.8" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline" onclick="toggleRoutes('{{ iface.get('name', '') }}')" style="font-size:11px;width:100%;justify-content:center;">
|
||||||
|
▼ Routes
|
||||||
|
</button>
|
||||||
|
<div id="routes-panel-{{ iface.get('name', '') }}" style="display:none;margin-top:6px;padding:8px;border:1px solid var(--border);border-radius:6px;background:var(--input-bg);">
|
||||||
|
<div id="routes-{{ iface.get('name', '') }}">
|
||||||
|
{% if routes %}
|
||||||
|
{% for route in routes %}
|
||||||
|
<div class="route-row" style="display:flex;gap:6px;align-items:center;margin-bottom:4px;">
|
||||||
|
<input type="text" class="route-dest" value="{{ route.get('destination', '') }}" placeholder="Destination CIDR" style="flex:1;" />
|
||||||
|
<input type="text" class="route-gw" value="{{ route.get('gateway', '') }}" placeholder="Gateway" style="flex:1;" />
|
||||||
|
<button type="button" class="btn btn-sm btn-danger" onclick="this.parentElement.remove();">✕</button>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<div class="text-muted text-sm">No static routes</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline" onclick="addRoute('{{ iface.get('name', '') }}')" style="margin-top:4px;font-size:11px;">+ Add Route</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="flex" style="flex-direction:column;gap:6px;">
|
||||||
|
<button class="btn btn-sm btn-primary" onclick="saveInterfaceConfig('{{ iface.get('name', '') }}')">Save</button>
|
||||||
|
<button class="btn btn-sm btn-outline" onclick="reloadNetworkd('{{ iface.get('name', '') }}')">Reload</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% if not (interfaces or []) %}
|
{% if not (interfaces or []) %}
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="6" class="text-muted text-sm">No interfaces found</td>
|
<td colspan="7" class="text-muted text-sm">No interfaces found</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
Reference in New Issue
Block a user