Refactor nginx to path-based domain model with config migration

Replace the legacy top-level management key with a unified paths-based
model. Each domain now contains a paths map where each entry defines its
own backend, auth, headers, and flags (is_management, is_websocket).

- Add _migrate_config() to auto-migrate legacy formats on first load
- Remove set_management_proxy() and POST_NGINX_MANAGEMENT endpoint
- Update server_block.conf template to iterate paths with per-location auth
- Update daemon handler, API blueprint, state collector, and install script
- Add server config generation tests for paths, WebSocket, auth inheritance
- Update frontend proxy page to display per-path rows with flags
This commit is contained in:
2026-06-27 23:34:06 +00:00
parent 8feb56faf6
commit 835326311b
14 changed files with 851 additions and 558 deletions
+177 -176
View File
@@ -17,7 +17,6 @@ from daemon.iface import (
POST_NGINX_CONFIG,
POST_NGINX_DOMAINS_ADD,
POST_NGINX_DOMAINS_UPDATE,
POST_NGINX_MANAGEMENT,
POST_NGINX_RELOAD,
POST_NGINX_SSL_APPLY,
POST_NGINX_TEST,
@@ -59,11 +58,53 @@ DEFAULT_SSL: dict[str, Any] = {
DEFAULT_CONFIG: dict[str, Any] = {
"domains": {},
"management": None,
"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
@@ -72,49 +113,47 @@ def _get_state() -> dict[str, Any] | None:
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.
"""
"""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.
Args:
cfg: The config dictionary to save.
"""
"""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.
Args:
domain_cfg: Domain config dict containing domain name, backend, headers, etc.
Returns:
Rendered server block as a string.
"""
"""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"],
backend=domain_cfg.get("backend", {}),
headers=domain_cfg.get("headers", {}),
paths=paths,
force_ssl=domain_cfg.get("force_ssl", True),
cert=domain_cfg.get("cert"),
auth=domain_cfg.get("auth"),
is_management=False,
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"),
@@ -122,12 +161,7 @@ def _generate_server_conf(domain_cfg: dict[str, Any]) -> str:
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.
"""
"""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")
@@ -170,11 +204,7 @@ def _write_ssl_snippet() -> None:
def _test_config() -> tuple[bool, str]:
"""Run `nginx -t` to validate the current config.
Returns:
Tuple of (passed, message).
"""
"""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()
@@ -184,10 +214,7 @@ def _test_config() -> tuple[bool, str]:
def _reload_nginx() -> None:
"""Send SIGHUP to nginx to reload its configuration.
Logs an error if the reload fails.
"""
"""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())
@@ -196,10 +223,7 @@ def _reload_nginx() -> None:
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.
"""
"""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()
@@ -209,27 +233,11 @@ def _write_all_sites() -> None:
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")
acme_home_path = PROJECT_DIR / "data" / "acme"
acme_cert_dir = str(find_cert_dir(mgmt.get("domain", ""), acme_home_path))
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_cert_dir=acme_cert_dir,
certs_dir=str(PROJECT_DIR / "data" / "certs"),
acme_webroot=str(PROJECT_DIR / "data" / "acme" / "www"),
)
_write_site("management", mgmt_conf)
written.add("management.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()
@@ -245,26 +253,14 @@ def _write_all_sites() -> None:
def _hash_password(password: str) -> str:
"""Hash *password* using SHA-256 crypt via passlib.
Args:
password: Plain-text password to hash.
Returns:
The hashed password string suitable for ``.htpasswd``.
"""
"""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.
Args:
user: The username to add or update.
password: Plain-text password to hash.
"""
"""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] = {}
@@ -300,11 +296,7 @@ def _get_nginx_state() -> dict[str, Any]:
@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.
"""
"""GET /nginx/config — return current nginx config."""
ng = _get_nginx_state()
if ng:
return ng.get("config", {})
@@ -313,11 +305,7 @@ def get_config(_request: Any, _body: Any) -> dict[str, Any]:
@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.
"""
"""POST /nginx/config — replace the entire nginx config and refresh state."""
if not body:
raise ValueError("Request body required")
_save_config(body)
@@ -327,11 +315,7 @@ def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str,
@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.
"""
"""PATCH /nginx/config — deep-merge partial updates into current config."""
if not body:
raise ValueError("Request body required")
from lib.common import deep_merge
@@ -345,11 +329,7 @@ def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
@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.
"""
"""GET /nginx/domains — return the list of configured proxy domains."""
ng = _get_nginx_state()
if ng:
return ng.get("domains", [])
@@ -360,39 +340,82 @@ def get_domains(_request: Any, _body: Any) -> list[dict[str, Any]]:
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.
Accepts either legacy backend_* fields or a ``paths`` map.
"""
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
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"])
@@ -401,12 +424,7 @@ def add_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
@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.
"""
"""DELETE /nginx/domains/remove — remove a domain from the proxy config."""
if not body:
raise ValueError("Request body required")
domain = body.get("domain", "").strip()
@@ -426,12 +444,7 @@ def remove_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
@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.
"""
"""POST /nginx/domains/update — patch fields of an existing domain entry."""
if not body:
raise ValueError("Request body required")
domain = body.get("domain", "").strip()
@@ -440,9 +453,36 @@ def update_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
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]
# 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:
@@ -454,11 +494,7 @@ def update_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
@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.
"""
"""POST /nginx/apply — render all configs, test, and reload nginx."""
_write_ssl_snippet()
_write_all_sites()
_write_include_file()
@@ -472,11 +508,7 @@ def apply(_request: Any, _body: Any) -> dict[str, Any]:
@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`.
"""
"""POST /nginx/test — dry-run validate the live nginx config without applying."""
valid, output = _test_config()
return {"valid": valid, "output": output}
@@ -489,37 +521,6 @@ def ssl_apply(_request: Any, _body: Any) -> dict[str, Any]:
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)."""
+1 -1
View File
@@ -43,7 +43,7 @@ POST_NGINX_DOMAINS_UPDATE: Endpoint = _ep("POST", "/nginx/domains/update")
POST_NGINX_APPLY: Endpoint = _ep("POST", "/nginx/apply")
POST_NGINX_TEST: Endpoint = _ep("POST", "/nginx/test")
POST_NGINX_SSL_APPLY: Endpoint = _ep("POST", "/nginx/ssl-apply")
POST_NGINX_MANAGEMENT: Endpoint = _ep("POST", "/nginx/management")
POST_NGINX_RELOAD: Endpoint = _ep("POST", "/nginx/reload")
# ---- Firewall ----