package web import ( "crypto/hmac" "crypto/rand" "crypto/sha256" "crypto/subtle" "encoding/hex" "encoding/json" "fmt" "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. 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. // 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ó/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}) } }