controller: customer-claim password gate v0.122.0 (closes DRILL-day0-vm F-4/F-5)
The customer sets + owns the dashboard password via a hub-emailed one-time claim code. An unclaimed box (code hash present, no password) serves ONLY the claim page — every other route → claim page (302) or 401, so a Day-0 box is never open on the internet. A set password disables the gate (auth wins). Reset rides the same code engine (login "Elfelejtett jelszó"). Legacy-open (no password, no hash) shows a red transition banner until the hub delivers a hash. Report ACK caches the code state idempotently by generation; report carries claimed (set-only). --print-reset-code root escape hatch. Requires hub v0.50.0. Gate-coverage signature test + 4 red-proofs proven.
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
package report
|
||||
|
||||
import "log"
|
||||
|
||||
// Customer-claim arc (v0.122.0, F-4) — the ACK-side of the claim-code delivery. The hub serves
|
||||
// the ACTIVE code's bcrypt hash + monotonic generation on every report ACK of a managed
|
||||
// customer; this reconciler caches it into settings.json IDEMPOTENTLY BY GENERATION (the
|
||||
// offsite-descriptor guard shape: an unchanged generation never rewrites, a hub outage never
|
||||
// clears — set/refresh only, exactly like the escrow confirmer's one-way rules). The gate itself
|
||||
// (internal/web) reads the cached state; nothing here decides gating.
|
||||
|
||||
// ClaimStatus mirrors the hub ACK's `claim` object (nil when the hub has no claim row / old hub).
|
||||
type ClaimStatus struct {
|
||||
CodeHash string `json:"code_hash"`
|
||||
Generation int `json:"generation"`
|
||||
IssuedAt string `json:"issued_at"` // RFC3339
|
||||
}
|
||||
|
||||
// ClaimSettings is the settings surface the sync needs (satisfied by *settings.Settings).
|
||||
type ClaimSettings interface {
|
||||
GetClaimCode() (hash string, generation int, issuedAt string)
|
||||
SetClaimCode(hash string, generation int, issuedAt string) error
|
||||
}
|
||||
|
||||
// ClaimSync applies one ACK's claim status to the settings cache.
|
||||
type ClaimSync struct {
|
||||
Settings ClaimSettings
|
||||
Logger *log.Logger
|
||||
}
|
||||
|
||||
func (c *ClaimSync) logf(f string, a ...any) {
|
||||
if c.Logger != nil {
|
||||
c.Logger.Printf(f, a...)
|
||||
}
|
||||
}
|
||||
|
||||
// Reconcile caches a newer-generation code state; same-or-older generations and nil/empty
|
||||
// statuses are no-ops (a rotation is the ONLY thing that moves the cache — no write-back, no
|
||||
// clearing on hub silence).
|
||||
func (c *ClaimSync) Reconcile(cs *ClaimStatus) {
|
||||
if cs == nil || cs.CodeHash == "" || cs.Generation <= 0 {
|
||||
return
|
||||
}
|
||||
_, curGen, _ := c.Settings.GetClaimCode()
|
||||
if cs.Generation <= curGen {
|
||||
return // idempotent: this generation (or a newer one) is already cached
|
||||
}
|
||||
if err := c.Settings.SetClaimCode(cs.CodeHash, cs.Generation, cs.IssuedAt); err != nil {
|
||||
c.logf("[ERROR] [claim-sync] caching hub claim code (gen %d) failed (retries next ACK): %v", cs.Generation, err)
|
||||
return
|
||||
}
|
||||
c.logf("[INFO] [claim-sync] hub claim code cached (generation %d) — hash first 8: %.8s…", cs.Generation, cs.CodeHash)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package report
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type fakeClaimSettings struct {
|
||||
hash string
|
||||
gen int
|
||||
issued string
|
||||
setCall int
|
||||
}
|
||||
|
||||
func (f *fakeClaimSettings) GetClaimCode() (string, int, string) { return f.hash, f.gen, f.issued }
|
||||
func (f *fakeClaimSettings) SetClaimCode(hash string, gen int, issued string) error {
|
||||
f.hash, f.gen, f.issued = hash, gen, issued
|
||||
f.setCall++
|
||||
return nil
|
||||
}
|
||||
|
||||
func newSync(f *fakeClaimSettings) *ClaimSync {
|
||||
return &ClaimSync{Settings: f, Logger: log.New(io.Discard, "", 0)}
|
||||
}
|
||||
|
||||
// A newer generation caches; the same/older generation and nil are no-ops (idempotent, one-way).
|
||||
func TestClaimSync_IdempotentByGeneration(t *testing.T) {
|
||||
f := &fakeClaimSettings{}
|
||||
s := newSync(f)
|
||||
|
||||
s.Reconcile(&ClaimStatus{CodeHash: "h1", Generation: 1, IssuedAt: "t1"})
|
||||
if f.gen != 1 || f.hash != "h1" || f.setCall != 1 {
|
||||
t.Fatalf("first cache: %+v", f)
|
||||
}
|
||||
|
||||
// Same generation → no write.
|
||||
s.Reconcile(&ClaimStatus{CodeHash: "h1-again", Generation: 1, IssuedAt: "t1"})
|
||||
if f.setCall != 1 || f.hash != "h1" {
|
||||
t.Fatalf("same generation must not rewrite: %+v", f)
|
||||
}
|
||||
|
||||
// Older generation → no write (a lagging ACK can't regress the cache).
|
||||
s.Reconcile(&ClaimStatus{CodeHash: "h0", Generation: 0, IssuedAt: "t0"})
|
||||
if f.setCall != 1 {
|
||||
t.Fatalf("older generation must not rewrite: %+v", f)
|
||||
}
|
||||
|
||||
// Newer generation (a resend) → cache advances.
|
||||
s.Reconcile(&ClaimStatus{CodeHash: "h2", Generation: 2, IssuedAt: "t2"})
|
||||
if f.gen != 2 || f.hash != "h2" || f.setCall != 2 {
|
||||
t.Fatalf("newer generation should advance: %+v", f)
|
||||
}
|
||||
|
||||
// nil / empty / non-positive generation → no-op (old hub, no claim row).
|
||||
s.Reconcile(nil)
|
||||
s.Reconcile(&ClaimStatus{CodeHash: "", Generation: 3})
|
||||
s.Reconcile(&ClaimStatus{CodeHash: "h", Generation: 0})
|
||||
if f.setCall != 2 {
|
||||
t.Fatalf("nil/empty/zero-gen must be no-ops: %+v", f)
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,9 @@ type PushResponse struct {
|
||||
// ring; the NEXT report ships controller_log_tail (selftail.go). Absent/false on an
|
||||
// old hub = nothing pending.
|
||||
ControllerLogRequested bool `json:"controller_log_requested"`
|
||||
// Claim (v0.122.0, F-4) — the hub's active claim-code state (bcrypt hash + generation) for
|
||||
// the customer-claim gate. nil on an old hub / no claim row → the cache stays as-is.
|
||||
Claim *ClaimStatus `json:"claim"`
|
||||
}
|
||||
|
||||
// Pusher sends reports to the central hub.
|
||||
|
||||
@@ -43,6 +43,11 @@ type Report struct {
|
||||
// the cycle right after the ACK's controller_log_requested (selftail.go; additive — the
|
||||
// app-tail flow above is untouched).
|
||||
ControllerLogTail *ControllerLogTail `json:"controller_log_tail,omitempty"`
|
||||
|
||||
// Claimed (v0.122.0, F-4) — whether the customer has completed the dashboard claim (set
|
||||
// their own password). The hub ingests it SET-ONLY: a later false (wiped settings.json
|
||||
// after DR) never un-claims the customer hub-side.
|
||||
Claimed bool `json:"claimed"`
|
||||
}
|
||||
|
||||
// SystemReport holds host-level system info.
|
||||
|
||||
Reference in New Issue
Block a user