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_CONFIG,
POST_NGINX_DOMAINS_ADD, POST_NGINX_DOMAINS_ADD,
POST_NGINX_DOMAINS_UPDATE, POST_NGINX_DOMAINS_UPDATE,
POST_NGINX_MANAGEMENT,
POST_NGINX_RELOAD, POST_NGINX_RELOAD,
POST_NGINX_SSL_APPLY, POST_NGINX_SSL_APPLY,
POST_NGINX_TEST, POST_NGINX_TEST,
@@ -59,11 +58,53 @@ DEFAULT_SSL: dict[str, Any] = {
DEFAULT_CONFIG: dict[str, Any] = { DEFAULT_CONFIG: dict[str, Any] = {
"domains": {}, "domains": {},
"management": None,
"ssl": {**DEFAULT_SSL}, "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: def _get_state() -> dict[str, Any] | None:
"""Retrieve cached nginx state from the state store.""" """Retrieve cached nginx state from the state store."""
from lib.state import state as 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]: def _get_config() -> dict[str, Any]:
"""Load the nginx config JSON, applying defaults for missing fields. """Load the nginx config JSON, applying defaults and migrations."""
Returns:
The parsed config dict with ssl defaults filled in.
"""
ensure_dirs(CONFIG_DIR, SITES_DIR) ensure_dirs(CONFIG_DIR, SITES_DIR)
raw = load_json(CONFIG_FILE) raw = load_json(CONFIG_FILE)
if not raw: if not raw:
raw = deepcopy(DEFAULT_CONFIG) raw = deepcopy(DEFAULT_CONFIG)
if "ssl" not in raw: if "ssl" not in raw:
raw["ssl"] = deepcopy(DEFAULT_SSL) raw["ssl"] = deepcopy(DEFAULT_SSL)
raw = _migrate_config(raw)
_save_config(raw)
return raw return raw
def _save_config(cfg: dict[str, Any]) -> None: def _save_config(cfg: dict[str, Any]) -> None:
"""Persist the nginx config dict to disk. """Persist the nginx config dict to disk."""
Args:
cfg: The config dictionary to save.
"""
save_json(CONFIG_FILE, cfg) save_json(CONFIG_FILE, cfg)
def _generate_server_conf(domain_cfg: dict[str, Any]) -> str: def _generate_server_conf(domain_cfg: dict[str, Any]) -> str:
"""Render an nginx server block config from a domain entry via Jinja. """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") tmpl = ENV.get_template("nginx/server_block.conf")
acme_home_path = PROJECT_DIR / "data" / "acme" acme_home_path = PROJECT_DIR / "data" / "acme"
acme_cert_dir = str(find_cert_dir(domain_cfg["domain"], acme_home_path)) 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( return tmpl.render(
domain=domain_cfg["domain"], domain=domain_cfg["domain"],
backend=domain_cfg.get("backend", {}), paths=paths,
headers=domain_cfg.get("headers", {}),
force_ssl=domain_cfg.get("force_ssl", True), force_ssl=domain_cfg.get("force_ssl", True),
cert=domain_cfg.get("cert"), cert=domain_cfg.get("cert"),
auth=domain_cfg.get("auth"), cert_path=cert_path,
is_management=False, cert_key_path=cert_key_path,
domain_auth=domain_cfg.get("auth"),
has_management=has_management,
acme_cert_dir=acme_cert_dir, acme_cert_dir=acme_cert_dir,
certs_dir=str(PROJECT_DIR / "data" / "certs"), certs_dir=str(PROJECT_DIR / "data" / "certs"),
acme_webroot=str(PROJECT_DIR / "data" / "acme" / "www"), 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: def _write_site(domain: str, conf_text: str) -> None:
"""Atomically write a single site config file into sites-enabled. """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) ensure_dirs(SITES_DIR)
path = SITES_DIR / f"{domain}.conf" path = SITES_DIR / f"{domain}.conf"
tmp = path.with_suffix(".tmp") tmp = path.with_suffix(".tmp")
@@ -170,11 +204,7 @@ def _write_ssl_snippet() -> None:
def _test_config() -> tuple[bool, str]: def _test_config() -> tuple[bool, str]:
"""Run `nginx -t` to validate the current config. """Run `nginx -t` to validate the current config."""
Returns:
Tuple of (passed, message).
"""
result = run_proc(["nginx", "-t"], sudo=True, check=False) result = run_proc(["nginx", "-t"], sudo=True, check=False)
ok = result.returncode == 0 ok = result.returncode == 0
output = (result.stderr or result.stdout or "").strip() output = (result.stderr or result.stdout or "").strip()
@@ -184,10 +214,7 @@ def _test_config() -> tuple[bool, str]:
def _reload_nginx() -> None: def _reload_nginx() -> None:
"""Send SIGHUP to nginx to reload its configuration. """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) result = run_proc(["nginx", "-s", "reload"], sudo=True, check=False)
if result.returncode != 0: if result.returncode != 0:
logger.error("nginx reload failed: %s", result.stderr.strip()) logger.error("nginx reload failed: %s", result.stderr.strip())
@@ -196,10 +223,7 @@ def _reload_nginx() -> None:
def _write_all_sites() -> None: def _write_all_sites() -> None:
"""Regenerate all site configs, management proxy, and ACME challenge site. """Regenerate all site configs and ACME challenge site."""
Removes stale .conf files that are no longer in config.
"""
ensure_dirs(SITES_DIR) ensure_dirs(SITES_DIR)
cfg = _get_config() cfg = _get_config()
existing = set(SITES_DIR.iterdir()) if SITES_DIR.exists() else set() 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) conf = _generate_server_conf(dom_copy)
_write_site(name, conf) _write_site(name, conf)
written.add(f"{name}.conf") written.add(f"{name}.conf")
if cfg.get("management"):
mgmt = cfg["management"] old_mgmt = SITES_DIR / "management.conf"
tmpl = ENV.get_template("nginx/server_block.conf") if old_mgmt.exists() and old_mgmt.name not in written:
acme_home_path = PROJECT_DIR / "data" / "acme" old_mgmt.unlink()
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")
for old in existing: for old in existing:
if old.suffix == ".conf" and old.name not in written: if old.suffix == ".conf" and old.name not in written:
old.unlink() old.unlink()
@@ -245,26 +253,14 @@ def _write_all_sites() -> None:
def _hash_password(password: str) -> str: def _hash_password(password: str) -> str:
"""Hash *password* using SHA-256 crypt via passlib. """Hash *password* using SHA-256 crypt via passlib."""
Args:
password: Plain-text password to hash.
Returns:
The hashed password string suitable for ``.htpasswd``.
"""
from passlib.hash import sha256_crypt from passlib.hash import sha256_crypt
return sha256_crypt.hash(password) return sha256_crypt.hash(password)
def _write_htpasswd(user: str, password: str) -> None: def _write_htpasswd(user: str, password: str) -> None:
"""Add or update a user entry in the .htpasswd file using SHA-256 hashing. """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) ensure_dirs(DATA_DIR)
hashed = _hash_password(password) hashed = _hash_password(password)
existing: dict[str, str] = {} existing: dict[str, str] = {}
@@ -300,11 +296,7 @@ def _get_nginx_state() -> dict[str, Any]:
@registry.register(GET_NGINX_CONFIG) @registry.register(GET_NGINX_CONFIG)
def get_config(_request: Any, _body: Any) -> dict[str, Any]: def get_config(_request: Any, _body: Any) -> dict[str, Any]:
"""GET /nginx/config — return current nginx config. """GET /nginx/config — return current nginx config."""
Returns:
Full config dict from state cache, or fallback to file.
"""
ng = _get_nginx_state() ng = _get_nginx_state()
if ng: if ng:
return ng.get("config", {}) return ng.get("config", {})
@@ -313,11 +305,7 @@ def get_config(_request: Any, _body: Any) -> dict[str, Any]:
@registry.register(POST_NGINX_CONFIG) @registry.register(POST_NGINX_CONFIG)
def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: 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. """POST /nginx/config — replace the entire nginx config and refresh state."""
Raises:
ValueError: When request body is missing.
"""
if not body: if not body:
raise ValueError("Request body required") raise ValueError("Request body required")
_save_config(body) _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) @registry.register(PATCH_NGINX_CONFIG)
def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""PATCH /nginx/config — deep-merge partial updates into current config. """PATCH /nginx/config — deep-merge partial updates into current config."""
Raises:
ValueError: When request body is missing.
"""
if not body: if not body:
raise ValueError("Request body required") raise ValueError("Request body required")
from lib.common import deep_merge 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) @registry.register(GET_NGINX_DOMAINS)
def get_domains(_request: Any, _body: Any) -> list[dict[str, Any]]: def get_domains(_request: Any, _body: Any) -> list[dict[str, Any]]:
"""GET /nginx/domains — return the list of configured proxy domains. """GET /nginx/domains — return the list of configured proxy domains."""
Returns:
Domains list from state cache, or empty list.
"""
ng = _get_nginx_state() ng = _get_nginx_state()
if ng: if ng:
return ng.get("domains", []) 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]: def add_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""POST /nginx/domains/add — add a new reverse-proxy domain entry. """POST /nginx/domains/add — add a new reverse-proxy domain entry.
Raises: Accepts either legacy backend_* fields or a ``paths`` map.
ValueError: When required fields (domain, backend_host, backend_port) are missing.
ValueError: When the domain already exists.
""" """
if not body: if not body:
raise ValueError("Request body required") raise ValueError("Request body required")
domain = body.get("domain", "").strip() 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: if not domain:
raise ValueError("'domain' is required") 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() cfg = _get_config()
if domain in cfg["domains"]: if domain in cfg["domains"]:
raise ValueError(f"Domain {domain!r} already configured") raise ValueError(f"Domain {domain!r} already configured")
entry: dict[str, Any] = {
"backend": { paths = body.get("paths")
"host": backend_host, cert = body.get("cert")
"port": int(backend_port), force_ssl = body.get("force_ssl", True)
"proto": backend_proto,
}, if paths is not None:
"force_ssl": True, entry: dict[str, Any] = {
} "paths": paths,
if cert is not None: "force_ssl": force_ssl,
entry["cert"] = cert }
if extra_headers is not None: if cert is not None:
entry["headers"] = extra_headers 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 cfg["domains"][domain] = entry
_save_config(cfg) _save_config(cfg)
refresh_state(["nginx"]) 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) @registry.register(DELETE_NGINX_DOMAINS_REMOVE)
def remove_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: def remove_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""DELETE /nginx/domains/remove — remove a domain from the proxy config. """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: if not body:
raise ValueError("Request body required") raise ValueError("Request body required")
domain = body.get("domain", "").strip() 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) @registry.register(POST_NGINX_DOMAINS_UPDATE)
def update_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: def update_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""POST /nginx/domains/update — patch fields of an existing domain entry. """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: if not body:
raise ValueError("Request body required") raise ValueError("Request body required")
domain = body.get("domain", "").strip() 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() cfg = _get_config()
if domain not in cfg["domains"]: if domain not in cfg["domains"]:
raise NotFoundError(f"Domain {domain!r} not configured") raise NotFoundError(f"Domain {domain!r} not configured")
updates = {k: v for k, v in body.items() if k != "domain"}
entry = cfg["domains"][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(): for key, val in updates.items():
if key in ("backend", "headers", "paths"):
continue
if isinstance(val, dict) and key in entry: if isinstance(val, dict) and key in entry:
entry[key].update(val) entry[key].update(val)
else: else:
@@ -454,11 +494,7 @@ def update_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
@registry.register(POST_NGINX_APPLY) @registry.register(POST_NGINX_APPLY)
def apply(_request: Any, _body: Any) -> dict[str, Any]: def apply(_request: Any, _body: Any) -> dict[str, Any]:
"""POST /nginx/apply — render all configs, test, and reload nginx. """POST /nginx/apply — render all configs, test, and reload nginx."""
Raises:
RuntimeError: When the nginx config test fails.
"""
_write_ssl_snippet() _write_ssl_snippet()
_write_all_sites() _write_all_sites()
_write_include_file() _write_include_file()
@@ -472,11 +508,7 @@ def apply(_request: Any, _body: Any) -> dict[str, Any]:
@registry.register(POST_NGINX_TEST) @registry.register(POST_NGINX_TEST)
def test(_request: Any, _body: Any) -> dict[str, Any]: def test(_request: Any, _body: Any) -> dict[str, Any]:
"""POST /nginx/test — dry-run validate the live nginx config without applying. """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() valid, output = _test_config()
return {"valid": valid, "output": output} return {"valid": valid, "output": output}
@@ -489,37 +521,6 @@ def ssl_apply(_request: Any, _body: Any) -> dict[str, Any]:
return {"applied": True} 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) @registry.register(POST_NGINX_RELOAD)
def reload_nginx(_request: Any, _body: Any) -> dict[str, Any]: def reload_nginx(_request: Any, _body: Any) -> dict[str, Any]:
"""POST /nginx/reload — trigger an nginx reload (SIGHUP).""" """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_APPLY: Endpoint = _ep("POST", "/nginx/apply")
POST_NGINX_TEST: Endpoint = _ep("POST", "/nginx/test") POST_NGINX_TEST: Endpoint = _ep("POST", "/nginx/test")
POST_NGINX_SSL_APPLY: Endpoint = _ep("POST", "/nginx/ssl-apply") 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") POST_NGINX_RELOAD: Endpoint = _ep("POST", "/nginx/reload")
# ---- Firewall ---- # ---- Firewall ----
+20 -27
View File
@@ -715,13 +715,15 @@ Write the global nginx SSL snippet configuration.
GET /api/proxy/domains GET /api/proxy/domains
``` ```
Return all configured proxy domains. Return all configured proxy domains. The response is flattened by path — each path within a domain produces a separate entry.
**Response:** **Response:**
| Field | Type | Description | | Field | Type | Description |
|-------|------|-------------| |-------|------|-------------|
| `data` | `[object, ...]` | Array of domain configuration objects | | `data` | `[object, ...]` | Array of path-level domain configuration objects |
Each entry contains `domain` (string), `path` (string), `backend` (object with `host`, `port`, `proto`), `online` (boolean), `force_ssl` (boolean), and optionally `is_management` or `is_websocket` flags.
--- ---
@@ -731,9 +733,18 @@ Return all configured proxy domains.
POST /api/proxy/domains POST /api/proxy/domains
``` ```
Add a new reverse proxy domain. Add a new reverse proxy domain. Accepts two modes:
**Request Body:** **Paths mode (preferred):**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `domain` | `string` | Yes | Domain name to proxy |
| `paths` | `object` | Yes | Path-to-config map. Each path entry must have a `backend` key with `host`, `port`, `proto`. |
| `cert` | `string` | No | Certificate type |
| `force_ssl` | `boolean` | No | HTTPS redirect flag (default `true`) |
**Legacy mode (backward compatible):**
| Field | Type | Required | Description | | Field | Type | Required | Description |
|-------|------|----------|-------------| |-------|------|----------|-------------|
@@ -741,7 +752,7 @@ Add a new reverse proxy domain.
| `backend_host` | `string` | Yes | Backend server IP or hostname | | `backend_host` | `string` | Yes | Backend server IP or hostname |
| `backend_port` | `number` | Yes | Backend server port | | `backend_port` | `number` | Yes | Backend server port |
| `backend_proto` | `string` | No | Backend protocol (`"http"` or `"https"`); defaults to `"http"` | | `backend_proto` | `string` | No | Backend protocol (`"http"` or `"https"`); defaults to `"http"` |
| `cert` | `string` | No | Certificate domain | | `cert` | `string` | No | Certificate type |
| `extra_headers` | `object` | No | Extra proxy headers | | `extra_headers` | `object` | No | Extra proxy headers |
**Response (`data`):** **Response (`data`):**
@@ -774,9 +785,9 @@ Returns HTTP `404` if the domain is not configured.
PUT /api/proxy/domains/<domain> PUT /api/proxy/domains/<domain>
``` ```
Update one or more fields of an existing domain entry. Only fields present in the body are modified. Update one or more fields of an existing domain entry. Only fields present in the body are modified. Supports both domain-level keys (`paths`, `force_ssl`, `cert`, `auth`) and path-level shorthand (`backend`, `headers` for the root path).
**Request Body:** Any subset of (`backend_host`, `backend_port`, `backend_proto`, `cert`, `extra_headers`). **Request Body:** Any subset of (`paths`, `backend`, `backend_host`, `backend_port`, `backend_proto`, `cert`, `extra_headers`, `force_ssl`, `auth`).
**Response (`data`):** **Response (`data`):**
@@ -837,27 +848,9 @@ Run `nginx -t` against the generated configuration without reloading.
**Error (invalid):** HTTP `400` with standard `{"ok": false, "error": "<nginx output>"}` response. **Error (invalid):** HTTP `400` with standard `{"ok": false, "error": "<nginx output>"}` response.
### Management ### Management Proxy
#### Configure Management WebUI Proxy >The legacy `POST /api/proxy/management` endpoint has been removed. The management WebUI proxy is now configured as a regular domain entry with `is_management: true` on the root path and `is_websocket: true` on the `/ws` path. Use the standard domain add/update endpoints to configure it.
```
POST /api/proxy/management
```
Configure the nginx proxy block for the management WebUI itself, including optional HTTP basic authentication.
**Request Body:**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `domain` | `string` | Yes | Management domain (e.g., `"myhost.local"`) |
| `flask_host` | `string` | No | Flask app bind host; defaults to `"127.0.0.1"` |
| `flask_port` | `number` | No | Flask app bind port; defaults to `9090` |
| `auth_user` | `string` | No | Username for basic auth |
| `auth_pass` | `string` | No | Password for basic auth |
**Response:** `data` is `null` on success.
--- ---
+86 -36
View File
@@ -76,35 +76,64 @@ Additional dnsmasq directives can be appended verbatim by placing plain-text fil
**File**: `config/nginx/config.json` **File**: `config/nginx/config.json`
This file defines reverse proxy domains, the management interface, and global SSL settings. The application renders it into per-domain server block files in `data/nginx/sites-enabled/` and into the shared SSL snippet at `/etc/nginx/snippets/vacuum-wall-ssl.conf`. This file defines reverse proxy domains with path-based routing, and global SSL settings. The application renders it into per-domain server block files in `data/nginx/sites-enabled/` and into the shared SSL snippet at `/etc/nginx/snippets/vacuum-wall-ssl.conf`.
```json ```json
{ {
"domains": { "domains": {
"app.example.com": { "app.example.com": {
"backend": {
"host": "192.168.2.50",
"port": 8080,
"proto": "http"
},
"force_ssl": true, "force_ssl": true,
"cert": "acme", "cert": "acme",
"headers": { "auth": {
"X-Forwarded-Proto": "https", "user": "admin",
"X-Real-IP": "$remote_addr" "htpasswd": "/home/wall/vacuum-wall/data/nginx/.htpasswd"
},
"paths": {
"/": {
"backend": {
"host": "192.168.2.50",
"port": 8080,
"proto": "http"
},
"headers": {
"X-Forwarded-Proto": "https"
}
},
"/api": {
"backend": {
"host": "192.168.2.51",
"port": 3000,
"proto": "http"
},
"auth": null
}
} }
}
},
"management": {
"domain": "vacuum-wall.local",
"backend": {
"host": "127.0.0.1",
"port": 9090,
"proto": "http"
}, },
"auth": { "mgmt.example.com": {
"user": "admin", "force_ssl": true,
"htpasswd": "/home/wall/vacuum-wall/data/nginx/.htpasswd" "cert": "acme",
"paths": {
"/": {
"backend": {
"host": "127.0.0.1",
"port": 9090,
"proto": "http"
},
"is_management": true,
"auth": {
"user": "admin",
"htpasswd": "/home/wall/vacuum-wall/data/nginx/.htpasswd"
}
},
"/ws": {
"backend": {
"host": "127.0.0.1",
"port": 9091,
"proto": "http"
},
"is_websocket": true
}
}
} }
}, },
"ssl": { "ssl": {
@@ -117,17 +146,40 @@ This file defines reverse proxy domains, the management interface, and global SS
### Domain Entries ### Domain Entries
The `domains` object maps domain names (keys) to proxy configurations. Each entry produces a separate nginx `server` block. The `domains` object maps domain names (keys) to proxy configurations. Each entry produces a separate nginx `server` block. All routing is path-based — a domain can proxy multiple paths to different backends.
| Field | Type | Required | Description | | Field | Type | Required | Description |
|---|---|---|---| |---|---|---|---|
| `backend` | object | Yes | The upstream service that receives proxied traffic. | | `paths` | object | Yes | Path-to-config map. Each key is a URL path (e.g., `"/"`, `"/api"`). No catch-all unless `"/"` is explicitly defined. |
| `force_ssl` | boolean | No | Enable HTTPS redirect. HTTP requests to this domain receive a 301 redirect to HTTPS. Default: `true`. |
| `cert` | string | No | Certificate provisioning method. One of: `"acme"`, `"file"`, or `"selfsigned"`. Omit for domains that don't need a dedicated cert. |
| `auth` | object | No | Domain-level HTTP basic auth configuration (`{ user, htppasswd }`). Applies to all paths unless overridden at the path level. |
### Path Entries
Each entry under `paths` defines a location block and its proxy backend.
| Field | Type | Required | Description |
|---|---|---|---|
| `backend` | object | Yes | The upstream service for this path. |
| `backend.host` | string | Yes | IP address or hostname of the backend service. | | `backend.host` | string | Yes | IP address or hostname of the backend service. |
| `backend.port` | integer | Yes | Port the backend service is listening on. | | `backend.port` | integer | Yes | Port the backend service is listening on. |
| `backend.proto` | string | No | Protocol for the backend connection: `http` or `https`. Default: `http`. | | `backend.proto` | string | No | Protocol: `http` or `https`. Default: `http`. |
| `force_ssl` | boolean | No | Enable HTTPS redirect. HTTP requests to this domain receive a 301 redirect to HTTPS. Default: `true`. | | `headers` | object | No | Custom proxy headers (key-value pairs). Supports nginx variable interpolation. |
| `headers` | object | No | Custom headers to set on proxied requests. Supports nginx variable interpolation (e.g., `$remote_addr`). | | `auth` | object \| null | No | Path-level auth override. `{ user, htppasswd }` replaces domain-level auth. `null` disables auth for this path. |
| `cert` | string | No | Certificate provisioning method. One of: `"acme"`, `"file"`, or `"selfsigned"`. Omit for domains that don't need a dedicated cert. | | `is_management` | boolean | No | Marks this path as the Vacuum Wall WebUI backend. Suppresses security headers (X-Frame-Options, etc.) so the SPA works correctly. |
| `is_websocket` | boolean | No | Marks this path as a WebSocket pass-through. Disables auth, sets Upgrade/Connection headers, uses extended timeouts. |
### Auth Inheritance Rules
- Domain-level `auth` applies to all paths unless overridden.
- Path-level `auth: null` means "no auth" for that path.
- Path-level `auth: { ... }` overrides domain-level for that path.
- No other domain-level settings inherit — `headers` is path-only.
### Path ordering
Nginx evaluates `location` blocks by specificity: more specific prefixes (e.g., `/api`) always match before `/` by nginx's own priority rules. The order of keys in the `paths` dict does not affect routing behavior.
### Certificate Types ### Certificate Types
@@ -141,22 +193,20 @@ The `cert` field is a string that selects the provisioning method:
### Management Domain ### Management Domain
The `management` block configures the Vacuum Wall admin interface itself. It follows the same structure as a domain entry but can include an `auth` block for HTTP Basic Authentication. The Vacuum Wall admin interface is configured as a regular domain entry under `domains`, with `is_management: true` on the path pointing to the Flask app. A second path (`/ws`) with `is_websocket: true` provides WebSocket pass-through for real-time state updates. This replaces the legacy `management` top-level key.
| Field | Type | Required | Description | The application can create the `.htpasswd` file programmatically via `write_htpasswd()` (using passlib's SHA-256 crypt). Manual creation is also possible:
|---|---|---|---|
| `domain` | string | Yes | The hostname used to access the management WebUI (e.g., `<hostname>.local`). |
| `backend` | object | Yes | Points to the Flask app at `127.0.0.1:9090`. |
| `auth` | object | No | HTTP Basic Authentication configuration. Only created if `auth_user` is provided when setting the management proxy. |
| `auth.user` | string | Yes | Username for the `.htpasswd` file. |
| `auth.htpasswd` | string | Yes | Full path to the `.htpasswd` file containing the username and hashed password. |
The application can create the `.htpasswd` file programmatically via `write_htpasswd()` (using passlib's `apache_passwd` with Apache-Round-12, falling back to SHA-256 crypt). Manual creation is also possible:
```bash ```bash
htpasswd -bc data/nginx/.htpasswd admin yourpassword htpasswd -bc data/nginx/.htpasswd admin yourpassword
``` ```
### Backward Compatibility
Config files using the legacy format are auto-migrated on first load:
- Domain entries with a top-level `backend` key are wrapped into `paths["/"]`.
- A legacy `management` top-level key is migrated into `domains[management.domain]` with `is_management` on the root path and a `/ws` WebSocket path.
### Global SSL Settings ### Global SSL Settings
The `ssl` block defines TLS parameters applied to all HTTPS server blocks via the shared snippet. The `ssl` block defines TLS parameters applied to all HTTPS server blocks via the shared snippet.
+13 -5
View File
@@ -365,7 +365,7 @@ else
"${PROJECT_DIR}/.venv/bin/python3" -c " "${PROJECT_DIR}/.venv/bin/python3" -c "
import daemon.client as c import daemon.client as c
from daemon.iface import ( from daemon.iface import (
POST_ACME_SELF_SIGNED, POST_NGINX_MANAGEMENT, POST_NGINX_APPLY, POST_ACME_SELF_SIGNED, POST_NGINX_DOMAINS_ADD, POST_NGINX_APPLY,
POST_FIREWALL_CONFIG, POST_FIREWALL_CONFIG_APPLY, POST_NETWORK_SYSCTL_SET, POST_FIREWALL_CONFIG, POST_FIREWALL_CONFIG_APPLY, POST_NETWORK_SYSCTL_SET,
GET_NETWORK_INFER_DHCP_RANGES, GET_NETWORK_INFER_DHCP_RANGES,
) )
@@ -384,12 +384,20 @@ try:
except Exception as e: except Exception as e:
print(f' [cert] Warning: {e}', file=sys.stderr) print(f' [cert] Warning: {e}', file=sys.stderr)
# Management proxy + htpasswd # Management proxy domain + htpasswd
try: try:
c.post(POST_NGINX_MANAGEMENT, { c.post(POST_NGINX_DOMAINS_ADD, {
'domain': domain, 'domain': domain,
'flask_host': '127.0.0.1', 'paths': {
'flask_port': 9090, '/': {
'backend': {'host': '127.0.0.1', 'port': 9090, 'proto': 'http'},
'is_management': True,
},
'/ws': {
'backend': {'host': '127.0.0.1', 'port': 9091, 'proto': 'http'},
'is_websocket': True,
},
},
'auth_user': mgmt_user, 'auth_user': mgmt_user,
'auth_pass': mgmt_pass, 'auth_pass': mgmt_pass,
}) })
+163 -119
View File
@@ -49,7 +49,6 @@ DEFAULT_SSL: dict[str, Any] = {
DEFAULT_CONFIG: dict[str, Any] = { DEFAULT_CONFIG: dict[str, Any] = {
"domains": {}, "domains": {},
"management": None,
"ssl": {**DEFAULT_SSL}, "ssl": {**DEFAULT_SSL},
} }
@@ -59,14 +58,71 @@ DEFAULT_CONFIG: dict[str, Any] = {
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def _migrate_config(raw: dict[str, Any]) -> dict[str, Any]:
"""Migrate legacy config formats to the new paths-based model.
Handles two migrations:
1. Legacy ``management`` top-level key -> path entry under its domain.
2. Domain entries without ``paths`` -> wrap ``backend`` inside ``paths["/"]``.
Args:
raw: Config dict as loaded from disk.
Returns:
The migrated config dict.
"""
# Migrate management key
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"]
# Add WebSocket path if not present
if "/ws" not in paths:
paths["/ws"] = {
"backend": {"host": "127.0.0.1", "port": 9091, "proto": "http"},
"is_websocket": True,
}
del raw["management"]
# Migrate domain entries without paths
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_config() -> dict[str, Any]: def get_config() -> dict[str, Any]:
"""Load the current nginx config, initializing with defaults if needed. """Load the current nginx config, initializing with defaults if needed.
Ensure config and sites directories exist, then return a copy of the Ensures config and sites directories exist, applies migrations for
JSON file. On missing file or missing keys, populate from defaults. legacy formats, then returns the config dict.
Returns: Returns:
The complete config dict with ``domains``, ``ssl``, and ``management`` keys. The complete config dict with ``domains`` and ``ssl`` keys.
""" """
ensure_dirs(CONFIG_DIR, SITES_DIR) ensure_dirs(CONFIG_DIR, SITES_DIR)
raw = load_json(CONFIG_FILE) raw = load_json(CONFIG_FILE)
@@ -74,6 +130,8 @@ def get_config() -> dict[str, Any]:
raw = deepcopy(DEFAULT_CONFIG) raw = deepcopy(DEFAULT_CONFIG)
if "ssl" not in raw: if "ssl" not in raw:
raw["ssl"] = deepcopy(DEFAULT_SSL) raw["ssl"] = deepcopy(DEFAULT_SSL)
raw = _migrate_config(raw)
save_config(raw)
return raw return raw
@@ -83,26 +141,35 @@ def save_config(cfg: dict[str, Any]) -> None:
def get_domains() -> list[dict[str, Any]]: def get_domains() -> list[dict[str, Any]]:
"""Return a list of all configured proxy domains with status. """Return a list of all configured proxy domains flattened by path.
Each entry includes the domain name, backend info, SSL flag, and Each path within a domain becomes a separate entry with domain-level
whether a site config file currently exists on disk. settings repeated.
Returns: Returns:
List of dicts with ``domain``, ``backend``, ``online``, and ``force_ssl``. List of dicts with ``domain``, ``path``, ``backend``, ``online``,
``force_ssl``, and path-level flags.
""" """
cfg = get_config() cfg = get_config()
result: list[dict[str, Any]] = [] result: list[dict[str, Any]] = []
for name, dom in cfg.get("domains", {}).items(): for name, dom in cfg.get("domains", {}).items():
site = SITES_DIR / f"{name}.conf" site = SITES_DIR / f"{name}.conf"
result.append( paths = dom.get("paths", {})
{ if not paths:
continue
for ppath, pcfg in paths.items():
entry: dict[str, Any] = {
"domain": name, "domain": name,
"backend": dom.get("backend", {}), "path": ppath,
"backend": pcfg.get("backend", {}),
"online": site.exists(), "online": site.exists(),
"force_ssl": dom.get("force_ssl", True), "force_ssl": dom.get("force_ssl", True),
} }
) if pcfg.get("is_management"):
entry["is_management"] = True
if pcfg.get("is_websocket"):
entry["is_websocket"] = True
result.append(entry)
return result return result
@@ -113,21 +180,24 @@ def get_domains() -> list[dict[str, Any]]:
def add_domain( def add_domain(
domain: str, domain: str,
backend_host: str, backend_host: str | None = None,
backend_port: int, backend_port: int | None = None,
backend_proto: str = "http", backend_proto: str = "http",
cert: str | None = None, cert: str | None = None,
extra_headers: dict[str, str] | None = None, extra_headers: dict[str, str] | None = None,
paths: dict[str, dict[str, Any]] | None = None,
) -> None: ) -> None:
"""Add a new proxy domain with the given backend and optional settings. """Add a new proxy domain with the given backend and optional settings.
Args: Args:
domain: Domain name to add. domain: Domain name to add.
backend_host: Upstream host to proxy to. backend_host: Upstream host to proxy to (legacy mode).
backend_port: Upstream port. backend_port: Upstream port (legacy mode).
backend_proto: Protocol (``http`` or ``https``). backend_proto: Protocol (``http`` or ``https``; legacy mode).
cert: Optional certificate type identifier. cert: Optional certificate type identifier.
extra_headers: Optional dict of extra headers to forward. extra_headers: Optional dict of extra headers to forward (legacy mode).
paths: Optional path-to-config map (new mode). Each path entry must
have a ``backend`` key with ``host``, ``port``, and ``proto``.
Raises: Raises:
ValueError: If the domain is already configured. ValueError: If the domain is already configured.
@@ -135,27 +205,36 @@ def add_domain(
cfg = get_config() cfg = get_config()
if domain in cfg["domains"]: if domain in cfg["domains"]:
raise ValueError(f"Domain {domain!r} already configured") raise ValueError(f"Domain {domain!r} already configured")
entry: dict[str, Any] = {
"backend": { if paths is not None:
"host": backend_host, entry: dict[str, Any] = {
"port": int(backend_port), "paths": paths,
"proto": backend_proto, "force_ssl": True,
}, }
"force_ssl": True, if cert is not None:
} entry["cert"] = cert
if cert is not None: else:
entry["cert"] = cert if not backend_host or backend_port is None:
if extra_headers is not None: raise ValueError("'backend_host' and 'backend_port' are required")
entry["headers"] = extra_headers entry = {
"paths": {
"/": {
"backend": {
"host": backend_host,
"port": int(backend_port),
"proto": backend_proto,
},
"headers": extra_headers or {},
}
},
"force_ssl": True,
}
if cert is not None:
entry["cert"] = cert
cfg["domains"][domain] = entry cfg["domains"][domain] = entry
save_config(cfg) save_config(cfg)
logger.info( logger.info("Proxy domain '%s' added", domain)
"Proxy domain '%s' added -> %s:%d (%s)",
domain,
backend_host,
backend_port,
backend_proto,
)
def remove_domain(domain: str) -> None: def remove_domain(domain: str) -> None:
@@ -172,6 +251,11 @@ def remove_domain(domain: str) -> None:
def update_domain(domain: str, **kwargs: Any) -> None: def update_domain(domain: str, **kwargs: Any) -> None:
"""Update fields of an existing domain entry in-place. """Update fields of an existing domain entry in-place.
Supports both domain-level keys (``force_ssl``, ``cert``, ``auth``,
``paths``) and paths-level shorthand (``backend``, ``headers`` for
the root path). When ``paths`` is provided, it is fully replaced.
When ``backend`` is provided, it updates ``paths["/"]["backend"]``.
Args: Args:
domain: Domain name to update. domain: Domain name to update.
**kwargs: Key-value pairs to merge into the domain config. **kwargs: Key-value pairs to merge into the domain config.
@@ -183,7 +267,27 @@ def update_domain(domain: str, **kwargs: Any) -> None:
if domain not in cfg["domains"]: if domain not in cfg["domains"]:
raise KeyError(f"Domain {domain!r} not configured") raise KeyError(f"Domain {domain!r} not configured")
entry = cfg["domains"][domain] entry = cfg["domains"][domain]
# If paths is given, replace entirely
if "paths" in kwargs:
entry["paths"] = kwargs["paths"]
else:
# Legacy: top-level backend/headers -> paths["/"]
paths = entry.setdefault("paths", {})
if "backend" in kwargs:
root = paths.setdefault("/", {"backend": {}, "headers": {}})
root["backend"] = kwargs["backend"]
if "headers" in kwargs:
root = paths.setdefault("/", {"backend": {}, "headers": {}})
root["headers"] = kwargs["headers"]
# Remove legacy top-level keys from domain entry
entry.pop("backend", None)
entry.pop("headers", None)
for key, val in kwargs.items(): for key, val in kwargs.items():
if key in ("backend", "headers", "paths"):
continue
if isinstance(val, dict) and key in entry: if isinstance(val, dict) and key in entry:
entry[key].update(val) entry[key].update(val)
else: else:
@@ -198,7 +302,7 @@ def update_domain(domain: str, **kwargs: Any) -> None:
def generate_server_conf(domain_cfg: dict[str, Any]) -> str: def generate_server_conf(domain_cfg: dict[str, Any]) -> str:
"""Render the Jinja template for a standard domain server block. """Render the Jinja template for a domain server block.
Args: Args:
domain_cfg: Domain entry dict including the ``domain`` key. domain_cfg: Domain entry dict including the ``domain`` key.
@@ -209,42 +313,25 @@ def generate_server_conf(domain_cfg: dict[str, Any]) -> str:
tmpl = ENV.get_template("nginx/server_block.conf") tmpl = ENV.get_template("nginx/server_block.conf")
acme_home_path = PROJECT_DIR / "data" / "acme" acme_home_path = PROJECT_DIR / "data" / "acme"
acme_cert_dir = str(find_cert_dir(domain_cfg["domain"], acme_home_path)) 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( return tmpl.render(
domain=domain_cfg["domain"], domain=domain_cfg["domain"],
backend=domain_cfg.get("backend", {}), paths=paths,
headers=domain_cfg.get("headers", {}),
force_ssl=domain_cfg.get("force_ssl", True), force_ssl=domain_cfg.get("force_ssl", True),
cert=domain_cfg.get("cert"), cert=domain_cfg.get("cert"),
auth=domain_cfg.get("auth"), cert_path=cert_path,
is_management=False, cert_key_path=cert_key_path,
acme_cert_dir=acme_cert_dir, domain_auth=domain_cfg.get("auth"),
certs_dir=str(PROJECT_DIR / "data" / "certs"), has_management=has_management,
acme_webroot=str(PROJECT_DIR / "data" / "acme" / "www"),
)
def _generate_management_conf(management: dict[str, Any]) -> str:
"""Render the Jinja template for the management WebUI server block.
Args:
management: Management proxy config dict containing ``domain`` and optional ``auth``.
Returns:
The complete nginx server-block configuration for the management UI.
"""
tmpl = ENV.get_template("nginx/server_block.conf")
acme_home_path = PROJECT_DIR / "data" / "acme"
acme_cert_dir = str(find_cert_dir(management.get("domain", ""), acme_home_path))
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_cert_dir=acme_cert_dir, acme_cert_dir=acme_cert_dir,
certs_dir=str(PROJECT_DIR / "data" / "certs"), certs_dir=str(PROJECT_DIR / "data" / "certs"),
acme_webroot=str(PROJECT_DIR / "data" / "acme" / "www"), acme_webroot=str(PROJECT_DIR / "data" / "acme" / "www"),
@@ -295,8 +382,8 @@ def write_acme_challenge() -> None:
def write_all_sites() -> None: def write_all_sites() -> None:
"""Regenerate all site configs from the current config state. """Regenerate all site configs from the current config state.
Writes server blocks for every configured domain and the management Writes server blocks for every configured domain (now unified, including
proxy (if any), removes orphaned site files, and ensures the ACME any management paths), removes orphaned site files, and ensures the ACME
challenge config is present. challenge config is present.
""" """
ensure_dirs(SITES_DIR) ensure_dirs(SITES_DIR)
@@ -311,10 +398,10 @@ def write_all_sites() -> None:
write_site(name, conf) write_site(name, conf)
written.add(f"{name}.conf") written.add(f"{name}.conf")
if cfg.get("management"): # Remove old management.conf if it exists
mgmt_conf = _generate_management_conf(cfg["management"]) old_mgmt = SITES_DIR / "management.conf"
write_site("management", mgmt_conf) if old_mgmt.exists() and old_mgmt.name not in written:
written.add("management.conf") old_mgmt.unlink()
for old in existing: for old in existing:
if old.suffix == ".conf" and old.name not in written: if old.suffix == ".conf" and old.name not in written:
@@ -411,48 +498,6 @@ def apply() -> None:
logger.info("nginx configuration applied and reloaded") 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:
"""Configure the management reverse proxy for the WebUI.
Args:
domain: Management domain name.
flask_host: Upstream Flask host (default ``127.0.0.1``).
flask_port: Upstream Flask port (default ``9090``).
auth_user: Optional basic-auth username.
auth_pass: Optional basic-auth password; writes htpasswd when provided with ``auth_user``.
"""
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 # htpasswd
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -510,7 +555,6 @@ __all__ = [
"get_domains", "get_domains",
"remove_domain", "remove_domain",
"save_config", "save_config",
"set_management_proxy",
"test_config", "test_config",
"update_domain", "update_domain",
"write_acme_challenge", "write_acme_challenge",
+16 -7
View File
@@ -636,7 +636,6 @@ def _collect_nginx() -> dict[str, Any]:
default_cfg: dict[str, Any] = { default_cfg: dict[str, Any] = {
"domains": {}, "domains": {},
"management": None,
"ssl": deepcopy(DEFAULT_SSL), "ssl": deepcopy(DEFAULT_SSL),
} }
cfg = deepcopy(default_cfg) cfg = deepcopy(default_cfg)
@@ -652,18 +651,27 @@ def _collect_nginx() -> dict[str, Any]:
except Exception: except Exception:
pass pass
# Build domains list with site existence # Build flattened domains list (one entry per path)
domains: list[dict[str, Any]] = [] domains: list[dict[str, Any]] = []
for name, dom in cfg.get("domains", {}).items(): for name, dom in cfg.get("domains", {}).items():
site = SITES_DIR / f"{name}.conf" site = SITES_DIR / f"{name}.conf"
domains.append( paths = dom.get("paths", {})
{ if not paths:
continue
for ppath, pcfg in paths.items():
entry: dict[str, Any] = {
"domain": name, "domain": name,
"backend": dom.get("backend", {}), "path": ppath,
"backend": pcfg.get("backend", {}),
"online": site.exists() if SITES_DIR.exists() else False, "online": site.exists() if SITES_DIR.exists() else False,
"force_ssl": dom.get("force_ssl", True), "force_ssl": dom.get("force_ssl", True),
"cert": dom.get("cert"),
} }
) if pcfg.get("is_management"):
entry["is_management"] = True
if pcfg.get("is_websocket"):
entry["is_websocket"] = True
domains.append(entry)
return { return {
"config": cfg, "config": cfg,
@@ -819,7 +827,8 @@ def _collect_acme() -> dict[str, Any]:
if not main: if not main:
continue continue
san_domains = [ san_domains = [
d.strip() for d in entry.get("san_domains", "").split(",") d.strip()
for d in entry.get("san_domains", "").split(",")
if d.strip() and d.strip().lower() != "no" if d.strip() and d.strip().lower() != "no"
] ]
cert_dir = acme_home / main cert_dir = acme_home / main
+55 -52
View File
@@ -12,94 +12,97 @@ server {
root {{ acme_webroot }}; root {{ acme_webroot }};
} }
# Redirect all HTTP traffic to HTTPS
return 301 https://$host$request_uri; return 301 https://$host$request_uri;
} }
{% endif %} {% endif %}
server { server {
listen 443 ssl; listen 443 ssl;
listen [::]:443 ssl; listen [::]:443 ssl;
server_name {{ domain }}; server_name {{ domain }};
{% if cert %} {% if cert %}
{% if cert.type == "acme" %} {% if cert == "acme" %}
# Certificate managed by acme.sh ssl_certificate {{ acme_cert_dir }}/fullchain.cer;
{% if cert.email %} # ACME contact: {{ cert.email }} ssl_certificate_key {{ acme_cert_dir }}/{{ domain }}.key;
{% endif %} ssl_certificate {{ acme_cert_dir }}/fullchain.cer; {% elif cert == "file" %}
ssl_certificate_key {{ acme_cert_dir }}/{{ domain }}.key; ssl_certificate {{ cert_path }};
ssl_certificate_key {{ cert_key_path }};
{% elif cert.type == "file" %} {% elif cert == "selfsigned" %}
ssl_certificate {{ cert.path }};
ssl_certificate_key {{ cert.key_path }};
{% elif cert.type == "selfsigned" %}
ssl_certificate {{ certs_dir }}/{{ domain }}.crt; ssl_certificate {{ certs_dir }}/{{ domain }}.crt;
ssl_certificate_key {{ certs_dir }}/{{ domain }}.key; ssl_certificate_key {{ certs_dir }}/{{ domain }}.key;
{% endif %} {% endif %}
{% elif is_management %} {% elif has_management %}
ssl_certificate {{ acme_cert_dir }}/fullchain.cer; ssl_certificate {{ acme_cert_dir }}/fullchain.cer;
ssl_certificate_key {{ acme_cert_dir }}/{{ domain }}.key; ssl_certificate_key {{ acme_cert_dir }}/{{ domain }}.key;
{% endif %} {% endif %}
# Shared SSL settings
include snippets/vacuum-wall-ssl.conf; include snippets/vacuum-wall-ssl.conf;
{% if auth %} {% if domain_auth %}
# HTTP basic authentication auth_basic "Restricted";
auth_basic "{{ "Vacuum Wall" if is_management else "Restricted" }}"; auth_basic_user_file {{ domain_auth.htpasswd }};
auth_basic_user_file {{ auth.htpasswd }}; {% endif %}
{% if not has_management %}
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options DENY always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
{% endif %} {% endif %}
{% if not is_management %}
# Security hardening headers {% for ppath, pcfg in paths.items() %}
add_header X-Content-Type-Options nosniff always; {% if pcfg.is_websocket %}
add_header X-Frame-Options DENY always; # {{ ppath }} -> {{ pcfg.backend.host }}:{{ pcfg.backend.port }} (WebSocket)
add_header X-XSS-Protection "1; mode=block" always; location {{ ppath }} {
add_header Referrer-Policy "strict-origin-when-cross-origin" always; auth_basic off;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; proxy_pass http://{{ pcfg.backend.host }}:{{ pcfg.backend.port }};
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
}
{% else %}
# {{ ppath }} -> {{ pcfg.backend.host }}:{{ pcfg.backend.port }}
location {{ ppath }} {
{% if pcfg.auth is none %}
auth_basic off;
{% elif pcfg.auth is defined %}
auth_basic "Restricted";
auth_basic_user_file {{ pcfg.auth.htpasswd }};
{% endif %} {% endif %}
location / {
# Proxy headers
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
{% if not is_management %} {% if not pcfg.is_management %}
{% for hname, hval in headers.items() %} {% for hname, hval in (pcfg.headers or {}).items() %}
proxy_set_header {{ hname }} {{ hval }}; proxy_set_header {{ hname }} {{ hval }};
{% endfor %} {% endfor %}
{% endif %} {% endif %}
# Proxy pass to backend proxy_pass {{ pcfg.backend.proto }}://{{ pcfg.backend.host }}:{{ pcfg.backend.port }};
proxy_pass {{ backend.proto }}://{{ backend.host }}:{{ backend.port }};
proxy_http_version 1.1; proxy_http_version 1.1;
# Timeouts
proxy_connect_timeout 30s; proxy_connect_timeout 30s;
proxy_send_timeout 60s; proxy_send_timeout 60s;
proxy_read_timeout 60s; proxy_read_timeout 60s;
proxy_buffering off; proxy_buffering off;
proxy_set_header Upgrade $http_upgrade; proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade; proxy_set_header Connection $connection_upgrade;
} }
{% if is_management %}
location /ws {
auth_basic off;
proxy_pass http://127.0.0.1:9091;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
{% endif %} {% endif %}
{% endfor %}
{% if not is_management %} {% if has_management %}
# Access / error logs
access_log /var/log/nginx/{{ domain }}_access.log;
error_log /var/log/nginx/{{ domain }}_error.log warn;
{% else %}
access_log /var/log/nginx/wall_mgmt_access.log; access_log /var/log/nginx/wall_mgmt_access.log;
error_log /var/log/nginx/wall_mgmt_error.log warn; error_log /var/log/nginx/wall_mgmt_error.log warn;
{% else %}
access_log /var/log/nginx/{{ domain }}_access.log;
error_log /var/log/nginx/{{ domain }}_error.log warn;
{% endif %} {% endif %}
} }
-15
View File
@@ -766,21 +766,6 @@ class TestDhcpConfigCrud:
# ============================================================================ # ============================================================================
class TestProxyManagement:
@_px("post")
def test_set_management(self, mock_post, client):
mock_post.return_value = {"domain": "vacuum-wall.local"}
resp = client.post(
"/api/proxy/management",
json={
"domain": "vacuum-wall.local",
"flask_host": "127.0.0.1",
"flask_port": 9090,
},
)
assert resp.status_code == 200
# ============================================================================ # ============================================================================
# Proxy domain update # Proxy domain update
# ============================================================================ # ============================================================================
+3 -1
View File
@@ -351,7 +351,9 @@ class TestCheckDnsPublic:
returncode=0, stdout="example.com has address 52.14.150.110" returncode=0, stdout="example.com has address 52.14.150.110"
) )
with ( with (
patch("daemon.handlers.acme._get_local_ips", return_value={"52.14.150.110"}), patch(
"daemon.handlers.acme._get_local_ips", return_value={"52.14.150.110"}
),
patch("subprocess.run", return_value=mock_result), patch("subprocess.run", return_value=mock_result),
): ):
passed, _ = _check_dns_public("example.com") passed, _ = _check_dns_public("example.com")
+139 -6
View File
@@ -63,7 +63,10 @@ class TestSaveConfig:
} }
nginx.save_config(cfg) nginx.save_config(cfg)
loaded = nginx.get_config() loaded = nginx.get_config()
assert loaded["domains"]["example.com"]["backend"]["host"] == "localhost" assert (
loaded["domains"]["example.com"]["paths"]["/"]["backend"]["host"]
== "localhost"
)
class TestGetDomains: class TestGetDomains:
@@ -97,8 +100,10 @@ class TestAddDomain:
nginx.add_domain("example.com", "10.0.0.5", 8080) nginx.add_domain("example.com", "10.0.0.5", 8080)
cfg = nginx.get_config() cfg = nginx.get_config()
assert "example.com" in cfg["domains"] assert "example.com" in cfg["domains"]
assert cfg["domains"]["example.com"]["backend"]["host"] == "10.0.0.5" assert (
assert cfg["domains"]["example.com"]["backend"]["port"] == 8080 cfg["domains"]["example.com"]["paths"]["/"]["backend"]["host"] == "10.0.0.5"
)
assert cfg["domains"]["example.com"]["paths"]["/"]["backend"]["port"] == 8080
@patch("lib.nginx.get_config") @patch("lib.nginx.get_config")
def test_duplicate_domain_raises(self, mock_get, temp_data_dir): def test_duplicate_domain_raises(self, mock_get, temp_data_dir):
@@ -163,21 +168,149 @@ class TestWriteSite:
assert "server { listen 443; }" in content assert "server { listen 443; }" in content
class TestGenerateServerConf:
def test_simple_root_path(self, temp_data_dir):
cfg = {
"domain": "example.com",
"paths": {
"/": {
"backend": {"host": "10.0.0.1", "port": 80, "proto": "http"},
"headers": {"X-Custom": "value"},
}
},
"force_ssl": True,
"cert": "acme",
}
out = nginx.generate_server_conf(cfg)
assert "location /" in out
assert "proxy_pass http://10.0.0.1:80;" in out
assert "proxy_set_header X-Custom value;" in out
assert "add_header X-Content-Type-Options" in out
def test_multiple_paths(self, temp_data_dir):
cfg = {
"domain": "app.example.com",
"paths": {
"/": {
"backend": {"host": "10.0.0.1", "port": 80, "proto": "http"},
"headers": {},
},
"/api": {
"backend": {"host": "10.0.0.2", "port": 8080, "proto": "http"},
},
},
"force_ssl": True,
"cert": "acme",
}
out = nginx.generate_server_conf(cfg)
assert "proxy_pass http://10.0.0.1:80;" in out
assert "proxy_pass http://10.0.0.2:8080;" in out
assert "location /api" in out
def test_management_path(self, temp_data_dir):
cfg = {
"domain": "mgmt.example.com",
"paths": {
"/": {
"backend": {"host": "127.0.0.1", "port": 9090, "proto": "http"},
"is_management": True,
}
},
"force_ssl": True,
"cert": "acme",
"auth": {"user": "admin", "htpasswd": "/path/.htpasswd"},
}
out = nginx.generate_server_conf(cfg)
assert "proxy_pass http://127.0.0.1:9090;" in out
assert "add_header X-Content-Type-Options" not in out
assert "wall_mgmt_access.log" in out
def test_websocket_path(self, temp_data_dir):
cfg = {
"domain": "mgmt.example.com",
"paths": {
"/": {
"backend": {"host": "127.0.0.1", "port": 9090, "proto": "http"},
},
"/ws": {
"backend": {"host": "127.0.0.1", "port": 9091, "proto": "http"},
"is_websocket": True,
},
},
"force_ssl": True,
"cert": "acme",
}
out = nginx.generate_server_conf(cfg)
assert "proxy_pass http://127.0.0.1:9091;" in out
assert "proxy_set_header Upgrade" in out
assert "proxy_read_timeout 86400s;" in out
def test_auth_inheritance(self, temp_data_dir):
cfg = {
"domain": "app.example.com",
"paths": {
"/": {
"backend": {"host": "10.0.0.1", "port": 80, "proto": "http"},
"headers": {},
},
"/api": {
"backend": {"host": "10.0.0.2", "port": 8080, "proto": "http"},
"auth": None,
},
"/admin": {
"backend": {"host": "10.0.0.3", "port": 9000, "proto": "http"},
"auth": {"user": "admin", "htpasswd": "/other/.htpasswd"},
},
},
"force_ssl": True,
"cert": "acme",
"auth": {"user": "admin", "htpasswd": "/path/.htpasswd"},
}
out = nginx.generate_server_conf(cfg)
assert "auth_basic_user_file /path/.htpasswd;" in out
lines = out.split("\n")
api_idx = next(i for i, line in enumerate(lines) if "location /api" in line)
admin_idx = next(i for i, line in enumerate(lines) if "location /admin" in line)
# /api should have auth_basic off
assert "auth_basic off;" in "\n".join(lines[api_idx : api_idx + 5])
# /admin should have path-level auth override
assert "auth_basic_user_file /other/.htpasswd;" in "\n".join(
lines[admin_idx : admin_idx + 5]
)
class TestWriteAllSites: class TestWriteAllSites:
@patch("lib.nginx.get_config") @patch("lib.nginx.get_config")
def test_writes_all_domains(self, mock_get, temp_data_dir): def test_writes_all_domains(self, mock_get, temp_data_dir):
mock_get.return_value = { mock_get.return_value = {
"domains": { "domains": {
"a.com": { "a.com": {
"backend": {"host": "10.0.0.1", "port": 80, "proto": "http"}, "paths": {
"/": {
"backend": {
"host": "10.0.0.1",
"port": 80,
"proto": "http",
},
"headers": {},
}
},
"force_ssl": True, "force_ssl": True,
}, },
"b.com": { "b.com": {
"backend": {"host": "10.0.0.2", "port": 80, "proto": "http"}, "paths": {
"/": {
"backend": {
"host": "10.0.0.2",
"port": 80,
"proto": "http",
},
"headers": {},
}
},
"force_ssl": True, "force_ssl": True,
}, },
}, },
"management": None,
"ssl": {"protocols": "TLSv1.2 TLSv1.3"}, "ssl": {"protocols": "TLSv1.2 TLSv1.3"},
} }
nginx.write_all_sites() nginx.write_all_sites()
+35 -67
View File
@@ -17,7 +17,6 @@ from daemon.iface import (
POST_NGINX_CONFIG, POST_NGINX_CONFIG,
POST_NGINX_DOMAINS_ADD, POST_NGINX_DOMAINS_ADD,
POST_NGINX_DOMAINS_UPDATE, POST_NGINX_DOMAINS_UPDATE,
POST_NGINX_MANAGEMENT,
POST_NGINX_SSL_APPLY, POST_NGINX_SSL_APPLY,
POST_NGINX_TEST, POST_NGINX_TEST,
) )
@@ -140,7 +139,13 @@ def add_domain_bp():
POST /api/proxy/domains POST /api/proxy/domains
Body fields: Body fields (paths mode):
domain: Domain name.
paths: Path-to-config map (e.g. ``{"/": {"backend": {...}}, "/app": {...}}``).
cert: Optional certificate type.
force_ssl: Optional SSL redirect flag (default ``true``).
Body fields (legacy mode):
domain: Domain name. domain: Domain name.
backend_host: Upstream host. backend_host: Upstream host.
backend_port: Upstream port. backend_port: Upstream port.
@@ -153,29 +158,37 @@ def add_domain_bp():
""" """
body = request.get_json(silent=True) or {} body = request.get_json(silent=True) or {}
domain = body.get("domain", "").strip() 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: if not domain:
return _error("'domain' is required", 400) return _error("'domain' is required", 400)
if not backend_host:
return _error("'backend_host' is required", 400) paths = body.get("paths")
if backend_port is None: if paths is not None:
return _error("'backend_port' is required", 400) payload = {
"domain": domain,
"paths": paths,
"cert": body.get("cert"),
"force_ssl": body.get("force_ssl", True),
}
else:
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 backend_host:
return _error("'backend_host' is required", 400)
if backend_port is None:
return _error("'backend_port' is required", 400)
payload = {
"domain": domain,
"backend_host": backend_host,
"backend_port": int(backend_port),
"backend_proto": backend_proto,
"cert": cert,
"extra_headers": extra_headers,
}
try: try:
post( post(POST_NGINX_DOMAINS_ADD, payload)
POST_NGINX_DOMAINS_ADD,
{
"domain": domain,
"backend_host": backend_host,
"backend_port": int(backend_port),
"backend_proto": backend_proto,
"cert": cert,
"extra_headers": extra_headers,
},
)
logger.info("Proxy domain added via API: %s", domain) logger.info("Proxy domain added via API: %s", domain)
return _ok({"domain": domain}) return _ok({"domain": domain})
except BadRequest as exc: except BadRequest as exc:
@@ -272,48 +285,3 @@ def test_bp():
except RuntimeError as exc: except RuntimeError as exc:
logger.error("nginx config test failed: %s", exc) logger.error("nginx config test failed: %s", exc)
return _error(str(exc), 500) return _error(str(exc), 500)
@bp.route("/management", methods=["POST"])
def management_bp():
"""Configure the management reverse proxy for the WebUI.
POST /api/proxy/management
Body fields:
domain: Management domain name.
flask_host: Upstream Flask host (default ``127.0.0.1``).
flask_port: Upstream Flask port (default 9090).
auth_user: Optional basic-auth username.
auth_pass: Optional basic-auth password.
Returns:
``{"ok": true}`` on success.
"""
body = request.get_json(silent=True) or {}
domain = body.get("domain", "").strip()
if not domain:
return _error("'domain' is required", 400)
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")
try:
post(
POST_NGINX_MANAGEMENT,
{
"domain": domain,
"flask_host": flask_host,
"flask_port": int(flask_port),
"auth_user": auth_user,
"auth_pass": auth_pass,
},
)
logger.info("Management proxy configured via API: %s", domain)
return _ok(None)
except BadRequest as exc:
logger.info("Management proxy config rejected: %s", exc)
return _error(str(exc), 400)
except RuntimeError as exc:
logger.error("Failed to set management proxy: %s", exc)
return _error(str(exc), 500)
+1
View File
@@ -197,6 +197,7 @@ def spa_root():
def vendor_files(filename): def vendor_files(filename):
"""Serve vendored JS libraries (htm.js, etc.).""" """Serve vendored JS libraries (htm.js, etc.)."""
from flask import send_file from flask import send_file
target = (VENDOR_DIR / filename).resolve() target = (VENDOR_DIR / filename).resolve()
if not target.is_relative_to(VENDOR_DIR): if not target.is_relative_to(VENDOR_DIR):
abort(404) abort(404)
+140 -44
View File
@@ -1,9 +1,26 @@
import { html, PageHeader, Badge, Empty, Table, renderGuardMulti, esc, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, ActionButton, ActionCell, certStatusBadge, ActionGroup, QuickModal } from '/static/hoover/index.js?v=7'; import { html, PageHeader, Badge, Empty, Table, renderGuardMulti, esc, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, ActionButton, ActionCell, ConfirmDelete, certStatusBadge, ActionGroup, QuickModal } from '/static/hoover/index.js?v=7';
// ---------------------------------------------------------------------------
// Cert lookup map from ACME state keyed by domain name
// ---------------------------------------------------------------------------
function certLookup(acmeData) {
const m = {};
if (acmeData && acmeData.certs) {
for (const c of acmeData.certs) {
m[c.domain] = c;
}
}
return m;
}
// ---------------------------------------------------------------------------
// Add Domain modal — paths-based body
// ---------------------------------------------------------------------------
const addDomain = QuickModal({ const addDomain = QuickModal({
title: 'Add Proxy Domain', title: 'Add Proxy Domain',
fields: [ fields: [
{ label: 'Domain', id: 'p-domain', placeholder: 'example.com' }, { label: 'Domain', id: 'p-domain', placeholder: 'example.com' },
{ label: 'Path', id: 'p-path', placeholder: '/' },
{ label: 'Backend Host', id: 'p-host', placeholder: '192.168.1.10' }, { label: 'Backend Host', id: 'p-host', placeholder: '192.168.1.10' },
{ label: 'Backend Port', id: 'p-port', type: 'number', placeholder: '8080' }, { label: 'Backend Port', id: 'p-port', type: 'number', placeholder: '8080' },
{ label: 'Protocol', id: 'p-proto', placeholder: 'http or https' }, { label: 'Protocol', id: 'p-proto', placeholder: 'http or https' },
@@ -11,42 +28,127 @@ const addDomain = QuickModal({
], ],
submit: { submit: {
url: '/api/proxy/domains', url: '/api/proxy/domains',
body: () => ({ body: () => {
domain: ($val('p-domain') || '').trim(), const path = ($val('p-path') || '/').trim() || '/';
backend_host: ($val('p-host') || '').trim(), return {
backend_port: parseInt($val('p-port')), domain: ($val('p-domain') || '').trim(),
backend_proto: ($val('p-proto') || 'http').trim() || 'http', paths: {
cert: ($val('p-cert') || '').trim() || undefined, [path]: {
}), backend: {
validate: (b) => !b.domain || !b.backend_host || !b.backend_port ? 'Domain, host, and port are required' : null, host: ($val('p-host') || '').trim(),
port: parseInt($val('p-port')),
proto: ($val('p-proto') || 'http').trim() || 'http',
},
headers: {},
},
},
cert: ($val('p-cert') || '').trim() || undefined,
};
},
validate: (b) => {
if (!b.domain) return 'Domain is required';
const p = b.paths ? Object.values(b.paths)[0] : {};
const be = p && p.backend;
if (!be || !be.host || !be.port) return 'Host and port are required';
return null;
},
successMsg: 'Domain added', successMsg: 'Domain added',
}, },
refresh: ['nginx', 'acme'], refresh: ['nginx', 'acme'],
}); });
// ---------------------------------------------------------------------------
// Edit Domain modal — updates backend for the root path
// ---------------------------------------------------------------------------
const editDomain = QuickModal({ const editDomain = QuickModal({
title: (d) => 'Edit: ' + d.domain, title: (d) => 'Edit: ' + d.domain + ' ' + d.path,
fields: (d) => [ fields: (d) => {
{ label: 'Backend Host', id: 'pe-host', value: d.backend_host || '' }, const be = d.backend || {};
{ label: 'Backend Port', id: 'pe-port', type: 'number', value: d.backend_port || '' }, return [
{ label: 'Protocol', id: 'pe-proto', value: d.backend_proto || d.protocol || 'http' }, { label: 'Backend Host', id: 'pe-host', value: be.host || '' },
{ label: 'Cert (optional)', id: 'pe-cert', value: d.cert || '' }, { label: 'Backend Port', id: 'pe-port', type: 'number', value: be.port || '' },
], { label: 'Protocol', id: 'pe-proto', value: be.proto || 'http' },
{ label: 'Cert (optional)', id: 'pe-cert', value: d._cert || d.cert || '' },
];
},
submit: { submit: {
url: (d) => '/api/proxy/domains/' + enc(d.domain), url: (d) => '/api/proxy/domains/' + enc(d.domain),
method: 'PUT', method: 'PUT',
body: () => ({ body: (d) => ({
backend_host: ($val('pe-host') || '').trim(), backend: {
backend_port: parseInt($val('pe-port')), host: ($val('pe-host') || '').trim(),
backend_proto: ($val('pe-proto') || 'http').trim(), port: parseInt($val('pe-port')),
proto: ($val('pe-proto') || 'http').trim(),
},
cert: ($val('pe-cert') || '').trim() || undefined, cert: ($val('pe-cert') || '').trim() || undefined,
}), }),
validate: (b) => !b.backend_host || !b.backend_port ? 'Host and port are required' : null, validate: (b) => !b.backend || !b.backend.host || !b.backend.port ? 'Host and port are required' : null,
successMsg: 'Domain updated', successMsg: 'Domain updated',
}, },
refresh: ['nginx', 'acme'], refresh: ['nginx', 'acme'],
}); });
// ---------------------------------------------------------------------------
// Path detail row
// ---------------------------------------------------------------------------
function pathRow(d, certs, domainPaths) {
const be = d.backend || {};
const cert = certs[d.domain];
const certBadge = cert
? certStatusBadge({
daysRemaining: cert.days_remaining,
expired: cert.expired,
})
: Badge({ text: '—', variant: 'info' });
const isWs = d.is_websocket;
const isMgmt = d.is_management;
const multiPath = (domainPaths || []).length > 1;
let actions;
if (isMgmt) {
actions = Badge({ text: 'mgmt', variant: 'warning' });
} else if (isWs) {
actions = ActionButton({
url: '/api/proxy/domains/' + enc(d.domain),
method: 'PUT',
body: () => ({ path: d.path }),
label: 'Delete',
cls: 'btn btn-sm btn-danger',
successMsg: 'Path removed',
refresh: ['nginx', 'acme'],
});
} else {
actions = ActionCell({
editLabel: 'Edit',
editClick: () => editDomain(d),
removeUrl: '/api/proxy/domains/' + enc(d.domain),
removeMessage: 'Remove ' + enc(d.domain) + ' ' + enc(d.path) + '?',
removeSuccess: 'Removed',
removeRefresh: ['nginx', 'acme'],
removeLabel: 'Delete',
});
}
const flagBadges = [];
if (isWs) flagBadges.push(Badge({ text: 'ws', variant: 'secondary' }));
if (isMgmt) flagBadges.push(Badge({ text: 'mgmt', variant: 'warning' }));
return html`<tr key=${d.domain + ':' + d.path} class="path-row">
<td>${esc(d.domain)}</td>
<td><code>${esc(d.path)}</code></td>
<td>${esc(be.host || '-')}</td>
<td>${be.port || '-'}</td>
<td><${Badge} text=${be.proto || 'http'} variant="info" /></td>
<td>${flagBadges}</td>
<td>${certBadge}</td>
<td>${actions}</td>
</tr>`;
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default definePage({ export default definePage({
init() { init() {
return { return {
@@ -59,28 +161,22 @@ export default definePage({
if (guard) return guard; if (guard) return guard;
const domains = state.nginx.data.domains || []; const domains = state.nginx.data.domains || [];
const rows = domains.map(d => { const certs = certLookup(state.acme.data);
const certBadge = certStatusBadge({
certStatus: d.cert_status,
daysRemaining: d.days_remaining,
expired: d.cert_status === 'expired',
});
return html`<tr key=${d.domain}> // Group by domain for multi-path awareness
<td><strong>${esc(d.domain)}</strong></td> const groups = {};
<td>${esc(d.backend_host || '-')}</td> for (const d of domains) {
<td>${d.backend_port || '-'}</td> if (!groups[d.domain]) groups[d.domain] = [];
<td><${Badge} text=${d.backend_proto || d.protocol || 'http'} variant="info" /></td> groups[d.domain].push(d);
<td>${certBadge}</td> }
<${ActionCell}
editLabel="Edit" editClick=${() => editDomain(d)} // Attach domain-level cert info to each entry
removeUrl=${'/api/proxy/domains/' + enc(d.domain)} const enriched = domains.map(d => ({
removeMessage=${'Remove proxy for ' + d.domain + '?'} ...d,
removeSuccess="Domain removed" _cert: certs[d.domain] || null,
removeRefresh={['nginx', 'acme']} }));
removeLabel="Delete" />
</tr>`; const rows = enriched.map(d => pathRow(d, certs, groups[d.domain]));
});
const actions = ActionGroup( const actions = ActionGroup(
html`<button class="btn btn-primary" onClick=${() => addDomain(state)}>Add Domain</button>`, html`<button class="btn btn-primary" onClick=${() => addDomain(state)}>Add Domain</button>`,
@@ -94,9 +190,9 @@ export default definePage({
return [ return [
PageHeader({ title: 'Proxy', subtitle: 'Nginx reverse proxy', actions }), PageHeader({ title: 'Proxy', subtitle: 'Nginx reverse proxy', actions }),
rows.length domains.length
? Table({ ? Table({
columns: ['Domain', 'Backend Host', 'Port', 'Proto', 'Cert', 'Actions'], columns: ['Domain', 'Path', 'Host', 'Port', 'Proto', 'Flags', 'Cert', 'Actions'],
rows, rows,
}) })
: Empty({ text: 'No proxy domains configured. Add a domain to start terminating SSL.' }), : Empty({ text: 'No proxy domains configured. Add a domain to start terminating SSL.' }),