Files
felhom-controller/controller/internal/web/claim.go
T
admin 48f3336956
gates / gates (push) Successful in 23s
v0.254.0 — the saved notes follow the language, and the switch becomes a globe (R-557 slice 2 release C; SLICE 2 CLOSED)
The notes a background run SAVES — last night's backup line, the last error, the proof
result, the restore outcome — are written in the BOX's language at the moment they are
written. A household that switches sees the previous run's note in the old language until
the next run rewrites it: the operator's §16 option 1, stated rather than hidden.
EndRestoreOp no longer receives a Hungarian literal from anywhere.

The language switch is a globe. Two text links wrapped in the sidebar footer and asked the
reader to recognise "Magyar"/"English" as links; a globe is the one symbol every web user
already reads as "language", so nobody has to read Hungarian to escape Hungarian. It is
<details>/<summary> — a menu with no script, drawn inline because the icon sprite lives
only in layout.html and the visitor pages have their own shell.

Those visitor pages get the same globe, and a visitor's choice stays theirs: a display-only
felhom_lang cookie that langFor reads ONLY when there is no session. A signed-in household
can never inherit a language a previous visitor picked in the same browser. POST /lang is
CSRF-exempt for a narrow reason written at the exemption — its only achievable effect is the
language of the page the victim's own browser shows them — and safeBackPath refuses
//evil.example as well as https://, because "starts with /" alone is not the test. §16 taken:
a successful claim carries the cookie into the household's setting.

TWO PARITY EXCEPTIONS, MEASURED: 106 fixtures compared with a real diff — exactly two change
shapes (the dashboard footer, the globe in the shells) and 5 byte-identical, which are the
three pages that must not change.

I INTRODUCED A DEADLOCK AND THE SUITE CAUGHT IT BY HANGING. UpdateOffboxStatus holds the
settings write lock while running its callback; boxLang() wants the read lock; sync.RWMutex
is not reentrant. On a real box an off-site run would have hung forever HOLDING the settings
lock. Fixed by resolving the language before the callback, and guarded by a test that names
the file and line in a second instead of hanging for 25 minutes.

MinAgent: 0.131.0 (unchanged). No hub release needed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-18 14:19:31 +02:00

567 lines
23 KiB
Go

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"
"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
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 = "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.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, "É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)
// §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, "É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ó 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})
}
}