"""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 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", {}) 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.""" 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 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"]) 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() 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}