Compare commits
3 Commits
b673e87c9b
...
5025dfaf30
| Author | SHA1 | Date | |
|---|---|---|---|
| 5025dfaf30 | |||
| 3a325504ec | |||
| 75aa6fb885 |
+1
-1
@@ -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/
|
||||
|
||||
@@ -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
@@ -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": ""}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
```
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/*
|
||||
|
||||
@@ -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
|
||||
|
||||
+1008
-2
File diff suppressed because it is too large
Load Diff
+61
-4
@@ -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
@@ -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;
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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'],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user