diff --git a/hub/CHANGELOG.md b/hub/CHANGELOG.md index e80b266..0365148 100644 --- a/hub/CHANGELOG.md +++ b/hub/CHANGELOG.md @@ -1,5 +1,67 @@ # Felhom Hub — Changelog +## v0.67.0 — the hub stops keeping things to itself: auto-minted self-bind link, post-RESET staleness, unprovisioned-offsite warning (2026-07-18) + +Four small items, each one a case where the hub already knew something and said nothing. Green: +`go build ./... && go vet ./... && go test ./...` all pass. + +- **(a) The self-bind link is minted automatically — at customer creation AND at RESET completion** + (R-36 sub-item). The box's console banner tells the customer to open „az e-mailben kapott link"; + until now that email existed only once the operator remembered to press *Send self-bind link*, so + the banner could be instructing someone to look for something that did not exist. During the + 2026-07-18 rehearsal the box sat in pairing mode for ~11.7 minutes waiting on exactly that. + `handleSelfBindLinkSend`'s body was extracted into a shared `mintAndSendSelfBindLink` core so the + button and the two auto-mint call sites **cannot drift apart on the honesty rules**: F1 (no + registered address → mint nothing, because a link nobody can receive is worse than none) and F2 + (send failed → delete the token, never leave it silently live). The auto-mint wrapper **never + fails the operation it rides on** — a customer create that provisioned Cloudflare, offsite and PBS + must not 500 because a courtesy email bounced; every outcome is logged instead, and the operator + can still re-send from the Setup tab. + **Gap found and closed while wiring this:** `PurgeCustomerResetDBState` does **not** clear + `selfbind_tokens`, so a capability link minted *before* a RESET would have stayed live across it. + A successful mint already replaces it (minting is delete-then-insert, single-active), but the skip + paths would not have — so the wrapper now clears stale tokens on those paths too. The invariant is + now: after auto-mint runs, the only live link is one it just issued, or none. + +- **(b) Post-RESET staleness banner** (R-37). When a RESET **completed** after the newest report, + every health figure on the customer page describes a lifecycle that no longer exists — and the + page went on showing pre-RESET warnings as if they were current. It now says so, quoting the + customer-facing phrasing („RESET óta nincs adat") and the reset's timestamp. Deliberately narrow: + an in-flight reset does **not** trigger it (only a completed one), and it clears itself the moment + a report arrives. Ties resolve to *stale* — SQLite timestamps are second-resolution, and a + same-second report almost certainly arrived just before the reset destroyed what it describes; + erring the other way would hide the banner exactly when it matters most. + +- **(c) Unprovisioned-offsite warning** (R-36 interim). `offsite.enabled == true` with no + descriptor (`type == ""`) is a real, stable, silent state: provisioning is *Save*-triggered + (`applyOffsite`), and the re-enroll auto-re-issue deliberately skips an unprovisioned target, so + nothing self-heals it. The page now names the state and the fix (press Save once, then verify), + reusing the exact predicate the offsite re-issue handler already refuses on. + +- **(d) `pbsdr_reissued` rendered an EMPTY flash box.** The flash key had no branch in the template, + so re-issuing PBS credentials showed the operator a success box containing nothing — observed live + on 2026-07-18. It now describes what was staged **and** carries the R-39 caveat: confirm + `pvesm status` shows the entry *active*, because a converged agent can report `applied` while the + storage still authenticates 401. + +- **Styling:** new `.flash-warn` (amber, `--warn`/`--warn-dim` tokens) for the deviation tier between + success and error — per the exception-color principle it appears ONLY on deviation, never on a + healthy page. + +- **Tests** (`customer_state_banners_test.go`, `selfbind_automint_test.go`) assert each banner is + **absent** in the nominal cases as well as present in the deviating one — a banner that renders + unconditionally is worse than none, because operators stop reading it. Auto-mint covers F1, F2, + the no-mailer case, and the pre-RESET-token invariant. **Both red-proofed:** deleting the + `pbsdr_reissued` branch reproduces the original empty-box bug, and neutering the staleness + predicate fails the banner assertion. New store accessor `CountSelfBindTokens` (read-only, keyed + by a customer id the operator already knows) makes the single-active invariant assertable. + +**Not in this train:** the R-39 hub-side generation-bump fix the pre-travel task made conditional. +Its condition was **refuted** — `SetHostDesired` bumps unconditionally and `applyPBSDR` is +idempotent as documented; the real mechanism is the agent's descriptor-hash convergence, which needs +its own spec. Nothing was improvised here. + + ## v0.66.0 — Customer self-bind (R-27 slice 1): tokenized capability link + public two-factor `/bind/` page (2026-07-17) Lets a customer bind their OWN freshly-installed appliance without the operator. Until now every diff --git a/hub/internal/store/selfbind.go b/hub/internal/store/selfbind.go index e31b918..255fa71 100644 --- a/hub/internal/store/selfbind.go +++ b/hub/internal/store/selfbind.go @@ -103,6 +103,17 @@ func (s *Store) DeleteSelfBindTokens(customerID string) error { return err } +// CountSelfBindTokens reports how many capability tokens exist for a customer (v0.67.0). Minting is +// single-active (delete-then-insert), so this is 0 or 1 in practice; it exists so callers can assert +// the "after this runs, the only live link is one we just issued — or none" invariant that the +// auto-mint at customer-create / RESET-completion depends on. Read-only, no oracle risk: it is keyed +// by customer id, which the operator already knows. +func (s *Store) CountSelfBindTokens(customerID string) (int, error) { + var n int + err := s.db.QueryRow(`SELECT COUNT(*) FROM selfbind_tokens WHERE customer_id = ?`, customerID).Scan(&n) + return n, err +} + // SelfBindTokenByHash resolves sha256(token) to its row, or (nil, nil) when unknown — the public GET // maps that to the same generic "invalid link" as an expired one: no oracle for "was this ever real". func (s *Store) SelfBindTokenByHash(tokenHash string) (*SelfBindToken, error) { diff --git a/hub/internal/web/configs.go b/hub/internal/web/configs.go index d20c7b3..d7bda3d 100644 --- a/hub/internal/web/configs.go +++ b/hub/internal/web/configs.go @@ -341,6 +341,18 @@ func (s *Server) handleCustomerUnified(w http.ResponseWriter, r *http.Request, c // 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 + + // StaleSinceReset (v0.67.0, R-37): a RESET completed AFTER the newest report, so every + // health number on this page describes a lifecycle that no longer exists. Without this the + // page keeps showing pre-RESET warnings as if they were current. + StaleSinceReset bool + ResetAt string + + // OffsiteUnprovisioned (v0.67.0, R-36 interim): the customer's config says offsite is + // ENABLED, but no descriptor was ever provisioned (type == ""). Provisioning is + // Save-triggered (applyOffsite), and the re-enroll auto-re-issue deliberately skips an + // unprovisioned target — so this state is stable and silent until someone presses Save. + OffsiteUnprovisioned bool } pendingSet := make(map[string]bool, len(pendingTails)) @@ -369,6 +381,35 @@ func (s *Server) handleCustomerUnified(w http.ResponseWriter, r *http.Request, c drUpdated = rec.UpdatedAt } + // R-37 (v0.67.0): is every health figure on this page pre-RESET? True only when a reset actually + // COMPLETED and no report has arrived since. A reset still running, or one older than the newest + // report, leaves the page exactly as it was. + var staleSinceReset bool + var resetAt string + if cr, err := s.store.LatestCustomerReset(customerID); err != nil { + s.logger.Printf("[WARN] LatestCustomerReset %s: %v", customerID, err) + } else if cr != nil && cr.CompletedAt != nil { + resetAt = cr.CompletedAt.Format("2006-01-02 15:04 MST") + // Stale unless a report is STRICTLY newer than the reset. SQLite timestamps are + // second-resolution, so a report and a reset can tie; a tie resolves to STALE because a + // same-second report almost certainly arrived just before the reset destroyed the state it + // describes. Erring the other way would hide the banner exactly when it matters most. + staleSinceReset = customer == nil || !customer.ReceivedAt.After(*cr.CompletedAt) + } + + // R-36 interim (v0.67.0): enabled-but-unprovisioned is a real, stable state — the same predicate + // the offsite re-issue handler already uses to refuse ("No provisioned offsite tier"). + var offsiteView struct { + Offsite struct { + Enabled bool `json:"enabled"` + Type string `json:"type"` + } `json:"offsite"` + } + if cfg != nil { + _ = json.Unmarshal([]byte(cfg.ConfigJSON), &offsiteView) + } + offsiteUnprovisioned := offsiteView.Offsite.Enabled && offsiteView.Offsite.Type == "" + data := pageData{ CustomerID: customerID, CustomerName: name, @@ -386,6 +427,10 @@ func (s *Server) handleCustomerUnified(w http.ResponseWriter, r *http.Request, c OverallStatus: overallStatus, HostCause: hostCause, + StaleSinceReset: staleSinceReset, + ResetAt: resetAt, + OffsiteUnprovisioned: offsiteUnprovisioned, + LatestVersion: latestVersion, UpdateAvailable: updateAvailable, ControllerURL: controllerURL, @@ -561,6 +606,10 @@ func (s *Server) handleConfigCreate(w http.ResponseWriter, r *http.Request) { } s.logger.Printf("[INFO] Customer config created: %s", customerID) + // v0.67.0 (R-36 sub-item): mint the self-bind link NOW, not when the operator remembers. The + // box's console banner tells the customer to open „az e-mailben kapott link" — that email should + // already exist by the time anyone reads the banner. Never fails the create (see autoMint…). + s.autoMintSelfBindLink(customerID, cfg.Email, "customer creation") s.bumpIntent(customerID) // Direction-2: wake a long-polling box in seconds http.Redirect(w, r, "/customers/"+customerID+"?flash=created", http.StatusSeeOther) } diff --git a/hub/internal/web/customer_reset.go b/hub/internal/web/customer_reset.go index 77ebdb1..0e179ac 100644 --- a/hub/internal/web/customer_reset.go +++ b/hub/internal/web/customer_reset.go @@ -223,6 +223,11 @@ func (s *Server) handleCustomerReset(w http.ResponseWriter, r *http.Request, cus } s.logger.Printf("[INFO] customer RESET complete for %s (journal #%d) — identity + basic config retained", customerID, resetID) + // v0.67.0 (R-36 sub-item): a RESET customer is about to be re-onboarded, and RESET cleared the + // claim state, so the very next thing that happens is a box asking to be bound. Mint the link now + // so the console banner's promised email is already true. Placed AFTER the DB purge on purpose — + // PurgeCustomerResetDBState would otherwise sweep the token we just minted. Never fails the reset. + s.autoMintSelfBindLink(customerID, cfg.Email, "RESET completion") s.bumpIntent(customerID) // wake any holding wait so a lingering box sees the cleared state promptly http.Redirect(w, r, "/customers/"+customerID+"?flash=reset_done", http.StatusSeeOther) } diff --git a/hub/internal/web/customer_state_banners_test.go b/hub/internal/web/customer_state_banners_test.go new file mode 100644 index 0000000..8127637 --- /dev/null +++ b/hub/internal/web/customer_state_banners_test.go @@ -0,0 +1,197 @@ +package web + +// v0.67.0 — the two "the hub knows but never said so" banners on the customer page, plus the +// self-bind auto-mint. Each test asserts the banner is ABSENT in the nominal case as well as +// present in the deviating one: a banner that renders unconditionally is worse than none, because +// operators stop reading it. + +import ( + "net/http/httptest" + "regexp" + "strings" + "testing" + "time" + + "gitea.dooplex.hu/admin/felhom-hub/internal/store" +) + +const staleBannerMarker = "No data since the RESET" +const offsiteBannerMarker = "Offsite is enabled but was never provisioned" + +func contains(html, needle string) bool { return strings.Contains(html, needle) } + +// renderCustomerPageWithQuery renders the page with a query string (the flash arrives that way). +func renderCustomerPageWithQuery(t *testing.T, s *Server, customerID, query string) string { + t.Helper() + rr := httptest.NewRecorder() + s.handleCustomerUnified(rr, httptest.NewRequest("GET", "/customers/"+customerID+query, nil), customerID) + if rr.Code != 200 { + t.Fatalf("customer page status = %d", rr.Code) + } + return rr.Body.String() +} + +// flashSuccessBox captures the success flash div's inner content so a branch-less flash key +// (which renders the div with nothing in it) can be told apart from one that has text. +var flashSuccessBox = regexp.MustCompile(`(?s)
(.*?)
`) + +func flashBoxIsEmpty(html string) bool { + m := flashSuccessBox.FindStringSubmatch(html) + if m == nil { + return true + } + return strings.TrimSpace(m[1]) == "" +} + +func seedCustomer(t *testing.T, st *store.Store, id, configJSON string) { + t.Helper() + cfg := &store.CustomerConfig{ + CustomerID: id, CustomerName: id, Domain: id + ".hu", + RetrievalPassword: "pw", APIKey: "k", Status: "active", + Email: id + "@example.com", + } + if configJSON != "" { + cfg.ConfigJSON = configJSON + } + if err := st.SaveCustomerConfig(cfg); err != nil { + t.Fatal(err) + } +} + +// --- R-37: post-RESET staleness ----------------------------------------------------------------- + +func TestCustomerPage_StaleSinceReset(t *testing.T) { + t.Run("reset newer than the last report → banner", func(t *testing.T) { + s, st := newTestServer(t) + seedCustomer(t, st, "acme", "") + if err := st.SaveReport("acme", []byte(tabsTestReportJSON)); err != nil { + t.Fatal(err) + } + // A reset that STARTED and COMPLETED after that report. + id, err := st.StartCustomerReset("acme", true) + if err != nil { + t.Fatal(err) + } + if err := st.FinishCustomerReset(id); err != nil { + t.Fatal(err) + } + + html := renderCustomerPage(t, s, "acme") + if !contains(html, staleBannerMarker) { + t.Error("a completed RESET newer than the newest report must raise the staleness banner — " + + "every health figure on the page describes a lifecycle that no longer exists") + } + // The Hungarian phrasing the customer-facing surface will use is quoted for the operator. + if !contains(html, "RESET óta nincs adat") { + t.Error("banner should quote „RESET óta nincs adat\"") + } + }) + + t.Run("no reset at all → no banner", func(t *testing.T) { + s, st := newTestServer(t) + seedCustomer(t, st, "acme", "") + if err := st.SaveReport("acme", []byte(tabsTestReportJSON)); err != nil { + t.Fatal(err) + } + if html := renderCustomerPage(t, s, "acme"); contains(html, staleBannerMarker) { + t.Error("a customer that was never reset must not show the staleness banner") + } + }) + + t.Run("reset still running (never completed) → no banner", func(t *testing.T) { + s, st := newTestServer(t) + seedCustomer(t, st, "acme", "") + if err := st.SaveReport("acme", []byte(tabsTestReportJSON)); err != nil { + t.Fatal(err) + } + if _, err := st.StartCustomerReset("acme", true); err != nil { // started, NOT finished + t.Fatal(err) + } + if html := renderCustomerPage(t, s, "acme"); contains(html, staleBannerMarker) { + t.Error("an in-flight reset must not claim the data is stale — only a COMPLETED one does") + } + }) + + t.Run("report arrived after the reset → banner clears", func(t *testing.T) { + s, st := newTestServer(t) + seedCustomer(t, st, "acme", "") + id, err := st.StartCustomerReset("acme", true) + if err != nil { + t.Fatal(err) + } + if err := st.FinishCustomerReset(id); err != nil { + t.Fatal(err) + } + // SQLite second-resolution timestamps: make the report unambiguously later. + time.Sleep(1100 * time.Millisecond) + if err := st.SaveReport("acme", []byte(tabsTestReportJSON)); err != nil { + t.Fatal(err) + } + if html := renderCustomerPage(t, s, "acme"); contains(html, staleBannerMarker) { + t.Error("once the box reports again the page is current — the banner must clear itself, " + + "otherwise it becomes permanent furniture") + } + }) +} + +// --- R-36 interim: offsite enabled but never provisioned ---------------------------------------- + +func TestCustomerPage_OffsiteUnprovisioned(t *testing.T) { + cases := []struct { + name string + configJSON string + wantBanner bool + }{ + { + name: "enabled with no type → the silent state, banner", + configJSON: `{"offsite":{"enabled":true}}`, + wantBanner: true, + }, + { + name: "enabled and provisioned → no banner", + configJSON: `{"offsite":{"enabled":true,"type":"storagebox"}}`, + wantBanner: false, + }, + { + name: "disabled → no banner (nothing was promised)", + configJSON: `{"offsite":{"enabled":false}}`, + wantBanner: false, + }, + { + name: "no offsite key at all → no banner", + configJSON: `{}`, + wantBanner: false, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + s, st := newTestServer(t) + seedCustomer(t, st, "acme", tc.configJSON) + html := renderCustomerPage(t, s, "acme") + if got := contains(html, offsiteBannerMarker); got != tc.wantBanner { + t.Errorf("banner present = %v, want %v (config %s)", got, tc.wantBanner, tc.configJSON) + } + }) + } +} + +// --- the pbsdr_reissued flash had no text at all (rendered an empty box) ------------------------- + +func TestCustomerPage_EveryRedirectFlashRendersText(t *testing.T) { + // Every flash key the server can redirect with must produce visible text. pbsdr_reissued + // shipped without a branch, so the operator got an empty success box after re-issuing PBS + // credentials — observed live 2026-07-18. + for _, flash := range []string{"pbsdr_reissued", "offsite_reissued", "reset_done", "created"} { + t.Run(flash, func(t *testing.T) { + s, st := newTestServer(t) + seedCustomer(t, st, "acme", "") + html := renderCustomerPageWithQuery(t, s, "acme", "?flash="+flash) + if !contains(html, `class="flash flash-success"`) { + t.Fatalf("flash %q rendered no success box at all", flash) + } + if flashBoxIsEmpty(html) { + t.Errorf("flash %q renders an EMPTY box — the operator is shown a confirmation with no words", flash) + } + }) + } +} diff --git a/hub/internal/web/selfbind_automint_test.go b/hub/internal/web/selfbind_automint_test.go new file mode 100644 index 0000000..4c909ed --- /dev/null +++ b/hub/internal/web/selfbind_automint_test.go @@ -0,0 +1,112 @@ +package web + +// v0.67.0 (R-36 sub-item) — the self-bind link is minted automatically at customer creation and at +// RESET completion, so the box's console banner („nyisd meg az e-mailben kapott link") is already +// true when the customer first reads it, instead of true-once-the-operator-remembers. +// +// The load-bearing property is that auto-minting NEVER fails the operation it rides on: a customer +// create that provisioned Cloudflare, offsite and PBS must not 500 because a courtesy email +// bounced. + +import ( + "testing" + + "gitea.dooplex.hu/admin/felhom-hub/internal/store" +) + +func seedForMint(t *testing.T, st *store.Store, id, email string) { + t.Helper() + if err := st.SaveCustomerConfig(&store.CustomerConfig{ + CustomerID: id, CustomerName: id, Domain: id + ".hu", + RetrievalPassword: "pw", APIKey: "k", Status: "active", Email: email, + }); err != nil { + t.Fatal(err) + } +} + +func liveTokenCount(t *testing.T, st *store.Store, customerID string) int { + t.Helper() + n, err := st.CountSelfBindTokens(customerID) + if err != nil { + t.Fatalf("count self-bind tokens: %v", err) + } + return n +} + +func TestAutoMintSelfBindLink(t *testing.T) { + t.Run("mints and sends when an email is registered", func(t *testing.T) { + s, st := newTestServer(t) + m := &stubMailer{} + s.SetSelfBindMailer(m) + seedForMint(t, st, "acme", "ops@acme.hu") + + s.autoMintSelfBindLink("acme", "ops@acme.hu", "customer creation") + + if m.link == "" { + t.Fatal("no self-bind link was sent") + } + if liveTokenCount(t, st, "acme") != 1 { + t.Error("expected exactly one live capability token after auto-mint") + } + }) + + t.Run("no registered email → nothing minted (F1 honoured)", func(t *testing.T) { + s, st := newTestServer(t) + m := &stubMailer{} + s.SetSelfBindMailer(m) + seedForMint(t, st, "acme", "") + + s.autoMintSelfBindLink("acme", "", "customer creation") + + if m.link != "" { + t.Error("a link was sent for a customer with no registered address") + } + if liveTokenCount(t, st, "acme") != 0 { + t.Error("a token was minted that nobody could ever receive — F1 says mint nothing") + } + }) + + t.Run("send failure invalidates the token (F2 honoured)", func(t *testing.T) { + s, st := newTestServer(t) + s.SetSelfBindMailer(&stubMailer{fail: true}) + seedForMint(t, st, "acme", "ops@acme.hu") + + s.autoMintSelfBindLink("acme", "ops@acme.hu", "customer creation") + + if liveTokenCount(t, st, "acme") != 0 { + t.Error("send failed but a live capability token was left behind — F2 says delete it") + } + }) + + t.Run("RESET does not leave a pre-RESET link live", func(t *testing.T) { + // PurgeCustomerResetDBState does NOT clear selfbind_tokens, so a link minted before a reset + // would otherwise survive it. Auto-mint must guarantee that afterwards the only live token + // is one it just issued — or none. + s, st := newTestServer(t) + s.SetSelfBindMailer(&stubMailer{}) + seedForMint(t, st, "acme", "ops@acme.hu") + + // A link exists from before the reset. + s.autoMintSelfBindLink("acme", "ops@acme.hu", "customer creation") + if liveTokenCount(t, st, "acme") != 1 { + t.Fatal("setup: expected a pre-reset token") + } + + // Now the customer loses their address and a RESET happens: the skip path must still clear it. + s.autoMintSelfBindLink("acme", "", "RESET completion") + if liveTokenCount(t, st, "acme") != 0 { + t.Error("a capability link minted BEFORE the reset is still live after it") + } + }) + + t.Run("no mailer configured → no token, no panic", func(t *testing.T) { + s, st := newTestServer(t) // SetSelfBindMailer never called + seedForMint(t, st, "acme", "ops@acme.hu") + + s.autoMintSelfBindLink("acme", "ops@acme.hu", "customer creation") + + if liveTokenCount(t, st, "acme") != 0 { + t.Error("a token was minted with no mailer to deliver it") + } + }) +} diff --git a/hub/internal/web/selfbind_mint.go b/hub/internal/web/selfbind_mint.go index 26acccf..eab388e 100644 --- a/hub/internal/web/selfbind_mint.go +++ b/hub/internal/web/selfbind_mint.go @@ -37,6 +37,98 @@ func selfBindHash(token string) string { return hex.EncodeToString(sum[:]) } +// selfBindOutcome is what mintAndSendSelfBindLink did, so each caller can decide how loud to be. +// The operator BUTTON turns these into flashes; the AUTO-MINT callers (create / RESET completion) +// only log, because neither may fail an otherwise-successful operation over a courtesy email. +type selfBindOutcome int + +const ( + selfBindSent selfBindOutcome = iota // minted + emailed + selfBindSkippedNoMailer // no mailer wired on this hub + selfBindSkippedNoEmail // F1: customer has no registered address + selfBindSendFailed // F2: send failed, token invalidated + selfBindMintFailed // token generation or DB write failed +) + +// mintAndSendSelfBindLink is the shared mint+send core (v0.67.0). It was extracted from +// handleSelfBindLinkSend so the auto-mint callers reuse the SAME honesty rules rather than +// re-implementing them: +// +// - F1: no registered email → nothing is minted (a link nobody can receive is worse than none). +// - F2: send failed → the just-minted token is DELETED, never left silently live. +// +// The plaintext token exists only between minting and the send: never logged (only an 8-char hash +// prefix), never persisted (only its sha256). Callers get the outcome and the underlying error; +// nothing here writes an HTTP response, which is what makes it reusable off the request path. +func (s *Server) mintAndSendSelfBindLink(customerID, email string) (selfBindOutcome, error) { + if s.selfBindMailer == nil { + return selfBindSkippedNoMailer, nil + } + if email == "" { + return selfBindSkippedNoEmail, nil + } + + token, err := configgen.RandomHex(32) // 256-bit capability token + if err != nil { + return selfBindMintFailed, err + } + hash := selfBindHash(token) + if err := s.store.MintSelfBindToken(customerID, hash, selfBindTTL); err != nil { + return selfBindMintFailed, err + } + + link := selfBindBaseURL + "/bind/" + token + if err := s.selfBindMailer.SendSelfBindEmail(customerID, email, link); err != nil { + if derr := s.store.DeleteSelfBindTokens(customerID); derr != nil { + s.logger.Printf("[ERROR] self-bind link for %s: send failed AND cleanup failed: send=%v cleanup=%v", customerID, err, derr) + } else { + s.logger.Printf("[ERROR] self-bind link for %s: email send failed, token invalidated (hash %s…): %v", customerID, hash[:8], err) + } + return selfBindSendFailed, err + } + if err := s.store.MarkSelfBindEmailed(hash); err != nil { + s.logger.Printf("[WARN] self-bind link sent to %s but emailed_at not recorded: %v", customerID, err) + } + s.logger.Printf("[INFO] self-bind link (hash %s…, valid 7 days) emailed to the registered address of %s", hash[:8], customerID) + return selfBindSent, nil +} + +// autoMintSelfBindLink is the fire-and-log wrapper used at customer creation and at RESET +// completion (v0.67.0, R-36 sub-item). The box's console banner tells the customer to open „az +// e-mailben kapott link", so that email should already exist by the time anyone reads the banner — +// previously it existed only once the operator remembered to press the button. +// +// It NEVER fails the caller's operation: a customer create that provisioned Cloudflare, offsite and +// PBS successfully must not 500 because a courtesy email bounced. Every outcome is logged; the +// operator can always re-send from the Setup tab. +func (s *Server) autoMintSelfBindLink(customerID, email, occasion string) { + outcome, err := s.mintAndSendSelfBindLink(customerID, email) + + // Invariant both call sites need: once this returns, the only live capability token for this + // customer is one we just minted — or none at all. It matters on the RESET path, because + // PurgeCustomerResetDBState does NOT clear selfbind_tokens, so a link minted BEFORE the reset + // would otherwise stay live across it. A successful mint already replaces it (MintSelfBindToken + // deletes-then-inserts, single-active); the skip paths are the ones that would leave it behind. + if outcome == selfBindSkippedNoMailer || outcome == selfBindSkippedNoEmail { + if derr := s.store.DeleteSelfBindTokens(customerID); derr != nil { + s.logger.Printf("[WARN] self-bind: could not clear stale tokens for %s on %s: %v", customerID, occasion, derr) + } + } + + switch outcome { + case selfBindSent: + s.logger.Printf("[INFO] self-bind link auto-minted for %s on %s (the console banner's promised email now exists)", customerID, occasion) + case selfBindSkippedNoMailer: + s.logger.Printf("[INFO] self-bind link NOT auto-minted for %s on %s: no mailer configured on this hub", customerID, occasion) + case selfBindSkippedNoEmail: + s.logger.Printf("[WARN] self-bind link NOT auto-minted for %s on %s: no registered email address (F1) — set one, then send from the Setup tab", customerID, occasion) + case selfBindSendFailed: + s.logger.Printf("[WARN] self-bind link auto-mint for %s on %s FAILED to send; token invalidated — re-send from the Setup tab: %v", customerID, occasion, err) + case selfBindMintFailed: + s.logger.Printf("[ERROR] self-bind link auto-mint for %s on %s failed to mint: %v", customerID, occasion, err) + } +} + // handleSelfBindLinkSend — POST /customers/{id}/selfbind-link. Mints a single-active capability token // for the customer and emails the public bind link. Honesty rules: // - F1: no registered email → nothing is minted, LOUD flash (a link no one can receive is useless). @@ -54,41 +146,18 @@ func (s *Server) handleSelfBindLinkSend(w http.ResponseWriter, r *http.Request, http.NotFound(w, r) return } - // F1: refuse to mint a link that cannot be delivered. - if cfg.Email == "" { + // The honesty rules (F1/F2) live in the shared core so the button and the auto-mint callers + // cannot drift apart; the button's job is only to turn the outcome into an operator-visible flash. + switch outcome, err := s.mintAndSendSelfBindLink(customerID, cfg.Email); outcome { + case selfBindSkippedNoEmail: s.logger.Printf("[WARN] self-bind link for %s NOT sent: customer has no registered email", customerID) http.Redirect(w, r, "/customers/"+customerID+"?flash=selfbind-no-email#tab=setup", http.StatusSeeOther) - return - } - - token, err := configgen.RandomHex(32) // 256-bit capability token - if err != nil { - s.logger.Printf("[ERROR] self-bind link for %s: token generation: %v", customerID, err) - http.Error(w, "Internal error", http.StatusInternalServerError) - return - } - hash := selfBindHash(token) - if err := s.store.MintSelfBindToken(customerID, hash, selfBindTTL); err != nil { - s.logger.Printf("[ERROR] self-bind link for %s: minting token: %v", customerID, err) - http.Error(w, "Internal error", http.StatusInternalServerError) - return - } - - link := selfBindBaseURL + "/bind/" + token - if err := s.selfBindMailer.SendSelfBindEmail(customerID, cfg.Email, link); err != nil { - // F2: delivery failed — do not leave a live capability token behind (the plaintext link is - // already gone from memory, so nobody could ever use it; delete it and surface the failure). - if derr := s.store.DeleteSelfBindTokens(customerID); derr != nil { - s.logger.Printf("[ERROR] self-bind link for %s: send failed AND cleanup failed: send=%v cleanup=%v", customerID, err, derr) - } else { - s.logger.Printf("[ERROR] self-bind link for %s: email send failed, token invalidated (hash %s…): %v", customerID, hash[:8], err) - } + case selfBindSendFailed: http.Redirect(w, r, "/customers/"+customerID+"?flash=selfbind-send-failed#tab=setup", http.StatusSeeOther) - return + case selfBindMintFailed: + s.logger.Printf("[ERROR] self-bind link for %s: %v", customerID, err) + http.Error(w, "Internal error", http.StatusInternalServerError) + default: // selfBindSent (the no-mailer case was refused above) + http.Redirect(w, r, "/customers/"+customerID+"?flash=selfbind-sent#tab=setup", http.StatusSeeOther) } - if err := s.store.MarkSelfBindEmailed(hash); err != nil { - s.logger.Printf("[WARN] self-bind link sent to %s but emailed_at not recorded: %v", customerID, err) - } - s.logger.Printf("[INFO] self-bind link (hash %s…, valid 7 days) emailed to the registered address of %s", hash[:8], customerID) - http.Redirect(w, r, "/customers/"+customerID+"?flash=selfbind-sent#tab=setup", http.StatusSeeOther) } diff --git a/hub/internal/web/templates/customer_unified.html b/hub/internal/web/templates/customer_unified.html index fd1d38d..5726121 100644 --- a/hub/internal/web/templates/customer_unified.html +++ b/hub/internal/web/templates/customer_unified.html @@ -48,6 +48,7 @@ {{else if eq .Flash "updated"}}Configuration updated. {{else if eq .Flash "password_regenerated"}}Retrieval password regenerated. {{else if eq .Flash "offsite_reissued"}}Offsite credentials re-issued — a fresh one-time password is staged; the controller picks it up on its next config refresh. + {{else if eq .Flash "pbsdr_reissued"}}PBS DR credentials re-issued — a fresh one-time token secret is staged for the agent. Confirm it actually landed: the host's pvesm status must show the PBS entry active. A converged agent can report applied while the storage still authenticates 401 (R-39). {{else if eq .Flash "offsite_frozen"}}Offsite storage FROZEN (read-only) — new backups and prune will fail until unfrozen. {{else if eq .Flash "offsite_unfrozen"}}Offsite storage unfrozen — read-write restored. {{else if eq .Flash "blocked"}}Customer blocked — hidden from Dashboard. @@ -69,6 +70,24 @@ {{end}} + {{if .StaleSinceReset}} +
+ No data since the RESET ({{.ResetAt}}) — „RESET óta nincs adat". + Every health figure below predates it and describes a lifecycle that no longer exists. + The box repopulates this page on its first report after re-onboarding. +
+ {{end}} + + {{if .OffsiteUnprovisioned}} +
+ Offsite is enabled but was never provisioned — no descriptor exists for + this customer, so nothing is being backed up offsite. Provisioning is Save-triggered: + open the Edit tab and press Save once to provision it, then verify on the Offsite page. + Re-enrollment will not fix this on its own — the auto-re-issue deliberately skips an + unprovisioned target. (R-36) +
+ {{end}} +
diff --git a/hub/internal/web/templates/style.css b/hub/internal/web/templates/style.css index f7aba9c..a4f5aa6 100644 --- a/hub/internal/web/templates/style.css +++ b/hub/internal/web/templates/style.css @@ -677,6 +677,21 @@ code { border: 1px solid rgba(229,83,75,.3); } +/* v0.67.0 — the deviation tier between success and error: a state that is not a failure but is + also not nominal, and that the operator must act on (post-RESET staleness R-37, enabled-but- + unprovisioned offsite R-36). Amber per the exception-color principle: it appears ONLY on + deviation, never on a healthy page. */ +.flash-warn { + background: var(--warn-dim); + color: var(--warn); + border: 1px solid rgba(224,169,62,.3); +} +.flash-warn strong { color: var(--text-1); } +.flash-warn code { + font-family: var(--font-data); + font-size: .85em; +} + /* YAML Preview */ .yaml-preview { background: var(--bg-0);