- PROJECT_PLAN.md: rewrite §5.1 for the toggle gate, mark D-025 superseded, refresh D-026 / O-8 / A13 / §12 and add the D-045 change-log entry. - SPEC-005: §1/§3/§5/§6 (AC-1/AC-2/AC-7 + AC-8) reflect the three states and the optional code; §9 marks the D-044 tunnel bits superseded; new §10. - SPEC-003: §4/§7 pipeline + AC-7/AC-10/AC-14; new §13 (D-045); D-025 ref removed from the governing decisions. - docs/deploy-zoraxy.md: §5 rewritten around the toggle + optional code; §1/§2/§3/§7 de-tunnelled; see-also updated. - README: first-run + deployment sections rewritten (enable setup: true, open /setup, complete, the app flips it off). - config.example.yaml: document the toggle and the optional code.
254 lines
15 KiB
Markdown
254 lines
15 KiB
Markdown
# SPEC-003 — the core door (`internal/core/server` + `httpx`)
|
|
|
|
| Field | Value |
|
|
|---|---|
|
|
| **Status** | **Implemented** (2026-10-13) — see §10; `build`/`vet`/`test`/`-race` green · static binary · live-smoked. **Hardened 2026-10-14** — see §11 (REVIEW-001 H1/H2/M1c/M3s/L1s/L4s). |
|
|
| **Created** | 2026-10-13 |
|
|
| **Author** | OpenArchitect |
|
|
| **Target milestone** | **M0** |
|
|
| **Governing decisions** | **D-002** · **D-003** · **D-031** · **D-039** · **D-043** · **D-045** · `PROJECT_PLAN.md` §4/§12 |
|
|
| **Related** | SPEC-002 (routing) · SPEC-004 (identity) |
|
|
|
|
---
|
|
|
|
## 1. Goal
|
|
|
|
The **single HTTP entry** — *the door* — that turns an inbound request into a dispatched module
|
|
function, applying **identity / session / role** and all cross-cutting concerns. The door
|
|
**resolves and gates**; a **module never sees a request it shouldn't** (D-002, D-039).
|
|
|
|
---
|
|
|
|
## 2. Non-goals
|
|
|
|
- The routing model → **SPEC-002**.
|
|
- Identity/session storage, users, roles, the chain → **SPEC-004**.
|
|
- The `/setup`, enroll, login, recovery **flows** → SPEC-005+.
|
|
|
|
---
|
|
|
|
## 3. Structural changes
|
|
|
|
| Path | Purpose |
|
|
|---|---|
|
|
| `cmd/myday/main.go` | assemble config → core → discover modules → serve |
|
|
| `internal/core/server/` | the door pipeline + dispatch |
|
|
| `internal/core/httpx/` | session cookie · client IP (trusted proxies) · `RedactPath` · security headers · CSRF |
|
|
| `internal/core/middleware/` | correlation id · rate limit · audit |
|
|
|
|
---
|
|
|
|
## 4. The door pipeline
|
|
|
|
```
|
|
request
|
|
1. correlation id — generate a UUID; put it on the context and in every log line
|
|
2. client IP — resolve from `server.trusted_proxies`; walk XFF **right→left** and
|
|
SET it to the one resolved value (never append) [A6/H1]
|
|
3. security headers — X-Content-Type-Options · Referrer-Policy · a strict CSP · HSTS (when https)
|
|
· Permissions-Policy · COOP · CORP [L4s]
|
|
4. routing.Dispatch() — the pure model (SPEC-002)
|
|
5. setup gate — the gate is the config toggle `setup` (D-045). While `setup: true`
|
|
`/setup` serves the wizard (the code form first when `setup_code` is
|
|
set, D-044); `GET /` points at it; everything else 404. `setup: false`
|
|
with no admin is the LOCKED state: `/` and `/setup` get the "enable
|
|
setup" boilerplate, everything else 404 (§7)
|
|
6. switch on Kind:
|
|
Redirect → 302
|
|
NotFound → 404
|
|
Setup → the setup wizard, ONLY in setup mode; else 404
|
|
Enroll → the enrollment screen (if already enrolled → redirect)
|
|
Route/Action → [the identity gate]
|
|
a. load the session cookie → validate:
|
|
exists · not expired · not revoked · **non-empty session id** [A2]
|
|
b. session.user_id == the resolved GUID's user (the identity match)
|
|
c. missing/invalid → KindLogin (login screen)
|
|
d. ROLE gate: does the user's role permit this function?
|
|
e. build the RequestContext → dispatch to the module function
|
|
7. audit — every auth event + every mutation (A11)
|
|
8. rate limit — `/{GUID}`, `/access`, `/setup` (A3)
|
|
```
|
|
|
|
- Before dispatching, the door attaches **`audit.Meta{ActorUserID · ClientIP · CorrelationID}`** to
|
|
the `context.Context`, so services can write their audit rows (SPEC-004 §5, ruling **R-1**).
|
|
- The **`setup` module is pinned at the door**: the wizard's functions are reachable **only**
|
|
through the reserved `setup` workflow; any other resolution naming it 404s (M1c/L3s).
|
|
|
|
**The door renders no dynamic HTML** — it resolves, gates, and hands off.
|
|
|
|
---
|
|
|
|
## 5. `RequestContext` (in-process hand-over)
|
|
|
|
```go
|
|
type RequestContext struct {
|
|
Workflow string // PARAM1 classification
|
|
UserID int64 // the resolved user (0 when anonymous)
|
|
Principal string // "user:<id>" — IDENTIFICATION only, never a credential
|
|
Function string // the resolved module function / action
|
|
Page string // the joined remainder
|
|
Correlation string // the request's correlation id
|
|
ClientIP string // resolved client IP
|
|
Location string // IP-derived, AUDIT ONLY (D-031) — no tracking
|
|
}
|
|
```
|
|
|
|
- Carries **identification, never a credential**.
|
|
- `Principal` and the credential (session cookie) are **separate** concerns (D-001).
|
|
|
|
---
|
|
|
|
## 6. Session cookie & CSRF
|
|
|
|
- Cookie `myday_session`: **`HttpOnly` · `Secure` · `SameSite=Lax` · `Path=/`**.
|
|
- Over **https** the cookie carries the **`__Host-`** prefix (`__Host-myday_session`, L1s): a
|
|
browser only accepts it with `Secure` · `Path=/` · no `Domain`, so the cookie is pinned to the
|
|
exact origin and a sibling subdomain cannot shadow it. On plain-http loopback (the bootstrap /
|
|
human test) the bare name is used — a browser **rejects** a `__Host-` cookie sent without
|
|
`Secure`, so the prefix cannot be unconditional.
|
|
- Opaque random id; the server-side row is authoritative (SPEC-004).
|
|
- **CSRF:** a per-session token on every **action** (`do`/`action`) — hidden field, verified
|
|
server-side (A8). The action fields are *not* a security control on their own.
|
|
- The **pre-session** double-submit token (A15) covers every anonymous form (`/setup`, `/access`,
|
|
enroll/login). In setup mode the CSRF + cookie signing keys are **generated at startup** when
|
|
left at a placeholder, so the ceremony is never signed with a shipped, public key (M1s).
|
|
- **Logout** revokes the session row.
|
|
|
|
---
|
|
|
|
## 7. Setup gate (D-045)
|
|
|
|
- **No bind refusal.** The app **may bind any address** — `0.0.0.0:8080` behind **Zoraxy** (D-043).
|
|
- **The gate is the config toggle `setup`** (D-045; the peer/tunnel rule of D-025 is **retired**).
|
|
- **`setup: true`** → `/setup` serves the **wizard**. If `setup_code` is set the **code form**
|
|
comes first (D-044) and the unlock is a signed cookie; otherwise the toggle alone admits.
|
|
`GET /` points at the wizard; **every other route is 404**.
|
|
- **`setup: false`, no admin** → the **LOCKED** state: `GET /` and `GET /setup` show an
|
|
"enable setup" boilerplate; every other route is 404. Not publicly claimable.
|
|
- **Once productive** (`setup: false` with an admin), normal service: the trusted proxy's
|
|
**`X-Forwarded-Proto`** drives `Secure` on the session cookie, the **`__Host-`** prefix, and HSTS
|
|
(§6 · D-043). The XFF handling keeps the **client IP** honest (A6) — it is no longer a gate.
|
|
|
|
---
|
|
|
|
## 8. Logging & redaction
|
|
|
|
- `slog`, structured, with the **correlation id** on every line.
|
|
- The access log records the raw path + `PARAMS`; the **GUID is public** (a username) but any
|
|
**credential that rides the path is redacted** (`RedactPath`) — A8/A11.
|
|
- The door logs the resolved `(workflow, module, function)`; a module logs its own steps.
|
|
|
|
---
|
|
|
|
## 9. Acceptance criteria
|
|
|
|
| # | Criterion |
|
|
|---|---|
|
|
| AC-1 | `/` → 302 `/home`; `/holy-shit` → 404 |
|
|
| AC-2 | `/home` renders with **no session** and **no DB** lookup |
|
|
| AC-3 | `/{GUID}` with no session → login; with a valid session → the module function |
|
|
| 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-6 | A `do`/`action` request without a valid CSRF token is **rejected** |
|
|
| AC-7 | While the wizard is enabled (`setup: true`) with a **required `setup_code`**, `GET /setup` shows the **code form** to every peer; only an **unlocked** request reaches the wizard. The peer/tunnel rule of D-025 is retired (D-045) |
|
|
| 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-10 | While the wizard is enabled with a **required code**, no peer reaches the **wizard** without the unlock; the code form is shown instead, and every non-wizard route is 404 (D-045, superseding D-025/H2) |
|
|
| 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-13 | Behind the proxy, `X-Forwarded-Proto: https` ⇒ the session cookie is **`Secure` + `__Host-`**, and HSTS is set (D-043) |
|
|
| AC-14 | `setup: true` → `GET /setup` serves the wizard (or the code form when `setup_code` is set); `GET /` points at it; the **correct setup code unlocks** it, a wrong/absent one does not (D-044/D-045). `setup: false` with no admin → a locked boilerplate at `/` and `/setup` (never a bare 404) |
|
|
|
|
---
|
|
|
|
## 10. Implementation notes (accepted rulings)
|
|
|
|
Built 2026-10-13. `build` · `vet` · `test` · `-race` green; **static CGO-free** binary; live smoke
|
|
verified (`/`→302 · `/home`→200 · `/setup`→200 · `/holy-shit`→404 · correlation id in header **and**
|
|
log · loopback enforcement · fail-closed secrets). `internal/core/routing` purity intact.
|
|
|
|
| # | Builder choice | Ruling |
|
|
|---|---|---|
|
|
| D-1 | `gopkg.in/yaml.v3` + `KnownFields(true)` (unknown config key = error) | **Accepted** — pure-Go, static-safe, fail-closed. |
|
|
| D-2 | Secret strength: `cookie_secret`/`csrf_key` ≥ 32 bytes; `totp_encryption_key` = 32 bytes; `CHANGE_ME` rejected in productive mode | **Accepted** — to be documented in the config reference. **Amended 2026-10-14**: the dead `session_key` is **removed** (L1c/L2s). |
|
|
| D-3 | CSRF = stateless `HMAC-SHA256(csrf_key, "myday.csrf.v1:"+session_id)` | **Accepted** — no extra table; dies with the session. |
|
|
| D-4 | In-memory fixed-window rate limiter (30/min; classes `access`·`setup`·`guid`) | **Accepted.** |
|
|
| D-5 | `RoleGate` **seam**, defaulting to *allow any authenticated user* | **Accepted** — the policy lands in **SPEC-007/008**. |
|
|
| D-6 | Env: config **path** via `MYDAY_CONFIG` only | **Accepted**; per-key env override deferred. |
|
|
| D-7 | `RequestContext` = **exactly** §5 (no `Module` field) | **Accepted** — module identity stays on `Resolution`. |
|
|
|
|
**Rulings on the flagged items:**
|
|
|
|
| # | Item | Ruling |
|
|
|---|---|---|
|
|
| **R-4** | `auth.Mint/Rotate/Revoke` hard-code the **owning user** as actor. | **Use the context actor** (creator/approver); the affected user is the **subject** — see SPEC-004 §8. |
|
|
| **R-5** | **Pre-session CSRF** — `/setup`, `/access` and the enroll/login actions run before a session, so §6's session-bound CSRF doesn't cover them. | **Must be added** — a signed **double-submit** cookie for anonymous forms. Applies to **SPEC-005/006/007**; tracked as **A15**. |
|
|
| — | `RequestContext.Location` always empty (D-031) | **Accepted** — the seam exists; the IP-geo lookup is deferred. |
|
|
| — | `RedactPath` over-redacts a segment after a credential-like name | **Accepted** — log-only; no leak. |
|
|
|
|
---
|
|
|
|
## 11. M0 review hardening (2026-10-14 · REVIEW-001)
|
|
|
|
The independent M0 review found the identity/session/CSRF core sound but the **perimeter** weak.
|
|
The fixes touching this spec:
|
|
|
|
| Finding | Fix |
|
|
|---|---|
|
|
| **H2** — `dispatch` checked loopback only at *bind* | §7: a **per-request** gate — in setup mode a non-loopback resolved client IP is 404 before dispatch (AC-10). |
|
|
| **H1** — `resolveForwarded` returned the *leftmost* untrusted hop | `ClientIP` now walks **right→left** and returns the first untrusted entry, falling back to the peer (AC-12). An append-style proxy can no longer be forged past. |
|
|
| **M1c** — the `/setup` action door was not pinned to the module | `serveSetupAction` rejects `Module != "setup"` (AC-11). |
|
|
| **L3s** — `/{GUID}?do=setup&…` fell through to `serveGUID` | The door rejects any resolution naming the `setup` module outside `Workflow == "setup"`, in every mode (AC-11). |
|
|
| **M1s** — placeholder signing keys ran the whole setup | `cmd/myday` generates the CSRF/cookie keys at **startup** in setup mode, mirroring the TOTP key (R-6/D-1); `finish` persists the same values. |
|
|
| **M3s** — the rate limiter's state map was never pruned | `middleware.RateLimiter` sweeps elapsed windows (time-gated, amortised) and **caps** the map. |
|
|
| **L1s** — no `__Host-` cookie prefix | §6: session cookie is `__Host-myday_session` over https. |
|
|
| **L4s** — missing hardening headers | `Permissions-Policy` · `Cross-Origin-Opener-Policy` · `Cross-Origin-Resource-Policy` (§4 step 3). |
|
|
| **L1c/L2s** — dead `secrets.session_key` | Removed (D-2 amended). |
|
|
| **L7c** — the passkey nudge trusted the client `Host` | SPEC-006 §8: the configured `base_url` host is the origin signal, falling back to the request `Host`. |
|
|
|
|
`build` · `vet` · `test -race` green; the static CGO-free binary is unchanged;
|
|
`internal/core/routing` purity intact.
|
|
|
|
---
|
|
|
|
## 12. D-044 — the setup code gate & the first-run boilerplate (2026-10-15b)
|
|
|
|
> **Superseded in part by D-045 (§13).** The **code mechanics** below stand; the **tunnel gate (a)**
|
|
> and the **generated** code are retired, and `GET /` is no longer the code form.
|
|
|
|
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).
|
|
|
|
---
|
|
|
|
## 13. D-045 — the toggle gate, the optional code, the retired tunnel (2026-10-15c)
|
|
|
|
The **peer/tunnel gate is retired**: `setup: true` is the gate. `dispatch` evaluates the config
|
|
toggle per request:
|
|
|
|
- **`setup: true`** — `setupAdmitted(r)` is true when no `setup_code` is required, else it needs a
|
|
valid `SetupGate.Unlocked(r)`. `/setup` with a required code un-entered → `SetupGate.CodeForm`;
|
|
`GET /` → `SetupGate.Landing`; the **unlock action** is the one carve-out; every other route → 404
|
|
(static assets are served so the first-run pages render inside the shell).
|
|
- **`setup: false`, no admin** — the **LOCKED** state (`Deps.LockedMode`): `GET /` and `GET /setup`
|
|
→ `SetupGate.Locked`; every other route → 404.
|
|
|
|
Removed: `httpx.SetupRequestAllowed`, `httpx.IsLoopbackIP` (and their tests). Kept: the XFF
|
|
right→left client-IP resolution and the `X-Forwarded-Proto`-driven `Secure`/`__Host-`/HSTS (A6/A11
|
|
· D-043). `SetupGate` is now `{ Required · Unlocked · CodeForm · Landing · Locked }`.
|