4d6ec7c7bb
gates / gates (push) Successful in 14s
Four paper debts and one fact given a reader. Hub-only — nothing to bake. A4 — the entry about "the tester's machine" named a risk correctly and labelled it in a way that invited deleting it. Established from the hub's own store: `peti-felhom` is a REAL machine (482 reports, 2026-02-27 → 2026-07-15, a named person's own box) and the 3.6 GB with no key and no backup is real. `david` → `tester-1` is a DIFFERENT record with no host, no escrow and no report, ever — deleted 07:55:49 and re-created 07:56:47 this morning. The prompt's premise conflated the two; the register now says which is which. A1 — R-312/R-313/R-303 recorded as DECIDED with their re-open triggers, and moved out of STATUS's "Waiting on you", which is now empty. A3 — day0-install §C.1 said pushing the installer publishes it. It has not since R-110. Corrected, with the two manifest pins named and an outside-verification command; the one copy that repeated it (a dated audit, true when written) carries a superseded note. A5 — standing rule 5: evidence comes off the machine at the end of the phase that produced it, before any revert. Earned twice in three days on the same box at the same point (R-320). Four homes, plus what to do when it is already gone. R-295 hub half — „Beállító kód" everywhere; „Visszaállító kód" retired. New `reenroll` mail kind so the mail names the page a REBUILT box actually shows („A szerver beállítása"), not the „Elfelejtett jelszó" page it has no login screen to reach. Naming only; the acceptance pin proves the secret is untouched. R-319 — the hub models `guest_net` after 23 days of receiving and discarding it. The signal is `heals_last_hour`, not `state`: a guest the watchdog keeps repairing reads healthy between repairs. `heal_succeeded` decoded too (R-260's lesson). Unknown is never drawn as healthy — three absences, three sentences. No alarm, deliberately. Three red-proofs, mutations asserted applied. Wire-gate checked tags 182 → 190. B1 — the operator's 2026-08-12 dispositions were NOT in the register; they are now. Third allowlist kind for the five ruled "no reader wanted"; `reporting_disabled` reclassified redundant. 8 read · 5 deliberately unread · 1 redundant · 6 still owed. Also filed: R-321 (a deliberately-silent box still alarms stale/down — the checker is age-only, and decoding the flag would not have fixed it), R-322 (the claim guard has never scanned the hub; a hand scan returns zero, so it is a scope gap, not a defect).
256 lines
12 KiB
Go
256 lines
12 KiB
Go
// Package claim is the customer-claim password-code engine (v0.50.0, closes DRILL-day0-vm F-4).
|
|
// The hub generates a one-time claim code, emails it (Hungarian) to the REGISTERED customer
|
|
// address, and stores only bcrypt(code) — the plaintext exists solely inside the email send
|
|
// (the retrieval-passphrase custody rule). The controller receives the hash (+ generation)
|
|
// baked into controller.yaml at Day-0 and via the report ACK for live boxes, and gates its
|
|
// dashboard until the customer claims it by setting their own password. Reset rides the same
|
|
// engine: a rotation bumps the generation (single active code) and NEVER clears claimed_at.
|
|
package claim
|
|
|
|
import (
|
|
"fmt"
|
|
"log"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-hub/internal/configgen"
|
|
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
|
|
"golang.org/x/crypto/bcrypt"
|
|
)
|
|
|
|
// codeWords is the claim/reset code length: 3 Hungarian words (~44 bits with the ~29K list) —
|
|
// dictatable over the phone, and the controller's 5-attempt/15-min lockout makes online guessing
|
|
// infeasible.
|
|
const codeWords = 3
|
|
|
|
// maxResetPerDay is the hub-side cap on controller-forwarded reset requests (per customer).
|
|
const maxResetPerDay = 3
|
|
|
|
// EmailKind selects the Hungarian template for a code delivery.
|
|
type EmailKind string
|
|
|
|
const (
|
|
EmailClaim EmailKind = "claim" // first setup: "Elindult a Felhom szervered"
|
|
EmailReset EmailKind = "reset" // forgotten password — the box HAS a password
|
|
EmailClaimed EmailKind = "claimed" // confirmation after a successful claim (carries no code)
|
|
// EmailReenroll — the box was wiped and re-enrolled, so the fresh controller has NO password
|
|
// while the hub-side claim is still set (ReissueForReenroll).
|
|
//
|
|
// R-295 (hub half, 2026-08-13): THIS EXISTS BECAUSE THE MAIL MUST NAME THE PAGE THE MACHINE IS
|
|
// ACTUALLY SHOWING. Both situations deliver the same secret and it keeps the same name — the
|
|
// three-word „Beállító kód" — but they do NOT show the same screen, and only the hub can tell
|
|
// them apart, because it is the hub that chose which call site fired. A rebuilt box renders
|
|
// „A szerver beállítása" (controller `web/claim.go:279`: `reset := s.authEnabled()`, and a fresh
|
|
// controller has no password), and it serves no login page — so it has no „Elfelejtett jelszó"
|
|
// link at all. Sending a re-enrolled customer to that page names a route that is not on their
|
|
// screen. Splitting the KIND rather than the NAME is what the ruling asks for: one secret in two
|
|
// situations keeps its name, and the sentence around it changes.
|
|
EmailReenroll EmailKind = "reenroll"
|
|
)
|
|
|
|
// Mailer delivers a claim-arc email. The code is passed through and MUST NOT be persisted or
|
|
// logged by implementations (empty for EmailClaimed). kind is one of the EmailKind constants
|
|
// (plain string in the signature so implementations need no import of this package).
|
|
type Mailer interface {
|
|
SendClaimEmail(kind, customerID, email, domain, code string) error
|
|
}
|
|
|
|
// Engine composes the store, the code generator and the mailer. All methods are idempotent or
|
|
// explicitly rotating; none ever stores or logs a plaintext code.
|
|
type Engine struct {
|
|
Store *store.Store
|
|
Mailer Mailer
|
|
Logger *log.Logger
|
|
}
|
|
|
|
func (e *Engine) logf(f string, a ...any) {
|
|
if e.Logger != nil {
|
|
e.Logger.Printf(f, a...)
|
|
}
|
|
}
|
|
|
|
// newCode generates a fresh code and its bcrypt hash.
|
|
func newCode() (code, hash string, err error) {
|
|
code, err = configgen.RandomPassphrase(codeWords)
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("claim: generating code: %w", err)
|
|
}
|
|
h, err := bcrypt.GenerateFromPassword([]byte(code), 10)
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("claim: hashing code: %w", err)
|
|
}
|
|
return code, string(h), nil
|
|
}
|
|
|
|
// rotateAndSend rotates the code (generation+1 / create) and emails it. The email failure path
|
|
// keeps the rotated hash (the gate stays armed) and is LOUD: the operator sees emailed_at unset
|
|
// on the customer page and can resend. Returns the new generation.
|
|
func (e *Engine) rotateAndSend(cc *store.CustomerConfig, kind EmailKind) (int, error) {
|
|
code, hash, err := newCode()
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
gen, err := e.Store.RotateClaimCode(cc.CustomerID, hash)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("claim: storing code hash: %w", err)
|
|
}
|
|
if cc.Email == "" {
|
|
e.logf("[ERROR] [claim] %s code generated (gen %d) but customer %s has NO registered email — deliver via resend after setting one", kind, gen, cc.CustomerID)
|
|
return gen, fmt.Errorf("claim: customer %s has no registered email", cc.CustomerID)
|
|
}
|
|
if err := e.Mailer.SendClaimEmail(string(kind), cc.CustomerID, cc.Email, cc.Domain, code); err != nil {
|
|
e.logf("[ERROR] [claim] %s code email to customer %s FAILED (gate stays armed; resend from the customer page): %v", kind, cc.CustomerID, err)
|
|
return gen, fmt.Errorf("claim: sending %s email: %w", kind, err)
|
|
}
|
|
if err := e.Store.MarkClaimEmailed(cc.CustomerID); err != nil {
|
|
e.logf("[WARN] [claim] %s code sent to customer %s but emailed_at not recorded: %v", kind, cc.CustomerID, err)
|
|
}
|
|
e.logf("[INFO] [claim] %s code (gen %d) emailed to the registered address of %s", kind, gen, cc.CustomerID)
|
|
return gen, nil
|
|
}
|
|
|
|
// EnsureIssued guarantees a claim row exists for the customer, issuing + emailing the first code
|
|
// when absent. An existing row (any state) is returned untouched — repeated config pulls and
|
|
// reports never rotate or re-send. This is the Day-0 entry point (API config retrieve) and the
|
|
// live-box entry point (first report of an upgrading fleet).
|
|
func (e *Engine) EnsureIssued(cc *store.CustomerConfig) (*store.ClaimState, error) {
|
|
cs, err := e.Store.GetClaim(cc.CustomerID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("claim: reading state: %w", err)
|
|
}
|
|
if cs != nil {
|
|
return cs, nil
|
|
}
|
|
if _, err := e.rotateAndSend(cc, EmailClaim); err != nil {
|
|
// The hash (if stored) still arms the gate; surface the error for the caller's log.
|
|
cs, gerr := e.Store.GetClaim(cc.CustomerID)
|
|
if gerr == nil && cs != nil {
|
|
return cs, err
|
|
}
|
|
return nil, err
|
|
}
|
|
return e.Store.GetClaim(cc.CustomerID)
|
|
}
|
|
|
|
// Resend rotates the code and re-sends it — the operator "Kód újraküldése" button. An unclaimed
|
|
// customer gets the claim template; a claimed one gets the reset template (the only code a
|
|
// claimed box can consume is a reset).
|
|
func (e *Engine) Resend(cc *store.CustomerConfig) error {
|
|
cs, err := e.Store.GetClaim(cc.CustomerID)
|
|
if err != nil {
|
|
return fmt.Errorf("claim: reading state: %w", err)
|
|
}
|
|
kind := EmailClaim
|
|
if cs.Claimed() {
|
|
kind = EmailReset
|
|
}
|
|
_, err = e.rotateAndSend(cc, kind)
|
|
return err
|
|
}
|
|
|
|
// RequestReset handles a controller-forwarded "Elfelejtett jelszó": rate-limited hub-side
|
|
// (maxResetPerDay per customer), then rotates + emails a reset code. The neutral customer-facing
|
|
// response ("ha az e-mail cím regisztrálva van…") is the CALLER's job — this returns real errors
|
|
// for the operator log.
|
|
func (e *Engine) RequestReset(cc *store.CustomerConfig) error {
|
|
cs, err := e.Store.GetClaim(cc.CustomerID)
|
|
if err != nil {
|
|
return fmt.Errorf("claim: reading state: %w", err)
|
|
}
|
|
if cs == nil {
|
|
// No claim row yet (pre-arc box asking for a reset): issue the first code instead.
|
|
_, err := e.EnsureIssued(cc)
|
|
return err
|
|
}
|
|
count, err := e.Store.BumpResetCount(cc.CustomerID)
|
|
if err != nil {
|
|
return fmt.Errorf("claim: reset limiter: %w", err)
|
|
}
|
|
if count > maxResetPerDay {
|
|
e.logf("[WARN] [claim] reset request for %s REFUSED: daily cap reached (%d/%d)", cc.CustomerID, count, maxResetPerDay)
|
|
return fmt.Errorf("claim: reset cap reached for %s (%d/day)", cc.CustomerID, maxResetPerDay)
|
|
}
|
|
_, err = e.rotateAndSend(cc, EmailReset)
|
|
return err
|
|
}
|
|
|
|
// ReissueForReenroll handles the clean-slate reinstall of a CLAIMED customer (F2, v0.57.0): the box
|
|
// (host + in-guest controller) was wiped and re-enrolls, so the fresh controller has NO password
|
|
// while the hub-side claim is set. This rotates + emails a RESET code (rides Resend's rotation
|
|
// semantics — a single generation bump, single active code) so the customer gets a fresh code
|
|
// automatically instead of hunting for the manual "request new code" button. The new hash reaches
|
|
// the fresh controller through the existing report ACK.
|
|
//
|
|
// No-op for an UNCLAIMED customer — that is the first-provision path where EnsureIssued already
|
|
// owns the first code; re-enrolling before the first claim must NOT rotate. The CALLER guarantees
|
|
// single-shot by invoking this only on a genuinely fresh host record (the host-enroll mint path,
|
|
// which fires exactly once per reinstall). Returns (generation, reissued, error).
|
|
func (e *Engine) ReissueForReenroll(cc *store.CustomerConfig) (gen int, reissued bool, err error) {
|
|
cs, err := e.Store.GetClaim(cc.CustomerID)
|
|
if err != nil {
|
|
return 0, false, fmt.Errorf("claim: reading state: %w", err)
|
|
}
|
|
if cs == nil || !cs.Claimed() {
|
|
return 0, false, nil // unclaimed → first-provision path; nothing to re-issue
|
|
}
|
|
// EmailReenroll, not EmailReset: same secret, same name, different screen — see the constant.
|
|
gen, err = e.rotateAndSend(cc, EmailReenroll)
|
|
if err != nil {
|
|
return gen, true, err // reissued=true so the caller records the attempt even on email failure
|
|
}
|
|
e.logf("[INFO] [claim] reset code re-issued (gen %d) for %s on box re-enrollment (clean-slate reinstall)", gen, cc.CustomerID)
|
|
return gen, true, nil
|
|
}
|
|
|
|
// ResetToUnclaimed returns a customer to the pre-first-install (unclaimed, no active code) state — the
|
|
// customer-RESET leg (v0.61.0). It DELETES the claim row so the engine's EnsureIssued mints a FRESH
|
|
// first code (generation reset, unclaimed) on the next onboarding: no parallel "revoked" flag, no stale
|
|
// generation. The active code is invalidated (the row is gone → the gate has no hash) and claimed_at is
|
|
// cleared (gone). Idempotent (a missing row is a no-op).
|
|
func (e *Engine) ResetToUnclaimed(cc *store.CustomerConfig) error {
|
|
if err := e.Store.DeleteClaim(cc.CustomerID); err != nil {
|
|
return fmt.Errorf("claim: reset to unclaimed: %w", err)
|
|
}
|
|
e.logf("[INFO] [claim] reset to unclaimed for %s (customer RESET) — next onboarding mints a fresh code", cc.CustomerID)
|
|
return nil
|
|
}
|
|
|
|
// defaultSeedEvents is the enabled_events set seeded at claim (v0.71.0, audit F12) — critical-only:
|
|
// no node_stale (too chatty), no *_recovered (the recovery pairing gate handles those and never
|
|
// consults enabled_events). Viktor may adjust the list at review.
|
|
var defaultSeedEvents = []string{
|
|
"node_down",
|
|
"backup_failed",
|
|
"disk_critical",
|
|
"host_disk_critical",
|
|
"storage_fill_critical",
|
|
"offbox_repo_orphaned",
|
|
}
|
|
|
|
// MarkClaimed records a controller-reported successful claim and sends the one-time confirmation
|
|
// email on the unclaimed→claimed transition (idempotent — repeated reports are no-ops). On the
|
|
// transition it also seeds notification prefs from the registered email (v0.71.0, audit F12) so a
|
|
// claimed customer can no longer be silently unnotifiable — insert-if-absent, never overwriting a
|
|
// customer-edited row, and never failing the claim (notification plumbing must not gate claiming).
|
|
func (e *Engine) MarkClaimed(cc *store.CustomerConfig) error {
|
|
transitioned, err := e.Store.MarkClaimed(cc.CustomerID)
|
|
if err != nil {
|
|
return fmt.Errorf("claim: marking claimed: %w", err)
|
|
}
|
|
if !transitioned {
|
|
return nil
|
|
}
|
|
e.logf("[INFO] [claim] customer %s CLAIMED its dashboard (password set by the customer)", cc.CustomerID)
|
|
if seeded, err := e.Store.SeedNotificationPrefs(cc.CustomerID, cc.Email, defaultSeedEvents); err != nil {
|
|
e.logf("[WARN] [claim] notification-prefs seed for %s failed (claim unaffected): %v", cc.CustomerID, err)
|
|
} else if seeded {
|
|
e.logf("[INFO] [claim] notification prefs seeded for %s from the registered email (default critical set)", cc.CustomerID)
|
|
} else {
|
|
e.logf("[INFO] [claim] notification prefs for %s left untouched (row exists or no registered email)", cc.CustomerID)
|
|
}
|
|
if cc.Email != "" {
|
|
if err := e.Mailer.SendClaimEmail(string(EmailClaimed), cc.CustomerID, cc.Email, cc.Domain, ""); err != nil {
|
|
e.logf("[WARN] [claim] claimed-confirmation email to %s failed: %v", cc.CustomerID, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|