From 6d421b61fd72248c532473b176dd969edd32b429 Mon Sep 17 00:00:00 2001 From: Claus Lohmar Date: Thu, 15 Oct 2026 06:33:13 +0000 Subject: [PATCH] feat(setup): gate first run with a printed setup code and a first-run page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The real topology is an LXC container (11020) behind Proxmox + Zoraxy, so the SSH tunnel is impractical: it lands on the container gateway, not loopback. D-025 is re-revised and D-044 adds the general gate. - core/setup: a Gate (gate.go) holding the 128-bit setup code (generated at first run, or pinned by config `setup_code`) plus a signed unlock cookie (`myday_setup`) that carries a keyed one-way FINGERPRINT of the code, never the code itself. The code is single-use (retired on the first success) and the comparison ignores case/separators. - The unlock action (`POST /setup?do=setup&action=unlock`) is CSRF-protected (A15), rate-limited (the /setup A3 class) and audited; a wrong or absent code gets the same generic 403 page. - The door (server.Deps.SetupGate): in setup mode the wizard is served to EITHER the direct tunnel (gate a) OR an unlocked request (gate b); `GET /` and an un-unlocked `GET /setup` render a friendly first-run page with the code form — never a bare 404 — and every other route stays 404. - cmd/myday prints an unmistakable startup banner: setup mode, the wizard URL, the code, where to read it (journalctl / pct exec 11020) and the restart that adopts the persisted secrets. - httpx.Signer.Fingerprint backs the unlock cookie; config gains `setup_code`. Tests cover the boilerplate at `/` and `/setup` (200, not 404), the unlock (code → wizard; wrong/absent → generic, 429 under the limit), single-use, the CSRF requirement, access-log redaction, and that a non-/setup route is still 404. Gate green (build · vet · test -race); live-smoked against the binary. --- cmd/myday/main.go | 59 +++- config.example.yaml | 18 +- internal/core/config/config.go | 6 + internal/core/httpx/presession.go | 12 + internal/core/server/server.go | 101 ++++-- internal/core/server/server_test.go | 92 ++++-- internal/core/server/setup_code_test.go | 364 ++++++++++++++++++++++ internal/core/server/setup_wizard_test.go | 66 +++- internal/core/server/setupflow.go | 6 +- internal/core/setup/gate.go | 221 +++++++++++++ internal/core/setup/render.go | 39 +++ internal/core/setup/setup.go | 71 ++++- 12 files changed, 983 insertions(+), 72 deletions(-) create mode 100644 internal/core/server/setup_code_test.go create mode 100644 internal/core/setup/gate.go diff --git a/cmd/myday/main.go b/cmd/myday/main.go index 011947e..29db5ec 100644 --- a/cmd/myday/main.go +++ b/cmd/myday/main.go @@ -237,6 +237,7 @@ func run() error { setupFlow, err = setup.New(setup.Deps{ Config: cfg, ConfigPath: configTarget(cfg), + Code: cfg.SetupCode, // D-044: operator override; blank → generated Users: us, Identities: ids, Enroll: wizardFlow, @@ -324,6 +325,10 @@ func run() error { CSRF: csrf, PreSession: preSession, SetupMode: setupMode, + // D-044: the first-run gate — the setup code + the signed unlock + // cookie, and the boilerplate `/` and `/setup` serve before the code is + // entered (never a bare 404). + SetupGate: setupFlow.Gate(), // SPEC-007: the tiered role gate (D-034). The floor admits any // authenticated user; role-ABOVE rules live in the services. // SPEC-008: the Modules page is admin-only — toggling modules changes @@ -342,8 +347,8 @@ func run() error { } if setupMode() { - slog.Warn("myday: SETUP MODE — serving /setup over the direct SSH tunnel only; " + - "bootstrap via `ssh -L 8080:127.0.0.1:8080` before exposing this instance") + slog.Warn("myday: SETUP MODE — the instance is not configured yet") + fmt.Fprint(os.Stderr, setupBanner(cfg, setupFlow.Code())) } if err := srv.Serve(cfg.Server.Listen); err != nil { @@ -461,9 +466,57 @@ func configTarget(cfg *config.Config) string { return src } +// setupContainerID is the LXC container MyDay runs in (the owner's topology — +// D-043/D-044). It is only used to make the banner's "read the code" hint +// copy-pasteable from the Proxmox host. +const setupContainerID = "11020" + +// setupBanner is the unmistakable first-run notice (D-044): that the instance is +// in setup mode, the wizard URL, the printed setup CODE, where to read the code +// again, and the restart that adopts the persisted secrets. It is printed to +// stderr, so it lands in the same journal as the structured logs. +func setupBanner(cfg *config.Config, code string) string { + public := strings.TrimRight(strings.TrimSpace(cfg.Server.BaseURL), "/") + setupURL := public + "/setup" + if public == "" { + setupURL = "http://" + strings.TrimSpace(cfg.Server.Listen) + "/setup" + } + line := strings.Repeat("=", 70) + return fmt.Sprintf(` +%s + MyDay is NOT set up yet — no admin exists. +%s + + Open the setup wizard at: + + %s + + and enter the one-time SETUP CODE below. (A direct SSH tunnel to the + app's loopback port also opens the wizard without a code.) + + SETUP CODE: %s + + Read the code again: + journalctl -u myday (in the container) + pct exec %s -- journalctl -u myday (from the Proxmox host) + + Until setup is complete: + - GET / and GET /setup show a friendly first-run page (never a bare 404); + - every other route answers 404; + - the code is single-use and the entry form is rate-limited. + + Finishing the wizard writes config.yaml. RESTART MyDay to adopt the + persisted secrets: + + systemctl restart myday + +%s +`, line, line, setupURL, code, setupContainerID, line) +} + // webauthnConfig derives the Relying Party configuration from server.base_url. // An empty/unparseable base URL falls back to loopback, which keeps setup mode -// (the tunnel-only gate, D-025) working before the domain is configured. +// (the setup gate, D-025/D-044) working before the domain is configured. func webauthnConfig(baseURL string) webauthn.Config { defaultCfg := webauthn.Config{ RPID: "localhost", diff --git a/config.example.yaml b/config.example.yaml index a458a5e..3867e88 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -4,12 +4,22 @@ # Settings live HERE, not in the database (D-035). # ── setup & recovery ───────────────────────────────────────────────────────── -setup: true # D-015 / D-026 — while true the app serves ONLY /setup. - # D-025 (revised): the wizard is reachable ONLY over the - # direct SSH tunnel — a request relayed by a proxy (a - # loopback peer carrying X-Forwarded-For) is 404'd. +setup: true # D-015 / D-026 — while true the app serves ONLY the + # setup wizard. D-025 (2026-10-15b) / D-044: the wizard + # is reachable EITHER over a direct SSH tunnel OR by + # entering the printed setup code — never + # first-visitor-wins. Until it is unlocked, `GET /` and + # `GET /setup` show a friendly first-run page (no bare + # 404) and every other route is 404. admin_recovery: false # D-033 — set true + restart to email user 1 an enrolment link +# D-044 — the first-run SETUP CODE. Leave blank and MyDay generates a 128-bit +# code at startup and prints it in a banner (read it with `journalctl -u myday` +# in the container, or `pct exec 11020 -- journalctl -u myday` from Proxmox). +# Set it here to pin your own value instead (then protect this file). Only +# consulted in setup mode. +setup_code: "" + # ── server ─────────────────────────────────────────────────────────────────── # D-043 (deployment behind Zoraxy): TLS terminates at the PROXY, so the app # speaks plain HTTP and binds 0.0.0.0:8080. FIREWALL :8080 TO ZORAXY ONLY — diff --git a/internal/core/config/config.go b/internal/core/config/config.go index 2a33a96..c7be1a3 100644 --- a/internal/core/config/config.go +++ b/internal/core/config/config.go @@ -53,6 +53,12 @@ type Config struct { // Without it nothing happens (dual control). AdminRecovery bool `yaml:"admin_recovery"` + // SetupCode pins the first-run SETUP CODE (D-044). When blank MyDay + // generates a 128-bit code at first run and prints it in the startup + // banner; setting it here overrides that (the value is then the operator's + // to protect). It is only consulted in setup mode. + SetupCode string `yaml:"setup_code"` + Server Server `yaml:"server"` App App `yaml:"app"` Database Database `yaml:"database"` diff --git a/internal/core/httpx/presession.go b/internal/core/httpx/presession.go index dde3fbf..3116305 100644 --- a/internal/core/httpx/presession.go +++ b/internal/core/httpx/presession.go @@ -5,6 +5,7 @@ import ( "crypto/rand" "crypto/sha256" "encoding/base64" + "encoding/hex" "errors" "fmt" "net/http" @@ -67,6 +68,17 @@ func (s *Signer) mac(payload string) string { return base64.RawURLEncoding.EncodeToString(m.Sum(nil)) } +// Fingerprint returns a keyed, ONE-WAY digest of value as a hex string. Unlike +// Sign it is not reversible: the digest reveals nothing about value, so it is +// safe to hand a client a signed fingerprint of a secret (the D-044 setup code) +// without leaking the secret itself. +func (s *Signer) Fingerprint(value string) string { + m := hmac.New(sha256.New, s.key) + m.Write([]byte("myday.fingerprint.v1:")) + m.Write([]byte(value)) + return hex.EncodeToString(m.Sum(nil)) +} + // PreSessionCSRF is the signed double-submit token for the anonymous // enroll/login forms (A15). SPEC-003 §6's session-bound CSRF does not apply // before a session exists, so this fills the gap: the server issues a signed diff --git a/internal/core/server/server.go b/internal/core/server/server.go index 15665eb..95d724d 100644 --- a/internal/core/server/server.go +++ b/internal/core/server/server.go @@ -65,6 +65,11 @@ type Deps struct { // it defaults to Config.Setup; the entrypoint widens it to "or no user // exists" (SPEC-005 §3). SetupMode func() bool + // SetupGate is the D-044 first-run gate: the setup code + the signed unlock + // cookie, and the first-run boilerplate. When nil, setup mode keeps the + // strict behaviour (only the tunnel served; everything else 404) — the + // entrypoint always wires it. + SetupGate SetupGate // RoleGate enforces per-function authorisation. Defaults to // AllowAuthenticated. RoleGate RoleGate @@ -90,12 +95,27 @@ type Server struct { precsrf *httpx.PreSessionCSRF trusted httpx.TrustedProxies setup func() bool + gate SetupGate roles RoleGate log *slog.Logger now func() time.Time limiter *middleware.RateLimiter } +// SetupGate is the D-044 first-run gate the door consults while in setup mode. +// It answers "does this request carry a granted setup-code unlock?" and renders +// the friendly first-run BOILERPLATE that replaces the bare 404. It is +// implemented by *setup.Gate; declaring the seam here keeps the door free of the +// setup package's internals. +type SetupGate interface { + // Unlocked reports whether the request carries a valid setup-code unlock + // (gate b, SPEC-003 §7). + Unlocked(r *http.Request) bool + // Boilerplate renders the first-run page with the setup-code entry form + // (D-044). + Boilerplate(w http.ResponseWriter, r *http.Request) error +} + // New validates the dependencies and builds the door. func New(d Deps) (*Server, error) { if d.Config == nil { @@ -159,6 +179,7 @@ func New(d Deps) (*Server, error) { precsrf: d.PreSession, trusted: trusted, setup: setup, + gate: d.SetupGate, roles: roles, log: logger, now: clock, @@ -253,25 +274,43 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { // dispatch is the Kind switch (SPEC-003 §4 step 5). func (s *Server) dispatch(w http.ResponseWriter, r *http.Request, res routing.Resolution, cid, ip string, setupTunnel bool) { - // SPEC-003 §7 / D-025 (revised 2026-10-15) / A13: setup mode is - // tunnel-only PER REQUEST. Serve only a request whose TCP peer is loopback - // AND which carries no X-Forwarded-For — the direct SSH tunnel. A proxy on - // the same host presents a loopback peer, so the ABSENCE of XFF is the - // proof it did not traverse one. Anything else → 404 before dispatch; an - // empty/unparseable peer fails closed. - if s.setup() && !setupTunnel { - s.log.Warn("setup request refused: not the direct tunnel", - "correlation_id", cid, "client_ip", ip) - s.writeError(w, http.StatusNotFound, "not found") - return - } + // SPEC-003 §7 / D-025 (2026-10-15b) / D-044 / A13: setup mode is gated PER + // REQUEST by EITHER (a) the direct SSH tunnel — a loopback TCP peer carrying + // no X-Forwarded-For (a same-host proxy presents a loopback peer, so the + // ABSENCE of XFF is the proof it did not traverse one; an empty/unparseable + // peer fails closed) — OR (b) a valid setup-code unlock (D-044). The RAW + // tunnel verdict was captured before ClientIP SET the header. + if s.setup() { + unlocked := s.gate != nil && s.gate.Unlocked(r) + allowed := setupTunnel || unlocked - // SPEC-005 §3: in setup mode the app serves ONLY /setup (+ static assets); - // every other route is 404 (delete, don't redirect). Reserved names like - // /home and /access are 404'd too — the bootstrap has no app yet. - if s.setup() && !setupAllowed(res) { - s.writeError(w, http.StatusNotFound, "not found") - return + // The boilerplate's code form is the ONE request reachable without the + // tunnel or an unlock — it is what GRANTS the unlock (D-044). It is + // rate-limited with the rest of /setup (A3) and CSRF-protected (A15). + if !s.isSetupUnlock(res) { + switch { + case res.Kind == routing.KindRedirect && res.Redirect == "/home": + // `GET /` — the friendly first-run page, ALWAYS in setup mode + // (never a bare 404: the domain must not look "down", D-044). + s.serveBoilerplate(w, r, cid, ip) + return + case res.Kind == routing.KindSetup && !allowed: + // `GET /setup` without the tunnel or a code — the same page, + // with the setup-code form. + s.serveBoilerplate(w, r, cid, ip) + return + case !allowed || !setupAllowed(res): + // SPEC-005 §3: once allowed, the app serves ONLY /setup (+ + // static assets); every other route is 404 (delete, don't + // redirect). Without the tunnel or a code, every route but `/` + // and `/setup` above is 404 too. + s.log.Warn("setup request refused: not the direct tunnel and not unlocked", + "correlation_id", cid, "client_ip", ip, + "path", httpx.RedactPath(r.URL.Path)) + s.writeError(w, http.StatusNotFound, "not found") + return + } + } } switch res.Kind { @@ -369,6 +408,32 @@ func setupAllowed(res routing.Resolution) bool { return false } +// isSetupUnlock reports whether res is the D-044 unlock action — the ONE request +// served in setup mode without the tunnel or an unlock, because it is what +// grants the unlock. The door still pins it to the `setup` module +// (serveSetupAction), so a `do=core` under /setup cannot ride this carve-out. +func (s *Server) isSetupUnlock(res routing.Resolution) bool { + return res.Kind == routing.KindAction && + res.Workflow == "setup" && + res.Module == setupModule && + res.Function == "unlock" +} + +// serveBoilerplate renders the D-044 first-run page. It is the hand-off target +// for `GET /` and for `GET /setup` before the wizard is unlocked, so the domain +// never answers a bare 404. A nil gate (not wired) keeps the strict 404 — the +// entrypoint always wires it. +func (s *Server) serveBoilerplate(w http.ResponseWriter, r *http.Request, cid, ip string) { + if s.gate == nil { + s.writeError(w, http.StatusNotFound, "not found") + return + } + if err := s.gate.Boilerplate(w, r); err != nil { + s.log.Error("setup: render boilerplate", "correlation_id", cid, "client_ip", ip, "error", err) + s.writeError(w, http.StatusInternalServerError, "internal error") + } +} + // serveCore hands off to a core-owned workflow handler. func (s *Server) serveCore(w http.ResponseWriter, r *http.Request, workflow string, res routing.Resolution, cid, ip string) { h, ok := s.reg.CoreHandler(workflow) diff --git a/internal/core/server/server_test.go b/internal/core/server/server_test.go index 23c70ac..aca3977 100644 --- a/internal/core/server/server_test.go +++ b/internal/core/server/server_test.go @@ -22,6 +22,7 @@ import ( "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/middleware" "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/routing" "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/server" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/setup" "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/store" "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/users" ) @@ -184,6 +185,13 @@ func newHarness(t *testing.T, opts ...func(*server.Deps)) *harness { t.Fatalf("NewPreSessionCSRF: %v", err) } + // D-044: the first-run gate. It is only consulted in setup mode; wiring it + // here lets a setup-mode test exercise the code + the boilerplate. + gate, err := setup.NewGate("test-setup-code", pre, nil) + if err != nil { + t.Fatalf("setup.NewGate: %v", err) + } + cfg := &config.Config{ Setup: false, Server: config.Server{Listen: "127.0.0.1:8080", BaseURL: "http://example.com"}, @@ -206,6 +214,7 @@ func newHarness(t *testing.T, opts ...func(*server.Deps)) *harness { Audit: aud, CSRF: csrf, PreSession: pre, + SetupGate: gate, } for _, o := range opts { o(&deps) @@ -459,12 +468,11 @@ func TestAC6_ActionRequiresCSRF(t *testing.T) { // ── AC-7 · AC-10 ───────────────────────────────────────────────────────────── -// TestAC7_SetupGateIsTunnelOnly pins D-025 (revised): in setup mode the app -// serves only the direct SSH tunnel — a loopback TCP peer with NO -// X-Forwarded-For. A request relayed by a same-host proxy presents a loopback -// peer but carries XFF, so it is refused (AC-7). Any non-loopback peer is -// refused too (AC-10). The bind is no longer checked: any address may be bound -// (D-043). +// TestAC7_SetupGateIsTunnelOnly pins D-025 (2026-10-15b) + D-044: in setup mode +// the WIZARD is served only to the direct SSH tunnel — a loopback TCP peer with +// NO X-Forwarded-For. Anyone else gets the friendly first-run page (never a bare +// 404, D-044), and never the wizard. The bind is not checked: any address may be +// bound (D-043). func TestAC7_SetupGateIsTunnelOnly(t *testing.T) { h := newHarness(t, func(d *server.Deps) { d.SetupMode = func() bool { return true } }) @@ -472,14 +480,14 @@ func TestAC7_SetupGateIsTunnelOnly(t *testing.T) { name string peer string xff string - wantOK bool + wizard bool comment string }{ - {"the direct tunnel", loopbackPeer, "", true, "loopback peer, no XFF"}, - {"relayed by a same-host proxy", "127.0.0.1:9999", "203.0.113.9", false, "loopback peer WITH XFF (AC-7)"}, - {"a remote peer", "203.0.113.9:4444", "", false, "non-loopback peer (AC-10)"}, - {"a remote peer behind a proxy", "203.0.113.9:4444", "198.51.100.7", false, "non-loopback peer + XFF (AC-10)"}, - {"an unparseable peer", "", "", false, "fail closed"}, + {"the direct tunnel", loopbackPeer, "", true, "loopback peer, no XFF → the wizard (gate a)"}, + {"relayed by a same-host proxy", "127.0.0.1:9999", "203.0.113.9", false, "loopback peer WITH XFF → the boilerplate (AC-7)"}, + {"a remote peer", "203.0.113.9:4444", "", false, "non-loopback peer → the boilerplate (AC-10)"}, + {"a remote peer behind a proxy", "203.0.113.9:4444", "198.51.100.7", false, "non-loopback peer + XFF → the boilerplate (AC-10)"}, + {"an unparseable peer", "", "", false, "fail closed → the boilerplate"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -490,10 +498,21 @@ func TestAC7_SetupGateIsTunnelOnly(t *testing.T) { } rec := httptest.NewRecorder() h.srv.ServeHTTP(rec, req) - if want := http.StatusNotFound; tc.wantOK && rec.Code != http.StatusOK { - t.Fatalf("GET /setup (%s) = %d, want 200", tc.comment, rec.Code) - } else if !tc.wantOK && rec.Code != want { - t.Errorf("GET /setup (%s) = %d, want 404", tc.comment, rec.Code) + if rec.Code != http.StatusOK { + t.Fatalf("GET /setup (%s) = %d, want 200 — never a bare 404 (D-044)", tc.comment, rec.Code) + } + body := rec.Body.String() + if tc.wizard { + if !strings.Contains(body, "Bootstrap wizard") { + t.Errorf("GET /setup (%s) did not serve the wizard", tc.comment) + } + return + } + if !strings.Contains(body, "not set up yet") { + t.Errorf("GET /setup (%s) did not serve the first-run boilerplate (D-044)", tc.comment) + } + if strings.Contains(body, "Bootstrap wizard") { + t.Errorf("GET /setup (%s) reached the wizard without the tunnel (D-025/D-044)", tc.comment) } }) } @@ -677,19 +696,31 @@ func TestSetupModeRouting(t *testing.T) { } } -// TestSpec003SetupIsLoopbackOnlyPerRequest covers AC-10/D-025 (revised): the -// setup gate is per request and strict — a loopback TCP peer AND no -// X-Forwarded-For (the direct SSH tunnel). A proxy on 127.0.0.1 relaying a -// remote client presents a loopback peer but carries XFF, so it is NOT -// laundered into the bootstrap. +// TestSpec003SetupIsLoopbackOnlyPerRequest covers AC-10/D-025 + D-044: the setup +// gate is per request — a loopback TCP peer AND no X-Forwarded-For (the direct +// SSH tunnel) serves the wizard. A proxy on 127.0.0.1 relaying a remote client +// presents a loopback peer but carries XFF, so it gets the first-run boilerplate +// (never the wizard), and every other route is 404. func TestSpec003SetupIsLoopbackOnlyPerRequest(t *testing.T) { h := newHarness(t, func(d *server.Deps) { d.SetupMode = func() bool { return true } }) - // A remote client → 404 before dispatch, on every setup route. + // A remote client → the boilerplate at /setup, and 404 for everything else + // (including the wizard's own steps, which need the unlock). + req := httptest.NewRequest("GET", "/setup", nil) + req.RemoteAddr = "203.0.113.9:4444" + rec := httptest.NewRecorder() + h.srv.ServeHTTP(rec, req) + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "not set up yet") { + t.Errorf("GET /setup from a remote client = %d, want the first-run page (D-044)", rec.Code) + } + if strings.Contains(rec.Body.String(), "Bootstrap wizard") { + t.Error("GET /setup from a remote client reached the wizard (D-025)") + } + for _, tc := range []struct{ method, target string }{ - {"GET", "/setup"}, {"POST", "/setup?do=setup&action=domain.save"}, {"GET", "/static/app.css"}, + {"GET", "/home"}, } { req := httptest.NewRequest(tc.method, tc.target, nil) req.RemoteAddr = "203.0.113.9:4444" // a remote peer through the proxy @@ -705,19 +736,22 @@ func TestSpec003SetupIsLoopbackOnlyPerRequest(t *testing.T) { t.Errorf("GET /setup from loopback = %d, want 200 (the tunnel must work)", rec.Code) } - // A TRUSTED proxy forwarding X-Forwarded-For is refused even though its - // peer is loopback: the absence of XFF is what proves the direct tunnel. + // A TRUSTED proxy forwarding X-Forwarded-For gets the boilerplate, never the + // wizard: the absence of XFF is what proves the direct tunnel. h2 := newHarness(t, func(d *server.Deps) { d.SetupMode = func() bool { return true } d.Config.Server.TrustedProxies = []string{"127.0.0.1"} }) - req := httptest.NewRequest("GET", "/setup", nil) + req = httptest.NewRequest("GET", "/setup", nil) req.RemoteAddr = "127.0.0.1:9999" // the proxy itself is local... req.Header.Set("X-Forwarded-For", "203.0.113.9") - rec := httptest.NewRecorder() + rec = httptest.NewRecorder() h2.srv.ServeHTTP(rec, req) - if rec.Code != http.StatusNotFound { - t.Errorf("GET /setup relayed by a trusted proxy = %d, want 404 (AC-7: tunnel-only)", rec.Code) + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "not set up yet") { + t.Errorf("GET /setup relayed by a trusted proxy = %d, want the boilerplate (AC-7)", rec.Code) + } + if strings.Contains(rec.Body.String(), "Bootstrap wizard") { + t.Error("GET /setup relayed by a trusted proxy reached the wizard (AC-7: tunnel-only)") } } diff --git a/internal/core/server/setup_code_test.go b/internal/core/server/setup_code_test.go new file mode 100644 index 0000000..0dd4200 --- /dev/null +++ b/internal/core/server/setup_code_test.go @@ -0,0 +1,364 @@ +package server_test + +// D-044 — the first-run SETUP CODE and the boilerplate (SPEC-005 §3/§4, +// SPEC-003 §7 / AC-14). These tests drive the real `/setup` gate end to end: +// `GET /` and `GET /setup` must never be a bare 404, the correct code unlocks +// the wizard, a wrong code is a generic (rate-limited) answer, and every other +// route stays 404 until setup completes. + +import ( + "log/slog" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/httpx" + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/setup" +) + +// proxyPeer / proxyXFF are a same-host reverse proxy relaying a remote client: +// a loopback TCP peer that carries X-Forwarded-For — i.e. NOT the tunnel. +const ( + proxyPeer = "127.0.0.1:9999" + proxyXFF = "203.0.113.9" +) + +// proxyGet issues a GET from behind the proxy (gate a does not apply). +func (h *setupHarness) proxyGet(target string, cookies ...*http.Cookie) *httptest.ResponseRecorder { + h.t.Helper() + req := httptest.NewRequest("GET", target, nil) + req.RemoteAddr = proxyPeer + req.Header.Set("X-Forwarded-For", proxyXFF) + for _, c := range cookies { + req.AddCookie(c) + } + rec := httptest.NewRecorder() + h.srv.ServeHTTP(rec, req) + return rec +} + +// proxyPost issues a wizard step from behind the proxy with a fresh pre-session +// CSRF token and cookie (A15). +func (h *setupHarness) proxyPost(action string, form url.Values, cookies ...*http.Cookie) *httptest.ResponseRecorder { + h.t.Helper() + cookie, token := h.preCSRF() + if form == nil { + form = url.Values{} + } + form.Set("csrf", token) + req := httptest.NewRequest("POST", "/setup?do=setup&action="+url.QueryEscape(action), + strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.RemoteAddr = proxyPeer + req.Header.Set("X-Forwarded-For", proxyXFF) + req.AddCookie(cookie) + for _, c := range cookies { + req.AddCookie(c) + } + rec := httptest.NewRecorder() + h.srv.ServeHTTP(rec, req) + return rec +} + +// unlockCookie extracts the D-044 unlock cookie from a response. +func unlockCookie(t *testing.T, rec *httptest.ResponseRecorder) *http.Cookie { + t.Helper() + for _, c := range rec.Result().Cookies() { + if c.Name == setup.SetupUnlockCookieName && c.Value != "" { + return c + } + } + return nil +} + +// ── the boilerplate (never a bare 404) ─────────────────────────────────────── + +// TestD044_BoilerplateAtRootAndSetup: in setup mode, without the tunnel, `GET /` +// and `GET /setup` render the friendly first-run page with the setup-code form. +func TestD044_BoilerplateAtRootAndSetup(t *testing.T) { + h := newSetupHarness(t) + + for _, target := range []string{"/", "/setup"} { + t.Run(target, func(t *testing.T) { + rec := h.proxyGet(target) + if rec.Code != http.StatusOK { + t.Fatalf("GET %s = %d, want 200 — never a bare 404 (D-044)", target, rec.Code) + } + body := rec.Body.String() + for _, want := range []string{ + "MyDay is not set up yet", + `action="/setup?do=setup&action=unlock"`, + `name="code"`, + "journalctl -u myday", + "pct exec 11020", + } { + if !strings.Contains(body, want) { + t.Errorf("GET %s boilerplate is missing %q", target, want) + } + } + // It must not be the wizard, and it must not be a bare 404 body. + if strings.Contains(body, "domain.save") { + t.Errorf("GET %s served the WIZARD without the code/tunnel (D-044)", target) + } + // The anonymous form carries the pre-session CSRF cookie (A15). + if !hasCookie(rec, httpx.PreSessionCookieName) { + t.Errorf("GET %s issued no pre-session CSRF cookie (A15)", target) + } + }) + } +} + +// TestD044_OtherRoutesStillNotFound: in setup mode, before the unlock, every +// route other than `/` and `/setup` is 404 — the bootstrap has no app yet. +func TestD044_OtherRoutesStillNotFound(t *testing.T) { + h := newSetupHarness(t) + + // A GUID branch: the resolver does not even know this name. + for _, target := range []string{"/home", "/access", "/some-guid", "/some-guid/home"} { + if rec := h.proxyGet(target); rec.Code != http.StatusNotFound { + t.Errorf("GET %s in setup mode = %d, want 404 (SPEC-005 §3)", target, rec.Code) + } + } + // The wizard's own steps are NOT steppable without the unlock. + if rec := h.proxyPost("domain.save", url.Values{"base_url": {"https://example.com"}}); rec.Code != http.StatusNotFound { + t.Errorf("POST domain.save before unlock = %d, want 404 (D-044)", rec.Code) + } +} + +// ── unlock ─────────────────────────────────────────────────────────────────── + +// TestD044_CorrectCodeUnlocksTheWizard: the correct code unlocks the wizard for +// that browser (a signed flag cookie, not a session), and `GET /setup` then +// renders the wizard. +func TestD044_CorrectCodeUnlocksTheWizard(t *testing.T) { + h := newSetupHarness(t) + + rec := h.proxyPost("unlock", url.Values{"code": {h.code}}) + if rec.Code != http.StatusSeeOther { + t.Fatalf("POST unlock with the correct code = %d, want 303 (D-044)", rec.Code) + } + if loc := rec.Header().Get("Location"); loc != "/setup" { + t.Errorf("post-unlock Location = %q, want /setup (PRG)", loc) + } + c := unlockCookie(t, rec) + if c == nil { + t.Fatal("the unlock set no signed unlock cookie (D-044)") + } + if !c.HttpOnly { + t.Error("the unlock cookie is not HttpOnly") + } + + // The wizard is now served to this browser over the proxy. + got := h.proxyGet("/setup", c) + if got.Code != http.StatusOK { + t.Fatalf("GET /setup after unlock = %d, want 200 (the wizard)", got.Code) + } + body := got.Body.String() + if !strings.Contains(body, "MyDay setup") || !strings.Contains(body, "domain.save") { + t.Errorf("GET /setup after unlock did not render the wizard (D-044)") + } + + // A different browser (no unlock cookie) still gets the boilerplate. + if other := h.proxyGet("/setup"); strings.Contains(other.Body.String(), "domain.save") { + t.Error("an unlocked browser leaked the wizard to another visitor (D-044)") + } +} + +// TestD044_TunnelNeedsNoCode: the direct SSH tunnel (gate a) still opens the +// wizard without any code (D-025). +func TestD044_TunnelNeedsNoCode(t *testing.T) { + h := newSetupHarness(t) + + rec := request(h.srv, "GET", "/setup") // loopback peer, no XFF — the tunnel + if rec.Code != http.StatusOK { + t.Fatalf("GET /setup over the tunnel = %d, want 200 (D-025 gate a)", rec.Code) + } + if !strings.Contains(rec.Body.String(), "domain.save") { + t.Error("the tunnel did not reach the wizard without a code (D-025)") + } +} + +// ── a wrong/absent code is generic and rate-limited ────────────────────────── + +// TestD044_WrongOrAbsentCodeIsGeneric: neither a wrong nor an absent code +// unlocks, and both get the SAME generic answer (no oracle, A3). +func TestD044_WrongOrAbsentCodeIsGeneric(t *testing.T) { + h := newSetupHarness(t) + + wrong := h.proxyPost("unlock", url.Values{"code": {"nope-not-the-code"}}) + absent := h.proxyPost("unlock", nil) + + // Both get the SAME generic answer — no oracle (A3). + for name, rec := range map[string]*httptest.ResponseRecorder{"wrong": wrong, "absent": absent} { + if rec.Code != http.StatusForbidden { + t.Errorf("%s code = %d, want 403 (generic, D-044)", name, rec.Code) + } + if unlockCookie(t, rec) != nil { + t.Errorf("%s code set an unlock cookie (D-044)", name) + } + body := rec.Body.String() + if strings.Contains(body, "domain.save") { + t.Errorf("%s code reached the wizard (D-044)", name) + } + if !strings.Contains(body, "That setup code is not correct.") { + t.Errorf("%s code did not get the generic notice (D-044)", name) + } + } + if wrong.Code != absent.Code { + t.Error("a wrong and an absent code gave different statuses (an oracle, A3)") + } + + // The correct code still works afterwards — a failed attempt does not + // consume the code (it retires only on SUCCESS). + if rec := h.proxyPost("unlock", url.Values{"code": {h.code}}); rec.Code != http.StatusSeeOther { + t.Errorf("the correct code after a failed attempt = %d, want 303", rec.Code) + } +} + +// TestD044_UnlockIsRateLimited: the setup route is rate-limited (A3/AC-9) — a +// burst of attempts is answered 429, not a free oracle. +func TestD044_UnlockIsRateLimited(t *testing.T) { + h := newSetupHarness(t, func(h *setupHarness) { + h.rateLimit = 2 + h.rateWindow = time.Minute + }) + + for i := 0; i < 2; i++ { + if rec := h.proxyPost("unlock", url.Values{"code": {"wrong"}}); rec.Code != http.StatusForbidden { + t.Fatalf("attempt %d = %d, want 403", i+1, rec.Code) + } + } + rec := h.proxyPost("unlock", url.Values{"code": {"wrong"}}) + if rec.Code != http.StatusTooManyRequests { + t.Fatalf("third attempt = %d, want 429 (A3)", rec.Code) + } + if rec.Header().Get("Retry-After") == "" { + t.Error("429 is missing a Retry-After header (A3)") + } +} + +// TestD044_UnlockRequiresCSRF: the unlock is an anonymous form, so it carries +// the pre-session double-submit token (A15) — a POST without it is refused. +func TestD044_UnlockRequiresCSRF(t *testing.T) { + h := newSetupHarness(t) + + req := httptest.NewRequest("POST", "/setup?do=setup&action=unlock", + strings.NewReader(url.Values{"code": {h.code}}.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.RemoteAddr = proxyPeer + req.Header.Set("X-Forwarded-For", proxyXFF) + rec := httptest.NewRecorder() + h.srv.ServeHTTP(rec, req) + + if rec.Code != http.StatusForbidden { + t.Errorf("POST unlock without CSRF = %d, want 403 (A15)", rec.Code) + } + if unlockCookie(t, rec) != nil { + t.Error("an unlock was granted without the CSRF token (A15)") + } +} + +// TestD044_CodeIsSingleUse: the code retires the moment it works — a second +// browser cannot re-use the printed value. +func TestD044_CodeIsSingleUse(t *testing.T) { + h := newSetupHarness(t) + + if rec := h.proxyPost("unlock", url.Values{"code": {h.code}}); rec.Code != http.StatusSeeOther { + t.Fatalf("first unlock = %d, want 303", rec.Code) + } + // A fresh visitor (no unlock cookie) tries the same code again. + again := h.proxyPost("unlock", url.Values{"code": {h.code}}) + if again.Code != http.StatusForbidden { + t.Errorf("re-use of the setup code = %d, want 403 (single-use, D-044)", again.Code) + } + if unlockCookie(t, again) != nil { + t.Error("a spent setup code granted a second unlock (single-use, D-044)") + } +} + +// ── redaction (A8) ─────────────────────────────────────────────────────────── + +// TestD044_CodeIsRedactedFromTheAccessLog: an attempt that carries the code in +// the QUERY (a hand-typed URL, say) must never leak it into the access log. +func TestD044_CodeIsRedactedFromTheAccessLog(t *testing.T) { + var logs strings.Builder + h := newSetupHarness(t, func(h *setupHarness) { + h.logger = slog.New(slog.NewTextHandler(&logs, nil)) + }) + + const secret = "SUPERSECRETSETUPCODE" + rec := h.proxyGet("/setup?do=setup&action=unlock&code=" + secret) + if rec.Code == 0 { + t.Fatal("no response") + } + + out := logs.String() + if strings.Contains(out, secret) { + t.Error("the access log leaked the setup code (A8)") + } + if !strings.Contains(strings.ToLower(out), "redacted") { + t.Errorf("the access log did not mark the credential redaction (A8):\n%s", out) + } +} + +// ── the gate, unit-level ───────────────────────────────────────────────────── + +// TestD044_GateGeneratesA128BitCode: a blank code generates 128 bits of +// entropy, and the generated value is what the banner prints. +func TestD044_GateGeneratesA128BitCode(t *testing.T) { + pre := mustPreSessionCSRF(t) + gate, err := setup.NewGate("", pre, nil) + if err != nil { + t.Fatalf("NewGate: %v", err) + } + code := gate.Code() + if code == "" { + t.Fatal("NewGate generated no code") + } + // 16 bytes → 32 hex chars, plus the visual grouping dashes. + plain := strings.ReplaceAll(code, "-", "") + if len(plain) != setup.CodeBytes*2 { + t.Errorf("generated code %q has %d hex chars, want %d (128 bits)", code, len(plain), setup.CodeBytes*2) + } + + // A second gate generates a DIFFERENT code (fresh entropy each boot). + other, err := setup.NewGate("", pre, nil) + if err != nil { + t.Fatalf("NewGate: %v", err) + } + if other.Code() == code { + t.Error("two gates generated the same code") + } +} + +// TestD044_GateAcceptsFormattingAndRetires: the comparison ignores case and +// separators, and the accepted code is spent. +func TestD044_GateAcceptsFormattingAndRetires(t *testing.T) { + pre := mustPreSessionCSRF(t) + gate, err := setup.NewGate("9F3A-1B2C", pre, nil) + if err != nil { + t.Fatalf("NewGate: %v", err) + } + + if gate.Accept("nope") { + t.Error("a wrong code was accepted") + } + if !gate.Accept("9f3a1b2c") { + t.Error("the code with different case/spacing was not accepted") + } + if gate.Accept("9f3a1b2c") { + t.Error("the code was accepted twice (single-use, D-044)") + } +} + +func mustPreSessionCSRF(t *testing.T) *httpx.PreSessionCSRF { + t.Helper() + pre, err := httpx.NewPreSessionCSRF([]byte("gate-test-cookie-secret-0123456789")) + if err != nil { + t.Fatalf("NewPreSessionCSRF: %v", err) + } + return pre +} diff --git a/internal/core/server/setup_wizard_test.go b/internal/core/server/setup_wizard_test.go index 24f5f05..c035ce3 100644 --- a/internal/core/server/setup_wizard_test.go +++ b/internal/core/server/setup_wizard_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "errors" + "log/slog" "net/http" "net/http/httptest" "net/url" @@ -44,9 +45,16 @@ type setupHarness struct { cfg *config.Config cfgPath string prints *bytes.Buffer + // code is the D-044 setup code the harness pins (so tests know it). + code string // writeFile overrides the persister (a failing writer exercises the // "print the values" fallback, SPEC-005 §4). writeFile func(string, []byte) error + // rateLimit / rateWindow tune the door's auth-sensitive limiter (A3). + rateLimit int + rateWindow time.Duration + // logger, when set, receives the door's logs (the access-log redaction test). + logger *slog.Logger setupFlow *setup.Actions srv *server.Server @@ -101,6 +109,7 @@ func newSetupHarness(t *testing.T, opts ...func(*setupHarness)) *setupHarness { t: t, ctx: ctx, db: db, users: us, ids: ids, sessions: sessions, csrf: csrf, pre: pre, wa: &fakeWebAuthn{}, totpSvc: totp.New("MyDay", ids), cfg: cfg, cfgPath: filepath.Join(dir, config.FileName), prints: &bytes.Buffer{}, + code: "test-setup-code-1234", } for _, o := range opts { o(h) @@ -148,6 +157,7 @@ func newSetupHarness(t *testing.T, opts ...func(*setupHarness)) *setupHarness { setupFlow, err := setup.New(setup.Deps{ Config: cfg, ConfigPath: h.cfgPath, Users: us, Identities: ids, + Code: h.code, Enroll: authFlow, Audit: aud, CSRF: pre, Printout: h.prints, WriteFile: h.writeFile, }) @@ -168,6 +178,9 @@ func newSetupHarness(t *testing.T, opts ...func(*setupHarness)) *setupHarness { Config: cfg, Resolver: ids, Registry: reg, Sessions: sessions, Identities: ids, Users: us, Audit: aud, CSRF: csrf, PreSession: pre, SetupMode: setupMode, + // D-044: the first-run gate (the setup code + the boilerplate). + SetupGate: setupFlow.Gate(), + RateLimit: h.rateLimit, RateWindow: h.rateWindow, Logger: h.logger, }) if err != nil { t.Fatalf("server.New: %v", err) @@ -256,6 +269,7 @@ func (h *setupHarness) enrollAdmin(t *testing.T) string { func TestSpec005AC1_SetupModeServesOnlySetup(t *testing.T) { h := newSetupHarness(t) + // Over the direct tunnel (gate a) `GET /setup` is the wizard. rec := request(h.srv, "GET", "/setup") if rec.Code != http.StatusOK { t.Fatalf("GET /setup on a fresh instance = %d, want 200 (AC-1)", rec.Code) @@ -268,9 +282,30 @@ func TestSpec005AC1_SetupModeServesOnlySetup(t *testing.T) { t.Error("the wizard issued no pre-session CSRF cookie (A15)") } + // D-044: without the tunnel, `GET /` and `GET /setup` show the friendly + // first-run boilerplate — NEVER a bare 404. (AC-14; see setup_code_test.go + // for the gate itself.) + proxyGET := func(target string) *httptest.ResponseRecorder { + req := httptest.NewRequest("GET", target, nil) + req.RemoteAddr = "127.0.0.1:9999" + req.Header.Set("X-Forwarded-For", "203.0.113.9") + out := httptest.NewRecorder() + h.srv.ServeHTTP(out, req) + return out + } + for _, p := range []string{"/", "/setup"} { + got := proxyGET(p) + if got.Code != http.StatusOK { + t.Errorf("GET %s before unlock = %d, want 200 (the first-run page, not a 404/D-044)", p, got.Code) + } + if !strings.Contains(got.Body.String(), "not set up yet") { + t.Errorf("GET %s did not render the first-run boilerplate (D-044)", p) + } + } + // EVERY other route is 404 — including the app the productive instance - // would serve (/home, /access, the canonical redirect, GUID branches). - for _, p := range []string{"/home", "/access", "/", "/holy-shit", "/some-guid", "/home/extra"} { + // would serve (/home, /access, GUID branches) and the canonical redirect. + for _, p := range []string{"/home", "/access", "/holy-shit", "/some-guid", "/home/extra"} { if rec := request(h.srv, "GET", p); rec.Code != http.StatusNotFound { t.Errorf("GET %s in setup mode = %d, want 404 (AC-1)", p, rec.Code) } @@ -329,25 +364,28 @@ func TestR6_WizardGeneratesStrongSecrets(t *testing.T) { // ── AC-2 ───────────────────────────────────────────────────────────────────── -// TestSpec005AC2_ServedOnlyOverTheTunnel covers D-025 (revised): there is no -// bind refusal (any address may be bound, D-043) — the bootstrap is protected -// per request: in setup mode only the direct SSH tunnel (a loopback TCP peer -// with no X-Forwarded-For) is served. +// TestSpec005AC2_ServedOnlyOverTheTunnel covers D-025 (2026-10-15b) + D-044: +// there is no bind refusal (any address may be bound, D-043) — the WIZARD is +// protected per request by EITHER the direct SSH tunnel (a loopback TCP peer +// with no X-Forwarded-For) OR the setup code. A remote peer, and a same-host +// proxy relaying a client, get the first-run boilerplate — never the wizard. func TestSpec005AC2_ServedOnlyOverTheTunnel(t *testing.T) { h := newSetupHarness(t) - // The tunnel works. - if rec := request(h.srv, "GET", "/setup"); rec.Code != http.StatusOK { - t.Errorf("GET /setup over the tunnel = %d, want 200 (AC-2)", rec.Code) + // The tunnel reaches the wizard. + if rec := request(h.srv, "GET", "/setup"); rec.Code != http.StatusOK || + !strings.Contains(rec.Body.String(), "domain.save") { + t.Errorf("GET /setup over the tunnel did not reach the wizard (AC-2)") } - // A remote peer, and a same-host proxy relaying a client, are both refused. + // A remote peer, and a same-host proxy relaying a client, are both refused + // the wizard — they get the friendly first-run page (D-044). remote := httptest.NewRequest("GET", "/setup", nil) remote.RemoteAddr = "203.0.113.9:4444" rec := httptest.NewRecorder() h.srv.ServeHTTP(rec, remote) - if rec.Code != http.StatusNotFound { - t.Errorf("GET /setup from a remote peer = %d, want 404 (AC-2)", rec.Code) + if rec.Code != http.StatusOK || strings.Contains(rec.Body.String(), "domain.save") { + t.Errorf("GET /setup from a remote peer = %d, want the boilerplate, never the wizard (AC-2)", rec.Code) } proxied := httptest.NewRequest("GET", "/setup", nil) @@ -355,8 +393,8 @@ func TestSpec005AC2_ServedOnlyOverTheTunnel(t *testing.T) { proxied.Header.Set("X-Forwarded-For", "203.0.113.9") rec = httptest.NewRecorder() h.srv.ServeHTTP(rec, proxied) - if rec.Code != http.StatusNotFound { - t.Errorf("GET /setup relayed by a same-host proxy = %d, want 404 (AC-2)", rec.Code) + if rec.Code != http.StatusOK || strings.Contains(rec.Body.String(), "domain.save") { + t.Errorf("GET /setup relayed by a same-host proxy = %d, want the boilerplate, never the wizard (AC-2)", rec.Code) } } diff --git a/internal/core/server/setupflow.go b/internal/core/server/setupflow.go index 4d20adc..d28eb13 100644 --- a/internal/core/server/setupflow.go +++ b/internal/core/server/setupflow.go @@ -11,13 +11,17 @@ import ( const setupModule = "setup" // RegisterSetupFlow registers the SPEC-005 `/setup` wizard: the page (the core -// `setup` workflow) and the action module (domain.save · admin.create · +// `setup` workflow) and the action module (unlock · domain.save · admin.create · // admin.enroll.* · admin.totp.* · finish). The door gates them to setup mode // and verifies the pre-session CSRF token (A15) before dispatch. +// +// `unlock` is the D-044 code gate: the door lets it through without the tunnel +// or an existing unlock, because it is what grants the unlock. func RegisterSetupFlow(reg *MapRegistry, a *setup.Actions) { reg.RegisterCore("setup", adaptSetup(a.Page)) reg.RegisterModule(NewModule(setupModule). + AddAction("unlock", "unlock", adaptSetup(a.Unlock)). AddAction("domain.save", "domain.save", adaptSetup(a.DomainSave)). AddAction("admin.create", "admin.create", adaptSetup(a.AdminCreate)). // Step 2 is TOTP only (D-041) — there is no admin.enroll.* action. diff --git a/internal/core/setup/gate.go b/internal/core/setup/gate.go new file mode 100644 index 0000000..8b1b3c3 --- /dev/null +++ b/internal/core/setup/gate.go @@ -0,0 +1,221 @@ +package setup + +import ( + "crypto/rand" + "crypto/subtle" + "encoding/hex" + "errors" + "fmt" + "log/slog" + "net/http" + "strings" + "sync" + "unicode" + + "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/httpx" +) + +// SetupUnlockCookieName is the signed cookie that carries a granted unlock of +// the first-run wizard (D-044). It is NOT the session cookie — no user exists +// yet (SPEC-005 §4) — and it is deliberately separate so it can be cleared +// when setup completes. +const SetupUnlockCookieName = "myday_setup" + +// CodeBytes is the setup code's entropy: 16 bytes == 128 bits (D-044). +const CodeBytes = 16 + +// ErrNoGateCSRF is returned when the gate is built without the pre-session CSRF +// helper — the code needs a signing key to bind the unlock cookie, so this +// fails closed. +var ErrNoGateCSRF = errors.New("setup: the gate needs the pre-session CSRF signer") + +// Gate is the D-044 first-run gate: the SETUP CODE plus the signed unlock +// cookie the boilerplate mints when the correct code is entered. +// +// It closes first-visitor-wins (D-025) where a tunnel cannot reach — the +// LXC/Proxmox + Zoraxy topology. `Unlocked` answers the door's "may this +// request see the wizard?" question (gate b); the direct tunnel (gate a) is +// decided by the door itself (SPEC-003 §7). +// +// The code is SINGLE-USE: the first successful unlock retires it. The unlock +// survives the retirement because the cookie carries a KEYED, ONE-WAY +// FINGERPRINT of the code (`Signer.Fingerprint`), not the code — so an operator +// who unlocked the wizard can keep working in that browser while a second +// visitor cannot re-use the printed code. +// +// Spec: docs/specs/spec-005-setup-wizard.md §3/§4. +type Gate struct { + csrf *httpx.PreSessionCSRF + logger *slog.Logger + + mu sync.Mutex + display string // the code as shown in the banner (grouped for readability) + norm string // the normalised form compared on entry ("" once consumed) + fp string // the keyed fingerprint; stable across consumption +} + +// NewGate builds the gate from the configured code (D-044). An empty/blank code +// generates a fresh 128-bit one. It returns the gate, whose Display/Code +// the caller prints in the startup banner. +func NewGate(code string, csrf *httpx.PreSessionCSRF, logger *slog.Logger) (*Gate, error) { + if csrf == nil { + return nil, ErrNoGateCSRF + } + if logger == nil { + logger = slog.Default() + } + + display := strings.TrimSpace(code) + norm := normalizeCode(display) + if norm == "" { + generated, err := generateCode() + if err != nil { + return nil, err + } + display, norm = generated, normalizeCode(generated) + } + + return &Gate{ + csrf: csrf, + logger: logger, + display: display, + norm: norm, + fp: csrf.Signer().Fingerprint(fingerprintDomain + norm), + }, nil +} + +// fingerprintDomain separates the setup-code fingerprint from every other use +// of the signer's key (the pre-session CSRF, the ceremony state). +const fingerprintDomain = "myday.setup.unlock.v1:" + +// Code returns the setup code to PRINT in the startup banner (D-044). It is the +// configured code verbatim, or the generated grouped form. +func (g *Gate) Code() string { + g.mu.Lock() + defer g.mu.Unlock() + return g.display +} + +// Accept reports whether a submitted code is the live setup code and, when it +// is, RETIRES it (single-use). The comparison is constant-time and ignores +// formatting (case, spaces, dashes), so a hand-typed value works. +func (g *Gate) Accept(code string) bool { + g.mu.Lock() + defer g.mu.Unlock() + if g.norm == "" { + return false // already consumed (or cleared) — never re-usable + } + if subtle.ConstantTimeCompare([]byte(normalizeCode(code)), []byte(g.norm)) != 1 { + return false + } + g.norm = "" // single-use: the code is spent the moment it works + return true +} + +// Grant mints the signed cookie value that records the unlock. It is valid only +// while the gate holds the same code fingerprint, so it dies with a restart or +// with setup completion. +func (g *Gate) Grant() string { + g.mu.Lock() + defer g.mu.Unlock() + if g.fp == "" { + return "" + } + return g.csrf.Signer().Sign(g.fp) +} + +// Unlocked reports whether the request carries a valid unlock grant (D-044, +// gate b). It is independent of the tunnel — the door ORs the two (SPEC-003 §7). +func (g *Gate) Unlocked(r *http.Request) bool { + g.mu.Lock() + fp := g.fp + g.mu.Unlock() + if fp == "" { + return false + } + c, err := r.Cookie(SetupUnlockCookieName) + if err != nil || c.Value == "" { + return false + } + v, ok := g.csrf.Signer().Unsign(c.Value) + return ok && subtle.ConstantTimeCompare([]byte(v), []byte(fp)) == 1 +} + +// SetUnlockCookie writes the granted-unlock cookie. Its `Secure` flag is +// resolved per request by the door (D-043), like every other cookie here. +func (g *Gate) SetUnlockCookie(w http.ResponseWriter, r *http.Request) { + http.SetCookie(w, &http.Cookie{ + Name: SetupUnlockCookieName, + Value: g.Grant(), + Path: "/", + HttpOnly: true, + Secure: httpx.SecureFromRequest(r), + SameSite: http.SameSiteLaxMode, + }) +} + +// Clear retires the gate — the fingerprint goes, so every outstanding unlock +// grant stops verifying. Called when the wizard completes (SPEC-005 §5). +func (g *Gate) Clear() { + g.mu.Lock() + g.norm, g.fp = "", "" + g.mu.Unlock() +} + +// ── the first-run boilerplate (D-044) ─────────────────────────────────────── + +// Boilerplate renders the friendly first-run page — "MyDay is not set up yet" — +// with the setup-code entry form. It is what `GET /` and `GET /setup` show in +// setup mode instead of a bare 404, so the domain never looks "down". +// +// The form is anonymous, so it carries the signed pre-session double-submit +// token (A15) exactly like every other pre-session form (SPEC-003 §6). +func (g *Gate) Boilerplate(w http.ResponseWriter, r *http.Request) error { + return g.boilerplate(w, r, http.StatusOK, "") +} + +// boilerplateError re-renders the page with a generic notice. The message is +// deliberately the same for a wrong and an absent code (no oracle, A3). +func (g *Gate) boilerplateError(w http.ResponseWriter, r *http.Request) error { + return g.boilerplate(w, r, http.StatusForbidden, "That setup code is not correct.") +} + +func (g *Gate) boilerplate(w http.ResponseWriter, r *http.Request, status int, flash string) error { + token, err := g.csrf.Ensure(w, r) + if err != nil { + return err + } + return renderHTML(w, status, boilerplatePage(token, flash)) +} + +// ── code generation & normalisation ───────────────────────────────────────── + +// generateCode returns a fresh 128-bit code, formatted as groups of four hex +// characters (e.g. `9f3a-…`) so it is easy to read from the log and to type. +func generateCode() (string, error) { + b := make([]byte, CodeBytes) + if _, err := rand.Read(b); err != nil { + return "", fmt.Errorf("setup: setup code entropy: %w", err) + } + raw := hex.EncodeToString(b) + var sb strings.Builder + for i := 0; i < len(raw); i += 4 { + if i > 0 { + sb.WriteByte('-') + } + sb.WriteString(raw[i : i+4]) + } + return sb.String(), nil +} + +// normalizeCode lowercases a code and drops every non-alphanumeric character, +// so `9F3A-1B2C` and `9f3a1b2c` are the same code on entry. +func normalizeCode(code string) string { + var sb strings.Builder + for _, r := range strings.ToLower(strings.TrimSpace(code)) { + if unicode.IsLetter(r) || unicode.IsDigit(r) { + sb.WriteRune(r) + } + } + return sb.String() +} diff --git a/internal/core/setup/render.go b/internal/core/setup/render.go index ab5736e..e7e525e 100644 --- a/internal/core/setup/render.go +++ b/internal/core/setup/render.go @@ -45,6 +45,45 @@ func wizardPage(st step, token, domain string) web.Page { } } +// boilerplatePage renders the D-044 first-run page — what `GET /` and +// `GET /setup` show in setup mode when the wizard has not been unlocked. It is +// deliberately friendly: the point is that the domain must NOT look "down". +// +// It is a GUEST screen (no identity, no nav) with the setup-code entry form. +// The form submits over POST, so the code never rides the URL (and is never +// access-logged — the `code` parameter is redacted even if it ever did, A8). +func boilerplatePage(token, flash string) web.Page { + c := html.EscapeString(token) + kind := "" + if flash != "" { + kind = "error" + } + return web.Page{ + Title: "MyDay is not set up yet", + Guest: true, + CSRF: token, + Flash: flash, + FlashKind: kind, + Content: web.Markup(`

MyDay is not set up yet

+

This instance is waiting for its first-run setup. Open the setup wizard with the one-time +setup code that MyDay printed to its log when it started.

+
+ + + +
+
+

Where is the code?

+

It is in MyDay's startup log:

+ +

Look for the line setup code.

+
`), + } +} + // stepBody renders the section for one step. func stepBody(st step, c, domain string) string { switch st { diff --git a/internal/core/setup/setup.go b/internal/core/setup/setup.go index dc45b2e..81da875 100644 --- a/internal/core/setup/setup.go +++ b/internal/core/setup/setup.go @@ -1,5 +1,12 @@ -// Package setup implements SPEC-005: the `/setup` wizard, reachable only over -// the direct SSH tunnel, that brings a fresh instance to life. +// Package setup implements SPEC-005: the `/setup` wizard, reachable over the +// direct SSH tunnel OR by unlocking it with the printed setup code (D-044), +// that brings a fresh instance to life. +// +// While in setup mode and NOT unlocked, `GET /` and `GET /setup` render a +// friendly first-run BOILERPLATE ("MyDay is not set up yet") with the setup-code +// entry form — never a bare 404, so the domain does not look "down" (D-044). +// `POST /setup?do=setup&action=unlock` with the correct code grants the unlock +// (a signed cookie, not a session — no user exists yet). // // The wizard walks three steps — (1) the domain / base URL, (2) the top admin // with TOTP enrollment, (3) mail · locale · timezone — and finishes by @@ -71,6 +78,10 @@ type Deps struct { // ConfigPath is where the settings are persisted to (usually config.yaml, // never the committed example). ConfigPath string + // Code is the D-044 setup code. When blank the wizard GENERATES a 128-bit + // one and prints it in the startup banner (`Actions.Code`); an operator may + // pin it via `setup_code` in the config. + Code string // Users creates and inspects the top admin. Users Users // Identities mints and resolves the admin's identity. @@ -94,6 +105,7 @@ type Deps struct { // Actions is the /setup wizard. type Actions struct { d Deps + gate *Gate mu sync.Mutex draft draft // done signals that the bootstrap is complete. The live config is treated @@ -139,7 +151,46 @@ func New(d Deps) (*Actions, error) { if d.WriteFile == nil { d.WriteFile = writeFile0600 } - return &Actions{d: d}, nil + // D-044: the first-run gate — the code + the signed unlock cookie. A blank + // code generates one (printed in the startup banner via Actions.Code). + gate, err := NewGate(d.Code, d.CSRF, d.Logger) + if err != nil { + return nil, err + } + return &Actions{d: d, gate: gate}, nil +} + +// Code returns the setup code to print in the startup banner (D-044). It is the +// operator's `setup_code`, or the generated 128-bit value. +func (a *Actions) Code() string { return a.gate.Code() } + +// Gate exposes the first-run gate so the door can ask whether a request is +// unlocked and render the boilerplate (D-044). The wizard itself never parses a +// path — the door decides. +func (a *Actions) Gate() *Gate { return a.gate } + +// ── the first-run gate (D-044) ────────────────────────────────────────────── + +// Unlock is the D-044 gate: `POST /setup?do=setup&action=unlock` carrying the +// setup code. The door has already verified the pre-session CSRF token (A15) +// and rate-limited the request (A3); here we check the code, grant the unlock +// for this browser, and send the operator on to the wizard (post/redirect/get). +// +// A wrong or absent code gets a GENERIC answer — the same page and message +// either way, so the form is no oracle — and is audited (no code value logged). +func (a *Actions) Unlock(w http.ResponseWriter, r *http.Request) error { + if a.done.Load() { + writeError(w, http.StatusNotFound, "not found") + return nil + } + if !a.gate.Accept(r.FormValue("code")) { + a.recordSetup(r.Context(), "setup.unlock", "refused") + return a.gate.boilerplateError(w, r) + } + a.gate.SetUnlockCookie(w, r) + a.recordSetup(r.Context(), "setup.unlock", "unlocked") + redirect(w, r, "/setup") + return nil } // ── the wizard page ───────────────────────────────────────────────────────── @@ -351,6 +402,7 @@ func (a *Actions) Finish(w http.ResponseWriter, r *http.Request) error { return err } a.done.Store(true) + a.gate.Clear() // retire the setup code + every outstanding unlock (D-044) a.record(ctx, "setup.complete", "bootstrap finished") target := "/home" @@ -516,6 +568,19 @@ func (a *Actions) record(ctx context.Context, action, detail string) { }) } +// recordSetup writes an audit row for an event that happens BEFORE the admin +// exists (the D-044 unlock). The subject is anonymous — there is no user yet. +func (a *Actions) recordSetup(ctx context.Context, action, detail string) { + if a.d.Audit == nil { + return + } + a.d.Audit.Record(ctx, audit.Event{ + Action: action, + Subject: "anonymous", + Detail: detail, + }) +} + // writeFile0600 is the default persister: the config holds secrets, so it is // written owner-only. func writeFile0600(path string, data []byte) error {