Compare commits

...

5 Commits

Author SHA1 Message Date
mteehan 835326311b Refactor nginx to path-based domain model with config migration
Replace the legacy top-level management key with a unified paths-based
model. Each domain now contains a paths map where each entry defines its
own backend, auth, headers, and flags (is_management, is_websocket).

- Add _migrate_config() to auto-migrate legacy formats on first load
- Remove set_management_proxy() and POST_NGINX_MANAGEMENT endpoint
- Update server_block.conf template to iterate paths with per-location auth
- Update daemon handler, API blueprint, state collector, and install script
- Add server config generation tests for paths, WebSocket, auth inheritance
- Update frontend proxy page to display per-path rows with flags
2026-06-27 23:34:06 +00:00
mteehan 8feb56faf6 fix: ECC cert support, ACME deploy hook path, NAT detection, and account config fallback
- Add find_cert_dir() to resolve both RSA and ECC (domain_ecc/) cert dirs
- Copy acme deploy hook to /deploy/ where acme.sh resolves it
- _parse_account_conf checks both legacy .account.conf and declarative config
- Skip public DNS check when all local IPs are private (NAT)
- Improve check message strings for validity and expiry status
- Support timezone-aware date formats in _days_until parsing
- Filter out "no" SAN domains in cert listing
- Bump frontend asset version cache keys
- Fix DOMContentLoaded race condition in app.js boot
- Fix spread operator in certs.js modal template
2026-06-27 14:23:40 +00:00
mteehan 398831b6e2 Refactor ACME module and add cert issuance conflict handling
- Move acme.sh utilities (_run_acme, _find_acme, etc.) from lib/state to lib/acme
- Rewrite _parse_list_output to support pipe, tab, and column-separated formats
- Add ConflictError (409) to block issuing when cert already exists
- Move _find_issuance helper to detect in-progress issuance per domain
- Update issue_cert to check existing certs and return issuance status
- Fix start_polling to accept event loop explicitly
- Add sudoers entry for chown on vacuum-wall.conf
- Extend systemd ReadWritePaths for /run/nginx.pid and /var/log/nginx
- Update frontend to handle 'existing' issuance status
2026-06-27 00:38:49 +00:00
mteehan feaf253403 Remove SPA catch-all, add vendor route and JSON 404 handler
The SPA uses hash-based routing, so the catch-all route was dead code.
Replace with explicit routes for / and /vendor/<path>, plus a 404
handler that returns JSON for /api/ paths and empty HTML otherwise.
2026-06-23 23:20:35 +00:00
mteehan e74f0a5ffb Remove unused htmx/json-enc vendor libs; fix missing htm.js symlink
- Remove vendor/htmx-2.0.4.min.js and vendor/json-enc-2.0.0.js (unused)
- Remove webui/static/htmx.min.js and webui/static/json-enc.js symlinks
- Add webui/static/vendor/htm.js symlink pointing to project root
  vendor/htm.js, fixing the module script MIME type error caused by
  hoover/html.js importing from a non-existent path
2026-06-23 21:34:57 +00:00
31 changed files with 1380 additions and 854 deletions
+8
View File
@@ -35,6 +35,12 @@ class BadRequest(Exception):
pass
class Conflict(Exception):
"""Raised when the daemon returns HTTP 409."""
pass
_DEFAULT_SOCKET = None
@@ -175,6 +181,8 @@ def request(
raise NotFound(data.get("error", str(exc))) from exc
if resp.status_code == 400:
raise BadRequest(data.get("error", str(exc))) from exc
if resp.status_code == 409:
raise Conflict(data.get("error", str(exc))) from exc
raise RuntimeError(data.get("error", str(exc))) from exc
if not data.get("ok"):
raise RuntimeError(data.get("error", "Unknown error"))
+83 -43
View File
@@ -15,6 +15,7 @@ from pathlib import Path
from typing import Any
from uuid import uuid4
import lib.acme
import lib.common as lib_common
from daemon.iface import (
DELETE_ACME_ACCOUNT_DEACTIVATE,
@@ -32,14 +33,16 @@ from daemon.iface import (
POST_ACME_SELF_SIGNED,
POST_ACME_VALIDATE,
)
from daemon.server import NotFoundError, refresh_state, registry
from lib.state import _run_acme
from daemon.server import ConflictError, NotFoundError, refresh_state, registry
from lib.acme import _run_acme
logger = logging.getLogger(__name__)
PROJECT_DIR = Path(__file__).resolve().parent.parent.parent
_ACME_HOME = PROJECT_DIR / "data" / "acme"
_DEPLOY_HOOK = str(PROJECT_DIR / "system" / "acme-deploy.sh")
# acme.sh resolves deploy hooks from $ACME_HOME/deploy/ -- _findHook
# only searches the deploy subdirectory, never accepts absolute paths.
_DEPLOY_HOOK = "acme-deploy.sh"
_ACME_ENVIRON = {
"HOME": str(PROJECT_DIR),
@@ -55,6 +58,14 @@ _ISSUANCES: dict[str, "IssueRequest"] = {}
_ISSUANCE_TTL = 300 # seconds to keep completed requests
def _find_issuance(domain: str) -> "IssueRequest | None":
"""Find an active (running) issuance request by domain."""
for req in _ISSUANCES.values():
if req.domain == domain and req.status == "running":
return req
return None
@dataclass
class IssueStep:
"""Single step in a certificate issuance workflow.
@@ -122,7 +133,7 @@ class IssueRequest:
def _find_acme_bin() -> str:
"""Return the path to the acme.sh binary."""
from lib.state import _find_acme
from lib.acme import _find_acme
return _find_acme()
@@ -172,7 +183,7 @@ def _check_domain_format(domain: str) -> tuple[bool, str]:
pattern = r"^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*$"
if not _re.match(pattern, domain):
return False, "Invalid domain name format"
return True, ""
return True, "Domain format is valid"
def _get_local_ips() -> set[str]:
@@ -332,14 +343,14 @@ def _check_challenge_config() -> tuple[bool, str]:
def _check_existing_cert(domain: str) -> tuple[bool, str]:
"""Warn if a valid cert already exists (not blocking)."""
try:
from lib.acme import days_until_expiry
days = days_until_expiry(domain)
if days is not None and days > 0:
return True, f"Valid certificate exists ({days} days remaining)"
except (ValueError, RuntimeError, FileNotFoundError):
pass
return True, ""
days = lib.acme.days_until_expiry(domain)
except (RuntimeError, FileNotFoundError):
return True, "No existing certificate found"
if days is None:
return True, "No existing certificate found"
if days > 0:
return True, f"Valid certificate exists ({days} days remaining)"
return True, f"Certificate expired ({abs(days)} days ago)"
def _check_nginx_running() -> tuple[bool, str]:
@@ -490,7 +501,11 @@ def _check_port_80_listening() -> tuple[bool, str]:
def _check_acme_account() -> tuple[bool, str]:
"""Non-blocking: check acme.sh account is configured."""
"""Non-blocking: check acme.sh account is configured.
Tries acme.sh --info first, then falls back to parsed account state
(handles both legacy .account.conf and modern declarative config).
"""
try:
acme_bin = _find_acme_bin()
acme_home_env = os.environ.get("ACME_HOME", str(_ACME_HOME))
@@ -513,14 +528,11 @@ def _check_acme_account() -> tuple[bool, str]:
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
from lib.state import _parse_account_conf
info = _parse_account_conf(_ACME_HOME)
if info.get("registered"):
return True, "ACME account is configured"
return (
False,
@@ -531,17 +543,14 @@ def _check_acme_account() -> tuple[bool, str]:
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.
Delegates to ``lib.state._parse_account_conf()`` which checks both
the legacy .account.conf and the declarative config/acme/config.json
used by modern acme.sh (v3.x).
"""
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:
from lib.state import _parse_account_conf
info = _parse_account_conf(_ACME_HOME)
if info.get("registered"):
return True, "ACME account is registered"
return False, "Register an ACME account before issuing certificates"
@@ -564,6 +573,11 @@ def _check_dns_public(domain: str) -> tuple[bool, str]:
if not local_ips:
return True, "Public DNS check skipped (no local IPs detected)"
# Behind NAT: public DNS can never match local (private) IPs.
# dns_resolves already verified the domain correctly via external IP.
if all(_is_private_ip(ip) for ip in local_ips):
return True, "Public DNS check skipped (NAT detected — dns_resolves verified)"
for dns_server in ("8.8.8.8", "1.1.1.1"):
try:
result = subprocess.run(
@@ -660,9 +674,23 @@ def get_cert_info(_request: Any, body: dict[str, Any] | None) -> dict:
raise ValueError("'domain' is required")
domain = body["domain"]
certs = list_certs(None, None)
req = _find_issuance(domain)
for c in certs:
if c["domain"] == domain or domain in c.get("san_domains", []):
return c
result = dict(c)
if req:
result["issuance"] = req.to_dict()
return result
# No cert found — check if there's an in-progress issuance
if req:
return {
"domain": domain,
"status": "issuing",
"issuance": req.to_dict(),
}
raise NotFoundError(f"No certificate found for domain: {domain}")
@@ -689,7 +717,6 @@ async def issue_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, An
Raises:
ValueError: When domain is missing.
RuntimeError: When pre-flight checks fail.
"""
if not body:
raise ValueError("Request body required")
@@ -707,16 +734,28 @@ async def issue_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, An
_clean_expired_issuances()
# Dedup: if domain already has an active request, return existing ID
# Dedup: if domain already has an active request, return it
for existing in _ISSUANCES.values():
if existing.domain == domain and existing.status == "running":
return {
"request_id": existing.request_id,
"status": "existing",
"domain": domain,
"message": "Issuance already in progress for this domain",
"status": "existing",
}
# Check if cert already exists — call acme.sh directly, not via state
try:
certs = lib.acme.list_certs()
except RuntimeError as exc:
raise RuntimeError(f"Cannot check existing certificates: {exc}") from exc
for c in certs:
if c["domain"] == domain or domain in c.get("san_domains", []):
days = c.get("days_until_expiry")
if days is not None and days >= 0:
raise ConflictError(
f"Certificate already exists for {domain} ({days} day{'s' if days != 1 else ''} remaining). Renew instead."
)
# Run pre-flight checks
_validate_checks = _validate(domain)
if not _validate_checks["ready"]:
@@ -910,13 +949,14 @@ def get_cert_paths(_request: Any, body: dict[str, Any] | None) -> dict[str, str]
if not body or "domain" not in body:
raise ValueError("'domain' is required")
domain = body["domain"]
acme_home_env = os.environ.get("ACME_HOME", str(_ACME_HOME))
acme_home = str(Path(acme_home_env) / domain)
from lib.acme import find_cert_dir
cert_dir = str(find_cert_dir(domain, _ACME_HOME))
return {
"cert": f"{acme_home}/{domain}.cert",
"key": f"{acme_home}/{domain}.key",
"ca": f"{acme_home}/ca.cer",
"fullchain": f"{acme_home}/fullchain.cer",
"cert": f"{cert_dir}/{domain}.cert",
"key": f"{cert_dir}/{domain}.key",
"ca": f"{cert_dir}/ca.cer",
"fullchain": f"{cert_dir}/fullchain.cer",
}
+181 -175
View File
@@ -17,12 +17,12 @@ from daemon.iface import (
POST_NGINX_CONFIG,
POST_NGINX_DOMAINS_ADD,
POST_NGINX_DOMAINS_UPDATE,
POST_NGINX_MANAGEMENT,
POST_NGINX_RELOAD,
POST_NGINX_SSL_APPLY,
POST_NGINX_TEST,
)
from daemon.server import NotFoundError, refresh_state, registry
from lib.acme import find_cert_dir
from lib.common import ensure_dirs, load_json, run, run_proc, save_json
logger = logging.getLogger(__name__)
@@ -58,11 +58,53 @@ DEFAULT_SSL: dict[str, Any] = {
DEFAULT_CONFIG: dict[str, Any] = {
"domains": {},
"management": None,
"ssl": {**DEFAULT_SSL},
}
def _migrate_config(raw: dict[str, Any]) -> dict[str, Any]:
"""Migrate legacy config formats to the new paths-based model."""
if "management" in raw and raw["management"] is not None:
mgmt = raw["management"]
mgmt_domain = mgmt.get("domain", "")
if mgmt_domain:
domains = raw.setdefault("domains", {})
if mgmt_domain not in domains:
domains[mgmt_domain] = {
"force_ssl": True,
"paths": {},
}
dom = domains[mgmt_domain]
paths = dom.setdefault("paths", {})
if "/" not in paths:
paths["/"] = {
"backend": {
"host": mgmt.get("backend", {}).get("host", "127.0.0.1"),
"port": mgmt.get("backend", {}).get("port", 9090),
"proto": "http",
},
"is_management": True,
}
if mgmt.get("auth"):
paths["/"]["auth"] = mgmt["auth"]
if "/ws" not in paths:
paths["/ws"] = {
"backend": {"host": "127.0.0.1", "port": 9091, "proto": "http"},
"is_websocket": True,
}
del raw["management"]
for dom in raw.get("domains", {}).values():
if "paths" not in dom and "backend" in dom:
dom["paths"] = {
"/": {
"backend": dom.pop("backend"),
"headers": dom.pop("headers", {}),
}
}
return raw
def _get_state() -> dict[str, Any] | None:
"""Retrieve cached nginx state from the state store."""
from lib.state import state as state_store
@@ -71,60 +113,55 @@ def _get_state() -> dict[str, Any] | None:
def _get_config() -> dict[str, Any]:
"""Load the nginx config JSON, applying defaults for missing fields.
Returns:
The parsed config dict with ssl defaults filled in.
"""
"""Load the nginx config JSON, applying defaults and migrations."""
ensure_dirs(CONFIG_DIR, SITES_DIR)
raw = load_json(CONFIG_FILE)
if not raw:
raw = deepcopy(DEFAULT_CONFIG)
if "ssl" not in raw:
raw["ssl"] = deepcopy(DEFAULT_SSL)
raw = _migrate_config(raw)
_save_config(raw)
return raw
def _save_config(cfg: dict[str, Any]) -> None:
"""Persist the nginx config dict to disk.
Args:
cfg: The config dictionary to save.
"""
"""Persist the nginx config dict to disk."""
save_json(CONFIG_FILE, cfg)
def _generate_server_conf(domain_cfg: dict[str, Any]) -> str:
"""Render an nginx server block config from a domain entry via Jinja.
Args:
domain_cfg: Domain config dict containing domain name, backend, headers, etc.
Returns:
Rendered server block as a string.
"""
"""Render an nginx server block config from a domain entry via Jinja."""
tmpl = ENV.get_template("nginx/server_block.conf")
acme_home_path = PROJECT_DIR / "data" / "acme"
acme_cert_dir = str(find_cert_dir(domain_cfg["domain"], acme_home_path))
paths = domain_cfg.get("paths", {})
has_management = any(p.get("is_management") for p in paths.values())
# Resolve custom cert paths for cert=="file"
cert_cfg = domain_cfg.get("cert")
if isinstance(cert_cfg, dict):
cert_path = cert_cfg.get("cert_path", "")
cert_key_path = cert_cfg.get("cert_key_path", "")
else:
cert_path = domain_cfg.get("cert_path", "")
cert_key_path = domain_cfg.get("cert_key_path", "")
return tmpl.render(
domain=domain_cfg["domain"],
backend=domain_cfg.get("backend", {}),
headers=domain_cfg.get("headers", {}),
paths=paths,
force_ssl=domain_cfg.get("force_ssl", True),
cert=domain_cfg.get("cert"),
auth=domain_cfg.get("auth"),
is_management=False,
acme_home=str(PROJECT_DIR / "data" / "acme"),
cert_path=cert_path,
cert_key_path=cert_key_path,
domain_auth=domain_cfg.get("auth"),
has_management=has_management,
acme_cert_dir=acme_cert_dir,
certs_dir=str(PROJECT_DIR / "data" / "certs"),
acme_webroot=str(PROJECT_DIR / "data" / "acme" / "www"),
)
def _write_site(domain: str, conf_text: str) -> None:
"""Atomically write a single site config file into sites-enabled.
Args:
domain: Site name used as the filename.
conf_text: Rendered nginx server block content.
"""
"""Atomically write a single site config file into sites-enabled."""
ensure_dirs(SITES_DIR)
path = SITES_DIR / f"{domain}.conf"
tmp = path.with_suffix(".tmp")
@@ -167,11 +204,7 @@ def _write_ssl_snippet() -> None:
def _test_config() -> tuple[bool, str]:
"""Run `nginx -t` to validate the current config.
Returns:
Tuple of (passed, message).
"""
"""Run `nginx -t` to validate the current config."""
result = run_proc(["nginx", "-t"], sudo=True, check=False)
ok = result.returncode == 0
output = (result.stderr or result.stdout or "").strip()
@@ -181,10 +214,7 @@ def _test_config() -> tuple[bool, str]:
def _reload_nginx() -> None:
"""Send SIGHUP to nginx to reload its configuration.
Logs an error if the reload fails.
"""
"""Send SIGHUP to nginx to reload its configuration."""
result = run_proc(["nginx", "-s", "reload"], sudo=True, check=False)
if result.returncode != 0:
logger.error("nginx reload failed: %s", result.stderr.strip())
@@ -193,10 +223,7 @@ def _reload_nginx() -> None:
def _write_all_sites() -> None:
"""Regenerate all site configs, management proxy, and ACME challenge site.
Removes stale .conf files that are no longer in config.
"""
"""Regenerate all site configs and ACME challenge site."""
ensure_dirs(SITES_DIR)
cfg = _get_config()
existing = set(SITES_DIR.iterdir()) if SITES_DIR.exists() else set()
@@ -206,25 +233,11 @@ def _write_all_sites() -> None:
conf = _generate_server_conf(dom_copy)
_write_site(name, conf)
written.add(f"{name}.conf")
if cfg.get("management"):
mgmt = cfg["management"]
tmpl = ENV.get_template("nginx/server_block.conf")
mgmt_conf = tmpl.render(
domain=mgmt.get("domain"),
backend=dict(
mgmt.get("backend", {}), host="127.0.0.1", port=9090, proto="http"
),
headers={},
force_ssl=True,
cert=None,
auth=mgmt.get("auth"),
is_management=True,
acme_home=str(PROJECT_DIR / "data" / "acme"),
certs_dir=str(PROJECT_DIR / "data" / "certs"),
acme_webroot=str(PROJECT_DIR / "data" / "acme" / "www"),
)
_write_site("management", mgmt_conf)
written.add("management.conf")
old_mgmt = SITES_DIR / "management.conf"
if old_mgmt.exists() and old_mgmt.name not in written:
old_mgmt.unlink()
for old in existing:
if old.suffix == ".conf" and old.name not in written:
old.unlink()
@@ -240,26 +253,14 @@ def _write_all_sites() -> None:
def _hash_password(password: str) -> str:
"""Hash *password* using SHA-256 crypt via passlib.
Args:
password: Plain-text password to hash.
Returns:
The hashed password string suitable for ``.htpasswd``.
"""
"""Hash *password* using SHA-256 crypt via passlib."""
from passlib.hash import sha256_crypt
return sha256_crypt.hash(password)
def _write_htpasswd(user: str, password: str) -> None:
"""Add or update a user entry in the .htpasswd file using SHA-256 hashing.
Args:
user: The username to add or update.
password: Plain-text password to hash.
"""
"""Add or update a user entry in the .htpasswd file using SHA-256 hashing."""
ensure_dirs(DATA_DIR)
hashed = _hash_password(password)
existing: dict[str, str] = {}
@@ -295,11 +296,7 @@ def _get_nginx_state() -> dict[str, Any]:
@registry.register(GET_NGINX_CONFIG)
def get_config(_request: Any, _body: Any) -> dict[str, Any]:
"""GET /nginx/config — return current nginx config.
Returns:
Full config dict from state cache, or fallback to file.
"""
"""GET /nginx/config — return current nginx config."""
ng = _get_nginx_state()
if ng:
return ng.get("config", {})
@@ -308,11 +305,7 @@ def get_config(_request: Any, _body: Any) -> dict[str, Any]:
@registry.register(POST_NGINX_CONFIG)
def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""POST /nginx/config — replace the entire nginx config and refresh state.
Raises:
ValueError: When request body is missing.
"""
"""POST /nginx/config — replace the entire nginx config and refresh state."""
if not body:
raise ValueError("Request body required")
_save_config(body)
@@ -322,11 +315,7 @@ def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str,
@registry.register(PATCH_NGINX_CONFIG)
def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""PATCH /nginx/config — deep-merge partial updates into current config.
Raises:
ValueError: When request body is missing.
"""
"""PATCH /nginx/config — deep-merge partial updates into current config."""
if not body:
raise ValueError("Request body required")
from lib.common import deep_merge
@@ -340,11 +329,7 @@ def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
@registry.register(GET_NGINX_DOMAINS)
def get_domains(_request: Any, _body: Any) -> list[dict[str, Any]]:
"""GET /nginx/domains — return the list of configured proxy domains.
Returns:
Domains list from state cache, or empty list.
"""
"""GET /nginx/domains — return the list of configured proxy domains."""
ng = _get_nginx_state()
if ng:
return ng.get("domains", [])
@@ -355,39 +340,82 @@ def get_domains(_request: Any, _body: Any) -> list[dict[str, Any]]:
def add_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""POST /nginx/domains/add — add a new reverse-proxy domain entry.
Raises:
ValueError: When required fields (domain, backend_host, backend_port) are missing.
ValueError: When the domain already exists.
Accepts either legacy backend_* fields or a ``paths`` map.
"""
if not body:
raise ValueError("Request body required")
domain = body.get("domain", "").strip()
backend_host = body.get("backend_host", "").strip()
backend_port = body.get("backend_port")
backend_proto = body.get("backend_proto", "http").strip() or "http"
cert = body.get("cert")
extra_headers = body.get("extra_headers")
if not domain:
raise ValueError("'domain' is required")
if not backend_host:
raise ValueError("'backend_host' is required")
if backend_port is None:
raise ValueError("'backend_port' is required")
cfg = _get_config()
if domain in cfg["domains"]:
raise ValueError(f"Domain {domain!r} already configured")
entry: dict[str, Any] = {
"backend": {
"host": backend_host,
"port": int(backend_port),
"proto": backend_proto,
},
"force_ssl": True,
}
if cert is not None:
entry["cert"] = cert
if extra_headers is not None:
entry["headers"] = extra_headers
paths = body.get("paths")
cert = body.get("cert")
force_ssl = body.get("force_ssl", True)
if paths is not None:
entry: dict[str, Any] = {
"paths": paths,
"force_ssl": force_ssl,
}
if cert is not None:
entry["cert"] = cert
else:
backend_host = body.get("backend_host", "").strip()
backend_port = body.get("backend_port")
backend_proto = body.get("backend_proto", "http").strip() or "http"
extra_headers = body.get("extra_headers")
if not backend_host:
raise ValueError("'backend_host' is required")
if backend_port is None:
raise ValueError("'backend_port' is required")
entry = {
"paths": {
"/": {
"backend": {
"host": backend_host,
"port": int(backend_port),
"proto": backend_proto,
},
"headers": extra_headers or {},
}
},
"force_ssl": force_ssl,
}
if cert is not None:
entry["cert"] = cert
# Handle auth credentials for management domain
auth_user = body.get("auth_user", "").strip()
auth_pass = body.get("auth_pass", "")
if auth_user and auth_pass:
_write_htpasswd(auth_user, auth_pass)
auth_dict = {"user": auth_user, "htpasswd": str(HTPASSWD_FILE)}
paths_entry = entry.get("paths", {})
for _ppath, pcfg in paths_entry.items():
if pcfg.get("is_management"):
pcfg["auth"] = auth_dict
break
entry["auth"] = auth_dict
# Handle auth credentials for management paths
auth_user = body.get("auth_user", "").strip()
auth_pass = body.get("auth_pass", "").strip()
if auth_user and auth_pass:
_write_htpasswd(auth_user, auth_pass)
auth_entry = {
"user": auth_user,
"htpasswd": str(HTPASSWD_FILE),
}
# Store auth on root path if it exists
root_path = entry.get("paths", {}).get("/")
if root_path:
root_path["auth"] = auth_entry
# Also store at domain level for template
entry["auth"] = auth_entry
cfg["domains"][domain] = entry
_save_config(cfg)
refresh_state(["nginx"])
@@ -396,12 +424,7 @@ def add_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
@registry.register(DELETE_NGINX_DOMAINS_REMOVE)
def remove_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""DELETE /nginx/domains/remove — remove a domain from the proxy config.
Raises:
ValueError: When request body or domain field is missing.
NotFoundError: When the domain is not configured.
"""
"""DELETE /nginx/domains/remove — remove a domain from the proxy config."""
if not body:
raise ValueError("Request body required")
domain = body.get("domain", "").strip()
@@ -421,12 +444,7 @@ def remove_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
@registry.register(POST_NGINX_DOMAINS_UPDATE)
def update_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""POST /nginx/domains/update — patch fields of an existing domain entry.
Raises:
ValueError: When request body or domain field is missing.
NotFoundError: When the domain is not configured.
"""
"""POST /nginx/domains/update — patch fields of an existing domain entry."""
if not body:
raise ValueError("Request body required")
domain = body.get("domain", "").strip()
@@ -435,9 +453,36 @@ def update_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
cfg = _get_config()
if domain not in cfg["domains"]:
raise NotFoundError(f"Domain {domain!r} not configured")
updates = {k: v for k, v in body.items() if k != "domain"}
entry = cfg["domains"][domain]
# Path removal: if body has `path` key (string) but no `paths`/`backend`/`headers`
path_to_remove = body.get("path")
if path_to_remove is not None and "paths" not in body and "backend" not in body and "headers" not in body:
paths = entry.get("paths", {})
if path_to_remove in paths:
del paths[path_to_remove]
if not paths:
entry.pop("paths", None)
_save_config(cfg)
refresh_state(["nginx"])
return {"domain": domain, "path_removed": path_to_remove}
updates = {k: v for k, v in body.items() if k not in ("domain", "path")}
if "paths" in updates:
entry["paths"] = updates["paths"]
else:
paths = entry.setdefault("paths", {})
if "backend" in updates:
root = paths.setdefault("/", {"backend": {}, "headers": {}})
root["backend"] = updates["backend"]
if "headers" in updates:
root = paths.setdefault("/", {"backend": {}, "headers": {}})
root["headers"] = updates["headers"]
for key, val in updates.items():
if key in ("backend", "headers", "paths"):
continue
if isinstance(val, dict) and key in entry:
entry[key].update(val)
else:
@@ -449,11 +494,7 @@ def update_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
@registry.register(POST_NGINX_APPLY)
def apply(_request: Any, _body: Any) -> dict[str, Any]:
"""POST /nginx/apply — render all configs, test, and reload nginx.
Raises:
RuntimeError: When the nginx config test fails.
"""
"""POST /nginx/apply — render all configs, test, and reload nginx."""
_write_ssl_snippet()
_write_all_sites()
_write_include_file()
@@ -467,11 +508,7 @@ def apply(_request: Any, _body: Any) -> dict[str, Any]:
@registry.register(POST_NGINX_TEST)
def test(_request: Any, _body: Any) -> dict[str, Any]:
"""POST /nginx/test — dry-run validate the live nginx config without applying.
Returns:
Dict with valid (bool) and output (str) from `nginx -t`.
"""
"""POST /nginx/test — dry-run validate the live nginx config without applying."""
valid, output = _test_config()
return {"valid": valid, "output": output}
@@ -484,37 +521,6 @@ def ssl_apply(_request: Any, _body: Any) -> dict[str, Any]:
return {"applied": True}
@registry.register(POST_NGINX_MANAGEMENT)
def set_management_proxy(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""POST /nginx/management — configure the management UI reverse proxy.
Raises:
ValueError: When request body or domain field is missing.
"""
if not body:
raise ValueError("Request body required")
domain = body.get("domain", "").strip()
if not domain:
raise ValueError("'domain' is required")
flask_host = body.get("flask_host", "127.0.0.1").strip() or "127.0.0.1"
flask_port = body.get("flask_port", 9090)
auth_user = body.get("auth_user")
auth_pass = body.get("auth_pass")
cfg = _get_config()
entry: dict[str, Any] = {
"domain": domain,
"backend": {"host": flask_host, "port": int(flask_port), "proto": "http"},
}
if auth_user:
entry["auth"] = {"user": auth_user, "htpasswd": str(HTPASSWD_FILE)}
cfg["management"] = entry
_save_config(cfg)
if auth_user and auth_pass:
_write_htpasswd(auth_user, auth_pass)
refresh_state(["nginx"])
return {"domain": domain}
@registry.register(POST_NGINX_RELOAD)
def reload_nginx(_request: Any, _body: Any) -> dict[str, Any]:
"""POST /nginx/reload — trigger an nginx reload (SIGHUP)."""
+1 -1
View File
@@ -43,7 +43,7 @@ POST_NGINX_DOMAINS_UPDATE: Endpoint = _ep("POST", "/nginx/domains/update")
POST_NGINX_APPLY: Endpoint = _ep("POST", "/nginx/apply")
POST_NGINX_TEST: Endpoint = _ep("POST", "/nginx/test")
POST_NGINX_SSL_APPLY: Endpoint = _ep("POST", "/nginx/ssl-apply")
POST_NGINX_MANAGEMENT: Endpoint = _ep("POST", "/nginx/management")
POST_NGINX_RELOAD: Endpoint = _ep("POST", "/nginx/reload")
# ---- Firewall ----
+11 -3
View File
@@ -159,6 +159,12 @@ class NotFoundError(Exception):
pass
class ConflictError(Exception):
"""Raised when a request conflicts with an existing resource."""
pass
def ok(data: Any = None) -> web.Response:
"""Create a success JSON response.
@@ -247,6 +253,8 @@ async def _handle_request(request: web.Request) -> web.Response:
result = await result
except NotFoundError as exc:
return error(str(exc), 404)
except ConflictError as exc:
return error(str(exc), 409)
except ValueError as exc:
return error(str(exc), 400)
except RuntimeError as exc:
@@ -420,10 +428,10 @@ async def _poll_loop(subsystem: str, interval: int) -> None:
await asyncio.sleep(interval)
def start_polling() -> None:
def start_polling(loop: asyncio.AbstractEventLoop) -> None:
"""Start one poll loop task per subsystem."""
for subsystem, interval in _POLL_INTERVALS.items():
task = asyncio.create_task(_poll_loop(subsystem, interval))
task = loop.create_task(_poll_loop(subsystem, interval))
task.add_done_callback(_poll_tasks.discard)
_poll_tasks.add(task)
@@ -547,7 +555,7 @@ def main() -> None:
for subsystem in state_store.SUBSYSTEMS:
if state_store.get(subsystem) is not None:
state_store.bump(subsystem)
loop.run_until_complete(start_polling())
start_polling(loop)
logger.info("vacuum-walld listening on %s", socket_path)
logger.info("WebSocket on 127.0.0.1:%d", _WS_PORT)
+20 -27
View File
@@ -715,13 +715,15 @@ Write the global nginx SSL snippet configuration.
GET /api/proxy/domains
```
Return all configured proxy domains.
Return all configured proxy domains. The response is flattened by path — each path within a domain produces a separate entry.
**Response:**
| Field | Type | Description |
|-------|------|-------------|
| `data` | `[object, ...]` | Array of domain configuration objects |
| `data` | `[object, ...]` | Array of path-level domain configuration objects |
Each entry contains `domain` (string), `path` (string), `backend` (object with `host`, `port`, `proto`), `online` (boolean), `force_ssl` (boolean), and optionally `is_management` or `is_websocket` flags.
---
@@ -731,9 +733,18 @@ Return all configured proxy domains.
POST /api/proxy/domains
```
Add a new reverse proxy domain.
Add a new reverse proxy domain. Accepts two modes:
**Request Body:**
**Paths mode (preferred):**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `domain` | `string` | Yes | Domain name to proxy |
| `paths` | `object` | Yes | Path-to-config map. Each path entry must have a `backend` key with `host`, `port`, `proto`. |
| `cert` | `string` | No | Certificate type |
| `force_ssl` | `boolean` | No | HTTPS redirect flag (default `true`) |
**Legacy mode (backward compatible):**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
@@ -741,7 +752,7 @@ Add a new reverse proxy domain.
| `backend_host` | `string` | Yes | Backend server IP or hostname |
| `backend_port` | `number` | Yes | Backend server port |
| `backend_proto` | `string` | No | Backend protocol (`"http"` or `"https"`); defaults to `"http"` |
| `cert` | `string` | No | Certificate domain |
| `cert` | `string` | No | Certificate type |
| `extra_headers` | `object` | No | Extra proxy headers |
**Response (`data`):**
@@ -774,9 +785,9 @@ Returns HTTP `404` if the domain is not configured.
PUT /api/proxy/domains/<domain>
```
Update one or more fields of an existing domain entry. Only fields present in the body are modified.
Update one or more fields of an existing domain entry. Only fields present in the body are modified. Supports both domain-level keys (`paths`, `force_ssl`, `cert`, `auth`) and path-level shorthand (`backend`, `headers` for the root path).
**Request Body:** Any subset of (`backend_host`, `backend_port`, `backend_proto`, `cert`, `extra_headers`).
**Request Body:** Any subset of (`paths`, `backend`, `backend_host`, `backend_port`, `backend_proto`, `cert`, `extra_headers`, `force_ssl`, `auth`).
**Response (`data`):**
@@ -837,27 +848,9 @@ Run `nginx -t` against the generated configuration without reloading.
**Error (invalid):** HTTP `400` with standard `{"ok": false, "error": "<nginx output>"}` response.
### Management
### Management Proxy
#### Configure Management WebUI Proxy
```
POST /api/proxy/management
```
Configure the nginx proxy block for the management WebUI itself, including optional HTTP basic authentication.
**Request Body:**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `domain` | `string` | Yes | Management domain (e.g., `"myhost.local"`) |
| `flask_host` | `string` | No | Flask app bind host; defaults to `"127.0.0.1"` |
| `flask_port` | `number` | No | Flask app bind port; defaults to `9090` |
| `auth_user` | `string` | No | Username for basic auth |
| `auth_pass` | `string` | No | Password for basic auth |
**Response:** `data` is `null` on success.
>The legacy `POST /api/proxy/management` endpoint has been removed. The management WebUI proxy is now configured as a regular domain entry with `is_management: true` on the root path and `is_websocket: true` on the `/ws` path. Use the standard domain add/update endpoints to configure it.
---
+86 -36
View File
@@ -76,35 +76,64 @@ Additional dnsmasq directives can be appended verbatim by placing plain-text fil
**File**: `config/nginx/config.json`
This file defines reverse proxy domains, the management interface, and global SSL settings. The application renders it into per-domain server block files in `data/nginx/sites-enabled/` and into the shared SSL snippet at `/etc/nginx/snippets/vacuum-wall-ssl.conf`.
This file defines reverse proxy domains with path-based routing, and global SSL settings. The application renders it into per-domain server block files in `data/nginx/sites-enabled/` and into the shared SSL snippet at `/etc/nginx/snippets/vacuum-wall-ssl.conf`.
```json
{
"domains": {
"app.example.com": {
"backend": {
"host": "192.168.2.50",
"port": 8080,
"proto": "http"
},
"force_ssl": true,
"cert": "acme",
"headers": {
"X-Forwarded-Proto": "https",
"X-Real-IP": "$remote_addr"
"auth": {
"user": "admin",
"htpasswd": "/home/wall/vacuum-wall/data/nginx/.htpasswd"
},
"paths": {
"/": {
"backend": {
"host": "192.168.2.50",
"port": 8080,
"proto": "http"
},
"headers": {
"X-Forwarded-Proto": "https"
}
},
"/api": {
"backend": {
"host": "192.168.2.51",
"port": 3000,
"proto": "http"
},
"auth": null
}
}
}
},
"management": {
"domain": "vacuum-wall.local",
"backend": {
"host": "127.0.0.1",
"port": 9090,
"proto": "http"
},
"auth": {
"user": "admin",
"htpasswd": "/home/wall/vacuum-wall/data/nginx/.htpasswd"
"mgmt.example.com": {
"force_ssl": true,
"cert": "acme",
"paths": {
"/": {
"backend": {
"host": "127.0.0.1",
"port": 9090,
"proto": "http"
},
"is_management": true,
"auth": {
"user": "admin",
"htpasswd": "/home/wall/vacuum-wall/data/nginx/.htpasswd"
}
},
"/ws": {
"backend": {
"host": "127.0.0.1",
"port": 9091,
"proto": "http"
},
"is_websocket": true
}
}
}
},
"ssl": {
@@ -117,17 +146,40 @@ This file defines reverse proxy domains, the management interface, and global SS
### Domain Entries
The `domains` object maps domain names (keys) to proxy configurations. Each entry produces a separate nginx `server` block.
The `domains` object maps domain names (keys) to proxy configurations. Each entry produces a separate nginx `server` block. All routing is path-based — a domain can proxy multiple paths to different backends.
| Field | Type | Required | Description |
|---|---|---|---|
| `backend` | object | Yes | The upstream service that receives proxied traffic. |
| `paths` | object | Yes | Path-to-config map. Each key is a URL path (e.g., `"/"`, `"/api"`). No catch-all unless `"/"` is explicitly defined. |
| `force_ssl` | boolean | No | Enable HTTPS redirect. HTTP requests to this domain receive a 301 redirect to HTTPS. Default: `true`. |
| `cert` | string | No | Certificate provisioning method. One of: `"acme"`, `"file"`, or `"selfsigned"`. Omit for domains that don't need a dedicated cert. |
| `auth` | object | No | Domain-level HTTP basic auth configuration (`{ user, htppasswd }`). Applies to all paths unless overridden at the path level. |
### Path Entries
Each entry under `paths` defines a location block and its proxy backend.
| Field | Type | Required | Description |
|---|---|---|---|
| `backend` | object | Yes | The upstream service for this path. |
| `backend.host` | string | Yes | IP address or hostname of the backend service. |
| `backend.port` | integer | Yes | Port the backend service is listening on. |
| `backend.proto` | string | No | Protocol for the backend connection: `http` or `https`. Default: `http`. |
| `force_ssl` | boolean | No | Enable HTTPS redirect. HTTP requests to this domain receive a 301 redirect to HTTPS. Default: `true`. |
| `headers` | object | No | Custom headers to set on proxied requests. Supports nginx variable interpolation (e.g., `$remote_addr`). |
| `cert` | string | No | Certificate provisioning method. One of: `"acme"`, `"file"`, or `"selfsigned"`. Omit for domains that don't need a dedicated cert. |
| `backend.proto` | string | No | Protocol: `http` or `https`. Default: `http`. |
| `headers` | object | No | Custom proxy headers (key-value pairs). Supports nginx variable interpolation. |
| `auth` | object \| null | No | Path-level auth override. `{ user, htppasswd }` replaces domain-level auth. `null` disables auth for this path. |
| `is_management` | boolean | No | Marks this path as the Vacuum Wall WebUI backend. Suppresses security headers (X-Frame-Options, etc.) so the SPA works correctly. |
| `is_websocket` | boolean | No | Marks this path as a WebSocket pass-through. Disables auth, sets Upgrade/Connection headers, uses extended timeouts. |
### Auth Inheritance Rules
- Domain-level `auth` applies to all paths unless overridden.
- Path-level `auth: null` means "no auth" for that path.
- Path-level `auth: { ... }` overrides domain-level for that path.
- No other domain-level settings inherit — `headers` is path-only.
### Path ordering
Nginx evaluates `location` blocks by specificity: more specific prefixes (e.g., `/api`) always match before `/` by nginx's own priority rules. The order of keys in the `paths` dict does not affect routing behavior.
### Certificate Types
@@ -141,22 +193,20 @@ The `cert` field is a string that selects the provisioning method:
### Management Domain
The `management` block configures the Vacuum Wall admin interface itself. It follows the same structure as a domain entry but can include an `auth` block for HTTP Basic Authentication.
The Vacuum Wall admin interface is configured as a regular domain entry under `domains`, with `is_management: true` on the path pointing to the Flask app. A second path (`/ws`) with `is_websocket: true` provides WebSocket pass-through for real-time state updates. This replaces the legacy `management` top-level key.
| Field | Type | Required | Description |
|---|---|---|---|
| `domain` | string | Yes | The hostname used to access the management WebUI (e.g., `<hostname>.local`). |
| `backend` | object | Yes | Points to the Flask app at `127.0.0.1:9090`. |
| `auth` | object | No | HTTP Basic Authentication configuration. Only created if `auth_user` is provided when setting the management proxy. |
| `auth.user` | string | Yes | Username for the `.htpasswd` file. |
| `auth.htpasswd` | string | Yes | Full path to the `.htpasswd` file containing the username and hashed password. |
The application can create the `.htpasswd` file programmatically via `write_htpasswd()` (using passlib's `apache_passwd` with Apache-Round-12, falling back to SHA-256 crypt). Manual creation is also possible:
The application can create the `.htpasswd` file programmatically via `write_htpasswd()` (using passlib's SHA-256 crypt). Manual creation is also possible:
```bash
htpasswd -bc data/nginx/.htpasswd admin yourpassword
```
### Backward Compatibility
Config files using the legacy format are auto-migrated on first load:
- Domain entries with a top-level `backend` key are wrapped into `paths["/"]`.
- A legacy `management` top-level key is migrated into `domains[management.domain]` with `is_management` on the root path and a `/ws` WebSocket path.
### Global SSL Settings
The `ssl` block defines TLS parameters applied to all HTTPS server blocks via the shared snippet.
+19 -7
View File
@@ -218,8 +218,12 @@ else
log "acme.sh already installed."
fi
# Ensure the acme deploy hook script has correct permissions
chmod 0755 "${PROJECT_DIR}/system/acme-deploy.sh"
# Install the deploy hook into acme.sh's deploy directory
# (acme.sh only resolves hooks from $ACME_HOME/deploy/)
mkdir -p "$ACME_HOME/deploy"
cp "${PROJECT_DIR}/system/acme-deploy.sh" "$ACME_HOME/deploy/acme-deploy.sh"
chown "$USER_DAEMON_NAME:$USER_GROUP" "$ACME_HOME/deploy/acme-deploy.sh"
chmod 0755 "$ACME_HOME/deploy/acme-deploy.sh"
# --- 3. Setup directories ---
log "Creating config and data directories..."
@@ -361,7 +365,7 @@ else
"${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_ACME_SELF_SIGNED, POST_NGINX_DOMAINS_ADD, POST_NGINX_APPLY,
POST_FIREWALL_CONFIG, POST_FIREWALL_CONFIG_APPLY, POST_NETWORK_SYSCTL_SET,
GET_NETWORK_INFER_DHCP_RANGES,
)
@@ -380,12 +384,20 @@ try:
except Exception as e:
print(f' [cert] Warning: {e}', file=sys.stderr)
# Management proxy + htpasswd
# Management proxy domain + htpasswd
try:
c.post(POST_NGINX_MANAGEMENT, {
c.post(POST_NGINX_DOMAINS_ADD, {
'domain': domain,
'flask_host': '127.0.0.1',
'flask_port': 9090,
'paths': {
'/': {
'backend': {'host': '127.0.0.1', 'port': 9090, 'proto': 'http'},
'is_management': True,
},
'/ws': {
'backend': {'host': '127.0.0.1', 'port': 9091, 'proto': 'http'},
'is_websocket': True,
},
},
'auth_user': mgmt_user,
'auth_pass': mgmt_pass,
})
+99 -30
View File
@@ -18,7 +18,9 @@ logger = logging.getLogger(__name__)
PROJECT_DIR = Path(__file__).resolve().parent.parent
_ACME_HOME = PROJECT_DIR / "data" / "acme"
_DEPLOY_HOOK = str(PROJECT_DIR / "system" / "acme-deploy.sh")
# acme.sh resolves deploy hooks from $ACME_HOME/deploy/ -- _findHook
# only searches the deploy subdirectory, never accepts absolute paths.
_DEPLOY_HOOK = "acme-deploy.sh"
_ACME_ENVIRON = {
"HOME": str(PROJECT_DIR),
@@ -157,10 +159,12 @@ def _read_acme_email() -> str:
except OSError as exc:
logger.warning("Could not read account config: %s", exc)
# Fallback: read from declarative ACME config
# Derive project root from acme_home (acme_home is at <root>/data/acme).
try:
from lib.common import load_json
acme_cfg = PROJECT_DIR / "config" / "acme" / "config.json"
project_root = acme_home.parent.parent
acme_cfg = project_root / "config" / "acme" / "config.json"
conf = load_json(acme_cfg)
if conf and "email" in conf:
return conf["email"]
@@ -257,29 +261,31 @@ def list_certs() -> list[dict]:
continue
san_domains = [
d.strip() for d in entry.get("san_domain", "").split(",") if d.strip()
d.strip()
for d in entry.get("san_domains", "").split(",")
if d.strip() and d.strip().lower() != "no"
]
cert_dir = acme_home / main
cert_dir = find_cert_dir(main, acme_home)
cert_path = str(cert_dir / "fullchain.cer")
key_path = str(cert_dir / f"{main}.key")
ca_path = str(cert_dir / "ca.cer")
days = _days_until(entry.get("certificate_expires", ""))
days = _days_until(entry.get("renew", ""))
auto = _has_auto_renew(main)
certs.append(
{
"domain": main,
"issuer": entry.get("CA", ""),
"expiry": entry.get("certificate_expires", ""),
"issuer": entry.get("ca", ""),
"expiry": entry.get("renew", ""),
"days_remaining": days,
"expired": days is not None and days <= 0,
"cert_path": cert_path,
"key_path": key_path,
"ca_path": ca_path,
"issued_at": entry.get("certificate_date", ""),
"expires_at": entry.get("certificate_expires", ""),
"issued_at": entry.get("created", ""),
"expires_at": entry.get("renew", ""),
"days_until_expiry": days,
"auto_renew": auto,
"san_domains": san_domains,
@@ -390,6 +396,34 @@ def copy_cert(domain: str, dest_dir: str) -> dict:
}
def find_cert_dir(domain: str, acme_home: Path | None = None) -> Path:
"""Find the certificate directory for a domain.
acme.sh may name the directory {domain}/ (RSA) or {domain}_ecc/ (ECC).
Checks both and returns whichever exists. Falls back to {domain}/ if
neither exists (preserves original behaviour for forward compatibility).
Args:
domain: The domain name.
acme_home: Override ACME home directory. Defaults to _ACME_HOME.
Returns:
Path to the directory containing the cert files.
"""
if acme_home is None:
acme_home_env = os.environ.get("ACME_HOME", str(_ACME_HOME))
acme_home = Path(acme_home_env)
ecc_dir = acme_home / f"{domain}_ecc"
rsa_dir = acme_home / domain
if ecc_dir.is_dir():
return ecc_dir
if rsa_dir.is_dir():
return rsa_dir
return rsa_dir
def get_cert_paths(domain: str) -> dict:
"""Return the file paths for all certificate components.
@@ -399,13 +433,12 @@ def get_cert_paths(domain: str) -> dict:
Returns:
Dict with keys 'cert', 'key', 'ca', 'fullchain' mapped to paths.
"""
acme_home_env = os.environ.get("ACME_HOME", str(_ACME_HOME))
acme_home = str(Path(acme_home_env) / domain)
cert_dir = str(find_cert_dir(domain))
return {
"cert": f"{acme_home}/{domain}.cert",
"key": f"{acme_home}/{domain}.key",
"ca": f"{acme_home}/ca.cer",
"fullchain": f"{acme_home}/fullchain.cer",
"cert": f"{cert_dir}/{domain}.cert",
"key": f"{cert_dir}/{domain}.key",
"ca": f"{cert_dir}/ca.cer",
"fullchain": f"{cert_dir}/fullchain.cer",
}
@@ -437,28 +470,57 @@ def deploy(domain: str) -> None:
# ------------------------------------------------------------------
def _split_line(line: str, separator: str | None) -> list[str]:
"""Split a line by *separator*, falling back to whitespace for column output."""
if separator in line:
return line.split(separator)
return line.split()
def _parse_list_output(raw: str) -> list[dict]:
"""Parse the text output from ``acme.sh --list`` into a list of dicts.
"""Parse output from ``acme.sh --list`` into a list of dicts.
Each line in the output contains ``Key:Value`` tokens separated by
whitespace, e.g.::
Handles three formats depending on system capabilities:
- Raw pipe-separated output (``|``)
- Tab-separated output (when ``column`` is unavailable)
- Column-aligned output (when ``column`` is available)
Main_Domain:example.com SAN_Domain:www.example.com CA:Let's
Encrypt Certificate_Date:2026-04-01 Certificate_Expired:No
Keys are converted to lowercase in the returned dicts.
All formats share the same header: Main_Domain, KeyLength, SAN_Domains,
Profile, CA, Created, Renew.
"""
lines = raw.strip().splitlines()
if len(lines) < 2:
return []
header_line = lines[0]
# Detect separator from header: pipe, tab, or whitespace
if "|" in header_line:
headers = _split_line(header_line, "|")
separator = "|"
elif "\t" in header_line:
headers = _split_line(header_line, "\t")
separator = "\t"
else:
headers = _split_line(header_line, None) # whitespace
separator = None
if "Main_Domain" not in headers:
raise ValueError(
f"acme.sh --list output is not in expected format: {header_line!r}"
)
entries: list[dict] = []
for line in raw.strip().splitlines():
for line in lines[1:]:
line = line.strip()
if not line:
continue
fields = _split_line(line, separator)
entry: dict[str, str] = {}
for token in line.split():
if ":" not in token:
continue
key, _, value = token.partition(":")
entry[key.lower()] = value
for i, h in enumerate(headers):
if i < len(fields):
entry[h.lower()] = fields[i].strip().strip('"')
if entry:
entries.append(entry)
return entries
@@ -468,9 +530,15 @@ def _days_until(date_str: str) -> int | None:
"""Parse an ISO date string and return days until that date from now."""
if not date_str:
return None
for fmt in ("%Y-%m-%d", "%Y-%m-%dT%H:%M:%S"):
for fmt in (
"%Y-%m-%d",
"%Y-%m-%dT%H:%M:%SZ",
"%Y-%m-%dT%H:%M:%S",
"%Y-%m-%dT%H:%M:%S%z",
):
try:
dt = datetime.strptime(date_str, fmt).replace(tzinfo=UTC)
dt = datetime.strptime(date_str, fmt)
dt = dt.replace(tzinfo=UTC) if dt.tzinfo is None else dt.astimezone(UTC)
delta = dt - datetime.now(UTC)
return delta.days
except ValueError:
@@ -500,6 +568,7 @@ __all__ = [
"copy_cert",
"days_until_expiry",
"deploy",
"find_cert_dir",
"get_cert_info",
"get_cert_paths",
"get_email",
+167 -118
View File
@@ -13,6 +13,7 @@ from typing import Any
from jinja2 import Environment, FileSystemLoader
from lib.acme import find_cert_dir
from lib.common import ensure_dirs, load_json, save_json
logger = logging.getLogger(__name__)
@@ -48,7 +49,6 @@ DEFAULT_SSL: dict[str, Any] = {
DEFAULT_CONFIG: dict[str, Any] = {
"domains": {},
"management": None,
"ssl": {**DEFAULT_SSL},
}
@@ -58,14 +58,71 @@ DEFAULT_CONFIG: dict[str, Any] = {
# ------------------------------------------------------------------
def _migrate_config(raw: dict[str, Any]) -> dict[str, Any]:
"""Migrate legacy config formats to the new paths-based model.
Handles two migrations:
1. Legacy ``management`` top-level key -> path entry under its domain.
2. Domain entries without ``paths`` -> wrap ``backend`` inside ``paths["/"]``.
Args:
raw: Config dict as loaded from disk.
Returns:
The migrated config dict.
"""
# Migrate management key
if "management" in raw and raw["management"] is not None:
mgmt = raw["management"]
mgmt_domain = mgmt.get("domain", "")
if mgmt_domain:
domains = raw.setdefault("domains", {})
if mgmt_domain not in domains:
domains[mgmt_domain] = {
"force_ssl": True,
"paths": {},
}
dom = domains[mgmt_domain]
paths = dom.setdefault("paths", {})
if "/" not in paths:
paths["/"] = {
"backend": {
"host": mgmt.get("backend", {}).get("host", "127.0.0.1"),
"port": mgmt.get("backend", {}).get("port", 9090),
"proto": "http",
},
"is_management": True,
}
if mgmt.get("auth"):
paths["/"]["auth"] = mgmt["auth"]
# Add WebSocket path if not present
if "/ws" not in paths:
paths["/ws"] = {
"backend": {"host": "127.0.0.1", "port": 9091, "proto": "http"},
"is_websocket": True,
}
del raw["management"]
# Migrate domain entries without paths
for dom in raw.get("domains", {}).values():
if "paths" not in dom and "backend" in dom:
dom["paths"] = {
"/": {
"backend": dom.pop("backend"),
"headers": dom.pop("headers", {}),
}
}
return raw
def get_config() -> dict[str, Any]:
"""Load the current nginx config, initializing with defaults if needed.
Ensure config and sites directories exist, then return a copy of the
JSON file. On missing file or missing keys, populate from defaults.
Ensures config and sites directories exist, applies migrations for
legacy formats, then returns the config dict.
Returns:
The complete config dict with ``domains``, ``ssl``, and ``management`` keys.
The complete config dict with ``domains`` and ``ssl`` keys.
"""
ensure_dirs(CONFIG_DIR, SITES_DIR)
raw = load_json(CONFIG_FILE)
@@ -73,6 +130,8 @@ def get_config() -> dict[str, Any]:
raw = deepcopy(DEFAULT_CONFIG)
if "ssl" not in raw:
raw["ssl"] = deepcopy(DEFAULT_SSL)
raw = _migrate_config(raw)
save_config(raw)
return raw
@@ -82,26 +141,35 @@ def save_config(cfg: dict[str, Any]) -> None:
def get_domains() -> list[dict[str, Any]]:
"""Return a list of all configured proxy domains with status.
"""Return a list of all configured proxy domains flattened by path.
Each entry includes the domain name, backend info, SSL flag, and
whether a site config file currently exists on disk.
Each path within a domain becomes a separate entry with domain-level
settings repeated.
Returns:
List of dicts with ``domain``, ``backend``, ``online``, and ``force_ssl``.
List of dicts with ``domain``, ``path``, ``backend``, ``online``,
``force_ssl``, and path-level flags.
"""
cfg = get_config()
result: list[dict[str, Any]] = []
for name, dom in cfg.get("domains", {}).items():
site = SITES_DIR / f"{name}.conf"
result.append(
{
paths = dom.get("paths", {})
if not paths:
continue
for ppath, pcfg in paths.items():
entry: dict[str, Any] = {
"domain": name,
"backend": dom.get("backend", {}),
"path": ppath,
"backend": pcfg.get("backend", {}),
"online": site.exists(),
"force_ssl": dom.get("force_ssl", True),
}
)
if pcfg.get("is_management"):
entry["is_management"] = True
if pcfg.get("is_websocket"):
entry["is_websocket"] = True
result.append(entry)
return result
@@ -112,21 +180,24 @@ def get_domains() -> list[dict[str, Any]]:
def add_domain(
domain: str,
backend_host: str,
backend_port: int,
backend_host: str | None = None,
backend_port: int | None = None,
backend_proto: str = "http",
cert: str | None = None,
extra_headers: dict[str, str] | None = None,
paths: dict[str, dict[str, Any]] | None = None,
) -> None:
"""Add a new proxy domain with the given backend and optional settings.
Args:
domain: Domain name to add.
backend_host: Upstream host to proxy to.
backend_port: Upstream port.
backend_proto: Protocol (``http`` or ``https``).
backend_host: Upstream host to proxy to (legacy mode).
backend_port: Upstream port (legacy mode).
backend_proto: Protocol (``http`` or ``https``; legacy mode).
cert: Optional certificate type identifier.
extra_headers: Optional dict of extra headers to forward.
extra_headers: Optional dict of extra headers to forward (legacy mode).
paths: Optional path-to-config map (new mode). Each path entry must
have a ``backend`` key with ``host``, ``port``, and ``proto``.
Raises:
ValueError: If the domain is already configured.
@@ -134,27 +205,36 @@ def add_domain(
cfg = get_config()
if domain in cfg["domains"]:
raise ValueError(f"Domain {domain!r} already configured")
entry: dict[str, Any] = {
"backend": {
"host": backend_host,
"port": int(backend_port),
"proto": backend_proto,
},
"force_ssl": True,
}
if cert is not None:
entry["cert"] = cert
if extra_headers is not None:
entry["headers"] = extra_headers
if paths is not None:
entry: dict[str, Any] = {
"paths": paths,
"force_ssl": True,
}
if cert is not None:
entry["cert"] = cert
else:
if not backend_host or backend_port is None:
raise ValueError("'backend_host' and 'backend_port' are required")
entry = {
"paths": {
"/": {
"backend": {
"host": backend_host,
"port": int(backend_port),
"proto": backend_proto,
},
"headers": extra_headers or {},
}
},
"force_ssl": True,
}
if cert is not None:
entry["cert"] = cert
cfg["domains"][domain] = entry
save_config(cfg)
logger.info(
"Proxy domain '%s' added -> %s:%d (%s)",
domain,
backend_host,
backend_port,
backend_proto,
)
logger.info("Proxy domain '%s' added", domain)
def remove_domain(domain: str) -> None:
@@ -171,6 +251,11 @@ def remove_domain(domain: str) -> None:
def update_domain(domain: str, **kwargs: Any) -> None:
"""Update fields of an existing domain entry in-place.
Supports both domain-level keys (``force_ssl``, ``cert``, ``auth``,
``paths``) and paths-level shorthand (``backend``, ``headers`` for
the root path). When ``paths`` is provided, it is fully replaced.
When ``backend`` is provided, it updates ``paths["/"]["backend"]``.
Args:
domain: Domain name to update.
**kwargs: Key-value pairs to merge into the domain config.
@@ -182,7 +267,27 @@ def update_domain(domain: str, **kwargs: Any) -> None:
if domain not in cfg["domains"]:
raise KeyError(f"Domain {domain!r} not configured")
entry = cfg["domains"][domain]
# If paths is given, replace entirely
if "paths" in kwargs:
entry["paths"] = kwargs["paths"]
else:
# Legacy: top-level backend/headers -> paths["/"]
paths = entry.setdefault("paths", {})
if "backend" in kwargs:
root = paths.setdefault("/", {"backend": {}, "headers": {}})
root["backend"] = kwargs["backend"]
if "headers" in kwargs:
root = paths.setdefault("/", {"backend": {}, "headers": {}})
root["headers"] = kwargs["headers"]
# Remove legacy top-level keys from domain entry
entry.pop("backend", None)
entry.pop("headers", None)
for key, val in kwargs.items():
if key in ("backend", "headers", "paths"):
continue
if isinstance(val, dict) and key in entry:
entry[key].update(val)
else:
@@ -197,7 +302,7 @@ def update_domain(domain: str, **kwargs: Any) -> None:
def generate_server_conf(domain_cfg: dict[str, Any]) -> str:
"""Render the Jinja template for a standard domain server block.
"""Render the Jinja template for a domain server block.
Args:
domain_cfg: Domain entry dict including the ``domain`` key.
@@ -206,41 +311,28 @@ def generate_server_conf(domain_cfg: dict[str, Any]) -> str:
The complete nginx server-block configuration as a string.
"""
tmpl = ENV.get_template("nginx/server_block.conf")
acme_home_path = PROJECT_DIR / "data" / "acme"
acme_cert_dir = str(find_cert_dir(domain_cfg["domain"], acme_home_path))
paths = domain_cfg.get("paths", {})
has_management = any(p.get("is_management") for p in paths.values())
# Resolve custom cert paths for cert=="file"
cert_cfg = domain_cfg.get("cert")
if isinstance(cert_cfg, dict):
cert_path = cert_cfg.get("cert_path", "")
cert_key_path = cert_cfg.get("cert_key_path", "")
else:
cert_path = domain_cfg.get("cert_path", "")
cert_key_path = domain_cfg.get("cert_key_path", "")
return tmpl.render(
domain=domain_cfg["domain"],
backend=domain_cfg.get("backend", {}),
headers=domain_cfg.get("headers", {}),
paths=paths,
force_ssl=domain_cfg.get("force_ssl", True),
cert=domain_cfg.get("cert"),
auth=domain_cfg.get("auth"),
is_management=False,
acme_home=str(PROJECT_DIR / "data" / "acme"),
certs_dir=str(PROJECT_DIR / "data" / "certs"),
acme_webroot=str(PROJECT_DIR / "data" / "acme" / "www"),
)
def _generate_management_conf(management: dict[str, Any]) -> str:
"""Render the Jinja template for the management WebUI server block.
Args:
management: Management proxy config dict containing ``domain`` and optional ``auth``.
Returns:
The complete nginx server-block configuration for the management UI.
"""
tmpl = ENV.get_template("nginx/server_block.conf")
return tmpl.render(
domain=management.get("domain"),
backend=dict(
management.get("backend", {}), host="127.0.0.1", port=9090, proto="http"
),
headers={},
force_ssl=True,
cert=None,
auth=management.get("auth"),
is_management=True,
acme_home=str(PROJECT_DIR / "data" / "acme"),
cert_path=cert_path,
cert_key_path=cert_key_path,
domain_auth=domain_cfg.get("auth"),
has_management=has_management,
acme_cert_dir=acme_cert_dir,
certs_dir=str(PROJECT_DIR / "data" / "certs"),
acme_webroot=str(PROJECT_DIR / "data" / "acme" / "www"),
)
@@ -290,8 +382,8 @@ def write_acme_challenge() -> None:
def write_all_sites() -> None:
"""Regenerate all site configs from the current config state.
Writes server blocks for every configured domain and the management
proxy (if any), removes orphaned site files, and ensures the ACME
Writes server blocks for every configured domain (now unified, including
any management paths), removes orphaned site files, and ensures the ACME
challenge config is present.
"""
ensure_dirs(SITES_DIR)
@@ -306,10 +398,10 @@ def write_all_sites() -> None:
write_site(name, conf)
written.add(f"{name}.conf")
if cfg.get("management"):
mgmt_conf = _generate_management_conf(cfg["management"])
write_site("management", mgmt_conf)
written.add("management.conf")
# Remove old management.conf if it exists
old_mgmt = SITES_DIR / "management.conf"
if old_mgmt.exists() and old_mgmt.name not in written:
old_mgmt.unlink()
for old in existing:
if old.suffix == ".conf" and old.name not in written:
@@ -406,48 +498,6 @@ def apply() -> None:
logger.info("nginx configuration applied and reloaded")
# ------------------------------------------------------------------
# Management WebUI
# ------------------------------------------------------------------
def set_management_proxy(
domain: str,
flask_host: str = "127.0.0.1",
flask_port: int = 9090,
auth_user: str | None = None,
auth_pass: str | None = None,
) -> None:
"""Configure the management reverse proxy for the WebUI.
Args:
domain: Management domain name.
flask_host: Upstream Flask host (default ``127.0.0.1``).
flask_port: Upstream Flask port (default ``9090``).
auth_user: Optional basic-auth username.
auth_pass: Optional basic-auth password; writes htpasswd when provided with ``auth_user``.
"""
cfg = get_config()
entry: dict[str, Any] = {
"domain": domain,
"backend": {
"host": flask_host,
"port": int(flask_port),
"proto": "http",
},
}
if auth_user:
entry["auth"] = {
"user": auth_user,
"htpasswd": str(HTPASSWD_FILE),
}
cfg["management"] = entry
save_config(cfg)
if auth_user and auth_pass:
write_htpasswd(auth_user, auth_pass)
logger.info("Management proxy set to '%s'", domain)
# ------------------------------------------------------------------
# htpasswd
# ------------------------------------------------------------------
@@ -505,7 +555,6 @@ __all__ = [
"get_domains",
"remove_domain",
"save_config",
"set_management_proxy",
"test_config",
"update_domain",
"write_acme_challenge",
+109 -167
View File
@@ -7,8 +7,6 @@ state instead of invoking subprocesses on every request.
import contextlib
import logging
import os
import shutil
import subprocess
from copy import deepcopy
from datetime import UTC, datetime
from pathlib import Path
@@ -28,6 +26,11 @@ logger = logging.getLogger(__name__)
PROJECT_DIR = Path(__file__).resolve().parent.parent
_CA_NAME_MAP: dict[str, str] = {
"letsencrypt": "Let's Encrypt",
"zerossl": "ZeroSSL",
}
_DEFAULT_POLL_INTERVALS: dict[str, int] = {
"firewall": 30,
"wireguard": 10,
@@ -633,7 +636,6 @@ def _collect_nginx() -> dict[str, Any]:
default_cfg: dict[str, Any] = {
"domains": {},
"management": None,
"ssl": deepcopy(DEFAULT_SSL),
}
cfg = deepcopy(default_cfg)
@@ -649,18 +651,27 @@ def _collect_nginx() -> dict[str, Any]:
except Exception:
pass
# Build domains list with site existence
# Build flattened domains list (one entry per path)
domains: list[dict[str, Any]] = []
for name, dom in cfg.get("domains", {}).items():
site = SITES_DIR / f"{name}.conf"
domains.append(
{
paths = dom.get("paths", {})
if not paths:
continue
for ppath, pcfg in paths.items():
entry: dict[str, Any] = {
"domain": name,
"backend": dom.get("backend", {}),
"path": ppath,
"backend": pcfg.get("backend", {}),
"online": site.exists() if SITES_DIR.exists() else False,
"force_ssl": dom.get("force_ssl", True),
"cert": dom.get("cert"),
}
)
if pcfg.get("is_management"):
entry["is_management"] = True
if pcfg.get("is_websocket"):
entry["is_websocket"] = True
domains.append(entry)
return {
"config": cfg,
@@ -677,117 +688,34 @@ register_collector("nginx", _collect_nginx)
# ---------------------------------------------------------------------------
def _find_acme() -> str:
"""Locate the ``acme.sh`` binary on the filesystem.
def _resolve_ca_name(ca_server: str) -> str:
"""Map a CA server identifier to its human-readable name.
Returns:
Absolute path to the ``acme.sh`` executable.
Raises:
FileNotFoundError: If acme.sh cannot be found.
"""
acme_home = PROJECT_DIR / "data" / "acme"
candidates = [acme_home / "acme.sh", Path("/usr/local/bin/acme.sh")]
for path in candidates:
if path.is_file() and os.access(path, os.X_OK):
return str(path)
acme = shutil.which("acme.sh")
if acme:
return acme
raise FileNotFoundError("acme.sh not found")
def _run_acme(args: list[str]) -> str:
"""Run ``acme.sh`` with *args* and return combined output.
Uses prefix matching sorted by longest prefix first to avoid
shorter prefixes winning (e.g. "letsencrypt" matching before
"letsencrypt.org").
Args:
args: Command-line arguments to pass after the home/config flags.
ca_server: Raw CA server string from acme.sh config.
Returns:
Combined stdout/stderr output.
Raises:
RuntimeError: If acme.sh exits non-zero or times out.
Human-readable name, or unchanged string if no match.
"""
acme_bin = _find_acme()
acme_home_env = os.environ.get("ACME_HOME", str(PROJECT_DIR / "data" / "acme"))
cmd = [acme_bin, "--home", acme_home_env, "--config-home", acme_home_env, *args]
_ACME_ENVIRON = {
"HOME": str(PROJECT_DIR),
"PATH": os.environ.get(
"PATH", "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
),
}
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=120,
env={**os.environ, **_ACME_ENVIRON},
)
except subprocess.TimeoutExpired as exc:
raise RuntimeError(f"acme.sh timed out: {' '.join(cmd)}") from exc
output = result.stdout
if result.stderr:
output = output + result.stderr if output else result.stderr
if result.returncode != 0:
raise RuntimeError(f"acme.sh failed (rc={result.returncode}): {output.strip()}")
return output
def _days_until(date_str: str) -> int | None:
"""Parse a date string and return days until *date_str* from now.
Args:
date_str: Date string in common ACME formats.
Returns:
Number of days remaining, or ``None`` if empty or unparseable.
"""
if not date_str:
return None
for fmt in ("%Y-%m-%d", "%Y-%m-%dT%H:%M:%S"):
try:
dt = datetime.strptime(date_str, fmt).replace(tzinfo=UTC)
return (dt - datetime.now(UTC)).days
except ValueError:
continue
try:
dt = datetime.strptime(date_str, "%Y%m%d%H%M%z").astimezone(UTC)
return (dt - datetime.now(UTC)).days
except ValueError:
pass
return None
def _parse_acme_list_output(raw: str) -> list[dict]:
"""Parse ``acme.sh --list`` output into a list of certificate dicts.
Args:
raw: Raw output string from ``acme.sh --list``.
Returns:
List of dicts with certificate entry fields.
"""
entries: list[dict] = []
for line in raw.strip().splitlines():
line = line.strip()
if not line:
continue
entry: dict[str, str] = {}
for token in line.split():
if ":" not in token:
continue
key, _, value = token.partition(":")
entry[key.lower()] = value
if entry:
entries.append(entry)
return entries
for prefix, name in sorted(
_CA_NAME_MAP.items(), key=lambda x: len(x[0]), reverse=True
):
if ca_server.startswith(prefix):
return name
return ca_server
def _parse_account_conf(acme_home: Path | None = None) -> dict[str, Any]:
"""Parse acme.sh .account.conf and return account status dict.
"""Parse acme.sh account information and return account status dict.
Checks three sources in order:
1. Legacy ``.account.conf`` file (acme.sh v2.x format)
2. Declarative ``config/acme/config.json`` (saved by the registration
handler with ``email`` and ``ca`` fields)
Args:
acme_home: Optional override for ACME home directory. Falls back
@@ -795,13 +723,12 @@ def _parse_account_conf(acme_home: Path | None = None) -> dict[str, Any]:
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.
``key_length`` keys. If no account is found, ``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,
@@ -810,55 +737,56 @@ def _parse_account_conf(acme_home: Path | None = None) -> dict[str, Any]:
"key_length": None,
}
if not account_path.is_file():
return default
# 1. Legacy .account.conf (acme.sh v2.x)
account_path = acme_home / ".account.conf"
if account_path.is_file():
try:
text = account_path.read_text()
except OSError:
pass
else:
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 email and ca_raw:
return {
"registered": True,
"email": email,
"ca": _resolve_ca_name(ca_raw),
"key_length": key_length,
}
# 2. Declarative config (saved by register_account / set_email handlers)
# Modern acme.sh (v3.x) stores account data in per-CA JSON files
# (ca/<server>/account.json) — we can't reliably parse those without
# walking the directory, so fall back to the declarative config
# which the handlers keep in sync.
# Derive project root from acme_home (acme_home is at <root>/data/acme).
try:
text = account_path.read_text()
except OSError:
return default
project_root = acme_home.parent.parent # data/acme → data → project root
acme_cfg = project_root / "config" / "acme" / "config.json"
data = load_json(acme_cfg)
email = (data.get("email") or "").strip()
ca_raw = (data.get("ca") or "").strip()
if email and ca_raw:
return {
"registered": True,
"email": email,
"ca": _resolve_ca_name(ca_raw),
"key_length": None,
}
except (OSError, ValueError):
pass
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.
Args:
domain: Domain name to check.
Returns:
``True`` if a corresponding ``acme.sh`` config file exists.
"""
acme_home_env = os.environ.get("ACME_HOME", str(PROJECT_DIR / "data" / "acme"))
return bool(Path(acme_home_env) / f"{domain}.conf")
return default
def _get_acme_email() -> str:
@@ -882,8 +810,16 @@ def _collect_acme() -> dict[str, Any]:
certs: list[dict[str, Any]] = []
try:
from lib.acme import (
_days_until,
_has_auto_renew,
_parse_list_output,
_run_acme,
)
raw = _run_acme(["--list"])
entries = _parse_acme_list_output(raw)
entries = _parse_list_output(raw)
acme_home_env = os.environ.get("ACME_HOME", str(PROJECT_DIR / "data" / "acme"))
acme_home = Path(acme_home_env)
for entry in entries:
@@ -891,29 +827,35 @@ def _collect_acme() -> dict[str, Any]:
if not main:
continue
san_domains = [
d.strip() for d in entry.get("san_domain", "").split(",") if d.strip()
d.strip()
for d in entry.get("san_domains", "").split(",")
if d.strip() and d.strip().lower() != "no"
]
cert_dir = acme_home / main
days = _days_until(entry.get("certificate_expires", ""))
days = _days_until(entry.get("renew", ""))
certs.append(
{
"domain": main,
"issuer": entry.get("CA", ""),
"expiry": entry.get("certificate_expires", ""),
"issuer": entry.get("ca", ""),
"expiry": entry.get("renew", ""),
"days_remaining": days,
"expired": days is not None and days <= 0,
"cert_path": str(cert_dir / "fullchain.cer"),
"key_path": str(cert_dir / f"{main}.key"),
"ca_path": str(cert_dir / "ca.cer"),
"issued_at": entry.get("certificate_date", ""),
"expires_at": entry.get("certificate_expires", ""),
"issued_at": entry.get("created", ""),
"expires_at": entry.get("renew", ""),
"days_until_expiry": days,
"auto_renew": _has_auto_renew(main),
"san_domains": san_domains,
}
)
except Exception:
pass
logger.warning(
"ACME state collection failed, returning empty cert list",
exc_info=True,
)
raise
account = _parse_account_conf()
+55 -52
View File
@@ -12,94 +12,97 @@ server {
root {{ acme_webroot }};
}
# Redirect all HTTP traffic to HTTPS
return 301 https://$host$request_uri;
}
{% endif %}
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name {{ domain }};
{% if cert %}
{% if cert.type == "acme" %}
# Certificate managed by acme.sh
{% if cert.email %} # ACME contact: {{ cert.email }}
{% endif %} ssl_certificate {{ acme_home }}/{{ domain }}/fullchain.cer;
ssl_certificate_key {{ acme_home }}/{{ domain }}/{{ domain }}.key;
{% elif cert.type == "file" %}
ssl_certificate {{ cert.path }};
ssl_certificate_key {{ cert.key_path }};
{% elif cert.type == "selfsigned" %}
{% if cert == "acme" %}
ssl_certificate {{ acme_cert_dir }}/fullchain.cer;
ssl_certificate_key {{ acme_cert_dir }}/{{ domain }}.key;
{% elif cert == "file" %}
ssl_certificate {{ cert_path }};
ssl_certificate_key {{ cert_key_path }};
{% elif cert == "selfsigned" %}
ssl_certificate {{ certs_dir }}/{{ domain }}.crt;
ssl_certificate_key {{ certs_dir }}/{{ domain }}.key;
{% endif %}
{% elif is_management %}
ssl_certificate {{ acme_home }}/{{ domain }}/fullchain.cer;
ssl_certificate_key {{ acme_home }}/{{ domain }}/{{ domain }}.key;
{% elif has_management %}
ssl_certificate {{ acme_cert_dir }}/fullchain.cer;
ssl_certificate_key {{ acme_cert_dir }}/{{ domain }}.key;
{% endif %}
# Shared SSL settings
include snippets/vacuum-wall-ssl.conf;
{% if auth %}
# HTTP basic authentication
auth_basic "{{ "Vacuum Wall" if is_management else "Restricted" }}";
auth_basic_user_file {{ auth.htpasswd }};
{% if domain_auth %}
auth_basic "Restricted";
auth_basic_user_file {{ domain_auth.htpasswd }};
{% endif %}
{% if not has_management %}
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options DENY always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
{% endif %}
{% if not is_management %}
# Security hardening headers
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options DENY always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
{% for ppath, pcfg in paths.items() %}
{% if pcfg.is_websocket %}
# {{ ppath }} -> {{ pcfg.backend.host }}:{{ pcfg.backend.port }} (WebSocket)
location {{ ppath }} {
auth_basic off;
proxy_pass http://{{ pcfg.backend.host }}:{{ pcfg.backend.port }};
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
}
{% else %}
# {{ ppath }} -> {{ pcfg.backend.host }}:{{ pcfg.backend.port }}
location {{ ppath }} {
{% if pcfg.auth is none %}
auth_basic off;
{% elif pcfg.auth is defined %}
auth_basic "Restricted";
auth_basic_user_file {{ pcfg.auth.htpasswd }};
{% endif %}
location / {
# Proxy headers
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
{% if not is_management %}
{% for hname, hval in headers.items() %}
{% if not pcfg.is_management %}
{% for hname, hval in (pcfg.headers or {}).items() %}
proxy_set_header {{ hname }} {{ hval }};
{% endfor %}
{% endif %}
# Proxy pass to backend
proxy_pass {{ backend.proto }}://{{ backend.host }}:{{ backend.port }};
proxy_pass {{ pcfg.backend.proto }}://{{ pcfg.backend.host }}:{{ pcfg.backend.port }};
proxy_http_version 1.1;
# Timeouts
proxy_connect_timeout 30s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
proxy_buffering off;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
{% if is_management %}
location /ws {
auth_basic off;
proxy_pass http://127.0.0.1:9091;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
{% endif %}
{% endfor %}
{% if not is_management %}
# Access / error logs
access_log /var/log/nginx/{{ domain }}_access.log;
error_log /var/log/nginx/{{ domain }}_error.log warn;
{% else %}
{% if has_management %}
access_log /var/log/nginx/wall_mgmt_access.log;
error_log /var/log/nginx/wall_mgmt_error.log warn;
{% else %}
access_log /var/log/nginx/{{ domain }}_access.log;
error_log /var/log/nginx/{{ domain }}_error.log warn;
{% endif %}
}
+1
View File
@@ -14,6 +14,7 @@ Defaults:{{ USER_DAEMON_NAME }} secure_path="/usr/local/sbin:/usr/local/bin:/usr
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/cp * /etc/nginx/snippets/*
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/rm /etc/nginx/conf.d/vacuum-wall.conf
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/rm /etc/nginx/snippets/vacuum-wall-ssl.conf
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/chown root\:root /etc/nginx/conf.d/vacuum-wall.conf
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/chown root\:root /etc/nginx/snippets/vacuum-wall-ssl.conf
# Dnsmasq management
+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 /run/nginx
ReadWritePaths={{ PROJECT_DIR }} /tmp /etc/systemd/network /etc/nginx /etc/dnsmasq.d /etc/wireguard /run/sudo /run/firewalld /run/nginx /run/nginx.pid /var/log/nginx
PrivateTmp=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
+85 -7
View File
@@ -61,17 +61,14 @@ class TestRunAcme:
class TestParseListOutput:
def test_parses_single_entry(self):
raw = "Main_Domain:example.com CA:LetsEncrypt Certificate_Date:2026-04-01 Certificate_Expires:2026-07-01 Certificate_Expired:No"
raw = "Main_Domain\tCA\nexample.com\tLetsEncrypt\n"
result = acme._parse_list_output(raw)
assert len(result) == 1
assert result[0]["main_domain"] == "example.com"
assert result[0]["ca"] == "LetsEncrypt"
def test_parses_multiple_entries(self):
raw = (
"Main_Domain:a.com CA:LE Certificate_Expires:2026-07-01 Certificate_Expired:No\n"
"Main_Domain:b.com CA:LE Certificate_Expires:2026-08-01 Certificate_Expired:No"
)
raw = "Main_Domain\tCA\na.com\tLE\nb.com\tLE\n"
result = acme._parse_list_output(raw)
assert len(result) == 2
@@ -79,10 +76,46 @@ class TestParseListOutput:
result = acme._parse_list_output("")
assert result == []
def test_skips_lines_without_colons(self):
raw = "some random line\nMain_Domain:a.com"
def test_skips_empty_lines(self):
raw = "Main_Domain\tCA\nexample.com\tLE\n\n \nother.com\tLE\n"
result = acme._parse_list_output(raw)
assert len(result) == 2
def test_header_skipped(self):
"""Header row is not included in results."""
raw = "Main_Domain\tKeyLength\tCA\nexample.com\tec-256\tZeroSSL.com\n"
result = acme._parse_list_output(raw)
assert len(result) == 1
assert result[0]["main_domain"] == "example.com"
def test_field_mapping(self):
"""Tab columns are mapped to lowercased header names as dict keys."""
raw = (
"Main_Domain\tKeyLength\tSAN_Domains\tProfile\tCA\tCreated\tRenew\n"
'example.com\t"ec-256"\twww.example.com\t\tZeroSSL.com\t2026-01-01\t2026-07-01\n'
)
result = acme._parse_list_output(raw)
assert len(result) == 1
entry = result[0]
assert entry["main_domain"] == "example.com"
assert entry["keylength"] == "ec-256"
assert entry["san_domains"] == "www.example.com"
assert entry["profile"] == ""
assert entry["ca"] == "ZeroSSL.com"
assert entry["created"] == "2026-01-01"
assert entry["renew"] == "2026-07-01"
def test_quoted_values_stripped(self):
"""Quoted values have quotes removed."""
raw = 'Main_Domain\tKeyLength\nexample.com\t"ec-256"\n'
result = acme._parse_list_output(raw)
assert result[0]["keylength"] == "ec-256"
def test_header_only(self):
"""Header with no data rows returns empty list."""
raw = "Main_Domain\tKeyLength\tCA\n"
result = acme._parse_list_output(raw)
assert result == []
class TestDaysUntil:
@@ -124,6 +157,39 @@ class TestGetEmail:
assert result == "test@example.com"
class TestFindCertDir:
def test_rsa_dir(self, tmp_path):
rsa_dir = tmp_path / "example.com"
rsa_dir.mkdir()
result = acme.find_cert_dir("example.com", tmp_path)
assert result == rsa_dir
def test_ecc_dir(self, tmp_path):
ecc_dir = tmp_path / "example.com_ecc"
ecc_dir.mkdir()
result = acme.find_cert_dir("example.com", tmp_path)
assert result == ecc_dir
def test_eccPreferred(self, tmp_path):
rsa_dir = tmp_path / "example.com"
rsa_dir.mkdir()
ecc_dir = tmp_path / "example.com_ecc"
ecc_dir.mkdir()
result = acme.find_cert_dir("example.com", tmp_path)
assert result == ecc_dir
def test_fallback_when_neither(self, tmp_path):
result = acme.find_cert_dir("example.com", tmp_path)
assert result == tmp_path / "example.com"
def test_resolves_ecc_only(self, tmp_path):
"""Only _ecc dir exists, no RSA dir — should resolve to _ecc."""
ecc_dir = tmp_path / "example.com_ecc"
ecc_dir.mkdir()
result = acme.find_cert_dir("example.com", tmp_path)
assert result == ecc_dir
class TestGetCertPaths:
def test_returns_paths(self, tmp_path):
with patch.object(acme, "_ACME_HOME", tmp_path / "data" / "acme"):
@@ -133,6 +199,18 @@ class TestGetCertPaths:
assert paths["ca"].endswith("example.com/ca.cer")
assert paths["fullchain"].endswith("example.com/fullchain.cer")
def test_resolves_ecc_dir(self, tmp_path):
acme_dir = tmp_path / "data" / "acme"
ecc_dir = acme_dir / "example.com_ecc"
ecc_dir.mkdir(parents=True)
with patch.object(acme, "_ACME_HOME", acme_dir):
paths = acme.get_cert_paths("example.com")
assert paths["cert"].endswith("example.com_ecc/example.com.cert")
assert paths["key"].endswith("example.com_ecc/example.com.key")
assert paths["ca"].endswith("example.com_ecc/ca.cer")
assert paths["fullchain"].endswith("example.com_ecc/fullchain.cer")
class TestDeployHook:
@patch("lib.acme._run_acme")
-15
View File
@@ -766,21 +766,6 @@ class TestDhcpConfigCrud:
# ============================================================================
class TestProxyManagement:
@_px("post")
def test_set_management(self, mock_post, client):
mock_post.return_value = {"domain": "vacuum-wall.local"}
resp = client.post(
"/api/proxy/management",
json={
"domain": "vacuum-wall.local",
"flask_host": "127.0.0.1",
"flask_port": 9090,
},
)
assert resp.status_code == 200
# ============================================================================
# Proxy domain update
# ============================================================================
+80 -3
View File
@@ -1,5 +1,6 @@
"""Tests for daemon/handlers/acme.py — handler endpoint logic."""
import asyncio
import urllib.error
from pathlib import Path
from unittest.mock import MagicMock, patch
@@ -24,8 +25,10 @@ from daemon.handlers.acme import (
deactivate_account,
generate_self_signed,
get_account,
issue_cert,
register_account,
)
from daemon.server import ConflictError
class TestGenerateSelfSigned:
@@ -345,10 +348,12 @@ class TestCheckDnsPublic:
from unittest.mock import patch
mock_result = MagicMock(
returncode=0, stdout="example.com has address 192.168.1.1"
returncode=0, stdout="example.com has address 52.14.150.110"
)
with (
patch("socket.gethostbyname", return_value="192.168.1.1"),
patch(
"daemon.handlers.acme._get_local_ips", return_value={"52.14.150.110"}
),
patch("subprocess.run", return_value=mock_result),
):
passed, _ = _check_dns_public("example.com")
@@ -359,12 +364,23 @@ class TestCheckDnsPublic:
mock_result = MagicMock(returncode=1, stdout="NXDOMAIN")
with (
patch("socket.gethostbyname", return_value="192.168.1.1"),
patch("daemon.handlers.acme._get_local_ips", return_value={"8.8.8.8"}),
patch("subprocess.run", return_value=mock_result),
):
passed, _ = _check_dns_public("example.com")
assert passed is False
def test_nat_detected_skips_check(self):
from unittest.mock import patch
with patch(
"daemon.handlers.acme._get_local_ips",
return_value={"192.168.1.1"},
):
passed, msg = _check_dns_public("example.com")
assert passed is True
assert "NAT" in msg
class TestCheckDomainFormat:
def test_valid(self):
@@ -1088,3 +1104,64 @@ class TestDeactivateAccount:
assert not (acme_dir / ".account.conf").is_file()
assert not (acme_dir / "account.conf").is_file()
class TestIssueCertExistingCerts:
"""Phase 3: issue_cert blocks when cert expires today (days == 0) or tomorrow (days == 1)."""
def test_days_zero_blocks(self):
"""days_until_expiry returns 0 (expires today) — should block."""
with (
patch(
"daemon.handlers.acme._validate",
return_value={"ready": True, "checks": []},
),
patch(
"lib.acme.list_certs",
return_value=[{"domain": "example.com", "days_until_expiry": 0}],
),
pytest.raises(ConflictError, match="0 days remaining"),
):
asyncio.run(issue_cert(None, {"domain": "example.com"}))
def test_days_one_blocks(self):
"""days_until_expiry returns 1 (expires tomorrow) — should still block."""
with (
patch(
"daemon.handlers.acme._validate",
return_value={"ready": True, "checks": []},
),
patch(
"lib.acme.list_certs",
return_value=[{"domain": "example.com", "days_until_expiry": 1}],
),
pytest.raises(ConflictError, match="1 day remaining"),
):
asyncio.run(issue_cert(None, {"domain": "example.com"}))
def test_days_negative_one_allows(self):
"""days_until_expiry returns -1 (already expired) — should not block."""
async def _fake_run_issue(req):
pass
with (
patch(
"daemon.handlers.acme._validate",
return_value={"ready": True, "checks": []},
),
patch(
"lib.acme.list_certs",
return_value=[{"domain": "example.com", "days_until_expiry": -1}],
),
patch.dict("daemon.handlers.acme._ISSUANCES", clear=True),
patch(
"daemon.handlers.acme._run_issue",
new=MagicMock(side_effect=_fake_run_issue),
) as mock_run_issue,
):
result = asyncio.run(issue_cert(None, {"domain": "example.com"}))
assert result["domain"] == "example.com"
assert "request_id" in result
mock_run_issue.assert_called_once()
+139 -6
View File
@@ -63,7 +63,10 @@ class TestSaveConfig:
}
nginx.save_config(cfg)
loaded = nginx.get_config()
assert loaded["domains"]["example.com"]["backend"]["host"] == "localhost"
assert (
loaded["domains"]["example.com"]["paths"]["/"]["backend"]["host"]
== "localhost"
)
class TestGetDomains:
@@ -97,8 +100,10 @@ class TestAddDomain:
nginx.add_domain("example.com", "10.0.0.5", 8080)
cfg = nginx.get_config()
assert "example.com" in cfg["domains"]
assert cfg["domains"]["example.com"]["backend"]["host"] == "10.0.0.5"
assert cfg["domains"]["example.com"]["backend"]["port"] == 8080
assert (
cfg["domains"]["example.com"]["paths"]["/"]["backend"]["host"] == "10.0.0.5"
)
assert cfg["domains"]["example.com"]["paths"]["/"]["backend"]["port"] == 8080
@patch("lib.nginx.get_config")
def test_duplicate_domain_raises(self, mock_get, temp_data_dir):
@@ -163,21 +168,149 @@ class TestWriteSite:
assert "server { listen 443; }" in content
class TestGenerateServerConf:
def test_simple_root_path(self, temp_data_dir):
cfg = {
"domain": "example.com",
"paths": {
"/": {
"backend": {"host": "10.0.0.1", "port": 80, "proto": "http"},
"headers": {"X-Custom": "value"},
}
},
"force_ssl": True,
"cert": "acme",
}
out = nginx.generate_server_conf(cfg)
assert "location /" in out
assert "proxy_pass http://10.0.0.1:80;" in out
assert "proxy_set_header X-Custom value;" in out
assert "add_header X-Content-Type-Options" in out
def test_multiple_paths(self, temp_data_dir):
cfg = {
"domain": "app.example.com",
"paths": {
"/": {
"backend": {"host": "10.0.0.1", "port": 80, "proto": "http"},
"headers": {},
},
"/api": {
"backend": {"host": "10.0.0.2", "port": 8080, "proto": "http"},
},
},
"force_ssl": True,
"cert": "acme",
}
out = nginx.generate_server_conf(cfg)
assert "proxy_pass http://10.0.0.1:80;" in out
assert "proxy_pass http://10.0.0.2:8080;" in out
assert "location /api" in out
def test_management_path(self, temp_data_dir):
cfg = {
"domain": "mgmt.example.com",
"paths": {
"/": {
"backend": {"host": "127.0.0.1", "port": 9090, "proto": "http"},
"is_management": True,
}
},
"force_ssl": True,
"cert": "acme",
"auth": {"user": "admin", "htpasswd": "/path/.htpasswd"},
}
out = nginx.generate_server_conf(cfg)
assert "proxy_pass http://127.0.0.1:9090;" in out
assert "add_header X-Content-Type-Options" not in out
assert "wall_mgmt_access.log" in out
def test_websocket_path(self, temp_data_dir):
cfg = {
"domain": "mgmt.example.com",
"paths": {
"/": {
"backend": {"host": "127.0.0.1", "port": 9090, "proto": "http"},
},
"/ws": {
"backend": {"host": "127.0.0.1", "port": 9091, "proto": "http"},
"is_websocket": True,
},
},
"force_ssl": True,
"cert": "acme",
}
out = nginx.generate_server_conf(cfg)
assert "proxy_pass http://127.0.0.1:9091;" in out
assert "proxy_set_header Upgrade" in out
assert "proxy_read_timeout 86400s;" in out
def test_auth_inheritance(self, temp_data_dir):
cfg = {
"domain": "app.example.com",
"paths": {
"/": {
"backend": {"host": "10.0.0.1", "port": 80, "proto": "http"},
"headers": {},
},
"/api": {
"backend": {"host": "10.0.0.2", "port": 8080, "proto": "http"},
"auth": None,
},
"/admin": {
"backend": {"host": "10.0.0.3", "port": 9000, "proto": "http"},
"auth": {"user": "admin", "htpasswd": "/other/.htpasswd"},
},
},
"force_ssl": True,
"cert": "acme",
"auth": {"user": "admin", "htpasswd": "/path/.htpasswd"},
}
out = nginx.generate_server_conf(cfg)
assert "auth_basic_user_file /path/.htpasswd;" in out
lines = out.split("\n")
api_idx = next(i for i, line in enumerate(lines) if "location /api" in line)
admin_idx = next(i for i, line in enumerate(lines) if "location /admin" in line)
# /api should have auth_basic off
assert "auth_basic off;" in "\n".join(lines[api_idx : api_idx + 5])
# /admin should have path-level auth override
assert "auth_basic_user_file /other/.htpasswd;" in "\n".join(
lines[admin_idx : admin_idx + 5]
)
class TestWriteAllSites:
@patch("lib.nginx.get_config")
def test_writes_all_domains(self, mock_get, temp_data_dir):
mock_get.return_value = {
"domains": {
"a.com": {
"backend": {"host": "10.0.0.1", "port": 80, "proto": "http"},
"paths": {
"/": {
"backend": {
"host": "10.0.0.1",
"port": 80,
"proto": "http",
},
"headers": {},
}
},
"force_ssl": True,
},
"b.com": {
"backend": {"host": "10.0.0.2", "port": 80, "proto": "http"},
"paths": {
"/": {
"backend": {
"host": "10.0.0.2",
"port": 80,
"proto": "http",
},
"headers": {},
}
},
"force_ssl": True,
},
},
"management": None,
"ssl": {"protocols": "TLSv1.2 TLSv1.3"},
}
nginx.write_all_sites()
+5 -6
View File
@@ -20,18 +20,17 @@ class TestSPARoutes:
assert resp.status_code == 200
assert b'id="app"' in resp.data
def test_spa_catch_all_serves_index(self, client):
def test_spa_unknown_path_404(self, client):
resp = client.get("/dashboard")
assert resp.status_code == 200
assert b"index.html" in resp.data or b'id="app"' in resp.data
assert resp.status_code == 404
def test_spa_catch_all_other_page(self, client):
def test_spa_unknown_path_404_other(self, client):
resp = client.get("/zones")
assert resp.status_code == 200
assert resp.status_code == 404
def test_api_routes_still_work(self, client):
resp = client.get("/api/firewall/zones")
assert resp.status_code in (200, 502, 503)
assert resp.status_code in (200, 500)
class TestWsUrlGeneration:
-1
View File
File diff suppressed because one or more lines are too long
-12
View File
@@ -1,12 +0,0 @@
htmx.defineExtension('json-enc', {
onEvent: function(name, evt) {
if (name === 'htmx:configRequest') {
evt.detail.headers['Content-Type'] = 'application/json'
}
},
encodeParameters: function(xhr, parameters, elt) {
xhr.overrideMimeType('text/json')
return (JSON.stringify(parameters))
}
})
+3 -1
View File
@@ -7,7 +7,7 @@ import logging
from flask import Blueprint, request
from daemon.client import BadRequest, NotFound, delete, get, post
from daemon.client import BadRequest, Conflict, NotFound, delete, get, post
from daemon.iface import (
DELETE_ACME_ACCOUNT_DEACTIVATE,
DELETE_ACME_REMOVE,
@@ -114,6 +114,8 @@ def issue_start():
except BadRequest as exc:
logger.info("Cert issue for '%s' rejected: %s", domain, exc)
return _error(str(exc), 400)
except Conflict as exc:
return _error(str(exc), 409)
except RuntimeError as exc:
logger.error("Failed to start cert issue for '%s': %s", domain, exc)
return _error(str(exc), 500)
+35 -67
View File
@@ -17,7 +17,6 @@ from daemon.iface import (
POST_NGINX_CONFIG,
POST_NGINX_DOMAINS_ADD,
POST_NGINX_DOMAINS_UPDATE,
POST_NGINX_MANAGEMENT,
POST_NGINX_SSL_APPLY,
POST_NGINX_TEST,
)
@@ -140,7 +139,13 @@ def add_domain_bp():
POST /api/proxy/domains
Body fields:
Body fields (paths mode):
domain: Domain name.
paths: Path-to-config map (e.g. ``{"/": {"backend": {...}}, "/app": {...}}``).
cert: Optional certificate type.
force_ssl: Optional SSL redirect flag (default ``true``).
Body fields (legacy mode):
domain: Domain name.
backend_host: Upstream host.
backend_port: Upstream port.
@@ -153,29 +158,37 @@ def add_domain_bp():
"""
body = request.get_json(silent=True) or {}
domain = body.get("domain", "").strip()
backend_host = body.get("backend_host", "").strip()
backend_port = body.get("backend_port")
backend_proto = body.get("backend_proto", "http").strip() or "http"
cert = body.get("cert")
extra_headers = body.get("extra_headers")
if not domain:
return _error("'domain' is required", 400)
if not backend_host:
return _error("'backend_host' is required", 400)
if backend_port is None:
return _error("'backend_port' is required", 400)
paths = body.get("paths")
if paths is not None:
payload = {
"domain": domain,
"paths": paths,
"cert": body.get("cert"),
"force_ssl": body.get("force_ssl", True),
}
else:
backend_host = body.get("backend_host", "").strip()
backend_port = body.get("backend_port")
backend_proto = body.get("backend_proto", "http").strip() or "http"
cert = body.get("cert")
extra_headers = body.get("extra_headers")
if not backend_host:
return _error("'backend_host' is required", 400)
if backend_port is None:
return _error("'backend_port' is required", 400)
payload = {
"domain": domain,
"backend_host": backend_host,
"backend_port": int(backend_port),
"backend_proto": backend_proto,
"cert": cert,
"extra_headers": extra_headers,
}
try:
post(
POST_NGINX_DOMAINS_ADD,
{
"domain": domain,
"backend_host": backend_host,
"backend_port": int(backend_port),
"backend_proto": backend_proto,
"cert": cert,
"extra_headers": extra_headers,
},
)
post(POST_NGINX_DOMAINS_ADD, payload)
logger.info("Proxy domain added via API: %s", domain)
return _ok({"domain": domain})
except BadRequest as exc:
@@ -272,48 +285,3 @@ def test_bp():
except RuntimeError as exc:
logger.error("nginx config test failed: %s", exc)
return _error(str(exc), 500)
@bp.route("/management", methods=["POST"])
def management_bp():
"""Configure the management reverse proxy for the WebUI.
POST /api/proxy/management
Body fields:
domain: Management domain name.
flask_host: Upstream Flask host (default ``127.0.0.1``).
flask_port: Upstream Flask port (default 9090).
auth_user: Optional basic-auth username.
auth_pass: Optional basic-auth password.
Returns:
``{"ok": true}`` on success.
"""
body = request.get_json(silent=True) or {}
domain = body.get("domain", "").strip()
if not domain:
return _error("'domain' is required", 400)
flask_host = body.get("flask_host", "127.0.0.1").strip() or "127.0.0.1"
flask_port = body.get("flask_port", 9090)
auth_user = body.get("auth_user")
auth_pass = body.get("auth_pass")
try:
post(
POST_NGINX_MANAGEMENT,
{
"domain": domain,
"flask_host": flask_host,
"flask_port": int(flask_port),
"auth_user": auth_user,
"auth_pass": auth_pass,
},
)
logger.info("Management proxy configured via API: %s", domain)
return _ok(None)
except BadRequest as exc:
logger.info("Management proxy config rejected: %s", exc)
return _error(str(exc), 400)
except RuntimeError as exc:
logger.error("Failed to set management proxy: %s", exc)
return _error(str(exc), 500)
+23 -11
View File
@@ -177,29 +177,41 @@ def api_status_all():
# ---------------------------------------------------------------------------
# SPA catch-all
# SPA entry point — serve index.html for /, 404 for everything else
# ---------------------------------------------------------------------------
SPA_DIR = STATIC_DIR
VENDOR_DIR = PROJECT_DIR / "vendor"
@app.route("/")
@app.route("/<path:path>")
def spa_page(path=""):
"""Single-page application catch-all.
Serves ``index.html`` (rendered as a Jinja2 template) for all non-API,
non-static paths. The client-side router handles navigation and defaults
to ``#dashboard``.
"""
if path.startswith("api/") or path.startswith("static/"):
abort(404)
def spa_root():
"""Serve the SPA entry point. No catch-all — client handles routing."""
scheme = "wss" if request.is_secure else "ws"
ws_url = f"{scheme}://{request.host}/ws"
html = (SPA_DIR / "index.html").read_text()
return html.replace("__WS_URL_PLACEHOLDER__", ws_url)
@app.route("/vendor/<path:filename>")
def vendor_files(filename):
"""Serve vendored JS libraries (htm.js, etc.)."""
from flask import send_file
target = (VENDOR_DIR / filename).resolve()
if not target.is_relative_to(VENDOR_DIR):
abort(404)
return send_file(target)
@app.errorhandler(404)
def not_found(e):
"""Return 404 JSON for API clients, 404 HTML for everything else."""
if request.path.startswith("/api/"):
return {"ok": False, "error": "Not found"}, 404
return "", 404
if __name__ == "__main__":
logger.info("Starting Flask on 127.0.0.1:9090")
app.run(host="127.0.0.1", port=9090)
+17 -13
View File
@@ -1,16 +1,16 @@
import { h, render, Link, hComp, ToastContainer, connect, apiFetch, modelRegister, modelFetch, reactive } from '/static/hoover/index.js?v=7';
import { h, render, Link, hComp, ToastContainer, connect, apiFetch, modelRegister, modelFetch, reactive } from '/static/hoover/index.js?v=8';
import DashboardPage from '/static/pages/dashboard.js?v=7';
import InterfacesPage from '/static/pages/interfaces.js?v=7';
import ZonesPage from '/static/pages/zones.js?v=7';
import RulesPage from '/static/pages/rules.js?v=7';
import NatPage from '/static/pages/nat.js?v=7';
import DhcpPage from '/static/pages/dhcp.js?v=7';
import ProxyPage from '/static/pages/proxy.js?v=7';
import CertsPage from '/static/pages/certs.js?v=7';
import WireguardPage from '/static/pages/wireguard.js?v=7';
import LogsPage from '/static/pages/logs.js?v=7';
import NotFoundPage from '/static/pages/notfound.js?v=7';
import DashboardPage from '/static/pages/dashboard.js?v=8';
import InterfacesPage from '/static/pages/interfaces.js?v=8';
import ZonesPage from '/static/pages/zones.js?v=8';
import RulesPage from '/static/pages/rules.js?v=8';
import NatPage from '/static/pages/nat.js?v=8';
import DhcpPage from '/static/pages/dhcp.js?v=8';
import ProxyPage from '/static/pages/proxy.js?v=8';
import CertsPage from '/static/pages/certs.js?v=8';
import WireguardPage from '/static/pages/wireguard.js?v=8';
import LogsPage from '/static/pages/logs.js?v=8';
import NotFoundPage from '/static/pages/notfound.js?v=8';
/* ── Navigation items ──────────────────────────────────────── */
const Nav = [
@@ -229,4 +229,8 @@ export function initApp() {
setTimeout(connect, 0);
}
document.addEventListener('DOMContentLoaded', initApp);
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initApp);
} else {
initApp();
}
-1
View File
@@ -1 +0,0 @@
../../vendor/htmx-2.0.4.min.js
+2 -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?v=8">
<link rel="stylesheet" href="/static/style.css?v=9">
</head>
<body>
<div id="app">
@@ -15,6 +15,6 @@
</div>
<div id="modal-root"></div>
<script>window.__WS_URL__ = "__WS_URL_PLACEHOLDER__"</script>
<script type="module" src="/static/app.js?v=8"></script>
<script type="module" src="/static/app.js?v=10"></script>
</body>
</html>
-1
View File
@@ -1 +0,0 @@
../../vendor/json-enc-2.0.0.js
+7 -2
View File
@@ -172,7 +172,7 @@ function _renderIssueContent() {
<label>Domain</label>
<input id="ic-domain" value=${esc(s.domain)} />
</div>
<div id="ic-vresults">${...resultsVNodes}</div>
<div id="ic-vresults">${resultsVNodes}</div>
</div>
<div class="modal-actions">
<button class="btn btn-outline" data-action="ic-cancel">Cancel</button>
@@ -236,7 +236,12 @@ function _bindIssueButtons(inner, modalIdx) {
const body = { domain: _currentIssueState.domain };
const issueResp = await apiFetch('/api/certs/issue/start', { method: 'POST', body });
if (issueResp.ok) {
toast('Issuance started for ' + _currentIssueState.domain, 'success');
const status = issueResp.data?.status;
if (status === 'existing') {
toast('Issuance already in progress for ' + _currentIssueState.domain, 'warning');
} else {
toast('Issuance started for ' + _currentIssueState.domain, 'success');
}
closeModal(modalIdx);
const rid = issueResp.data?.request_id;
if (rid) pollCertIssue(rid);
+140 -44
View File
@@ -1,9 +1,26 @@
import { html, PageHeader, Badge, Empty, Table, renderGuardMulti, esc, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, ActionButton, ActionCell, certStatusBadge, ActionGroup, QuickModal } from '/static/hoover/index.js?v=7';
import { html, PageHeader, Badge, Empty, Table, renderGuardMulti, esc, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, ActionButton, ActionCell, ConfirmDelete, certStatusBadge, ActionGroup, QuickModal } from '/static/hoover/index.js?v=7';
// ---------------------------------------------------------------------------
// Cert lookup map from ACME state keyed by domain name
// ---------------------------------------------------------------------------
function certLookup(acmeData) {
const m = {};
if (acmeData && acmeData.certs) {
for (const c of acmeData.certs) {
m[c.domain] = c;
}
}
return m;
}
// ---------------------------------------------------------------------------
// Add Domain modal — paths-based body
// ---------------------------------------------------------------------------
const addDomain = QuickModal({
title: 'Add Proxy Domain',
fields: [
{ label: 'Domain', id: 'p-domain', placeholder: 'example.com' },
{ label: 'Path', id: 'p-path', placeholder: '/' },
{ label: 'Backend Host', id: 'p-host', placeholder: '192.168.1.10' },
{ label: 'Backend Port', id: 'p-port', type: 'number', placeholder: '8080' },
{ label: 'Protocol', id: 'p-proto', placeholder: 'http or https' },
@@ -11,42 +28,127 @@ const addDomain = QuickModal({
],
submit: {
url: '/api/proxy/domains',
body: () => ({
domain: ($val('p-domain') || '').trim(),
backend_host: ($val('p-host') || '').trim(),
backend_port: parseInt($val('p-port')),
backend_proto: ($val('p-proto') || 'http').trim() || 'http',
cert: ($val('p-cert') || '').trim() || undefined,
}),
validate: (b) => !b.domain || !b.backend_host || !b.backend_port ? 'Domain, host, and port are required' : null,
body: () => {
const path = ($val('p-path') || '/').trim() || '/';
return {
domain: ($val('p-domain') || '').trim(),
paths: {
[path]: {
backend: {
host: ($val('p-host') || '').trim(),
port: parseInt($val('p-port')),
proto: ($val('p-proto') || 'http').trim() || 'http',
},
headers: {},
},
},
cert: ($val('p-cert') || '').trim() || undefined,
};
},
validate: (b) => {
if (!b.domain) return 'Domain is required';
const p = b.paths ? Object.values(b.paths)[0] : {};
const be = p && p.backend;
if (!be || !be.host || !be.port) return 'Host and port are required';
return null;
},
successMsg: 'Domain added',
},
refresh: ['nginx', 'acme'],
});
// ---------------------------------------------------------------------------
// Edit Domain modal — updates backend for the root path
// ---------------------------------------------------------------------------
const editDomain = QuickModal({
title: (d) => 'Edit: ' + d.domain,
fields: (d) => [
{ label: 'Backend Host', id: 'pe-host', value: d.backend_host || '' },
{ label: 'Backend Port', id: 'pe-port', type: 'number', value: d.backend_port || '' },
{ label: 'Protocol', id: 'pe-proto', value: d.backend_proto || d.protocol || 'http' },
{ label: 'Cert (optional)', id: 'pe-cert', value: d.cert || '' },
],
title: (d) => 'Edit: ' + d.domain + ' ' + d.path,
fields: (d) => {
const be = d.backend || {};
return [
{ label: 'Backend Host', id: 'pe-host', value: be.host || '' },
{ label: 'Backend Port', id: 'pe-port', type: 'number', value: be.port || '' },
{ label: 'Protocol', id: 'pe-proto', value: be.proto || 'http' },
{ label: 'Cert (optional)', id: 'pe-cert', value: d._cert || d.cert || '' },
];
},
submit: {
url: (d) => '/api/proxy/domains/' + enc(d.domain),
method: 'PUT',
body: () => ({
backend_host: ($val('pe-host') || '').trim(),
backend_port: parseInt($val('pe-port')),
backend_proto: ($val('pe-proto') || 'http').trim(),
body: (d) => ({
backend: {
host: ($val('pe-host') || '').trim(),
port: parseInt($val('pe-port')),
proto: ($val('pe-proto') || 'http').trim(),
},
cert: ($val('pe-cert') || '').trim() || undefined,
}),
validate: (b) => !b.backend_host || !b.backend_port ? 'Host and port are required' : null,
validate: (b) => !b.backend || !b.backend.host || !b.backend.port ? 'Host and port are required' : null,
successMsg: 'Domain updated',
},
refresh: ['nginx', 'acme'],
});
// ---------------------------------------------------------------------------
// Path detail row
// ---------------------------------------------------------------------------
function pathRow(d, certs, domainPaths) {
const be = d.backend || {};
const cert = certs[d.domain];
const certBadge = cert
? certStatusBadge({
daysRemaining: cert.days_remaining,
expired: cert.expired,
})
: Badge({ text: '—', variant: 'info' });
const isWs = d.is_websocket;
const isMgmt = d.is_management;
const multiPath = (domainPaths || []).length > 1;
let actions;
if (isMgmt) {
actions = Badge({ text: 'mgmt', variant: 'warning' });
} else if (isWs) {
actions = ActionButton({
url: '/api/proxy/domains/' + enc(d.domain),
method: 'PUT',
body: () => ({ path: d.path }),
label: 'Delete',
cls: 'btn btn-sm btn-danger',
successMsg: 'Path removed',
refresh: ['nginx', 'acme'],
});
} else {
actions = ActionCell({
editLabel: 'Edit',
editClick: () => editDomain(d),
removeUrl: '/api/proxy/domains/' + enc(d.domain),
removeMessage: 'Remove ' + enc(d.domain) + ' ' + enc(d.path) + '?',
removeSuccess: 'Removed',
removeRefresh: ['nginx', 'acme'],
removeLabel: 'Delete',
});
}
const flagBadges = [];
if (isWs) flagBadges.push(Badge({ text: 'ws', variant: 'secondary' }));
if (isMgmt) flagBadges.push(Badge({ text: 'mgmt', variant: 'warning' }));
return html`<tr key=${d.domain + ':' + d.path} class="path-row">
<td>${esc(d.domain)}</td>
<td><code>${esc(d.path)}</code></td>
<td>${esc(be.host || '-')}</td>
<td>${be.port || '-'}</td>
<td><${Badge} text=${be.proto || 'http'} variant="info" /></td>
<td>${flagBadges}</td>
<td>${certBadge}</td>
<td>${actions}</td>
</tr>`;
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default definePage({
init() {
return {
@@ -59,28 +161,22 @@ export default definePage({
if (guard) return guard;
const domains = state.nginx.data.domains || [];
const rows = domains.map(d => {
const certBadge = certStatusBadge({
certStatus: d.cert_status,
daysRemaining: d.days_remaining,
expired: d.cert_status === 'expired',
});
const certs = certLookup(state.acme.data);
return html`<tr key=${d.domain}>
<td><strong>${esc(d.domain)}</strong></td>
<td>${esc(d.backend_host || '-')}</td>
<td>${d.backend_port || '-'}</td>
<td><${Badge} text=${d.backend_proto || d.protocol || 'http'} variant="info" /></td>
<td>${certBadge}</td>
<${ActionCell}
editLabel="Edit" editClick=${() => editDomain(d)}
removeUrl=${'/api/proxy/domains/' + enc(d.domain)}
removeMessage=${'Remove proxy for ' + d.domain + '?'}
removeSuccess="Domain removed"
removeRefresh={['nginx', 'acme']}
removeLabel="Delete" />
</tr>`;
});
// Group by domain for multi-path awareness
const groups = {};
for (const d of domains) {
if (!groups[d.domain]) groups[d.domain] = [];
groups[d.domain].push(d);
}
// Attach domain-level cert info to each entry
const enriched = domains.map(d => ({
...d,
_cert: certs[d.domain] || null,
}));
const rows = enriched.map(d => pathRow(d, certs, groups[d.domain]));
const actions = ActionGroup(
html`<button class="btn btn-primary" onClick=${() => addDomain(state)}>Add Domain</button>`,
@@ -94,9 +190,9 @@ export default definePage({
return [
PageHeader({ title: 'Proxy', subtitle: 'Nginx reverse proxy', actions }),
rows.length
domains.length
? Table({
columns: ['Domain', 'Backend Host', 'Port', 'Proto', 'Cert', 'Actions'],
columns: ['Domain', 'Path', 'Host', 'Port', 'Proto', 'Flags', 'Cert', 'Actions'],
rows,
})
: Empty({ text: 'No proxy domains configured. Add a domain to start terminating SSL.' }),
+1
View File
@@ -0,0 +1 @@
../../../vendor/htm.js