hub v0.103.0 — a host can read the packages we kept for it (R-311)
ListSupersededEscrow had zero production callers for nineteen days. It is the only reader of a retained identity_blob, so the retention shipped in v0.93.0 was material the product could not reach - proven on the fixture 2026-08-12, where a code that opens a retained package was answered as a code that opened nothing. New GET /api/v1/hosts/<id>/escrow/retained: self-scoped exactly as the current-row GET, same recovery-mode gate, same audit event written BEFORE the bytes leave, capped at 16. Rows with a NULL identity_blob are WITHHELD and returned as unopenable_count. They retain the PBS key, not the repository password, so they can never open what the caller is asking about; serving them would have the agent try packages that cannot succeed and would let the screen claim an earlier package is openable on exactly the boxes the original defect hurt. The count is returned because their existence is load-bearing and underivable. The trade, stated rather than waved through: the hub still cannot read any of it - sealed bytes in, sealed bytes out, no decrypt path, no recovery code ever held. What widens is volume, bounded by self-scope, the recovery-mode gate and the cap. The response is a NAMED TYPE, not a map, so the wire-contract gate can resolve it; the wire is declared as a fourth ROOT and the gate now checks 182 tags rather than 174. A positive control shows that check is name-presence, not decodability - filed as R-315 rather than reported as coverage. Six tests through the real endpoint; four red-proofs asserted applied.
This commit is contained in:
@@ -0,0 +1,218 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
|
||||
)
|
||||
|
||||
// R-311 (v0.103.0) — GET /hosts/<id>/escrow/retained.
|
||||
//
|
||||
// WHY THESE TESTS EXIST. `ListSupersededEscrow` had zero production callers for nineteen days; the
|
||||
// retention it reads was proven on 2026-08-12 to hold the right material and to open a store the box
|
||||
// itself could not, while the customer was told their (correct) code opened nothing. These tests pin
|
||||
// the endpoint that ends that, and — more importantly — they pin the two things it must NOT do:
|
||||
// serve a package that cannot be opened, and let one host read another's.
|
||||
|
||||
type retainedResp struct {
|
||||
HostID string `json:"host_id"`
|
||||
Count int `json:"count"`
|
||||
UnopenableCount int `json:"unopenable_count"`
|
||||
TruncatedCount int `json:"truncated_count"`
|
||||
Packages []struct {
|
||||
Index int `json:"index"`
|
||||
SupersededAt string `json:"superseded_at"`
|
||||
KeyFingerprint string `json:"key_fingerprint"`
|
||||
IdentityEscrowB64 string `json:"identity_escrow_b64"`
|
||||
} `json:"packages"`
|
||||
}
|
||||
|
||||
func getRetained(t *testing.T, h *Handler, hostID, key string) (int, retainedResp) {
|
||||
t.Helper()
|
||||
rr := do(h, http.MethodGet, "/hosts/"+hostID+"/escrow/retained", key, "")
|
||||
var out retainedResp
|
||||
if rr.Code == http.StatusOK {
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &out); err != nil {
|
||||
t.Fatalf("decode retained response: %v (body %s)", err, rr.Body.String())
|
||||
}
|
||||
}
|
||||
return rr.Code, out
|
||||
}
|
||||
|
||||
// seedSuperseded drives the REAL supersession path (a PUT sealing a different password demotes the
|
||||
// current row) rather than inserting into host_escrow_superseded directly — so what the test proves
|
||||
// includes the demote wiring, not just this handler's SELECT.
|
||||
func seedSuperseded(t *testing.T, h *Handler, hostID, key string, gens []struct{ sha, identity string }) {
|
||||
t.Helper()
|
||||
for i, g := range gens {
|
||||
if rr := do(h, http.MethodPut, "/hosts/"+hostID+"/escrow", key, escrowBodyWithHash([]byte("k"), g.sha, g.identity)); rr.Code != http.StatusOK {
|
||||
t.Fatalf("seed PUT %d = %d (%s)", i, rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// THE ONE THAT MATTERS: a retained package that carries key material is served, and the caller can
|
||||
// tell it apart from the current row.
|
||||
//
|
||||
// RED-PROOF: change the handler's SELECT source from ListSupersededEscrow to GetHostDRBundle (i.e.
|
||||
// serve the CURRENT row) → the served blob becomes "age-gen3" → this FAILS on the body comparison,
|
||||
// which is exactly the confusion the endpoint exists to end.
|
||||
func TestRetainedEscrowGet_ServesRetainedPackages(t *testing.T) {
|
||||
h, st, _ := newTestHandler(t)
|
||||
st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "HKEY"})
|
||||
seedSuperseded(t, h, "h1", "HKEY", []struct{ sha, identity string }{
|
||||
{"SHA_GEN1", "age-gen1"},
|
||||
{"SHA_GEN2", "age-gen2"},
|
||||
{"SHA_GEN3", "age-gen3"}, // current after this
|
||||
})
|
||||
|
||||
code, out := getRetained(t, h, "h1", "HKEY")
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("GET retained = %d, want 200", code)
|
||||
}
|
||||
if out.Count != 2 || len(out.Packages) != 2 {
|
||||
t.Fatalf("want 2 retained packages, got count=%d len=%d", out.Count, len(out.Packages))
|
||||
}
|
||||
if out.UnopenableCount != 0 {
|
||||
t.Errorf("unopenable_count = %d, want 0 (every seeded row carried an identity blob)", out.UnopenableCount)
|
||||
}
|
||||
// The retained packages must be the SUPERSEDED generations, never the current one. If this ever
|
||||
// serves gen3 the endpoint is answering the wrong question and the customer is misled again.
|
||||
got := map[string]bool{}
|
||||
for _, p := range out.Packages {
|
||||
b, err := base64.StdEncoding.DecodeString(p.IdentityEscrowB64)
|
||||
if err != nil {
|
||||
t.Fatalf("package %d: identity blob is not base64: %v", p.Index, err)
|
||||
}
|
||||
got[string(b)] = true
|
||||
if p.SupersededAt == "" {
|
||||
t.Errorf("package %d has no superseded_at — the screen needs a date to name the package", p.Index)
|
||||
}
|
||||
}
|
||||
if !got["age-gen1"] || !got["age-gen2"] {
|
||||
t.Errorf("served packages = %v, want the two SUPERSEDED generations (age-gen1, age-gen2)", got)
|
||||
}
|
||||
if got["age-gen3"] {
|
||||
t.Error("the CURRENT package was served as retained — that is the wrong-package confusion this endpoint exists to end")
|
||||
}
|
||||
}
|
||||
|
||||
// A retained row with NO identity blob (every pre-v0.93.0 row) must be WITHHELD and COUNTED. Serving
|
||||
// it would have the agent try a package that cannot open anything, and would let the screen claim an
|
||||
// earlier package is recoverable on exactly the boxes hurt by the original defect.
|
||||
//
|
||||
// RED-PROOF: delete the `if len(e.IdentityBlob) == 0 { unopenable++; continue }` guard → the empty
|
||||
// row is served as a package → Count becomes 2 and UnopenableCount 0 → this FAILS twice.
|
||||
func TestRetainedEscrowGet_WithholdsAndCountsUnopenableRows(t *testing.T) {
|
||||
h, st, _ := newTestHandler(t)
|
||||
st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "HKEY"})
|
||||
// gen1 carries NO identity blob — the pre-v0.93.0 shape.
|
||||
seedSuperseded(t, h, "h1", "HKEY", []struct{ sha, identity string }{
|
||||
{"SHA_GEN1", ""},
|
||||
{"SHA_GEN2", "age-gen2"},
|
||||
{"SHA_GEN3", "age-gen3"},
|
||||
})
|
||||
|
||||
code, out := getRetained(t, h, "h1", "HKEY")
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("GET retained = %d, want 200", code)
|
||||
}
|
||||
if out.Count != 1 {
|
||||
t.Errorf("count = %d, want 1 (only gen2 carries material)", out.Count)
|
||||
}
|
||||
if out.UnopenableCount != 1 {
|
||||
t.Errorf("unopenable_count = %d, want 1 — the caller cannot derive this and needs it to explain "+
|
||||
"why a correct old code opens nothing on such a box", out.UnopenableCount)
|
||||
}
|
||||
for _, p := range out.Packages {
|
||||
if p.IdentityEscrowB64 == "" {
|
||||
t.Error("a package with an empty identity blob was served — it can never open anything")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SELF-SCOPE. The current-row GET has this and a retained read is strictly more material, so the same
|
||||
// asymmetry must hold: a host key reads only its own.
|
||||
//
|
||||
// RED-PROOF: remove the `!isGlobal && authHostID != pathHostID` branch → h2's key reads h1's packages
|
||||
// → this FAILS with 200.
|
||||
func TestRetainedEscrowGet_SelfScoped(t *testing.T) {
|
||||
h, st, _ := newTestHandler(t)
|
||||
st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "HKEY1"})
|
||||
st.UpsertHost(&store.Host{HostID: "h2", CustomerID: "c2", APIKey: "HKEY2"})
|
||||
seedSuperseded(t, h, "h1", "HKEY1", []struct{ sha, identity string }{
|
||||
{"SHA_GEN1", "age-gen1"},
|
||||
{"SHA_GEN2", "age-gen2"},
|
||||
})
|
||||
|
||||
rr := do(h, http.MethodGet, "/hosts/h1/escrow/retained", "HKEY2", "")
|
||||
if rr.Code != http.StatusForbidden {
|
||||
t.Fatalf("h2 reading h1's retained packages = %d, want 403 — a host key must never be a fleet-wide reader", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// A host that has never re-sealed gets a clean, empty answer — not a 404 and not a fault. "No retained
|
||||
// package" is an ordinary situation and must be distinguishable from "something is broken", or the
|
||||
// agent's try-loop cannot tell them apart either.
|
||||
//
|
||||
// RED-PROOF: make the handler 404 on an empty list → this FAILS with 404.
|
||||
func TestRetainedEscrowGet_NoneIsACleanEmptyAnswer(t *testing.T) {
|
||||
h, st, _ := newTestHandler(t)
|
||||
st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "HKEY"})
|
||||
if rr := do(h, http.MethodPut, "/hosts/h1/escrow", "HKEY", escrowBodyWithHash([]byte("k"), "SHA1", "age-1")); rr.Code != http.StatusOK {
|
||||
t.Fatalf("seed PUT = %d", rr.Code)
|
||||
}
|
||||
|
||||
code, out := getRetained(t, h, "h1", "HKEY")
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("GET retained on a never-superseded host = %d, want 200", code)
|
||||
}
|
||||
if out.Count != 0 || len(out.Packages) != 0 || out.UnopenableCount != 0 {
|
||||
t.Errorf("want an empty answer, got count=%d packages=%d unopenable=%d", out.Count, len(out.Packages), out.UnopenableCount)
|
||||
}
|
||||
}
|
||||
|
||||
// The retained route must not be reachable without a key at all.
|
||||
//
|
||||
// RED-PROOF: drop the checkAuthHost block → this FAILS with 200.
|
||||
func TestRetainedEscrowGet_RequiresAuth(t *testing.T) {
|
||||
h, st, _ := newTestHandler(t)
|
||||
st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "HKEY"})
|
||||
if rr := do(h, http.MethodGet, "/hosts/h1/escrow/retained", "", ""); rr.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("unauthenticated retained GET = %d, want 401", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ROUTING: /escrow and /escrow/retained must stay two different answers. A prefix "tidy-up" that
|
||||
// collapsed them would route retained reads to the current row — silently the wrong package.
|
||||
//
|
||||
// RED-PROOF: change the retained case's suffix to "/escrow" (so the earlier case wins) → the retained
|
||||
// route returns the CURRENT-row shape, which has no "packages" key → this FAILS.
|
||||
func TestRetainedEscrowGet_IsADistinctRouteFromTheCurrentRow(t *testing.T) {
|
||||
h, st, _ := newTestHandler(t)
|
||||
st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "HKEY"})
|
||||
seedSuperseded(t, h, "h1", "HKEY", []struct{ sha, identity string }{
|
||||
{"SHA_GEN1", "age-gen1"},
|
||||
{"SHA_GEN2", "age-gen2"},
|
||||
})
|
||||
|
||||
cur := do(h, http.MethodGet, "/hosts/h1/escrow", "HKEY", "")
|
||||
var curBody map[string]any
|
||||
if err := json.Unmarshal(cur.Body.Bytes(), &curBody); err != nil {
|
||||
t.Fatalf("decode current-row body: %v", err)
|
||||
}
|
||||
if _, hasPackages := curBody["packages"]; hasPackages {
|
||||
t.Error("the CURRENT-row route grew a packages key — the two routes have been merged")
|
||||
}
|
||||
if _, hasPresent := curBody["present"]; !hasPresent {
|
||||
t.Error("the current-row route lost its present flag")
|
||||
}
|
||||
|
||||
_, ret := getRetained(t, h, "h1", "HKEY")
|
||||
if ret.Count != 1 {
|
||||
t.Fatalf("retained route count = %d, want 1 — it is answering the current row's question", ret.Count)
|
||||
}
|
||||
}
|
||||
@@ -245,6 +245,13 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
case r.Method == http.MethodGet && strings.HasPrefix(path, "/hosts/") && strings.HasSuffix(path, "/escrow"):
|
||||
hostID := strings.TrimSuffix(strings.TrimPrefix(path, "/hosts/"), "/escrow")
|
||||
h.handleHostEscrowGet(w, r, hostID)
|
||||
// R-311 (v0.103.0): the RETAINED siblings of the row above. The two cases cannot collide — one
|
||||
// ends `/escrow`, the other `/escrow/retained` — but do NOT "tidy" them into a single prefix
|
||||
// match: a prefix match would route retained reads to the CURRENT row, which is silently the
|
||||
// wrong package and is exactly the confusion this endpoint exists to end.
|
||||
case r.Method == http.MethodGet && strings.HasPrefix(path, "/hosts/") && strings.HasSuffix(path, "/escrow/retained"):
|
||||
hostID := strings.TrimSuffix(strings.TrimPrefix(path, "/hosts/"), "/escrow/retained")
|
||||
h.handleHostEscrowRetainedGet(w, r, hostID)
|
||||
// G1 break-glass: day-0 vaults the root@pam console credential (self-scoped host key); the
|
||||
// operator retrieves it via the /admin/ path (global key only).
|
||||
case r.Method == http.MethodPut && strings.HasPrefix(path, "/hosts/") && strings.HasSuffix(path, "/recovery-credential"):
|
||||
@@ -1371,6 +1378,158 @@ func (h *Handler) handleHostEscrowGet(w http.ResponseWriter, r *http.Request, pa
|
||||
})
|
||||
}
|
||||
|
||||
// handleHostEscrowRetainedGet serves a host its own RETAINED (superseded) sealed identity packages.
|
||||
//
|
||||
// ── WHY THIS EXISTS (R-311) ─────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// `ListSupersededEscrow` has been the only reader of a retained identity blob since v0.93.0 and had
|
||||
// ZERO production callers — every call site was a test. The consequence, measured on the fixture
|
||||
// 2026-08-12: a recovery code that demonstrably opens a retained package (proven by hand: unsealed,
|
||||
// and it restored planted files byte-identical from a store the box could no longer open) was
|
||||
// reported to the customer as a code that opened nothing. The screen already hedged that with two
|
||||
// possible causes (R-222/R-226) and said it could not tell them apart. **It could not tell them
|
||||
// apart because nothing ever looked.** This endpoint is what makes looking possible.
|
||||
//
|
||||
// ── WHAT THIS DOES NOT CHANGE ───────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// The hub still cannot read any of it. Sealed bytes in, sealed bytes out; there is no decrypt path
|
||||
// here and the hub has never held a recovery code. What DOES widen is volume: a host key that could
|
||||
// previously fetch one opaque package can now fetch N. The trade is stated rather than waved through
|
||||
// — see the audit — and it is bounded three ways: the same self-scope as the current row, the same
|
||||
// recovery-mode gate, and an explicit cap so a host with a long supersession history cannot turn one
|
||||
// request into an unbounded read.
|
||||
//
|
||||
// Rows whose `identity_blob` is NULL are NOT served and are counted separately. They are the
|
||||
// pre-v0.93.0 rows; they retain the PBS key and not the repository password, so they can never open
|
||||
// anything the caller is asking about. Serving them would make the agent try packages that cannot
|
||||
// succeed and would let the screen claim an earlier package is openable when it is not — the same
|
||||
// false-explanation trap `SupersededPresent` already avoids at store.go. They are counted because
|
||||
// their EXISTENCE is a true and load-bearing fact: on those boxes a correct old code opens nothing,
|
||||
// and the honest reason is a defect of ours, not the customer's typing.
|
||||
const retainedEscrowServeCap = 16
|
||||
|
||||
// RetainedEscrowPackage / RetainedEscrowResponse are the WIRE for GET /hosts/<id>/escrow/retained.
|
||||
//
|
||||
// They are named types rather than a `map[string]any` on purpose: the wire-contract gate resolves a
|
||||
// declared ROOT by TYPE, so an untyped map is a cross-repo contract the gate cannot see. This wire is
|
||||
// declared in `scripts/wire_contract_gate.py` ROOTS — hub → agent — and the agent's mirror is
|
||||
// `felhom-agent/internal/hub.RetainedEscrowResponse`. Change a tag here and the gate fails there,
|
||||
// which is the entire point.
|
||||
type RetainedEscrowPackage struct {
|
||||
// Index is a label WITHIN ONE RESPONSE. Not durable, never persisted, never a lookup key.
|
||||
Index int `json:"index"`
|
||||
// SupersededAt is when this package stopped being the current one. It is the only field here a
|
||||
// customer ever sees — it is how they recognise which recovery code they are holding.
|
||||
SupersededAt string `json:"superseded_at"`
|
||||
KeyFingerprint string `json:"key_fingerprint"`
|
||||
// IdentityEscrowB64 is OPAQUE ciphertext. The hub cannot open it and never could.
|
||||
IdentityEscrowB64 string `json:"identity_escrow_b64"`
|
||||
}
|
||||
|
||||
type RetainedEscrowResponse struct {
|
||||
HostID string `json:"host_id"`
|
||||
Count int `json:"count"`
|
||||
// UnopenableCount is the number of retained rows withheld because they carry no key material
|
||||
// (every pre-v0.93.0 row). Underivable by the caller and load-bearing: on a box with only those,
|
||||
// a perfectly correct old code opens nothing and the reason is a defect of ours.
|
||||
UnopenableCount int `json:"unopenable_count"`
|
||||
TruncatedCount int `json:"truncated_count"`
|
||||
Packages []RetainedEscrowPackage `json:"packages"`
|
||||
}
|
||||
|
||||
func (h *Handler) handleHostEscrowRetainedGet(w http.ResponseWriter, r *http.Request, pathHostID string) {
|
||||
authHostID, _, isGlobal, ok := h.checkAuthHost(r)
|
||||
if !ok {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if pathHostID == "" {
|
||||
http.Error(w, "Missing host_id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// SELF-SCOPED, identically to the current-row GET. Without this line any host key is a fleet-wide
|
||||
// reader of every retained package, which is strictly worse than the same hole on one row.
|
||||
if !isGlobal && authHostID != pathHostID {
|
||||
h.logger.Printf("[WARN] retained escrow GET REFUSED: host %s asked for %s's packages (self-scope)", authHostID, pathHostID)
|
||||
http.Error(w, "Forbidden: host_id mismatch", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
host, err := h.store.GetHost(pathHostID)
|
||||
if err != nil {
|
||||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if host == nil {
|
||||
http.Error(w, "Unknown host_id", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
// The SAME §8.3 gate as the current row. A retained package is not less sensitive than the
|
||||
// current one; if self-service retrieval is ever switched off, it must go dark with it.
|
||||
if !escrowSelfServiceRetrieval && !host.InRecoveryMode(time.Now().UTC()) {
|
||||
h.logger.Printf("[WARN] retained escrow GET REFUSED for %s — self-service retrieval is disabled and recovery mode is not armed", pathHostID)
|
||||
http.Error(w, "Forbidden: host not in recovery mode (operator must arm it)", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
retained, rerr := h.store.ListSupersededEscrow(pathHostID)
|
||||
if rerr != nil {
|
||||
h.logger.Printf("[ERROR] retained escrow GET for %s: %v", pathHostID, rerr)
|
||||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
packages := make([]RetainedEscrowPackage, 0, len(retained))
|
||||
unopenable := 0
|
||||
for _, e := range retained {
|
||||
if len(e.IdentityBlob) == 0 {
|
||||
unopenable++
|
||||
continue
|
||||
}
|
||||
if len(packages) >= retainedEscrowServeCap {
|
||||
continue
|
||||
}
|
||||
packages = append(packages, RetainedEscrowPackage{
|
||||
Index: len(packages),
|
||||
SupersededAt: e.UpdatedAt,
|
||||
KeyFingerprint: e.KeyFingerprint,
|
||||
IdentityEscrowB64: base64.StdEncoding.EncodeToString(e.IdentityBlob),
|
||||
})
|
||||
}
|
||||
truncated := 0
|
||||
if n := len(retained) - unopenable; n > len(packages) {
|
||||
truncated = n - len(packages)
|
||||
}
|
||||
|
||||
// The audit row is written BEFORE the bytes leave, exactly as the current-row GET does, so a
|
||||
// retrieval cannot be served without its record. Severity matches that path deliberately: this is
|
||||
// a recovery in progress and nothing else, and it must reach the operator by e-mail.
|
||||
if host.CustomerID != "" && len(packages) > 0 {
|
||||
msg := fmt.Sprintf("Retained recovery packages served: host %s retrieved %d retained sealed package(s) (%d unopenable pre-v0.93.0 row(s) withheld). "+
|
||||
"This is the recovery path in use — the packages cannot be opened without the customer's recovery code, which the hub never holds. "+
|
||||
"If no recovery is in progress on that box, investigate.", pathHostID, len(packages), unopenable)
|
||||
details, _ := json.Marshal(map[string]any{
|
||||
"host_id": pathHostID,
|
||||
"served": len(packages),
|
||||
"unopenable": unopenable,
|
||||
"truncated": truncated,
|
||||
"self_scope": !isGlobal,
|
||||
})
|
||||
if _, eerr := h.store.SaveEvent(host.CustomerID, eventEscrowBlobServed, "warning", msg, string(details), "hub"); eerr != nil {
|
||||
h.logger.Printf("[WARN] %s event save FAILED for %s (serving anyway): %v", eventEscrowBlobServed, pathHostID, eerr)
|
||||
} else if h.dispatcher != nil {
|
||||
go h.dispatcher.ProcessEvent(host.CustomerID, eventEscrowBlobServed, "warning", msg, string(details), "hub")
|
||||
}
|
||||
}
|
||||
h.logger.Printf("[WARN] retained escrow SERVED to host %s (%d package(s), %d unopenable withheld, %d over cap, self_scope=%v)",
|
||||
pathHostID, len(packages), unopenable, truncated, !isGlobal)
|
||||
writeJSON(w, http.StatusOK, RetainedEscrowResponse{
|
||||
HostID: pathHostID,
|
||||
Count: len(packages),
|
||||
UnopenableCount: unopenable,
|
||||
TruncatedCount: truncated,
|
||||
Packages: packages,
|
||||
})
|
||||
}
|
||||
|
||||
// maybeEmitRepoKeyChanged raises ONE operator signal per supersession when the sealed offsite repo
|
||||
// password demonstrably changed. Both hashes have been stored since SLICE 3 (host_escrow and, since
|
||||
// v0.60.0, host_escrow_superseded) and NOTHING compared them: demo-felhom's repository password
|
||||
|
||||
Reference in New Issue
Block a user