package web import ( "crypto/hmac" "crypto/rand" "crypto/sha256" "crypto/subtle" "encoding/hex" "encoding/json" "fmt" "gitea.dooplex.hu/admin/felhom-controller/internal/i18n" "io" "net/http" "os" "strings" "time" "gitea.dooplex.hu/admin/felhom-controller/internal/report" "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. // // R-204 item 1 (v0.198.0): it now READS THROUGH to the persisted settings first, because // `--print-reset-code` mints its code in a SEPARATE PROCESS and this one's cache never heard — so a // freshly minted code was refused until the controller restarted, and nothing said so. The // PRECEDENCE RULE BELOW IS UNCHANGED and deliberate (settings wins only at an equal-or-newer // generation); the defect was the freshness of the settings value, not which source wins. // // The read-through is why the ERROR RETURN exists: a persisted state that cannot be read must FAIL // CLOSED at every caller (an absent file is not an error — see settings.ReloadClaimCode). A gate that // opens because it could not read its own state is the shape this project has removed four times. func (s *Server) effectiveClaimCode() (hash string, generation int, issuedAt string, err error) { var sHash, sIssued string var sGen int if s.settings != nil { if rerr := s.settings.ReloadClaimCode(); rerr != nil { return "", 0, "", rerr } 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, nil } return cHash, cGen, cIssued, nil } // 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, _, _, err := s.effectiveClaimCode() if err != nil { // FAIL CLOSED. Unreadable claim state must not open the dashboard — keep the gate up. The // claim page itself stays reachable (claimPageAllowedPath), so this is recoverable, not a brick. s.logger.Printf("[ERROR] [web] claim: cannot read the persisted claim state — keeping the gate CLOSED: %v", err) return true } 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, _, _, err := s.effectiveClaimCode() if err != nil { return false // FAIL CLOSED: an unreadable state is not evidence the box is legacy-open } 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() } // clientIP and rateKey live in clientaddr.go (R-753). // ── 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 case langCookiePath: // The claim page carries the language globe, so its switch has to work on an unclaimed box — // which is precisely the box whose first visitor may not read Hungarian (v0.254.0, R-557). // It sets a display-only cookie and can do nothing else; see langCookieHandler. 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, _, _, cerr := s.effectiveClaimCode() if cerr != nil { // FAIL CLOSED on the page too: never invite a code we could not read the state for. s.logger.Printf("[ERROR] [web] claim: cannot read the persisted claim state while rendering the claim page: %v", cerr) hash = "" if errorMsg == "" { errorMsg = s.msg(r, "claim.msg.state_unreadable") } } reset := s.authEnabled() // a set password means this is the reset flow, not first-claim // `Title` USED TO BE HERE, carrying the Hungarian "A szerver beállítása", and R-596 named it as // one of the strings an English household reads. It is not: claim.html is a STANDALONE page with // its own (already `claim.jelszo_visszaallitasa` / `claim.a_szerver_beallitasa`), and // `.Title` is consumed only by layout.html, which this page never includes. So the field was dead // — rendered by nothing, in either language. Deleted rather than translated: a translated dead // field would read, for ever after, as evidence that this page's title is handled here. data := map[string]interface{}{ "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.executeTemplateLang(w, r, "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, s.msg(r, "claim.msg.invalid_form"), "") return } wasReset := s.authEnabled() // a password already set → this is a reset, not a first-claim ip := rateKey(r) if locked, _ := s.claimRateLocked(); locked { s.handleClaimPage(w, r, s.msg(r, "claim.msg.too_many"), "") return } if locked, _ := s.claimSourceLocked(ip); locked { s.handleClaimPage(w, r, s.msg(r, "claim.msg.too_many"), "") return } code := strings.TrimSpace(r.FormValue("code")) newPassword := r.FormValue("new_password") confirm := r.FormValue("confirm_password") hash, generation, issuedAt, cerr := s.effectiveClaimCode() if cerr != nil { // FAIL CLOSED: refuse the claim rather than validate against a possibly-superseded cache. // NOT counted as a failed attempt — the customer typed nothing wrong. s.logger.Printf("[ERROR] [web] claim: refusing the submission — the persisted claim state is unreadable: %v", cerr) s.handleClaimPage(w, r, s.msg(r, "claim.msg.state_unreadable"), "") return } if hash == "" { s.handleClaimPage(w, r, s.msg(r, "claim.msg.no_active_code"), "") 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, s.msg(r, "claim.msg.too_many"), "") return } s.handleClaimPage(w, r, s.msg(r, "claim.msg.bad_code"), "") return } // Password rules (min length, match). if len(newPassword) < claimMinPassword { s.handleClaimPage(w, r, s.msg(r, "claim.msg.password_too_short", claimMinPassword), "") return } if newPassword != confirm { s.handleClaimPage(w, r, s.msg(r, "claim.msg.password_mismatch"), "") 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, s.msg(r, "claim.msg.save_failed"), "") return } if err := s.settings.SetPasswordHash(string(pwHash)); err != nil { s.logger.Printf("[ERROR] [web] claim: saving password: %v", err) s.handleClaimPage(w, r, s.msg(r, "claim.msg.save_failed"), "") 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) } // v0.139.0: report out-of-cycle so the hub's Claimed flag (and claim-code consumption) // flips in seconds — the operator sees the customer claim land immediately. s.reportTriggerNow() s.claimClearFailures(ip) // §16 (operator default, v0.254.0): the claim CARRIES the visitor's language. Someone who switched // the claim page to English and then claimed the box chose English deliberately; the first // dashboard they see should be in it, and the hub's e-mails will follow once slice 3 lands. // // Only here, and only on SUCCESS: this is the one moment an anonymous visitor becomes the // household, so it is the one moment their display cookie may become the household's setting. // Every other anonymous request leaves settings.json alone (langFor, CsrfProtect). if c, err := r.Cookie(langCookieName); err == nil && i18n.IsSupported(c.Value) { if err := s.settings.SetLanguage(c.Value); err != nil { // Not fatal: the box is claimed either way, and the household can switch on the dashboard. s.logger.Printf("[WARN] [web] claim: could not carry the visitor's language %q: %v", c.Value, err) } else { s.logger.Printf("[INFO] [web] claim: household language set to %q from the claim page", c.Value) } } 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, s.msg(r, "claim.msg.invalid_form"), "") return } go s.requestHubResetCode() // fire-and-forget; the neutral response never reveals the outcome s.handleClaimPage(w, r, "", s.msg(r, "claim.msg.code_sent")) } // requestHubResetCode calls POST /api/v1/claim/reset-request with the box's own report key. // v0.123.0 (take-two F-15): a hub ≥0.52.0 returns the freshly rotated code state in the response; // it is applied through the SAME generation-guarded consumer as the report ACK (ClaimSync), so the // emailed code works the moment it lands instead of after the next ACK (~15 min). An old hub's // bare {"status":"ok"} response is a clean no-op (no claim object → Reconcile skips). 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 } defer 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) var payload struct { Claim *report.ClaimStatus `json:"claim"` } if err := json.NewDecoder(io.LimitReader(resp.Body, 64<<10)).Decode(&payload); err != nil { s.logger.Printf("[WARN] [web] claim: parsing reset-request response failed (code arrives via the next ACK): %v", err) return } if payload.Claim != nil && s.settings != nil { sync := &report.ClaimSync{Settings: s.settings, Logger: s.logger} sync.Reconcile(payload.Claim) } } // 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ó 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ó kód — a beállító oldal 15 percre zárolva", map[string]interface{}{"source": ip}) } }