diff --git a/AGENTS.md b/AGENTS.md index 77899c6..44b980a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -68,7 +68,7 @@ Conventions: - `h()` builds VNodes with `on:click` prefix. `html` tag (htm) templates use camelCase `onClick` (adapter translates). - State always has `loading`, `refreshing`, `error` plus data. `load()` receives `(state, abortController, entry)`. - `openModal` + `formModal` for dialogs; `apiSubmit()` for form submission. -- No build step — ES modules served raw. Cache controlled via HTTP headers. +- No build step — ES modules served raw. Cache controlled via HTTP headers. For the management domain, nginx serves `/static/` directly from `webui/static/` (generated `location /static/` alias with `no-cache` + ETag revalidation); Flask's static route is the dev-mode fallback. ### Daemon Endpoints diff --git a/daemon/handlers/nginx.py b/daemon/handlers/nginx.py index 84a1f94..d70e4bb 100644 --- a/daemon/handlers/nginx.py +++ b/daemon/handlers/nginx.py @@ -293,6 +293,7 @@ def _generate_server_conf(domain_cfg: dict[str, Any], backends: dict[str, Any]) cert_key_path=cert_key_path, domain_auth=_ngx_resolve_auth(domain_cfg, backends), has_management=has_management, + static_root=str(PROJECT_DIR / "webui" / "static"), acme_cert_dir=acme_cert_dir, certs_dir=str(PROJECT_DIR / "data" / "certs"), acme_webroot=str(PROJECT_DIR / "data" / "acme" / "www"), diff --git a/docs/architecture.md b/docs/architecture.md index 85f3df9..1ebe6b3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -337,7 +337,7 @@ The web UI is a single-page application built on **Hoover**, a custom lightweigh ``` Client requests / ──→ nginx ──→ Flask (serves index.html) -Client loads /static/app.js ──→ Hoover initializes, checkSession() (401 with valid refresh token → one refresh) → if no valid session, render #login +Client loads /static/app.js ──→ served by nginx directly from disk (mgmt `location /static/` alias, no Flask round-trip) ──→ Hoover initializes, checkSession() (401 with valid refresh token → one refresh) → if no valid session, render #login Authenticated ──→ mounts #sidebar and #main render roots apiFetch() ──→ injects Authorization: Bearer header ──→ Flask REST API Flask before_request ──→ validates JWT from header, checks blacklist, verifies permissions @@ -348,7 +348,7 @@ Token expiry ──→ refreshScheduler() ──→ POST /api/auth/refresh ─ WS connect ──→ snapshot (full state) / versions + tick deltas (per-subsystem data) ──→ modelSet() patches model in place ──→ render engine VDOM-diffs and patches only changed DOM nodes ``` -The SPA entry point only serves `index.html` at `/`. All other paths return 404. Non-API, non-static paths are not served by Flask — the client-side router handles all navigation via hash changes. A dedicated `/vendor/` route serves vendored JS libraries. +The SPA entry point only serves `index.html` at `/`. All other paths return 404. Non-API, non-static paths are not served by Flask — the client-side router handles all navigation via hash changes. A dedicated `/vendor/` route serves vendored JS libraries. On the management domain, nginx serves `/static/` directly from `webui/static/` via a `location /static/` alias in the generated server block, so asset requests never reach Flask in production; Flask's static route remains as the dev-mode fallback. ### Component Model @@ -356,7 +356,7 @@ Each route is a `definePage()` component with reactive state, async data loading ### No Build Step -All JavaScript is served as ES modules. Cache invalidation is handled via HTTP cache-control headers. Dev mode (`VACUUM_WALL_DEV`) disables aggressive static asset caching. +All JavaScript is served as ES modules. Cache invalidation is handled via HTTP cache-control headers: the management domain's `/static/` assets carry `Cache-Control: no-cache` (browsers revalidate every load; unchanged files return 304 via nginx's built-in ETag), so updates are picked up on the next page load. Dev mode (`VACUUM_WALL_DEV`) uses short TTLs instead. ### WebSocket Data Streaming diff --git a/docs/security.md b/docs/security.md index 75aba91..ddc8d6a 100644 --- a/docs/security.md +++ b/docs/security.md @@ -70,7 +70,7 @@ The `daemon/client.py` module resolves `` placeholders in URL paths befor ### Management Interface -The Flask WebUI binds exclusively to `127.0.0.1:9090`. It is not exposed directly to any network interface. All external access to the management UI is routed through an nginx reverse proxy on the designated management domain, which provides SSL termination. Authentication is handled at the Flask layer via JWT validation — no nginx-level `auth_basic` is applied to the management domain. +The Flask WebUI binds exclusively to `127.0.0.1:9090`. It is not exposed directly to any network interface. All external access to the management UI is routed through an nginx reverse proxy on the designated management domain, which provides SSL termination. Authentication is handled at the Flask layer via JWT validation — no nginx-level `auth_basic` is applied to the management domain. Static assets under `/static/` are served directly by nginx from `webui/static/` (unauthenticated, the same exposure as the Flask static route) with `Cache-Control: no-cache`, `X-Content-Type-Options: nosniff`, and a restrictive `Content-Security-Policy: default-src 'none'`. JWT tokens are stored in browser `sessionStorage` and injected as `Authorization: Bearer ` headers. The API **never** reads cookies — authentication is header-only. This eliminates CSRF concerns: cross-origin requests cannot set custom headers. diff --git a/lib/nginx.py b/lib/nginx.py index db10c93..d5b6030 100644 --- a/lib/nginx.py +++ b/lib/nginx.py @@ -416,6 +416,7 @@ def generate_server_conf( cert_key_path=cert_key_path, domain_auth=domain_auth, has_management=has_management, + static_root=str(PROJECT_DIR / "webui" / "static"), acme_cert_dir=acme_cert_dir, certs_dir=str(PROJECT_DIR / "data" / "certs"), acme_webroot=str(PROJECT_DIR / "data" / "acme" / "www"), diff --git a/scripts/install.sh b/scripts/install.sh index 48cb069..7476bd7 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -238,6 +238,15 @@ mkdir -p "${PROJECT_DIR}/config"/{dnsmasq,nginx,wireguard,firewall} mkdir -p "${PROJECT_DIR}/data"/{nginx/sites-enabled,dnsmasq,firewall,wireguard,acme} mkdir -p /etc/wireguard mkdir -p /etc/dnsmasq +# nginx workers (www-data) serve webui/static directly from disk for the +# management domain — ensure read access regardless of checkout umask. +chmod -R a+rX "${PROJECT_DIR}/webui/static" +# ...and traversal (x only) up the parent chain, so repo-in-$HOME installs work. +_d="${PROJECT_DIR}" +while [[ "$d" != "/" && -n "$d" ]]; do + chmod a+x "$d" 2>/dev/null || true + d="$(dirname "$d")" +done # Set ownership: daemon owns project dir in prod, repo owner keeps ownership in dev. # The top-level .git (directory or worktree pointer file) is left untouched so # the repo owner's git isn't tripped by git's dubious-ownership check. diff --git a/system/nginx/server_block.conf b/system/nginx/server_block.conf index 363b282..cc72f66 100644 --- a/system/nginx/server_block.conf +++ b/system/nginx/server_block.conf @@ -53,6 +53,16 @@ server { {% endif %} {% for ppath, pcfg in paths.items() %} +{% if pcfg.is_management and ppath == '/' %} + # SPA static assets — served from disk, no Flask round-trip. + # no-cache: browsers revalidate every load; unchanged files are 304s. + location /static/ { + alias {{ static_root }}/; + add_header Cache-Control "no-cache" always; + add_header X-Content-Type-Options nosniff always; + add_header Content-Security-Policy "default-src 'none'" always; + } +{% endif %} {% if pcfg.is_websocket %} # {{ ppath }} -> {{ pcfg.backend.host }}:{{ pcfg.backend.port }} (WebSocket) location {{ ppath }} { diff --git a/tests/test_nginx.py b/tests/test_nginx.py index fb2d4e7..e602476 100644 --- a/tests/test_nginx.py +++ b/tests/test_nginx.py @@ -304,9 +304,51 @@ class TestGenerateServerConf: } 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 + # Server-level security headers come from Flask, not nginx + assert "Strict-Transport-Security" not in out + assert "Referrer-Policy" not in out assert "wall_mgmt_access.log" in out + def test_management_static_location(self, temp_data_dir): + cfg = { + "domain": "mgmt.example.com", + "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, + }, + }, + "force_ssl": True, + "cert": "acme", + } + out = nginx.generate_server_conf(cfg) + static_root = str(nginx.PROJECT_DIR / "webui" / "static") + assert "location /static/ {" in out + assert f"alias {static_root}/;" in out + assert 'add_header Cache-Control "no-cache" always;' in out + assert "add_header X-Content-Type-Options nosniff always;" in out + assert ( + "add_header Content-Security-Policy \"default-src 'none'\" always;" in out + ) + + def test_static_location_only_for_management_root(self, temp_data_dir): + cfg = { + "domain": "app.example.com", + "paths": { + "/": { + "backend": {"host": "10.0.0.1", "port": 80, "proto": "http"}, + } + }, + "force_ssl": True, + "cert": "acme", + } + out = nginx.generate_server_conf(cfg) + assert "location /static/" not in out + def test_websocket_path(self, temp_data_dir): cfg = { "domain": "mgmt.example.com",