Files
vacuum-wall/daemon/handlers/nginx.py
T
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

528 lines
16 KiB
Python

"""Nginx daemon handler."""
import logging
import os
from copy import deepcopy
from pathlib import Path
from typing import Any
from jinja2 import Environment, FileSystemLoader
from daemon.iface import (
DELETE_NGINX_DOMAINS_REMOVE,
GET_NGINX_CONFIG,
GET_NGINX_DOMAINS,
PATCH_NGINX_CONFIG,
POST_NGINX_APPLY,
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__)
PROJECT_DIR = Path(__file__).resolve().parent.parent.parent
CONFIG_DIR = PROJECT_DIR / "config" / "nginx"
DATA_DIR = PROJECT_DIR / "data" / "nginx"
SITES_DIR = DATA_DIR / "sites-enabled"
CONFIG_FILE = CONFIG_DIR / "config.json"
INCLUDE_FILE = Path("/etc/nginx/conf.d/vacuum-wall.conf")
SSL_SNIPPET = Path("/etc/nginx/snippets/vacuum-wall-ssl.conf")
HTPASSWD_FILE = DATA_DIR / ".htpasswd"
ENV = Environment(
loader=FileSystemLoader(str(PROJECT_DIR / "system")),
autoescape=False,
lstrip_blocks=True,
trim_blocks=True,
)
DEFAULT_SSL: dict[str, Any] = {
"protocols": "TLSv1.2 TLSv1.3",
"ciphers": (
"ECDHE-ECDSA-AES128-GCM-SHA256:"
"ECDHE-RSA-AES128-GCM-SHA256:"
"ECDHE-ECDSA-AES256-GCM-SHA384:"
"ECDHE-RSA-AES256-GCM-SHA384:"
"ECDHE-ECDSA-CHACHA20-POLY1305:"
"ECDHE-RSA-CHACHA20-POLY1305"
),
"prefer_server_ciphers": False,
}
DEFAULT_CONFIG: dict[str, Any] = {
"domains": {},
"management": None,
"ssl": {**DEFAULT_SSL},
}
def _get_state() -> dict[str, Any] | None:
"""Retrieve cached nginx state from the state store."""
from lib.state import state as state_store
return state_store.get("nginx")
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.
"""
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)
return raw
def _save_config(cfg: dict[str, Any]) -> None:
"""Persist the nginx config dict to disk.
Args:
cfg: The config dictionary to save.
"""
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.
"""
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))
return tmpl.render(
domain=domain_cfg["domain"],
backend=domain_cfg.get("backend", {}),
headers=domain_cfg.get("headers", {}),
force_ssl=domain_cfg.get("force_ssl", True),
cert=domain_cfg.get("cert"),
auth=domain_cfg.get("auth"),
is_management=False,
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.
"""
ensure_dirs(SITES_DIR)
path = SITES_DIR / f"{domain}.conf"
tmp = path.with_suffix(".tmp")
with open(tmp, "w") as f:
f.write(conf_text)
f.write("\n")
os.chmod(tmp, 0o644)
os.replace(tmp, path)
def _write_include_file() -> None:
"""Write the system include file that references all per-site configs."""
tmpl = ENV.get_template("nginx/include.conf")
content = tmpl.render(sites_glob=str(SITES_DIR / "*.conf"))
tmp = Path("/tmp") / "vacuum-wall-include.tmp"
with open(tmp, "w") as f:
f.write(content)
os.chmod(tmp, 0o644)
run(["cp", str(tmp), str(INCLUDE_FILE)], sudo=True)
run(["chown", "root:root", str(INCLUDE_FILE)], sudo=True)
tmp.unlink(missing_ok=True)
def _write_ssl_snippet() -> None:
"""Render and install the shared SSL snippet to /etc/nginx/snippets/."""
cfg = _get_config()
ssl_cfg = cfg.get("ssl", {})
ssl_cfg.setdefault("prefer_server_ciphers", DEFAULT_SSL["prefer_server_ciphers"])
ssl_cfg.setdefault("protocols", DEFAULT_SSL["protocols"])
ssl_cfg.setdefault("ciphers", DEFAULT_SSL["ciphers"])
tmpl = ENV.get_template("nginx/ssl_snippet.conf")
content = tmpl.render(ssl=ssl_cfg)
tmp = Path("/tmp") / "vacuum-wall-ssl-snippet.tmp"
with open(tmp, "w") as f:
f.write(content)
os.chmod(tmp, 0o644)
run(["cp", str(tmp), str(SSL_SNIPPET)], sudo=True)
run(["chown", "root:root", str(SSL_SNIPPET)], sudo=True)
tmp.unlink(missing_ok=True)
def _test_config() -> tuple[bool, str]:
"""Run `nginx -t` to validate the current config.
Returns:
Tuple of (passed, message).
"""
result = run_proc(["nginx", "-t"], sudo=True, check=False)
ok = result.returncode == 0
output = (result.stderr or result.stdout or "").strip()
if not output and ok:
output = "nginx configuration test passed"
return ok, output
def _reload_nginx() -> None:
"""Send SIGHUP to nginx to reload its configuration.
Logs an error if the reload fails.
"""
result = run_proc(["nginx", "-s", "reload"], sudo=True, check=False)
if result.returncode != 0:
logger.error("nginx reload failed: %s", result.stderr.strip())
else:
logger.info("nginx configuration applied and reloaded")
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.
"""
ensure_dirs(SITES_DIR)
cfg = _get_config()
existing = set(SITES_DIR.iterdir()) if SITES_DIR.exists() else set()
written: set[str] = set()
for name, dom in cfg.get("domains", {}).items():
dom_copy = dict(dom, domain=name)
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")
acme_home_path = PROJECT_DIR / "data" / "acme"
acme_cert_dir = str(find_cert_dir(mgmt.get("domain", ""), acme_home_path))
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_cert_dir=acme_cert_dir,
certs_dir=str(PROJECT_DIR / "data" / "certs"),
acme_webroot=str(PROJECT_DIR / "data" / "acme" / "www"),
)
_write_site("management", mgmt_conf)
written.add("management.conf")
for old in existing:
if old.suffix == ".conf" and old.name not in written:
old.unlink()
tmpl = ENV.get_template("nginx/acme-challenge.conf")
acme_content = tmpl.render(acme_webroot=str(PROJECT_DIR / "data" / "acme" / "www"))
site = SITES_DIR / "_acme-challenge.conf"
tmp = site.with_suffix(".tmp")
with open(tmp, "w") as f:
f.write(acme_content)
f.write("\n")
os.chmod(tmp, 0o644)
os.replace(tmp, site)
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``.
"""
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.
"""
ensure_dirs(DATA_DIR)
hashed = _hash_password(password)
existing: dict[str, str] = {}
if HTPASSWD_FILE.exists():
with open(HTPASSWD_FILE) as f:
for line in f:
line = line.strip()
if not line or line.startswith("#"):
continue
parts = line.split(":", 1)
if len(parts) == 2:
existing[parts[0]] = line
existing[user] = f"{user}:{hashed}"
tmp = HTPASSWD_FILE.with_suffix(".tmp")
with open(tmp, "w") as f:
for _uname, entry in existing.items():
f.write(entry + "\n")
os.chmod(tmp, 0o640)
os.replace(tmp, HTPASSWD_FILE)
def _get_nginx_state() -> dict[str, Any]:
"""Return a shallow copy of the cached nginx state, or empty dict if unset."""
ng = _get_state()
if ng is None:
return {}
return ng
# ---------------------------------------------------------------------------
# Routes
@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.
"""
ng = _get_nginx_state()
if ng:
return ng.get("config", {})
return _get_config()
@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.
"""
if not body:
raise ValueError("Request body required")
_save_config(body)
refresh_state(["nginx"])
return {"config_saved": True}
@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.
"""
if not body:
raise ValueError("Request body required")
from lib.common import deep_merge
current = _get_config()
merged = deep_merge(current, body)
_save_config(merged)
refresh_state(["nginx"])
return {"config_saved": True}
@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.
"""
ng = _get_nginx_state()
if ng:
return ng.get("domains", [])
return []
@registry.register(POST_NGINX_DOMAINS_ADD)
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.
"""
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
cfg["domains"][domain] = entry
_save_config(cfg)
refresh_state(["nginx"])
return {"domain": domain}
@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.
"""
if not body:
raise ValueError("Request body required")
domain = body.get("domain", "").strip()
if not domain:
raise ValueError("'domain' is required")
cfg = _get_config()
if domain not in cfg["domains"]:
raise NotFoundError(f"Domain {domain!r} not found")
del cfg["domains"][domain]
_save_config(cfg)
site = SITES_DIR / f"{domain}.conf"
if site.exists():
site.unlink()
refresh_state(["nginx"])
return {"domain": domain}
@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.
"""
if not body:
raise ValueError("Request body required")
domain = body.get("domain", "").strip()
if not domain:
raise ValueError("'domain' is required")
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]
for key, val in updates.items():
if isinstance(val, dict) and key in entry:
entry[key].update(val)
else:
entry[key] = val
_save_config(cfg)
refresh_state(["nginx"])
return {"domain": domain}
@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.
"""
_write_ssl_snippet()
_write_all_sites()
_write_include_file()
ok, msg = _test_config()
if not ok:
raise RuntimeError(f"nginx config test failed: {msg}")
_reload_nginx()
refresh_state(["nginx"])
return {"applied": True}
@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`.
"""
valid, output = _test_config()
return {"valid": valid, "output": output}
@registry.register(POST_NGINX_SSL_APPLY)
def ssl_apply(_request: Any, _body: Any) -> dict[str, Any]:
"""POST /nginx/ssl-apply — re-render and install only the SSL snippet."""
_write_ssl_snippet()
refresh_state(["nginx"])
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)."""
_reload_nginx()
return {"reloaded": True}