hub: customer-claim password arc parts 1+2 — code engine, emails, ACK, configgen bake, UI (v0.50.0)

Closes DRILL-day0-vm F-4 hub-side: per-customer claim state (customer_claims,
bcrypt-only custody), the claim engine (issue at real config retrieve = Day-0
bake; first-report issue for live boxes; resend rotates generation; reset
rate-limited 3/day), three Hungarian emails via the dispatcher, report-ACK
claim object {code_hash, generation, issued_at} + set-only claimed ingest,
web.claim_code_* baked into generated controller.yaml, Setup-tab status chip
+ resend button, POST /api/v1/claim/reset-request (self-scoped), claim_lockout
event allowlisted. 13 new tests; full repo green.

Claude-Session: https://claude.ai/code/session_01NptTCFtu7dz2Ru89qHRagN
This commit is contained in:
2026-07-12 18:12:48 +02:00
parent b904477ed9
commit 6b40eb8619
14 changed files with 1103 additions and 7 deletions
+116 -3
View File
@@ -14,6 +14,7 @@ import (
"time"
"gitea.dooplex.hu/admin/felhom-hub/internal/assets"
"gitea.dooplex.hu/admin/felhom-hub/internal/claim"
"gitea.dooplex.hu/admin/felhom-hub/internal/configgen"
"gitea.dooplex.hu/admin/felhom-hub/internal/mailrelay"
"gitea.dooplex.hu/admin/felhom-hub/internal/notify"
@@ -53,6 +54,15 @@ type Handler struct {
// S1 offsite connectivity: the wgsync reconciler seam (internal/api/wg.go). nil = peer-sync
// disabled — mutations still persist, responses carry sync:"disabled".
wgSyncer WGSyncer
// claimEngine is the customer-claim code engine (v0.50.0). nil = claim arc disabled: no codes
// issued, no claim field in ACKs/configs — pre-arc behavior exactly.
claimEngine *claim.Engine
}
// SetClaimEngine wires the customer-claim code engine (nil-safe everywhere it is used).
func (h *Handler) SetClaimEngine(e *claim.Engine) {
h.claimEngine = e
}
// SetLatestVersionProvider wires the registry version checker so the controller report ACK can
@@ -224,6 +234,8 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
h.handleAdminSetOperatorPeer(w, r)
case r.Method == http.MethodGet && path == "/admin/wg/operator-peer":
h.handleAdminGetOperatorPeer(w, r)
case r.Method == http.MethodPost && path == "/claim/reset-request":
h.handleClaimResetRequest(w, r)
case r.Method == http.MethodPost && path == "/event":
h.handleEvent(w, r)
case r.Method == http.MethodPost && path == "/mail":
@@ -382,6 +394,37 @@ func (h *Handler) handleReport(w http.ResponseWriter, r *http.Request) {
// (pull-based config delivery — the hub never connects into the box). Only emitted for
// config-managed customers (a report-only box without a config row gets no field and is unaffected).
resp["config_version"] = custCfg.ConfigVersion
// Customer-claim arc (v0.50.0, F-4): ensure a claim code exists for every reporting managed
// customer (idempotent — the live-box entry point; Day-0 boxes get theirs at config retrieve),
// ingest the controller's reported claimed flag (set-only — a wiped settings.json can never
// un-claim), and serve the ACTIVE code hash + generation in the ACK. The hash is bcrypt (non-
// reversible) — safe to serve on the authenticated report channel; the plaintext code exists
// only in the customer's mailbox.
if h.claimEngine != nil {
cs, cerr := h.claimEngine.EnsureIssued(custCfg)
if cerr != nil {
h.logger.Printf("[WARN] claim issue for %s on report: %v", payload.CustomerID, cerr)
}
var claimedPayload struct {
Claimed *bool `json:"claimed"`
}
if err := json.Unmarshal(body, &claimedPayload); err == nil &&
claimedPayload.Claimed != nil && *claimedPayload.Claimed {
if err := h.claimEngine.MarkClaimed(custCfg); err != nil {
h.logger.Printf("[WARN] claim mark-claimed for %s: %v", payload.CustomerID, err)
} else if cs != nil && cs.ClaimedAt == nil {
cs, _ = h.store.GetClaim(payload.CustomerID) // refresh for the ACK below
}
}
if cs != nil {
resp["claim"] = map[string]interface{}{
"code_hash": cs.CodeHash,
"generation": cs.Generation,
"issued_at": cs.IssuedAt.UTC().Format(time.RFC3339),
}
}
}
}
// SLICE 3 — escrow status for the hub-verified auto-confirm: the controller flips its offbox
@@ -1304,10 +1347,59 @@ func (h *Handler) handleAdminEnqueueJob(w http.ResponseWriter, r *http.Request,
json.NewEncoder(w).Encode(map[string]interface{}{"status": "ok", "job_id": req.JobID})
}
// handleClaimResetRequest is the controller-forwarded "Elfelejtett jelszó" (v0.50.0): the box
// asks the hub to email a fresh reset code to the REGISTERED customer address — the requester
// never chooses the destination. Auth: the customer's own report Bearer key (self-scoped).
// The response is deliberately neutral 200 on every authorized outcome (cap reached, email
// failure) — the customer-facing message is always "ha az e-mail cím regisztrálva van…"; the
// real outcome goes to the operator log + notification_log.
func (h *Handler) handleClaimResetRequest(w http.ResponseWriter, r *http.Request) {
authCustomerID, isGlobal, ok := h.checkAuthCustomer(r)
if !ok {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
body, err := io.ReadAll(io.LimitReader(r.Body, 4096))
if err != nil {
http.Error(w, "Bad request", http.StatusBadRequest)
return
}
var payload struct {
CustomerID string `json:"customer_id"`
}
if err := json.Unmarshal(body, &payload); err != nil || payload.CustomerID == "" {
http.Error(w, "Invalid payload: customer_id required", http.StatusBadRequest)
return
}
if !isGlobal && authCustomerID != payload.CustomerID {
http.Error(w, "Forbidden: customer_id mismatch", http.StatusForbidden)
return
}
if h.claimEngine == nil {
http.Error(w, "Claim engine not available", http.StatusServiceUnavailable)
return
}
cfg, err := h.store.GetCustomerConfig(payload.CustomerID)
if err != nil || cfg == nil {
http.Error(w, "Not found", http.StatusNotFound)
return
}
if err := h.claimEngine.RequestReset(cfg); err != nil {
// Neutral to the box; loud to the operator (cap reached / send failure / no email).
h.logger.Printf("[WARN] claim reset-request for %s: %v", payload.CustomerID, err)
} else {
h.logger.Printf("[INFO] claim reset-request for %s: reset code emailed to the registered address", payload.CustomerID)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status":"ok"}`))
}
// allowedEventTypes lists all valid event_type values the Hub accepts.
var allowedEventTypes = map[string]bool{
// Controller-pushed events
"controller_started": true,
"claim_lockout": true, // v0.50.0 — claim/reset code brute-force lockout tripped
"controller_updated": true,
"backup_completed": true,
"backup_failed": true,
@@ -1696,10 +1788,16 @@ func (h *Handler) handleRecovery(w http.ResponseWriter, r *http.Request, custome
return
}
// Generate controller.yaml
// Generate controller.yaml. The claim state is baked read-only (no issue on the DR path — a
// recovered box whose settings.json is gone re-gates on the EXISTING hash; the reset flow
// covers a customer who lost the password with the box).
var configYAML string
if h.templateProvider != nil {
yamlOutput, err := configgen.Generate(h.templateProvider.Template(), cfg)
var claimState *store.ClaimState
if h.claimEngine != nil {
claimState, _ = h.store.GetClaim(customerID)
}
yamlOutput, err := configgen.Generate(h.templateProvider.Template(), cfg, claimState)
if err != nil {
h.logger.Printf("[ERROR] Recovery: failed to generate config for %s: %v", customerID, err)
http.Error(w, "Internal error", http.StatusInternalServerError)
@@ -1761,7 +1859,22 @@ func (h *Handler) handleConfigRetrieve(w http.ResponseWriter, r *http.Request, c
return
}
yamlOutput, err := configgen.Generate(h.templateProvider.Template(), cfg)
// Customer-claim arc (v0.50.0): the REAL config pull (Day-0 installer / controller refresh) is
// the Day-0 claim entry point — issue + email the first code here (idempotent), and bake the
// active hash into the generated web.claim_code_hash so the box is gated from FIRST boot.
// (The operator-UI preview deliberately does NOT issue — it only bakes an existing hash.)
var claimState *store.ClaimState
if h.claimEngine != nil {
var cerr error
claimState, cerr = h.claimEngine.EnsureIssued(cfg)
if cerr != nil {
// Loud but non-fatal: the config is still served; if a hash was stored the gate is armed
// and the operator resends the email from the customer page.
h.logger.Printf("[WARN] claim issue for %s on config retrieve: %v", customerID, cerr)
}
}
yamlOutput, err := configgen.Generate(h.templateProvider.Template(), cfg, claimState)
if err != nil {
h.logger.Printf("[ERROR] Failed to generate config for %s: %v", customerID, err)
http.Error(w, "Internal error", http.StatusInternalServerError)