Files
vacuum-wall/daemon/handlers/nginx.py
T
mteehan 5135de0921 feat: add system config import, refactor install script and nginx auth
- lib/system_import: new module to import system configs into JSON at daemon startup
- daemon/server.py: call import_all() during startup for config reconciliation
- daemon/handlers/nginx.py: simplify add_domain auth handling, remove duplicate code
- scripts/install.sh: replace inline Python setup with curl-based daemon API calls; apply IP forwarding at runtime
- hoover: bump internal asset versions to v=8
- pages: bump asset versions to v=9
2026-07-08 02:20:28 +00:00

531 lines
17 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_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 (
_APPLY_HASH_KEY,
config_hash,
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": {},
"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
return state_store.get("nginx")
def _get_config() -> dict[str, Any]:
"""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."""
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."""
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"],
paths=paths,
force_ssl=domain_cfg.get("force_ssl", True),
cert=domain_cfg.get("cert"),
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."""
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."""
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."""
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 and ACME challenge site."""
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")
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()
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."""
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."""
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."""
ng = _get_nginx_state()
if ng:
return ng.get("config", {})
cfg = _get_config()
return {k: v for k, v in cfg.items() if k != _APPLY_HASH_KEY}
@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."""
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."""
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."""
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.
Accepts either legacy backend_* fields or a ``paths`` map.
"""
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 in cfg["domains"]:
raise ValueError(f"Domain {domain!r} already configured")
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
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 = {
"user": auth_user,
"htpasswd": str(HTPASSWD_FILE),
}
root_path = entry.get("paths", {}).get("/")
if root_path:
root_path["auth"] = auth
entry["auth"] = auth
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."""
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."""
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")
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:
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."""
_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()
cfg_after = _get_config()
cfg_after[_APPLY_HASH_KEY] = config_hash(cfg_after)
_save_config(cfg_after)
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."""
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_RELOAD)
def reload_nginx(_request: Any, _body: Any) -> dict[str, Any]:
"""POST /nginx/reload — trigger an nginx reload (SIGHUP)."""
_reload_nginx()
return {"reloaded": True}