diff --git a/cmd/myday/main.go b/cmd/myday/main.go index 29db5ec..3eb3e0c 100644 --- a/cmd/myday/main.go +++ b/cmd/myday/main.go @@ -1,9 +1,8 @@ // Command myday is the MyDay entrypoint. // // It assembles config → core services → the registry → the door and serves -// (SPEC-003 §3). Any address may be bound (D-025 revised / D-043): the -// bootstrap is protected per request — in setup mode the door serves only the -// direct SSH tunnel — not by refusing a non-loopback bind. +// (SPEC-003 §3). Any address may be bound (D-043): the bootstrap is gated by +// the `setup` config toggle (D-045), not by refusing a non-loopback bind. // // Spec: docs/specs/spec-003-core-door.md, docs/specs/spec-006-enroll-login.md. package main @@ -120,25 +119,23 @@ func run() error { ids := core.Identities() sessions := core.Sessions() - // Setup mode (SPEC-005 §3): `config.setup` OR the instance is not yet - // bootstrapped (no user, or no ACTIVE admin). It is LATCHED at startup, so - // the wizard can run to completion in this process — mid-wizard the admin - // exists but is still pending, and a live "no user" check would end setup - // mode too early. The wizard clears it via Done(); `config.Setup` is the - // startup flag and is never mutated (so the door reads it race-free). + // D-045: three states, decided by the `setup` toggle and whether the + // instance has any user at all. + // + // * `setup: true` → the WIZARD (the operator's switch); + // * `setup: false`, no user → LOCKED (an "enable setup" boilerplate; + // deliberately not publicly claimable); + // * `setup: false`, a user exists → PRODUCTIVE. + // + // `bootstrapped` is LATCHED at startup: a live per-request count would be a + // needless DB hit, and no user can appear while the instance is locked (the + // wizard is off). An unreadable count fails closed to "no user" → locked, + // which asks the operator to act rather than exposing the bootstrap. bootstrapped := func() bool { - ctx := context.Background() - n, err := us.Count(ctx) - if err != nil || n == 0 { - return false // fail-safe: an unreadable count means "not bootstrapped" - } - idn, err := ids.ByUserID(ctx, 1) - if err != nil { - return false - } - return idn.Status == auth.StatusActive - } - inSetup := cfg.Setup || !bootstrapped() + n, err := us.Count(context.Background()) + return err == nil && n > 0 + }() + inSetup := cfg.Setup // Adopt a generated TOTP key only where it is legitimate: a first boot // (setup mode). Outside setup mode a placeholder is a configuration fault @@ -180,12 +177,17 @@ func run() error { } var setupFlow *setup.Actions + // setupMode (D-045) reports whether the WIZARD is enabled: the `setup` + // toggle is on and the wizard has not completed in this process. setupMode := func() bool { if setupFlow != nil && setupFlow.Done() { return false } return inSetup } + // locked (D-045) reports the non-claimable dead end: `setup: false` and no + // user exists. Latched at startup (see `bootstrapped`). + locked := func() bool { return !cfg.Setup && !bootstrapped } // SPEC-006: the enroll/login flow (WebAuthn + TOTP). The passkey nudge // (SPEC-006 §8) is suppressed in setup mode — a loopback passkey is @@ -325,9 +327,11 @@ 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). + // D-045: the non-claimable locked state — `setup: false` and no admin. + LockedMode: locked, + // D-044/D-045: the first-run gate — the OPTIONAL setup code + the + // signed unlock cookie, and the first-run pages (the code form, the + // `GET /` landing, and the locked boilerplate). SetupGate: setupFlow.Gate(), // SPEC-007: the tiered role gate (D-034). The floor admits any // authenticated user; role-ABOVE rules live in the services. @@ -346,9 +350,13 @@ func run() error { return err } - if setupMode() { + switch { + case setupMode(): slog.Warn("myday: SETUP MODE — the instance is not configured yet") - fmt.Fprint(os.Stderr, setupBanner(cfg, setupFlow.Code())) + fmt.Fprint(os.Stderr, setupBanner(cfg, setupFlow.Code(), setupFlow.CodeRequired())) + case locked(): + slog.Warn("myday: LOCKED — no admin exists and setup is disabled") + fmt.Fprint(os.Stderr, lockedBanner()) } if err := srv.Serve(cfg.Server.Listen); err != nil { @@ -471,52 +479,75 @@ func configTarget(cfg *config.Config) string { // 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 { +// setupBanner is the unmistakable first-run notice (D-044/D-045): that the +// instance is in setup mode, the wizard URL, and — only when `setup_code` is +// set (`codeRequired`) — the printed setup CODE. It is printed to stderr, so it +// lands in the same journal as the structured logs. +func setupBanner(cfg *config.Config, code string, codeRequired bool) 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.) + codeBlock := "" + if codeRequired { + codeBlock = fmt.Sprintf(` + The wizard is protected by a one-time SETUP CODE: SETUP CODE: %s Read the code again: journalctl -u myday (in the container) pct exec %s -- journalctl -u myday (from the Proxmox host) +`, code, setupContainerID) + } + line := strings.Repeat("=", 70) + return fmt.Sprintf(` +%s + MyDay is in SETUP MODE — no admin exists yet. +%s + Open the setup wizard at: + + %s +%s 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. + - GET / points at the wizard; + - every other route answers 404. - Finishing the wizard writes config.yaml. RESTART MyDay to adopt the - persisted secrets: + Finishing the wizard writes config.yaml with setup: false and creates the + admin. RESTART MyDay to adopt the persisted secrets: systemctl restart myday %s -`, line, line, setupURL, code, setupContainerID, line) +`, line, line, setupURL, codeBlock, line) +} + +// lockedBanner is the notice for the non-claimable locked state (D-045): +// `setup: false` and no admin. The operator must flip the toggle and restart. +func lockedBanner() string { + line := strings.Repeat("=", 70) + return fmt.Sprintf(` +%s + MyDay is LOCKED — no admin exists and setup is disabled. +%s + + To bring this instance to life, set + + setup: true + + in config.yaml and restart MyDay, then open /setup: + + systemctl restart myday + +%s +`, line, line, 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 setup gate, D-025/D-044) working before the domain is configured. +// (the `setup` toggle, D-045) 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 3867e88..c376f5b 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -4,20 +4,21 @@ # Settings live HERE, not in the database (D-035). # ── setup & recovery ───────────────────────────────────────────────────────── -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. +setup: true # D-045 — the setup gate. While true the app serves the + # /setup wizard at the public domain: completing it writes + # `setup: false` and creates the admin. While FALSE with no + # admin yet, the app is LOCKED (a boilerplate at / and + # /setup; every other route 404) — deliberately not + # publicly claimable, so a fresh instance never exposes + # the bootstrap by accident. 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. +# D-044/D-045 — the OPTIONAL first-run SETUP CODE (a second factor for /setup). +# Leave it EMPTY ("") and the `setup: true` toggle alone admits — the wizard is +# served directly. Set it and the code form comes first; the code is single-use, +# rate-limited and redacted from the logs (A8). If you expose MyDay publicly, +# SET THIS: while `setup: true` whoever reaches /setup first could otherwise +# claim the admin. setup_code: "" # ── server ─────────────────────────────────────────────────────────────────── @@ -28,7 +29,7 @@ server: listen: "0.0.0.0:8080" # D-043 — any bind is allowed (no setup-mode refusal) base_url: "https://myday.sechpoint.app" # the PUBLIC origin: invite / recovery links # Zoraxy's address — the ONLY peer whose forwarding headers are honoured (A6/D-043). - # Zoraxy MUST set `X-Forwarded-For` (the setup gate's "no XFF" test relies on it) + # Zoraxy MUST set `X-Forwarded-For` (so the audit log records the real client) # and `X-Forwarded-Proto`. A WRONG VALUE HERE FAILS SILENTLY: `X-Forwarded-Proto` # is then ignored, the session cookie loses `Secure` (and with it the `__Host-` # prefix and HSTS), and the audit log records the proxy — not the user — as the diff --git a/internal/core/config/config.go b/internal/core/config/config.go index c7be1a3..7fae05e 100644 --- a/internal/core/config/config.go +++ b/internal/core/config/config.go @@ -4,12 +4,13 @@ // Settings live in the config file, not the database (D-035): infra · secrets · // domain · mail · locale. The real `config.yaml` is gitignored (D-010); the // committed `config.example.yaml` is used as a development fallback so a fresh -// checkout can still start (in setup mode, reachable only over the tunnel). +// checkout can still start (in setup mode — the `setup` toggle, D-045). // // Fail-closed rule (SPEC-003 §1): in PRODUCTIVE mode (`setup: false`) the // process refuses to start if a required secret is missing or weak. Setup mode // tolerates the placeholder values in the example, because the operator has not -// provisioned the instance yet — and setup mode is tunnel-only (D-025). +// provisioned the instance yet — and setup mode is gated by the `setup` toggle +// (D-045). // // Spec: docs/specs/spec-003-core-door.md §1, §7. package config @@ -45,18 +46,18 @@ const ( // Config is the typed shape of `config.yaml` / `config.example.yaml`. type Config struct { - // Setup forces setup mode (D-026). Together with "no user exists" it makes - // the instance serve only /setup, tunnel-only (D-025, SPEC-005 §3). + // Setup is the setup gate (D-045). While true the app serves the /setup + // wizard; when false with no admin it is LOCKED (a boilerplate telling the + // operator to enable setup). Completion writes `setup: false`. Setup bool `yaml:"setup"` // AdminRecovery is the config approval flag for admin recovery (D-033). // 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 is the OPTIONAL first-run setup code (D-044 · D-045). When set, + // the wizard requires it (a second factor); when blank the `setup` toggle + // alone admits. It is only consulted while setup is enabled. SetupCode string `yaml:"setup_code"` Server Server `yaml:"server"` @@ -227,8 +228,8 @@ func (c *Config) Validate() error { } // Setup mode may run with placeholder secrets: the operator has not yet - // provisioned the instance, and it is tunnel-only (D-025). Productive - // mode is strict. + // provisioned the instance, and it is gated by the `setup` toggle (D-045). + // Productive mode is strict. if c.Setup { return nil } diff --git a/internal/core/httpx/httpx_test.go b/internal/core/httpx/httpx_test.go index f98aadc..a786c46 100644 --- a/internal/core/httpx/httpx_test.go +++ b/internal/core/httpx/httpx_test.go @@ -117,37 +117,6 @@ func TestSecureFromRequestDefaultsFalse(t *testing.T) { } } -// TestSetupRequestAllowed pins D-025 (revised): only a loopback TCP peer with -// NO X-Forwarded-For is the direct tunnel; everything else — including a -// loopback peer carrying XFF, or an unparseable peer — fails closed. -func TestSetupRequestAllowed(t *testing.T) { - cases := []struct { - name string - peer string - xff string - want bool - }{ - {"loopback peer, no XFF", "127.0.0.1:52345", "", true}, - {"loopback peer with XFF", "127.0.0.1:52345", "203.0.113.9", false}, - {"remote peer", "203.0.113.9:4444", "", false}, - {"remote peer with XFF", "203.0.113.9:4444", "198.51.100.7", false}, - {"empty peer", "", "", false}, - {"unparseable peer", "not-an-address", "", false}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - r := httptest.NewRequest("GET", "/setup", nil) - r.RemoteAddr = tc.peer - if tc.xff != "" { - r.Header.Set("X-Forwarded-For", tc.xff) - } - if got := httpx.SetupRequestAllowed(r); got != tc.want { - t.Errorf("SetupRequestAllowed = %v, want %v", got, tc.want) - } - }) - } -} - func TestClientIPUntrustedPeerIgnoresXFF(t *testing.T) { tp, _ := httpx.ParseTrustedProxies([]string{"127.0.0.1"}) r := httptest.NewRequest("GET", "/", nil) @@ -232,31 +201,6 @@ func TestIsLoopbackHost(t *testing.T) { } } -// TestIsLoopbackIPFailsClosed pins the setup-mode gate's predicate (H2): an -// empty or unparseable resolved client IP must NOT count as loopback. -func TestIsLoopbackIPFailsClosed(t *testing.T) { - cases := []struct { - ip string - want bool - }{ - {"127.0.0.1", true}, - {"127.0.0.5", true}, - {"::1", true}, - {"localhost", true}, - {"[::1]", true}, - {"203.0.113.9", false}, - {"10.0.0.5", false}, - {"", false}, // unparseable: fail closed - {"not-an-ip", false}, - {"0.0.0.0", false}, - } - for _, tc := range cases { - if got := httpx.IsLoopbackIP(tc.ip); got != tc.want { - t.Errorf("IsLoopbackIP(%q) = %v, want %v", tc.ip, got, tc.want) - } - } -} - // TestSessionCookieNameForPrefix pins L1s: the `__Host-` prefix is used exactly // when the cookie is Secure (a browser rejects a `__Host-` cookie over http). func TestSessionCookieNameForPrefix(t *testing.T) { diff --git a/internal/core/httpx/loopback.go b/internal/core/httpx/loopback.go index 185a06a..4e32c9b 100644 --- a/internal/core/httpx/loopback.go +++ b/internal/core/httpx/loopback.go @@ -2,7 +2,6 @@ package httpx import ( "net" - "net/http" "strings" ) @@ -19,38 +18,8 @@ func IsLoopbackHost(hostport string) bool { return !known || loopback } -// IsLoopbackIP reports whether a RESOLVED client IP (or a raw TCP peer address) -// is loopback. It gates the setup-mode check (SPEC-003 §7, D-025) and therefore -// FAILS CLOSED in the other direction: an empty or unparseable value is NOT -// loopback. -func IsLoopbackIP(ip string) bool { - loopback, known := classifyHost(ip) - return known && loopback -} - -// SetupRequestAllowed reports whether a request may be served while the -// instance is in setup mode (SPEC-003 §7, D-025 revised 2026-10-15): the TCP -// PEER (the connection's RemoteAddr) must be loopback AND the request must -// carry NO `X-Forwarded-For` — i.e. the direct SSH tunnel. Anything else, -// including a request relayed by a trusted proxy, is refused (the door answers -// 404 before dispatch). -// -// Both tests are required: a proxy on the same host presents a loopback peer, -// so the peer alone is not enough — the ABSENCE of `X-Forwarded-For` is what -// proves the request did not traverse a proxy. An empty/unparseable peer fails -// closed. NB: this must be evaluated on the RAW request, before ClientIP() SETS -// the header (A6). -func SetupRequestAllowed(r *http.Request) bool { - if strings.TrimSpace(r.Header.Get("X-Forwarded-For")) != "" { - return false - } - return IsLoopbackIP(r.RemoteAddr) -} - // classifyHost reports whether hostport ("host", "host:port", or a bare IP) is -// loopback, and whether it could be classified at all. The two callers above -// resolve "unknown" in opposite directions on purpose: each is fail-closed for -// its own decision. +// loopback, and whether it could be classified at all. // // "Unknown" is only an empty/blank value: a NAME that is not `localhost` is // known — and it is not loopback. diff --git a/internal/core/httpx/scheme.go b/internal/core/httpx/scheme.go index d9362b7..037e010 100644 --- a/internal/core/httpx/scheme.go +++ b/internal/core/httpx/scheme.go @@ -21,7 +21,7 @@ func WithSecure(ctx context.Context, secure bool) context.Context { // SecureFromRequest reports whether the request is served over https, as // resolved by the door (D-043). The zero value is false — plain http — which is -// what keeps the loopback bootstrap (the SSH tunnel) working. +// what keeps a plain-http bootstrap working. func SecureFromRequest(r *http.Request) bool { v, _ := r.Context().Value(secureKey{}).(bool) return v diff --git a/internal/core/server/server.go b/internal/core/server/server.go index 95d724d..97080dc 100644 --- a/internal/core/server/server.go +++ b/internal/core/server/server.go @@ -61,14 +61,19 @@ type Deps struct { PreSession *httpx.PreSessionCSRF // TrustedProxies overrides the set parsed from Config.Server.TrustedProxies. TrustedProxies httpx.TrustedProxies - // SetupMode reports whether the instance is in setup mode (D-025). When nil - // it defaults to Config.Setup; the entrypoint widens it to "or no user - // exists" (SPEC-005 §3). + // SetupMode reports whether the WIZARD is enabled — the config toggle + // `setup` (D-045). When nil it defaults to Config.Setup; the entrypoint + // also clears it in-process once the wizard completes. 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. + // LockedMode reports whether the instance is LOCKED: `setup: false` and no + // admin exists yet (D-045). In that state `GET /` and `GET /setup` render + // the "enable setup" boilerplate and every other route is 404 — the state + // is deliberately not publicly claimable. When nil it is never locked. + LockedMode func() bool + // SetupGate is the first-run gate: the OPTIONAL setup code + the signed + // unlock cookie, and the first-run pages (D-044/D-045). When nil, setup + // mode keeps the strict behaviour (only `/setup` served, everything else + // 404) — the entrypoint always wires it. SetupGate SetupGate // RoleGate enforces per-function authorisation. Defaults to // AllowAuthenticated. @@ -95,6 +100,7 @@ type Server struct { precsrf *httpx.PreSessionCSRF trusted httpx.TrustedProxies setup func() bool + locked func() bool gate SetupGate roles RoleGate log *slog.Logger @@ -102,18 +108,25 @@ type Server struct { 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. +// SetupGate is the first-run gate the door consults. It reports whether a +// required setup code has been unlocked and renders the first-run pages that +// replace a bare 404 (D-044/D-045). It is implemented by *setup.Gate; +// declaring the seam here keeps the door free of the setup package's internals. type SetupGate interface { + // Required reports whether a setup code is required to reach the wizard + // (D-045: `setup_code` is set). When false the `setup` toggle alone admits. + Required() bool // 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 + Unlocked(r *http.Request) bool + // CodeForm renders the setup-code entry page (setup_code set, not yet + // unlocked). + CodeForm(w http.ResponseWriter, r *http.Request) error + // Landing renders the page `GET /` shows while setup is enabled. + Landing(w http.ResponseWriter, r *http.Request) error + // Locked renders the "enable setup and restart" page (setup: false, no + // admin). + Locked(w http.ResponseWriter, r *http.Request) error } // New validates the dependencies and builds the door. @@ -154,6 +167,10 @@ func New(d Deps) (*Server, error) { cfg := d.Config setup = func() bool { return cfg.Setup } } + locked := d.LockedMode + if locked == nil { + locked = func() bool { return false } + } logger := d.Logger if logger == nil { logger = slog.Default() @@ -179,6 +196,7 @@ func New(d Deps) (*Server, error) { precsrf: d.PreSession, trusted: trusted, setup: setup, + locked: locked, gate: d.SetupGate, roles: roles, log: logger, @@ -191,9 +209,9 @@ func New(d Deps) (*Server, error) { func (s *Server) Handler() http.Handler { return middleware.CorrelationID(s) } // Serve binds listen and serves the door until it fails. Any address may be -// bound — including `0.0.0.0:8080` behind a reverse proxy (D-025 revised, -// D-043); the bootstrap is protected per request by the setup gate, not by a -// bind check. +// bound — including `0.0.0.0:8080` behind a reverse proxy (D-043); the +// bootstrap is gated per request by the `setup` toggle (D-045), not by a bind +// check. func (s *Server) Serve(listen string) error { s.log.Info("myday: listening", "addr", listen, "setup_mode", s.setup()) srv := &http.Server{ @@ -208,10 +226,6 @@ func (s *Server) Serve(listen string) error { func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { start := s.now() - // D-025 (revised): the setup gate must see the RAW X-Forwarded-For, before - // step 2 below SETS it (A6). Capture the tunnel verdict now. - setupTunnel := httpx.SetupRequestAllowed(r) - ctx := r.Context() cid := middleware.CorrelationFrom(ctx) if cid == "" { @@ -269,47 +283,63 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { res = s.disp.Dispatch(r.URL.Path, r.FormValue("do"), r.FormValue("action")) // 6. the Kind switch. - s.dispatch(sw, r, res, cid, ip, setupTunnel) + s.dispatch(sw, r, res, cid, ip) } // 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 (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. +func (s *Server) dispatch(w http.ResponseWriter, r *http.Request, res routing.Resolution, cid, ip string) { + // SPEC-005 §3 / D-045: the setup gate is the config toggle `setup`, + // evaluated per request. + // + // * wizard mode (`setup: true`) — `GET /setup` serves the wizard. If a + // `setup_code` is set it must be unlocked first (D-044); `GET /` points + // at the wizard; every other route is 404. + // * locked mode (`setup: false`, no admin) — `GET /` and `GET /setup` + // render the "enable setup" boilerplate; every other route is 404. + // Deliberately not claimable: the operator must flip the toggle. if s.setup() { - unlocked := s.gate != nil && s.gate.Unlocked(r) - allowed := setupTunnel || unlocked + admitted := s.setupAdmitted(r) + // Static assets are the first-run pages' stylesheet — served in every + // first-run state (SPEC-005 §3). + static := res.Kind == routing.KindRoute && res.Workflow == "static" - // 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 { + case res.Kind == routing.KindRedirect && res.Redirect == "/home": + // `GET /` — point at the wizard (never the app, D-045). + s.serveLanding(w, r, cid, ip) + return + case res.Kind == routing.KindSetup && !admitted: + // `GET /setup` with a required code not yet entered. + s.serveCodeForm(w, r, cid, ip) + return + case static: + // fall through — the wizard's stylesheet. + case !s.isSetupUnlock(res) && (!admitted || !setupAllowed(res)): + // SPEC-005 §3: once admitted, the app serves ONLY /setup; every + // other route is 404 (delete, don't redirect). The unlock action is + // the one exception — it is what grants the unlock (D-044). + s.log.Warn("setup request refused: the wizard is not admitted", + "correlation_id", cid, "client_ip", ip, + "path", httpx.RedactPath(r.URL.Path)) + s.writeError(w, http.StatusNotFound, "not found") + return + } + } else if s.locked() { + // D-045: `setup: false` and no admin — a friendly, non-claimable + // boilerplate at `/` and `/setup`; 404 elsewhere (static assets are the + // one exception, so the boilerplate renders inside the shell). + switch { + case res.Kind == routing.KindRedirect && res.Redirect == "/home": + s.serveLocked(w, r, cid, ip) + return + case res.Kind == routing.KindSetup: + s.serveLocked(w, r, cid, ip) + return + case res.Kind == routing.KindRoute && res.Workflow == "static": + // fall through — the boilerplate's stylesheet (SPEC-005 §3). + default: + s.writeError(w, http.StatusNotFound, "not found") + return } } @@ -392,24 +422,32 @@ func (s *Server) dispatch(w http.ResponseWriter, r *http.Request, res routing.Re } } -// setupAllowed reports whether a Resolution is one of the only routes served -// while in setup mode: the wizard itself (`/setup`, path or action) and static -// assets (SPEC-005 §3). Everything else — including the canonical redirect and -// every GUID branch — is 404. +// setupAllowed reports whether a Resolution is the wizard itself — the +// reserved `setup` workflow, path or action (SPEC-005 §3). Used while the +// wizard is enabled: everything else is 404. Static assets are handled +// separately (they are served in every first-run state). func setupAllowed(res routing.Resolution) bool { switch res.Kind { case routing.KindSetup: return true case routing.KindAction: return res.Workflow == "setup" - case routing.KindRoute: - return res.Workflow == "static" } return false } +// setupAdmitted reports whether the request may reach the wizard while +// `setup: true` (D-045): always when no `setup_code` is required, otherwise +// only with a valid code unlock (D-044). +func (s *Server) setupAdmitted(r *http.Request) bool { + if s.gate == nil || !s.gate.Required() { + return true + } + return s.gate.Unlocked(r) +} + // 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 +// served while the wizard is enabled without 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 { @@ -419,17 +457,33 @@ func (s *Server) isSetupUnlock(res routing.Resolution) bool { 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 +// serveCodeForm renders the setup-code entry page — `GET /setup` with a required +// code not yet entered. 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) { +func (s *Server) serveCodeForm(w http.ResponseWriter, r *http.Request, cid, ip string) { + s.serveGatePage(w, r, cid, ip, "code form", func(g SetupGate) error { return g.CodeForm(w, r) }) +} + +// serveLanding renders the page `GET /` shows while `setup: true` (D-045). +func (s *Server) serveLanding(w http.ResponseWriter, r *http.Request, cid, ip string) { + s.serveGatePage(w, r, cid, ip, "setup landing", func(g SetupGate) error { return g.Landing(w, r) }) +} + +// serveLocked renders the "enable setup" boilerplate (D-045). +func (s *Server) serveLocked(w http.ResponseWriter, r *http.Request, cid, ip string) { + s.serveGatePage(w, r, cid, ip, "locked boilerplate", func(g SetupGate) error { return g.Locked(w, r) }) +} + +// serveGatePage hands off to a first-run page rendered by the gate, mapping a +// render fault to a logged 500. A nil gate keeps the strict 404 — the +// entrypoint always wires it. +func (s *Server) serveGatePage(w http.ResponseWriter, r *http.Request, cid, ip, label string, render func(SetupGate) error) { 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) + if err := render(s.gate); err != nil { + s.log.Error("setup: render "+label, "correlation_id", cid, "client_ip", ip, "error", err) s.writeError(w, http.StatusInternalServerError, "internal error") } } diff --git a/internal/core/server/server_test.go b/internal/core/server/server_test.go index aca3977..d8d1153 100644 --- a/internal/core/server/server_test.go +++ b/internal/core/server/server_test.go @@ -185,8 +185,10 @@ 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. + // The first-run gate (D-044/D-045). It is only consulted while the wizard is + // enabled; a REQUIRED code is wired here so a setup-mode test exercises the + // code form + the unlock. A test that wants the code-less wizard overrides + // SetupGate. gate, err := setup.NewGate("test-setup-code", pre, nil) if err != nil { t.Fatalf("setup.NewGate: %v", err) @@ -275,16 +277,14 @@ func (h *harness) cookie() *http.Cookie { return &http.Cookie{Name: httpx.SessionCookieName, Value: h.session.ID} } -// loopbackPeer is the RemoteAddr the test door speaks from. Setup mode is -// tunnel-only PER REQUEST (SPEC-003 §7, D-025): a loopback peer with no XFF. -// Tests must explicitly opt in to a different peer (or add XFF) to exercise -// the rejection. +// loopbackPeer is the RemoteAddr the test door speaks from. It is a plain +// same-host peer: the setup gate is the `setup` toggle (D-045), not the peer. const loopbackPeer = "127.0.0.1:52345" // request issues a request directly against the door (no network). func request(h http.Handler, method, target string, cookies ...*http.Cookie) *httptest.ResponseRecorder { req := httptest.NewRequest(method, target, nil) - req.RemoteAddr = loopbackPeer // the operator reaches /setup over a tunnel + req.RemoteAddr = loopbackPeer for _, c := range cookies { req.AddCookie(c) } @@ -468,26 +468,35 @@ func TestAC6_ActionRequiresCSRF(t *testing.T) { // ── AC-7 · AC-10 ───────────────────────────────────────────────────────────── -// 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 } }) +// TestAC7_SetupGateIsTheConfigToggle covers D-045 + D-044 at the door: while the +// wizard is enabled with a required setup code, the code is the gate — no peer +// (loopback or proxied) reaches the wizard without the unlock, and every visitor +// sees the code form. The peer/tunnel rule of D-025 is retired. The unlock +// action itself is the ONE request admitted without an unlock. +func TestAC7_SetupGateIsTheConfigToggle(t *testing.T) { + unlocked := func(w http.ResponseWriter, r *http.Request, _ *server.RequestContext) error { + http.SetCookie(w, &http.Cookie{Name: setup.SetupUnlockCookieName, Value: "granted", Path: "/"}) + http.Redirect(w, r, "/setup", http.StatusSeeOther) + return nil + } + h := newHarness(t, func(d *server.Deps) { + d.SetupMode = func() bool { return true } + reg := testRegistry(nil) + reg.RegisterModule(server.NewModule("setup"). + AddAction("unlock", "unlock", unlocked). + AddAction("domain.save", "domain.save", unlocked)) + d.Registry = reg + }) cases := []struct { - name string - peer string - xff string - wizard bool - comment string + name string + peer string + xff string }{ - {"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"}, + {"a loopback peer", loopbackPeer, ""}, + {"a same-host proxy", "127.0.0.1:9999", "203.0.113.9"}, + {"a remote peer", "203.0.113.9:4444", ""}, + {"a remote peer behind a proxy", "203.0.113.9:4444", "198.51.100.7"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -499,23 +508,42 @@ func TestAC7_SetupGateIsTunnelOnly(t *testing.T) { rec := httptest.NewRecorder() h.srv.ServeHTTP(rec, req) if rec.Code != http.StatusOK { - t.Fatalf("GET /setup (%s) = %d, want 200 — never a bare 404 (D-044)", tc.comment, rec.Code) + t.Fatalf("GET /setup (%s) = %d, want 200 (the code form)", tc.name, 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) + t.Errorf("GET /setup (%s) reached the wizard without the code (D-045)", tc.name) + } + if !strings.Contains(body, `name="code"`) { + t.Errorf("GET /setup (%s) did not show the code form (D-044)", tc.name) } }) } + + // A non-unlock wizard step is refused before dispatch while not admitted. + if rec := request(h.srv, "POST", "/setup?do=setup&action=domain.save"); rec.Code != http.StatusNotFound { + t.Errorf("domain.save without the unlock = %d, want 404 (D-045)", rec.Code) + } + + // The unlock action is the ONE carve-out: admitted without an unlock (it is + // what grants it), and it needs the pre-session CSRF token (A15). + cookie, token := h.preCSRF() + form := url.Values{"csrf": {token}, "code": {"whatever"}} + req := httptest.NewRequest("POST", "/setup?do=setup&action=unlock", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.RemoteAddr = loopbackPeer + req.AddCookie(cookie) + rec := httptest.NewRecorder() + h.srv.ServeHTTP(rec, req) + if rec.Code != http.StatusSeeOther { + t.Errorf("POST unlock (the carve-out) = %d, want 303 (D-044)", rec.Code) + } + + // Without the CSRF token it is refused (A15). + rec = request(h.srv, "POST", "/setup?do=setup&action=unlock") + if rec.Code != http.StatusForbidden { + t.Errorf("POST unlock without CSRF = %d, want 403 (A15)", rec.Code) + } } // ── AC-8 ───────────────────────────────────────────────────────────────────── @@ -679,7 +707,8 @@ func TestRequestContextHandover(t *testing.T) { } } -// TestSetupModeRouting: /setup is served only in setup mode (SPEC-003 §4). +// TestSetupModeRouting: /setup is served only while the wizard is enabled +// (SPEC-003 §4, D-045). func TestSetupModeRouting(t *testing.T) { productive := newHarness(t) if rec := request(productive.srv, "GET", "/setup"); rec.Code != http.StatusNotFound { @@ -690,37 +719,39 @@ func TestSetupModeRouting(t *testing.T) { t.Errorf("POST /setup?do=setup in productive mode = %d, want 404", rec.Code) } + // Setup mode: /setup is served — the harness gate requires a code, so this + // is the code form (200), never a bare 404 (D-045). inSetup := newHarness(t, func(d *server.Deps) { d.SetupMode = func() bool { return true } }) if rec := request(inSetup.srv, "GET", "/setup"); rec.Code != http.StatusOK { t.Errorf("GET /setup in setup mode = %d, want 200", rec.Code) } } -// 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) { +// TestSpec003SetupGateIsPerRequest covers AC-10 under D-045: while the wizard +// is enabled with a required code, `GET /setup` is the code form from EVERY +// peer — the wizard needs the unlock, which is not a peer property (the D-025 +// tunnel rule is retired). Every non-wizard route is 404. +func TestSpec003SetupGateIsPerRequest(t *testing.T) { h := newHarness(t, func(d *server.Deps) { d.SetupMode = func() bool { return true } }) - // A remote client → the boilerplate at /setup, and 404 for everything else - // (including the wizard's own steps, which need the unlock). + // A remote client: the code form, never the wizard. 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 rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `name="code"`) { + t.Errorf("GET /setup from a remote client = %d, want the code form (D-045)", rec.Code) } if strings.Contains(rec.Body.String(), "Bootstrap wizard") { - t.Error("GET /setup from a remote client reached the wizard (D-025)") + t.Error("GET /setup from a remote client reached the wizard without the code (D-045)") } + // Every other route is 404. (Static assets are the first-run stylesheet, so + // they are served — see TestSpec009_StaticIsServedInSetupMode.) for _, tc := range []struct{ method, target string }{ {"POST", "/setup?do=setup&action=domain.save"}, - {"GET", "/static/app.css"}, {"GET", "/home"}, + {"GET", "/" + h.guid}, } { req := httptest.NewRequest(tc.method, tc.target, nil) req.RemoteAddr = "203.0.113.9:4444" // a remote peer through the proxy @@ -730,29 +761,6 @@ func TestSpec003SetupIsLoopbackOnlyPerRequest(t *testing.T) { t.Errorf("%s %s from a non-loopback client = %d, want 404 (AC-10)", tc.method, tc.target, rec.Code) } } - - // The tunnel (a loopback peer, no XFF) still works. - if rec := request(h.srv, "GET", "/setup"); rec.Code != http.StatusOK { - t.Errorf("GET /setup from loopback = %d, want 200 (the tunnel must work)", rec.Code) - } - - // 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.RemoteAddr = "127.0.0.1:9999" // the proxy itself is local... - req.Header.Set("X-Forwarded-For", "203.0.113.9") - rec = httptest.NewRecorder() - h2.srv.ServeHTTP(rec, req) - 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)") - } } // TestSpec003SetupModulePinnedAtTheDoor covers M1c/L3s: the wizard's module is diff --git a/internal/core/server/setup_code_test.go b/internal/core/server/setup_code_test.go index 0dd4200..06e68c7 100644 --- a/internal/core/server/setup_code_test.go +++ b/internal/core/server/setup_code_test.go @@ -1,10 +1,17 @@ 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. +// D-045 — the setup gate is the config toggle `setup` (SPEC-005 §3/§4, +// SPEC-003 §7). These tests drive the real `/setup` gate end to end: +// +// * `setup: false`, no admin → LOCKED: `/` and `/setup` render an "enable +// setup" boilerplate (never a bare 404), every other route 404, and the +// wizard is not claimable; +// * `setup: true`, no code → the wizard is served directly; +// * `setup: true`, code set → the code form comes first (D-044), and the +// correct code unlocks the wizard. +// +// The D-044 code mechanics (single-use · rate-limited · CSRF · redacted) are +// exercised here too. import ( "log/slog" @@ -20,13 +27,14 @@ import ( ) // 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. +// a loopback TCP peer that carries X-Forwarded-For. Under D-045 the peer no +// longer decides setup access, but the proxy path is still the realistic one. 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). +// proxyGet issues a GET from behind the proxy. func (h *setupHarness) proxyGet(target string, cookies ...*http.Cookie) *httptest.ResponseRecorder { h.t.Helper() req := httptest.NewRequest("GET", target, nil) @@ -74,67 +82,68 @@ func unlockCookie(t *testing.T, rec *httptest.ResponseRecorder) *http.Cookie { return nil } -// ── the boilerplate (never a bare 404) ─────────────────────────────────────── +// ── state 2 · `setup: true` without a code (D-045) ─────────────────────────── -// 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) +// TestD045_NoCodeServesTheWizardDirectly: with `setup: true` and no +// `setup_code`, `GET /setup` is the wizard for every visitor — no form, no +// unlock. `GET /` points at it. +func TestD045_NoCodeServesTheWizardDirectly(t *testing.T) { + h := newSetupHarness(t) // default: no code required - 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) + for name, rec := range map[string]*httptest.ResponseRecorder{ + "loopback": request(h.srv, "GET", "/setup"), + "proxied": h.proxyGet("/setup"), + } { + if rec.Code != http.StatusOK { + t.Fatalf("GET /setup (%s) = %d, want 200 (the wizard)", name, rec.Code) + } + body := rec.Body.String() + if !strings.Contains(body, "domain.save") { + t.Errorf("GET /setup (%s) did not render the wizard (D-045)", name) + } + if strings.Contains(body, `action="/setup?do=setup&action=unlock"`) { + t.Errorf("GET /setup (%s) showed the code form though no code is required (D-045)", name) } } - // 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) + + // `GET /` is a landing pointing at the wizard — never a bare 404, never the + // wizard itself. + root := h.proxyGet("/") + if root.Code != http.StatusOK { + t.Fatalf("GET / = %d, want 200 (D-045)", root.Code) + } + if !strings.Contains(root.Body.String(), "/setup") { + t.Error("GET / did not point at the wizard (D-045)") } } -// ── unlock ─────────────────────────────────────────────────────────────────── +// ── state 2 · `setup: true` with a code (D-044) ────────────────────────────── -// 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) +// TestD045_CodeFormAndUnlock: with `setup_code` set, `GET /setup` shows the code +// form; the correct code unlocks the wizard for that browser. +func TestD045_CodeFormAndUnlock(t *testing.T) { + h := newSetupHarness(t, withSetupCode("t3st-c0de-9999")) + + form := h.proxyGet("/setup") + if form.Code != http.StatusOK { + t.Fatalf("GET /setup with a code set = %d, want 200 (the form)", form.Code) + } + for _, want := range []string{ + `action="/setup?do=setup&action=unlock"`, + `name="code"`, + "not set up yet", + } { + if !strings.Contains(form.Body.String(), want) { + t.Errorf("the code form is missing %q", want) + } + } + if strings.Contains(form.Body.String(), "domain.save") { + t.Error("GET /setup served the wizard before the code (D-044)") + } + // The anonymous form carries the pre-session CSRF cookie (A15). + if !hasCookie(form, httpx.PreSessionCookieName) { + t.Error("the code form issued no pre-session CSRF cookie (A15)") + } rec := h.proxyPost("unlock", url.Values{"code": {h.code}}) if rec.Code != http.StatusSeeOther { @@ -151,47 +160,30 @@ func TestD044_CorrectCodeUnlocksTheWizard(t *testing.T) { 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)") + if !strings.Contains(got.Body.String(), "domain.save") { + t.Error("GET /setup after unlock did not render the wizard (D-044)") } - // A different browser (no unlock cookie) still gets the boilerplate. + // A different browser (no unlock cookie) still gets the form. 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) + h := newSetupHarness(t, withSetupCode("t3st-c0de-9999")) 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) @@ -221,7 +213,7 @@ func TestD044_WrongOrAbsentCodeIsGeneric(t *testing.T) { // 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 := newSetupHarness(t, withSetupCode("t3st-c0de-9999"), func(h *setupHarness) { h.rateLimit = 2 h.rateWindow = time.Minute }) @@ -243,7 +235,7 @@ func TestD044_UnlockIsRateLimited(t *testing.T) { // 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) + h := newSetupHarness(t, withSetupCode("t3st-c0de-9999")) req := httptest.NewRequest("POST", "/setup?do=setup&action=unlock", strings.NewReader(url.Values{"code": {h.code}}.Encode())) @@ -264,7 +256,7 @@ func TestD044_UnlockRequiresCSRF(t *testing.T) { // 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) + h := newSetupHarness(t, withSetupCode("t3st-c0de-9999")) if rec := h.proxyPost("unlock", url.Values{"code": {h.code}}); rec.Code != http.StatusSeeOther { t.Fatalf("first unlock = %d, want 303", rec.Code) @@ -285,7 +277,7 @@ func TestD044_CodeIsSingleUse(t *testing.T) { // 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 := newSetupHarness(t, withSetupCode("t3st-c0de-9999"), func(h *setupHarness) { h.logger = slog.New(slog.NewTextHandler(&logs, nil)) }) @@ -306,42 +298,42 @@ func TestD044_CodeIsRedactedFromTheAccessLog(t *testing.T) { // ── 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) { +// TestD045_BlankCodeIsNotRequired: a blank `setup_code` means no code is +// required, nothing is printed, and no unlock is possible. +func TestD045_BlankCodeIsNotRequired(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") + if gate.Required() { + t.Error("a blank code must not be required (D-045)") } - // 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) + if gate.Code() != "" { + t.Errorf("a blank code printed %q, want nothing (D-045)", gate.Code()) } - - // 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 gate.Accept("anything") { + t.Error("a non-required gate accepted a code") } - if other.Code() == code { - t.Error("two gates generated the same code") + if gate.Grant() != "" { + t.Error("a non-required gate granted an unlock cookie") } } -// TestD044_GateAcceptsFormattingAndRetires: the comparison ignores case and -// separators, and the accepted code is spent. -func TestD044_GateAcceptsFormattingAndRetires(t *testing.T) { +// TestD044_SetCodeIsRequiredAndRetires: a set `setup_code` is required, is +// printed verbatim, ignores formatting, and is single-use. +func TestD044_SetCodeIsRequiredAndRetires(t *testing.T) { pre := mustPreSessionCSRF(t) gate, err := setup.NewGate("9F3A-1B2C", pre, nil) if err != nil { t.Fatalf("NewGate: %v", err) } + if !gate.Required() { + t.Fatal("a set code must be required (D-045)") + } + if gate.Code() != "9F3A-1B2C" { + t.Errorf("Code() = %q, want the configured value", gate.Code()) + } if gate.Accept("nope") { t.Error("a wrong code was accepted") diff --git a/internal/core/server/setup_wizard_test.go b/internal/core/server/setup_wizard_test.go index c035ce3..ac2ce2f 100644 --- a/internal/core/server/setup_wizard_test.go +++ b/internal/core/server/setup_wizard_test.go @@ -45,7 +45,9 @@ 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 is the OPTIONAL setup code (D-045). Empty (the default) means no + // code is required and the wizard is served directly; a test that exercises + // the code path sets it via withSetupCode. code string // writeFile overrides the persister (a failing writer exercises the // "print the values" fallback, SPEC-005 §4). @@ -109,32 +111,26 @@ 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) } - // Setup mode (SPEC-005 §3), latched: config.setup OR not yet bootstrapped. - // Latched at startup so the wizard can finish in-process; cleared by Done(). + // D-045: the three states. `setupMode` is the `setup` toggle, cleared by + // Done(); `locked` is `setup: false` and no user. `bootstrapped` is latched + // at startup (the DB is empty here). bootstrapped := func() bool { n, err := us.Count(context.Background()) - if err != nil || n == 0 { - return false - } - idn, err := ids.ByUserID(context.Background(), 1) - if err != nil { - return false - } - return idn.Status == auth.StatusActive - } - inSetup := cfg.Setup || !bootstrapped() + return err == nil && n > 0 + }() + inSetup := cfg.Setup setupMode := func() bool { if h.setupFlow != nil && h.setupFlow.Done() { return false } return inSetup } + locked := func() bool { return !cfg.Setup && !bootstrapped } // The SPEC-006 flow, reused by the wizard. TOTPOnlyEnroll is the wizard's // D-041 mode (setup enrolls TOTP only); SuccessPath keeps the operator on @@ -178,7 +174,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). + // D-045: the non-claimable locked state. + LockedMode: locked, + // D-044/D-045: the optional-code gate + the first-run pages. SetupGate: setupFlow.Gate(), RateLimit: h.rateLimit, RateWindow: h.rateWindow, Logger: h.logger, }) @@ -189,6 +187,11 @@ func newSetupHarness(t *testing.T, opts ...func(*setupHarness)) *setupHarness { return h } +// withSetupCode pins a required setup code for a harness (D-044/D-045). +func withSetupCode(code string) func(*setupHarness) { + return func(h *setupHarness) { h.code = code } +} + // preCSRF mints a signed pre-session token plus its cookie (A15). func (h *setupHarness) preCSRF() (*http.Cookie, string) { h.t.Helper() @@ -211,8 +214,8 @@ func (h *setupHarness) post(action string, form url.Values, cookies ...*http.Coo 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") - // The wizard is tunnel-only per request (SPEC-003 §7, D-025): the operator - // reaches it over an SSH tunnel, so the tests speak from loopback with no XFF. + // The peer no longer decides setup access (D-045); the request is a plain + // wizard step over loopback. req.RemoteAddr = loopbackPeer req.AddCookie(cookie) for _, c := range cookies { @@ -266,10 +269,14 @@ func (h *setupHarness) enrollAdmin(t *testing.T) string { // ── AC-1 ───────────────────────────────────────────────────────────────────── +// TestSpec005AC1_SetupModeServesOnlySetup covers D-045 state 2 (`setup: true`): +// `GET /setup` serves the wizard (no code is required by default), `GET /` +// points at it, and every other route is 404. func TestSpec005AC1_SetupModeServesOnlySetup(t *testing.T) { h := newSetupHarness(t) - // Over the direct tunnel (gate a) `GET /setup` is the wizard. + // `setup: true` → `GET /setup` is the wizard, from any peer (there is no + // tunnel privilege any more, D-045). 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) @@ -282,29 +289,20 @@ 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 + // `GET /` is a friendly page pointing at the wizard — never the app. + root := request(h.srv, "GET", "/") + if root.Code != http.StatusOK { + t.Errorf("GET / while setup is enabled = %d, want 200 (D-045)", root.Code) } - 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) - } + if !strings.Contains(root.Body.String(), "/setup") { + t.Error("GET / did not point at the wizard (D-045)") + } + if strings.Contains(root.Body.String(), "domain.save") { + t.Error("GET / served the wizard itself; it should point at /setup (D-045)") } // EVERY other route is 404 — including the app the productive instance - // would serve (/home, /access, GUID branches) and the canonical redirect. + // would serve (/home, /access, GUID branches). 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) @@ -364,37 +362,52 @@ func TestR6_WizardGeneratesStrongSecrets(t *testing.T) { // ── AC-2 ───────────────────────────────────────────────────────────────────── -// 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) +// TestSpec005AC2_WizardRequiresTheCodeWhenSet covers D-044 + D-045: with +// `setup_code` SET, `GET /setup` shows the code form from ANY peer (the tunnel +// gate is retired); only an unlocked browser reaches the wizard, and a wrong +// code does not unlock. +func TestSpec005AC2_WizardRequiresTheCodeWhenSet(t *testing.T) { + h := newSetupHarness(t, withSetupCode("t3st-c0de-9999")) - // 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)") + for name, peer := range map[string]string{ + "loopback peer": loopbackPeer, + "remote peer": "203.0.113.9:4444", + } { + req := httptest.NewRequest("GET", "/setup", nil) + req.RemoteAddr = peer + rec := httptest.NewRecorder() + h.srv.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Errorf("GET /setup (%s) = %d, want 200 (the code form)", name, rec.Code) + } + if strings.Contains(rec.Body.String(), "domain.save") { + t.Errorf("GET /setup (%s) reached the wizard without the code (D-045)", name) + } + if !strings.Contains(rec.Body.String(), "name=\"code\"") { + t.Errorf("GET /setup (%s) did not show the code form (D-044)", name) + } } - // 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.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) + // Unlock per browser → the wizard; another browser still gets the form. + rec := h.proxyPost("unlock", url.Values{"code": {h.code}}) + if rec.Code != http.StatusSeeOther { + t.Fatalf("unlock = %d, want 303 (D-044)", rec.Code) + } + unlock := unlockCookie(t, rec) + if unlock == nil { + t.Fatal("the correct code set no unlock cookie (D-044)") + } + got := h.proxyGet("/setup", unlock) + if got.Code != http.StatusOK || !strings.Contains(got.Body.String(), "domain.save") { + t.Errorf("GET /setup after unlock = %d, want the wizard", got.Code) + } + 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)") } - proxied := httptest.NewRequest("GET", "/setup", nil) - proxied.RemoteAddr = "127.0.0.1:9999" - proxied.Header.Set("X-Forwarded-For", "203.0.113.9") - rec = httptest.NewRecorder() - h.srv.ServeHTTP(rec, proxied) - 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) + // A wrong code does not unlock. + if wrong := h.proxyPost("unlock", url.Values{"code": {"nope"}}); wrong.Code != http.StatusForbidden { + t.Errorf("a wrong code = %d, want 403 (D-044)", wrong.Code) } } @@ -611,23 +624,45 @@ func TestSpec005AdminCreateRepairsMissingIdentity(t *testing.T) { } } -// TestSpec005FailSafeSetupMode: with `config.setup: false` and an empty -// database the instance is STILL in setup mode (fail-safe) — and it stays there -// until the wizard finishes, even though creating the admin makes a live -// "no user exists" check false. -func TestSpec005FailSafeSetupMode(t *testing.T) { +// TestSpec005LockedState: `setup: false` with no admin is the LOCKED state +// (D-045): `/` and `/setup` render the "enable setup" boilerplate (never a bare +// 404), every other route is 404, and the wizard is unreachable — the state is +// deliberately not claimable. +func TestSpec005LockedState(t *testing.T) { h := newSetupHarness(t, func(h *setupHarness) { h.cfg.Setup = false }) - // Fresh: setup mode via the fail-safe clause. - if rec := request(h.srv, "GET", "/setup"); rec.Code != http.StatusOK { - t.Fatalf("GET /setup (fail-safe) = %d, want 200", rec.Code) + for _, p := range []string{"/", "/setup"} { + rec := request(h.srv, "GET", p) + if rec.Code != http.StatusOK { + t.Fatalf("GET %s locked = %d, want 200 (the boilerplate, never a bare 404)", p, rec.Code) + } + body := rec.Body.String() + if !strings.Contains(body, "setup: true") || !strings.Contains(body, "config.yaml") { + t.Errorf("GET %s did not render the locked boilerplate (D-045)", p) + } + if strings.Contains(body, "domain.save") || strings.Contains(body, `action="/setup?do=setup`) { + t.Errorf("GET %s exposed the wizard or the code form while locked (D-045)", p) + } } - if rec := request(h.srv, "GET", "/home"); rec.Code != http.StatusNotFound { - t.Fatalf("GET /home (fail-safe) = %d, want 404", rec.Code) + for _, p := range []string{"/home", "/access", "/some-guid", "/some-guid/home"} { + if rec := request(h.srv, "GET", p); rec.Code != http.StatusNotFound { + t.Errorf("GET %s locked = %d, want 404 (D-045)", p, rec.Code) + } } + // Not claimable: the wizard's own action 404s while locked. + if rec := h.post("unlock", url.Values{"code": {"anything"}}); rec.Code != http.StatusNotFound { + t.Errorf("POST unlock while locked = %d, want 404 (not claimable, D-045)", rec.Code) + } +} + +// TestSpec005SetupHoldsMidWizard: once the admin row exists the wizard must stay +// reachable until `finish`. The door must not fall out of setup mode on a live +// "a user exists" check (D-045: setup mode is the toggle; completion is the +// in-process Done signal). +func TestSpec005SetupHoldsMidWizard(t *testing.T) { + h := newSetupHarness(t) + h.beginAdmin(t) // creates user 1 (pending enrollment) - // Create the admin (now a user exists) and enroll — setup mode must HOLD. - h.beginAdmin(t) if rec := request(h.srv, "GET", "/setup"); rec.Code != http.StatusOK { t.Fatalf("GET /setup after admin.create = %d, want 200 (setup must hold mid-wizard)", rec.Code) } @@ -636,11 +671,14 @@ func TestSpec005FailSafeSetupMode(t *testing.T) { } h.enrollAdmin(t) - // Finish ends setup mode. + // Finish ends setup mode: /setup 404s and the app serves normally. if rec := h.post("finish", url.Values{"app.locale": {"en-GB"}}); rec.Code != http.StatusSeeOther { t.Fatalf("finish = %d, want 303", rec.Code) } + if !h.setupFlow.Done() { + t.Error("the wizard did not signal completion") + } if rec := request(h.srv, "GET", "/setup"); rec.Code != http.StatusNotFound { - t.Errorf("GET /setup after finish = %d, want 404", rec.Code) + t.Errorf("GET /setup after finish = %d, want 404 (D-045)", rec.Code) } } diff --git a/internal/core/server/setupflow.go b/internal/core/server/setupflow.go index d28eb13..b56ad8c 100644 --- a/internal/core/server/setupflow.go +++ b/internal/core/server/setupflow.go @@ -13,10 +13,11 @@ const setupModule = "setup" // RegisterSetupFlow registers the SPEC-005 `/setup` wizard: the page (the core // `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. +// (`config.setup`, D-045) 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. +// `unlock` is the D-044 code gate: when a `setup_code` is set the door lets it +// through without an existing unlock, because it is what grants the unlock. func RegisterSetupFlow(reg *MapRegistry, a *setup.Actions) { reg.RegisterCore("setup", adaptSetup(a.Page)) diff --git a/internal/core/setup/gate.go b/internal/core/setup/gate.go index 8b1b3c3..2a7ee14 100644 --- a/internal/core/setup/gate.go +++ b/internal/core/setup/gate.go @@ -1,11 +1,8 @@ package setup import ( - "crypto/rand" "crypto/subtle" - "encoding/hex" "errors" - "fmt" "log/slog" "net/http" "strings" @@ -21,42 +18,46 @@ import ( // 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. +// helper — a required 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. +// Gate is the first-run access gate. Under D-045 the gate is the **config +// toggle `setup`**: while `setup: true` the wizard is enabled. The setup CODE +// (`setup_code`) is an OPTIONAL second factor — when set, the wizard requires +// it; when empty, the toggle alone admits. // -// 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). +// When a code IS set the gate is the D-044 mechanism: 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 — the first successful unlock retires +// it — while the browser keeps its unlock cookie, so the operator can keep +// working and a second visitor cannot re-use the printed value. // -// 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. +// The gate also renders the three first-run pages so the door stays free of the +// setup package's HTML: +// +// - CodeForm — `setup: true` + a required code, not yet unlocked; +// - Landing — `setup: true`, `GET /` points at the wizard; +// - Locked — `setup: false` and no admin: enable setup and restart. // // Spec: docs/specs/spec-005-setup-wizard.md §3/§4. type Gate struct { csrf *httpx.PreSessionCSRF logger *slog.Logger + // required is immutable after construction: true when `setup_code` is set. + required bool + mu sync.Mutex - display string // the code as shown in the banner (grouped for readability) + display string // the code as shown in the banner (the configured value) 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. +// NewGate builds the gate from the configured code (D-044/D-045). An +// empty/blank code means NO code is required — the `setup` toggle alone admits +// and the wizard is served directly. A non-empty code is required. func NewGate(code string, csrf *httpx.PreSessionCSRF, logger *slog.Logger) (*Gate, error) { if csrf == nil { return nil, ErrNoGateCSRF @@ -68,19 +69,17 @@ func NewGate(code string, csrf *httpx.PreSessionCSRF, logger *slog.Logger) (*Gat display := strings.TrimSpace(code) norm := normalizeCode(display) if norm == "" { - generated, err := generateCode() - if err != nil { - return nil, err - } - display, norm = generated, normalizeCode(generated) + // D-045: no code — the config toggle is the gate. + return &Gate{csrf: csrf, logger: logger}, nil } return &Gate{ - csrf: csrf, - logger: logger, - display: display, - norm: norm, - fp: csrf.Signer().Fingerprint(fingerprintDomain + norm), + csrf: csrf, + logger: logger, + required: true, + display: display, + norm: norm, + fp: csrf.Signer().Fingerprint(fingerprintDomain + norm), }, nil } @@ -88,8 +87,12 @@ func NewGate(code string, csrf *httpx.PreSessionCSRF, logger *slog.Logger) (*Gat // 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. +// Required reports whether a setup code is required to reach the wizard +// (D-045: `setup_code` is set). When false the `setup` toggle alone admits. +func (g *Gate) Required() bool { return g.required } + +// Code returns the setup code to PRINT in the startup banner, or "" when no +// code is required (D-045) — there is nothing to print in that case. func (g *Gate) Code() string { g.mu.Lock() defer g.mu.Unlock() @@ -98,12 +101,13 @@ func (g *Gate) Code() string { // 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. +// formatting (case, spaces, dashes), so a hand-typed value works. It always +// returns false when no code is required. 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 + return false // not required, already consumed, or cleared — never re-usable } if subtle.ConstantTimeCompare([]byte(normalizeCode(code)), []byte(g.norm)) != 1 { return false @@ -124,8 +128,9 @@ func (g *Gate) Grant() string { 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). +// Unlocked reports whether the request carries a valid unlock grant (D-044). +// It is always false when no code is required — that case is admitted by the +// toggle alone, which the door decides (SPEC-003 §7). func (g *Gate) Unlocked(r *http.Request) bool { g.mu.Lock() fp := g.fp @@ -162,52 +167,45 @@ func (g *Gate) Clear() { g.mu.Unlock() } -// ── the first-run boilerplate (D-044) ─────────────────────────────────────── +// ── the first-run pages (D-044 · D-045) ───────────────────────────────────── -// 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, "") +// CodeForm renders the setup-code entry page — `setup: true` with a required +// `setup_code`, before the code is entered. 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) CodeForm(w http.ResponseWriter, r *http.Request) error { + return g.codeForm(w, r, http.StatusOK, "") } -// boilerplateError re-renders the page with a generic notice. The message is +// codeFormError 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) codeFormError(w http.ResponseWriter, r *http.Request) error { + return g.codeForm(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 { +func (g *Gate) codeForm(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)) + return renderHTML(w, status, codeFormPage(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 +// Landing renders the friendly page `GET /` shows while `setup: true` (D-045): +// a pointer to the wizard at `/setup`. +func (g *Gate) Landing(w http.ResponseWriter, r *http.Request) error { + return renderHTML(w, http.StatusOK, landingPage()) } +// Locked renders the page `GET /` and `GET /setup` show when `setup: false` and +// no admin exists (D-045): the operator must enable the toggle and restart. +// There is no form — this state is deliberately not claimable. +func (g *Gate) Locked(w http.ResponseWriter, r *http.Request) error { + return renderHTML(w, http.StatusOK, lockedPage()) +} + +// ── code normalisation ────────────────────────────────────────────────────── + // 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 { diff --git a/internal/core/setup/render.go b/internal/core/setup/render.go index e7e525e..21021ca 100644 --- a/internal/core/setup/render.go +++ b/internal/core/setup/render.go @@ -45,14 +45,14 @@ 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 +// codeFormPage renders the D-044 first-run page — what `GET /setup` shows while +// `setup: true` and a required `setup_code` has not yet been entered. 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 { +func codeFormPage(token, flash string) web.Page { c := html.EscapeString(token) kind := "" if flash != "" { @@ -84,6 +84,32 @@ setup code that MyDay printed to its log when it started.

} } +// landingPage is the friendly page `GET /` shows while `setup: true` (D-045) — +// a pointer to the wizard. It carries no form and no code. +func landingPage() web.Page { + return web.Page{ + Title: "MyDay setup", + Guest: true, + Content: web.Markup(`

MyDay is ready for setup

+

The setup wizard is enabled on this instance.

+

Open the setup wizard

`), + } +} + +// lockedPage is what `GET /` and `GET /setup` show when `setup: false` and no +// admin exists yet (D-045): the instance is deliberately locked, not claimable. +// It is a GUEST screen with no form. +func lockedPage() web.Page { + return web.Page{ + Title: "MyDay is not set up", + Guest: true, + Content: web.Markup(`

MyDay is not set up

+

This instance has no administrator yet, and setup is disabled.

+

To run the first-run setup, set setup: true in config.yaml and +restart MyDay. Then open /setup.

`), + } +} + // 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 81da875..cf80869 100644 --- a/internal/core/setup/setup.go +++ b/internal/core/setup/setup.go @@ -1,12 +1,16 @@ -// 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. +// Package setup implements SPEC-005: the `/setup` wizard that brings a fresh +// instance to life. The gate is the **config toggle `setup`** (D-045): // -// 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). +// - `setup: true` → `/setup` serves the wizard at the public domain; +// - `setup: false` + no admin → the instance is LOCKED: `GET /` and +// `GET /setup` render a boilerplate telling the operator to enable setup, +// and every other route is 404 (this state is not publicly claimable); +// - completion → the wizard writes `setup: false` and creates the admin. +// +// The setup CODE (`setup_code`) is an OPTIONAL second factor (D-044 · D-045): +// when set, `GET /setup` first shows the code form and `POST /setup?do=setup& +// action=unlock` with the correct code grants the unlock (a signed cookie, not +// a session — no user exists yet); when empty, the toggle alone admits. // // The wizard walks three steps — (1) the domain / base URL, (2) the top admin // with TOTP enrollment, (3) mail · locale · timezone — and finishes by @@ -21,8 +25,8 @@ // no duplication. // // The wizard holds no routing of its own — the door (SPEC-003/005) gates it to -// setup mode and the tunnel, and verifies the pre-session CSRF token (A15) -// before invoking any action here. +// setup mode and verifies the pre-session CSRF token (A15) before invoking any +// action here. // // Spec: docs/specs/spec-005-setup-wizard.md. package setup @@ -78,9 +82,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 is the OPTIONAL setup code (D-044 · D-045). When blank the toggle + // alone admits and the wizard is served directly; when set, the wizard + // requires it (a second factor). The startup banner prints it only when set + // (`Actions.Code`). Code string // Users creates and inspects the top admin. Users Users @@ -151,8 +156,8 @@ func New(d Deps) (*Actions, error) { if d.WriteFile == nil { d.WriteFile = writeFile0600 } - // 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). + // D-044: the first-run gate — the OPTIONAL setup code + the signed unlock + // cookie. A blank code means no code is required (D-045). gate, err := NewGate(d.Code, d.CSRF, d.Logger) if err != nil { return nil, err @@ -160,32 +165,43 @@ func New(d Deps) (*Actions, error) { 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. +// Code returns the setup code to print in the startup banner, or "" when no +// code is required (D-045) — there is nothing to print in that case. func (a *Actions) Code() string { return a.gate.Code() } +// CodeRequired reports whether the wizard requires a code (D-045: `setup_code` +// is set). The startup banner prints the code only when it does. +func (a *Actions) CodeRequired() bool { return a.gate.Required() } + // 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. +// unlocked and render the first-run pages (D-044/D-045). 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). +// Unlock is the D-044 code 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). +// When no code is required (D-045) the action is a no-op that simply lands on +// the wizard. 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.Required() { + redirect(w, r, "/setup") + return nil + } if !a.gate.Accept(r.FormValue("code")) { a.recordSetup(r.Context(), "setup.unlock", "refused") - return a.gate.boilerplateError(w, r) + return a.gate.codeFormError(w, r) } a.gate.SetUnlockCookie(w, r) a.recordSetup(r.Context(), "setup.unlock", "unlocked")