docs: document the setup-code first run for the LXC/Zoraxy topology

- PROJECT_PLAN: register D-044, re-revise D-025 to "tunnel OR setup code",
  update A13, O-8, §5.1, §12 and the change log.
- SPEC-003 §4/§7/§9/§12: the gate now serves the wizard to the tunnel OR an
  unlocked request; AC-7/AC-10 reworded ("refused the wizard" — the boilerplate
  may still answer `/` and `/setup`), AC-14 added.
- SPEC-005 §3/§4/§6/§9: the boilerplate, the unlock action, the code rules and
  the ACs; AC-7 added for single-use/rate-limit/redaction.
- deploy-zoraxy §1/§3/§5/§7 + README: the LXC/Proxmox topology, how to read the
  code (journalctl · pct exec 11020), the code gate replacing the tunnel-only
  bootstrap (the tunnel stays as gate a); firewall and Zoraxy sections kept.
This commit is contained in:
Claus Lohmar 2026-10-15 06:33:16 +00:00
parent 6d421b61fd
commit cec7340c55
5 changed files with 241 additions and 96 deletions

View file

@ -287,19 +287,27 @@ the highest role, the **root of the chain**. When the `users` table is empty, th
### 5.1 First run — the `/setup` wizard ### 5.1 First run — the `/setup` wizard
``` ```
fresh deploy → config.setup: true → the app serves ONLY /setup (the tunnel-only gate, D-025) fresh deploy → config.setup: true → setup mode: `/` and `/setup` show a first-run page
Step 1 the domain / base URL Step 1 the domain / base URL
Step 2 the top admin — name, email, Passkey + TOTP enrollment (user_id = 1, role: admin) Step 2 the top admin — name, email, **TOTP** enrollment (the passkey is added at first
sign-in, D-041) (user_id = 1, role: admin)
Step 3 mail / SMTP · locale · timezone Step 3 mail / SMTP · locale · timezone
Finish → settings persisted → the app runs in PRODUCTIVE MODE (setup: false) Finish → settings persisted → the app runs in PRODUCTIVE MODE (setup: false)
→ /setup now 404s (delete, don't redirect); re-run only via config.setup: true + restart → /setup now 404s (delete, don't redirect); re-run only via config.setup: true + restart
``` ```
- `setup` is a **reserved `PARAM1`** (always — so no user can ever be named `setup`). - `setup` is a **reserved `PARAM1`** (always — so no user can ever be named `setup`).
- In setup mode, `/setup` is the **only served route**, reachable **only over the SSH tunnel** (a - **The bootstrap is gated — never first-visitor-wins (D-025 · D-044).** In setup mode the **wizard**
loopback TCP peer with **no** `X-Forwarded-For` — D-025). is served to **either** **(a)** a **direct tunnel** — a loopback TCP peer with **no**
- **It supersedes `{bootstrap_guid}`** — the wizard *is* the bootstrap; the operator runs it once `X-Forwarded-For` — **or** **(b)** a request **unlocked with the printed SETUP CODE** (D-044).
over an **SSH tunnel** to `127.0.0.1:8080`, then exposes the instance. Before it is unlocked, `GET /` and `GET /setup` show a friendly **first-run page** (never a bare
404, so the domain does not look "down"); every other route is 404.
- **The setup code (D-044).** A **128-bit** code is generated at first run and printed in a
**startup banner** (`setup_code` in config overrides it); it is **single-use** and
**rate-limited**. This is the general gate for the **LXC/Proxmox** topology, where an SSH tunnel
is impractical (the tunnel would land on the container *gateway*, not loopback).
- **It supersedes `{bootstrap_guid}`** — the wizard *is* the bootstrap; the operator unlocks it (the
code, or an SSH tunnel to `127.0.0.1:8080`) and completes it, then exposes the instance.
- *README: do NOT expose MyDay publicly before the first admin exists.* - *README: do NOT expose MyDay publicly before the first admin exists.*
### 5.2 Admin recovery — a config-gated enrolment link ### 5.2 Admin recovery — a config-gated enrolment link
@ -458,8 +466,8 @@ the top admin (first user)
| **D-022** | 2026-10-13 | **Bootstrap:** `users` empty → **setup mode**; the top admin is created at a **gated** path; setup ends at the first user. | **Gate it** (loopback and/or setup token) or "first user" = "first visitor". | | **D-022** | 2026-10-13 | **Bootstrap:** `users` empty → **setup mode**; the top admin is created at a **gated** path; setup ends at the first user. | **Gate it** (loopback and/or setup token) or "first user" = "first visitor". |
| **D-023** | 2026-10-13 | **Admin recovery = `reset_admin` config toggle** — rotate the top admin's identity to `pending_enrollment`, log the new GUID; audited; auto-clears. | The config is the offline-held root of trust. | | **D-023** | 2026-10-13 | **Admin recovery = `reset_admin` config toggle** — rotate the top admin's identity to `pending_enrollment`, log the new GUID; audited; auto-clears. | The config is the offline-held root of trust. |
| **D-024** | 2026-10-13 | **Roles (KISS): `admin > director > manager > team`.** You may create only roles **below** your own; the new user's manager defaults to the creator. | Answers "who may create a user?" | | **D-024** | 2026-10-13 | **Roles (KISS): `admin > director > manager > team`.** You may create only roles **below** your own; the new user's manager defaults to the creator. | Answers "who may create a user?" |
| **D-025** *(rev. 2026-10-15)* | 2026-10-13 | **The bootstrap is loopback-only — enforced PER REQUEST, not at bind.** In **setup mode** the app serves only a request whose **TCP peer is loopback *and* which carries no `X-Forwarded-For`** — the **direct SSH tunnel**; anything else → 404. The app **may bind `0.0.0.0`** (D-043). | **Revised**: the old bind refusal is incompatible with the Zoraxy deployment (D-043). The per-request gate still closes first-visitor-wins **in code**. | | **D-025** *(rev. 2026-10-15b)* | 2026-10-13 | **The bootstrap is gated — never first-visitor-wins.** In **setup mode**, `/setup` is served to a request that is **either** **(a)** a **direct tunnel** — **TCP peer loopback *and* no `X-Forwarded-For`** — **or** **(b)** **unlocked with the setup code** (D-044). Anything else → the **boilerplate**/404. The app may bind `0.0.0.0` (D-043). | **Re-revised**: a nested **LXC** tunnel lands on the container **gateway**, not loopback — so gate (a) alone is unusable there. The **setup code** (D-044) is the general gate. |
| **D-026** | 2026-10-13 | **A `/setup` web wizard is the bootstrap** (a reserved `PARAM1`). It configures the **domain**, creates the **top admin** (user 1) with Passkey + TOTP, and mail/locale — **setup-mode only · the tunnel-only gate (D-025)** — then the app runs productive. `config.setup` forces the mode. | Supersedes `{bootstrap_guid}`; configuration through a UI instead of hand-editing config. | | **D-026** | 2026-10-13 | **A `/setup` web wizard is the bootstrap** (a reserved `PARAM1`). It configures the **domain**, creates the **top admin** (user 1) with Passkey + TOTP, and mail/locale — **setup-mode only · the setup gate (D-025/D-044)** — then the app runs productive. `config.setup` forces the mode. | Supersedes `{bootstrap_guid}`; configuration through a UI instead of hand-editing config. |
| **D-027** | 2026-10-13 | **Modular architecture.** The **core is `MyDay.auth`** (identity · session · roles · the door · the **module registry**); features are **modules** mounted at **`/{GUID}/{module}/{function}/{param}`**. A module depends only on core interfaces and **owns its own tables**. | The natural generalisation of the `PARAM2` switch — the same URL shape now carries a plugin model. Makes `expense`/IDM additive, not invasive. | | **D-027** | 2026-10-13 | **Modular architecture.** The **core is `MyDay.auth`** (identity · session · roles · the door · the **module registry**); features are **modules** mounted at **`/{GUID}/{module}/{function}/{param}`**. A module depends only on core interfaces and **owns its own tables**. | The natural generalisation of the `PARAM2` switch — the same URL shape now carries a plugin model. Makes `expense`/IDM additive, not invasive. |
| **D-028** | 2026-10-13 | **Drop-in modules, discovered at BUILD time.** A module is a directory (`internal/modules/<name>/`) that **self-registers** via `init()`; a **generated aggregator** blank-imports every module dir, so the directory is the source of truth. **No runtime plugins.** | The **Caddy** pattern. Drop a dir → `go generate` → rebuild. Deterministic order; log the loaded set at startup. | | **D-028** | 2026-10-13 | **Drop-in modules, discovered at BUILD time.** A module is a directory (`internal/modules/<name>/`) that **self-registers** via `init()`; a **generated aggregator** blank-imports every module dir, so the directory is the source of truth. **No runtime plugins.** | The **Caddy** pattern. Drop a dir → `go generate` → rebuild. Deterministic order; log the loaded set at startup. |
| **D-029** | 2026-10-13 | **User management is CORE, not a module.** Identities, roles, and the chain live in `MyDay.auth`. | "Management" decomposes into **core (users)** + `requests` (workflow) + `hr` (records). | | **D-029** | 2026-10-13 | **User management is CORE, not a module.** Identities, roles, and the chain live in `MyDay.auth`. | "Management" decomposes into **core (users)** + `requests` (workflow) + `hr` (records). |
@ -477,6 +485,7 @@ the top admin (first user)
| **D-041** | 2026-10-14 | **The setup wizard enrolls TOTP only.** Any **TOTP login with no passkey** is **nudged** to enroll one, with *not now* / ***don't ask again*** (`users.passkey_opt_out`). The nudge is **suppressed while the origin is loopback** (or in setup mode). | Resolves **O-13**: a passkey enrolled over loopback binds RP ID `127.0.0.1` and is unusable at the public domain. Passkeys are encouraged, not forced — TOTP stays a valid factor (D-005). | | **D-041** | 2026-10-14 | **The setup wizard enrolls TOTP only.** Any **TOTP login with no passkey** is **nudged** to enroll one, with *not now* / ***don't ask again*** (`users.passkey_opt_out`). The nudge is **suppressed while the origin is loopback** (or in setup mode). | Resolves **O-13**: a passkey enrolled over loopback binds RP ID `127.0.0.1` and is unusable at the public domain. Passkeys are encouraged, not forced — TOTP stays a valid factor (D-005). |
| **D-042** | 2026-10-14 | **TOTP-only enrollment is specific to the bootstrap admin (`/setup`).** **Invited users enroll both factors** (Passkey + TOTP) at the real origin; the passkey **nudge** remains the safety net for any identity without a passkey. | Resolves **O-14** (owner, Option A): keep the **strongest default** where the origin permits it. | | **D-042** | 2026-10-14 | **TOTP-only enrollment is specific to the bootstrap admin (`/setup`).** **Invited users enroll both factors** (Passkey + TOTP) at the real origin; the passkey **nudge** remains the safety net for any identity without a passkey. | Resolves **O-14** (owner, Option A): keep the **strongest default** where the origin permits it. |
| **D-043** | 2026-10-15 | **Deployment: behind Zoraxy.** The app listens on **`0.0.0.0:8080` over HTTP**; **TLS terminates at Zoraxy** at **`10.1.0.10`**; the public origin is **`https://myday.sechpoint.app`** (`base_url`). `trusted_proxies` = `["10.1.0.10"]` (Zoraxy). The app **honours `X-Forwarded-Proto`** from the proxy to set `Secure` / `__Host-` cookies and HSTS. | The app is **not** the TLS endpoint, so it must honour the proxy's scheme header. **Firewall `:8080` to Zoraxy only.** | | **D-043** | 2026-10-15 | **Deployment: behind Zoraxy.** The app listens on **`0.0.0.0:8080` over HTTP**; **TLS terminates at Zoraxy** at **`10.1.0.10`**; the public origin is **`https://myday.sechpoint.app`** (`base_url`). `trusted_proxies` = `["10.1.0.10"]` (Zoraxy). The app **honours `X-Forwarded-Proto`** from the proxy to set `Secure` / `__Host-` cookies and HSTS. | The app is **not** the TLS endpoint, so it must honour the proxy's scheme header. **Firewall `:8080` to Zoraxy only.** |
| **D-044** | 2026-10-15 | **First-run access = a printed SETUP CODE + a boilerplate page.** On first run the app generates a **128-bit setup code** and prints it in a **startup banner** (overridable via `setup.code` in config). `GET /setup` shows a **first-run boilerplate** with a **code-entry form** — *never a bare 404*, so the domain does not look "down"; the **correct code unlocks the wizard**. The code is **single-use**, **rate-limited**, and **redacted from the logs** (A8). | The topology (**LXC/Proxmox + Zoraxy**) makes an SSH tunnel impractical — so the gate is a **code**, not a peer. Grafana/Jupyter-style: it still closes first-visitor-wins. |
--- ---
@ -490,7 +499,7 @@ the top admin (first user)
| O-5 | `reset_admin` semantics | **RESOLVED** (D-033): rotate the identity · **config approval flag** · email the enrolment link to user 1's address on record · flag cleared. | | O-5 | `reset_admin` semantics | **RESOLVED** (D-033): rotate the identity · **config approval flag** · email the enrolment link to user 1's address on record · flag cleared. |
| O-6 | GUID placement for actions | **RESOLVED** (D-032): the GUID **rides the path**; `do` / `action` carry the module/function, never identity. | | O-6 | GUID placement for actions | **RESOLVED** (D-032): the GUID **rides the path**; `do` / `action` carry the module/function, never identity. |
| O-7 | One top admin or several | **RESOLVED** — **exactly one** ("the Highlander"). The root of the chain; recovered via D-033. | | O-7 | One top admin or several | **RESOLVED** — **exactly one** ("the Highlander"). The root of the chain; recovered via D-033. |
| O-8 | Bootstrap gate | **RESOLVED** (D-025): **loopback-only, enforced** · SSH-tunnel first step · optional `bootstrap_guid`. | | O-8 | Bootstrap gate | **RESOLVED** (D-025, re-revised 2026-10-15b): the **tunnel OR the setup code** (D-044), enforced **per request**; the boilerplate replaces the bare 404. |
| O-9 | "KISS" = the **principle** | **RESOLVED** (D-034): a **simple tiered model, no ACL** — `admin > director > manager > team`; a role **implies all lower roles**; roles assigned via **multi-select tick boxes**. | | O-9 | "KISS" = the **principle** | **RESOLVED** (D-034): a **simple tiered model, no ACL** — `admin > director > manager > team`; a role **implies all lower roles**; roles assigned via **multi-select tick boxes**. |
| O-10 | Where settings live | **RESOLVED** (D-035): **the config file** — settings are simpler to change there than via a DB migration. The **DB holds operational data only**. | | O-10 | Where settings live | **RESOLVED** (D-035): **the config file** — settings are simpler to change there than via a DB migration. The **DB holds operational data only**. |
| O-11 | The `requests` engine (data-model) | **RESOLVED / build-time** — the dispatch is settled (D-032 · D-039). Whether `requests` is a generic engine or `absence`/`time` own their flows is an **internal data-model choice inside the module**, decided at build (M3). **Not a plan-blocker.** | | O-11 | The `requests` engine (data-model) | **RESOLVED / build-time** — the dispatch is settled (D-032 · D-039). Whether `requests` is a generic engine or `absence`/`time` own their flows is an **internal data-model choice inside the module**, decided at build (M3). **Not a plan-blocker.** |
@ -523,7 +532,7 @@ the top admin (first user)
| A10 | GUID **tombstoned, never recycled** | HIGH | designed (D-008) | | A10 | GUID **tombstoned, never recycled** | HIGH | designed (D-008) |
| A11 | Every auth event audited (bootstrap, enroll, login, recovery request/approve/deny, revoke, `reset_admin`) | HIGH | open (L4c: `Sessions.GC` · `UpdateWebAuthnSignCount` unaudited) | | A11 | Every auth event audited (bootstrap, enroll, login, recovery request/approve/deny, revoke, `reset_admin`) | HIGH | open (L4c: `Sessions.GC` · `UpdateWebAuthnSignCount` unaudited) |
| A12 | Changing the **email on record** is a privileged, audited operation | HIGH | open | | A12 | Changing the **email on record** is a privileged, audited operation | HIGH | open |
| A13 | **Setup window hardened** — `/setup` served **only** in setup mode; **tunnel-only, enforced per request** (a loopback TCP peer **with no `X-Forwarded-For`**; D-025 revised); the `setup` module **pinned at the door (M1c/L3s)**; rate-limited; `/setup` → 404 once productive | HIGH | **enforced** | | A13 | **Setup window hardened** — `/setup` serves the **wizard** only in setup mode and only to **either** the **direct tunnel** (a loopback TCP peer **with no `X-Forwarded-For`**) **or** a request **unlocked with the printed setup code** (D-025 re-revised · D-044); `GET /` and `/setup` show the **first-run boilerplate** (never a bare 404), every other route 404; the `setup` module **pinned at the door (M1c/L3s)**; the unlock **rate-limited** with the `/setup` class (A3) and the code **redacted** (A8); `/setup` → 404 once productive | HIGH | **enforced** |
| A14 | **Top admin protected** — cannot be deleted; `reset_admin` rotations audited; startup warning while setup mode is reachable | HIGH | open | | A14 | **Top admin protected** — cannot be deleted; `reset_admin` rotations audited; startup warning while setup mode is reachable | HIGH | open |
| A15 | **Pre-session CSRF** — the anonymous forms (`/setup` · `/access` · enroll/login) need a signed **double-submit** cookie (SPEC-005/006/007) | HIGH | **enforced** — signing keys generated at startup in setup mode (M1s) | | A15 | **Pre-session CSRF** — the anonymous forms (`/setup` · `/access` · enroll/login) need a signed **double-submit** cookie (SPEC-005/006/007) | HIGH | **enforced** — signing keys generated at startup in setup mode (M1s) |
@ -550,7 +559,7 @@ the top admin (first user)
|---|---| |---|---|
| **git repo + `.gitignore`** | `git init`; the real `config.yaml` is excluded **from the very first commit** (D-010); `config.example.yaml` is committed. | | **git repo + `.gitignore`** | `git init`; the real `config.yaml` is excluded **from the very first commit** (D-010); `config.example.yaml` is committed. |
| **`AGENT.md`** | the repo's **operating instructions for AI agents**: what MyDay is, the doctrine (§3–§4), the **rule of the door**, the stack, and a pointer to **this plan as the source of truth**. | | **`AGENT.md`** | the repo's **operating instructions for AI agents**: what MyDay is, the doctrine (§3–§4), the **rule of the door**, the stack, and a pointer to **this plan as the source of truth**. |
| **`README.md`** | human quickstart — build, run, config, **and the first-run rule: do NOT expose MyDay publicly before the first admin exists; bootstrap over an SSH tunnel to `127.0.0.1:8080`.** | | **`README.md`** | human quickstart — build, run, config, **and the first-run rule: do NOT expose MyDay publicly before the first admin exists; unlock the wizard with the printed setup code (or an SSH tunnel to `127.0.0.1:8080`).** |
| **`docs/`** | this plan at the root; specs later in `docs/specs/`. | | **`docs/`** | this plan at the root; specs later in `docs/specs/`. |
| **CI (later)** | build + test on push. | | **CI (later)** | build + test on push. |
@ -646,3 +655,4 @@ shell/layout lives in `core/web`; a module's views live with the module.
| 2026-10-15 | **D-043 follow-ups (rulings).** **B1 → accepted (add):** a **startup warning** when `base_url` is `https://` but `trusted_proxies` is empty/wrong — otherwise `X-Forwarded-Proto` is ignored and the session cookie is silently **not** `Secure`. **B2 accepted** — `X-Forwarded-Proto`'s leftmost token is correct for a single Zoraxy; a multi-proxy chain would need the same right→left walk as XFF. **B3 accepted** — the tunnel gate trusts any **local** connection (inherent to "local = trusted"; D-025). Deviations **D1–D5 accepted**, incl. D4 (no redirect code was needed — redirects are relative and absolute URLs come from `base_url`). **Deployment requirement:** Zoraxy **must set `X-Forwarded-For`** — the setup gate's "no XFF" test depends on it. | | 2026-10-15 | **D-043 follow-ups (rulings).** **B1 → accepted (add):** a **startup warning** when `base_url` is `https://` but `trusted_proxies` is empty/wrong — otherwise `X-Forwarded-Proto` is ignored and the session cookie is silently **not** `Secure`. **B2 accepted** — `X-Forwarded-Proto`'s leftmost token is correct for a single Zoraxy; a multi-proxy chain would need the same right→left walk as XFF. **B3 accepted** — the tunnel gate trusts any **local** connection (inherent to "local = trusted"; D-025). Deviations **D1–D5 accepted**, incl. D4 (no redirect code was needed — redirects are relative and absolute URLs come from `base_url`). **Deployment requirement:** Zoraxy **must set `X-Forwarded-For`** — the setup gate's "no XFF" test depends on it. |
| 2026-10-15 | **D-025 (revised) + D-043 ✅ IMPLEMENTED.** **Bind refusal removed** — `server.CheckSetupBind` / `ErrNonLoopbackSetup` / `server.IsLoopback` deleted; `Serve` binds any address (the deployment binds `0.0.0.0:8080`). **Setup gate is per request and strict** — `httpx.SetupRequestAllowed` requires a **loopback TCP peer AND no `X-Forwarded-For`**, evaluated on the RAW request (captured before `ClientIP` SETS the header); anything else → **404 before dispatch**; an unparseable peer fails closed (AC-7/AC-10). **Scheme is per request (D-043)** — `httpx.RequestIsHTTPS` (direct TLS, or a **trusted** proxy's `X-Forwarded-Proto`), carried on the request context (`httpx.WithSecure`), so the session cookie, the **`__Host-`** prefix, the pre-session/ceremony cookies and **HSTS** all agree; the plain-http tunnel keeps the **bare, non-Secure** cookie — which also fixes the latent `config.example.yaml` bootstrap (an `https://` `base_url` no longer force-marks the cookie Secure over the tunnel). `config.IsSecure` **removed** (dead). `config.example.yaml` documents the Zoraxy topology + the **firewall-`:8080`-to-Zoraxy-only** rule; the README gains a **Deployment** section and the tunnel-only bootstrap. Syncs **SPEC-003 §4/§6/§7/§9** and **SPEC-005 §3/§5/§6**. Gate green (`build` · `vet` · `test -race` — 18 pkgs) · **static CGO-free** binary · `routing` purity intact. Live smoke (real binary): `/setup` via the tunnel → **200**, `+ X-Forwarded-For` → **404**, `/home` → **404**, `X-Forwarded-Proto: https` → **HSTS + Secure** cookie, plain http → **neither**. | | 2026-10-15 | **D-025 (revised) + D-043 ✅ IMPLEMENTED.** **Bind refusal removed** — `server.CheckSetupBind` / `ErrNonLoopbackSetup` / `server.IsLoopback` deleted; `Serve` binds any address (the deployment binds `0.0.0.0:8080`). **Setup gate is per request and strict** — `httpx.SetupRequestAllowed` requires a **loopback TCP peer AND no `X-Forwarded-For`**, evaluated on the RAW request (captured before `ClientIP` SETS the header); anything else → **404 before dispatch**; an unparseable peer fails closed (AC-7/AC-10). **Scheme is per request (D-043)** — `httpx.RequestIsHTTPS` (direct TLS, or a **trusted** proxy's `X-Forwarded-Proto`), carried on the request context (`httpx.WithSecure`), so the session cookie, the **`__Host-`** prefix, the pre-session/ceremony cookies and **HSTS** all agree; the plain-http tunnel keeps the **bare, non-Secure** cookie — which also fixes the latent `config.example.yaml` bootstrap (an `https://` `base_url` no longer force-marks the cookie Secure over the tunnel). `config.IsSecure` **removed** (dead). `config.example.yaml` documents the Zoraxy topology + the **firewall-`:8080`-to-Zoraxy-only** rule; the README gains a **Deployment** section and the tunnel-only bootstrap. Syncs **SPEC-003 §4/§6/§7/§9** and **SPEC-005 §3/§5/§6**. Gate green (`build` · `vet` · `test -race` — 18 pkgs) · **static CGO-free** binary · `routing` purity intact. Live smoke (real binary): `/setup` via the tunnel → **200**, `+ X-Forwarded-For` → **404**, `/home` → **404**, `X-Forwarded-Proto: https` → **HSTS + Secure** cookie, plain http → **neither**. |
| 2026-10-15 | **Deployment docs for D-043 (Zoraxy).** Added **`docs/deploy-zoraxy.md`** (topology · the app `config.yaml` · the Zoraxy **ProxyHost** with `X-Forwarded-For` + `X-Forwarded-Proto` · the **firewall `:8080` from `10.1.0.10` only** · the **tunnel-only bootstrap** + the restart that adopts the persisted secrets · the **systemd** install · the **misconfig warnings**) and **`deploy/myday.service`** (a `Type=simple` · `Restart=on-failure` unit with `User=`/`WorkingDirectory=`/`ExecStart=`/`After=network-online.target`). `config.example.yaml` now sets **`trusted_proxies: ["10.1.0.10"]`** (Zoraxy) with the silent-failure + firewall caveats; the README Deployment section points at the guide. **Docs/deploy only — no code change**; gate green. | | 2026-10-15 | **Deployment docs for D-043 (Zoraxy).** Added **`docs/deploy-zoraxy.md`** (topology · the app `config.yaml` · the Zoraxy **ProxyHost** with `X-Forwarded-For` + `X-Forwarded-Proto` · the **firewall `:8080` from `10.1.0.10` only** · the **tunnel-only bootstrap** + the restart that adopts the persisted secrets · the **systemd** install · the **misconfig warnings**) and **`deploy/myday.service`** (a `Type=simple` · `Restart=on-failure` unit with `User=`/`WorkingDirectory=`/`ExecStart=`/`After=network-online.target`). `config.example.yaml` now sets **`trusted_proxies: ["10.1.0.10"]`** (Zoraxy) with the silent-failure + firewall caveats; the README Deployment section points at the guide. **Docs/deploy only — no code change**; gate green. |
| 2026-10-15 | **D-044 ✅ IMPLEMENTED — the setup code + the first-run boilerplate (D-025 re-revised).** The real topology is **LXC (11020) behind Proxmox + Zoraxy**, so the SSH tunnel is impractical (it lands on the container *gateway*, not loopback). `core/setup` gains a **`Gate`** — a **128-bit setup code** (generated at first run, printed in an unmistakable **startup banner**; `setup_code` overrides it) plus a **signed unlock cookie** (`myday_setup`, carrying a keyed one-way **fingerprint**, never the code). The door (`Deps.SetupGate`) now serves the **wizard** to **either** the tunnel **(a)** or an **unlocked** request **(b)**; `GET /` and an un-unlocked `GET /setup` render a friendly **first-run page** with the code form — **never a bare 404**, so the domain stops looking "down" — and every other route stays 404. The unlock is **single-use**, **rate-limited** (the `/setup` A3 class), **CSRF-protected (A15)** and **redacted** (A8); a wrong/absent code gets a **generic 403**. New: `httpx.Signer.Fingerprint` · `config.setup_code` · `setup.Gate` (`gate.go`) · the `unlock` action · `server.SetupGate`/`serveBoilerplate`. Docs: `deploy-zoraxy.md` §5 rewritten for the LXC/Proxmox + code flow (tunnel kept as gate a), README first-run/deploy sections, SPEC-003 §7/§9 (AC-7 · AC-10 · AC-14) and SPEC-005 §3/§4/§6/§9. Gate green (`build` · `vet` · `test -race`) · **static CGO-free** binary · `routing` purity intact. **Live smoke** (real binary, proxy-style `X-Forwarded-For`): the banner prints the code · `/` and `/setup` → **200 boilerplate** · `/home` → **404** · the tunnel → the wizard · wrong code → **403 generic** · correct code → **303** + unlock cookie → the wizard · a spent code → **403** · the code absent from the access log and redacted when hand-typed into the query. |

View file

@ -38,30 +38,43 @@ go build -o myday ./cmd/myday
--- ---
## ⚠️ First run — do NOT expose it publicly ## ⚠️ First run — the setup code
The **first user becomes the top admin** (the root of the chain). So the instance bootstraps The **first user becomes the top admin** (the root of the chain). MyDay bootstraps through a
through a **`/setup` wizard** that is reachable **only over a direct SSH tunnel**, until that admin **`/setup` wizard** that is **gated — never first-visitor-wins** (D-025/D-044). On first start it
exists. prints a **one-time 128-bit SETUP CODE** in its startup log:
```bash ```
# from your workstation — tunnel to the app's port on the server ======================================================================
ssh -L 8080:127.0.0.1:8080 user@server MyDay is NOT set up yet — no admin exists.
# then open http://127.0.0.1:8080/setup and complete the wizard ======================================================================
Open the setup wizard at:
https://myday.sechpoint.app/setup
SETUP CODE: 9f3a-1b2c-…
``` ```
> **The bootstrap is tunnel-only, enforced per request** (D-025 revised). While in setup mode the app Open the domain (or `http://localhost:8080`) and enter that code at `/setup`. **Until then every
> answers **404** to any request that is not the direct tunnel — i.e. a request whose **TCP peer is page is a friendly first-run page — never a bare 404** — and every other route answers 404. Read
> not loopback**, **or which carries an `X-Forwarded-For`** header. So a reverse proxy (even one on the code again with:
> the same host) **cannot** reach a fresh instance: a same-host proxy presents a loopback peer but
> always adds `X-Forwarded-For`, which gives it away. Bootstrap over the SSH tunnel (above) **first**
> — then expose the instance behind the proxy.
> **Reach the app over `localhost` / `127.0.0.1` — not a hostname.** Only a **loopback** origin ```bash
> counts as a trustworthy/secure context for the browser's **WebAuthn** dialogs. So for first run and journalctl -u myday # inside the container
> any human testing, open the app at **`http://localhost:8080`** or **`http://127.0.0.1:8080`** (via pct exec 11020 -- journalctl -u myday # from the Proxmox host
> the tunnel above) — never at a bare server hostname. Over plain http the cookies are sent without ```
> `Secure` (needed by the bootstrap); behind the proxy (https) they are hardened — see *Deployment*.
> **Why a code, not a tunnel?** MyDay's reference deployment is an **LXC container behind
> Proxmox + Zoraxy**. An SSH tunnel is impractical there: `pct enter`/SSH lands on the container
> **gateway**, not loopback, so a tunnel to `127.0.0.1:8080` cannot be formed. The setup code
> closes first-visitor-wins instead. A **direct SSH tunnel** to the app's loopback port still works
> as a second gate and needs **no code** (D-025 gate a).
> **Note:** the code is **single-use** and the entry form is **rate-limited**. Complete the wizard,
> then **restart** so the app adopts the `config.yaml` the wizard wrote.
> **Bonus:** to use the tunnel gate, reach the app over **`localhost` / `127.0.0.1`** — only a
> loopback origin counts as a secure context for the browser's **WebAuthn** dialogs. Over plain http
> the cookies are sent without `Secure` (required by the tunnel bootstrap); behind the proxy (https)
> they are hardened — see *Deployment*.
Only **after** the top admin exists should you put MyDay behind a reverse proxy and expose it. Only **after** the top admin exists should you put MyDay behind a reverse proxy and expose it.
@ -70,8 +83,8 @@ Only **after** the top admin exists should you put MyDay behind a reverse proxy
## Deployment — behind Zoraxy (TLS at the proxy) ## Deployment — behind Zoraxy (TLS at the proxy)
> **Full walkthrough:** [`docs/deploy-zoraxy.md`](docs/deploy-zoraxy.md) — topology · config · > **Full walkthrough:** [`docs/deploy-zoraxy.md`](docs/deploy-zoraxy.md) — topology · config ·
> the Zoraxy ProxyHost · the firewall rule · the tunnel-only bootstrap · systemd · the misconfig > the Zoraxy ProxyHost · the firewall rule · the first-run setup code (no tunnel needed) · systemd
> warnings. This section is the summary. > · the misconfig warnings. This section is the summary.
The reference deployment terminates **TLS at [Zoraxy](https://github.com/tobychui/zoraxy)** at The reference deployment terminates **TLS at [Zoraxy](https://github.com/tobychui/zoraxy)** at
**`10.1.0.10`**; the app itself speaks **plain HTTP**. Config (`config.example.yaml` → **`10.1.0.10`**; the app itself speaks **plain HTTP**. Config (`config.example.yaml` →
@ -90,9 +103,10 @@ The reference deployment terminates **TLS at [Zoraxy](https://github.com/tobychu
the port bypasses the proxy **and its TLS**; the app would then serve plain HTTP to that client. the port bypasses the proxy **and its TLS**; the app would then serve plain HTTP to that client.
```bash ```bash
# the tunnel-only bootstrap — the one path a proxy request cannot take # read the one-time setup code, then open the domain and enter it at /setup
ssh -L 8080:127.0.0.1:8080 user@server journalctl -u myday # inside the container
# open http://127.0.0.1:8080/setup (before the first admin exists) pct exec 11020 -- journalctl -u myday # from the Proxmox host
# (or, for the no-code tunnel gate: ssh -L 8080:127.0.0.1:8080 user@server)
``` ```
--- ---

View file

@ -1,10 +1,11 @@
# Deploying MyDay behind Zoraxy # Deploying MyDay behind Zoraxy
> **Status:** the reference deployment for **D-043**. The app is never the TLS > **Status:** the reference deployment for **D-043** / **D-044**. The app is never the TLS
> endpoint; **[Zoraxy](https://github.com/tobychui/zoraxy) terminates TLS** and > endpoint; **[Zoraxy](https://github.com/tobychui/zoraxy) terminates TLS** and
> relays plain HTTP to the app. This document is operator-facing — it changes no > relays plain HTTP to the app. This document is operator-facing — it changes no
> code. The decision of record is `PROJECT_PLAN.md` **D-043** / **D-025 (revised)**; > code. The decision of record is `PROJECT_PLAN.md` **D-043** / **D-025 (re-revised 2026-10-15b)**;
> the gate's behaviour is `docs/specs/spec-003-core-door.md` **§7**. > the gate's behaviour is `docs/specs/spec-003-core-door.md` **§7** and
> `docs/specs/spec-005-setup-wizard.md` **§3/§4**.
--- ---
@ -13,8 +14,9 @@
``` ```
browser ──https──▶ Zoraxy 10.1.0.10 ──http──▶ app 0.0.0.0:8080 browser ──https──▶ Zoraxy 10.1.0.10 ──http──▶ app 0.0.0.0:8080
(TLS public) terminates TLS (plain HTTP, trusted proxy) (TLS public) terminates TLS (plain HTTP, trusted proxy)
sets X-Forwarded-For sets X-Forwarded-For │
+ X-Forwarded-Proto + X-Forwarded-Proto │ runs inside LXC
▼ container 11020 (Proxmox)
``` ```
- **Zoraxy** — `10.1.0.10`, holds the certificate for `myday.sechpoint.app`, - **Zoraxy** — `10.1.0.10`, holds the certificate for `myday.sechpoint.app`,
@ -23,6 +25,9 @@ browser ──https──▶ Zoraxy 10.1.0.10 ──http──▶ app 0.0.0.0
app is not the TLS endpoint it must **honour the proxy's `X-Forwarded-Proto`** to app is not the TLS endpoint it must **honour the proxy's `X-Forwarded-Proto`** to
know a request arrived over `https` (that is what turns on the `Secure` + know a request arrived over `https` (that is what turns on the `Secure` +
`__Host-` session cookie and HSTS). `__Host-` session cookie and HSTS).
- **Container** — MyDay runs in an **LXC container (id `11020`)** under Proxmox. This is why
the first-run gate is a **setup code** and not just an SSH tunnel: SSH/`pct enter` lands on the
container **gateway**, not loopback, so a tunnel to `127.0.0.1:8080` cannot be formed (§5).
- **Firewall** — `:8080` is reachable **only from `10.1.0.10`** (see §4). The port - **Firewall** — `:8080` is reachable **only from `10.1.0.10`** (see §4). The port
is the app's whole attack surface: anything that can reach it bypasses both is the app's whole attack surface: anything that can reach it bypasses both
Zoraxy **and its TLS**. Zoraxy **and its TLS**.
@ -74,8 +79,9 @@ The app deliberately **requires** both:
- `X-Forwarded-Proto: https` is what makes the session cookie `Secure` - `X-Forwarded-Proto: https` is what makes the session cookie `Secure`
(`__Host-myday_session`) and adds HSTS (AC-13). (`__Host-myday_session`) and adds HSTS (AC-13).
- The **absence of `X-Forwarded-For`** is the proof the setup gate uses that a - `X-Forwarded-For` is what keeps the setup gate's tunnel test (gate a, §5) honest:
request is the direct SSH tunnel, not a proxy relay (§6, D-025 revised). a request with **no** `X-Forwarded-For` from a loopback peer is the direct tunnel,
not a proxy relay.
--- ---
@ -107,28 +113,56 @@ table inet myday_guard {
--- ---
## 5. Tunnel-only bootstrap (before the first admin exists) ## 5. First-run bootstrap — the setup code (no tunnel needed)
The **first user becomes the top admin**, so the initial `/setup` wizard is served The **first user becomes the top admin**, so the initial `/setup` wizard is gated **per request**
**only over a direct SSH tunnel** — never through the proxy. This is enforced — **never first-visitor-wins** (D-025 · D-044). There are **two** ways in:
**per request** (D-025 revised / SPEC-003 §7): in setup mode the app serves a
request **only** if its **TCP peer is loopback *and* it carries no
`X-Forwarded-For`** — i.e. the tunnel. Everything else is **404**.
```bash | Gate | Who | Needs a code? |
# from your workstation — tunnel to the app's loopback port on the server |---|---|---|
ssh -L 8080:127.0.0.1:8080 <user>@<app-host> | **(a) the direct SSH tunnel** — a loopback TCP peer **with no `X-Forwarded-For`** | a workstation that can tunnel to the app's loopback port | no |
# then, in a browser on your workstation: | **(b) the printed SETUP CODE** | anyone who can read the server log, over the public domain | yes |
# http://127.0.0.1:8080/setup
**The setup code is the general gate for this topology.** MyDay generates a **128-bit** code at
first start and prints it in an unmistakable **startup banner**:
```
======================================================================
MyDay is NOT set up yet — no admin exists.
======================================================================
Open the setup wizard at:
https://myday.sechpoint.app/setup
SETUP CODE: 9f3a-1b2c-4d5e-6f70-8192-a3b4-c5d6-e7f8
Read the code again:
journalctl -u myday (in the container)
pct exec 11020 -- journalctl -u myday (from the Proxmox host)
``` ```
- Open the app over **`localhost` / `127.0.0.1`** — a loopback origin is the only **Read the code** (in the LXC container, or from Proxmox):
one the browser treats as a secure context for **WebAuthn** dialogs.
- The wizard creates the top admin (**Passkey + TOTP**; the TOTP secret is sealed ```bash
with the generated `totp_encryption_key`) and writes `config.yaml` # inside the container
(`setup: false`, the base URL, mail/locale, and the secrets). journalctl -u myday | grep -i 'setup code'
- A proxy **cannot** reach a fresh instance: even a same-host proxy presents a
loopback peer but always adds `X-Forwarded-For`, which gives it away → 404. # from the Proxmox host (container 11020)
pct exec 11020 -- journalctl -u myday | grep -i 'setup code'
```
Then **open `https://myday.sechpoint.app/setup`** and enter the code. Until the code is entered:
- `GET /` and `GET /setup` show a **friendly first-run page** (with the code form) — **never a bare
404**, so the domain does not look "down";
- **every other route** answers **404**;
- the code is **single-use** and the entry form is **rate-limited** (wrong code → a generic 403).
> **No tunnel required.** The old guidance ("bootstrap over an SSH tunnel first") no longer
> applies: in LXC a tunnel to `127.0.0.1:8080` cannot be formed (it lands on the container
> gateway), which is exactly why D-044 replaced it with the code. The tunnel gate (a) still works
> and needs no code, if you have a route to the app's loopback port.
The wizard creates the top admin (**TOTP**; the passkey is added at first sign-in on the real
origin — D-041) and writes `config.yaml` (`setup: false`, the base URL, mail/locale, and the
secrets).
### The restart — adopting the persisted secrets ### The restart — adopting the persisted secrets
@ -141,10 +175,13 @@ sudo systemctl restart myday
``` ```
The restart **adopts the written `config.yaml`** — the generated keys are now on The restart **adopts the written `config.yaml`** — the generated keys are now on
disk, so the admin's TOTP secret (sealed with that key) keeps working. **Boot the disk, so the admin's TOTP secret (sealed with that key) keeps working, and the
productive instance from the written `config.yaml`, never from **setup code is gone** (`GET /setup` → 404). **Boot the productive instance from the written
`config.example.yaml`** (its placeholders fail closed in productive mode by `config.yaml`, never from `config.example.yaml`** (its placeholders fail closed in productive mode
design). After the restart, `/setup` is **404** and the app serves normally. by design).
> **Pinning your own code.** Set `setup_code:` in `config.yaml` to override the generated one
> (then protect the file). Leave it blank to have MyDay generate and print a fresh code each boot.
--- ---
@ -186,13 +223,17 @@ curl -sI https://myday.sechpoint.app/ | grep -i '^strict-transport-security'
|---|---|---| |---|---|---|
| Session cookie has **no `Secure`**, **no `__Host-`** prefix, and **no HSTS** | `trusted_proxies` is **empty or wrong**, so `X-Forwarded-Proto` is ignored (fails *silently*) | Set `server.trusted_proxies: ["10.1.0.10"]` and restart | | Session cookie has **no `Secure`**, **no `__Host-`** prefix, and **no HSTS** | `trusted_proxies` is **empty or wrong**, so `X-Forwarded-Proto` is ignored (fails *silently*) | Set `server.trusted_proxies: ["10.1.0.10"]` and restart |
| Client IP in the audit log is the **proxy**, not the user | same — `X-Forwarded-For` is not trusted | as above | | Client IP in the audit log is the **proxy**, not the user | same — `X-Forwarded-For` is not trusted | as above |
| `/` and `/home` return **404** while bootstrapping | **by design** — in setup mode only `/setup` (and static assets) is served | complete the wizard; the app serves normally once productive | | `GET /` and `GET /setup` show a **first-run page** with a code box | **by design** — MyDay is not set up yet (D-044) | read the setup code (§5) and enter it; complete the wizard |
| `/setup` returns **404** even via the proxy | **by design** — the gate is tunnel-only | bootstrap over the SSH tunnel (§5), *then* expose it | | Any **other** route returns **404** while bootstrapping | **by design** — only `/` and `/setup` are served in setup mode (SPEC-005 §3) | complete the wizard; the app serves normally once productive |
| The setup code is **rejected** (403) | it is **single-use** (already spent) or mistyped | restart MyDay to print a fresh code, or pin `setup_code:` in `config.yaml` (§5) |
| `/setup` returns **404** after the wizard | **by design** — setup is complete | re-enter only via `setup: true` + restart |
> **The load-bearing deployment requirement:** Zoraxy **must set > **The load-bearing deployment requirement:** Zoraxy **must set
> `X-Forwarded-For`**. The setup gate's "no XFF ⇒ it's the tunnel" test depends on > `X-Forwarded-For`**. It is what distinguishes a proxy relay from the direct
> it, and a missing header would re-open first-visitor-wins. `X-Forwarded-Proto` > tunnel (gate a): a proxied request with **no** `X-Forwarded-For` would look like
> is what keeps the cookie `Secure` over the proxy. > the tunnel and open the wizard without a code. (The **setup code** still closes
> first-visitor-wins for anything the proxy *does* mark.)
> `X-Forwarded-Proto` is what keeps the cookie `Secure` over the proxy.
--- ---
@ -200,7 +241,8 @@ curl -sI https://myday.sechpoint.app/ | grep -i '^strict-transport-security'
| | | | | |
|---|---| |---|---|
| [`PROJECT_PLAN.md`](../PROJECT_PLAN.md) | D-043 · D-025 (revised) | | [`PROJECT_PLAN.md`](../PROJECT_PLAN.md) | D-043 · D-025 (re-revised) · D-044 |
| [`docs/specs/spec-003-core-door.md`](specs/spec-003-core-door.md) | §7 the loopback/setup gate · §9 AC-7 / AC-10 / AC-13 | | [`docs/specs/spec-003-core-door.md`](specs/spec-003-core-door.md) | §7 the setup gate · §9 AC-7 / AC-10 / AC-13 / AC-14 |
| [`docs/specs/spec-005-setup-wizard.md`](specs/spec-005-setup-wizard.md) | §3/§4 the bootstrap + the setup code |
| [`config.example.yaml`](../config.example.yaml) | the commented config template | | [`config.example.yaml`](../config.example.yaml) | the commented config template |
| [`deploy/myday.service`](../deploy/myday.service) | the systemd unit | | [`deploy/myday.service`](../deploy/myday.service) | the systemd unit |

View file

@ -48,7 +48,10 @@ request
3. security headers — X-Content-Type-Options · Referrer-Policy · a strict CSP · HSTS (when https) 3. security headers — X-Content-Type-Options · Referrer-Policy · a strict CSP · HSTS (when https)
· Permissions-Policy · COOP · CORP [L4s] · Permissions-Policy · COOP · CORP [L4s]
4. routing.Dispatch() — the pure model (SPEC-002) 4. routing.Dispatch() — the pure model (SPEC-002)
5. setup-mode gate — in setup mode the **TCP peer must be loopback AND no XFF**, else 404 (A13/D-025) 5. setup-mode gate — in setup mode a request is served ONLY if it is the direct tunnel
(TCP peer loopback AND no XFF) OR unlocked with the setup code (D-044);
`GET /` and an un-unlocked `GET /setup` get the first-run boilerplate,
everything else 404 (§7)
6. switch on Kind: 6. switch on Kind:
Redirect → 302 Redirect → 302
NotFound → 404 NotFound → 404
@ -112,16 +115,15 @@ type RequestContext struct {
--- ---
## 7. Loopback enforcement (D-025, revised 2026-10-15) ## 7. Setup gate (D-025 re-revised 2026-10-15 · D-044)
- **No bind refusal.** The app **may bind any address** — including `0.0.0.0:8080` behind a reverse - **No bind refusal.** The app **may bind any address** — `0.0.0.0:8080` behind **Zoraxy** (D-043).
proxy (**Zoraxy**, D-043). *(A bind check is incompatible with a proxy-fronted deployment.)* - **While in setup mode**, `/setup` is served when **either**:
- **Per request, while in setup mode, the gate is strict:** serve only a request whose **TCP peer is - **(a)** the request is a **direct tunnel** — **TCP peer loopback** **and** **no `X-Forwarded-For`**
loopback** **and** which carries **no `X-Forwarded-For`** — i.e. the **direct SSH tunnel**. (an empty/unparseable peer fails closed). *A same-host proxy presents a loopback peer, so the
Anything else — including a request relayed by a trusted proxy — → **404**, before dispatch. absence of XFF is the proof the request did not traverse one;* **or**
An empty/unparseable peer **fails closed**. - **(b)** the request is **unlocked with the setup code** (D-044) — the boilerplate's code form.
- *Why both tests:* a proxy on the same host presents a **loopback peer**, so the peer check alone - Anything else in setup mode → the **first-run boilerplate** (`/` · `/setup`) or **404**.
is not enough; the **absence of `X-Forwarded-For`** proves the request did not traverse a proxy.
- **Once productive** (`setup: false`), normal service: the trusted proxy's **`X-Forwarded-Proto`** - **Once productive** (`setup: false`), normal service: the trusted proxy's **`X-Forwarded-Proto`**
drives `Secure` on the session cookie, the **`__Host-`** prefix, and HSTS (§6 · D-043). drives `Secure` on the session cookie, the **`__Host-`** prefix, and HSTS (§6 · D-043).
@ -146,13 +148,14 @@ type RequestContext struct {
| AC-4 | A session whose `user_id` ≠ the GUID's user is **rejected** | | AC-4 | A session whose `user_id` ≠ the GUID's user is **rejected** |
| AC-5 | An **empty / missing session id** is rejected on **every** path (A2) | | AC-5 | An **empty / missing session id** is rejected on **every** path (A2) |
| AC-6 | A `do`/`action` request without a valid CSRF token is **rejected** | | AC-6 | A `do`/`action` request without a valid CSRF token is **rejected** |
| AC-7 | In setup mode, a request **relayed by a proxy** (a loopback peer **with** `X-Forwarded-For`) is **404** — the bootstrap is **tunnel-only** (D-025 revised) | | AC-7 | In setup mode, a request **relayed by a proxy** (a loopback peer **with** `X-Forwarded-For`) never reaches the **wizard** — it gets the first-run boilerplate (`/` · `/setup`) or a 404; the bootstrap is never first-visitor-wins (D-025 · D-044) |
| AC-8 | Every response carries a correlation id, echoed in the log; path credentials are redacted | | AC-8 | Every response carries a correlation id, echoed in the log; path credentials are redacted |
| AC-9 | The rate limiter returns **429** on the auth-sensitive routes (A3) | | AC-9 | The rate limiter returns **429** on the auth-sensitive routes (A3) |
| AC-10 | In setup mode a request whose **TCP peer is not loopback**, **or** which carries **any `X-Forwarded-For`**, is **404** (D-025/H2/A13) | | AC-10 | In setup mode a request whose **TCP peer is not loopback**, **or** which carries **any `X-Forwarded-For`**, never reaches the wizard unless it is **unlocked with the setup code** (D-025/H2/A13/D-044) |
| AC-11 | The **`setup` module** is reachable only through `Workflow == "setup"`; any other resolution naming it is 404 (M1c/L3s) | | AC-11 | The **`setup` module** is reachable only through `Workflow == "setup"`; any other resolution naming it is 404 (M1c/L3s) |
| AC-12 | `X-Forwarded-For` is resolved **right→left**; a forged leftmost entry is ignored (H1/A6) | | AC-12 | `X-Forwarded-For` is resolved **right→left**; a forged leftmost entry is ignored (H1/A6) |
| AC-13 | Behind the proxy, `X-Forwarded-Proto: https` ⇒ the session cookie is **`Secure` + `__Host-`**, and HSTS is set (D-043) | | AC-13 | Behind the proxy, `X-Forwarded-Proto: https` ⇒ the session cookie is **`Secure` + `__Host-`**, and HSTS is set (D-043) |
| AC-14 | In setup mode `GET /` and `GET /setup` show the **first-run boilerplate** (never a bare 404); the **correct setup code unlocks the wizard**; a wrong/absent code does not (D-044) |
--- ---
@ -203,3 +206,26 @@ The fixes touching this spec:
`build` · `vet` · `test -race` green; the static CGO-free binary is unchanged; `build` · `vet` · `test -race` green; the static CGO-free binary is unchanged;
`internal/core/routing` purity intact. `internal/core/routing` purity intact.
---
## 12. D-044 — the setup code gate & the first-run boilerplate (2026-10-15b)
The topology (**LXC behind Proxmox + Zoraxy**) makes the SSH tunnel impractical: `pct enter`/SSH
lands on the container **gateway**, so a tunnel to `127.0.0.1:8080` cannot be formed and gate (a)
alone would lock the operator out. **D-025 is re-revised** so `/setup` is served when **either**
**(a)** the request is the direct tunnel or **(b)** it is **unlocked with the setup code** (D-044).
- `dispatch` gains the gate: `unlocked := SetupGate.Unlocked(r)`; `allowed := setupTunnel ||
unlocked`. The **unlock action** (`POST /setup?do=setup&action=unlock`) is the ONE request served
without either — it is what grants the unlock — and it is still pinned to the `setup` module and
CSRF-protected (A15) and rate-limited (A3).
- `GET /` and an un-unlocked `GET /setup` are handed to the **first-run boilerplate** — the door
resolves to `SetupGate.Boilerplate` rather than a 404, so the domain never looks "down".
- The gate is a **narrow interface** on `server.Deps` (implemented by `*setup.Gate`), so the door
keeps rendering no dynamic HTML itself.
- The code is **single-use**, **rate-limited** and **redacted** (A8); the unlock is a signed
cookie carrying a keyed **fingerprint** of the code, never the code (SPEC-005 §9).
AC-7/AC-10 are reworded: "refused" now means "refused the **wizard**" — the boilerplate may still
answer `GET /`/`GET /setup` with 200 (AC-14).

View file

@ -2,7 +2,7 @@
| Field | Value | | Field | Value |
|---|---| |---|---|
| **Status** | **Implemented** (2026-10-13) — see §7; `build`/`vet`/`test`/`-race` green · static binary · live-smoked · **rev 2026-10-14 (D-041): TOTP-only setup** · **rev 2026-10-15 (D-025/D-043): tunnel-only per-request gate** | | **Status** | **Implemented** (2026-10-13) — see §7; `build`/`vet`/`test`/`-race` green · static binary · live-smoked · **rev 2026-10-14 (D-041): TOTP-only setup** · **rev 2026-10-15 (D-025/D-043): per-request tunnel gate** · **rev 2026-10-15b (D-044): the setup code + the first-run boilerplate** — see §9 |
| **Created** | 2026-10-13 | | **Created** | 2026-10-13 |
| **Author** | OpenArchitect | | **Author** | OpenArchitect |
| **Target milestone** | **M0** | | **Target milestone** | **M0** |
@ -25,29 +25,39 @@ Module setup screens (each module owns its config). Multi-admin (there is **one*
`setup mode = (config.setup == true) OR (no user exists)` — **fail-safe**. `setup mode = (config.setup == true) OR (no user exists)` — **fail-safe**.
- In setup mode the app serves **only** `/setup` (+ static assets); **every other route → 404**. - The app **may bind any address** — including `0.0.0.0:8080` behind a reverse proxy (D-043); there is
- **Tunnel-only, enforced per request (D-025, revised 2026-10-15):** the app **may bind any address** **no** bind refusal.
— including `0.0.0.0:8080` behind a reverse proxy (D-043); there is **no** bind refusal. The gate - **Gated — never first-visitor-wins (D-025 · D-044).** `/setup` is served to a request that is
is **per request**: only a request whose **TCP peer is loopback** **and** which carries **no **either** **(a)** a **direct tunnel** — **TCP peer loopback** **and** **no `X-Forwarded-For`** (an
`X-Forwarded-For`** is served (the **direct SSH tunnel**). Anything else — including a request empty/unparseable peer fails closed; a same-host proxy presents a loopback peer, so the *absence of
relayed by a trusted proxy — → **404** before dispatch; an empty/unparseable peer **fails closed** XFF* is what proves no proxy) — **or** **(b)** **unlocked with the setup code** (below).
(SPEC-003 §7 · AC-7/AC-10). *A proxy on the same host presents a loopback peer, so the absence of - **The first-run boilerplate (D-044).** In setup mode:
XFF is what proves the request did not traverse one.* - `GET /` and `GET /setup` show a **friendly first-run page** — *"MyDay is not set up yet"* — with a
**setup-code entry form** and the instruction to read the code from the **server log**.
**Never a bare 404** (the domain must not look "down").
- Every **other** route → **404**.
- The correct code **unlocks the wizard**; the code is **single-use**, **rate-limited**, and
**redacted from the logs** (A8).
- The `setup` **module** is pinned at the door: the wizard's actions are reachable only through the - The `setup` **module** is pinned at the door: the wizard's actions are reachable only through the
reserved `setup` workflow (M1c/L3s · SPEC-003 §4). reserved `setup` workflow (M1c/L3s · SPEC-003 §4).
## 4. The wizard ## 4. The wizard
``` ```
GET /setup → the wizard GET /setup → the first-run boilerplate + the setup-code form
POST /setup?do=setup&action=unlock → the code → unlocks the wizard (a session flag)
GET /setup → the wizard (once unlocked, or via the tunnel)
Step 1 domain / base_url Step 1 domain / base_url
Step 2 the top admin — display_name · email · **TOTP enrollment** (Passkey deferred — D-041) Step 2 the top admin — display_name · email · **TOTP enrollment** (Passkey deferred — D-041)
Step 3 mail (SMTP) · locale · timezone Step 3 mail (SMTP) · locale · timezone
POST /setup?do=setup&action=… → each step (CSRF-protected) POST /setup?do=setup&action=… → each step (CSRF-protected)
Finish → persist settings → config.setup = false Finish → persist settings → config.setup = false
→ user_id = 1 (role admin) · identity minted + activated · session minted → user_id = 1 (role admin) · identity minted + activated · session minted
``` ```
- **The setup code (D-044):** a **128-bit** value generated at first run and printed in a **startup
banner** (`setup.code` in config overrides it). A direct-tunnel request (gate **a**) needs no code.
- **Settings persist to `config.yaml`** (D-035). *Caveat:* if the file is not writable, the wizard - **Settings persist to `config.yaml`** (D-035). *Caveat:* if the file is not writable, the wizard
**prints the values** and instructs the operator to write them, then marks setup complete. **prints the values** and instructs the operator to write them, then marks setup complete.
- **One admin only:** the wizard creates `user_id = 1`; later attempts to create another `admin` - **One admin only:** the wizard creates `user_id = 1`; later attempts to create another `admin`
@ -67,12 +77,13 @@ POST /setup?do=setup&action=… → each step (CSRF-protected)
| # | Criterion | | # | Criterion |
|---|---| |---|---|
| AC-1 | Fresh instance → `/setup`; any other route → 404 | | AC-1 | Fresh instance → `GET /` and `GET /setup` show the **first-run boilerplate** (200 — **never a bare 404**); every **other** route → 404 |
| AC-2 | In setup mode **only the direct tunnel** is served (loopback TCP peer **and** no `X-Forwarded-For`); anything else → 404. Any bind is allowed (D-025 revised) | | AC-2 | In setup mode the **wizard** is served to **either** the direct tunnel (loopback TCP peer **and** no `X-Forwarded-For`) **or** a request **unlocked with the setup code**; anything else gets the boilerplate (`/` · `/setup`) or a 404. Any bind is allowed (D-025 revised) |
| AC-3 | Completing the wizard creates `user_id = 1` (role `admin`) with **TOTP** enrolled and **no passkey** (D-041) | | AC-3 | Completing the wizard creates `user_id = 1` (role `admin`) with **TOTP** enrolled and **no passkey** (D-041) |
| AC-4 | After completion `config.setup = false` (or printed) and `/setup` → 404 | | AC-4 | After completion `config.setup = false` (or printed) and `/setup` → 404 |
| AC-5 | A second `admin` cannot be created | | AC-5 | A second `admin` cannot be created |
| AC-6 | The full bootstrap is audited | | AC-6 | The full bootstrap is audited |
| AC-7 | The setup code is **single-use** and **rate-limited**; a wrong or absent code gets a **generic** answer that reveals nothing, and the code is **redacted from the logs** (A8). The direct tunnel needs no code (D-044) |
--- ---
@ -140,3 +151,45 @@ Built 2026-10-14. `build` · `vet` · `test` (incl. `-race`) green; static CGO-f
The admin adds a passkey at first login on the real origin, via the **passkey nudge** (SPEC-006 §8). The admin adds a passkey at first login on the real origin, via the **passkey nudge** (SPEC-006 §8).
---
## 9. Implementation notes — the setup code & boilerplate (D-044, 2026-10-15b)
Built 2026-10-15. `build` · `vet` · `test -race` green; **static CGO-free** binary; live-smoked
against the real binary (banner + code, boilerplate at `/` and `/setup` behind a proxy, the tunnel
opens the wizard with no code, wrong code → **403**, correct code → **303** + unlock cookie, the
wizard follows, a spent code is refused, and neither the code nor a query-borne copy appears in
the access log).
**What landed**
- `internal/core/setup` — `Gate` (`gate.go`): the **setup code** + the signed unlock cookie, and
the first-run **boilerplate** renderer. `Actions` gains `Unlock` (the `unlock` action), `Code`
and `Gate`.
- `internal/core/server` — `Deps.SetupGate` (a narrow `SetupGate` interface); `dispatch` now
honours gate **(b)** (an unlocked request) alongside gate **(a)** (the tunnel), and hands `GET /`
+ an un-unlocked `GET /setup` to the boilerplate (**never a bare 404**).
- `internal/core/httpx` — `Signer.Fingerprint`: a keyed, **one-way** digest so the unlock cookie can
bind to the code without carrying it.
- `internal/core/config` — `setup_code` (see **D-1** below); `cmd/myday` builds the gate, wires it
into the door, and prints the **startup banner**.
**The gate, precisely**
| | |
|---|---|
| **The code** | 128-bit, generated at startup (`crypto/rand`), printed in the banner, or pinned by `setup_code`. Entry ignores case/separators. |
| **Single-use** | `Accept` retires the code on the **first success**; a failed attempt does not consume it. |
| **The unlock** | A signed cookie (`myday_setup`) carrying a **fingerprint** of the code — not the code — so it survives the code's retirement but dies on a restart (new code) or on completion (`Clear`). It is **not** the session cookie (no user exists). |
| **Generic** | A wrong and an absent code return the same **403** page/message. |
| **Rate-limited** | The `/setup` class of the A3 limiter covers the unlock form. |
| **Redacted** | The code rides a POST **body** (never a URL); a hand-typed `?code=…` is redacted by `RedactQuery` (A8). |
| **Audited** | `setup.unlock` (`unlocked` / `refused`) — no code value. |
| # | Builder choice | Ruling |
|---|---|---|
| **D-1** | The config key is **`setup_code`** (top level), not the literal `setup.code`: `setup:` is already the boolean mode flag (D-026), and YAML cannot be both a scalar and a mapping. | **Accepted** — the smallest faithful encoding of "`setup.code` in config". |
| **D-2** | "Single-use" = the code retires on the first **successful** unlock; the browser keeps its signed unlock cookie (multi-tab works), a **second** visitor cannot re-use the printed value. A restart prints a **fresh** code. | **Accepted** — honours single-use without a lock-out when a tab is closed. |
| **D-3** | `GET /` shows the boilerplate **always** in setup mode (tunnel or not) — `/` is not the wizard. | **Accepted.** |
| **D-4** | With **no** gate wired (a door built by hand) setup mode keeps the strict 404 — the entrypoint always wires it. | **Accepted** — keeps the door honest without a setup package. |