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.
This commit is contained in:
@@ -43,19 +43,32 @@ type claimAttempt struct {
|
||||
// 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) {
|
||||
//
|
||||
// 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
|
||||
return sHash, sGen, sIssued, nil
|
||||
}
|
||||
return cHash, cGen, cIssued
|
||||
return cHash, cGen, cIssued, nil
|
||||
}
|
||||
|
||||
// claimGateActive reports whether the unclaimed-gate applies: no password set anywhere, a claim
|
||||
@@ -65,7 +78,13 @@ 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()
|
||||
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
|
||||
}
|
||||
@@ -81,7 +100,10 @@ func (s *Server) claimLegacyOpen() bool {
|
||||
if s.authEnabled() {
|
||||
return false
|
||||
}
|
||||
hash, _, _ := s.effectiveClaimCode()
|
||||
hash, _, _, err := s.effectiveClaimCode()
|
||||
if err != nil {
|
||||
return false // FAIL CLOSED: an unreadable state is not evidence the box is legacy-open
|
||||
}
|
||||
return hash == ""
|
||||
}
|
||||
|
||||
@@ -244,7 +266,15 @@ func (s *Server) serveClaimGate(w http.ResponseWriter, r *http.Request) {
|
||||
// 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()
|
||||
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",
|
||||
@@ -290,7 +320,14 @@ func (s *Server) handleClaimSubmit(w http.ResponseWriter, r *http.Request) {
|
||||
newPassword := r.FormValue("new_password")
|
||||
confirm := r.FormValue("confirm_password")
|
||||
|
||||
hash, generation, issuedAt := s.effectiveClaimCode()
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user