d1ab717c0f
- Fix WireGuard private key leak in API responses and config updates - Update systemd service to serve from repo root with adjusted sandbox - Add CLI flags, idempotency, and dev mode to install.sh - Extract common utilities to lib/common.py and webui/api/common.py - Migrate frontend to htmx for simpler, more maintainable UI - Update docs to reflect current architecture and deployment model - Vendor htmx dependencies per project requirements
413 lines
12 KiB
Python
413 lines
12 KiB
Python
"""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",
|
|
]
|