hub v0.125.0: the customer delete lists the Cloudflare items to remove by hand instead of promising it (R-688)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -860,7 +860,7 @@
|
||||
{{if .Deletable}}
|
||||
<section class="card">
|
||||
<h2>Danger zone</h2>
|
||||
<p class="text-muted">{{if not .HasConfig}}<strong style="color: var(--warn);">Ghost customer</strong> — the configuration record is already gone; Delete is the applicable action. {{end}}Blocking hides the customer from the Dashboard (reports are still accepted). <strong>Delete customer</strong> 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 <strong>retained recovery-key custody</strong> 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 <em>Ügyfél-visszaállítás (RESET)</em> above instead.</p>
|
||||
<p class="text-muted">{{if not .HasConfig}}<strong style="color: var(--warn);">Ghost customer</strong> — the configuration record is already gone; Delete is the applicable action. {{end}}Blocking hides the customer from the Dashboard (reports are still accepted). <strong>Delete customer</strong> is the full offboarding teardown (v0.69.0): it deletes the host(s), then RESETs the customer (offsite repository destroyed, PBS credentials revoked — <strong>Cloudflare is not touched</strong>: 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 <strong>retained recovery-key custody</strong> 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 <em>Ügyfél-visszaállítás (RESET)</em> above instead.</p>
|
||||
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap; margin-top: 0.5rem;">
|
||||
{{if .HasConfig}}{{/* v0.70.1: blocking gates dashboard visibility of a CONFIGURED customer — meaningless for a ghost */}}
|
||||
{{if .IsBlocked}}
|
||||
@@ -890,7 +890,7 @@
|
||||
</label>
|
||||
<label style="display: block; margin: 0 0 0.5rem; font-size: 0.85em;">
|
||||
<input type="checkbox" id="cust-del-ack2-{{.CustomerID}}">
|
||||
<strong>2.</strong> The customer will be <strong>RESET</strong> — the offsite repository is <strong>DESTROYED</strong>, PBS credentials are revoked, tunnel and zone are removed.
|
||||
<strong>2.</strong> The customer will be <strong>RESET</strong> — the offsite repository is <strong>DESTROYED</strong> and PBS credentials are revoked. <strong>Cloudflare is not touched</strong> — I remove the items listed above by hand.
|
||||
</label>
|
||||
<label style="display: block; margin: 0 0 0.75rem; font-size: 0.85em; color: var(--crit);">
|
||||
<input type="checkbox" id="cust-del-ack3-{{.CustomerID}}">
|
||||
@@ -951,6 +951,16 @@
|
||||
inv.innerHTML = '<strong>Will be destroyed:</strong> ' + dies.join(', ') +
|
||||
'. <strong>Custody:</strong> ' + custody + ' (purged in the final leg). ' +
|
||||
'<strong>Survives:</strong> the audit event stream, the notification log and the deletion provenance.';
|
||||
// R-688: the cascade has NO Cloudflare leg — name what the operator removes by hand.
|
||||
if (d.cloudflare_manual && d.cloudflare_manual.length) {
|
||||
var ul = document.createElement('div');
|
||||
ul.style.marginTop = '0.4em';
|
||||
var head = document.createElement('strong');
|
||||
head.textContent = 'Not removed by the hub — remove by hand in Cloudflare: ';
|
||||
ul.appendChild(head);
|
||||
ul.appendChild(document.createTextNode(d.cloudflare_manual.join('; ') + '.'));
|
||||
inv.appendChild(ul);
|
||||
}
|
||||
if (d.has_config === false) {
|
||||
inv.innerHTML = '<strong style="color: var(--warn)">Ghost customer:</strong> the configuration record ' +
|
||||
'is already gone, but ' + d.residue_total + ' row(s) of report/telemetry state keep it on the ' +
|
||||
|
||||
Reference in New Issue
Block a user