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
This commit is contained in:
2026-06-23 14:24:19 +00:00
parent 3a325504ec
commit 5025dfaf30
19 changed files with 2073 additions and 141 deletions
+449 -45
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 _get_local_ips() -> set[str]:
"""Return the set of all non-loopback IPv4 addresses on this host."""
import struct
from fcntl import ioctl
ips: set[str] = set()
with suppress(OSError):
ips.add(socket.gethostbyname(socket.gethostname()))
try:
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
names = b"\x00" * 4096
raw = ioctl(s.fileno(), 0x8912, names)
s.close()
for i in range(0, 4096, 32):
name = raw[i : i + 16].split(b"\x00")[0].decode()
if name == "lo":
continue
addr = struct.unpack("<I", raw[i + 16 : i + 20])[0]
ips.add(str(ipaddress.IPv4Address(addr)))
except Exception:
pass
return ips
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 via A record."""
"""Check that domain resolves to this machine's IP (NAT-aware).
1. Match against local interface IPs — pass immediately.
2. If no local match, compare against external IP for NAT scenarios.
3. If ext IP lookup fails, downgrade to non-blocking warning.
4. Private-range resolved IP always fails.
"""
try:
results = socket.getaddrinfo(domain, 80, socket.AF_UNSPEC, socket.SOCK_STREAM)
if not results:
return False, "Domain does not resolve to any address"
local_ips = set()
hostname = socket.gethostname()
with suppress(OSError):
local_ips.add(socket.gethostbyname(hostname))
# Also collect all interface IPs
try:
import ipaddress
from fcntl import ioctl
resolved_ips = [addr[4][0] for addr in results]
local_ips = _get_local_ips()
def get_interfaces():
import struct
# Step 1: direct local match
for rip in resolved_ips:
if rip in local_ips:
return True, "DNS resolves correctly"
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
names = b"\x00" * 4096
raw = ioctl(s.fileno(), 0x8912, names)
s.close()
ifaces = []
for i in range(0, 4096, 32):
name = raw[i : i + 16].split(b"\x00")[0].decode()
if name == "lo":
continue
addr = struct.unpack("<I", raw[i + 16 : i + 20])[0]
ifaces.append(str(ipaddress.IPv4Address(addr)))
return ifaces
# 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)",
)
local_ips.update(get_interfaces())
except Exception:
pass
# 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 {rip} but external IP is {external_ip}. "
f"Check your DNS A record points to this server's public IP.",
)
resolved = False
for _, _, _, _, addr in results:
if addr in local_ips:
resolved = True
break
# 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.",
)
if resolved:
return True, "DNS resolves correctly"
return (
False,
f"Domain resolves to {results[0][4][0]}, not this server",
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,7 +631,8 @@ def _validate(domain: str) -> dict[str, Any]:
"blocking": blocking,
}
)
ready = False
if blocking:
ready = False
return {"domain": domain, "checks": checks, "ready": ready}
@@ -360,10 +693,16 @@ async def issue_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, An
"""
if not body:
raise ValueError("Request body required")
domain = body.get("domain", "").strip()
domain = (body.get("domain") or "").strip()
if not domain:
raise ValueError("'domain' is required")
email = body.get("email", "").strip() or None
# email is kept for backward API compatibility but ignored —
# _run_issue() uses the registered account's email instead
provided_email = (body.get("email") or "").strip()
if provided_email:
logger.warning(
"email field in issue/start is ignored, using registered account's email"
)
webroot = body.get("webroot")
_clean_expired_issuances()
@@ -399,7 +738,6 @@ async def issue_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, An
req = IssueRequest(
request_id=request_id,
domain=domain,
email=email,
webroot=webroot,
steps=steps,
)
@@ -437,11 +775,9 @@ async def _run_issue(req: IssueRequest) -> None:
req.steps[0].status = "running"
args: list[str] = ["--issue", "-d", req.domain]
args.extend(["--webroot", req.webroot or str(_WEBROOT)])
contact = req.email
if not contact:
contact = _get_acme_email()
if contact:
args.extend(["-m", contact])
account_email = _get_acme_email()
if account_email:
args.extend(["-m", account_email])
args.append("--force")
output = _run_acme(args)
req.steps[0].status = "done"
@@ -647,3 +983,71 @@ def generate_self_signed(_request: Any, body: dict[str, Any] | None) -> dict[str
"key": str(key_file),
"generated": True,
}
@registry.register(GET_ACME_ACCOUNT)
def get_account(_request: Any, _body: Any) -> dict[str, Any]:
"""GET /acme/account — return ACME account information."""
return _get_account_info()
@registry.register(POST_ACME_ACCOUNT_REGISTER)
def register_account(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""POST /acme/account/register — register a new ACME account.
Raises:
ValueError: When email is missing or invalid.
"""
if not body:
raise ValueError("Request body required")
email = (body.get("email") or "").strip()
if not email:
raise ValueError("'email' is required")
import re as _re
if not _re.match(r"^[^@\s]+@[^@\s]+\.[^@\s]+$", email):
raise ValueError("Invalid email format")
server = (body.get("server") or "letsencrypt").strip()
_run_acme(["--register-account", "-m", email, "--server", server])
acme_cfg = PROJECT_DIR / "config" / "acme" / "config.json"
acme_cfg.parent.mkdir(parents=True, exist_ok=True)
from lib.common import load_json, save_json
acme_data = load_json(acme_cfg)
acme_data["email"] = email
acme_data["ca"] = server
save_json(acme_cfg, acme_data)
logger.info("ACME account registered: %s (%s)", email, server)
refresh_state(["acme"])
return {"registered": True, "email": email, "ca": server}
@registry.register(DELETE_ACME_ACCOUNT_DEACTIVATE)
def deactivate_account(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""DELETE /acme/account/deactivate — deactivate the ACME account."""
try:
_run_acme(["--deactivate-account"])
except RuntimeError as exc:
logger.warning("acme.sh deactivate failed: %s", exc)
acme_cfg = PROJECT_DIR / "config" / "acme" / "config.json"
if acme_cfg.is_file():
from lib.common import load_json, save_json
acme_data = load_json(acme_cfg)
acme_data.pop("email", None)
acme_data.pop("ca", None)
save_json(acme_cfg, acme_data)
account_conf = _ACME_HOME / ".account.conf"
if account_conf.is_file():
account_conf.unlink()
account_conf_no_dot = _ACME_HOME / "account.conf"
if account_conf_no_dot.is_file():
account_conf_no_dot.unlink()
logger.info("ACME account deactivated")
refresh_state(["acme"])
return {"email": ""}
+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")