docs(project-state): E1 — SSO gate in front of stats.hoelee.com (authentik proxy provider pk 64, per-app flow auth-stats, skip-path tracker); record the forward_single-vs-proxy pitfall, the outpost config lag, and the post-cut-over tracker verification
Deploy / build (push) Successful in 37s

This commit is contained in:
2026-09-29 05:51:41 +08:00
parent c5e10a0e70
commit 88560a8afe
+20 -2
View File
@@ -72,14 +72,32 @@ unanswerable, and the job-hunt thesis can't be verified. Decisions (2026-09-29):
| Env | `DATABASE_URL`, `APP_SECRET`, `TWO_FACTOR_ENCRYPTION_KEY`, `CLIENT_IP_HEADER=x-forwarded-for`, `DISABLE_TELEMETRY=1`, `DISABLE_UPDATES=1`, `MCP_ENABLED=1`, `TZ=Asia/Kuala_Lumpur`. No `cpus:` (DSM has no CFS quota) |
| Verified on LAN | `/api/heartbeat` → `{"ok":true}` (first hit 6.1 s cold, then 66 ms), `/login` 200, container `healthy`, prisma migrations created **16 tables** |
| Ops notes | `/volume1/docker/umami/README.md` |
| **Public URL** | **https://stats.hoelee.com** live 2026-09-29 — DNS **A → 180.73.9.11** (the user's usual path, **not** through Cloudflare) → router 443 → DSM nginx reverse proxy (`ReverseProxy.json` key `8df2ab4d-ff73-47a0-b5ad-5a09957e6402`, frontend `stats.hoelee.com:443` → `localhost:5410`) → **gate** (stack 285) → container. Cert = Let's Encrypt `CN=stats.hoelee.com`, 2026-09-28 → 2026-12-27; `http://` → 308. Public allowlist only: `/script.js` 200, `POST /api/send` 200/400, `/api/heartbeat` 200; everything else 403 |
| **Public URL** | **https://stats.hoelee.com** live 2026-09-29 — DNS **A → 180.73.9.11** (the user's usual path, **not** through Cloudflare) → router 443 → DSM nginx reverse proxy (`ReverseProxy.json` key `8df2ab4d-ff73-47a0-b5ad-5a09957e6402`, frontend `stats.hoelee.com:443` → **`localhost:10000`** = the authentik outpost since the SSO cut-over) → `http://umami:3000`. Cert = Let's Encrypt `CN=stats.hoelee.com`, 2026-09-28 → 2026-12-27; `http://` → 308. Public allowlist via `skip_path_regex` only: `/script.js` 200, `POST /api/send` 200/400, `/api/heartbeat` 200; every dashboard path 302 → `auth.hoelee.com` |
| **Client IP fix** | ⚠ Because traffic does **not** pass Cloudflare, `CLIENT_IP_HEADER` was changed `cf-connecting-ip` → **`x-forwarded-for`** (what DSM nginx sets) on 2026-09-29, stack re-PUT and verified in the running container. Without it every visit would be attributed to the proxy. Consequence of no CF: no WAF/rate-limit/bot protection on this hostname |
| ✅ **Hardening (A+B) done 2026-09-29** | (A) Default `admin`/`umami` replaced with a random 20-char password (`C:\Users\hoelee\.secrets\umami-admin.txt`) and `APP_SECRET` rotated → every previously issued session invalidated. Verified: old password **401**, new password **200**, old token **401**. (B) New stack **285 `umami-gateway`** (`nginx:1.29-alpine`, holds host port **5410** = what the DSM vhost targets) — public allowlist is exactly `/script.js`, `/api/send`, `/api/heartbeat`; **everything else 403**. Umami itself no longer publishes a public port; the dashboard moved to **LAN-only `192.168.1.1:5411`** (stack 284 republished `5411:3000`), which is unreachable from the internet — verified from the VPS: TCP 5411 closed/filtered, TCP 443 open |
| ✅ **Hardening (A+B) done 2026-09-29** | (A) Default `admin`/`umami` replaced with a random 20-char password (`C:\Users\hoelee\.secrets\umami-admin.txt`) and `APP_SECRET` rotated → every previously issued session invalidated. Verified: old password **401**, new password **200**, old token **401**. (B) New stack **285 `umami-gateway`** (`nginx:1.29-alpine`, holds host port **5410** = what the DSM vhost targets) — public allowlist is exactly `/script.js`, `/api/send`, `/api/heartbeat`; **everything else 403**. Umami itself no longer publishes a public port; the dashboard moved to **LAN-only `192.168.1.1:5411`** (stack 284 republished `5411:3000`), which is unreachable from the internet — verified from the VPS: TCP 5411 closed/filtered, TCP 443 open. ⚠ **Superseded later the same day by the SSO gate below** — the DSM vhost now points at the outpost, so stack 285 is no longer in the public path but is **left running as the rollback** |
| ⚠ Gate design constraint found | **HTTP basic auth cannot be used here**: the Umami front end sends `Authorization: Bearer <jwt>` on its own API calls, and a browser sends only one `Authorization` header — Bearer replaces Basic, so a basic-auth gate 401s every API call and the dashboard breaks. If a *remote* dashboard is ever wanted, **one hostname is still enough** — either rely on Umami's own login (+ enable 2FA) or put cookie auth (authentik forward-auth / oauth2-proxy) on this same hostname with skip-paths for the tracker (the `traefik-login-numerology` pattern). A second hostname (tracker vs admin) is the PostHog/Sentry-scale ingest-vs-app split, not a requirement. `stats-admin.hoelee.com` was only a hypothetical and was never created (NXDOMAIN verified) |
| End-to-end tracker verified | A real pageview POSTed through the **public** gate was recorded: 1 pageview / 1 visitor, with `country=MY, region=MY-07, city=George Town`, browser chrome, os Windows 10 — proving the `X-Forwarded-For` chain (DSM nginx → gate → Umami) works and geo needs no Cloudflare headers. The temporary test website was deleted afterwards (website list back to 0) |
| Still open | (a) CF Web Analytics still not enabled (independent of Umami). (b) GSC sitemap submission still unconfirmed. (c) The blog's tracker snippet is **not** wired yet — no data is collected until a website record exists and the snippet is in the base layout. |
| Dashboard login | **LAN only**: <http://192.168.1.1:5411> — `admin` + the random password in `C:\Users\hoelee\.secrets\umami-admin.txt` (agent-set 2026-09-29; change it in Settings → Profile if you prefer) |
**E1 progress — SSO gate wired 2026-09-29 ✅ (authentik `forward-auth`-style proxy in front of the dashboard; tracker stays public)**
The user asked for the dashboard to be reachable remotely **without** publishing an Umami login page, i.e. one hostname, SSO on the dashboard, tracker open. Built on the existing authentik instance (`auth.hoelee.com`, 2026.8.2) instead of a second container:
| Item | State |
|---|---|
| authentik objects | proxy provider **pk 64 `Provider Proxy Stats`** (`mode=proxy`, `external_host=https://stats.hoelee.com`, `internal_host=http://umami:3000`, `skip_path_regex` = `^/script\.js$` + `^/api/send` + `^/api/heartbeat$`) + application **`umami-stats`** (launch URL + 512×512 logo, see below). Attached to the **embedded outpost** `e16274ac-a3ad-4c21-bb16-4a5d32570bc6` (20th provider) |
| Per-app login flow | **`auth-stats`** (designation `authentication`, 4 stages: identification → password → mfa-validation → user-login), so the SSO page for this app is its own branded screen and **not** shared with the other 19 apps. Provider `authentication_flow` points at it |
| App icon | ⚠ In this build the application icon field is **`meta_icon`** (not `icon` — PATCHing `icon` silently no-ops). Uploaded with the house convention `application-icons/umami.png` via `POST /admin/file/` (multipart, `usage=media`), then `PATCH /core/applications/umami-stats/ {"meta_icon": "application-icons/umami.png"}` → `meta_icon_url` `/files/media/public/application-icons/umami.png` verified 200 (21,989 B PNG) |
| DSM cut-over | vhost `…c378795c2acb.w3conf` §2731 `proxy_pass http://localhost:5410` → **`http://localhost:10000`** (the outpost) + `ReverseProxy.json` `"port" : 5410` → `10000`; backups `*.bak-20260929-sso-stats-sso`. Both edits asserted unique first (`grep -c 5410` = 1 in each file), `nginx -t` clean, reloaded |
| ⚠ Pitfall found | **`mode=proxy` is required here, `forward_single` breaks it**: `forward_single` leaves `internal_host` empty, so the outpost has no upstream — the SSO redirect chain looks perfectly healthy while **every app path silently 404s** (`/script.js` 404 with `x-powered-by: authentik`). `proxy` + `internal_host=http://umami:3000` is also what HA (pk 6) / NocoDB (pk 62) use |
| ⚠ Other pitfall | After any provider change the **embedded outpost needs ~1–2 min to pick up the new config**; before that, `/` answers `302 → /flows/-/default/authentication/` and app paths 404. Judging the wiring before that window expires gives a false "it's broken" |
| Verified from the public internet (2026-09-29) | anonymous `/` → 302 → `/outpost.goauthentik.io/start` → `auth.hoelee.com/if/flow/auth-stats/…` → **200** (same shape as the working `auth-mysql` chain); `/login`, `/api/websites` → 302 gated; `/script.js` **200**, `/api/heartbeat` **200**, `POST /api/send` **400 app-level** |
| End-to-end tracker through the SSO path | temp website + 2 pageviews POSTed to `https://stats.hoelee.com/api/send` → recorded **2 pageviews / 1 visitor / 1 session**, `country=MY, region=MY-07, city=George Town`, referrer metric `google.com` → **the outpost does not break `X-Forwarded-For`, geo still works**; temp website deleted |
| Known trade-off (honest) | (1) SSO protects the *dashboard route*, but Umami has **no OIDC**, so after SSO the user still sees **Umami's own login page** — double login, expected, one-click after the first time (2FA is available per-account in Settings → Profile; `TWO_FACTOR_ENCRYPTION_KEY` already in the stack env). (2) `<http://192.168.1.1:5411>` remains a **LAN break-glass path that bypasses SSO** (still needs Umami credentials; verified closed from the internet) — delete/close it if that is not wanted. (3) If the authentik outpost goes down, the dashboard goes down with it (tracker too — but the site keeps loading, the tracker fails silently) |
| Rollback | Re-point the vhost + `ReverseProxy.json` back to `5410` (stack 285 is still running) and reload nginx → instantly back to the plain allowlist gate. Backups of both files sit next to the originals |
| Note | The `hermes3` authentik API token expired mid-session (DB says `expires 2026-09-28 22:17:08Z`); the leftover self-test account was removed through authentik's own ORM (`ak shell`), so no admin-group test user is left behind |
**Step E2 — Wire the two sites together (www.hoelee.com → blog).** ⏸ Deferred by user 2026-09-29
Measured: **all six pages of www.hoelee.com** (home, `/zh-hans/`, `/about-mrhoelee/`, T&C, support, privacy) contain