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:
+116
-3
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user