Compare commits

..

3 Commits

Author SHA1 Message Date
mteehan 5025dfaf30 feat: add ACME account management with validation pipeline
- Register, view, and deactivate ACME accounts via API and UI
- 16-check validation framework for certificate issuance readiness
- DNS resolution, port, nginx, and firewall pre-flight checks
- External IP detection with NAT support and fallback providers
- Account card and settings modal in certificates page
- Guard certificate issuance behind account registration
- Update modal CSS to overlay-based approach
- 1000+ lines of tests for validation and account handlers
2026-06-23 14:24:19 +00:00
mteehan 3a325504ec gitignore: broaden PLAN.md pattern to *PLAN.md 2026-06-23 05:34:16 +00:00
mteehan 75aa6fb885 fix: wrap model data in named objects and fix renderGuardMulti empty check 2026-06-23 00:08:40 +00:00
23 changed files with 2086 additions and 148 deletions
+1 -1
View File
@@ -15,7 +15,7 @@ __pycache__/
# Local AI tool config (contains internal hostnames)
opencode.json
opencode.json.pwenv
PLAN.md
*PLAN.md
# Playwright MCP artifacts
.playwright-mcp/
+21 -17
View File
@@ -13,6 +13,9 @@ Flask ──→ daemon/client.py (Unix socket) ──→ vacuum-walld (aiohttp,
vacuum-walld ──→ daemon/handlers/*.py ──→ sudo <cmd> ──→ 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 <cmd> ──→ syste
- `config/<subsystem>/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": <value>}` — 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": <value>}` — 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
+443 -39
View File
@@ -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 _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
def _get_local_ips() -> set[str]:
"""Return the set of all non-loopback IPv4 addresses on this host."""
import struct
from fcntl import ioctl
def get_interfaces():
import struct
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()
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())
ips.add(str(ipaddress.IPv4Address(addr)))
except Exception:
pass
return ips
resolved = False
for _, _, _, _, addr in results:
if addr in local_ips:
resolved = True
break
if resolved:
def _get_external_ip(timeout: int = 5) -> 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 (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"
resolved_ips = [addr[4][0] for addr in results]
local_ips = _get_local_ips()
# Step 1: direct local match
for rip in resolved_ips:
if rip in local_ips:
return True, "DNS resolves correctly"
# Step 2: NAT — compare against external IP
external_ip = _get_external_ip()
if external_ip:
for rip in resolved_ips:
if rip == external_ip:
return (
True,
"DNS resolves correctly (matches external IP — server is behind NAT)",
)
# Resolved IP is public but doesn't match external IP
for rip in resolved_ips:
if not _is_private_ip(rip):
return (
False,
f"Domain resolves to {results[0][4][0]}, not this server",
f"Domain resolves to {rip} but external IP is {external_ip}. "
f"Check your DNS A record points to this server's public IP.",
)
# Step 3: external IP unavailable — check for private range first, then warn
for rip in resolved_ips:
if _is_private_ip(rip):
return (
False,
f"Domain resolves to private IP {rip}. "
f"Ensure public DNS points to this server's public IP.",
)
return (
False,
f"Domain resolves to {resolved_ips[0]}, not a local interface IP. "
f"Cannot verify via external IP (lookup failed).",
)
except socket.gaierror:
return False, "Domain does not resolve (NXDOMAIN or timeout)"
@@ -234,7 +306,10 @@ 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"
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,6 +631,7 @@ def _validate(domain: str) -> dict[str, Any]:
"blocking": blocking,
}
)
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": ""}
+3
View File
@@ -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")
+68 -3
View File
@@ -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
```
+90
View File
@@ -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`).
- `<domain>/` — 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 <email> --server <ca>` 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`
+6 -9
View File
@@ -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"
+4 -4
View File
@@ -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://<hostname>.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.
+2 -18
View File
@@ -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,7 +44,6 @@ 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)" \
" --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"
+3 -2
View File
@@ -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"
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
+65
View File
@@ -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(),
}
+3
View File
@@ -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
+2 -1
View File
@@ -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/*
+1 -1
View File
@@ -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
File diff suppressed because it is too large Load Diff
+61 -4
View File
@@ -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)
+15 -4
View File
@@ -93,16 +93,27 @@ modelRegister('nginx', {
fetch: async () => {
const r = await apiFetch('/api/proxy/domains');
if (!r.ok) throw new Error(r.error);
return r.data || [];
return { domains: r.data || [] };
},
});
modelRegister('acme', {
subsystem: 'acme',
fetch: async () => {
const r = await apiFetch('/api/certs/list');
if (!r.ok) throw new Error(r.error);
return 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;
},
});
+8 -2
View File
@@ -80,7 +80,7 @@ export function renderGuard(state, title, subtitle, data) {
*/
export function renderGuardMulti(title, subtitle, ...models) {
const combined = collectLoadingModels(...models);
return renderGuard(combined, title, subtitle);
return renderGuard(combined, title, subtitle, models.map(m => m.data));
}
/**
@@ -90,7 +90,13 @@ export function renderGuardMulti(title, subtitle, ...models) {
*/
function isEmpty(data) {
if (data === null || data === undefined || data === '') return true;
if (Array.isArray(data)) return data.length === 0;
if (Array.isArray(data)) {
// Array of model data values (from renderGuardMulti) — empty only if all models have no data
if (data.length === 0) return true;
return data.every(d => d === null || d === undefined ||
(Array.isArray(d) && d.length === 0) ||
(typeof d === 'object' && Object.keys(d).length === 0));
}
if (typeof data === 'object') return Object.keys(data).length === 0;
if (typeof data === 'number') return false;
return !data;
+3 -2
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vacuum Wall</title>
<link rel="stylesheet" href="/static/style.css">
<link rel="stylesheet" href="/static/style.css?v=8">
</head>
<body>
<div id="app">
@@ -13,7 +13,8 @@
<div class="main" id="main"></div>
</div>
</div>
<div id="modal-root"></div>
<script>window.__WS_URL__ = "__WS_URL_PLACEHOLDER__"</script>
<script type="module" src="/static/app.js?v=7"></script>
<script type="module" src="/static/app.js?v=8"></script>
</body>
</html>
+252 -19
View File
@@ -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' },
[
{ 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-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' },
[
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 = '<h2 class="modal-title">Account Settings</h2>'
+ '<div class="modal-body">'
+ '<div class="form-group"><label>Current Account</label><div class="text-sm">'
+ esc(account.email) + (account.ca ? ' (' + esc(account.ca) + ')' : '')
+ '</div></div>'
+ '<hr>'
+ '<div class="form-group"><label>Update Email</label>'
+ '<input id="set-email" type="email" placeholder="new@example.com"></div>'
+ '<hr>'
+ '<div class="text-danger"><strong>Danger Zone</strong></div>'
+ '<button class="btn btn-danger" data-action="set-deactivate">Deactivate Account</button>'
+ '</div><div class="modal-actions">'
+ '<button class="btn btn-outline" data-action="set-cancel">Cancel</button>'
+ '<button class="btn btn-primary" data-action="set-save">Save Email</button>'
+ '</div>';
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 = '<h2 class="modal-title">Issue Certificate</h2>'
+ '<div class="modal-body">'
+ '<div id="ic-account-info" class="text-sm mb-2">'
+ '<strong>Using account:</strong> ' + esc(accountBadge) + '</div>'
+ (registered
? '<div class="form-group"><label>Domain</label><input id="ic-domain" placeholder="example.com"></div>'
: '<div class="text-warning">Register an ACME account first</div>'
)
+ '<div id="ic-vresults"></div>'
+ '</div><div class="modal-actions">'
+ '<button class="btn btn-outline" data-action="ic-cancel">Cancel</button>'
+ (registered
? '<button class="btn btn-primary" data-action="ic-validate">Validate</button>'
: '<button class="btn btn-primary" disabled>Validate</button>'
+ '<button class="btn btn-outline" data-action="ic-register" style="margin-left:8px;">Register Account</button>'
)
+ '</div>';
_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 '<div>' + icon + ' <strong>' + esc(c.name) + '</strong>'
+ ': ' + '<span class="' + cls + '">' + esc(c.message) + '</span></div>';
}).join('');
inner.innerHTML = '<h2 class="modal-title">Validate: ' + esc(domain) + '</h2>'
+ '<div class="modal-body">'
+ '<div class="form-group"><label>Domain</label><input id="ic-domain" value="' + esc(domain) + '"></div>'
+ '<div id="ic-vresults">' + resultsHtml + '</div>'
+ '</div><div class="modal-actions">'
+ '<button class="btn btn-outline" data-action="ic-cancel">Cancel</button>'
+ '<button class="btn btn-outline" data-action="ic-validate" style="margin-right:8px;">Re-validate</button>'
+ '<button class="btn btn-primary" data-action="ic-issue"'
+ (ready ? '' : ' disabled') + '>Issue</button>'
+ '</div>';
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,7 +289,8 @@ export default definePage({
const guard = renderGuard(state.acme, 'Certificates', 'ACME certificate management', state.acme.data);
if (guard) return guard;
const rows = (state.acme.data || []).map(c => {
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 });
return h('tr', { key: c.domain },
@@ -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'],
+1 -1
View File
@@ -58,7 +58,7 @@ export default definePage({
const guard = renderGuardMulti('Proxy', 'Nginx reverse proxy', state.nginx, state.acme);
if (guard) return guard;
const domains = state.nginx.data || [];
const domains = state.nginx.data.domains || [];
const rows = domains.map(d => {
const certBadge = certStatusBadge({
certStatus: d.cert_status,
+1 -1
View File
@@ -57,7 +57,7 @@ export default definePage({
};
},
render(state) {
const guard = renderGuard(state.wireguard, 'WireGuard', null, state.wireguard.data?.peers);
const guard = renderGuard(state.wireguard, 'WireGuard', 'Tunnel & peer management', state.wireguard.data);
if (guard) return guard;
const st = state.wireguard.data?.status || {};
+12 -5
View File
@@ -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;