5135de0921
- 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
889 lines
28 KiB
Python
889 lines
28 KiB
Python
"""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
|