diff --git a/AGENTS.md b/AGENTS.md index f5a93ed..c75b7c4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,14 +9,26 @@ Deploys on Debian 13 (trixie). Serves from repo root by default. ``` Client ──→ nginx (SSL + basic auth) ──→ Flask (127.0.0.1:9090) -Flask ──→ lib/*.py ──→ sudo ──→ system service +Flask ──→ daemon/client.py (Unix socket) ──→ vacuum-walld (aiohttp, daemon.sock) +vacuum-walld ──→ daemon/handlers/*.py ──→ sudo ──→ system service ``` +### Two-User Model with Shared Group + +- **`vacuum-walld`** (daemon user): runs the privileged background daemon with `NOPASSWD sudo` whitelist (`/etc/sudoers.d/vacuum-walld`). Owns project directory and socket. +- **`vacuum-wall`** (WebUI user): runs the Flask process with **zero sudo** access. Communicates with the daemon via Unix socket. +- **`vacuum-wall`** (shared group): both users belong to this group. Socket is `vacuum-walld:vacuum-wall` with mode `0660`. Project dir is owned by `vacuum-walld:vacuum-wall` with group-read+execute. + +### Code Layout + - `webui/server.py` — Flask app entry point. **Only** file that creates the `app`. -- `webui/api/*.py` — Flask blueprints, one per subsystem. Routes prefix `/api//`. +- `webui/api/*.py` — Flask blueprints, one per subsystem. Routes prefix `/api//`. All call `daemon.client` instead of `lib/` directly. - `webui/api/common.py` — Shared `_ok()` / `_error()` response helpers used by all blueprints. +- `daemon/server.py` — aiohttp server, cache engine, batch routing, handler registry. +- `daemon/client.py` — Sync HTTP client over Unix socket using `requests_unixsocket.Session`. +- `daemon/handlers/*.py` — Privileged operation handlers (all `sudo` calls live here). - `lib/common.py` — Shared utilities: `run()`, `run_proc()`, `load_json()`, `save_json()`, `deep_merge()`, `ensure_dirs()`. All `lib/` modules use these instead of defining local helpers. -- `lib/*.py` — Backend modules. All have full type hints and `__all__` exports. +- `lib/*.py` — Backend modules (parsing, config, shared logic). All have full type hints and `__all__` exports. No sudo calls — privilege escalation is handled by `daemon/handlers/*.py`. - `data/` — Runtime artifacts (generated .confs, `.htpasswd`, ACME certs, firewall backup, dnsmasq fragments). - `config//config.json` — Declarative JSON configs (source of truth). Generated `.conf` in `data/nginx/sites-enabled/`. Certs in `data/acme/`. - `system/` — System file templates. `systemd/` (service units installed to `/etc/systemd/system/`), `sudoers.d/`, `nginx/`. @@ -45,6 +57,8 @@ All Python modules use `Path(__file__).resolve().parent.parent` for `PROJECT_DIR In production the systemd unit runs as the `vacuum-wall` system user (`NoNewPrivileges`, `ProtectSystem=strict`, loopback-only networking). +When `install.sh --dev` is used, the repo owner gets NOPASSWD sudo for system service commands (`nginx -t`, `nginx -s reload`, `firewall-cmd`, `wg`, `systemctl reload dnsmasq`, etc.). This allows invoking those commands directly in bash to inspect or test live system state during debugging, without relying on the mocked test suite. + ## Blueprint ↔ lib Mapping (Naming Is Not 1:1) | Blueprint | URL prefix | Backend module | @@ -57,12 +71,12 @@ In production the systemd unit runs as the `vacuum-wall` system user (`NoNewPriv ## Privileged Operations -`lib/` modules call `sudo` for everything that touches system services. Whitelist is `system/sudoers.d/vacuum-wall`. +`daemon/handlers/*.py` call `sudo` for everything that touches system services. Whitelist is `system/sudoers.d/vacuum-walld`. **acme.sh must never run as root** — always as the service user via `sudo -u`. Pattern for mutations: write JSON → render native config → `sudo ` to apply. -Adding a new privileged command requires a sudoers entry **and** the `lib/` code. +Adding a new privileged command requires a sudoers entry **and** the `daemon/handlers/` code. ## API Response Contract @@ -70,8 +84,6 @@ Adding a new privileged command requires a sudoers entry **and** the `lib/` code - Error: `{"ok": false, "error": "msg"}` — helper `_error(msg, code=400)` from `webui.api.common` - `acme.issue()` / `acme.renew()` raise `RuntimeError` on failure — API layer wraps in try/except - HTTP codes: `400` bad request, `404` not found, `500` internal failure -- Full spec: `docs/api.md` - ## Page Routes vs API `server.py` serves HTML pages with Jinja templates. All data is wrapped in `_safely(fn, default)` so page routes never 500 — they render with fallback values instead. @@ -89,14 +101,23 @@ Adding a new privileged command requires a sudoers entry **and** the `lib/` code ```bash .venv/bin/ruff check lib/ webui/ tests/ # lint .venv/bin/ruff format lib/ webui/ tests/ # format -.venv/bin/python -m pytest tests/ -v # test (192 tests) +.venv/bin/python -m pytest tests/ -v # test (212 tests) ``` Install dev tooling with `pip install -e ".[dev]"`. ## Docs -`docs/` contains the authoritative reference. `docs/architecture.md` covers request flow, zone model, data directory layout, and shared utility patterns in detail. +`docs/` contains the authoritative reference for each subsystem. + +| Doc | Contents | +|-----|----------| +| `docs/architecture.md` | Request flow, subsystem communication, two-user model, zone model, state management | +| `docs/security.md` | Privilege model, sudo whitelist, systemd hardening, TLS config, zone trust levels | +| `docs/deployment.md` | Install script options, what install.sh does, post-install setup, troubleshooting | +| `docs/config.md` | JSON schema for each subsystem config (dnsmasq, nginx, wireguard, cert types) | +| `docs/api.md` | REST API endpoint reference, request/response contracts, route patterns | +| `docs/overview.md` | Subsystem summaries, tech stack, complete project directory tree | ## Important Rules 1. Ask, don't assume. If something is unclear, ask before writing a single line. Never make silent assumptions about intent, architecture, or requirements. diff --git a/daemon/__init__.py b/daemon/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/daemon/__main__.py b/daemon/__main__.py new file mode 100644 index 0000000..24e9c2f --- /dev/null +++ b/daemon/__main__.py @@ -0,0 +1,3 @@ +from daemon.server import main + +main() diff --git a/daemon/client.py b/daemon/client.py new file mode 100644 index 0000000..3fe6ad3 --- /dev/null +++ b/daemon/client.py @@ -0,0 +1,138 @@ +"""Sync daemon client for the web UI. + +Communicates with vacuum-walld over a Unix socket using requests-unixsocket. +""" + +import json +import logging +from typing import Any + +import requests +import requests_unixsocket + +logger = logging.getLogger(__name__) + + +class NotFound(Exception): + """Raised when the daemon returns HTTP 404.""" + + pass + + +class BadRequest(Exception): + """Raised when the daemon returns HTTP 400.""" + + pass + + +_DEFAULT_SOCKET = None + + +def _get_socket_path() -> str: + global _DEFAULT_SOCKET + if _DEFAULT_SOCKET is None: + import os + from pathlib import Path + + project_dir = Path(__file__).resolve().parent.parent + socket_env = os.environ.get( + "VACUUM_WALLD_SOCKET", str(project_dir / "data" / "daemon.sock") + ) + _DEFAULT_SOCKET = socket_env + return _DEFAULT_SOCKET + + +def set_socket_path(path: str) -> None: + global _DEFAULT_SOCKET + _DEFAULT_SOCKET = path + + +def request( + method: str, + path: str, + json_body: dict[str, Any] | None = None, + query_params: dict[str, Any] | None = None, + socket_path: str | None = None, + timeout: float = 30, +) -> dict[str, Any]: + """Make a request to the daemon and return the parsed response body. + + For GET requests, query_params are sent as URL query parameters instead + of a JSON body. For other methods, json_body is sent as JSON. + + Raises RuntimeError on non-2xx responses or connection errors. + Raises NotFound on HTTP 404. Raises BadRequest on HTTP 400. + """ + sp = socket_path or _get_socket_path() + url = f"http://localhost{path}" + sess = requests_unixsocket.Session() + try: + kwargs: dict[str, Any] = { + "timeout": timeout, + "unix_socket": sp, + } + if method == "GET": + if query_params: + kwargs["params"] = query_params + else: + if json_body is not None: + kwargs["json"] = json_body + resp = sess.request( + method, + url, + **kwargs, + ) + resp.raise_for_status() + try: + data = resp.json() + except json.JSONDecodeError as exc: + raise RuntimeError( + f"Daemon returned non-JSON response ({resp.status_code}): {exc}" + ) from exc + except requests.ConnectionError as exc: + raise RuntimeError(f"Cannot connect to daemon at {sp}: {exc}") from exc + except requests.Timeout as exc: + raise RuntimeError(f"Daemon request timed out: {method} {path}") from exc + except requests.HTTPError as exc: + try: + data = resp.json() + except Exception: + data = {"ok": False, "error": resp.text} + if not data.get("ok"): + if resp.status_code == 404: + raise NotFound(data.get("error", str(exc))) from exc + if resp.status_code == 400: + raise BadRequest(data.get("error", str(exc))) from exc + raise RuntimeError(data.get("error", str(exc))) from exc + if not data.get("ok"): + raise RuntimeError(data.get("error", "Unknown error")) + return data.get("data") + + +def get(path: str, params: dict[str, Any] | None = None, **kwargs: Any) -> Any: + """GET request to daemon. Params are sent as URL query parameters.""" + return request("GET", path, query_params=params, **kwargs) + + +def post(path: str, body: dict[str, Any] | None = None, **kwargs: Any) -> Any: + """POST request to daemon.""" + return request("POST", path, json_body=body, **kwargs) + + +def patch(path: str, body: dict[str, Any] | None = None, **kwargs: Any) -> Any: + """PATCH request to daemon.""" + return request("PATCH", path, json_body=body, **kwargs) + + +def delete(path: str, body: dict[str, Any] | None = None, **kwargs: Any) -> Any: + """DELETE request to daemon.""" + return request("DELETE", path, json_body=body, **kwargs) + + +def batch(ops: list[dict[str, Any]], **kwargs: Any) -> dict[str, Any]: + """Batch request to daemon. + + Each op is a dict with 'id', 'method', 'path', and optionally 'body'. + Returns a dict mapping each id to its result. + """ + return post("/batch", {"ops": ops}, **kwargs) diff --git a/daemon/handlers/__init__.py b/daemon/handlers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/daemon/handlers/acme.py b/daemon/handlers/acme.py new file mode 100644 index 0000000..38bb4c3 --- /dev/null +++ b/daemon/handlers/acme.py @@ -0,0 +1,252 @@ +"""ACME certificate daemon handler.""" + +import logging +import os +import re +import shutil +import subprocess +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +from daemon.server import NotFoundError, registry + +logger = logging.getLogger(__name__) + +PROJECT_DIR = Path(__file__).resolve().parent.parent.parent +_ACME_HOME = PROJECT_DIR / "data" / "acme" +_DEPLOY_HOOK = str(PROJECT_DIR / "system" / "acme-deploy.sh") + +_ACME_ENVIRON = { + "HOME": str(PROJECT_DIR), + "PATH": os.environ.get( + "PATH", "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" + ), +} + +_WEBROOT = PROJECT_DIR / "data" / "acme" / "www" + +_ACME_TAGS = {"acme"} + + +def _find_acme() -> str: + candidates = [_ACME_HOME / "acme.sh", Path("/usr/local/bin/acme.sh")] + for path in candidates: + if path.is_file() and os.access(path, os.X_OK): + return str(path) + acme = shutil.which("acme.sh") + if acme: + return acme + raise FileNotFoundError("acme.sh not found") + + +def _run_acme(args: list[str]) -> str: + acme_bin = _find_acme() + acme_home_env = os.environ.get("ACME_HOME", str(_ACME_HOME)) + cmd = [acme_bin, "--home", acme_home_env, "--config-home", acme_home_env, *args] + try: + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=120, + env={**os.environ, **_ACME_ENVIRON}, + ) + except subprocess.TimeoutExpired as exc: + raise RuntimeError(f"acme.sh timed out: {' '.join(cmd)}") from exc + output = result.stdout + if result.stderr: + output = output + result.stderr if output else result.stderr + if result.returncode != 0: + raise RuntimeError(f"acme.sh failed (rc={result.returncode}): {output.strip()}") + return output + + +def _days_until(date_str: str) -> int | None: + if not date_str: + return None + for fmt in ("%Y-%m-%d", "%Y-%m-%dT%H:%M:%S"): + try: + dt = datetime.strptime(date_str, fmt).replace(tzinfo=UTC) + return (dt - datetime.now(UTC)).days + except ValueError: + continue + try: + dt = datetime.strptime(date_str, "%Y%m%d%H%M%z").astimezone(UTC) + return (dt - datetime.now(UTC)).days + except ValueError: + pass + return None + + +def _parse_list_output(raw: str) -> list[dict]: + entries: list[dict] = [] + for line in raw.strip().splitlines(): + line = line.strip() + if not line: + continue + entry: dict[str, str] = {} + for token in line.split(): + if ":" not in token: + continue + key, _, value = token.partition(":") + entry[key.lower()] = value + if entry: + entries.append(entry) + return entries + + +def _has_auto_renew(domain: str) -> bool: + acme_home_env = os.environ.get("ACME_HOME", str(_ACME_HOME)) + return bool(Path(acme_home_env) / f"{domain}.conf") + + +@registry.register("GET", "/acme/list", cache_tags=_ACME_TAGS) +def list_certs(_request: Any, _body: Any) -> list[dict]: + raw = _run_acme(["--list"]) + certs: list[dict] = [] + entries = _parse_list_output(raw) + acme_home_env = os.environ.get("ACME_HOME", str(_ACME_HOME)) + acme_home = Path(acme_home_env) + for entry in entries: + main = entry["main_domain"] + if not main: + continue + san_domains = [ + d.strip() for d in entry.get("san_domain", "").split(",") if d.strip() + ] + cert_dir = acme_home / main + days = _days_until(entry.get("certificate_expires", "")) + certs.append( + { + "domain": main, + "issuer": entry.get("CA", ""), + "expiry": entry.get("certificate_expires", ""), + "days_remaining": days, + "expired": days is not None and days <= 0, + "cert_path": str(cert_dir / "fullchain.cer"), + "key_path": str(cert_dir / f"{main}.key"), + "ca_path": str(cert_dir / "ca.cer"), + "issued_at": entry.get("certificate_date", ""), + "expires_at": entry.get("certificate_expires", ""), + "days_until_expiry": days, + "auto_renew": _has_auto_renew(main), + "san_domains": san_domains, + } + ) + return certs + + +@registry.register("GET", "/acme/info", cache_tags=_ACME_TAGS) +def get_cert_info(_request: Any, body: dict[str, Any] | None) -> dict: + if not body or "domain" not in body: + raise ValueError("'domain' is required") + domain = body["domain"] + certs = list_certs(None, None) + for c in certs: + if c["domain"] == domain or domain in c["san_domains"]: + return c + raise NotFoundError(f"No certificate found for domain: {domain}") + + +@registry.register("POST", "/acme/issue", invalidate=_ACME_TAGS | {"nginx"}) +def issue_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: + if not body: + raise ValueError("Request body required") + domain = body.get("domain", "").strip() + if not domain: + raise ValueError("'domain' is required") + webroot = body.get("webroot") + email = body.get("email", "").strip() or None + args: list[str] = ["--issue", "-d", domain] + args.extend(["--webroot", webroot or str(_WEBROOT)]) + contact = email + if not contact: + try: + acme_home = Path(os.environ.get("ACME_HOME", str(_ACME_HOME))) + account_conf = acme_home / "account.conf" + if account_conf.is_file(): + text = account_conf.read_text() + match = re.search(r"^ACME_LEEMAIL=(.+)$", text, re.MULTILINE) + if match: + contact = match.group(1).strip().strip("'\"") + except OSError: + pass + if contact: + args.extend(["-m", contact]) + args.append("--force") + output = _run_acme(args) + _run_acme(["--deploy", "-d", domain, "--deploy-hook", _DEPLOY_HOOK]) + logger.info("Certificate for %s issued", domain) + return {"domain": domain, "output": output.strip()} + + +@registry.register("POST", "/acme/renew", invalidate=_ACME_TAGS | {"nginx"}) +def renew_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: + if not body: + raise ValueError("Request body required") + domain = body.get("domain", "").strip() + if not domain: + raise ValueError("'domain' is required") + force = body.get("force", False) + args: list[str] = ["--renew", "-d", domain] + if force: + args.append("--force") + output = _run_acme(args) + _run_acme(["--deploy", "-d", domain, "--deploy-hook", _DEPLOY_HOOK]) + logger.info("Certificate for %s renewed", domain) + return {"domain": domain, "output": output.strip()} + + +@registry.register("DELETE", "/acme/remove", invalidate=_ACME_TAGS) +def remove_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: + if not body: + raise ValueError("Request body required") + domain = body.get("domain", "").strip() + if not domain: + raise ValueError("'domain' is required") + _run_acme(["--remove", "-d", domain]) + logger.info("Certificate for %s removed", domain) + return {"domain": domain} + + +@registry.register("POST", "/acme/email", invalidate=_ACME_TAGS) +def set_email(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: + if not body: + raise ValueError("Request body required") + email = body.get("email", "").strip() + if not email: + raise ValueError("'email' is required") + _run_acme(["--register-account", "-m", email]) + logger.info("ACME email set to %s", email) + return {"email": email} + + +@registry.register("GET", "/acme/email", cache_tags=_ACME_TAGS) +def get_email(_request: Any, _body: Any) -> dict[str, Any]: + try: + acme_home = Path(os.environ.get("ACME_HOME", str(_ACME_HOME))) + account_conf = acme_home / "account.conf" + if account_conf.is_file(): + text = account_conf.read_text() + match = re.search(r"^ACME_LEEMAIL=(.+)$", text, re.MULTILINE) + if match: + return {"email": match.group(1).strip().strip("'\"")} + except OSError: + pass + return {"email": ""} + + +@registry.register("GET", "/acme/paths", cache_tags=_ACME_TAGS) +def get_cert_paths(_request: Any, body: dict[str, Any] | None) -> dict[str, str]: + if not body or "domain" not in body: + raise ValueError("'domain' is required") + domain = body["domain"] + acme_home_env = os.environ.get("ACME_HOME", str(_ACME_HOME)) + acme_home = str(Path(acme_home_env) / domain) + return { + "cert": f"{acme_home}/{domain}.cert", + "key": f"{acme_home}/{domain}.key", + "ca": f"{acme_home}/ca.cer", + "fullchain": f"{acme_home}/fullchain.cer", + } diff --git a/daemon/handlers/dnsmasq.py b/daemon/handlers/dnsmasq.py new file mode 100644 index 0000000..f84a33e --- /dev/null +++ b/daemon/handlers/dnsmasq.py @@ -0,0 +1,364 @@ +"""Dnsmasq daemon handler.""" + +import logging +from copy import deepcopy +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +from jinja2 import Environment, FileSystemLoader + +from daemon.server import NotFoundError, registry +from lib.common import deep_merge, ensure_dirs, load_json, run, run_proc, save_json + +logger = logging.getLogger(__name__) + +PROJECT_DIR = Path(__file__).resolve().parent.parent.parent +CONFIG_DIR = PROJECT_DIR / "config" / "dnsmasq" +DATA_DIR = PROJECT_DIR / "data" / "dnsmasq" +CONFIG_PATH = CONFIG_DIR / "config.json" +FRAGMENTS_DIR = DATA_DIR / "fragments" +DNSMASQ_CONF = "/etc/dnsmasq.d/vacuum-wall.conf" +LEASE_FILE = "/var/lib/dnsmasq/dnsmasq.leases" + +ENV = Environment( + loader=FileSystemLoader(str(PROJECT_DIR / "system")), + autoescape=False, + lstrip_blocks=True, + trim_blocks=True, +) + +DEFAULT_CFG: dict[str, Any] = { + "dhcp": {"ranges": [], "static_leases": []}, + "dns": {"upstreams": ["8.8.8.8", "1.1.1.1"], "domain": None, "custom_records": []}, +} + +_DNSMASQ_TAGS = {"dnsmasq"} + + +def _get_config() -> dict[str, Any]: + 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) + + +def _save_config(cfg: dict[str, Any]) -> None: + ensure_dirs(CONFIG_DIR, DATA_DIR, FRAGMENTS_DIR) + merged = deep_merge(deepcopy(DEFAULT_CFG), cfg) + save_json(CONFIG_PATH, merged) + + +def _generate_conf(cfg: dict[str, Any]) -> str: + dhcp_cfg = cfg.get("dhcp", {}) + dns_cfg = cfg.get("dns", {}) + interfaces = [ + r["interface"] for r in dhcp_cfg.get("ranges", []) if "interface" in r + ] + tmpl = ENV.get_template("dnsmasq.conf") + return tmpl.render( + timestamp=datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"), + interfaces=interfaces, + dhcp=dhcp_cfg, + dns=dns_cfg, + fragments_dir=str(FRAGMENTS_DIR) if FRAGMENTS_DIR.exists() else None, + ) + + +def _parse_lease_line(line: str) -> dict[str, Any] | None: + line = line.strip() + if not line or line.startswith("#"): + return None + parts = line.split() + if len(parts) < 3: + return None + try: + ts = datetime.fromtimestamp(int(parts[0]), tz=UTC) + except (ValueError, OSError): + ts = None + return { + "expires_at": ts, + "mac": parts[1], + "ip": parts[2], + "hostname": parts[3] if len(parts) > 3 else "", + "interface": parts[4] if len(parts) > 4 else "", + } + + +def _get_lease_table() -> list[dict[str, Any]]: + leases: list[dict[str, Any]] = [] + try: + result = run_proc( + ["cat", LEASE_FILE], + sudo=True, + check=True, + ) + for entry in map(_parse_lease_line, result.stdout.splitlines()): + if entry is not None: + leases.append(entry) + except RuntimeError: + pass + return leases + + +@registry.register("GET", "/dnsmasq/config", cache_tags=_DNSMASQ_TAGS) +def get_config(_request: Any, _body: Any) -> dict[str, Any]: + return _get_config() + + +@registry.register("POST", "/dnsmasq/config", invalidate=_DNSMASQ_TAGS) +def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: + if not body: + raise ValueError("Request body required") + _save_config(body) + return {"config_saved": True} + + +@registry.register("PATCH", "/dnsmasq/config", invalidate=_DNSMASQ_TAGS) +def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: + if not body: + raise ValueError("Request body required") + current = _get_config() + merged = deep_merge(current, body) + _save_config(merged) + return {"config_saved": True} + + +@registry.register("POST", "/dnsmasq/apply", invalidate=_DNSMASQ_TAGS) +def apply_config(_request: Any, _body: Any) -> dict[str, Any]: + cfg = _get_config() + conf_text = _generate_conf(cfg) + ensure_dirs(CONFIG_DIR, DATA_DIR, FRAGMENTS_DIR) + run(["mkdir", "-p", "/etc/dnsmasq.d"], sudo=True) + run_proc( + ["tee", DNSMASQ_CONF, "--"], + sudo=True, + check=True, + input=conf_text, + ) + run(["systemctl", "reload", "dnsmasq"], sudo=True) + logger.info("dnsmasq config written and reloaded") + return {"applied": True} + + +@registry.register("GET", "/dnsmasq/status", cache_tags=_DNSMASQ_TAGS) +def get_status(_request: Any, _body: Any) -> dict[str, Any]: + cfg = _get_config() + try: + proc = run_proc( + ["systemctl", "is-active", "dnsmasq"], sudo=True + ) + active = proc.stdout.strip() == "active" + except Exception: + active = False + conf_exists = Path(DNSMASQ_CONF).is_file() + conf_on_disk = "" + if conf_exists: + try: + with open(DNSMASQ_CONF) as f: + conf_on_disk = f.read() + except PermissionError: + pass + expected = _generate_conf(cfg) + leases = _get_lease_table() + return { + "service_active": active, + "config_file_exists": conf_exists, + "config_in_sync": conf_on_disk == expected, + "dhcp_ranges": len(cfg["dhcp"]["ranges"]), + "static_leases": len(cfg["dhcp"]["static_leases"]), + "custom_dns_records": len(cfg["dns"]["custom_records"]), + "upstreams": cfg["dns"]["upstreams"], + "domain": cfg["dns"].get("domain"), + "active_leases": len(leases), + "leases": leases, + } + + +@registry.register("POST", "/dnsmasq/ranges/add", invalidate=_DNSMASQ_TAGS) +def set_dhcp_range(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: + if not body: + raise ValueError("Request body required") + iface = body.get("interface", "").strip() or "" + start = body.get("start", "").strip() + end = body.get("end", "").strip() + lease_time = body.get("lease_time", "12h") + if not start or not end: + raise ValueError("'start' and 'end' are required") + cfg = _get_config() + ranges = cfg["dhcp"]["ranges"] + found = False + for i, r in enumerate(ranges): + if r.get("interface") == iface: + ranges[i] = { + "interface": iface, + "start": start, + "end": end, + "lease_time": lease_time, + } + if body.get("gateway"): + ranges[i]["gateway"] = body["gateway"] + if body.get("dns"): + ranges[i]["dns"] = body["dns"] + found = True + break + if not found: + entry: dict[str, Any] = { + "interface": iface, + "start": start, + "end": end, + "lease_time": lease_time, + } + if body.get("gateway"): + entry["gateway"] = body["gateway"] + if body.get("dns"): + entry["dns"] = body["dns"] + ranges.append(entry) + _save_config(cfg) + return {"interface": iface, "start": start, "end": end} + + +@registry.register("DELETE", "/dnsmasq/ranges/remove", invalidate=_DNSMASQ_TAGS) +def remove_dhcp_range(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: + if not body: + raise ValueError("Request body required") + iface = body.get("interface", "").strip() or "" + start = body.get("start", "").strip() + end = body.get("end", "").strip() + if not start or not end: + raise ValueError("'start' and 'end' are required") + cfg = _get_config() + ranges = cfg["dhcp"]["ranges"] + before = len(ranges) + cfg["dhcp"]["ranges"] = [ + r + for r in ranges + if not ( + r.get("interface") == iface + and r.get("start") == start + and r.get("end") == end + ) + ] + if len(cfg["dhcp"]["ranges"]) == before: + raise NotFoundError( + f"DHCP range for interface '{iface}' ({start}-{end}) not found" + ) + _save_config(cfg) + return {"interface": iface, "start": start, "end": end} + + +@registry.register("GET", "/dnsmasq/leases", cache_tags=_DNSMASQ_TAGS) +def get_leases(_request: Any, _body: Any) -> list[dict[str, Any]]: + return _get_lease_table() + + +@registry.register("POST", "/dnsmasq/static-lease/add", invalidate=_DNSMASQ_TAGS) +def add_static_lease(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: + if not body: + raise ValueError("Request body required") + mac = body.get("mac", "").strip() + ip = body.get("ip", "").strip() + hostname = body.get("hostname") + if not mac or not ip: + raise ValueError("'mac' and 'ip' are required") + cfg = _get_config() + leases = cfg["dhcp"]["static_leases"] + for i, lease in enumerate(leases): + if lease["mac"].lower() == mac.lower(): + leases[i].update({"mac": mac, "ip": ip}) + if hostname is not None: + leases[i]["hostname"] = hostname + _save_config(cfg) + return {"mac": mac, "ip": ip, "hostname": hostname} + entry: dict[str, Any] = {"mac": mac, "ip": ip} + if hostname: + entry["hostname"] = hostname + leases.append(entry) + _save_config(cfg) + return {"mac": mac, "ip": ip, "hostname": hostname} + + +@registry.register("DELETE", "/dnsmasq/static-lease/remove", invalidate=_DNSMASQ_TAGS) +def remove_static_lease(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: + if not body: + raise ValueError("Request body required") + mac = body.get("mac", "").strip() + if not mac: + raise ValueError("'mac' is required") + cfg = _get_config() + leases = cfg["dhcp"]["static_leases"] + before = len(leases) + cfg["dhcp"]["static_leases"] = [ + lease for lease in leases if lease["mac"].lower() != mac.lower() + ] + if len(cfg["dhcp"]["static_leases"]) == before: + raise NotFoundError(f"Static lease for MAC '{mac}' not found") + _save_config(cfg) + return {"mac": mac} + + +@registry.register("POST", "/dnsmasq/dns-record/add", invalidate=_DNSMASQ_TAGS) +def add_dns_record(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: + if not body: + raise ValueError("Request body required") + name = body.get("name", "").strip() + address = body.get("address", "").strip() + hostname = body.get("hostname") + if not name or not address: + raise ValueError("'name' and 'address' are required") + cfg = _get_config() + records = cfg["dns"]["custom_records"] + for i, r in enumerate(records): + if r["name"] == name: + records[i].update({"name": name, "address": address}) + if hostname is not None: + records[i]["hostname"] = hostname + _save_config(cfg) + return {"name": name, "address": address, "hostname": hostname} + entry: dict[str, Any] = {"name": name, "address": address} + if hostname: + entry["hostname"] = hostname + records.append(entry) + _save_config(cfg) + return {"name": name, "address": address, "hostname": hostname} + + +@registry.register("DELETE", "/dnsmasq/dns-record/remove", invalidate=_DNSMASQ_TAGS) +def remove_dns_record(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: + if not body: + raise ValueError("Request body required") + name = body.get("name", "").strip() + if not name: + raise ValueError("'name' is required") + cfg = _get_config() + records = cfg["dns"]["custom_records"] + before = len(records) + cfg["dns"]["custom_records"] = [ + r for r in records if r["name"] != name + ] + if len(cfg["dns"]["custom_records"]) == before: + raise NotFoundError(f"DNS record '{name}' not found") + _save_config(cfg) + return {"name": name} + + +@registry.register("POST", "/dnsmasq/upstreams", invalidate=_DNSMASQ_TAGS) +def set_upstreams(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: + if not body or "servers" not in body: + raise ValueError("'servers' is required") + cfg = _get_config() + cfg["dns"]["upstreams"] = list(body["servers"]) + _save_config(cfg) + return {"upstreams": cfg["dns"]["upstreams"]} + + +@registry.register("POST", "/dnsmasq/domain", invalidate=_DNSMASQ_TAGS) +def set_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: + if not body: + raise ValueError("Request body required") + domain = body.get("domain") + cfg = _get_config() + cfg["dns"]["domain"] = domain if domain else None + _save_config(cfg) + return {"domain": cfg["dns"]["domain"]} diff --git a/daemon/handlers/firewall.py b/daemon/handlers/firewall.py new file mode 100644 index 0000000..ce6b919 --- /dev/null +++ b/daemon/handlers/firewall.py @@ -0,0 +1,793 @@ +"""Firewall daemon handler. + +Executes firewall-cmd and ip commands with sudo, returns structured results. +Parsing helpers are imported from lib.firewall. +""" + +import logging +from contextlib import suppress +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +from daemon.server import NotFoundError, registry +from lib.common import load_json, run, save_json +from lib.firewall import ( + _normalize_target, + _parse_active_zones, + _parse_zone_output, +) +from lib.firewall import ( + config_pending as _config_pending, +) +from lib.firewall import ( + get_config as _get_lib_config, +) +from lib.firewall import ( + save_backup as _save_backup, +) + +logger = logging.getLogger(__name__) + +PROJECT_DIR = Path(__file__).resolve().parent.parent.parent +DATA_DIR = PROJECT_DIR / "data" / "firewall" +RULES_FILE = DATA_DIR / "rules.json" +CONFIG_DIR = PROJECT_DIR / "config" / "firewall" +CONFIG_FILE = CONFIG_DIR / "config.json" +DEFAULT_CONFIG = {"zones": {}} + + +# --------------------------------------------------------------------------- +# Config helpers +# --------------------------------------------------------------------------- + + +def _ensure_config_file() -> None: + if not CONFIG_FILE.exists(): + CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True) + save_json(CONFIG_FILE, DEFAULT_CONFIG, indent=2) + + +def _get_config() -> dict[str, Any]: + _ensure_config_file() + return load_json(CONFIG_FILE) + + +def _save_config(cfg: dict[str, Any]) -> None: + _ensure_config_file() + save_json(CONFIG_FILE, cfg, indent=2) + + +def _reload() -> None: + run(["firewall-cmd", "--reload"], sudo=True) + + +def _now_iso() -> str: + return datetime.now(UTC).isoformat() + + +def _fp_to_str(fp: dict[str, Any]) -> str: + parts = [f"port={fp['port']}", f"proto={fp['proto']}"] + if "toaddr" in fp: + parts.append(f"toaddr={fp['toaddr']}") + if "toport" in fp: + parts.append(f"toport={fp['toport']}") + return "/".join(parts) + + +def _get_forward_ports(zone_name: str) -> list[str]: + with suppress(Exception): + fps = _parse_zone_output( + zone_name, + run(["firewall-cmd", f"--zone={zone_name}", "--list-all"], sudo=True), + ).get("forward-ports", []) + return [_fp_to_str(fp) for fp in fps if isinstance(fp, dict)] + return [] + + +def _get_state() -> dict[str, Any]: + """Return the complete current state of firewalld.""" + zone_names = run(["firewall-cmd", "--get-zones"], sudo=True).split() + active_raw = run(["firewall-cmd", "--get-active-zones"], sudo=True) + active = _parse_active_zones(active_raw) + services = run(["firewall-cmd", "--get-services"], sudo=True).split() or [] + link_out = run(["ip", "-o", "link", "show"], sudo=True) + addr_out = run(["ip", "-o", "addr", "show"], sudo=True) + + iface_map: dict[str, dict[str, Any]] = {} + for line in link_out.splitlines(): + if not line: + continue + parts = line.split() + if len(parts) < 2: + continue + raw_name = parts[1].rstrip(":") + state = "UNKNOWN" + mtu = None + mac = None + for i, p in enumerate(parts): + if p == "state" and i + 1 < len(parts): + state = parts[i + 1] + if p == "mtu" and i + 1 < len(parts): + mtu = int(parts[i + 1]) + if p.startswith("link/ether") and i + 1 < len(parts): + mac = parts[i + 1] + iface_map[raw_name] = { + "name": raw_name, + "display_name": raw_name.partition("@")[0], + "mac": mac, + "state": state, + "mtu": mtu, + "ips": [], + "ipv6": [], + "zone": None, + } + + for line in addr_out.splitlines(): + if not line: + continue + parts = line.split() + if len(parts) < 4: + continue + addr_name = parts[1] + addr_key = "ipv6" if parts[2] == "inet6" else "ips" + for entry in iface_map.values(): + if entry["display_name"] == addr_name: + entry[addr_key].append(parts[3]) + break + + for zone_name, ifaces in active.items(): + for raw_if in ifaces: + clean = raw_if.partition("@")[0] + for entry in iface_map.values(): + if entry["display_name"] == clean or entry["name"] == raw_if: + entry["zone"] = zone_name + break + + ifaces = list(iface_map.values()) + + zones: dict[str, dict[str, Any]] = {} + for zn in zone_names: + try: + zones[zn] = _parse_zone_output( + zn, run(["firewall-cmd", f"--zone={zn}", "--list-all"], sudo=True) + ) + except Exception: + continue + + return { + "active_zones": active, + "interfaces": ifaces, + "available_services": services, + "zones": zones, + "rich_rules": {n: z.get("rich-rules", []) for n, z in zones.items()}, + "timestamp": _now_iso(), + } + + +def _config_apply() -> dict[str, Any]: + """Apply the declarative config to live firewalld.""" + cfg = _get_lib_config() + cfg_zones = cfg.get("zones", {}) + + _save_backup(_get_state()) + + available = run(["firewall-cmd", "--get-zones"], sudo=True).split() + applied: list[str] = [] + for zone_name, zone_cfg in cfg_zones.items(): + need_create = zone_name not in available + + if need_create: + target = _normalize_target(zone_cfg.get("target", "DEFAULT")) + run( + [ + "firewall-cmd", + f"--zone={zone_name}", + f"--set-target={target}", + "--permanent", + ], + sudo=True, + ) + _reload() + else: + desired_target = _normalize_target(zone_cfg.get("target", "DEFAULT")) + if desired_target != "default": + with suppress(RuntimeError): + run( + [ + "firewall-cmd", + f"--zone={zone_name}", + f"--set-target={desired_target}", + "--permanent", + ], + sudo=True, + check=False, + ) + + current_svcs: list[str] = [] + with suppress(Exception): + current_svcs = _parse_zone_output( + zone_name, + run(["firewall-cmd", f"--zone={zone_name}", "--list-all"], sudo=True), + ).get("services", []) + for svc in current_svcs: + run( + [ + "firewall-cmd", + f"--zone={zone_name}", + f"--remove-service={svc}", + "--permanent", + ], + sudo=True, + check=False, + ) + for svc in zone_cfg.get("services", []): + run( + [ + "firewall-cmd", + f"--zone={zone_name}", + f"--add-service={svc}", + "--permanent", + ], + sudo=True, + ) + + current_ifaces: list[str] = [] + with suppress(Exception): + current_ifaces = _parse_zone_output( + zone_name, + run(["firewall-cmd", f"--zone={zone_name}", "--list-all"], sudo=True), + ).get("interfaces", []) + for iface in current_ifaces: + run( + [ + "firewall-cmd", + f"--zone={zone_name}", + "--remove-interface=" + iface, + "--permanent", + ], + sudo=True, + check=False, + ) + for iface in zone_cfg.get("interfaces", []): + run( + [ + "firewall-cmd", + f"--zone={zone_name}", + "--add-interface=" + iface, + "--permanent", + ], + sudo=True, + ) + + mq = zone_cfg.get("masquerade", False) + if mq is not None: + action = "--add-masquerade" if mq else "--remove-masquerade" + run( + ["firewall-cmd", f"--zone={zone_name}", action, "--permanent"], + sudo=True, + ) + + for rule_entry in zone_cfg.get("rich_rules", []): + rule_str = ( + rule_entry.get("rule", "") + if isinstance(rule_entry, dict) + else str(rule_entry) + ) + if rule_str: + run( + [ + "firewall-cmd", + f"--zone={zone_name}", + f"--add-rich-rule={rule_str}", + "--permanent", + ], + sudo=True, + check=False, + ) + + current_fps = _get_forward_ports(zone_name) + for fp_str in current_fps: + run( + [ + "firewall-cmd", + f"--zone={zone_name}", + f"--remove-forward-port={fp_str}", + "--permanent", + ], + sudo=True, + check=False, + ) + for fp_entry in zone_cfg.get("forward_ports", []): + fp_str = fp_entry if isinstance(fp_entry, str) else _fp_to_str(fp_entry) + run( + [ + "firewall-cmd", + f"--zone={zone_name}", + f"--add-forward-port={fp_str}", + "--permanent", + ], + sudo=True, + check=False, + ) + + applied.append(zone_name) + + _reload() + backup_path = _save_backup(_get_state()) + logger.info("Firewall config applied to %d zones", len(applied)) + return { + "applied_zones": applied, + "backup": backup_path, + } + + +# --------------------------------------------------------------------------- +# Routes +# --------------------------------------------------------------------------- + +_READ_TAGS = {"firewall", "interfaces", "zones"} + + +@registry.register("GET", "/firewall/interfaces", cache_tags=_READ_TAGS) +def get_interfaces(_request: Any, _body: Any) -> list[dict[str, Any]]: + link_out = run(["ip", "-o", "link", "show"], sudo=True) + addr_out = run(["ip", "-o", "addr", "show"], sudo=True) + zones_out = run(["firewall-cmd", "--get-active-zones"], sudo=True) + + iface_map: dict[str, dict[str, Any]] = {} + for line in link_out.splitlines(): + if not line: + continue + parts = line.split() + if len(parts) < 2: + continue + raw_name = parts[1].rstrip(":") + state = "UNKNOWN" + mtu = None + mac = None + for i, p in enumerate(parts): + if p == "state" and i + 1 < len(parts): + state = parts[i + 1] + if p == "mtu" and i + 1 < len(parts): + mtu = int(parts[i + 1]) + if p.startswith("link/ether") and i + 1 < len(parts): + mac = parts[i + 1] + iface_map[raw_name] = { + "name": raw_name, + "display_name": raw_name.partition("@")[0], + "mac": mac, + "state": state, + "mtu": mtu, + "ips": [], + "ipv6": [], + "zone": None, + } + + for line in addr_out.splitlines(): + if not line: + continue + parts = line.split() + if len(parts) < 4: + continue + addr_name = parts[1] + addr_key = "ipv6" if parts[2] == "inet6" else "ips" + for entry in iface_map.values(): + if entry["display_name"] == addr_name: + entry[addr_key].append(parts[3]) + break + + active = _parse_active_zones(zones_out) + for zone_name, ifaces in active.items(): + for raw_if in ifaces: + clean = raw_if.partition("@")[0] + for entry in iface_map.values(): + if entry["display_name"] == clean or entry["name"] == raw_if: + entry["zone"] = zone_name + break + + return list(iface_map.values()) + + +@registry.register("GET", "/firewall/zones", cache_tags=_READ_TAGS) +def get_zones(_request: Any, _body: Any) -> dict[str, Any]: + available = run(["firewall-cmd", "--get-zones"], sudo=True).split() + active = _parse_active_zones(run(["firewall-cmd", "--get-active-zones"], sudo=True)) + return {"active": active, "available": available} + + +@registry.register("GET", "/firewall/zones/info", cache_tags=_READ_TAGS) +def get_zone_info(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: + if not body or "zone" not in body: + raise ValueError("'zone' is required") + zone = body["zone"] + if zone not in run(["firewall-cmd", "--get-zones"], sudo=True).split(): + raise NotFoundError(f"Zone '{zone}' does not exist") + raw = run(["firewall-cmd", f"--zone={zone}", "--list-all"], sudo=True) + return _parse_zone_output(zone, raw) + + +@registry.register("GET", "/firewall/zones/all", cache_tags=_READ_TAGS) +def get_all_zones_info(_request: Any, _body: Any) -> list[dict[str, Any]]: + active = _parse_active_zones(run(["firewall-cmd", "--get-active-zones"], sudo=True)) + result: list[dict[str, Any]] = [] + for zone_name in active: + try: + raw = run(["firewall-cmd", f"--zone={zone_name}", "--list-all"], sudo=True) + result.append(_parse_zone_output(zone_name, raw)) + except Exception: + continue + return result + + +@registry.register("GET", "/firewall/services", cache_tags=_READ_TAGS) +def get_services(_request: Any, _body: Any) -> list[str]: + return run(["firewall-cmd", "--get-services"], sudo=True).split() + + +@registry.register("GET", "/firewall/config", cache_tags=_READ_TAGS) +def get_config(_request: Any, _body: Any) -> dict[str, Any]: + return _get_config() + + +@registry.register("POST", "/firewall/config", invalidate=_READ_TAGS) +def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: + if not body or "zones" not in body: + raise ValueError("'zones' key is required") + if not isinstance(body["zones"], dict): + raise ValueError("'zones' must be a dict") + _save_config(body) + logger.info("Firewall config saved (%d zones)", len(body["zones"])) + return {"config_saved": True} + + +@registry.register("PATCH", "/firewall/config", invalidate=_READ_TAGS) +def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: + if not body: + raise ValueError("Request body must be a JSON object") + from lib.common import deep_merge + + current = _get_config() + merged = deep_merge(current, body) + _save_config(merged) + logger.info("Firewall config patched: %s", sorted(body.keys())) + return {"config_saved": True} + + +@registry.register("GET", "/firewall/config/pending", cache_tags=_READ_TAGS) +def config_pending(_request: Any, _body: Any) -> dict[str, Any]: + return _config_pending(_get_state()) + + +@registry.register("POST", "/firewall/config/apply", invalidate=_READ_TAGS) +def config_apply(_request: Any, _body: Any) -> dict[str, Any]: + result = _config_apply() + logger.info("Firewall config applied: %s", result.get("applied_zones", [])) + return result + + +@registry.register("POST", "/firewall/zones/create", invalidate=_READ_TAGS) +def create_zone(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: + if not body: + raise ValueError("Request body required") + zone_name = body.get("name", "").strip() + target = body.get("target", "default").strip() or "default" + if not zone_name: + raise ValueError("Zone name is required") + available = run(["firewall-cmd", "--get-zones"], sudo=True).split() + if zone_name in available: + raise ValueError(f"Zone '{zone_name}' already exists") + run( + [ + "firewall-cmd", + f"--zone={zone_name}", + f"--set-target={target}", + "--permanent", + ], + sudo=True, + ) + _reload() + logger.info("Zone '%s' created (target=%s)", zone_name, target) + return {"zone": zone_name} + + +@registry.register("DELETE", "/firewall/zones/delete", invalidate=_READ_TAGS) +def delete_zone(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: + if not body or "zone" not in body: + raise ValueError("'zone' is required") + zone = body["zone"] + available = run(["firewall-cmd", "--get-zones"], sudo=True).split() + if zone not in available: + raise NotFoundError(f"Zone '{zone}' does not exist") + run(["firewall-cmd", f"--zone={zone}", "--delete", "--permanent"], sudo=True) + _reload() + logger.info("Zone '%s' deleted", zone) + return {"zone": zone} + + +@registry.register("POST", "/firewall/zones/interfaces", invalidate=_READ_TAGS) +def set_zone_interfaces(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: + if not body: + raise ValueError("Request body required") + zone = body.get("zone", "").strip() + interfaces = body.get("interfaces", []) + if not zone: + raise ValueError("'zone' is required") + if zone not in run(["firewall-cmd", "--get-zones"], sudo=True).split(): + raise NotFoundError(f"Zone '{zone}' does not exist") + try: + current = _parse_zone_output( + zone, run(["firewall-cmd", f"--zone={zone}", "--list-all"], sudo=True) + ).get("interfaces", []) + except Exception: + current = [] + for iface in current: + run( + [ + "firewall-cmd", + f"--zone={zone}", + "--remove-interface=" + iface, + "--permanent", + ], + sudo=True, + check=False, + ) + for iface in interfaces: + run( + [ + "firewall-cmd", + f"--zone={zone}", + "--add-interface=" + iface, + "--permanent", + ], + sudo=True, + ) + _reload() + logger.info("Zone '%s' interfaces set to %s", zone, interfaces) + return {"zone": zone, "interfaces": interfaces} + + +@registry.register("POST", "/firewall/zones/services", invalidate=_READ_TAGS) +def set_zone_services(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: + if not body: + raise ValueError("Request body required") + zone = body.get("zone", "").strip() + services = body.get("services", []) + if not zone: + raise ValueError("'zone' is required") + if zone not in run(["firewall-cmd", "--get-zones"], sudo=True).split(): + raise NotFoundError(f"Zone '{zone}' does not exist") + current = _parse_zone_output( + zone, run(["firewall-cmd", f"--zone={zone}", "--list-all"], sudo=True) + ).get("services", []) + for svc in current: + run( + [ + "firewall-cmd", + f"--zone={zone}", + f"--remove-service={svc}", + "--permanent", + ], + sudo=True, + check=False, + ) + for svc in services: + run( + [ + "firewall-cmd", + f"--zone={zone}", + f"--add-service={svc}", + "--permanent", + ], + sudo=True, + ) + _reload() + return {"zone": zone, "services": services} + + +@registry.register("POST", "/firewall/rich-rules/add", invalidate=_READ_TAGS) +def add_rich_rule(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: + if not body: + raise ValueError("Request body required") + zone = body.get("zone", "").strip() + rule = body.get("rule", "").strip() + if not zone or not rule: + raise ValueError("'zone' and 'rule' are required") + if zone not in run(["firewall-cmd", "--get-zones"], sudo=True).split(): + raise NotFoundError(f"Zone '{zone}' does not exist") + from uuid import uuid4 + + run( + [ + "firewall-cmd", + f"--zone={zone}", + "--add-rich-rule=" + rule, + "--permanent", + ], + sudo=True, + ) + _reload() + cfg = _get_config() + cfg.setdefault("zones", {}).setdefault(zone, {}).setdefault("rich_rules", []) + rule_id = uuid4().hex[:8] + entry = {"id": rule_id, "rule": rule} + cfg["zones"][zone]["rich_rules"].append(entry) + _save_config(cfg) + return {"zone": zone, "id": rule_id, "rule": rule} + + +@registry.register("DELETE", "/firewall/rich-rules/remove", invalidate=_READ_TAGS) +def remove_rich_rule(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: + if not body: + raise ValueError("Request body required") + zone = body.get("zone", "").strip() + rule_id = body.get("id", "").strip() + if not zone or not rule_id: + raise ValueError("'zone' and 'id' are required") + if zone not in run(["firewall-cmd", "--get-zones"], sudo=True).split(): + raise NotFoundError(f"Zone '{zone}' does not exist") + cfg = _get_config() + zone_cfg = cfg.get("zones", {}).get(zone, {}) + entry = None + for r in zone_cfg.get("rich_rules", []): + if r.get("id") == rule_id: + entry = r + break + if entry is None: + raise NotFoundError(f"Rich rule '{rule_id}' not found in zone '{zone}'") + rule = entry["rule"] + run( + [ + "firewall-cmd", + f"--zone={zone}", + "--remove-rich-rule=" + rule, + "--permanent", + ], + sudo=True, + ) + _reload() + zone_cfg["rich_rules"] = [ + r for r in zone_cfg.get("rich_rules", []) if r.get("id") != rule_id + ] + _save_config(cfg) + return {"zone": zone, "id": rule_id} + + +@registry.register("GET", "/firewall/rich-rules", cache_tags=_READ_TAGS) +def list_rich_rules(_request: Any, body: dict[str, Any] | None) -> list[dict[str, Any]]: + if not body or "zone" not in body: + raise ValueError("'zone' is required") + zone = body["zone"] + raw = run(["firewall-cmd", f"--zone={zone}", "--list-rich-rules"], sudo=True) + raw = raw.strip() + if not raw: + return [] + rules: list[str] = [] + current: list[str] = [] + for line in raw.splitlines(): + r = line.rstrip() + if not r.endswith(";"): + current.append(r) + else: + current.append(r) + rules.append(" ".join(current)) + current = [] + if current: + rules.append(" ".join(current)) + cfg = _get_config() + cfg_entries = cfg.get("zones", {}).get(zone, {}).get("rich_rules", []) + result: list[dict[str, Any]] = [] + for rule_str in rules: + matched = next((e for e in cfg_entries if e.get("rule") == rule_str), None) + if matched: + result.append({"id": matched["id"], "rule": rule_str}) + else: + result.append({"rule": rule_str}) + return result + + +@registry.register("POST", "/firewall/masquerade", invalidate=_READ_TAGS) +def set_masquerade(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: + if not body: + raise ValueError("Request body required") + zone = body.get("zone", "").strip() + enable = body.get("enable") + if not zone or enable is None: + raise ValueError("'zone' and 'enable' (bool) are required") + action = "--add-masquerade" if enable else "--remove-masquerade" + run(["firewall-cmd", f"--zone={zone}", action, "--permanent"], sudo=True) + _reload() + return {"zone": zone, "masquerade": bool(enable)} + + +@registry.register("POST", "/firewall/forward-port/add", invalidate=_READ_TAGS) +def add_forward_port(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: + if not body: + raise ValueError("Request body required") + zone = body.get("zone", "").strip() + port = body.get("port") + proto = body.get("proto", "").strip() + toaddr = body.get("toaddr") + toport = body.get("toport") + if not zone or port is None or not proto: + raise ValueError("'zone', 'port', and 'proto' are required") + from uuid import uuid4 + + fwd = f"port={port}/proto={proto}" + if toaddr and toport: + fwd += f"/toaddr={toaddr}/toport={toport}" + elif toport: + fwd += f"/toport={toport}" + elif toaddr: + fwd += f"/toaddr={toaddr}" + run( + [ + "firewall-cmd", + f"--zone={zone}", + f"--add-forward-port={fwd}", + "--permanent", + ], + sudo=True, + ) + _reload() + fp_id = uuid4().hex[:8] + entry: dict[str, Any] = {"id": fp_id, "port": int(port), "proto": proto} + if toaddr: + entry["toaddr"] = toaddr + if toport: + entry["toport"] = int(toport) + cfg = _get_config() + cfg.setdefault("zones", {}).setdefault(zone, {}).setdefault("forward_ports", []) + cfg["zones"][zone]["forward_ports"].append(entry) + _save_config(cfg) + return {"zone": zone, "id": fp_id, "port": int(port), "proto": proto} + + +@registry.register("DELETE", "/firewall/forward-port/remove", invalidate=_READ_TAGS) +def remove_forward_port(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: + if not body: + raise ValueError("Request body required") + zone = body.get("zone", "").strip() + port = body.get("port") + proto = body.get("proto", "").strip() + if not zone or port is None or not proto: + raise ValueError("'zone', 'port', and 'proto' are required") + if zone not in run(["firewall-cmd", "--get-zones"], sudo=True).split(): + raise NotFoundError(f"Zone '{zone}' does not exist") + fwd = f"port={port}/proto={proto}" + cfg = _get_config() + fps = cfg.get("zones", {}).get(zone, {}).get("forward_ports", []) + found = False + for fp in fps: + if fp.get("port") == port and fp.get("proto") == proto: + found = True + if fp.get("toaddr") and fp.get("toport"): + fwd += f"/toaddr={fp['toaddr']}/toport={fp['toport']}" + elif fp.get("toport"): + fwd += f"/toport={fp['toport']}" + elif fp.get("toaddr"): + fwd += f"/toaddr={fp['toaddr']}" + break + if not found: + raise NotFoundError(f"Forward port {port}/{proto} not found in zone '{zone}'") + run( + [ + "firewall-cmd", + f"--zone={zone}", + f"--remove-forward-port={fwd}", + "--permanent", + ], + sudo=True, + ) + _reload() + cfg.setdefault("zones", {}).setdefault(zone, {}) + cfg["zones"][zone]["forward_ports"] = [ + fp for fp in fps if not (fp.get("port") == port and fp.get("proto") == proto) + ] + _save_config(cfg) + return {"zone": zone, "port": int(port), "proto": proto} + + +@registry.register("GET", "/firewall/state", cache_tags=_READ_TAGS) +def get_state(_request: Any, _body: Any) -> dict[str, Any]: + return _get_state() diff --git a/daemon/handlers/logs.py b/daemon/handlers/logs.py new file mode 100644 index 0000000..c58c1ef --- /dev/null +++ b/daemon/handlers/logs.py @@ -0,0 +1,72 @@ +"""Logs daemon handler. + +Reads system logs and journal entries. +""" + +import logging +from pathlib import Path + +from daemon.server import registry +from lib.common import run_proc + +logger = logging.getLogger(__name__) + +PROJECT_DIR = Path(__file__).resolve().parent.parent.parent +APP_LOG_FILE = PROJECT_DIR / "data" / "logs" / "vacuum-wall.log" +_MAX_LINES = 200 + +_LOG_TAGS = {"logs"} + + +def _tail_file(path: str, n: int = _MAX_LINES, sudo: bool = False) -> str: + try: + if sudo: + result = run_proc(["cat", path], sudo=True) + lines = result.stdout.splitlines(keepends=True) + else: + with open(path) as f: + lines = f.readlines() + return "".join(lines[-n:]) + except FileNotFoundError: + return "(log file not found)\n" + except PermissionError: + return "(permission denied)\n" + + +def _sudo_journalctl(unit: str, n: int = _MAX_LINES) -> str: + try: + result = run_proc( + ["journalctl", "-u", unit, "--no-pager", "-n", str(n)], + sudo=True, + check=False, + timeout=10, + ) + output = result.stdout.strip() + return output if output else f"(no journal entries for {unit})\n" + except Exception as exc: + return f"(error reading journal: {exc})\n" + + +@registry.register("GET", "/logs/journal", cache_tags=_LOG_TAGS) +def journal(_request, _body) -> str: + return _sudo_journalctl("vacuum-wall") + + +@registry.register("GET", "/logs/nginx/access", cache_tags=_LOG_TAGS) +def nginx_access(_request, _body) -> str: + return _tail_file("/var/log/nginx/access.log", sudo=True) + + +@registry.register("GET", "/logs/nginx/error", cache_tags=_LOG_TAGS) +def nginx_error(_request, _body) -> str: + return _tail_file("/var/log/nginx/error.log", sudo=True) + + +@registry.register("GET", "/logs/dnsmasq", cache_tags=_LOG_TAGS) +def dnsmasq_log(_request, _body) -> str: + return _sudo_journalctl("dnsmasq") + + +@registry.register("GET", "/logs/app", cache_tags=_LOG_TAGS) +def app_log(_request, _body) -> str: + return _tail_file(str(APP_LOG_FILE)) diff --git a/daemon/handlers/nginx.py b/daemon/handlers/nginx.py new file mode 100644 index 0000000..7e8a632 --- /dev/null +++ b/daemon/handlers/nginx.py @@ -0,0 +1,382 @@ +"""Nginx daemon handler.""" + +import logging +import os +from copy import deepcopy +from pathlib import Path +from typing import Any + +from jinja2 import Environment, FileSystemLoader + +from daemon.server import NotFoundError, registry +from lib.common import ensure_dirs, load_json, run, run_proc, save_json + +logger = logging.getLogger(__name__) + +PROJECT_DIR = Path(__file__).resolve().parent.parent.parent +CONFIG_DIR = PROJECT_DIR / "config" / "nginx" +DATA_DIR = PROJECT_DIR / "data" / "nginx" +SITES_DIR = DATA_DIR / "sites-enabled" +CONFIG_FILE = CONFIG_DIR / "config.json" +INCLUDE_FILE = Path("/etc/nginx/conf.d/vacuum-wall.conf") +SSL_SNIPPET = Path("/etc/nginx/snippets/vacuum-wall-ssl.conf") +HTPASSWD_FILE = DATA_DIR / ".htpasswd" + +ENV = Environment( + loader=FileSystemLoader(str(PROJECT_DIR / "system")), + autoescape=False, + lstrip_blocks=True, + trim_blocks=True, +) + +DEFAULT_SSL: dict[str, Any] = { + "protocols": "TLSv1.2 TLSv1.3", + "ciphers": ( + "ECDHE-ECDSA-AES128-GCM-SHA256:" + "ECDHE-RSA-AES128-GCM-SHA256:" + "ECDHE-ECDSA-AES256-GCM-SHA384:" + "ECDHE-RSA-AES256-GCM-SHA384:" + "ECDHE-ECDSA-CHACHA20-POLY1305:" + "ECDHE-RSA-CHACHA20-POLY1305" + ), + "prefer_server_ciphers": False, +} + +DEFAULT_CONFIG: dict[str, Any] = { + "domains": {}, + "management": None, + "ssl": {**DEFAULT_SSL}, +} + +_NGINX_TAGS = {"nginx"} + + +def _get_config() -> dict[str, Any]: + ensure_dirs(CONFIG_DIR, SITES_DIR) + raw = load_json(CONFIG_FILE) + if not raw: + raw = deepcopy(DEFAULT_CONFIG) + if "ssl" not in raw: + raw["ssl"] = deepcopy(DEFAULT_SSL) + return raw + + +def _save_config(cfg: dict[str, Any]) -> None: + save_json(CONFIG_FILE, cfg) + + +def _generate_server_conf(domain_cfg: dict[str, Any]) -> str: + tmpl = ENV.get_template("nginx/server_block.conf") + return tmpl.render( + domain=domain_cfg["domain"], + backend=domain_cfg.get("backend", {}), + headers=domain_cfg.get("headers", {}), + force_ssl=domain_cfg.get("force_ssl", True), + cert=domain_cfg.get("cert"), + auth=domain_cfg.get("auth"), + is_management=False, + acme_home=str(PROJECT_DIR / "data" / "acme"), + certs_dir=str(PROJECT_DIR / "data" / "certs"), + acme_webroot=str(PROJECT_DIR / "data" / "acme" / "www"), + ) + + +def _write_site(domain: str, conf_text: str) -> None: + ensure_dirs(SITES_DIR) + path = SITES_DIR / f"{domain}.conf" + tmp = path.with_suffix(".tmp") + with open(tmp, "w") as f: + f.write(conf_text) + f.write("\n") + os.chmod(tmp, 0o644) + os.replace(tmp, path) + + +def _write_include_file() -> None: + tmpl = ENV.get_template("nginx/include.conf") + content = tmpl.render(sites_glob=str(SITES_DIR / "*.conf")) + tmp = INCLUDE_FILE.with_suffix(".tmp") + with open(tmp, "w") as f: + f.write(content) + os.chmod(tmp, 0o644) + run(["cp", str(tmp), str(INCLUDE_FILE)], sudo=True) + run(["chown", "root:root", str(INCLUDE_FILE)], sudo=True) + tmp.unlink(missing_ok=True) + + +def _write_ssl_snippet() -> None: + cfg = _get_config() + ssl_cfg = cfg.get("ssl", {}) + ssl_cfg.setdefault("prefer_server_ciphers", DEFAULT_SSL["prefer_server_ciphers"]) + ssl_cfg.setdefault("protocols", DEFAULT_SSL["protocols"]) + ssl_cfg.setdefault("ciphers", DEFAULT_SSL["ciphers"]) + tmpl = ENV.get_template("nginx/ssl_snippet.conf") + content = tmpl.render(ssl=ssl_cfg) + tmp = SSL_SNIPPET.with_suffix(".tmp") + with open(tmp, "w") as f: + f.write(content) + os.chmod(tmp, 0o644) + run(["cp", str(tmp), str(SSL_SNIPPET)], sudo=True) + run(["chown", "root:root", str(SSL_SNIPPET)], sudo=True) + tmp.unlink(missing_ok=True) + + +def _test_config() -> tuple[bool, str]: + result = run_proc( + ["nginx", "-t"], sudo=True, check=False + ) + ok = result.returncode == 0 + output = (result.stderr or result.stdout or "").strip() + if not output and ok: + output = "nginx configuration test passed" + return ok, output + + +def _reload_nginx() -> None: + result = run_proc( + ["nginx", "-s", "reload"], sudo=True, check=False + ) + if result.returncode != 0: + logger.error("nginx reload failed: %s", result.stderr.strip()) + else: + logger.info("nginx configuration applied and reloaded") + + +def _write_all_sites() -> None: + ensure_dirs(SITES_DIR) + cfg = _get_config() + existing = set(SITES_DIR.iterdir()) if SITES_DIR.exists() else set() + written: set[str] = set() + for name, dom in cfg.get("domains", {}).items(): + dom_copy = dict(dom, domain=name) + conf = _generate_server_conf(dom_copy) + _write_site(name, conf) + written.add(f"{name}.conf") + if cfg.get("management"): + mgmt = cfg["management"] + tmpl = ENV.get_template("nginx/server_block.conf") + mgmt_conf = tmpl.render( + domain=mgmt.get("domain"), + backend=dict( + mgmt.get("backend", {}), host="127.0.0.1", port=9090, proto="http" + ), + headers={}, + force_ssl=True, + cert=None, + auth=mgmt.get("auth"), + is_management=True, + acme_home=str(PROJECT_DIR / "data" / "acme"), + certs_dir=str(PROJECT_DIR / "data" / "certs"), + acme_webroot=str(PROJECT_DIR / "data" / "acme" / "www"), + ) + _write_site("management", mgmt_conf) + written.add("management.conf") + for old in existing: + if old.suffix == ".conf" and old.name not in written: + old.unlink() + tmpl = ENV.get_template("nginx/acme-challenge.conf") + acme_content = tmpl.render(acme_webroot=str(PROJECT_DIR / "data" / "acme" / "www")) + site = SITES_DIR / "_acme-challenge.conf" + tmp = site.with_suffix(".tmp") + with open(tmp, "w") as f: + f.write(acme_content) + f.write("\n") + os.chmod(tmp, 0o644) + os.replace(tmp, site) + + +def _write_htpasswd(user: str, password: str) -> None: + ensure_dirs(DATA_DIR) + import crypt + + salt = os.urandom(16).hex()[:16] + hashed = crypt.crypt(password, f"$5${salt}") + existing: dict[str, str] = {} + if HTPASSWD_FILE.exists(): + with open(HTPASSWD_FILE) as f: + for line in f: + line = line.strip() + if not line or line.startswith("#"): + continue + parts = line.split(":", 1) + if len(parts) == 2: + existing[parts[0]] = line + existing[user] = f"{user}:{hashed}" + tmp = HTPASSWD_FILE.with_suffix(".tmp") + with open(tmp, "w") as f: + for _uname, entry in existing.items(): + f.write(entry + "\n") + os.chmod(tmp, 0o640) + os.replace(tmp, HTPASSWD_FILE) + + +@registry.register("GET", "/nginx/config", cache_tags=_NGINX_TAGS) +def get_config(_request: Any, _body: Any) -> dict[str, Any]: + return _get_config() + + +@registry.register("POST", "/nginx/config", invalidate=_NGINX_TAGS) +def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: + if not body: + raise ValueError("Request body required") + _save_config(body) + return {"config_saved": True} + + +@registry.register("PATCH", "/nginx/config", invalidate=_NGINX_TAGS) +def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: + if not body: + raise ValueError("Request body required") + from lib.common import deep_merge + + current = _get_config() + merged = deep_merge(current, body) + _save_config(merged) + return {"config_saved": True} + + +@registry.register("GET", "/nginx/domains", cache_tags=_NGINX_TAGS) +def get_domains(_request: Any, _body: Any) -> list[dict[str, Any]]: + cfg = _get_config() + result: list[dict[str, Any]] = [] + for name, dom in cfg.get("domains", {}).items(): + site = SITES_DIR / f"{name}.conf" + result.append( + { + "domain": name, + "backend": dom.get("backend", {}), + "online": site.exists(), + "force_ssl": dom.get("force_ssl", True), + } + ) + return result + + +@registry.register("POST", "/nginx/domains/add", invalidate=_NGINX_TAGS) +def add_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: + if not body: + raise ValueError("Request body required") + domain = body.get("domain", "").strip() + backend_host = body.get("backend_host", "").strip() + backend_port = body.get("backend_port") + backend_proto = body.get("backend_proto", "http").strip() or "http" + cert = body.get("cert") + extra_headers = body.get("extra_headers") + if not domain: + raise ValueError("'domain' is required") + if not backend_host: + raise ValueError("'backend_host' is required") + if backend_port is None: + raise ValueError("'backend_port' is required") + cfg = _get_config() + if domain in cfg["domains"]: + raise ValueError(f"Domain {domain!r} already configured") + entry: dict[str, Any] = { + "backend": { + "host": backend_host, + "port": int(backend_port), + "proto": backend_proto, + }, + "force_ssl": True, + } + if cert is not None: + entry["cert"] = cert + if extra_headers is not None: + entry["headers"] = extra_headers + cfg["domains"][domain] = entry + _save_config(cfg) + return {"domain": domain} + + +@registry.register("DELETE", "/nginx/domains/remove", invalidate=_NGINX_TAGS) +def remove_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: + if not body: + raise ValueError("Request body required") + domain = body.get("domain", "").strip() + if not domain: + raise ValueError("'domain' is required") + cfg = _get_config() + if domain not in cfg["domains"]: + raise NotFoundError(f"Domain {domain!r} not found") + del cfg["domains"][domain] + _save_config(cfg) + site = SITES_DIR / f"{domain}.conf" + if site.exists(): + site.unlink() + return {"domain": domain} + + +@registry.register("POST", "/nginx/domains/update", invalidate=_NGINX_TAGS) +def update_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: + if not body: + raise ValueError("Request body required") + domain = body.get("domain", "").strip() + if not domain: + raise ValueError("'domain' is required") + cfg = _get_config() + if domain not in cfg["domains"]: + raise NotFoundError(f"Domain {domain!r} not configured") + updates = {k: v for k, v in body.items() if k != "domain"} + entry = cfg["domains"][domain] + for key, val in updates.items(): + if isinstance(val, dict) and key in entry: + entry[key].update(val) + else: + entry[key] = val + _save_config(cfg) + return {"domain": domain} + + +@registry.register("POST", "/nginx/apply", invalidate=_NGINX_TAGS) +def apply(_request: Any, _body: Any) -> dict[str, Any]: + _write_ssl_snippet() + _write_all_sites() + _write_include_file() + ok, msg = _test_config() + if not ok: + raise RuntimeError(f"nginx config test failed: {msg}") + _reload_nginx() + return {"applied": True} + + +@registry.register("POST", "/nginx/test", invalidate=_NGINX_TAGS) +def test(_request: Any, _body: Any) -> dict[str, Any]: + valid, output = _test_config() + return {"valid": valid, "output": output} + + +@registry.register("POST", "/nginx/ssl-apply", invalidate=_NGINX_TAGS) +def ssl_apply(_request: Any, _body: Any) -> dict[str, Any]: + _write_ssl_snippet() + return {"applied": True} + + +@registry.register("POST", "/nginx/management", invalidate=_NGINX_TAGS) +def set_management_proxy(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: + if not body: + raise ValueError("Request body required") + domain = body.get("domain", "").strip() + if not domain: + raise ValueError("'domain' is required") + flask_host = body.get("flask_host", "127.0.0.1").strip() or "127.0.0.1" + flask_port = body.get("flask_port", 9090) + auth_user = body.get("auth_user") + auth_pass = body.get("auth_pass") + cfg = _get_config() + entry: dict[str, Any] = { + "domain": domain, + "backend": {"host": flask_host, "port": int(flask_port), "proto": "http"}, + } + if auth_user: + entry["auth"] = {"user": auth_user, "htpasswd": str(HTPASSWD_FILE)} + cfg["management"] = entry + _save_config(cfg) + if auth_user and auth_pass: + _write_htpasswd(auth_user, auth_pass) + return {"domain": domain} + + +@registry.register("POST", "/nginx/reload") +def reload_nginx(_request: Any, _body: Any) -> dict[str, Any]: + _reload_nginx() + return {"reloaded": True} diff --git a/daemon/handlers/wireguard.py b/daemon/handlers/wireguard.py new file mode 100644 index 0000000..8f75a04 --- /dev/null +++ b/daemon/handlers/wireguard.py @@ -0,0 +1,330 @@ +"""WireGuard daemon handler.""" + +import logging +import os +from copy import deepcopy +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +from jinja2 import Environment, FileSystemLoader + +from daemon.server import NotFoundError, registry +from lib.common import deep_merge, load_json, run, run_proc, save_json + +logger = logging.getLogger(__name__) + +PROJECT_DIR = Path(__file__).resolve().parent.parent.parent +CONFIG_PATH = PROJECT_DIR / "config" / "wireguard" / "config.json" +WG_CONF_PATH = "/etc/wireguard/wg0.conf" +WG_QUICK_BIN = "wg-quick" +WG_BIN = "wg" + +ENV = Environment( + loader=FileSystemLoader(str(PROJECT_DIR / "system")), + autoescape=False, + lstrip_blocks=True, + trim_blocks=True, +) + +DEFAULT_CONFIG: dict[str, Any] = { + "interface": { + "name": "wg0", + "listen_port": 51820, + "private_key": "", + "public_key": "", + "addresses": ["10.137.0.1/24"], + "post_up": None, + "post_down": None, + }, + "peers": {}, +} + +_WG_TAGS = {"wireguard"} + + +def _get_config() -> dict[str, Any]: + return deep_merge(deepcopy(DEFAULT_CONFIG), load_json(CONFIG_PATH)) + + +def _save_config(cfg: dict[str, Any]) -> None: + save_json(CONFIG_PATH, cfg) + + +def _generate_conf(cfg: dict[str, Any]) -> str: + tmpl = ENV.get_template("wireguard.conf") + return tmpl.render( + timestamp=datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"), + interface=cfg["interface"], + peers=cfg.get("peers", {}), + ) + + +@registry.register("GET", "/wireguard/config", cache_tags=_WG_TAGS) +def get_config(_request: Any, _body: Any) -> dict[str, Any]: + cfg = _get_config() + safe = dict(cfg) + if "interface" in safe: + safe["interface"] = dict(safe["interface"]) + safe["interface"].pop("private_key", None) + return safe + + +@registry.register("POST", "/wireguard/config", invalidate=_WG_TAGS) +def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: + if not body: + raise ValueError("Request body required") + current = _get_config() + current_key = current.get("interface", {}).get("private_key", "") + if "interface" in body: + body = dict(body) + body["interface"] = dict(body["interface"]) + body["interface"].pop("private_key", None) + if current_key: + body.setdefault("interface", {})["private_key"] = current_key + _save_config(body) + return {"config_saved": True} + + +@registry.register("PATCH", "/wireguard/config", invalidate=_WG_TAGS) +def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: + if not body: + raise ValueError("Request body required") + if "interface" in body: + body = dict(body) + body["interface"] = dict(body["interface"]) + body["interface"].pop("private_key", None) + current = _get_config() + merged = deep_merge(current, body) + _save_config(merged) + return {"config_saved": True} + + +@registry.register("POST", "/wireguard/apply", invalidate=_WG_TAGS) +def apply(_request: Any, _body: Any) -> dict[str, Any]: + cfg = _get_config() + conf_text = _generate_conf(cfg) + _save_config(cfg) + local_dir = PROJECT_DIR / "data" / "wireguard" + local_dir.mkdir(parents=True, exist_ok=True) + local_tmp = local_dir / "wg0.conf.tmp" + with open(local_tmp, "w") as f: + f.write(conf_text) + os.chmod(local_tmp, 0o600) + run(["cp", "--", str(local_tmp), WG_CONF_PATH], sudo=True) + run(["chown", "root:root", WG_CONF_PATH], sudo=True, check=False) + local_tmp.unlink(missing_ok=True) + run([WG_QUICK_BIN, "up", cfg["interface"]["name"]], sudo=True) + logger.info("WireGuard tunnel '%s' brought up", cfg["interface"]["name"]) + return {"applied": True} + + +@registry.register("POST", "/wireguard/down", invalidate=_WG_TAGS) +def down(_request: Any, _body: Any) -> dict[str, Any]: + cfg = _get_config() + name = cfg["interface"]["name"] + run([WG_QUICK_BIN, "down", name], sudo=True) + logger.info("WireGuard tunnel '%s' brought down", name) + return {"down": True} + + +@registry.register("GET", "/wireguard/status", cache_tags=_WG_TAGS) +def status(_request: Any, _body: Any) -> dict[str, Any]: + cfg = _get_config() + name = cfg["interface"]["name"] + result: dict[str, Any] = {"up": False, "interface": {}, "peers": []} + try: + res = run_proc([WG_BIN, "show", name], sudo=True, check=False) + if res.returncode != 0: + return result + raw = res.stdout.strip() + except Exception: + return result + + current_peer: dict[str, Any] | None = None + peers: list[dict[str, Any]] = [] + for line in raw.splitlines(): + line = line.strip() + if not line: + continue + if line.startswith("interface:"): + result["up"] = True + result["interface"] = {} + current_peer = None + continue + if line.startswith("public key:"): + result["interface"]["public_key"] = line.split(":", 1)[1].strip() + continue + if line.startswith("listening port:"): + result["interface"]["listen_port"] = int(line.split(":", 1)[1].strip()) + continue + if line.startswith("fwmark:"): + result["interface"]["fwmark"] = line.split(":", 1)[1].strip() + continue + if line.startswith("peer:"): + cur_key = line.split(":", 1)[1].strip() + current_peer = { + "public_key": cur_key, + "endpoint": None, + "allowed_ips": [], + "latest_handshake": None, + "transfer_received": 0, + "transfer_sent": 0, + "persistent_keepalive": None, + } + peers.append(current_peer) + continue + if current_peer is None: + continue + if line.startswith("endpoint:"): + current_peer["endpoint"] = line.split(":", 1)[1].strip() + elif line.startswith("allowed ips:"): + current_peer["allowed_ips"] = line.split(":", 1)[1].strip().split(", ") + elif line.startswith("latest handshake:"): + current_peer["latest_handshake"] = line.split(":", 1)[1].strip() + elif line.startswith("transfer:"): + rest = line.split(":", 1)[1].strip().split(", ") + if rest: + current_peer["transfer_received"] = rest[0].strip() + if len(rest) > 1: + current_peer["transfer_sent"] = rest[1].strip() + elif line.startswith("persistent-keepalive:"): + try: + current_peer["persistent_keepalive"] = int( + line.split(":", 1)[1].strip() + ) + except ValueError: + current_peer["persistent_keepalive"] = None + result["peers"] = peers + return result + + +@registry.register("POST", "/wireguard/initialize", invalidate=_WG_TAGS) +def initialize(_request: Any, _body: Any) -> dict[str, Any]: + cfg = _get_config() + if cfg["interface"].get("private_key"): + return {"initialized": False, "reason": "already initialized"} + res = run_proc([WG_BIN, "genkey"], sudo=True) + private_key = res.stdout.strip() + res2 = run_proc([WG_BIN, "pubkey"], sudo=True, input=private_key) + public_key = res2.stdout.strip() + cfg["interface"]["private_key"] = private_key + cfg["interface"]["public_key"] = public_key + _save_config(cfg) + logger.info("WireGuard initialised (pubkey=%s...)", public_key[:16]) + safe = dict(cfg) + safe["interface"] = dict(safe["interface"]) + safe["interface"].pop("private_key", None) + return {"initialized": True, "config": safe} + + +@registry.register("POST", "/wireguard/peers/add", invalidate=_WG_TAGS) +def add_peer(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: + if not body: + raise ValueError("Request body required") + name = body.get("name", "").strip() + if not name: + raise ValueError("'name' is required") + cfg = _get_config() + peers = cfg.setdefault("peers", {}) + allowed_ips = body.get("allowed_ips", []) + if name in peers: + peer = peers[name] + peer["endpoint"] = body.get("endpoint") + peer["allowed_ips"] = allowed_ips + peer["persistent_keepalive"] = body.get("persistent_keepalive") + if body.get("preshared_key") is not None: + peer["preshared_key"] = body["preshared_key"] + logger.info("WireGuard peer '%s' updated", name) + else: + res = run_proc([WG_BIN, "genkey"], sudo=True) + priv = res.stdout.strip() + res2 = run_proc([WG_BIN, "pubkey"], sudo=True, input=priv) + pub = res2.stdout.strip() + peers[name] = { + "public_key": pub, + "private_key": priv, + "endpoint": body.get("endpoint"), + "allowed_ips": allowed_ips, + "persistent_keepalive": body.get("persistent_keepalive"), + "preshared_key": body.get("preshared_key"), + } + logger.info("WireGuard peer '%s' added", name) + _save_config(cfg) + peer_out = dict(peers[name]) + peer_out.pop("private_key", None) + return peer_out + + +@registry.register("DELETE", "/wireguard/peers/remove", invalidate=_WG_TAGS) +def remove_peer(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: + if not body: + raise ValueError("Request body required") + name = body.get("name", "").strip() + if not name: + raise ValueError("'name' is required") + cfg = _get_config() + peers = cfg.setdefault("peers", {}) + if name not in peers: + raise NotFoundError(f"Peer '{name}' not found") + del peers[name] + _save_config(cfg) + logger.info("WireGuard peer '%s' removed", name) + return {"name": name} + + +@registry.register("GET", "/wireguard/peers", cache_tags=_WG_TAGS) +def list_peers(_request: Any, _body: Any) -> list[dict[str, Any]]: + cfg = _get_config() + result: list[dict[str, Any]] = [] + for name, info in cfg.get("peers", {}).items(): + entry = dict(info) + entry["name"] = name + entry.pop("private_key", None) + result.append(entry) + return result + + +@registry.register("GET", "/wireguard/peer-status", cache_tags=_WG_TAGS) +def get_peer_status(_request: Any, _body: Any) -> list[dict[str, Any]]: + st = status(None, None) + return st.get("peers", []) + + +@registry.register("POST", "/wireguard/generate-client") +def generate_client_conf(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: + if not body: + raise ValueError("Request body required") + name = body.get("name", "").strip() + if not name: + raise ValueError("'name' is required") + server_endpoint = body.get("server_endpoint", "") + if not server_endpoint: + raise ValueError("'server_endpoint' is required") + cfg = _get_config() + if name not in cfg.get("peers", {}): + raise NotFoundError(f"Peer '{name}' not found") + peer = cfg["peers"][name] + client_priv = peer.get("private_key", "") + if not client_priv: + raise NotFoundError(f"Peer '{name}' has no private key") + iface = cfg["interface"] + sorted_peers = sorted(cfg.get("peers", {}).keys()) + peer_index = sorted_peers.index(name) + 2 + srv_addr = iface["addresses"][0] if iface["addresses"] else "10.137.0.1/24" + addr_part, prefix = srv_addr.rsplit("/", 1) + prefix_base = addr_part.rsplit(".", 1)[0] + client_addr = f"{prefix_base}.{peer_index}/{prefix}" + tmpl = ENV.get_template("wireguard-client.conf") + conf = tmpl.render( + timestamp=datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"), + peer_name=name, + client_priv=client_priv, + client_addr=client_addr, + server_pubkey=iface.get("public_key", ""), + server_endpoint=server_endpoint, + allowed_ips=peer.get("allowed_ips", ["0.0.0.0/0"]), + preshared_key=peer.get("preshared_key"), + persistent_keepalive=peer.get("persistent_keepalive"), + ) + return {"config": conf} diff --git a/daemon/server.py b/daemon/server.py new file mode 100644 index 0000000..bcc61e8 --- /dev/null +++ b/daemon/server.py @@ -0,0 +1,321 @@ +"""aiohttp server for vacuum-walld. + +Listens on a Unix socket, serves the daemon API to the web UI. +Handles routing, caching, batching, and request/response lifecycle. +""" + +import asyncio +import json +import logging +import os +import signal +from collections.abc import Callable +from pathlib import Path +from typing import Any + +from aiohttp import web + +logger = logging.getLogger(__name__) + +PROJECT_DIR = Path(__file__).resolve().parent.parent +SOCKET_PATH = PROJECT_DIR / "data" / "daemon.sock" + + +class Cache: + """Tag-based cache. Entries persist until invalidated by write operations. + + External changes to system state (e.g., manual firewall-cmd, config edits on + disk) bypass cache invalidation and will result in stale data until the cache + is cleared or affected tags are invalidated. + """ + + def __init__(self) -> None: + self._store: dict[str, Any] = {} + self._tags: dict[str, set[str]] = {} + + def get(self, key: str) -> Any | None: + return self._store.get(key) + + def set(self, key: str, value: Any, tags: set[str]) -> None: + self._store[key] = value + self._tags[key] = tags + + def invalidate(self, *tags: str) -> None: + for tag in tags: + keys = [k for k, ts in self._tags.items() if tag in ts] + for k in keys: + self._store.pop(k, None) + self._tags.pop(k, None) + + def clear(self) -> None: + self._store.clear() + self._tags.clear() + + +cache = Cache() + + +class Handler: + """Wrapper for a daemon handler function.""" + + def __init__( + self, + method: str, + path: str, + cache_tags: set[str] | None = None, + invalidate: set[str] | None = None, + ) -> None: + self.method = method.upper() + self.path = path + self.cache_tags = cache_tags or set() + self.invalidate = invalidate or set() + + +class Registry: + """Route registry for daemon handlers.""" + + def __init__(self) -> None: + self._routes: dict[tuple[str, str], Callable] = {} + + def register( + self, + method: str, + path: str, + cache_tags: set[str] | None = None, + invalidate: set[str] | None = None, + ): + def decorator(fn: Callable) -> Callable: + self._routes[(method.upper(), path)] = fn + fn._handler = Handler(method, path, cache_tags, invalidate) # type: ignore[attr-defined] + return fn + + return decorator + + def get(self, method: str, path: str) -> Callable | None: + return self._routes.get((method.upper(), path)) + + +registry = Registry() + + +class NotFoundError(Exception): + """Raised when a requested resource is not found.""" + + pass + + +def ok(data: Any = None) -> web.Response: + return web.json_response({"ok": True, "data": data}) + + +def error(msg: str, code: int = 400) -> web.Response: + return web.json_response({"ok": False, "error": msg}, status=code) + + +async def _handle_request(request: web.Request) -> web.Response: + """Dispatch a request to the appropriate handler.""" + handler_fn = registry.get(request.method, request.path) + if handler_fn is None: + return error(f"Method {request.method} not allowed for {request.path}", 404) + + h = getattr(handler_fn, "_handler", None) + + # Build body from JSON and merge query params. GET requests send params + # as URL query string, so they need to be treated as body for handlers. + body: dict[str, Any] | None = None + if request.content_type == "application/json": + try: + body = await request.json() + except json.JSONDecodeError: + return error("Invalid JSON body", 400) + + query_dict = dict(request.query) + if query_dict: + query_body = {k: v[0] if len(v) == 1 else v for k, v in query_dict.items()} + if body is not None: + merged = {**query_body, **body} + body = merged + else: + body = query_body + + cache_key = json.dumps( + { + "method": request.method, + "path": request.path, + "query": query_dict, + "body": body, + }, + sort_keys=True, + ) + + # Cache hit for read operations + if h and h.cache_tags: + cached = cache.get(cache_key) + if cached is not None: + return ok(cached) + + try: + if body is not None: + result = handler_fn(request, body) + if asyncio.iscoroutine(result): + result = await result + else: + result = handler_fn(request, None) + if asyncio.iscoroutine(result): + result = await result + except NotFoundError as exc: + return error(str(exc), 404) + except ValueError as exc: + return error(str(exc), 400) + except RuntimeError as exc: + logger.error("Handler error: %s", exc) + return error(str(exc), 500) + except Exception as exc: + logger.exception( + "Unexpected handler error in %s %s", request.method, request.path + ) + return error(f"Internal error: {exc}", 500) + + # Cache write for read operations + if h and h.cache_tags and isinstance(result, dict) and result.get("ok"): + cache.set(cache_key, result.get("data"), h.cache_tags) + + # Invalidate on write operations + if h and h.invalidate: + cache.invalidate(*h.invalidate) + + # Convert result to response if not already + if isinstance(result, web.Response): + return result + if isinstance(result, dict) and result.get("ok") is False: + return error(result["error"], result.get("code", 400)) + return ok(result) + + +async def _handle_batch(request: web.Request) -> web.Response: + """Handle batch requests: execute operations in order, return keyed results.""" + try: + body = await request.json() + except json.JSONDecodeError: + return error("Invalid JSON body", 400) + + ops = body.get("ops", []) + if not isinstance(ops, list): + return error("'ops' must be a list", 400) + + results: dict[str, Any] = {} + for op in ops: + op_id = op.get("id") + method = op.get("method", "GET").upper() + path = op.get("path", "") + + if not op_id or not path: + results[op_id] = {"ok": False, "error": "'id' and 'path' are required"} + continue + + handler_fn = registry.get(method, path) + if handler_fn is None: + results[op_id] = { + "ok": False, + "error": f"Endpoint not found: {method} {path}", + } + continue + + op_body = op.get("body") + h = getattr(handler_fn, "_handler", None) + + try: + result = handler_fn(None, op_body) + if asyncio.iscoroutine(result): + result = await result + except Exception as exc: + logger.error("Batch handler error for %s: %s", op_id, exc) + results[op_id] = {"ok": False, "error": str(exc)} + continue + + # Strip ok/data wrapper for batch results + if isinstance(result, dict) and result.get("ok") is not None: + results[op_id] = result + else: + results[op_id] = {"ok": True, "data": result} + + # Invalidate on write + if h and h.invalidate: + cache.invalidate(*h.invalidate) + + return ok(results) + + +def create_app() -> web.Application: + app = web.Application() + app.router.add_route("GET", "/health", _health) + app.router.add_route("POST", "/batch", _handle_batch) + app.router.add_route("{tail:.*}", _catch_all) + return app + + +async def _health(_request: web.Request) -> web.Response: + return ok({"pid": os.getpid(), "socket": str(SOCKET_PATH)}) + + +async def _catch_all(request: web.Request) -> web.Response: + """Catch-all for registered routes.""" + return await _handle_request(request) + + +def _register_routes() -> None: + """Import all handler modules to register routes.""" + from daemon.handlers import ( + acme, # noqa: F401 + dnsmasq, # noqa: F401 + firewall, # noqa: F401 + logs, # noqa: F401 + nginx, # noqa: F401 + wireguard, # noqa: F401 + ) + + +def main() -> None: + """Entry point for vacuum-walld.""" + from lib.logging import setup_logging + + setup_logging() + + _register_routes() + app = create_app() + + socket_path = os.environ.get("VACUUM_WALLD_SOCKET", str(SOCKET_PATH)) + socket_dir = Path(socket_path).parent + socket_dir.mkdir(parents=True, exist_ok=True) + + if Path(socket_path).exists(): + os.unlink(socket_path) + + loop = asyncio.new_event_loop() + + def _on_shutdown(_sig: int) -> None: + logger.info("Shutting down daemon...") + loop.stop() + + for sig in (signal.SIGTERM, signal.SIGINT): + loop.add_signal_handler(sig, _on_shutdown, sig) + + runner = web.AppRunner(app) + loop.run_until_complete(runner.setup()) + site = web.UnixSite(runner, socket_path) + loop.run_until_complete(site.start()) + + os.chmod(socket_path, 0o660) + logger.info("vacuum-walld listening on %s", socket_path) + + try: + loop.run_forever() + finally: + loop.run_until_complete(runner.cleanup()) + if Path(socket_path).exists(): + os.unlink(socket_path) + logger.info("vacuum-walld stopped") + + +if __name__ == "__main__": + main() diff --git a/docs/api.md b/docs/api.md index 4980b36..3a0bdc1 100644 --- a/docs/api.md +++ b/docs/api.md @@ -174,6 +174,8 @@ Create a new firewalld zone. **Response:** `data` is `null` on success. +Returns HTTP `400` if the zone already exists. + --- #### Delete Zone @@ -637,6 +639,74 @@ Returns HTTP `404` if no matching record is found. Endpoints prefixed with `/api/proxy/...`. Manage reverse proxy domains, nginx configuration generation, and the management WebUI proxy. +### Configuration + +#### Get Proxy Configuration + +``` +GET /api/proxy/config +``` + +Return the current proxy configuration object. + +**Response:** + +| Field | Type | Description | +|-------|------|-------------| +| `data` | `object` | Full proxy configuration dictionary | + +--- + +#### Replace Proxy Configuration + +``` +POST /api/proxy/config +``` + +Replace the entire proxy configuration with the provided JSON object. + +**Request Body:** + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| *(entire body)* | `object` | Yes | Complete proxy configuration object | + +**Response:** `data` is `null` on success. + +--- + +#### Partial Update Proxy Configuration + +``` +PATCH /api/proxy/config +``` + +Deep-merge the provided fields into the existing proxy configuration. + +**Request Body:** + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| *(any subset)* | `any` | Yes | Fields to merge into the config | + +**Response:** `data` is `null` on success. + +--- + +### SSL + +#### Apply SSL Snippet + +``` +POST /api/proxy/ssl-apply +``` + +Write the global nginx SSL snippet configuration. + +**Response:** `data` is `null` on success. + +--- + ### Domain Management #### List All Domains @@ -842,6 +912,7 @@ Request a new certificate for a domain. | Field | Type | Required | Description | |-------|------|----------|-------------| | `domain` | `string` | Yes | Domain to issue the certificate for | +| `email` | `string` | No | ACME contact email | | `webroot` | `string` | No | Custom webroot path for HTTP-01 validation | **Response:** `data` is `null` on success. @@ -932,7 +1003,7 @@ Replace the entire WireGuard configuration. The `private_key` field is stripped |-------|------|----------|-------------| | *(entire body)* | `object` | Yes | Complete WireGuard configuration object | -**Response:** `data` contains the updated configuration (`private_key` omitted). +**Response:** `data` is `null` on success. --- @@ -1115,4 +1186,70 @@ Generate a complete WireGuard client configuration file. The returned config inc This is the only endpoint that returns a WireGuard private key. All other endpoints strip private keys from responses. -Returns HTTP `404` if the peer is not found. \ No newline at end of file +Returns HTTP `404` if the peer is not found. + +--- + +## Logs API + +Endpoints prefixed with `/api/logs/...`. Serve rendered HTML log line fragments for HTMX consumption. These endpoints **do not** follow the standard JSON `{"ok": true, "data": ...}` response contract — they return HTML `
` elements directly. Errors are rendered inline as `(error reading ...)` text rather than returning JSON error responses. + +### System Journal + +#### Get Journal Entries + +``` +GET /api/logs/journal +``` + +Return recent system journal entries as rendered HTML log lines. + +**Response:** HTML fragment of `
` elements. + +### Nginx Logs + +#### Nginx Access Log + +``` +GET /api/logs/nginx/access +``` + +Return recent nginx access log entries as rendered HTML. + +**Response:** HTML fragment of `
` elements. + +--- + +#### Nginx Error Log + +``` +GET /api/logs/nginx/error +``` + +Return recent nginx error log entries as rendered HTML. + +**Response:** HTML fragment of `
` elements. + +### Dnsmasq Log + +#### Dnsmasq Entries + +``` +GET /api/logs/dnsmasq +``` + +Return recent dnsmasq journal entries as rendered HTML. + +**Response:** HTML fragment of `
` elements. + +### Application Log + +#### App Log Entries + +``` +GET /api/logs/app +``` + +Return recent application log entries as rendered HTML. + +**Response:** HTML fragment of `
` elements. \ No newline at end of file diff --git a/docs/architecture.md b/docs/architecture.md index 6322050..3d67712 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -22,8 +22,9 @@ For HTTP requests (port 80), nginx returns a 301 redirect to the HTTPS equivalen 1. A client sends an HTTPS request to the management domain. 2. nginx terminates TLS and checks for HTTP Basic Authentication credentials against the `.htpasswd` file. 3. If authentication succeeds, the request is proxied to `127.0.0.1:9090` where the Flask WebUI is listening. -4. The Flask application processes the request, performs any necessary privileged operations through the sudo whitelist, and returns an HTML or JSON response. -5. nginx returns the response to the client over the encrypted connection. +4. The Flask application processes the request and communicates with the `vacuum-walld` daemon via a Unix socket (`data/daemon.sock`) for any privileged operations. +5. The daemon executes the privileged commands via the sudo whitelist and returns structured results. +6. Flask renders an HTML or JSON response, which nginx returns to the client over the encrypted connection. Because Flask binds only to `127.0.0.1`, it is unreachable directly from any external interface. The nginx reverse proxy is the sole entry point. @@ -33,14 +34,24 @@ The following diagram summarizes how the Flask WebUI communicates with each mana ``` External Client ──→ nginx (SSL termination) ──→ Flask WebUI (127.0.0.1:9090) -Flask WebUI ──→ lib/firewall.py ──→ sudo firewall-cmd ──→ firewalld / D-Bus ──→ nftables -Flask WebUI ──→ lib/nginx.py ──→ write local .conf files ──→ sudo cp to /etc/nginx/ ──→ sudo nginx -t && sudo nginx -s reload -Flask WebUI ──→ lib/dnsmasq.py ──→ render config ──→ sudo tee /etc/dnsmasq.d/vacuum-wall.conf ──→ sudo systemctl reload dnsmasq -Flask WebUI ──→ lib/acme.py ──→ acme.sh (no sudo, runs as service user) ──→ ZeroSSL ACME -Flask WebUI ──→ lib/wireguard.py ──→ render data/wireguard/wg0.conf ──→ sudo cp to /etc/wireguard/ ──→ sudo wg-quick up wg0 +Flask WebUI ──→ daemon/client.py (Unix socket) ──→ vacuum-walld (aiohttp server) +vacuum-walld ──→ daemon/handlers/firewall.py ──→ sudo firewall-cmd ──→ firewalld / D-Bus ──→ nftables +vacuum-walld ──→ daemon/handlers/nginx.py ──→ write local .conf files ──→ sudo cp to /etc/nginx/ ──→ sudo nginx -t && sudo nginx -s reload +vacuum-walld ──→ daemon/handlers/dnsmasq.py ──→ render config ──→ sudo tee /etc/dnsmasq.d/vacuum-wall.conf ──→ sudo systemctl reload dnsmasq +vacuum-walld ──→ daemon/handlers/acme.py ──→ acme.sh (subprocess) ──→ deploy hook (daemon API) ──→ ZeroSSL ACME +vacuum-walld ──→ daemon/handlers/wireguard.py ──→ render data/wireguard/wg0.conf ──→ sudo cp to /etc/wireguard/ ──→ sudo wg-quick up wg0 +vacuum-walld ──→ daemon/handlers/logs.py ──→ sudo journalctl ──→ systemd journal ``` -Each `lib/` module encapsulates command construction, privilege escalation (via sudo where needed), and error handling for its subsystem. The modules read declarative configuration from `config/` and runtime artifacts from `data/`, render the appropriate system configuration files, and invoke the corresponding privileged operation. Note that `lib/acme.py` runs `acme.sh` without sudo — it executes as the unprivileged service user using webroot validation rather than standalone/TLS-ALPN modes that would require elevated privileges. +### Two-User Model with Shared Group + +Vacuum Wall uses two distinct system users bridged by a shared group: + +- **`vacuum-walld`** (daemon user): Runs the privileged background daemon. Holds the NOPASSWD sudo whitelist for all system-level commands. Owns the project directory and data files. Runs with `NoNewPrivileges=yes` (satisfiable since sudo is called directly by the daemon process). +- **`vacuum-wall`** (web UI user): Runs the Flask web serving process. Has **zero** sudo access. Communicates with the daemon via a Unix socket at `data/daemon.sock`. Runs with `NoNewPrivileges=yes`. +- **`vacuum-wall`** (shared group): Both users belong to this group. The daemon socket is owned by `vacuum-walld:vacuum-wall` with mode `0660`, allowing the web UI user to connect via group permission. The project directory is owned by `vacuum-walld:vacuum-wall` with group-read+execute, giving the web UI user read access to configs and shared files. + +This design isolates privilege escalation entirely within the daemon, so a compromised Flask process cannot invoke sudo directly. The `lib/` modules no longer contain sudo calls; all privileged command execution lives in `daemon/handlers/*.py`. The `lib/` modules auto-discover the project root at runtime via `Path(__file__).resolve().parent.parent`. This works because `install.sh` performs an editable pip install (`pip install -e .`), keeping module files in the project directory rather than copying them to `site-packages/`. @@ -48,8 +59,9 @@ The `lib/` modules auto-discover the project root at runtime via `Path(__file__) System configuration files in `system/` are Jinja2 templates rendered by `install.sh` at install time: -- **`systemd/vacuum-wall.service`**, **`systemd/vacuum-wall-acme.service`** — `{{ USER_NAME }}`, `{{ PROJECT_DIR }}`, `{{ ACME_HOME }}` are substituted to produce the final systemd unit files installed to `/etc/systemd/system/`. The `PROJECT_DIR` template variable is set from the `INSTALL_DIR` environment variable (defaults to the repo root). -- **`sudoers.d/vacuum-wall`** — `{{ USER_NAME }}` is substituted to produce the sudoers whitelist. +- **`systemd/vacuum-wall.service`**, **`systemd/vacuum-walld.service`**, **`systemd/vacuum-wall-acme.service`** — `{{ USER_NAME }}`, `{{ USER_DAEMON_NAME }}`, `{{ USER_GROUP }}`, `{{ PROJECT_DIR }}`, `{{ ACME_HOME }}` are substituted to produce the final systemd unit files installed to `/etc/systemd/system/`. The `PROJECT_DIR` template variable is set from the `INSTALL_DIR` environment variable (defaults to the repo root). +- **`sudoers.d/vacuum-walld`** — `{{ USER_DAEMON_NAME }}` is substituted to produce the sudoers whitelist for the daemon user. +- **`sudoers.d/vacuum-wall`** — Reserved for the WebUI user; currently contains no sudo rules (privilege escalation is handled entirely by the daemon). - The timer file (`vacuum-wall-acme.timer`) contains no variable paths and is installed as-is. Runtime templates (`system/nginx/*.conf`, `system/dnsmasq.conf`, `system/wireguard*.conf`) are rendered at runtime by `lib/` modules via Jinja2 with Python data. @@ -60,7 +72,7 @@ Vacuum Wall uses a declarative configuration model. Persistent user-facing confi | Subsystem | Declarative Config | Runtime Data | Rendered Target | State Persistence | |---|---|---|---|---| -| firewalld | N/A (firewalld manages own state) | `data/firewall/rules.json` | N/A (commands issued directly to firewalld via D-Bus) | firewalld manages its own persistent state in `/etc/firewalld/`. `rules.json` serves as an automated backup snapshot. | +| firewalld | `config/firewall/config.json` | `data/firewall/rules.json` | N/A (commands issued directly to firewalld via D-Bus) | firewalld manages its own persistent state in `/etc/firewalld/`. `config.json` is the declarative source of truth. `rules.json` serves as an automated backup snapshot. | | dnsmasq | `config/dnsmasq/config.json` | `data/dnsmasq/fragments/` | `/etc/dnsmasq.d/vacuum-wall.conf` | The JSON file is the source of truth. The rendered `.conf` file is overwritten on each apply. | | nginx | `config/nginx/config.json` | `data/nginx/.htpasswd`, `data/nginx/sites-enabled/` | `data/nginx/sites-enabled/.conf` + `/etc/nginx/conf.d/vacuum-wall.conf` | All proxy and management domain definitions are derived from the JSON config. Generated `.conf` files are overwritten on each apply. | | WireGuard | `config/wireguard/config.json` | `data/wireguard/` | `/etc/wireguard/wg0.conf` | The JSON file defines the interface and all peers. The rendered WireGuard config is overwritten on each apply. | @@ -76,6 +88,8 @@ Config files are persistent, user-editable JSON that defines the desired state f config/ ├── dnsmasq/ │ └── config.json # DHCP ranges, static leases, DNS forwarding, custom records +├── firewall/ +│ └── config.json # Firewall zones, rich rules, forward ports ├── nginx/ │ └── config.json # Proxy domain definitions, management domain, SSL settings └── wireguard/ @@ -96,6 +110,8 @@ data/ ├── firewall/ │ └── rules.json # Auto-generated firewall rule state backup ├── acme/ # ACME certificate files (acme.sh home) +├── logs/ +│ └── vacuum-wall.log # Application log file └── wireguard/ # WireGuard runtime artifacts ``` diff --git a/docs/config.md b/docs/config.md index 29cde4e..c6713bc 100644 --- a/docs/config.md +++ b/docs/config.md @@ -47,14 +47,14 @@ This file defines all DHCP server settings and DNS resolution behavior for the d | Field | Type | Required | Description | |---|---|---|---| -| `ranges` | array | Yes | One or more DHCP address pools. Each range defines a subnet from which addresses are leased. | +| `ranges` | array | No | One or more DHCP address pools. Each range defines a subnet from which addresses are leased. Default: `[]`. | | `ranges[].interface` | string | Yes | Network interface on which to serve this DHCP range (e.g., `eth1`). | | `ranges[].start` | string | Yes | First IP address in the pool. | | `ranges[].end` | string | Yes | Last IP address in the pool. | -| `ranges[].lease_time` | string | No | DHCP lease duration. Accepts values like `12h`, `1d`, `30m`. Default: `1h`. | +| `ranges[].lease_time` | string | No | DHCP lease duration. Accepts values like `12h`, `1d`, `30m`. Default: `12h`. | | `ranges[].gateway` | string | No | Default gateway advertised to DHCP clients. Typically the router's LAN IP. | | `ranges[].dns` | string | No | DNS server address advertised to DHCP clients. Typically the Vacuum Wall host's LAN IP. | -| `static_leases` | array | No | Fixed IP assignments tied to MAC addresses. Clients with matching MACs always receive the specified IP. | +| `static_leases` | array | No | Fixed IP assignments tied to MAC addresses. Clients with matching MACs always receive the specified IP. Default: `[]`. | | `static_leases[].mac` | string | Yes | MAC address of the client (colon-separated lowercase hex). | | `static_leases[].ip` | string | Yes | The IP address to assign to this MAC. Must be outside the dynamic pool ranges. | | `static_leases[].hostname` | string | No | Hostname to associate with the lease. Used for reverse DNS and mDNS. | @@ -63,9 +63,9 @@ This file defines all DHCP server settings and DNS resolution behavior for the d | Field | Type | Required | Description | |---|---|---|---| -| `upstreams` | array | Yes | Upstream DNS servers to forward unresolved queries to. Supports IPv4 and IPv6 addresses. | -| `domain` | string | Yes | Local domain suffix. Hostnames without a FQDN are resolved within this domain (e.g., `printer` becomes `printer.lan`). | -| `custom_records` | array | No | Static DNS A records for internal services and devices. | +| `upstreams` | array | Yes | Upstream DNS servers to forward unresolved queries to. Supports IPv4 and IPv6 addresses. Default: `["8.8.8.8", "1.1.1.1"]`. | +| `domain` | string | No | Local domain suffix. Hostnames without a FQDN are resolved within this domain (e.g., `printer` becomes `printer.lan`). Default: `null`. | +| `custom_records` | array | No | Static DNS A records for internal services and devices. Default: `[]`. | | `custom_records[].name` | string | Yes | Fully qualified domain name (e.g., `nas.lan`). | | `custom_records[].address` | string | Yes | The IP address to resolve the name to. | | `custom_records[].hostname` | string | No | Short hostname without the domain suffix. Adds a reverse DNS entry as well. | @@ -88,18 +88,15 @@ This file defines reverse proxy domains, the management interface, and global SS "proto": "http" }, "force_ssl": true, + "cert": "acme", "headers": { "X-Forwarded-Proto": "https", "X-Real-IP": "$remote_addr" - }, - "cert": { - "type": "acme", - "email": "admin@example.com" } } }, "management": { - "domain": ".local", + "domain": "vacuum-wall.local", "backend": { "host": "127.0.0.1", "port": 9090, @@ -107,12 +104,12 @@ This file defines reverse proxy domains, the management interface, and global SS }, "auth": { "user": "admin", - "htpasswd": "data/nginx/.htpasswd" + "htpasswd": "/home/wall/vacuum-wall/data/nginx/.htpasswd" } }, "ssl": { "protocols": "TLSv1.2 TLSv1.3", - "ciphers": "ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384", + "ciphers": "ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305", "prefer_server_ciphers": false } } @@ -127,36 +124,34 @@ The `domains` object maps domain names (keys) to proxy configurations. Each entr | `backend` | object | Yes | The upstream service that receives proxied traffic. | | `backend.host` | string | Yes | IP address or hostname of the backend service. | | `backend.port` | integer | Yes | Port the backend service is listening on. | -| `backend.proto` | string | Yes | Protocol for the backend connection: `http` or `https`. | +| `backend.proto` | string | No | Protocol for the backend connection: `http` or `https`. Default: `http`. | | `force_ssl` | boolean | No | Enable HTTPS redirect. HTTP requests to this domain receive a 301 redirect to HTTPS. Default: `true`. | | `headers` | object | No | Custom headers to set on proxied requests. Supports nginx variable interpolation (e.g., `$remote_addr`). | -| `cert` | object | No | Certificate configuration for this domain. Required unless the management domain shares its cert. | -| `cert.type` | string | Yes (if `cert`) | Certificate provisioning method. One of: `acme`, `file`, or `selfsigned`. | -| `cert.email` | string | Yes (if `acme`) | ACME account email used by the CA provider. | -| `cert.path` | string | Yes (if `file`) | Full path to the public certificate file (PEM). | -| `cert.key_path` | string | Yes (if `file`) | Full path to the private key file (PEM). | +| `cert` | string | No | Certificate provisioning method. One of: `"acme"`, `"file"`, or `"selfsigned"`. Omit for domains that don't need a dedicated cert. | ### Certificate Types -| Type | Description | +The `cert` field is a string that selects the provisioning method: + +| Value | Description | |---|---| -| `acme` | Vacuum Wall uses acme.sh to request and renew an ACME certificate via the HTTP-01 challenge. The nginx configuration is temporarily modified to serve the ACME challenge files at `/.well-known/acme-challenge/`. The `email` field is required. | -| `file` | Use a pre-existing certificate and private key from the local file system. The `path` and `key_path` fields must point to readable PEM files. Vacuum Wall will not attempt to renew these certificates. | +| `acme` | Vacuum Wall uses acme.sh to request and renew an ACME certificate via the HTTP-01 challenge. The nginx configuration serves ACME challenge files at `/.well-known/acme-challenge/`. | +| `file` | Use a pre-existing certificate and private key from the local file system. Vacuum Wall will not attempt to renew these certificates. | | `selfsigned` | Vacuum Wall generates a self-signed certificate and private key on first apply. Useful for internal domains or testing. The generated certificate is stored at `data/certs/`. | ### Management Domain -The `management` block configures the Vacuum Wall admin interface itself. It follows the same structure as a domain entry but includes an `auth` block for HTTP Basic Authentication. +The `management` block configures the Vacuum Wall admin interface itself. It follows the same structure as a domain entry but can include an `auth` block for HTTP Basic Authentication. | Field | Type | Required | Description | |---|---|---|---| | `domain` | string | Yes | The hostname used to access the management WebUI (e.g., `.local`). | | `backend` | object | Yes | Points to the Flask app at `127.0.0.1:9090`. | -| `auth` | object | Yes | HTTP Basic Authentication configuration. | +| `auth` | object | No | HTTP Basic Authentication configuration. Only created if `auth_user` is provided when setting the management proxy. | | `auth.user` | string | Yes | Username for the `.htpasswd` file. | | `auth.htpasswd` | string | Yes | Full path to the `.htpasswd` file containing the username and hashed password. | -The `.htpasswd` file can be created with the `htpasswd` utility: +The application can create the `.htpasswd` file programmatically via `write_htpasswd()` (using passlib's `apache_passwd` with Apache-Round-12, falling back to SHA-256 crypt). Manual creation is also possible: ```bash htpasswd -bc data/nginx/.htpasswd admin yourpassword @@ -176,23 +171,23 @@ The `ssl` block defines TLS parameters applied to all HTTPS server blocks via th **File**: `config/wireguard/config.json` -This file defines the WireGuard server interface and all connected peers. The application renders it into `/etc/wireguard/wg0.conf` and applies it with `wg-quick`. +This file defines the WireGuard server interface and all connected peers. The application renders it into `/etc/wireguard/wg0.conf` and applies it with `wg-quick`. The file is created automatically when `initialize()` generates the server key pair via `wg genkey` / `wg pubkey`. ```json { "interface": { "name": "wg0", "listen_port": 51820, - "private_key": "kOv8lK...', - "public_key": "YzP3xI...', + "private_key": "", + "public_key": "", "addresses": ["10.137.0.1/24"], "post_up": null, "post_down": null }, "peers": { "alice": { - "public_key": "nR7mQ2...', - "private_key": "xLpDgF...', + "public_key": "", + "private_key": "", "endpoint": "203.0.113.1:51820", "allowed_ips": ["0.0.0.0/0"], "persistent_keepalive": 25, @@ -206,39 +201,94 @@ This file defines the WireGuard server interface and all connected peers. The ap | Field | Type | Required | Description | |---|---|---|---| -| `name` | string | Yes | WireGuard interface name. Default: `wg0`. | -| `listen_port` | integer | Yes | Port the WireGuard interface listens on. Default: `51820`. Must be opened in the firewall. | -| `private_key` | string | Yes | Base64-encoded private key for the server interface. Use `wg genkey` to generate. | -| `public_key` | string | Yes | Corresponding public key. Use `wg pubkey` to derive from the private key. | -| `addresses` | array | Yes | IP address(es) assigned to the server interface in CIDR notation (e.g., `10.137.0.1/24`). | -| `post_up` | string | No | Shell command to run after the interface is brought up. Common uses: adding NAT rules, enabling IP forwarding for the tunnel. Set to `null` to omit. | -| `post_down` | string | No | Shell command to run after the interface is brought down. Used to clean up rules added by `post_up`. Set to `null` to omit. | +| `name` | string | No | WireGuard interface name. Default: `wg0`. | +| `listen_port` | integer | No | Port the WireGuard interface listens on. Default: `51820`. Must be opened in the firewall. | +| `private_key` | string | Yes (after init) | Base64-encoded private key for the server interface. Generated automatically by `initialize()` via `wg genkey`. | +| `public_key` | string | Yes (after init) | Corresponding public key. Generated automatically by `initialize()` via `wg pubkey`. | +| `addresses` | array | No | IP address(es) assigned to the server interface in CIDR notation (e.g., `10.137.0.1/24`). Default: `["10.137.0.1/24"]`. | +| `post_up` | string | No | Shell command to run after the interface is brought up. Common uses: adding NAT rules, enabling IP forwarding for the tunnel. Set to `null` to omit. Default: `null`. | +| `post_down` | string | No | Shell command to run after the interface is brought down. Used to clean up rules added by `post_up`. Set to `null` to omit. Default: `null`. | ### Peer Fields -Peers are stored in an object keyed by a human-readable identifier (e.g., `alice`, `office-laptop`). Each peer entry defines a WireGuard peer configuration. +Peers are stored in an object keyed by a human-readable identifier (e.g., `alice`, `office-laptop`). Each peer entry defines a WireGuard peer configuration. When `add_peer()` is called, the peer's key pair is auto-generated. The `private_key` is stored for client configuration generation but stripped from all API responses. | Field | Type | Required | Description | |---|---|---|---| -| `public_key` | string | Yes | The peer's public key. | -| `private_key` | string | No | The peer's private key, stored for generating downloadable client configuration files. This value is stripped from all API responses — the WebUI never exposes peer private keys over the network. | -| `endpoint` | string | No | The peer's public endpoint (IP:port). Required for server-initiated connections (e.g., the server reaching out to a peer behind a firewall). Leave empty or `null` for peer-initiated connections where the peer connects to the server. | -| `allowed_ips` | array | No | CIDR blocks that traffic from this peer is allowed to route. Defaults to `[]` (no routing restrictions from the server side). `["0.0.0.0/0"]` allows all traffic. `["10.137.0.0/16"]` restricts traffic to the VPN subnet. | -| `persistent_keepalive` | integer | No | Keepalive interval in seconds. `25` is recommended for peers behind NAT. Set to `0` or `null` to disable. | -| `preshared_key` | string | No | Optional pre-shared key for post-quantum resistance. Use `wg genpsk` to generate. | +| `public_key` | string | Yes | The peer's public key. Auto-generated when the peer is added. | +| `private_key` | string | No | The peer's private key, stored for generating downloadable client configuration files. Auto-generated when the peer is added. Stripped from all API responses — the WebUI never exposes peer private keys over the network. | +| `endpoint` | string | No | The peer's public endpoint (IP:port). Required for server-initiated connections (e.g., the server reaching out to a peer behind a firewall). Leave empty or `null` for peer-initiated connections where the peer connects to the server. Default: `null`. | +| `allowed_ips` | array | No | CIDR blocks that traffic from this peer is allowed to route. Default: `[]` (no routing restrictions from the server side). `["0.0.0.0/0"]` allows all traffic. `["10.137.0.0/16"]` restricts traffic to the VPN subnet. | +| `persistent_keepalive` | integer | No | Keepalive interval in seconds. `25` is recommended for peers behind NAT. Set to `0` or `null` to disable. Default: `null`. | +| `preshared_key` | string | No | Optional pre-shared key for post-quantum resistance. Use `wg genpsk` to generate. Default: `null`. | ### Client Configuration Generation -When a peer's `private_key` is set, the WebUI can generate a complete WireGuard client configuration file that the user can download and import into their WireGuard client app. The generated config includes the peer's interface settings, the server as a `[Peer]` entry, and the appropriate `Endpoint` and `AllowedIPs` values. The `private_key` field is written into the client config file for download but is never returned by the API. +When a peer's `private_key` is set (which is the case when `add_peer()` auto-generates it), the WebUI can generate a complete WireGuard client configuration file that the user can download and import into their WireGuard client app. The generated config includes the peer's interface settings, the server as a `[Peer]` entry, and the appropriate `Endpoint` and `AllowedIPs` values. The `private_key` field is written into the client config file for download but is never returned by the API. `generate_client_conf()` computes the client IP address from the server's subnet and the peer's sorted index position. ### Applying Configuration When configuration is saved through the WebUI or API, the application: -1. Validates all key pairs and IP ranges. -2. Renders the `wg0.conf` file from the JSON configuration. -3. Copies the rendered file to `/etc/wireguard/wg0.conf` using the sudo whitelist. -4. Runs `sudo wg-quick up wg0` to apply the configuration. -5. Returns success or error status to the caller. +1. Renders the `wg0.conf` file from the JSON configuration. +2. Writes the file to `/etc/wireguard/wg0.conf` with `600` permissions via `sudo cp`. +3. Runs `sudo wg-quick up ` to apply the configuration. +4. Returns success or error status to the caller. -If the interface is already up, `wg-quick up` will reconfigure it in place without dropping existing connections. \ No newline at end of file +If the interface is already up, `wg-quick up` will reconfigure it in place without dropping existing connections. + +## Firewall Configuration + +**File**: `config/firewall/config.json` + +This file defines the declarative firewalld zone configuration. The application compares it against the live firewalld state via `_compute_pending_changes()` and applies incremental changes. Runtime state backups are stored in `data/firewall/rules.json`. + +```json +{ + "zones": { + "public": { + "interfaces": ["eth0"], + "services": ["dhcp", "dns", "https", "ssh"], + "target": "DEFAULT", + "masquerade": true, + "forward_ports": [ + { + "id": "abc123", + "port": 443, + "proto": "tcp", + "toaddr": "192.168.2.50", + "toport": 8080 + } + ], + "rich_rules": [ + { + "rule": "rule family=\"ipv4\" source address=\"10.0.0.0/8\" reject" + } + ] + } + } +} +``` + +### Zone Fields + +The `zones` object maps zone names (keys) to zone configurations. Each zone corresponds to a firewalld zone applied via `firewall-cmd`. + +| Field | Type | Required | Description | +|---|---|---|---| +| `interfaces` | array | No | Network interfaces assigned to this zone. Computed against live state to detect pending changes. Default: `[]`. | +| `services` | array | No | Firewalld services to allow in this zone (e.g., `ssh`, `https`, `dns`, `dhcp`). Default: `[]`. | +| `target` | string | No | Zone target policy. One of: `DEFAULT`, `ACCEPT`, `DROP`, `REJECT`. The code maps these to firewalld's canonical target values (`default`, `ACCEPT`, `DROP`, `REJECT`). Default: `DEFAULT`. | +| `masquerade` | boolean | No | Enable IP masquerading (NAT) for this zone. Default: `false`. | +| `forward_ports` | array | No | Port forwarding rules. Each entry has an auto-generated `id` field and the standard firewalld forward-port fields. Default: `[]`. | +| `forward_ports[].id` | string | No | Auto-generated unique identifier for the port forwarding rule. Not user-settable. | +| `forward_ports[].port` | integer | Yes | Destination port to forward. | +| `forward_ports[].proto` | string | Yes | Protocol: `tcp` or `udp`. | +| `forward_ports[].toaddr` | string | No | Internal IP address to forward to. Omit for broadcast forwarding. | +| `forward_ports[].toport` | integer | No | Internal port to forward to. Omit to keep the same port. | +| `rich_rules` | array | No | Rich rule entries for advanced firewall policies. Default: `[]`. | +| `rich_rules[].rule` | string | Yes | The full firewalld rich rule string, e.g., `rule family="ipv4" source address="10.0.0.0/8" reject`. | + +### Applying Firewall Configuration + +The `config_pending()` function compares the declarative config in `config/firewall/config.json` against the live firewalld state returned by the daemon (via `daemon.handlers.firewall.get_state()`). It returns a diff indicating which zones have pending changes for interfaces, services, target, masquerade, forward ports, and rich rules. Zones that exist live but not in config are reported as `unmanaged_zones`. \ No newline at end of file diff --git a/docs/deployment.md b/docs/deployment.md index c834580..5da3b92 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -51,6 +51,7 @@ All settings that can be passed as an environment variable also have a CLI flag | `--dev` | -- | No | Development mode: auto-detects repo owner as service user, skips safety warning. | | `--wan-iface` | `WAN_IFACE` | No | WAN interface name. Auto-detected from default gateway. | | `--lan-ifaces` | `LAN_IFACES` | No | LAN interface names, comma-separated. Auto-detected from non-loopback, non-WAN interfaces. | +| `--force-venv` | — | No | Force recreation of the Python virtual environment. | Run `./install.sh --help` for full usage. @@ -75,13 +76,15 @@ The systemd service unit files and sudoers whitelist are rendered from Jinja2 te The installer performs the following steps automatically: -- **Package installation**: Installs firewalld, nginx, dnsmasq, avahi-daemon, wireguard-tools, python3, Flask, pip, jq, curl, iptables, nftables, and apache2-utils. -- **System user creation**: Creates a dedicated system user (default: `vacuum-wall`, configurable via `USER_NAME`) with a nologin shell that owns the project data and runs the WebUI service. -- **Python venv**: Creates or recreates the Python virtual environment and installs project dependencies. +- **Package installation**: Installs firewalld, nginx, dnsmasq, avahi-daemon, wireguard-tools, python3, python3-pip, jq, curl, iptables, nftables, and apache2-utils. +- **Shared group creation**: Creates a shared system group (`vacuum-wall`) both service users belong to. +- **Daemon user creation**: Creates `vacuum-walld` (derived from WebUI user name) — a system user with `NOPASSWD` sudo access for privileged operations. Owns the project directory and daemon socket. +- **WebUI user creation**: Creates a dedicated system user (default: `vacuum-wall`, configurable via `USER_NAME`) with zero sudo access. Communicates with the daemon via Unix socket. +- **Python venv**: Creates the Python virtual environment and installs project dependencies. Skips if already present (use `--force-venv` to recreate). - **acme.sh installation**: Copies the vendored acme.sh client to the data directory for ACME certificate management. Skips if already installed. - **Directory setup**: Creates config directories under `config/` for each subsystem's declarative JSON, and data directories under `data/` for generated files (nginx sites, dnsmasq fragments, firewall backup, WireGuard config). - **Template rendering**: Renders system template files (`systemd/*.service`, `sudoers.d/`) via Jinja2, substituting `USER_NAME`, `INSTALL_DIR`, and `ACME_HOME`. Installed systemd and sudoers files contain no hardcoded values. -- **Sudoers whitelist**: Installs a restrictive sudoers file at `/etc/sudoers.d/vacuum-wall` allowing the configured user to run only the specific privileged commands needed for firewall, nginx, and dnsmasq management. Validates syntax with `visudo -cf`. +- **Sudoers whitelist**: Installs a restrictive sudoers file at `/etc/sudoers.d/vacuum-walld` granting the daemon user `NOPASSWD` sudo for only the specific privileged commands needed for firewall, nginx, dnsmasq, and acme.sh management. Validates syntax with `visudo -cf`. The WebUI user's sudoers file (`/etc/sudoers.d/vacuum-wall`) is empty — it has no sudo access. - **IP forwarding**: Enables `net.ipv4.ip_forward=1` in sysctl.conf and applies it at runtime, required for routing traffic between zones. Appends only if not already present. - **Firewalld initialization**: Starts and enables firewalld. Opens HTTP, HTTPS, and SSH services on the public zone for management access. - **Dnsmasq initialization**: Starts and enables dnsmasq for future DHCP/DNS serving on internal interfaces. @@ -91,22 +94,23 @@ The installer performs the following steps automatically: - **Credentials**: Generates an htpasswd file using `apache2-utils` (with a Python fallback) for the management proxy's basic auth. Updates existing file if already present. - **Initial nginx config**: Writes `$PROJECT_DIR/config/nginx/config.json` with the management domain and auth settings pre-configured. Skips if the file already exists (preserves user-customized config). - **Initial firewall config**: Writes `$PROJECT_DIR/config/firewall/config.json` with auto-detected WAN/LAN interfaces. Skips if the file already exists. -- **Systemd units**: Installs three units (rendered from Jinja2 templates): +- **Systemd units**: Installs four units (rendered from Jinja2 templates): + - `vacuum-walld.service` — the privileged background daemon (aiohttp, daemon socket). - `vacuum-wall.service` — the Flask WebUI backend. - `vacuum-wall-acme.service` — the certificate renewal oneshot. - `vacuum-wall-acme.timer` — periodic timer that triggers cert renewals. - **Firewalld zones**: Creates initial zones: - `internal` — trusted LAN zone with DHCP, DNS, and NTP services allowed. - `vpn` — WireGuard tunnel zone. -- **Service startup**: Enables and starts/restarts nginx and the WebUI service, and enables the ACME renewal timer. nginx is reloaded (or restarted) to pick up any config changes. +- **Service startup**: Enables and starts/restarts nginx, the daemon (`vacuum-walld`), the WebUI (`vacuum-wall`), and the ACME renewal timer. nginx is reloaded (or restarted) to pick up any config changes. - **ACME registration**: Registers the ACME account with the provided email via acme.sh. ### Idempotent Re-Runs `install.sh` is fully idempotent and safe to run multiple times. Re-running the script: -- Rebuilds the Python venv and reinstalls dependencies -- Restarts `vacuum-wall` and reloads `nginx` to pick up changes +- Skips the Python venv (use `--force-venv` to rebuild) +- Restarts `vacuum-walld`, `vacuum-wall`, and reloads `nginx` to pick up changes - Preserves existing SSL certificates (skips self-signed generation if a cert exists) - Preserves existing `config.json` files (skips initial write if file exists) - Safely updates `htpasswd` (uses update mode instead of create mode) @@ -122,7 +126,7 @@ This makes it safe for development workflows: simply run `bash install.sh` again After the installer completes, confirm all services are running: ```bash -systemctl status vacuum-wall nginx firewalld dnsmasq +systemctl status vacuum-walld vacuum-wall nginx firewalld dnsmasq ``` Each should be active (running). The `vacuum-wall-acme.timer` should also be active (waiting). @@ -249,8 +253,8 @@ Vacuum Wall includes integrated WireGuard server support for VPN access. Check service logs and configuration: ```bash +journalctl -u vacuum-walld --no-pager -n 50 journalctl -u vacuum-wall --no-pager -n 50 -journalctl -u nginx --no-pager -n 50 nginx -t ``` @@ -268,7 +272,7 @@ systemctl status firewalld If firewalld is not running, start it with `systemctl start firewalld`. Check that the sudoers whitelist is valid: ```bash -visudo -cf /etc/sudoers.d/vacuum-wall +visudo -cf /etc/sudoers.d/vacuum-walld ``` ### Certificate Issuance Fails @@ -280,7 +284,7 @@ ACME validation via the ACME provider requires: - The ACME email was registered correctly. Check with: ```bash -su -s /bin/bash "$USER_NAME" -c "~/.acme.sh/acme.sh --list" +su -s /bin/bash "$USER_DAEMON_NAME" -c "~/data/acme/acme.sh --list" ``` If port 80 is blocked or the DNS record hasn't propagated yet, wait and retry. The ACME timer will also attempt renewal automatically. @@ -308,10 +312,11 @@ Verify that: | Component | Service | Config Location | |---|---|---| +| Daemon (privileged) | `vacuum-walld.service` | `daemon/` | | WebUI backend | `vacuum-wall.service` | `webui/` | | Reverse proxy | `nginx` | `/etc/nginx/conf.d/vacuum-wall-mgmt.conf` | | Firewall | `firewalld` | Managed via WebUI and `firewall-cmd` | | DHCP/DNS | `dnsmasq` | `config/dnsmasq/` | | VPN | wireguard-tools | `config/wireguard/` | -| Certificates | `vacuum-wall-acme.timer` | `~/.acme.sh/` | -| Sudoers | — | `/etc/sudoers.d/vacuum-wall` | \ No newline at end of file +| Certificates | `vacuum-wall-acme.timer` | `$PROJECT_DIR/data/acme/` | +| Sudoers (daemon) | — | `/etc/sudoers.d/vacuum-walld` | \ No newline at end of file diff --git a/docs/overview.md b/docs/overview.md index a843b5a..159c5c5 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -6,7 +6,7 @@ Vacuum Wall is a zone-based firewall appliance with a built-in SSL reverse proxy ## Architecture Overview -Vacuum Wall is built around four integrated subsystems managed through a central Flask web interface. The traffic plane uses firewalld with its nftables backend, supporting zone-based policies, source NAT, and destination NAT for port forwarding. The DNS/DHCP plane serves private subnets via dnsmasq, providing address allocation and local name resolution. The proxy plane runs nginx with automatic ACME certificates through acme.sh, handling SSL termination and reverse proxying for backend services. The VPN plane uses WireGuard (wg-quick) for encrypted tunnel management. All subsystems are configured and monitored through the Flask web UI, which is itself proxied through nginx with basic HTTP authentication. +Vacuum Wall is built around four integrated subsystems managed through a two-layer architecture: a non-privileged Flask web UI and a privileged background daemon (`vacuum-walld`). The web UI communicates with the daemon via a Unix socket. The daemon handles all privileged operations (sudo) for the subsystems: the traffic plane uses firewalld with its nftables backend (zone-based policies, source NAT, destination NAT); the DNS/DHCP plane serves private subnets via dnsmasq; the proxy plane runs nginx with automatic ACME certificates through acme.sh; and the VPN plane uses WireGuard (wg-quick) for encrypted tunnel management. All subsystems are configured and monitored through the Flask web UI, which is itself proxied through nginx with basic HTTP authentication. ## Subsystems @@ -60,13 +60,20 @@ After installation, access the management interface at `https://.local ├── .venv/ # Python virtual environment ├── config/ # Declarative JSON configuration (source of truth) │ ├── dnsmasq/ # DHCP/DNS config +│ ├── firewall/ # Firewall zone & rule config │ ├── nginx/ # Proxy domain & SSL config │ └── wireguard/ # VPN interface & peer config ├── data/ # Runtime artifacts & generated files │ ├── nginx/sites-enabled/ # Generated server blocks │ ├── dnsmasq/fragments/ # User config fragments │ ├── acme/ # ACME certificates -│ └── firewall/ # Firewall rule backup +│ ├── firewall/ # Firewall rule backup +│ ├── logs/ # Application logs +│ └── wireguard/ # Generated WireGuard configs +├── daemon/ # Privileged background daemon +│ ├── server.py # aiohttp server, cache, batch routing, handler registry +│ ├── client.py # Sync HTTP client over Unix socket +│ └── handlers/ # Privileged operation handlers (all sudo calls) ├── system/ # System file templates (all Jinja2) │ ├── systemd/ # Service and timer unit files │ │ ├── vacuum-wall.service # Web UI service (rendered at install) @@ -86,17 +93,25 @@ After installation, access the management interface at `https://.local │ └── wireguard.py # VPN tunnel management ├── webui/ # Flask web application │ ├── server.py # Application entry point -│ ├── api/ # REST API route modules -│ │ └── common.py # Shared API response helpers (_ok, _error) +│ ├── api/ # REST API route modules (blueprints) +│ │ ├── common.py # Shared API response helpers (_ok, _error) +│ │ ├── firewall.py # Firewall API +│ │ ├── dhcp.py # DHCP/DNS API +│ │ ├── proxy.py # Nginx proxy API +│ │ ├── certs.py # Certificate API +│ │ ├── wireguard.py # WireGuard API +│ │ └── logs.py # Logs API │ ├── templates/ # Jinja2/HTMX templates │ └── static/ # CSS and client-side JS -└── docs/ # Documentation - ├── overview.md # This file - ├── deployment.md - ├── api.md - ├── security.md - ├── architecture.md - └── config.md +├── docs/ # Documentation +│ ├── overview.md # This file +│ ├── deployment.md +│ ├── api.md +│ ├── security.md +│ ├── architecture.md +│ └── config.md +└── scripts/ # Utility scripts + └── update-vendor.sh # Vendor frontend library updates ``` ## Documentation diff --git a/docs/security.md b/docs/security.md index a356a7e..83a3df4 100644 --- a/docs/security.md +++ b/docs/security.md @@ -2,31 +2,46 @@ ## Privilege Model -The Vacuum Wall management WebUI (Flask application) runs as an unprivileged system user (default name: `vacuum-wall`, configurable via the `USER_NAME` environment variable at install time). The application never runs as root. All privileged operations — firewall rule changes, nginx reloads, dnsmasq config writes, WireGuard tunnel management — are executed through a restricted sudo whitelist defined at `/etc/sudoers.d/vacuum-wall`. ACME certificate operations via `acme.sh` are the exception: they run directly as the application user without sudo escalation, using webroot validation that doesn't require binding to privileged ports. +Vacuum Wall uses two distinct system users bridged by a shared group (`vacuum-wall`): -This design follows the principle of least privilege: only explicitly enumerated commands are permitted to escalate. There is no path to a full root shell from the application or the dedicated service user. If the WebUI process is compromised, an attacker is confined to the sudo whitelist surface rather than gaining unrestricted system access. +- **`vacuum-walld`** (daemon user): Runs the `vacuum-walld` background daemon, which is the only process with sudo access. The daemon communicates with the WebUI over a Unix socket at `data/daemon.sock`. All privileged operations — firewall rule changes, nginx reloads, dnsmasq config writes, WireGuard tunnel management — are executed by the daemon through a restricted sudo whitelist at `/etc/sudoers.d/vacuum-walld`. +- **`vacuum-wall`** (WebUI user): Runs the Flask management WebUI. Has **zero** sudo access. If the WebUI process is compromised, an attacker cannot invoke sudo directly — they are confined to the sandboxed Flask process with no privilege escalation path. + +ACME certificate operations via `acme.sh` run as the WebUI user (`vacuum-wall`) — not as root, and not as the daemon user. The automated renewal timer (`vacuum-wall-acme.timer`) runs `acme.sh --cron` as `{{ USER_NAME }}`. When triggered from the WebUI or daemon, acme.sh also runs as the non-root process invoking it, using webroot validation that does not require binding to privileged ports. + +This design follows the principle of least privilege: only the daemon process holds sudo access, and only for explicitly enumerated commands. The WebUI user is completely isolated from sudo. + +## Communication Between WebUI and Daemon + +The WebUI communicates with the daemon via synchronous HTTP requests over a Unix socket (`data/daemon.sock`), owned by `vacuum-walld:vacuum-wall` with mode `0660`. The shared group membership allows the WebUI user to connect to the socket. The daemon runs an `aiohttp` server that routes requests to handler modules (`daemon/handlers/*.py`), which execute the privileged commands. ## Sudo Whitelist -The file `/etc/sudoers.d/vacuum-wall` grants the configured system user passwordless sudo access to a strict set of commands. Each entry is scoped to a single binary with allowed arguments. The categories are: +The file `/etc/sudoers.d/vacuum-walld` grants the daemon user (`vacuum-walld`) passwordless sudo access to a strict set of commands. The WebUI user (`/etc/sudoers.d/vacuum-wall`) has no sudo entries. Each daemon entry is scoped to a single binary with allowed arguments: | Category | Whitelisted Command | Purpose | |---|---|---| | Firewall | `firewall-cmd *` | All firewalld operations (zone management, rules, services, ports) | | Nginx | `nginx -s reload` | Graceful nginx configuration reload | | Nginx | `nginx -t` | Nginx configuration syntax validation | +| Nginx file ops | `cp -- * /etc/nginx/`, `/etc/nginx/conf.d/`, `/etc/nginx/snippets/` | Copy rendered config files to system paths | +| Nginx file ops | `rm /etc/nginx/conf.d/vacuum-wall.conf`, `/etc/nginx/snippets/vacuum-wall-ssl.conf` | Clean up generated nginx config files | +| Nginx file ops | `chown root:root /etc/nginx/snippets/vacuum-wall-ssl.conf` | Ensure correct ownership of SSL snippet | | Dnsmasq | `systemctl reload dnsmasq` | Apply updated dnsmasq configuration | | Dnsmasq | `systemctl is-active dnsmasq` | Check dnsmasq service status | +| Dnsmasq file ops | `cp -- * /etc/dnsmasq.d/` | Copy rendered config files | +| Dnsmasq file ops | `tee /etc/dnsmasq.d/vacuum-wall.conf` | Write dnsmasq configuration | +| Dnsmasq file ops | `mkdir -p /etc/dnsmasq.d` | Ensure target directory exists | +| Dnsmasq leases | `cat /var/lib/dnsmasq/dnsmasq.leases` | Read dnsmasq lease table | | WireGuard | `wg-quick *` | WireGuard tunnel lifecycle (up, down, save, show) | | WireGuard | `wg *` | WireGuard status and peer management | -| Certificates | (none) | acme.sh runs as the unprivileged service user directly; no sudo escalation is needed for certificate operations (webroot validation is used instead of standalone/TLS-ALPN) | -| File writes | `sudo cp` to `/etc/nginx/`, `/etc/nginx/conf.d/`, `/etc/nginx/snippets/`, `/etc/dnsmasq.d/`, `/etc/wireguard/` | Copy rendered config files to system paths | -| File writes | `sudo tee` to `/etc/dnsmasq.d/vacuum-wall.conf` | Write dnsmasq configuration | -| File removal | `sudo rm` for `/etc/nginx/conf.d/vacuum-wall.conf`, `/etc/nginx/snippets/vacuum-wall-ssl.conf` | Clean up generated nginx config files | -| Directory creation | `sudo mkdir -p /etc/dnsmasq.d`, `sudo mkdir -p /etc/wireguard` | Ensure target directories exist | -| Logs | `sudo journalctl --unit=* -n *` | Query systemd journal for managed services | -| Logs | `sudo cat /var/log/nginx/*` | Read nginx access and error logs | -| Leases | `sudo cat /var/lib/dnsmasq/dnsmasq.leases` | Read dnsmasq lease table | +| WireGuard file ops | `cp -- * /etc/wireguard/` | Copy rendered config files | +| WireGuard file ops | `chown root:root /etc/wireguard/wg0.conf` | Ensure correct ownership of WG config | +| Certificates | (none) | acme.sh runs as the non-root service user directly; no sudo escalation is needed (webroot validation is used) | +| Network queries | `ip -o link show` | List network interfaces | +| Network queries | `ip -o addr show` | List IP addresses on interfaces | +| Logs | `journalctl --unit=* -n *` | Query systemd journal for managed services | +| Logs | `cat /var/log/nginx/*` | Read nginx access and error logs | Key safety properties: @@ -41,41 +56,41 @@ Key safety properties: The Flask WebUI binds exclusively to `127.0.0.1:9090`. It is not exposed directly to any network interface. All external access to the management UI is routed through an nginx reverse proxy on the designated management domain, which provides SSL termination and HTTP Basic Authentication. The `.htpasswd` file is stored at `data/nginx/.htpasswd`. +The management interface does not set security hardening headers (e.g., `X-Content-Type-Options`, `X-Frame-Options`, HSTS). It relies on nginx basic authentication, SSL termination, and the systemd sandbox for its security boundary. + ### Proxy Domains Every proxied domain configured in Vacuum Wall enforces: - **HTTP-to-HTTPS redirect** — All HTTP requests return a 301 Permanent Redirect to the HTTPS equivalent. -- **HTTP Strict Transport Security (HSTS)** — The `Strict-Transport-Security` header is set with a long max-age to prevent downgrade attacks. +- **HTTP Strict Transport Security (HSTS)** — The `Strict-Transport-Security` header is set with a long max-age and `includeSubDomains` to prevent downgrade attacks. - **Security headers** on all proxied responses: - `X-Content-Type-Options: nosniff` — Prevents MIME-type sniffing. - `X-Frame-Options: DENY` — Prevents clickjacking via iframes. - `X-XSS-Protection: 1; mode=block` — Enables browser XSS filtering. - `Referrer-Policy: strict-origin-when-cross-origin` — Limits referrer information leakage. - - `Content-Security-Policy` rules can be customized per-domain via the configuration. + +Additional proxy headers (`extra_headers` in the domain config) are delivered to the upstream backend via nginx `proxy_set_header` directives — they are not sent as response headers to clients. ### TLS Configuration The default nginx SSL configuration enforces modern TLS only: - **Protocols**: TLSv1.2 and TLSv1.3. Older protocols (SSLv3, TLSv1.0, TLSv1.1) are disabled. -- **Cipher suites**: A curated set of AEAD ciphers (ECDHE-ECDSA and ECDHE-RSA key exchange with AES-GCM and CHACHA20-POLY1305). -- **DH parameters**: 2048-bit generated Diffie-Hellman parameters are used when ECDHE is not selected. -- **OCSP stapling** is enabled for faster certificate validation. -- **ssl_prefer_server_ciphers** can be toggled per-domain; the default is to let the client choose. +- **Cipher suites**: A curated set of AEAD ciphers using ECDHE key exchange (ECDHE-ECDSA and ECDHE-RSA with AES-GCM and CHACHA20-POLY1305). No non-ECDHE ciphers are included. +- **ssl_prefer_server_ciphers** defaults to `off` (client chooses). +- **Session settings**: `ssl_session_timeout 1d`, `ssl_session_cache shared:TLS:10m`, `ssl_session_tickets off`. ## Systemd Hardening -The `vacuum-wall.service` unit file applies a comprehensive set of systemd sandboxing directives to isolate the WebUI process from the rest of the system: +Both `vacuum-wall.service` (WebUI) and `vacuum-walld.service` (daemon) apply comprehensive systemd sandboxing directives to isolate their processes from the rest of the system: | Directive | Value | Effect | |---|---|---| | `ProtectSystem` | `strict` | Mounts the entire file system as read-only, except explicitly allowed paths | -| `ReadWritePaths` | `$INSTALL_DIR`, `$INSTALL_DIR/config`, `$INSTALL_DIR/data`, and `/tmp` | The project directory, config directory, data directory, and `/tmp` are writable (required by `ProtectSystem=strict`). The project path is templated at install time. | +| `ReadWritePaths` | project dir, `/tmp`, and (WebUI only) `config/`, `data/` subdirs | The project directory and runtime paths are writable | | `PrivateTmp` | `yes` | Provides a private `/tmp` and `/var/tmp` namespace | | `NoNewPrivileges` | `yes` | Prevents the process from gaining new privileges via `setuid`/`setgid` | -| `IPAddressDeny` | `all` | Drops all network traffic | -| `IPAddressAllow` | `localhost` | Allows only loopback communication (required to reach nginx upstream at 127.0.0.1:9090) | | `PrivateDevices` | `yes` | Hides all device files under `/dev` | | `ProtectKernelTunables` | `yes` | Makes `/proc/sys`, `/sys`, and `/proc/sysrq-trigger` read-only | | `ProtectKernelModules` | `yes` | Disables `init_module` and `finit_module` syscalls | @@ -87,10 +102,13 @@ The `vacuum-wall.service` unit file applies a comprehensive set of systemd sandb | `MemoryDenyWriteExecute` | `yes` | Prevents creating memory regions that are both writable and executable | | `SystemCallFilter` | `@system-service` | Allows only a curated set of system calls safe for services | | `RestrictRealtime` | `yes` | Prevents the process from acquiring realtime scheduling priorities | +| `RestrictAddressFamilies` | `AF_UNIX AF_INET AF_INET6` | Restricts available address families | +| `IPAddressDeny` | `any` | Drops all network traffic by default | +| `IPAddressAllow` | `localhost` | Allows only loopback communication (required to reach the other process at 127.0.0.1) | -The `User`, `Group`, `WorkingDirectory`, `ExecStart`, and `ReadWritePaths` directives in the service unit are rendered from a Jinja2 template at install time with the configured `USER_NAME` and `INSTALL_DIR`. +The WebUI unit additionally restricts address families and denies all IP traffic except to localhost — it cannot reach any external network interface. Both units use template variables (`{{ USER_NAME }}`, `{{ USER_GROUP }}`, `{{ USER_DAEMON_NAME }}`, `{{ PROJECT_DIR }}`) rendered at install time. -This hardening ensures that even if the Flask application is compromised, the attacker is confined to a sandboxed environment with no direct network access, no write access outside the config and data directories, and no ability to escalate privileges through kernel interfaces. +This hardening ensures that even if either process is compromised, the attacker is confined to a sandboxed environment with no direct network access, no write access outside the project directory, and no ability to escalate privileges through kernel interfaces. ## Network Security @@ -100,6 +118,8 @@ The firewalld default zone policy is set to deny all incoming traffic. Only expl ### Zone-Based Traffic Isolation +The `lib/firewall` module is a generic firewalld parser with no hardcoded zone definitions. Zone structure is defined declaratively in `config/firewall/config.json` at runtime. A typical deployment uses: + | Zone | Interface | Purpose | Behavior | |---|---|---|---| | `external` | WAN (e.g., `eth0`) | Untrusted Internet-facing | Only essential services (HTTPS, WireGuard) are open. ICMP echo is rate-limited. | @@ -116,16 +136,16 @@ IP forwarding (`net.ipv4.ip_forward = 1`) is enabled system-wide to allow routin ### acme.sh Integration -Certificate management is handled by acme.sh, which stores all certificates and private keys in the service user's home directory under `~/.acme.sh/`. The directory is owned by and writable only by the service user. +Certificate management is handled by acme.sh, which stores all certificates and private keys under `PROJECT_DIR/data/acme/` (set via the `ACME_HOME` environment variable). The directory is owned by and writable only by the service users. ### Private Key Protection -Private keys are never exposed through the WebUI API or returned in API responses. The API only returns certificate metadata such as domain names, validity dates, and renewal status. When a domain's certificate is needed by nginx, the rendered nginx configuration references the file paths managed by acme.sh (`~/.acme.sh//fullchain.cer` and `~/.acme.sh//.key`), and nginx reads them directly through symbolic links or includes. +Private key material is never exposed through the WebUI API. The API returns certificate metadata such as domain names, validity dates, file paths, and renewal status. File paths are returned so downstream tooling (nginx, cert management) can reference them. When a domain's certificate is needed by nginx, the rendered nginx configuration references the acme.sh file paths directly via `ssl_certificate` and `ssl_certificate_key` directives — no symlinks are created. ### HSTS Enforcement -All HTTPS proxy domains have HTTP Strict Transport Security enabled at the nginx layer with a long max-age and the `includeSubDomains` directive. This ensures browsers always use HTTPS for the domain and all subdomains, preventing SSL stripping attacks. +All HTTPS proxy domains (excluding the management interface) have HTTP Strict Transport Security enabled at the nginx layer with a long max-age and the `includeSubDomains` directive. This ensures browsers always use HTTPS for proxied domains and their subdomains, preventing SSL stripping attacks. ### Modern TLS Only -As noted in the Web Security section, the default ssl snippet enforces TLSv1.2 and TLSv1.3 with strong AEAD cipher suites. Weak ciphers, EXPORT grades, RC4, DES, 3DES, MD5, and null ciphers are explicitly excluded. \ No newline at end of file +As noted in the Web Security section, the default SSL snippet enforces TLSv1.2 and TLSv1.3 with strong AEAD cipher suites using ECDHE key exchange. The cipher suite list excludes weak ciphers, EXPORT grades, RC4, DES, 3DES, MD5, and null ciphers. diff --git a/install.sh b/install.sh index e5fe079..a29d49d 100755 --- a/install.sh +++ b/install.sh @@ -139,6 +139,8 @@ fi # Optional settings with defaults USER_NAME="${_cli_user:-${USER_NAME:-vacuum-wall}}" +# Daemon user name (derived from web UI user name) +USER_DAEMON_NAME="${USER_NAME}d" # --- Safety check: running service as a non-system regular user --- if [[ "$_cli_is_dev" != true ]] && [[ "$USER_NAME" != "vacuum-wall" ]] && id "$USER_NAME" &>/dev/null; then @@ -147,15 +149,20 @@ if [[ "$_cli_is_dev" != true ]] && [[ "$USER_NAME" != "vacuum-wall" ]] && id "$U _shell=$(getent passwd "$USER_NAME" | cut -d: -f7) if [[ "$_uid" -ge 1000 ]] && [[ "$_shell" != "/usr/sbin/nologin" && "$_shell" != "/bin/false" ]]; then warn "USER_NAME='$USER_NAME' is a regular user (UID=$_uid, shell=$_shell)!" - warn "This grants NOPASSWD sudo and runs the web service as your login account." + warn "This runs the web service as your login account." + warn "Sudo access is held only by the daemon user ($USER_DAEMON_NAME)." warn "Only use for development. For production, use --user vacuum-wall." fi fi +# Shared group for both users to access project files and socket +USER_GROUP="vacuum-wall" echo "============================================" echo " Vacuum Wall Appliance Installer" -echo " Install dir: $PROJECT_DIR" -echo " System user: $USER_NAME" +echo " Install dir: $PROJECT_DIR" +echo " WebUI user: $USER_NAME" +echo " Daemon user: $USER_DAEMON_NAME" +echo " Shared group: $USER_GROUP" echo " Management domain: $DOMAIN" echo "============================================" @@ -176,15 +183,35 @@ apt-get install -y -qq \ apache2-utils \ avahi-daemon -# --- 2. Create system user (home set to PROJECT_DIR for env, no-create) --- -if ! id "$USER_NAME" &>/dev/null; then - log "Creating system user $USER_NAME..." - useradd --system --home-dir "$PROJECT_DIR" --no-create-home --shell /usr/sbin/nologin "$USER_NAME" +# --- 2. Create shared group --- +if ! getent group "$USER_GROUP" &>/dev/null; then + log "Creating shared group $USER_GROUP..." + groupadd --system "$USER_GROUP" else - log "User $USER_NAME already exists." + log "Group $USER_GROUP already exists." fi -# --- 2b. Setup Python venv --- +# --- 2a. Create daemon user (has sudo for privileged operations) --- +if ! id "$USER_DAEMON_NAME" &>/dev/null; then + log "Creating system user $USER_DAEMON_NAME..." + useradd --system --home-dir "$PROJECT_DIR" --no-create-home --shell /usr/sbin/nologin \ + --gid "$USER_GROUP" "$USER_DAEMON_NAME" +else + log "User $USER_DAEMON_NAME already exists." + usermod -g "$USER_GROUP" "$USER_DAEMON_NAME" 2>/dev/null || true +fi + +# --- 2b. Create web UI user (no sudo, communicates with daemon) --- +if ! id "$USER_NAME" &>/dev/null; then + log "Creating system user $USER_NAME..." + useradd --system --home-dir "$PROJECT_DIR" --no-create-home --shell /usr/sbin/nologin \ + --gid "$USER_GROUP" "$USER_NAME" +else + log "User $USER_NAME already exists." + usermod -g "$USER_GROUP" "$USER_NAME" 2>/dev/null || true +fi + +# --- 2c. Setup Python venv --- if [[ -x "${PROJECT_DIR}/.venv/bin/python3" ]] && [[ "$_cli_force_venv" != true ]]; then log "Python venv already exists, skipping (use --force-venv to recreate)." else @@ -192,16 +219,16 @@ else rm -rf "${PROJECT_DIR}/.venv" python3 -m venv "${PROJECT_DIR}/.venv" "${PROJECT_DIR}/.venv/bin/pip" install -qe "${PROJECT_DIR}" - chown -R "$USER_NAME:$USER_NAME" "${PROJECT_DIR}/.venv" + chown -R "$USER_DAEMON_NAME:$USER_GROUP" "${PROJECT_DIR}/.venv" fi -# --- 2c. Install acme.sh (vendored) --- +# --- 2d. Install acme.sh (vendored) --- if [[ ! -x "$ACME_HOME/acme.sh" ]]; then log "Installing acme.sh (vendored)..." mkdir -p "$ACME_HOME" cp "${PROJECT_DIR}/vendor/acme.sh" "$ACME_HOME/acme.sh" chmod +x "$ACME_HOME/acme.sh" - chown -R "$USER_NAME:$USER_NAME" "$ACME_HOME" + chown -R "$USER_DAEMON_NAME:$USER_GROUP" "$ACME_HOME" else log "acme.sh already installed." fi @@ -215,16 +242,21 @@ mkdir -p "${PROJECT_DIR}/config"/{dnsmasq,nginx,wireguard,firewall} mkdir -p "${PROJECT_DIR}/data"/{nginx/sites-enabled,dnsmasq,firewall,wireguard,acme} mkdir -p /etc/wireguard mkdir -p /etc/dnsmasq +# Set ownership: daemon owns project dir, web UI user is group member +chown -R "$USER_DAEMON_NAME:$USER_GROUP" "$PROJECT_DIR" +chmod -R g+rX "$PROJECT_DIR" # --- 4. Template rendering function --- render_template() { - export USER_NAME PROJECT_DIR ACME_HOME + export USER_NAME USER_DAEMON_NAME USER_GROUP PROJECT_DIR ACME_HOME "${PROJECT_DIR}/.venv/bin/python3" -c " import sys, os from jinja2 import Template text = open(sys.argv[1]).read() env = { 'USER_NAME': os.environ['USER_NAME'], + 'USER_DAEMON_NAME': os.environ.get('USER_DAEMON_NAME', ''), + 'USER_GROUP': os.environ.get('USER_GROUP', ''), 'PROJECT_DIR': os.environ['PROJECT_DIR'], 'ACME_HOME': os.environ['ACME_HOME'], } @@ -234,13 +266,24 @@ print(Template(text).render(**env), end='') # --- 5. Install sudoers --- log "Installing sudoers whitelist..." -render_template "${PROJECT_DIR}/system/sudoers.d/vacuum-wall" \ - | install -m 0440 /dev/stdin /etc/sudoers.d/vacuum-wall +export USER_DAEMON_NAME +render_template "${PROJECT_DIR}/system/sudoers.d/vacuum-walld" \ + | install -m 0440 /dev/stdin /etc/sudoers.d/vacuum-walld -visudo -cf /etc/sudoers.d/vacuum-wall || err "Invalid sudoers file!" +visudo -cf /etc/sudoers.d/vacuum-walld || err "Invalid sudoers file!" + +# WebUI user no longer has sudo — write minimal sudoers file +install -m 0440 /dev/null /etc/sudoers.d/vacuum-wall 2>/dev/null || true +echo "# WebUI user ($USER_NAME) has no sudo access." > /etc/sudoers.d/vacuum-wall +echo "# Privileged operations are handled by $USER_DAEMON_NAME via the daemon API." >> /etc/sudoers.d/vacuum-wall +visudo -cf /etc/sudoers.d/vacuum-wall || true # --- 6. Install systemd units --- log "Installing systemd units..." +export USER_GROUP +render_template "${PROJECT_DIR}/system/systemd/vacuum-walld.service" \ + | install -m 0644 /dev/stdin /etc/systemd/system/vacuum-walld.service + render_template "${PROJECT_DIR}/system/systemd/vacuum-wall.service" \ | install -m 0644 /dev/stdin /etc/systemd/system/vacuum-wall.service @@ -291,7 +334,7 @@ else -addext "subjectAltName=DNS:$DOMAIN" fi -chown -R "$USER_NAME:$USER_NAME" "$ACME_HOME" +chown -R "$USER_DAEMON_NAME:$USER_GROUP" "$ACME_HOME" # Generate/update htpasswd directly in data/nginx/ HTPASSWD_FILE="${PROJECT_DIR}/data/nginx/.htpasswd" @@ -312,7 +355,7 @@ with open(os.environ['HTFILE'], 'w') as f: warn "Could not generate htpasswd (install apache2-utils or python3-crypt)" fi -chown "$USER_NAME:$USER_NAME" "${PROJECT_DIR}/data/nginx/.htpasswd" +chown "$USER_NAME:$USER_GROUP" "${PROJECT_DIR}/data/nginx/.htpasswd" # Remove default nginx site so vacuum-wall management config takes precedence rm -f /etc/nginx/sites-enabled/default @@ -523,6 +566,8 @@ log "Firewalld rules reloaded" log "Enabling services..." systemctl enable nginx >/dev/null 2>&1 || true log "Enabled nginx" +systemctl enable vacuum-walld >/dev/null 2>&1 || true +log "Enabled vacuum-walld" systemctl enable vacuum-wall >/dev/null 2>&1 || true log "Enabled vacuum-wall" systemctl enable vacuum-wall-acme.timer >/dev/null 2>&1 || true @@ -532,7 +577,30 @@ systemctl enable avahi-daemon >/dev/null 2>&1 || true log "Enabled avahi-daemon" systemctl start avahi-daemon >/dev/null 2>&1 && log "Started avahi-daemon" || warn "Could not start avahi-daemon" +# Start daemon first, then web UI +# Stop vacuum-wall first. vacuum-wall.service Requires=vacuum-walld.service, +# so stopping vacuum-wall triggers a cascade stop of vacuum-walld. The explicit +# stop of vacuum-walld below is redundant but ensures clean teardown. systemctl stop vacuum-wall >/dev/null 2>&1 || true +systemctl stop vacuum-walld >/dev/null 2>&1 || true +systemctl start vacuum-walld >/dev/null 2>&1 && log "Started vacuum-walld daemon" || warn "Could not start vacuum-walld daemon" + +# Wait for daemon socket +_SOCKET="$PROJECT_DIR/data/daemon.sock" +for _i in $(seq 1 10); do + [[ -S "$_SOCKET" ]] && break + sleep 0.5 +done +if [[ ! -S "$_SOCKET" ]]; then + warn "Daemon socket not found at $_SOCKET" +fi + +# Set socket ownership so web UI user can connect +if [[ -S "$_SOCKET" ]]; then + chown "$USER_DAEMON_NAME:$USER_GROUP" "$_SOCKET" 2>/dev/null || true + chmod 0660 "$_SOCKET" 2>/dev/null || true +fi + systemctl start vacuum-wall >/dev/null 2>&1 && log "Started vacuum-wall WebUI" || warn "Could not start vacuum-wall WebUI" nginx -t 2>/dev/null && nginx -s reload 2>/dev/null && log "Reloaded nginx" || \ @@ -545,8 +613,8 @@ if [[ -f "$ACME_HOME/account.conf" ]] && grep -q '^ACME_LEEMAIL=' "$ACME_HOME/ac else log "Registering acme.sh account with email $ACME_EMAIL..." mkdir -p "$ACME_HOME/www" - chown "$USER_NAME:$USER_NAME" "$ACME_HOME/www" - sudo -u "$USER_NAME" env ACME_HOME="$ACME_HOME" HOME="$PROJECT_DIR" \ + chown "$USER_DAEMON_NAME:$USER_GROUP" "$ACME_HOME/www" + sudo -u "$USER_DAEMON_NAME" env ACME_HOME="$ACME_HOME" HOME="$PROJECT_DIR" \ "$ACME_HOME/acme.sh" --home "$ACME_HOME" --config-home "$ACME_HOME" \ --register-account -m "$ACME_EMAIL" 2>/dev/null || \ warn "Could not register acme.sh account (will be done from WebUI)" @@ -560,8 +628,9 @@ echo "============================================" echo "" echo " Management UI: https://$DOMAIN" echo " User: $MGMT_USER" -echo " WebUI service: vacuum-wall.service" -echo " ACME renewal: vacuum-wall-acme.timer" +echo " Daemon service: vacuum-walld.service" +echo " WebUI service: vacuum-wall.service" +echo " ACME renewal: vacuum-wall-acme.timer" echo "" echo " Firewall zones:" if [[ -n "$WAN_IFACE" ]]; then diff --git a/lib/firewall.py b/lib/firewall.py index 86ac2de..17320ba 100644 --- a/lib/firewall.py +++ b/lib/firewall.py @@ -1,21 +1,16 @@ """ -firewall.py - firewalld manager for Vacuum Wall SSL proxy firewall appliance. +firewall.py - firewalld parsing helpers & declarative config for Vacuum Wall. -Wraps firewall-cmd CLI via sudo, manages zones, rules, masquerade/NAT, -and port-forwarding. All mutations are --permanent followed by --reload. - -A JSON snapshot of all rules is persisted at DATA_DIR/rules.json so the -Flask UI can inspect or restore previous configurations. +Pure logic only — no subprocess or sudo calls. +All privileged commands are handled by daemon/handlers/firewall.py. """ import logging -from contextlib import suppress from datetime import UTC, datetime from pathlib import Path from typing import Any -from uuid import uuid4 -from lib.common import load_json, run, save_json +from lib.common import load_json, save_json logger = logging.getLogger(__name__) @@ -33,35 +28,8 @@ DEFAULT_CONFIG: dict[str, Any] = {"zones": {}} # --------------------------------------------------------------------------- -def _reload() -> None: - """Reload firewalld so permanent changes take effect immediately.""" - try: - run(["firewall-cmd", "--reload"], sudo=True) - logger.info("firewalld reloaded") - except RuntimeError as exc: - logger.error("firewalld reload failed: %s", exc) - raise - - -def _gen_id() -> str: - """Generate a short unique identifier (8 hex characters).""" - return uuid4().hex[:8] - - -# --------------------------------------------------------------------------- -# Read-only queries -# --------------------------------------------------------------------------- - - -def get_available_zones() -> list[str]: - """Return the list of all built-in (available) firewalld zone names.""" - output = run(["firewall-cmd", "--get-zones"], sudo=True) - return output.split() - - -def get_active_zones() -> dict[str, list[str]]: - """Return a dict mapping active zone names to their assigned interfaces.""" - output = run(["firewall-cmd", "--get-active-zones"], sudo=True) +def _parse_active_zones(output: str) -> dict[str, list[str]]: + """Parse ``firewall-cmd --get-active-zones`` output.""" zones: dict[str, list[str]] = {} current_zone: str | None = None for raw_line in output.splitlines(): @@ -75,17 +43,30 @@ def get_active_zones() -> dict[str, list[str]]: else zones.get(list(zones.keys())[-1], []) ) for piece in stripped.split(): + if piece.endswith(":"): + continue if current_zone and piece not in current_ifaces: current_ifaces.append(piece) else: - current_zone = stripped + current_zone = stripped.removesuffix(" (default)") zones[current_zone] = [] return zones -def get_zone_info(zone: str) -> dict[str, Any]: - """Return detailed information for *zone*.""" - output = run(["firewall-cmd", f"--zone={zone}", "--list-all"], sudo=True) +def _parse_interfaces(output: str) -> list[str]: + """Parse ``ip -o link show`` output.""" + ifaces: list[str] = [] + for line in output.splitlines(): + if line: + parts = line.split() + if len(parts) >= 2: + name = parts[1].rstrip(":") + ifaces.append(name) + return ifaces + + +def _parse_zone_output(zone: str, output: str) -> dict[str, Any]: + """Parse ``firewall-cmd --zone=Z --list-all`` output.""" info: dict[str, Any] = {"name": zone} for line in output.splitlines(): line = line.strip() @@ -135,444 +116,6 @@ def get_zone_info(zone: str) -> dict[str, Any]: return info -def get_services() -> list[str]: - """Return the list of available service names known to firewalld.""" - output = run(["firewall-cmd", "--get-services"], sudo=True) - return output.split() - - -def get_icmp_blocks() -> list[str]: - """Return the list of available ICMP block names.""" - output = run(["firewall-cmd", "--get-icmptypes"], sudo=True) - return output.split() - - -def get_interfaces() -> list[str]: - """Return the list of network interfaces visible via iproute2.""" - output = run(["ip", "-o", "link", "show"]) - ifaces: list[str] = [] - for line in output.splitlines(): - if line: - parts = line.split() - if len(parts) >= 2: - name = parts[1].rstrip(":") - ifaces.append(name) - return ifaces - - -def get_rich_rules(zone: str) -> list[str]: - """Return the rich rules defined for *zone* as a list of raw strings.""" - output = run(["firewall-cmd", f"--zone={zone}", "--list-rich-rules"], sudo=True) - output = output.strip() - if not output: - return [] - rules: list[str] = [] - current: list[str] = [] - for line in output.splitlines(): - raw = line.rstrip() - if not raw.endswith(";"): - current.append(raw) - else: - current.append(raw) - rules.append(" ".join(current)) - current = [] - if current: - rules.append(" ".join(current)) - return rules - - -# --------------------------------------------------------------------------- -# Zone CRUD -# --------------------------------------------------------------------------- - - -def create_zone(zone: str, target: str = "default") -> None: - """Create a new permanent zone in firewalld.""" - run( - [ - "firewall-cmd", - f"--zone={zone}", - f"--set-target={target}", - "--permanent", - ], - sudo=True, - ) - _reload() - logger.info("Firewall zone '%s' created (target=%s)", zone, target) - - -def delete_zone(zone: str) -> None: - """Delete an existing zone.""" - run(["firewall-cmd", f"--zone={zone}", "--delete", "--permanent"], sudo=True) - _reload() - logger.info("Firewall zone '%s' deleted", zone) - - -# --------------------------------------------------------------------------- -# Interface assignment -# --------------------------------------------------------------------------- - - -def set_zone_interfaces(zone: str, interfaces: list[str]) -> None: - """Assign *interfaces* to *zone*, replacing any existing assignments.""" - try: - current = get_zone_info(zone).get("interfaces", []) - except Exception: - current = [] - for iface in current: - run( - [ - "firewall-cmd", - f"--zone={zone}", - "--remove-interface=" + iface, - "--permanent", - ], - sudo=True, - check=False, - ) - - for iface in interfaces: - run( - [ - "firewall-cmd", - f"--zone={zone}", - "--add-interface=" + iface, - "--permanent", - ], - sudo=True, - ) - _reload() - logger.info("Zone '%s' interfaces set to %s", zone, interfaces) - - -def add_zone_interface(zone: str, iface: str) -> None: - """Add a single interface to *zone*.""" - run( - [ - "firewall-cmd", - f"--zone={zone}", - "--add-interface=" + iface, - "--permanent", - ], - sudo=True, - ) - _reload() - logger.info("Interface '%s' added to zone '%s'", iface, zone) - - -def remove_zone_interface(zone: str, iface: str) -> None: - """Remove a single interface from *zone*.""" - run( - [ - "firewall-cmd", - f"--zone={zone}", - "--remove-interface=" + iface, - "--permanent", - ], - sudo=True, - ) - _reload() - logger.info("Interface '%s' removed from zone '%s'", iface, zone) - - -# --------------------------------------------------------------------------- -# Service management -# --------------------------------------------------------------------------- - - -def set_zone_services(zone: str, services: list[str]) -> None: - """Set services for *zone*, replacing any previously allowed services.""" - current = get_zone_info(zone).get("services", []) - for svc in current: - run( - [ - "firewall-cmd", - f"--zone={zone}", - f"--remove-service={svc}", - "--permanent", - ], - sudo=True, - check=False, - ) - - for svc in services: - run( - [ - "firewall-cmd", - f"--zone={zone}", - f"--add-service={svc}", - "--permanent", - ], - sudo=True, - ) - _reload() - logger.info("Zone '%s' services set to %s", zone, services) - - -def add_zone_service(zone: str, service: str) -> None: - """Add a single service to *zone*.""" - run( - [ - "firewall-cmd", - f"--zone={zone}", - f"--add-service={service}", - "--permanent", - ], - sudo=True, - ) - _reload() - logger.info("Service '%s' added to zone '%s'", service, zone) - - -def remove_zone_service(zone: str, service: str) -> None: - """Remove a single service from *zone*.""" - run( - [ - "firewall-cmd", - f"--zone={zone}", - f"--remove-service={service}", - "--permanent", - ], - sudo=True, - ) - _reload() - logger.info("Service '%s' removed from zone '%s'", service, zone) - - -# --------------------------------------------------------------------------- -# Rich rules -# --------------------------------------------------------------------------- - - -def add_rich_rule(zone: str, rule: str) -> dict[str, Any]: - """Add a rich rule to *zone* and persist to declarative config.""" - run( - [ - "firewall-cmd", - f"--zone={zone}", - "--add-rich-rule=" + rule, - "--permanent", - ], - sudo=True, - ) - _reload() - _persist_rich_rule(zone, rule) - rule_entry = _get_rich_rule_entry(zone, rule) - logger.info("Rich rule added to zone '%s': %s", zone, rule[:80]) - return rule_entry - - -def remove_rich_rule(zone: str, rule: str) -> None: - """Remove a rich rule from *zone*.""" - run( - [ - "firewall-cmd", - f"--zone={zone}", - "--remove-rich-rule=" + rule, - "--permanent", - ], - sudo=True, - ) - _reload() - _unpersist_rich_rule(zone, rule) - logger.info("Rich rule removed from zone '%s': %s", zone, rule[:80]) - - -def _persist_rich_rule(zone: str, rule: str) -> dict[str, Any]: - """Add a rich rule to the declarative config with a generated id.""" - cfg = get_config() - cfg.setdefault("zones", {}) - cfg["zones"].setdefault(zone, {}) - cfg["zones"][zone].setdefault("rich_rules", []) - existing_rules = cfg["zones"][zone]["rich_rules"] - rule_id = _gen_id() - entry = {"id": rule_id, "rule": rule} - existing_rules.append(entry) - save_config(cfg) - return entry - - -def _unpersist_rich_rule(zone: str, rule: str) -> None: - """Remove a rich rule from the declarative config by rule string.""" - cfg = get_config() - zone_cfg = cfg.get("zones", {}).get(zone, {}) - rules = zone_cfg.get("rich_rules", []) - zone_cfg["rich_rules"] = [r for r in rules if r.get("rule") != rule] - save_config(cfg) - - -def _get_rich_rule_entry(zone: str, rule: str) -> dict[str, Any]: - """Look up a rich rule entry in the declarative config.""" - cfg = get_config() - for r in cfg.get("zones", {}).get(zone, {}).get("rich_rules", []): - if r.get("rule") == rule: - return r - return {"rule": rule} - - -def remove_rich_rule_by_id(zone: str, rule_id: str) -> None: - """Remove a rich rule from *zone* by its config id.""" - cfg = get_config() - zone_cfg = cfg.get("zones", {}).get(zone, {}) - entry = None - for r in zone_cfg.get("rich_rules", []): - if r.get("id") == rule_id: - entry = r - break - if entry is None: - raise ValueError(f"Rich rule '{rule_id}' not found in zone '{zone}'") - rule = entry["rule"] - remove_rich_rule(zone, rule) - - -# --------------------------------------------------------------------------- -# Masquerade (NAT) -# --------------------------------------------------------------------------- - - -def set_masquerade(zone: str, enable: bool) -> None: - """Enable or disable masquerade (source-NAT) on *zone*.""" - action = "--add-masquerade" if enable else "--remove-masquerade" - run(["firewall-cmd", f"--zone={zone}", action, "--permanent"], sudo=True) - _reload() - logger.info("Masquerade %s on zone '%s'", "enabled" if enable else "disabled", zone) - - -# --------------------------------------------------------------------------- -# Port forwarding -# --------------------------------------------------------------------------- - - -def add_forward_port( - zone: str, - port: int, - protocol: str, - toaddr: str | None = None, - toport: int | None = None, -) -> dict[str, Any]: - """Add a port forwarding rule to *zone* and persist to declarative config.""" - fwd = f"port={port}/proto={protocol}" - if toaddr and toport: - fwd += f"/toaddr={toaddr}/toport={toport}" - elif toport: - fwd += f"/toport={toport}" - else: - fwd += f"/toaddr={toaddr}" if toaddr else "" - - run( - [ - "firewall-cmd", - f"--zone={zone}", - f"--add-forward-port={fwd}", - "--permanent", - ], - sudo=True, - ) - _reload() - _persist_forward_port(zone, port, protocol, toaddr, toport) - fp_entry = _get_forward_port_entry(zone, port, protocol) - logger.info("Port forward added to zone '%s': %s", zone, fwd) - return fp_entry - - -def remove_forward_port( - zone: str, - port: int, - protocol: str, - toaddr: str | None = None, - toport: int | None = None, -) -> None: - """Remove a previously added port-forwarding rule from *zone*.""" - fwd = f"port={port}/proto={protocol}" - if toaddr and toport: - fwd += f"/toaddr={toaddr}/toport={toport}" - elif toport: - fwd += f"/toport={toport}" - else: - fwd += f"/toaddr={toaddr}" if toaddr else "" - - run( - [ - "firewall-cmd", - f"--zone={zone}", - f"--remove-forward-port={fwd}", - "--permanent", - ], - sudo=True, - ) - _reload() - _unpersist_forward_port(zone, port, protocol) - logger.info("Port forward removed from zone '%s': %s", zone, fwd) - - -def _persist_forward_port( - zone: str, - port: int, - protocol: str, - toaddr: str | None = None, - toport: int | None = None, -) -> dict[str, Any]: - """Add a forward port to the declarative config with a generated id.""" - cfg = get_config() - cfg.setdefault("zones", {}) - cfg["zones"].setdefault(zone, {}) - cfg["zones"][zone].setdefault("forward_ports", []) - fp_id = _gen_id() - entry: dict[str, Any] = { - "id": fp_id, - "port": port, - "proto": protocol, - } - if toaddr: - entry["toaddr"] = toaddr - if toport: - entry["toport"] = toport - cfg["zones"][zone]["forward_ports"].append(entry) - save_config(cfg) - return entry - - -def _unpersist_forward_port(zone: str, port: int, protocol: str) -> None: - """Remove a forward port from the declarative config by port+proto.""" - cfg = get_config() - fps = cfg.get("zones", {}).get(zone, {}).get("forward_ports", []) - cfg.setdefault("zones", {}).setdefault(zone, {}) - cfg["zones"][zone]["forward_ports"] = [ - fp for fp in fps if not (fp.get("port") == port and fp.get("proto") == protocol) - ] - save_config(cfg) - - -def _get_forward_port_entry(zone: str, port: int, protocol: str) -> dict[str, Any]: - """Look up a forward port entry in the declarative config.""" - cfg = get_config() - for fp in cfg.get("zones", {}).get(zone, {}).get("forward_ports", []): - if fp.get("port") == port and fp.get("proto") == protocol: - return fp - entry: dict[str, Any] = {"port": port, "proto": protocol} - return entry - - -def remove_forward_port_by_id(zone: str, port: int, protocol: str) -> None: - """Remove a forward port from *zone* by port+proto (id used by API layer).""" - cfg = get_config() - zone_cfg = cfg.get("zones", {}).get(zone, {}) - entry = None - for fp in zone_cfg.get("forward_ports", []): - if fp.get("port") == port and fp.get("proto") == protocol: - entry = fp - break - if entry is None: - raise ValueError(f"Forward port {port}/{protocol} not found in zone '{zone}'") - remove_forward_port( - zone, - port, - protocol, - toaddr=entry.get("toaddr"), - toport=entry.get("toport"), - ) - - # --------------------------------------------------------------------------- # Helpers for parsing forward-port lines # --------------------------------------------------------------------------- @@ -608,36 +151,16 @@ def _parse_forward_ports(value: str) -> list[dict[str, Any]]: # --------------------------------------------------------------------------- -def get_state() -> dict[str, Any]: - """Return the complete current state of firewalld as a Python dict.""" - zones: dict[str, dict[str, Any]] = {} - for name in get_available_zones(): - try: - zones[name] = get_zone_info(name) - except Exception: - continue - - return { - "active_zones": get_active_zones(), - "interfaces": get_interfaces(), - "available_services": get_services(), - "zones": zones, - "rich_rules": {name: get_rich_rules(name) for name in zones}, - "timestamp": _now_iso(), - } - - def _now_iso() -> str: """Return the current UTC time as an ISO-8601 string.""" return datetime.now(UTC).isoformat() -def save_backup() -> str: - """Capture the full state and write it to RULES_FILE on disk.""" - state = get_state() +def save_backup(state: dict[str, Any]) -> str: + """Write *state* to RULES_FILE on disk.""" save_json(RULES_FILE, state) logger.info("Firewall state backup saved to %s", RULES_FILE) - return RULES_FILE + return str(RULES_FILE) def load_backup() -> dict[str, Any]: @@ -645,60 +168,6 @@ def load_backup() -> dict[str, Any]: return load_json(RULES_FILE) -def restore_backup(state: dict[str, Any]) -> None: - """Apply the zone configuration described in *state*.""" - zones_cfg = state.get("zones", {}) - for zone_name, zinfo in zones_cfg.items(): - if zone_name not in get_available_zones(): - target = zinfo.get("target", "default") - create_zone(zone_name, target) - - services = zinfo.get("services", []) - set_zone_services(zone_name, services) - - interfaces = zinfo.get("interfaces", []) - set_zone_interfaces(zone_name, interfaces) - - if zinfo.get("masquerade"): - set_masquerade(zone_name, True) - - for fp in zinfo.get("forward-ports", []): - if isinstance(fp, str): - fp_str = fp - else: - parts = [f"port={fp['port']}", f"proto={fp['proto']}"] - if "toaddr" in fp: - parts.append(f"toaddr={fp['toaddr']}") - if "toport" in fp: - parts.append(f"toport={fp['toport']}") - fp_str = "/".join(parts) - run( - [ - "firewall-cmd", - f"--zone={zone_name}", - f"--add-forward-port={fp_str}", - "--permanent", - ], - sudo=True, - check=False, - ) - - for rule in zinfo.get("rich-rules", []): - run( - [ - "firewall-cmd", - f"--zone={zone_name}", - f"--add-rich-rule={rule}", - "--permanent", - ], - sudo=True, - check=False, - ) - - _reload() - logger.info("Firewall backup restored, %d zones processed", len(zones_cfg)) - - # --------------------------------------------------------------------------- # Declarative config management (config/firewall/config.json) # --------------------------------------------------------------------------- @@ -745,12 +214,16 @@ def _live_target_to_config(target: str) -> str: return "DEFAULT" -def config_pending() -> dict[str, Any]: - """Compare declarative config against live firewalld state, return diff.""" - cfg = get_config() - live_state = get_state() +def _compute_pending_changes( + cfg: dict[str, Any], + live_zones: dict[str, dict[str, Any]], +) -> dict[str, Any]: + """Compare declarative config against live zone state, return diff. + + Pure function — no subprocess calls. Caller is responsible for providing + live state (typically from the daemon). + """ cfg_zones = cfg.get("zones", {}) - live_zones = live_state.get("zones", {}) changes: list[dict[str, Any]] = [] unknown_live: dict[str, Any] = {} @@ -851,93 +324,15 @@ def config_pending() -> dict[str, Any]: } -def config_apply() -> dict[str, Any]: - """Apply the declarative config to live firewalld.""" +def config_pending(state: dict[str, Any]) -> dict[str, Any]: + """Compare declarative config against firewalld live state, return diff. + + *state* is required — the daemon always passes live state via + `daemon.handlers.firewall.get_state()`. + """ cfg = get_config() - cfg_zones = cfg.get("zones", {}) - - save_backup() - - available = get_available_zones() - applied: list[str] = [] - for zone_name, zone_cfg in cfg_zones.items(): - need_create = zone_name not in available - - if need_create: - target = _normalize_target(zone_cfg.get("target", "DEFAULT")) - create_zone(zone_name, target) - else: - desired_target = _normalize_target(zone_cfg.get("target", "DEFAULT")) - if desired_target != "default": - with suppress(RuntimeError): - run( - [ - "firewall-cmd", - f"--zone={zone_name}", - f"--set-target={desired_target}", - "--permanent", - ], - sudo=True, - check=False, - ) - - set_zone_services(zone_name, zone_cfg.get("services", [])) - set_zone_interfaces(zone_name, zone_cfg.get("interfaces", [])) - - mq = zone_cfg.get("masquerade", False) - if mq is not None: - set_masquerade(zone_name, mq) - - for rule_entry in zone_cfg.get("rich_rules", []): - rule_str = ( - rule_entry.get("rule", "") - if isinstance(rule_entry, dict) - else str(rule_entry) - ) - if rule_str: - run( - [ - "firewall-cmd", - f"--zone={zone_name}", - f"--add-rich-rule={rule_str}", - "--permanent", - ], - sudo=True, - check=False, - ) - - for fp_entry in zone_cfg.get("forward_ports", []): - if isinstance(fp_entry, str): - fp_str = fp_entry - else: - parts = [f"port={fp_entry['port']}", f"proto={fp_entry['proto']}"] - if "toaddr" in fp_entry: - parts.append(f"toaddr={fp_entry['toaddr']}") - if "toport" in fp_entry: - parts.append(f"toport={fp_entry['toport']}") - fp_str = "/".join(parts) - run( - [ - "firewall-cmd", - f"--zone={zone_name}", - f"--add-forward-port={fp_str}", - "--permanent", - ], - sudo=True, - check=False, - ) - - applied.append(zone_name) - - _reload() - backup_path = save_backup() - - logger.info("Firewall config applied to %d zones", len(applied)) - - return { - "applied_zones": applied, - "backup": backup_path, - } + live_zones = state.get("zones", {}) + return _compute_pending_changes(cfg, live_zones) __all__ = [ @@ -946,35 +341,18 @@ __all__ = [ "DATA_DIR", "DEFAULT_CONFIG", "RULES_FILE", - "_reload", - "add_forward_port", - "add_rich_rule", - "add_zone_interface", - "add_zone_service", - "config_apply", + "_compute_pending_changes", + "_ensure_config_file", + "_live_target_to_config", + "_normalize_target", + "_now_iso", + "_parse_active_zones", + "_parse_forward_ports", + "_parse_interfaces", + "_parse_zone_output", "config_pending", - "create_zone", - "delete_zone", - "get_active_zones", - "get_available_zones", "get_config", - "get_icmp_blocks", - "get_interfaces", - "get_rich_rules", - "get_services", - "get_state", - "get_zone_info", "load_backup", - "remove_forward_port", - "remove_forward_port_by_id", - "remove_rich_rule", - "remove_rich_rule_by_id", - "remove_zone_interface", - "remove_zone_service", - "restore_backup", "save_backup", "save_config", - "set_masquerade", - "set_zone_interfaces", - "set_zone_services", ] diff --git a/pyproject.toml b/pyproject.toml index ca3eaf4..23f03b5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,8 +9,13 @@ description = "SSL proxy / firewall appliance with zone-based policies" requires-python = ">=3.13" dependencies = [ "Flask>=3.0,<4.0", + "aiohttp>=3.9,<4.0", + "requests-unixsocket>=0.2,<1.0", ] +[project.scripts] +vacuum-walld = "daemon.server:main" + [project.optional-dependencies] dev = [ "ruff>=0.4.0", @@ -19,7 +24,7 @@ dev = [ ] [tool.setuptools.packages.find] -include = ["lib*", "webui*"] +include = ["lib*", "webui*", "daemon*"] [tool.ruff] target-version = "py313" diff --git a/system/acme-deploy.py b/system/acme-deploy.py new file mode 100644 index 0000000..d8c31ae --- /dev/null +++ b/system/acme-deploy.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python3 +"""acme-deploy.py — Deploy hook for acme.sh (Vacuum Wall). + +Called by acme.sh after every successful certificate issue or renewal. +Reloads nginx via the daemon API so acme.sh never touches sudo directly. +""" + +import logging +import os +import sys + +try: + import requests_unixsocket + + from daemon.client import post + + logging.basicConfig(level=logging.INFO) + project_dir = os.environ.get("INSTALL_DIR", os.path.dirname(os.path.dirname(__file__))) + socket_path = os.environ.get( + "VACUUM_WALLD_SOCKET", + os.path.join(project_dir, "data", "daemon.sock"), + ) + post("/nginx/reload", socket_path=socket_path) + sys.exit(0) +except Exception as exc: + logging.error("acme-deploy hook failed: %s", exc) + sys.exit(0) diff --git a/system/sudoers.d/vacuum-wall b/system/sudoers.d/vacuum-wall index 2ae4853..49d0e41 100644 --- a/system/sudoers.d/vacuum-wall +++ b/system/sudoers.d/vacuum-wall @@ -1,34 +1,2 @@ -# Defaults directives -Defaults:{{ USER_NAME }} !requiretty -Defaults:{{ USER_NAME }} secure_path="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" - -# Firewall management -{{ USER_NAME }} ALL=(root) NOPASSWD: /usr/bin/firewall-cmd * - -# Nginx management -{{ USER_NAME }} ALL=(root) NOPASSWD: /usr/sbin/nginx -s reload -{{ USER_NAME }} ALL=(root) NOPASSWD: /usr/sbin/nginx -t -{{ USER_NAME }} ALL=(root) NOPASSWD: /usr/bin/cp -- * /etc/nginx/ -{{ USER_NAME }} ALL=(root) NOPASSWD: /usr/bin/cp -- * /etc/nginx/conf.d/ -{{ USER_NAME }} ALL=(root) NOPASSWD: /usr/bin/cp -- * /etc/nginx/snippets/ -{{ USER_NAME }} ALL=(root) NOPASSWD: /usr/bin/rm /etc/nginx/conf.d/vacuum-wall.conf -{{ USER_NAME }} ALL=(root) NOPASSWD: /usr/bin/rm /etc/nginx/snippets/vacuum-wall-ssl.conf -{{ USER_NAME }} ALL=(root) NOPASSWD: /usr/bin/chown root\:root /etc/nginx/snippets/vacuum-wall-ssl.conf - -# Dnsmasq management -{{ USER_NAME }} ALL=(root) NOPASSWD: /usr/bin/systemctl reload dnsmasq -{{ USER_NAME }} ALL=(root) NOPASSWD: /usr/bin/systemctl is-active dnsmasq -{{ USER_NAME }} ALL=(root) NOPASSWD: /usr/bin/cp -- * /etc/dnsmasq.d/ -{{ USER_NAME }} ALL=(root) NOPASSWD: /usr/bin/cat /var/lib/dnsmasq/dnsmasq.leases -{{ USER_NAME }} ALL=(root) NOPASSWD: /usr/bin/tee /etc/dnsmasq.d/vacuum-wall.conf - -# WireGuard management -{{ USER_NAME }} ALL=(root) NOPASSWD: /usr/bin/wg-quick * -{{ USER_NAME }} ALL=(root) NOPASSWD: /usr/bin/wg * -{{ USER_NAME }} ALL=(root) NOPASSWD: /usr/bin/cp -- * /etc/wireguard/ - -# Misc -{{ USER_NAME }} ALL=(root) NOPASSWD: /usr/bin/journalctl --unit=* -n * -{{ USER_NAME }} ALL=(root) NOPASSWD: /usr/bin/cat /var/log/nginx/* -{{ USER_NAME }} ALL=(root) NOPASSWD: /usr/bin/mkdir -p /etc/dnsmasq.d -{{ USER_NAME }} ALL=(root) NOPASSWD: /usr/bin/mkdir -p /etc/wireguard +# WebUI user ({{ USER_NAME }}) no longer has sudo access. +# Privileged operations are handled by vacuum-walld via the daemon API. diff --git a/system/sudoers.d/vacuum-walld b/system/sudoers.d/vacuum-walld new file mode 100644 index 0000000..6a9c935 --- /dev/null +++ b/system/sudoers.d/vacuum-walld @@ -0,0 +1,38 @@ +# Defaults directives +Defaults:{{ USER_DAEMON_NAME }} !requiretty +Defaults:{{ USER_DAEMON_NAME }} secure_path="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" + +# Firewall management +{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/firewall-cmd * + +# Nginx management +{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/sbin/nginx -s reload +{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/sbin/nginx -t +{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/cp -- * /etc/nginx/ +{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/cp -- * /etc/nginx/conf.d/ +{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/cp -- * /etc/nginx/snippets/ +{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/rm /etc/nginx/conf.d/vacuum-wall.conf +{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/rm /etc/nginx/snippets/vacuum-wall-ssl.conf +{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/chown root\:root /etc/nginx/snippets/vacuum-wall-ssl.conf + +# Dnsmasq management +{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/systemctl reload dnsmasq +{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/systemctl is-active dnsmasq +{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/cp -- * /etc/dnsmasq.d/ +{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/cat /var/lib/dnsmasq/dnsmasq.leases +{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/tee /etc/dnsmasq.d/vacuum-wall.conf +{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/mkdir -p /etc/dnsmasq.d + +# WireGuard management +{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/wg-quick * +{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/wg * +{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/cp -- * /etc/wireguard/ +{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/chown root\:root /etc/wireguard/wg0.conf + +# Network interface queries +{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/sbin/ip -o link show +{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/sbin/ip -o addr show + +# Misc +{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/journalctl --unit=* -n * +{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/cat /var/log/nginx/* diff --git a/system/systemd/vacuum-wall.service b/system/systemd/vacuum-wall.service index a078805..a9721fa 100644 --- a/system/systemd/vacuum-wall.service +++ b/system/systemd/vacuum-wall.service @@ -1,13 +1,14 @@ [Unit] Description=Vacuum Wall Management WebUI Documentation=https://github.com/wall/vacuum-wall -After=network.target firewalld.service nginx.service dnsmasq.service +Requires=vacuum-walld.service +After=network.target firewalld.service nginx.service dnsmasq.service vacuum-walld.service Wants=firewalld.service [Service] Type=simple User={{ USER_NAME }} -Group={{ USER_NAME }} +Group={{ USER_GROUP }} WorkingDirectory={{ PROJECT_DIR }} ExecStart={{ PROJECT_DIR }}/.venv/bin/python webui/server.py Restart=on-failure diff --git a/system/systemd/vacuum-walld.service b/system/systemd/vacuum-walld.service new file mode 100644 index 0000000..63ceed7 --- /dev/null +++ b/system/systemd/vacuum-walld.service @@ -0,0 +1,42 @@ +[Unit] +Description=Vacuum Wall Daemon (privileged backend) +Documentation=https://github.com/wall/vacuum-wall +After=network.target firewalld.service +Wants=firewalld.service + +[Service] +Type=simple +User={{ USER_DAEMON_NAME }} +Group={{ USER_GROUP }} +WorkingDirectory={{ PROJECT_DIR }} +ExecStart={{ PROJECT_DIR }}/.venv/bin/python -m daemon +Restart=on-failure +RestartSec=5 +Environment=PATH=/usr/local/bin:/usr/bin +Environment=PYTHONUNBUFFERED=1 +Environment=ACME_HOME={{ PROJECT_DIR }}/data/acme +Environment=HOME={{ PROJECT_DIR }} + +# Security hardening +ProtectSystem=strict +ReadWritePaths={{ PROJECT_DIR }} /tmp +PrivateTmp=yes +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectControlGroups=yes +ProtectHostname=yes +RestrictSUIDSGID=yes +MemoryDenyWriteExecute=yes +RestrictRealtime=yes +RestrictNamespaces=yes +LockPersonality=yes +SystemCallFilter=@system-service +PrivateDevices=yes + +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 +IPAddressDeny=any +IPAddressAllow=localhost +NoNewPrivileges=yes + +[Install] +WantedBy=multi-user.target diff --git a/tests/test_api.py b/tests/test_api.py index 327e97f..fc607c7 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,30 +1,56 @@ """ API integration tests — all blueprints tested via a single Flask app fixture. + +Mocks daemon.client in each blueprint's module namespace to avoid needing +a running daemon. """ -from unittest.mock import patch +from unittest.mock import patch as _patch import pytest -from webui.api.certs import bp as certs_bp -from webui.api.dhcp import bp as dhcp_bp -from webui.api.firewall import bp -from webui.api.proxy import bp as proxy_bp -from webui.api.wireguard import bp as wg_bp + +def _fw(func, **kw): + """Patch daemon.client.{func} in the firewall blueprint namespace.""" + return _patch(f"webui.api.firewall.{func}", **kw) + + +def _dh(func, **kw): + """Patch daemon.client.{func} in the dhcp blueprint namespace.""" + return _patch(f"webui.api.dhcp.{func}", **kw) + + +def _px(func, **kw): + """Patch daemon.client.{func} in the proxy blueprint namespace.""" + return _patch(f"webui.api.proxy.{func}", **kw) + + +def _ce(func, **kw): + """Patch daemon.client.{func} in the certs blueprint namespace.""" + return _patch(f"webui.api.certs.{func}", **kw) + + +def _wg(func, **kw): + """Patch daemon.client.{func} in the wireguard blueprint namespace.""" + return _patch(f"webui.api.wireguard.{func}", **kw) @pytest.fixture def client(): from flask import Flask - app = Flask(__name__) + from webui.api.certs import bp as certs_bp + from webui.api.dhcp import bp as dhcp_bp + from webui.api.firewall import bp as firewall_bp + from webui.api.proxy import bp as proxy_bp + from webui.api.wireguard import bp as wg_bp - app.register_blueprint(bp, url_prefix="/api/firewall") + app = Flask(__name__) + app.register_blueprint(firewall_bp, url_prefix="/api/firewall") app.register_blueprint(dhcp_bp, url_prefix="/api/dhcp") app.register_blueprint(proxy_bp, url_prefix="/api/proxy") app.register_blueprint(certs_bp, url_prefix="/api/certs") app.register_blueprint(wg_bp, url_prefix="/api/wireguard") - return app.test_client() @@ -34,20 +60,21 @@ def client(): class TestFirewallListZones: - @patch("webui.api.firewall.get_active_zones") - @patch("webui.api.firewall.get_available_zones") - def test_success(self, mock_available, mock_active, client): - mock_active.return_value = {"public": ["eth0"]} - mock_available.return_value = ["public", "internal"] + @_fw("get") + def test_success(self, mock_get, client): + mock_get.return_value = { + "active": {"public": ["eth0"]}, + "available": ["public", "internal"], + } resp = client.get("/api/firewall/zones") assert resp.status_code == 200 data = resp.get_json() assert data["ok"] is True assert "public" in data["data"]["active"] - @patch("webui.api.firewall.get_active_zones") - def test_runtime_error(self, mock_active, client): - mock_active.side_effect = RuntimeError("no sudo") + @_fw("get") + def test_runtime_error(self, mock_get, client): + mock_get.side_effect = RuntimeError("no sudo") resp = client.get("/api/firewall/zones") assert resp.status_code == 500 data = resp.get_json() @@ -55,11 +82,9 @@ class TestFirewallListZones: class TestFirewallZoneDetails: - @patch("webui.api.firewall.get_zone_info") - @patch("webui.api.firewall.get_available_zones") - def test_success(self, mock_available, mock_info, client): - mock_available.return_value = ["public", "internal"] - mock_info.return_value = {"name": "public", "services": ["ssh"]} + @_fw("get") + def test_success(self, mock_get, client): + mock_get.return_value = {"name": "public", "services": ["ssh"]} resp = client.get("/api/firewall/zones/public") assert resp.status_code == 200 data = resp.get_json() @@ -67,11 +92,11 @@ class TestFirewallZoneDetails: class TestFirewallCreateZone: - @patch("webui.api.firewall.create_zone") - @patch("webui.api.firewall.get_available_zones") - def test_success(self, mock_zones, mock_create, client): - mock_zones.return_value = ["public", "internal"] - mock_create.return_value = None + @_fw("post") + @_fw("get") + def test_success(self, mock_get, mock_post, client): + mock_get.return_value = {"active": {}, "available": ["public", "internal"]} + mock_post.return_value = {"zone": "dmz"} resp = client.post( "/api/firewall/zones", json={"name": "dmz", "target": "default"}, @@ -91,25 +116,29 @@ class TestFirewallCreateZone: class TestFirewallDeleteZone: - @patch("webui.api.firewall.delete_zone") - @patch("webui.api.firewall.get_available_zones") - def test_success(self, mock_zones, mock_delete, client): - mock_zones.return_value = ["public", "dmz"] - mock_delete.return_value = None + @_fw("delete") + def test_success(self, mock_delete, client): + mock_delete.return_value = {"zone": "dmz"} resp = client.delete("/api/firewall/zones/dmz") assert resp.status_code == 200 - @patch("webui.api.firewall.get_available_zones") - def test_not_found(self, mock_zones, client): - mock_zones.return_value = ["public"] + @_fw("delete") + def test_not_found(self, mock_delete, client): + from daemon.client import NotFound + + mock_delete.side_effect = NotFound("Zone does not exist") resp = client.delete("/api/firewall/zones/dmz") assert resp.status_code == 404 class TestFirewallRichRules: - @patch("webui.api.firewall.add_rich_rule") - def test_add(self, mock_add, client): - mock_add.return_value = {"id": "abc123", "rule": "rule accept"} + @_fw("post") + def test_add(self, mock_post, client): + mock_post.return_value = { + "id": "abc123", + "rule": "rule accept", + "zone": "public", + } resp = client.post( "/api/firewall/rich-rules", json={ @@ -126,55 +155,74 @@ class TestFirewallRichRules: resp = client.post("/api/firewall/rich-rules", json={}) assert resp.status_code == 400 - @patch("webui.api.firewall.get_rich_rules") - @patch("webui.api.firewall.get_config") - def test_list(self, mock_cfg, mock_list, client): - mock_list.return_value = ["rule1"] - mock_cfg.return_value = { - "zones": {"public": {"rich_rules": [{"id": "a1", "rule": "rule1"}]}} - } + @_fw("get") + def test_list(self, mock_get, client): + mock_get.return_value = [{"id": "a1", "rule": "rule1"}] resp = client.get("/api/firewall/rich-rules/public") assert resp.status_code == 200 data = resp.get_json() assert isinstance(data["data"], list) - @patch("webui.api.firewall.remove_rich_rule_by_id") - def test_remove_by_id(self, mock_remove, client): - mock_remove.return_value = None + @_fw("delete") + def test_remove_by_id(self, mock_delete, client): + mock_delete.return_value = {"zone": "public", "id": "abc123"} resp = client.delete("/api/firewall/rich-rules/public/abc123") assert resp.status_code == 200 data = resp.get_json() assert data["ok"] is True - @patch("webui.api.firewall.remove_rich_rule_by_id") - def test_remove_not_found(self, mock_remove, client): - mock_remove.side_effect = ValueError("not found") + @_fw("delete") + def test_remove_not_found(self, mock_delete, client): + from daemon.client import NotFound + + mock_delete.side_effect = NotFound("not found") resp = client.delete("/api/firewall/rich-rules/public/abc123") assert resp.status_code == 404 class TestFirewallServices: - @patch("webui.api.firewall.get_services") - def test_list(self, mock_services, client): - mock_services.return_value = ["ssh", "http", "dns"] + @_fw("get") + def test_list(self, mock_get, client): + mock_get.return_value = ["ssh", "http", "dns"] resp = client.get("/api/firewall/services") assert resp.status_code == 200 assert resp.get_json()["data"] == ["ssh", "http", "dns"] class TestFirewallInterfaces: - @patch("webui.api.firewall.get_interfaces") - def test_list(self, mock_ifaces, client): - mock_ifaces.return_value = ["eth0", "eth1"] + @_fw("get") + def test_list(self, mock_get, client): + mock_get.return_value = [ + { + "name": "eth0", + "mac": "aa:bb:cc:dd:ee:00", + "state": "UP", + "mtu": 1500, + "ips": ["192.168.1.1/24"], + "ipv6": [], + "zone": "internal", + }, + { + "name": "eth1", + "mac": "aa:bb:cc:dd:ee:01", + "state": "UP", + "mtu": 1500, + "ips": ["10.0.0.1/24"], + "ipv6": [], + "zone": "public", + }, + ] resp = client.get("/api/firewall/interfaces") assert resp.status_code == 200 - assert resp.get_json()["data"] == ["eth0", "eth1"] + data = resp.get_json()["data"] + assert len(data) == 2 + assert data[0]["name"] == "eth0" class TestFirewallMasquerade: - @patch("webui.api.firewall.set_masquerade") - def test_enable(self, mock_set, client): - mock_set.return_value = None + @_fw("post") + def test_enable(self, mock_post, client): + mock_post.return_value = {"zone": "internal", "masquerade": True} resp = client.post( "/api/firewall/masquerade", json={"zone": "internal", "enable": True}, @@ -187,9 +235,14 @@ class TestFirewallMasquerade: class TestFirewallForwardPort: - @patch("webui.api.firewall.add_forward_port") - def test_add(self, mock_add, client): - mock_add.return_value = {"id": "fp1", "port": 443, "proto": "tcp"} + @_fw("post") + def test_add(self, mock_post, client): + mock_post.return_value = { + "id": "fp1", + "port": 443, + "proto": "tcp", + "zone": "public", + } resp = client.post( "/api/firewall/forward-port", json={"zone": "public", "port": 443, "proto": "tcp"}, @@ -205,20 +258,52 @@ class TestFirewallForwardPort: ) assert resp.status_code == 400 - @patch("webui.api.firewall.remove_forward_port_by_id") - def test_remove_by_id(self, mock_remove, client): - mock_remove.return_value = None + @_fw("delete") + def test_remove_by_id(self, mock_delete, client): + mock_delete.return_value = {"zone": "public", "port": 443, "proto": "tcp"} resp = client.delete("/api/firewall/forward-port/public/443/tcp") assert resp.status_code == 200 data = resp.get_json() assert data["ok"] is True - @patch("webui.api.firewall.remove_forward_port_by_id") - def test_remove_not_found(self, mock_remove, client): - mock_remove.side_effect = ValueError("not found") + @_fw("delete") + def test_remove_not_found(self, mock_delete, client): + from daemon.client import NotFound + + mock_delete.side_effect = NotFound("not found") resp = client.delete("/api/firewall/forward-port/public/999/tcp") assert resp.status_code == 404 + def test_add_value_error(self, client): + resp = client.post( + "/api/firewall/forward-port", + json={"zone": "public", "port": "not_a_number", "proto": "tcp"}, + ) + assert resp.status_code == 400 + + +class TestFirewallConfigApply: + @_fw("post") + def test_config_apply_success(self, mock_post, client): + mock_post.return_value = { + "applied_zones": ["public"], + "backup": "/tmp/rules.json", + } + resp = client.post("/api/firewall/config/apply") + assert resp.status_code == 200 + data = resp.get_json() + assert data["ok"] is True + assert "public" in data["data"]["applied_zones"] + + @_fw("post") + def test_config_apply_error(self, mock_post, client): + mock_post.side_effect = RuntimeError("apply failed") + resp = client.post("/api/firewall/config/apply") + assert resp.status_code == 500 + data = resp.get_json() + assert data["ok"] is False + assert data["error"] == "apply failed" + # ============================================================================ # DHCP @@ -226,14 +311,15 @@ class TestFirewallForwardPort: class TestDhcpConfig: - @patch("webui.api.dhcp.get_config") + @_dh("get") def test_get(self, mock_get, client): mock_get.return_value = {"dhcp": {}, "dns": {}} resp = client.get("/api/dhcp/config") assert resp.status_code == 200 assert resp.get_json()["ok"] is True - def test_post_invalid_body(self, client): + @_dh("post") + def test_post_invalid_body(self, mock_post, client): resp = client.post( "/api/dhcp/config", data="not json", content_type="text/plain" ) @@ -242,9 +328,9 @@ class TestDhcpConfig: class TestDhcpApply: - @patch("webui.api.dhcp.apply_config") - def test_apply(self, mock_apply, client): - mock_apply.return_value = None + @_dh("post") + def test_apply(self, mock_post, client): + mock_post.return_value = {"applied": True} resp = client.post("/api/dhcp/apply") assert resp.status_code == 200 data = resp.get_json() @@ -252,9 +338,9 @@ class TestDhcpApply: class TestDhcpStatus: - @patch("webui.api.dhcp.dnsmasq_status") - def test_success(self, mock_status, client): - mock_status.return_value = {"service_active": True} + @_dh("get") + def test_success(self, mock_get, client): + mock_get.return_value = {"service_active": True} resp = client.get("/api/dhcp/status") assert resp.status_code == 200 data = resp.get_json() @@ -262,9 +348,13 @@ class TestDhcpStatus: class TestDhcpRanges: - @patch("webui.api.dhcp.set_dhcp_range") - def test_add_range(self, mock_set, client): - mock_set.return_value = None + @_dh("post") + def test_add_range(self, mock_post, client): + mock_post.return_value = { + "interface": "eth0", + "start": "192.168.1.100", + "end": "192.168.1.200", + } resp = client.post( "/api/dhcp/ranges", json={ @@ -282,9 +372,13 @@ class TestDhcpRanges: resp = client.post("/api/dhcp/ranges", json={"start": "192.168.1.100"}) assert resp.status_code == 400 - @patch("webui.api.dhcp.remove_dhcp_range") - def test_remove_range(self, mock_remove, client): - mock_remove.return_value = None + @_dh("delete") + def test_remove_range(self, mock_delete, client): + mock_delete.return_value = { + "interface": "eth0", + "start": "192.168.1.100", + "end": "192.168.1.200", + } resp = client.delete( "/api/dhcp/ranges", json={ @@ -303,9 +397,9 @@ class TestDhcpRanges: class TestDhcpStaticLease: - @patch("webui.api.dhcp.add_static_lease") - def test_add(self, mock_add, client): - mock_add.return_value = None + @_dh("post") + def test_add(self, mock_post, client): + mock_post.return_value = {"mac": "AA:BB:CC", "ip": "10.0.0.5"} resp = client.post( "/api/dhcp/static-lease", json={"mac": "AA:BB:CC", "ip": "10.0.0.5"}, @@ -316,27 +410,17 @@ class TestDhcpStaticLease: resp = client.post("/api/dhcp/static-lease", json={"ip": "10.0.0.5"}) assert resp.status_code == 400 - @patch("webui.api.dhcp.remove_static_lease") - @patch("webui.api.dhcp.get_config") - def test_remove(self, mock_get, mock_remove, client): - mock_get.return_value = { - "dhcp": {"static_leases": [{"mac": "AA:BB:CC", "ip": "10.0.0.5"}]} - } - mock_remove.return_value = None + @_dh("delete") + def test_remove(self, mock_delete, client): + mock_delete.return_value = {"mac": "AA:BB:CC"} resp = client.delete("/api/dhcp/static-lease/AA:BB:CC") assert resp.status_code == 200 - @patch("webui.api.dhcp.get_config") - def test_remove_not_found(self, mock_get, client): - mock_get.return_value = {"dhcp": {"static_leases": []}} - resp = client.delete("/api/dhcp/static-lease/AA:BB:CC") - assert resp.status_code == 404 - class TestDhcpDnsRecord: - @patch("webui.api.dhcp.add_dns_record") - def test_add(self, mock_add, client): - mock_add.return_value = None + @_dh("post") + def test_add(self, mock_post, client): + mock_post.return_value = {"name": "host.local", "address": "10.0.0.10"} resp = client.post( "/api/dhcp/dns-record", json={"name": "host.local", "address": "10.0.0.10"}, @@ -347,22 +431,12 @@ class TestDhcpDnsRecord: resp = client.post("/api/dhcp/dns-record", json={}) assert resp.status_code == 400 - @patch("webui.api.dhcp.remove_dns_record") - @patch("webui.api.dhcp.get_config") - def test_remove(self, mock_get, mock_remove, client): - mock_get.return_value = { - "dns": {"custom_records": [{"name": "host.local", "address": "10.0.0.10"}]} - } - mock_remove.return_value = None + @_dh("delete") + def test_remove(self, mock_delete, client): + mock_delete.return_value = {"name": "host.local"} resp = client.delete("/api/dhcp/dns-record/host.local") assert resp.status_code == 200 - @patch("webui.api.dhcp.get_config") - def test_remove_not_found(self, mock_get, client): - mock_get.return_value = {"dns": {"custom_records": []}} - resp = client.delete("/api/dhcp/dns-record/host.local") - assert resp.status_code == 404 - # ============================================================================ # Proxy @@ -370,15 +444,15 @@ class TestDhcpDnsRecord: class TestProxyDomains: - @patch("webui.api.proxy.get_domains") + @_px("get") def test_list(self, mock_get, client): mock_get.return_value = [] resp = client.get("/api/proxy/domains") assert resp.status_code == 200 - @patch("webui.api.proxy.add_domain") - def test_add(self, mock_add, client): - mock_add.return_value = None + @_px("post") + def test_add(self, mock_post, client): + mock_post.return_value = {"domain": "ex.com"} resp = client.post( "/api/proxy/domains", json={"domain": "ex.com", "backend_host": "10.0.0.1", "backend_port": 80}, @@ -391,25 +465,25 @@ class TestProxyDomains: class TestProxyApply: - @patch("webui.api.proxy.apply") - def test_apply(self, mock_apply, client): - mock_apply.return_value = None + @_px("post") + def test_apply(self, mock_post, client): + mock_post.return_value = {"applied": True} resp = client.post("/api/proxy/apply") assert resp.status_code == 200 class TestProxyTest: - @patch("webui.api.proxy.test_config") - def test_valid(self, mock_test, client): - mock_test.return_value = (True, "syntax ok") + @_px("post") + def test_valid(self, mock_post, client): + mock_post.return_value = {"valid": True, "output": "syntax ok"} resp = client.post("/api/proxy/test") assert resp.status_code == 200 data = resp.get_json() assert data["data"]["valid"] is True - @patch("webui.api.proxy.test_config") - def test_invalid(self, mock_test, client): - mock_test.return_value = (False, "error msg") + @_px("post") + def test_invalid(self, mock_post, client): + mock_post.return_value = {"valid": False, "output": "error msg"} resp = client.post("/api/proxy/test") assert resp.status_code == 400 data = resp.get_json() @@ -423,15 +497,17 @@ class TestProxyTest: class TestCertsList: - @patch("webui.api.certs.list_certs") - def test_list(self, mock_list, client): - mock_list.return_value = [] + @_ce("get") + def test_list(self, mock_get, client): + mock_get.return_value = [] resp = client.get("/api/certs/list") assert resp.status_code == 200 - @patch("webui.api.certs.get_cert_info") - def test_details_not_found(self, mock_info, client): - mock_info.side_effect = ValueError("not found") + @_ce("get") + def test_details_not_found(self, mock_get, client): + from daemon.client import NotFound + + mock_get.side_effect = NotFound("not found") resp = client.get("/api/certs/example.com") assert resp.status_code == 404 @@ -454,10 +530,10 @@ class TestCertsEmail: class TestWireguardConfig: - @patch("webui.api.wireguard.get_config") + @_wg("get") def test_get(self, mock_get, client): mock_get.return_value = { - "interface": {"name": "wg0", "private_key": "secret"}, + "interface": {"name": "wg0"}, "peers": {}, } resp = client.get("/api/wireguard/config") @@ -465,55 +541,32 @@ class TestWireguardConfig: assert data["ok"] is True assert "private_key" not in data["data"]["interface"] - @patch("webui.api.wireguard.save_config") - def test_post(self, mock_save, client): - mock_save.return_value = None + @_wg("post") + def test_post(self, mock_post, client): + mock_post.return_value = {"config_saved": True} resp = client.post("/api/wireguard/config", json={"peers": {}}) assert resp.status_code == 200 - @patch("webui.api.wireguard.save_config") - @patch("webui.api.wireguard.get_config") - def test_post_strips_private_key(self, mock_get, mock_save, client): - mock_get.return_value = { - "interface": {"name": "wg0", "private_key": "existing"}, - "peers": {}, - } - mock_save.return_value = None - resp = client.post( - "/api/wireguard/config", - json={"interface": {"name": "wg0", "private_key": "secret"}, "peers": {}}, - ) - assert resp.status_code == 200 - saved = mock_save.call_args[0][0] - assert saved["interface"]["private_key"] == "existing" - - @patch("webui.api.wireguard.save_config") - @patch("webui.api.wireguard.get_config") - def test_patch_strips_private_key(self, mock_get, mock_save, client): - mock_get.return_value = { - "interface": {"name": "wg0", "private_key": "existing"}, - "peers": {}, - } - mock_save.return_value = None + @_wg("patch") + def test_patch(self, mock_patch, client): + mock_patch.return_value = {"config_saved": True} resp = client.patch( "/api/wireguard/config", - json={"interface": {"name": "wg1", "private_key": "injected"}}, + json={"interface": {"name": "wg0"}}, ) assert resp.status_code == 200 - saved = mock_save.call_args[0][0] - assert saved.get("interface", {}).get("private_key") == "existing" class TestWireguardPeers: - @patch("webui.api.wireguard.get_peers") + @_wg("get") def test_list(self, mock_get, client): mock_get.return_value = [] resp = client.get("/api/wireguard/peers") assert resp.status_code == 200 - @patch("webui.api.wireguard.add_peer") - def test_add(self, mock_add, client): - mock_add.return_value = { + @_wg("post") + def test_add(self, mock_post, client): + mock_post.return_value = { "name": "client1", "public_key": "pub", } @@ -529,25 +582,17 @@ class TestWireguardPeers: resp = client.post("/api/wireguard/peers", json={}) assert resp.status_code == 400 - @patch("webui.api.wireguard.remove_peer") - @patch("webui.api.wireguard.get_config") - def test_remove_by_name(self, mock_get, mock_remove, client): - mock_get.return_value = {"peers": {"client1": {}}} - mock_remove.return_value = None + @_wg("delete") + def test_remove_by_name(self, mock_delete, client): + mock_delete.return_value = {"name": "client1"} resp = client.delete("/api/wireguard/peers/client1") assert resp.status_code == 200 - @patch("webui.api.wireguard.get_config") - def test_remove_not_found(self, mock_get, client): - mock_get.return_value = {"peers": {}} - resp = client.delete("/api/wireguard/peers/unknown") - assert resp.status_code == 404 - class TestWireguardInitialize: - @patch("webui.api.wireguard.initialize") - def test_initialize(self, mock_init, client): - mock_init.return_value = None + @_wg("post") + def test_initialize(self, mock_post, client): + mock_post.return_value = {"initialized": True} resp = client.post("/api/wireguard/initialize") data = resp.get_json() assert data["ok"] is True @@ -560,39 +605,39 @@ class TestWireguardGenerateClient: class TestWireguardStatus: - @patch("webui.api.wireguard.status") - def test_get(self, mock_status, client): - mock_status.return_value = {"up": True, "interface": {}, "peers": []} + @_wg("get") + def test_get(self, mock_get, client): + mock_get.return_value = {"up": True, "interface": {}, "peers": []} resp = client.get("/api/wireguard/status") assert resp.status_code == 200 class TestWireguardUp: - @patch("webui.api.wireguard.apply") - def test_up_starts_tunnel(self, mock_apply, client): - mock_apply.return_value = None + @_wg("post") + def test_up_starts_tunnel(self, mock_post, client): + mock_post.return_value = {"applied": True} resp = client.post("/api/wireguard/up") assert resp.status_code == 200 - @patch("webui.api.wireguard.apply") - def test_up_error(self, mock_apply, client): - mock_apply.side_effect = RuntimeError("interface down") + @_wg("post") + def test_up_error(self, mock_post, client): + mock_post.side_effect = RuntimeError("interface down") resp = client.post("/api/wireguard/up") assert resp.status_code == 500 class TestWireguardDown: - @patch("webui.api.wireguard.down") - def test_down_stops_tunnel(self, mock_down, client): - mock_down.return_value = None + @_wg("post") + def test_down_stops_tunnel(self, mock_post, client): + mock_post.return_value = {"down": True} resp = client.post("/api/wireguard/down") assert resp.status_code == 200 class TestWireguardApply: - @patch("webui.api.wireguard.apply") - def test_apply(self, mock_apply, client): - mock_apply.return_value = None + @_wg("post") + def test_apply(self, mock_post, client): + mock_post.return_value = {"applied": True} resp = client.post("/api/wireguard/apply") assert resp.status_code == 200 @@ -603,12 +648,143 @@ class TestWireguardApply: class TestResponseHelpers: - @patch("webui.api.firewall.get_active_zones") - @patch("webui.api.firewall.get_available_zones") - def test_error_response_format(self, mock_a, mock_b, client): - mock_a.side_effect = RuntimeError("fail") + @_fw("get") + def test_error_response_format(self, mock_get, client): + mock_get.side_effect = RuntimeError("fail") resp = client.get("/api/firewall/zones") data = resp.get_json() assert "error" in data assert "ok" in data assert data["ok"] is False + + +# ============================================================================ +# Firewall Config CRUD (daemon-backed) +# ============================================================================ + + +class TestFirewallConfig: + @_fw("get") + def test_config_get(self, mock_get, client): + mock_get.return_value = {"zones": {"public": {"interfaces": ["eth0"]}}} + resp = client.get("/api/firewall/config") + assert resp.status_code == 200 + + @_fw("post") + @_fw("get") + def test_config_save(self, mock_get, mock_post, client): + mock_post.return_value = {"config_saved": True} + mock_get.return_value = { + "pending": [], + "needs_apply": False, + "unmanaged_zones": {}, + } + resp = client.post( + "/api/firewall/config", json={"zones": {"public": {"interfaces": ["eth0"]}}} + ) + assert resp.status_code == 200 + + def test_config_save_missing_zones(self, client): + resp = client.post("/api/firewall/config", json={}) + assert resp.status_code == 400 + + @_fw("patch") + @_fw("get") + def test_config_patch(self, mock_get, mock_patch, client): + mock_patch.return_value = {"config_saved": True} + mock_get.return_value = { + "pending": [], + "needs_apply": False, + "unmanaged_zones": {}, + } + resp = client.patch("/api/firewall/config", json={"zones": {}}) + assert resp.status_code == 200 + + @_fw("post") + def test_config_apply(self, mock_post, client): + mock_post.return_value = { + "applied_zones": ["public"], + "backup": "/tmp/rules.json", + } + resp = client.post("/api/firewall/config/apply") + assert resp.status_code == 200 + + @_fw("get") + def test_config_pending(self, mock_get, client): + mock_get.return_value = {"pending": [], "needs_apply": False} + resp = client.get("/api/firewall/config/pending") + assert resp.status_code == 200 + + +# ============================================================================ +# Proxy config +# ============================================================================ + + +class TestProxyConfig: + @_px("get") + def test_get(self, mock_get, client): + mock_get.return_value = {"domains": {}, "ssl": {}} + resp = client.get("/api/proxy/config") + assert resp.status_code == 200 + + @_px("post") + def test_post(self, mock_post, client): + mock_post.return_value = {"config_saved": True} + resp = client.post("/api/proxy/config", json={"domains": {}}) + assert resp.status_code == 200 + + @_px("patch") + def test_patch(self, mock_patch, client): + mock_patch.return_value = {"config_saved": True} + resp = client.patch("/api/proxy/config", json={}) + assert resp.status_code == 200 + + +# ============================================================================ +# DHCP config +# ============================================================================ + + +class TestDhcpConfigCrud: + @_dh("patch") + def test_patch(self, mock_patch, client): + mock_patch.return_value = {"config_saved": True} + resp = client.patch("/api/dhcp/config", json={"dhcp": {}}) + assert resp.status_code == 200 + + +# ============================================================================ +# Proxy management +# ============================================================================ + + +class TestProxyManagement: + @_px("post") + def test_set_management(self, mock_post, client): + mock_post.return_value = {"domain": "vacuum-wall.local"} + resp = client.post( + "/api/proxy/management", + json={ + "domain": "vacuum-wall.local", + "flask_host": "127.0.0.1", + "flask_port": 9090, + }, + ) + assert resp.status_code == 200 + + +# ============================================================================ +# Proxy domain update +# ============================================================================ + + +class TestProxyDomainUpdate: + @_px("post") + def test_update(self, mock_post, client): + mock_post.return_value = {"domain": "ex.com"} + resp = client.put( + "/api/proxy/domains/ex.com", + json={"backend_host": "10.0.0.2"}, + ) + assert resp.status_code == 200 diff --git a/tests/test_dnsmasq.py b/tests/test_dnsmasq.py index 8d1ccda..4c198ce 100644 --- a/tests/test_dnsmasq.py +++ b/tests/test_dnsmasq.py @@ -167,3 +167,103 @@ class TestUpstreamsAndDomain: dnsmasq.set_domain(None) cfg = dnsmasq.get_config() assert cfg["dns"]["domain"] is None + + +# ── Daemon handler tests (NotFoundError on missing resources) ── + + +class TestDaemonRemoveStaticLease: + @patch("daemon.handlers.dnsmasq._get_config") + @patch("daemon.handlers.dnsmasq._save_config") + def test_raises_not_found_when_missing(self, mock_save, mock_get): + from daemon.handlers import dnsmasq as daemon_dnsmasq + + mock_get.return_value = {"dhcp": {"static_leases": []}, "dns": {}} + with pytest.raises(Exception, match="not found"): + daemon_dnsmasq.remove_static_lease(None, {"mac": "FF:FF:FF"}) + + @patch("daemon.handlers.dnsmasq._get_config") + @patch("daemon.handlers.dnsmasq._save_config") + def test_succeeds_when_exists(self, mock_save, mock_get): + from daemon.handlers import dnsmasq as daemon_dnsmasq + + mock_get.return_value = { + "dhcp": {"static_leases": [{"mac": "aa:bb:cc", "ip": "10.0.0.1"}]}, + "dns": {}, + } + result = daemon_dnsmasq.remove_static_lease(None, {"mac": "AA:BB:CC"}) + assert result["mac"] == "AA:BB:CC" + + +class TestDaemonRemoveDnsRecord: + @patch("daemon.handlers.dnsmasq._get_config") + @patch("daemon.handlers.dnsmasq._save_config") + def test_raises_not_found_when_missing(self, mock_save, mock_get): + from daemon.handlers import dnsmasq as daemon_dnsmasq + + mock_get.return_value = { + "dhcp": {}, + "dns": {"custom_records": []}, + } + with pytest.raises(Exception, match="not found"): + daemon_dnsmasq.remove_dns_record(None, {"name": "nonexistent"}) + + @patch("daemon.handlers.dnsmasq._get_config") + @patch("daemon.handlers.dnsmasq._save_config") + def test_succeeds_when_exists(self, mock_save, mock_get): + from daemon.handlers import dnsmasq as daemon_dnsmasq + + mock_get.return_value = { + "dhcp": {}, + "dns": {"custom_records": [{"name": "host", "address": "10.0.0.1"}]}, + } + result = daemon_dnsmasq.remove_dns_record(None, {"name": "host"}) + assert result["name"] == "host" + + +class TestDaemonRemoveDhcpRange: + @patch("daemon.handlers.dnsmasq._get_config") + @patch("daemon.handlers.dnsmasq._save_config") + def test_raises_not_found_when_missing(self, mock_save, mock_get): + from daemon.handlers import dnsmasq as daemon_dnsmasq + + mock_get.return_value = { + "dhcp": {"ranges": []}, + "dns": {}, + } + with pytest.raises(Exception, match="not found"): + daemon_dnsmasq.remove_dhcp_range( + None, + { + "interface": "eth0", + "start": "10.0.0.100", + "end": "10.0.0.200", + }, + ) + + @patch("daemon.handlers.dnsmasq._get_config") + @patch("daemon.handlers.dnsmasq._save_config") + def test_succeeds_when_exists(self, mock_save, mock_get): + from daemon.handlers import dnsmasq as daemon_dnsmasq + + mock_get.return_value = { + "dhcp": { + "ranges": [ + { + "interface": "eth0", + "start": "10.0.0.100", + "end": "10.0.0.200", + } + ] + }, + "dns": {}, + } + result = daemon_dnsmasq.remove_dhcp_range( + None, + { + "interface": "eth0", + "start": "10.0.0.100", + "end": "10.0.0.200", + }, + ) + assert result["interface"] == "eth0" diff --git a/tests/test_firewall.py b/tests/test_firewall.py index fd0b21c..1aeed07 100644 --- a/tests/test_firewall.py +++ b/tests/test_firewall.py @@ -1,17 +1,26 @@ -from datetime import datetime -from unittest.mock import patch +"""Tests for lib/firewall.py (pure logic) and daemon/handlers/firewall.py (privilege boundary).""" +from unittest.mock import MagicMock, patch + +import pytest + +from daemon.handlers import firewall as daemonfirewall +from daemon.server import NotFoundError from lib import firewall +# --------------------------------------------------------------------------- +# lib/firewall.py — pure parsing (no sudo) +# --------------------------------------------------------------------------- + class TestParseForwardPorts: - def test_single_entry(self): + def test_lib_single_entry(self): result = firewall._parse_forward_ports("port=443/proto=tcp") assert len(result) == 1 assert result[0]["port"] == 443 assert result[0]["proto"] == "tcp" - def test_multiple_entries(self): + def test_lib_multiple_entries(self): result = firewall._parse_forward_ports( "port=443/proto=tcp port=80/proto=tcp/toaddr=10.0.0.1/toport=8080" ) @@ -21,144 +30,59 @@ class TestParseForwardPorts: assert result[1]["toaddr"] == "10.0.0.1" assert result[1]["toport"] == 8080 - def test_empty_string(self): + def test_lib_empty_string(self): assert firewall._parse_forward_ports("") == [] + def test_daemon_no_redundant_import(self): + assert not hasattr(daemonfirewall, "_parse_forward_ports") -class TestGetActiveZones: - @patch("lib.firewall.run") - def test_parses_active_zones(self, mock_run): - mock_run.return_value = "public\n eth0\ninternal\n eth1\n eth2" - result = firewall.get_active_zones() + +class TestParseActiveZones: + def test_lib_parses_zones(self): + result = firewall._parse_active_zones( + "public\n eth0\ninternal\n eth1\n eth2" + ) assert result == { "public": ["eth0"], "internal": ["eth1", "eth2"], } - @patch("lib.firewall.run") - def test_empty_output(self, mock_run): - mock_run.return_value = "" - result = firewall.get_active_zones() - assert result == {} + def test_lib_empty_output(self): + assert firewall._parse_active_zones("") == {} - @patch("lib.firewall.run") - def test_zone_with_no_interfaces(self, mock_run): - mock_run.return_value = "dmz" - result = firewall.get_active_zones() - assert result == {"dmz": []} + def test_lib_zone_no_interfaces(self): + assert firewall._parse_active_zones("dmz") == {"dmz": []} + + def test_daemon_import_same(self): + assert daemonfirewall._parse_active_zones is firewall._parse_active_zones -class TestGetZoneInfo: - @patch("lib.firewall.run") - def test_parses_zone_info(self, mock_run): - mock_run.return_value = ( - "target: default\n" - "interfaces: eth0\n" - "sources: \n" - "services: ssh dhcp\n" - "ports: 8080/tcp\n" - "protocols: \n" - "forward-ports: \n" - "masquerade: yes\n" - "ics: no\n" - "rich-rules: \n" - "icmp-blocks: \n" - "module: \n" +class TestParseZoneOutput: + def test_lib_parses_zone(self): + result = firewall._parse_zone_output( + "public", + ( + "target: default\n" + "interfaces: eth0\n" + "services: ssh dhcp\n" + "masquerade: yes\n" + ), ) - result = firewall.get_zone_info("public") assert result["name"] == "public" assert result["services"] == ["ssh", "dhcp"] - assert result["ports"] == ["8080/tcp"] assert result["masquerade"] is True - assert result["interfaces"] == ["eth0"] - assert result["sources"] == [] - assert result["rich-rules"] == [] + + def test_daemon_import_same(self): + assert daemonfirewall._parse_zone_output is firewall._parse_zone_output -class TestGetInterfaces: - @patch("lib.firewall.run") - def test_parses_interfaces(self, mock_run): - mock_run.return_value = ( +class TestParseInterfaces: + def test_lib_parses_interfaces(self): + result = firewall._parse_interfaces( "1: lo: mtu 65536\n" "2: eth0: mtu 1500\n" - "3: eth1: mtu 1500\n" ) - result = firewall.get_interfaces() - assert result == ["lo", "eth0", "eth1"] - - -class TestGetRichRules: - @patch("lib.firewall.run") - def test_single_rule(self, mock_run): - mock_run.return_value = ( - 'rule family="ipv4" port protocol="tcp" port="443" accept;' - ) - result = firewall.get_rich_rules("public") - assert len(result) == 1 - - @patch("lib.firewall.run") - def test_empty_rules(self, mock_run): - mock_run.return_value = "" - result = firewall.get_rich_rules("public") - assert result == [] - - @patch("lib.firewall.run") - def test_multiline_rule(self, mock_run): - mock_run.return_value = ( - 'rule family="ipv4"\n source address="10.0.0.0/24"\n reject;' - ) - result = firewall.get_rich_rules("public") - assert len(result) == 1 - assert "10.0.0.0/24" in result[0] - - -class TestNowIso: - def test_returns_iso_string(self): - result = firewall._now_iso() - datetime.fromisoformat(result) - assert "+" in result - - -class TestAddForwardPort: - @patch("lib.firewall.run") - def test_forward_port_basic(self, mock_run): - mock_run.return_value = "" - firewall.add_forward_port("public", 443, "tcp", toaddr="10.0.0.5", toport=8080) - calls = [c[0][0] for c in mock_run.call_args_list] - assert any("--add-forward-port=" in str(c) for c in calls) - - @patch("lib.firewall.run") - def test_forward_port_port_only(self, mock_run): - mock_run.return_value = "" - firewall.add_forward_port("public", 80, "tcp", toport=8080) - - -class TestGetState: - @patch("lib.firewall.get_available_zones") - @patch("lib.firewall.get_zone_info") - @patch("lib.firewall.get_active_zones") - @patch("lib.firewall.get_interfaces") - @patch("lib.firewall.get_services") - @patch("lib.firewall.get_rich_rules") - def test_returns_full_state( - self, - mock_rich, - mock_services, - mock_ifaces, - mock_active, - mock_zone_info, - mock_available, - ): - mock_available.return_value = ["public", "internal"] - mock_active.return_value = {"public": ["eth0"]} - mock_ifaces.return_value = ["eth0", "eth1"] - mock_services.return_value = ["ssh", "http"] - mock_zone_info.return_value = {"name": "public", "services": []} - mock_rich.return_value = [] - result = firewall.get_state() - assert "zones" in result - assert "active_zones" in result - assert "timestamp" in result + assert result == ["lo", "eth0"] class TestNormalizeTarget: @@ -192,6 +116,11 @@ class TestLiveTargetToConfig: assert firewall._live_target_to_config("") == "DEFAULT" +# --------------------------------------------------------------------------- +# lib/firewall.py — config helpers (no sudo) +# --------------------------------------------------------------------------- + + class TestEnsureConfigFile: def test_creates_file_if_missing(self, tmp_path): cfg_dir = tmp_path / "config" / "firewall" @@ -248,26 +177,299 @@ class TestConfigSet: assert content["zones"]["test"]["interfaces"] == ["eth0"] -class TestConfigApply: +class TestConfigPending: @patch("lib.firewall.get_config") - @patch("lib.firewall.save_backup") - @patch("lib.firewall.get_available_zones") - @patch("lib.firewall.create_zone") - @patch("lib.firewall.set_zone_services") - @patch("lib.firewall.set_zone_interfaces") - @patch("lib.firewall.set_masquerade") - @patch("lib.firewall._reload") - def test_applies_existing_zone( - self, - mock_reload, - mock_set_mq, - mock_set_ifaces, - mock_set_svcs, - mock_create, - mock_available, - mock_backup, - mock_cfg, - ): + def test_detects_interface_drift(self, mock_cfg): + mock_cfg.return_value = { + "zones": { + "public": { + "interfaces": ["eth0"], + "services": ["http"], + "masquerade": False, + }, + }, + } + state = { + "zones": { + "public": { + "interfaces": ["eth1"], + "services": ["http"], + "masquerade": False, + }, + }, + } + result = firewall.config_pending(state) + assert result["needs_apply"] is True + assert any(c["type"] == "interfaces" for c in result["pending"]) + + @patch("lib.firewall.get_config") + def test_in_sync(self, mock_cfg): + mock_cfg.return_value = { + "zones": { + "public": { + "interfaces": ["eth0"], + "services": ["http"], + "masquerade": False, + }, + }, + } + state = { + "zones": { + "public": { + "interfaces": ["eth0"], + "services": ["http"], + "masquerade": False, + }, + }, + } + result = firewall.config_pending(state) + assert result["needs_apply"] is False + + @patch("lib.firewall.get_config") + def test_detects_services_drift(self, mock_cfg): + mock_cfg.return_value = { + "zones": { + "public": { + "interfaces": ["eth0"], + "services": ["http", "ssh"], + "masquerade": False, + }, + }, + } + state = { + "zones": { + "public": { + "interfaces": ["eth0"], + "services": ["http"], + "masquerade": False, + }, + }, + } + result = firewall.config_pending(state) + assert any(c["type"] == "services" for c in result["pending"]) + + @patch("lib.firewall.get_config") + def test_detects_unmanaged_zones(self, mock_cfg): + mock_cfg.return_value = {"zones": {}} + state = { + "zones": { + "public": { + "interfaces": ["eth0"], + "services": [], + "masquerade": False, + }, + }, + } + result = firewall.config_pending(state) + assert "public" in result["unmanaged_zones"] + + +# --------------------------------------------------------------------------- +# lib/firewall.py — parse zone output (used by both lib and daemon) +# --------------------------------------------------------------------------- + + +class TestGetZoneInfo: + def test_parses_zone_info(self): + result = firewall._parse_zone_output( + "public", + ( + "target: default\n" + "interfaces: eth0\n" + "sources: \n" + "services: ssh dhcp\n" + "ports: 8080/tcp\n" + "protocols: \n" + "forward-ports: \n" + "masquerade: yes\n" + "ics: no\n" + "rich-rules: \n" + "icmp-blocks: \n" + "module: \n" + ), + ) + assert result["name"] == "public" + assert result["services"] == ["ssh", "dhcp"] + assert result["ports"] == ["8080/tcp"] + assert result["masquerade"] is True + assert result["interfaces"] == ["eth0"] + assert result["sources"] == [] + assert result["rich-rules"] == [] + + +# --------------------------------------------------------------------------- +# lib/firewall.py — no sudo functions +# --------------------------------------------------------------------------- + + +class TestLibNoSudo: + def test_no_run_import(self): + import inspect + + source = inspect.getsource(firewall) + assert "sudo=True" not in source, "lib/firewall.py must not call sudo" + + +# --------------------------------------------------------------------------- +# daemon/handlers/firewall.py — privileged operations +# --------------------------------------------------------------------------- + + +def _mock_run_factory(*outputs): + """Create a mock run() that cycles through outputs on successive calls.""" + idx = [0] + + def side_effect(*args, **kwargs): + result = outputs[idx[0] % len(outputs)] + idx[0] += 1 + if result is RuntimeError: + raise RuntimeError("command failed") + return result + + return side_effect + + +class TestDaemonParseForwardPorts: + def test_handler_uses_get_forward_ports(self): + assert callable(daemonfirewall._get_forward_ports) + + +class TestDaemonParseActiveZones: + def test_parses_active_zones(self): + result = daemonfirewall._parse_active_zones( + "public\n eth0\ninternal\n eth1\n eth2" + ) + assert result == { + "public": ["eth0"], + "internal": ["eth1", "eth2"], + } + + def test_empty_output(self): + assert daemonfirewall._parse_active_zones("") == {} + + def test_zone_with_no_interfaces(self): + assert daemonfirewall._parse_active_zones("dmz") == {"dmz": []} + + +class TestDaemonParseZoneOutput: + def test_parses_zone_info(self): + result = daemonfirewall._parse_zone_output( + "public", + ( + "target: default\n" + "interfaces: eth0\n" + "sources: \n" + "services: ssh dhcp\n" + "ports: 8080/tcp\n" + "protocols: \n" + "forward-ports: \n" + "masquerade: yes\n" + "ics: no\n" + "rich-rules: \n" + "icmp-blocks: \n" + "module: \n" + ), + ) + assert result["name"] == "public" + assert result["services"] == ["ssh", "dhcp"] + assert result["ports"] == ["8080/tcp"] + assert result["masquerade"] is True + assert result["interfaces"] == ["eth0"] + + +class TestDaemonGetInterfaces: + @patch("daemon.handlers.firewall.run") + def test_parses_interfaces(self, mock_run): + link_out = ( + "1: lo: mtu 65536\n" + "2: eth0: mtu 1500\n" + "3: eth1: mtu 1500\n" + ) + mock_run.return_value = link_out + result = daemonfirewall.get_interfaces(None, None) + assert [i["name"] for i in result] == ["lo", "eth0", "eth1"] + + +class TestDaemonGetRichRules: + @patch("daemon.handlers.firewall.run") + def test_single_rule(self, mock_run): + mock_run.return_value = ( + 'rule family="ipv4" port protocol="tcp" port="443" accept;' + ) + cfg_mock = MagicMock(return_value={"zones": {"public": {"rich_rules": []}}}) + with patch.object(daemonfirewall, "_get_config", cfg_mock): + result = daemonfirewall.list_rich_rules(None, {"zone": "public"}) + assert len(result) == 1 + + @patch("daemon.handlers.firewall.run") + def test_empty_rules(self, mock_run): + mock_run.return_value = "" + cfg_mock = MagicMock(return_value={"zones": {"public": {"rich_rules": []}}}) + with patch.object(daemonfirewall, "_get_config", cfg_mock): + result = daemonfirewall.list_rich_rules(None, {"zone": "public"}) + assert result == [] + + @patch("daemon.handlers.firewall.run") + def test_multiline_rule(self, mock_run): + mock_run.return_value = ( + 'rule family="ipv4"\n source address="10.0.0.0/24"\n reject;' + ) + cfg_mock = MagicMock(return_value={"zones": {"public": {"rich_rules": []}}}) + with patch.object(daemonfirewall, "_get_config", cfg_mock): + result = daemonfirewall.list_rich_rules(None, {"zone": "public"}) + assert len(result) == 1 + assert "10.0.0.0/24" in result[0]["rule"] + + +class TestDaemonGetState: + @patch("daemon.handlers.firewall.run") + def test_returns_full_state(self, mock_run): + def run_side_effect(args, **kwargs): + if "--get-zones" in args: + return "public\ninternal" + if "--get-active-zones" in args: + return "public\n eth0\ninternal\n eth1" + if "--get-services" in args: + return "ssh http dns" + if "ip" in args[0]: + if "link" in args: + return "1: lo: mtu 65536\n2: eth0: mtu 1500 link/ether aa:bb:cc\n" + if "addr" in args: + return "2: eth0 inet 192.168.1.1/24 brd 192.168.1.255 scope global eth0\n" + if "--list-all" in args: + return ( + "target: default\n" + "interfaces: eth0\n" + "sources: \n" + "services: \n" + "ports: \n" + "protocols: \n" + "forward-ports: \n" + "masquerade: no\n" + "ics: no\n" + "rich-rules: \n" + "icmp-blocks: \n" + "module: \n" + ) + return "" + + mock_run.side_effect = run_side_effect + + result = daemonfirewall._get_state() + assert "zones" in result + assert "active_zones" in result + assert "timestamp" in result + assert "interfaces" in result + assert len(result["interfaces"]) >= 2 + assert "public" in result["zones"] + + +class TestDaemonConfigApply: + @patch("daemon.handlers.firewall._save_backup") + @patch("daemon.handlers.firewall._get_state") + @patch("daemon.handlers.firewall._get_lib_config") + @patch("daemon.handlers.firewall.run") + def test_applies_existing_zone(self, mock_run, mock_cfg, mock_state, mock_backup): mock_cfg.return_value = { "zones": { "public": { @@ -278,34 +480,35 @@ class TestConfigApply: }, }, } - mock_available.return_value = ["public", "internal"] + mock_run.return_value = ( + "public\ninternal\ntarget: default\n" + "interfaces: \n" + "sources: \n" + "services: \n" + "ports: \n" + "protocols: \n" + "forward-ports: \n" + "masquerade: no\n" + "ics: no\n" + "rich-rules: \n" + "icmp-blocks: \n" + "module: \n" + ) + mock_state.return_value = {"zones": {"public": {}}} mock_backup.return_value = "/tmp/rules.json" - result = firewall.config_apply() + + result = daemonfirewall._config_apply() assert result["applied_zones"] == ["public"] assert result["backup"] == "/tmp/rules.json" - mock_set_ifaces.assert_called_once_with("public", ["eth0"]) - mock_set_svcs.assert_called_once_with("public", ["http", "https"]) - mock_set_mq.assert_called_once_with("public", True) + calls = [str(c) for c in mock_run.call_args_list] + assert any("--add-service=" in c for c in calls) + assert any("--add-interface=" in c for c in calls) - @patch("lib.firewall.get_config") - @patch("lib.firewall.save_backup") - @patch("lib.firewall.get_available_zones") - @patch("lib.firewall.create_zone") - @patch("lib.firewall.set_zone_services") - @patch("lib.firewall.set_zone_interfaces") - @patch("lib.firewall.set_masquerade") - @patch("lib.firewall._reload") - def test_creates_new_zone( - self, - mock_reload, - mock_set_mq, - mock_set_ifaces, - mock_set_svcs, - mock_create, - mock_available, - mock_backup, - mock_cfg, - ): + @patch("daemon.handlers.firewall._save_backup") + @patch("daemon.handlers.firewall._get_state") + @patch("daemon.handlers.firewall._get_lib_config") + @patch("daemon.handlers.firewall.run") + def test_creates_new_zone(self, mock_run, mock_cfg, mock_state, mock_backup): mock_cfg.return_value = { "zones": { "custom": { @@ -316,18 +519,44 @@ class TestConfigApply: }, }, } - mock_available.return_value = ["public", "internal"] + mock_run.return_value = ( + "public\ninternal\ntarget: default\n" + "interfaces: \n" + "sources: \n" + "services: \n" + "ports: \n" + "protocols: \n" + "forward-ports: \n" + "masquerade: no\n" + "ics: no\n" + "rich-rules: \n" + "icmp-blocks: \n" + "module: \n" + ) + mock_state.return_value = {"zones": {"custom": {}}} mock_backup.return_value = "/tmp/rules.json" - result = firewall.config_apply() + + result = daemonfirewall._config_apply() assert result["applied_zones"] == ["custom"] - mock_create.assert_called_once_with("custom", "ACCEPT") - mock_set_ifaces.assert_called_once_with("custom", ["eth2"]) + + @patch("daemon.handlers.firewall._save_backup") + @patch("daemon.handlers.firewall._get_state") + @patch("daemon.handlers.firewall._get_lib_config") + @patch("daemon.handlers.firewall.run") + def test_empty_config_no_ops(self, mock_run, mock_cfg, mock_state, mock_backup): + mock_cfg.return_value = {"zones": {}} + mock_run.return_value = "" + mock_state.return_value = {"zones": {}} + mock_backup.return_value = "/tmp/rules.json" + + result = daemonfirewall._config_apply() + assert result["applied_zones"] == [] -class TestConfigPending: +class TestDaemonConfigPending: + @patch("daemon.handlers.firewall._get_state") @patch("lib.firewall.get_config") - @patch("lib.firewall.get_state") - def test_detects_interface_drift(self, mock_state, mock_cfg): + def test_detects_interface_drift(self, mock_cfg, mock_state): mock_cfg.return_value = { "zones": { "public": { @@ -346,13 +575,12 @@ class TestConfigPending: }, }, } - result = firewall.config_pending() + result = daemonfirewall.config_pending(None, None) assert result["needs_apply"] is True - assert any(c["type"] == "interfaces" for c in result["pending"]) + @patch("daemon.handlers.firewall._get_state") @patch("lib.firewall.get_config") - @patch("lib.firewall.get_state") - def test_in_sync(self, mock_state, mock_cfg): + def test_in_sync(self, mock_cfg, mock_state): mock_cfg.return_value = { "zones": { "public": { @@ -371,74 +599,104 @@ class TestConfigPending: }, }, } - result = firewall.config_pending() + result = daemonfirewall.config_pending(None, None) assert result["needs_apply"] is False - @patch("lib.firewall.get_config") - @patch("lib.firewall.get_state") - def test_detects_services_drift(self, mock_state, mock_cfg): + +# --------------------------------------------------------------------------- +# Zone validation in add_rich_rule, remove_rich_rule, remove_forward_port +# --------------------------------------------------------------------------- + + +class TestDaemonZoneValidation: + @patch("daemon.handlers.firewall.run") + def test_add_rich_rule_invalid_zone(self, mock_run): + mock_run.return_value = "public\ninternal" + with ( + patch.object(daemonfirewall, "_get_config", return_value={"zones": {}}), + patch.object(daemonfirewall, "_save_config"), + pytest.raises(NotFoundError), + ): + daemonfirewall.add_rich_rule( + None, + { + "zone": "nonexistent", + "rule": "rule accept", + }, + ) + + @patch("daemon.handlers.firewall.run") + def test_remove_rich_rule_invalid_zone(self, mock_run): + mock_run.return_value = "public\ninternal" + with ( + patch.object(daemonfirewall, "_get_config", return_value={"zones": {}}), + pytest.raises(NotFoundError), + ): + daemonfirewall.remove_rich_rule( + None, {"zone": "nonexistent", "id": "abc123"} + ) + + @patch("daemon.handlers.firewall.run") + def test_remove_forward_port_invalid_zone(self, mock_run): + mock_run.return_value = "public\ninternal" + with ( + patch.object(daemonfirewall, "_get_config", return_value={"zones": {}}), + pytest.raises(NotFoundError), + ): + daemonfirewall.remove_forward_port( + None, + { + "zone": "nonexistent", + "port": 443, + "proto": "tcp", + }, + ) + + +# --------------------------------------------------------------------------- +# Forward port removal during config_apply +# --------------------------------------------------------------------------- + + +class TestDaemonConfigApplyForwardPorts: + @patch("daemon.handlers.firewall._save_backup") + @patch("daemon.handlers.firewall._get_state") + @patch("daemon.handlers.firewall._get_lib_config") + @patch("daemon.handlers.firewall.run") + def test_removes_stale_forward_ports( + self, mock_run, mock_cfg, mock_state, mock_backup + ): mock_cfg.return_value = { "zones": { "public": { - "interfaces": ["eth0"], - "services": ["http", "ssh"], - "masquerade": False, - }, - }, - } - mock_state.return_value = { - "zones": { - "public": { - "interfaces": ["eth0"], - "services": ["http"], - "masquerade": False, - }, - }, - } - result = firewall.config_pending() - assert any(c["type"] == "services" for c in result["pending"]) - - @patch("lib.firewall.get_config") - @patch("lib.firewall.get_state") - def test_detects_unmanaged_zones(self, mock_state, mock_cfg): - mock_cfg.return_value = {"zones": {}} - mock_state.return_value = { - "zones": { - "public": { - "interfaces": ["eth0"], + "interfaces": [], "services": [], "masquerade": False, + "forward_ports": [ + {"id": "fp_new", "port": 8443, "proto": "tcp"}, + ], }, }, } - result = firewall.config_pending() - assert "public" in result["unmanaged_zones"] - - -class TestConfigEmptyZones: - @patch("lib.firewall.get_config") - @patch("lib.firewall.save_backup") - @patch("lib.firewall.get_available_zones") - @patch("lib.firewall.create_zone") - @patch("lib.firewall.set_zone_services") - @patch("lib.firewall.set_zone_interfaces") - @patch("lib.firewall.set_masquerade") - @patch("lib.firewall._reload") - def test_empty_config_no_ops( - self, - mock_reload, - mock_set_mq, - mock_set_ifaces, - mock_set_svcs, - mock_create, - mock_available, - mock_backup, - mock_cfg, - ): - mock_cfg.return_value = {"zones": {}} - mock_available.return_value = [] + mock_run.return_value = ( + "public\ntarget: default\n" + "interfaces: \n" + "sources: \n" + "services: \n" + "ports: \n" + "protocols: \n" + "forward-ports: port=443/proto=tcp\n" + "masquerade: no\n" + "ics: no\n" + "rich-rules: \n" + "icmp-blocks: \n" + "module: \n" + ) + mock_state.return_value = {"zones": {"public": {}}} mock_backup.return_value = "/tmp/rules.json" - result = firewall.config_apply() - assert result["applied_zones"] == [] - mock_create.assert_not_called() - mock_set_ifaces.assert_not_called() + + result = daemonfirewall._config_apply() + assert result["applied_zones"] == ["public"] + calls = [str(c) for c in mock_run.call_args_list] + assert any("--remove-forward-port=" in c for c in calls) + assert any("--add-forward-port=" in c for c in calls) diff --git a/tests/test_server.py b/tests/test_server.py index 5497d44..12f26dd 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -90,27 +90,8 @@ class TestSafelyHelper: class TestPageRoutes: - @patch("webui.server.get_active_zones") - @patch("webui.server.get_interfaces") - @patch("webui.server.dnsmasq_status") - @patch("webui.server.get_domains") - @patch("webui.server.list_certs") - @patch("webui.server.wg_status") - def test_dashboard_no_crash( - self, - mock_wg, - mock_certs, - mock_domains, - mock_dnsmasq, - mock_ifaces, - mock_zones, - client, - ): - mock_zones.return_value = {} - mock_ifaces.return_value = [] - mock_dnsmasq.return_value = {} - mock_domains.return_value = [] - mock_certs.return_value = [] - mock_wg.return_value = {} + @patch("webui.server.get") + def test_dashboard_no_crash(self, mock_get, client): + mock_get.return_value = {} resp = client.get("/") assert resp.status_code == 200 diff --git a/webui/api/certs.py b/webui/api/certs.py index 6aa7095..be2dbd8 100644 --- a/webui/api/certs.py +++ b/webui/api/certs.py @@ -1,36 +1,24 @@ """ACME certificate management API blueprint. -Exposed at /api/certs/* and delegates to lib.acme. +Exposed at /api/certs/* and delegates to vacuum-walld. """ import logging from flask import Blueprint, request -from lib.acme import ( - get_cert_info, - issue, - list_certs, - remove, - renew, - set_email, -) +from daemon.client import BadRequest, NotFound, delete, get, post from webui.api.common import _error, _ok logger = logging.getLogger(__name__) bp = Blueprint("certs", __name__) -# --------------------------------------------------------------------------- -# Certificate listing -# --------------------------------------------------------------------------- - - @bp.route("/list", methods=["GET"]) def list_certs_bp(): try: - return _ok(list_certs()) - except (RuntimeError, FileNotFoundError) as exc: + return _ok(get("/acme/list")) + except RuntimeError as exc: logger.error("Failed to list certificates: %s", exc) return _error(str(exc), 500) @@ -38,20 +26,15 @@ def list_certs_bp(): @bp.route("/", methods=["GET"]) def cert_details(domain: str): try: - info = get_cert_info(domain) - return _ok(info) - except ValueError as exc: + return _ok(get("/acme/info", {"domain": domain})) + except NotFound as exc: + logger.info("Cert for '%s' not found: %s", domain, exc) return _error(str(exc), 404) - except (RuntimeError, FileNotFoundError) as exc: + except RuntimeError as exc: logger.error("Failed to get cert info for '%s': %s", domain, exc) return _error(str(exc), 500) -# --------------------------------------------------------------------------- -# Issue -# --------------------------------------------------------------------------- - - @bp.route("/issue", methods=["POST"]) def issue_bp(): body = request.get_json(silent=True) or {} @@ -62,59 +45,46 @@ def issue_bp(): email = body.get("email", "").strip() or None try: logger.info("Certificate issuance requested for '%s' via API", domain) - issue(domain, webroot=webroot, email=email) + post("/acme/issue", {"domain": domain, "webroot": webroot, "email": email}) logger.info("Certificate issued for '%s'", domain) return _ok(None) - except (RuntimeError, FileNotFoundError) as exc: + except BadRequest as exc: + logger.info("Cert issue for '%s' rejected: %s", domain, exc) + return _error(str(exc), 400) + except RuntimeError as exc: logger.error("Failed to issue cert for '%s': %s", domain, exc) return _error(str(exc), 500) -# --------------------------------------------------------------------------- -# Renew -# --------------------------------------------------------------------------- - - @bp.route("//renew", methods=["POST"]) def renew_bp(domain: str): try: logger.info("Certificate renewal requested for '%s' via API", domain) - renew(domain) + post("/acme/renew", {"domain": domain}) logger.info("Certificate renewed for '%s'", domain) return _ok(None) - except (RuntimeError, FileNotFoundError) as exc: + except BadRequest as exc: + logger.info("Cert renew for '%s' rejected: %s", domain, exc) + return _error(str(exc), 400) + except RuntimeError as exc: logger.error("Failed to renew cert for '%s': %s", domain, exc) return _error(str(exc), 500) -# --------------------------------------------------------------------------- -# Remove -# --------------------------------------------------------------------------- - - @bp.route("/", methods=["DELETE"]) def remove_bp(domain: str): try: - get_cert_info(domain) - except ValueError as exc: - return _error(str(exc), 404) - except (RuntimeError, FileNotFoundError) as exc: - logger.error("Failed to verify cert '%s': %s", domain, exc) - return _error(str(exc), 500) - try: - remove(domain) + delete("/acme/remove", {"domain": domain}) logger.info("Certificate removed for '%s' via API", domain) return _ok(None) - except (RuntimeError, FileNotFoundError) as exc: + except NotFound as exc: + logger.info("Cert '%s' not found: %s", domain, exc) + return _error(str(exc), 404) + except RuntimeError as exc: logger.error("Failed to remove cert '%s': %s", domain, exc) return _error(str(exc), 500) -# --------------------------------------------------------------------------- -# Contact email -# --------------------------------------------------------------------------- - - @bp.route("/email", methods=["POST"]) def set_email_bp(): body = request.get_json(silent=True) or {} @@ -122,9 +92,12 @@ def set_email_bp(): if not email: return _error("'email' is required", 400) try: - set_email(email) + post("/acme/email", {"email": email}) logger.info("ACME email set via API: %s", email) return _ok({"email": email}) - except (RuntimeError, FileNotFoundError) as exc: + except BadRequest as exc: + logger.info("ACME email set rejected: %s", exc) + return _error(str(exc), 400) + except RuntimeError as exc: logger.error("Failed to set ACME email: %s", exc) return _error(str(exc), 500) diff --git a/webui/api/dhcp.py b/webui/api/dhcp.py index 7fbb42f..c5635ac 100644 --- a/webui/api/dhcp.py +++ b/webui/api/dhcp.py @@ -1,29 +1,13 @@ -""" -webui/api/dhcp.py - DHCP/DNS (dnsmasq) management API blueprint. +"""DHCP/DNS (dnsmasq) management API blueprint. -Exposed at /api/dhcp/* and delegates all operations to lib.dnsmasq. +Exposed at /api/dhcp/* and delegates all operations to vacuum-walld. """ import logging from flask import Blueprint, request -from lib.common import deep_merge -from lib.dnsmasq import ( - add_dns_record, - add_static_lease, - apply_config, - get_config, - get_lease_table, - remove_dhcp_range, - remove_dns_record, - remove_static_lease, - save_config, - set_dhcp_range, -) -from lib.dnsmasq import ( - get_status as dnsmasq_status, -) +from daemon.client import BadRequest, NotFound, delete, get, patch, post from webui.api.common import _error, _ok logger = logging.getLogger(__name__) @@ -38,7 +22,7 @@ bp = Blueprint("dhcp", __name__) @bp.route("/config", methods=["GET"]) def get_config_bp(): try: - return _ok(get_config()) + return _ok(get("/dnsmasq/config")) except RuntimeError as exc: logger.error("Failed to read DHCP config: %s", exc) return _error(str(exc), 500) @@ -50,8 +34,11 @@ def post_config(): if not isinstance(body, dict): return _error("Request body must be a JSON object", 400) try: - save_config(body) + post("/dnsmasq/config", body) return _ok(None) + except BadRequest as exc: + logger.info("DHCP config save rejected: %s", exc) + return _error(str(exc), 400) except RuntimeError as exc: logger.error("Failed to save DHCP config: %s", exc) return _error(str(exc), 500) @@ -63,10 +50,11 @@ def patch_config(): if not isinstance(body, dict): return _error("Request body must be a JSON object", 400) try: - current = get_config() - merged = deep_merge(current, body) - save_config(merged) + patch("/dnsmasq/config", body) return _ok(None) + except BadRequest as exc: + logger.info("DHCP config patch rejected: %s", exc) + return _error(str(exc), 400) except RuntimeError as exc: logger.error("Failed to patch DHCP config: %s", exc) return _error(str(exc), 500) @@ -75,7 +63,7 @@ def patch_config(): @bp.route("/apply", methods=["POST"]) def apply_bp(): try: - apply_config() + post("/dnsmasq/apply") logger.info("dnsmasq config applied via API") return _ok(None) except RuntimeError as exc: @@ -91,7 +79,7 @@ def apply_bp(): @bp.route("/status", methods=["GET"]) def status_bp(): try: - return _ok(dnsmasq_status()) + return _ok(get("/dnsmasq/status")) except RuntimeError as exc: logger.error("Failed to get DHCP status: %s", exc) return _error(str(exc), 500) @@ -112,14 +100,20 @@ def add_range_bp(): if not start or not end: return _error("'start' and 'end' are required", 400) try: - set_dhcp_range( - iface if iface else "", - start, - end, - lease_time=lease_time, + post( + "/dnsmasq/ranges/add", + { + "interface": iface or "", + "start": start, + "end": end, + "lease_time": lease_time, + }, ) logger.info("DHCP range added via API: %s-%s", start, end) return _ok(None) + except BadRequest as exc: + logger.info("Add DHCP range rejected: %s", exc) + return _error(str(exc), 400) except RuntimeError as exc: logger.error("Failed to add DHCP range: %s", exc) return _error(str(exc), 500) @@ -134,23 +128,28 @@ def remove_range_bp(): if not start or not end: return _error("'start' and 'end' are required", 400) try: - remove_dhcp_range(iface, start, end) + delete( + "/dnsmasq/ranges/remove", {"interface": iface, "start": start, "end": end} + ) logger.info("DHCP range removed via API: %s-%s", start, end) return _ok(None) + except NotFound as exc: + logger.info("Remove DHCP range not found: %s", exc) + return _error(str(exc), 404) except RuntimeError as exc: logger.error("Failed to remove DHCP range: %s", exc) return _error(str(exc), 500) # --------------------------------------------------------------------------- -# Static leases +# Leases # --------------------------------------------------------------------------- @bp.route("/leases", methods=["GET"]) def leases_bp(): try: - return _ok(get_lease_table()) + return _ok(get("/dnsmasq/leases")) except RuntimeError as exc: logger.error("Failed to read lease table: %s", exc) return _error(str(exc), 500) @@ -170,9 +169,12 @@ def add_static_lease_bp(): if not mac or not ip: return _error("'mac' and 'ip' are required", 400) try: - add_static_lease(mac, ip, hostname) + post("/dnsmasq/static-lease/add", {"mac": mac, "ip": ip, "hostname": hostname}) logger.info("Static lease added via API: %s -> %s", mac, ip) return _ok({"mac": mac, "ip": ip, "hostname": hostname}) + except BadRequest as exc: + logger.info("Add static lease rejected: %s", exc) + return _error(str(exc), 400) except RuntimeError as exc: logger.error("Failed to add static lease: %s", exc) return _error(str(exc), 500) @@ -180,19 +182,15 @@ def add_static_lease_bp(): @bp.route("/static-lease/", methods=["DELETE"]) def remove_static_lease_bp(mac): - current = get_config() - found = any( - lease["mac"].lower() == mac.lower() - for lease in current.get("dhcp", {}).get("static_leases", []) - ) - if not found: - return _error(f"No static lease found for MAC '{mac}'", 404) try: - remove_static_lease(mac) + delete("/dnsmasq/static-lease/remove", {"mac": mac}) logger.info("Static lease removed via API: %s", mac) return _ok(None) + except NotFound as exc: + logger.info("Static lease '%s' not found: %s", mac, exc) + return _error(str(exc), 404) except RuntimeError as exc: - logger.error("Failed to remove static lease: %s", exc) + logger.error("Failed to remove static lease '%s': %s", mac, exc) return _error(str(exc), 500) @@ -210,9 +208,15 @@ def add_dns_record_bp(): if not name or not address: return _error("'name' and 'address' are required", 400) try: - add_dns_record(name, address, hostname) + post( + "/dnsmasq/dns-record/add", + {"name": name, "address": address, "hostname": hostname}, + ) logger.info("DNS record added via API: %s -> %s", name, address) return _ok({"name": name, "address": address, "hostname": hostname}) + except BadRequest as exc: + logger.info("Add DNS record rejected: %s", exc) + return _error(str(exc), 400) except RuntimeError as exc: logger.error("Failed to add DNS record: %s", exc) return _error(str(exc), 500) @@ -220,16 +224,13 @@ def add_dns_record_bp(): @bp.route("/dns-record/", methods=["DELETE"]) def remove_dns_record_bp(name): - current = get_config() - found = any( - r["name"] == name for r in current.get("dns", {}).get("custom_records", []) - ) - if not found: - return _error(f"No DNS record found for '{name}'", 404) try: - remove_dns_record(name) + delete("/dnsmasq/dns-record/remove", {"name": name}) logger.info("DNS record removed via API: %s", name) return _ok(None) + except NotFound as exc: + logger.info("DNS record '%s' not found: %s", name, exc) + return _error(str(exc), 404) except RuntimeError as exc: - logger.error("Failed to remove DNS record: %s", exc) + logger.error("Failed to remove DNS record '%s': %s", name, exc) return _error(str(exc), 500) diff --git a/webui/api/firewall.py b/webui/api/firewall.py index 8c98a2d..07439d6 100644 --- a/webui/api/firewall.py +++ b/webui/api/firewall.py @@ -1,34 +1,13 @@ """Firewall (firewalld) management API blueprint. -Exposed at /api/firewall/* and delegates all mutations to lib.firewall. +Exposed at /api/firewall/* and delegates all operations to vacuum-walld. """ import logging from flask import Blueprint, request -from lib.common import deep_merge -from lib.firewall import ( - add_forward_port, - add_rich_rule, - config_apply, - config_pending, - create_zone, - delete_zone, - get_active_zones, - get_available_zones, - get_config, - get_interfaces, - get_rich_rules, - get_services, - get_zone_info, - remove_forward_port_by_id, - remove_rich_rule_by_id, - save_config, - set_masquerade, - set_zone_interfaces, - set_zone_services, -) +from daemon.client import BadRequest, NotFound, delete, get, patch, post from webui.api.common import _error, _ok logger = logging.getLogger(__name__) @@ -43,7 +22,7 @@ bp = Blueprint("firewall", __name__) @bp.route("/config", methods=["GET"]) def config_list(): try: - return _ok(get_config()) + return _ok(get("/firewall/config")) except RuntimeError as exc: logger.error("Failed to read firewall config: %s", exc) return _error(str(exc), 500) @@ -57,17 +36,27 @@ def config_save(): if not isinstance(body["zones"], dict): return _error("'zones' must be a dict", 400) try: - save_config(body) - pending_info = config_pending() + post("/firewall/config", body) + try: + pending = get("/firewall/config/pending") + pending_data = { + "pending": pending.get("pending", []), + "needs_apply": pending.get("needs_apply", False), + "unmanaged_zones": pending.get("unmanaged_zones", {}), + } + except RuntimeError as exc: + pending_data = None + logger.warning("Failed to read pending state after config save: %s", exc) logger.info("Firewall config saved (%d zones)", len(body["zones"])) return _ok( { "config_saved": True, - "pending": pending_info["pending"], - "needs_apply": pending_info["needs_apply"], - "unmanaged_zones": pending_info.get("unmanaged_zones", {}), + **(pending_data or {}), } ) + except BadRequest as exc: + logger.info("Firewall config save rejected: %s", exc) + return _error(str(exc), 400) except RuntimeError as exc: logger.error("Failed to save firewall config: %s", exc) return _error(str(exc), 500) @@ -79,19 +68,26 @@ def patch_config(): if not isinstance(body, dict): return _error("Request body must be a JSON object", 400) try: - current = get_config() - merged = deep_merge(current, body) - save_config(merged) - pending_info = config_pending() - logger.info("Firewall config patched: %s", sorted(body.keys())) + patch("/firewall/config", body) + try: + pending = get("/firewall/config/pending") + pending_data = { + "pending": pending.get("pending", []), + "needs_apply": pending.get("needs_apply", False), + "unmanaged_zones": pending.get("unmanaged_zones", {}), + } + except RuntimeError as exc: + pending_data = None + logger.warning("Failed to read pending state after config patch: %s", exc) return _ok( { "config_saved": True, - "pending": pending_info["pending"], - "needs_apply": pending_info["needs_apply"], - "unmanaged_zones": pending_info.get("unmanaged_zones", {}), + **(pending_data or {}), } ) + except BadRequest as exc: + logger.info("Firewall config patch rejected: %s", exc) + return _error(str(exc), 400) except RuntimeError as exc: logger.error("Failed to patch firewall config: %s", exc) return _error(str(exc), 500) @@ -100,7 +96,7 @@ def patch_config(): @bp.route("/config/apply", methods=["POST"]) def config_apply_bp(): try: - result = config_apply() + result = post("/firewall/config/apply") logger.info("Firewall config applied: %s", result.get("applied_zones", [])) return _ok(result) except RuntimeError as exc: @@ -111,7 +107,7 @@ def config_apply_bp(): @bp.route("/config/pending", methods=["GET"]) def config_pending_bp(): try: - return _ok(config_pending()) + return _ok(get("/firewall/config/pending")) except RuntimeError as exc: logger.error("Failed to check pending config: %s", exc) return _error(str(exc), 500) @@ -125,9 +121,10 @@ def config_pending_bp(): @bp.route("/zones", methods=["GET"]) def list_zones(): try: - active = get_active_zones() - available = get_available_zones() - return _ok({"active": active, "available": available}) + data = get("/firewall/zones") + return _ok( + {"active": data.get("active", {}), "available": data.get("available", [])} + ) except RuntimeError as exc: logger.error("Failed to list zones: %s", exc) return _error(str(exc), 500) @@ -136,10 +133,11 @@ def list_zones(): @bp.route("/zones/", methods=["GET"]) def zone_details(name: str): try: - if name not in get_available_zones(): - return _error(f"Zone '{name}' does not exist", 404) - info = get_zone_info(name) + info = get("/firewall/zones/info", {"zone": name}) return _ok(info) + except NotFound as exc: + logger.info("Zone '%s' not found: %s", name, exc) + return _error(str(exc), 404) except RuntimeError as exc: logger.error("Failed to get zone '%s' info: %s", name, exc) return _error(str(exc), 500) @@ -153,11 +151,12 @@ def create_zone_bp(): if not zone_name: return _error("Zone name is required", 400) try: - if zone_name in get_available_zones(): - return _error(f"Zone '{zone_name}' already exists", 400) - create_zone(zone_name, target) + post("/firewall/zones/create", {"name": zone_name, "target": target}) logger.info("Zone '%s' created via API", zone_name) return _ok(None) + except BadRequest as exc: + logger.info("Zone '%s' creation rejected: %s", zone_name, exc) + return _error(str(exc), 400) except RuntimeError as exc: logger.error("Failed to create zone '%s': %s", zone_name, exc) return _error(str(exc), 500) @@ -166,12 +165,12 @@ def create_zone_bp(): @bp.route("/zones/", methods=["DELETE"]) def delete_zone_bp(name: str): try: - available = get_available_zones() - if name not in available: - return _error(f"Zone '{name}' does not exist", 404) - delete_zone(name) + delete("/firewall/zones/delete", {"zone": name}) logger.info("Zone '%s' deleted via API", name) return _ok(None) + except NotFound as exc: + logger.info("Zone '%s' not found: %s", name, exc) + return _error(str(exc), 404) except RuntimeError as exc: logger.error("Failed to delete zone '%s': %s", name, exc) return _error(str(exc), 500) @@ -189,9 +188,15 @@ def set_zone_interfaces_bp(name: str): if not isinstance(interfaces, list): return _error("'interfaces' must be a list", 400) try: - set_zone_interfaces(name, interfaces) + post("/firewall/zones/interfaces", {"zone": name, "interfaces": interfaces}) logger.info("Zone '%s' interfaces updated: %s", name, interfaces) return _ok({"zone": name, "interfaces": interfaces}) + except BadRequest as exc: + logger.info("Set interfaces for zone '%s' rejected: %s", name, exc) + return _error(str(exc), 400) + except NotFound as exc: + logger.info("Zone '%s' not found: %s", name, exc) + return _error(str(exc), 404) except RuntimeError as exc: logger.error("Failed to set interfaces for zone '%s': %s", name, exc) return _error(str(exc), 500) @@ -209,8 +214,14 @@ def set_zone_services_bp(name: str): if not isinstance(services, list): return _error("'services' must be a list", 400) try: - set_zone_services(name, services) + post("/firewall/zones/services", {"zone": name, "services": services}) return _ok({"zone": name, "services": services}) + except BadRequest as exc: + logger.info("Set services for zone '%s' rejected: %s", name, exc) + return _error(str(exc), 400) + except NotFound as exc: + logger.info("Zone '%s' not found: %s", name, exc) + return _error(str(exc), 404) except RuntimeError as exc: logger.error("Failed to set services for zone '%s': %s", name, exc) return _error(str(exc), 500) @@ -224,7 +235,7 @@ def set_zone_services_bp(name: str): @bp.route("/services", methods=["GET"]) def list_services(): try: - return _ok(get_services()) + return _ok(get("/firewall/services")) except RuntimeError as exc: logger.error("Failed to list services: %s", exc) return _error(str(exc), 500) @@ -233,7 +244,7 @@ def list_services(): @bp.route("/interfaces", methods=["GET"]) def list_interfaces(): try: - return _ok(get_interfaces()) + return _ok(get("/firewall/interfaces")) except RuntimeError as exc: logger.error("Failed to list interfaces: %s", exc) return _error(str(exc), 500) @@ -252,9 +263,12 @@ def add_rich_rule_bp(): if not zone or not rule: return _error("Both 'zone' and 'rule' are required", 400) try: - entry = add_rich_rule(zone, rule) + entry = post("/firewall/rich-rules/add", {"zone": zone, "rule": rule}) logger.info("Rich rule added to zone '%s': %s", zone, rule[:80]) return _ok({"zone": zone, "id": entry["id"], "rule": rule}) + except BadRequest as exc: + logger.info("Add rich rule for zone '%s' rejected: %s", zone, exc) + return _error(str(exc), 400) except RuntimeError as exc: logger.error("Failed to add rich rule to zone '%s': %s", zone, exc) return _error(str(exc), 500) @@ -263,17 +277,7 @@ def add_rich_rule_bp(): @bp.route("/rich-rules/", methods=["GET"]) def list_rich_rules(zone: str): try: - rules = get_rich_rules(zone) - cfg = get_config() - cfg_entries = cfg.get("zones", {}).get(zone, {}).get("rich_rules", []) - result = [] - for rule_str in rules: - matched = next((e for e in cfg_entries if e.get("rule") == rule_str), None) - if matched: - result.append({"id": matched["id"], "rule": rule_str}) - else: - result.append({"rule": rule_str}) - return _ok(result) + return _ok(get("/firewall/rich-rules", {"zone": zone})) except RuntimeError as exc: logger.error("Failed to get rich rules for zone '%s': %s", zone, exc) return _error(str(exc), 500) @@ -282,10 +286,11 @@ def list_rich_rules(zone: str): @bp.route("/rich-rules//", methods=["DELETE"]) def remove_rich_rule_bp(zone: str, rule_id: str): try: - remove_rich_rule_by_id(zone, rule_id) + delete("/firewall/rich-rules/remove", {"zone": zone, "id": rule_id}) logger.info("Rich rule '%s' removed from zone '%s'", rule_id, zone) return _ok({"zone": zone, "id": rule_id}) - except ValueError as exc: + except NotFound as exc: + logger.info("Rich rule '%s' not found in zone '%s': %s", rule_id, zone, exc) return _error(str(exc), 404) except RuntimeError as exc: logger.error("Failed to remove rich rule from zone '%s': %s", zone, exc) @@ -305,13 +310,16 @@ def set_masquerade_bp(): if not zone or enable is None: return _error("'zone' and 'enable' (bool) are required", 400) try: - set_masquerade(zone, bool(enable)) + post("/firewall/masquerade", {"zone": zone, "enable": bool(enable)}) logger.info( "Masquerade %s on zone '%s' via API", "enabled" if enable else "disabled", zone, ) return _ok({"zone": zone, "masquerade": bool(enable)}) + except BadRequest as exc: + logger.info("Set masquerade for zone '%s' rejected: %s", zone, exc) + return _error(str(exc), 400) except RuntimeError as exc: logger.error("Failed to set masquerade on zone '%s': %s", zone, exc) return _error(str(exc), 500) @@ -333,27 +341,49 @@ def add_forward_port_bp(): if not zone or port is None or not proto: return _error("'zone', 'port', and 'proto' are required", 400) try: - entry = add_forward_port( - zone, - int(port), - proto, - toaddr=str(toaddr) if toaddr else None, - toport=int(toport) if toport else None, + port_int = int(port) + except ValueError: + return _error("'port' must be an integer", 400) + toport_int = None + if toport is not None: + try: + toport_int = int(toport) + except ValueError: + return _error("'toport' must be an integer", 400) + toaddr_str = str(toaddr) if toaddr else None + try: + entry = post( + "/firewall/forward-port/add", + { + "zone": zone, + "port": port_int, + "proto": proto, + "toaddr": toaddr_str, + "toport": toport_int, + }, ) - return _ok({"zone": zone, "id": entry["id"], "port": int(port), "proto": proto}) - except (ValueError, RuntimeError) as exc: - code = 400 if isinstance(exc, ValueError) else 500 + return _ok({"zone": zone, "id": entry["id"], "port": port_int, "proto": proto}) + except BadRequest as exc: + logger.info("Add forward port rejected: %s", exc) + return _error(str(exc), 400) + except RuntimeError as exc: logger.error("Failed to add forward port: %s", exc) - return _error(str(exc), code) + return _error(str(exc), 500) @bp.route("/forward-port///", methods=["DELETE"]) def remove_forward_port_bp(zone: str, port: int, proto: str): try: - remove_forward_port_by_id(zone, port, proto) + delete( + "/firewall/forward-port/remove", + {"zone": zone, "port": port, "proto": proto}, + ) logger.info("Forward port %s/%s removed from zone '%s'", port, proto, zone) return _ok({"zone": zone, "port": port, "proto": proto}) - except ValueError as exc: + except NotFound as exc: + logger.info( + "Forward port %s/%s not found in zone '%s': %s", port, proto, zone, exc + ) return _error(str(exc), 404) except RuntimeError as exc: logger.error("Failed to remove forward port from zone '%s': %s", zone, exc) diff --git a/webui/api/logs.py b/webui/api/logs.py index 233b33d..ae85597 100644 --- a/webui/api/logs.py +++ b/webui/api/logs.py @@ -1,57 +1,17 @@ -""" -webui/api/logs.py - Log viewing API blueprint. +"""Log viewing API blueprint. -Serves log content to the /logs page via HTMX endpoints: - /api/logs/journal — systemd journal for vacuum-wall - /api/logs/nginx/access — nginx access log tail - /api/logs/nginx/error — nginx error log tail - /api/logs/dnsmasq — systemd journal for dnsmasq - /api/logs/app — Vacuum Wall application log file +Serves log content to the /logs page via HTMX endpoints through vacuum-walld. """ import logging -import subprocess -from pathlib import Path from flask import Blueprint, render_template_string +from daemon.client import get + logger = logging.getLogger(__name__) - bp = Blueprint("logs", __name__) -PROJECT_DIR = Path(__file__).resolve().parent.parent -APP_LOG_FILE = PROJECT_DIR / "data" / "logs" / "vacuum-wall.log" - -_MAX_LINES = 200 - - -def _tail_file(path: str, n: int = _MAX_LINES) -> str: - """Return the last *n* lines of a file.""" - try: - with open(path) as f: - lines = f.readlines() - return "".join(lines[-n:]) - except FileNotFoundError: - return "(log file not found)\n" - except PermissionError: - return "(permission denied)\n" - - -def _sudo_journalctl(unit: str, n: int = _MAX_LINES) -> str: - """Run ``sudo journalctl -u --no-pager -n `` and return output.""" - try: - result = subprocess.run( - ["sudo", "journalctl", "-u", unit, "--no-pager", "-n", str(n)], - capture_output=True, - text=True, - timeout=10, - ) - output = result.stdout.strip() - return output if output else f"(no journal entries for {unit})\n" - except (subprocess.TimeoutExpired, FileNotFoundError) as exc: - return f"(error reading journal: {exc})\n" - - _LOG_LINE_TEMPLATE = """\ {% for line in lines %}
{{ line | e }}
@@ -59,41 +19,50 @@ _LOG_LINE_TEMPLATE = """\ def _render_log_lines(text: str) -> str: - """Render raw log text into HTML fragment with line-by-line coloring.""" lines = text.rstrip("\n").split("\n") if text.strip() else [] return render_template_string(_LOG_LINE_TEMPLATE, lines=lines) -# --------------------------------------------------------------------------- -# Endpoints -# --------------------------------------------------------------------------- - - @bp.route("/journal") def journal(): - text = _sudo_journalctl("vacuum-wall") - return _render_log_lines(text) + try: + text = get("/logs/journal") + return _render_log_lines(text) + except RuntimeError: + return _render_log_lines("(error reading journal)\n") @bp.route("/nginx/access") def nginx_access(): - text = _tail_file("/var/log/nginx/access.log") - return _render_log_lines(text) + try: + text = get("/logs/nginx/access") + return _render_log_lines(text) + except RuntimeError: + return _render_log_lines("(log file not found)\n") @bp.route("/nginx/error") def nginx_error(): - text = _tail_file("/var/log/nginx/error.log") - return _render_log_lines(text) + try: + text = get("/logs/nginx/error") + return _render_log_lines(text) + except RuntimeError: + return _render_log_lines("(log file not found)\n") @bp.route("/dnsmasq") def dnsmasq(): - text = _sudo_journalctl("dnsmasq") - return _render_log_lines(text) + try: + text = get("/logs/dnsmasq") + return _render_log_lines(text) + except RuntimeError: + return _render_log_lines("(error reading journal)\n") @bp.route("/app") def app_log(): - text = _tail_file(str(APP_LOG_FILE)) - return _render_log_lines(text) + try: + text = get("/logs/app") + return _render_log_lines(text) + except RuntimeError: + return _render_log_lines("(log file not found)\n") diff --git a/webui/api/proxy.py b/webui/api/proxy.py index 91bb879..06a73b4 100644 --- a/webui/api/proxy.py +++ b/webui/api/proxy.py @@ -1,26 +1,13 @@ -""" -webui/api/proxy.py - Nginx proxy domain management API blueprint. +"""Nginx proxy domain management API blueprint. -Exposed at /api/proxy/* and delegates to lib.nginx. +Exposed at /api/proxy/* and delegates to vacuum-walld. """ import logging from flask import Blueprint, request -from lib.common import deep_merge -from lib.nginx import ( - add_domain, - apply, - get_config, - get_domains, - remove_domain, - save_config, - set_management_proxy, - test_config, - update_domain, - write_ssl_snippet, -) +from daemon.client import BadRequest, NotFound, delete, get, patch, post from webui.api.common import _error, _ok logger = logging.getLogger(__name__) @@ -29,9 +16,8 @@ bp = Blueprint("proxy", __name__) @bp.route("/ssl-apply", methods=["POST"]) def ssl_apply_bp(): - """Apply (write) the global SSL snippet for all Nginx server blocks.""" try: - write_ssl_snippet() + post("/nginx/ssl-apply") logger.info("SSL snippet written via API") return _ok(None) except RuntimeError as exc: @@ -39,15 +25,10 @@ def ssl_apply_bp(): return _error(str(exc), 500) -# --------------------------------------------------------------------------- -# Config (declarative) -# --------------------------------------------------------------------------- - - @bp.route("/config", methods=["GET"]) def get_config_bp(): try: - return _ok(get_config()) + return _ok(get("/nginx/config")) except RuntimeError as exc: logger.error("Failed to read proxy config: %s", exc) return _error(str(exc), 500) @@ -59,9 +40,12 @@ def post_config(): if not isinstance(body, dict): return _error("Request body must be a JSON object", 400) try: - save_config(body) + post("/nginx/config", body) logger.info("Proxy config saved: %s", sorted(body.keys())) return _ok(None) + except BadRequest as exc: + logger.info("Proxy config save rejected: %s", exc) + return _error(str(exc), 400) except RuntimeError as exc: logger.error("Failed to save proxy config: %s", exc) return _error(str(exc), 500) @@ -73,25 +57,21 @@ def patch_config(): if not isinstance(body, dict): return _error("Request body must be a JSON object", 400) try: - current = get_config() - merged = deep_merge(current, body) - save_config(merged) + patch("/nginx/config", body) logger.info("Proxy config patched: %s", sorted(body.keys())) return _ok(None) + except BadRequest as exc: + logger.info("Proxy config patch rejected: %s", exc) + return _error(str(exc), 400) except RuntimeError as exc: logger.error("Failed to patch proxy config: %s", exc) return _error(str(exc), 500) -# --------------------------------------------------------------------------- -# Domains -# --------------------------------------------------------------------------- - - @bp.route("/domains", methods=["GET"]) def list_domains(): try: - return _ok(get_domains()) + return _ok(get("/nginx/domains")) except RuntimeError as exc: logger.error("Failed to list proxy domains: %s", exc) return _error(str(exc), 500) @@ -113,26 +93,24 @@ def add_domain_bp(): if backend_port is None: return _error("'backend_port' is required", 400) try: - add_domain( - domain, backend_host, int(backend_port), backend_proto, cert, extra_headers + post( + "/nginx/domains/add", + { + "domain": domain, + "backend_host": backend_host, + "backend_port": int(backend_port), + "backend_proto": backend_proto, + "cert": cert, + "extra_headers": extra_headers, + }, ) logger.info("Proxy domain added via API: %s", domain) return _ok({"domain": domain}) - except (ValueError, RuntimeError) as exc: - logger.error("Failed to add proxy domain '%s': %s", domain, exc) - return _error(str(exc), 500) - - -@bp.route("/domains/", methods=["GET"]) -def domain_details(domain): - try: - cfg = get_config() - entry = cfg.get("domains", {}).get(domain) - if entry is None: - return _error(f"Domain '{domain}' not found", 404) - return _ok({"domain": domain, **entry}) + except BadRequest as exc: + logger.info("Add proxy domain '%s' rejected: %s", domain, exc) + return _error(str(exc), 400) except RuntimeError as exc: - logger.error("Failed to get domain details: %s", exc) + logger.error("Failed to add proxy domain '%s': %s", domain, exc) return _error(str(exc), 500) @@ -142,10 +120,14 @@ def update_domain_bp(domain): if not body: return _error("Request body must be a JSON object with fields to update", 400) try: - update_domain(domain, **body) + post("/nginx/domains/update", {"domain": domain, **body}) logger.info("Proxy domain '%s' updated via API", domain) return _ok({"domain": domain}) - except KeyError as exc: + except BadRequest as exc: + logger.info("Update domain '%s' rejected: %s", domain, exc) + return _error(str(exc), 400) + except NotFound as exc: + logger.info("Domain '%s' not found: %s", domain, exc) return _error(str(exc), 404) except RuntimeError as exc: logger.error("Failed to update domain '%s': %s", domain, exc) @@ -155,26 +137,21 @@ def update_domain_bp(domain): @bp.route("/domains/", methods=["DELETE"]) def remove_domain_bp(domain): try: - cfg = get_config() - if domain not in cfg.get("domains", {}): - return _error(f"Domain '{domain}' not found", 404) - remove_domain(domain) + delete("/nginx/domains/remove", {"domain": domain}) logger.info("Proxy domain removed via API: %s", domain) return _ok({"domain": domain}) + except NotFound as exc: + logger.info("Domain '%s' not found: %s", domain, exc) + return _error(str(exc), 404) except RuntimeError as exc: logger.error("Failed to remove domain '%s': %s", domain, exc) return _error(str(exc), 500) -# --------------------------------------------------------------------------- -# Apply / test -# --------------------------------------------------------------------------- - - @bp.route("/apply", methods=["POST"]) def apply_bp(): try: - apply() + post("/nginx/apply") logger.info("nginx config applied via API") return _ok(None) except RuntimeError as exc: @@ -185,20 +162,15 @@ def apply_bp(): @bp.route("/test", methods=["POST"]) def test_bp(): try: - valid, output = test_config() - if valid: - return _ok({"valid": True, "output": output}) - return _error(output, 400) + result = post("/nginx/test") + if result.get("valid"): + return _ok({"valid": True, "output": result.get("output", "")}) + return _error(result.get("output", "unknown error"), 400) except RuntimeError as exc: logger.error("nginx config test failed: %s", exc) return _error(str(exc), 500) -# --------------------------------------------------------------------------- -# Management proxy -# --------------------------------------------------------------------------- - - @bp.route("/management", methods=["POST"]) def management_bp(): body = request.get_json(silent=True) or {} @@ -210,10 +182,21 @@ def management_bp(): auth_user = body.get("auth_user") auth_pass = body.get("auth_pass") try: - set_management_proxy(domain, flask_host, int(flask_port), auth_user, auth_pass) + post( + "/nginx/management", + { + "domain": domain, + "flask_host": flask_host, + "flask_port": int(flask_port), + "auth_user": auth_user, + "auth_pass": auth_pass, + }, + ) logger.info("Management proxy configured via API: %s", domain) return _ok(None) - except (ValueError, RuntimeError) as exc: - code = 400 if isinstance(exc, ValueError) else 500 + except BadRequest as exc: + logger.info("Management proxy config rejected: %s", exc) + return _error(str(exc), 400) + except RuntimeError as exc: logger.error("Failed to set management proxy: %s", exc) - return _error(str(exc), code) + return _error(str(exc), 500) diff --git a/webui/api/wireguard.py b/webui/api/wireguard.py index cdd37e2..6851596 100644 --- a/webui/api/wireguard.py +++ b/webui/api/wireguard.py @@ -1,47 +1,23 @@ -""" -webui/api/wireguard.py - WireGuard tunnel management API blueprint. +"""WireGuard tunnel management API blueprint. -Exposed at /api/wireguard/* and delegates to lib.wireguard. +Exposed at /api/wireguard/* and delegates to vacuum-walld. """ import logging from flask import Blueprint, request -from lib.common import deep_merge -from lib.wireguard import ( - add_peer, - apply, - down, - generate_client_conf, - get_config, - get_peer_status, - get_peers, - initialize, - remove_peer, - save_config, - status, -) +from daemon.client import BadRequest, NotFound, delete, get, patch, post from webui.api.common import _error, _ok logger = logging.getLogger(__name__) bp = Blueprint("wireguard", __name__) -# --------------------------------------------------------------------------- -# Config -# --------------------------------------------------------------------------- - - @bp.route("/config", methods=["GET"]) def get_config_bp(): try: - cfg = get_config() - safe = dict(cfg) - if "interface" in safe: - safe["interface"] = dict(safe["interface"]) - safe["interface"].pop("private_key", None) - return _ok(safe) + return _ok(get("/wireguard/config")) except RuntimeError as exc: logger.error("Failed to read WireGuard config: %s", exc) return _error(str(exc), 500) @@ -53,19 +29,15 @@ def post_config(): if not isinstance(body, dict): return _error("Request body must be a JSON object", 400) try: - # Preserve existing server private key through full replacement - current = get_config() - current_key = current.get("interface", {}).get("private_key", "") - if "interface" in body: + body = dict(body) body["interface"] = dict(body["interface"]) body["interface"].pop("private_key", None) - - if current_key: - body.setdefault("interface", {})["private_key"] = current_key - - save_config(body) + post("/wireguard/config", body) return _ok(None) + except BadRequest as exc: + logger.info("WireGuard config save rejected: %s", exc) + return _error(str(exc), 400) except RuntimeError as exc: logger.error("Failed to save WireGuard config: %s", exc) return _error(str(exc), 500) @@ -78,27 +50,24 @@ def patch_config(): return _error("Request body must be a JSON object", 400) try: if "interface" in body: + body = dict(body) body["interface"] = dict(body["interface"]) body["interface"].pop("private_key", None) - current = get_config() - merged = deep_merge(current, body) - save_config(merged) + patch("/wireguard/config", body) logger.info("WireGuard config patched: %s", sorted(body.keys())) return _ok(None) + except BadRequest as exc: + logger.info("WireGuard config patch rejected: %s", exc) + return _error(str(exc), 400) except RuntimeError as exc: logger.error("Failed to patch WireGuard config: %s", exc) return _error(str(exc), 500) -# --------------------------------------------------------------------------- -# Apply / down -# --------------------------------------------------------------------------- - - @bp.route("/apply", methods=["POST"]) def apply_bp(): try: - apply() + post("/wireguard/apply") logger.info("WireGuard tunnel applied via API") return _ok(None) except RuntimeError as exc: @@ -109,7 +78,7 @@ def apply_bp(): @bp.route("/up", methods=["POST"]) def up_bp(): try: - apply() + post("/wireguard/apply") logger.info("WireGuard tunnel started via API") return _ok(None) except RuntimeError as exc: @@ -120,7 +89,7 @@ def up_bp(): @bp.route("/down", methods=["POST"]) def down_bp(): try: - down() + post("/wireguard/down") logger.info("WireGuard tunnel brought down via API") return _ok(None) except RuntimeError as exc: @@ -128,29 +97,19 @@ def down_bp(): return _error(str(exc), 500) -# --------------------------------------------------------------------------- -# Status -# --------------------------------------------------------------------------- - - @bp.route("/status", methods=["GET"]) def status_bp(): try: - return _ok(status()) + return _ok(get("/wireguard/status")) except RuntimeError as exc: logger.error("Failed to get WireGuard status: %s", exc) return _error(str(exc), 500) -# --------------------------------------------------------------------------- -# Initialize (first-time setup) -# --------------------------------------------------------------------------- - - @bp.route("/initialize", methods=["POST"]) def initialize_bp(): try: - initialize() + post("/wireguard/initialize") logger.info("WireGuard initialized via API") return _ok(None) except RuntimeError as exc: @@ -158,11 +117,6 @@ def initialize_bp(): return _error(str(exc), 500) -# --------------------------------------------------------------------------- -# Peer management -# --------------------------------------------------------------------------- - - @bp.route("/peers", methods=["POST"]) def add_peer_bp(): body = request.get_json(silent=True) or {} @@ -170,15 +124,21 @@ def add_peer_bp(): if not name: return _error("'name' is required", 400) try: - peer = add_peer( - name=name, - endpoint=body.get("endpoint"), - allowed_ips=body.get("allowed_ips", []), - persistent_keepalive=body.get("persistent_keepalive"), - preshared_key=body.get("preshared_key"), + peer = post( + "/wireguard/peers/add", + { + "name": name, + "endpoint": body.get("endpoint"), + "allowed_ips": body.get("allowed_ips", []), + "persistent_keepalive": body.get("persistent_keepalive"), + "preshared_key": body.get("preshared_key"), + }, ) logger.info("WireGuard peer '%s' added via API", name) return _ok(peer) + except BadRequest as exc: + logger.info("Add peer '%s' rejected: %s", name, exc) + return _error(str(exc), 400) except RuntimeError as exc: logger.error("Failed to add peer '%s': %s", name, exc) return _error(str(exc), 500) @@ -187,12 +147,12 @@ def add_peer_bp(): @bp.route("/peers/", methods=["DELETE"]) def remove_peer_bp(name): try: - cfg = get_config() - if name not in cfg.get("peers", {}): - return _error(f"Peer '{name}' not found", 404) - remove_peer(name) + delete("/wireguard/peers/remove", {"name": name}) logger.info("WireGuard peer '%s' removed via API", name) return _ok({"name": name}) + except NotFound as exc: + logger.info("WireGuard peer '%s' not found: %s", name, exc) + return _error(str(exc), 404) except RuntimeError as exc: logger.error("Failed to remove peer '%s': %s", name, exc) return _error(str(exc), 500) @@ -201,7 +161,7 @@ def remove_peer_bp(name): @bp.route("/peers", methods=["GET"]) def peers_bp(): try: - return _ok(get_peers()) + return _ok(get("/wireguard/peers")) except RuntimeError as exc: logger.error("Failed to list WireGuard peers: %s", exc) return _error(str(exc), 500) @@ -210,37 +170,34 @@ def peers_bp(): @bp.route("/peer-status", methods=["GET"]) def peer_status_bp(): try: - return _ok(get_peer_status()) + return _ok(get("/wireguard/peer-status")) except RuntimeError as exc: logger.error("Failed to get WireGuard peer status: %s", exc) return _error(str(exc), 500) -# --------------------------------------------------------------------------- -# Client config generation -# --------------------------------------------------------------------------- - - @bp.route("/generate-client", methods=["POST"]) def generate_client_bp(): body = request.get_json(silent=True) or {} name = body.get("name", "").strip() if not name: return _error("Field 'name' is required", 400) + server_endpoint = body.get("server_endpoint", "") + if not server_endpoint: + return _error("Field 'server_endpoint' is required", 400) try: - cfg = get_config() - if name not in cfg.get("peers", {}): - return _error(f"Peer '{name}' not found", 404) - server_endpoint = body.get("server_endpoint", "") - server_pubkey = cfg["interface"].get("public_key", "") - if not server_endpoint: - return _error( - "Field 'server_endpoint' is required (e.g., '203.0.113.1:51820')", 400 - ) - conf_text = generate_client_conf(name, server_endpoint, server_pubkey) + result = post( + "/wireguard/generate-client", + { + "name": name, + "server_endpoint": server_endpoint, + }, + ) logger.info("Client config generated for peer '%s' via API", name) - return _ok({"config": conf_text}) - except (KeyError, ValueError, RuntimeError) as exc: - code = 404 if isinstance(exc, (KeyError, ValueError)) else 500 + return _ok({"config": result.get("config", "")}) + except NotFound as exc: + logger.info("Peer '%s' not found for client config: %s", name, exc) + return _error(str(exc), 404) + except RuntimeError as exc: logger.error("Failed to generate client config for '%s': %s", name, exc) - return _error(str(exc), code) + return _error(str(exc), 500) diff --git a/webui/server.py b/webui/server.py index 7908746..9d96a0a 100644 --- a/webui/server.py +++ b/webui/server.py @@ -14,25 +14,8 @@ from pathlib import Path from flask import Flask, render_template, request -from lib.acme import get_email, list_certs -from lib.dnsmasq import get_config as dnsmasq_config -from lib.dnsmasq import get_lease_table -from lib.dnsmasq import get_status as dnsmasq_status -from lib.firewall import ( - config_pending, - get_active_zones, - get_interfaces, - get_services, - get_zone_info, -) -from lib.firewall import ( - get_config as fw_config_get, -) +from daemon.client import get from lib.logging import setup_logging -from lib.nginx import get_config as nginx_config -from lib.nginx import get_domains -from lib.wireguard import get_config as wg_config -from lib.wireguard import status as wg_status from webui.api.certs import bp as certs_bp from webui.api.dhcp import bp as dhcp_bp from webui.api.firewall import bp as firewall_bp @@ -117,7 +100,6 @@ def _log_request_finish(response): @app.template_filter("timestamp") def timestamp_filter(value): - """Convert an ISO timestamp string to a human-readable date.""" if not value: return "" try: @@ -129,7 +111,6 @@ def timestamp_filter(value): @app.template_filter("bytes") def bytes_filter(value): - """Format a byte count to a human-readable string (KB / MB / GB).""" try: num = float(value) except (ValueError, TypeError): @@ -145,7 +126,6 @@ def bytes_filter(value): @app.template_filter("duration") def duration_filter(value): - """Format a duration in seconds to a human-readable string.""" try: total = int(float(value)) except (ValueError, TypeError): @@ -168,7 +148,6 @@ def duration_filter(value): @app.template_filter("json_pretty") def json_pretty_filter(value): - """Pretty-print a JSON-serialisable value for debug displays.""" import json try: @@ -205,14 +184,21 @@ def _get_service_status(dnsmasq_info, wg_info): return services +def _fw_config_get(): + """Read firewall config via daemon.""" + return get("/firewall/config") + + @app.route("/") def dashboard(): - active_zones = _safely(get_active_zones, {}) - interfaces = _safely(get_interfaces, []) - dnsmasq = _safely(dnsmasq_status, {}) - domains = _safely(get_domains, []) - certs = _safely(list_certs, []) - wg = _safely(wg_status, {}) + active_zones = _safely( + lambda: {k: v for k, v in get("/firewall/zones").get("active", {}).items()}, {} + ) + interfaces = _safely(lambda: get("/firewall/interfaces"), []) + dnsmasq = _safely(lambda: get("/dnsmasq/status"), {}) + domains = _safely(lambda: get("/nginx/domains"), []) + certs = _safely(lambda: get("/acme/list"), []) + wg = _safely(lambda: get("/wireguard/status"), {}) return render_template( "dashboard.html", @@ -223,36 +209,30 @@ def dashboard(): certs=certs, wg_status=wg, services=_get_service_status(dnsmasq, wg), - firewall_config=_safely(fw_config_get, {}), - firewall_pending=_safely(config_pending, {}), + firewall_config=_safely(_fw_config_get, {}), + firewall_pending=_safely(lambda: get("/firewall/config/pending"), {}), ) @app.route("/interfaces") def interfaces_page(): - firewall_config = _safely(fw_config_get, {}) - firewall_pending = _safely(config_pending, {}) return render_template( "interfaces.html", - interfaces=_safely(get_interfaces, []), - active_zones=_safely(get_active_zones, {}), - firewall_config=firewall_config, - firewall_pending=firewall_pending, + interfaces=_safely(lambda: get("/firewall/interfaces"), []), + zones=_safely(lambda: get("/firewall/zones").get("available", []), []), + firewall_config=_safely(_fw_config_get, {}), + firewall_pending=_safely(lambda: get("/firewall/config/pending"), {}), ) @app.route("/zones") def zones_page(): - firewall_config = _safely(fw_config_get, {}) - firewall_pending = _safely(config_pending, {}) - zones_data = {} - for name in _safely(get_active_zones, {}): - zones_data[name] = _safely(lambda n=name: get_zone_info(n), {}) + firewall_config = _safely(_fw_config_get, {}) + firewall_pending = _safely(lambda: get("/firewall/config/pending"), {}) return render_template( "zones.html", - zones=zones_data, - interfaces=_safely(get_interfaces, []), - services=_safely(get_services, []), + zones=_safely(lambda: get("/firewall/zones/all"), []), + services=_safely(lambda: get("/firewall/services"), []), firewall_config=firewall_config, firewall_pending=firewall_pending, ) @@ -260,8 +240,8 @@ def zones_page(): @app.route("/rules") def rules_page(): - zones = list(_safely(get_active_zones, {}).keys()) - raw = _safely(fw_config_get, {}) + zones = list(_safely(lambda: get("/firewall/zones").get("active", {}).keys(), [])) + raw = _safely(_fw_config_get, {}) rules = {} for zname, zcfg in raw.get("zones", {}).items(): rr = zcfg.get("rich_rules", []) @@ -272,40 +252,46 @@ def rules_page(): @app.route("/nat") def nat_page(): - zones = {} - for name in _safely(get_active_zones, {}): - zones[name] = _safely(lambda n=name: get_zone_info(n), {}) - return render_template("nat.html", zones=zones) + return render_template( + "nat.html", zones=_safely(lambda: get("/firewall/zones/all"), []) + ) @app.route("/dhcp") def dhcp_page(): return render_template( "dhcp.html", - config=_safely(dnsmasq_config, {}), - status=_safely(dnsmasq_status, {}), - leases=_safely(get_lease_table, []), + config=_safely(lambda: get("/dnsmasq/config"), {}), + status=_safely(lambda: get("/dnsmasq/status"), {}), + leases=_safely(lambda: get("/dnsmasq/leases"), []), ) @app.route("/proxy") def proxy_page(): return render_template( - "proxy.html", domains=_safely(get_domains, []), config=_safely(nginx_config, {}) + "proxy.html", + domains=_safely(lambda: get("/nginx/domains"), []), + config=_safely(lambda: get("/nginx/config"), {}), ) @app.route("/certs") def certs_page(): + email_data = _safely(lambda: get("/acme/email"), {"email": ""}) return render_template( - "certs.html", certs=_safely(list_certs, []), email=_safely(get_email, "") + "certs.html", + certs=_safely(lambda: get("/acme/list"), []), + email=email_data.get("email", ""), ) @app.route("/wireguard") def wireguard_page(): return render_template( - "wireguard.html", config=_safely(wg_config, {}), status=_safely(wg_status, {}) + "wireguard.html", + config=_safely(lambda: get("/wireguard/config"), {}), + status=_safely(lambda: get("/wireguard/status"), {}), ) diff --git a/webui/templates/interfaces.html b/webui/templates/interfaces.html index d6d839f..15d84e1 100644 --- a/webui/templates/interfaces.html +++ b/webui/templates/interfaces.html @@ -23,7 +23,7 @@ {% for iface in (interfaces or []) %} - {{ iface.get('name', 'unknown') }} + {{ iface.get('display_name', iface.get('name', 'unknown')) }} {{ iface.get('mac', 'N/A') }} {% for ip in iface.get('ips', []) %} @@ -32,16 +32,16 @@ {% if not iface.get('ips') %}N/A{% endif %} - - {{ 'Up' if iface.get('state') == 'up' else 'Down' }} + + {{ 'Up' if iface.get('state') == 'UP' else 'Down' }} {% if zones %} {% else %} diff --git a/webui/templates/nat.html b/webui/templates/nat.html index 9b9af25..6fc9c7b 100644 --- a/webui/templates/nat.html +++ b/webui/templates/nat.html @@ -102,19 +102,19 @@ {% set all_forwards = [] %} {% for zone in (zones or []) %} {% for fwd in zone.get('forward_ports', []) %} - {% set _ = all_forwards.append({'zone': zone.get('name'), 'proxy-protocol': fwd.get('proxy-protocol'), 'port': fwd.get('port'), 'to-addr': fwd.get('to-addr'), 'to-port': fwd.get('to-port')}) %} + {% set _ = all_forwards.append({'zone': zone.get('name'), 'proto': fwd.get('proto'), 'port': fwd.get('port'), 'toaddr': fwd.get('toaddr'), 'toport': fwd.get('toport')}) %} {% endfor %} {% endfor %} {% for fwd in all_forwards %} {{ fwd.zone }} - {{ fwd['proxy-protocol'] }} + {{ fwd.proto }} {{ fwd.port }} - {{ fwd['to-addr'] }} - {{ fwd['to-port'] }} + {{ fwd.toaddr }} + {{ fwd.toport }} -
- + +