Files
felhom-controller/controller/internal/web/claim.go
T
admin 73b6dbc27d R-204 item 1: a freshly minted reset code works without a restart (v0.198.0)
--print-reset-code runs as a separate process and persists the new code;
the running server's cache was never told, so the code the customer was told
to type was refused until the controller restarted. Nothing said so — during
the 2026-08-04 drill that cost two attempts with an operator present.

effectiveClaimCode now reads through to the persisted state before applying
the settings-vs-config precedence, which is itself unchanged. Read-through,
not a TTL: a TTL would leave a window in which a superseded code still works,
which is worse than the bug. Fails closed on an unreadable state; an absent
file is not an error.
2026-08-05 07:17:13 +02:00

544 lines
22 KiB
Go

package web
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net"
"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 returns the client IP used as the rate-limiter key. Order: the X-Forwarded-For first
// hop (set by the traefik/Cloudflare proxy) wins; otherwise the HOST portion of RemoteAddr with the
// ephemeral PORT stripped (net.SplitHostPort). This is the CAMPAIGN-4 F-B fix: keying on the raw
// RemoteAddr (IP:PORT) meant every fresh direct connection from one host got a distinct ephemeral
// port → a distinct key → the failed-attempt counter never accrued, so a direct-to-controller
// (LAN/guest, non-proxied) path had NO brute-force protection. A RemoteAddr with no port
// (tests/edge) or an IPv6 form is handled by SplitHostPort, falling back to the raw value.
//
// Accepted limitation (out of scope here): X-Forwarded-For is attacker-controlled on a direct path,
// so a client rotating the first hop still evades the per-IP counter. This fix only closes the
// port-in-key bug so the proxied / stable-source-IP case — the real deployment — works; it does NOT
// attempt to establish XFF trust.
func clientIP(r *http.Request) string {
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
return strings.TrimSpace(strings.Split(fwd, ",")[0])
}
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
return host
}
return strings.TrimSpace(r.RemoteAddr)
}
// ── 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, _, _, 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 = "A beállító állapot most nem olvasható — próbáld újra néhány perc múlva."
}
}
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 := clientIP(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, 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, "A beállító állapot most nem olvasható — próbáld újra néhány perc múlva.", "")
return
}
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)
}
// 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)
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})
}
}