"""Nginx server-block generator for Vacuum Wall SSL proxy firewall. Manages per-domain SSL reverse proxy configurations, certificate bootstrap, basic-auth htpasswd files, and nginx reload cycles. """ import logging import os import subprocess from copy import deepcopy from pathlib import Path from typing import Any from jinja2 import Environment, FileSystemLoader from lib.common import ensure_dirs, load_json, save_json logger = logging.getLogger(__name__) PROJECT_DIR = Path(__file__).resolve().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}, } # ------------------------------------------------------------------ # Public API # ------------------------------------------------------------------ def get_config() -> dict[str, Any]: 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: save_json(CONFIG_FILE, cfg) def get_domains() -> list[dict[str, Any]]: cfg = get_config() result: list[dict[str, Any]] = [] for name, dom in cfg.get("domains", {}).items(): site = SITES_DIR / f"{name}.conf" result.append( { "domain": name, "backend": dom.get("backend", {}), "online": site.exists(), "force_ssl": dom.get("force_ssl", True), } ) return result # ------------------------------------------------------------------ # Domain CRUD # ------------------------------------------------------------------ def add_domain( domain: str, backend_host: str, backend_port: int, backend_proto: str = "http", cert: str | None = None, extra_headers: dict[str, str] | None = None, ) -> None: 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) logger.info( "Proxy domain '%s' added -> %s:%d (%s)", domain, backend_host, backend_port, backend_proto, ) def remove_domain(domain: str) -> None: cfg = get_config() cfg["domains"].pop(domain, None) save_config(cfg) site = SITES_DIR / f"{domain}.conf" if site.exists(): site.unlink() logger.info("Proxy domain '%s' removed", domain) def update_domain(domain: str, **kwargs: Any) -> None: cfg = get_config() if domain not in cfg["domains"]: raise KeyError(f"Domain {domain!r} not configured") entry = cfg["domains"][domain] for key, val in kwargs.items(): if isinstance(val, dict) and key in entry: entry[key].update(val) else: entry[key] = val save_config(cfg) logger.info("Proxy domain '%s' updated: %s", domain, list(kwargs.keys())) # ------------------------------------------------------------------ # Nginx config generation # ------------------------------------------------------------------ def generate_server_conf(domain_cfg: dict[str, Any]) -> str: tmpl = ENV.get_template("nginx/server_block.conf") 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_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: 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"), certs_dir=str(PROJECT_DIR / "data" / "certs"), acme_webroot=str(PROJECT_DIR / "data" / "acme" / "www"), ) # ------------------------------------------------------------------ # File writers # ------------------------------------------------------------------ def write_site(domain: str, conf_text: str) -> None: 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_acme_challenge() -> None: """Write the ACME HTTP-01 challenge catch-all nginx config. Serves ``/.well-known/acme-challenge/`` on port 80 from the ACME webroot for any domain not yet covered by a dedicated server block. """ tmpl = ENV.get_template("nginx/acme-challenge.conf") 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(content) f.write("\n") os.chmod(tmp, 0o644) os.replace(tmp, site) def write_all_sites() -> None: 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_conf = _generate_management_conf(cfg["management"]) 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() write_acme_challenge() logger.info("All nginx site configs written (%d sites)", len(written)) def write_include_file() -> None: tmpl = ENV.get_template("nginx/include.conf") content = tmpl.render(sites_glob=str(SITES_DIR / "*.conf")) tmp = INCLUDE_FILE.with_suffix(".tmp") with open(tmp, "w") as f: f.write(content) os.chmod(tmp, 0o644) subprocess.run(["sudo", "cp", str(tmp), str(INCLUDE_FILE)], check=True) subprocess.run(["sudo", "chown", "root:root", str(INCLUDE_FILE)], check=True) tmp.unlink(missing_ok=True) def write_ssl_snippet() -> None: 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 = SSL_SNIPPET.with_suffix(".tmp") with open(tmp, "w") as f: f.write(content) os.chmod(tmp, 0o644) subprocess.run(["sudo", "cp", str(tmp), str(SSL_SNIPPET)], check=True) subprocess.run(["sudo", "chown", "root:root", str(SSL_SNIPPET)], check=True) tmp.unlink(missing_ok=True) # ------------------------------------------------------------------ # nginx lifecycle # ------------------------------------------------------------------ def test_config() -> tuple[bool, str]: result = subprocess.run( ["sudo", "nginx", "-t"], capture_output=True, text=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" if ok: logger.info("nginx config test passed") else: logger.error("nginx config test failed: %s", output) return ok, output def apply() -> None: write_ssl_snippet() write_all_sites() write_include_file() ok, msg = test_config() if not ok: raise RuntimeError(f"nginx config test failed: {msg}") result = subprocess.run( ["sudo", "nginx", "-s", "reload"], capture_output=True, text=True, check=False ) if result.returncode != 0: logger.error("nginx reload failed: %s", result.stderr.strip()) else: 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: 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 # ------------------------------------------------------------------ def write_htpasswd(user: str, password: str) -> None: """Append (or create) an htpasswd entry for *user*.""" 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 _hash_password(password: str) -> str: try: from passlib.hash import apache_passwd return apache_passwd.using(rounds=12).hash(password) except Exception: import crypt as _crypt salt = os.urandom(16).hex()[:16] return _crypt.crypt(password, f"$5${salt}") __all__ = [ "add_domain", "apply", "generate_server_conf", "get_config", "get_domains", "remove_domain", "save_config", "set_management_proxy", "test_config", "update_domain", "write_acme_challenge", "write_all_sites", "write_htpasswd", "write_include_file", "write_site", "write_ssl_snippet", ]