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:
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
+65
-99
@@ -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 ""
|
||||
|
||||
@@ -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
@@ -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 = [
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user