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).
This commit is contained in:
parent
cec7340c55
commit
c7229e23ff
14 changed files with 669 additions and 590 deletions
|
|
@ -1,9 +1,8 @@
|
||||||
// Command myday is the MyDay entrypoint.
|
// Command myday is the MyDay entrypoint.
|
||||||
//
|
//
|
||||||
// It assembles config → core services → the registry → the door and serves
|
// 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
|
// (SPEC-003 §3). Any address may be bound (D-043): the bootstrap is gated by
|
||||||
// bootstrap is protected per request — in setup mode the door serves only the
|
// the `setup` config toggle (D-045), not by refusing a non-loopback bind.
|
||||||
// direct SSH tunnel — not by refusing a non-loopback bind.
|
|
||||||
//
|
//
|
||||||
// Spec: docs/specs/spec-003-core-door.md, docs/specs/spec-006-enroll-login.md.
|
// Spec: docs/specs/spec-003-core-door.md, docs/specs/spec-006-enroll-login.md.
|
||||||
package main
|
package main
|
||||||
|
|
@ -120,25 +119,23 @@ func run() error {
|
||||||
ids := core.Identities()
|
ids := core.Identities()
|
||||||
sessions := core.Sessions()
|
sessions := core.Sessions()
|
||||||
|
|
||||||
// Setup mode (SPEC-005 §3): `config.setup` OR the instance is not yet
|
// D-045: three states, decided by the `setup` toggle and whether the
|
||||||
// bootstrapped (no user, or no ACTIVE admin). It is LATCHED at startup, so
|
// instance has any user at all.
|
||||||
// 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
|
// * `setup: true` → the WIZARD (the operator's switch);
|
||||||
// mode too early. The wizard clears it via Done(); `config.Setup` is the
|
// * `setup: false`, no user → LOCKED (an "enable setup" boilerplate;
|
||||||
// startup flag and is never mutated (so the door reads it race-free).
|
// 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 {
|
bootstrapped := func() bool {
|
||||||
ctx := context.Background()
|
n, err := us.Count(context.Background())
|
||||||
n, err := us.Count(ctx)
|
return err == nil && n > 0
|
||||||
if err != nil || n == 0 {
|
}()
|
||||||
return false // fail-safe: an unreadable count means "not bootstrapped"
|
inSetup := cfg.Setup
|
||||||
}
|
|
||||||
idn, err := ids.ByUserID(ctx, 1)
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return idn.Status == auth.StatusActive
|
|
||||||
}
|
|
||||||
inSetup := cfg.Setup || !bootstrapped()
|
|
||||||
|
|
||||||
// Adopt a generated TOTP key only where it is legitimate: a first boot
|
// Adopt a generated TOTP key only where it is legitimate: a first boot
|
||||||
// (setup mode). Outside setup mode a placeholder is a configuration fault
|
// (setup mode). Outside setup mode a placeholder is a configuration fault
|
||||||
|
|
@ -180,12 +177,17 @@ func run() error {
|
||||||
}
|
}
|
||||||
|
|
||||||
var setupFlow *setup.Actions
|
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 {
|
setupMode := func() bool {
|
||||||
if setupFlow != nil && setupFlow.Done() {
|
if setupFlow != nil && setupFlow.Done() {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return inSetup
|
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: the enroll/login flow (WebAuthn + TOTP). The passkey nudge
|
||||||
// (SPEC-006 §8) is suppressed in setup mode — a loopback passkey is
|
// (SPEC-006 §8) is suppressed in setup mode — a loopback passkey is
|
||||||
|
|
@ -325,9 +327,11 @@ func run() error {
|
||||||
CSRF: csrf,
|
CSRF: csrf,
|
||||||
PreSession: preSession,
|
PreSession: preSession,
|
||||||
SetupMode: setupMode,
|
SetupMode: setupMode,
|
||||||
// D-044: the first-run gate — the setup code + the signed unlock
|
// D-045: the non-claimable locked state — `setup: false` and no admin.
|
||||||
// cookie, and the boilerplate `/` and `/setup` serve before the code is
|
LockedMode: locked,
|
||||||
// entered (never a bare 404).
|
// 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(),
|
SetupGate: setupFlow.Gate(),
|
||||||
// SPEC-007: the tiered role gate (D-034). The floor admits any
|
// SPEC-007: the tiered role gate (D-034). The floor admits any
|
||||||
// authenticated user; role-ABOVE rules live in the services.
|
// authenticated user; role-ABOVE rules live in the services.
|
||||||
|
|
@ -346,9 +350,13 @@ func run() error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if setupMode() {
|
switch {
|
||||||
|
case setupMode():
|
||||||
slog.Warn("myday: SETUP MODE — the instance is not configured yet")
|
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 {
|
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.
|
// copy-pasteable from the Proxmox host.
|
||||||
const setupContainerID = "11020"
|
const setupContainerID = "11020"
|
||||||
|
|
||||||
// setupBanner is the unmistakable first-run notice (D-044): that the instance is
|
// setupBanner is the unmistakable first-run notice (D-044/D-045): that the
|
||||||
// in setup mode, the wizard URL, the printed setup CODE, where to read the code
|
// instance is in setup mode, the wizard URL, and — only when `setup_code` is
|
||||||
// again, and the restart that adopts the persisted secrets. It is printed to
|
// set (`codeRequired`) — the printed setup CODE. It is printed to stderr, so it
|
||||||
// stderr, so it lands in the same journal as the structured logs.
|
// lands in the same journal as the structured logs.
|
||||||
func setupBanner(cfg *config.Config, code string) string {
|
func setupBanner(cfg *config.Config, code string, codeRequired bool) string {
|
||||||
public := strings.TrimRight(strings.TrimSpace(cfg.Server.BaseURL), "/")
|
public := strings.TrimRight(strings.TrimSpace(cfg.Server.BaseURL), "/")
|
||||||
setupURL := public + "/setup"
|
setupURL := public + "/setup"
|
||||||
if public == "" {
|
if public == "" {
|
||||||
setupURL = "http://" + strings.TrimSpace(cfg.Server.Listen) + "/setup"
|
setupURL = "http://" + strings.TrimSpace(cfg.Server.Listen) + "/setup"
|
||||||
}
|
}
|
||||||
line := strings.Repeat("=", 70)
|
codeBlock := ""
|
||||||
return fmt.Sprintf(`
|
if codeRequired {
|
||||||
%s
|
codeBlock = fmt.Sprintf(`
|
||||||
MyDay is NOT set up yet — no admin exists.
|
The wizard is protected by a one-time SETUP CODE:
|
||||||
%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
|
SETUP CODE: %s
|
||||||
|
|
||||||
Read the code again:
|
Read the code again:
|
||||||
journalctl -u myday (in the container)
|
journalctl -u myday (in the container)
|
||||||
pct exec %s -- journalctl -u myday (from the Proxmox host)
|
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:
|
Until setup is complete:
|
||||||
- GET / and GET /setup show a friendly first-run page (never a bare 404);
|
- GET / points at the wizard;
|
||||||
- every other route answers 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
|
Finishing the wizard writes config.yaml with setup: false and creates the
|
||||||
persisted secrets:
|
admin. RESTART MyDay to adopt the persisted secrets:
|
||||||
|
|
||||||
systemctl restart myday
|
systemctl restart myday
|
||||||
|
|
||||||
%s
|
%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.
|
// webauthnConfig derives the Relying Party configuration from server.base_url.
|
||||||
// An empty/unparseable base URL falls back to loopback, which keeps setup mode
|
// 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 {
|
func webauthnConfig(baseURL string) webauthn.Config {
|
||||||
defaultCfg := webauthn.Config{
|
defaultCfg := webauthn.Config{
|
||||||
RPID: "localhost",
|
RPID: "localhost",
|
||||||
|
|
|
||||||
|
|
@ -4,20 +4,21 @@
|
||||||
# Settings live HERE, not in the database (D-035).
|
# Settings live HERE, not in the database (D-035).
|
||||||
|
|
||||||
# ── setup & recovery ─────────────────────────────────────────────────────────
|
# ── setup & recovery ─────────────────────────────────────────────────────────
|
||||||
setup: true # D-015 / D-026 — while true the app serves ONLY the
|
setup: true # D-045 — the setup gate. While true the app serves the
|
||||||
# setup wizard. D-025 (2026-10-15b) / D-044: the wizard
|
# /setup wizard at the public domain: completing it writes
|
||||||
# is reachable EITHER over a direct SSH tunnel OR by
|
# `setup: false` and creates the admin. While FALSE with no
|
||||||
# entering the printed setup code — never
|
# admin yet, the app is LOCKED (a boilerplate at / and
|
||||||
# first-visitor-wins. Until it is unlocked, `GET /` and
|
# /setup; every other route 404) — deliberately not
|
||||||
# `GET /setup` show a friendly first-run page (no bare
|
# publicly claimable, so a fresh instance never exposes
|
||||||
# 404) and every other route is 404.
|
# the bootstrap by accident.
|
||||||
admin_recovery: false # D-033 — set true + restart to email user 1 an enrolment link
|
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
|
# D-044/D-045 — the OPTIONAL first-run SETUP CODE (a second factor for /setup).
|
||||||
# code at startup and prints it in a banner (read it with `journalctl -u myday`
|
# Leave it EMPTY ("") and the `setup: true` toggle alone admits — the wizard is
|
||||||
# in the container, or `pct exec 11020 -- journalctl -u myday` from Proxmox).
|
# served directly. Set it and the code form comes first; the code is single-use,
|
||||||
# Set it here to pin your own value instead (then protect this file). Only
|
# rate-limited and redacted from the logs (A8). If you expose MyDay publicly,
|
||||||
# consulted in setup mode.
|
# SET THIS: while `setup: true` whoever reaches /setup first could otherwise
|
||||||
|
# claim the admin.
|
||||||
setup_code: ""
|
setup_code: ""
|
||||||
|
|
||||||
# ── server ───────────────────────────────────────────────────────────────────
|
# ── server ───────────────────────────────────────────────────────────────────
|
||||||
|
|
@ -28,7 +29,7 @@ server:
|
||||||
listen: "0.0.0.0:8080" # D-043 — any bind is allowed (no setup-mode refusal)
|
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
|
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'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`
|
# 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-`
|
# 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
|
# prefix and HSTS), and the audit log records the proxy — not the user — as the
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,13 @@
|
||||||
// Settings live in the config file, not the database (D-035): infra · secrets ·
|
// 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
|
// 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
|
// 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
|
// 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
|
// 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
|
// 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.
|
// Spec: docs/specs/spec-003-core-door.md §1, §7.
|
||||||
package config
|
package config
|
||||||
|
|
@ -45,18 +46,18 @@ const (
|
||||||
|
|
||||||
// Config is the typed shape of `config.yaml` / `config.example.yaml`.
|
// Config is the typed shape of `config.yaml` / `config.example.yaml`.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
// Setup forces setup mode (D-026). Together with "no user exists" it makes
|
// Setup is the setup gate (D-045). While true the app serves the /setup
|
||||||
// the instance serve only /setup, tunnel-only (D-025, SPEC-005 §3).
|
// 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"`
|
Setup bool `yaml:"setup"`
|
||||||
|
|
||||||
// AdminRecovery is the config approval flag for admin recovery (D-033).
|
// AdminRecovery is the config approval flag for admin recovery (D-033).
|
||||||
// Without it nothing happens (dual control).
|
// Without it nothing happens (dual control).
|
||||||
AdminRecovery bool `yaml:"admin_recovery"`
|
AdminRecovery bool `yaml:"admin_recovery"`
|
||||||
|
|
||||||
// SetupCode pins the first-run SETUP CODE (D-044). When blank MyDay
|
// SetupCode is the OPTIONAL first-run setup code (D-044 · D-045). When set,
|
||||||
// generates a 128-bit code at first run and prints it in the startup
|
// the wizard requires it (a second factor); when blank the `setup` toggle
|
||||||
// banner; setting it here overrides that (the value is then the operator's
|
// alone admits. It is only consulted while setup is enabled.
|
||||||
// to protect). It is only consulted in setup mode.
|
|
||||||
SetupCode string `yaml:"setup_code"`
|
SetupCode string `yaml:"setup_code"`
|
||||||
|
|
||||||
Server Server `yaml:"server"`
|
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
|
// Setup mode may run with placeholder secrets: the operator has not yet
|
||||||
// provisioned the instance, and it is tunnel-only (D-025). Productive
|
// provisioned the instance, and it is gated by the `setup` toggle (D-045).
|
||||||
// mode is strict.
|
// Productive mode is strict.
|
||||||
if c.Setup {
|
if c.Setup {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
func TestClientIPUntrustedPeerIgnoresXFF(t *testing.T) {
|
||||||
tp, _ := httpx.ParseTrustedProxies([]string{"127.0.0.1"})
|
tp, _ := httpx.ParseTrustedProxies([]string{"127.0.0.1"})
|
||||||
r := httptest.NewRequest("GET", "/", nil)
|
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
|
// TestSessionCookieNameForPrefix pins L1s: the `__Host-` prefix is used exactly
|
||||||
// when the cookie is Secure (a browser rejects a `__Host-` cookie over http).
|
// when the cookie is Secure (a browser rejects a `__Host-` cookie over http).
|
||||||
func TestSessionCookieNameForPrefix(t *testing.T) {
|
func TestSessionCookieNameForPrefix(t *testing.T) {
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ package httpx
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -19,38 +18,8 @@ func IsLoopbackHost(hostport string) bool {
|
||||||
return !known || loopback
|
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
|
// 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
|
// loopback, and whether it could be classified at all.
|
||||||
// resolve "unknown" in opposite directions on purpose: each is fail-closed for
|
|
||||||
// its own decision.
|
|
||||||
//
|
//
|
||||||
// "Unknown" is only an empty/blank value: a NAME that is not `localhost` is
|
// "Unknown" is only an empty/blank value: a NAME that is not `localhost` is
|
||||||
// known — and it is not loopback.
|
// known — and it is not loopback.
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ func WithSecure(ctx context.Context, secure bool) context.Context {
|
||||||
|
|
||||||
// SecureFromRequest reports whether the request is served over https, as
|
// 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
|
// 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 {
|
func SecureFromRequest(r *http.Request) bool {
|
||||||
v, _ := r.Context().Value(secureKey{}).(bool)
|
v, _ := r.Context().Value(secureKey{}).(bool)
|
||||||
return v
|
return v
|
||||||
|
|
|
||||||
|
|
@ -61,14 +61,19 @@ type Deps struct {
|
||||||
PreSession *httpx.PreSessionCSRF
|
PreSession *httpx.PreSessionCSRF
|
||||||
// TrustedProxies overrides the set parsed from Config.Server.TrustedProxies.
|
// TrustedProxies overrides the set parsed from Config.Server.TrustedProxies.
|
||||||
TrustedProxies httpx.TrustedProxies
|
TrustedProxies httpx.TrustedProxies
|
||||||
// SetupMode reports whether the instance is in setup mode (D-025). When nil
|
// SetupMode reports whether the WIZARD is enabled — the config toggle
|
||||||
// it defaults to Config.Setup; the entrypoint widens it to "or no user
|
// `setup` (D-045). When nil it defaults to Config.Setup; the entrypoint
|
||||||
// exists" (SPEC-005 §3).
|
// also clears it in-process once the wizard completes.
|
||||||
SetupMode func() bool
|
SetupMode func() bool
|
||||||
// SetupGate is the D-044 first-run gate: the setup code + the signed unlock
|
// LockedMode reports whether the instance is LOCKED: `setup: false` and no
|
||||||
// cookie, and the first-run boilerplate. When nil, setup mode keeps the
|
// admin exists yet (D-045). In that state `GET /` and `GET /setup` render
|
||||||
// strict behaviour (only the tunnel served; everything else 404) — the
|
// the "enable setup" boilerplate and every other route is 404 — the state
|
||||||
// entrypoint always wires it.
|
// 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
|
SetupGate SetupGate
|
||||||
// RoleGate enforces per-function authorisation. Defaults to
|
// RoleGate enforces per-function authorisation. Defaults to
|
||||||
// AllowAuthenticated.
|
// AllowAuthenticated.
|
||||||
|
|
@ -95,6 +100,7 @@ type Server struct {
|
||||||
precsrf *httpx.PreSessionCSRF
|
precsrf *httpx.PreSessionCSRF
|
||||||
trusted httpx.TrustedProxies
|
trusted httpx.TrustedProxies
|
||||||
setup func() bool
|
setup func() bool
|
||||||
|
locked func() bool
|
||||||
gate SetupGate
|
gate SetupGate
|
||||||
roles RoleGate
|
roles RoleGate
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
|
|
@ -102,18 +108,25 @@ type Server struct {
|
||||||
limiter *middleware.RateLimiter
|
limiter *middleware.RateLimiter
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetupGate is the D-044 first-run gate the door consults while in setup mode.
|
// SetupGate is the first-run gate the door consults. It reports whether a
|
||||||
// It answers "does this request carry a granted setup-code unlock?" and renders
|
// required setup code has been unlocked and renders the first-run pages that
|
||||||
// the friendly first-run BOILERPLATE that replaces the bare 404. It is
|
// replace a bare 404 (D-044/D-045). It is implemented by *setup.Gate;
|
||||||
// implemented by *setup.Gate; declaring the seam here keeps the door free of the
|
// declaring the seam here keeps the door free of the setup package's internals.
|
||||||
// setup package's internals.
|
|
||||||
type SetupGate interface {
|
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
|
// 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).
|
// (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.
|
// New validates the dependencies and builds the door.
|
||||||
|
|
@ -154,6 +167,10 @@ func New(d Deps) (*Server, error) {
|
||||||
cfg := d.Config
|
cfg := d.Config
|
||||||
setup = func() bool { return cfg.Setup }
|
setup = func() bool { return cfg.Setup }
|
||||||
}
|
}
|
||||||
|
locked := d.LockedMode
|
||||||
|
if locked == nil {
|
||||||
|
locked = func() bool { return false }
|
||||||
|
}
|
||||||
logger := d.Logger
|
logger := d.Logger
|
||||||
if logger == nil {
|
if logger == nil {
|
||||||
logger = slog.Default()
|
logger = slog.Default()
|
||||||
|
|
@ -179,6 +196,7 @@ func New(d Deps) (*Server, error) {
|
||||||
precsrf: d.PreSession,
|
precsrf: d.PreSession,
|
||||||
trusted: trusted,
|
trusted: trusted,
|
||||||
setup: setup,
|
setup: setup,
|
||||||
|
locked: locked,
|
||||||
gate: d.SetupGate,
|
gate: d.SetupGate,
|
||||||
roles: roles,
|
roles: roles,
|
||||||
log: logger,
|
log: logger,
|
||||||
|
|
@ -191,9 +209,9 @@ func New(d Deps) (*Server, error) {
|
||||||
func (s *Server) Handler() http.Handler { return middleware.CorrelationID(s) }
|
func (s *Server) Handler() http.Handler { return middleware.CorrelationID(s) }
|
||||||
|
|
||||||
// Serve binds listen and serves the door until it fails. Any address may be
|
// 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,
|
// bound — including `0.0.0.0:8080` behind a reverse proxy (D-043); the
|
||||||
// D-043); the bootstrap is protected per request by the setup gate, not by a
|
// bootstrap is gated per request by the `setup` toggle (D-045), not by a bind
|
||||||
// bind check.
|
// check.
|
||||||
func (s *Server) Serve(listen string) error {
|
func (s *Server) Serve(listen string) error {
|
||||||
s.log.Info("myday: listening", "addr", listen, "setup_mode", s.setup())
|
s.log.Info("myday: listening", "addr", listen, "setup_mode", s.setup())
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
|
|
@ -208,10 +226,6 @@ func (s *Server) Serve(listen string) error {
|
||||||
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
start := s.now()
|
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()
|
ctx := r.Context()
|
||||||
cid := middleware.CorrelationFrom(ctx)
|
cid := middleware.CorrelationFrom(ctx)
|
||||||
if cid == "" {
|
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"))
|
res = s.disp.Dispatch(r.URL.Path, r.FormValue("do"), r.FormValue("action"))
|
||||||
|
|
||||||
// 6. the Kind switch.
|
// 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).
|
// 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) {
|
func (s *Server) dispatch(w http.ResponseWriter, r *http.Request, res routing.Resolution, cid, ip string) {
|
||||||
// SPEC-003 §7 / D-025 (2026-10-15b) / D-044 / A13: setup mode is gated PER
|
// SPEC-005 §3 / D-045: the setup gate is the config toggle `setup`,
|
||||||
// REQUEST by EITHER (a) the direct SSH tunnel — a loopback TCP peer carrying
|
// evaluated per request.
|
||||||
// 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
|
// * wizard mode (`setup: true`) — `GET /setup` serves the wizard. If a
|
||||||
// peer fails closed) — OR (b) a valid setup-code unlock (D-044). The RAW
|
// `setup_code` is set it must be unlocked first (D-044); `GET /` points
|
||||||
// tunnel verdict was captured before ClientIP SET the header.
|
// 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() {
|
if s.setup() {
|
||||||
unlocked := s.gate != nil && s.gate.Unlocked(r)
|
admitted := s.setupAdmitted(r)
|
||||||
allowed := setupTunnel || unlocked
|
// 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 {
|
switch {
|
||||||
case res.Kind == routing.KindRedirect && res.Redirect == "/home":
|
case res.Kind == routing.KindRedirect && res.Redirect == "/home":
|
||||||
// `GET /` — the friendly first-run page, ALWAYS in setup mode
|
// `GET /` — point at the wizard (never the app, D-045).
|
||||||
// (never a bare 404: the domain must not look "down", D-044).
|
s.serveLanding(w, r, cid, ip)
|
||||||
s.serveBoilerplate(w, r, cid, ip)
|
|
||||||
return
|
return
|
||||||
case res.Kind == routing.KindSetup && !allowed:
|
case res.Kind == routing.KindSetup && !admitted:
|
||||||
// `GET /setup` without the tunnel or a code — the same page,
|
// `GET /setup` with a required code not yet entered.
|
||||||
// with the setup-code form.
|
s.serveCodeForm(w, r, cid, ip)
|
||||||
s.serveBoilerplate(w, r, cid, ip)
|
|
||||||
return
|
return
|
||||||
case !allowed || !setupAllowed(res):
|
case static:
|
||||||
// SPEC-005 §3: once allowed, the app serves ONLY /setup (+
|
// fall through — the wizard's stylesheet.
|
||||||
// static assets); every other route is 404 (delete, don't
|
case !s.isSetupUnlock(res) && (!admitted || !setupAllowed(res)):
|
||||||
// redirect). Without the tunnel or a code, every route but `/`
|
// SPEC-005 §3: once admitted, the app serves ONLY /setup; every
|
||||||
// and `/setup` above is 404 too.
|
// other route is 404 (delete, don't redirect). The unlock action is
|
||||||
s.log.Warn("setup request refused: not the direct tunnel and not unlocked",
|
// 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,
|
"correlation_id", cid, "client_ip", ip,
|
||||||
"path", httpx.RedactPath(r.URL.Path))
|
"path", httpx.RedactPath(r.URL.Path))
|
||||||
s.writeError(w, http.StatusNotFound, "not found")
|
s.writeError(w, http.StatusNotFound, "not found")
|
||||||
return
|
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
|
// setupAllowed reports whether a Resolution is the wizard itself — the
|
||||||
// while in setup mode: the wizard itself (`/setup`, path or action) and static
|
// reserved `setup` workflow, path or action (SPEC-005 §3). Used while the
|
||||||
// assets (SPEC-005 §3). Everything else — including the canonical redirect and
|
// wizard is enabled: everything else is 404. Static assets are handled
|
||||||
// every GUID branch — is 404.
|
// separately (they are served in every first-run state).
|
||||||
func setupAllowed(res routing.Resolution) bool {
|
func setupAllowed(res routing.Resolution) bool {
|
||||||
switch res.Kind {
|
switch res.Kind {
|
||||||
case routing.KindSetup:
|
case routing.KindSetup:
|
||||||
return true
|
return true
|
||||||
case routing.KindAction:
|
case routing.KindAction:
|
||||||
return res.Workflow == "setup"
|
return res.Workflow == "setup"
|
||||||
case routing.KindRoute:
|
|
||||||
return res.Workflow == "static"
|
|
||||||
}
|
}
|
||||||
return false
|
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
|
// 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
|
// grants the unlock. The door still pins it to the `setup` module
|
||||||
// (serveSetupAction), so a `do=core` under /setup cannot ride this carve-out.
|
// (serveSetupAction), so a `do=core` under /setup cannot ride this carve-out.
|
||||||
func (s *Server) isSetupUnlock(res routing.Resolution) bool {
|
func (s *Server) isSetupUnlock(res routing.Resolution) bool {
|
||||||
|
|
@ -419,17 +457,33 @@ func (s *Server) isSetupUnlock(res routing.Resolution) bool {
|
||||||
res.Function == "unlock"
|
res.Function == "unlock"
|
||||||
}
|
}
|
||||||
|
|
||||||
// serveBoilerplate renders the D-044 first-run page. It is the hand-off target
|
// serveCodeForm renders the setup-code entry page — `GET /setup` with a required
|
||||||
// for `GET /` and for `GET /setup` before the wizard is unlocked, so the domain
|
// code not yet entered. A nil gate (not wired) keeps the strict 404; the
|
||||||
// never answers a bare 404. A nil gate (not wired) keeps the strict 404 — the
|
|
||||||
// entrypoint always wires it.
|
// 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 {
|
if s.gate == nil {
|
||||||
s.writeError(w, http.StatusNotFound, "not found")
|
s.writeError(w, http.StatusNotFound, "not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := s.gate.Boilerplate(w, r); err != nil {
|
if err := render(s.gate); err != nil {
|
||||||
s.log.Error("setup: render boilerplate", "correlation_id", cid, "client_ip", ip, "error", err)
|
s.log.Error("setup: render "+label, "correlation_id", cid, "client_ip", ip, "error", err)
|
||||||
s.writeError(w, http.StatusInternalServerError, "internal error")
|
s.writeError(w, http.StatusInternalServerError, "internal error")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -185,8 +185,10 @@ func newHarness(t *testing.T, opts ...func(*server.Deps)) *harness {
|
||||||
t.Fatalf("NewPreSessionCSRF: %v", err)
|
t.Fatalf("NewPreSessionCSRF: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// D-044: the first-run gate. It is only consulted in setup mode; wiring it
|
// The first-run gate (D-044/D-045). It is only consulted while the wizard is
|
||||||
// here lets a setup-mode test exercise the code + the boilerplate.
|
// 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)
|
gate, err := setup.NewGate("test-setup-code", pre, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("setup.NewGate: %v", err)
|
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}
|
return &http.Cookie{Name: httpx.SessionCookieName, Value: h.session.ID}
|
||||||
}
|
}
|
||||||
|
|
||||||
// loopbackPeer is the RemoteAddr the test door speaks from. Setup mode is
|
// loopbackPeer is the RemoteAddr the test door speaks from. It is a plain
|
||||||
// tunnel-only PER REQUEST (SPEC-003 §7, D-025): a loopback peer with no XFF.
|
// same-host peer: the setup gate is the `setup` toggle (D-045), not the peer.
|
||||||
// Tests must explicitly opt in to a different peer (or add XFF) to exercise
|
|
||||||
// the rejection.
|
|
||||||
const loopbackPeer = "127.0.0.1:52345"
|
const loopbackPeer = "127.0.0.1:52345"
|
||||||
|
|
||||||
// request issues a request directly against the door (no network).
|
// request issues a request directly against the door (no network).
|
||||||
func request(h http.Handler, method, target string, cookies ...*http.Cookie) *httptest.ResponseRecorder {
|
func request(h http.Handler, method, target string, cookies ...*http.Cookie) *httptest.ResponseRecorder {
|
||||||
req := httptest.NewRequest(method, target, nil)
|
req := httptest.NewRequest(method, target, nil)
|
||||||
req.RemoteAddr = loopbackPeer // the operator reaches /setup over a tunnel
|
req.RemoteAddr = loopbackPeer
|
||||||
for _, c := range cookies {
|
for _, c := range cookies {
|
||||||
req.AddCookie(c)
|
req.AddCookie(c)
|
||||||
}
|
}
|
||||||
|
|
@ -468,26 +468,35 @@ func TestAC6_ActionRequiresCSRF(t *testing.T) {
|
||||||
|
|
||||||
// ── AC-7 · AC-10 ─────────────────────────────────────────────────────────────
|
// ── AC-7 · AC-10 ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// TestAC7_SetupGateIsTunnelOnly pins D-025 (2026-10-15b) + D-044: in setup mode
|
// TestAC7_SetupGateIsTheConfigToggle covers D-045 + D-044 at the door: while the
|
||||||
// the WIZARD is served only to the direct SSH tunnel — a loopback TCP peer with
|
// wizard is enabled with a required setup code, the code is the gate — no peer
|
||||||
// NO X-Forwarded-For. Anyone else gets the friendly first-run page (never a bare
|
// (loopback or proxied) reaches the wizard without the unlock, and every visitor
|
||||||
// 404, D-044), and never the wizard. The bind is not checked: any address may be
|
// sees the code form. The peer/tunnel rule of D-025 is retired. The unlock
|
||||||
// bound (D-043).
|
// action itself is the ONE request admitted without an unlock.
|
||||||
func TestAC7_SetupGateIsTunnelOnly(t *testing.T) {
|
func TestAC7_SetupGateIsTheConfigToggle(t *testing.T) {
|
||||||
h := newHarness(t, func(d *server.Deps) { d.SetupMode = func() bool { return true } })
|
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 {
|
cases := []struct {
|
||||||
name string
|
name string
|
||||||
peer string
|
peer string
|
||||||
xff string
|
xff string
|
||||||
wizard bool
|
|
||||||
comment string
|
|
||||||
}{
|
}{
|
||||||
{"the direct tunnel", loopbackPeer, "", true, "loopback peer, no XFF → the wizard (gate a)"},
|
{"a loopback peer", loopbackPeer, ""},
|
||||||
{"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 same-host proxy", "127.0.0.1:9999", "203.0.113.9"},
|
||||||
{"a remote peer", "203.0.113.9:4444", "", false, "non-loopback peer → the boilerplate (AC-10)"},
|
{"a remote peer", "203.0.113.9:4444", ""},
|
||||||
{"a remote peer behind a proxy", "203.0.113.9:4444", "198.51.100.7", false, "non-loopback peer + XFF → the boilerplate (AC-10)"},
|
{"a remote peer behind a proxy", "203.0.113.9:4444", "198.51.100.7"},
|
||||||
{"an unparseable peer", "", "", false, "fail closed → the boilerplate"},
|
|
||||||
}
|
}
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
|
@ -499,23 +508,42 @@ func TestAC7_SetupGateIsTunnelOnly(t *testing.T) {
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
h.srv.ServeHTTP(rec, req)
|
h.srv.ServeHTTP(rec, req)
|
||||||
if rec.Code != http.StatusOK {
|
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()
|
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") {
|
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 ─────────────────────────────────────────────────────────────────────
|
// ── 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) {
|
func TestSetupModeRouting(t *testing.T) {
|
||||||
productive := newHarness(t)
|
productive := newHarness(t)
|
||||||
if rec := request(productive.srv, "GET", "/setup"); rec.Code != http.StatusNotFound {
|
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)
|
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 } })
|
inSetup := newHarness(t, func(d *server.Deps) { d.SetupMode = func() bool { return true } })
|
||||||
if rec := request(inSetup.srv, "GET", "/setup"); rec.Code != http.StatusOK {
|
if rec := request(inSetup.srv, "GET", "/setup"); rec.Code != http.StatusOK {
|
||||||
t.Errorf("GET /setup in setup mode = %d, want 200", rec.Code)
|
t.Errorf("GET /setup in setup mode = %d, want 200", rec.Code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestSpec003SetupIsLoopbackOnlyPerRequest covers AC-10/D-025 + D-044: the setup
|
// TestSpec003SetupGateIsPerRequest covers AC-10 under D-045: while the wizard
|
||||||
// gate is per request — a loopback TCP peer AND no X-Forwarded-For (the direct
|
// is enabled with a required code, `GET /setup` is the code form from EVERY
|
||||||
// SSH tunnel) serves the wizard. A proxy on 127.0.0.1 relaying a remote client
|
// peer — the wizard needs the unlock, which is not a peer property (the D-025
|
||||||
// presents a loopback peer but carries XFF, so it gets the first-run boilerplate
|
// tunnel rule is retired). Every non-wizard route is 404.
|
||||||
// (never the wizard), and every other route is 404.
|
func TestSpec003SetupGateIsPerRequest(t *testing.T) {
|
||||||
func TestSpec003SetupIsLoopbackOnlyPerRequest(t *testing.T) {
|
|
||||||
h := newHarness(t, func(d *server.Deps) { d.SetupMode = func() bool { return true } })
|
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
|
// A remote client: the code form, never the wizard.
|
||||||
// (including the wizard's own steps, which need the unlock).
|
|
||||||
req := httptest.NewRequest("GET", "/setup", nil)
|
req := httptest.NewRequest("GET", "/setup", nil)
|
||||||
req.RemoteAddr = "203.0.113.9:4444"
|
req.RemoteAddr = "203.0.113.9:4444"
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
h.srv.ServeHTTP(rec, req)
|
h.srv.ServeHTTP(rec, req)
|
||||||
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "not set up yet") {
|
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `name="code"`) {
|
||||||
t.Errorf("GET /setup from a remote client = %d, want the first-run page (D-044)", rec.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") {
|
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 }{
|
for _, tc := range []struct{ method, target string }{
|
||||||
{"POST", "/setup?do=setup&action=domain.save"},
|
{"POST", "/setup?do=setup&action=domain.save"},
|
||||||
{"GET", "/static/app.css"},
|
|
||||||
{"GET", "/home"},
|
{"GET", "/home"},
|
||||||
|
{"GET", "/" + h.guid},
|
||||||
} {
|
} {
|
||||||
req := httptest.NewRequest(tc.method, tc.target, nil)
|
req := httptest.NewRequest(tc.method, tc.target, nil)
|
||||||
req.RemoteAddr = "203.0.113.9:4444" // a remote peer through the proxy
|
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)
|
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
|
// TestSpec003SetupModulePinnedAtTheDoor covers M1c/L3s: the wizard's module is
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,17 @@
|
||||||
package server_test
|
package server_test
|
||||||
|
|
||||||
// D-044 — the first-run SETUP CODE and the boilerplate (SPEC-005 §3/§4,
|
// D-045 — the setup gate is the config toggle `setup` (SPEC-005 §3/§4,
|
||||||
// SPEC-003 §7 / AC-14). These tests drive the real `/setup` gate end to end:
|
// SPEC-003 §7). 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
|
// * `setup: false`, no admin → LOCKED: `/` and `/setup` render an "enable
|
||||||
// route stays 404 until setup completes.
|
// 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 (
|
import (
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
|
@ -20,13 +27,14 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
// proxyPeer / proxyXFF are a same-host reverse proxy relaying a remote client:
|
// 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 (
|
const (
|
||||||
proxyPeer = "127.0.0.1:9999"
|
proxyPeer = "127.0.0.1:9999"
|
||||||
proxyXFF = "203.0.113.9"
|
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 {
|
func (h *setupHarness) proxyGet(target string, cookies ...*http.Cookie) *httptest.ResponseRecorder {
|
||||||
h.t.Helper()
|
h.t.Helper()
|
||||||
req := httptest.NewRequest("GET", target, nil)
|
req := httptest.NewRequest("GET", target, nil)
|
||||||
|
|
@ -74,67 +82,68 @@ func unlockCookie(t *testing.T, rec *httptest.ResponseRecorder) *http.Cookie {
|
||||||
return nil
|
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 /`
|
// TestD045_NoCodeServesTheWizardDirectly: with `setup: true` and no
|
||||||
// and `GET /setup` render the friendly first-run page with the setup-code form.
|
// `setup_code`, `GET /setup` is the wizard for every visitor — no form, no
|
||||||
func TestD044_BoilerplateAtRootAndSetup(t *testing.T) {
|
// unlock. `GET /` points at it.
|
||||||
h := newSetupHarness(t)
|
func TestD045_NoCodeServesTheWizardDirectly(t *testing.T) {
|
||||||
|
h := newSetupHarness(t) // default: no code required
|
||||||
|
|
||||||
for _, target := range []string{"/", "/setup"} {
|
for name, rec := range map[string]*httptest.ResponseRecorder{
|
||||||
t.Run(target, func(t *testing.T) {
|
"loopback": request(h.srv, "GET", "/setup"),
|
||||||
rec := h.proxyGet(target)
|
"proxied": h.proxyGet("/setup"),
|
||||||
|
} {
|
||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
t.Fatalf("GET %s = %d, want 200 — never a bare 404 (D-044)", target, rec.Code)
|
t.Fatalf("GET /setup (%s) = %d, want 200 (the wizard)", name, rec.Code)
|
||||||
}
|
}
|
||||||
body := rec.Body.String()
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// `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)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── state 2 · `setup: true` with a code (D-044) ──────────────────────────────
|
||||||
|
|
||||||
|
// 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{
|
for _, want := range []string{
|
||||||
"MyDay is not set up yet",
|
|
||||||
`action="/setup?do=setup&action=unlock"`,
|
`action="/setup?do=setup&action=unlock"`,
|
||||||
`name="code"`,
|
`name="code"`,
|
||||||
"journalctl -u myday",
|
"not set up yet",
|
||||||
"pct exec 11020",
|
|
||||||
} {
|
} {
|
||||||
if !strings.Contains(body, want) {
|
if !strings.Contains(form.Body.String(), want) {
|
||||||
t.Errorf("GET %s boilerplate is missing %q", target, want)
|
t.Errorf("the code form is missing %q", want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// It must not be the wizard, and it must not be a bare 404 body.
|
if strings.Contains(form.Body.String(), "domain.save") {
|
||||||
if strings.Contains(body, "domain.save") {
|
t.Error("GET /setup served the wizard before the code (D-044)")
|
||||||
t.Errorf("GET %s served the WIZARD without the code/tunnel (D-044)", target)
|
|
||||||
}
|
}
|
||||||
// The anonymous form carries the pre-session CSRF cookie (A15).
|
// The anonymous form carries the pre-session CSRF cookie (A15).
|
||||||
if !hasCookie(rec, httpx.PreSessionCookieName) {
|
if !hasCookie(form, httpx.PreSessionCookieName) {
|
||||||
t.Errorf("GET %s issued no pre-session CSRF cookie (A15)", target)
|
t.Error("the code form issued no pre-session CSRF cookie (A15)")
|
||||||
}
|
}
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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}})
|
rec := h.proxyPost("unlock", url.Values{"code": {h.code}})
|
||||||
if rec.Code != http.StatusSeeOther {
|
if rec.Code != http.StatusSeeOther {
|
||||||
|
|
@ -151,47 +160,30 @@ func TestD044_CorrectCodeUnlocksTheWizard(t *testing.T) {
|
||||||
t.Error("the unlock cookie is not 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)
|
got := h.proxyGet("/setup", c)
|
||||||
if got.Code != http.StatusOK {
|
if got.Code != http.StatusOK {
|
||||||
t.Fatalf("GET /setup after unlock = %d, want 200 (the wizard)", got.Code)
|
t.Fatalf("GET /setup after unlock = %d, want 200 (the wizard)", got.Code)
|
||||||
}
|
}
|
||||||
body := got.Body.String()
|
if !strings.Contains(got.Body.String(), "domain.save") {
|
||||||
if !strings.Contains(body, "MyDay setup") || !strings.Contains(body, "domain.save") {
|
t.Error("GET /setup after unlock did not render the wizard (D-044)")
|
||||||
t.Errorf("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") {
|
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)")
|
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 ──────────────────────────
|
// ── a wrong/absent code is generic and rate-limited ──────────────────────────
|
||||||
|
|
||||||
// TestD044_WrongOrAbsentCodeIsGeneric: neither a wrong nor an absent code
|
// TestD044_WrongOrAbsentCodeIsGeneric: neither a wrong nor an absent code
|
||||||
// unlocks, and both get the SAME generic answer (no oracle, A3).
|
// unlocks, and both get the SAME generic answer (no oracle, A3).
|
||||||
func TestD044_WrongOrAbsentCodeIsGeneric(t *testing.T) {
|
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"}})
|
wrong := h.proxyPost("unlock", url.Values{"code": {"nope-not-the-code"}})
|
||||||
absent := h.proxyPost("unlock", nil)
|
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} {
|
for name, rec := range map[string]*httptest.ResponseRecorder{"wrong": wrong, "absent": absent} {
|
||||||
if rec.Code != http.StatusForbidden {
|
if rec.Code != http.StatusForbidden {
|
||||||
t.Errorf("%s code = %d, want 403 (generic, D-044)", name, rec.Code)
|
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
|
// TestD044_UnlockIsRateLimited: the setup route is rate-limited (A3/AC-9) — a
|
||||||
// burst of attempts is answered 429, not a free oracle.
|
// burst of attempts is answered 429, not a free oracle.
|
||||||
func TestD044_UnlockIsRateLimited(t *testing.T) {
|
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.rateLimit = 2
|
||||||
h.rateWindow = time.Minute
|
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
|
// TestD044_UnlockRequiresCSRF: the unlock is an anonymous form, so it carries
|
||||||
// the pre-session double-submit token (A15) — a POST without it is refused.
|
// the pre-session double-submit token (A15) — a POST without it is refused.
|
||||||
func TestD044_UnlockRequiresCSRF(t *testing.T) {
|
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",
|
req := httptest.NewRequest("POST", "/setup?do=setup&action=unlock",
|
||||||
strings.NewReader(url.Values{"code": {h.code}}.Encode()))
|
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
|
// TestD044_CodeIsSingleUse: the code retires the moment it works — a second
|
||||||
// browser cannot re-use the printed value.
|
// browser cannot re-use the printed value.
|
||||||
func TestD044_CodeIsSingleUse(t *testing.T) {
|
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 {
|
if rec := h.proxyPost("unlock", url.Values{"code": {h.code}}); rec.Code != http.StatusSeeOther {
|
||||||
t.Fatalf("first unlock = %d, want 303", rec.Code)
|
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.
|
// the QUERY (a hand-typed URL, say) must never leak it into the access log.
|
||||||
func TestD044_CodeIsRedactedFromTheAccessLog(t *testing.T) {
|
func TestD044_CodeIsRedactedFromTheAccessLog(t *testing.T) {
|
||||||
var logs strings.Builder
|
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))
|
h.logger = slog.New(slog.NewTextHandler(&logs, nil))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -306,42 +298,42 @@ func TestD044_CodeIsRedactedFromTheAccessLog(t *testing.T) {
|
||||||
|
|
||||||
// ── the gate, unit-level ─────────────────────────────────────────────────────
|
// ── the gate, unit-level ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
// TestD044_GateGeneratesA128BitCode: a blank code generates 128 bits of
|
// TestD045_BlankCodeIsNotRequired: a blank `setup_code` means no code is
|
||||||
// entropy, and the generated value is what the banner prints.
|
// required, nothing is printed, and no unlock is possible.
|
||||||
func TestD044_GateGeneratesA128BitCode(t *testing.T) {
|
func TestD045_BlankCodeIsNotRequired(t *testing.T) {
|
||||||
pre := mustPreSessionCSRF(t)
|
pre := mustPreSessionCSRF(t)
|
||||||
gate, err := setup.NewGate("", pre, nil)
|
gate, err := setup.NewGate("", pre, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewGate: %v", err)
|
t.Fatalf("NewGate: %v", err)
|
||||||
}
|
}
|
||||||
code := gate.Code()
|
if gate.Required() {
|
||||||
if code == "" {
|
t.Error("a blank code must not be required (D-045)")
|
||||||
t.Fatal("NewGate generated no code")
|
|
||||||
}
|
}
|
||||||
// 16 bytes → 32 hex chars, plus the visual grouping dashes.
|
if gate.Code() != "" {
|
||||||
plain := strings.ReplaceAll(code, "-", "")
|
t.Errorf("a blank code printed %q, want nothing (D-045)", gate.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.Accept("anything") {
|
||||||
// A second gate generates a DIFFERENT code (fresh entropy each boot).
|
t.Error("a non-required gate accepted a code")
|
||||||
other, err := setup.NewGate("", pre, nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("NewGate: %v", err)
|
|
||||||
}
|
}
|
||||||
if other.Code() == code {
|
if gate.Grant() != "" {
|
||||||
t.Error("two gates generated the same code")
|
t.Error("a non-required gate granted an unlock cookie")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestD044_GateAcceptsFormattingAndRetires: the comparison ignores case and
|
// TestD044_SetCodeIsRequiredAndRetires: a set `setup_code` is required, is
|
||||||
// separators, and the accepted code is spent.
|
// printed verbatim, ignores formatting, and is single-use.
|
||||||
func TestD044_GateAcceptsFormattingAndRetires(t *testing.T) {
|
func TestD044_SetCodeIsRequiredAndRetires(t *testing.T) {
|
||||||
pre := mustPreSessionCSRF(t)
|
pre := mustPreSessionCSRF(t)
|
||||||
gate, err := setup.NewGate("9F3A-1B2C", pre, nil)
|
gate, err := setup.NewGate("9F3A-1B2C", pre, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewGate: %v", err)
|
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") {
|
if gate.Accept("nope") {
|
||||||
t.Error("a wrong code was accepted")
|
t.Error("a wrong code was accepted")
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,9 @@ type setupHarness struct {
|
||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
cfgPath string
|
cfgPath string
|
||||||
prints *bytes.Buffer
|
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
|
code string
|
||||||
// writeFile overrides the persister (a failing writer exercises the
|
// writeFile overrides the persister (a failing writer exercises the
|
||||||
// "print the values" fallback, SPEC-005 §4).
|
// "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,
|
t: t, ctx: ctx, db: db, users: us, ids: ids, sessions: sessions,
|
||||||
csrf: csrf, pre: pre, wa: &fakeWebAuthn{}, totpSvc: totp.New("MyDay", ids),
|
csrf: csrf, pre: pre, wa: &fakeWebAuthn{}, totpSvc: totp.New("MyDay", ids),
|
||||||
cfg: cfg, cfgPath: filepath.Join(dir, config.FileName), prints: &bytes.Buffer{},
|
cfg: cfg, cfgPath: filepath.Join(dir, config.FileName), prints: &bytes.Buffer{},
|
||||||
code: "test-setup-code-1234",
|
|
||||||
}
|
}
|
||||||
for _, o := range opts {
|
for _, o := range opts {
|
||||||
o(h)
|
o(h)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Setup mode (SPEC-005 §3), latched: config.setup OR not yet bootstrapped.
|
// D-045: the three states. `setupMode` is the `setup` toggle, cleared by
|
||||||
// Latched at startup so the wizard can finish in-process; cleared by Done().
|
// Done(); `locked` is `setup: false` and no user. `bootstrapped` is latched
|
||||||
|
// at startup (the DB is empty here).
|
||||||
bootstrapped := func() bool {
|
bootstrapped := func() bool {
|
||||||
n, err := us.Count(context.Background())
|
n, err := us.Count(context.Background())
|
||||||
if err != nil || n == 0 {
|
return err == nil && n > 0
|
||||||
return false
|
}()
|
||||||
}
|
inSetup := cfg.Setup
|
||||||
idn, err := ids.ByUserID(context.Background(), 1)
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return idn.Status == auth.StatusActive
|
|
||||||
}
|
|
||||||
inSetup := cfg.Setup || !bootstrapped()
|
|
||||||
setupMode := func() bool {
|
setupMode := func() bool {
|
||||||
if h.setupFlow != nil && h.setupFlow.Done() {
|
if h.setupFlow != nil && h.setupFlow.Done() {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return inSetup
|
return inSetup
|
||||||
}
|
}
|
||||||
|
locked := func() bool { return !cfg.Setup && !bootstrapped }
|
||||||
|
|
||||||
// The SPEC-006 flow, reused by the wizard. TOTPOnlyEnroll is the wizard's
|
// 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
|
// 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,
|
Config: cfg, Resolver: ids, Registry: reg, Sessions: sessions,
|
||||||
Identities: ids, Users: us, Audit: aud, CSRF: csrf, PreSession: pre,
|
Identities: ids, Users: us, Audit: aud, CSRF: csrf, PreSession: pre,
|
||||||
SetupMode: setupMode,
|
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(),
|
SetupGate: setupFlow.Gate(),
|
||||||
RateLimit: h.rateLimit, RateWindow: h.rateWindow, Logger: h.logger,
|
RateLimit: h.rateLimit, RateWindow: h.rateWindow, Logger: h.logger,
|
||||||
})
|
})
|
||||||
|
|
@ -189,6 +187,11 @@ func newSetupHarness(t *testing.T, opts ...func(*setupHarness)) *setupHarness {
|
||||||
return h
|
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).
|
// preCSRF mints a signed pre-session token plus its cookie (A15).
|
||||||
func (h *setupHarness) preCSRF() (*http.Cookie, string) {
|
func (h *setupHarness) preCSRF() (*http.Cookie, string) {
|
||||||
h.t.Helper()
|
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),
|
req := httptest.NewRequest("POST", "/setup?do=setup&action="+url.QueryEscape(action),
|
||||||
strings.NewReader(form.Encode()))
|
strings.NewReader(form.Encode()))
|
||||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
// The wizard is tunnel-only per request (SPEC-003 §7, D-025): the operator
|
// The peer no longer decides setup access (D-045); the request is a plain
|
||||||
// reaches it over an SSH tunnel, so the tests speak from loopback with no XFF.
|
// wizard step over loopback.
|
||||||
req.RemoteAddr = loopbackPeer
|
req.RemoteAddr = loopbackPeer
|
||||||
req.AddCookie(cookie)
|
req.AddCookie(cookie)
|
||||||
for _, c := range cookies {
|
for _, c := range cookies {
|
||||||
|
|
@ -266,10 +269,14 @@ func (h *setupHarness) enrollAdmin(t *testing.T) string {
|
||||||
|
|
||||||
// ── AC-1 ─────────────────────────────────────────────────────────────────────
|
// ── 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) {
|
func TestSpec005AC1_SetupModeServesOnlySetup(t *testing.T) {
|
||||||
h := newSetupHarness(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")
|
rec := request(h.srv, "GET", "/setup")
|
||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
t.Fatalf("GET /setup on a fresh instance = %d, want 200 (AC-1)", rec.Code)
|
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)")
|
t.Error("the wizard issued no pre-session CSRF cookie (A15)")
|
||||||
}
|
}
|
||||||
|
|
||||||
// D-044: without the tunnel, `GET /` and `GET /setup` show the friendly
|
// `GET /` is a friendly page pointing at the wizard — never the app.
|
||||||
// first-run boilerplate — NEVER a bare 404. (AC-14; see setup_code_test.go
|
root := request(h.srv, "GET", "/")
|
||||||
// for the gate itself.)
|
if root.Code != http.StatusOK {
|
||||||
proxyGET := func(target string) *httptest.ResponseRecorder {
|
t.Errorf("GET / while setup is enabled = %d, want 200 (D-045)", root.Code)
|
||||||
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"} {
|
if !strings.Contains(root.Body.String(), "/setup") {
|
||||||
got := proxyGET(p)
|
t.Error("GET / did not point at the wizard (D-045)")
|
||||||
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(), "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
|
// 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"} {
|
for _, p := range []string{"/home", "/access", "/holy-shit", "/some-guid", "/home/extra"} {
|
||||||
if rec := request(h.srv, "GET", p); rec.Code != http.StatusNotFound {
|
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)
|
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 ─────────────────────────────────────────────────────────────────────
|
// ── AC-2 ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// TestSpec005AC2_ServedOnlyOverTheTunnel covers D-025 (2026-10-15b) + D-044:
|
// TestSpec005AC2_WizardRequiresTheCodeWhenSet covers D-044 + D-045: with
|
||||||
// there is no bind refusal (any address may be bound, D-043) — the WIZARD is
|
// `setup_code` SET, `GET /setup` shows the code form from ANY peer (the tunnel
|
||||||
// protected per request by EITHER the direct SSH tunnel (a loopback TCP peer
|
// gate is retired); only an unlocked browser reaches the wizard, and a wrong
|
||||||
// with no X-Forwarded-For) OR the setup code. A remote peer, and a same-host
|
// code does not unlock.
|
||||||
// proxy relaying a client, get the first-run boilerplate — never the wizard.
|
func TestSpec005AC2_WizardRequiresTheCodeWhenSet(t *testing.T) {
|
||||||
func TestSpec005AC2_ServedOnlyOverTheTunnel(t *testing.T) {
|
h := newSetupHarness(t, withSetupCode("t3st-c0de-9999"))
|
||||||
h := newSetupHarness(t)
|
|
||||||
|
|
||||||
// The tunnel reaches the wizard.
|
for name, peer := range map[string]string{
|
||||||
if rec := request(h.srv, "GET", "/setup"); rec.Code != http.StatusOK ||
|
"loopback peer": loopbackPeer,
|
||||||
!strings.Contains(rec.Body.String(), "domain.save") {
|
"remote peer": "203.0.113.9:4444",
|
||||||
t.Errorf("GET /setup over the tunnel did not reach the wizard (AC-2)")
|
} {
|
||||||
}
|
req := httptest.NewRequest("GET", "/setup", nil)
|
||||||
|
req.RemoteAddr = peer
|
||||||
// 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()
|
rec := httptest.NewRecorder()
|
||||||
h.srv.ServeHTTP(rec, remote)
|
h.srv.ServeHTTP(rec, req)
|
||||||
if rec.Code != http.StatusOK || strings.Contains(rec.Body.String(), "domain.save") {
|
if rec.Code != http.StatusOK {
|
||||||
t.Errorf("GET /setup from a remote peer = %d, want the boilerplate, never the wizard (AC-2)", rec.Code)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
proxied := httptest.NewRequest("GET", "/setup", nil)
|
// Unlock per browser → the wizard; another browser still gets the form.
|
||||||
proxied.RemoteAddr = "127.0.0.1:9999"
|
rec := h.proxyPost("unlock", url.Values{"code": {h.code}})
|
||||||
proxied.Header.Set("X-Forwarded-For", "203.0.113.9")
|
if rec.Code != http.StatusSeeOther {
|
||||||
rec = httptest.NewRecorder()
|
t.Fatalf("unlock = %d, want 303 (D-044)", rec.Code)
|
||||||
h.srv.ServeHTTP(rec, proxied)
|
}
|
||||||
if rec.Code != http.StatusOK || strings.Contains(rec.Body.String(), "domain.save") {
|
unlock := unlockCookie(t, rec)
|
||||||
t.Errorf("GET /setup relayed by a same-host proxy = %d, want the boilerplate, never the wizard (AC-2)", rec.Code)
|
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)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
// TestSpec005LockedState: `setup: false` with no admin is the LOCKED state
|
||||||
// database the instance is STILL in setup mode (fail-safe) — and it stays there
|
// (D-045): `/` and `/setup` render the "enable setup" boilerplate (never a bare
|
||||||
// until the wizard finishes, even though creating the admin makes a live
|
// 404), every other route is 404, and the wizard is unreachable — the state is
|
||||||
// "no user exists" check false.
|
// deliberately not claimable.
|
||||||
func TestSpec005FailSafeSetupMode(t *testing.T) {
|
func TestSpec005LockedState(t *testing.T) {
|
||||||
h := newSetupHarness(t, func(h *setupHarness) { h.cfg.Setup = false })
|
h := newSetupHarness(t, func(h *setupHarness) { h.cfg.Setup = false })
|
||||||
|
|
||||||
// Fresh: setup mode via the fail-safe clause.
|
for _, p := range []string{"/", "/setup"} {
|
||||||
if rec := request(h.srv, "GET", "/setup"); rec.Code != http.StatusOK {
|
rec := request(h.srv, "GET", p)
|
||||||
t.Fatalf("GET /setup (fail-safe) = %d, want 200", rec.Code)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
if rec := request(h.srv, "GET", "/home"); rec.Code != http.StatusNotFound {
|
|
||||||
t.Fatalf("GET /home (fail-safe) = %d, want 404", rec.Code)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create the admin (now a user exists) and enroll — setup mode must HOLD.
|
// TestSpec005SetupHoldsMidWizard: once the admin row exists the wizard must stay
|
||||||
h.beginAdmin(t)
|
// 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)
|
||||||
|
|
||||||
if rec := request(h.srv, "GET", "/setup"); rec.Code != http.StatusOK {
|
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)
|
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)
|
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 {
|
if rec := h.post("finish", url.Values{"app.locale": {"en-GB"}}); rec.Code != http.StatusSeeOther {
|
||||||
t.Fatalf("finish = %d, want 303", rec.Code)
|
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 {
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,10 +13,11 @@ const setupModule = "setup"
|
||||||
// RegisterSetupFlow registers the SPEC-005 `/setup` wizard: the page (the core
|
// RegisterSetupFlow registers the SPEC-005 `/setup` wizard: the page (the core
|
||||||
// `setup` workflow) and the action module (unlock · 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
|
// 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
|
// `unlock` is the D-044 code gate: when a `setup_code` is set the door lets it
|
||||||
// or an existing unlock, because it is what grants the unlock.
|
// through without an existing unlock, because it is what grants the unlock.
|
||||||
func RegisterSetupFlow(reg *MapRegistry, a *setup.Actions) {
|
func RegisterSetupFlow(reg *MapRegistry, a *setup.Actions) {
|
||||||
reg.RegisterCore("setup", adaptSetup(a.Page))
|
reg.RegisterCore("setup", adaptSetup(a.Page))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,8 @@
|
||||||
package setup
|
package setup
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/rand"
|
|
||||||
"crypto/subtle"
|
"crypto/subtle"
|
||||||
"encoding/hex"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
@ -21,42 +18,46 @@ import (
|
||||||
// when setup completes.
|
// when setup completes.
|
||||||
const SetupUnlockCookieName = "myday_setup"
|
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
|
// 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
|
// helper — a required code needs a signing key to bind the unlock cookie, so
|
||||||
// fails closed.
|
// this fails closed.
|
||||||
var ErrNoGateCSRF = errors.New("setup: the gate needs the pre-session CSRF signer")
|
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
|
// Gate is the first-run access gate. Under D-045 the gate is the **config
|
||||||
// cookie the boilerplate mints when the correct code is entered.
|
// 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
|
// When a code IS set the gate is the D-044 mechanism: a **signed unlock cookie**
|
||||||
// LXC/Proxmox + Zoraxy topology. `Unlocked` answers the door's "may this
|
// (`myday_setup`) that carries a keyed, one-way FINGERPRINT of the code (never
|
||||||
// request see the wizard?" question (gate b); the direct tunnel (gate a) is
|
// the code itself). The code is SINGLE-USE — the first successful unlock retires
|
||||||
// decided by the door itself (SPEC-003 §7).
|
// 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
|
// The gate also renders the three first-run pages so the door stays free of the
|
||||||
// survives the retirement because the cookie carries a KEYED, ONE-WAY
|
// setup package's HTML:
|
||||||
// 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
|
// - CodeForm — `setup: true` + a required code, not yet unlocked;
|
||||||
// visitor cannot re-use the printed code.
|
// - 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.
|
// Spec: docs/specs/spec-005-setup-wizard.md §3/§4.
|
||||||
type Gate struct {
|
type Gate struct {
|
||||||
csrf *httpx.PreSessionCSRF
|
csrf *httpx.PreSessionCSRF
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
|
|
||||||
|
// required is immutable after construction: true when `setup_code` is set.
|
||||||
|
required bool
|
||||||
|
|
||||||
mu sync.Mutex
|
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)
|
norm string // the normalised form compared on entry ("" once consumed)
|
||||||
fp string // the keyed fingerprint; stable across consumption
|
fp string // the keyed fingerprint; stable across consumption
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewGate builds the gate from the configured code (D-044). An empty/blank code
|
// NewGate builds the gate from the configured code (D-044/D-045). An
|
||||||
// generates a fresh 128-bit one. It returns the gate, whose Display/Code
|
// empty/blank code means NO code is required — the `setup` toggle alone admits
|
||||||
// the caller prints in the startup banner.
|
// and the wizard is served directly. A non-empty code is required.
|
||||||
func NewGate(code string, csrf *httpx.PreSessionCSRF, logger *slog.Logger) (*Gate, error) {
|
func NewGate(code string, csrf *httpx.PreSessionCSRF, logger *slog.Logger) (*Gate, error) {
|
||||||
if csrf == nil {
|
if csrf == nil {
|
||||||
return nil, ErrNoGateCSRF
|
return nil, ErrNoGateCSRF
|
||||||
|
|
@ -68,16 +69,14 @@ func NewGate(code string, csrf *httpx.PreSessionCSRF, logger *slog.Logger) (*Gat
|
||||||
display := strings.TrimSpace(code)
|
display := strings.TrimSpace(code)
|
||||||
norm := normalizeCode(display)
|
norm := normalizeCode(display)
|
||||||
if norm == "" {
|
if norm == "" {
|
||||||
generated, err := generateCode()
|
// D-045: no code — the config toggle is the gate.
|
||||||
if err != nil {
|
return &Gate{csrf: csrf, logger: logger}, nil
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
display, norm = generated, normalizeCode(generated)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return &Gate{
|
return &Gate{
|
||||||
csrf: csrf,
|
csrf: csrf,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
|
required: true,
|
||||||
display: display,
|
display: display,
|
||||||
norm: norm,
|
norm: norm,
|
||||||
fp: csrf.Signer().Fingerprint(fingerprintDomain + norm),
|
fp: csrf.Signer().Fingerprint(fingerprintDomain + norm),
|
||||||
|
|
@ -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).
|
// of the signer's key (the pre-session CSRF, the ceremony state).
|
||||||
const fingerprintDomain = "myday.setup.unlock.v1:"
|
const fingerprintDomain = "myday.setup.unlock.v1:"
|
||||||
|
|
||||||
// Code returns the setup code to PRINT in the startup banner (D-044). It is the
|
// Required reports whether a setup code is required to reach the wizard
|
||||||
// configured code verbatim, or the generated grouped form.
|
// (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 {
|
func (g *Gate) Code() string {
|
||||||
g.mu.Lock()
|
g.mu.Lock()
|
||||||
defer g.mu.Unlock()
|
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
|
// 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
|
// 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 {
|
func (g *Gate) Accept(code string) bool {
|
||||||
g.mu.Lock()
|
g.mu.Lock()
|
||||||
defer g.mu.Unlock()
|
defer g.mu.Unlock()
|
||||||
if g.norm == "" {
|
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 {
|
if subtle.ConstantTimeCompare([]byte(normalizeCode(code)), []byte(g.norm)) != 1 {
|
||||||
return false
|
return false
|
||||||
|
|
@ -124,8 +128,9 @@ func (g *Gate) Grant() string {
|
||||||
return g.csrf.Signer().Sign(g.fp)
|
return g.csrf.Signer().Sign(g.fp)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unlocked reports whether the request carries a valid unlock grant (D-044,
|
// 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).
|
// 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 {
|
func (g *Gate) Unlocked(r *http.Request) bool {
|
||||||
g.mu.Lock()
|
g.mu.Lock()
|
||||||
fp := g.fp
|
fp := g.fp
|
||||||
|
|
@ -162,52 +167,45 @@ func (g *Gate) Clear() {
|
||||||
g.mu.Unlock()
|
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" —
|
// CodeForm renders the setup-code entry page — `setup: true` with a required
|
||||||
// with the setup-code entry form. It is what `GET /` and `GET /setup` show in
|
// `setup_code`, before the code is entered. The form is anonymous, so it
|
||||||
// setup mode instead of a bare 404, so the domain never looks "down".
|
// carries the signed pre-session double-submit token (A15) exactly like every
|
||||||
//
|
// other pre-session form (SPEC-003 §6).
|
||||||
// The form is anonymous, so it carries the signed pre-session double-submit
|
func (g *Gate) CodeForm(w http.ResponseWriter, r *http.Request) error {
|
||||||
// token (A15) exactly like every other pre-session form (SPEC-003 §6).
|
return g.codeForm(w, r, http.StatusOK, "")
|
||||||
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
|
// 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).
|
// deliberately the same for a wrong and an absent code (no oracle, A3).
|
||||||
func (g *Gate) boilerplateError(w http.ResponseWriter, r *http.Request) error {
|
func (g *Gate) codeFormError(w http.ResponseWriter, r *http.Request) error {
|
||||||
return g.boilerplate(w, r, http.StatusForbidden, "That setup code is not correct.")
|
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)
|
token, err := g.csrf.Ensure(w, r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return renderHTML(w, status, boilerplatePage(token, flash))
|
return renderHTML(w, status, codeFormPage(token, flash))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── code generation & normalisation ─────────────────────────────────────────
|
// 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())
|
||||||
|
}
|
||||||
|
|
||||||
// generateCode returns a fresh 128-bit code, formatted as groups of four hex
|
// Locked renders the page `GET /` and `GET /setup` show when `setup: false` and
|
||||||
// characters (e.g. `9f3a-…`) so it is easy to read from the log and to type.
|
// no admin exists (D-045): the operator must enable the toggle and restart.
|
||||||
func generateCode() (string, error) {
|
// There is no form — this state is deliberately not claimable.
|
||||||
b := make([]byte, CodeBytes)
|
func (g *Gate) Locked(w http.ResponseWriter, r *http.Request) error {
|
||||||
if _, err := rand.Read(b); err != nil {
|
return renderHTML(w, http.StatusOK, lockedPage())
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── code normalisation ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
// normalizeCode lowercases a code and drops every non-alphanumeric character,
|
// normalizeCode lowercases a code and drops every non-alphanumeric character,
|
||||||
// so `9F3A-1B2C` and `9f3a1b2c` are the same code on entry.
|
// so `9F3A-1B2C` and `9f3a1b2c` are the same code on entry.
|
||||||
func normalizeCode(code string) string {
|
func normalizeCode(code string) string {
|
||||||
|
|
|
||||||
|
|
@ -45,14 +45,14 @@ func wizardPage(st step, token, domain string) web.Page {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// boilerplatePage renders the D-044 first-run page — what `GET /` and
|
// codeFormPage renders the D-044 first-run page — what `GET /setup` shows while
|
||||||
// `GET /setup` show in setup mode when the wizard has not been unlocked. It is
|
// `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".
|
// 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.
|
// 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
|
// 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).
|
// 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)
|
c := html.EscapeString(token)
|
||||||
kind := ""
|
kind := ""
|
||||||
if flash != "" {
|
if flash != "" {
|
||||||
|
|
@ -84,6 +84,32 @@ setup code that MyDay printed to its log when it started.</p>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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(`<h1>MyDay is ready for setup</h1>
|
||||||
|
<p>The setup wizard is enabled on this instance.</p>
|
||||||
|
<p><a href="/setup">Open the setup wizard</a></p>`),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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(`<h1>MyDay is not set up</h1>
|
||||||
|
<p>This instance has no administrator yet, and setup is disabled.</p>
|
||||||
|
<p>To run the first-run setup, set <code>setup: true</code> in <code>config.yaml</code> and
|
||||||
|
restart MyDay. Then open <code>/setup</code>.</p>`),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// stepBody renders the section for one step.
|
// stepBody renders the section for one step.
|
||||||
func stepBody(st step, c, domain string) string {
|
func stepBody(st step, c, domain string) string {
|
||||||
switch st {
|
switch st {
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,16 @@
|
||||||
// Package setup implements SPEC-005: the `/setup` wizard, reachable over the
|
// Package setup implements SPEC-005: the `/setup` wizard that brings a fresh
|
||||||
// direct SSH tunnel OR by unlocking it with the printed setup code (D-044),
|
// instance to life. The gate is the **config toggle `setup`** (D-045):
|
||||||
// that brings a fresh instance to life.
|
|
||||||
//
|
//
|
||||||
// While in setup mode and NOT unlocked, `GET /` and `GET /setup` render a
|
// - `setup: true` → `/setup` serves the wizard at the public domain;
|
||||||
// friendly first-run BOILERPLATE ("MyDay is not set up yet") with the setup-code
|
// - `setup: false` + no admin → the instance is LOCKED: `GET /` and
|
||||||
// entry form — never a bare 404, so the domain does not look "down" (D-044).
|
// `GET /setup` render a boilerplate telling the operator to enable setup,
|
||||||
// `POST /setup?do=setup&action=unlock` with the correct code grants the unlock
|
// and every other route is 404 (this state is not publicly claimable);
|
||||||
// (a signed cookie, not a session — no user exists yet).
|
// - 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
|
// The wizard walks three steps — (1) the domain / base URL, (2) the top admin
|
||||||
// with TOTP enrollment, (3) mail · locale · timezone — and finishes by
|
// with TOTP enrollment, (3) mail · locale · timezone — and finishes by
|
||||||
|
|
@ -21,8 +25,8 @@
|
||||||
// no duplication.
|
// no duplication.
|
||||||
//
|
//
|
||||||
// The wizard holds no routing of its own — the door (SPEC-003/005) gates it to
|
// 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)
|
// setup mode and verifies the pre-session CSRF token (A15) before invoking any
|
||||||
// before invoking any action here.
|
// action here.
|
||||||
//
|
//
|
||||||
// Spec: docs/specs/spec-005-setup-wizard.md.
|
// Spec: docs/specs/spec-005-setup-wizard.md.
|
||||||
package setup
|
package setup
|
||||||
|
|
@ -78,9 +82,10 @@ type Deps struct {
|
||||||
// ConfigPath is where the settings are persisted to (usually config.yaml,
|
// ConfigPath is where the settings are persisted to (usually config.yaml,
|
||||||
// never the committed example).
|
// never the committed example).
|
||||||
ConfigPath string
|
ConfigPath string
|
||||||
// Code is the D-044 setup code. When blank the wizard GENERATES a 128-bit
|
// Code is the OPTIONAL setup code (D-044 · D-045). When blank the toggle
|
||||||
// one and prints it in the startup banner (`Actions.Code`); an operator may
|
// alone admits and the wizard is served directly; when set, the wizard
|
||||||
// pin it via `setup_code` in the config.
|
// requires it (a second factor). The startup banner prints it only when set
|
||||||
|
// (`Actions.Code`).
|
||||||
Code string
|
Code string
|
||||||
// Users creates and inspects the top admin.
|
// Users creates and inspects the top admin.
|
||||||
Users Users
|
Users Users
|
||||||
|
|
@ -151,8 +156,8 @@ func New(d Deps) (*Actions, error) {
|
||||||
if d.WriteFile == nil {
|
if d.WriteFile == nil {
|
||||||
d.WriteFile = writeFile0600
|
d.WriteFile = writeFile0600
|
||||||
}
|
}
|
||||||
// D-044: the first-run gate — the code + the signed unlock cookie. A blank
|
// D-044: the first-run gate — the OPTIONAL setup code + the signed unlock
|
||||||
// code generates one (printed in the startup banner via Actions.Code).
|
// cookie. A blank code means no code is required (D-045).
|
||||||
gate, err := NewGate(d.Code, d.CSRF, d.Logger)
|
gate, err := NewGate(d.Code, d.CSRF, d.Logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
@ -160,32 +165,43 @@ func New(d Deps) (*Actions, error) {
|
||||||
return &Actions{d: d, gate: gate}, nil
|
return &Actions{d: d, gate: gate}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Code returns the setup code to print in the startup banner (D-044). It is the
|
// Code returns the setup code to print in the startup banner, or "" when no
|
||||||
// operator's `setup_code`, or the generated 128-bit value.
|
// code is required (D-045) — there is nothing to print in that case.
|
||||||
func (a *Actions) Code() string { return a.gate.Code() }
|
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
|
// 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
|
// unlocked and render the first-run pages (D-044/D-045). The wizard itself
|
||||||
// path — the door decides.
|
// never parses a path — the door decides.
|
||||||
func (a *Actions) Gate() *Gate { return a.gate }
|
func (a *Actions) Gate() *Gate { return a.gate }
|
||||||
|
|
||||||
// ── the first-run gate (D-044) ──────────────────────────────────────────────
|
// ── the first-run gate (D-044) ──────────────────────────────────────────────
|
||||||
|
|
||||||
// Unlock is the D-044 gate: `POST /setup?do=setup&action=unlock` carrying the
|
// Unlock is the D-044 code gate: `POST /setup?do=setup&action=unlock` carrying
|
||||||
// setup code. The door has already verified the pre-session CSRF token (A15)
|
// the setup code. The door has already verified the pre-session CSRF token
|
||||||
// and rate-limited the request (A3); here we check the code, grant the unlock
|
// (A15) and rate-limited the request (A3); here we check the code, grant the
|
||||||
// for this browser, and send the operator on to the wizard (post/redirect/get).
|
// 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
|
// When no code is required (D-045) the action is a no-op that simply lands on
|
||||||
// either way, so the form is no oracle — and is audited (no code value logged).
|
// 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 {
|
func (a *Actions) Unlock(w http.ResponseWriter, r *http.Request) error {
|
||||||
if a.done.Load() {
|
if a.done.Load() {
|
||||||
writeError(w, http.StatusNotFound, "not found")
|
writeError(w, http.StatusNotFound, "not found")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
if !a.gate.Required() {
|
||||||
|
redirect(w, r, "/setup")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
if !a.gate.Accept(r.FormValue("code")) {
|
if !a.gate.Accept(r.FormValue("code")) {
|
||||||
a.recordSetup(r.Context(), "setup.unlock", "refused")
|
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.gate.SetUnlockCookie(w, r)
|
||||||
a.recordSetup(r.Context(), "setup.unlock", "unlocked")
|
a.recordSetup(r.Context(), "setup.unlock", "unlocked")
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue