NextMyDay/internal/core/setup/gate.go
Claus Lohmar c7229e23ff feat(setup): gate setup with the config toggle and an optional code
D-045 — the setup gate is now `config.setup`, not the peer/SSH tunnel:

- setup: true      -> /setup serves the wizard (the code form first when
                      `setup_code` is set, via the existing D-044 gate);
                      GET / points at the wizard; every other route -> 404.
- setup: false + no admin -> LOCKED: GET / and /setup render an "enable
                      setup" boilerplate; every other route -> 404; the
                      wizard is not claimable.
- completion       -> writes `setup: false` and creates the admin.

`setup_code` is now OPTIONAL: blank => Gate.Required() false => the toggle
alone admits; set => required (reusing the unlock cookie/fingerprint/
redaction/rate-limit). The peer/tunnel gate is retired: `httpx.
SetupRequestAllowed` and `httpx.IsLoopbackIP` are deleted; the XFF/XFP
handling is kept for the client IP and the Secure/__Host- cookies + HSTS
(A6/A11/D-043). The !bootstrapped() fail-safe becomes the locked predicate,
latched at startup.

New door seams: Deps.LockedMode and SetupGate.{Required,CodeForm,Landing,
Locked}; the Gate renders three first-run pages. The startup banner states
the mode and prints the code only when required.

Tests cover the three states, the boilerplate, the code optionality and the
unlock mechanics; live-smoked end to end (locked / wizard / code form /
full bootstrap -> setup: false).
2026-10-15 06:47:33 +00:00

219 lines
7.9 KiB
Go

package setup
import (
"crypto/subtle"
"errors"
"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"
// ErrNoGateCSRF is returned when the gate is built without the pre-session CSRF
// 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 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.
//
// 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 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 (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/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
}
if logger == nil {
logger = slog.Default()
}
display := strings.TrimSpace(code)
norm := normalizeCode(display)
if norm == "" {
// D-045: no code — the config toggle is the gate.
return &Gate{csrf: csrf, logger: logger}, nil
}
return &Gate{
csrf: csrf,
logger: logger,
required: true,
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:"
// 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()
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. 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 // not required, 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).
// 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
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 pages (D-044 · D-045) ─────────────────────────────────────
// 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, "")
}
// 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) codeFormError(w http.ResponseWriter, r *http.Request) error {
return g.codeForm(w, r, http.StatusForbidden, "That setup code is not correct.")
}
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, codeFormPage(token, flash))
}
// 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 {
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()
}