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:
@@ -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
@@ -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