diff --git a/daemon/handlers/nginx.py b/daemon/handlers/nginx.py index 56d0c80..43cbb8e 100644 --- a/daemon/handlers/nginx.py +++ b/daemon/handlers/nginx.py @@ -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).""" diff --git a/daemon/iface.py b/daemon/iface.py index df76638..5bd978c 100644 --- a/daemon/iface.py +++ b/daemon/iface.py @@ -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 ---- diff --git a/docs/api.md b/docs/api.md index 361b5b3..84e91f9 100644 --- a/docs/api.md +++ b/docs/api.md @@ -715,13 +715,15 @@ Write the global nginx SSL snippet configuration. 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:** | 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 ``` -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 | |-------|------|----------|-------------| @@ -741,7 +752,7 @@ Add a new reverse proxy domain. | `backend_host` | `string` | Yes | Backend server IP or hostname | | `backend_port` | `number` | Yes | Backend server port | | `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 | **Response (`data`):** @@ -774,9 +785,9 @@ Returns HTTP `404` if the domain is not configured. PUT /api/proxy/domains/ ``` -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`):** @@ -837,27 +848,9 @@ Run `nginx -t` against the generated configuration without reloading. **Error (invalid):** HTTP `400` with standard `{"ok": false, "error": ""}` response. -### Management +### Management Proxy -#### Configure Management WebUI Proxy - -``` -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. +>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. --- diff --git a/docs/config.md b/docs/config.md index 2ccefe7..3b8f362 100644 --- a/docs/config.md +++ b/docs/config.md @@ -76,35 +76,64 @@ Additional dnsmasq directives can be appended verbatim by placing plain-text fil **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 { "domains": { "app.example.com": { - "backend": { - "host": "192.168.2.50", - "port": 8080, - "proto": "http" - }, "force_ssl": true, "cert": "acme", - "headers": { - "X-Forwarded-Proto": "https", - "X-Real-IP": "$remote_addr" + "auth": { + "user": "admin", + "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": { - "user": "admin", - "htpasswd": "/home/wall/vacuum-wall/data/nginx/.htpasswd" + "mgmt.example.com": { + "force_ssl": true, + "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": { @@ -117,17 +146,40 @@ This file defines reverse proxy domains, the management interface, and global SS ### 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 | |---|---|---|---| -| `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.port` | integer | Yes | Port the backend service is listening on. | -| `backend.proto` | string | No | Protocol for the backend connection: `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 headers to set on proxied requests. Supports nginx variable interpolation (e.g., `$remote_addr`). | -| `cert` | string | No | Certificate provisioning method. One of: `"acme"`, `"file"`, or `"selfsigned"`. Omit for domains that don't need a dedicated cert. | +| `backend.proto` | string | No | Protocol: `http` or `https`. Default: `http`. | +| `headers` | object | No | Custom proxy headers (key-value pairs). Supports nginx variable interpolation. | +| `auth` | object \| null | No | Path-level auth override. `{ user, htppasswd }` replaces domain-level auth. `null` disables auth for this path. | +| `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 @@ -141,22 +193,20 @@ The `cert` field is a string that selects the provisioning method: ### 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 | -|---|---|---|---| -| `domain` | string | Yes | The hostname used to access the management WebUI (e.g., `.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: +The application can create the `.htpasswd` file programmatically via `write_htpasswd()` (using passlib's SHA-256 crypt). Manual creation is also possible: ```bash 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 The `ssl` block defines TLS parameters applied to all HTTPS server blocks via the shared snippet. diff --git a/install.sh b/install.sh index c331de1..2af0c79 100755 --- a/install.sh +++ b/install.sh @@ -365,7 +365,7 @@ else "${PROJECT_DIR}/.venv/bin/python3" -c " import daemon.client as c 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, GET_NETWORK_INFER_DHCP_RANGES, ) @@ -384,12 +384,20 @@ try: except Exception as e: print(f' [cert] Warning: {e}', file=sys.stderr) -# Management proxy + htpasswd +# Management proxy domain + htpasswd try: - c.post(POST_NGINX_MANAGEMENT, { + c.post(POST_NGINX_DOMAINS_ADD, { 'domain': domain, - 'flask_host': '127.0.0.1', - 'flask_port': 9090, + 'paths': { + '/': { + '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_pass': mgmt_pass, }) diff --git a/lib/nginx.py b/lib/nginx.py index 4ea94ef..19df751 100644 --- a/lib/nginx.py +++ b/lib/nginx.py @@ -49,7 +49,6 @@ DEFAULT_SSL: dict[str, Any] = { DEFAULT_CONFIG: dict[str, Any] = { "domains": {}, - "management": None, "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]: """Load the current nginx config, initializing with defaults if needed. - Ensure config and sites directories exist, then return a copy of the - JSON file. On missing file or missing keys, populate from defaults. + Ensures config and sites directories exist, applies migrations for + legacy formats, then returns the config dict. 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) raw = load_json(CONFIG_FILE) @@ -74,6 +130,8 @@ def get_config() -> dict[str, Any]: raw = deepcopy(DEFAULT_CONFIG) if "ssl" not in raw: raw["ssl"] = deepcopy(DEFAULT_SSL) + raw = _migrate_config(raw) + save_config(raw) return raw @@ -83,26 +141,35 @@ def save_config(cfg: dict[str, Any]) -> None: 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 - whether a site config file currently exists on disk. + Each path within a domain becomes a separate entry with domain-level + settings repeated. 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() result: list[dict[str, Any]] = [] for name, dom in cfg.get("domains", {}).items(): 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, - "backend": dom.get("backend", {}), + "path": ppath, + "backend": pcfg.get("backend", {}), "online": site.exists(), "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 @@ -113,21 +180,24 @@ def get_domains() -> list[dict[str, Any]]: def add_domain( domain: str, - backend_host: str, - backend_port: int, + backend_host: str | None = None, + backend_port: int | None = None, backend_proto: str = "http", cert: str | None = None, extra_headers: dict[str, str] | None = None, + paths: dict[str, dict[str, Any]] | None = None, ) -> None: """Add a new proxy domain with the given backend and optional settings. Args: domain: Domain name to add. - backend_host: Upstream host to proxy to. - backend_port: Upstream port. - backend_proto: Protocol (``http`` or ``https``). + backend_host: Upstream host to proxy to (legacy mode). + backend_port: Upstream port (legacy mode). + backend_proto: Protocol (``http`` or ``https``; legacy mode). 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: ValueError: If the domain is already configured. @@ -135,27 +205,36 @@ def add_domain( 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 + + if paths is not None: + entry: dict[str, Any] = { + "paths": paths, + "force_ssl": True, + } + if cert is not None: + entry["cert"] = cert + else: + if not backend_host or backend_port is None: + raise ValueError("'backend_host' and 'backend_port' are required") + 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 save_config(cfg) - logger.info( - "Proxy domain '%s' added -> %s:%d (%s)", - domain, - backend_host, - backend_port, - backend_proto, - ) + logger.info("Proxy domain '%s' added", domain) def remove_domain(domain: str) -> None: @@ -172,6 +251,11 @@ def remove_domain(domain: str) -> None: def update_domain(domain: str, **kwargs: Any) -> None: """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: domain: Domain name to update. **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"]: raise KeyError(f"Domain {domain!r} not configured") 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(): + if key in ("backend", "headers", "paths"): + continue if isinstance(val, dict) and key in entry: entry[key].update(val) else: @@ -198,7 +302,7 @@ def update_domain(domain: str, **kwargs: Any) -> None: 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: 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") 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, - acme_cert_dir=acme_cert_dir, - certs_dir=str(PROJECT_DIR / "data" / "certs"), - 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, + 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"), @@ -295,8 +382,8 @@ def write_acme_challenge() -> None: def write_all_sites() -> None: """Regenerate all site configs from the current config state. - Writes server blocks for every configured domain and the management - proxy (if any), removes orphaned site files, and ensures the ACME + Writes server blocks for every configured domain (now unified, including + any management paths), removes orphaned site files, and ensures the ACME challenge config is present. """ ensure_dirs(SITES_DIR) @@ -311,10 +398,10 @@ def write_all_sites() -> None: write_site(name, conf) written.add(f"{name}.conf") - if cfg.get("management"): - mgmt_conf = _generate_management_conf(cfg["management"]) - write_site("management", mgmt_conf) - written.add("management.conf") + # Remove old management.conf if it exists + 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: @@ -411,48 +498,6 @@ def apply() -> None: 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 # ------------------------------------------------------------------ @@ -510,7 +555,6 @@ __all__ = [ "get_domains", "remove_domain", "save_config", - "set_management_proxy", "test_config", "update_domain", "write_acme_challenge", diff --git a/lib/state.py b/lib/state.py index 8d46b66..8ca3516 100644 --- a/lib/state.py +++ b/lib/state.py @@ -636,7 +636,6 @@ def _collect_nginx() -> dict[str, Any]: default_cfg: dict[str, Any] = { "domains": {}, - "management": None, "ssl": deepcopy(DEFAULT_SSL), } cfg = deepcopy(default_cfg) @@ -652,18 +651,27 @@ def _collect_nginx() -> dict[str, Any]: except Exception: pass - # Build domains list with site existence + # Build flattened domains list (one entry per path) domains: list[dict[str, Any]] = [] for name, dom in cfg.get("domains", {}).items(): 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, - "backend": dom.get("backend", {}), + "path": ppath, + "backend": pcfg.get("backend", {}), "online": site.exists() if SITES_DIR.exists() else False, "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 { "config": cfg, @@ -819,7 +827,8 @@ def _collect_acme() -> dict[str, Any]: if not main: continue 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" ] cert_dir = acme_home / main diff --git a/system/nginx/server_block.conf b/system/nginx/server_block.conf index 6ec87a0..9e3a313 100644 --- a/system/nginx/server_block.conf +++ b/system/nginx/server_block.conf @@ -12,94 +12,97 @@ server { root {{ acme_webroot }}; } - # Redirect all HTTP traffic to HTTPS return 301 https://$host$request_uri; } - {% endif %} + server { listen 443 ssl; listen [::]:443 ssl; server_name {{ domain }}; {% if cert %} -{% if cert.type == "acme" %} - # Certificate managed by acme.sh -{% if cert.email %} # ACME contact: {{ cert.email }} -{% endif %} ssl_certificate {{ acme_cert_dir }}/fullchain.cer; - ssl_certificate_key {{ acme_cert_dir }}/{{ domain }}.key; - -{% elif cert.type == "file" %} - ssl_certificate {{ cert.path }}; - ssl_certificate_key {{ cert.key_path }}; - -{% elif cert.type == "selfsigned" %} +{% if cert == "acme" %} + ssl_certificate {{ acme_cert_dir }}/fullchain.cer; + ssl_certificate_key {{ acme_cert_dir }}/{{ domain }}.key; +{% elif cert == "file" %} + ssl_certificate {{ cert_path }}; + ssl_certificate_key {{ cert_key_path }}; +{% elif cert == "selfsigned" %} ssl_certificate {{ certs_dir }}/{{ domain }}.crt; ssl_certificate_key {{ certs_dir }}/{{ domain }}.key; - {% endif %} -{% elif is_management %} - ssl_certificate {{ acme_cert_dir }}/fullchain.cer; - ssl_certificate_key {{ acme_cert_dir }}/{{ domain }}.key; - +{% elif has_management %} + ssl_certificate {{ acme_cert_dir }}/fullchain.cer; + ssl_certificate_key {{ acme_cert_dir }}/{{ domain }}.key; {% endif %} - # Shared SSL settings + include snippets/vacuum-wall-ssl.conf; -{% if auth %} - # HTTP basic authentication - auth_basic "{{ "Vacuum Wall" if is_management else "Restricted" }}"; - auth_basic_user_file {{ auth.htpasswd }}; +{% if domain_auth %} + auth_basic "Restricted"; + auth_basic_user_file {{ domain_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 %} -{% if not is_management %} - # Security hardening headers - 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; + +{% for ppath, pcfg in paths.items() %} +{% if pcfg.is_websocket %} + # {{ ppath }} -> {{ pcfg.backend.host }}:{{ pcfg.backend.port }} (WebSocket) + location {{ ppath }} { + auth_basic off; + 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 %} - location / { - # Proxy headers 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; -{% if not is_management %} -{% for hname, hval in headers.items() %} +{% if not pcfg.is_management %} +{% for hname, hval in (pcfg.headers or {}).items() %} proxy_set_header {{ hname }} {{ hval }}; {% endfor %} {% endif %} - # Proxy pass to backend - proxy_pass {{ backend.proto }}://{{ backend.host }}:{{ backend.port }}; + proxy_pass {{ pcfg.backend.proto }}://{{ pcfg.backend.host }}:{{ pcfg.backend.port }}; proxy_http_version 1.1; - - # Timeouts proxy_connect_timeout 30s; proxy_send_timeout 60s; proxy_read_timeout 60s; proxy_buffering off; - proxy_set_header Upgrade $http_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 %} +{% endfor %} -{% if not is_management %} - # Access / error logs - access_log /var/log/nginx/{{ domain }}_access.log; - error_log /var/log/nginx/{{ domain }}_error.log warn; -{% else %} +{% if has_management %} access_log /var/log/nginx/wall_mgmt_access.log; 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 %} -} +} \ No newline at end of file diff --git a/tests/test_api.py b/tests/test_api.py index 56470e7..77b623d 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -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 # ============================================================================ diff --git a/tests/test_handler_acme.py b/tests/test_handler_acme.py index 4de5580..d2c2006 100644 --- a/tests/test_handler_acme.py +++ b/tests/test_handler_acme.py @@ -351,7 +351,9 @@ class TestCheckDnsPublic: returncode=0, stdout="example.com has address 52.14.150.110" ) 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), ): passed, _ = _check_dns_public("example.com") diff --git a/tests/test_nginx.py b/tests/test_nginx.py index 10a4692..a3c029e 100644 --- a/tests/test_nginx.py +++ b/tests/test_nginx.py @@ -63,7 +63,10 @@ class TestSaveConfig: } nginx.save_config(cfg) loaded = nginx.get_config() - assert loaded["domains"]["example.com"]["backend"]["host"] == "localhost" + assert ( + loaded["domains"]["example.com"]["paths"]["/"]["backend"]["host"] + == "localhost" + ) class TestGetDomains: @@ -97,8 +100,10 @@ class TestAddDomain: nginx.add_domain("example.com", "10.0.0.5", 8080) cfg = nginx.get_config() assert "example.com" in cfg["domains"] - assert cfg["domains"]["example.com"]["backend"]["host"] == "10.0.0.5" - assert cfg["domains"]["example.com"]["backend"]["port"] == 8080 + assert ( + 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") def test_duplicate_domain_raises(self, mock_get, temp_data_dir): @@ -163,21 +168,149 @@ class TestWriteSite: 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: @patch("lib.nginx.get_config") def test_writes_all_domains(self, mock_get, temp_data_dir): mock_get.return_value = { "domains": { "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, }, "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, }, }, - "management": None, "ssl": {"protocols": "TLSv1.2 TLSv1.3"}, } nginx.write_all_sites() diff --git a/webui/api/proxy.py b/webui/api/proxy.py index 9e2c5ac..af90004 100644 --- a/webui/api/proxy.py +++ b/webui/api/proxy.py @@ -17,7 +17,6 @@ from daemon.iface import ( POST_NGINX_CONFIG, POST_NGINX_DOMAINS_ADD, POST_NGINX_DOMAINS_UPDATE, - POST_NGINX_MANAGEMENT, POST_NGINX_SSL_APPLY, POST_NGINX_TEST, ) @@ -140,7 +139,13 @@ def add_domain_bp(): 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. backend_host: Upstream host. backend_port: Upstream port. @@ -153,29 +158,37 @@ def add_domain_bp(): """ body = request.get_json(silent=True) or {} 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: return _error("'domain' is required", 400) - if not backend_host: - return _error("'backend_host' is required", 400) - if backend_port is None: - return _error("'backend_port' is required", 400) + + paths = body.get("paths") + if paths is not None: + 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: - post( - 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, - }, - ) + post(POST_NGINX_DOMAINS_ADD, payload) logger.info("Proxy domain added via API: %s", domain) return _ok({"domain": domain}) except BadRequest as exc: @@ -272,48 +285,3 @@ def test_bp(): except RuntimeError as exc: logger.error("nginx config test failed: %s", exc) 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) diff --git a/webui/server.py b/webui/server.py index 85409b8..5526095 100644 --- a/webui/server.py +++ b/webui/server.py @@ -197,6 +197,7 @@ def spa_root(): def vendor_files(filename): """Serve vendored JS libraries (htm.js, etc.).""" from flask import send_file + target = (VENDOR_DIR / filename).resolve() if not target.is_relative_to(VENDOR_DIR): abort(404) diff --git a/webui/static/pages/proxy.js b/webui/static/pages/proxy.js index 16f71af..5398abc 100644 --- a/webui/static/pages/proxy.js +++ b/webui/static/pages/proxy.js @@ -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({ title: 'Add Proxy Domain', fields: [ { 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 Port', id: 'p-port', type: 'number', placeholder: '8080' }, { label: 'Protocol', id: 'p-proto', placeholder: 'http or https' }, @@ -11,42 +28,127 @@ const addDomain = QuickModal({ ], submit: { url: '/api/proxy/domains', - body: () => ({ - domain: ($val('p-domain') || '').trim(), - backend_host: ($val('p-host') || '').trim(), - backend_port: parseInt($val('p-port')), - backend_proto: ($val('p-proto') || 'http').trim() || 'http', - cert: ($val('p-cert') || '').trim() || undefined, - }), - validate: (b) => !b.domain || !b.backend_host || !b.backend_port ? 'Domain, host, and port are required' : null, + body: () => { + const path = ($val('p-path') || '/').trim() || '/'; + return { + domain: ($val('p-domain') || '').trim(), + paths: { + [path]: { + backend: { + 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', }, refresh: ['nginx', 'acme'], }); +// --------------------------------------------------------------------------- +// Edit Domain modal — updates backend for the root path +// --------------------------------------------------------------------------- const editDomain = QuickModal({ - title: (d) => 'Edit: ' + d.domain, - fields: (d) => [ - { label: 'Backend Host', id: 'pe-host', value: d.backend_host || '' }, - { label: 'Backend Port', id: 'pe-port', type: 'number', value: d.backend_port || '' }, - { label: 'Protocol', id: 'pe-proto', value: d.backend_proto || d.protocol || 'http' }, - { label: 'Cert (optional)', id: 'pe-cert', value: d.cert || '' }, - ], + title: (d) => 'Edit: ' + d.domain + ' ' + d.path, + fields: (d) => { + const be = d.backend || {}; + return [ + { label: 'Backend Host', id: 'pe-host', value: be.host || '' }, + { 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: { url: (d) => '/api/proxy/domains/' + enc(d.domain), method: 'PUT', - body: () => ({ - backend_host: ($val('pe-host') || '').trim(), - backend_port: parseInt($val('pe-port')), - backend_proto: ($val('pe-proto') || 'http').trim(), + body: (d) => ({ + backend: { + host: ($val('pe-host') || '').trim(), + port: parseInt($val('pe-port')), + proto: ($val('pe-proto') || 'http').trim(), + }, 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', }, 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` + ${esc(d.domain)} + ${esc(d.path)} + ${esc(be.host || '-')} + ${be.port || '-'} + <${Badge} text=${be.proto || 'http'} variant="info" /> + ${flagBadges} + ${certBadge} + ${actions} + `; +} + +// --------------------------------------------------------------------------- +// Page +// --------------------------------------------------------------------------- export default definePage({ init() { return { @@ -59,28 +161,22 @@ export default definePage({ if (guard) return guard; const domains = state.nginx.data.domains || []; - const rows = domains.map(d => { - const certBadge = certStatusBadge({ - certStatus: d.cert_status, - daysRemaining: d.days_remaining, - expired: d.cert_status === 'expired', - }); + const certs = certLookup(state.acme.data); - return html` - ${esc(d.domain)} - ${esc(d.backend_host || '-')} - ${d.backend_port || '-'} - <${Badge} text=${d.backend_proto || d.protocol || 'http'} variant="info" /> - ${certBadge} - <${ActionCell} - editLabel="Edit" editClick=${() => editDomain(d)} - removeUrl=${'/api/proxy/domains/' + enc(d.domain)} - removeMessage=${'Remove proxy for ' + d.domain + '?'} - removeSuccess="Domain removed" - removeRefresh={['nginx', 'acme']} - removeLabel="Delete" /> - `; - }); + // Group by domain for multi-path awareness + const groups = {}; + for (const d of domains) { + if (!groups[d.domain]) groups[d.domain] = []; + groups[d.domain].push(d); + } + + // Attach domain-level cert info to each entry + const enriched = domains.map(d => ({ + ...d, + _cert: certs[d.domain] || null, + })); + + const rows = enriched.map(d => pathRow(d, certs, groups[d.domain])); const actions = ActionGroup( html``, @@ -94,12 +190,12 @@ export default definePage({ return [ PageHeader({ title: 'Proxy', subtitle: 'Nginx reverse proxy', actions }), - rows.length + domains.length ? Table({ - columns: ['Domain', 'Backend Host', 'Port', 'Proto', 'Cert', 'Actions'], + columns: ['Domain', 'Path', 'Host', 'Port', 'Proto', 'Flags', 'Cert', 'Actions'], rows, }) : Empty({ text: 'No proxy domains configured. Add a domain to start terminating SSL.' }), ]; }, -}); +}); \ No newline at end of file