Compare commits

...

3 Commits

Author SHA1 Message Date
mteehan 7beba44b4b feat: pre-computed state store and async ACME issuance (fixes timeout mismatch)
- Add lib/state.py: in-memory state store with subsystem collectors
  (firewall, dnsmasq, nginx, acme, wireguard)
- Refactor all handlers: read from state on GET, call refresh_state()
  after mutations instead of invoking subprocesses per request
- daemon/server.py: add refresh_state(), /status/all, /status/refresh;
  populate state at startup
- webui/api/certs.py: async step-by-step ACME issuance (validate,
  issue with request_id, poll status) replacing blocking endpoint
- webui/server.py: render pages from state instead of direct lib calls
- Update templates, JS for async cert issuance with polling UI
- Update tests for state-based mocking; add test_state.py
- Fix SIM105 lint issue (contextlib.suppress)
- Add TODO.md with certificate issuance issue tracking

Resolves: WebUI 30s timeout freeze during cert issuance (Problem 1)
2026-05-30 05:45:40 +00:00
mteehan c091063248 fix: two-user model bug fixes and docs 2026-05-29 22:29:59 +00:00
mteehan cb683f7e61 install: two-user model ownership and deployment 2026-05-29 22:29:54 +00:00
28 changed files with 2100 additions and 1091 deletions
+5 -5
View File
@@ -15,9 +15,9 @@ vacuum-walld ──→ daemon/handlers/*.py ──→ sudo <cmd> ──→ syste
### Two-User Model with Shared Group ### 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-walld`** (daemon user): runs the privileged background daemon with `NOPASSWD sudo` whitelist (`/etc/sudoers.d/vacuum-walld`). Owns project directory and socket. Primary group is the WebUI user's primary group.
- **`vacuum-wall`** (WebUI user): runs the Flask process with **zero sudo** access. Communicates with the daemon via Unix socket. - **WebUI user** (default: repo owner in `--dev` mode): 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. - **Shared group**: both users share the WebUI user's primary group. Socket is `vacuum-walld:<group>` with mode `0660`. Project dir is owned by the WebUI user with group-read+execute.
### Code Layout ### Code Layout
@@ -45,7 +45,7 @@ Project uses `.venv`. Install deps with `pip install -e .` (from `pyproject.toml
## Deployment ## Deployment
`install.sh` installs only system components and configures them; the project serves from the repo root by default. All options can be set via env vars or CLI flags (CLI takes precedence). Set `INSTALL_DIR` or `--path` to override install directory. Use `--dev` to auto-detect repo owner as service user. The `vacuum-wall` system user has `HOME=$INSTALL_DIR` but no actual home directory (`--no-create-home`). `install.sh` installs only system components and configures them; the project serves from the repo root by default. All options can be set via env vars or CLI flags (CLI takes precedence). Set `INSTALL_DIR` or `--path` to override install directory. Use `--dev` to auto-detect repo owner as service user (non-dev mode requires `--user`).
All Python modules use `Path(__file__).resolve().parent.parent` for `PROJECT_DIR` — no hardcoded paths. ACME certs live at `PROJECT_DIR/data/acme/`. All Python modules use `Path(__file__).resolve().parent.parent` for `PROJECT_DIR` — no hardcoded paths. ACME certs live at `PROJECT_DIR/data/acme/`.
@@ -55,7 +55,7 @@ All Python modules use `Path(__file__).resolve().parent.parent` for `PROJECT_DIR
.venv/bin/python webui/server.py # binds 127.0.0.1:9090 .venv/bin/python webui/server.py # binds 127.0.0.1:9090
``` ```
In production the systemd unit runs as the `vacuum-wall` system user (`NoNewPrivileges`, `ProtectSystem=strict`, loopback-only networking). In production the systemd unit runs as the configured service 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. 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.
+44
View File
@@ -0,0 +1,44 @@
# TODO — Certificate Issuance Issues
## Problem 1: Timeout Mismatch Causes WebUI Freeze → FIXED
**Date Found:** May 30, 2026
**Date Fixed:** May 30, 2026
**Symptom:** Clicking "Issue Certificate" in the WebUI freezes for ~30 seconds, then returns a 500 error with "Daemon request timed out". Meanwhile the daemon silently runs `acme.sh` in the background for up to 120s before timing out itself.
**Root Cause:**
- `daemon/client.py` — WebUI client uses `timeout=30` for all daemon requests
- `daemon/handlers/acme.py` — Daemon allows `acme.sh` subprocess `timeout=120`
- The WebUI gives up at 30s while the daemon is still legitimately processing
**Fix Applied:** Replaced blocking issue endpoint with async step-by-step issuance:
- `POST /acme/validate` — Pre-flight checks (instant): acme.sh installed, email configured, webroot ready, DNS resolves, challenge configured
- `POST /acme/issue` — Returns immediately with `request_id`, spawns background task
- `GET /acme/issue/status` — Client polls for step-by-step progress
- UI shows pre-check results, then step progress with polling (no timeout issues)
- Blocking DNS check prevents wasted acme.sh calls when domain doesn't resolve
**Files Changed:** `daemon/handlers/acme.py`, `webui/api/certs.py`, `webui/templates/certs.html`, `webui/static/app.js`
---
## Problem 2: ZeroSSL Rate Limits Block Certificate Issuance
**Date Found:** May 30, 2026
**Symptom:** `acme.sh` fails to issue a certificate for `218broad.vacuum.network` with:
```
The retryafter=86400 value is too large (> 600), will not retry anymore.
```
**Root Cause:** ZeroSSL CA returns a `retry-after` of 86400 seconds (24 hours), likely from a prior failed challenge. `acme.sh` has a hard cap of 600s on retry-after values and refuses to proceed when the CA requests a longer wait.
**Files:** N/A (acme.sh behavior, not a project code issue)
**Workarounds:**
- Switch CA to Let's Encrypt: `acme.sh --set-default-ca --server letsencrypt`
- Wait 24 hours and retry
- Investigate and clean up prior failed challenges for the domain on ZeroSSL's side
## Status
+2 -2
View File
@@ -5,6 +5,7 @@ Communicates with vacuum-walld over a Unix socket using requests-unixsocket.
import json import json
import logging import logging
import urllib.parse
from typing import Any from typing import Any
import requests import requests
@@ -64,12 +65,11 @@ def request(
Raises NotFound on HTTP 404. Raises BadRequest on HTTP 400. Raises NotFound on HTTP 404. Raises BadRequest on HTTP 400.
""" """
sp = socket_path or _get_socket_path() sp = socket_path or _get_socket_path()
url = f"http://localhost{path}" url = f"http+unix://{urllib.parse.quote(sp, safe='')}{path}"
sess = requests_unixsocket.Session() sess = requests_unixsocket.Session()
try: try:
kwargs: dict[str, Any] = { kwargs: dict[str, Any] = {
"timeout": timeout, "timeout": timeout,
"unix_socket": sp,
} }
if method == "GET": if method == "GET":
if query_params: if query_params:
+386 -119
View File
@@ -1,15 +1,19 @@
"""ACME certificate daemon handler.""" """ACME certificate daemon handler."""
import asyncio
import logging import logging
import os import os
import re import re
import shutil import socket
import subprocess import subprocess
from contextlib import suppress
from dataclasses import dataclass, field
from datetime import UTC, datetime from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from uuid import uuid4
from daemon.server import NotFoundError, registry from daemon.server import NotFoundError, refresh_state, registry
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -26,21 +30,56 @@ _ACME_ENVIRON = {
_WEBROOT = PROJECT_DIR / "data" / "acme" / "www" _WEBROOT = PROJECT_DIR / "data" / "acme" / "www"
_ACME_TAGS = {"acme"} # In-memory store for active issuance requests.
_ISSUANCES: dict[str, "IssueRequest"] = {}
_ISSUANCE_TTL = 300 # seconds to keep completed requests
def _find_acme() -> str: @dataclass
candidates = [_ACME_HOME / "acme.sh", Path("/usr/local/bin/acme.sh")] class IssueStep:
for path in candidates: name: str
if path.is_file() and os.access(path, os.X_OK): label: str
return str(path) status: str = "pending"
acme = shutil.which("acme.sh") message: str | None = None
if acme:
return acme
raise FileNotFoundError("acme.sh not found") @dataclass
class IssueRequest:
request_id: str
domain: str
email: str | None = None
webroot: str | None = None
steps: list[IssueStep] = field(default_factory=list)
status: str = "running"
created_at: float = field(default_factory=lambda: datetime.now(UTC).timestamp())
expires_at: float | None = None
def to_dict(self) -> dict[str, Any]:
return {
"request_id": self.request_id,
"domain": self.domain,
"status": self.status,
"steps": [
{
"name": s.name,
"label": s.label,
"status": s.status,
"message": s.message,
}
for s in self.steps
],
"created_at": self.created_at,
"expires_at": self.expires_at,
}
# ---------------------------------------------------------------------------
# Internal helpers
def _run_acme(args: list[str]) -> str: def _run_acme(args: list[str]) -> str:
from lib.state import _find_acme
acme_bin = _find_acme() acme_bin = _find_acme()
acme_home_env = os.environ.get("ACME_HOME", str(_ACME_HOME)) acme_home_env = os.environ.get("ACME_HOME", str(_ACME_HOME))
cmd = [acme_bin, "--home", acme_home_env, "--config-home", acme_home_env, *args] cmd = [acme_bin, "--home", acme_home_env, "--config-home", acme_home_env, *args]
@@ -62,106 +101,13 @@ def _run_acme(args: list[str]) -> str:
return output return output
def _days_until(date_str: str) -> int | None: def _find_acme_bin() -> str:
if not date_str: from lib.state import _find_acme
return None
for fmt in ("%Y-%m-%d", "%Y-%m-%dT%H:%M:%S"): return _find_acme()
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]: def _get_acme_email() -> str:
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: try:
acme_home = Path(os.environ.get("ACME_HOME", str(_ACME_HOME))) acme_home = Path(os.environ.get("ACME_HOME", str(_ACME_HOME)))
account_conf = acme_home / "account.conf" account_conf = acme_home / "account.conf"
@@ -169,19 +115,334 @@ def issue_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
text = account_conf.read_text() text = account_conf.read_text()
match = re.search(r"^ACME_LEEMAIL=(.+)$", text, re.MULTILINE) match = re.search(r"^ACME_LEEMAIL=(.+)$", text, re.MULTILINE)
if match: if match:
contact = match.group(1).strip().strip("'\"") return match.group(1).strip().strip("'\"")
except OSError: except OSError:
pass pass
return ""
def _get_state() -> dict[str, Any] | None:
from lib.state import state as state_store
return state_store.get("acme")
def _get_acme_state() -> dict[str, Any]:
ac = _get_state()
if ac is None:
return {}
return ac
def _clean_expired_issuances() -> None:
"""Remove completed requests older than TTL."""
now = datetime.now(UTC).timestamp()
expired = [
rid
for rid, req in _ISSUANCES.items()
if req.expires_at and now > req.expires_at
]
for rid in expired:
del _ISSUANCES[rid]
# ---------------------------------------------------------------------------
# Validation helpers
def _check_domain_format(domain: str) -> tuple[bool, str]:
"""Validate basic domain name format."""
import re as _re
pattern = r"^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*$"
if not _re.match(pattern, domain):
return False, "Invalid domain name format"
return True, ""
def _check_dns_resolves(domain: str) -> tuple[bool, str]:
"""Check that domain resolves to this machine's IP via A record."""
try:
results = socket.getaddrinfo(domain, 80, socket.AF_UNSPEC, socket.SOCK_STREAM)
if not results:
return False, "Domain does not resolve to any address"
local_ips = set()
hostname = socket.gethostname()
with suppress(OSError):
local_ips.add(socket.gethostbyname(hostname))
# Also collect all interface IPs
try:
import ipaddress
from fcntl import ioctl
def get_interfaces():
import struct
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
names = b"\x00" * 4096
raw = ioctl(s.fileno(), 0x8912, names)
s.close()
ifaces = []
for i in range(0, 4096, 32):
name = raw[i : i + 16].split(b"\x00")[0].decode()
if name == "lo":
continue
addr = struct.unpack("<I", raw[i + 16 : i + 20])[0]
ifaces.append(str(ipaddress.IPv4Address(addr)))
return ifaces
local_ips.update(get_interfaces())
except Exception:
pass
resolved = False
for _, _, _, _, addr in results:
if addr in local_ips:
resolved = True
break
if resolved:
return True, "DNS resolves correctly"
return (
False,
f"Domain resolves to {results[0][4][0]}, not this server",
)
except socket.gaierror:
return False, "Domain does not resolve (NXDOMAIN or timeout)"
def _check_acme_installed() -> tuple[bool, str]:
try:
_find_acme_bin()
return True, "acme.sh found"
except FileNotFoundError:
return False, "acme.sh not installed"
def _check_email_configured() -> tuple[bool, str]:
email = _get_acme_email() or ""
if email:
return True, f"Contact email configured: {email}"
return False, "No ACME contact email configured"
def _check_webroot() -> tuple[bool, str]:
if _WEBROOT.is_dir() and os.access(str(_WEBROOT), os.W_OK):
return True, "ACME webroot ready"
return False, "ACME webroot not ready or not writable"
def _check_challenge_config() -> tuple[bool, str]:
from lib.nginx import SITES_DIR
site_conf = SITES_DIR / "_acme-challenge.conf" if SITES_DIR else None
if site_conf and site_conf.is_file():
return True, "ACME challenge nginx config present"
return False, "ACME challenge nginx config missing"
def _check_existing_cert(domain: str) -> tuple[bool, str]:
"""Warn if a valid cert already exists (not blocking)."""
try:
from lib.acme import days_until_expiry
days = days_until_expiry(domain)
if days is not None and days > 0:
return True, f"Valid certificate exists ({days} days remaining)"
except (ValueError, RuntimeError, FileNotFoundError):
pass
return True, ""
def _validate(domain: str) -> dict[str, Any]:
"""Run all pre-checks for a domain. Returns structured results."""
checks: list[dict[str, Any]] = []
ready = True
check_fns = [
("acme_installed", _check_acme_installed, True),
("email_configured", _check_email_configured, True),
("webroot_ready", _check_webroot, True),
("challenge_configured", _check_challenge_config, True),
("domain_format", lambda: _check_domain_format(domain), True),
("dns_resolves", lambda: _check_dns_resolves(domain), True),
("existing_cert", lambda: _check_existing_cert(domain), False),
]
for name, fn, blocking in check_fns:
try:
passed, msg = fn()
checks.append(
{"name": name, "passed": passed, "message": msg, "blocking": blocking}
)
if not passed and blocking:
ready = False
except Exception as exc:
checks.append(
{
"name": name,
"passed": False,
"message": str(exc),
"blocking": blocking,
}
)
ready = False
return {"domain": domain, "checks": checks, "ready": ready}
# ---------------------------------------------------------------------------
# Routes — status reads from state, mutations call refresh_state
@registry.register("GET", "/acme/list")
def list_certs(_request: Any, _body: Any) -> list[dict]:
ac = _get_acme_state()
return ac.get("certs", [])
@registry.register("GET", "/acme/info")
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.get("san_domains", []):
return c
raise NotFoundError(f"No certificate found for domain: {domain}")
@registry.register("POST", "/acme/validate")
def validate_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")
return _validate(domain)
@registry.register("POST", "/acme/issue")
async 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")
email = body.get("email", "").strip() or None
webroot = body.get("webroot")
_clean_expired_issuances()
# Dedup: if domain already has an active request, return existing ID
for existing in _ISSUANCES.values():
if existing.domain == domain and existing.status == "running":
return {
"request_id": existing.request_id,
"status": "existing",
"domain": domain,
"message": "Issuance already in progress for this domain",
}
# Run pre-flight checks
_validate_checks = _validate(domain)
if not _validate_checks["ready"]:
failed = [
c["name"]
for c in _validate_checks["checks"]
if not c["passed"] and c["blocking"]
]
raise RuntimeError(f"Pre-flight checks failed: {', '.join(failed)}")
# Create tracked request
request_id = uuid4().hex[:12]
steps = [
IssueStep(name="issue", label="Issuing certificate"),
IssueStep(name="deploy", label="Registering deploy hook"),
IssueStep(name="refresh", label="Refreshing certificate state"),
]
req = IssueRequest(
request_id=request_id,
domain=domain,
email=email,
webroot=webroot,
steps=steps,
)
_ISSUANCES[request_id] = req
# Spawn background task
_task = asyncio.create_task(_run_issue(req)) # noqa: RUF006 — task runs to completion on its own
return {"request_id": request_id, "domain": domain}
@registry.register("GET", "/acme/issue/status")
def get_issuance_status(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
request_id = (body or {}).get("id", "").strip()
if not request_id:
raise ValueError("'id' is required")
req = _ISSUANCES.get(request_id)
if not req:
raise NotFoundError(f"Issuance request {request_id} not found")
return req.to_dict()
async def _run_issue(req: IssueRequest) -> None:
"""Background task: run acme.sh steps, update step status."""
try:
# Step 1: issue
req.steps[0].status = "running"
args: list[str] = ["--issue", "-d", req.domain]
args.extend(["--webroot", req.webroot or str(_WEBROOT)])
contact = req.email
if not contact:
contact = _get_acme_email()
if contact: if contact:
args.extend(["-m", contact]) args.extend(["-m", contact])
args.append("--force") args.append("--force")
output = _run_acme(args) output = _run_acme(args)
_run_acme(["--deploy", "-d", domain, "--deploy-hook", _DEPLOY_HOOK]) req.steps[0].status = "done"
logger.info("Certificate for %s issued", domain) req.steps[0].message = output.strip()[:200]
return {"domain": domain, "output": output.strip()}
# Step 2: deploy
req.steps[1].status = "running"
_run_acme(["--deploy", "-d", req.domain, "--deploy-hook", _DEPLOY_HOOK])
req.steps[1].status = "done"
req.steps[1].message = "Deploy hook registered"
# Step 3: refresh state
req.steps[2].status = "running"
refresh_state(["acme"])
req.steps[2].status = "done"
req.steps[2].message = "State refreshed"
req.status = "completed"
req.expires_at = datetime.now(UTC).timestamp() + _ISSUANCE_TTL
logger.info(
"Certificate for %s issued (request %s)", req.domain, req.request_id
)
except Exception as exc:
# Mark current running step as error, overall as failed
for step in req.steps:
if step.status == "running":
step.status = "error"
step.message = str(exc)
break
else:
req.steps.append(
IssueStep(name="error", label="Error", status="error", message=str(exc))
)
req.status = "failed"
req.expires_at = datetime.now(UTC).timestamp() + _ISSUANCE_TTL
logger.error("Cert issuance for %s failed: %s", req.domain, exc)
@registry.register("POST", "/acme/renew", invalidate=_ACME_TAGS | {"nginx"}) @registry.register("POST", "/acme/renew")
def renew_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: def renew_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
if not body: if not body:
raise ValueError("Request body required") raise ValueError("Request body required")
@@ -195,10 +456,11 @@ def renew_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
output = _run_acme(args) output = _run_acme(args)
_run_acme(["--deploy", "-d", domain, "--deploy-hook", _DEPLOY_HOOK]) _run_acme(["--deploy", "-d", domain, "--deploy-hook", _DEPLOY_HOOK])
logger.info("Certificate for %s renewed", domain) logger.info("Certificate for %s renewed", domain)
refresh_state(["acme"])
return {"domain": domain, "output": output.strip()} return {"domain": domain, "output": output.strip()}
@registry.register("DELETE", "/acme/remove", invalidate=_ACME_TAGS) @registry.register("DELETE", "/acme/remove")
def remove_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: def remove_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
if not body: if not body:
raise ValueError("Request body required") raise ValueError("Request body required")
@@ -207,10 +469,11 @@ def remove_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
raise ValueError("'domain' is required") raise ValueError("'domain' is required")
_run_acme(["--remove", "-d", domain]) _run_acme(["--remove", "-d", domain])
logger.info("Certificate for %s removed", domain) logger.info("Certificate for %s removed", domain)
refresh_state(["acme"])
return {"domain": domain} return {"domain": domain}
@registry.register("POST", "/acme/email", invalidate=_ACME_TAGS) @registry.register("POST", "/acme/email")
def set_email(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: def set_email(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
if not body: if not body:
raise ValueError("Request body required") raise ValueError("Request body required")
@@ -219,11 +482,15 @@ def set_email(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
raise ValueError("'email' is required") raise ValueError("'email' is required")
_run_acme(["--register-account", "-m", email]) _run_acme(["--register-account", "-m", email])
logger.info("ACME email set to %s", email) logger.info("ACME email set to %s", email)
refresh_state(["acme"])
return {"email": email} return {"email": email}
@registry.register("GET", "/acme/email", cache_tags=_ACME_TAGS) @registry.register("GET", "/acme/email")
def get_email(_request: Any, _body: Any) -> dict[str, Any]: def get_email(_request: Any, _body: Any) -> dict[str, Any]:
ac = _get_acme_state()
if ac:
return {"email": ac.get("email", "")}
try: try:
acme_home = Path(os.environ.get("ACME_HOME", str(_ACME_HOME))) acme_home = Path(os.environ.get("ACME_HOME", str(_ACME_HOME)))
account_conf = acme_home / "account.conf" account_conf = acme_home / "account.conf"
@@ -237,7 +504,7 @@ def get_email(_request: Any, _body: Any) -> dict[str, Any]:
return {"email": ""} return {"email": ""}
@registry.register("GET", "/acme/paths", cache_tags=_ACME_TAGS) @registry.register("GET", "/acme/paths")
def get_cert_paths(_request: Any, body: dict[str, Any] | None) -> dict[str, str]: def get_cert_paths(_request: Any, body: dict[str, Any] | None) -> dict[str, str]:
if not body or "domain" not in body: if not body or "domain" not in body:
raise ValueError("'domain' is required") raise ValueError("'domain' is required")
+52 -82
View File
@@ -8,7 +8,7 @@ from typing import Any
from jinja2 import Environment, FileSystemLoader from jinja2 import Environment, FileSystemLoader
from daemon.server import NotFoundError, registry from daemon.server import NotFoundError, refresh_state, registry
from lib.common import deep_merge, ensure_dirs, load_json, run, run_proc, save_json from lib.common import deep_merge, ensure_dirs, load_json, run, run_proc, save_json
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -33,7 +33,11 @@ DEFAULT_CFG: dict[str, Any] = {
"dns": {"upstreams": ["8.8.8.8", "1.1.1.1"], "domain": None, "custom_records": []}, "dns": {"upstreams": ["8.8.8.8", "1.1.1.1"], "domain": None, "custom_records": []},
} }
_DNSMASQ_TAGS = {"dnsmasq"}
def _get_state() -> dict[str, Any] | None:
from lib.state import state as state_store
return state_store.get("dnsmasq")
def _get_config() -> dict[str, Any]: def _get_config() -> dict[str, Any]:
@@ -66,66 +70,46 @@ def _generate_conf(cfg: dict[str, Any]) -> str:
) )
def _parse_lease_line(line: str) -> dict[str, Any] | None: def _get_dnsmasq_state() -> dict[str, Any]:
line = line.strip() dm = _get_state()
if not line or line.startswith("#"): if dm is None:
return None return {}
parts = line.split() return dm
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]] = [] # Routes
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) @registry.register("GET", "/dnsmasq/config")
def get_config(_request: Any, _body: Any) -> dict[str, Any]: def get_config(_request: Any, _body: Any) -> dict[str, Any]:
dm = _get_dnsmasq_state()
if dm:
return dm.get("config", {})
return _get_config() return _get_config()
@registry.register("POST", "/dnsmasq/config", invalidate=_DNSMASQ_TAGS) @registry.register("POST", "/dnsmasq/config")
def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
if not body: if not body:
raise ValueError("Request body required") raise ValueError("Request body required")
_save_config(body) _save_config(body)
refresh_state(["dnsmasq"])
return {"config_saved": True} return {"config_saved": True}
@registry.register("PATCH", "/dnsmasq/config", invalidate=_DNSMASQ_TAGS) @registry.register("PATCH", "/dnsmasq/config")
def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
if not body: if not body:
raise ValueError("Request body required") raise ValueError("Request body required")
current = _get_config() current = _get_config()
merged = deep_merge(current, body) merged = deep_merge(current, body)
_save_config(merged) _save_config(merged)
refresh_state(["dnsmasq"])
return {"config_saved": True} return {"config_saved": True}
@registry.register("POST", "/dnsmasq/apply", invalidate=_DNSMASQ_TAGS) @registry.register("POST", "/dnsmasq/apply")
def apply_config(_request: Any, _body: Any) -> dict[str, Any]: def apply_config(_request: Any, _body: Any) -> dict[str, Any]:
cfg = _get_config() cfg = _get_config()
conf_text = _generate_conf(cfg) conf_text = _generate_conf(cfg)
@@ -139,44 +123,19 @@ def apply_config(_request: Any, _body: Any) -> dict[str, Any]:
) )
run(["systemctl", "reload", "dnsmasq"], sudo=True) run(["systemctl", "reload", "dnsmasq"], sudo=True)
logger.info("dnsmasq config written and reloaded") logger.info("dnsmasq config written and reloaded")
refresh_state(["dnsmasq"])
return {"applied": True} return {"applied": True}
@registry.register("GET", "/dnsmasq/status", cache_tags=_DNSMASQ_TAGS) @registry.register("GET", "/dnsmasq/status")
def get_status(_request: Any, _body: Any) -> dict[str, Any]: def get_status(_request: Any, _body: Any) -> dict[str, Any]:
cfg = _get_config() dm = _get_dnsmasq_state()
try: if dm and "status" in dm:
proc = run_proc( return dm["status"]
["systemctl", "is-active", "dnsmasq"], sudo=True return {}
)
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) @registry.register("POST", "/dnsmasq/ranges/add")
def set_dhcp_range(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: def set_dhcp_range(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
if not body: if not body:
raise ValueError("Request body required") raise ValueError("Request body required")
@@ -216,10 +175,11 @@ def set_dhcp_range(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]
entry["dns"] = body["dns"] entry["dns"] = body["dns"]
ranges.append(entry) ranges.append(entry)
_save_config(cfg) _save_config(cfg)
refresh_state(["dnsmasq"])
return {"interface": iface, "start": start, "end": end} return {"interface": iface, "start": start, "end": end}
@registry.register("DELETE", "/dnsmasq/ranges/remove", invalidate=_DNSMASQ_TAGS) @registry.register("DELETE", "/dnsmasq/ranges/remove")
def remove_dhcp_range(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: def remove_dhcp_range(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
if not body: if not body:
raise ValueError("Request body required") raise ValueError("Request body required")
@@ -245,15 +205,19 @@ def remove_dhcp_range(_request: Any, body: dict[str, Any] | None) -> dict[str, A
f"DHCP range for interface '{iface}' ({start}-{end}) not found" f"DHCP range for interface '{iface}' ({start}-{end}) not found"
) )
_save_config(cfg) _save_config(cfg)
refresh_state(["dnsmasq"])
return {"interface": iface, "start": start, "end": end} return {"interface": iface, "start": start, "end": end}
@registry.register("GET", "/dnsmasq/leases", cache_tags=_DNSMASQ_TAGS) @registry.register("GET", "/dnsmasq/leases")
def get_leases(_request: Any, _body: Any) -> list[dict[str, Any]]: def get_leases(_request: Any, _body: Any) -> list[dict[str, Any]]:
return _get_lease_table() dm = _get_dnsmasq_state()
if dm:
return dm.get("leases", [])
return []
@registry.register("POST", "/dnsmasq/static-lease/add", invalidate=_DNSMASQ_TAGS) @registry.register("POST", "/dnsmasq/static-lease/add")
def add_static_lease(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: def add_static_lease(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
if not body: if not body:
raise ValueError("Request body required") raise ValueError("Request body required")
@@ -270,16 +234,18 @@ def add_static_lease(_request: Any, body: dict[str, Any] | None) -> dict[str, An
if hostname is not None: if hostname is not None:
leases[i]["hostname"] = hostname leases[i]["hostname"] = hostname
_save_config(cfg) _save_config(cfg)
refresh_state(["dnsmasq"])
return {"mac": mac, "ip": ip, "hostname": hostname} return {"mac": mac, "ip": ip, "hostname": hostname}
entry: dict[str, Any] = {"mac": mac, "ip": ip} entry: dict[str, Any] = {"mac": mac, "ip": ip}
if hostname: if hostname:
entry["hostname"] = hostname entry["hostname"] = hostname
leases.append(entry) leases.append(entry)
_save_config(cfg) _save_config(cfg)
refresh_state(["dnsmasq"])
return {"mac": mac, "ip": ip, "hostname": hostname} return {"mac": mac, "ip": ip, "hostname": hostname}
@registry.register("DELETE", "/dnsmasq/static-lease/remove", invalidate=_DNSMASQ_TAGS) @registry.register("DELETE", "/dnsmasq/static-lease/remove")
def remove_static_lease(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: def remove_static_lease(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
if not body: if not body:
raise ValueError("Request body required") raise ValueError("Request body required")
@@ -295,10 +261,11 @@ def remove_static_lease(_request: Any, body: dict[str, Any] | None) -> dict[str,
if len(cfg["dhcp"]["static_leases"]) == before: if len(cfg["dhcp"]["static_leases"]) == before:
raise NotFoundError(f"Static lease for MAC '{mac}' not found") raise NotFoundError(f"Static lease for MAC '{mac}' not found")
_save_config(cfg) _save_config(cfg)
refresh_state(["dnsmasq"])
return {"mac": mac} return {"mac": mac}
@registry.register("POST", "/dnsmasq/dns-record/add", invalidate=_DNSMASQ_TAGS) @registry.register("POST", "/dnsmasq/dns-record/add")
def add_dns_record(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: def add_dns_record(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
if not body: if not body:
raise ValueError("Request body required") raise ValueError("Request body required")
@@ -315,16 +282,18 @@ def add_dns_record(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]
if hostname is not None: if hostname is not None:
records[i]["hostname"] = hostname records[i]["hostname"] = hostname
_save_config(cfg) _save_config(cfg)
refresh_state(["dnsmasq"])
return {"name": name, "address": address, "hostname": hostname} return {"name": name, "address": address, "hostname": hostname}
entry: dict[str, Any] = {"name": name, "address": address} entry: dict[str, Any] = {"name": name, "address": address}
if hostname: if hostname:
entry["hostname"] = hostname entry["hostname"] = hostname
records.append(entry) records.append(entry)
_save_config(cfg) _save_config(cfg)
refresh_state(["dnsmasq"])
return {"name": name, "address": address, "hostname": hostname} return {"name": name, "address": address, "hostname": hostname}
@registry.register("DELETE", "/dnsmasq/dns-record/remove", invalidate=_DNSMASQ_TAGS) @registry.register("DELETE", "/dnsmasq/dns-record/remove")
def remove_dns_record(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: def remove_dns_record(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
if not body: if not body:
raise ValueError("Request body required") raise ValueError("Request body required")
@@ -334,26 +303,26 @@ def remove_dns_record(_request: Any, body: dict[str, Any] | None) -> dict[str, A
cfg = _get_config() cfg = _get_config()
records = cfg["dns"]["custom_records"] records = cfg["dns"]["custom_records"]
before = len(records) before = len(records)
cfg["dns"]["custom_records"] = [ cfg["dns"]["custom_records"] = [r for r in records if r["name"] != name]
r for r in records if r["name"] != name
]
if len(cfg["dns"]["custom_records"]) == before: if len(cfg["dns"]["custom_records"]) == before:
raise NotFoundError(f"DNS record '{name}' not found") raise NotFoundError(f"DNS record '{name}' not found")
_save_config(cfg) _save_config(cfg)
refresh_state(["dnsmasq"])
return {"name": name} return {"name": name}
@registry.register("POST", "/dnsmasq/upstreams", invalidate=_DNSMASQ_TAGS) @registry.register("POST", "/dnsmasq/upstreams")
def set_upstreams(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: def set_upstreams(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
if not body or "servers" not in body: if not body or "servers" not in body:
raise ValueError("'servers' is required") raise ValueError("'servers' is required")
cfg = _get_config() cfg = _get_config()
cfg["dns"]["upstreams"] = list(body["servers"]) cfg["dns"]["upstreams"] = list(body["servers"])
_save_config(cfg) _save_config(cfg)
refresh_state(["dnsmasq"])
return {"upstreams": cfg["dns"]["upstreams"]} return {"upstreams": cfg["dns"]["upstreams"]}
@registry.register("POST", "/dnsmasq/domain", invalidate=_DNSMASQ_TAGS) @registry.register("POST", "/dnsmasq/domain")
def set_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: def set_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
if not body: if not body:
raise ValueError("Request body required") raise ValueError("Request body required")
@@ -361,4 +330,5 @@ def set_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
cfg = _get_config() cfg = _get_config()
cfg["dns"]["domain"] = domain if domain else None cfg["dns"]["domain"] = domain if domain else None
_save_config(cfg) _save_config(cfg)
refresh_state(["dnsmasq"])
return {"domain": cfg["dns"]["domain"]} return {"domain": cfg["dns"]["domain"]}
+106 -217
View File
@@ -1,28 +1,21 @@
"""Firewall daemon handler. """Firewall daemon handler.
Executes firewall-cmd and ip commands with sudo, returns structured results. Reads from the pre-computed state for status endpoints. Executes
Parsing helpers are imported from lib.firewall. firewall-cmd with sudo for mutations. Refers state after each mutation.
""" """
import logging import logging
from contextlib import suppress from contextlib import suppress
from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from uuid import uuid4
from daemon.server import NotFoundError, registry from daemon.server import NotFoundError, refresh_state, registry
from lib.common import load_json, run, save_json from lib.common import load_json, run, save_json
from lib.firewall import ( from lib.firewall import (
_normalize_target, _normalize_target,
_parse_active_zones,
_parse_zone_output, _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 ( from lib.firewall import (
save_backup as _save_backup, save_backup as _save_backup,
) )
@@ -30,16 +23,16 @@ from lib.firewall import (
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
PROJECT_DIR = Path(__file__).resolve().parent.parent.parent 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_DIR = PROJECT_DIR / "config" / "firewall"
CONFIG_FILE = CONFIG_DIR / "config.json" CONFIG_FILE = CONFIG_DIR / "config.json"
DEFAULT_CONFIG = {"zones": {}} DEFAULT_CONFIG = {"zones": {}}
# --------------------------------------------------------------------------- def _get_state() -> dict[str, Any] | None:
# Config helpers """Return the current firewall state from the state store."""
# --------------------------------------------------------------------------- from lib.state import state as state_store
return state_store.get("firewall")
def _ensure_config_file() -> None: def _ensure_config_file() -> None:
@@ -62,10 +55,6 @@ def _reload() -> None:
run(["firewall-cmd", "--reload"], sudo=True) run(["firewall-cmd", "--reload"], sudo=True)
def _now_iso() -> str:
return datetime.now(UTC).isoformat()
def _fp_to_str(fp: dict[str, Any]) -> str: def _fp_to_str(fp: dict[str, Any]) -> str:
parts = [f"port={fp['port']}", f"proto={fp['proto']}"] parts = [f"port={fp['port']}", f"proto={fp['proto']}"]
if "toaddr" in fp: if "toaddr" in fp:
@@ -85,92 +74,22 @@ def _get_forward_ports(zone_name: str) -> list[str]:
return [] 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]: def _config_apply() -> dict[str, Any]:
"""Apply the declarative config to live firewalld.""" """Apply the declarative config to live firewalld."""
from lib.firewall import get_config as _get_lib_config
cfg = _get_lib_config() cfg = _get_lib_config()
cfg_zones = cfg.get("zones", {}) cfg_zones = cfg.get("zones", {})
_save_backup(_get_state()) full_state: dict[str, Any] = {
"active_zones": {},
"interfaces": [],
"available_services": [],
"zones": {},
"rich_rules": {},
"timestamp": "",
}
_save_backup(full_state)
available = run(["firewall-cmd", "--get-zones"], sudo=True).split() available = run(["firewall-cmd", "--get-zones"], sudo=True).split()
applied: list[str] = [] applied: list[str] = []
@@ -314,7 +233,15 @@ def _config_apply() -> dict[str, Any]:
applied.append(zone_name) applied.append(zone_name)
_reload() _reload()
backup_path = _save_backup(_get_state()) full_state = {
"active_zones": {},
"interfaces": [],
"available_services": [],
"zones": {},
"rich_rules": {},
"timestamp": "",
}
backup_path = _save_backup(full_state)
logger.info("Firewall config applied to %d zones", len(applied)) logger.info("Firewall config applied to %d zones", len(applied))
return { return {
"applied_zones": applied, "applied_zones": applied,
@@ -323,114 +250,67 @@ def _config_apply() -> dict[str, Any]:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Routes # Routes — GET endpoints read from state, mutations call refresh_state
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
_READ_TAGS = {"firewall", "interfaces", "zones"}
def _get_fw_state() -> dict[str, Any]:
fw = _get_state()
if fw is None:
return {}
return fw
@registry.register("GET", "/firewall/interfaces", cache_tags=_READ_TAGS) @registry.register("GET", "/firewall/interfaces")
def get_interfaces(_request: Any, _body: Any) -> list[dict[str, Any]]: def get_interfaces(_request: Any, _body: Any) -> list[dict[str, Any]]:
link_out = run(["ip", "-o", "link", "show"], sudo=True) fw = _get_fw_state()
addr_out = run(["ip", "-o", "addr", "show"], sudo=True) return fw.get("interfaces", [])
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) @registry.register("GET", "/firewall/zones")
def get_zones(_request: Any, _body: Any) -> dict[str, Any]: def get_zones(_request: Any, _body: Any) -> dict[str, Any]:
available = run(["firewall-cmd", "--get-zones"], sudo=True).split() fw = _get_fw_state()
active = _parse_active_zones(run(["firewall-cmd", "--get-active-zones"], sudo=True)) active = fw.get("active_zones", {})
return {"active": active, "available": available} zones = fw.get("zones", {})
return {"active": active, "available": list(zones.keys())}
@registry.register("GET", "/firewall/zones/info", cache_tags=_READ_TAGS) @registry.register("GET", "/firewall/zones/info")
def get_zone_info(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: def get_zone_info(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
if not body or "zone" not in body: if not body or "zone" not in body:
raise ValueError("'zone' is required") raise ValueError("'zone' is required")
zone = body["zone"] zone = body["zone"]
if zone not in run(["firewall-cmd", "--get-zones"], sudo=True).split(): fw = _get_fw_state()
zones = fw.get("zones", {})
if zone not in zones:
raise NotFoundError(f"Zone '{zone}' does not exist") raise NotFoundError(f"Zone '{zone}' does not exist")
raw = run(["firewall-cmd", f"--zone={zone}", "--list-all"], sudo=True) return zones[zone]
return _parse_zone_output(zone, raw)
@registry.register("GET", "/firewall/zones/all", cache_tags=_READ_TAGS) @registry.register("GET", "/firewall/zones/all")
def get_all_zones_info(_request: Any, _body: Any) -> list[dict[str, Any]]: 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)) fw = _get_fw_state()
active = fw.get("active_zones", {})
zones = fw.get("zones", {})
result: list[dict[str, Any]] = [] result: list[dict[str, Any]] = []
for zone_name in active: for zone_name in active:
try: if zone_name in zones:
raw = run(["firewall-cmd", f"--zone={zone_name}", "--list-all"], sudo=True) result.append(zones[zone_name])
result.append(_parse_zone_output(zone_name, raw))
except Exception:
continue
return result return result
@registry.register("GET", "/firewall/services", cache_tags=_READ_TAGS) @registry.register("GET", "/firewall/services")
def get_services(_request: Any, _body: Any) -> list[str]: def get_services(_request: Any, _body: Any) -> list[str]:
return run(["firewall-cmd", "--get-services"], sudo=True).split() fw = _get_fw_state()
return fw.get("available_services", [])
@registry.register("GET", "/firewall/config", cache_tags=_READ_TAGS) @registry.register("GET", "/firewall/config")
def get_config(_request: Any, _body: Any) -> dict[str, Any]: def get_config(_request: Any, _body: Any) -> dict[str, Any]:
return _get_config() return _get_config()
@registry.register("POST", "/firewall/config", invalidate=_READ_TAGS) @registry.register("POST", "/firewall/config")
def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
if not body or "zones" not in body: if not body or "zones" not in body:
raise ValueError("'zones' key is required") raise ValueError("'zones' key is required")
@@ -438,10 +318,11 @@ def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str,
raise ValueError("'zones' must be a dict") raise ValueError("'zones' must be a dict")
_save_config(body) _save_config(body)
logger.info("Firewall config saved (%d zones)", len(body["zones"])) logger.info("Firewall config saved (%d zones)", len(body["zones"]))
refresh_state(["firewall"])
return {"config_saved": True} return {"config_saved": True}
@registry.register("PATCH", "/firewall/config", invalidate=_READ_TAGS) @registry.register("PATCH", "/firewall/config")
def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
if not body: if not body:
raise ValueError("Request body must be a JSON object") raise ValueError("Request body must be a JSON object")
@@ -451,22 +332,25 @@ def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
merged = deep_merge(current, body) merged = deep_merge(current, body)
_save_config(merged) _save_config(merged)
logger.info("Firewall config patched: %s", sorted(body.keys())) logger.info("Firewall config patched: %s", sorted(body.keys()))
refresh_state(["firewall"])
return {"config_saved": True} return {"config_saved": True}
@registry.register("GET", "/firewall/config/pending", cache_tags=_READ_TAGS) @registry.register("GET", "/firewall/config/pending")
def config_pending(_request: Any, _body: Any) -> dict[str, Any]: def config_pending_handler(_request: Any, _body: Any) -> dict[str, Any]:
return _config_pending(_get_state()) fw = _get_fw_state()
return fw.get("pending", {})
@registry.register("POST", "/firewall/config/apply", invalidate=_READ_TAGS) @registry.register("POST", "/firewall/config/apply")
def config_apply(_request: Any, _body: Any) -> dict[str, Any]: def config_apply(_request: Any, _body: Any) -> dict[str, Any]:
result = _config_apply() result = _config_apply()
logger.info("Firewall config applied: %s", result.get("applied_zones", [])) logger.info("Firewall config applied: %s", result.get("applied_zones", []))
refresh_state(["firewall"])
return result return result
@registry.register("POST", "/firewall/zones/create", invalidate=_READ_TAGS) @registry.register("POST", "/firewall/zones/create")
def create_zone(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: def create_zone(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
if not body: if not body:
raise ValueError("Request body required") raise ValueError("Request body required")
@@ -488,10 +372,11 @@ def create_zone(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
) )
_reload() _reload()
logger.info("Zone '%s' created (target=%s)", zone_name, target) logger.info("Zone '%s' created (target=%s)", zone_name, target)
refresh_state(["firewall"])
return {"zone": zone_name} return {"zone": zone_name}
@registry.register("DELETE", "/firewall/zones/delete", invalidate=_READ_TAGS) @registry.register("DELETE", "/firewall/zones/delete")
def delete_zone(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: def delete_zone(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
if not body or "zone" not in body: if not body or "zone" not in body:
raise ValueError("'zone' is required") raise ValueError("'zone' is required")
@@ -502,10 +387,11 @@ def delete_zone(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
run(["firewall-cmd", f"--zone={zone}", "--delete", "--permanent"], sudo=True) run(["firewall-cmd", f"--zone={zone}", "--delete", "--permanent"], sudo=True)
_reload() _reload()
logger.info("Zone '%s' deleted", zone) logger.info("Zone '%s' deleted", zone)
refresh_state(["firewall"])
return {"zone": zone} return {"zone": zone}
@registry.register("POST", "/firewall/zones/interfaces", invalidate=_READ_TAGS) @registry.register("POST", "/firewall/zones/interfaces")
def set_zone_interfaces(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: def set_zone_interfaces(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
if not body: if not body:
raise ValueError("Request body required") raise ValueError("Request body required")
@@ -544,10 +430,11 @@ def set_zone_interfaces(_request: Any, body: dict[str, Any] | None) -> dict[str,
) )
_reload() _reload()
logger.info("Zone '%s' interfaces set to %s", zone, interfaces) logger.info("Zone '%s' interfaces set to %s", zone, interfaces)
refresh_state(["firewall"])
return {"zone": zone, "interfaces": interfaces} return {"zone": zone, "interfaces": interfaces}
@registry.register("POST", "/firewall/zones/services", invalidate=_READ_TAGS) @registry.register("POST", "/firewall/zones/services")
def set_zone_services(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: def set_zone_services(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
if not body: if not body:
raise ValueError("Request body required") raise ValueError("Request body required")
@@ -582,10 +469,11 @@ def set_zone_services(_request: Any, body: dict[str, Any] | None) -> dict[str, A
sudo=True, sudo=True,
) )
_reload() _reload()
refresh_state(["firewall"])
return {"zone": zone, "services": services} return {"zone": zone, "services": services}
@registry.register("POST", "/firewall/rich-rules/add", invalidate=_READ_TAGS) @registry.register("POST", "/firewall/rich-rules/add")
def add_rich_rule(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: def add_rich_rule(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
if not body: if not body:
raise ValueError("Request body required") raise ValueError("Request body required")
@@ -595,7 +483,6 @@ def add_rich_rule(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
raise ValueError("'zone' and 'rule' are required") raise ValueError("'zone' and 'rule' are required")
if zone not in run(["firewall-cmd", "--get-zones"], sudo=True).split(): if zone not in run(["firewall-cmd", "--get-zones"], sudo=True).split():
raise NotFoundError(f"Zone '{zone}' does not exist") raise NotFoundError(f"Zone '{zone}' does not exist")
from uuid import uuid4
run( run(
[ [
@@ -613,10 +500,11 @@ def add_rich_rule(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
entry = {"id": rule_id, "rule": rule} entry = {"id": rule_id, "rule": rule}
cfg["zones"][zone]["rich_rules"].append(entry) cfg["zones"][zone]["rich_rules"].append(entry)
_save_config(cfg) _save_config(cfg)
refresh_state(["firewall"])
return {"zone": zone, "id": rule_id, "rule": rule} return {"zone": zone, "id": rule_id, "rule": rule}
@registry.register("DELETE", "/firewall/rich-rules/remove", invalidate=_READ_TAGS) @registry.register("DELETE", "/firewall/rich-rules/remove")
def remove_rich_rule(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: def remove_rich_rule(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
if not body: if not body:
raise ValueError("Request body required") raise ValueError("Request body required")
@@ -650,34 +538,22 @@ def remove_rich_rule(_request: Any, body: dict[str, Any] | None) -> dict[str, An
r for r in zone_cfg.get("rich_rules", []) if r.get("id") != rule_id r for r in zone_cfg.get("rich_rules", []) if r.get("id") != rule_id
] ]
_save_config(cfg) _save_config(cfg)
refresh_state(["firewall"])
return {"zone": zone, "id": rule_id} return {"zone": zone, "id": rule_id}
@registry.register("GET", "/firewall/rich-rules", cache_tags=_READ_TAGS) @registry.register("GET", "/firewall/rich-rules")
def list_rich_rules(_request: Any, body: dict[str, Any] | None) -> list[dict[str, Any]]: def list_rich_rules(_request: Any, body: dict[str, Any] | None) -> list[dict[str, Any]]:
if not body or "zone" not in body: if not body or "zone" not in body:
raise ValueError("'zone' is required") raise ValueError("'zone' is required")
zone = body["zone"] zone = body["zone"]
raw = run(["firewall-cmd", f"--zone={zone}", "--list-rich-rules"], sudo=True) fw = _get_fw_state()
raw = raw.strip() rich_rules = fw.get("rich_rules", {})
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 = _get_config()
cfg_entries = cfg.get("zones", {}).get(zone, {}).get("rich_rules", []) cfg_entries = cfg.get("zones", {}).get(zone, {}).get("rich_rules", [])
result: list[dict[str, Any]] = [] result: list[dict[str, Any]] = []
for rule_str in rules: zone_rules = rich_rules.get(zone, [])
for rule_str in zone_rules:
matched = next((e for e in cfg_entries if e.get("rule") == rule_str), None) matched = next((e for e in cfg_entries if e.get("rule") == rule_str), None)
if matched: if matched:
result.append({"id": matched["id"], "rule": rule_str}) result.append({"id": matched["id"], "rule": rule_str})
@@ -686,7 +562,7 @@ def list_rich_rules(_request: Any, body: dict[str, Any] | None) -> list[dict[str
return result return result
@registry.register("POST", "/firewall/masquerade", invalidate=_READ_TAGS) @registry.register("POST", "/firewall/masquerade")
def set_masquerade(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: def set_masquerade(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
if not body: if not body:
raise ValueError("Request body required") raise ValueError("Request body required")
@@ -697,10 +573,11 @@ def set_masquerade(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]
action = "--add-masquerade" if enable else "--remove-masquerade" action = "--add-masquerade" if enable else "--remove-masquerade"
run(["firewall-cmd", f"--zone={zone}", action, "--permanent"], sudo=True) run(["firewall-cmd", f"--zone={zone}", action, "--permanent"], sudo=True)
_reload() _reload()
refresh_state(["firewall"])
return {"zone": zone, "masquerade": bool(enable)} return {"zone": zone, "masquerade": bool(enable)}
@registry.register("POST", "/firewall/forward-port/add", invalidate=_READ_TAGS) @registry.register("POST", "/firewall/forward-port/add")
def add_forward_port(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: def add_forward_port(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
if not body: if not body:
raise ValueError("Request body required") raise ValueError("Request body required")
@@ -711,7 +588,6 @@ def add_forward_port(_request: Any, body: dict[str, Any] | None) -> dict[str, An
toport = body.get("toport") toport = body.get("toport")
if not zone or port is None or not proto: if not zone or port is None or not proto:
raise ValueError("'zone', 'port', and 'proto' are required") raise ValueError("'zone', 'port', and 'proto' are required")
from uuid import uuid4
fwd = f"port={port}/proto={proto}" fwd = f"port={port}/proto={proto}"
if toaddr and toport: if toaddr and toport:
@@ -740,10 +616,11 @@ def add_forward_port(_request: Any, body: dict[str, Any] | None) -> dict[str, An
cfg.setdefault("zones", {}).setdefault(zone, {}).setdefault("forward_ports", []) cfg.setdefault("zones", {}).setdefault(zone, {}).setdefault("forward_ports", [])
cfg["zones"][zone]["forward_ports"].append(entry) cfg["zones"][zone]["forward_ports"].append(entry)
_save_config(cfg) _save_config(cfg)
refresh_state(["firewall"])
return {"zone": zone, "id": fp_id, "port": int(port), "proto": proto} return {"zone": zone, "id": fp_id, "port": int(port), "proto": proto}
@registry.register("DELETE", "/firewall/forward-port/remove", invalidate=_READ_TAGS) @registry.register("DELETE", "/firewall/forward-port/remove")
def remove_forward_port(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: def remove_forward_port(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
if not body: if not body:
raise ValueError("Request body required") raise ValueError("Request body required")
@@ -752,7 +629,8 @@ def remove_forward_port(_request: Any, body: dict[str, Any] | None) -> dict[str,
proto = body.get("proto", "").strip() proto = body.get("proto", "").strip()
if not zone or port is None or not proto: if not zone or port is None or not proto:
raise ValueError("'zone', 'port', and 'proto' are required") raise ValueError("'zone', 'port', and 'proto' are required")
if zone not in run(["firewall-cmd", "--get-zones"], sudo=True).split(): available = run(["firewall-cmd", "--get-zones"], sudo=True).split()
if zone not in available:
raise NotFoundError(f"Zone '{zone}' does not exist") raise NotFoundError(f"Zone '{zone}' does not exist")
fwd = f"port={port}/proto={proto}" fwd = f"port={port}/proto={proto}"
cfg = _get_config() cfg = _get_config()
@@ -785,9 +663,20 @@ def remove_forward_port(_request: Any, body: dict[str, Any] | None) -> dict[str,
fp for fp in fps if not (fp.get("port") == port and fp.get("proto") == proto) fp for fp in fps if not (fp.get("port") == port and fp.get("proto") == proto)
] ]
_save_config(cfg) _save_config(cfg)
refresh_state(["firewall"])
return {"zone": zone, "port": int(port), "proto": proto} return {"zone": zone, "port": int(port), "proto": proto}
@registry.register("GET", "/firewall/state", cache_tags=_READ_TAGS) @registry.register("GET", "/firewall/state")
def get_state(_request: Any, _body: Any) -> dict[str, Any]: def get_state(_request: Any, _body: Any) -> dict[str, Any]:
return _get_state() fw = _get_state()
if fw is None:
return {}
return {
"active_zones": fw.get("active_zones", {}),
"interfaces": fw.get("interfaces", []),
"available_services": fw.get("available_services", []),
"zones": fw.get("zones", {}),
"rich_rules": fw.get("rich_rules", {}),
"timestamp": fw.get("timestamp", ""),
}
+8 -10
View File
@@ -12,11 +12,9 @@ from lib.common import run_proc
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
PROJECT_DIR = Path(__file__).resolve().parent.parent.parent PROJECT_DIR = Path(__file__).resolve().parent.parent.parent
APP_LOG_FILE = PROJECT_DIR / "data" / "logs" / "vacuum-wall.log" _APP_LOG_FILE = PROJECT_DIR / "data" / "logs" / "vacuum-wall.log"
_MAX_LINES = 200 _MAX_LINES = 200
_LOG_TAGS = {"logs"}
def _tail_file(path: str, n: int = _MAX_LINES, sudo: bool = False) -> str: def _tail_file(path: str, n: int = _MAX_LINES, sudo: bool = False) -> str:
try: try:
@@ -36,7 +34,7 @@ def _tail_file(path: str, n: int = _MAX_LINES, sudo: bool = False) -> str:
def _sudo_journalctl(unit: str, n: int = _MAX_LINES) -> str: def _sudo_journalctl(unit: str, n: int = _MAX_LINES) -> str:
try: try:
result = run_proc( result = run_proc(
["journalctl", "-u", unit, "--no-pager", "-n", str(n)], ["journalctl", "--unit=" + unit, "-n", str(n)],
sudo=True, sudo=True,
check=False, check=False,
timeout=10, timeout=10,
@@ -47,26 +45,26 @@ def _sudo_journalctl(unit: str, n: int = _MAX_LINES) -> str:
return f"(error reading journal: {exc})\n" return f"(error reading journal: {exc})\n"
@registry.register("GET", "/logs/journal", cache_tags=_LOG_TAGS) @registry.register("GET", "/logs/journal")
def journal(_request, _body) -> str: def journal(_request, _body) -> str:
return _sudo_journalctl("vacuum-wall") return _sudo_journalctl("vacuum-wall")
@registry.register("GET", "/logs/nginx/access", cache_tags=_LOG_TAGS) @registry.register("GET", "/logs/nginx/access")
def nginx_access(_request, _body) -> str: def nginx_access(_request, _body) -> str:
return _tail_file("/var/log/nginx/access.log", sudo=True) return _tail_file("/var/log/nginx/access.log", sudo=True)
@registry.register("GET", "/logs/nginx/error", cache_tags=_LOG_TAGS) @registry.register("GET", "/logs/nginx/error")
def nginx_error(_request, _body) -> str: def nginx_error(_request, _body) -> str:
return _tail_file("/var/log/nginx/error.log", sudo=True) return _tail_file("/var/log/nginx/error.log", sudo=True)
@registry.register("GET", "/logs/dnsmasq", cache_tags=_LOG_TAGS) @registry.register("GET", "/logs/dnsmasq")
def dnsmasq_log(_request, _body) -> str: def dnsmasq_log(_request, _body) -> str:
return _sudo_journalctl("dnsmasq") return _sudo_journalctl("dnsmasq")
@registry.register("GET", "/logs/app", cache_tags=_LOG_TAGS) @registry.register("GET", "/logs/app")
def app_log(_request, _body) -> str: def app_log(_request, _body) -> str:
return _tail_file(str(APP_LOG_FILE)) return _tail_file(str(_APP_LOG_FILE))
+45 -32
View File
@@ -8,7 +8,7 @@ from typing import Any
from jinja2 import Environment, FileSystemLoader from jinja2 import Environment, FileSystemLoader
from daemon.server import NotFoundError, registry from daemon.server import NotFoundError, refresh_state, registry
from lib.common import ensure_dirs, load_json, run, run_proc, save_json from lib.common import ensure_dirs, load_json, run, run_proc, save_json
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -48,7 +48,11 @@ DEFAULT_CONFIG: dict[str, Any] = {
"ssl": {**DEFAULT_SSL}, "ssl": {**DEFAULT_SSL},
} }
_NGINX_TAGS = {"nginx"}
def _get_state() -> dict[str, Any] | None:
from lib.state import state as state_store
return state_store.get("nginx")
def _get_config() -> dict[str, Any]: def _get_config() -> dict[str, Any]:
@@ -122,9 +126,7 @@ def _write_ssl_snippet() -> None:
def _test_config() -> tuple[bool, str]: def _test_config() -> tuple[bool, str]:
result = run_proc( result = run_proc(["nginx", "-t"], sudo=True, check=False)
["nginx", "-t"], sudo=True, check=False
)
ok = result.returncode == 0 ok = result.returncode == 0
output = (result.stderr or result.stdout or "").strip() output = (result.stderr or result.stdout or "").strip()
if not output and ok: if not output and ok:
@@ -133,9 +135,7 @@ def _test_config() -> tuple[bool, str]:
def _reload_nginx() -> None: def _reload_nginx() -> None:
result = run_proc( result = run_proc(["nginx", "-s", "reload"], sudo=True, check=False)
["nginx", "-s", "reload"], sudo=True, check=False
)
if result.returncode != 0: if result.returncode != 0:
logger.error("nginx reload failed: %s", result.stderr.strip()) logger.error("nginx reload failed: %s", result.stderr.strip())
else: else:
@@ -210,20 +210,35 @@ def _write_htpasswd(user: str, password: str) -> None:
os.replace(tmp, HTPASSWD_FILE) os.replace(tmp, HTPASSWD_FILE)
@registry.register("GET", "/nginx/config", cache_tags=_NGINX_TAGS) def _get_nginx_state() -> dict[str, Any]:
ng = _get_state()
if ng is None:
return {}
return ng
# ---------------------------------------------------------------------------
# Routes
@registry.register("GET", "/nginx/config")
def get_config(_request: Any, _body: Any) -> dict[str, Any]: def get_config(_request: Any, _body: Any) -> dict[str, Any]:
ng = _get_nginx_state()
if ng:
return ng.get("config", {})
return _get_config() return _get_config()
@registry.register("POST", "/nginx/config", invalidate=_NGINX_TAGS) @registry.register("POST", "/nginx/config")
def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
if not body: if not body:
raise ValueError("Request body required") raise ValueError("Request body required")
_save_config(body) _save_config(body)
refresh_state(["nginx"])
return {"config_saved": True} return {"config_saved": True}
@registry.register("PATCH", "/nginx/config", invalidate=_NGINX_TAGS) @registry.register("PATCH", "/nginx/config")
def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
if not body: if not body:
raise ValueError("Request body required") raise ValueError("Request body required")
@@ -232,27 +247,19 @@ def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
current = _get_config() current = _get_config()
merged = deep_merge(current, body) merged = deep_merge(current, body)
_save_config(merged) _save_config(merged)
refresh_state(["nginx"])
return {"config_saved": True} return {"config_saved": True}
@registry.register("GET", "/nginx/domains", cache_tags=_NGINX_TAGS) @registry.register("GET", "/nginx/domains")
def get_domains(_request: Any, _body: Any) -> list[dict[str, Any]]: def get_domains(_request: Any, _body: Any) -> list[dict[str, Any]]:
cfg = _get_config() ng = _get_nginx_state()
result: list[dict[str, Any]] = [] if ng:
for name, dom in cfg.get("domains", {}).items(): return ng.get("domains", [])
site = SITES_DIR / f"{name}.conf" return []
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) @registry.register("POST", "/nginx/domains/add")
def add_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: def add_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
if not body: if not body:
raise ValueError("Request body required") raise ValueError("Request body required")
@@ -285,10 +292,11 @@ def add_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
entry["headers"] = extra_headers entry["headers"] = extra_headers
cfg["domains"][domain] = entry cfg["domains"][domain] = entry
_save_config(cfg) _save_config(cfg)
refresh_state(["nginx"])
return {"domain": domain} return {"domain": domain}
@registry.register("DELETE", "/nginx/domains/remove", invalidate=_NGINX_TAGS) @registry.register("DELETE", "/nginx/domains/remove")
def remove_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: def remove_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
if not body: if not body:
raise ValueError("Request body required") raise ValueError("Request body required")
@@ -303,10 +311,11 @@ def remove_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
site = SITES_DIR / f"{domain}.conf" site = SITES_DIR / f"{domain}.conf"
if site.exists(): if site.exists():
site.unlink() site.unlink()
refresh_state(["nginx"])
return {"domain": domain} return {"domain": domain}
@registry.register("POST", "/nginx/domains/update", invalidate=_NGINX_TAGS) @registry.register("POST", "/nginx/domains/update")
def update_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: def update_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
if not body: if not body:
raise ValueError("Request body required") raise ValueError("Request body required")
@@ -324,10 +333,11 @@ def update_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
else: else:
entry[key] = val entry[key] = val
_save_config(cfg) _save_config(cfg)
refresh_state(["nginx"])
return {"domain": domain} return {"domain": domain}
@registry.register("POST", "/nginx/apply", invalidate=_NGINX_TAGS) @registry.register("POST", "/nginx/apply")
def apply(_request: Any, _body: Any) -> dict[str, Any]: def apply(_request: Any, _body: Any) -> dict[str, Any]:
_write_ssl_snippet() _write_ssl_snippet()
_write_all_sites() _write_all_sites()
@@ -336,22 +346,24 @@ def apply(_request: Any, _body: Any) -> dict[str, Any]:
if not ok: if not ok:
raise RuntimeError(f"nginx config test failed: {msg}") raise RuntimeError(f"nginx config test failed: {msg}")
_reload_nginx() _reload_nginx()
refresh_state(["nginx"])
return {"applied": True} return {"applied": True}
@registry.register("POST", "/nginx/test", invalidate=_NGINX_TAGS) @registry.register("POST", "/nginx/test")
def test(_request: Any, _body: Any) -> dict[str, Any]: def test(_request: Any, _body: Any) -> dict[str, Any]:
valid, output = _test_config() valid, output = _test_config()
return {"valid": valid, "output": output} return {"valid": valid, "output": output}
@registry.register("POST", "/nginx/ssl-apply", invalidate=_NGINX_TAGS) @registry.register("POST", "/nginx/ssl-apply")
def ssl_apply(_request: Any, _body: Any) -> dict[str, Any]: def ssl_apply(_request: Any, _body: Any) -> dict[str, Any]:
_write_ssl_snippet() _write_ssl_snippet()
refresh_state(["nginx"])
return {"applied": True} return {"applied": True}
@registry.register("POST", "/nginx/management", invalidate=_NGINX_TAGS) @registry.register("POST", "/nginx/management")
def set_management_proxy(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: def set_management_proxy(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
if not body: if not body:
raise ValueError("Request body required") raise ValueError("Request body required")
@@ -373,6 +385,7 @@ def set_management_proxy(_request: Any, body: dict[str, Any] | None) -> dict[str
_save_config(cfg) _save_config(cfg)
if auth_user and auth_pass: if auth_user and auth_pass:
_write_htpasswd(auth_user, auth_pass) _write_htpasswd(auth_user, auth_pass)
refresh_state(["nginx"])
return {"domain": domain} return {"domain": domain}
+49 -82
View File
@@ -9,7 +9,7 @@ from typing import Any
from jinja2 import Environment, FileSystemLoader from jinja2 import Environment, FileSystemLoader
from daemon.server import NotFoundError, registry from daemon.server import NotFoundError, refresh_state, registry
from lib.common import deep_merge, load_json, run, run_proc, save_json from lib.common import deep_merge, load_json, run, run_proc, save_json
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -40,7 +40,11 @@ DEFAULT_CONFIG: dict[str, Any] = {
"peers": {}, "peers": {},
} }
_WG_TAGS = {"wireguard"}
def _get_state() -> dict[str, Any] | None:
from lib.state import state as state_store
return state_store.get("wireguard")
def _get_config() -> dict[str, Any]: def _get_config() -> dict[str, Any]:
@@ -60,8 +64,22 @@ def _generate_conf(cfg: dict[str, Any]) -> str:
) )
@registry.register("GET", "/wireguard/config", cache_tags=_WG_TAGS) def _get_wg_state() -> dict[str, Any]:
wg = _get_state()
if wg is None:
return {}
return wg
# ---------------------------------------------------------------------------
# Routes
@registry.register("GET", "/wireguard/config")
def get_config(_request: Any, _body: Any) -> dict[str, Any]: def get_config(_request: Any, _body: Any) -> dict[str, Any]:
wg = _get_wg_state()
if wg:
return wg.get("config", {})
cfg = _get_config() cfg = _get_config()
safe = dict(cfg) safe = dict(cfg)
if "interface" in safe: if "interface" in safe:
@@ -70,7 +88,7 @@ def get_config(_request: Any, _body: Any) -> dict[str, Any]:
return safe return safe
@registry.register("POST", "/wireguard/config", invalidate=_WG_TAGS) @registry.register("POST", "/wireguard/config")
def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
if not body: if not body:
raise ValueError("Request body required") raise ValueError("Request body required")
@@ -83,10 +101,11 @@ def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str,
if current_key: if current_key:
body.setdefault("interface", {})["private_key"] = current_key body.setdefault("interface", {})["private_key"] = current_key
_save_config(body) _save_config(body)
refresh_state(["wireguard"])
return {"config_saved": True} return {"config_saved": True}
@registry.register("PATCH", "/wireguard/config", invalidate=_WG_TAGS) @registry.register("PATCH", "/wireguard/config")
def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
if not body: if not body:
raise ValueError("Request body required") raise ValueError("Request body required")
@@ -97,10 +116,11 @@ def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
current = _get_config() current = _get_config()
merged = deep_merge(current, body) merged = deep_merge(current, body)
_save_config(merged) _save_config(merged)
refresh_state(["wireguard"])
return {"config_saved": True} return {"config_saved": True}
@registry.register("POST", "/wireguard/apply", invalidate=_WG_TAGS) @registry.register("POST", "/wireguard/apply")
def apply(_request: Any, _body: Any) -> dict[str, Any]: def apply(_request: Any, _body: Any) -> dict[str, Any]:
cfg = _get_config() cfg = _get_config()
conf_text = _generate_conf(cfg) conf_text = _generate_conf(cfg)
@@ -116,90 +136,29 @@ def apply(_request: Any, _body: Any) -> dict[str, Any]:
local_tmp.unlink(missing_ok=True) local_tmp.unlink(missing_ok=True)
run([WG_QUICK_BIN, "up", cfg["interface"]["name"]], sudo=True) run([WG_QUICK_BIN, "up", cfg["interface"]["name"]], sudo=True)
logger.info("WireGuard tunnel '%s' brought up", cfg["interface"]["name"]) logger.info("WireGuard tunnel '%s' brought up", cfg["interface"]["name"])
refresh_state(["wireguard"])
return {"applied": True} return {"applied": True}
@registry.register("POST", "/wireguard/down", invalidate=_WG_TAGS) @registry.register("POST", "/wireguard/down")
def down(_request: Any, _body: Any) -> dict[str, Any]: def down(_request: Any, _body: Any) -> dict[str, Any]:
cfg = _get_config() cfg = _get_config()
name = cfg["interface"]["name"] name = cfg["interface"]["name"]
run([WG_QUICK_BIN, "down", name], sudo=True) run([WG_QUICK_BIN, "down", name], sudo=True)
logger.info("WireGuard tunnel '%s' brought down", name) logger.info("WireGuard tunnel '%s' brought down", name)
refresh_state(["wireguard"])
return {"down": True} return {"down": True}
@registry.register("GET", "/wireguard/status", cache_tags=_WG_TAGS) @registry.register("GET", "/wireguard/status")
def status(_request: Any, _body: Any) -> dict[str, Any]: def status(_request: Any, _body: Any) -> dict[str, Any]:
cfg = _get_config() wg = _get_wg_state()
name = cfg["interface"]["name"] if wg:
result: dict[str, Any] = {"up": False, "interface": {}, "peers": []} return wg.get("status", {"up": False, "interface": {}, "peers": []})
try: return {"up": False, "interface": {}, "peers": []}
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) @registry.register("POST", "/wireguard/initialize")
def initialize(_request: Any, _body: Any) -> dict[str, Any]: def initialize(_request: Any, _body: Any) -> dict[str, Any]:
cfg = _get_config() cfg = _get_config()
if cfg["interface"].get("private_key"): if cfg["interface"].get("private_key"):
@@ -212,13 +171,14 @@ def initialize(_request: Any, _body: Any) -> dict[str, Any]:
cfg["interface"]["public_key"] = public_key cfg["interface"]["public_key"] = public_key
_save_config(cfg) _save_config(cfg)
logger.info("WireGuard initialised (pubkey=%s...)", public_key[:16]) logger.info("WireGuard initialised (pubkey=%s...)", public_key[:16])
refresh_state(["wireguard"])
safe = dict(cfg) safe = dict(cfg)
safe["interface"] = dict(safe["interface"]) safe["interface"] = dict(safe["interface"])
safe["interface"].pop("private_key", None) safe["interface"].pop("private_key", None)
return {"initialized": True, "config": safe} return {"initialized": True, "config": safe}
@registry.register("POST", "/wireguard/peers/add", invalidate=_WG_TAGS) @registry.register("POST", "/wireguard/peers/add")
def add_peer(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: def add_peer(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
if not body: if not body:
raise ValueError("Request body required") raise ValueError("Request body required")
@@ -251,12 +211,13 @@ def add_peer(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
} }
logger.info("WireGuard peer '%s' added", name) logger.info("WireGuard peer '%s' added", name)
_save_config(cfg) _save_config(cfg)
refresh_state(["wireguard"])
peer_out = dict(peers[name]) peer_out = dict(peers[name])
peer_out.pop("private_key", None) peer_out.pop("private_key", None)
return peer_out return peer_out
@registry.register("DELETE", "/wireguard/peers/remove", invalidate=_WG_TAGS) @registry.register("DELETE", "/wireguard/peers/remove")
def remove_peer(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: def remove_peer(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
if not body: if not body:
raise ValueError("Request body required") raise ValueError("Request body required")
@@ -270,11 +231,15 @@ def remove_peer(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
del peers[name] del peers[name]
_save_config(cfg) _save_config(cfg)
logger.info("WireGuard peer '%s' removed", name) logger.info("WireGuard peer '%s' removed", name)
refresh_state(["wireguard"])
return {"name": name} return {"name": name}
@registry.register("GET", "/wireguard/peers", cache_tags=_WG_TAGS) @registry.register("GET", "/wireguard/peers")
def list_peers(_request: Any, _body: Any) -> list[dict[str, Any]]: def list_peers(_request: Any, _body: Any) -> list[dict[str, Any]]:
wg = _get_wg_state()
if wg:
return wg.get("peers", [])
cfg = _get_config() cfg = _get_config()
result: list[dict[str, Any]] = [] result: list[dict[str, Any]] = []
for name, info in cfg.get("peers", {}).items(): for name, info in cfg.get("peers", {}).items():
@@ -285,10 +250,12 @@ def list_peers(_request: Any, _body: Any) -> list[dict[str, Any]]:
return result return result
@registry.register("GET", "/wireguard/peer-status", cache_tags=_WG_TAGS) @registry.register("GET", "/wireguard/peer-status")
def get_peer_status(_request: Any, _body: Any) -> list[dict[str, Any]]: def get_peer_status(_request: Any, _body: Any) -> list[dict[str, Any]]:
st = status(None, None) wg = _get_wg_state()
return st.get("peers", []) if wg:
return wg.get("status", {}).get("peers", [])
return []
@registry.register("POST", "/wireguard/generate-client") @registry.register("POST", "/wireguard/generate-client")
+36 -84
View File
@@ -1,7 +1,7 @@
"""aiohttp server for vacuum-walld. """aiohttp server for vacuum-walld.
Listens on a Unix socket, serves the daemon API to the web UI. Listens on a Unix socket, serves the daemon API to the web UI.
Handles routing, caching, batching, and request/response lifecycle. Handles routing, batching, and request/response lifecycle.
""" """
import asyncio import asyncio
@@ -15,60 +15,20 @@ from typing import Any
from aiohttp import web from aiohttp import web
from lib.state import state as state_store
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
PROJECT_DIR = Path(__file__).resolve().parent.parent PROJECT_DIR = Path(__file__).resolve().parent.parent
SOCKET_PATH = PROJECT_DIR / "data" / "daemon.sock" 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: class Handler:
"""Wrapper for a daemon handler function.""" """Wrapper for a daemon handler function."""
def __init__( def __init__(self, method: str, path: str) -> None:
self,
method: str,
path: str,
cache_tags: set[str] | None = None,
invalidate: set[str] | None = None,
) -> None:
self.method = method.upper() self.method = method.upper()
self.path = path self.path = path
self.cache_tags = cache_tags or set()
self.invalidate = invalidate or set()
class Registry: class Registry:
@@ -77,16 +37,10 @@ class Registry:
def __init__(self) -> None: def __init__(self) -> None:
self._routes: dict[tuple[str, str], Callable] = {} self._routes: dict[tuple[str, str], Callable] = {}
def register( def register(self, method: str, path: str):
self,
method: str,
path: str,
cache_tags: set[str] | None = None,
invalidate: set[str] | None = None,
):
def decorator(fn: Callable) -> Callable: def decorator(fn: Callable) -> Callable:
self._routes[(method.upper(), path)] = fn self._routes[(method.upper(), path)] = fn
fn._handler = Handler(method, path, cache_tags, invalidate) # type: ignore[attr-defined] fn._handler = Handler(method, path) # type: ignore[attr-defined]
return fn return fn
return decorator return decorator
@@ -98,6 +52,11 @@ class Registry:
registry = Registry() registry = Registry()
def refresh_state(subsystems: list[str] | None = None) -> None:
"""Refresh the pre-computed state for the given subsystems (or all)."""
state_store.populate(subsystems)
class NotFoundError(Exception): class NotFoundError(Exception):
"""Raised when a requested resource is not found.""" """Raised when a requested resource is not found."""
@@ -118,8 +77,6 @@ async def _handle_request(request: web.Request) -> web.Response:
if handler_fn is None: if handler_fn is None:
return error(f"Method {request.method} not allowed for {request.path}", 404) 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 # 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. # as URL query string, so they need to be treated as body for handlers.
body: dict[str, Any] | None = None body: dict[str, Any] | None = None
@@ -138,22 +95,6 @@ async def _handle_request(request: web.Request) -> web.Response:
else: else:
body = query_body 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: try:
if body is not None: if body is not None:
result = handler_fn(request, body) result = handler_fn(request, body)
@@ -176,14 +117,6 @@ async def _handle_request(request: web.Request) -> web.Response:
) )
return error(f"Internal error: {exc}", 500) 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 # Convert result to response if not already
if isinstance(result, web.Response): if isinstance(result, web.Response):
return result return result
@@ -222,7 +155,6 @@ async def _handle_batch(request: web.Request) -> web.Response:
continue continue
op_body = op.get("body") op_body = op.get("body")
h = getattr(handler_fn, "_handler", None)
try: try:
result = handler_fn(None, op_body) result = handler_fn(None, op_body)
@@ -239,18 +171,16 @@ async def _handle_batch(request: web.Request) -> web.Response:
else: else:
results[op_id] = {"ok": True, "data": result} results[op_id] = {"ok": True, "data": result}
# Invalidate on write
if h and h.invalidate:
cache.invalidate(*h.invalidate)
return ok(results) return ok(results)
def create_app() -> web.Application: def create_app() -> web.Application:
app = web.Application() app = web.Application()
app.router.add_route("GET", "/health", _health) app.router.add_route("GET", "/health", _health)
app.router.add_route("GET", "/status/all", get_status_all)
app.router.add_route("POST", "/status/refresh", refresh_status)
app.router.add_route("POST", "/batch", _handle_batch) app.router.add_route("POST", "/batch", _handle_batch)
app.router.add_route("{tail:.*}", _catch_all) app.router.add_route("*", "/{tail:.*}", _catch_all)
return app return app
@@ -258,6 +188,24 @@ async def _health(_request: web.Request) -> web.Response:
return ok({"pid": os.getpid(), "socket": str(SOCKET_PATH)}) return ok({"pid": os.getpid(), "socket": str(SOCKET_PATH)})
async def get_status_all(_request: web.Request) -> web.Response:
"""Return the entire state snapshot in one call."""
return ok({name: state_store.get(name) for name in state_store.SUBSYSTEMS})
async def refresh_status(_request: web.Request) -> web.Response:
"""Re-collect all state from system."""
try:
body = await _request.json()
except (json.JSONDecodeError, ValueError):
body = None
subsystems = None
if body and "subsystems" in body:
subsystems = body["subsystems"]
state_store.populate(subsystems)
return ok({name: state_store.get(name) for name in state_store.SUBSYSTEMS})
async def _catch_all(request: web.Request) -> web.Response: async def _catch_all(request: web.Request) -> web.Response:
"""Catch-all for registered routes.""" """Catch-all for registered routes."""
return await _handle_request(request) return await _handle_request(request)
@@ -306,6 +254,10 @@ def main() -> None:
loop.run_until_complete(site.start()) loop.run_until_complete(site.start())
os.chmod(socket_path, 0o660) os.chmod(socket_path, 0o660)
# Populate state from system (blocking — OK at startup)
logger.info("Populating system state...")
state_store.populate()
logger.info("vacuum-walld listening on %s", socket_path) logger.info("vacuum-walld listening on %s", socket_path)
try: try:
+6 -5
View File
@@ -47,12 +47,14 @@ vacuum-walld ──→ daemon/handlers/logs.py ──→ sudo journalctl ──
Vacuum Wall uses two distinct system users bridged by a 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-walld`** (daemon user): Runs the privileged background daemon. Holds the NOPASSWD sudo whitelist for all system-level commands. 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`. - **WebUI user** (default: repo owner in `--dev` mode): 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. - **Shared group**: Both users share the WebUI user's primary group. The daemon socket is owned by `vacuum-walld:<group>` with mode `0660`, allowing the web UI user to connect via group permission. The project directory is owned by the WebUI user with group-read+execute, giving the daemon 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`. 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`.
**Dev mode variant**: When `install.sh --dev` is used, the repo owner (e.g., `wall`) becomes the WebUI user. The project directory remains owned by the repo owner, preserving git operations and code editing. The daemon user (`vacuum-walld`) has the repo owner's primary group as its own primary group, granting read access to project files. All subdirectories carry the setgid bit (`g+s`) so new files inherit the group regardless of the creator's primary group.
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/`. 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/`.
## Install-Time Templating ## Install-Time Templating
@@ -61,7 +63,6 @@ System configuration files in `system/` are Jinja2 templates rendered by `instal
- **`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). - **`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-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. - 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. Runtime templates (`system/nginx/*.conf`, `system/dnsmasq.conf`, `system/wireguard*.conf`) are rendered at runtime by `lib/` modules via Jinja2 with Python data.
@@ -127,7 +128,7 @@ The following file system locations are used for integration with system service
| `/etc/nginx/snippets/vacuum-wall-ssl.conf` | Shared SSL configuration snippet (protocols, ciphers, DH parameters, OCSP). Included by all HTTPS server blocks. | Vacuum Wall (lib/nginx.py) | | `/etc/nginx/snippets/vacuum-wall-ssl.conf` | Shared SSL configuration snippet (protocols, ciphers, DH parameters, OCSP). Included by all HTTPS server blocks. | Vacuum Wall (lib/nginx.py) |
| `/etc/dnsmasq.d/vacuum-wall.conf` | Generated dnsmasq configuration file. Written from `config/dnsmasq/config.json`. | Vacuum Wall (lib/dnsmasq.py) | | `/etc/dnsmasq.d/vacuum-wall.conf` | Generated dnsmasq configuration file. Written from `config/dnsmasq/config.json`. | Vacuum Wall (lib/dnsmasq.py) |
| `/etc/wireguard/wg0.conf` | Generated WireGuard interface configuration. Written from `config/wireguard/config.json`. | Vacuum Wall (lib/wireguard.py) | | `/etc/wireguard/wg0.conf` | Generated WireGuard interface configuration. Written from `config/wireguard/config.json`. | Vacuum Wall (lib/wireguard.py) |
| `/etc/sudoers.d/vacuum-wall` | Sudo whitelist for the configured system user. Defines all permitted privilege escalations. | Install script (rendered from Jinja2 template) | | `/etc/sudoers.d/vacuum-walld` | Sudo whitelist for the daemon user. Defines all permitted privilege escalations. | Install script (rendered from Jinja2 template) |
The `/etc/nginx/conf.d/vacuum-wall.conf` include file ensures that all domain-specific configurations in `sites-enabled/` are loaded by nginx without modifying the main `nginx.conf`. The SSL snippet keeps TLS settings consistent across all managed domains and allows global updates from a single location. The `/etc/nginx/conf.d/vacuum-wall.conf` include file ensures that all domain-specific configurations in `sites-enabled/` are loaded by nginx without modifying the main `nginx.conf`. The SSL snippet keeps TLS settings consistent across all managed domains and allows global updates from a single location.
+32 -5
View File
@@ -26,7 +26,7 @@ Download the Vacuum Wall repository onto the target machine, then run the instal
MGMT_DOMAIN=wall.example.com \ MGMT_DOMAIN=wall.example.com \
MGMT_PASS="strongpassword" \ MGMT_PASS="strongpassword" \
ACME_EMAIL="admin@example.com" \ ACME_EMAIL="admin@example.com" \
bash install.sh ./install.sh --user vacuum-wall
# Dev mode: CLI flags, auto-detects repo owner # Dev mode: CLI flags, auto-detects repo owner
./install.sh --dev --mgmt-pass strongpassword --acme-email "admin@example.com" ./install.sh --dev --mgmt-pass strongpassword --acme-email "admin@example.com"
@@ -46,7 +46,7 @@ All settings that can be passed as an environment variable also have a CLI flag
| `--mgmt-pass` | `MGMT_PASS` | Yes | Password for HTTP basic auth protecting the WebUI. | | `--mgmt-pass` | `MGMT_PASS` | Yes | Password for HTTP basic auth protecting the WebUI. |
| `--mgmt-user` | `MGMT_USER` | No | Username for WebUI access. Defaults to `admin`. | | `--mgmt-user` | `MGMT_USER` | No | Username for WebUI access. Defaults to `admin`. |
| `--acme-email` | `ACME_EMAIL` | Yes | Email for ACME provider (ZeroSSL by default). | | `--acme-email` | `ACME_EMAIL` | Yes | Email for ACME provider (ZeroSSL by default). |
| `--user, -u` | `USER_NAME` | No | System user for the WebUI service. Defaults to `vacuum-wall`. | | `--user, -u` | `USER_NAME` | Yes* | WebUI service user (created if it does not exist). Required for non-dev mode. In `--dev` mode, auto-detected from repo owner. |
| `--path, -p` | `INSTALL_DIR` | No | Install directory. Defaults to repo root. Set to deploy from a custom path (e.g., `/opt/vacuum-wall`). | | `--path, -p` | `INSTALL_DIR` | No | Install directory. Defaults to repo root. Set to deploy from a custom path (e.g., `/opt/vacuum-wall`). |
| `--dev` | -- | No | Development mode: auto-detects repo owner as service user, skips safety warning. | | `--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. | | `--wan-iface` | `WAN_IFACE` | No | WAN interface name. Auto-detected from default gateway. |
@@ -57,6 +57,33 @@ Run `./install.sh --help` for full usage.
--- ---
## Dev Mode
The `--dev` flag is designed for developers working in a git clone. It auto-detects the repo owner and uses that user as the WebUI service user.
### Ownership Model
In dev mode, the ownership model preserves the developer's ability to work with the repository:
- **Project directory**: Owned by the repo owner (e.g., `wall`), group is the repo owner's primary group (e.g., `wall`). The developer retains full control — `git add`, `git commit`, editing code and config files all work normally.
- **Daemon access**: The daemon user (`vacuum-walld`) has the repo owner's primary group as its own primary group, granting read access to all project files. The project directory has the setgid bit (`g+s`) on all subdirectories, ensuring new files inherit the group.
- **`.venv/` and `data/`**: Owned by the repo owner, group is the repo owner's primary group. The developer can run `pip install`, inspect logs, and manage runtime artifacts. The daemon reads `.venv/` (Python interpreter) and writes to `data/` (runtime files) via group permissions.
- **Daemon socket** (`data/daemon.sock`): Owned by `vacuum-walld:<group>` (mode `0660`). The repo owner accesses it via primary group membership.
### Running the Installer in Dev Mode
```bash
./install.sh --dev --mgmt-pass strongpassword --acme-email "dev@example.com"
```
The script detects the repo owner (e.g., `wall`), creates the `vacuum-walld` daemon user with the repo owner's primary group, and sets up the ownership model described above.
### Idempotent Re-Runs
Running `--dev` again is safe. The ownership is idempotent (`chown -R` to the same owner), supplementary group membership is deduplicated by the OS, and setgid bits are applied recursively each time.
---
## Container / Custom Deployment ## Container / Custom Deployment
You can deploy Vacuum Wall in a container or at any custom path. Use `--path` (or `INSTALL_DIR`) for the mount or bind path, and `--user` (or `USER_NAME`) for whatever system user exists: You can deploy Vacuum Wall in a container or at any custom path. Use `--path` (or `INSTALL_DIR`) for the mount or bind path, and `--user` (or `USER_NAME`) for whatever system user exists:
@@ -77,14 +104,14 @@ The systemd service unit files and sudoers whitelist are rendered from Jinja2 te
The installer performs the following steps automatically: The installer performs the following steps automatically:
- **Package installation**: Installs firewalld, nginx, dnsmasq, avahi-daemon, wireguard-tools, python3, python3-pip, jq, curl, iptables, nftables, and apache2-utils. - **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. - **WebUI user creation**: Creates the WebUI user (from `--user`) as a system user if it does not exist.
- **Shared group**: Uses the WebUI user's primary group as the shared group between both service users.
- **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. - **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). - **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. - **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). - **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. - **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-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. - **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`.
- **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. - **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. - **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. - **Dnsmasq initialization**: Starts and enables dnsmasq for future DHCP/DNS serving on internal interfaces.
+5 -5
View File
@@ -2,22 +2,22 @@
## Privilege Model ## Privilege Model
Vacuum Wall uses two distinct system users bridged by a shared group (`vacuum-wall`): Vacuum Wall uses two distinct system users bridged by a shared group (the WebUI user's primary group):
- **`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-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. - **WebUI user** (default: repo owner in `--dev` mode): 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. ACME certificate operations via `acme.sh` run as the WebUI user — 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. 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 ## 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. The WebUI communicates with the daemon via synchronous HTTP requests over a Unix socket (`data/daemon.sock`), owned by `vacuum-walld:<group>` 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 ## Sudo Whitelist
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: 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 has no sudo access. Each daemon entry is scoped to a single binary with allowed arguments:
| Category | Whitelisted Command | Purpose | | Category | Whitelisted Command | Purpose |
|---|---|---| |---|---|---|
+90 -78
View File
@@ -40,7 +40,7 @@ while [[ $# -gt 0 ]]; do
"Usage: install.sh [OPTIONS]" \ "Usage: install.sh [OPTIONS]" \
"" \ "" \
"Options:" \ "Options:" \
" --user, -u USER System user for service (default: vacuum-wall)" \ " --user, -u USER WebUI user (created if it does not exist, required for non-dev mode)" \
" --path, -p DIR Install directory (default: repo root)" \ " --path, -p DIR Install directory (default: repo root)" \
" --dev Dev mode: auto-detect repo owner, skip safety warning" \ " --dev Dev mode: auto-detect repo owner, skip safety warning" \
" --mgmt-pass PASS WebUI basic auth password (required)" \ " --mgmt-pass PASS WebUI basic auth password (required)" \
@@ -60,7 +60,7 @@ while [[ $# -gt 0 ]]; do
" ./install.sh --dev --mgmt-pass pass --acme-email me@example.com" \ " ./install.sh --dev --mgmt-pass pass --acme-email me@example.com" \
"" \ "" \
"Example (prod):" \ "Example (prod):" \
" MGMT_PASS=pass ACME_EMAIL=me@example.com ./install.sh" " MGMT_PASS=pass ACME_EMAIL=me@example.com ./install.sh --user vacuum-wall"
exit 0 exit 0
;; ;;
*) *)
@@ -137,25 +137,31 @@ if [[ "$_cli_is_dev" == true ]]; then
fi fi
fi fi
# Optional settings with defaults # Resolve USER_NAME: dev mode auto-detects, non-dev requires --user
USER_NAME="${_cli_user:-${USER_NAME:-vacuum-wall}}" USER_NAME="${_cli_user:-${USER_NAME:-}}"
if [[ -z "$USER_NAME" ]]; then
err "WebUI user is required. Use --dev to auto-detect repo owner, or set --user / USER_NAME."
fi
# Daemon user name (derived from web UI user name) # Daemon user name (derived from web UI user name)
USER_DAEMON_NAME="${USER_NAME}d" USER_DAEMON_NAME="${USER_NAME}d"
# --- Safety check: running service as a non-system regular user --- # --- Safety check: running service as a regular user ---
if [[ "$_cli_is_dev" != true ]] && [[ "$USER_NAME" != "vacuum-wall" ]] && id "$USER_NAME" &>/dev/null; then if [[ "$_cli_is_dev" != true ]] && id "$USER_NAME" &>/dev/null; then
_uid=$(id -u "$USER_NAME") _uid=$(id -u "$USER_NAME")
_gid=$(id -g "$USER_NAME")
_shell=$(getent passwd "$USER_NAME" | cut -d: -f7) _shell=$(getent passwd "$USER_NAME" | cut -d: -f7)
if [[ "$_uid" -ge 1000 ]] && [[ "$_shell" != "/usr/sbin/nologin" && "$_shell" != "/bin/false" ]]; then 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 "USER_NAME='$USER_NAME' is a regular user (UID=$_uid, shell=$_shell)!"
warn "This 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 "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
fi fi
# Shared group for both users to access project files and socket # Create WebUI user if it does not exist
USER_GROUP="vacuum-wall" 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"
fi
# Shared group: use the WebUI user's primary group
USER_GROUP=$(id -gn "$USER_NAME")
echo "============================================" echo "============================================"
echo " Vacuum Wall Appliance Installer" echo " Vacuum Wall Appliance Installer"
@@ -183,13 +189,8 @@ apt-get install -y -qq \
apache2-utils \ apache2-utils \
avahi-daemon avahi-daemon
# --- 2. Create shared group --- # --- 2. Setup users ---
if ! getent group "$USER_GROUP" &>/dev/null; then log "WebUI user: $USER_NAME (group: $USER_GROUP)"
log "Creating shared group $USER_GROUP..."
groupadd --system "$USER_GROUP"
else
log "Group $USER_GROUP already exists."
fi
# --- 2a. Create daemon user (has sudo for privileged operations) --- # --- 2a. Create daemon user (has sudo for privileged operations) ---
if ! id "$USER_DAEMON_NAME" &>/dev/null; then if ! id "$USER_DAEMON_NAME" &>/dev/null; then
@@ -201,17 +202,7 @@ else
usermod -g "$USER_GROUP" "$USER_DAEMON_NAME" 2>/dev/null || true usermod -g "$USER_GROUP" "$USER_DAEMON_NAME" 2>/dev/null || true
fi fi
# --- 2b. Create web UI user (no sudo, communicates with daemon) --- # --- 2b. Setup Python venv ---
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 if [[ -x "${PROJECT_DIR}/.venv/bin/python3" ]] && [[ "$_cli_force_venv" != true ]]; then
log "Python venv already exists, skipping (use --force-venv to recreate)." log "Python venv already exists, skipping (use --force-venv to recreate)."
else else
@@ -220,9 +211,10 @@ else
python3 -m venv "${PROJECT_DIR}/.venv" python3 -m venv "${PROJECT_DIR}/.venv"
"${PROJECT_DIR}/.venv/bin/pip" install -qe "${PROJECT_DIR}" "${PROJECT_DIR}/.venv/bin/pip" install -qe "${PROJECT_DIR}"
chown -R "$USER_DAEMON_NAME:$USER_GROUP" "${PROJECT_DIR}/.venv" chown -R "$USER_DAEMON_NAME:$USER_GROUP" "${PROJECT_DIR}/.venv"
chmod -R g+x "${PROJECT_DIR}/.venv"
fi fi
# --- 2d. Install acme.sh (vendored) --- # --- 2c. Install acme.sh (vendored) ---
if [[ ! -x "$ACME_HOME/acme.sh" ]]; then if [[ ! -x "$ACME_HOME/acme.sh" ]]; then
log "Installing acme.sh (vendored)..." log "Installing acme.sh (vendored)..."
mkdir -p "$ACME_HOME" mkdir -p "$ACME_HOME"
@@ -242,11 +234,19 @@ mkdir -p "${PROJECT_DIR}/config"/{dnsmasq,nginx,wireguard,firewall}
mkdir -p "${PROJECT_DIR}/data"/{nginx/sites-enabled,dnsmasq,firewall,wireguard,acme} mkdir -p "${PROJECT_DIR}/data"/{nginx/sites-enabled,dnsmasq,firewall,wireguard,acme}
mkdir -p /etc/wireguard mkdir -p /etc/wireguard
mkdir -p /etc/dnsmasq mkdir -p /etc/dnsmasq
# Set ownership: daemon owns project dir, web UI user is group member # Set ownership: daemon owns project dir in prod, repo owner keeps ownership in dev
chown -R "$USER_DAEMON_NAME:$USER_GROUP" "$PROJECT_DIR" if [[ "$_cli_is_dev" == true ]]; then
chmod -R g+rX "$PROJECT_DIR" _dev_owner="$USER_NAME"
else
_dev_owner="$USER_DAEMON_NAME"
fi
chown -R "$_dev_owner:$USER_GROUP" "$PROJECT_DIR"
chmod -R g+rwX "$PROJECT_DIR"
find "$PROJECT_DIR" -type d -exec chmod g+s '{}' +
# --- 4. Template rendering function --- # --- 4. Template rendering function ---
# Renders Jinja2 templates by injecting env vars as template context.
# Used for systemd units and sudoers files.
render_template() { render_template() {
export USER_NAME USER_DAEMON_NAME USER_GROUP PROJECT_DIR ACME_HOME export USER_NAME USER_DAEMON_NAME USER_GROUP PROJECT_DIR ACME_HOME
"${PROJECT_DIR}/.venv/bin/python3" -c " "${PROJECT_DIR}/.venv/bin/python3" -c "
@@ -272,12 +272,6 @@ render_template "${PROJECT_DIR}/system/sudoers.d/vacuum-walld" \
visudo -cf /etc/sudoers.d/vacuum-walld || 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 --- # --- 6. Install systemd units ---
log "Installing systemd units..." log "Installing systemd units..."
export USER_GROUP export USER_GROUP
@@ -305,18 +299,22 @@ log "Enabling firewalld..."
systemctl enable firewalld >/dev/null 2>&1 || warn "Could not enable firewalld (already running?)" systemctl enable firewalld >/dev/null 2>&1 || warn "Could not enable firewalld (already running?)"
systemctl start firewalld >/dev/null 2>&1 || warn "Could not start firewalld (may need D-Bus)" systemctl start firewalld >/dev/null 2>&1 || warn "Could not start firewalld (may need D-Bus)"
firewall-cmd --permanent --add-service=http >/dev/null 2>&1 || true firewall-cmd --permanent --add-service=http >/dev/null 2>&1 && \
log "Added service http to public zone" log "Added service http to public zone" || \
firewall-cmd --permanent --add-service=https >/dev/null 2>&1 || true warn "Could not add service http to public zone (already exists?)"
log "Added service https to public zone" firewall-cmd --permanent --add-service=https >/dev/null 2>&1 && \
firewall-cmd --permanent --add-service=ssh >/dev/null 2>&1 || true log "Added service https to public zone" || \
log "Added service ssh to public zone" warn "Could not add service https to public zone (already exists?)"
firewall-cmd --reload >/dev/null 2>&1 || true firewall-cmd --permanent --add-service=ssh >/dev/null 2>&1 && \
log "Firewalld rules reloaded" log "Added service ssh to public zone" || \
warn "Could not add service ssh to public zone (already exists?)"
firewall-cmd --reload >/dev/null 2>&1 && \
log "Firewalld rules reloaded" || \
warn "Could not reload firewalld rules"
# --- 9. Configure dnsmasq --- # --- 9. Configure dnsmasq ---
log "Configuring dnsmasq..." log "Configuring dnsmasq..."
systemctl enable dnsmasq >/dev/null 2>&1 || true systemctl enable dnsmasq >/dev/null 2>&1 && log "Enabled dnsmasq" || warn "Could not enable dnsmasq"
systemctl start dnsmasq >/dev/null 2>&1 || warn "Could not start dnsmasq (no interfaces configured yet)" systemctl start dnsmasq >/dev/null 2>&1 || warn "Could not start dnsmasq (no interfaces configured yet)"
log "dnsmasq configured (will fully start after DHCP ranges are set)" log "dnsmasq configured (will fully start after DHCP ranges are set)"
@@ -360,6 +358,10 @@ chown "$USER_NAME:$USER_GROUP" "${PROJECT_DIR}/data/nginx/.htpasswd"
# Remove default nginx site so vacuum-wall management config takes precedence # Remove default nginx site so vacuum-wall management config takes precedence
rm -f /etc/nginx/sites-enabled/default rm -f /etc/nginx/sites-enabled/default
# Write initial management proxy config directly to /etc/nginx/conf.d/.
# This bootstrap config is needed before the WebUI is running. Once the
# WebUI is up, it manages proxy configs from config/nginx/config.json
# and renders them to data/nginx/sites-enabled/.
# Write WebSocket upgrade map (nginx conf.d/ is already inside http {} context) # Write WebSocket upgrade map (nginx conf.d/ is already inside http {} context)
cat > /etc/nginx/conf.d/vacuum-wall-map.conf <<'MAPEOF' cat > /etc/nginx/conf.d/vacuum-wall-map.conf <<'MAPEOF'
# Vacuum Wall - WebSocket upgrade map # Vacuum Wall - WebSocket upgrade map
@@ -455,7 +457,8 @@ log "Detecting network interfaces..."
# Auto-detect WAN (interface with default gateway) # Auto-detect WAN (interface with default gateway)
WAN_IFACE="${WAN_IFACE:-}" WAN_IFACE="${WAN_IFACE:-}"
if [[ -z "$WAN_IFACE" ]]; then if [[ -z "$WAN_IFACE" ]]; then
DETECTED_WAN=$(ip route show default 2>/dev/null | awk '/default/ {print $5; exit}') # Strip @if<port> suffix — physical port index can change on reboot
DETECTED_WAN=$(ip route show default 2>/dev/null | awk '/default/ {print $5; exit}' | cut -d'@' -f1)
if [[ -n "$DETECTED_WAN" ]]; then if [[ -n "$DETECTED_WAN" ]]; then
WAN_IFACE="$DETECTED_WAN" WAN_IFACE="$DETECTED_WAN"
log "Auto-detected WAN interface: $WAN_IFACE" log "Auto-detected WAN interface: $WAN_IFACE"
@@ -526,26 +529,35 @@ with open(os.path.join(p, 'config/firewall/config.json'), 'w') as f:
fi fi
# Apply zones via firewall-cmd (Python venv not yet fully available for apply_config) # Apply zones via firewall-cmd (Python venv not yet fully available for apply_config)
firewall-cmd --permanent --new-zone=internal >/dev/null 2>&1 || true firewall-cmd --permanent --new-zone=internal >/dev/null 2>&1 && \
log "Created firewalld zone: internal" log "Created firewalld zone: internal" || \
firewall-cmd --permanent --zone=internal --set-target=ACCEPT >/dev/null 2>&1 || true warn "firewalld zone 'internal' may already exist"
firewall-cmd --permanent --zone=internal --add-service=dhcp >/dev/null 2>&1 || true firewall-cmd --permanent --zone=internal --set-target=ACCEPT >/dev/null 2>&1 || \
log "Added service dhcp to internal zone" warn "Could not set target ACCEPT on internal zone"
firewall-cmd --permanent --zone=internal --add-service=dns >/dev/null 2>&1 || true firewall-cmd --permanent --zone=internal --add-service=dhcp >/dev/null 2>&1 && \
log "Added service dns to internal zone" log "Added service dhcp to internal zone" || \
firewall-cmd --permanent --zone=internal --add-service=ntp >/dev/null 2>&1 || true warn "Could not add service dhcp to internal zone"
log "Added service ntp to internal zone" firewall-cmd --permanent --zone=internal --add-service=dns >/dev/null 2>&1 && \
log "Added service dns to internal zone" || \
warn "Could not add service dns to internal zone"
firewall-cmd --permanent --zone=internal --add-service=ntp >/dev/null 2>&1 && \
log "Added service ntp to internal zone" || \
warn "Could not add service ntp to internal zone"
firewall-cmd --permanent --new-zone=vpn >/dev/null 2>&1 || true firewall-cmd --permanent --new-zone=vpn >/dev/null 2>&1 && \
log "Created firewalld zone: vpn" log "Created firewalld zone: vpn" || \
firewall-cmd --permanent --zone=vpn --set-target=ACCEPT >/dev/null 2>&1 || true warn "firewalld zone 'vpn' may already exist"
firewall-cmd --permanent --zone=vpn --set-target=ACCEPT >/dev/null 2>&1 || \
warn "Could not set target ACCEPT on vpn zone"
# Apply masquerade on public/WAN # Apply masquerade on public/WAN
if [[ -n "$WAN_IFACE" ]]; then if [[ -n "$WAN_IFACE" ]]; then
firewall-cmd --permanent --zone=public --add-masquerade >/dev/null 2>&1 || true firewall-cmd --permanent --zone=public --add-masquerade >/dev/null 2>&1 && \
log "Enabled masquerade on public zone ($WAN_IFACE)" log "Enabled masquerade on public zone ($WAN_IFACE)" || \
firewall-cmd --permanent --zone=public --add-interface="$WAN_IFACE" >/dev/null 2>&1 || true warn "Could not enable masquerade on public zone"
log "Assigned $WAN_IFACE to public zone" firewall-cmd --permanent --zone=public --add-interface="$WAN_IFACE" >/dev/null 2>&1 && \
log "Assigned $WAN_IFACE to public zone" || \
warn "Could not assign $WAN_IFACE to public zone"
fi fi
# Assign LAN interfaces to internal zone # Assign LAN interfaces to internal zone
@@ -554,27 +566,24 @@ if [[ -n "$LAN_IFACES" ]]; then
for iface in "${LAN_ARRAY[@]}"; do for iface in "${LAN_ARRAY[@]}"; do
iface=$(echo "$iface" | xargs) iface=$(echo "$iface" | xargs)
[[ -z "$iface" ]] && continue [[ -z "$iface" ]] && continue
firewall-cmd --permanent --zone=internal --add-interface="$iface" >/dev/null 2>&1 || true firewall-cmd --permanent --zone=internal --add-interface="$iface" >/dev/null 2>&1 && \
log "Assigned $iface to internal zone" log "Assigned $iface to internal zone" || \
warn "Could not assign $iface to internal zone"
done done
fi fi
firewall-cmd --reload >/dev/null 2>&1 || true firewall-cmd --reload >/dev/null 2>&1 && \
log "Firewalld rules reloaded" log "Firewalld rules reloaded" || \
warn "Could not reload firewalld rules"
# --- 13. Enable and start services --- # --- 13. Enable and start services ---
log "Enabling services..." log "Enabling services..."
systemctl enable nginx >/dev/null 2>&1 || true systemctl enable nginx >/dev/null 2>&1 && log "Enabled nginx" || warn "Could not enable nginx"
log "Enabled nginx" systemctl enable vacuum-walld >/dev/null 2>&1 && log "Enabled vacuum-walld" || warn "Could not enable vacuum-walld"
systemctl enable vacuum-walld >/dev/null 2>&1 || true systemctl enable vacuum-wall >/dev/null 2>&1 && log "Enabled vacuum-wall" || warn "Could not enable vacuum-wall"
log "Enabled vacuum-walld" systemctl enable vacuum-wall-acme.timer >/dev/null 2>&1 && log "Enabled vacuum-wall-acme.timer" || warn "Could not enable vacuum-wall-acme.timer"
systemctl enable vacuum-wall >/dev/null 2>&1 || true
log "Enabled vacuum-wall"
systemctl enable vacuum-wall-acme.timer >/dev/null 2>&1 || true
log "Enabled vacuum-wall-acme.timer"
systemctl enable avahi-daemon >/dev/null 2>&1 || true systemctl enable avahi-daemon >/dev/null 2>&1 && log "Enabled avahi-daemon" || warn "Could not enable avahi-daemon"
log "Enabled avahi-daemon"
systemctl start avahi-daemon >/dev/null 2>&1 && log "Started avahi-daemon" || warn "Could not start 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 # Start daemon first, then web UI
@@ -611,6 +620,9 @@ nginx -t 2>/dev/null && nginx -s reload 2>/dev/null && log "Reloaded nginx" || \
if [[ -f "$ACME_HOME/account.conf" ]] && grep -q '^ACME_LEEMAIL=' "$ACME_HOME/account.conf" 2>/dev/null; then if [[ -f "$ACME_HOME/account.conf" ]] && grep -q '^ACME_LEEMAIL=' "$ACME_HOME/account.conf" 2>/dev/null; then
log "acme.sh account already registered, skipping." log "acme.sh account already registered, skipping."
else else
# acme.sh must never run as root — always as the service user via sudo -u.
# This prevents acme.sh from running any command as root and limits its
# ability to modify system files.
log "Registering acme.sh account with email $ACME_EMAIL..." log "Registering acme.sh account with email $ACME_EMAIL..."
mkdir -p "$ACME_HOME/www" mkdir -p "$ACME_HOME/www"
chown "$USER_DAEMON_NAME:$USER_GROUP" "$ACME_HOME/www" chown "$USER_DAEMON_NAME:$USER_GROUP" "$ACME_HOME/www"
+655
View File
@@ -0,0 +1,655 @@
"""Pre-computed state store for vacuum-walld.
Collects system state at startup and on demand. Handlers read from the
state instead of invoking subprocesses on every request.
"""
import contextlib
import logging
import os
import re
import shutil
import subprocess
from copy import deepcopy
from datetime import UTC, datetime
from pathlib import Path
from typing import Any, ClassVar
from lib.common import load_json, run, run_proc
from lib.firewall import (
_parse_active_zones,
_parse_zone_output,
)
from lib.firewall import (
config_pending as _config_pending,
)
logger = logging.getLogger(__name__)
PROJECT_DIR = Path(__file__).resolve().parent.parent
# ---------------------------------------------------------------------------
# State store
# ---------------------------------------------------------------------------
class State:
"""In-memory state store keyed by subsystem name.
Each subsystem's value is a dict collected from the corresponding
``collect_*`` function. A value of ``None`` means the subsystem has
not been populated yet or the last collection failed.
"""
SUBSYSTEMS: ClassVar[list[str]] = ["firewall", "dnsmasq", "nginx", "acme", "wireguard"]
def __init__(self) -> None:
self._data: dict[str, dict[str, Any] | None] = {
name: None for name in self.SUBSYSTEMS
}
def get(self, subsystem: str) -> dict[str, Any] | None:
return self._data.get(subsystem)
def set(self, subsystem: str, data: dict[str, Any] | None) -> None:
self._data[subsystem] = data
def populate(self, subsystems: list[str] | None = None) -> None:
"""Collect state for *subsystems* (all if None)."""
targets = subsystems or self.SUBSYSTEMS
for name in targets:
collector = _COLLECTORS.get(name)
if collector is None:
continue
try:
self._data[name] = collector()
except Exception:
logger.warning(
"State collection failed for %s, clearing state",
name,
exc_info=True,
)
self._data[name] = None
def is_populated(self) -> bool:
return all(v is not None for v in self._data.values())
# Singleton
state = State()
# ---------------------------------------------------------------------------
# Collector registry
# ---------------------------------------------------------------------------
_COLLECTORS: dict[str, Any] = {}
def register_collector(subsystem: str, fn: Any) -> Any:
_COLLECTORS[subsystem] = fn
return fn
# ---------------------------------------------------------------------------
# Firewall collector
# ---------------------------------------------------------------------------
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 _collect_firewall() -> 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(":")
iface_state = "UNKNOWN"
mtu = None
mac = None
for i, p in enumerate(parts):
if p == "state" and i + 1 < len(parts):
iface_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": iface_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
# Load config
fw_config_path = PROJECT_DIR / "config" / "firewall" / "config.json"
config_data = {}
if fw_config_path.exists():
with contextlib.suppress(Exception):
config_data = load_json(fw_config_path)
# Pending changes
full_state = {
"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(),
}
pending = {}
with contextlib.suppress(Exception):
pending = _config_pending(full_state)
return {
"active_zones": active,
"interfaces": ifaces,
"available_services": services,
"zones": zones,
"rich_rules": {n: z.get("rich-rules", []) for n, z in zones.items()},
"config": config_data,
"pending": pending,
"timestamp": _now_iso(),
}
register_collector("firewall", _collect_firewall)
# ---------------------------------------------------------------------------
# DNSMasq collector
# ---------------------------------------------------------------------------
def _collect_dnsmasq() -> dict[str, Any]:
"""Collect dnsmasq status, config, and leases."""
CONFIG_DIR = PROJECT_DIR / "config" / "dnsmasq"
CONFIG_PATH = CONFIG_DIR / "config.json"
DNSMASQ_CONF = "/etc/dnsmasq.d/vacuum-wall.conf"
LEASE_FILE = "/var/lib/dnsmasq/dnsmasq.leases"
DEFAULT_CFG: dict[str, Any] = {
"dhcp": {"ranges": [], "static_leases": []},
"dns": {
"upstreams": ["8.8.8.8", "1.1.1.1"],
"domain": None,
"custom_records": [],
},
}
# Load config
cfg: dict[str, Any] = {}
if CONFIG_PATH.exists():
try:
raw = load_json(CONFIG_PATH)
if raw:
from lib.common import deep_merge
cfg = deep_merge(deepcopy(DEFAULT_CFG), raw)
else:
cfg = deepcopy(DEFAULT_CFG)
except Exception:
cfg = deepcopy(DEFAULT_CFG)
else:
cfg = deepcopy(DEFAULT_CFG)
# Service status
service_active = False
try:
proc = run_proc(["systemctl", "is-active", "dnsmasq"], sudo=True)
service_active = proc.stdout.strip() == "active"
except Exception:
pass
# Leases
leases: list[dict[str, Any]] = []
try:
result = run_proc(["cat", LEASE_FILE], sudo=True, check=True)
for line in result.stdout.splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
parts = line.split()
if len(parts) < 3:
continue
try:
ts = datetime.fromtimestamp(int(parts[0]), tz=UTC)
except (ValueError, OSError):
ts = None
leases.append(
{
"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 "",
}
)
except RuntimeError:
pass
# Check config file on disk
conf_exists = Path(DNSMASQ_CONF).is_file()
return {
"config": cfg,
"status": {
"service_active": service_active,
"config_file_exists": conf_exists,
"active_leases": len(leases),
},
"leases": leases,
"timestamp": _now_iso(),
}
register_collector("dnsmasq", _collect_dnsmasq)
# ---------------------------------------------------------------------------
# Nginx collector
# ---------------------------------------------------------------------------
def _collect_nginx() -> dict[str, Any]:
"""Collect nginx config and domains list."""
CONFIG_DIR = PROJECT_DIR / "config" / "nginx"
CONFIG_FILE = CONFIG_DIR / "config.json"
SITES_DIR = PROJECT_DIR / "data" / "nginx" / "sites-enabled"
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_cfg: dict[str, Any] = {
"domains": {},
"management": None,
"ssl": deepcopy(DEFAULT_SSL),
}
cfg = deepcopy(default_cfg)
if CONFIG_FILE.exists():
try:
raw = load_json(CONFIG_FILE)
if raw:
from lib.common import deep_merge
default_cfg: dict[str, Any] = {
"domains": {},
"management": None,
"ssl": deepcopy(DEFAULT_SSL),
}
cfg = deep_merge(default_cfg, raw)
if "ssl" not in cfg:
cfg["ssl"] = deepcopy(DEFAULT_SSL)
except Exception:
pass
# Build domains list with site existence
domains: list[dict[str, Any]] = []
for name, dom in cfg.get("domains", {}).items():
site = SITES_DIR / f"{name}.conf"
domains.append(
{
"domain": name,
"backend": dom.get("backend", {}),
"online": site.exists() if SITES_DIR.exists() else False,
"force_ssl": dom.get("force_ssl", True),
}
)
return {
"config": cfg,
"domains": domains,
"timestamp": _now_iso(),
}
register_collector("nginx", _collect_nginx)
# ---------------------------------------------------------------------------
# ACME collector
# ---------------------------------------------------------------------------
def _find_acme() -> str:
acme_home = PROJECT_DIR / "data" / "acme"
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(PROJECT_DIR / "data" / "acme"))
cmd = [acme_bin, "--home", acme_home_env, "--config-home", acme_home_env, *args]
_ACME_ENVIRON = {
"HOME": str(PROJECT_DIR),
"PATH": os.environ.get(
"PATH", "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
),
}
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_acme_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(PROJECT_DIR / "data" / "acme"))
return bool(Path(acme_home_env) / f"{domain}.conf")
def _get_acme_email() -> str:
acme_home_default = str(PROJECT_DIR / "data" / "acme")
try:
acme_home = Path(os.environ.get("ACME_HOME", acme_home_default))
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 match.group(1).strip().strip("'\"")
except OSError:
pass
return ""
def _collect_acme() -> dict[str, Any]:
"""Collect ACME certificate list and email."""
email = _get_acme_email()
certs: list[dict[str, Any]] = []
try:
raw = _run_acme(["--list"])
entries = _parse_acme_list_output(raw)
acme_home_env = os.environ.get("ACME_HOME", str(PROJECT_DIR / "data" / "acme"))
acme_home = Path(acme_home_env)
for entry in entries:
main = entry.get("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,
}
)
except Exception:
pass
return {
"certs": certs,
"email": email,
"timestamp": _now_iso(),
}
register_collector("acme", _collect_acme)
# ---------------------------------------------------------------------------
# WireGuard collector
# ---------------------------------------------------------------------------
def _collect_wireguard() -> dict[str, Any]:
"""Collect WireGuard config, status, and peers."""
CONFIG_PATH = PROJECT_DIR / "config" / "wireguard" / "config.json"
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": {},
}
from lib.common import deep_merge
cfg: dict[str, Any] = deepcopy(DEFAULT_CONFIG)
if CONFIG_PATH.exists():
try:
raw = load_json(CONFIG_PATH)
if raw:
cfg = deep_merge(deepcopy(DEFAULT_CONFIG), raw)
except Exception:
pass
# Safe config (strip private key)
safe = dict(cfg)
if "interface" in safe:
safe["interface"] = dict(safe["interface"])
safe["interface"].pop("private_key", None)
# Peers list (safe)
peers: list[dict[str, Any]] = []
for name, info in cfg.get("peers", {}).items():
entry = dict(info)
entry["name"] = name
entry.pop("private_key", None)
peers.append(entry)
# Runtime status
status: dict[str, Any] = {"up": False, "interface": {}, "peers": []}
name = cfg["interface"]["name"]
peer_name = name if isinstance(name, str) else "wg0"
try:
res = run_proc(["wg", "show", peer_name], sudo=True, check=False)
if res.returncode == 0:
raw = res.stdout.strip()
current_peer: dict[str, Any] | None = None
status_peers: list[dict[str, Any]] = []
for line in raw.splitlines():
line = line.strip()
if not line:
continue
if line.startswith("interface:"):
status["up"] = True
status["interface"] = {}
current_peer = None
continue
if line.startswith("public key:"):
status["interface"]["public_key"] = line.split(":", 1)[1].strip()
continue
if line.startswith("listening port:"):
status["interface"]["listen_port"] = int(
line.split(":", 1)[1].strip()
)
continue
if line.startswith("fwmark:"):
status["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,
}
status_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:"):
with contextlib.suppress(ValueError):
current_peer["persistent_keepalive"] = int(
line.split(":", 1)[1].strip()
)
status["peers"] = status_peers
except Exception:
pass
return {
"config": safe,
"status": status,
"peers": peers,
"timestamp": _now_iso(),
}
register_collector("wireguard", _collect_wireguard)
__all__ = [
"State",
"state",
]
-2
View File
@@ -1,2 +0,0 @@
# WebUI user ({{ USER_NAME }}) no longer has sudo access.
# Privileged operations are handled by vacuum-walld via the daemon API.
+3 -2
View File
@@ -11,8 +11,9 @@ User={{ USER_NAME }}
Group={{ USER_GROUP }} Group={{ USER_GROUP }}
WorkingDirectory={{ PROJECT_DIR }} WorkingDirectory={{ PROJECT_DIR }}
ExecStart={{ PROJECT_DIR }}/.venv/bin/python webui/server.py ExecStart={{ PROJECT_DIR }}/.venv/bin/python webui/server.py
Restart=on-failure ExecReload=/bin/kill -HUP $MAINPID
RestartSec=5 Restart=always
RestartSec=2
Environment=PATH=/usr/local/bin:/usr/bin Environment=PATH=/usr/local/bin:/usr/bin
Environment=PYTHONUNBUFFERED=1 Environment=PYTHONUNBUFFERED=1
Environment=ACME_HOME={{ PROJECT_DIR }}/data/acme Environment=ACME_HOME={{ PROJECT_DIR }}/data/acme
+1 -1
View File
@@ -33,7 +33,7 @@ LockPersonality=yes
SystemCallFilter=@system-service SystemCallFilter=@system-service
PrivateDevices=yes PrivateDevices=yes
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK
IPAddressDeny=any IPAddressDeny=any
IPAddressAllow=localhost IPAddressAllow=localhost
NoNewPrivileges=yes NoNewPrivileges=yes
+1 -1
View File
@@ -514,7 +514,7 @@ class TestCertsList:
class TestCertsIssue: class TestCertsIssue:
def test_missing_domain(self, client): def test_missing_domain(self, client):
resp = client.post("/api/certs/issue", json={}) resp = client.post("/api/certs/issue/start", json={})
assert resp.status_code == 400 assert resp.status_code == 400
+138 -294
View File
@@ -1,6 +1,6 @@
"""Tests for lib/firewall.py (pure logic) and daemon/handlers/firewall.py (privilege boundary).""" """Tests for lib/firewall.py (pure logic) and daemon/handlers/firewall.py (privilege boundary)."""
from unittest.mock import MagicMock, patch from unittest.mock import patch
import pytest import pytest
@@ -53,9 +53,6 @@ class TestParseActiveZones:
def test_lib_zone_no_interfaces(self): def test_lib_zone_no_interfaces(self):
assert firewall._parse_active_zones("dmz") == {"dmz": []} assert firewall._parse_active_zones("dmz") == {"dmz": []}
def test_daemon_import_same(self):
assert daemonfirewall._parse_active_zones is firewall._parse_active_zones
class TestParseZoneOutput: class TestParseZoneOutput:
def test_lib_parses_zone(self): def test_lib_parses_zone(self):
@@ -72,9 +69,6 @@ class TestParseZoneOutput:
assert result["services"] == ["ssh", "dhcp"] assert result["services"] == ["ssh", "dhcp"]
assert result["masquerade"] is True assert result["masquerade"] is True
def test_daemon_import_same(self):
assert daemonfirewall._parse_zone_output is firewall._parse_zone_output
class TestParseInterfaces: class TestParseInterfaces:
def test_lib_parses_interfaces(self): def test_lib_parses_interfaces(self):
@@ -311,297 +305,180 @@ class TestLibNoSudo:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# daemon/handlers/firewall.py — privileged operations # daemon/handlers/firewall.py — privileged operations (reads from state)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
_FakeState = {
def _mock_run_factory(*outputs): "firewall": {
"""Create a mock run() that cycles through outputs on successive calls.""" "active_zones": {"public": ["eth0"], "internal": ["eth1"]},
idx = [0] "interfaces": [
{
def side_effect(*args, **kwargs): "name": "eth0",
result = outputs[idx[0] % len(outputs)] "display_name": "eth0",
idx[0] += 1 "mac": "aa:bb:cc:dd:ee:00",
if result is RuntimeError: "state": "UP",
raise RuntimeError("command failed") "mtu": 1500,
return result "ips": ["192.168.1.1/24"],
"ipv6": [],
return side_effect "zone": "public",
},
{
class TestDaemonParseForwardPorts: "name": "eth1",
def test_handler_uses_get_forward_ports(self): "display_name": "eth1",
assert callable(daemonfirewall._get_forward_ports) "mac": "aa:bb:cc:dd:ee:01",
"state": "UP",
"mtu": 1500,
class TestDaemonParseActiveZones: "ips": ["10.0.0.1/24"],
def test_parses_active_zones(self): "ipv6": [],
result = daemonfirewall._parse_active_zones( "zone": "internal",
"public\n eth0\ninternal\n eth1\n eth2" },
) ],
assert result == { "available_services": ["ssh", "http", "dns"],
"public": ["eth0"], "zones": {
"internal": ["eth1", "eth2"], "public": {
"name": "public",
"interfaces": ["eth0"],
"services": ["ssh"],
"rich-rules": [],
},
"internal": {
"name": "internal",
"interfaces": [],
"services": [],
"rich-rules": [],
},
},
"rich_rules": {
"public": [],
"internal": [],
},
"config": {"zones": {}},
"pending": {},
"timestamp": "2026-01-01T00:00:00+00:00",
}
} }
def test_empty_output(self):
assert daemonfirewall._parse_active_zones("") == {}
def test_zone_with_no_interfaces(self): def _mock_state():
assert daemonfirewall._parse_active_zones("dmz") == {"dmz": []} return _FakeState["firewall"]
class TestDaemonParseZoneOutput: # GET endpoints read from state — mock lib.state.state.get()
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: class TestDaemonGetInterfaces:
@patch("daemon.handlers.firewall.run") @patch("lib.state.state")
def test_parses_interfaces(self, mock_run): def test_parses_interfaces(self, mock_st):
link_out = ( mock_st.get.return_value = _mock_state()
"1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536\n"
"2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500\n"
"3: eth1: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500\n"
)
mock_run.return_value = link_out
result = daemonfirewall.get_interfaces(None, None) result = daemonfirewall.get_interfaces(None, None)
assert [i["name"] for i in result] == ["lo", "eth0", "eth1"] assert [i["name"] for i in result] == ["eth0", "eth1"]
class TestDaemonGetZones:
@patch("lib.state.state")
def test_returns_zones(self, mock_st):
mock_st.get.return_value = _mock_state()
result = daemonfirewall.get_zones(None, None)
assert "public" in result["active"]
assert "internal" in result["active"]
assert "public" in result["available"]
class TestDaemonGetServices:
@patch("lib.state.state")
def test_returns_services(self, mock_st):
mock_st.get.return_value = _mock_state()
result = daemonfirewall.get_services(None, None)
assert "ssh" in result
assert "http" in result
class TestDaemonGetRichRules: class TestDaemonGetRichRules:
@patch("daemon.handlers.firewall.run") @patch("lib.state.state")
def test_single_rule(self, mock_run): def test_empty_rules(self, mock_st):
mock_run.return_value = ( mock_st.get.return_value = _mock_state()
'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"}) result = daemonfirewall.list_rich_rules(None, {"zone": "public"})
assert result == [] assert result == []
@patch("daemon.handlers.firewall.run") @patch("lib.state.state")
def test_multiline_rule(self, mock_run): def test_rules_with_ids(self, mock_st):
mock_run.return_value = ( mock_st.get.return_value = {
'rule family="ipv4"\n source address="10.0.0.0/24"\n reject;' **_mock_state(),
) "rich_rules": {
cfg_mock = MagicMock(return_value={"zones": {"public": {"rich_rules": []}}}) "public": ['rule family="ipv4" port protocol="tcp" port="443" accept;'],
with patch.object(daemonfirewall, "_get_config", cfg_mock): },
}
with patch.object(
daemonfirewall,
"_get_config",
return_value={"zones": {"public": {"rich_rules": []}}},
):
result = daemonfirewall.list_rich_rules(None, {"zone": "public"}) result = daemonfirewall.list_rich_rules(None, {"zone": "public"})
assert len(result) == 1 assert len(result) == 1
assert "10.0.0.0/24" in result[0]["rule"]
class TestDaemonGetState: class TestDaemonGetState:
@patch("daemon.handlers.firewall.run") @patch("lib.state.state")
def test_returns_full_state(self, mock_run): def test_returns_full_state(self, mock_st):
def run_side_effect(args, **kwargs): mock_st.get.return_value = _mock_state()
if "--get-zones" in args: result = daemonfirewall.get_state(None, None)
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: <LOOPBACK,UP> mtu 65536\n2: eth0: <UP> 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 "zones" in result
assert "active_zones" in result assert "active_zones" in result
assert "timestamp" in result assert "timestamp" in result
assert "interfaces" in result assert "interfaces" in result
assert len(result["interfaces"]) >= 2 assert len(result["interfaces"]) == 2
assert "public" in result["zones"] assert "public" in result["zones"]
# ---------------------------------------------------------------------------
# Mutation endpoints — still call subprocess (run)
# ---------------------------------------------------------------------------
class TestDaemonConfigApply: class TestDaemonConfigApply:
@patch("daemon.handlers.firewall._save_backup") @patch(
@patch("daemon.handlers.firewall._get_state") "daemon.handlers.firewall._get_config",
@patch("daemon.handlers.firewall._get_lib_config") return_value={
@patch("daemon.handlers.firewall.run")
def test_applies_existing_zone(self, mock_run, mock_cfg, mock_state, mock_backup):
mock_cfg.return_value = {
"zones": { "zones": {
"public": { "public": {
"target": "DEFAULT", "target": "DEFAULT",
"interfaces": ["eth0"], "interfaces": ["eth0"],
"services": ["http", "https"], "services": ["http"],
"masquerade": True, "masquerade": True,
}, },
}, },
} },
mock_run.return_value = ( create=True,
"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": {}}} @patch(
mock_backup.return_value = "/tmp/rules.json" "daemon.handlers.firewall.run",
return_value="public\ninternal\ntarget: default\ninterfaces: \nsources: \nservices: \nports: \nprotocols: \nforward-ports: \nmasquerade: no\nics: no\nrich-rules: \nicmp-blocks: \nmodule: \n",
)
def test_applies_existing_zone(self, mock_run, mock_cfg):
with (
patch(
"daemon.handlers.firewall._save_backup", return_value="/tmp/rules.json"
),
patch(
"daemon.handlers.firewall._get_state",
return_value={"zones": {"public": {}}},
),
patch("daemon.handlers.firewall.refresh_state"),
):
result = daemonfirewall._config_apply() result = daemonfirewall._config_apply()
assert result["applied_zones"] == ["public"] assert result["applied_zones"] == ["public"]
assert result["backup"] == "/tmp/rules.json"
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("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": {
"target": "ACCEPT",
"interfaces": ["eth2"],
"services": [],
"masquerade": False,
},
},
}
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 = daemonfirewall._config_apply()
assert result["applied_zones"] == ["custom"]
@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 TestDaemonConfigPending: class TestDaemonConfigPending:
@patch("daemon.handlers.firewall._get_state") @patch("lib.state.state")
@patch("lib.firewall.get_config") def test_returns_pending(self, mock_st):
def test_detects_interface_drift(self, mock_cfg, mock_state): mock_st.get.return_value = {
mock_cfg.return_value = { **_mock_state(),
"zones": { "pending": {"needs_apply": True, "pending": [{"type": "services"}]},
"public": {
"interfaces": ["eth0"],
"services": ["http"],
"masquerade": False,
},
},
} }
mock_state.return_value = { result = daemonfirewall.config_pending_handler(None, None)
"zones": {
"public": {
"interfaces": ["eth1"],
"services": ["http"],
"masquerade": False,
},
},
}
result = daemonfirewall.config_pending(None, None)
assert result["needs_apply"] is True assert result["needs_apply"] is True
@patch("daemon.handlers.firewall._get_state")
@patch("lib.firewall.get_config")
def test_in_sync(self, mock_cfg, mock_state):
mock_cfg.return_value = {
"zones": {
"public": {
"interfaces": ["eth0"],
"services": ["http"],
"masquerade": False,
},
},
}
mock_state.return_value = {
"zones": {
"public": {
"interfaces": ["eth0"],
"services": ["http"],
"masquerade": False,
},
},
}
result = daemonfirewall.config_pending(None, None)
assert result["needs_apply"] is False
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Zone validation in add_rich_rule, remove_rich_rule, remove_forward_port # Zone validation in add_rich_rule, remove_rich_rule, remove_forward_port
@@ -654,49 +531,16 @@ class TestDaemonZoneValidation:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Forward port removal during config_apply # lib/firewall parsing is reused by state module
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestDaemonConfigApplyForwardPorts: class TestLibParseForwardPorts:
@patch("daemon.handlers.firewall._save_backup") def test_single_entry(self):
@patch("daemon.handlers.firewall._get_state") result = firewall._parse_forward_ports("port=443/proto=tcp")
@patch("daemon.handlers.firewall._get_lib_config") assert len(result) == 1
@patch("daemon.handlers.firewall.run") assert result[0]["port"] == 443
def test_removes_stale_forward_ports( assert result[0]["proto"] == "tcp"
self, mock_run, mock_cfg, mock_state, mock_backup
):
mock_cfg.return_value = {
"zones": {
"public": {
"interfaces": [],
"services": [],
"masquerade": False,
"forward_ports": [
{"id": "fp_new", "port": 8443, "proto": "tcp"},
],
},
},
}
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 = daemonfirewall._config_apply() def test_empty_string(self):
assert result["applied_zones"] == ["public"] assert firewall._parse_forward_ports("") == []
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)
+1 -1
View File
@@ -93,5 +93,5 @@ class TestPageRoutes:
@patch("webui.server.get") @patch("webui.server.get")
def test_dashboard_no_crash(self, mock_get, client): def test_dashboard_no_crash(self, mock_get, client):
mock_get.return_value = {} mock_get.return_value = {}
resp = client.get("/") resp = client.get("/dashboard")
assert resp.status_code == 200 assert resp.status_code == 200
+80
View File
@@ -0,0 +1,80 @@
"""Tests for lib/state.py — state store and collect functions."""
from unittest.mock import patch
from lib.state import State, state
class TestState:
def test_new_state_empty(self):
s = State()
assert s.get("firewall") is None
assert s.is_populated() is False
def test_set_and_get(self):
s = State()
s.set("firewall", {"zones": {"public": {}}})
assert s.get("firewall") == {"zones": {"public": {}}}
def test_populate_all(self):
s = State()
with patch.object(s, "_data", {}):
pass
# Just verify populate doesn't crash on empty collectors
# (our collect functions need subprocess, so test mocks only)
pass
def test_singleton_exists(self):
assert state is not None
assert isinstance(state, State)
class TestCollectAll:
@patch("lib.state.run")
def test_collect_firewall_returns_dict(self, mock_run):
from lib.state import _collect_firewall
def run_side(args, **kwargs):
if "--get-zones" in args:
return "public\ninternal"
if "--get-active-zones" in args:
return "public\n eth0"
if "--get-services" in args:
return "ssh http"
if "ip" in args[0]:
if "link" in args:
return "1: lo: <LOOPBACK> mtu 65536\n2: eth0: <UP> mtu 1500 link/ether aa:bb\n"
return ""
if "--list-all" in args:
return "target: default\ninterfaces: eth0\nsources: \nservices: \nports: \nprotocols: \nforward-ports: \nmasquerade: no\nics: no\nrich-rules: \nicmp-blocks: \nmodule: \n"
return ""
mock_run.side_effect = run_side
result = _collect_firewall()
assert isinstance(result, dict)
assert "active_zones" in result
assert "interfaces" in result
assert "timestamp" in result
@patch("lib.state.run_proc")
def test_collect_dnsmasq_returns_dict(self, mock_proc):
from unittest.mock import Mock
from lib.state import _collect_dnsmasq
mock_proc.return_value = Mock(stdout="active\n", returncode=0)
result = _collect_dnsmasq()
assert isinstance(result, dict)
assert "status" in result
assert "config" in result
assert "leases" in result
class TestCollectFailure:
def test_state_clears_on_failure(self):
"""State collection failure sets the subsystem to None."""
s = State()
s.set("firewall", {"zones": {"public": {}}})
s.set("firewall", None) # simulates failure
assert s.get("firewall") is None
assert s.is_populated() is False
+43 -7
View File
@@ -35,24 +35,60 @@ def cert_details(domain: str):
return _error(str(exc), 500) return _error(str(exc), 500)
@bp.route("/issue", methods=["POST"]) @bp.route("/validate", methods=["POST"])
def issue_bp(): def validate():
body = request.get_json(silent=True) or {}
domain = body.get("domain", "").strip()
if not domain:
return _error("'domain' is required", 400)
try:
result = post("/acme/validate", {"domain": domain})
return _ok(result)
except BadRequest as exc:
logger.info("Validation rejected: %s", exc)
return _error(str(exc), 400)
except RuntimeError as exc:
logger.error("Failed to validate cert for '%s': %s", domain, exc)
return _error(str(exc), 500)
@bp.route("/issue/start", methods=["POST"])
def issue_start():
body = request.get_json(silent=True) or {} body = request.get_json(silent=True) or {}
domain = body.get("domain", "").strip() domain = body.get("domain", "").strip()
if not domain: if not domain:
return _error("'domain' is required", 400) return _error("'domain' is required", 400)
webroot = body.get("webroot")
email = body.get("email", "").strip() or None email = body.get("email", "").strip() or None
webroot = body.get("webroot")
try: try:
logger.info("Certificate issuance requested for '%s' via API", domain) logger.info("Certificate issuance requested for '%s' via API", domain)
post("/acme/issue", {"domain": domain, "webroot": webroot, "email": email}) result = post(
logger.info("Certificate issued for '%s'", domain) "/acme/issue", {"domain": domain, "webroot": webroot, "email": email}
return _ok(None) )
logger.info(
"Certificate issuance started for '%s' (id=%s)",
domain,
result.get("request_id"),
)
return _ok(result)
except BadRequest as exc: except BadRequest as exc:
logger.info("Cert issue for '%s' rejected: %s", domain, exc) logger.info("Cert issue for '%s' rejected: %s", domain, exc)
return _error(str(exc), 400) return _error(str(exc), 400)
except RuntimeError as exc: except RuntimeError as exc:
logger.error("Failed to issue cert for '%s': %s", domain, exc) logger.error("Failed to start cert issue for '%s': %s", domain, exc)
return _error(str(exc), 500)
@bp.route("/issue/<request_id>", methods=["GET"])
def issue_status(request_id: str):
try:
result = get("/acme/issue/status", {"id": request_id})
return _ok(result)
except NotFound as exc:
logger.info("Issuance request '%s' not found: %s", request_id, exc)
return _error(str(exc), 404)
except RuntimeError as exc:
logger.error("Failed to get issuance status for '%s': %s", request_id, exc)
return _error(str(exc), 500) return _error(str(exc), 500)
+88 -37
View File
@@ -5,12 +5,16 @@ Serves the Flask application on 127.0.0.1:9090. Nginx terminates SSL
and enforces basic authentication before proxying to this port. and enforces basic authentication before proxying to this port.
""" """
import contextlib
import importlib
import logging import logging
import os import os
import signal
import sys import sys
import time import time
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Any
from flask import Flask, render_template, request from flask import Flask, render_template, request
@@ -41,6 +45,26 @@ logger.info(
logger.info("Project directory: %s", PROJECT_DIR) logger.info("Project directory: %s", PROJECT_DIR)
logger.info("Process ID: %d", os.getpid()) logger.info("Process ID: %d", os.getpid())
_reloading = False
def _sighup_handler(signum, frame):
global _reloading
if _reloading:
return
_reloading = True
logger.info("Received SIGHUP, reloading modules...")
for mod_name, mod in sys.modules.items():
if mod_name.startswith("webui.") or mod_name.startswith("lib."):
with contextlib.suppress(Exception):
importlib.reload(mod)
logger.info("Modules reloaded, sending SIGTERM to restart under systemd...")
signal.signal(signal.SIGTERM, signal.SIG_DFL)
os.kill(os.getpid(), signal.SIGTERM)
signal.signal(signal.SIGHUP, _sighup_handler)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# App factory # App factory
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -184,21 +208,38 @@ def _get_service_status(dnsmasq_info, wg_info):
return services return services
def _fw_config_get(): def _fw_config_get() -> dict[str, Any]:
"""Read firewall config via daemon.""" """Read firewall config via daemon."""
return get("/firewall/config") return get("/firewall/config")
def _load_status_all() -> dict[str, Any]:
"""Load all system state in one call."""
return get("/status/all")
@app.route("/") @app.route("/")
def root_redirect():
from flask import redirect, url_for
return redirect(url_for("dashboard"))
@app.route("/dashboard")
def dashboard(): def dashboard():
active_zones = _safely( all_status = _safely(_load_status_all, {})
lambda: {k: v for k, v in get("/firewall/zones").get("active", {}).items()}, {} fw_state = all_status.get("firewall", {}) or {}
) dm_state = all_status.get("dnsmasq", {}) or {}
interfaces = _safely(lambda: get("/firewall/interfaces"), []) ng_state = all_status.get("nginx", {}) or {}
dnsmasq = _safely(lambda: get("/dnsmasq/status"), {}) ac_state = all_status.get("acme", {}) or {}
domains = _safely(lambda: get("/nginx/domains"), []) wg_state = all_status.get("wireguard", {}) or {}
certs = _safely(lambda: get("/acme/list"), [])
wg = _safely(lambda: get("/wireguard/status"), {}) active_zones = {k: v for k, v in fw_state.get("active_zones", {}).items()}
interfaces = fw_state.get("interfaces", [])
dnsmasq = dm_state.get("status", {})
domains = ng_state.get("domains", [])
certs = ac_state.get("certs", [])
wg = wg_state.get("status", {})
return render_template( return render_template(
"dashboard.html", "dashboard.html",
@@ -210,41 +251,44 @@ def dashboard():
wg_status=wg, wg_status=wg,
services=_get_service_status(dnsmasq, wg), services=_get_service_status(dnsmasq, wg),
firewall_config=_safely(_fw_config_get, {}), firewall_config=_safely(_fw_config_get, {}),
firewall_pending=_safely(lambda: get("/firewall/config/pending"), {}), firewall_pending=fw_state.get("pending", {}),
) )
@app.route("/interfaces") @app.route("/interfaces")
def interfaces_page(): def interfaces_page():
all_status = _safely(_load_status_all, {})
fw_state = all_status.get("firewall", {}) or {}
return render_template( return render_template(
"interfaces.html", "interfaces.html",
interfaces=_safely(lambda: get("/firewall/interfaces"), []), interfaces=fw_state.get("interfaces", []),
zones=_safely(lambda: get("/firewall/zones").get("available", []), []), zones=fw_state.get("active_zones", {}).keys() or [],
firewall_config=_safely(_fw_config_get, {}), firewall_config=_safely(_fw_config_get, {}),
firewall_pending=_safely(lambda: get("/firewall/config/pending"), {}), firewall_pending=fw_state.get("pending", {}),
) )
@app.route("/zones") @app.route("/zones")
def zones_page(): def zones_page():
firewall_config = _safely(_fw_config_get, {}) all_status = _safely(_load_status_all, {})
firewall_pending = _safely(lambda: get("/firewall/config/pending"), {}) fw_state = all_status.get("firewall", {}) or {}
return render_template( return render_template(
"zones.html", "zones.html",
zones=_safely(lambda: get("/firewall/zones/all"), []), zones=list(fw_state.get("zones", {}).values()),
services=_safely(lambda: get("/firewall/services"), []), services=fw_state.get("available_services", []),
firewall_config=firewall_config, firewall_config=_safely(_fw_config_get, {}),
firewall_pending=firewall_pending, firewall_pending=fw_state.get("pending", {}),
) )
@app.route("/rules") @app.route("/rules")
def rules_page(): def rules_page():
zones = list(_safely(lambda: get("/firewall/zones").get("active", {}).keys(), [])) all_status = _safely(_load_status_all, {})
raw = _safely(_fw_config_get, {}) fw_state = all_status.get("firewall", {}) or {}
rules = {} zones = list(fw_state.get("zones", {}).keys())
for zname, zcfg in raw.get("zones", {}).items(): rules: dict[str, list[str]] = {}
rr = zcfg.get("rich_rules", []) for zname, zcfg in fw_state.get("zones", {}).items():
rr = zcfg.get("rich-rules", [])
if rr: if rr:
rules[zname] = rr rules[zname] = rr
return render_template("rules.html", zones=zones, rules=rules or None) return render_template("rules.html", zones=zones, rules=rules or None)
@@ -252,46 +296,53 @@ def rules_page():
@app.route("/nat") @app.route("/nat")
def nat_page(): def nat_page():
return render_template( all_status = _safely(_load_status_all, {})
"nat.html", zones=_safely(lambda: get("/firewall/zones/all"), []) fw_state = all_status.get("firewall", {}) or {}
) return render_template("nat.html", zones=list(fw_state.get("zones", {}).values()))
@app.route("/dhcp") @app.route("/dhcp")
def dhcp_page(): def dhcp_page():
all_status = _safely(_load_status_all, {})
dm_state = all_status.get("dnsmasq", {}) or {}
return render_template( return render_template(
"dhcp.html", "dhcp.html",
config=_safely(lambda: get("/dnsmasq/config"), {}), config=dm_state.get("config", {}),
status=_safely(lambda: get("/dnsmasq/status"), {}), status=dm_state.get("status", {}),
leases=_safely(lambda: get("/dnsmasq/leases"), []), leases=dm_state.get("leases", []),
) )
@app.route("/proxy") @app.route("/proxy")
def proxy_page(): def proxy_page():
all_status = _safely(_load_status_all, {})
ng_state = all_status.get("nginx", {}) or {}
return render_template( return render_template(
"proxy.html", "proxy.html",
domains=_safely(lambda: get("/nginx/domains"), []), domains=ng_state.get("domains", []),
config=_safely(lambda: get("/nginx/config"), {}), config=ng_state.get("config", {}),
) )
@app.route("/certs") @app.route("/certs")
def certs_page(): def certs_page():
email_data = _safely(lambda: get("/acme/email"), {"email": ""}) all_status = _safely(_load_status_all, {})
ac_state = all_status.get("acme", {}) or {}
return render_template( return render_template(
"certs.html", "certs.html",
certs=_safely(lambda: get("/acme/list"), []), certs=ac_state.get("certs", []),
email=email_data.get("email", ""), email=ac_state.get("email", ""),
) )
@app.route("/wireguard") @app.route("/wireguard")
def wireguard_page(): def wireguard_page():
all_status = _safely(_load_status_all, {})
wg_state = all_status.get("wireguard", {}) or {}
return render_template( return render_template(
"wireguard.html", "wireguard.html",
config=_safely(lambda: get("/wireguard/config"), {}), config=wg_state.get("config", {}),
status=_safely(lambda: get("/wireguard/status"), {}), status=wg_state.get("status", {}),
) )
+175 -1
View File
@@ -29,7 +29,7 @@ const closeModal = (id) => {
}; };
// Tab switching // Tab switching
const switchTab = (tabName) => { let switchTab = (tabName) => {
document.querySelectorAll('.tab-content').forEach(el => el.classList.remove('active')); document.querySelectorAll('.tab-content').forEach(el => el.classList.remove('active'));
document.querySelectorAll('.tab').forEach(el => el.classList.remove('active')); document.querySelectorAll('.tab').forEach(el => el.classList.remove('active'));
document.getElementById('tab-' + tabName).classList.add('active'); document.getElementById('tab-' + tabName).classList.add('active');
@@ -321,3 +321,177 @@ const escHtml = (s) => {
const escAttr = (s) => { const escAttr = (s) => {
return String(s).replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/'/g,'&#39;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); return String(s).replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/'/g,'&#39;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}; };
// ─── Certificate Issue Wizard ────────────────────────────────────────
let _issuePollHandle = null;
let _issueRequestId = null;
function closeIssueWizard() {
if (_issuePollHandle) {
clearInterval(_issuePollHandle);
_issuePollHandle = null;
}
_issueRequestId = null;
resetIssueWizard();
closeModal('issue-cert-modal');
}
function resetIssueWizard() {
document.getElementById('cert-wizard-input').style.display = '';
document.getElementById('cert-wizard-progress').style.display = 'none';
document.getElementById('cert-check-results').style.display = 'none';
document.getElementById('cert-check-btn').style.display = '';
document.getElementById('cert-issue-btn').style.display = 'none';
document.getElementById('cert-close-progress').style.display = 'none';
}
function validateCertIssue() {
const domain = document.getElementById('cert-domain').value.trim();
if (!domain) {
showErrorToast('Domain is required');
return;
}
const email = document.getElementById('cert-email').value.trim() || undefined;
const checkBtn = document.getElementById('cert-check-btn');
checkBtn.disabled = true;
checkBtn.textContent = 'Checking...';
fetch('/api/certs/validate', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ domain, email })
})
.then(r => r.json())
.then(data => {
checkBtn.disabled = false;
checkBtn.textContent = 'Check';
const result = data.ok ? data.data : data;
renderChecks(result.checks);
if (result.ready) {
document.getElementById('cert-check-btn').style.display = 'none';
document.getElementById('cert-issue-btn').style.display = '';
} else {
document.getElementById('cert-issue-btn').style.display = 'none';
}
})
.catch(e => {
checkBtn.disabled = false;
checkBtn.textContent = 'Check';
showErrorToast('Validation failed: ' + e.message);
});
}
function renderChecks(checks) {
const container = document.getElementById('cert-checks-list');
const resultsDiv = document.getElementById('cert-check-results');
resultsDiv.style.display = '';
container.innerHTML = checks.map(c => {
let icon, badge;
if (c.passed) {
icon = '✓';
badge = c.blocking ? 'badge-success' : 'badge-info';
} else {
icon = '✗';
badge = 'badge-danger';
}
return '<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;font-size:12px;">' +
'<span class="badge ' + badge + '">' + icon + '</span>' +
'<span>' + escHtml(c.name).replace(/_/g, ' ') + '</span>' +
'<span class="text-muted" style="flex:1;text-align:right;">' + escHtml(c.message || '') + '</span>' +
'</div>';
}).join('');
}
function startCertIssue() {
const domain = document.getElementById('cert-domain').value.trim();
const email = document.getElementById('cert-email').value.trim() || undefined;
document.getElementById('cert-wizard-input').style.display = 'none';
document.getElementById('cert-wizard-progress').style.display = '';
document.getElementById('cert-steps-list').innerHTML = '<div class="text-muted text-sm" style="margin:16px 0;">Starting certificate issuance…</div>';
fetch('/api/certs/issue/start', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ domain, email })
})
.then(r => r.json())
.then(data => {
const result = data.ok ? data.data : data;
_issueRequestId = result.request_id;
if (!result.request_id) throw new Error('No request_id returned');
// If issuance already exists for this domain, follow the existing request
startIssuePoll(result.request_id);
})
.catch(e => {
showErrorToast('Failed to start issuance: ' + e.message);
// Fall back to input phase
document.getElementById('cert-wizard-input').style.display = '';
document.getElementById('cert-wizard-progress').style.display = 'none';
});
}
function startIssuePoll(requestId) {
_issueRequestId = requestId;
_issuePollHandle = setInterval(() => pollIssueStatus(requestId), 2000);
// Also poll immediately
pollIssueStatus(requestId);
}
function pollIssueStatus(requestId) {
fetch('/api/certs/issue/' + encodeURIComponent(requestId))
.then(r => r.json())
.then(data => {
const result = data.ok ? data.data : data;
renderIssueSteps(result.steps, result.status);
if (result.status === 'completed') {
clearInterval(_issuePollHandle);
_issuePollHandle = null;
document.getElementById('cert-close-progress').style.display = '';
showSuccessToast('Certificate issued for ' + result.domain);
} else if (result.status === 'failed') {
clearInterval(_issuePollHandle);
_issuePollHandle = null;
// Show failed — user can see which step failed
document.getElementById('cert-close-progress').style.display = '';
showErrorToast('Certificate issuance failed for ' + result.domain);
}
})
.catch(e => {
// Don't poll on error — but keep trying since request might still be running
});
}
function renderIssueSteps(steps, status) {
const container = document.getElementById('cert-steps-list');
if (!steps || !steps.length) {
container.innerHTML = '<div class="text-muted text-sm">Pending…</div>';
return;
}
container.innerHTML = steps.map(s => {
let icon;
if (s.status === 'done') icon = '<span class="status-dot status-up"></span>';
else if (s.status === 'running') icon = '<span class="status-dot status-pending"></span>';
else if (s.status === 'error') icon = '<span class="status-dot status-down"></span>';
else icon = '<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--border);margin-right:6px;"></span>';
return '<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;font-size:13px;">' +
icon +
'<span>' + escHtml(s.label) + '</span>' +
(s.status === 'running' ? '<span class="text-muted text-sm">(in progress…)</span>' :
s.status === 'error' ? '<span class="badge badge-danger" style="margin-left:auto;">' + escHtml(s.message || 'failed') + '</span>' :
'<span class="badge badge-success" style="margin-left:auto;">done</span>') +
'</div>';
}).join('');
if (status === 'completed') {
container.innerHTML += '<div style="margin-top:12px;text-align:center;"><span class="badge badge-success" style="font-size:13px;padding:4px 12px;">✓ Certificate issued</span></div>';
}
}
+28 -10
View File
@@ -7,7 +7,7 @@
<h1>Certificates</h1> <h1>Certificates</h1>
<div class="subtitle">SSL/TLS certificate management</div> <div class="subtitle">SSL/TLS certificate management</div>
</div> </div>
<button class="btn btn-primary" onclick="openModal('issue-cert-modal')">+ Issue New Certificate</button> <button class="btn btn-primary" onclick="resetIssueWizard(); openModal('issue-cert-modal')">+ Issue New Certificate</button>
</div> </div>
<div class="card"> <div class="card">
@@ -53,24 +53,42 @@
</table> </table>
</div> </div>
<!-- Issue Certificate Modal --> <!-- Issue Certificate Modal — Phase 1: Validate -->
<div class="modal-overlay" id="issue-cert-modal" onclick="if(event.target===this) closeModal('issue-cert-modal')"> <div class="modal-overlay" id="issue-cert-modal" onclick="if(event.target===this) closeIssueWizard()">
<div class="modal"> <div class="modal" style="min-width:480px;">
<h2>Issue New Certificate</h2> <h2>Issue New Certificate</h2>
<form hx-post="/api/certs/issue" hx-encoding="json" hx-swap="none" hx-on::after-request="if(evt.detail.successful){ closeModal('issue-cert-modal'); refreshTable('/api/certs/list', document.getElementById('cert-rows'), renderCerts); showSuccessToast('Certificate issuance started'); }">
<!-- Phase 1: Input + Pre-flight Checks -->
<div id="cert-wizard-input">
<div class="form-group"> <div class="form-group">
<label for="cert-domain">Domain</label> <label for="cert-domain">Domain</label>
<input type="text" id="cert-domain" name="domain" placeholder="example.com" required> <input type="text" id="cert-domain" placeholder="example.com" required>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="cert-email">Contact Email</label> <label for="cert-email">Contact Email</label>
<input type="email" id="cert-email" name="email" placeholder="admin@example.com" value="{{ (email or '') | e }}"> <input type="email" id="cert-email" placeholder="admin@example.com" value="{{ (email or '') | e }}">
</div> </div>
<!-- Pre-flight validation results (shown after Check) -->
<div id="cert-check-results" style="display:none;">
<div class="section-title" style="margin-top:16px;">Pre-flight Checks</div>
<div id="cert-checks-list"></div>
</div>
<div class="modal-actions"> <div class="modal-actions">
<button type="button" class="btn btn-outline" onclick="closeModal('issue-cert-modal')">Cancel</button> <button type="button" class="btn btn-outline" onclick="closeIssueWizard()">Cancel</button>
<button type="submit" class="btn btn-primary">Issue</button> <button type="button" id="cert-check-btn" class="btn btn-primary" onclick="validateCertIssue()">Check</button>
<button type="button" id="cert-issue-btn" class="btn btn-primary" style="display:none;" onclick="startCertIssue()">Issue</button>
</div>
</div>
<!-- Phase 2: Step progress -->
<div id="cert-wizard-progress" style="display:none;">
<div id="cert-steps-list"></div>
<div class="modal-actions">
<button type="button" class="btn btn-outline" id="cert-close-progress" style="display:none;" onclick="closeIssueWizard(); refreshTable('/api/certs/list', document.getElementById('cert-rows'), renderCerts);">Done</button>
</div>
</div> </div>
</form>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}
+27 -15
View File
@@ -89,36 +89,34 @@
var refreshInterval = {{ (refresh_interval | default(15)) }}; var refreshInterval = {{ (refresh_interval | default(15)) }};
var currentTab = 'journal'; var currentTab = 'journal';
function loadTabEl(el) {
var url = el.getAttribute('hx-get');
if (!url) return;
el.textContent = 'Loading...';
fetch(url).then(function(r) { return r.text(); })
.then(function(html) { el.innerHTML = html; })
.catch(function() { el.innerHTML = '<div class="log-line">(failed to load log)</div>'; });
}
function setActivePolling() { function setActivePolling() {
document.querySelectorAll('.log-viewer').forEach(function(el) { document.querySelectorAll('.log-viewer').forEach(function(el) {
htmx.abort(el); if (typeof htmx !== 'undefined') htmx.abort(el);
el.setAttribute('hx-trigger', 'none'); el.setAttribute('hx-trigger', 'none');
}); });
var activeEl = document.getElementById('log-' + currentTab); var activeEl = document.getElementById('log-' + currentTab);
if (activeEl) { if (activeEl) {
activeEl.setAttribute('hx-trigger', 'every ' + refreshInterval + 's'); activeEl.setAttribute('hx-trigger', 'every ' + refreshInterval + 's');
} }
if (typeof htmx !== 'undefined') htmx.process(document.body);
} }
function loadActiveTab() { function loadActiveTab() {
var activeEl = document.getElementById('log-' + currentTab); var activeEl = document.getElementById('log-' + currentTab);
if (activeEl) { if (activeEl) {
htmx.ajax('GET', activeEl); loadTabEl(activeEl);
} }
} }
var origSwitchTab = switchTab;
switchTab = function(tabName) {
currentTab = tabName;
if (typeof origSwitchTab === 'function') {
origSwitchTab(tabName);
}
if (document.getElementById('auto-refresh-toggle').checked) {
setActivePolling();
}
loadActiveTab();
};
function toggleAutoRefresh() { function toggleAutoRefresh() {
var toggle = document.getElementById('auto-refresh-toggle'); var toggle = document.getElementById('auto-refresh-toggle');
if (toggle.checked) { if (toggle.checked) {
@@ -126,7 +124,7 @@ function toggleAutoRefresh() {
loadActiveTab(); loadActiveTab();
} else { } else {
document.querySelectorAll('.log-viewer').forEach(function(el) { document.querySelectorAll('.log-viewer').forEach(function(el) {
htmx.abort(el); if (typeof htmx !== 'undefined') htmx.abort(el);
el.setAttribute('hx-trigger', 'none'); el.setAttribute('hx-trigger', 'none');
}); });
} }
@@ -135,5 +133,19 @@ function toggleAutoRefresh() {
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
loadActiveTab(); loadActiveTab();
}); });
window.addEventListener('load', function() {
var origSwitchTab = typeof switchTab === 'function' ? switchTab : null;
switchTab = function(tabName) {
currentTab = tabName;
if (origSwitchTab) {
origSwitchTab(tabName);
}
if (document.getElementById('auto-refresh-toggle').checked) {
setActivePolling();
}
loadActiveTab();
};
});
</script> </script>
{% endblock %} {% endblock %}
+1 -1
View File
@@ -18,7 +18,7 @@
<select id="rule-zone" name="zone" required> <select id="rule-zone" name="zone" required>
<option value="">— Select zone —</option> <option value="">— Select zone —</option>
{% for zone in (zones or []) %} {% for zone in (zones or []) %}
<option value="{{ zone.get('name', '') }}">{{ zone.get('name', '') }}</option> <option value="{{ zone }}">{{ zone }}</option>
{% endfor %} {% endfor %}
</select> </select>
</div> </div>