2f215793e9
Add comprehensive docstrings to firewall, DHCP, proxy, wireguard, certs, and logs API endpoints. Document parameters, return values, and error cases for the documentation system.
498 lines
16 KiB
Python
498 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.server import NotFoundError, refresh_state, registry
|
|
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")
|
|
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 _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 = INCLUDE_FILE.with_suffix(".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 = SSL_SNIPPET.with_suffix(".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")
|
|
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")
|
|
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 _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)
|
|
import crypt
|
|
|
|
salt = os.urandom(16).hex()[:16]
|
|
hashed = crypt.crypt(password, f"$5${salt}")
|
|
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}
|