diff --git a/hub/CHANGELOG.md b/hub/CHANGELOG.md index c3428f0d..2e5e09ee 100644 --- a/hub/CHANGELOG.md +++ b/hub/CHANGELOG.md @@ -1,3 +1,12 @@ +## v0.125.0 — the customer delete stops promising a Cloudflare removal it never did (2026-09-25, R-688) + +- **Customer delete dialog** (`web/customer_delete.go`, `templates/customer_unified.html`): no leg of the cascade + calls Cloudflare, yet the dialog and the danger-zone text said "tunnel and zone are removed". Both now say + **Cloudflare is not touched**, and the preview (`GET /configs/{id}/delete`) carries `cloudflare_manual` — what the + operator removes by hand, by the customer's domain: the tunnel that serves it, and its DNS records (apex and + wildcard). Names only; the tunnel token never appears (tested). The Cloudflare leg itself is NOT built. +- Test `TestR688_PreviewNamesCloudflareByHand`, red-proofed (drop the field → it fails). + ## v0.124.0 — a thin pool is critical at 90 %, one alarm per pool per 6 hours (2026-09-24, R-672) - **Storage fill, thin pools** (`monitor/storage_fill.go`): an `lvmthin` target is judged on the WORSE of data diff --git a/hub/internal/web/customer_delete.go b/hub/internal/web/customer_delete.go index 20582bd2..1d20e797 100644 --- a/hub/internal/web/customer_delete.go +++ b/hub/internal/web/customer_delete.go @@ -38,13 +38,15 @@ import ( "strconv" "strings" "time" + + "gitea.dooplex.hu/admin/felhom-hub/internal/store" ) // deleteCascadeAcks is the three-acknowledgement gate. Every one is REQUIRED — there is no force or // skip flag anywhere in this file (a missing ack is a refusal, never a downgrade to a partial run). type deleteCascadeAcks struct { Hosts bool // "N host(s) will be deleted — recovery-key custody is demoted, not destroyed" - Reset bool // "the customer will be RESET — offsite repo DESTROYED, PBS revoked, tunnel/zone removed" + Reset bool // "the customer will be RESET — offsite repo DESTROYED, PBS revoked" (Cloudflare is NOT touched — R-688) Purge bool // "the customer record and ALL escrow ciphertext are PURGED — unrecoverable" } @@ -125,6 +127,10 @@ func (s *Server) handleCustomerDeletePreview(w http.ResponseWriter, r *http.Requ if cfg != nil { customerName = cfg.CustomerName } + // R-688 (v0.125.0): NO leg of the cascade calls Cloudflare. The dialog used to promise "tunnel and + // zone removed"; it now LISTS what the operator removes by hand, by name. Names and booleans only — + // never the token itself. + cfManual := cloudflareManualRemoval(cfg) // An incomplete journal row = a cascade that stopped mid-way; the dialog renders it + Resume. var pending map[string]any if cr, jerr := s.store.LatestCustomerReset(customerID); jerr != nil { @@ -153,6 +159,7 @@ func (s *Server) handleCustomerDeletePreview(w http.ResponseWriter, r *http.Requ "offsite_identifier": offsiteName, "pbs_tenancy_configured": s.tenantsync != nil, "pending_journal": pending, + "cloudflare_manual": cfManual, "residue_total": residue.Total(), "residue": map[string]int{ "reports": residue.Reports, @@ -304,3 +311,25 @@ func (s *Server) handleCustomerDelete(w http.ResponseWriter, r *http.Request, cu s.bumpIntent(customerID) // Direction-2: wake any still-holding wait so it completes promptly http.Redirect(w, r, "/configs?flash=deleted", http.StatusSeeOther) } + +// cloudflareManualRemoval names what the customer delete leaves on Cloudflare's side (R-688): the cascade +// has no Cloudflare leg, so the operator removes these by hand. Nil when the customer has no domain. +func cloudflareManualRemoval(cfg *store.CustomerConfig) []string { + if cfg == nil || strings.TrimSpace(cfg.Domain) == "" { + return nil + } + d := strings.TrimSpace(cfg.Domain) + items := []string{ + "the Cloudflare tunnel that serves " + d, + "the DNS records of " + d + " (the apex and *." + d + ")", + } + var c struct { + Infrastructure map[string]any `json:"infrastructure"` + } + if json.Unmarshal([]byte(cfg.ConfigJSON), &c) == nil { + if v, _ := c.Infrastructure["cf_tunnel_token"].(string); strings.TrimSpace(v) != "" { + items[0] += " (this customer's config carried its own tunnel token)" + } + } + return items +} diff --git a/hub/internal/web/customer_delete_test.go b/hub/internal/web/customer_delete_test.go index 9a425e45..aa468e0a 100644 --- a/hub/internal/web/customer_delete_test.go +++ b/hub/internal/web/customer_delete_test.go @@ -21,6 +21,7 @@ import ( "net/http" "net/http/httptest" "net/url" + "os" "strconv" "strings" "testing" @@ -585,3 +586,38 @@ func TestDeleteCascade_404WhenNothingRemains(t *testing.T) { t.Errorf("cascade for an empty id = %d, want 404", rr2.Code) } } + +// TestR688_PreviewNamesCloudflareByHand — no leg of the cascade calls Cloudflare (R-688), so the preview +// names what the operator removes by hand, by the customer's domain, and never carries the token; and the +// dialog no longer promises "tunnel and zone are removed". +// COMPANION RED-PROOF (REPORT.md): drop "cloudflare_manual" from the preview — this fails. +func TestR688_PreviewNamesCloudflareByHand(t *testing.T) { + s, st := newTestServer(t) + seedDeletable(t, st, "acme") + cfg, _ := st.GetCustomerConfig("acme") + cfg.ConfigJSON = `{"infrastructure":{"cf_tunnel_token":"SECRET-TUNNEL-TOKEN"}}` + if err := st.SaveCustomerConfig(cfg); err != nil { + t.Fatal(err) + } + rr := httptest.NewRecorder() + s.handleCustomerDeletePreview(rr, httptest.NewRequest("GET", "/configs/acme/delete", nil), "acme") + body := rr.Body.String() + for _, want := range []string{`"cloudflare_manual":[`, "the Cloudflare tunnel that serves acme.example", "*.acme.example"} { + if !strings.Contains(body, want) { + t.Errorf("preview missing %q\nbody: %s", want, body) + } + } + if strings.Contains(body, "SECRET-TUNNEL-TOKEN") { + t.Fatal("the preview leaked the tunnel token") + } + tpl, err := os.ReadFile("templates/customer_unified.html") + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(tpl), "tunnel and zone are removed") || strings.Contains(string(tpl), "tunnel and zone removed") { + t.Fatal("the delete dialog still promises a Cloudflare removal no leg performs") + } + if !strings.Contains(string(tpl), "cloudflare_manual") { + t.Fatal("the dialog does not render the manual-removal list") + } +} diff --git a/hub/internal/web/templates/customer_unified.html b/hub/internal/web/templates/customer_unified.html index 7be1f7dc..96974393 100644 --- a/hub/internal/web/templates/customer_unified.html +++ b/hub/internal/web/templates/customer_unified.html @@ -860,7 +860,7 @@ {{if .Deletable}}

Danger zone

-

{{if not .HasConfig}}Ghost customer — the configuration record is already gone; Delete is the applicable action. {{end}}Blocking hides the customer from the Dashboard (reports are still accepted). Delete customer is the full offboarding teardown (v0.69.0): it deletes the host(s), then RESETs the customer (offsite repository destroyed, PBS credentials revoked, tunnel and zone removed), then purges the customer record and all escrow ciphertext — including the retained recovery-key custody for this customer's hosts. This is the one true purge point; host deletion only demotes custody, never destroys it. Three acknowledgements and the typed customer-id are required. For identity-preserving re-onboarding use Ügyfél-visszaállítás (RESET) above instead.

+

{{if not .HasConfig}}Ghost customer — the configuration record is already gone; Delete is the applicable action. {{end}}Blocking hides the customer from the Dashboard (reports are still accepted). Delete customer is the full offboarding teardown (v0.69.0): it deletes the host(s), then RESETs the customer (offsite repository destroyed, PBS credentials revoked — Cloudflare is not touched: the tunnel and DNS records are removed by hand, and the dialog lists them), then purges the customer record and all escrow ciphertext — including the retained recovery-key custody for this customer's hosts. This is the one true purge point; host deletion only demotes custody, never destroys it. Three acknowledgements and the typed customer-id are required. For identity-preserving re-onboarding use Ügyfél-visszaállítás (RESET) above instead.

{{if .HasConfig}}{{/* v0.70.1: blocking gates dashboard visibility of a CONFIGURED customer — meaningless for a ghost */}} {{if .IsBlocked}} @@ -890,7 +890,7 @@