86a78c6767
gates / gates (push) Successful in 14s
The orphan card told a customer their set-aside off-site history may be restorable later with their recovery code. The discriminator lives on the hub and no wire field carries it, so the box rendering that card cannot evaluate the promise. Copy replaced per the spec: state what happens, decline what we cannot know and say why, name a route. The claim page called the same three-word dashboard code two different names depending on branch, one of which collides with the ten-word escrow code. Retired 'Visszaallito kod'; the name is now constant and the sentence changes. Naming only - a test pins that a reset code is still accepted. secret_in_markup_gate no longer convicts Go template comments, which are stripped before render; still convicts a real rendered secret.
544 lines
22 KiB
Go
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ó 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})
|
|
}
|
|
}
|