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