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:
@@ -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
|
||||
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user