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:
@@ -15,6 +15,7 @@ import (
|
|||||||
|
|
||||||
"gitea.dooplex.hu/admin/felhom-hub/internal/api"
|
"gitea.dooplex.hu/admin/felhom-hub/internal/api"
|
||||||
"gitea.dooplex.hu/admin/felhom-hub/internal/assets"
|
"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/gitea"
|
||||||
"gitea.dooplex.hu/admin/felhom-hub/internal/hetznerapi"
|
"gitea.dooplex.hu/admin/felhom-hub/internal/hetznerapi"
|
||||||
"gitea.dooplex.hu/admin/felhom-hub/internal/mailrelay"
|
"gitea.dooplex.hu/admin/felhom-hub/internal/mailrelay"
|
||||||
@@ -245,6 +246,13 @@ func main() {
|
|||||||
)
|
)
|
||||||
apiHandler.SetDispatcher(dispatcher)
|
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
|
// 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
|
// 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
|
// 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 := web.New(dataStore, cfg.Auth.PasswordHash, cfg.API.ReportAPIKey, Version, staleThreshold, logger)
|
||||||
webServer.SetTemplateFetcher(templateFetcher)
|
webServer.SetTemplateFetcher(templateFetcher)
|
||||||
webServer.SetAssetManager(assetsMgr)
|
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
|
// 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
|
// sha256 from Gitea (no hand-copied checksums). Reuses the registry creds; degrades to manual text
|
||||||
// entry when they're absent.
|
// entry when they're absent.
|
||||||
|
|||||||
@@ -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"
|
"time"
|
||||||
|
|
||||||
"gitea.dooplex.hu/admin/felhom-hub/internal/assets"
|
"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/configgen"
|
||||||
"gitea.dooplex.hu/admin/felhom-hub/internal/mailrelay"
|
"gitea.dooplex.hu/admin/felhom-hub/internal/mailrelay"
|
||||||
"gitea.dooplex.hu/admin/felhom-hub/internal/notify"
|
"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
|
// S1 offsite connectivity: the wgsync reconciler seam (internal/api/wg.go). nil = peer-sync
|
||||||
// disabled — mutations still persist, responses carry sync:"disabled".
|
// disabled — mutations still persist, responses carry sync:"disabled".
|
||||||
wgSyncer WGSyncer
|
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
|
// 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)
|
h.handleAdminSetOperatorPeer(w, r)
|
||||||
case r.Method == http.MethodGet && path == "/admin/wg/operator-peer":
|
case r.Method == http.MethodGet && path == "/admin/wg/operator-peer":
|
||||||
h.handleAdminGetOperatorPeer(w, r)
|
h.handleAdminGetOperatorPeer(w, r)
|
||||||
|
case r.Method == http.MethodPost && path == "/claim/reset-request":
|
||||||
|
h.handleClaimResetRequest(w, r)
|
||||||
case r.Method == http.MethodPost && path == "/event":
|
case r.Method == http.MethodPost && path == "/event":
|
||||||
h.handleEvent(w, r)
|
h.handleEvent(w, r)
|
||||||
case r.Method == http.MethodPost && path == "/mail":
|
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
|
// (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).
|
// config-managed customers (a report-only box without a config row gets no field and is unaffected).
|
||||||
resp["config_version"] = custCfg.ConfigVersion
|
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
|
// 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})
|
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.
|
// allowedEventTypes lists all valid event_type values the Hub accepts.
|
||||||
var allowedEventTypes = map[string]bool{
|
var allowedEventTypes = map[string]bool{
|
||||||
// Controller-pushed events
|
// Controller-pushed events
|
||||||
"controller_started": true,
|
"controller_started": true,
|
||||||
|
"claim_lockout": true, // v0.50.0 — claim/reset code brute-force lockout tripped
|
||||||
"controller_updated": true,
|
"controller_updated": true,
|
||||||
"backup_completed": true,
|
"backup_completed": true,
|
||||||
"backup_failed": true,
|
"backup_failed": true,
|
||||||
@@ -1696,10 +1788,16 @@ func (h *Handler) handleRecovery(w http.ResponseWriter, r *http.Request, custome
|
|||||||
return
|
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
|
var configYAML string
|
||||||
if h.templateProvider != nil {
|
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 {
|
if err != nil {
|
||||||
h.logger.Printf("[ERROR] Recovery: failed to generate config for %s: %v", customerID, err)
|
h.logger.Printf("[ERROR] Recovery: failed to generate config for %s: %v", customerID, err)
|
||||||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||||
@@ -1761,7 +1859,22 @@ func (h *Handler) handleConfigRetrieve(w http.ResponseWriter, r *http.Request, c
|
|||||||
return
|
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 {
|
if err != nil {
|
||||||
h.logger.Printf("[ERROR] Failed to generate config for %s: %v", customerID, err)
|
h.logger.Printf("[ERROR] Failed to generate config for %s: %v", customerID, err)
|
||||||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,7 +13,11 @@ import (
|
|||||||
// Generate takes the template YAML and a customer config,
|
// Generate takes the template YAML and a customer config,
|
||||||
// then produces a complete controller.yaml with customer-specific values
|
// then produces a complete controller.yaml with customer-specific values
|
||||||
// merged in. The returned string is valid YAML ready for deployment.
|
// 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
|
// Parse template into generic map
|
||||||
var base map[string]interface{}
|
var base map[string]interface{}
|
||||||
if err := yaml.Unmarshal([]byte(templateYAML), &base); err != nil {
|
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)
|
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
|
// Marshal back to YAML
|
||||||
out, err := yaml.Marshal(base)
|
out, err := yaml.Marshal(base)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ func TestGenerate_DebugLevelOverride(t *testing.T) {
|
|||||||
// Override present → the generated YAML carries level: debug, and the info default is gone.
|
// Override present → the generated YAML carries level: debug, and the info default is gone.
|
||||||
dbg, err := Generate(tmpl, &store.CustomerConfig{
|
dbg, err := Generate(tmpl, &store.CustomerConfig{
|
||||||
CustomerID: "c1", ConfigJSON: `{"logging":{"level":"debug"}}`,
|
CustomerID: "c1", ConfigJSON: `{"logging":{"level":"debug"}}`,
|
||||||
})
|
}, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("generate (debug): %v", err)
|
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.
|
// 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 {
|
if err != nil {
|
||||||
t.Fatalf("generate (default): %v", err)
|
t.Fatalf("generate (default): %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -224,3 +224,24 @@ func isEventEnabled(enabledEvents []string, eventType string) bool {
|
|||||||
}
|
}
|
||||||
return false
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ Message: %s`, customerID, eventType, severity, now, message)
|
|||||||
|
|
||||||
// customerMessages maps event_type → Hungarian customer message.
|
// customerMessages maps event_type → Hungarian customer message.
|
||||||
var customerMessages = map[string]string{
|
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 events
|
||||||
"backup_completed": "A biztonsági mentés sikeresen elkészült.",
|
"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.",
|
"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
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -459,6 +459,29 @@ func (s *Store) migrate() error {
|
|||||||
return err
|
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:
|
// 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
|
// 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
|
// 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
|
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.
|
// SetCustomerConfigStatus sets the status (active/blocked) for a customer config.
|
||||||
func (s *Store) SetCustomerConfigStatus(customerID, status string) error {
|
func (s *Store) SetCustomerConfigStatus(customerID, status string) error {
|
||||||
_, err := s.db.Exec(`
|
_, err := s.db.Exec(`
|
||||||
|
|||||||
@@ -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?!")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
// the same configFormData the standalone chrome renders. Zero-valued (and never rendered)
|
||||||
// when the customer has no config.
|
// when the customer has no config.
|
||||||
ConfigForm configFormView
|
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))
|
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, "")
|
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")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
if err := s.templates.ExecuteTemplate(w, "customer_unified.html", data); err != nil {
|
if err := s.templates.ExecuteTemplate(w, "customer_unified.html", data); err != nil {
|
||||||
s.logger.Printf("[ERROR] Template render: %v", err)
|
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)
|
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 —
|
// 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
|
// 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).
|
// 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()
|
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 {
|
if err != nil {
|
||||||
s.logger.Printf("[ERROR] Failed to generate preview for %s: %v", customerID, err)
|
s.logger.Printf("[ERROR] Failed to generate preview for %s: %v", customerID, err)
|
||||||
http.Error(w, "Generation error: "+err.Error(), http.StatusInternalServerError)
|
http.Error(w, "Generation error: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.dooplex.hu/admin/felhom-hub/internal/assets"
|
"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/gitea"
|
||||||
"gitea.dooplex.hu/admin/felhom-hub/internal/offsite"
|
"gitea.dooplex.hu/admin/felhom-hub/internal/offsite"
|
||||||
"gitea.dooplex.hu/admin/felhom-hub/internal/semver"
|
"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
|
gitea *gitea.Client // optional; enables the Day-0 artifact version dropdowns
|
||||||
offsite *offsite.Provisioner // optional; enables Hetzner offsite provisioning (SLICE 1)
|
offsite *offsite.Provisioner // optional; enables Hetzner offsite provisioning (SLICE 1)
|
||||||
tenantsync tenancyProvisioner // optional; enables PBS DR tier provisioning (web/pbsdr.go)
|
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
|
sessions map[string]*hubSession
|
||||||
sessionsMu sync.RWMutex
|
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).
|
// offsite enabled returns an error (offsite not configured on this hub).
|
||||||
func (s *Server) SetOffsiteProvisioner(p *offsite.Provisioner) { s.offsite = p }
|
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
|
// SetGiteaClient enables the Day-0 artifact version dropdowns (optional). Without it the artifact form
|
||||||
// degrades to manual text entry.
|
// degrades to manual text entry.
|
||||||
func (s *Server) SetGiteaClient(c *gitea.Client) {
|
func (s *Server) SetGiteaClient(c *gitea.Client) {
|
||||||
@@ -418,6 +423,14 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
} else {
|
} else {
|
||||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
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"):
|
case strings.HasPrefix(path, "/configs/") && strings.HasSuffix(path, "/pbsdr-reissue"):
|
||||||
customerID := strings.TrimPrefix(path, "/configs/")
|
customerID := strings.TrimPrefix(path, "/configs/")
|
||||||
customerID = strings.TrimSuffix(customerID, "/pbsdr-reissue")
|
customerID = strings.TrimSuffix(customerID, "/pbsdr-reissue")
|
||||||
|
|||||||
@@ -421,6 +421,38 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>Dashboard access — customer claim</h2>
|
||||||
|
<div class="credential-row">
|
||||||
|
<div>
|
||||||
|
<span class="label">Claim status</span>
|
||||||
|
<div style="margin: 0.3rem 0;">
|
||||||
|
{{if .Claim}}
|
||||||
|
{{if .Claim.ClaimedAt}}
|
||||||
|
<span class="badge badge-ok">Claimed {{timeAgoPtr .Claim.ClaimedAt}}</span>
|
||||||
|
{{else if .Claim.EmailedAt}}
|
||||||
|
<span class="badge badge-warn">Nyitott — kód kiküldve {{timeAgoPtr .Claim.EmailedAt}}</span>
|
||||||
|
{{else}}
|
||||||
|
<span class="badge badge-error">Nyitott — a kód e-mail NEM ment ki (resend!)</span>
|
||||||
|
{{end}}
|
||||||
|
<span class="form-hint" style="margin-left: 0.5rem;">generation {{.Claim.Generation}} · issued {{timeAgo .Claim.IssuedAt}}</span>
|
||||||
|
{{else}}
|
||||||
|
<span class="badge badge-neutral">no code issued yet</span>
|
||||||
|
<span class="form-hint" style="margin-left: 0.5rem;">issued automatically at the first config pull or report</span>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
<span class="form-hint">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.</span>
|
||||||
|
</div>
|
||||||
|
{{if .Claim}}
|
||||||
|
<form method="POST" action="/configs/{{.CustomerID}}/claim-resend" style="margin-top: 0.5rem;"
|
||||||
|
onsubmit="return confirm('Send a fresh code to the registered address? The previous code stops working immediately.')">
|
||||||
|
{{.CSRFField}}
|
||||||
|
<button type="submit" class="btn btn-outline btn-sm">{{if .Claim.ClaimedAt}}Visszaállító kód küldése{{else}}Kód újraküldése{{end}}</button>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<h2>Setup Command</h2>
|
<h2>Setup Command</h2>
|
||||||
<p class="text-muted" style="margin-bottom: 1rem; font-size: 0.85rem;">
|
<p class="text-muted" style="margin-bottom: 1rem; font-size: 0.85rem;">
|
||||||
|
|||||||
Reference in New Issue
Block a user