diff --git a/hub/cmd/hub/main.go b/hub/cmd/hub/main.go index 9b09e48..4b40af3 100644 --- a/hub/cmd/hub/main.go +++ b/hub/cmd/hub/main.go @@ -15,6 +15,7 @@ import ( "gitea.dooplex.hu/admin/felhom-hub/internal/api" "gitea.dooplex.hu/admin/felhom-hub/internal/assets" + "gitea.dooplex.hu/admin/felhom-hub/internal/claim" "gitea.dooplex.hu/admin/felhom-hub/internal/gitea" "gitea.dooplex.hu/admin/felhom-hub/internal/hetznerapi" "gitea.dooplex.hu/admin/felhom-hub/internal/mailrelay" @@ -245,6 +246,13 @@ func main() { ) apiHandler.SetDispatcher(dispatcher) + // Customer-claim password arc (v0.50.0, F-4): the code engine — the dispatcher delivers the + // Hungarian emails, the store holds bcrypt(code) only. Wired into the API (Day-0 issue at + // config retrieve, live-box issue + claimed ingest + ACK on report, reset endpoint) and the + // web UI (status chip + resend). + claimEngine := &claim.Engine{Store: dataStore, Mailer: dispatcher, Logger: logger} + apiHandler.SetClaimEngine(claimEngine) + // App-email passthrough (POST /api/v1/mail): a customer box's shim forwards a raw message // here and the hub re-emits it to Resend over SMTP, UNCHANGED (raw passthrough — NOT the // dispatcher's structured HTTP-API path, which drops inline CID images). The Resend key stays @@ -260,6 +268,7 @@ func main() { webServer := web.New(dataStore, cfg.Auth.PasswordHash, cfg.API.ReportAPIKey, Version, staleThreshold, logger) webServer.SetTemplateFetcher(templateFetcher) webServer.SetAssetManager(assetsMgr) + webServer.SetClaimEngine(claimEngine) // v0.50.0 — Setup-tab claim chip + resend button // Day-0 artifact version dropdowns: let the operator pick a version and have the hub derive the // sha256 from Gitea (no hand-copied checksums). Reuses the registry creds; degrades to manual text // entry when they're absent. diff --git a/hub/internal/api/claim_ack_test.go b/hub/internal/api/claim_ack_test.go new file mode 100644 index 0000000..b274a26 --- /dev/null +++ b/hub/internal/api/claim_ack_test.go @@ -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) + } +} diff --git a/hub/internal/api/handler.go b/hub/internal/api/handler.go index a6dcb74..94b2730 100644 --- a/hub/internal/api/handler.go +++ b/hub/internal/api/handler.go @@ -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) diff --git a/hub/internal/claim/engine.go b/hub/internal/claim/engine.go new file mode 100644 index 0000000..8b817c4 --- /dev/null +++ b/hub/internal/claim/engine.go @@ -0,0 +1,179 @@ +// 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 + EmailClaimed EmailKind = "claimed" // confirmation after a successful claim (carries no code) +) + +// 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 +} + +// 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). +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 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 +} diff --git a/hub/internal/claim/engine_test.go b/hub/internal/claim/engine_test.go new file mode 100644 index 0000000..0e6d89d --- /dev/null +++ b/hub/internal/claim/engine_test.go @@ -0,0 +1,204 @@ +package claim + +import ( + "io" + "log" + "path/filepath" + "strings" + "testing" + + "gitea.dooplex.hu/admin/felhom-hub/internal/store" + "golang.org/x/crypto/bcrypt" +) + +// fakeMailer records sends and captures the LAST code (test-only — the engine itself never +// retains one). failNext makes the next send fail. +type fakeMailer struct { + sends []string // "kind:customerID:email" + lastCode string + failNext bool +} + +func (f *fakeMailer) SendClaimEmail(kind, customerID, email, domain, code string) error { + if f.failNext { + f.failNext = false + return errSend + } + f.sends = append(f.sends, kind+":"+customerID+":"+email) + f.lastCode = code + return nil +} + +var errSend = &sendErr{} + +type sendErr struct{} + +func (*sendErr) Error() string { return "send failed" } + +func newTestEngine(t *testing.T) (*Engine, *store.Store, *fakeMailer) { + t.Helper() + st, err := store.New(filepath.Join(t.TempDir(), "t.db"), log.New(io.Discard, "", 0)) + if err != nil { + t.Fatalf("store.New: %v", err) + } + t.Cleanup(func() { st.Close() }) + m := &fakeMailer{} + return &Engine{Store: st, Mailer: m, Logger: log.New(io.Discard, "", 0)}, st, m +} + +func cust() *store.CustomerConfig { + return &store.CustomerConfig{CustomerID: "c1", Email: "owner@example.hu", Domain: "example.hu"} +} + +// EnsureIssued creates the row + emails ONCE; repeated calls neither rotate nor re-send. +func TestEnsureIssued_IdempotentSingleEmail(t *testing.T) { + e, st, m := newTestEngine(t) + cs, err := e.EnsureIssued(cust()) + if err != nil { + t.Fatalf("EnsureIssued: %v", err) + } + if cs == nil || cs.Generation != 1 || cs.CodeHash == "" { + t.Fatalf("first issue: got %+v", cs) + } + if len(m.sends) != 1 || !strings.HasPrefix(m.sends[0], "claim:c1:") { + t.Fatalf("expected exactly one claim email, got %v", m.sends) + } + // The stored hash must verify the emailed code and must NOT contain it (bcrypt-only custody). + if bcrypt.CompareHashAndPassword([]byte(cs.CodeHash), []byte(m.lastCode)) != nil { + t.Fatal("stored hash does not verify the emailed code") + } + if strings.Contains(cs.CodeHash, m.lastCode) { + t.Fatal("plaintext code leaked into the stored hash") + } + + cs2, err := e.EnsureIssued(cust()) + if err != nil { + t.Fatalf("EnsureIssued (second): %v", err) + } + if cs2.Generation != 1 || cs2.CodeHash != cs.CodeHash { + t.Fatalf("second EnsureIssued must not rotate: gen %d hash-changed=%v", cs2.Generation, cs2.CodeHash != cs.CodeHash) + } + if len(m.sends) != 1 { + t.Fatalf("second EnsureIssued must not re-send: %v", m.sends) + } + // §10 bcrypt-only storage: the DB row itself never contains the plaintext. + row, err := st.GetClaim("c1") + if err != nil || row == nil { + t.Fatalf("GetClaim: %v %v", row, err) + } + if strings.Contains(row.CodeHash, m.lastCode) || row.CodeHash == m.lastCode { + t.Fatal("DB row contains the plaintext code") + } +} + +// Resend rotates: generation bumps, the OLD code no longer verifies against the stored hash +// (single active code). Red-proof partner: drop the generation bump in RotateClaimCode → fails. +func TestResend_RotatesAndInvalidatesOldCode(t *testing.T) { + e, st, m := newTestEngine(t) + if _, err := e.EnsureIssued(cust()); err != nil { + t.Fatalf("EnsureIssued: %v", err) + } + oldCode := m.lastCode + if err := e.Resend(cust()); err != nil { + t.Fatalf("Resend: %v", err) + } + cs, _ := st.GetClaim("c1") + if cs.Generation != 2 { + t.Fatalf("generation after resend = %d, want 2", cs.Generation) + } + if bcrypt.CompareHashAndPassword([]byte(cs.CodeHash), []byte(oldCode)) == nil { + t.Fatal("OLD code still verifies after resend — single-active-code broken") + } + if bcrypt.CompareHashAndPassword([]byte(cs.CodeHash), []byte(m.lastCode)) != nil { + t.Fatal("new code does not verify after resend") + } + if len(m.sends) != 2 || !strings.HasPrefix(m.sends[1], "claim:") { + t.Fatalf("unclaimed resend should use the claim template: %v", m.sends) + } +} + +// A claimed customer's resend uses the RESET template and never clears claimed_at. +func TestResend_ClaimedGetsResetTemplateAndStaysClaimed(t *testing.T) { + e, st, m := newTestEngine(t) + if _, err := e.EnsureIssued(cust()); err != nil { + t.Fatalf("EnsureIssued: %v", err) + } + if err := e.MarkClaimed(cust()); err != nil { + t.Fatalf("MarkClaimed: %v", err) + } + if err := e.Resend(cust()); err != nil { + t.Fatalf("Resend: %v", err) + } + cs, _ := st.GetClaim("c1") + if !cs.Claimed() { + t.Fatal("rotation cleared claimed_at — resets must never un-claim") + } + last := m.sends[len(m.sends)-1] + if !strings.HasPrefix(last, "reset:") { + t.Fatalf("claimed resend should use the reset template, got %s", last) + } +} + +// RequestReset caps at 3/day per customer, hub-side. +func TestRequestReset_DailyCap(t *testing.T) { + e, _, m := newTestEngine(t) + if _, err := e.EnsureIssued(cust()); err != nil { + t.Fatalf("EnsureIssued: %v", err) + } + for i := 0; i < 3; i++ { + if err := e.RequestReset(cust()); err != nil { + t.Fatalf("RequestReset %d: %v", i+1, err) + } + } + sendsBefore := len(m.sends) + if err := e.RequestReset(cust()); err == nil { + t.Fatal("4th reset request of the day should be refused") + } + if len(m.sends) != sendsBefore { + t.Fatal("refused reset must not send an email") + } +} + +// MarkClaimed transitions once: confirmation email exactly once, idempotent afterwards. +func TestMarkClaimed_TransitionOnce(t *testing.T) { + e, st, m := newTestEngine(t) + if _, err := e.EnsureIssued(cust()); err != nil { + t.Fatalf("EnsureIssued: %v", err) + } + if err := e.MarkClaimed(cust()); err != nil { + t.Fatalf("MarkClaimed: %v", err) + } + if err := e.MarkClaimed(cust()); err != nil { + t.Fatalf("MarkClaimed (repeat): %v", err) + } + confirms := 0 + for _, s := range m.sends { + if strings.HasPrefix(s, "claimed:") { + confirms++ + } + } + if confirms != 1 { + t.Fatalf("claimed-confirmation emails = %d, want exactly 1", confirms) + } + cs, _ := st.GetClaim("c1") + if !cs.Claimed() { + t.Fatal("not claimed after MarkClaimed") + } +} + +// An email send failure keeps the rotated hash (gate stays armed) and surfaces the error. +func TestIssue_EmailFailureKeepsGateArmed(t *testing.T) { + e, st, m := newTestEngine(t) + m.failNext = true + cs, err := e.EnsureIssued(cust()) + if err == nil { + t.Fatal("EnsureIssued should surface the send failure") + } + if cs == nil || cs.CodeHash == "" { + t.Fatal("hash must be stored (gate armed) even when the email failed") + } + row, _ := st.GetClaim("c1") + if row == nil || row.EmailedAt != nil { + t.Fatalf("emailed_at must stay unset on failure: %+v", row) + } +} diff --git a/hub/internal/configgen/configgen.go b/hub/internal/configgen/configgen.go index db4a104..3d21782 100644 --- a/hub/internal/configgen/configgen.go +++ b/hub/internal/configgen/configgen.go @@ -13,7 +13,11 @@ import ( // Generate takes the template YAML and a customer config, // then produces a complete controller.yaml with customer-specific values // merged in. The returned string is valid YAML ready for deployment. -func Generate(templateYAML string, cfg *store.CustomerConfig) (string, error) { +// +// claimState (v0.50.0, nil-safe): when present, the ACTIVE claim-code bcrypt hash + generation +// are baked into web.claim_code_* so a Day-0 box is claim-gated from its FIRST boot (the +// controller's precedence: a set password always wins; the hash alone never overrides one). +func Generate(templateYAML string, cfg *store.CustomerConfig, claimState *store.ClaimState) (string, error) { // Parse template into generic map var base map[string]interface{} if err := yaml.Unmarshal([]byte(templateYAML), &base); err != nil { @@ -53,6 +57,14 @@ func Generate(templateYAML string, cfg *store.CustomerConfig) (string, error) { } setNested(base, []string{"web", "session_secret"}, sessionSecret) + // Customer-claim arc (v0.50.0): bake the active claim-code hash so the gate is armed from + // first boot. bcrypt only — the plaintext code never reaches any config. + if claimState != nil && claimState.CodeHash != "" { + setNested(base, []string{"web", "claim_code_hash"}, claimState.CodeHash) + setNested(base, []string{"web", "claim_code_generation"}, claimState.Generation) + setNested(base, []string{"web", "claim_code_issued_at"}, claimState.IssuedAt.UTC().Format(time.RFC3339)) + } + // Marshal back to YAML out, err := yaml.Marshal(base) if err != nil { diff --git a/hub/internal/configgen/configgen_debug_test.go b/hub/internal/configgen/configgen_debug_test.go index 4c597eb..6efb1b4 100644 --- a/hub/internal/configgen/configgen_debug_test.go +++ b/hub/internal/configgen/configgen_debug_test.go @@ -18,7 +18,7 @@ func TestGenerate_DebugLevelOverride(t *testing.T) { // Override present → the generated YAML carries level: debug, and the info default is gone. dbg, err := Generate(tmpl, &store.CustomerConfig{ CustomerID: "c1", ConfigJSON: `{"logging":{"level":"debug"}}`, - }) + }, nil) if err != nil { t.Fatalf("generate (debug): %v", err) } @@ -30,7 +30,7 @@ func TestGenerate_DebugLevelOverride(t *testing.T) { } // No override → the template default (info) stands, debug absent. - def, err := Generate(tmpl, &store.CustomerConfig{CustomerID: "c1", ConfigJSON: "{}"}) + def, err := Generate(tmpl, &store.CustomerConfig{CustomerID: "c1", ConfigJSON: "{}"}, nil) if err != nil { t.Fatalf("generate (default): %v", err) } diff --git a/hub/internal/notify/dispatcher.go b/hub/internal/notify/dispatcher.go index 5f3e455..d152831 100644 --- a/hub/internal/notify/dispatcher.go +++ b/hub/internal/notify/dispatcher.go @@ -224,3 +224,24 @@ func isEventEnabled(enabledEvents []string, eventType string) bool { } return false } + +// SendClaimEmail delivers a customer-claim arc email (claim / reset / claimed confirmation) to +// the REGISTERED customer address — the claim.Mailer implementation. Every send result is +// logged + recorded in notification_log; the code itself never is (rule: plaintext exists only +// inside the send). +func (d *Dispatcher) SendClaimEmail(kind, customerID, email, domain, code string) error { + if d.resendAPIKey == "" { + d.logger.Printf("[ERROR] claim %s email for %s NOT sent: no Resend API key configured", kind, customerID) + return fmt.Errorf("notify: no resend api key") + } + subject, body := FormatClaimEmail(kind, customerID, domain, code) + eventType := "claim_" + kind + if err := d.sendEmailFn(email, subject, body); err != nil { + d.logger.Printf("[ERROR] claim %s email to customer %s failed: %v", kind, customerID, err) + d.store.LogNotification(customerID, eventType, "info", subject, "failed", err.Error(), "customer") + return err + } + d.logger.Printf("[INFO] claim %s email sent to the registered address of %s", kind, customerID) + d.store.LogNotification(customerID, eventType, "info", subject, "sent", "", "customer") + return nil +} diff --git a/hub/internal/notify/templates.go b/hub/internal/notify/templates.go index 894a65c..b69781b 100644 --- a/hub/internal/notify/templates.go +++ b/hub/internal/notify/templates.go @@ -51,6 +51,8 @@ Message: %s`, customerID, eventType, severity, now, message) // customerMessages maps event_type → Hungarian customer message. var customerMessages = map[string]string{ + // Customer-claim arc (v0.50.0) + "claim_lockout": "Túl sok hibás beállító/visszaállító kód próbálkozás történt — a beállító oldal 15 percre zárolva lett. Ha nem te próbálkoztál, jelezd az üzemeltetőnek.", // Backup events "backup_completed": "A biztonsági mentés sikeresen elkészült.", "backup_failed": "A biztonsági mentés sikertelen! Kérjük, ellenőrizd a rendszert.", @@ -161,3 +163,67 @@ Felhom.eu monitoring` return subject, body } + +// ────────────────────────────────────────────────────────────────────── +// Customer-claim password arc (v0.50.0) — Hungarian code-delivery emails +// ────────────────────────────────────────────────────────────────────── + +// FormatClaimEmail returns (subject, textBody) for a claim-arc email. kind is one of +// "claim" | "reset" | "claimed" (claim.EmailKind values). The code appears ONLY in the +// returned body — callers must never log it. +func FormatClaimEmail(kind, customerID, domain, code string) (string, string) { + dashboardURL := "https://felhom." + domain + switch kind { + case "reset": + subject := "[Felhom] Jelszó-visszaállítási kód" + body := fmt.Sprintf(`Kedves Ügyfél! + +Jelszó-visszaállítást kértél a Felhom vezérlőpultodhoz. + +Visszaállító kód: %s + +A kód 72 óráig érvényes, és egyszer használható fel. Add meg a vezérlőpult +"Elfelejtett jelszó" oldalán, majd válassz új jelszót: + +%s + +Ha nem te kérted, hagyd figyelmen kívül — a jelenlegi jelszavad változatlan. + +Üdvözlettel, +Felhom.eu`, code, dashboardURL) + return subject, body + case "claimed": + subject := "[Felhom] A vezérlőpultod mostantól jelszóval védett" + body := fmt.Sprintf(`Kedves Ügyfél! + +A Felhom vezérlőpultod beállítása elkészült — a vezérlőpultod mostantól +jelszóval védett. A megadott jelszóval tudsz bejelentkezni: + +%s + +Ha nem te végezted a beállítást, azonnal vedd fel a kapcsolatot az üzemeltetővel. + +Üdvözlettel, +Felhom.eu`, dashboardURL) + return subject, body + default: // "claim" + subject := "[Felhom] Elindult a Felhom szervered — beállító kód" + body := fmt.Sprintf(`Kedves Ügyfél! + +Elindult a Felhom szervered. A vezérlőpult első használatához add meg az +alábbi beállító kódot, majd válassz saját jelszót: + +Beállító kód: %s + +A kód 72 óráig érvényes, és egyszer használható fel. A vezérlőpultot itt éred el: + +%s + +Ha nem kaptad volna meg időben, a vezérlőpult "Új kód kérése" gombjával +kérhetsz frisset — az mindig erre az e-mail címre érkezik. + +Üdvözlettel, +Felhom.eu`, code, dashboardURL) + return subject, body + } +} diff --git a/hub/internal/store/store.go b/hub/internal/store/store.go index 03798ab..eeb1880 100644 --- a/hub/internal/store/store.go +++ b/hub/internal/store/store.go @@ -459,6 +459,29 @@ func (s *Store) migrate() error { return err } + // v0.50.0 — customer-claim password arc (DRILL-day0-vm F-4): one row per customer holding the + // ACTIVE claim/reset code state. code_hash is bcrypt(code) — the plaintext exists ONLY inside + // the email send (same custody rule as the retrieval passphrase). generation is monotonic: a + // resend/reset rotates the code (generation+1) and the controller refuses codes of an already- + // consumed generation. claimed_at is set once (first successful claim) and NEVER cleared by a + // rotation — a reset code on a claimed box must not un-claim it. emailed_at records the last + // send; reset_day/reset_count are the hub-side 3/day reset-request limiter. + _, err = s.db.Exec(` + CREATE TABLE IF NOT EXISTS customer_claims ( + customer_id TEXT PRIMARY KEY, + code_hash TEXT NOT NULL, + generation INTEGER NOT NULL DEFAULT 1, + issued_at DATETIME NOT NULL, + emailed_at DATETIME, + claimed_at DATETIME, + reset_day TEXT NOT NULL DEFAULT '', + reset_count INTEGER NOT NULL DEFAULT 0 + ); + `) + if err != nil { + return err + } + // v0.43.0 — remote app-log diagnostics. Additive columns on app_log_issues: // context = JSON array of ±5 redacted lines around the FIRST occurrence (first capture // wins — stable repro context, no churn); context_customer = whose box it came from @@ -1030,6 +1053,113 @@ func (s *Store) GetCustomerConfigByAPIKey(apiKey string) (*CustomerConfig, error return &cfg, nil } +// ── Customer-claim password arc (v0.50.0, DRILL-day0-vm F-4) ────────────────────────────── + +// ClaimState is one customer's active claim/reset-code state. CodeHash is bcrypt(code) — the +// store NEVER holds a plaintext code. Claimed means the customer has completed the claim (set +// their own password) at least once; rotations never clear it. +type ClaimState struct { + CustomerID string + CodeHash string + Generation int + IssuedAt time.Time + EmailedAt *time.Time + ClaimedAt *time.Time + ResetDay string + ResetCount int +} + +// Claimed reports whether the claim has been completed at least once. +func (c *ClaimState) Claimed() bool { return c != nil && c.ClaimedAt != nil } + +// GetClaim returns the customer's claim state, or nil when none exists. +func (s *Store) GetClaim(customerID string) (*ClaimState, error) { + var cs ClaimState + var issuedAt string + var emailedAt, claimedAt sql.NullString + err := s.db.QueryRow(` + SELECT customer_id, code_hash, generation, issued_at, emailed_at, claimed_at, reset_day, reset_count + FROM customer_claims WHERE customer_id = ?`, customerID, + ).Scan(&cs.CustomerID, &cs.CodeHash, &cs.Generation, &issuedAt, &emailedAt, &claimedAt, &cs.ResetDay, &cs.ResetCount) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + cs.IssuedAt = parseSQLiteTime(issuedAt) + if emailedAt.Valid { + t := parseSQLiteTime(emailedAt.String) + cs.EmailedAt = &t + } + if claimedAt.Valid { + t := parseSQLiteTime(claimedAt.String) + cs.ClaimedAt = &t + } + return &cs, nil +} + +// RotateClaimCode installs a fresh code hash: creates the row (generation 1) or rotates it +// (generation+1, new issued_at, emailed_at cleared until the send is confirmed). claimed_at is +// deliberately PRESERVED — rotating a claimed customer's code (a password reset) never un-claims +// the box. Returns the new generation. +func (s *Store) RotateClaimCode(customerID, codeHash string) (int, error) { + _, err := s.db.Exec(` + INSERT INTO customer_claims (customer_id, code_hash, generation, issued_at) + VALUES (?, ?, 1, datetime('now')) + ON CONFLICT(customer_id) DO UPDATE SET + code_hash = excluded.code_hash, + generation = customer_claims.generation + 1, + issued_at = datetime('now'), + emailed_at = NULL`, + customerID, codeHash) + if err != nil { + return 0, err + } + var gen int + if err := s.db.QueryRow(`SELECT generation FROM customer_claims WHERE customer_id = ?`, customerID).Scan(&gen); err != nil { + return 0, err + } + return gen, nil +} + +// MarkClaimEmailed records that the active code was delivered to the registered address. +func (s *Store) MarkClaimEmailed(customerID string) error { + _, err := s.db.Exec(`UPDATE customer_claims SET emailed_at = datetime('now') WHERE customer_id = ?`, customerID) + return err +} + +// MarkClaimed records the first successful claim (idempotent — an already-set claimed_at stays). +// Returns true when this call performed the unclaimed→claimed transition. +func (s *Store) MarkClaimed(customerID string) (bool, error) { + res, err := s.db.Exec(`UPDATE customer_claims SET claimed_at = datetime('now') WHERE customer_id = ? AND claimed_at IS NULL`, customerID) + if err != nil { + return false, err + } + n, _ := res.RowsAffected() + return n > 0, nil +} + +// BumpResetCount enforces the hub-side reset-request limiter: increments today's counter and +// returns the post-increment count (the caller compares against the daily cap). The day key +// rolls over automatically (UTC date). +func (s *Store) BumpResetCount(customerID string) (int, error) { + day := time.Now().UTC().Format("2006-01-02") + _, err := s.db.Exec(` + UPDATE customer_claims SET + reset_count = CASE WHEN reset_day = ? THEN reset_count + 1 ELSE 1 END, + reset_day = ? + WHERE customer_id = ?`, day, day, customerID) + if err != nil { + return 0, err + } + var count int + if err := s.db.QueryRow(`SELECT reset_count FROM customer_claims WHERE customer_id = ?`, customerID).Scan(&count); err != nil { + return 0, err + } + return count, nil +} + // SetCustomerConfigStatus sets the status (active/blocked) for a customer config. func (s *Store) SetCustomerConfigStatus(customerID, status string) error { _, err := s.db.Exec(` diff --git a/hub/internal/web/claim_ui_test.go b/hub/internal/web/claim_ui_test.go new file mode 100644 index 0000000..eaa2fbf --- /dev/null +++ b/hub/internal/web/claim_ui_test.go @@ -0,0 +1,119 @@ +package web + +// Customer-claim arc (v0.50.0) — the Setup-tab claim card + the operator resend route. + +import ( + "io" + "log" + "net/http/httptest" + "strings" + "testing" + + "gitea.dooplex.hu/admin/felhom-hub/internal/claim" + "gitea.dooplex.hu/admin/felhom-hub/internal/store" + "golang.org/x/crypto/bcrypt" +) + +type uiFakeMailer struct{ sends []string } + +func (f *uiFakeMailer) SendClaimEmail(kind, customerID, email, domain, code string) error { + f.sends = append(f.sends, kind) + return nil +} + +func withUIClaim(t *testing.T, s *Server, st *store.Store) (*claim.Engine, *uiFakeMailer) { + t.Helper() + m := &uiFakeMailer{} + e := &claim.Engine{Store: st, Mailer: m, Logger: log.New(io.Discard, "", 0)} + s.SetClaimEngine(e) + return e, m +} + +// The Setup tab renders the claim card in each state, and never any plaintext code (there is +// none to render — the assertion pins that no template regression invents one). +func TestCustomerPage_ClaimCardStates(t *testing.T) { + s, st := newTestServer(t) + e, _ := withUIClaim(t, s, st) + cc := &store.CustomerConfig{ + CustomerID: "acme", CustomerName: "Acme", Domain: "acme.hu", + RetrievalPassword: "pw", APIKey: "k", Status: "active", Email: "owner@acme.hu", + } + if err := st.SaveCustomerConfig(cc); err != nil { + t.Fatal(err) + } + + // No claim row yet → neutral chip, no resend form. + html := renderCustomerPage(t, s, "acme") + if !strings.Contains(html, "no code issued yet") { + t.Error("missing the no-code-issued chip") + } + if strings.Contains(html, "/configs/acme/claim-resend") { + t.Error("resend form must not render before a code exists") + } + + // Issued (unclaimed) → Nyitott chip + resend form; the bcrypt hash must NOT leak into HTML. + cs, err := e.EnsureIssued(cc) + if err != nil { + t.Fatalf("EnsureIssued: %v", err) + } + html = renderCustomerPage(t, s, "acme") + if !strings.Contains(html, "Nyitott — kód kiküldve") { + t.Error("missing the unclaimed-emailed chip") + } + if !strings.Contains(html, "/configs/acme/claim-resend") || !strings.Contains(html, "Kód újraküldése") { + t.Error("missing the resend form/button") + } + if strings.Contains(html, cs.CodeHash) { + t.Error("the code HASH leaked into the page (nothing claim-secret may render)") + } + + // Claimed → Claimed chip + the reset-flavored button label. + if err := e.MarkClaimed(cc); err != nil { + t.Fatalf("MarkClaimed: %v", err) + } + html = renderCustomerPage(t, s, "acme") + if !strings.Contains(html, ">Claimed ") { + t.Error("missing the Claimed chip") + } + if !strings.Contains(html, "Visszaállító kód küldése") { + t.Error("claimed state should offer the reset-code button") + } +} + +// The resend route rotates the code (generation++) and redirects with a flash. +func TestClaimResendRoute_RotatesAndRedirects(t *testing.T) { + s, st := newTestServer(t) + e, m := withUIClaim(t, s, st) + cc := &store.CustomerConfig{ + CustomerID: "acme", CustomerName: "Acme", Domain: "acme.hu", + RetrievalPassword: "pw", APIKey: "k", Status: "active", Email: "owner@acme.hu", + } + st.SaveCustomerConfig(cc) + if _, err := e.EnsureIssued(cc); err != nil { + t.Fatalf("EnsureIssued: %v", err) + } + before, _ := st.GetClaim("acme") + + rr := httptest.NewRecorder() + s.handleClaimResend(rr, httptest.NewRequest("POST", "/configs/acme/claim-resend", nil), "acme") + if rr.Code != 303 { + t.Fatalf("resend status = %d, want 303", rr.Code) + } + if loc := rr.Header().Get("Location"); !strings.Contains(loc, "flash=claim-resent") { + t.Fatalf("resend redirect = %q", loc) + } + after, _ := st.GetClaim("acme") + if after.Generation != before.Generation+1 { + t.Fatalf("generation %d → %d, want +1", before.Generation, after.Generation) + } + if after.CodeHash == before.CodeHash { + t.Fatal("resend did not rotate the hash") + } + if len(m.sends) != 2 { + t.Fatalf("sends = %v, want issue+resend", m.sends) + } + // Sanity: the stored value is a bcrypt hash (never a plaintext code). + if bcrypt.CompareHashAndPassword([]byte(after.CodeHash), []byte("definitely-wrong")) == nil { + t.Fatal("stored hash verified a wrong code?!") + } +} diff --git a/hub/internal/web/configs.go b/hub/internal/web/configs.go index ec823f1..d6a9a76 100644 --- a/hub/internal/web/configs.go +++ b/hub/internal/web/configs.go @@ -338,6 +338,10 @@ func (s *Server) handleCustomerUnified(w http.ResponseWriter, r *http.Request, c // the same configFormData the standalone chrome renders. Zero-valued (and never rendered) // when the customer has no config. ConfigForm configFormView + + // Claim (v0.50.0, customer-claim arc): the dashboard claim state for the Setup-tab card — + // nil when no code has been issued yet (pre-arc / never-pulled customer). + Claim *store.ClaimState } pendingSet := make(map[string]bool, len(pendingTails)) @@ -427,6 +431,11 @@ func (s *Server) handleCustomerUnified(w http.ResponseWriter, r *http.Request, c data.ConfigForm = s.configFormData(r, false, cfg, nil, "") } + // Claim state (v0.50.0) for the Setup-tab access card (nil-safe: no row → no card content). + if cs, err := s.store.GetClaim(customerID); err == nil { + data.Claim = cs + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := s.templates.ExecuteTemplate(w, "customer_unified.html", data); err != nil { s.logger.Printf("[ERROR] Template render: %v", err) @@ -615,6 +624,29 @@ func (s *Server) handleConfigUpdate(w http.ResponseWriter, r *http.Request, cust http.Redirect(w, r, "/customers/"+customerID+"?flash=updated#tab=edit", http.StatusSeeOther) } +// handleClaimResend (v0.50.0, customer-claim arc) rotates the claim/reset code and re-sends it to +// the REGISTERED customer address — the operator "Kód újraküldése" / "Visszaállító kód küldése" +// button. The old code stops verifying immediately (single active code); the fresh hash reaches +// the box on its next report ACK (no config bump needed). No plaintext is ever rendered or logged. +func (s *Server) handleClaimResend(w http.ResponseWriter, r *http.Request, customerID string) { + if s.claimEngine == nil { + http.Error(w, "Claim engine is not configured on this hub", http.StatusBadGateway) + return + } + cfg, err := s.store.GetCustomerConfig(customerID) + if err != nil || cfg == nil { + http.NotFound(w, r) + return + } + if err := s.claimEngine.Resend(cfg); err != nil { + s.logger.Printf("[ERROR] claim resend for %s: %v", customerID, err) + http.Redirect(w, r, "/customers/"+customerID+"?flash=claim-resend-failed#tab=setup", http.StatusSeeOther) + return + } + s.logger.Printf("[INFO] claim code re-sent for %s (operator resend; generation rotated)", customerID) + http.Redirect(w, r, "/customers/"+customerID+"?flash=claim-resent#tab=setup", http.StatusSeeOther) +} + // handleOffsiteReissue (F4) resets the customer's offsite credential and stores a fresh one-time password — // the explicit operator recovery for a consumed-password dead-end (fresh-guest DR, consumed-but-failed // install). Scoped to the resource labelled for THIS customer (the provisioner refuses unless exactly one). @@ -724,7 +756,10 @@ func (s *Server) handleConfigPreview(w http.ResponseWriter, r *http.Request, cus templateYAML = s.templateFetcher.Template() } - yamlOutput, err := configgen.Generate(templateYAML, cfg) + // Claim arc (v0.50.0): the preview BAKES an existing claim hash (so it matches what a box + // would pull) but never ISSUES one — issuing + emailing belongs to the real config retrieve. + claimState, _ := s.store.GetClaim(customerID) + yamlOutput, err := configgen.Generate(templateYAML, cfg, claimState) if err != nil { s.logger.Printf("[ERROR] Failed to generate preview for %s: %v", customerID, err) http.Error(w, "Generation error: "+err.Error(), http.StatusInternalServerError) diff --git a/hub/internal/web/server.go b/hub/internal/web/server.go index f8222f0..aec0e8c 100644 --- a/hub/internal/web/server.go +++ b/hub/internal/web/server.go @@ -16,6 +16,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/gitea" "gitea.dooplex.hu/admin/felhom-hub/internal/offsite" "gitea.dooplex.hu/admin/felhom-hub/internal/semver" @@ -60,6 +61,7 @@ type Server struct { gitea *gitea.Client // optional; enables the Day-0 artifact version dropdowns offsite *offsite.Provisioner // optional; enables Hetzner offsite provisioning (SLICE 1) tenantsync tenancyProvisioner // optional; enables PBS DR tier provisioning (web/pbsdr.go) + claimEngine *claim.Engine // optional; enables the customer-claim resend button (v0.50.0) sessions map[string]*hubSession sessionsMu sync.RWMutex @@ -149,6 +151,9 @@ func (s *Server) SetAssetManager(am *assets.Manager) { // offsite enabled returns an error (offsite not configured on this hub). func (s *Server) SetOffsiteProvisioner(p *offsite.Provisioner) { s.offsite = p } +// SetClaimEngine wires the customer-claim code engine for the Setup-tab resend button (v0.50.0). +func (s *Server) SetClaimEngine(e *claim.Engine) { s.claimEngine = e } + // SetGiteaClient enables the Day-0 artifact version dropdowns (optional). Without it the artifact form // degrades to manual text entry. func (s *Server) SetGiteaClient(c *gitea.Client) { @@ -418,6 +423,14 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { } else { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) } + case strings.HasPrefix(path, "/configs/") && strings.HasSuffix(path, "/claim-resend"): + customerID := strings.TrimPrefix(path, "/configs/") + customerID = strings.TrimSuffix(customerID, "/claim-resend") + if r.Method == http.MethodPost { + s.handleClaimResend(w, r, customerID) + } else { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } case strings.HasPrefix(path, "/configs/") && strings.HasSuffix(path, "/pbsdr-reissue"): customerID := strings.TrimPrefix(path, "/configs/") customerID = strings.TrimSuffix(customerID, "/pbsdr-reissue") diff --git a/hub/internal/web/templates/customer_unified.html b/hub/internal/web/templates/customer_unified.html index 0d3b0a6..3466ba1 100644 --- a/hub/internal/web/templates/customer_unified.html +++ b/hub/internal/web/templates/customer_unified.html @@ -421,6 +421,38 @@ +
+

Dashboard access — customer claim

+
+
+ Claim status +
+ {{if .Claim}} + {{if .Claim.ClaimedAt}} + Claimed {{timeAgoPtr .Claim.ClaimedAt}} + {{else if .Claim.EmailedAt}} + Nyitott — kód kiküldve {{timeAgoPtr .Claim.EmailedAt}} + {{else}} + Nyitott — a kód e-mail NEM ment ki (resend!) + {{end}} + generation {{.Claim.Generation}} · issued {{timeAgo .Claim.IssuedAt}} + {{else}} + no code issued yet + issued automatically at the first config pull or report + {{end}} +
+ The customer sets + owns the dashboard password (claim code → own password). The code goes ONLY to the registered address ({{.Email}}); the hub stores a hash — no plaintext code exists to display. +
+ {{if .Claim}} +
+ {{.CSRFField}} + +
+ {{end}} +
+
+

Setup Command