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
+163
View File
@@ -0,0 +1,163 @@
package api
import (
"encoding/json"
"io"
"log"
"net/http"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-hub/internal/claim"
"gitea.dooplex.hu/admin/felhom-hub/internal/configgen"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
)
type nullMailer struct{ sends int }
func (n *nullMailer) SendClaimEmail(kind, customerID, email, domain, code string) error {
n.sends++
return nil
}
func withClaimEngine(t *testing.T, h *Handler, st *store.Store) *nullMailer {
t.Helper()
m := &nullMailer{}
h.SetClaimEngine(&claim.Engine{Store: st, Mailer: m, Logger: log.New(io.Discard, "", 0)})
return m
}
// The report ACK of a managed customer carries the claim object (hash + generation + issued_at),
// and the first report ISSUES the code for a live box that has none yet.
func TestReportACK_ClaimServedAndIssuedOnFirstReport(t *testing.T) {
h, st, _ := newTestHandler(t)
m := withClaimEngine(t, h, st)
if err := st.SaveCustomerConfig(&store.CustomerConfig{
CustomerID: "c", RetrievalPassword: "pw", APIKey: "k", ConfigJSON: "{}",
Email: "owner@example.hu", Domain: "example.hu",
}); err != nil {
t.Fatalf("SaveCustomerConfig: %v", err)
}
rr := do(h, http.MethodPost, "/report", globalKey, `{"customer_id":"c"}`)
if rr.Code != http.StatusOK {
t.Fatalf("report = %d (%s)", rr.Code, rr.Body.String())
}
var ack struct {
Claim *struct {
CodeHash string `json:"code_hash"`
Generation int `json:"generation"`
IssuedAt string `json:"issued_at"`
} `json:"claim"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &ack); err != nil {
t.Fatalf("decode: %v", err)
}
if ack.Claim == nil || ack.Claim.Generation != 1 || !strings.HasPrefix(ack.Claim.CodeHash, "$2") {
t.Fatalf("ACK claim = %+v, want gen 1 with a bcrypt hash", ack.Claim)
}
if m.sends != 1 {
t.Fatalf("first report should have emailed the code once, sends=%d", m.sends)
}
// Second report: same generation, no re-send (idempotent), claim still served.
rr = do(h, http.MethodPost, "/report", globalKey, `{"customer_id":"c"}`)
var ack2 struct {
Claim *struct {
Generation int `json:"generation"`
} `json:"claim"`
}
json.Unmarshal(rr.Body.Bytes(), &ack2)
if ack2.Claim == nil || ack2.Claim.Generation != 1 || m.sends != 1 {
t.Fatalf("second report: claim=%+v sends=%d, want gen 1 / 1 send", ack2.Claim, m.sends)
}
}
// A reported claimed:true flips the store row (set-only) and a later claimed:false report can
// never un-claim it (wiped-settings DR case).
func TestReport_ClaimedIngestIsSetOnly(t *testing.T) {
h, st, _ := newTestHandler(t)
withClaimEngine(t, h, st)
st.SaveCustomerConfig(&store.CustomerConfig{
CustomerID: "c", RetrievalPassword: "pw", APIKey: "k", ConfigJSON: "{}",
Email: "owner@example.hu",
})
do(h, http.MethodPost, "/report", globalKey, `{"customer_id":"c","claimed":true}`)
cs, _ := st.GetClaim("c")
if !cs.Claimed() {
t.Fatal("claimed:true report did not mark the customer claimed")
}
claimedAt := *cs.ClaimedAt
do(h, http.MethodPost, "/report", globalKey, `{"customer_id":"c","claimed":false}`)
cs, _ = st.GetClaim("c")
if !cs.Claimed() || !cs.ClaimedAt.Equal(claimedAt) {
t.Fatal("claimed:false report altered the claimed state — must be set-only")
}
}
// The reset-request endpoint is self-scoped: a customer key may only request its own reset.
func TestClaimResetRequest_SelfScoped(t *testing.T) {
h, st, _ := newTestHandler(t)
m := withClaimEngine(t, h, st)
st.SaveCustomerConfig(&store.CustomerConfig{
CustomerID: "c1", RetrievalPassword: "pw", APIKey: "KEY1", ConfigJSON: "{}", Email: "a@b.hu",
})
st.SaveCustomerConfig(&store.CustomerConfig{
CustomerID: "c2", RetrievalPassword: "pw", APIKey: "KEY2", ConfigJSON: "{}", Email: "c@d.hu",
})
rr := do(h, http.MethodPost, "/claim/reset-request", "KEY1", `{"customer_id":"c2"}`)
if rr.Code != http.StatusForbidden {
t.Fatalf("cross-customer reset request = %d, want 403", rr.Code)
}
rr = do(h, http.MethodPost, "/claim/reset-request", "KEY1", `{"customer_id":"c1"}`)
if rr.Code != http.StatusOK {
t.Fatalf("own reset request = %d, want 200 (%s)", rr.Code, rr.Body.String())
}
if m.sends == 0 {
t.Fatal("reset request sent no email")
}
rr = do(h, http.MethodPost, "/claim/reset-request", "", `{"customer_id":"c1"}`)
if rr.Code != http.StatusUnauthorized {
t.Fatalf("unauthenticated reset request = %d, want 401", rr.Code)
}
}
// The generated controller.yaml bakes the claim hash (Day-0 gate-from-first-boot) and NEVER a
// plaintext code; without a claim state the web section is unchanged.
func TestConfiggen_BakesClaimHashOnly(t *testing.T) {
tmpl := "web:\n listen: :8080\n password_hash: \"\"\n"
cfg := &store.CustomerConfig{CustomerID: "c", ConfigJSON: "{}"}
out, err := configgen.Generate(tmpl, cfg, nil)
if err != nil {
t.Fatalf("generate (nil claim): %v", err)
}
if strings.Contains(out, "claim_code_hash") {
t.Fatal("nil claim state must not emit claim_code_hash")
}
h, st, _ := newTestHandler(t)
m := withClaimEngine(t, h, st)
_ = m
st.SaveCustomerConfig(&store.CustomerConfig{
CustomerID: "c", RetrievalPassword: "pw", APIKey: "k", ConfigJSON: "{}", Email: "a@b.hu",
})
do(h, http.MethodPost, "/report", globalKey, `{"customer_id":"c"}`) // issues gen 1
cs, _ := st.GetClaim("c")
out, err = configgen.Generate(tmpl, cfg, cs)
if err != nil {
t.Fatalf("generate (with claim): %v", err)
}
if !strings.Contains(out, "claim_code_hash: "+cs.CodeHash) &&
!strings.Contains(out, "claim_code_hash: '"+cs.CodeHash+"'") &&
!strings.Contains(out, "claim_code_hash: \""+cs.CodeHash+"\"") {
t.Fatalf("generated yaml missing the claim hash:\n%s", out)
}
if !strings.Contains(out, "claim_code_generation: 1") {
t.Fatalf("generated yaml missing the generation:\n%s", out)
}
}
+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)