refactor: introduce two-user daemon architecture with socket-based communication
- Add daemon/ module with aiohttp server, sync client, and handler registry - Add daemon/handlers/ for privileged operations (acme, dnsmasq, firewall, logs, nginx, wireguard) - Add system/acme-deploy.py, vacuum-walld sudoers and systemd service - Update API routes to use daemon client instead of lib/ directly - Update lib/, tests/, and webui/ for new architecture - Update docs and deployment scripts
This commit is contained in:
@@ -0,0 +1,382 @@
|
||||
"""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, 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},
|
||||
}
|
||||
|
||||
_NGINX_TAGS = {"nginx"}
|
||||
|
||||
|
||||
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 _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 _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_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)
|
||||
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:
|
||||
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]:
|
||||
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:
|
||||
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:
|
||||
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:
|
||||
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)
|
||||
|
||||
|
||||
@registry.register("GET", "/nginx/config", cache_tags=_NGINX_TAGS)
|
||||
def get_config(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
return _get_config()
|
||||
|
||||
|
||||
@registry.register("POST", "/nginx/config", invalidate=_NGINX_TAGS)
|
||||
def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
_save_config(body)
|
||||
return {"config_saved": True}
|
||||
|
||||
|
||||
@registry.register("PATCH", "/nginx/config", invalidate=_NGINX_TAGS)
|
||||
def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
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)
|
||||
return {"config_saved": True}
|
||||
|
||||
|
||||
@registry.register("GET", "/nginx/domains", cache_tags=_NGINX_TAGS)
|
||||
def get_domains(_request: Any, _body: Any) -> 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
|
||||
|
||||
|
||||
@registry.register("POST", "/nginx/domains/add", invalidate=_NGINX_TAGS)
|
||||
def add_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
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)
|
||||
return {"domain": domain}
|
||||
|
||||
|
||||
@registry.register("DELETE", "/nginx/domains/remove", invalidate=_NGINX_TAGS)
|
||||
def remove_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
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()
|
||||
return {"domain": domain}
|
||||
|
||||
|
||||
@registry.register("POST", "/nginx/domains/update", invalidate=_NGINX_TAGS)
|
||||
def update_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
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)
|
||||
return {"domain": domain}
|
||||
|
||||
|
||||
@registry.register("POST", "/nginx/apply", invalidate=_NGINX_TAGS)
|
||||
def apply(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
_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()
|
||||
return {"applied": True}
|
||||
|
||||
|
||||
@registry.register("POST", "/nginx/test", invalidate=_NGINX_TAGS)
|
||||
def test(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
valid, output = _test_config()
|
||||
return {"valid": valid, "output": output}
|
||||
|
||||
|
||||
@registry.register("POST", "/nginx/ssl-apply", invalidate=_NGINX_TAGS)
|
||||
def ssl_apply(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
_write_ssl_snippet()
|
||||
return {"applied": True}
|
||||
|
||||
|
||||
@registry.register("POST", "/nginx/management", invalidate=_NGINX_TAGS)
|
||||
def set_management_proxy(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
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)
|
||||
return {"domain": domain}
|
||||
|
||||
|
||||
@registry.register("POST", "/nginx/reload")
|
||||
def reload_nginx(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
_reload_nginx()
|
||||
return {"reloaded": True}
|
||||
Reference in New Issue
Block a user