feat: add system config import, refactor install script and nginx auth

- lib/system_import: new module to import system configs into JSON at daemon startup
- daemon/server.py: call import_all() during startup for config reconciliation
- daemon/handlers/nginx.py: simplify add_domain auth handling, remove duplicate code
- scripts/install.sh: replace inline Python setup with curl-based daemon API calls; apply IP forwarding at runtime
- hoover: bump internal asset versions to v=8
- pages: bump asset versions to v=9
This commit is contained in:
2026-07-08 02:20:28 +00:00
parent fb39af126a
commit 5135de0921
13 changed files with 1633 additions and 144 deletions
+4 -19
View File
@@ -396,34 +396,19 @@ def add_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
if cert is not None:
entry["cert"] = cert
# Handle auth credentials for management domain
# Handle auth credentials
auth_user = body.get("auth_user", "").strip()
auth_pass = body.get("auth_pass", "")
if auth_user and auth_pass:
_write_htpasswd(auth_user, auth_pass)
auth_dict = {"user": auth_user, "htpasswd": str(HTPASSWD_FILE)}
paths_entry = entry.get("paths", {})
for _ppath, pcfg in paths_entry.items():
if pcfg.get("is_management"):
pcfg["auth"] = auth_dict
break
entry["auth"] = auth_dict
# Handle auth credentials for management paths
auth_user = body.get("auth_user", "").strip()
auth_pass = body.get("auth_pass", "").strip()
if auth_user and auth_pass:
_write_htpasswd(auth_user, auth_pass)
auth_entry = {
auth = {
"user": auth_user,
"htpasswd": str(HTPASSWD_FILE),
}
# Store auth on root path if it exists
root_path = entry.get("paths", {}).get("/")
if root_path:
root_path["auth"] = auth_entry
# Also store at domain level for template
entry["auth"] = auth_entry
root_path["auth"] = auth
entry["auth"] = auth
cfg["domains"][domain] = entry
_save_config(cfg)
+6
View File
@@ -558,6 +558,12 @@ def main() -> None:
os.chmod(socket_path, 0o660)
# Import system configs → JSON (blocking — OK at startup)
from lib.system_import import import_all
reconciled = import_all()
if reconciled:
logger.info("Reconciled subsystems: %s", ", ".join(reconciled))
# Populate state from system (blocking — OK at startup)
logger.info("Populating system state...")
state_store.populate()
+888
View File
@@ -0,0 +1,888 @@
"""System config import on daemon startup.
On first start, vacuum-walld parses each subsystem's live system config
file and writes the canonical JSON config. This reconciles any drift
caused by install.sh or manual edits to system files.
"""
import contextlib
import logging
import re
from pathlib import Path
from typing import Any
from lib.common import load_json, run, save_json
from lib.firewall import _live_target_to_config, _parse_all_zones_output
logger = logging.getLogger(__name__)
PROJECT_DIR = Path(__file__).resolve().parent.parent
def _safe_int(val: str) -> int | str:
"""Try to parse *val* as int; return original string on failure."""
with contextlib.suppress(ValueError):
return int(val)
return val
DNSMASQ_CONF = Path("/etc/dnsmasq.d/vacuum-wall.conf")
WG_CONF = Path("/etc/wireguard/wg0.conf")
NETWORKD_DIR = Path("/etc/systemd/network")
NGINX_SITES_DIR = PROJECT_DIR / "data" / "nginx" / "sites-enabled"
DNSTART = "# ---- vacuum-wall managed dnsmasq configuration ----"
DNEND = "# ---- end vacuum-wall config ----"
def import_all() -> list[str]:
"""Run all subsystem imports. Returns list of subsystems that were updated."""
updated: list[str] = []
for fn, name in [
(import_dnsmasq, "dnsmasq"),
(import_firewall, "firewall"),
(import_wireguard, "wireguard"),
(import_networkd, "network"),
(import_nginx, "nginx"),
]:
try:
if fn():
updated.append(name)
except Exception:
logger.warning("Import failed for %s", name, exc_info=True)
return updated
# ------------------------------------------------------------------
# Dnsmasq
# ------------------------------------------------------------------
def import_dnsmasq() -> bool:
"""Parse /etc/dnsmasq.d/vacuum-wall.conf -> config/dnsmasq/config.json."""
if not DNSMASQ_CONF.exists():
logger.debug("Skipping dnsmasq: %s not found", DNSMASQ_CONF)
return False
try:
text = DNSMASQ_CONF.read_text()
except OSError:
logger.debug("Skipping dnsmasq: cannot read %s", DNSMASQ_CONF)
return False
# Extract managed block
start_idx = text.find(DNSTART)
end_idx = text.rfind(DNEND)
if start_idx == -1 or end_idx == -1 or end_idx <= start_idx:
logger.debug("Skipping dnsmasq: no managed markers found")
return False
block = text[start_idx + len(DNSTART) : end_idx]
try:
cfg = _parse_dnsmasq_block(block)
except Exception:
logger.warning("Failed to parse dnsmasq config", exc_info=True)
return False
cfg_path = PROJECT_DIR / "config" / "dnsmasq" / "config.json"
if cfg_path.exists():
existing = load_json(cfg_path)
if _cfgs_equal(existing, cfg):
logger.debug("Skipping dnsmasq: config already matches")
return False
save_json(cfg_path, cfg)
summary = f"upstreams={len(cfg.get('dns', {}).get('upstreams', []))}, ranges={len(cfg.get('dhcp', {}).get('ranges', []))}"
logger.info("Imported dnsmasq config from %s: %s", DNSMASQ_CONF, summary)
return True
def _parse_dnsmasq_block(block: str) -> dict[str, Any]:
"""Parse a dnsmasq managed block into JSON config dict."""
upstreams: list[str] = []
domains: str | None = None
ranges: list[dict[str, Any]] = []
static_leases: list[dict[str, Any]] = []
custom_records: list[dict[str, Any]] = []
for line in block.splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
if line.startswith("server="):
upstreams.append(line.split("=", 1)[1].strip())
elif line == "no-resolv":
upstreams = []
elif line.startswith("domain="):
domains = line.split("=", 1)[1].strip()
elif line.startswith("dhcp-range="):
rng = _parse_dhcp_range(line.split("=", 1)[1])
ranges.append(rng)
elif line.startswith("dhcp-option="):
target = _attach_dhcp_option(line.split("=", 1)[1], ranges)
if target:
_set_dhcp_option_option(target, line.split("=", 1)[1])
elif line.startswith("dhcp-host="):
lease = _parse_dhcp_host(line.split("=", 1)[1])
if lease:
static_leases.append(lease)
elif line.startswith("addr/"):
rec = _parse_addr_directive(line)
if rec:
custom_records.append(rec)
elif line.startswith("host-record="):
pass # paired with addr/, skip
return {
"dhcp": {
"ranges": ranges,
"static_leases": static_leases,
},
"dns": {
"upstreams": upstreams,
"domain": domains,
"custom_records": custom_records,
},
}
def _parse_dhcp_range(value: str) -> dict[str, Any]:
"""Parse dhcp-range value into dict."""
parts = value.split(",")
if parts[0].startswith("set:"):
iface = parts[0][4:]
addr_parts = parts[1:4]
else:
iface = None
addr_parts = parts[0:3]
rng: dict[str, Any] = {}
if iface:
rng["interface"] = iface
rng["start"] = addr_parts[0] if len(addr_parts) > 0 else ""
rng["end"] = addr_parts[1] if len(addr_parts) > 1 else ""
rng["lease_time"] = addr_parts[2] if len(addr_parts) > 2 else "12h"
return rng
def _attach_dhcp_option(
value: str, ranges: list[dict[str, Any]]
) -> dict[str, Any] | None:
"""Parse dhcp-option and return the target range to attach to."""
m = re.match(r"tag:([^,]+),(\d+),(.+)", value)
if not m:
return None
tag_iface = m.group(1)
for rng in reversed(ranges):
if rng.get("interface") == tag_iface:
return rng
return None
def _set_dhcp_option_option(target: dict[str, Any], value: str) -> None:
"""Parse dhcp-option value string and set gateway/dns on target range."""
m = re.match(r"tag:[^,]+,(\d+),(.+)", value)
if not m:
return
option_code = m.group(1)
option_val = m.group(2).strip()
if option_code == "3":
target["gateway"] = option_val
elif option_code == "6":
target["dns"] = option_val
def _parse_dhcp_host(value: str) -> dict[str, Any] | None:
"""Parse dhcp-host value into dict."""
parts = value.split(",")
if len(parts) < 2:
return None
lease: dict[str, Any] = {
"mac": parts[0].strip().lower(),
"ip": parts[1].strip(),
}
if len(parts) >= 3:
lease["hostname"] = parts[2].strip()
return lease
def _parse_addr_directive(line: str) -> dict[str, Any] | None:
"""Parse addr/name/addr into dict."""
parts = line.split("/")
if len(parts) < 3:
return None
return {"name": parts[1].strip(), "address": parts[2].strip()}
# ------------------------------------------------------------------
# WireGuard
# ------------------------------------------------------------------
def import_wireguard() -> bool:
"""Parse /etc/wireguard/wg0.conf -> config/wireguard/config.json."""
if not WG_CONF.exists():
logger.debug("Skipping wireguard: %s not found", WG_CONF)
return False
try:
text = WG_CONF.read_text()
except OSError:
logger.debug("Skipping wireguard: cannot read %s", WG_CONF)
return False
try:
cfg = _parse_wireguard_conf(text)
except Exception:
logger.warning("Failed to parse wireguard config", exc_info=True)
return False
cfg_path = PROJECT_DIR / "config" / "wireguard" / "config.json"
if cfg_path.exists():
existing = load_json(cfg_path)
if _cfgs_equal(existing, cfg):
logger.debug("Skipping wireguard: config already matches")
return False
save_json(cfg_path, cfg)
peer_count = len(cfg.get("peers", {}))
logger.info("Imported wireguard config from %s: peers=%d", WG_CONF, peer_count)
return True
def _parse_wireguard_conf(text: str) -> dict[str, Any]:
"""Parse wg-quick INI format into JSON config dict."""
interface: dict[str, Any] = {
"name": "wg0",
"listen_port": 51820,
"private_key": "",
"public_key": "",
"addresses": ["10.137.0.1/24"],
"post_up": None,
"post_down": None,
}
peers: dict[str, dict[str, Any]] = {}
current_section = None
current_peer_name = None
current_peer: dict[str, Any] | None = None
def _flush_peer() -> None:
nonlocal current_peer, current_peer_name
if (
current_peer is not None
and current_peer_name
and current_peer.get("public_key")
):
peers[current_peer_name] = current_peer
current_peer = None
current_peer_name = None
for line in text.splitlines():
stripped = line.strip()
# Section headers — allow trailing content like comments
m = re.match(r"^\[(\w+)\](.*)?$", stripped)
if m:
section_name = m.group(1)
if section_name == "Interface":
current_section = "interface"
_flush_peer()
elif section_name == "Peer":
current_section = "peer"
_flush_peer()
# Look for name in comment after [Peer]
m2 = re.match(r"^\[Peer\]\s*#\s*(.+)", stripped)
current_peer_name = m2.group(1).strip() if m2 else None
current_peer = {}
else:
current_section = None
_flush_peer()
continue
if not stripped or stripped.startswith("#"):
continue
if "=" not in stripped:
continue
key, _, val = stripped.partition("=")
key = key.strip()
val = val.strip()
if current_section == "interface":
if key == "PrivateKey":
interface["private_key"] = val
elif key == "Address":
interface["addresses"] = [a.strip() for a in val.split(",")]
elif key == "ListenPort":
parsed = _safe_int(val)
if isinstance(parsed, int):
interface["listen_port"] = parsed
elif key == "PostUp":
interface["post_up"] = val
elif key == "PostDown":
interface["post_down"] = val
elif current_section == "peer" and current_peer is not None:
if key == "PublicKey":
current_peer["public_key"] = val
if current_peer_name is None:
current_peer_name = val
elif key == "PresharedKey":
current_peer["preshared_key"] = val
elif key == "Endpoint":
current_peer["endpoint"] = val
elif key == "AllowedIPs":
current_peer["allowed_ips"] = [a.strip() for a in val.split(",")]
elif key == "PersistentKeepalive":
parsed = _safe_int(val)
current_peer["persistent_keepalive"] = (
parsed if isinstance(parsed, int) else None
)
# Flush any remaining peer
if (
current_peer is not None
and current_peer_name
and current_peer.get("public_key")
):
peers[current_peer_name] = current_peer
return {
"interface": interface,
"peers": peers,
}
# ------------------------------------------------------------------
# Networkd
# ------------------------------------------------------------------
def import_networkd() -> bool:
"""Parse /etc/systemd/network/99-*.network -> config/network/config.json."""
if not NETWORKD_DIR.exists():
logger.debug("Skipping network: %s not found", NETWORKD_DIR)
return False
network_files = sorted(NETWORKD_DIR.glob("99-*.network"))
if not network_files:
logger.debug("Skipping network: no 99-*.network files")
return False
parsed_interfaces: dict[str, dict[str, Any]] = {}
for nf in network_files:
# Extract interface name from filename: 99-eth0.network -> eth0
iface_name = nf.stem[3:] # strip "99-"
try:
parsed_interface = _parse_network_file(nf)
if parsed_interface:
parsed_interfaces[iface_name] = parsed_interface
except Exception:
logger.warning("Failed to parse %s", nf, exc_info=True)
if not parsed_interfaces:
logger.debug("Skipping network: no valid .network files")
return False
cfg_path = PROJECT_DIR / "config" / "network" / "config.json"
existing: dict[str, Any] = load_json(cfg_path, {"interfaces": {}})
interfaces_cfg = existing.setdefault("interfaces", {})
# Only add/update interfaces with .network files; don't remove interfaces
# without a file (they may be pending apply).
changed = False
for name, entry in parsed_interfaces.items():
if name not in interfaces_cfg or not _cfgs_equal(interfaces_cfg[name], entry):
interfaces_cfg[name] = entry
changed = True
if not changed:
logger.debug("Skipping network: config already matches")
return False
save_json(cfg_path, existing)
logger.info(
"Imported network config from %s/*.network: interfaces=%s",
NETWORKD_DIR,
", ".join(parsed_interfaces.keys()),
)
return True
def _parse_network_file(path: Path) -> dict[str, Any] | None:
"""Parse a .network INI file into interface config dict."""
text = path.read_text()
iface: dict[str, Any] = {}
cur_section: str | None = None
cur_addr: dict[str, Any] | None = None
cur_route: dict[str, Any] | None = None
def _flush() -> None:
nonlocal cur_addr, cur_route
if cur_addr is not None:
if "address" in cur_addr and len(cur_addr) == 1:
iface.setdefault("addresses", []).append(cur_addr["address"])
elif cur_addr:
iface.setdefault("addresses", []).append(cur_addr)
cur_addr = None
if cur_route is not None and cur_route:
iface.setdefault("routes", []).append(cur_route)
cur_route = None
for line in text.splitlines():
stripped = line.strip()
if not stripped or stripped.startswith("#"):
continue
m = re.match(r"^\[(.+)\]$", stripped)
if m:
_flush()
sec = m.group(1)
if sec.startswith("Address"):
cur_section = sec
cur_addr = {}
elif sec.startswith("Route"):
cur_section = sec
cur_route = {}
else:
cur_section = sec
continue
if "=" not in stripped:
continue
key, _, val = stripped.partition("=")
key = key.strip()
val = val.strip()
if cur_addr is not None:
cur_addr = _parse_addr_key(cur_addr, key, val)
continue
if cur_route is not None:
cur_route = _parse_route_key(cur_route, key, val)
continue
_parse_network_section(iface, cur_section, key, val)
_flush()
return iface if iface else None
def _parse_addr_key(entry: dict[str, Any], key: str, val: str) -> dict[str, Any]:
"""Parse an [Address] key-value pair, return updated entry."""
if key == "Address":
entry["address"] = val
elif key == "Label":
entry["label"] = val
elif key == "Scope":
entry["scope"] = val
elif key == "RouteMetric":
parsed = _safe_int(val)
if isinstance(parsed, int):
entry["route_metric"] = parsed
elif key == "DuplicateAddressDetection":
entry["duplicate_address_detection"] = val
elif key == "ManageTemporaryAddress":
entry["manage_temporary_address"] = _parse_bool(val)
elif key == "AddPrefixRoute":
entry["add_prefix_route"] = _parse_bool(val)
return entry
def _parse_route_key(entry: dict[str, Any], key: str, val: str) -> dict[str, Any]:
"""Parse a [Route] key-value pair, return updated entry."""
if key == "Destination":
entry["destination"] = val
elif key == "Gateway":
entry["gateway"] = val
elif key == "Metric":
parsed = _safe_int(val)
if isinstance(parsed, int):
entry["metric"] = parsed
elif key == "Table":
entry["table"] = val
elif key == "Type":
entry["type"] = val
elif key == "Scope":
entry["scope"] = val
elif key == "GatewayOnLink":
entry["gateway_on_link"] = _parse_bool(val)
elif key == "IPv6Preference":
entry["ipv6_preference"] = val
elif key == "MTUBytes":
parsed = _safe_int(val)
if isinstance(parsed, int):
entry["mtu_bytes"] = parsed
return entry
def _parse_network_section(
iface: dict[str, Any], section: str | None, key: str, val: str
) -> None:
"""Parse a [Match]/[Link]/[Network] key-value pair into iface dict."""
if section == "Match":
return
if section == "Link":
link = iface.setdefault("link", {})
_set_link_key(link, key, val)
return
if section == "Network":
_set_network_key(iface, key, val)
return
def _set_link_key(link: dict[str, Any], key: str, val: str) -> None:
if key == "MTUBytes":
parsed = _safe_int(val)
if isinstance(parsed, int):
link["mtu_bytes"] = parsed
elif key == "MACAddress":
link["mac_address"] = val
elif key in ("ARP", "Multicast", "AllMulticast", "Promiscuous", "Unmanaged"):
pk = {
"ARP": "arp",
"Multicast": "multicast",
"AllMulticast": "all_multicast",
"Promiscuous": "promiscuous",
"Unmanaged": "unmanaged",
}[key]
link[pk] = _parse_bool(val)
elif key == "ActivationPolicy":
link["activation_policy"] = val
elif key == "RequiredForOnline":
link["required_for_online"] = val
def _set_network_key(iface: dict[str, Any], key: str, val: str) -> None:
if key == "DHCP":
iface["dhcp"] = val
elif key == "Gateway":
iface["gateway"] = val
elif key == "IPv6Gateway":
iface["ipv6_gateway"] = val
elif key == "DNS":
for d in val.split(","):
d = d.strip()
if d:
iface.setdefault("dns", []).append(d)
elif key == "IPv6DNS":
for d in val.split(","):
d = d.strip()
if d:
iface.setdefault("ipv6_dns", []).append(d)
elif key == "Domains":
for d in val.split(","):
d = d.strip()
if d:
iface.setdefault("domains", []).append(d)
elif key == "IPv6Domains":
for d in val.split(","):
d = d.strip()
if d:
iface.setdefault("ipv6_domains", []).append(d)
elif key == "DNSDefaultRoute":
iface["dns_default_route"] = _parse_bool(val)
elif key == "BindCarrier":
iface.setdefault("bind_carrier", []).append(val)
elif key == "IgnoreCarrierLoss":
iface["ignore_carrier_loss"] = val
elif key == "KeepConfiguration":
iface["keep_configuration"] = val
elif key == "ConfigureWithoutCarrier":
iface["configure_without_carrier"] = _parse_bool(val)
elif key == "LinkLocalAddressing":
iface["link_local_addressing"] = val
elif key == "IPv6LinkLocalAddressGenerationMode":
iface["ipv6_link_local_address_generation_mode"] = val
elif key == "IPv6StableSecretAddress":
iface["ipv6_stable_secret_address"] = val
elif key == "IPv4LLStartAddress":
iface["ipv4_ll_start_address"] = val
elif key == "IPv4LLRoute":
iface["ipv4_ll_route"] = _parse_bool(val)
elif key == "DefaultRouteOnDevice":
iface["default_route_on_device"] = _parse_bool(val)
elif key == "IPv6HopLimit":
parsed = _safe_int(val)
if isinstance(parsed, int):
iface["ipv6_hop_limit"] = parsed
elif key == "IPv6RetransmissionTimeSec":
iface["ipv6_retransmission_time_sec"] = val
elif key == "IPv4DuplicateAddressDetectionTimeoutSec":
iface["ipv4_duplicate_address_detection_timeout_sec"] = val
elif key == "IPv4ReversePathFilter":
iface["ipv4_reverse_path_filter"] = val
elif key == "IPv4AcceptLocal":
iface["ipv4_accept_local"] = _parse_bool(val)
elif key == "IPv4RouteLocalnet":
iface["ipv4_route_localnet"] = _parse_bool(val)
elif key == "IPv4ProxyARP":
iface["ipv4_proxy_arp"] = _parse_bool(val)
elif key == "IPv4ProxyARPPrivateVLAN":
iface["ipv4_proxy_arp_private_vlan"] = _parse_bool(val)
elif key == "IPv6ProxyNDP":
iface["ipv6_proxy_ndp"] = _parse_bool(val)
elif key == "IPv6ProxyNDPAddress":
iface["ipv6_proxy_ndp_address"] = val
elif key == "IPv6SendRA":
iface["ipv6_send_ra"] = _parse_bool(val)
elif key == "MPLSRouting":
iface["m_pls_routing"] = _parse_bool(val)
elif key == "KeepMaster":
iface["keep_master"] = _parse_bool(val)
elif key == "IPFamily":
iface["ip_family"] = val
def _parse_bool(val: str) -> bool | str:
"""Parse common boolean representations to bool, return as-is otherwise."""
if val.lower() in ("yes", "true", "1", "on"):
return True
if val.lower() in ("no", "false", "0", "off"):
return False
return val
# ------------------------------------------------------------------
# Nginx
# ------------------------------------------------------------------
def import_nginx() -> bool:
"""Parse data/nginx/sites-enabled/*.conf -> config/nginx/config.json."""
if not NGINX_SITES_DIR.exists():
logger.debug("Skipping nginx: %s not found", NGINX_SITES_DIR)
return False
conf_files = sorted(NGINX_SITES_DIR.glob("*.conf"))
sites: dict[str, dict[str, Any]] = {}
for cf in conf_files:
# Skip acme challenge
if cf.name == "_acme-challenge.conf":
continue
domain = cf.stem
if not domain:
continue
try:
parsed = _parse_nginx_site(cf, domain)
if parsed:
sites[domain] = parsed
except Exception:
logger.warning("Failed to parse nginx site %s", cf, exc_info=True)
if not sites:
logger.debug("Skipping nginx: no valid site files")
return False
cfg_path = PROJECT_DIR / "config" / "nginx" / "config.json"
existing: dict[str, Any] = load_json(cfg_path, {"domains": {}, "ssl": {}})
domains_cfg = existing.setdefault("domains", {})
changed = False
for name, entry in sites.items():
if name not in domains_cfg or not _cfgs_equal(domains_cfg[name], entry):
domains_cfg[name] = entry
changed = True
if not changed:
logger.debug("Skipping nginx: config already matches")
return False
save_json(cfg_path, existing)
logger.info(
"Imported nginx config from %s/*.conf: domains=%s",
NGINX_SITES_DIR,
", ".join(sites.keys()),
)
return True
def _parse_nginx_site(path: Path, domain: str) -> dict[str, Any] | None:
"""Parse a vacuum-wall nginx site conf into domain config dict."""
text = path.read_text()
# Check it's our file
if "# Auto-generated by Vacuum Wall" not in text:
logger.debug("Skipping nginx site %s: not vacuum-wall generated", path)
return None
# Determine force_ssl: look for port 80 redirect block
force_ssl = bool(re.search(r"listen\s+80\b", text))
# Parse domain-level auth
domain_auth = None
auth_match = re.search(
r"auth_basic\s+.*;\s*\n\s*auth_basic_user_file\s+(.+?);", text
)
if auth_match:
domain_auth = {"htpasswd": auth_match.group(1).strip()}
# Infer cert type
cert = _infer_cert_type(text, domain)
# Parse location blocks
paths: dict[str, dict[str, Any]] = {}
_parse_location_blocks(text, paths, domain_auth)
entry: dict[str, Any] = {
"force_ssl": force_ssl,
"paths": paths,
}
if cert:
entry["cert"] = cert
if domain_auth:
entry["auth"] = domain_auth
return entry
def _infer_cert_type(text: str, domain: str) -> str | None:
"""Infer cert type from ssl_certificate path."""
m = re.search(r"ssl_certificate\s+(.+?);", text)
if not m:
return None
cert_path = m.group(1).strip()
if "acme" in cert_path:
return "acme"
elif "data/certs" in cert_path:
return "selfsigned"
else:
return "file"
def _parse_location_blocks(
text: str, paths: dict[str, dict[str, Any]], domain_auth: dict[str, Any] | None
) -> None:
"""Extract location blocks and their annotations."""
# Match annotation comment before location block
# # /path -> host:port (WebSocket)
# # /path -> host:port
loc_re = re.compile(
r"#\s*(/[\S]*)\s*->\s*(\S+?)(?:\s*\(WebSocket\))?\s*\n"
r"\s*location\s+\1\s*\{",
re.MULTILINE,
)
for m in loc_re.finditer(text):
ppath = m.group(1)
backend_str = m.group(2)
is_ws = "WebSocket" in m.group(0)
host, _, port_str = backend_str.partition(":")
try:
port = int(port_str)
except ValueError:
port = 80
is_websocket = is_ws
# Check for auth_basic off inside this location block
# Find the location block for this path
block_start = m.end()
# Find matching closing brace
depth = 1
idx = block_start
while idx < len(text) and depth > 0:
if text[idx] == "{":
depth += 1
elif text[idx] == "}":
depth -= 1
idx += 1
block_text = text[block_start:idx]
entry: dict[str, Any] = {
"backend": {
"host": host,
"port": port,
"proto": "http",
},
}
if is_websocket:
entry["is_websocket"] = True
elif "auth_basic off" in block_text:
entry["auth"] = None
if "is_management" in block_text or (
"/api" not in ppath and "management" in block_text.lower()
):
entry["is_management"] = True
# Check for WebSocket path
if ppath == "/ws":
entry["is_websocket"] = True
paths[ppath] = entry
# ------------------------------------------------------------------
# Firewall
# ------------------------------------------------------------------
def import_firewall() -> bool:
"""Run firewall-cmd --list-all-zones -> config/firewall/config.json."""
cfg_path = PROJECT_DIR / "config" / "firewall" / "config.json"
if cfg_path.exists():
logger.debug("Skipping firewall: config already exists at %s", cfg_path)
return False
try:
output = run("firewall-cmd --list-all-zones", sudo=True)
except RuntimeError:
logger.warning("Import failed for firewall: firewall-cmd unavailable")
return False
try:
zones = _parse_all_zones_output(output)
except Exception:
logger.warning("Failed to parse firewall zones", exc_info=True)
return False
zone_configs = {
zone_name: {
"target": _live_target_to_config(parsed["target"]),
"interfaces": parsed["interfaces"],
"services": parsed["services"],
"masquerade": parsed["masquerade"],
"rich_rules": [{"rule": r} for r in parsed["rich-rules"]],
"forward_ports": parsed["forward-ports"],
}
for zone_name, parsed in zones.items()
if parsed["interfaces"]
}
if not zone_configs:
logger.debug("Skipping firewall: no zones with interfaces")
return False
save_json(cfg_path, {"zones": zone_configs})
logger.info(
"Imported firewall config: zones=%s",
", ".join(zone_configs.keys()),
)
return True
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
def _cfgs_equal(a: dict[str, Any], b: dict[str, Any]) -> bool:
"""Compare two configs ignoring _last_applied_hash."""
a_clean = {k: v for k, v in a.items() if k != "_last_applied_hash"}
b_clean = {k: v for k, v in b.items() if k != "_last_applied_hash"}
return a_clean == b_clean
+64 -98
View File
@@ -277,11 +277,16 @@ render_template "${PROJECT_DIR}/system/systemd/vacuum-wall-acme.service" \
install -m 0644 "${PROJECT_DIR}/system/systemd/vacuum-wall-acme.timer" /etc/systemd/system/vacuum-wall-acme.timer
systemctl daemon-reload
# --- 7. Enable IP forwarding (persistent via sysctl.conf) ---
# --- 7. Enable IP forwarding (persistent via sysctl.conf + runtime apply) ---
log "Enabling IP forwarding..."
if ! grep -q "^net.ipv4.ip_forward=1" /etc/sysctl.conf 2>/dev/null; then
echo "net.ipv4.ip_forward=1" >> /etc/sysctl.conf
fi
# Apply immediately so NAT works without reboot
if [ "$(cat /proc/sys/net/ipv4/ip_forward 2>/dev/null)" != "1" ]; then
sysctl -w net.ipv4.ip_forward=1 >/dev/null 2>&1 && log "IP forwarding enabled at runtime" || \
warn "Could not enable IP forwarding at runtime"
fi
# --- 8. Detect network interfaces ---
log "Detecting network interfaces..."
@@ -348,108 +353,73 @@ else
chown "$USER_DAEMON_NAME:$USER_GROUP" "$_SOCKET" 2>/dev/null || true
chmod 0660 "$_SOCKET" 2>/dev/null || true
# --- 10. Configure subsystems via daemon API ---
log "Configuring subsystems via daemon API..."
WAN_IFACE="$WAN_IFACE" \
LAN_IFACES="$LAN_IFACES" \
MGMT_DOMAIN="$DOMAIN" \
MGMT_USER="$MGMT_USER" \
MGMT_PASS="$MGMT_PASS" \
"${PROJECT_DIR}/.venv/bin/python3" -c "
import daemon.client as c
from daemon.iface import (
POST_ACME_SELF_SIGNED, POST_NGINX_DOMAINS_ADD, POST_NGINX_APPLY,
POST_FIREWALL_CONFIG, POST_FIREWALL_CONFIG_APPLY, POST_NETWORK_SYSCTL_SET,
GET_NETWORK_INFER_DHCP_RANGES,
)
import sys
echo ""
echo " Setting up initial management configuration..."
domain = '${DOMAIN}'
mgmt_user = '${MGMT_USER}'
mgmt_pass = '${MGMT_PASS}'
wan_iface = '${WAN_IFACE}'
lan_ifaces = '${LAN_IFACES}'
# htpasswd is created by the daemon via /nginx/domains/add (writes to data/.htpasswd)
# Self-signed cert for management domain
try:
res = c.post(POST_ACME_SELF_SIGNED, {'domain': domain, 'days': 365})
print(f' [cert] Self-signed: {\"generated\" if res.get(\"generated\") else \"exists\"}')
except Exception as e:
print(f' [cert] Warning: {e}', file=sys.stderr)
# Management proxy domain + htpasswd
try:
c.post(POST_NGINX_DOMAINS_ADD, {
'domain': domain,
'paths': {
'/': {
'backend': {'host': '127.0.0.1', 'port': 9090, 'proto': 'http'},
'is_management': True,
},
'/ws': {
'backend': {'host': '127.0.0.1', 'port': 9091, 'proto': 'http'},
'is_websocket': True,
},
},
'auth_user': mgmt_user,
'auth_pass': mgmt_pass,
})
c.post(POST_NGINX_APPLY)
print(f' [proxy] Management proxy configured for {domain}')
except Exception as e:
print(f' [proxy] Warning: {e}', file=sys.stderr)
# Firewall config (interface detection done in bash above)
import json as _json
zones = {}
if wan_iface:
zones['public'] = {
'target': 'DEFAULT',
'interfaces': [i for i in wan_iface.split(',') if i],
'services': ['http', 'https', 'ssh'],
'masquerade': True,
# Helper: POST JSON to daemon API over Unix socket
_daemon_post() {
local endpoint="$1"
local json="$2"
local label="${3:-POST $endpoint}"
local resp
if resp=$(curl -s -f --unix-socket "$_SOCKET" \
"http://localhost${endpoint}" \
-H "Content-Type: application/json" \
-d "$json" 2>&1); then
log "$label"
return 0
else
warn "$label: $resp"
return 1
fi
}
if lan_ifaces:
zones['internal'] = {
'target': 'ACCEPT',
'interfaces': [i for i in lan_ifaces.split(',') if i],
'services': ['dhcp', 'dns', 'ntp'],
'masquerade': False,
# 1A. Self-signed certificate
_daemon_post "/acme/self-signed" "{\"domain\":\"$DOMAIN\"}" "Self-signed certificate"
# 1C. Management proxy domain
_daemon_post "/nginx/domains/add" "$(jq -n \
--arg domain "$DOMAIN" \
--arg user "$MGMT_USER" \
--arg pass "$MGMT_PASS" \
'{
domain: $domain,
paths: {
"/": {
backend: {host: "127.0.0.1", port: 9090, proto: "http"},
is_management: true
},
"/ws": {
backend: {host: "127.0.0.1", port: 9091, proto: "http"},
is_websocket: true
}
},
auth_user: $user,
auth_pass: $pass
}')" "Management domain configured"
# Always create vpn zone skeleton for later WireGuard setup
zones['vpn'] = {
'target': 'ACCEPT',
'interfaces': [],
'services': [],
'masquerade': False,
}
_daemon_post "/nginx/apply" "{}" "Nginx config applied"
try:
c.post(POST_FIREWALL_CONFIG, {'zones': zones})
c.post(POST_FIREWALL_CONFIG_APPLY)
print(' [firewall] Zones configured and applied')
except Exception as e:
print(f' [firewall] Warning: {e}', file=sys.stderr)
# Firewall zone assignment
if [[ -n "$WAN_IFACE" ]]; then
_daemon_post "/firewall/zones/interfaces" \
"$(jq -n --arg zone "public" --arg iface "$WAN_IFACE" \
'{zone: $zone, interfaces: [$iface]}')" \
"WAN interface assigned to public zone"
fi
# IP forwarding
try:
c.post(POST_NETWORK_SYSCTL_SET, {'name': 'net.ipv4.ip_forward', 'value': '1'})
print(' [network] IP forwarding enabled')
except Exception as e:
print(f' [network] Warning: {e}', file=sys.stderr)
if [[ -n "$LAN_IFACES" ]]; then
# Convert comma-separated list to JSON array
LAN_JSON=$(echo "$LAN_IFACES" | tr ',' '\n' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | jq -R . | jq -s '.')
_daemon_post "/firewall/zones/interfaces" \
"$(jq -n --arg zone "internal" --argjson ifaces "$LAN_JSON" \
'{zone: $zone, interfaces: $ifaces}')" \
"LAN interfaces assigned to internal zone"
fi
# Infer DHCP ranges (logged for user reference)
try:
ranges = c.get(GET_NETWORK_INFER_DHCP_RANGES)
for iface, rng in ranges.get('ranges', {}).items():
print(f' [suggestion] DHCP range for {iface}: {rng.get(\"start\")}-{rng.get(\"end\")}')
except Exception:
pass
"
log "Subsystem configuration complete"
unset _daemon_post
fi
systemctl start vacuum-wall >/dev/null 2>&1 && log "Started vacuum-wall WebUI" || warn "Could not start vacuum-wall WebUI"
@@ -489,7 +459,3 @@ echo " 3. Configure DHCP ranges for your LAN"
echo " 4. Add proxy domains with ACME certificates"
echo " 5. Set up WireGuard tunnel (optional)"
echo ""
echo " NOTE: A self-signed certificate was generated."
echo " From the WebUI, issue a real certificate for $DOMAIN"
echo " when DNS points to this appliance."
echo ""
+644
View File
@@ -0,0 +1,644 @@
"""Tests for lib/system_import module."""
import json
from pathlib import Path
from unittest.mock import patch
import pytest
from lib import system_import
from lib.common import save_json
@pytest.fixture
def temp_project(tmp_path):
"""Patch all module-level path constants to tmp_path subdirs."""
originals = {
"PROJECT_DIR": system_import.PROJECT_DIR,
"DNSMASQ_CONF": system_import.DNSMASQ_CONF,
"WG_CONF": system_import.WG_CONF,
"NETWORKD_DIR": system_import.NETWORKD_DIR,
"NGINX_SITES_DIR": system_import.NGINX_SITES_DIR,
}
system_import.PROJECT_DIR = tmp_path
system_import.DNSMASQ_CONF = tmp_path / "etc" / "dnsmasq.d" / "vacuum-wall.conf"
system_import.WG_CONF = tmp_path / "etc" / "wireguard" / "wg0.conf"
system_import.NETWORKD_DIR = tmp_path / "etc" / "systemd" / "network"
system_import.NGINX_SITES_DIR = tmp_path / "data" / "nginx" / "sites-enabled"
yield tmp_path
system_import.PROJECT_DIR = originals["PROJECT_DIR"]
system_import.DNSMASQ_CONF = originals["DNSMASQ_CONF"]
system_import.WG_CONF = originals["WG_CONF"]
system_import.NETWORKD_DIR = originals["NETWORKD_DIR"]
system_import.NGINX_SITES_DIR = originals["NGINX_SITES_DIR"]
# ──────────────────────────────────────────────────────────────────────
# Dnsmasq
# ──────────────────────────────────────────────────────────────────────
class TestImportDnsmasq:
def _write_conf(self, tmp_path, content: str) -> Path:
p = tmp_path / "etc" / "dnsmasq.d"
p.mkdir(parents=True, exist_ok=True)
(p / "vacuum-wall.conf").write_text(content)
return p / "vacuum-wall.conf"
def _read_json(self, tmp_path) -> dict:
p = tmp_path / "config" / "dnsmasq" / "config.json"
return json.loads(p.read_text()) if p.exists() else {}
def test_no_conf_file(self, temp_project):
assert not system_import.import_dnsmasq()
def test_no_markers(self, temp_project, tmp_path):
self._write_conf(tmp_path, "# some random config\nserver=1.1.1.1\n")
assert not system_import.import_dnsmasq()
def test_empty_managed_block(self, temp_project, tmp_path):
self._write_conf(tmp_path, f"{system_import.DNSTART}\n{system_import.DNEND}")
assert system_import.import_dnsmasq()
cfg = self._read_json(tmp_path)
assert cfg["dns"]["upstreams"] == []
assert cfg["dhcp"]["ranges"] == []
def test_upstreams_only(self, temp_project, tmp_path):
conf = (
f"{system_import.DNSTART}\n"
"server=8.8.8.8\n"
"server=1.1.1.1\n"
f"{system_import.DNEND}"
)
self._write_conf(tmp_path, conf)
assert system_import.import_dnsmasq()
cfg = self._read_json(tmp_path)
assert cfg["dns"]["upstreams"] == ["8.8.8.8", "1.1.1.1"]
def test_full_config(self, temp_project, tmp_path):
conf = (
f"{system_import.DNSTART}\n"
"server=8.8.8.8\n"
"server=1.1.1.1\n"
"domain=lan\n"
"expand-hosts\n"
"dhcp-range=set:eth1,192.168.2.100,192.168.2.200,12h\n"
"dhcp-option=tag:eth1,3,192.168.2.1\n"
"dhcp-option=tag:eth1,6,192.168.2.1\n"
"dhcp-host=aa:bb:cc:dd:ee:ff,192.168.2.50,printer\n"
"addr/nas.lan/192.168.2.10\n"
f"{system_import.DNEND}"
)
self._write_conf(tmp_path, conf)
assert system_import.import_dnsmasq()
cfg = self._read_json(tmp_path)
assert cfg["dns"]["upstreams"] == ["8.8.8.8", "1.1.1.1"]
assert cfg["dns"]["domain"] == "lan"
assert len(cfg["dhcp"]["ranges"]) == 1
rng = cfg["dhcp"]["ranges"][0]
assert rng["interface"] == "eth1"
assert rng["start"] == "192.168.2.100"
assert rng["end"] == "192.168.2.200"
assert rng["lease_time"] == "12h"
assert rng["gateway"] == "192.168.2.1"
assert rng["dns"] == "192.168.2.1"
assert len(cfg["dhcp"]["static_leases"]) == 1
lease = cfg["dhcp"]["static_leases"][0]
assert lease["mac"] == "aa:bb:cc:dd:ee:ff"
assert lease["ip"] == "192.168.2.50"
assert lease["hostname"] == "printer"
assert len(cfg["dns"]["custom_records"]) == 1
assert cfg["dns"]["custom_records"][0] == {
"name": "nas.lan",
"address": "192.168.2.10",
}
def test_no_resolv_resets_upstreams(self, temp_project, tmp_path):
conf = f"{system_import.DNSTART}\nno-resolv\n{system_import.DNEND}"
self._write_conf(tmp_path, conf)
assert system_import.import_dnsmasq()
cfg = self._read_json(tmp_path)
assert cfg["dns"]["upstreams"] == []
def test_idempotent(self, temp_project, tmp_path):
conf = f"{system_import.DNSTART}\nserver=8.8.8.8\n{system_import.DNEND}"
self._write_conf(tmp_path, conf)
assert system_import.import_dnsmasq()
assert not system_import.import_dnsmasq()
def test_parse_error_returns_false(self, temp_project, tmp_path):
# Conf with markers — parses fine, so this tests the exception handler
# by mocking _parse_dnsmasq_block to raise
conf = f"{system_import.DNSTART}\nserver=8.8.8.8\n{system_import.DNEND}"
self._write_conf(tmp_path, conf)
with patch(
"lib.system_import._parse_dnsmasq_block", side_effect=ValueError("bad")
):
assert not system_import.import_dnsmasq()
# ──────────────────────────────────────────────────────────────────────
# WireGuard
# ──────────────────────────────────────────────────────────────────────
class TestImportWireguard:
def _write_conf(self, tmp_path, content: str) -> Path:
p = tmp_path / "etc" / "wireguard"
p.mkdir(parents=True, exist_ok=True)
(p / "wg0.conf").write_text(content)
return p / "wg0.conf"
def _read_json(self, tmp_path) -> dict:
p = tmp_path / "config" / "wireguard" / "config.json"
return json.loads(p.read_text()) if p.exists() else {}
def test_no_conf_file(self, temp_project):
assert not system_import.import_wireguard()
def test_basic_interface(self, temp_project, tmp_path):
conf = (
"[Interface]\n"
" PrivateKey = abc123\n"
" Address = 10.137.0.1/24\n"
" ListenPort = 51820\n"
)
self._write_conf(tmp_path, conf)
assert system_import.import_wireguard()
cfg = self._read_json(tmp_path)
assert cfg["interface"]["private_key"] == "abc123"
assert cfg["interface"]["addresses"] == ["10.137.0.1/24"]
assert cfg["interface"]["listen_port"] == 51820
assert cfg["interface"]["name"] == "wg0"
assert cfg["peers"] == {}
def test_full_with_peers(self, temp_project, tmp_path):
conf = (
"[Interface]\n"
" PrivateKey = srv-priv\n"
" Address = 10.137.0.1/24\n"
" ListenPort = 51820\n"
" PostUp = iptables -I FORWARD -i wg0 -j ACCEPT\n"
" PostDown = iptables -D FORWARD -i wg0 -j ACCEPT\n"
"\n"
"[Peer] # alice\n"
" PublicKey = alice-pub\n"
" Endpoint = 203.0.113.1:51820\n"
" AllowedIPs = 0.0.0.0/0\n"
" PersistentKeepalive = 25\n"
"\n"
"[Peer] # bob\n"
" PublicKey = bob-pub\n"
" AllowedIPs = 10.0.0.0/8,172.16.0.0/12\n"
)
self._write_conf(tmp_path, conf)
assert system_import.import_wireguard()
cfg = self._read_json(tmp_path)
assert cfg["interface"]["post_up"] == "iptables -I FORWARD -i wg0 -j ACCEPT"
assert cfg["interface"]["post_down"] == "iptables -D FORWARD -i wg0 -j ACCEPT"
assert "alice" in cfg["peers"]
assert cfg["peers"]["alice"]["public_key"] == "alice-pub"
assert cfg["peers"]["alice"]["endpoint"] == "203.0.113.1:51820"
assert cfg["peers"]["alice"]["allowed_ips"] == ["0.0.0.0/0"]
assert cfg["peers"]["alice"]["persistent_keepalive"] == 25
assert "bob" in cfg["peers"]
assert cfg["peers"]["bob"]["allowed_ips"] == ["10.0.0.0/8", "172.16.0.0/12"]
def test_peer_without_name_uses_pubkey(self, temp_project, tmp_path):
conf = (
"[Interface]\n"
" PrivateKey = srv-priv\n"
" Address = 10.137.0.1/24\n"
" ListenPort = 51820\n"
"\n"
"[Peer]\n"
" PublicKey = anon-pub\n"
" AllowedIPs = 0.0.0.0/0\n"
)
self._write_conf(tmp_path, conf)
assert system_import.import_wireguard()
cfg = self._read_json(tmp_path)
assert "anon-pub" in cfg["peers"]
def test_idempotent(self, temp_project, tmp_path):
conf = (
"[Interface]\n"
" PrivateKey = abc123\n"
" Address = 10.137.0.1/24\n"
" ListenPort = 51820\n"
)
self._write_conf(tmp_path, conf)
assert system_import.import_wireguard()
assert not system_import.import_wireguard()
# ──────────────────────────────────────────────────────────────────────
# Networkd
# ──────────────────────────────────────────────────────────────────────
class TestImportNetworkd:
def _write_network(self, tmp_path, name: str, content: str) -> Path:
p = tmp_path / "etc" / "systemd" / "network"
p.mkdir(parents=True, exist_ok=True)
file_path = p / f"99-{name}.network"
file_path.write_text(content)
return file_path
def _read_json(self, tmp_path) -> dict:
p = tmp_path / "config" / "network" / "config.json"
return json.loads(p.read_text()) if p.exists() else {}
def test_no_network_dir(self, temp_project):
assert not system_import.import_networkd()
def test_no_files(self, temp_project, tmp_path):
(tmp_path / "etc" / "systemd" / "network").mkdir(parents=True, exist_ok=True)
assert not system_import.import_networkd()
def test_basic_interface(self, temp_project, tmp_path):
conf = (
"[Match]\n"
"Name=eth0\n"
"\n"
"[Network]\n"
"DHCP=no\n"
"Addresses=192.168.1.1/24\n"
"Gateway=192.168.1.254\n"
"DNS=8.8.8.8\n"
"DNS=1.1.1.1\n"
)
self._write_network(tmp_path, "eth0", conf)
assert system_import.import_networkd()
cfg = self._read_json(tmp_path)
eth0 = cfg["interfaces"]["eth0"]
assert eth0["dhcp"] == "no"
assert eth0["gateway"] == "192.168.1.254"
assert eth0["dns"] == ["8.8.8.8", "1.1.1.1"]
def test_multiple_interfaces(self, temp_project, tmp_path):
self._write_network(
tmp_path, "eth0", "[Match]\nName=eth0\n\n[Network]\nDHCP=yes\n"
)
self._write_network(
tmp_path, "eth1", "[Match]\nName=eth1\n\n[Network]\nDHCP=no\n"
)
assert system_import.import_networkd()
cfg = self._read_json(tmp_path)
assert "eth0" in cfg["interfaces"]
assert "eth1" in cfg["interfaces"]
assert cfg["interfaces"]["eth0"]["dhcp"] == "yes"
assert cfg["interfaces"]["eth1"]["dhcp"] == "no"
def test_preserves_existing_interfaces(self, temp_project, tmp_path):
# Pre-existing JSON has eth2 with no .network file
cfg_path = tmp_path / "config" / "network"
cfg_path.mkdir(parents=True, exist_ok=True)
save_json(cfg_path / "config.json", {"interfaces": {"eth2": {"dhcp": "no"}}})
self._write_network(
tmp_path, "eth0", "[Match]\nName=eth0\n\n[Network]\nDHCP=yes\n"
)
assert system_import.import_networkd()
cfg = self._read_json(tmp_path)
assert "eth0" in cfg["interfaces"]
assert "eth2" in cfg["interfaces"]
def test_idempotent(self, temp_project, tmp_path):
self._write_network(
tmp_path, "eth0", "[Match]\nName=eth0\n\n[Network]\nDHCP=yes\n"
)
assert system_import.import_networkd()
assert not system_import.import_networkd()
def test_address_section_parsed(self, temp_project, tmp_path):
conf = (
"[Match]\n"
"Name=eth0\n"
"\n"
"[Network]\n"
"DHCP=no\n"
"\n"
"[Address]\n"
"Address=192.168.1.1/24\n"
)
self._write_network(tmp_path, "eth0", conf)
assert system_import.import_networkd()
cfg = self._read_json(tmp_path)
eth0 = cfg["interfaces"]["eth0"]
assert "192.168.1.1/24" in eth0.get("addresses", [])
def test_route_section_parsed(self, temp_project, tmp_path):
conf = (
"[Match]\n"
"Name=eth0\n"
"\n"
"[Network]\n"
"DHCP=no\n"
"\n"
"[Route]\n"
"Destination=10.0.0.0/8\n"
"Gateway=192.168.1.254\n"
"Metric=100\n"
)
self._write_network(tmp_path, "eth0", conf)
assert system_import.import_networkd()
cfg = self._read_json(tmp_path)
eth0 = cfg["interfaces"]["eth0"]
assert len(eth0.get("routes", [])) == 1
route = eth0["routes"][0]
assert route["destination"] == "10.0.0.0/8"
assert route["gateway"] == "192.168.1.254"
assert route["metric"] == 100
# ──────────────────────────────────────────────────────────────────────
# Nginx
# ──────────────────────────────────────────────────────────────────────
class TestImportNginx:
def _write_site(self, tmp_path, domain: str, content: str) -> Path:
p = tmp_path / "data" / "nginx" / "sites-enabled"
p.mkdir(parents=True, exist_ok=True)
file_path = p / f"{domain}.conf"
file_path.write_text(content)
return file_path
def _read_json(self, tmp_path) -> dict:
p = tmp_path / "config" / "nginx" / "config.json"
return json.loads(p.read_text()) if p.exists() else {}
def test_no_sites_dir(self, temp_project):
assert not system_import.import_nginx()
def test_no_files(self, temp_project, tmp_path):
(tmp_path / "data" / "nginx" / "sites-enabled").mkdir(
parents=True, exist_ok=True
)
assert not system_import.import_nginx()
def test_acme_challenge_skipped(self, temp_project, tmp_path):
(tmp_path / "data" / "nginx" / "sites-enabled").mkdir(
parents=True, exist_ok=True
)
(
tmp_path / "data" / "nginx" / "sites-enabled" / "_acme-challenge.conf"
).write_text("# stuff\n")
assert not system_import.import_nginx()
def test_unrecognized_file_skipped(self, temp_project, tmp_path):
self._write_site(tmp_path, "my-site", "# some random nginx config\nserver {}\n")
assert not system_import.import_nginx()
def test_basic_site(self, temp_project, tmp_path):
conf = (
"# Auto-generated by Vacuum Wall — do not edit manually\n"
"# Domain: example.com\n"
"\n"
"server {\n"
" listen 80;\n"
" listen [::]:80;\n"
" server_name example.com;\n"
" return 301 https://$host$request_uri;\n"
"}\n"
"\n"
"server {\n"
" listen 443 ssl;\n"
" listen [::]:443 ssl;\n"
" server_name example.com;\n"
"\n"
" ssl_certificate /home/wall/vacuum-wall/data/acme/example.com/fullchain.cer;\n"
" ssl_certificate_key /home/wall/vacuum-wall/data/acme/example.com/example.com.key;\n"
"\n"
" # / -> 192.168.2.50:8080\n"
" location / {\n"
" auth_basic off;\n"
" proxy_pass http://192.168.2.50:8080;\n"
" }\n"
"}\n"
)
self._write_site(tmp_path, "example.com", conf)
assert system_import.import_nginx()
cfg = self._read_json(tmp_path)
assert "example.com" in cfg["domains"]
dom = cfg["domains"]["example.com"]
assert dom["force_ssl"] is True
assert dom["cert"] == "acme"
assert "/" in dom["paths"]
assert dom["paths"]["/"]["backend"]["host"] == "192.168.2.50"
assert dom["paths"]["/"]["backend"]["port"] == 8080
def test_websocket_path(self, temp_project, tmp_path):
conf = (
"# Auto-generated by Vacuum Wall — do not edit manually\n"
"# Domain: example.com\n"
"\n"
"server {\n"
" listen 443 ssl;\n"
" server_name example.com;\n"
"\n"
" ssl_certificate /data/certs/example.com.crt;\n"
" ssl_certificate_key /data/certs/example.com.key;\n"
"\n"
" # / -> 127.0.0.1:9090\n"
" location / {\n"
" auth_basic off;\n"
" proxy_pass http://127.0.0.1:9090;\n"
" }\n"
"\n"
" # /ws -> 127.0.0.1:9091 (WebSocket)\n"
" location /ws {\n"
" auth_basic off;\n"
" proxy_pass http://127.0.0.1:9091;\n"
" }\n"
"}\n"
)
self._write_site(tmp_path, "example.com", conf)
assert system_import.import_nginx()
cfg = self._read_json(tmp_path)
dom = cfg["domains"]["example.com"]
assert dom["cert"] == "selfsigned"
assert dom["paths"]["/ws"]["is_websocket"] is True
assert dom["paths"]["/ws"]["backend"]["port"] == 9091
def test_idempotent(self, temp_project, tmp_path):
conf = (
"# Auto-generated by Vacuum Wall — do not edit manually\n"
"server {\n"
" listen 443 ssl;\n"
" server_name example.com;\n"
" ssl_certificate /data/acme/example.com/fullchain.cer;\n"
" ssl_certificate_key /data/acme/example.com/example.com.key;\n"
" # / -> 127.0.0.1:9090\n"
" location / {\n"
" auth_basic off;\n"
" proxy_pass http://127.0.0.1:9090;\n"
" }\n"
"}\n"
)
self._write_site(tmp_path, "example.com", conf)
assert system_import.import_nginx()
assert not system_import.import_nginx()
# ──────────────────────────────────────────────────────────────────────
# Firewall
# ──────────────────────────────────────────────────────────────────────
FIREWALL_ZONES_OUTPUT = (
"public (active)\n"
" target: default\n"
" interfaces: eth0 eth1\n"
" sources: \n"
" services: dhcpv6-cidr dns mdns ssh\n"
" ports: \n"
" protocols: \n"
" forward-ports: \n"
" source-ports: \n"
" icmp-blocks: \n"
" rich rules: \n"
"\n"
"internal (active)\n"
" target: DEFAULT\n"
" interfaces: eth2\n"
" sources: \n"
" services: dhcpv6-cidr dns mdns samba-client ssh\n"
" ports: \n"
" protocols: \n"
" forward-ports: \n"
" source-ports: \n"
" icmp-blocks: \n"
" rich rules: \n"
"\n"
"dmz (active)\n"
" target: DROP\n"
" interfaces: \n"
" sources: \n"
" services: dns\n"
" ports: \n"
" protocols: \n"
" forward-ports: \n"
" source-ports: \n"
" icmp-blocks: \n"
" rich rules: \n"
)
class TestImportFirewall:
def _read_json(self, tmp_path) -> dict:
p = tmp_path / "config" / "firewall" / "config.json"
return json.loads(p.read_text()) if p.exists() else {}
def test_no_config_file_and_firewalld_down(self, temp_project, tmp_path):
with patch(
"lib.system_import.run", side_effect=RuntimeError("firewalld not running")
):
assert not system_import.import_firewall()
def test_existing_config_not_overwritten(self, temp_project, tmp_path):
cfg_path = tmp_path / "config" / "firewall"
cfg_path.mkdir(parents=True, exist_ok=True)
save_json(
cfg_path / "config.json", {"zones": {"public": {"interfaces": ["eth0"]}}}
)
assert not system_import.import_firewall()
def test_import_zones(self, temp_project, tmp_path):
with patch("lib.system_import.run", return_value=FIREWALL_ZONES_OUTPUT):
assert system_import.import_firewall()
cfg = self._read_json(tmp_path)
assert "zones" in cfg
assert "public" in cfg["zones"]
assert "internal" in cfg["zones"]
assert cfg["zones"]["public"]["target"] == "DEFAULT"
assert cfg["zones"]["public"]["interfaces"] == ["eth0", "eth1"]
assert cfg["zones"]["public"]["services"] == [
"dhcpv6-cidr",
"dns",
"mdns",
"ssh",
]
assert cfg["zones"]["internal"]["target"] == "DEFAULT"
assert cfg["zones"]["internal"]["interfaces"] == ["eth2"]
def test_empty_interface_zones_skipped(self, temp_project, tmp_path):
with patch("lib.system_import.run", return_value=FIREWALL_ZONES_OUTPUT):
assert system_import.import_firewall()
cfg = self._read_json(tmp_path)
assert "dmz" not in cfg["zones"]
def test_parse_error_returns_false(self, temp_project, tmp_path):
with patch("lib.system_import.run", return_value="garbage with no valid zones"):
assert not system_import.import_firewall()
# ──────────────────────────────────────────────────────────────────────
# import_all
# ──────────────────────────────────────────────────────────────────────
class TestImportAll:
def test_all_missing(self, temp_project):
result = system_import.import_all()
assert result == []
def test_returns_updated_subsystems(self, temp_project, tmp_path):
# Create dnsmasq conf
etc = tmp_path / "etc" / "dnsmasq.d"
etc.mkdir(parents=True, exist_ok=True)
conf = f"{system_import.DNSTART}\nserver=8.8.8.8\n{system_import.DNEND}"
(etc / "vacuum-wall.conf").write_text(conf)
# Create wireguard conf
wg_etc = tmp_path / "etc" / "wireguard"
wg_etc.mkdir(parents=True, exist_ok=True)
(wg_etc / "wg0.conf").write_text(
"[Interface]\n PrivateKey = abc\n Address = 10.137.0.1/24\n ListenPort = 51820\n"
)
result = system_import.import_all()
assert "dnsmasq" in result
assert "wireguard" in result
assert "network" not in result
assert "nginx" not in result
def test_parse_error_does_not_crash(self, temp_project, tmp_path):
# Create a dnsmasq conf that will parse fine
etc = tmp_path / "etc" / "dnsmasq.d"
etc.mkdir(parents=True, exist_ok=True)
conf = f"{system_import.DNSTART}\nserver=8.8.8.8\n{system_import.DNEND}"
(etc / "vacuum-wall.conf").write_text(conf)
# Make wireguard import fail
wg_etc = tmp_path / "etc" / "wireguard"
wg_etc.mkdir(parents=True, exist_ok=True)
(wg_etc / "wg0.conf").write_text("[Interface]\n")
# This should not raise, just log warning
result = system_import.import_all()
assert "dnsmasq" in result
# ──────────────────────────────────────────────────────────────────────
# _cfgs_equal
# ──────────────────────────────────────────────────────────────────────
class TestCfgsEqual:
def test_equal(self):
assert system_import._cfgs_equal({"a": 1}, {"a": 1})
def test_not_equal(self):
assert not system_import._cfgs_equal({"a": 1}, {"a": 2})
def test_ignores_applied_hash(self):
a = {"a": 1, "_last_applied_hash": "abc"}
b = {"a": 1, "_last_applied_hash": "xyz"}
assert system_import._cfgs_equal(a, b)
def test_nested(self):
a = {"dhcp": {"ranges": [{"start": "1.2.3.4"}]}}
b = {"dhcp": {"ranges": [{"start": "1.2.3.4"}]}}
assert system_import._cfgs_equal(a, b)
+12 -12
View File
@@ -1,16 +1,16 @@
import { h, render, Link, hComp, ToastContainer, connect, apiFetch, modelRegister, modelFetch, reactive } from '/static/hoover/index.js?v=9';
import { h, render, Link, hComp, ToastContainer, connect, apiFetch, modelRegister, modelFetch, reactive } from '/static/hoover/index.js?v=8';
import DashboardPage from '/static/pages/dashboard.js?v=8';
import InterfacesPage from '/static/pages/interfaces.js?v=8';
import ZonesPage from '/static/pages/zones.js?v=8';
import RulesPage from '/static/pages/rules.js?v=8';
import NatPage from '/static/pages/nat.js?v=8';
import DhcpPage from '/static/pages/dhcp.js?v=8';
import ProxyPage from '/static/pages/proxy.js?v=8';
import CertsPage from '/static/pages/certs.js?v=8';
import WireguardPage from '/static/pages/wireguard.js?v=8';
import LogsPage from '/static/pages/logs.js?v=8';
import NotFoundPage from '/static/pages/notfound.js?v=8';
import DashboardPage from '/static/pages/dashboard.js?v=9';
import InterfacesPage from '/static/pages/interfaces.js?v=9';
import ZonesPage from '/static/pages/zones.js?v=9';
import RulesPage from '/static/pages/rules.js?v=9';
import NatPage from '/static/pages/nat.js?v=9';
import DhcpPage from '/static/pages/dhcp.js?v=9';
import ProxyPage from '/static/pages/proxy.js?v=9';
import CertsPage from '/static/pages/certs.js?v=9';
import WireguardPage from '/static/pages/wireguard.js?v=9';
import LogsPage from '/static/pages/logs.js?v=9';
import NotFoundPage from '/static/pages/notfound.js?v=9';
/* ── Navigation items ──────────────────────────────────────── */
const Nav = [
+3 -3
View File
@@ -15,9 +15,9 @@
* });
*/
import { reactive } from './reactivity.js?v=7';
import { h } from './vdom.js?v=7';
import { _compExpandedCache } from './render.js?v=7';
import { reactive } from './reactivity.js?v=8';
import { h } from './vdom.js?v=8';
import { _compExpandedCache } from './render.js?v=8';
/** Registry of mounted components: key → { state } */
const _mounted = new Map();
+3 -3
View File
@@ -4,9 +4,9 @@
* Layout components: PageHeader, Tabs, SectionTitle, DataTableSection, ActionGroup.
*/
import { h } from '../vdom.js?v=7';
import { Table } from './data.js?v=7';
import { collectLoadingModels } from '../model.js?v=7';
import { h } from '../vdom.js?v=8';
import { Table } from './data.js?v=8';
import { collectLoadingModels } from '../model.js?v=8';
/**
* Page header with title, optional subtitle, and action buttons.
+1 -1
View File
@@ -1,4 +1,4 @@
import htm from '../../vendor/htm.js';
import { htmAdapter } from './vdom.js?v=7';
import { htmAdapter } from './vdom.js?v=8';
export const html = htm.bind(htmAdapter);
+1 -1
View File
@@ -13,7 +13,7 @@
* collectLoadingModels(...models) — combine loading/refreshing/error
*/
import { reactive } from './reactivity.js?v=7';
import { reactive } from './reactivity.js?v=8';
/** Registered models: name → { model, subsystem, fetch } */
const _models = new Map();
+3 -3
View File
@@ -5,12 +5,12 @@
* batched re-render loop integration with reactivity.js.
*/
import { requestUpdate, setCommitFn } from './reactivity.js?v=7';
import { requestUpdate, setCommitFn } from './reactivity.js?v=8';
import {
_vnodeDom, createDom, getDom, patchNode, sweepDom,
setMountFn, setUnmountFn,
} from './vdom.js?v=7';
import { mountComponent, unmountComponent } from './component.js?v=7';
} from './vdom.js?v=8';
import { mountComponent, unmountComponent } from './component.js?v=8';
/** Container → previous root vnodes */
export const _renderSlots = new Map();
+2 -2
View File
@@ -5,8 +5,8 @@
* navigation). Link component for client-side navigation.
*/
import { reactive } from './reactivity.js?v=7';
import { h } from './vdom.js?v=7';
import { reactive } from './reactivity.js?v=8';
import { h } from './vdom.js?v=8';
/**
* Hash-based router.
+1 -1
View File
@@ -6,7 +6,7 @@
* Page-level subscribe/unsubscribe is replaced by the model layer.
*/
import { refreshByTopic } from './model.js?v=7';
import { refreshByTopic } from './model.js?v=8';
let _wsConn = null;
let _wsReconnectMs = 0;