refactor: unify project structure, improve security, and enhance deployment

- Fix WireGuard private key leak in API responses and config updates
- Update systemd service to serve from repo root with adjusted sandbox
- Add CLI flags, idempotency, and dev mode to install.sh
- Extract common utilities to lib/common.py and webui/api/common.py
- Migrate frontend to htmx for simpler, more maintainable UI
- Update docs to reflect current architecture and deployment model
- Vendor htmx dependencies per project requirements
This commit is contained in:
2026-05-25 00:53:32 +00:00
parent 8829ac579d
commit d1ab717c0f
36 changed files with 857 additions and 626 deletions
+48 -61
View File
@@ -1,13 +1,10 @@
"""
dnsmasq configuration manager for Vacuum Wall SSL proxy firewall.
"""Dnsmasq configuration manager for Vacuum Wall SSL proxy firewall.
Generates /etc/dnsmasq.d/vacuum-wall.conf and manages DHCP range,
static leases, and custom DNS records through sudo.
"""
import json
import logging
import os
import subprocess
from copy import deepcopy
from datetime import UTC, datetime
@@ -16,6 +13,8 @@ from typing import Any
from jinja2 import Environment, FileSystemLoader
from lib.common import deep_merge, ensure_dirs, load_json, save_json
logger = logging.getLogger(__name__)
PROJECT_DIR = Path(__file__).resolve().parent.parent
@@ -46,64 +45,24 @@ DEFAULT_CFG: dict[str, Any] = {
},
}
# ───────── helpers ───────────────────────────────────────────────────
def _ensure_dirs() -> None:
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
DATA_DIR.mkdir(parents=True, exist_ok=True)
FRAGMENTS_DIR.mkdir(parents=True, exist_ok=True)
def _sudo(*cmd: str) -> subprocess.CompletedProcess[str]:
return subprocess.run(
["sudo", *list(cmd)],
capture_output=True,
text=True,
check=True,
)
def _load_json(path: Path) -> dict:
if not path.exists():
return {}
with open(path) as f:
return json.load(f)
def _save_json(path: Path, data: dict) -> None:
_ensure_dirs()
with open(path, "w") as f:
json.dump(data, f, indent=4)
def _deep_merge(base: dict, overrides: dict) -> dict:
result = deepcopy(base)
for k, v in overrides.items():
if k in result and isinstance(result[k], dict) and isinstance(v, dict):
result[k] = _deep_merge(result[k], v)
else:
result[k] = deepcopy(v)
return result
# ───────── config lifecycle ──────────────────────────────────────────
def get_config() -> dict:
def get_config() -> dict[str, Any]:
"""Load current dnsmasq config from JSON state file."""
_ensure_dirs()
raw = _load_json(CONFIG_PATH)
ensure_dirs(CONFIG_DIR, DATA_DIR, FRAGMENTS_DIR)
raw = load_json(CONFIG_PATH)
if not raw:
return deepcopy(DEFAULT_CFG)
return _deep_merge(deepcopy(DEFAULT_CFG), raw)
return deep_merge(deepcopy(DEFAULT_CFG), raw)
def save_config(cfg: dict) -> None:
def save_config(cfg: dict[str, Any]) -> None:
"""Persist config to JSON (does NOT touch on-disk dnsmasq config)."""
_ensure_dirs()
merged = _deep_merge(deepcopy(DEFAULT_CFG), cfg)
_save_json(CONFIG_PATH, merged)
ensure_dirs(CONFIG_DIR, DATA_DIR, FRAGMENTS_DIR)
merged = deep_merge(deepcopy(DEFAULT_CFG), cfg)
save_json(CONFIG_PATH, merged)
logger.info("dnsmasq config saved")
@@ -112,8 +71,8 @@ def apply_config() -> None:
cfg = get_config()
conf_text = generate_conf(cfg)
_ensure_dirs()
_sudo("mkdir", "-p", "/etc/dnsmasq.d")
ensure_dirs(CONFIG_DIR, DATA_DIR, FRAGMENTS_DIR)
subprocess.run(["sudo", "mkdir", "-p", "/etc/dnsmasq.d"], check=True)
subprocess.run(
["sudo", "tee", DNSMASQ_CONF, "--"],
input=conf_text,
@@ -121,14 +80,19 @@ def apply_config() -> None:
text=True,
check=True,
)
_sudo("systemctl", "reload", "dnsmasq")
subprocess.run(
["sudo", "systemctl", "reload", "dnsmasq"],
capture_output=True,
text=True,
check=True,
)
logger.info("dnsmasq config written and reloaded")
# ───────── config generation ─────────────────────────────────────────
def generate_conf(cfg: dict) -> str:
def generate_conf(cfg: dict[str, Any]) -> str:
"""Render a complete dnsmasq.conf text block from the config dict."""
dhcp_cfg = cfg.get("dhcp", {})
dns_cfg = cfg.get("dns", {})
@@ -306,11 +270,16 @@ def _parse_lease_line(line: str) -> dict[str, Any] | None:
}
def get_lease_table() -> list[dict]:
def get_lease_table() -> list[dict[str, Any]]:
"""Read and parse the current dnsmasq lease file."""
leases: list[dict] = []
leases: list[dict[str, Any]] = []
try:
result = _sudo("cat", LEASE_FILE)
result = subprocess.run(
["sudo", "cat", LEASE_FILE],
capture_output=True,
text=True,
check=True,
)
for entry in map(_parse_lease_line, result.stdout.splitlines()):
if entry is not None:
leases.append(entry)
@@ -341,7 +310,7 @@ def set_domain(domain: str | None) -> None:
# ───────── status / info ─────────────────────────────────────────────
def get_status() -> dict:
def get_status() -> dict[str, Any]:
"""Return service status, config summary, and current lease count."""
cfg = get_config()
@@ -355,7 +324,7 @@ def get_status() -> dict:
except Exception:
active = False
conf_exists = os.path.isfile(DNSMASQ_CONF)
conf_exists = Path(DNSMASQ_CONF).is_file()
if conf_exists:
try:
with open(DNSMASQ_CONF) as f:
@@ -381,3 +350,21 @@ def get_status() -> dict:
"active_leases": len(leases),
"leases": leases,
}
__all__ = [
"add_dns_record",
"add_static_lease",
"apply_config",
"generate_conf",
"get_config",
"get_lease_table",
"get_status",
"remove_dhcp_range",
"remove_dns_record",
"remove_static_lease",
"save_config",
"set_dhcp_range",
"set_domain",
"set_upstreams",
]