diff --git a/AGENTS.md b/AGENTS.md index 7debfbb..e7a2306 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,10 +9,13 @@ Deploys on Debian 13 (trixie). Serves from repo root by default. ``` Client ──→ nginx (SSL + basic auth) ──→ Flask (127.0.0.1:9090) -Flask ──→ daemon/client.py (Unix socket) ──→ vacuum-walld (aiohttp, daemon.sock) +Flask ──→ daemon/client.py (Unix socket) ──→ vacuum-walld (aiohttp, daemon.sock) vacuum-walld ──→ daemon/handlers/*.py ──→ sudo ──→ system service ``` +Blueprints are thin proxies — they never call `lib/` directly. All operations flow +through the daemon client over a Unix socket. + ### 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 socket. Primary group is the WebUI user's primary group. Daemon user name is derived: `USER_NAME` + `d`. @@ -37,7 +40,7 @@ vacuum-walld ──→ daemon/handlers/*.py ──→ sudo ──→ syste - `config//config.json` — Declarative JSON configs (source of truth). Generated `.conf` in `data/nginx/sites-enabled/`. Certs in `data/acme/`. - `system/` — System file templates. `systemd/` (units installed to `/etc/systemd/system/`), `sudoers.d/`, `nginx/`. -Project uses `.venv`. Install deps with `pip install -e .` (from `pyproject.toml`). `__init__.py` files in `webui/` and `lib/` are intentionally empty. +Project uses `.venv`. Install deps with `pip install -e .` (from `pyproject.toml`). `__init__.py` files in `webui/`, `lib/`, and `daemon/` are intentionally empty. ### Frontend (hoover) @@ -57,7 +60,7 @@ Conventions: - Unix socket at `data/daemon.sock` (configurable via `VACUUM_WALLD_SOCKET` env var) - WebSocket at `127.0.0.1:9091` (configurable via `VACUUM_WALLD_WS_PORT`) for real-time state change notifications -- Can also be started as `python -m daemon` or via the `vacuum-walld` console script +- Can be started as `python -m daemon.server` or via the `vacuum-walld` console script ## Environment Variables @@ -85,17 +88,17 @@ Reload running Flask via SIGHUP (auto-reloads `webui.*` and `lib.*` modules, the In production: systemd units run with `NoNewPrivileges`, `ProtectSystem=strict`, loopback-only networking. -## Blueprint ↔ lib Mapping +## Blueprint ↔ Handler ↔ lib Mapping -| Blueprint | URL prefix | Backend module | -|-----------------------|-------------------|------------------| -| `webui/api/firewall` | `/api/firewall/` | `lib.firewall` | -| `webui/api/dhcp` | `/api/dhcp/` | `lib.dnsmasq` | -| `webui/api/proxy` | `/api/proxy/` | `lib.nginx` | -| `webui/api/certs` | `/api/certs/` | `lib.acme` | -| `webui/api/wireguard` | `/api/wireguard/` | `lib.wireguard` | -| `webui/api/network` | `/api/network/` | `lib.network` | -| `webui/api/logs` | `/api/logs/` | `lib.logging` | +| Blueprint | URL Prefix | Handler Module | lib Module | +|-----------------------|---------------------|--------------------------|-----------------| +| `webui/api/firewall` | `/api/firewall/` | `daemon/handlers/firewall` | `lib.firewall` | +| `webui/api/dhcp` | `/api/dhcp/` | `daemon/handlers/dnsmasq` | `lib.dnsmasq` | +| `webui/api/proxy` | `/api/proxy/` | `daemon/handlers/nginx` | `lib.nginx` | +| `webui/api/certs` | `/api/certs/` | `daemon/handlers/acme` | `lib.acme` | +| `webui/api/wireguard` | `/api/wireguard/` | `daemon/handlers/wireguard`| `lib.wireguard` | +| `webui/api/network` | `/api/network/` | `daemon/handlers/network` | `lib.network` | +| `webui/api/logs` | `/api/logs/` | `daemon/handlers/logs` | — | ## Privileged Operations @@ -108,10 +111,10 @@ Adding a new privileged command requires a sudoers entry **and** the `daemon/han ## API Response Contract -- Success: `{"ok": true, "data": }` — helper `_ok(data)` from `webui.api.common` -- Error: `{"ok": false, "error": "msg"}` — helper `_error(msg, code=400)` from `webui.api.common` -- `acme.issue()` / `acme.renew()` raise `RuntimeError` on failure — API layer wraps in try/except -- HTTP codes: `400` bad request, `404` not found, `500` internal failure +- Success: `{"ok": true, "data": }` — helper `_ok(data)` from `webui.api.common` (Flask) or `ok(data)` from `daemon.server` (aiohttp). +- Error: `{"ok": false, "error": "msg"}` — helper `_error(msg, code=400)` from `webui.api.common` or `error(msg, code)` from `daemon.server`. +- `acme.issue()` / `acme.renew()` raise `RuntimeError` on failure — API layer wraps in try/except. +- HTTP codes: `400` bad request, `404` not found, `500` internal failure. ## Deploy @@ -121,7 +124,7 @@ Adding a new privileged command requires a sudoers entry **and** the `daemon/han **Linter / formatter:** Ruff (`ruff check` + `ruff format`). Config in `pyproject.toml` under `[tool.ruff]`. -**Tests:** pytest in `tests/`. Run with `python -m pytest`. Tests mock out subprocess calls (firewalld, acme.sh, WireGuard, nginx, dnsmasq) — no system services required. +**Tests:** pytest in `tests/`. Tests mock out subprocess calls — no system services required. ```bash .venv/bin/ruff check lib/ webui/ tests/ # lint @@ -142,6 +145,7 @@ Install dev tooling with `pip install -e ".[dev]"`. | `docs/deployment.md` | Install script options, what install.sh does, post-install setup, troubleshooting | | `docs/config.md` | JSON schema for each subsystem config (dnsmasq, nginx, wireguard, cert types) | | `docs/api.md` | REST API endpoint reference, request/response contracts, route patterns | +| `docs/hoover.md` | Custom frontend framework API reference | | `docs/overview.md` | Subsystem summaries, tech stack, complete project directory tree | ## Important Rules diff --git a/daemon/handlers/acme.py b/daemon/handlers/acme.py index ef2298e..d411458 100644 --- a/daemon/handlers/acme.py +++ b/daemon/handlers/acme.py @@ -1,10 +1,13 @@ """ACME certificate daemon handler.""" import asyncio +import ipaddress import logging import os +import shutil import socket import subprocess +import urllib.request from contextlib import suppress from dataclasses import dataclass, field from datetime import UTC, datetime @@ -12,13 +15,17 @@ from pathlib import Path from typing import Any from uuid import uuid4 +import lib.common as lib_common from daemon.iface import ( + DELETE_ACME_ACCOUNT_DEACTIVATE, DELETE_ACME_REMOVE, + GET_ACME_ACCOUNT, GET_ACME_EMAIL, GET_ACME_INFO, GET_ACME_ISSUE_STATUS, GET_ACME_LIST, GET_ACME_PATHS, + POST_ACME_ACCOUNT_REGISTER, POST_ACME_EMAIL, POST_ACME_ISSUE, POST_ACME_RENEW, @@ -168,54 +175,119 @@ def _check_domain_format(domain: str) -> tuple[bool, str]: return True, "" +def _get_local_ips() -> set[str]: + """Return the set of all non-loopback IPv4 addresses on this host.""" + import struct + from fcntl import ioctl + + ips: set[str] = set() + with suppress(OSError): + ips.add(socket.gethostbyname(socket.gethostname())) + try: + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + names = b"\x00" * 4096 + raw = ioctl(s.fileno(), 0x8912, names) + s.close() + for i in range(0, 4096, 32): + name = raw[i : i + 16].split(b"\x00")[0].decode() + if name == "lo": + continue + addr = struct.unpack(" str | None: + """Fetch the server's public IP address from external services. + + Returns None on error or timeout. Honours VACUUM_WALL_EXTERNAL_IP_URL + env var for testing or custom providers. + """ + urls: list[str] = [] + + custom_url = os.environ.get("VACUUM_WALL_EXTERNAL_IP_URL") + if custom_url: + urls.append(custom_url) + else: + urls.append("https://api.ipify.org") + urls.append("https://checkip.amazonaws.com") + + for url in urls: + try: + req = urllib.request.Request(url, headers={"User-Agent": "vacuum-wall/1.0"}) + with urllib.request.urlopen(req, timeout=timeout) as resp: + return resp.read().decode().strip() + except Exception: + continue + + return None + + +def _is_private_ip(ip_str: str) -> bool: + """Return True if the IP address is not globally routable.""" + try: + return not ipaddress.ip_address(ip_str).is_global + except ValueError: + return False + + def _check_dns_resolves(domain: str) -> tuple[bool, str]: - """Check that domain resolves to this machine's IP via A record.""" + """Check that domain resolves to this machine's IP (NAT-aware). + + 1. Match against local interface IPs — pass immediately. + 2. If no local match, compare against external IP for NAT scenarios. + 3. If ext IP lookup fails, downgrade to non-blocking warning. + 4. Private-range resolved IP always fails. + """ 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 + resolved_ips = [addr[4][0] for addr in results] + local_ips = _get_local_ips() - def get_interfaces(): - import struct + # Step 1: direct local match + for rip in resolved_ips: + if rip in local_ips: + return True, "DNS resolves correctly" - 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(" tuple[bool, str]: email = _get_acme_email() or "" if email: return True, f"Contact email configured: {email}" - return False, "No ACME contact email configured" + return ( + False, + "Contact email not set — configure in Account Settings for renewal notifications", + ) def _check_webroot() -> tuple[bool, str]: @@ -267,18 +342,275 @@ def _check_existing_cert(domain: str) -> tuple[bool, str]: return True, "" +def _check_nginx_running() -> tuple[bool, str]: + """Check whether the nginx process is currently running.""" + try: + result = lib_common.run_proc( + ["systemctl", "is-active", "nginx"], sudo=True, check=False, timeout=10 + ) + if result.stdout.strip() == "active": + return True, "nginx is running" + except (FileNotFoundError, subprocess.TimeoutExpired): + pass + + try: + pid_file = Path("/var/run/nginx.pid") + if pid_file.is_file(): + pid = int(pid_file.read_text().strip()) + proc_status = Path(f"/proc/{pid}/status") + if proc_status.is_file(): + return True, "nginx is running" + except (ValueError, OSError): + pass + + return False, "nginx is not running — start it before issuing certificates" + + +def _check_nginx_config() -> tuple[bool, str]: + """Test nginx configuration syntax via ``nginx -t``.""" + from lib.nginx import test_config + + ok, msg = test_config() + if ok: + return True, "nginx configuration is valid" + return False, f"nginx configuration test failed: {msg}" + + +def _check_firewall_port_80() -> tuple[bool, str]: + """Check that port 80/tcp is open in firewalld across all active zones.""" + try: + proc = lib_common.run_proc( + ["firewall-cmd", "--get-active-zones"], sudo=True, check=False, timeout=10 + ) + if proc.returncode != 0: + return True, "firewalld not detected, skipping port check" + + zone_lines = proc.stdout.strip() + if not zone_lines: + return True, "firewalld not detected, skipping port check" + + zones = _parse_active_zones(zone_lines) + port_open = False + + for zone in zones: + proc = lib_common.run_proc( + ["firewall-cmd", f"--zone={zone}", "--list-services"], + sudo=True, + check=False, + timeout=10, + ) + if "http" in (proc.stdout or "").split(): + port_open = True + break + + proc = lib_common.run_proc( + ["firewall-cmd", f"--zone={zone}", "--list-ports"], + sudo=True, + check=False, + timeout=10, + ) + for item in (proc.stdout or "").split(): + if "80" in item.split("/"): + port_open = True + break + + if port_open: + break + + if port_open: + return True, "Port 80 is open in firewall" + return ( + False, + "Port 80 blocked by firewall — allow with: firewall-cmd --add-service=http --permanent && firewall-cmd --reload", + ) + except Exception: + return True, "firewalld check unavailable, skipping" + + +def _parse_active_zones(output: str) -> list[str]: + """Parse ``firewall-cmd --get-active-zones`` output into zone names.""" + zones = [] + for line in output.splitlines(): + stripped = line.strip() + if stripped and not stripped.startswith(" "): + zones.append(stripped.removesuffix(" (default)")) + return zones + + +def _check_acme_home_writable() -> tuple[bool, str]: + """Verify data/acme/ is writable with a temporary file probe.""" + if not _ACME_HOME.is_dir(): + return False, "ACME home directory does not exist" + if not os.access(str(_ACME_HOME), os.W_OK): + return False, "ACME home directory is not writable" + try: + probe = _ACME_HOME / ".write-probe" + probe.write_text("ok") + probe.unlink() + return True, "ACME home directory is writable" + except OSError: + return False, "ACME home directory is not writable" + + +def _check_openssl_available() -> tuple[bool, str]: + """Verify openssl binary is available and functional.""" + openssl_path = shutil.which("openssl") + if not openssl_path: + return False, "openssl binary not found" + try: + result = subprocess.run( + ["openssl", "version"], + capture_output=True, + text=True, + timeout=10, + ) + if result.returncode == 0: + ver = result.stdout.strip() + return True, f"openssl available ({ver})" + except subprocess.TimeoutExpired: + pass + return False, "openssl is not working" + + +def _check_port_80_listening() -> tuple[bool, str]: + """Check that something is listening on port 80 (IPv4 or IPv6).""" + # Check localhost first + for af, host in [(socket.AF_INET, "127.0.0.1"), (socket.AF_INET6, "::1")]: + with suppress(OSError), socket.socket(af, socket.SOCK_STREAM) as s: + s.settimeout(2) + if s.connect_ex((host, 80)) == 0: + return True, "Port 80 is listening" + # Also check all interface IPs in case nginx only binds on a public interface + for ip in _get_local_ips(): + with suppress(OSError), socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.settimeout(2) + if s.connect_ex((ip, 80)) == 0: + return True, "Port 80 is listening" + return False, "Nothing listening on port 80 — needed for ACME HTTP-01 challenge" + + +def _check_acme_account() -> tuple[bool, str]: + """Non-blocking: check acme.sh account is configured.""" + try: + acme_bin = _find_acme_bin() + acme_home_env = os.environ.get("ACME_HOME", str(_ACME_HOME)) + result = subprocess.run( + [ + acme_bin, + "--home", + acme_home_env, + "--config-home", + acme_home_env, + "--info", + ], + capture_output=True, + text=True, + timeout=30, + env={**os.environ, **_ACME_ENVIRON}, + ) + if result.returncode == 0: + return True, "ACME account is configured" + except (FileNotFoundError, subprocess.TimeoutExpired): + pass + + try: + account_conf = _ACME_HOME / ".account.conf" + if account_conf.is_file(): + text = account_conf.read_text() + if "ACME_LEEMAIL" in text and "ACME_MCA" in text: + return True, "ACME account is configured" + except OSError: + pass + + return ( + False, + "ACME account may need re-registration — check email is set before issuance", + ) + + +def _check_account_registered() -> tuple[bool, str]: + """Blocking check: verify an ACME account is registered. + + Reads the user-facing .account.conf (with leading dot) which stores + the registered account's ACME_LEEMAIL and ACME_MCA keys. + """ + account_conf = _ACME_HOME / ".account.conf" + if not account_conf.is_file(): + return False, "Register an ACME account before issuing certificates" + try: + text = account_conf.read_text() + except OSError: + return False, "Register an ACME account before issuing certificates" + if "ACME_LEEMAIL" in text and "ACME_MCA" in text: + return True, "ACME account is registered" + return False, "Register an ACME account before issuing certificates" + + +def _get_account_info() -> dict[str, Any]: + """Read and return the ACME account info dict. + + Delegates to ``lib.state._parse_account_conf()`` for a single + source of truth. + """ + from lib.state import _parse_account_conf + + return _parse_account_conf(_ACME_HOME) + + +def _check_dns_public(domain: str) -> tuple[bool, str]: + """Non-blocking: verify public DNS resolves domain to this server.""" + local_ips = _get_local_ips() + + if not local_ips: + return True, "Public DNS check skipped (no local IPs detected)" + + for dns_server in ("8.8.8.8", "1.1.1.1"): + try: + result = subprocess.run( + ["host", domain, dns_server], + capture_output=True, + text=True, + timeout=10, + ) + output = result.stdout or "" + for ip in local_ips: + for line in output.splitlines(): + if ip in line.split(): + return True, "Public DNS resolves correctly" + except (FileNotFoundError, subprocess.TimeoutExpired): + continue + + return ( + False, + "Public DNS may not resolve to this server — allow a few minutes for propagation", + ) + + 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 = [ + # Environment — must be present before anything else ("acme_installed", _check_acme_installed, True), - ("email_configured", _check_email_configured, True), + ("openssl_available", _check_openssl_available, True), + ("acme_home_writable", _check_acme_home_writable, True), + ("account_registered", _check_account_registered, True), + ("email_configured", _check_email_configured, False), + ("acme_account_valid", _check_acme_account, False), ("webroot_ready", _check_webroot, True), + # Nginx stack — must serve challenges + ("nginx_running", _check_nginx_running, True), + ("nginx_config_valid", _check_nginx_config, True), ("challenge_configured", _check_challenge_config, True), + ("port_80_listening", _check_port_80_listening, True), + ("firewall_open", _check_firewall_port_80, True), + # Domain — must be reachable ("domain_format", lambda: _check_domain_format(domain), True), ("dns_resolves", lambda: _check_dns_resolves(domain), True), + ("dns_public", lambda: _check_dns_public(domain), False), + # Existing cert — informational ("existing_cert", lambda: _check_existing_cert(domain), False), ] @@ -299,7 +631,8 @@ def _validate(domain: str) -> dict[str, Any]: "blocking": blocking, } ) - ready = False + if blocking: + ready = False return {"domain": domain, "checks": checks, "ready": ready} @@ -360,10 +693,16 @@ async def issue_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, An """ if not body: raise ValueError("Request body required") - domain = body.get("domain", "").strip() + domain = (body.get("domain") or "").strip() if not domain: raise ValueError("'domain' is required") - email = body.get("email", "").strip() or None + # email is kept for backward API compatibility but ignored — + # _run_issue() uses the registered account's email instead + provided_email = (body.get("email") or "").strip() + if provided_email: + logger.warning( + "email field in issue/start is ignored, using registered account's email" + ) webroot = body.get("webroot") _clean_expired_issuances() @@ -399,7 +738,6 @@ async def issue_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, An req = IssueRequest( request_id=request_id, domain=domain, - email=email, webroot=webroot, steps=steps, ) @@ -437,11 +775,9 @@ async def _run_issue(req: IssueRequest) -> None: 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: - args.extend(["-m", contact]) + account_email = _get_acme_email() + if account_email: + args.extend(["-m", account_email]) args.append("--force") output = _run_acme(args) req.steps[0].status = "done" @@ -647,3 +983,71 @@ def generate_self_signed(_request: Any, body: dict[str, Any] | None) -> dict[str "key": str(key_file), "generated": True, } + + +@registry.register(GET_ACME_ACCOUNT) +def get_account(_request: Any, _body: Any) -> dict[str, Any]: + """GET /acme/account — return ACME account information.""" + return _get_account_info() + + +@registry.register(POST_ACME_ACCOUNT_REGISTER) +def register_account(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: + """POST /acme/account/register — register a new ACME account. + + Raises: + ValueError: When email is missing or invalid. + """ + if not body: + raise ValueError("Request body required") + email = (body.get("email") or "").strip() + if not email: + raise ValueError("'email' is required") + import re as _re + + if not _re.match(r"^[^@\s]+@[^@\s]+\.[^@\s]+$", email): + raise ValueError("Invalid email format") + server = (body.get("server") or "letsencrypt").strip() + + _run_acme(["--register-account", "-m", email, "--server", server]) + + acme_cfg = PROJECT_DIR / "config" / "acme" / "config.json" + acme_cfg.parent.mkdir(parents=True, exist_ok=True) + from lib.common import load_json, save_json + + acme_data = load_json(acme_cfg) + acme_data["email"] = email + acme_data["ca"] = server + save_json(acme_cfg, acme_data) + + logger.info("ACME account registered: %s (%s)", email, server) + refresh_state(["acme"]) + return {"registered": True, "email": email, "ca": server} + + +@registry.register(DELETE_ACME_ACCOUNT_DEACTIVATE) +def deactivate_account(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: + """DELETE /acme/account/deactivate — deactivate the ACME account.""" + try: + _run_acme(["--deactivate-account"]) + except RuntimeError as exc: + logger.warning("acme.sh deactivate failed: %s", exc) + acme_cfg = PROJECT_DIR / "config" / "acme" / "config.json" + if acme_cfg.is_file(): + from lib.common import load_json, save_json + + acme_data = load_json(acme_cfg) + acme_data.pop("email", None) + acme_data.pop("ca", None) + save_json(acme_cfg, acme_data) + + account_conf = _ACME_HOME / ".account.conf" + if account_conf.is_file(): + account_conf.unlink() + account_conf_no_dot = _ACME_HOME / "account.conf" + if account_conf_no_dot.is_file(): + account_conf_no_dot.unlink() + + logger.info("ACME account deactivated") + refresh_state(["acme"]) + return {"email": ""} diff --git a/daemon/iface.py b/daemon/iface.py index 7880c0d..df76638 100644 --- a/daemon/iface.py +++ b/daemon/iface.py @@ -99,6 +99,9 @@ POST_ACME_EMAIL: Endpoint = _ep("POST", "/acme/email") GET_ACME_EMAIL: Endpoint = _ep("GET", "/acme/email") GET_ACME_PATHS: Endpoint = _ep("GET", "/acme/paths") POST_ACME_SELF_SIGNED: Endpoint = _ep("POST", "/acme/self-signed") +GET_ACME_ACCOUNT: Endpoint = _ep("GET", "/acme/account") +POST_ACME_ACCOUNT_REGISTER: Endpoint = _ep("POST", "/acme/account/register") +DELETE_ACME_ACCOUNT_DEACTIVATE: Endpoint = _ep("DELETE", "/acme/account/deactivate") # ---- Dnsmasq / DHCP ---- GET_DNSMASQ_CONFIG: Endpoint = _ep("GET", "/dnsmasq/config") diff --git a/docs/api.md b/docs/api.md index 67e7bda..264f4de 100644 --- a/docs/api.md +++ b/docs/api.md @@ -905,19 +905,19 @@ Returns HTTP `404` if no certificate is found for the domain. POST /api/certs/issue ``` -Request a new certificate for a domain. +Request a new certificate for a domain. Returns HTTP `500` if issuance fails. **Request Body:** | Field | Type | Required | Description | |-------|------|----------|-------------| | `domain` | `string` | Yes | Domain to issue the certificate for | -| `email` | `string` | No | ACME contact email | +| `email` | `string` | No | ACME contact email — **deprecated**, ignored in favor of the registered account email | | `webroot` | `string` | No | Custom webroot path for HTTP-01 validation | **Response:** `data` is `null` on success. -Returns HTTP `400` if the domain is missing. Returns HTTP `500` if issuance fails. +Returns HTTP `400` if the domain is missing. An ACME account must be registered before issuance (verified by the `account_registered` blocking check in the validation pipeline). --- @@ -949,6 +949,71 @@ Returns HTTP `404` if the certificate is not found. ### Account +#### Get ACME Account Status + +``` +GET /api/certs/account +``` + +Return the ACME account registration status. + +**Response (`data`):** + +| Field | Type | Description | +|-------|------|-------------| +| `registered` | `boolean` | Whether an ACME account is registered | +| `email` | `string` | Registered contact email (empty if unregistered) | +| `ca` | `string` | CA provider (e.g., `"let's encrypt"`, `"ZeroSSL"`) (empty if unregistered) | + +Returns HTTP `500` if the account status cannot be determined. + +--- + +#### Register ACME Account + +``` +POST /api/certs/account/register +``` + +Register a new ACME account with the specified email and CA provider. + +**Request Body:** + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `email` | `string` | Yes | Contact email address | +| `server` | `string` | No | CA provider: `"letsencrypt"` or `"zerossl"`. Default: `"letsencrypt"` | + +**Response (`data`):** + +| Field | Type | Description | +|-------|------|-------------| +| `registered` | `boolean` | Always `true` on success | +| `email` | `string` | Registered contact email | +| `ca` | `string` | CA provider | + +Returns HTTP `400` if the email is missing or invalid. Returns HTTP `500` if registration fails. An ACME account must be registered before certificates can be issued. + +--- + +#### Deactivate ACME Account + +``` +DELETE /api/certs/account +``` + +Deactivate the ACME account via `acme.sh --deactivate-account`. Clears the `email` and `ca` fields from `config/acme/config.json`. + +**Response (`data`):** + +| Field | Type | Description | +|-------|------|-------------| +| `email` | `string` | Empty string indicating the account was deactivated | + +Returns HTTP `500` if deactivation fails. + +--- + #### Set ACME Contact Email ``` diff --git a/docs/config.md b/docs/config.md index ef2d248..2ccefe7 100644 --- a/docs/config.md +++ b/docs/config.md @@ -167,6 +167,96 @@ The `ssl` block defines TLS parameters applied to all HTTPS server blocks via th | `ciphers` | string | No | nginx `ssl_ciphers` directive value. Default is a curated AEAD-only cipher string. | | `prefer_server_ciphers` | boolean | No | Whether to prefer server cipher order. Default: `false`. | +## ACME (Certificate) Configuration + +**File**: `config/acme/config.json` + +This file stores the ACME account settings used by acme.sh for certificate provisioning. Account registration, modification, and deactivation are performed through the WebUI at the Certificates page — not by editing this file directly. + +```json +{ + "email": "admin@example.com", + "ca": "letsencrypt" +} +``` + +### ACME Fields + +| Field | Type | Required | Description | +|---|---|---|---| +| `email` | string | No | Contact email for the ACME account. Used for certificate expiry notifications and recovery. Populated automatically when an account is registered via the WebUI. Default: `""`. | +| `ca` | string | No | ACME CA provider. One of: `"letsencrypt"` (Let's Encrypt), `"zerossl"` (ZeroSSL). Populated automatically when an account is registered. Default: `""`. | + +### Account Registration + +ACME account registration is handled entirely through the WebUI. When the user registers an account: + +1. The user navigates to the Certificates page and clicks "Register Account". +2. Provides an email address and selects a CA provider (Let's Encrypt or ZeroSSL). +3. The backend calls `acme.sh --register-account` with the provided parameters. +4. On success, the `email` and `ca` fields in `config/acme/config.json` are populated, and acme.sh writes its `.account.conf` file under `data/acme/`. + +Before any certificate can be issued, an ACME account must be registered. The certificate validation flow includes a blocking check (`account_registered`) that prevents issuance if no account exists. + +### Account Management + +After registration, the account can be managed from the WebUI: + +- **Update email**: The Settings modal allows changing the contact email, which triggers an update via `acme.sh --register-account -u`. +- **Deactivate account**: The Settings modal includes a button to deactivate the account via `acme.sh --deactivate-account`, which clears the `email` and `ca` fields and removes the ACME account. + +### ACME Home Directory + +acme.sh stores its state under `data/acme/` (the ACME home directory). Key files: + +- `.account.conf` — ACME account credentials and settings (contains `ACME_LEEMAIL`, `ACME_MCA`). +- `/` — Per-domain certificate and key files issued by acme.sh. + +The application reads `.account.conf` to determine registration status. If the file is missing or lacks required keys, the account is considered unregistered. + +## ACME Configuration + +**File**: `config/acme/config.json` + +This file stores the ACME account settings used by vacuum-wall for automatic certificate issuance via acme.sh. Account registration is performed exclusively through the WebUI — the Certificates page provides a "Register Account" modal where the user enters an email and selects a CA provider. + +```json +{ + "email": "", + "ca": "" +} +``` + +### ACME Config Fields + +| Field | Type | Required | Description | +|---|---|---|---| +| `email` | string | No (WebUI) | Contact email for the ACME account, used for certificate expiry notifications and recovery. Populated when the user registers an account via the WebUI. Default: `""`. | +| `ca` | string | No (defaults to `letsencrypt`) | ACME CA provider. One of: `"letsencrypt"`, `"zerossl"`. Populated during account registration. Default: `""` (acme.sh defaults to Let's Encrypt if omitted). | + +### Account Registration Flow + +1. User navigates to the Certificates page in the WebUI. +2. Clicks "Register Account" and provides an email address, optionally selecting a CA provider. +3. The application calls `acme.sh --register-account -m --server ` as a privileged operation via the daemon. +4. On success, `config/acme/config.json` is updated with the email and CA. acme.sh writes its own state to `data/acme/.account.conf`. +5. The `account_registered` check in the certificate validation pipeline transitions from blocking to passing, enabling certificate issuance. + +The `account_registered` check is **blocking** — certificate issuance and validation will fail until an ACME account is registered. The `email_configured` check is **non-blocking** — it produces a warning if the email is empty but does not prevent issuance. + +### Account Management API + +| Endpoint | Method | Description | +|---|---|---| +| `/api/certs/account` | `GET` | Returns account status: registered, email, CA provider. | +| `/api/certs/account/register` | `POST` | Registers a new ACME account. Body: `{ "email": "..." }`. Optional: `{ "server": "letsencrypt" }`. | +| `/api/certs/account` | `DELETE` | Deactivates the ACME account via `acme.sh --deactivate-account`. Clears email and CA from config. | +| `/api/certs/email` | `POST` | Updates the contact email on an existing account. Body: `{ "email": "..." }`. | + +### ACME Home Directory + +acme.sh stores its operational state under `data/acme/`. The application reads `data/acme/.account.conf` to determine whether an account is registered. Required keys: `ACME_LEEMAIL` and `ACME_MCA`. Their absence or the file's absence means the account is unregistered. + ## WireGuard Configuration **File**: `config/wireguard/config.json` diff --git a/docs/deployment.md b/docs/deployment.md index ff9b39f..d1d0921 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -25,14 +25,13 @@ Download the Vacuum Wall repository onto the target machine, then run the instal # Production: all env vars MGMT_DOMAIN=wall.example.com \ MGMT_PASS="strongpassword" \ -ACME_EMAIL="admin@example.com" \ ./install.sh --user vacuum-wall # 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 # mDNS (LAN-only, no DNS record needed) -./install.sh --mgmt-domain vacuum-wall.local --mgmt-pass strongpass --acme-email "me@example.com" +./install.sh --mgmt-domain vacuum-wall.local --mgmt-pass strongpass ``` ### Options @@ -45,7 +44,6 @@ All settings that can be passed as an environment variable also have a CLI flag | `--mgmt-domain` | `MGMT_DOMAIN` | No | (same as above) | | `--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`. | -| `--acme-email` | `ACME_EMAIL` | Yes | Email for ACME provider (ZeroSSL by default). | | `--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`). | | `--dev` | -- | No | Development mode: auto-detects repo owner as service user, skips safety warning. | @@ -73,7 +71,7 @@ In dev mode, the ownership model preserves the developer's ability to work with ### Running the Installer in Dev Mode ```bash -./install.sh --dev --mgmt-pass strongpassword --acme-email "dev@example.com" +./install.sh --dev --mgmt-pass strongpassword ``` 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. @@ -91,8 +89,7 @@ You can deploy Vacuum Wall in a container or at any custom path. Use `--path` (o ```bash # Docker volume mount example ./install.sh --path /app/vacuum-wall --user ww-app \ - --mgmt-domain proxy.internal --mgmt-pass strongpassword \ - --acme-email "admin@example.com" + --mgmt-domain proxy.internal --mgmt-pass strongpassword ``` The systemd service unit files and sudoers whitelist are rendered from Jinja2 templates at install time, substituting `USER_NAME` and `INSTALL_DIR`. This means no hardcoded paths remain after installation. @@ -130,7 +127,7 @@ The installer performs the following steps automatically: - `internal` — trusted LAN zone with DHCP, DNS, and NTP services allowed. - `vpn` — WireGuard tunnel zone. - **Service startup**: Enables and starts/restarts nginx, the daemon (`vacuum-walld`), the WebUI (`vacuum-wall`), and the ACME renewal timer. nginx is reloaded (or restarted) to pick up any config changes. -- **ACME registration**: Registers the ACME account with the provided email via acme.sh. +- **ACME account**: No account registration during install. Register the account via the WebUI after first login. ### Idempotent Re-Runs @@ -308,7 +305,7 @@ ACME validation via the ACME provider requires: - The domain's DNS A record points to the appliance's public IP. - Port 80 (HTTP-01 challenge) is accessible from the internet on the external interface. -- The ACME email was registered correctly. Check with: +- An ACME account is registered (check the Certs page in the WebUI). Verify with: ```bash su -s /bin/bash "$USER_DAEMON_NAME" -c "~/data/acme/acme.sh --list" diff --git a/docs/overview.md b/docs/overview.md index a74b640..e9da28d 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -20,7 +20,7 @@ dnsmasq serves as both the DHCP server and local DNS resolver. It is configured ### SSL Proxy -The nginx reverse proxy handles HTTPS termination for user-defined domains, with certificates automatically provisioned and renewed via acme.sh and an ACME provider (ZeroSSL by default). Each proxy domain is configured with an HTTP-to-HTTPS redirect, modern TLS settings, and a configurable backend target. New proxy domains are added through the web UI, and the configuration is applied without manual intervention. +The nginx reverse proxy handles HTTPS termination for user-defined domains, with certificates automatically provisioned and renewed via acme.sh and an ACME provider (Let's Encrypt by default). Each proxy domain is configured with an HTTP-to-HTTPS redirect, modern TLS settings, and a configurable backend target. New proxy domains are added through the web UI, and the configuration is applied without manual intervention. ### Network (systemd-networkd) @@ -39,7 +39,7 @@ WireGuard support provides server-side VPN tunnel management. Peers are added th - nginx 1.26+ - dnsmasq - WireGuard tools (wireguard-tools) -- acme.sh for ACME certificate management (ZeroSSL by default) +- acme.sh for ACME certificate management (Let's Encrypt by default) ## Quick Start @@ -47,10 +47,10 @@ To install Vacuum Wall on a Debian 13 system, run `install.sh` as root with requ ```bash # Production -./install.sh --mgmt-pass yourpassword --acme-email "admin@example.com" +./install.sh --mgmt-pass yourpassword # Development (auto-detects your user) -./install.sh --dev --mgmt-pass yourpassword --acme-email "admin@example.com" +./install.sh --dev --mgmt-pass yourpassword ``` After installation, access the management interface at `https://.local` using the credentials you configured. The `install.sh` script auto-detects the system hostname, network interfaces, and provisions nginx, authentication, an initial self-signed certificate, and all services. Run `./install.sh --help` for all options. diff --git a/install.sh b/install.sh index 2870c2f..5955f9b 100755 --- a/install.sh +++ b/install.sh @@ -19,7 +19,6 @@ _cli_path="" _cli_mgmt_pass="" _cli_mgmt_user="" _cli_mgmt_domain="" -_cli_acme_email="" _cli_force_venv=false _cli_wan_iface="" _cli_lan_ifaces="" @@ -31,7 +30,6 @@ while [[ $# -gt 0 ]]; do --mgmt-pass) _cli_mgmt_pass="$2"; shift 2 ;; --mgmt-user) _cli_mgmt_user="$2"; shift 2 ;; --mgmt-domain) _cli_mgmt_domain="$2"; shift 2 ;; - --acme-email) _cli_acme_email="$2"; shift 2 ;; --force-venv) _cli_force_venv=true; shift ;; --wan-iface) _cli_wan_iface="$2"; shift 2 ;; --lan-ifaces) _cli_lan_ifaces="$2"; shift 2 ;; @@ -46,8 +44,7 @@ while [[ $# -gt 0 ]]; do " --mgmt-pass PASS WebUI basic auth password (required)" \ " --mgmt-user USER WebUI basic auth username (default: admin)" \ " --mgmt-domain DOMAIN Management domain (auto-detected)" \ - " --acme-email EMAIL ACME contact email (optional, deprecated — use WebUI)" \ - " --wan-iface IFACE WAN interface name (auto-detected)" \ + " --wan-iface IFACE WAN interface name (auto-detected)" \ " --lan-ifaces IFC,... LAN interface names, comma-separated (auto-detected)" \ " -h, --help Show this help" \ "" \ @@ -74,9 +71,6 @@ REPO_DIR="$(cd "$(dirname "$0")" && pwd)" # Required settings (no defaults — must be provided) MGMT_PASS="${_cli_mgmt_pass:-${MGMT_PASS:-}}" -# ACME_EMAIL is optional — will be configured from the WebUI -ACME_EMAIL="${_cli_acme_email:-${ACME_EMAIL:-}}" - # Optional settings with defaults MGMT_USER="${_cli_mgmt_user:-${MGMT_USER:-admin}}" @@ -364,20 +358,18 @@ else MGMT_DOMAIN="$DOMAIN" \ MGMT_USER="$MGMT_USER" \ MGMT_PASS="$MGMT_PASS" \ - ACME_EMAIL="$ACME_EMAIL" \ "${PROJECT_DIR}/.venv/bin/python3" -c " import daemon.client as c from daemon.iface import ( POST_ACME_SELF_SIGNED, POST_NGINX_MANAGEMENT, POST_NGINX_APPLY, POST_FIREWALL_CONFIG, POST_FIREWALL_CONFIG_APPLY, POST_NETWORK_SYSCTL_SET, - POST_ACME_EMAIL, GET_NETWORK_INFER_DHCP_RANGES, + GET_NETWORK_INFER_DHCP_RANGES, ) import sys domain = '${DOMAIN}' mgmt_user = '${MGMT_USER}' mgmt_pass = '${MGMT_PASS}' -acme_email = '${ACME_EMAIL}' wan_iface = '${WAN_IFACE}' lan_ifaces = '${LAN_IFACES}' @@ -444,14 +436,6 @@ try: except Exception as e: print(f' [network] Warning: {e}', file=sys.stderr) -# ACME email (optional) -if acme_email: - try: - c.post(POST_ACME_EMAIL, {'email': acme_email}) - print(f' [acme] Email set to {acme_email}') - except Exception as e: - print(f' [acme] Warning: {e}', file=sys.stderr) - # Infer DHCP ranges (logged for user reference) try: ranges = c.get(GET_NETWORK_INFER_DHCP_RANGES) @@ -494,7 +478,7 @@ else fi echo "" echo " Next steps:" -echo " 1. Set ACME contact email at https://$DOMAIN/certs/settings" +echo " 1. Register your ACME account at https://$DOMAIN/certs" echo " 2. Verify zone assignments at https://$DOMAIN/interfaces" echo " 3. Configure DHCP ranges for your LAN" echo " 4. Add proxy domains with ACME certificates" diff --git a/lib/acme.py b/lib/acme.py index abc880c..1851509 100644 --- a/lib/acme.py +++ b/lib/acme.py @@ -147,14 +147,15 @@ def _read_acme_email() -> str: """Read ACME email from account.conf, falling back to declarative config.""" try: acme_home = Path(os.environ.get("ACME_HOME", str(_ACME_HOME))) - account_conf = acme_home / "account.conf" - if account_conf.is_file(): - text = account_conf.read_text() - match = re.search(r"^ACME_LEEMAIL=(.+)$", text, re.MULTILINE) - if match: - return match.group(1).strip().strip("'\"") + for conf_name in (".account.conf", "account.conf"): + account_conf = acme_home / conf_name + 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 as exc: - logger.warning("Could not read account.conf: %s", exc) + logger.warning("Could not read account config: %s", exc) # Fallback: read from declarative ACME config try: from lib.common import load_json diff --git a/lib/state.py b/lib/state.py index 64297c6..038d176 100644 --- a/lib/state.py +++ b/lib/state.py @@ -586,6 +586,68 @@ def _parse_acme_list_output(raw: str) -> list[dict]: return entries +def _parse_account_conf(acme_home: Path | None = None) -> dict[str, Any]: + """Parse acme.sh .account.conf and return account status dict. + + Args: + acme_home: Optional override for ACME home directory. Falls back + to ``ACME_HOME`` env var or ``PROJECT_DIR/data/acme``. + + Returns: + Dict with ``registered``, ``email``, ``ca``, and + ``key_length`` keys. If the file is missing or keys are absent, + ``registered`` is ``False`` with empty / ``None`` values. + """ + if acme_home is None: + acme_home_env = os.environ.get("ACME_HOME", str(PROJECT_DIR / "data" / "acme")) + acme_home = Path(acme_home_env) + account_path = acme_home / ".account.conf" + + default = { + "registered": False, + "email": "", + "ca": "", + "key_length": None, + } + + if not account_path.is_file(): + return default + + try: + text = account_path.read_text() + except OSError: + return default + + email = "" + ca_raw = "" + key_length = None + + for line in text.splitlines(): + if line.startswith("ACME_LEEMAIL="): + email = line.split("=", 1)[1].strip().strip("'\"") + elif line.startswith("ACME_MCA="): + ca_raw = line.split("=", 1)[1].strip().strip("'\"") + elif line.startswith("ACME_CERTKEYSIZE="): + raw_val = line.split("=", 1)[1].strip().strip("'\"") + key_length = int(raw_val) if raw_val.isdigit() else None + + if not email or not ca_raw: + return default + + ca_map = { + "letsencrypt": "Let's Encrypt", + "zerossl": "ZeroSSL", + } + ca = ca_map.get(ca_raw, ca_raw) + + return { + "registered": True, + "email": email, + "ca": ca, + "key_length": key_length, + } + + def _has_auto_renew(domain: str) -> bool: """Check whether *domain* has an auto-renew configuration file. @@ -653,9 +715,12 @@ def _collect_acme() -> dict[str, Any]: except Exception: pass + account = _parse_account_conf() + return { "certs": certs, "email": email, + "account": account, "timestamp": _now_iso(), } diff --git a/restart-services.sh b/restart-services.sh index 91fcba1..eac5494 100755 --- a/restart-services.sh +++ b/restart-services.sh @@ -1,9 +1,12 @@ #!/bin/bash +echo "systemctl restart nginx" systemctl restart nginx sleep 1 +echo "systemctl restart vacuum-walld" systemctl restart vacuum-walld sleep 1 +echo "systemctl restart vacuum-wall" systemctl restart vacuum-wall # Verify services are running diff --git a/system/sudoers.d/vacuum-walld b/system/sudoers.d/vacuum-walld index b1400c7..10b8a11 100644 --- a/system/sudoers.d/vacuum-walld +++ b/system/sudoers.d/vacuum-walld @@ -6,8 +6,9 @@ Defaults:{{ USER_DAEMON_NAME }} secure_path="/usr/local/sbin:/usr/local/bin:/usr {{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/firewall-cmd * # Nginx management -{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/sbin/nginx -s reload {{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/sbin/nginx -t +{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/sbin/nginx -s reload +{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/systemctl is-active nginx {{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/cp * /etc/nginx/* {{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/cp * /etc/nginx/conf.d/* {{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/cp * /etc/nginx/snippets/* diff --git a/system/systemd/vacuum-walld.service b/system/systemd/vacuum-walld.service index bef7d42..4758f5b 100644 --- a/system/systemd/vacuum-walld.service +++ b/system/systemd/vacuum-walld.service @@ -19,7 +19,7 @@ Environment=HOME={{ PROJECT_DIR }} # Security hardening ProtectSystem=strict -ReadWritePaths={{ PROJECT_DIR }} /tmp /etc/systemd/network /etc/nginx /etc/dnsmasq.d /etc/wireguard /run/sudo /run/firewalld +ReadWritePaths={{ PROJECT_DIR }} /tmp /etc/systemd/network /etc/nginx /etc/dnsmasq.d /etc/wireguard /run/sudo /run/firewalld /run/nginx PrivateTmp=yes ProtectKernelTunables=yes ProtectKernelModules=yes diff --git a/tests/test_handler_acme.py b/tests/test_handler_acme.py index cd5ce08..6a56851 100644 --- a/tests/test_handler_acme.py +++ b/tests/test_handler_acme.py @@ -1,11 +1,31 @@ """Tests for daemon/handlers/acme.py — handler endpoint logic.""" +import urllib.error from pathlib import Path -from unittest.mock import patch +from unittest.mock import MagicMock, patch import pytest -from daemon.handlers.acme import generate_self_signed +from daemon.handlers.acme import ( + _check_account_registered, + _check_acme_account, + _check_acme_home_writable, + _check_dns_public, + _check_dns_resolves, + _check_firewall_port_80, + _check_nginx_config, + _check_nginx_running, + _check_openssl_available, + _check_port_80_listening, + _get_account_info, + _get_external_ip, + _is_private_ip, + _validate, + deactivate_account, + generate_self_signed, + get_account, + register_account, +) class TestGenerateSelfSigned: @@ -82,3 +102,989 @@ class TestGenerateSelfSigned: def test_generate_requires_body(self): with pytest.raises(ValueError, match="body"): generate_self_signed(None, None) + + +class TestCheckNginxRunning: + def test_via_systemctl_active(self): + from unittest.mock import patch + + mock_result = MagicMock(returncode=0, stdout="active\n") + with patch("subprocess.run", return_value=mock_result): + passed, msg = _check_nginx_running() + assert passed is True + assert "running" in msg + + def test_via_systemctl_inactive(self, tmp_path): + from unittest.mock import patch + + mock_result = MagicMock(returncode=3, stdout="inactive\n") + with ( + patch("subprocess.run", return_value=mock_result), + patch.object(Path, "is_file", return_value=False), + ): + passed, msg = _check_nginx_running() + assert passed is False + + def test_via_pid_file(self): + from unittest.mock import patch + + def run_side_effect(cmd, **kwargs): + raise FileNotFoundError() + + pid_file = Path("/var/run/nginx.pid") + with patch("subprocess.run", side_effect=run_side_effect): + with patch.object(Path, "is_file") as mock_is_file: + with patch.object(Path, "read_text", return_value="1234\n"): + + def fake_is_file(self): + if self == Path("/var/run/nginx.pid"): + return True + if str(self) == "/proc/1234/status": + return True + return Path(self).is_file() + + with patch.object(Path, "is_file", fake_is_file): + passed, msg = _check_nginx_running() + assert passed is True + + +class TestCheckNginxConfig: + def test_valid_config(self): + with patch("lib.nginx.test_config", return_value=(True, "test passed")): + passed, msg = _check_nginx_config() + assert passed is True + + def test_invalid_config(self): + with patch("lib.nginx.test_config", return_value=(False, "test failed: blah")): + passed, msg = _check_nginx_config() + assert passed is False + assert "blah" in msg + + +class TestCheckFirewallPort80: + def test_port_open_via_services(self): + from unittest.mock import patch + + def proc_side_effect(cmd, **kwargs): + if "--get-active-zones" in cmd: + return MagicMock(returncode=0, stdout="public\n eth0\n") + if "--list-services" in cmd: + return MagicMock(returncode=0, stdout="http https dns ssh\n") + return MagicMock(returncode=1, stdout="") + + with ( + patch("lib.common.run_proc", side_effect=proc_side_effect), + ): + passed, msg = _check_firewall_port_80() + assert passed is True + + def test_blocked_by_firewall(self): + from unittest.mock import patch + + def proc_side_effect(cmd, **kwargs): + if "--get-active-zones" in cmd: + return MagicMock(returncode=0, stdout="public\n eth0\n") + if "--list-services" in cmd: + return MagicMock(returncode=0, stdout="https dns ssh\n") + if "--list-ports" in cmd: + return MagicMock(returncode=0, stdout="443/tcp\n") + return MagicMock(returncode=1, stdout="") + + with ( + patch("lib.common.run_proc", side_effect=proc_side_effect), + ): + passed, msg = _check_firewall_port_80() + assert passed is False + assert "80" in msg + + def test_firewalld_not_detected(self): + from unittest.mock import patch + + with patch( + "lib.common.run_proc", return_value=MagicMock(returncode=1, stdout="") + ): + passed, msg = _check_firewall_port_80() + assert passed is True + assert "skipping" in msg + + +class TestCheckAcmeHomeWritable: + def test_writable(self, tmp_path): + acme_dir = tmp_path / "acme" + acme_dir.mkdir() + with patch("daemon.handlers.acme._ACME_HOME", acme_dir): + passed, msg = _check_acme_home_writable() + assert passed is True + + +class TestCheckAcmeHomeWritable_Missing: + def test_missing_dir(self, tmp_path): + acme_dir = tmp_path / "acme" + with patch("daemon.handlers.acme._ACME_HOME", acme_dir): + passed, msg = _check_acme_home_writable() + assert passed is False + assert "does not exist" in msg + + +class TestCheckAcmeHomeWritable_Permissions: + def test_not_writable(self, tmp_path): + acme_dir = tmp_path / "acme" + acme_dir.mkdir() + acme_dir.chmod(0o444) + try: + with patch("daemon.handlers.acme._ACME_HOME", acme_dir): + passed, msg = _check_acme_home_writable() + assert passed is False + finally: + acme_dir.chmod(0o755) + + +class TestCheckOpensslAvailable: + def test_available(self): + from unittest.mock import patch + + mock_result = MagicMock(returncode=0, stdout="OpenSSL 3.0.0\n") + with ( + patch("shutil.which", return_value="/usr/bin/openssl"), + patch("subprocess.run", return_value=mock_result), + ): + passed, msg = _check_openssl_available() + assert passed is True + assert "OpenSSL" in msg + + def test_not_found(self): + with patch("shutil.which", return_value=None): + passed, msg = _check_openssl_available() + assert passed is False + + +class TestCheckPort80Listening: + def test_listening(self): + from unittest.mock import patch + + mock_sock = MagicMock() + mock_sock.connect_ex.return_value = 0 + mock_sock.__enter__ = MagicMock(return_value=mock_sock) + mock_sock.__exit__ = MagicMock(return_value=False) + + with patch("socket.socket", return_value=mock_sock): + passed, msg = _check_port_80_listening() + assert passed is True + + def test_not_listening(self): + from unittest.mock import patch + + mock_sock = MagicMock() + mock_sock.connect_ex.return_value = 111 + mock_sock.__enter__ = MagicMock(return_value=mock_sock) + mock_sock.__exit__ = MagicMock(return_value=False) + + with patch("socket.socket", return_value=mock_sock): + passed, msg = _check_port_80_listening() + assert passed is False + + +class TestCheckAcmeAccount: + def test_via_acme_info(self, tmp_path): + from unittest.mock import patch + + acme_dir = tmp_path / "acme" + acme_dir.mkdir() + (acme_dir / "acme.sh").write_text("#!/bin/sh\nexit 0") + (acme_dir / "acme.sh").chmod(0o755) + + with ( + patch("daemon.handlers.acme._ACME_HOME", acme_dir), + patch( + "daemon.handlers.acme._find_acme_bin", + return_value=str(acme_dir / "acme.sh"), + ), + patch("subprocess.run", return_value=MagicMock(returncode=0, stdout="ok")), + ): + passed, msg = _check_acme_account() + assert passed is True + + def test_via_account_conf(self, tmp_path): + from unittest.mock import patch + + acme_dir = tmp_path / "acme" + acme_dir.mkdir() + (acme_dir / ".account.conf").write_text( + "ACME_LEEMAIL='test@example.com'\nACME_MCA='letsencrypt'\n" + ) + + def run_side_effect(cmd, **kwargs): + raise FileNotFoundError() + + with ( + patch("daemon.handlers.acme._ACME_HOME", acme_dir), + patch("subprocess.run", side_effect=run_side_effect), + ): + passed, msg = _check_acme_account() + assert passed is True + + def test_not_configured(self, tmp_path): + from unittest.mock import patch + + acme_dir = tmp_path / "acme" + acme_dir.mkdir() + + def run_side_effect(cmd, **kwargs): + raise FileNotFoundError() + + with ( + patch("daemon.handlers.acme._ACME_HOME", acme_dir), + patch("subprocess.run", side_effect=run_side_effect), + ): + passed, msg = _check_acme_account() + assert passed is False + + +class TestCheckDnsPublic: + def test_resolves_correctly(self): + from unittest.mock import patch + + mock_result = MagicMock( + returncode=0, stdout="example.com has address 192.168.1.1" + ) + with ( + patch("socket.gethostbyname", return_value="192.168.1.1"), + patch("subprocess.run", return_value=mock_result), + ): + passed, msg = _check_dns_public("example.com") + assert passed is True + + def test_does_not_resolve(self): + from unittest.mock import patch + + mock_result = MagicMock(returncode=1, stdout="NXDOMAIN") + with ( + patch("socket.gethostbyname", return_value="192.168.1.1"), + patch("subprocess.run", return_value=mock_result), + ): + passed, msg = _check_dns_public("example.com") + assert passed is False + + +class TestCheckDomainFormat: + def test_valid(self): + from daemon.handlers.acme import _check_domain_format + + passed, _ = _check_domain_format("example.com") + assert passed is True + + def test_invalid(self): + from daemon.handlers.acme import _check_domain_format + + passed, _ = _check_domain_format("-bad.com") + assert passed is False + + +class TestValidate: + def test_returns_all_checks(self): + + with ( + patch( + "daemon.handlers.acme._check_acme_installed", return_value=(True, "ok") + ), + patch( + "daemon.handlers.acme._check_openssl_available", + return_value=(True, "ok"), + ), + patch( + "daemon.handlers.acme._check_acme_home_writable", + return_value=(True, "ok"), + ), + patch( + "daemon.handlers.acme._check_account_registered", + return_value=(True, "ok"), + ), + patch( + "daemon.handlers.acme._check_email_configured", + return_value=(True, "ok"), + ), + patch( + "daemon.handlers.acme._check_acme_account", return_value=(True, "ok") + ), + patch("daemon.handlers.acme._check_webroot", return_value=(True, "ok")), + patch( + "daemon.handlers.acme._check_nginx_running", return_value=(True, "ok") + ), + patch( + "daemon.handlers.acme._check_nginx_config", return_value=(True, "ok") + ), + patch( + "daemon.handlers.acme._check_challenge_config", + return_value=(True, "ok"), + ), + patch( + "daemon.handlers.acme._check_port_80_listening", + return_value=(True, "ok"), + ), + patch( + "daemon.handlers.acme._check_firewall_port_80", + return_value=(True, "ok"), + ), + patch( + "daemon.handlers.acme._check_domain_format", return_value=(True, "ok") + ), + patch( + "daemon.handlers.acme._check_dns_resolves", return_value=(True, "ok") + ), + patch("daemon.handlers.acme._check_dns_public", return_value=(True, "ok")), + patch( + "daemon.handlers.acme._check_existing_cert", return_value=(True, "ok") + ), + ): + result = _validate("example.com") + + assert result["domain"] == "example.com" + assert result["ready"] is True + assert len(result["checks"]) == 16 + for c in result["checks"]: + assert c["passed"] is True + + def test_failing_check_blocks_ready(self): + with ( + patch( + "daemon.handlers.acme._check_acme_installed", return_value=(True, "ok") + ), + patch( + "daemon.handlers.acme._check_openssl_available", + return_value=(True, "ok"), + ), + patch( + "daemon.handlers.acme._check_acme_home_writable", + return_value=(True, "ok"), + ), + patch( + "daemon.handlers.acme._check_account_registered", + return_value=(True, "ok"), + ), + patch( + "daemon.handlers.acme._check_email_configured", + return_value=(True, "ok"), + ), + patch( + "daemon.handlers.acme._check_acme_account", return_value=(True, "ok") + ), + patch("daemon.handlers.acme._check_webroot", return_value=(True, "ok")), + patch( + "daemon.handlers.acme._check_nginx_running", + return_value=(False, "not running"), + ), + patch( + "daemon.handlers.acme._check_nginx_config", return_value=(True, "ok") + ), + patch( + "daemon.handlers.acme._check_challenge_config", + return_value=(True, "ok"), + ), + patch( + "daemon.handlers.acme._check_port_80_listening", + return_value=(True, "ok"), + ), + patch( + "daemon.handlers.acme._check_firewall_port_80", + return_value=(True, "ok"), + ), + patch( + "daemon.handlers.acme._check_dns_resolves", return_value=(True, "ok") + ), + patch("daemon.handlers.acme._check_dns_public", return_value=(True, "ok")), + patch( + "daemon.handlers.acme._check_existing_cert", return_value=(True, "ok") + ), + patch( + "daemon.handlers.acme._check_domain_format", return_value=(True, "ok") + ), + ): + result = _validate("example.com") + + assert result["ready"] is False + nginx_check = [c for c in result["checks"] if c["name"] == "nginx_running"][0] + assert nginx_check["passed"] is False + assert nginx_check["blocking"] is True + + def test_non_blocking_failure_allows_ready(self): + with ( + patch( + "daemon.handlers.acme._check_acme_installed", return_value=(True, "ok") + ), + patch( + "daemon.handlers.acme._check_openssl_available", + return_value=(True, "ok"), + ), + patch( + "daemon.handlers.acme._check_acme_home_writable", + return_value=(True, "ok"), + ), + patch( + "daemon.handlers.acme._check_account_registered", + return_value=(True, "ok"), + ), + patch( + "daemon.handlers.acme._check_email_configured", + return_value=(True, "ok"), + ), + patch( + "daemon.handlers.acme._check_acme_account", return_value=(True, "ok") + ), + patch("daemon.handlers.acme._check_webroot", return_value=(True, "ok")), + patch( + "daemon.handlers.acme._check_nginx_running", return_value=(True, "ok") + ), + patch( + "daemon.handlers.acme._check_nginx_config", return_value=(True, "ok") + ), + patch( + "daemon.handlers.acme._check_challenge_config", + return_value=(True, "ok"), + ), + patch( + "daemon.handlers.acme._check_port_80_listening", + return_value=(True, "ok"), + ), + patch( + "daemon.handlers.acme._check_firewall_port_80", + return_value=(True, "ok"), + ), + patch( + "daemon.handlers.acme._check_dns_resolves", return_value=(True, "ok") + ), + patch( + "daemon.handlers.acme._check_dns_public", + return_value=(False, "skipped"), + ), + patch( + "daemon.handlers.acme._check_existing_cert", return_value=(True, "ok") + ), + patch( + "daemon.handlers.acme._check_domain_format", return_value=(True, "ok") + ), + ): + result = _validate("example.com") + + assert result["ready"] is True + dns_pub = [c for c in result["checks"] if c["name"] == "dns_public"][0] + assert dns_pub["passed"] is False + assert dns_pub["blocking"] is False + + +class TestExternalIp: + def test_success(self): + mock_resp = MagicMock() + mock_resp.read.return_value = b"93.184.216.34" + mock_resp.__enter__ = MagicMock(return_value=mock_resp) + mock_resp.__exit__ = MagicMock(return_value=False) + + with patch("urllib.request.urlopen", return_value=mock_resp): + result = _get_external_ip() + assert result == "93.184.216.34" + + def test_primary_fails_fallback_succeeds(self): + def urlopen_side_effect(url, timeout=None): + if "ipify" in url.full_url: + raise urllib.error.URLError("primary down") + + mock_resp = MagicMock() + mock_resp.read.return_value = b"93.184.216.50" + mock_resp.__enter__ = MagicMock(return_value=mock_resp) + mock_resp.__exit__ = MagicMock(return_value=False) + return mock_resp + + with patch("urllib.request.urlopen", side_effect=urlopen_side_effect): + result = _get_external_ip() + assert result == "93.184.216.50" + + def test_both_fail_returns_none(self): + with patch("urllib.request.urlopen", side_effect=urllib.error.URLError("fail")): + result = _get_external_ip() + assert result is None + + def test_env_override_url(self): + mock_resp = MagicMock() + mock_resp.read.return_value = b"8.8.8.8" + mock_resp.__enter__ = MagicMock(return_value=mock_resp) + mock_resp.__exit__ = MagicMock(return_value=False) + + with ( + patch.dict( + "os.environ", {"VACUUM_WALL_EXTERNAL_IP_URL": "https://my.ip.api"} + ), + patch("urllib.request.urlopen", return_value=mock_resp) as mock_urlopen, + ): + result = _get_external_ip() + assert result == "8.8.8.8" + call_url = mock_urlopen.call_args[0][0] + assert "my.ip.api" in call_url.full_url + + +class TestIsPrivateIp: + def test_public_ip(self): + assert _is_private_ip("8.8.8.8") is False + + def test_private_10(self): + assert _is_private_ip("10.0.0.1") is True + + def test_private_192(self): + assert _is_private_ip("192.168.1.1") is True + + def test_private_172(self): + assert _is_private_ip("172.16.0.1") is True + + def test_invalid_ip(self): + assert _is_private_ip("not-an-ip") is False + + def test_doc_range_203(self): + assert _is_private_ip("203.0.113.5") is True + + +class TestCheckDnsResolves: + def test_match_local_ip(self): + mock_results = [("AF_INET", "STREAM", 6, "", ("192.168.1.10", 80))] + with ( + patch("socket.getaddrinfo", return_value=mock_results), + patch("daemon.handlers.acme._get_local_ips", return_value={"192.168.1.10"}), + ): + passed, msg = _check_dns_resolves("example.com") + assert passed is True + assert "DNS resolves correctly" in msg + + def test_match_external_ip(self): + mock_results = [("AF_INET", "STREAM", 6, "", ("93.184.216.34", 80))] + with ( + patch("socket.getaddrinfo", return_value=mock_results), + patch("daemon.handlers.acme._get_local_ips", return_value={"192.168.1.10"}), + patch( + "daemon.handlers.acme._get_external_ip", return_value="93.184.216.34" + ), + ): + passed, msg = _check_dns_resolves("example.com") + assert passed is True + assert "NAT" in msg + + def test_external_ip_mismatch(self): + mock_results = [("AF_INET", "STREAM", 6, "", ("93.184.216.99", 80))] + with ( + patch("socket.getaddrinfo", return_value=mock_results), + patch("daemon.handlers.acme._get_local_ips", return_value={"192.168.1.10"}), + patch( + "daemon.handlers.acme._get_external_ip", return_value="93.184.216.34" + ), + ): + passed, msg = _check_dns_resolves("example.com") + assert passed is False + assert "93.184.216.99" in msg + assert "93.184.216.34" in msg + + def test_external_ip_unavailable(self): + mock_results = [("AF_INET", "STREAM", 6, "", ("93.184.216.34", 80))] + with ( + patch("socket.getaddrinfo", return_value=mock_results), + patch("daemon.handlers.acme._get_local_ips", return_value={"192.168.1.10"}), + patch("daemon.handlers.acme._get_external_ip", return_value=None), + ): + passed, msg = _check_dns_resolves("example.com") + assert passed is False + assert "Cannot verify via external IP" in msg + + def test_private_ip(self): + mock_results = [("AF_INET", "STREAM", 6, "", ("192.168.1.50", 80))] + with ( + patch("socket.getaddrinfo", return_value=mock_results), + patch("daemon.handlers.acme._get_local_ips", return_value={"10.0.0.1"}), + patch("daemon.handlers.acme._get_external_ip", return_value=None), + ): + passed, msg = _check_dns_resolves("example.com") + assert passed is False + assert "private IP" in msg + + def test_unresolved(self): + import socket + + with patch("socket.getaddrinfo", side_effect=socket.gaierror("NXDOMAIN")): + passed, msg = _check_dns_resolves("nonexistent.example") + assert passed is False + assert "NXDOMAIN" in msg + + +class TestCheckAccountRegistered: + def test_registered_with_keys(self, tmp_path): + acme_dir = tmp_path / "acme" + acme_dir.mkdir() + (acme_dir / ".account.conf").write_text( + "ACME_LEEMAIL='user@example.com'\nACME_MCA='letsencrypt'\n" + ) + with patch("daemon.handlers.acme._ACME_HOME", acme_dir): + passed, msg = _check_account_registered() + assert passed is True + assert "registered" in msg + + def test_missing_account_conf(self, tmp_path): + acme_dir = tmp_path / "acme" + acme_dir.mkdir() + with patch("daemon.handlers.acme._ACME_HOME", acme_dir): + passed, msg = _check_account_registered() + assert passed is False + assert "Register" in msg + + def test_incomplete_account_conf(self, tmp_path): + acme_dir = tmp_path / "acme" + acme_dir.mkdir() + (acme_dir / ".account.conf").write_text("ACME_LEEMAIL='user@example.com'\n") + with patch("daemon.handlers.acme._ACME_HOME", acme_dir): + passed, msg = _check_account_registered() + assert passed is False + assert "Register" in msg + + +class TestValidateAccountCheck: + def test_account_not_registered_blocks(self): + with ( + patch( + "daemon.handlers.acme._check_acme_installed", return_value=(True, "ok") + ), + patch( + "daemon.handlers.acme._check_openssl_available", + return_value=(True, "ok"), + ), + patch( + "daemon.handlers.acme._check_acme_home_writable", + return_value=(True, "ok"), + ), + patch( + "daemon.handlers.acme._check_account_registered", + return_value=( + False, + "Register an ACME account before issuing certificates", + ), + ), + patch( + "daemon.handlers.acme._check_email_configured", + return_value=(True, "ok"), + ), + patch( + "daemon.handlers.acme._check_acme_account", return_value=(True, "ok") + ), + patch("daemon.handlers.acme._check_webroot", return_value=(True, "ok")), + patch( + "daemon.handlers.acme._check_nginx_running", return_value=(True, "ok") + ), + patch( + "daemon.handlers.acme._check_nginx_config", return_value=(True, "ok") + ), + patch( + "daemon.handlers.acme._check_challenge_config", + return_value=(True, "ok"), + ), + patch( + "daemon.handlers.acme._check_port_80_listening", + return_value=(True, "ok"), + ), + patch( + "daemon.handlers.acme._check_firewall_port_80", + return_value=(True, "ok"), + ), + patch( + "daemon.handlers.acme._check_domain_format", return_value=(True, "ok") + ), + patch( + "daemon.handlers.acme._check_dns_resolves", return_value=(True, "ok") + ), + patch("daemon.handlers.acme._check_dns_public", return_value=(True, "ok")), + patch( + "daemon.handlers.acme._check_existing_cert", return_value=(True, "ok") + ), + ): + result = _validate("example.com") + + assert result["ready"] is False + acct = next(c for c in result["checks"] if c["name"] == "account_registered") + assert acct["passed"] is False + assert acct["blocking"] is True + + def test_account_registered_passes(self): + with ( + patch( + "daemon.handlers.acme._check_acme_installed", return_value=(True, "ok") + ), + patch( + "daemon.handlers.acme._check_openssl_available", + return_value=(True, "ok"), + ), + patch( + "daemon.handlers.acme._check_acme_home_writable", + return_value=(True, "ok"), + ), + patch( + "daemon.handlers.acme._check_account_registered", + return_value=(True, "ACME account is registered"), + ), + patch( + "daemon.handlers.acme._check_email_configured", + return_value=(True, "ok"), + ), + patch( + "daemon.handlers.acme._check_acme_account", return_value=(True, "ok") + ), + patch("daemon.handlers.acme._check_webroot", return_value=(True, "ok")), + patch( + "daemon.handlers.acme._check_nginx_running", return_value=(True, "ok") + ), + patch( + "daemon.handlers.acme._check_nginx_config", return_value=(True, "ok") + ), + patch( + "daemon.handlers.acme._check_challenge_config", + return_value=(True, "ok"), + ), + patch( + "daemon.handlers.acme._check_port_80_listening", + return_value=(True, "ok"), + ), + patch( + "daemon.handlers.acme._check_firewall_port_80", + return_value=(True, "ok"), + ), + patch( + "daemon.handlers.acme._check_domain_format", return_value=(True, "ok") + ), + patch( + "daemon.handlers.acme._check_dns_resolves", return_value=(True, "ok") + ), + patch("daemon.handlers.acme._check_dns_public", return_value=(True, "ok")), + patch( + "daemon.handlers.acme._check_existing_cert", return_value=(True, "ok") + ), + ): + result = _validate("example.com") + + assert result["ready"] is True + acct = next(c for c in result["checks"] if c["name"] == "account_registered") + assert acct["passed"] is True + assert acct["blocking"] is True + + def test_email_missing_non_blocking(self): + with ( + patch( + "daemon.handlers.acme._check_acme_installed", return_value=(True, "ok") + ), + patch( + "daemon.handlers.acme._check_openssl_available", + return_value=(True, "ok"), + ), + patch( + "daemon.handlers.acme._check_acme_home_writable", + return_value=(True, "ok"), + ), + patch( + "daemon.handlers.acme._check_account_registered", + return_value=(True, "ok"), + ), + patch( + "daemon.handlers.acme._check_email_configured", + return_value=(False, "Contact email not set"), + ), + patch( + "daemon.handlers.acme._check_acme_account", return_value=(True, "ok") + ), + patch("daemon.handlers.acme._check_webroot", return_value=(True, "ok")), + patch( + "daemon.handlers.acme._check_nginx_running", return_value=(True, "ok") + ), + patch( + "daemon.handlers.acme._check_nginx_config", return_value=(True, "ok") + ), + patch( + "daemon.handlers.acme._check_challenge_config", + return_value=(True, "ok"), + ), + patch( + "daemon.handlers.acme._check_port_80_listening", + return_value=(True, "ok"), + ), + patch( + "daemon.handlers.acme._check_firewall_port_80", + return_value=(True, "ok"), + ), + patch( + "daemon.handlers.acme._check_domain_format", return_value=(True, "ok") + ), + patch( + "daemon.handlers.acme._check_dns_resolves", return_value=(True, "ok") + ), + patch("daemon.handlers.acme._check_dns_public", return_value=(True, "ok")), + patch( + "daemon.handlers.acme._check_existing_cert", return_value=(True, "ok") + ), + ): + result = _validate("example.com") + + assert result["ready"] is True + email_chk = next(c for c in result["checks"] if c["name"] == "email_configured") + assert email_chk["passed"] is False + assert email_chk["blocking"] is False + + +class TestGetAccount: + def test_registered_account(self, tmp_path): + acme_dir = tmp_path / "acme" + acme_dir.mkdir() + (acme_dir / ".account.conf").write_text( + "ACME_LEEMAIL='admin@example.com'\nACME_MCA='letsencrypt'\nACME_CERTKEYSIZE='2048'\n" + ) + with patch("daemon.handlers.acme._ACME_HOME", acme_dir): + result = _get_account_info() + + assert result["registered"] is True + assert result["email"] == "admin@example.com" + assert result["ca"] == "Let's Encrypt" + assert result["key_length"] == 2048 + + def test_unregistered_account(self, tmp_path): + acme_dir = tmp_path / "acme" + acme_dir.mkdir() + with patch("daemon.handlers.acme._ACME_HOME", acme_dir): + result = _get_account_info() + + assert result["registered"] is False + assert result["email"] == "" + assert result["ca"] == "" + assert result["key_length"] is None + + def test_zerossl_account(self, tmp_path): + acme_dir = tmp_path / "acme" + acme_dir.mkdir() + (acme_dir / ".account.conf").write_text( + "ACME_LEEMAIL='user@zerossl.com'\nACME_MCA='zerossl'\n" + ) + with patch("daemon.handlers.acme._ACME_HOME", acme_dir): + result = _get_account_info() + + assert result["ca"] == "ZeroSSL" + + def test_get_account_endpoint(self, tmp_path): + acme_dir = tmp_path / "acme" + acme_dir.mkdir() + (acme_dir / ".account.conf").write_text( + "ACME_LEEMAIL='test@example.com'\nACME_MCA='letsencrypt'\n" + ) + with patch("daemon.handlers.acme._ACME_HOME", acme_dir): + result = get_account(None, None) + + assert result["registered"] is True + assert result["email"] == "test@example.com" + + +class TestRegisterAccount: + def test_success(self, tmp_path): + proj = tmp_path / "project" + proj.mkdir() + acme_dir = proj / "data" / "acme" + acme_dir.mkdir(parents=True) + config_dir = proj / "config" / "acme" + config_dir.mkdir(parents=True) + (config_dir / "config.json").write_text("{}\n") + + def fake_run_acme(args): + (acme_dir / ".account.conf").write_text( + "ACME_LEEMAIL='test@example.com'\nACME_MCA='letsencrypt'\n" + ) + + with ( + patch("daemon.handlers.acme._ACME_HOME", acme_dir), + patch("daemon.handlers.acme.PROJECT_DIR", proj), + patch("daemon.handlers.acme._run_acme", side_effect=fake_run_acme), + patch("daemon.handlers.acme.refresh_state"), + ): + result = register_account( + None, {"email": "test@example.com", "server": "letsencrypt"} + ) + + assert result["registered"] is True + assert result["email"] == "test@example.com" + assert result["ca"] == "letsencrypt" + + def test_missing_email(self): + with pytest.raises(ValueError, match="email"): + register_account(None, {"foo": "bar"}) + + def test_server_default_letsencrypt(self, tmp_path): + proj = tmp_path / "project" + proj.mkdir() + acme_dir = proj / "data" / "acme" + acme_dir.mkdir(parents=True) + config_dir = proj / "config" / "acme" + config_dir.mkdir(parents=True) + (config_dir / "config.json").write_text("{}\n") + + captured_args = [] + + def fake_run_acme(args): + captured_args.append(args) + (acme_dir / ".account.conf").write_text( + "ACME_LEEMAIL='test@example.com'\nACME_MCA='letsencrypt'\n" + ) + + with ( + patch("daemon.handlers.acme._ACME_HOME", acme_dir), + patch("daemon.handlers.acme.PROJECT_DIR", proj), + patch("daemon.handlers.acme._run_acme", side_effect=fake_run_acme), + patch("daemon.handlers.acme.refresh_state"), + ): + register_account(None, {"email": "test@example.com"}) + + assert "--server" in captured_args[0] + assert "letsencrypt" in captured_args[0] + + +class TestDeactivateAccount: + def test_success(self, tmp_path): + proj = tmp_path / "project" + proj.mkdir() + config_dir = proj / "config" / "acme" + config_dir.mkdir(parents=True) + (config_dir / "config.json").write_text( + '{"email": "test@example.com", "ca": "letsencrypt"}\n' + ) + + with ( + patch("daemon.handlers.acme.PROJECT_DIR", proj), + patch("daemon.handlers.acme._run_acme"), + patch("daemon.handlers.acme.refresh_state"), + ): + result = deactivate_account(None, None) + + assert result["email"] == "" + cfg_text = (config_dir / "config.json").read_text() + assert "email" not in cfg_text + assert "ca" not in cfg_text + + def test_cleans_account_conf_files(self, tmp_path): + proj = tmp_path / "project" + proj.mkdir() + config_dir = proj / "config" / "acme" + config_dir.mkdir(parents=True) + (config_dir / "config.json").write_text( + '{"email": "test@example.com", "ca": "letsencrypt"}\n' + ) + acme_dir = proj / "data" / "acme" + acme_dir.mkdir(parents=True) + (acme_dir / ".account.conf").write_text( + "ACME_LEEMAIL='test@example.com'\nACME_MCA='letsencrypt'\n" + ) + (acme_dir / "account.conf").write_text( + "ACME_LEEMAIL='test@example.com'\nACME_MCA='letsencrypt'\n" + ) + + with ( + patch("daemon.handlers.acme._ACME_HOME", acme_dir), + patch("daemon.handlers.acme.PROJECT_DIR", proj), + patch("daemon.handlers.acme._run_acme"), + patch("daemon.handlers.acme.refresh_state"), + ): + deactivate_account(None, None) + + assert not (acme_dir / ".account.conf").is_file() + assert not (acme_dir / "account.conf").is_file() diff --git a/webui/api/certs.py b/webui/api/certs.py index cb5fb1a..550ee67 100644 --- a/webui/api/certs.py +++ b/webui/api/certs.py @@ -9,10 +9,13 @@ from flask import Blueprint, request from daemon.client import BadRequest, NotFound, delete, get, post from daemon.iface import ( + DELETE_ACME_ACCOUNT_DEACTIVATE, DELETE_ACME_REMOVE, + GET_ACME_ACCOUNT, GET_ACME_INFO, GET_ACME_ISSUE_STATUS, GET_ACME_LIST, + POST_ACME_ACCOUNT_REGISTER, POST_ACME_EMAIL, POST_ACME_ISSUE, POST_ACME_RENEW, @@ -68,7 +71,7 @@ def validate(): Response containing validation results or an error message. """ body = request.get_json(silent=True) or {} - domain = body.get("domain", "").strip() + domain = (body.get("domain") or "").strip() if not domain: return _error("'domain' is required", 400) try: @@ -92,10 +95,10 @@ def issue_start(): Response containing an issuance request ID or an error message. """ body = request.get_json(silent=True) or {} - domain = body.get("domain", "").strip() + domain = (body.get("domain") or "").strip() if not domain: return _error("'domain' is required", 400) - email = body.get("email", "").strip() or None + email = (body.get("email") or "").strip() or None webroot = body.get("webroot") try: logger.info("Certificate issuance requested for '%s' via API", domain) @@ -192,7 +195,7 @@ def set_email_bp(): Response confirming the email was set or an error message. """ body = request.get_json(silent=True) or {} - email = body.get("email", "").strip() + email = (body.get("email") or "").strip() if not email: return _error("'email' is required", 400) try: @@ -205,3 +208,57 @@ def set_email_bp(): except RuntimeError as exc: logger.error("Failed to set ACME email: %s", exc) return _error(str(exc), 500) + + +@bp.route("/account", methods=["GET"]) +def account(): + """GET /api/certs/account — return ACME account information. + + Returns: + Response containing account status or an error message. + """ + try: + result = get(GET_ACME_ACCOUNT) + return _ok(result) + except RuntimeError as exc: + logger.error("Failed to get ACME account: %s", exc) + return _error(str(exc), 500) + + +@bp.route("/account/register", methods=["POST"]) +def register_account(): + """POST /api/certs/account/register — register a new ACME account. + + Expects JSON body with ``{``email``, ``server``?}``. + + Returns: + Response confirming registration or an error message. + """ + body = request.get_json(silent=True) or {} + email = (body.get("email") or "").strip() + if not email: + return _error("'email' is required", 400) + server = (body.get("server") or "").strip() + try: + result = post(POST_ACME_ACCOUNT_REGISTER, {"email": email, "server": server}) + return _ok(result) + except BadRequest as exc: + return _error(str(exc), 400) + except RuntimeError as exc: + logger.error("Failed to register ACME account: %s", exc) + return _error(str(exc), 500) + + +@bp.route("/account", methods=["DELETE"]) +def deactivate_account(): + """DELETE /api/certs/account — deactivate the ACME account. + + Returns: + Response confirming deactivation or an error message. + """ + try: + result = delete(DELETE_ACME_ACCOUNT_DEACTIVATE) + return _ok(result) + except RuntimeError as exc: + logger.error("Failed to deactivate ACME account: %s", exc) + return _error(str(exc), 500) diff --git a/webui/static/app.js b/webui/static/app.js index 8181a9e..fa4b8e2 100644 --- a/webui/static/app.js +++ b/webui/static/app.js @@ -100,9 +100,20 @@ modelRegister('nginx', { modelRegister('acme', { subsystem: 'acme', fetch: async () => { - const r = await apiFetch('/api/certs/list'); - if (!r.ok) throw new Error(r.error); - return { certs: r.data || [] }; + const [listR, acctR] = await Promise.allSettled([ + apiFetch('/api/certs/list'), + apiFetch('/api/certs/account'), + ]); + const result = {}; + if (listR.status === 'fulfilled' && listR.value.ok) { + result.certs = listR.value.data || []; + } else if (listR.status === 'rejected' || !listR.value.ok) { + throw new Error(listR.status === 'rejected' ? (listR.reason?.message || 'Failed') : (listR.value.error || 'Failed')); + } + if (acctR.status === 'fulfilled' && acctR.value.ok) { + result.account = acctR.value.data || { registered: false, email: '', ca: '' }; + } + return result; }, }); diff --git a/webui/static/index.html b/webui/static/index.html index 59fcf2b..b360e5e 100644 --- a/webui/static/index.html +++ b/webui/static/index.html @@ -4,7 +4,7 @@ Vacuum Wall - +
@@ -13,7 +13,8 @@
+ - + diff --git a/webui/static/pages/certs.js b/webui/static/pages/certs.js index f328767..a4e79a5 100644 --- a/webui/static/pages/certs.js +++ b/webui/static/pages/certs.js @@ -1,30 +1,74 @@ -import { h, PageHeader, Empty, Table, renderGuard, esc, enc, $val, apiFetch, toast, openModal, closeModal, formModal, definePage, getModel, modelFetch, ActionCell, certStatusBadge, poll } from '/static/hoover/index.js?v=7'; +import { h, PageHeader, Empty, Table, Card, Badge, renderGuard, esc, enc, $val, apiFetch, toast, openModal, closeModal, formModal, definePage, getModel, modelFetch, ActionCell, certStatusBadge, poll } from '/static/hoover/index.js?v=7'; +const _issueState = { domain: '', modalIdx: -1, account: null, validating: false }; -function issueCertModal(state) { - openModal((inner, idx) => { - formModal(inner, 'Issue Certificate', +function _accountCard(account) { + if (!account || !account.registered) { + return h('div', { class: 'card' }, + h('div', { class: 'card-header' }, + [ + h('span', null, 'ACME Account'), + h('button', { + class: 'btn btn-sm btn-primary', + style: 'margin-left:auto;', + 'on:click': () => registerAccountModal(), + }, 'Register Account'), + ] + ), + h('div', { class: 'card-body' }, + h('div', { class: 'text-muted text-sm' }, 'Not registered'), + ), + ); + } + return h('div', { class: 'card' }, + h('div', { class: 'card-header' }, [ - { label: 'Domain', id: 'ic-domain', placeholder: 'example.com' }, - { label: 'Email (optional)', id: 'ic-email', placeholder: 'you@example.com' }, + h('span', null, 'ACME Account'), + h('button', { + class: 'btn btn-sm btn-outline', + style: 'margin-left:auto;', + 'on:click': () => settingsModal(account), + }, '\u2699'), + ] + ), + h('div', { class: 'card-body' }, + h('div', null, ['Registered as ', h('strong', null, esc(account.email))]), + h('div', { class: 'text-sm text-muted' }, ['CA: ', esc(account.ca)]), + ), + ); +} + +function registerAccountModal() { + openModal((inner) => { + formModal(inner, 'Register ACME Account', + [ + { label: 'Email', id: 'reg-email', type: 'email', placeholder: 'you@example.com' }, + { + label: 'CA Provider', + id: 'reg-server', + tag: 'select', + options: [['letsencrypt', "Let's Encrypt"], ['zerossl', 'ZeroSSL']], + }, ], [ - { label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal(idx) }, + { label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal() }, { - label: 'Issue', cls: 'btn-primary', action: 's', handler: async () => { - const domain = ($val('ic-domain') || '').trim(); - if (!domain) { toast('Domain is required', 'error'); return; } - const body = { domain, email: ($val('ic-email') || '').trim() || undefined }; - const resp = await apiFetch('/api/certs/issue/start', { + label: 'Register', + cls: 'btn-primary', + action: 'r', + handler: async () => { + const email = ($val('reg-email') || '').trim(); + if (!email) { toast('Email is required', 'error'); return; } + const server = document.getElementById('reg-server')?.value || 'letsencrypt'; + const resp = await apiFetch('/api/certs/account/register', { method: 'POST', - body, + body: { email, server }, }); if (resp.ok) { - toast('Issuance started for ' + domain, 'success'); - closeModal(idx); - const rid = resp.data?.request_id; - if (rid) pollCertIssue(rid, state); + toast('ACME account registered', 'success'); + closeModal(); + modelFetch('acme'); } else { - toast(resp.error || 'Failed', 'error'); + toast(resp.error || 'Registration failed', 'error'); } }, }, @@ -33,6 +77,193 @@ function issueCertModal(state) { }); } +function settingsModal(account) { + openModal((inner) => { + inner.innerHTML = '' + + ''; + + inner.querySelector('[data-action="set-cancel"]')?.addEventListener('click', () => { + closeModal(); + }); + + inner.querySelector('[data-action="set-save"]')?.addEventListener('click', async () => { + const email = ($val('set-email') || '').trim(); + if (!email) { toast('Email is required', 'error'); return; } + const resp = await apiFetch('/api/certs/email', { + method: 'POST', + body: { email }, + }); + if (resp.ok) { + toast('Email updated', 'success'); + closeModal(); + modelFetch('acme'); + } else { + toast(resp.error || 'Failed', 'error'); + } + }); + + inner.querySelector('[data-action="set-deactivate"]')?.addEventListener('click', async () => { + if (!confirm('Deactivate ACME account? You will need to register again to issue certificates.')) return; + const resp = await apiFetch('/api/certs/account', { method: 'DELETE' }); + if (resp.ok) { + toast('Account deactivated', 'success'); + closeModal(); + modelFetch('acme'); + } else { + toast(resp.error || 'Failed', 'error'); + } + }); + }); +} + +function issueCertModal(state) { + _issueState.domain = ''; + _issueState.modalIdx = -1; + _issueState.account = null; + _issueState.validating = false; + + (async () => { + const accountResp = await apiFetch('/api/certs/account'); + _issueState.account = accountResp.ok ? accountResp.data : null; + _renderIssueModal(state); + })(); +} + +function _renderIssueModal(state) { + const account = _issueState.account; + const registered = account && account.registered; + const accountBadge = registered + ? esc(account.email) + ' (' + esc(account.ca) + ')' + : 'No account registered'; + + openModal((inner) => { + inner.innerHTML = '' + + ''; + + _issueState.modalIdx = document.querySelectorAll('#modal-root > div').length - 1; + + inner.querySelector('[data-action="ic-cancel"]')?.addEventListener('click', () => { + closeModal(_issueState.modalIdx); + }); + + inner.querySelector('[data-action="ic-register"]')?.addEventListener('click', () => { + closeModal(_issueState.modalIdx); + registerAccountModal(); + }); + + if (!registered) return; + + inner.querySelector('[data-action="ic-validate"]')?.addEventListener('click', async () => { + if (_issueState.validating) return; + const domain = ($val('ic-domain') || '').trim(); + if (!domain) { toast('Domain is required', 'error'); return; } + _issueState.validating = true; + _issueState.domain = domain; + try { + const resp = await apiFetch('/api/certs/validate', { + method: 'POST', + body: { domain }, + }); + if (!resp.ok) { + toast(resp.error || 'Validation failed', 'error'); + return; + } + _showValidate(inner, domain, resp.data.checks, resp.data.ready, state); + } finally { + _issueState.validating = false; + } + }); + }); +} + +function _showValidate(inner, domain, checks, ready, state) { + const resultsHtml = checks.map(c => { + let cls = 'text-success'; + let icon = '\u2713'; + if (!c.passed && c.blocking) { cls = 'text-danger'; icon = '\u2717'; } + else if (!c.passed && !c.blocking) { cls = 'text-warning'; icon = '\u26A0'; } + return '
' + icon + ' ' + esc(c.name) + '' + + ': ' + '' + esc(c.message) + '
'; + }).join(''); + + inner.innerHTML = '' + + ''; + + inner.querySelector('[data-action="ic-cancel"]')?.addEventListener('click', () => { + closeModal(_issueState.modalIdx); + }); + + inner.querySelector('[data-action="ic-validate"]')?.addEventListener('click', async () => { + if (_issueState.validating) return; + const domain2 = ($val('ic-domain') || '').trim(); + if (!domain2) { toast('Domain is required', 'error'); return; } + _issueState.validating = true; + _issueState.domain = domain2; + try { + const resp2 = await apiFetch('/api/certs/validate', { + method: 'POST', + body: { domain: domain2 }, + }); + if (!resp2.ok) { toast(resp2.error || 'Validation failed', 'error'); return; } + _showValidate(inner, domain2, resp2.data.checks, resp2.data.ready, state); + } finally { + _issueState.validating = false; + } + }); + + inner.querySelector('[data-action="ic-issue"]')?.addEventListener('click', async () => { + const body = { domain: _issueState.domain }; + const issueResp = await apiFetch('/api/certs/issue/start', { + method: 'POST', + body, + }); + if (issueResp.ok) { + toast('Issuance started for ' + _issueState.domain, 'success'); + closeModal(_issueState.modalIdx); + const rid = issueResp.data?.request_id; + if (rid) pollCertIssue(rid, state); + } else { + toast(issueResp.error || 'Failed', 'error'); + } + }); +} + async function pollCertIssue(rid, state) { poll({ url: '/api/certs/issue/' + enc(rid), @@ -58,6 +289,7 @@ export default definePage({ const guard = renderGuard(state.acme, 'Certificates', 'ACME certificate management', state.acme.data); if (guard) return guard; + const account = state.acme.data?.account || { registered: false, email: '', ca: '' }; const rows = (state.acme.data?.certs || []).map(c => { const badge = certStatusBadge({ expired: c.expired, daysRemaining: c.days_remaining }); @@ -88,6 +320,7 @@ export default definePage({ actions: h('button', { class: 'btn btn-primary', 'on:click': () => issueCertModal(state) }, 'Issue Certificate'), }), + _accountCard(account), rows.length ? Table({ columns: ['Domain', 'Issuer', 'Expiry', 'Status', 'Actions'], diff --git a/webui/static/style.css b/webui/static/style.css index e28d903..dbe0b53 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -367,7 +367,7 @@ body { } /* Modal */ -.modal { +.modal-overlay { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.6); @@ -380,12 +380,16 @@ body { transition: opacity 0.2s, visibility 0.2s; } -.modal.show { +.modal-overlay.active { opacity: 1; visibility: visible; } -.modal-content { +.modal-overlay.active .modal { + transform: scale(1); +} + +.modal-overlay .modal { background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 10px; @@ -397,10 +401,13 @@ body { transition: transform 0.2s; } -.modal.show .modal-content { - transform: scale(1); +.modal-title { + margin: 0 0 1rem; + font-size: 1.1rem; } + + /* Toggle Switch */ .toggle-switch { position: relative;