From 8f3befaf7268a22dd702b73c82ac67156ce0d9cd Mon Sep 17 00:00:00 2001 From: hoelee Date: Tue, 29 Sep 2026 05:12:31 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20stats.hoelee.com=20hardening=20(A+B)=20?= =?UTF-8?q?=E2=80=94=20rotate=20password/APP=5FSECRET,=20add=20the=20umami?= =?UTF-8?q?-gateway,=20dashboard=20LAN-only,=20tracker=20verified=20end-to?= =?UTF-8?q?-end?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/project-state.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/project-state.md b/docs/project-state.md index fec574e..a3e3e31 100644 --- a/docs/project-state.md +++ b/docs/project-state.md @@ -72,11 +72,13 @@ 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`) → container. Cert = Let's Encrypt `CN=stats.hoelee.com`, 2026-09-28 → 2026-12-27; `http://` → 308. Public checks: `/api/heartbeat` 200 `{"ok":true}`, `/login` 200, `/script.js` 200, `/api/send` with a bogus id → 400 `Website not found` | +| **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 | | **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 | -| ⚠ **Open security item** | The Umami dashboard is **publicly reachable with the default `admin` / `umami` credentials** — verified 2026-09-29 (`POST /api/auth/login` → 200 + token). New subdomains appear in Certificate Transparency logs within minutes, so this needs closing now: (a) change the password, and/or (b) gate it — public router for `/script.js` + `/api/send` + `/api/heartbeat`, everything else behind basicAuth/authentik (recipe in the stack README) | +| ✅ **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 | +| ⚠ Gate design constraint found | **HTTP basic auth cannot be used here**: the Umami front end sends `Authorization: Bearer ` 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. Consequence: if a *remote* dashboard is ever wanted, use cookie auth (authentik forward-auth / oauth2-proxy) on a separate hostname, never basic auth on `stats.hoelee.com` | +| 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 | default `admin` / `umami` — change on first login (agent does not hold this password) | +| Dashboard login | **LAN only**: — `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) | **Step E2 — Wire the two sites together (www.hoelee.com → blog).** ⏸ Deferred by user 2026-09-29