- 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.
14 KiB
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-025 · D-031 · D-039 · 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-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:
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 thecontext.Context, so services can write their audit rows (SPEC-004 §5, ruling R-1). - The
setupmodule is pinned at the door: the wizard's functions are reachable only through the reservedsetupworkflow; 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)
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.
Principaland 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 withSecure·Path=/· noDomain, 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 withoutSecure, 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-025 re-revised 2026-10-15 · D-044)
- No bind refusal. The app may bind any address —
0.0.0.0:8080behind Zoraxy (D-043). - While in setup mode,
/setupis served when either:- (a) the request is a direct tunnel — TCP peer loopback and no
X-Forwarded-For(an empty/unparseable peer fails closed). A same-host proxy presents a loopback peer, so the absence of XFF is the proof the request did not traverse one; or - (b) the request is unlocked with the setup code (D-044) — the boilerplate's code form.
- (a) the request is a direct tunnel — TCP peer loopback and no
- Anything else in setup mode → the first-run boilerplate (
/·/setup) or 404. - Once productive (
setup: false), normal service: the trusted proxy'sX-Forwarded-ProtodrivesSecureon the session cookie, the__Host-prefix, and HSTS (§6 · D-043).
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 | 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-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, 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-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 | 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) |
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)
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).
dispatchgains 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 thesetupmodule and CSRF-protected (A15) and rate-limited (A3).GET /and an un-unlockedGET /setupare handed to the first-run boilerplate — the door resolves toSetupGate.Boilerplaterather 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).