nginx: serve mgmt /static/ assets from disk (no Flask round-trip)

Generated management-domain server blocks now include a
location /static/ aliasing webui/static/ with Cache-Control: no-cache
(ETag revalidation -> 304), nosniff, and a restrictive CSP, so SPA
asset requests no longer reach Flask. Flask's static route remains
the dev-mode fallback.

- lib/nginx.py + daemon/handlers/nginx.py: static_root render context
  (the handler renders the template directly, so both paths need it)
- template: alias uses a trailing slash (nginx concatenates the
  location remainder onto the alias value)
- install.sh: a+rX on webui/static plus execute-up-the-parent-chain
  so the nginx worker (www-data) can traverse repo-in-$HOME installs
- tests: mgmt static location assertions (positive + non-mgmt negative)
- docs: AGENTS.md, architecture.md, security.md static-serving notes
This commit is contained in:
2026-09-03 14:58:57 +00:00
parent d78b90db00
commit fc478a016e
8 changed files with 69 additions and 6 deletions
+1 -1
View File
@@ -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
+1
View File
@@ -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"),
+3 -3
View File
@@ -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 <token> 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/<path>` 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/<path>` 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
+1 -1
View File
@@ -70,7 +70,7 @@ The `daemon/client.py` module resolves `<param>` 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 <token>` headers. The API **never** reads cookies — authentication is header-only. This eliminates CSRF concerns: cross-origin requests cannot set custom headers.
+1
View File
@@ -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"),
+9
View File
@@ -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.
+10
View File
@@ -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 }} {
+43 -1
View File
@@ -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",