hub v0.67.0 — auto-minted self-bind link, post-RESET staleness, unprovisioned-offsite warning
Four small items, each a case where the hub already knew something and said
nothing. Green: build, vet, tests all pass.
(a) Self-bind link is minted automatically at customer creation AND at RESET
completion (R-36 sub-item). The console banner tells the customer to open
"az e-mailben kapott link"; until now that email existed only once the
operator remembered the button, so the banner could point at something that
did not exist — during the 2026-07-18 rehearsal the box waited ~11.7 min on
exactly that. handleSelfBindLinkSend's body was extracted into a shared
mintAndSendSelfBindLink core so the button and the auto-mint callers cannot
drift apart on the honesty rules: F1 (no address -> mint nothing) and F2
(send failed -> delete the token, never leave it live). The wrapper NEVER
fails the operation it rides on — a create that provisioned Cloudflare,
offsite and PBS must not 500 over a courtesy email.
Gap found and closed while wiring it: PurgeCustomerResetDBState does NOT
clear selfbind_tokens, so a link minted BEFORE a reset would have stayed
live across it. A successful mint already replaces it (delete-then-insert,
single-active); the skip paths would not have, so they now clear stale
tokens too. Invariant: 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 page describes a lifecycle that no longer
exists, and the page kept showing pre-RESET warnings as current. Narrow on
purpose: an in-flight reset does not trigger it, and it clears itself when a
report arrives. Ties resolve to STALE — SQLite timestamps are second-
resolution and a same-second report almost certainly predates the reset;
erring the other way would hide the banner exactly when it matters.
(c) Unprovisioned-offsite warning (R-36 interim). enabled==true with type=="" is
a real, stable, silent state: provisioning is Save-triggered and the
re-enroll auto-re-issue deliberately skips an unprovisioned target, so
nothing self-heals it. Reuses the exact predicate the offsite re-issue
handler already refuses on.
(d) pbsdr_reissued rendered an EMPTY flash box — the key had no template branch,
so re-issuing PBS credentials showed a success box with no words (observed
live 2026-07-18). Now describes what was staged plus the R-39 caveat:
confirm `pvesm status` shows the entry active, because a converged agent can
report `applied` while the storage still 401s.
New .flash-warn (amber, --warn tokens) for the deviation tier between success
and error — exception-color principle: only on deviation, never on a healthy
page.
Tests assert each banner is ABSENT in the nominal cases as well as present in
the deviating one — a banner that always renders is worse than none. Both
red-proofed: deleting the pbsdr_reissued branch reproduces the original empty
box; neutering the staleness predicate fails the banner assertion. New
read-only store accessor CountSelfBindTokens 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; applyPBSDR is idempotent as documented) — the real mechanism is
the agent's descriptor-hash convergence and needs its own spec.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nn3VgQk9iwEGgyx6QJ2NvE
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)<div class="flash flash-success">(.*?)</div>`)
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 <code>pvesm status</code> must show the PBS entry <strong>active</strong>. A converged agent can report <code>applied</code> 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 @@
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .StaleSinceReset}}
|
||||
<div class="flash flash-warn">
|
||||
<strong>No data since the RESET</strong> ({{.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.
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .OffsiteUnprovisioned}}
|
||||
<div class="flash flash-warn">
|
||||
<strong>Offsite is enabled but was never provisioned</strong> — no descriptor exists for
|
||||
this customer, so nothing is being backed up offsite. Provisioning is <em>Save</em>-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)
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<!-- Sticky summary strip: identity + liveness at a glance while any tab is scrolled.
|
||||
Values mirror fields already rendered in the panels — no extra handler data. -->
|
||||
<div class="summary-strip">
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user