From 5135de0921f1a8470f51bd4ecbc9c9be4c4eee58 Mon Sep 17 00:00:00 2001 From: Mike Teehan Date: Wed, 8 Jul 2026 02:20:28 +0000 Subject: [PATCH] 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 --- daemon/handlers/nginx.py | 23 +- daemon/server.py | 6 + lib/system_import.py | 888 +++++++++++++++++++++++ scripts/install.sh | 164 ++--- tests/test_system_import.py | 644 ++++++++++++++++ webui/static/app.js | 24 +- webui/static/hoover/component.js | 6 +- webui/static/hoover/components/layout.js | 6 +- webui/static/hoover/html.js | 2 +- webui/static/hoover/model.js | 2 +- webui/static/hoover/render.js | 6 +- webui/static/hoover/router.js | 4 +- webui/static/hoover/websocket.js | 2 +- 13 files changed, 1633 insertions(+), 144 deletions(-) create mode 100644 lib/system_import.py create mode 100644 tests/test_system_import.py diff --git a/daemon/handlers/nginx.py b/daemon/handlers/nginx.py index 8f6166f..8babd83 100644 --- a/daemon/handlers/nginx.py +++ b/daemon/handlers/nginx.py @@ -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) diff --git a/daemon/server.py b/daemon/server.py index 169bb87..f128d75 100644 --- a/daemon/server.py +++ b/daemon/server.py @@ -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() diff --git a/lib/system_import.py b/lib/system_import.py new file mode 100644 index 0000000..043b8cb --- /dev/null +++ b/lib/system_import.py @@ -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 diff --git a/scripts/install.sh b/scripts/install.sh index 1cb1ed7..761ef54 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -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) + # 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 + } -# 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, - }, + # 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': 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) + auth_user: $user, + auth_pass: $pass + }')" "Management domain configured" -# Firewall config (interface detection done in bash above) -import json as _json -zones = {} + _daemon_post "/nginx/apply" "{}" "Nginx config applied" -if wan_iface: - zones['public'] = { - 'target': 'DEFAULT', - 'interfaces': [i for i in wan_iface.split(',') if i], - 'services': ['http', 'https', 'ssh'], - 'masquerade': True, - } + # 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 -if lan_ifaces: - zones['internal'] = { - 'target': 'ACCEPT', - 'interfaces': [i for i in lan_ifaces.split(',') if i], - 'services': ['dhcp', 'dns', 'ntp'], - 'masquerade': False, - } + 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 -# Always create vpn zone skeleton for later WireGuard setup -zones['vpn'] = { - 'target': 'ACCEPT', - 'interfaces': [], - 'services': [], - 'masquerade': False, -} - -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) - -# 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) - -# 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 "" diff --git a/tests/test_system_import.py b/tests/test_system_import.py new file mode 100644 index 0000000..752a159 --- /dev/null +++ b/tests/test_system_import.py @@ -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) diff --git a/webui/static/app.js b/webui/static/app.js index 67ea9f7..bac37ac 100644 --- a/webui/static/app.js +++ b/webui/static/app.js @@ -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 = [ diff --git a/webui/static/hoover/component.js b/webui/static/hoover/component.js index 9d2c197..7eb9026 100644 --- a/webui/static/hoover/component.js +++ b/webui/static/hoover/component.js @@ -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(); diff --git a/webui/static/hoover/components/layout.js b/webui/static/hoover/components/layout.js index 490f074..12944a8 100644 --- a/webui/static/hoover/components/layout.js +++ b/webui/static/hoover/components/layout.js @@ -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. diff --git a/webui/static/hoover/html.js b/webui/static/hoover/html.js index bc52d91..12d5fa8 100644 --- a/webui/static/hoover/html.js +++ b/webui/static/hoover/html.js @@ -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); diff --git a/webui/static/hoover/model.js b/webui/static/hoover/model.js index 1ad9ad9..1ffed1f 100644 --- a/webui/static/hoover/model.js +++ b/webui/static/hoover/model.js @@ -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(); diff --git a/webui/static/hoover/render.js b/webui/static/hoover/render.js index 49be170..e06e31a 100644 --- a/webui/static/hoover/render.js +++ b/webui/static/hoover/render.js @@ -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(); diff --git a/webui/static/hoover/router.js b/webui/static/hoover/router.js index decabbb..bf32231 100644 --- a/webui/static/hoover/router.js +++ b/webui/static/hoover/router.js @@ -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. diff --git a/webui/static/hoover/websocket.js b/webui/static/hoover/websocket.js index aa6cd6f..aecfdb4 100644 --- a/webui/static/hoover/websocket.js +++ b/webui/static/hoover/websocket.js @@ -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;