diff --git a/CHANGELOG.md b/CHANGELOG.md index 57c99ea..46004eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,39 @@ ## Changelog +### v0.122.0 — customer-claim password gate (closes DRILL-day0-vm F-4/F-5) (2026-07-12) — MinAgent: 0.81.0 + +The customer sets + OWNS the dashboard password; the old "no password → open dashboard" is gone. +An unclaimed box (hub-delivered claim-code hash present, no password) serves ONLY the claim page +— every other route answers the claim page (302 → `/claim`) or `401` (API), so a Day-0 box is +never open on the public internet (closes F-4; F-5's unauthenticated geo toggle closes with it). +Requires the hub's v0.50.0 claim engine (code generation + email + ACK/config delivery). + +- **`internal/web/claim.go`** — the gate + pages. `claimGateActive()` (no password + code hash + + not claimed), `effectiveClaimCode()` (ACK-cached settings beats the config bake by generation), + the claim page (`GET /claim`), submit (`POST /claim`: verify code → set own password → claimed + → consume generation → session), and "kérj új kódot / Elfelejtett jelszó" (`POST + /claim/request-new-code` → hub `reset-request`). Code checks: bcrypt match AND generation not + yet consumed (single-use) AND ≤ 72 h old. Per-source + global brute-force limiter (5 tries → + 15-min lockout, fake-clock tested); a lockout raises the allowlisted `claim_lockout` event. + Pre-auth CSRF is an HMAC over `web.session_secret` (closes the CTRL-007 bare-double-submit + weakness), min password length 12. +- **Gate wiring** (`auth.go`, `csrf.go`, `server.go`, `cmd`): the gate sits atop `RequireAuth`; a + SET password disables it entirely (password auth wins — claimed boxes never regress). `/claim*` + + `/static/*` stay reachable pre-auth (the code is the strong factor). Legacy-open (no password, + no hash) passes through with a red transition banner (`layout.html`) until the hub delivers a + hash. Login page gains an "Elfelejtett jelszó" link. +- **`internal/report/claim_sync.go`** — caches the ACK's `claim` {hash, generation} into + settings.json IDEMPOTENTLY BY GENERATION (offsite-descriptor one-way shape: newer generation + advances; same/older/nil never rewrites, a hub outage never clears). The report carries + `claimed` (set-only hub-side). `config.web.claim_code_*` baked by the hub gates from first boot. +- **`internal/settings`** — `Claimed` (set-only), `ClaimCode*` cache, `ClaimConsumedGeneration` + (single-use). **`--print-reset-code`** root escape hatch: prints a one-time local code (a + generation above cached/baked/consumed), the same gate consumes it. +- Tests: gate-coverage signature test (every route → claim/401, a deploy POST mutates nothing) + + happy-path/reuse-refused/expired/lockout+window-reopen; four §10 red-proofs proven + (mutate→FAIL→revert): gate skip-line, single-use generation (hub + controller), reset non-DoS, + rate-limiter. + ### v0.121.0 — backups page truth pass (dead sections removed, real Tier-3 state, SQLite-honest DB) (2026-07-12) — MinAgent: 0.81.0 Pure UI/data-plumbing on `/backups`; no backup-engine behavior change, no agent-API change, MinAgent diff --git a/controller/README.md b/controller/README.md index 7accbec..bc05af8 100644 --- a/controller/README.md +++ b/controller/README.md @@ -1557,6 +1557,33 @@ self_update: ### 8. Authentication & Settings +#### Customer-claim gate (`internal/web/claim.go`, v0.122.0 — closes DRILL-day0-vm F-4/F-5) + +The dashboard password is **customer-owned**, set through a one-time claim code the hub emails to +the registered address (no operator-set path, no open-until-set window). This closes the fresh-box +race where a new `felhom.` cert appears in CT logs minutes before any password exists. + +- **States** (precedence): a SET password (settings→config) always wins — the gate never shows. + Else a delivered **claim-code hash + not-yet-claimed** → GATED: every route serves the claim + page (`302 → /claim`) or `401` JSON (API); only `/claim*`, `/static/*`, `/api/health` pass. Else + (no password, no hash) → **legacy-open** with a red transition banner until the hub delivers a + hash (transitional only, never the fresh-box state). +- **Claim/reset flow**: `GET /claim` (code + new password ×2, min 12) → `POST /claim` verifies the + code (bcrypt match AND generation not yet consumed AND ≤ 72 h old), sets the customer's password + via `settings.SetPasswordHash`, marks `Claimed` (set-only), consumes the generation (single-use), + invalidates sessions, issues a fresh one. `POST /claim/request-new-code` (the "Új kód kérése" / + login-page "Elfelejtett jelszó") forwards to the hub, which emails a fresh code to the + registered address only. Reset rides the same page (a claimed box reaches `/claim` pre-auth). +- **Anti-brute-force**: per-source + global counter, 5 failures → 15-minute lockout (both scopes), + raising the allowlisted `claim_lockout` event. Pre-auth CSRF is an HMAC over `web.session_secret` + (fixes the CTRL-007 bare-double-submit weakness). +- **Delivery**: the hub bakes `web.claim_code_{hash,generation,issued_at}` into the Day-0 + controller.yaml (gate-from-first-boot) and serves the freshest state in the report ACK + (`report/claim_sync.go` caches it idempotently by generation — newer advances, same/older/nil + never rewrites, a hub outage never clears). The report carries `claimed` (hub ingests set-only). +- **Escape hatch**: `felhom-controller --print-reset-code` prints a one-time local code (generation + above cached/baked/consumed); the same gate consumes it. Root-gated by `docker exec` reachability. + #### Session Auth (`internal/web/auth.go`) - bcrypt password verification with configurable source priority: `settings.json` → `controller.yaml` → no auth (open access) diff --git a/controller/cmd/controller/main.go b/controller/cmd/controller/main.go index d173a6e..e2abc43 100644 --- a/controller/cmd/controller/main.go +++ b/controller/cmd/controller/main.go @@ -67,6 +67,7 @@ func main() { configPath := flag.String("config", "/opt/docker/felhom-controller/controller.yaml", "Path to configuration file") showVersion := flag.Bool("version", false, "Show version and exit") + printResetCode := flag.Bool("print-reset-code", false, "Customer-claim escape hatch (v0.122.0, F-4): print a fresh one-time local claim/reset code to stdout, then exit. Root-gated by reachability (docker exec). Same gate consumes it.") flag.Parse() if *showVersion { @@ -74,6 +75,20 @@ func main() { os.Exit(0) } + if *printResetCode { + cfg, err := config.LoadPermissive(*configPath) + if err != nil { + fmt.Fprintf(os.Stderr, "print-reset-code: loading config: %v\n", err) + os.Exit(1) + } + sett, err := settings.Load(cfg.Paths.DataDir+"/settings.json", log.New(os.Stderr, "", 0)) + if err != nil { + fmt.Fprintf(os.Stderr, "print-reset-code: loading settings: %v\n", err) + os.Exit(1) + } + os.Exit(web.PrintLocalResetCode(sett, cfg)) + } + startTime := time.Now() // --- Load configuration --- @@ -487,6 +502,10 @@ func main() { report.SetPendingLogTails(resp.LogTailRequests) // v0.116.0: the controller's OWN ring, same pattern (selftail.go). report.SetPendingControllerLog(resp.ControllerLogRequested) + // v0.122.0 (F-4): cache the hub-delivered claim-code state (idempotent by + // generation). The web gate reads it on the next request — no restart needed. + claimSync := &report.ClaimSync{Settings: sett, Logger: logger} + claimSync.Reconcile(resp.Claim) } // Wire hub push status into alert manager for dashboard alerts alertMgr.SetHubPushStatus(func() web.HubPushStatusData { @@ -579,6 +598,7 @@ func main() { } sched.Every("hub-report", pushInterval, func(ctx context.Context) error { r := report.BuildReport(cfg, *configPath, stackMgr, backupMgr, cpuCollector, metricsStore, Version, sett.GetStoragePaths(), sett.GetGeoRestriction(), logger) + r.Claimed = sett.GetClaimed() // v0.122.0 (F-4): set-only claim flag for the hub if err := hubPusher.Push(r); err != nil { return err } @@ -684,6 +704,7 @@ func main() { if hubPusher != nil { if cfg.Hub.Enabled { r := report.BuildReport(cfg, *configPath, stackMgr, backupMgr, cpuCollector, metricsStore, Version, sett.GetStoragePaths(), sett.GetGeoRestriction(), logger) + r.Claimed = sett.GetClaimed() // v0.122.0 (F-4): set-only claim flag for the hub var pushErr error for attempt := 1; attempt <= 3; attempt++ { pushErr = hubPusher.Push(r) @@ -762,6 +783,7 @@ func main() { apiRouter.SetReportPushTrigger(func() { go func() { rep := report.BuildReport(cfg, *configPath, stackMgr, backupMgr, cpuCollector, metricsStore, Version, sett.GetStoragePaths(), sett.GetGeoRestriction(), logger) + rep.Claimed = sett.GetClaimed() // v0.122.0 (F-4): set-only claim flag for the hub if err := hubPusher.Push(rep); err != nil { logger.Printf("[WARN] [report] Out-of-band geo report push failed: %v", err) } @@ -876,6 +898,7 @@ func main() { if hubPusher != nil { dc.TriggerHubReportPush = func() error { r := report.BuildReport(cfg, *configPath, stackMgr, backupMgr, cpuCollector, metricsStore, Version, sett.GetStoragePaths(), sett.GetGeoRestriction(), logger) + r.Claimed = sett.GetClaimed() // v0.122.0 (F-4): set-only claim flag for the hub return hubPusher.Push(r) } } diff --git a/controller/internal/config/config.go b/controller/internal/config/config.go index 899d590..ca03ed4 100644 --- a/controller/internal/config/config.go +++ b/controller/internal/config/config.go @@ -122,6 +122,14 @@ type WebConfig struct { SetupListen string `yaml:"setup_listen"` // Plain HTTP listener for setup wizard (only active during setup mode) PasswordHash string `yaml:"password_hash"` SessionSecret string `yaml:"session_secret"` + + // Customer-claim arc (v0.122.0, DRILL-day0-vm F-4): the hub-baked claim/reset code state — + // bcrypt(code) + monotonic generation + issue time (RFC3339). Day-0 configs carry these so a + // fresh box is claim-gated from FIRST boot; live boxes get fresher values via the report ACK + // (settings.json wins when its generation is newer). A set password always beats the gate. + ClaimCodeHash string `yaml:"claim_code_hash"` + ClaimCodeGeneration int `yaml:"claim_code_generation"` + ClaimCodeIssuedAt string `yaml:"claim_code_issued_at"` } type GitConfig struct { @@ -418,3 +426,9 @@ func (cfg *Config) AppScreenshotURL(slug string, index int) string { func (cfg *Config) AppPageURL(slug string) string { return fmt.Sprintf("/apps/%s", slug) } + +// WebClaimGeneration returns the config-baked claim-code generation (0 when none). Satisfies the +// web.ClaimHatchConfig seam for the --print-reset-code escape hatch. +func (cfg *Config) WebClaimGeneration() int { + return cfg.Web.ClaimCodeGeneration +} diff --git a/controller/internal/report/claim_sync.go b/controller/internal/report/claim_sync.go new file mode 100644 index 0000000..3018184 --- /dev/null +++ b/controller/internal/report/claim_sync.go @@ -0,0 +1,53 @@ +package report + +import "log" + +// Customer-claim arc (v0.122.0, F-4) — the ACK-side of the claim-code delivery. The hub serves +// the ACTIVE code's bcrypt hash + monotonic generation on every report ACK of a managed +// customer; this reconciler caches it into settings.json IDEMPOTENTLY BY GENERATION (the +// offsite-descriptor guard shape: an unchanged generation never rewrites, a hub outage never +// clears — set/refresh only, exactly like the escrow confirmer's one-way rules). The gate itself +// (internal/web) reads the cached state; nothing here decides gating. + +// ClaimStatus mirrors the hub ACK's `claim` object (nil when the hub has no claim row / old hub). +type ClaimStatus struct { + CodeHash string `json:"code_hash"` + Generation int `json:"generation"` + IssuedAt string `json:"issued_at"` // RFC3339 +} + +// ClaimSettings is the settings surface the sync needs (satisfied by *settings.Settings). +type ClaimSettings interface { + GetClaimCode() (hash string, generation int, issuedAt string) + SetClaimCode(hash string, generation int, issuedAt string) error +} + +// ClaimSync applies one ACK's claim status to the settings cache. +type ClaimSync struct { + Settings ClaimSettings + Logger *log.Logger +} + +func (c *ClaimSync) logf(f string, a ...any) { + if c.Logger != nil { + c.Logger.Printf(f, a...) + } +} + +// Reconcile caches a newer-generation code state; same-or-older generations and nil/empty +// statuses are no-ops (a rotation is the ONLY thing that moves the cache — no write-back, no +// clearing on hub silence). +func (c *ClaimSync) Reconcile(cs *ClaimStatus) { + if cs == nil || cs.CodeHash == "" || cs.Generation <= 0 { + return + } + _, curGen, _ := c.Settings.GetClaimCode() + if cs.Generation <= curGen { + return // idempotent: this generation (or a newer one) is already cached + } + if err := c.Settings.SetClaimCode(cs.CodeHash, cs.Generation, cs.IssuedAt); err != nil { + c.logf("[ERROR] [claim-sync] caching hub claim code (gen %d) failed (retries next ACK): %v", cs.Generation, err) + return + } + c.logf("[INFO] [claim-sync] hub claim code cached (generation %d) — hash first 8: %.8s…", cs.Generation, cs.CodeHash) +} diff --git a/controller/internal/report/claim_sync_test.go b/controller/internal/report/claim_sync_test.go new file mode 100644 index 0000000..c0febbd --- /dev/null +++ b/controller/internal/report/claim_sync_test.go @@ -0,0 +1,62 @@ +package report + +import ( + "io" + "log" + "testing" +) + +type fakeClaimSettings struct { + hash string + gen int + issued string + setCall int +} + +func (f *fakeClaimSettings) GetClaimCode() (string, int, string) { return f.hash, f.gen, f.issued } +func (f *fakeClaimSettings) SetClaimCode(hash string, gen int, issued string) error { + f.hash, f.gen, f.issued = hash, gen, issued + f.setCall++ + return nil +} + +func newSync(f *fakeClaimSettings) *ClaimSync { + return &ClaimSync{Settings: f, Logger: log.New(io.Discard, "", 0)} +} + +// A newer generation caches; the same/older generation and nil are no-ops (idempotent, one-way). +func TestClaimSync_IdempotentByGeneration(t *testing.T) { + f := &fakeClaimSettings{} + s := newSync(f) + + s.Reconcile(&ClaimStatus{CodeHash: "h1", Generation: 1, IssuedAt: "t1"}) + if f.gen != 1 || f.hash != "h1" || f.setCall != 1 { + t.Fatalf("first cache: %+v", f) + } + + // Same generation → no write. + s.Reconcile(&ClaimStatus{CodeHash: "h1-again", Generation: 1, IssuedAt: "t1"}) + if f.setCall != 1 || f.hash != "h1" { + t.Fatalf("same generation must not rewrite: %+v", f) + } + + // Older generation → no write (a lagging ACK can't regress the cache). + s.Reconcile(&ClaimStatus{CodeHash: "h0", Generation: 0, IssuedAt: "t0"}) + if f.setCall != 1 { + t.Fatalf("older generation must not rewrite: %+v", f) + } + + // Newer generation (a resend) → cache advances. + s.Reconcile(&ClaimStatus{CodeHash: "h2", Generation: 2, IssuedAt: "t2"}) + if f.gen != 2 || f.hash != "h2" || f.setCall != 2 { + t.Fatalf("newer generation should advance: %+v", f) + } + + // nil / empty / non-positive generation → no-op (old hub, no claim row). + s.Reconcile(nil) + s.Reconcile(&ClaimStatus{CodeHash: "", Generation: 3}) + s.Reconcile(&ClaimStatus{CodeHash: "h", Generation: 0}) + if f.setCall != 2 { + t.Fatalf("nil/empty/zero-gen must be no-ops: %+v", f) + } +} diff --git a/controller/internal/report/pusher.go b/controller/internal/report/pusher.go index 3ce3a5c..31d039e 100644 --- a/controller/internal/report/pusher.go +++ b/controller/internal/report/pusher.go @@ -48,6 +48,9 @@ type PushResponse struct { // ring; the NEXT report ships controller_log_tail (selftail.go). Absent/false on an // old hub = nothing pending. ControllerLogRequested bool `json:"controller_log_requested"` + // Claim (v0.122.0, F-4) — the hub's active claim-code state (bcrypt hash + generation) for + // the customer-claim gate. nil on an old hub / no claim row → the cache stays as-is. + Claim *ClaimStatus `json:"claim"` } // Pusher sends reports to the central hub. diff --git a/controller/internal/report/types.go b/controller/internal/report/types.go index a0d3b97..9c318ed 100644 --- a/controller/internal/report/types.go +++ b/controller/internal/report/types.go @@ -43,6 +43,11 @@ type Report struct { // the cycle right after the ACK's controller_log_requested (selftail.go; additive — the // app-tail flow above is untouched). ControllerLogTail *ControllerLogTail `json:"controller_log_tail,omitempty"` + + // Claimed (v0.122.0, F-4) — whether the customer has completed the dashboard claim (set + // their own password). The hub ingests it SET-ONLY: a later false (wiped settings.json + // after DR) never un-claims the customer hub-side. + Claimed bool `json:"claimed"` } // SystemReport holds host-level system info. diff --git a/controller/internal/settings/settings.go b/controller/internal/settings/settings.go index 695f506..6d5c4ac 100644 --- a/controller/internal/settings/settings.go +++ b/controller/internal/settings/settings.go @@ -28,6 +28,17 @@ type Settings struct { // Auth PasswordHash string `json:"password_hash,omitempty"` // bcrypt hash, overrides controller.yaml + // Customer-claim arc (v0.122.0, F-4). Claimed is SET-ONLY (a claim or reset completed at + // least once — never cleared). ClaimCode* cache the freshest hub-delivered code state (report + // ACK; beats controller.yaml when its generation is newer). ClaimConsumedGeneration records + // the last code generation successfully consumed — a code of a consumed generation is dead + // even if its hash still matches (single-use). + Claimed bool `json:"claimed,omitempty"` + ClaimCodeHash string `json:"claim_code_hash,omitempty"` + ClaimCodeGeneration int `json:"claim_code_generation,omitempty"` + ClaimCodeIssuedAt string `json:"claim_code_issued_at,omitempty"` // RFC3339 + ClaimConsumedGeneration int `json:"claim_consumed_generation,omitempty"` + // Notification preferences (Phase 2 — define struct now, leave empty) Notifications *NotificationPrefs `json:"notifications,omitempty"` @@ -405,6 +416,60 @@ func (s *Settings) SetPasswordHash(hash string) error { return s.save() } +// ── Customer-claim arc (v0.122.0) ────────────────────────────────────────────── + +// GetClaimed reports whether this box has completed a claim (set-only). +func (s *Settings) GetClaimed() bool { + s.mu.RLock() + defer s.mu.RUnlock() + return s.Claimed +} + +// SetClaimed marks the box claimed (never un-claims) and saves. +func (s *Settings) SetClaimed() error { + s.mu.Lock() + defer s.mu.Unlock() + if s.Claimed { + return nil + } + s.Claimed = true + return s.save() +} + +// GetClaimCode returns the cached hub-delivered code state (hash, generation, issuedAt RFC3339). +func (s *Settings) GetClaimCode() (hash string, generation int, issuedAt string) { + s.mu.RLock() + defer s.mu.RUnlock() + return s.ClaimCodeHash, s.ClaimCodeGeneration, s.ClaimCodeIssuedAt +} + +// SetClaimCode caches a hub-delivered code state (idempotent by generation — the caller guards). +func (s *Settings) SetClaimCode(hash string, generation int, issuedAt string) error { + s.mu.Lock() + defer s.mu.Unlock() + s.ClaimCodeHash = hash + s.ClaimCodeGeneration = generation + s.ClaimCodeIssuedAt = issuedAt + return s.save() +} + +// GetClaimConsumedGeneration returns the last successfully consumed code generation. +func (s *Settings) GetClaimConsumedGeneration() int { + s.mu.RLock() + defer s.mu.RUnlock() + return s.ClaimConsumedGeneration +} + +// SetClaimConsumedGeneration records a consumed code generation (single-use enforcement). +func (s *Settings) SetClaimConsumedGeneration(gen int) error { + s.mu.Lock() + defer s.mu.Unlock() + if gen > s.ClaimConsumedGeneration { + s.ClaimConsumedGeneration = gen + } + return s.save() +} + // GetDBValidations returns a copy of the cached DB validations. func (s *Settings) GetDBValidations() map[string]DBValidationCache { s.mu.RLock() diff --git a/controller/internal/web/auth.go b/controller/internal/web/auth.go index 91d3503..228dc83 100644 --- a/controller/internal/web/auth.go +++ b/controller/internal/web/auth.go @@ -51,7 +51,24 @@ func (s *Server) authEnabled() bool { // RequireAuth returns middleware that checks for valid session or shows login. func (s *Server) RequireAuth(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Skip auth if no password is configured + // Customer-claim gate (v0.122.0, F-4): an unclaimed box with a delivered code hash and no + // password serves ONLY the claim page + its assets; everything else → claim page / 401. + // The claim routes (/claim, /claim/request-new-code) are handled by the mux — let them + // through so serveClaimGate only intercepts the GATED paths. A set password disables the + // gate entirely (claimGateActive returns false → the normal auth path below runs). + if s.claimGateActive() { + if claimPageAllowedPath(r.URL.Path) { + next.ServeHTTP(w, r) + return + } + if s.isDebug() { + s.logger.Printf("[DEBUG] [web] claim gate: intercepting %s %s (unclaimed)", r.Method, r.URL.Path) + } + s.serveClaimGate(w, r) + return + } + + // Skip auth if no password is configured (legacy-open transition state, or claim disabled). if !s.authEnabled() { if s.isDebug() { s.logger.Printf("[DEBUG] [web] auth: no password configured, passing through %s %s", r.Method, r.URL.Path) @@ -65,6 +82,13 @@ func (s *Server) RequireAuth(next http.Handler) http.Handler { return } + // Claim/reset routes stay reachable pre-auth even on a claimed box: they are the RESET + // entry (code-gated internally). Static assets for the page too. + if r.URL.Path == "/claim" || r.URL.Path == "/claim/request-new-code" || strings.HasPrefix(r.URL.Path, "/static/") { + next.ServeHTTP(w, r) + return + } + if r.URL.Path == "/login" && r.Method == http.MethodPost { s.handleLogin(w, r) return diff --git a/controller/internal/web/claim.go b/controller/internal/web/claim.go new file mode 100644 index 0000000..78ae57c --- /dev/null +++ b/controller/internal/web/claim.go @@ -0,0 +1,471 @@ +package web + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "os" + "strings" + "time" + + "golang.org/x/crypto/bcrypt" +) + +// Customer-claim password arc (v0.122.0, DRILL-day0-vm F-4). The customer OWNS the dashboard +// password: an unclaimed box serves ONLY the claim page (code → set own password → claimed); +// everything else answers the claim page (HTML) or 401 (API). A set password always wins (the +// gate never shows once effectivePasswordHash != ""). Reset rides the same code engine. A box +// with a code hash but no password and not-yet-claimed is GATED; a box with neither hash nor +// password is legacy-open with a red transition banner (transitional only). + +const ( + claimCodeTTL = 72 * time.Hour + claimMinPassword = 12 + claimMaxAttempts = 5 + claimLockoutWindow = 15 * time.Minute + claimCSRFCookie = "felhom_claim_csrf" +) + +// claimAttempt tracks failed claim-code attempts for the per-source + global limiter. +type claimAttempt struct { + count int + lockedTill time.Time +} + +// effectiveClaimCode returns the freshest hub-delivered claim-code state: the ACK-cached +// settings value when its generation is at least the config-baked one (fresher), else the +// controller.yaml bake. Returns ("", 0, "") when neither carries a code. +func (s *Server) effectiveClaimCode() (hash string, generation int, issuedAt string) { + var sHash, sIssued string + var sGen int + if s.settings != nil { + sHash, sGen, sIssued = s.settings.GetClaimCode() + } + cHash := s.cfg.Web.ClaimCodeHash + cGen := s.cfg.Web.ClaimCodeGeneration + cIssued := s.cfg.Web.ClaimCodeIssuedAt + if sHash != "" && sGen >= cGen { + return sHash, sGen, sIssued + } + return cHash, cGen, cIssued +} + +// claimGateActive reports whether the unclaimed-gate applies: no password set anywhere, a claim +// code hash is present, and the box has not been claimed. A set password (settings or config) +// disables the gate entirely — password auth wins. +func (s *Server) claimGateActive() bool { + if s.authEnabled() { + return false // a password beats the gate (claimed boxes, or an operator-set one) + } + hash, _, _ := s.effectiveClaimCode() + if hash == "" { + return false // legacy-open (transition state) — no code to gate on + } + if s.settings != nil && s.settings.GetClaimed() { + return false // claimed but password somehow cleared — don't re-gate; treat as legacy-open + } + return true +} + +// claimLegacyOpen reports the transitional open state: no password, no code hash — the red +// banner is shown until the hub delivers a code hash. NOT the fresh-box state (that is gated). +func (s *Server) claimLegacyOpen() bool { + if s.authEnabled() { + return false + } + hash, _, _ := s.effectiveClaimCode() + return hash == "" +} + +// ── pre-auth CSRF for the claim form (closes CTRL-007: HMAC with the server-side session +// secret, not a bare double-submit) ──────────────────────────────────────────────────────── + +func (s *Server) claimCSRFToken() string { + mac := hmac.New(sha256.New, []byte(s.cfg.Web.SessionSecret)) + mac.Write([]byte("felhom-claim-csrf-v1")) + return hex.EncodeToString(mac.Sum(nil)) +} + +func (s *Server) setClaimCSRFCookie(w http.ResponseWriter, r *http.Request) string { + tok := s.claimCSRFToken() + http.SetCookie(w, &http.Cookie{ + Name: claimCSRFCookie, + Value: tok, + Path: "/", + HttpOnly: false, // read back only by the form on the same page; SameSite blocks cross-site + SameSite: http.SameSiteStrictMode, + Secure: r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https", + MaxAge: int(claimCodeTTL.Seconds()), + }) + return tok +} + +func (s *Server) validClaimCSRF(r *http.Request) bool { + want := s.claimCSRFToken() + form := r.FormValue(csrfFormField) + if subtle.ConstantTimeCompare([]byte(form), []byte(want)) != 1 { + return false + } + c, err := r.Cookie(claimCSRFCookie) + if err != nil { + return false + } + return subtle.ConstantTimeCompare([]byte(c.Value), []byte(want)) == 1 +} + +// ── the limiter (per-source IP + a global counter; both must be clear) ─────────────────────── + +func (s *Server) claimRateLocked() (locked bool, till time.Time) { + s.claimMu.Lock() + defer s.claimMu.Unlock() + now := s.claimNow() + if s.claimGlobal.lockedTill.After(now) { + return true, s.claimGlobal.lockedTill + } + return false, time.Time{} +} + +func (s *Server) claimSourceLocked(ip string) (locked bool, till time.Time) { + s.claimMu.Lock() + defer s.claimMu.Unlock() + now := s.claimNow() + a := s.claimAttempts[ip] + if a != nil && a.lockedTill.After(now) { + return true, a.lockedTill + } + return false, time.Time{} +} + +// claimRegisterFailure bumps the per-IP + global counters; on hitting the cap it locks that +// scope for claimLockoutWindow and returns locked=true (the caller reports the lockout event). +// An EXPIRED lock resets its scope's counter first, so a fresh attempt after the window starts +// clean rather than re-locking on a stale count. +func (s *Server) claimRegisterFailure(ip string) (locked bool) { + s.claimMu.Lock() + defer s.claimMu.Unlock() + now := s.claimNow() + if s.claimAttempts == nil { + s.claimAttempts = make(map[string]*claimAttempt) + } + a := s.claimAttempts[ip] + if a == nil { + a = &claimAttempt{} + s.claimAttempts[ip] = a + } + if !a.lockedTill.IsZero() && !a.lockedTill.After(now) { + *a = claimAttempt{} // per-IP lock expired → clean slate + } + if !s.claimGlobal.lockedTill.IsZero() && !s.claimGlobal.lockedTill.After(now) { + s.claimGlobal = claimAttempt{} // global lock expired → clean slate + } + a.count++ + s.claimGlobal.count++ + if a.count >= claimMaxAttempts { + a.lockedTill = now.Add(claimLockoutWindow) + locked = true + } + if s.claimGlobal.count >= claimMaxAttempts { + s.claimGlobal.lockedTill = now.Add(claimLockoutWindow) + locked = true + } + return locked +} + +func (s *Server) claimClearFailures(ip string) { + s.claimMu.Lock() + defer s.claimMu.Unlock() + delete(s.claimAttempts, ip) + s.claimGlobal = claimAttempt{} +} + +// claimNow is the clock seam (tests inject a fake). Defaults to time.Now. +func (s *Server) claimNow() time.Time { + if s.claimClock != nil { + return s.claimClock() + } + return time.Now() +} + +func requestIP(r *http.Request) string { + ip := r.RemoteAddr + if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" { + ip = strings.Split(fwd, ",")[0] + } + return strings.TrimSpace(ip) +} + +// ── the pages ──────────────────────────────────────────────────────────────────────────────── + +// claimPageAllowedPath reports the paths reachable while the unclaimed gate is active (the claim +// page itself, its static assets, health). Everything else is gated. +func claimPageAllowedPath(path string) bool { + switch path { + case "/claim", "/claim/request-new-code", "/api/health": + return true + } + return strings.HasPrefix(path, "/static/") +} + +// serveClaimGate is invoked by RequireAuth when the unclaimed gate is active and the request is +// NOT an allowed path: render the claim page (HTML) or a 401 (API / mutating). +func (s *Server) serveClaimGate(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, "/api/") { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + fmt.Fprint(w, `{"ok":false,"error":"dashboard not yet claimed"}`) + return + } + http.Redirect(w, r, "/claim", http.StatusFound) +} + +// handleClaimPage renders the claim/reset code-entry page (GET). Reachable pre-auth: the code is +// the strong factor. For a claimed box (password set) it doubles as the reset-code entry. +func (s *Server) handleClaimPage(w http.ResponseWriter, r *http.Request, errorMsg, flashMsg string) { + csrf := s.setClaimCSRFCookie(w, r) + hash, _, _ := s.effectiveClaimCode() + reset := s.authEnabled() // a set password means this is the reset flow, not first-claim + data := map[string]interface{}{ + "Title": "A szerver beállítása", + "CustomerName": s.cfg.Customer.Name, + "Domain": s.cfg.Customer.Domain, + "Version": s.version, + "Error": errorMsg, + "Flash": flashMsg, + "ClaimCSRF": csrf, + "IsReset": reset, + "HasCode": hash != "", + "MinPassword": claimMinPassword, + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := s.tmpl.ExecuteTemplate(w, "claim", data); err != nil { + s.logger.Printf("[ERROR] [web] Template error (claim): %v", err) + http.Error(w, "Internal error", http.StatusInternalServerError) + } +} + +// handleClaimSubmit verifies the code and sets the customer's password (POST /claim). On success +// the box is claimed (or the password reset), the code generation is consumed (single-use), all +// sessions are invalidated and a fresh one is issued. +func (s *Server) handleClaimSubmit(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + if !s.validClaimCSRF(r) { + s.handleClaimPage(w, r, "Érvénytelen űrlap — töltsd újra az oldalt.", "") + return + } + wasReset := s.authEnabled() // a password already set → this is a reset, not a first-claim + ip := requestIP(r) + + if locked, _ := s.claimRateLocked(); locked { + s.handleClaimPage(w, r, "Túl sok próbálkozás — próbáld újra 15 perc múlva.", "") + return + } + if locked, _ := s.claimSourceLocked(ip); locked { + s.handleClaimPage(w, r, "Túl sok próbálkozás — próbáld újra 15 perc múlva.", "") + return + } + + code := strings.TrimSpace(r.FormValue("code")) + newPassword := r.FormValue("new_password") + confirm := r.FormValue("confirm_password") + + hash, generation, issuedAt := s.effectiveClaimCode() + if hash == "" { + s.handleClaimPage(w, r, "Nincs aktív kód — kérj újat az alábbi gombbal.", "") + return + } + + // Code checks: not expired, not an already-consumed generation, hash matches. A failure of + // ANY of these counts toward the lockout (they are indistinguishable to a guesser). + valid := true + if consumed := s.settings.GetClaimConsumedGeneration(); generation <= consumed { + valid = false // this code was already used (single-use) + } + if valid && issuedAt != "" { + if t, err := time.Parse(time.RFC3339, issuedAt); err == nil && s.claimNow().Sub(t) > claimCodeTTL { + valid = false // expired + } + } + if valid && bcrypt.CompareHashAndPassword([]byte(hash), []byte(code)) != nil { + valid = false // wrong code + } + if !valid { + if s.claimRegisterFailure(ip) { + s.reportClaimLockout(ip) + s.handleClaimPage(w, r, "Túl sok próbálkozás — próbáld újra 15 perc múlva.", "") + return + } + s.handleClaimPage(w, r, "Hibás vagy lejárt kód", "") + return + } + + // Password rules (min length, match). + if len(newPassword) < claimMinPassword { + s.handleClaimPage(w, r, fmt.Sprintf("A jelszónak legalább %d karakter hosszúnak kell lennie", claimMinPassword), "") + return + } + if newPassword != confirm { + s.handleClaimPage(w, r, "A két jelszó nem egyezik", "") + return + } + + pwHash, err := bcrypt.GenerateFromPassword([]byte(newPassword), 10) + if err != nil { + s.logger.Printf("[ERROR] [web] claim: hashing new password: %v", err) + s.handleClaimPage(w, r, "Belső hiba a jelszó mentésekor", "") + return + } + if err := s.settings.SetPasswordHash(string(pwHash)); err != nil { + s.logger.Printf("[ERROR] [web] claim: saving password: %v", err) + s.handleClaimPage(w, r, "Belső hiba a jelszó mentésekor", "") + return + } + // Consume the generation (single-use) + mark claimed (set-only). Order: consume BEFORE + // claimed so a crash between them can't leave a reusable code on a claimed box. + if err := s.settings.SetClaimConsumedGeneration(generation); err != nil { + s.logger.Printf("[WARN] [web] claim: recording consumed generation failed: %v", err) + } + if err := s.settings.SetClaimed(); err != nil { + s.logger.Printf("[WARN] [web] claim: marking claimed failed: %v", err) + } + s.claimClearFailures(ip) + s.invalidateAllSessions() // reset: kill old sessions; first-claim: none exist + + action := "claimed" + if wasReset { + action = "password reset" + } + s.logger.Printf("[INFO] [web] dashboard %s by the customer from %s (code generation %d consumed)", action, ip, generation) + + // Issue a fresh session so the customer lands logged-in. + token := s.createSession() + http.SetCookie(w, &http.Cookie{ + Name: sessionCookieName, + Value: token, + Path: "/", + MaxAge: int(sessionMaxAge.Seconds()), + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + Secure: r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https", + }) + http.Redirect(w, r, "/", http.StatusFound) +} + +// handleClaimRequestNewCode forwards a "kérj új kódot" / "Elfelejtett jelszó" to the hub, which +// emails a FRESH code to the REGISTERED address only (the requester never chooses the +// destination). The response is always the neutral confirmation page. +func (s *Server) handleClaimRequestNewCode(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + if !s.validClaimCSRF(r) { + s.handleClaimPage(w, r, "Érvénytelen űrlap — töltsd újra az oldalt.", "") + return + } + go s.requestHubResetCode() // fire-and-forget; the neutral response never reveals the outcome + s.handleClaimPage(w, r, "", "Ha az e-mail cím regisztrálva van, elküldtük a kódot.") +} + +// requestHubResetCode calls POST /api/v1/claim/reset-request with the box's own report key. +func (s *Server) requestHubResetCode() { + if s.cfg.Hub.URL == "" || s.cfg.Hub.APIKey == "" { + s.logger.Printf("[WARN] [web] claim: cannot request a new code — hub URL/key not configured") + return + } + body, _ := json.Marshal(map[string]string{"customer_id": s.cfg.Customer.ID}) + req, err := http.NewRequest(http.MethodPost, strings.TrimRight(s.cfg.Hub.URL, "/")+"/api/v1/claim/reset-request", strings.NewReader(string(body))) + if err != nil { + s.logger.Printf("[ERROR] [web] claim: building reset-request: %v", err) + return + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+s.cfg.Hub.APIKey) + client := &http.Client{Timeout: 15 * time.Second} + resp, err := client.Do(req) + if err != nil { + s.logger.Printf("[ERROR] [web] claim: reset-request to hub failed: %v", err) + return + } + resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + s.logger.Printf("[WARN] [web] claim: hub reset-request returned HTTP %d", resp.StatusCode) + return + } + s.logger.Printf("[INFO] [web] claim: requested a fresh code from the hub for %s", s.cfg.Customer.ID) +} + +// PrintLocalResetCode is the root escape hatch (v0.122.0, --print-reset-code): generate a fresh +// local claim/reset code, install its hash at a generation ABOVE any cached/consumed one (so the +// gate accepts it), persist to settings.json, and print the plaintext ONCE to stdout. Same gate +// consumes it (single-use). Root-gated by reachability (docker exec into the container). Returns +// a process exit code. +func PrintLocalResetCode(sett ClaimHatchSettings, cfg ClaimHatchConfig) int { + code, err := localCode() + if err != nil { + fmt.Fprintf(os.Stderr, "print-reset-code: generating code: %v\n", err) + return 1 + } + hash, err := bcrypt.GenerateFromPassword([]byte(code), 10) + if err != nil { + fmt.Fprintf(os.Stderr, "print-reset-code: hashing code: %v\n", err) + return 1 + } + _, cachedGen, _ := sett.GetClaimCode() + nextGen := cachedGen + if cfg.WebClaimGeneration() > nextGen { + nextGen = cfg.WebClaimGeneration() + } + if c := sett.GetClaimConsumedGeneration(); c >= nextGen { + nextGen = c + } + nextGen++ // strictly above cached, baked, and consumed → the gate treats it as fresh + unused + if err := sett.SetClaimCode(string(hash), nextGen, time.Now().UTC().Format(time.RFC3339)); err != nil { + fmt.Fprintf(os.Stderr, "print-reset-code: saving code: %v\n", err) + return 1 + } + fmt.Printf("Egyszer használható helyi beállító/visszaállító kód (generation %d):\n\n %s\n\nAdd meg a vezérlőpult beállító oldalán (/claim), majd válassz új jelszót.\n", nextGen, code) + return 0 +} + +// ClaimHatchSettings / ClaimHatchConfig are the minimal seams the escape hatch needs (satisfied +// by *settings.Settings and *config.Config respectively — kept as interfaces so cmd/ wires them +// without this package importing config for a one-off). +type ClaimHatchSettings interface { + GetClaimCode() (hash string, generation int, issuedAt string) + GetClaimConsumedGeneration() int + SetClaimCode(hash string, generation int, issuedAt string) error +} + +type ClaimHatchConfig interface { + WebClaimGeneration() int +} + +// localCode makes a readable one-time code (three 4-char base32-ish groups) without needing the +// hub's Hungarian word list — it is typed once, locally, by the operator. +func localCode() (string, error) { + const alphabet = "abcdefghjkmnpqrstuvwxyz23456789" // no ambiguous 0/1/i/l/o + b := make([]byte, 12) + if _, err := rand.Read(b); err != nil { + return "", err + } + out := make([]byte, 0, 14) + for i, v := range b { + if i > 0 && i%4 == 0 { + out = append(out, '-') + } + out = append(out, alphabet[int(v)%len(alphabet)]) + } + return string(out), nil +} + +// reportClaimLockout pushes the allowlisted claim_lockout event (operator + customer visibility). +func (s *Server) reportClaimLockout(ip string) { + s.logger.Printf("[WARN] [web] claim: code lockout tripped (source %s) — 15 min", ip) + if s.notifier != nil { + s.notifier.PushEvent("claim_lockout", "warning", + "Túl sok hibás beállító/visszaállító kód — a beállító oldal 15 percre zárolva", + map[string]interface{}{"source": ip}) + } +} diff --git a/controller/internal/web/claim_test.go b/controller/internal/web/claim_test.go new file mode 100644 index 0000000..bb5b2e4 --- /dev/null +++ b/controller/internal/web/claim_test.go @@ -0,0 +1,245 @@ +package web + +import ( + "io" + "log" + "net/http" + "net/http/httptest" + "net/url" + "path/filepath" + "strings" + "testing" + "time" + + "gitea.dooplex.hu/admin/felhom-controller/internal/config" + "gitea.dooplex.hu/admin/felhom-controller/internal/settings" + "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" + "golang.org/x/crypto/bcrypt" +) + +// claimTestServer builds a Server with a claim code installed (unclaimed, no password) and the +// full mux (RequireAuth+CsrfProtect wired exactly as main.go does), so route-level gating is +// exercised end-to-end. Returns the server, the plaintext code, and the settings. +func claimTestServer(t *testing.T) (*Server, string, *settings.Settings) { + t.Helper() + lg := log.New(io.Discard, "", 0) + dir := t.TempDir() + cfg := &config.Config{} + cfg.Customer.ID = "c1" + cfg.Customer.Name = "Teszt" + cfg.Customer.Domain = "example.hu" + cfg.Paths.StacksDir = filepath.Join(dir, "stacks") + cfg.Paths.DataDir = filepath.Join(dir, "data") + cfg.Stacks.ComposeCommand = "docker compose" + cfg.Web.SessionSecret = "test-session-secret-abcdef" + + sett, err := settings.Load(filepath.Join(dir, "settings.json"), lg) + if err != nil { + t.Fatalf("settings: %v", err) + } + mgr, err := stacks.NewManager(cfg, lg) + if err != nil { + t.Fatalf("stacks: %v", err) + } + s := &Server{cfg: cfg, settings: sett, stackMgr: mgr, logger: lg, version: "test"} + s.loadTemplates() + + code := "alma-korte-szilva" + hash, _ := bcrypt.GenerateFromPassword([]byte(code), 10) + if err := sett.SetClaimCode(string(hash), 1, time.Now().UTC().Format(time.RFC3339)); err != nil { + t.Fatalf("SetClaimCode: %v", err) + } + return s, code, sett +} + +// fullMux replicates main.go's handler composition so the gate is tested where it actually runs. +func (s *Server) fullMux() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("/api/health", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) }) + mux.Handle("/", s.RequireAuth(s.CsrfProtect(http.HandlerFunc(s.ServeHTTP)))) + return mux +} + +// §10 THE SIGNATURE TEST — every route of an unclaimed box (with a code hash) answers the claim +// page (redirect to /claim) or a 401 JSON; NOTHING else is reachable, and a mutating POST reaches +// NO handler. Red-proof: remove the claim-gate block in RequireAuth → these assertions fail. +func TestClaimGate_EveryRouteGated(t *testing.T) { + s, _, _ := claimTestServer(t) + mux := s.fullMux() + + // A representative sweep of the real route surface (pages + APIs + a mutating deploy POST). + htmlRoutes := []string{"/", "/dashboard", "/stacks", "/backups", "/monitoring", "/settings", "/settings/security", "/storage", "/apps/vaultwarden", "/import", "/debug"} + for _, p := range htmlRoutes { + rr := httptest.NewRecorder() + mux.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, p, nil)) + if rr.Code != http.StatusFound || rr.Header().Get("Location") != "/claim" { + t.Errorf("GET %s: got %d loc=%q, want 302→/claim", p, rr.Code, rr.Header().Get("Location")) + } + } + + apiRoutes := []string{"/api/disks", "/api/storage/x", "/api/host-metrics", "/api/backup/restore-status"} + for _, p := range apiRoutes { + rr := httptest.NewRecorder() + mux.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, p, nil)) + if rr.Code != http.StatusUnauthorized || !strings.Contains(rr.Body.String(), "not yet claimed") { + t.Errorf("GET %s: got %d body=%q, want 401 not-yet-claimed", p, rr.Code, rr.Body.String()) + } + } + + // A mutating deploy POST must be REFUSED before any handler runs (401, no side effect). + rr := httptest.NewRecorder() + mux.ServeHTTP(rr, httptest.NewRequest(http.MethodPost, "/api/stacks/vaultwarden/deploy", strings.NewReader("{}"))) + if rr.Code != http.StatusUnauthorized { + t.Errorf("POST deploy on unclaimed box: got %d, want 401 (no mutation reachable)", rr.Code) + } + + // The claim page + its assets + health ARE reachable. + for _, p := range []string{"/claim", "/api/health", "/static/style.css"} { + rr := httptest.NewRecorder() + mux.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, p, nil)) + if rr.Code != http.StatusOK { + t.Errorf("GET %s on unclaimed box: got %d, want 200 (allowed)", p, rr.Code) + } + } +} + +// The happy-path claim: correct code + password → password set, claimed, code consumed, session +// issued; a second use of the SAME code is refused (single-use via consumed generation). +func TestClaimSubmit_HappyPathThenReuseRefused(t *testing.T) { + s, code, sett := claimTestServer(t) + + do := func(codeVal, pw string) *httptest.ResponseRecorder { + form := url.Values{"_csrf": {s.claimCSRFToken()}, "code": {codeVal}, "new_password": {pw}, "confirm_password": {pw}} + req := httptest.NewRequest(http.MethodPost, "/claim", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.AddCookie(&http.Cookie{Name: claimCSRFCookie, Value: s.claimCSRFToken()}) + rr := httptest.NewRecorder() + s.handleClaimSubmit(rr, req) + return rr + } + + rr := do(code, "a-strong-passphrase-12") + if rr.Code != http.StatusFound || rr.Header().Get("Location") != "/" { + t.Fatalf("claim submit: got %d loc=%q, want 302→/", rr.Code, rr.Header().Get("Location")) + } + if !sett.GetClaimed() { + t.Fatal("box not marked claimed after a successful claim") + } + if !s.authEnabled() { + t.Fatal("password not set after claim (authEnabled false)") + } + if s.claimGateActive() { + t.Fatal("gate still active after claim") + } + if bcrypt.CompareHashAndPassword([]byte(s.effectivePasswordHash()), []byte("a-strong-passphrase-12")) != nil { + t.Fatal("stored password does not verify the chosen password") + } + // A session cookie was issued. + if len(rr.Result().Cookies()) == 0 { + t.Fatal("no session cookie issued on claim") + } + + // Reuse the SAME code (generation 1, now consumed) → refused even though the hash matches. + rr = do(code, "another-strong-pass-12") + if rr.Code == http.StatusFound { + t.Fatal("consumed code was accepted again — single-use broken") + } + if !strings.Contains(rr.Body.String(), "Hibás vagy lejárt kód") { + t.Errorf("reuse should show the wrong/expired-code error, body=%q", claimFirstLine(rr.Body.String())) + } +} + +// Wrong codes lock the endpoint after 5 attempts (fake clock); the window then reopens. +func TestClaimSubmit_LockoutAndWindowReopen(t *testing.T) { + s, _, _ := claimTestServer(t) + now := time.Date(2026, 7, 12, 12, 0, 0, 0, time.UTC) + s.claimClock = func() time.Time { return now } + + submitWrong := func() *httptest.ResponseRecorder { + form := url.Values{"_csrf": {s.claimCSRFToken()}, "code": {"wrong-wrong-wrong"}, "new_password": {"x-really-long-pass"}, "confirm_password": {"x-really-long-pass"}} + req := httptest.NewRequest(http.MethodPost, "/claim", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.RemoteAddr = "203.0.113.7:5000" + req.AddCookie(&http.Cookie{Name: claimCSRFCookie, Value: s.claimCSRFToken()}) + rr := httptest.NewRecorder() + s.handleClaimSubmit(rr, req) + return rr + } + + for i := 0; i < claimMaxAttempts; i++ { + submitWrong() + } + // The 6th (post-cap) attempt is locked out. + rr := submitWrong() + if !strings.Contains(rr.Body.String(), "Túl sok próbálkozás") { + t.Fatalf("expected lockout after %d failures, body=%q", claimMaxAttempts, claimFirstLine(rr.Body.String())) + } + // Advance past the window → unlocked (a wrong code shows the normal error again, not lockout). + now = now.Add(claimLockoutWindow + time.Minute) + rr = submitWrong() + if strings.Contains(rr.Body.String(), "Túl sok próbálkozás") { + t.Fatal("still locked after the window elapsed") + } + if !strings.Contains(rr.Body.String(), "Hibás vagy lejárt kód") { + t.Errorf("post-window wrong code should show the normal error, body=%q", claimFirstLine(rr.Body.String())) + } +} + +// An expired code (issued > 72h ago) is refused. +func TestClaimSubmit_ExpiredCodeRefused(t *testing.T) { + s, code, sett := claimTestServer(t) + // Re-issue the code with an old issued_at. + hash, _ := bcrypt.GenerateFromPassword([]byte(code), 10) + sett.SetClaimCode(string(hash), 2, time.Now().Add(-73*time.Hour).UTC().Format(time.RFC3339)) + + form := url.Values{"_csrf": {s.claimCSRFToken()}, "code": {code}, "new_password": {"a-strong-passphrase-12"}, "confirm_password": {"a-strong-passphrase-12"}} + req := httptest.NewRequest(http.MethodPost, "/claim", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.AddCookie(&http.Cookie{Name: claimCSRFCookie, Value: s.claimCSRFToken()}) + rr := httptest.NewRecorder() + s.handleClaimSubmit(rr, req) + if rr.Code == http.StatusFound { + t.Fatal("expired code was accepted") + } + if !strings.Contains(rr.Body.String(), "Hibás vagy lejárt kód") { + t.Errorf("expected expired-code error, body=%q", claimFirstLine(rr.Body.String())) + } +} + +// Legacy-open (no password, no code hash) passes through with the red banner flag; a box with a +// set password is entirely ungated (no claim page ever). +func TestClaimGate_LegacyOpenAndPasswordSet(t *testing.T) { + // Legacy-open: fresh server, no claim code, no password. + lg := log.New(io.Discard, "", 0) + dir := t.TempDir() + cfg := &config.Config{} + cfg.Customer.Domain = "example.hu" + cfg.Paths.StacksDir = filepath.Join(dir, "s") + cfg.Paths.DataDir = filepath.Join(dir, "d") + cfg.Stacks.ComposeCommand = "docker compose" + sett, _ := settings.Load(filepath.Join(dir, "settings.json"), lg) + mgr, _ := stacks.NewManager(cfg, lg) + s := &Server{cfg: cfg, settings: sett, stackMgr: mgr, logger: lg, version: "test"} + s.loadTemplates() + + if s.claimGateActive() { + t.Fatal("no code hash → gate must NOT be active (legacy-open)") + } + if !s.claimLegacyOpen() { + t.Fatal("no password + no code → expected legacy-open") + } + + // Password set → neither gated nor legacy-open. + pw, _ := bcrypt.GenerateFromPassword([]byte("existing-strong-pass"), 10) + sett.SetPasswordHash(string(pw)) + if s.claimGateActive() || s.claimLegacyOpen() { + t.Fatal("a set password must disable both the gate and the legacy banner") + } +} + +func claimFirstLine(s string) string { + if i := strings.IndexByte(s, '\n'); i >= 0 { + return s[:i] + } + return s +} diff --git a/controller/internal/web/csrf.go b/controller/internal/web/csrf.go index 78b2c17..9685d12 100644 --- a/controller/internal/web/csrf.go +++ b/controller/internal/web/csrf.go @@ -32,6 +32,13 @@ func (s *Server) CsrfProtect(next http.Handler) http.Handler { return } + // Claim/reset POSTs carry their OWN pre-auth HMAC CSRF (validated in the handler) — the + // customer resetting a claimed box has no session yet, so the session-CSRF path can't apply. + if r.URL.Path == "/claim" || r.URL.Path == "/claim/request-new-code" { + next.ServeHTTP(w, r) + return + } + // Skip CSRF for Bearer-token authenticated requests. // Validate the token against the configured API key before skipping. if auth := r.Header.Get("Authorization"); strings.HasPrefix(auth, "Bearer ") { diff --git a/controller/internal/web/handlers.go b/controller/internal/web/handlers.go index 55fd1a6..5eb57db 100644 --- a/controller/internal/web/handlers.go +++ b/controller/internal/web/handlers.go @@ -121,6 +121,9 @@ func (s *Server) baseData(page, title string) map[string]interface{} { "Version": s.version, "AuthEnabled": s.authEnabled(), "DebugMode": s.isDebug(), + // Customer-claim arc (v0.122.0, F-4): the transitional legacy-open banner — no password, + // no code hash yet. Cleared the moment the hub delivers a code hash (gate flips on). + "ClaimLegacyOpen": s.claimLegacyOpen(), } if s.alertManager != nil { data["Alerts"] = s.alertManager.GetAlerts() diff --git a/controller/internal/web/server.go b/controller/internal/web/server.go index df81b17..09f3d65 100644 --- a/controller/internal/web/server.go +++ b/controller/internal/web/server.go @@ -50,6 +50,13 @@ type Server struct { done chan struct{} closeOnce sync.Once + // Customer-claim arc (v0.122.0, F-4): the claim/reset code brute-force limiter. Per-source + // (IP) + a global counter; both must be clear. claimClock is the test clock seam (nil → time.Now). + claimMu sync.Mutex + claimAttempts map[string]*claimAttempt + claimGlobal claimAttempt + claimClock func() time.Time + // Guard for FileBrowser sync — prevents concurrent file writes (H5 fix) fileBrowserMu sync.Mutex @@ -272,6 +279,14 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { } switch { + // Customer-claim arc (v0.122.0, F-4): the code-entry page + its handlers. Reachable pre-auth + // (code-gated internally); CSRF via the pre-auth HMAC token (validated inside the handlers). + case path == "/claim" && r.Method == http.MethodGet: + s.handleClaimPage(w, r, "", r.URL.Query().Get("flash")) + case path == "/claim" && r.Method == http.MethodPost: + s.handleClaimSubmit(w, r) + case path == "/claim/request-new-code" && r.Method == http.MethodPost: + s.handleClaimRequestNewCode(w, r) case path == "/" || path == "/dashboard": s.dashboardHandler(w, r) case path == "/stacks": diff --git a/controller/internal/web/templates/claim.html b/controller/internal/web/templates/claim.html new file mode 100644 index 0000000..dbe4ac5 --- /dev/null +++ b/controller/internal/web/templates/claim.html @@ -0,0 +1,56 @@ +{{define "claim"}} + + + + + + {{if .IsReset}}Jelszó visszaállítása{{else}}A szerver beállítása{{end}} — Felhom + + + +
+ +

{{if .IsReset}}Jelszó visszaállítása{{else}}A szerver beállítása{{end}}

+ + + {{if .Flash}}
{{.Flash}}
{{end}} + {{if .Error}}
{{.Error}}
{{end}} + + {{if .HasCode}} +

+ {{if .IsReset}}Add meg az e-mailben kapott visszaállító kódot, majd válassz új jelszót.{{else}}Add meg az e-mailben kapott beállító kódot, majd válassz saját jelszót a vezérlőpult védelméhez.{{end}} +

+
+ +
+ + +
+
+ + +
+
+ + +
+ +
+ {{else}} +
Jelenleg nincs aktív kód ehhez a szerverhez. Kérj egy újat az alábbi gombbal — az e-mailben érkezik a regisztrált címre.
+ {{end}} + +
+ + +
+ + +
+ + +{{end}} diff --git a/controller/internal/web/templates/layout.html b/controller/internal/web/templates/layout.html index af76146..f83d4aa 100644 --- a/controller/internal/web/templates/layout.html +++ b/controller/internal/web/templates/layout.html @@ -47,6 +47,14 @@
+{{if .ClaimLegacyOpen}} +
+
+ + A vezérlőpult még nincs jelszóval védve — a beállító kódot hamarosan e-mailben küldjük. +
+
+{{end}} {{if .Alerts}}
{{range .Alerts}} diff --git a/controller/internal/web/templates/login.html b/controller/internal/web/templates/login.html index 2068c00..2d47315 100644 --- a/controller/internal/web/templates/login.html +++ b/controller/internal/web/templates/login.html @@ -23,6 +23,7 @@
+

Elfelejtett jelszó