Files
felhom.eu/documentation/audits/SPIKE-ep0-established-connections-2026-08-20.md
T
admin 910fd91124
gates / gates (push) Successful in 14s
agent 0.130.0 published and vouched; R-347 closed, R-349 + R-350 filed
Released via scripts/release-agent.sh: tag v0.130.0 at 7569f34, sha256
a56a92a7bd68f5b46736eaec4806c3d26c16ccb35118c4ac0e3d8094eaefabc3,
verified by independent download and reproducible byte for byte with
-trimpath -buildvcs=false.

Vouched agent 0.129.0 -> 0.130.0 in the Day-0 manifest. Only the agent
fields changed: min_agent stays 0.129.0 because it states what the GOLDEN
CONTROLLER requires, and raising it would have HELD the floor for every
box below 0.130.0. Global floor untouched at 0.216.0 -- and on hub
v0.106.0 it is a separate form with its own action, so publish-train
rule 2's hazard no longer exists in the shape its incident describes.

No --no-verify: the CHANGELOG heading was flipped only after the tag and
package existed, so release-complete passes on the real artifact.

R-349: the fleet was running a DIFFERENT binary under the same version
name -- the proof deploy was a hand build, the release is -trimpath.
Self-update could never have corrected it, because every version check
compares the string. Both boxes reinstalled from the downloaded package.
The proper fix exists in miniature as wrapper_sha256 and was never
extended to the agent's own binary.

R-350: I printed the hub password into the session transcript via
curl -w '%{redirect_url}' -- the hub answers 303 and curl re-attaches the
credential. Not in git, not in any committed file, not in the evidence
directory. Rotation is the operator's call.

ep0 closes at fd 17, ESTAB 0, CLOSE-WAIT 0 -- its t0 baseline -- and was
read-only for this entire arc.
2026-08-20 12:54:54 +02:00

34 KiB
Raw Blame History

SPIKE — who holds ep0's connections open, and does the leak track the poll rate?

Run: 2026-08-20 08:01–08:35 UTC, read-only throughout. Phase C (the one mutation) has NOT been run — it is held at STOP 1 for the operator's window decision. Evidence: evidence-ep0-established-connections-2026-08-20/, one file per step. Supersedes the plan in SPIKE-ep0-established-connections.md (issued 2026-08-18, never run).

Headline, and it is not the headline the spike expected. The leak is ours. Every one of the 388 leaked descriptors on ep0 is an established connection held open by felhom-agent on the customer boxes — one per PBS poll cycle, never closed on either side. pvestatd and proxmox-backup-client made 162,404 requests in the same window and leaked zero. R-336's premise — that cutting the ~85,000/day poll rate is the leak fix — does not survive this measurement. The poll rate is a separate (real) problem; it is not what is consuming descriptors.


Q0 — R-341's first dated check: has the fd slope changed since the PBS 4.2.5-1 upgrade?

Answer: NO. Unchanged, and now measured over a window 88× longer than the one it replaces.

Precondition passed. MainPID = 551655, ps -o lstart = Tue Aug 18 09:51:04 2026, NRestarts = 0 on both proxmox-backup-proxy and proxmox-backup. The proxy generation that carries t0 is the one that was read. The elapsed-window measurement is valid.

value
t0 fd 17, ESTAB 0, 2026-08-18 09:51:22Z, PID 551655
reading A fd 405, ESTAB 388, CLOSE-WAIT 0, 2026-08-20 08:02:13Z, PID 551655
elapsed 166,251 s = 46.181 h = 1.9242 d
delta +388 fd
rate 201.6 fd/day (8.40/h)
Poisson on n=388 ±19.7 counts → ±10.2/day (1σ), ±20.5/day (2σ) → 181.2 … 222.1 /day
limits soft/hard 65536 / 65536 — the drop-in survived the upgrade
listener LISTEN Recv-Q 0 Send-Q 1024 — accept queue empty, nothing wedged

Verdict against the pre-registration, quoted from the spike prompt §2 so it is visible beside the result:

"Predicted range: roughly 370–450 … A reading anywhere in 330–490 confirms an unchanged rate … In range → unchanged rate. This is the EXPECTED result, consistent with the changelog containing no mechanism to change it. It is not a failed upgrade."

Observed: 388. In range, and close to the centre. The rate is unchanged since the upgrade. This is the expected result and it is recorded as such — not as a success and not as a failure of the upgrade, which was run for rehearsal value and never claimed to fix anything.

For comparison, on the identical method: 183/day (31 min, pre-upgrade), 200/day (5.64 h, pre-upgrade), 225/day (32 min, post-upgrade). This window carries 97× the descriptor count of the 31-minute window, so its uncertainty is ±5% rather than ±50%.

Composition — the split, which is what says which leak it is

t0        fd  17   ESTAB   0   CLOSE-WAIT 0
reading A fd 405   ESTAB 388   CLOSE-WAIT 0          17 + 388 = 405, exactly

CLOSE-WAIT is absent from the ss histogram entirely — 0, not merely flat. 100% of the growth is established connections on :8007. This closes the question the incident's own correction block opened: the mechanism is connections the proxy never reaps, and CLOSE-WAIT is not merely the minority half, it is not present at all on this proxy generation.

Runway

From fd 405 at 201.6/day to the 65536 soft limit: ≈ 323 days, i.e. around 2027-07-09. Under a year. A deadline, not a comfort — unchanged in character from what R-341 recorded.


Q1 — who is at the far end?

Answer: exactly the two demo boxes, in a dead-even split. No third peer. Outcome (c) is excluded.

At 08:02:39Z, ss -tn state established '( sport = :8007 )', peer histogram:

peer connections identity
10.77.0.2 194 demo-felhom (host felhom-pve) — token felhom@pbs!demo-felhom
10.77.0.3 194 demo-hp (host felhom-host) — token felhom@pbs!demo-hp

The identity is not inferred from the address: ep0's own access log carries the API token name on every line (::ffff:10.77.0.3 - felhom@pbs!demo-hp …), and the mapping is one-to-one across the whole file.

194 / 194 is not approximately even — it is exactly even, which is itself a finding: whatever opens these is a per-box periodic task running the same schedule on both, not traffic proportional to anything the boxes differ in.

Descriptor-type breakdown (1.3)

lsof -np 551655, which distinguishes sockets from files, pipes and eventfds so a "leak" that is not sockets gets caught here:

type count
IPv6 (TCP) 390
REG (log files, rrd journal, datastore lock) 32
unix 7
a_inode (epoll ×2, eventfd ×1) 3
DIR 2
CHR (/dev/null) 1

lsof … | grep -c TCP = 390 = 388 established + 1 listener + 1 other. The independent /proc/<pid>/fd link-target histogram agrees: 397 sockets (390 TCP + 7 unix), 9 regular/anon fds. The leak is sockets, and the sockets are TCP on :8007. Nothing else is growing.

1.4 — persistence: are they long-lived, or churn?

Full 4-tuples diffed between reading A (08:02:39Z) and reading B (08:33:42Z), 1,843 s apart:

count
sockets in reading A 388
sockets in reading B 392
in BOTH — long-lived 388
only in A — closed during the interval 0
only in B — new arrivals 4 (2 per box)

Not one socket closed in 31 minutes. There is no churn to separate from the leak: every established connection on this proxy is the leak. The 4 arrivals over 1,843 s = 187.5 fd/day, consistent with the elapsed-window 201.6/day within Poisson on n=4.

Idle age and liveness. All 392 sockets carry a TCP keepalive timer and all 392 report retrans=0 — the far end is answering keepalive probes. These are not half-open sockets whose peer went away; they are mutually held, live, idle connections. That, on its own, points at outcome (a) and Part 2 confirms it.


Q2 — do the boxes hold them too?

Answer: outcome (a) — both sides hold every connection, and the two counts match exactly. Twice.

instant (UTC) ep0 ESTAB on :8007 demo-felhom ESTAB to 10.77.0.1:8007 demo-hp sum
reading A — ep0 08:02:39, boxes 08:04:37/38 388 194 194 388
reading B — ep0 08:33:42, boxes 08:34:01/02 392 196 196 392

Two independent paired readings, both exact. And the confirmation is tighter than the totals: the four new sockets that appeared on ep0 during the persistence window carry the same four source ports the boxes report as new —

ep0 new:            10.77.0.2:45620  10.77.0.2:53226  10.77.0.3:37554  10.77.0.3:46214
demo-felhom new:    10.77.0.2:45620  10.77.0.2:53226
demo-hp new:        10.77.0.3:37554  10.77.0.3:46214

Both boxes also closed zero sockets in the same interval (A=194, B=196, closed=0, new=2, on each).

This is not a hedge between two outcomes. Outcome (b) — half-open sockets ep0 never noticed — is excluded by both the matching counts and the retrans=0 keepalives. Outcome (c) is excluded by Q1.

And the owner has a name

ss -tnp on the boxes attributes 194 of 194 on each, to a single PID:

box PID process
demo-felhom 2596329 /usr/local/bin/felhom-agent --config /etc/felhom-agent/agent.json
demo-hp 3199936 /usr/local/bin/felhom-agent --config /etc/felhom-agent/agent.json

Zero are held by pvestatd. Zero by proxmox-backup-client. Both agents have been running continuously since 2026-08-12 (they did not restart); the count restarted at the proxy's 08-18 restart because that restart closed the old sockets on both sides.

Each agent's own descriptor table is 208 fds, of which 196 are these connections — so the leak is bilateral. The agents' RLIMIT_NOFILE is 524287, so they are in no danger; that is a reason it stayed invisible, not a reason it is harmless.


The user-agent attribution (1.5) — the access log settles it independently

Over the same 46.18 h window, from ep0's own access.log.1 + access.log:

user agent endpoint requests req/day descriptors leaked
libwww-perl/6.78 GET /admin/datastore (pvestatd) 81,192 42,195 0
proxmox-backup-client/1.0 GET …/felhom-offsite/status 80,061 41,607 0
libwww-perl/6.78 GET …/felhom-offsite/snapshots 1,131 588 0
Go-http-client/1.1 GET …/felhom-offsite/snapshots 387 201 388
Go-http-client/1.1 GET /version 371 193 (same connections)
Go-http-client/1.1 POST …/verify + task status 53 28 (same connections)
total, both boxes 163,215 84,822 388

Go-http-client/1.1 is felhom-agent — the same process ss names on the box side, reached by a completely independent route. 387 agent /snapshots calls against 388 leaked sockets: one per call, within one.

162,404 requests from the two Proxmox pollers leaked nothing. They are 99.5% of the traffic and 0% of the leak.

Cadence reconciliation — the numbers close

source cadence cycles in 166,251 s
agent hub-report poll (poll_seconds: 900, read from each box's agent.json) 15 min 184.7
pbs.VerifyLoop (DefaultVerifyCadence = 6h) 6 h 7.7
sum 192.4

Observed leaked per box: 194. Observed POST …/verify per box in the window: 8 (predicted 7.7). Observed GET /version per box: ~185.5 (predicted 184.7). Mean interval between leaked descriptors per box: 857 s. Every count reconciles against a cadence read from configuration, not fitted to the data.

Mechanism — named from source, and it is a two-line Go footgun

felhom-agent/internal/pbs/client.go:56-60:

http: &http.Client{
    Timeout:   timeout,
    Transport: &http.Transport{TLSClientConfig: tlsCfg},
},

A hand-rolled http.Transport takes the zero value for IdleConnTimeout, which in Go means no limit — idle keep-alive connections are never closed. (http.DefaultTransport sets 90 s; a composite literal does not inherit it.) That alone would be harmless if one client were reused. It is not: felhom-agent/cmd/felhom-agent/main.go:1486-1509 — pbsTargetsFromPVE — says so in its own doc comment:

"returns a pbs.Targets closure that, each cycle, discovers the pbs storages from the PVE config and builds a fingerprint-pinned, token-authed client for each"

So each cycle constructs a new Transport, performs one request, and leaves the connection idle in that transport's pool forever. The transport then becomes unreachable, but Go's persistConn read-loop goroutine keeps the socket alive — an unreachable http.Transport does not close its connections. grep across the whole felhom-agent repo for CloseIdleConnections, IdleConnTimeout, MaxIdleConns: no matches at all.

The same composite-literal pattern appears in two more clients — internal/hub/client.go:53 and internal/proxmox/client.go:69. Those are built once at start-up rather than per cycle, so they do not leak by this route; they are recorded because the pattern is one refactor away from doing so. Filed as R-344.


Control-window cleanliness (1.6)

The elapsed window is doing double duty as Phase C's control, so it has to be shown undisturbed. It was.

check finding
ep0 host reboot none — booted 2026-08-03 11:12:57, up 17 d
proxy / API daemon restart none — NRestarts = 0 on both units, PID 551655 throughout
box reboots none — both booted 2026-08-10 09:2xZ, up 10 d
agent restarts none — both ActiveEnterTimestamp 2026-08-12
request-rate gaps none. Hourly totals across the window: 3,536–3,553 every hour, no hour missing
HTTP errors none. Every response in the window is 200, except 8 × 101 (expected protocol upgrades)
tunnel flaps no evidence — WireGuard handshakes for 10.77.0.2, .3, .250 all seconds old; and a flap would have shown as a request gap or errors, and neither exists
a backup inside the window? NO. The last offsite protocol upgrades were 2026-08-18 03:57 and 03:58Z — the incident re-runs, before t0 at 09:51:22Z. Both boxes' pvesm list felhom-pbs confirms the newest snapshots are 2026-08-18T03:57:43Z / 03:58:43Z. The tier's cadence_seconds is 604800 (weekly), read from each box's agent.json, so the next offsite run is due ~2026-08-25
other non-poll activity yes, and it is routine, quantified, and does not contaminate. Server-side verify jobs ran on ep0 on a ~6 h cadence (8 per box in the window), and restore-test reads ran on 2026-08-19 at 04:45–04:50 and 06:52–06:54Z (8 reader upgrades total). Together with snapshot/version/task-status calls this is ~1,963 of 163,215 requests = 1.2% of the window's traffic, and at most 8 of the 388 descriptors could be attributed to reader connections (≤2%). The verify cycle is already counted inside the 194/box reconciliation above — it is part of the measured leak, not a contaminant of it

Verdict: the control window is clean. No correction is applied and none is needed.

Two nuances recorded rather than smoothed over.

  1. systemctl show proxmox-backup-proxy -p ActiveEnterTimestamp reads 2026-08-18 03:54:54Z, not 09:51:04Z, while NRestarts = 0 and MainPID did change. The upgrade therefore re-exec'd the proxy rather than restarting the unit — systemd never saw a stop. Anyone using ActiveEnterTimestamp as the "when did this proxy generation start" anchor would get a figure 5 h 56 m too early and compute a rate ~12% low (388 fd over 52.1 h instead of 46.2 h = 178.7/day instead of 201.6/day). Use ps -o lstart= -p $MainPID, as R-341's own command does.
  2. ep0 carries a fourth WireGuard peer, 10.77.0.4, whose last handshake was 2026-08-13 (7 d ago). It is drill-r50-0a4f9a, a drill host, and its dormant peer entry is already recorded in architecture/_recovery-inventory-2026-07-28.md. Outside the window; not a contaminant; not a new finding.

Hub view. No pbsdr_box_unreachable / offsite_box_unreachable and no *_recovered event was logged. The positive observable rather than the absent one: the PBS-DR gauge refreshes on schedule (PBS-DR box refreshed: 3.7% full (3.7 GB of 97.9 GB), every ~16 min through the run) and host reports land from both boxes. Honest limit: the hub pod is 39 h old (redeployed for v0.106.0 at 2026-08-18 19:29), so hub-side log evidence covers 36.6 of the window's 46.2 hours. For the first 9.6 h the evidence is ep0's own access log — which shows no gap and no error — and NRestarts = 0. Hub log timestamps render CEST.


Q3 — is the leak rate proportional to the request rate?

NOT ANSWERED BY MEASUREMENT. Phase C has not been run; it is held at STOP 1.

What exists instead is a pre-registered prediction, written and committed before any Phase C number can exist: evidence-ep0-established-connections-2026-08-20/phaseC-prediction.txt. Its substance:

  • Stopping pvestatd on demo-hp removes both pvestatd-driven streams from that box (the PVE PBS storage plugin issues the libwww-perl datastore list and shells out to proxmox-backup-client for the status call, both on pvestatd's cycle) — −98.8% of demo-hp's requests, −49.4% of the fleet total. If ep0's request count does not fall by close to half, the independent variable did not move and no ratio may be computed.
  • H1 (what Parts 1–2 predict): NOT proportional. demo-hp's felhom-agent keeps running, so both boxes keep leaking. Expect ~201.6 fd/day, unchanged — ~84 descriptors in a 10 h window (2σ 66–102) — split symmetrically ~42/~42 between the two peers.
  • H2 (the hypothesis Q3 was written to test): proportional. Expect ~101 fd/day — ~42 descriptors in 10 h (2σ 29–55) — split strongly asymmetrically ~42/~0.

H1 and H2 do not overlap at 2σ for a window of 10 h or more. The peer split is the sharpest discriminator and it is free: it needs no second box and no arithmetic.

A result matching H2 falsifies the Parts 1–2 attribution, and the prediction file says so in those words, so the outcome cannot be rationalised in either direction afterwards.


Which fix the evidence points at

No design here, per the spike-first gate — only the target. The evidence points at felhom-agent/internal/pbs/client.go's HTTP transport lifetime, not at the poll rate. One pbs.Client is constructed per cycle by pbsTargetsFromPVE, each carrying a fresh http.Transport whose IdleConnTimeout is the zero value, and nothing ever closes it; the observed leak is exactly one socket per agent /snapshots call, on both sides, on both boxes, and it reconciles with cadences read from configuration. A fix that touches only the ~85,000/day Proxmox poll rate would reduce ep0's request load by ~99.5% and its descriptor leak by zero. R-336's poll rate remains a genuine problem — 85,000 requests/day to a weekly-write DR endpoint is still wrong — but it is a different problem from this one, and the row's "cut the poll rate, then confirm the fd count stops climbing" plan would have produced a confusing null result. That is the substantive change to R-336 this spike delivers.


What was inconclusive

  • Q3 is unmeasured, by design — Phase C is held at STOP 1 for the operator. Everything above about proportionality is a prediction, labelled as one.
  • The ~9.6 h of hub-side evidence before the v0.106.0 pod redeploy is gone with the previous pod's logs. The window is covered by ep0-side evidence for that period, which is adequate for the questions asked, but it is not the same evidence and is not presented as such.
  • Why demo-hp's and demo-felhom's leaked counts are identical rather than merely similar is not established. Both run the same cadences, so equality is expected — but exact equality at two separate instants (194/194 and 196/196) is stronger than the cadences alone require, and no attempt was made to explain it beyond noting it.
  • Whether a reader (restore-test) connection also leaks was not separated out. At most 8 of 388 sockets could be involved (≤2%), which is inside the noise, so it was not pursued.

Fix and proof — 2026-08-20, later the same day (R-344)

Run under Claude Code Prompt: fix the agent's leaked PBS connections, then prove it on one box, which cancelled Phase C. Evidence: evidence-agent-transport-leak-2026-08-20/.

Phase C was never run, and cancelling it was right. It would have quietened pvestatd on demo-hp to test whether the leak tracks the request rate. The spike had already shown pvestatd leaks nothing, so the honest prediction was a null result. The experiment that replaced it — deploy the fix to one box and leave the other as a control — proves the fix and re-proves the mechanism in one window, and it opens with a free, immediate, decisive observation Phase C could not have produced.

The fix

One field, restored to the standard library's own value. New leaf package felhom-agent/internal/httpx owns DefaultIdleConnTimeout = 90 * time.Second — 90s because that is http.DefaultTransport's value, so there is nothing invented to justify — and NewTransport(tlsCfg, idle), which returns a fresh transport every call (never shared: each caller pins a different endpoint) and treats a zero or negative timeout as use the default, never "no timeout". Wired into all three hand-rolled transports; grep '&http.Transport{' now matches only httpx itself.

internal/hub/client.go and internal/proxmox/client.go carried the identical missing default and were corrected in the same pass. Neither contributed to the ep0 leak — both are built once per process, so they held one idle connection for the life of the daemon rather than accumulating, and neither talks to ep0:8007. This must not be read as three leaks having been found.

A check made before trusting the fix: doBody already reads the response body to completion and closes it, so the connection genuinely reaches the idle pool. Had it not, IdleConnTimeout would have been the wrong fix and the leak would have been an un-drained body instead.

The tests, and the two red-proofs

internal/pbs/client_leak_test.go counts connections server-side and models what pbsTargetsFromPVE actually does — build a client, use it once, drop it on the floor. It deliberately does not assert err == nil and does not assert that a field holds a value; both were true of the leaking code. This is the R-224 lesson applied: assert the consequence, not the mechanism.

red-proof mutation result
1 — the fix remove IdleConnTimeout from NewTransport FAILS: "abandoned pbs.Clients: after 5s the server still holds 5 open connection(s), want 0 (5 dialled in total)" — the count is in the message, so the failure cannot be a timeout with another cause
2 — the worse fix DisableKeepAlives: true the leak test PASSES. Disabling keep-alive also stops the leak — by dialling fresh for every one of ~40,000 daily requests. TestPBSClient_KeepAliveStillReuses is what catches it: "3 sequential requests over 3 connection(s), want 1"

Red-proof 2 is the load-bearing one: Scenario A alone would have accepted a fix that made the problem worse. Both mutations were reverted and the tree re-verified clean.

Green gate: go build ./... && go vet ./... && go test ./... → 30 packages, 0 failures. python3 scripts/agent_gates.py → all 4 gates OK.

P1 — the restart observation

Predicted in advance as one of three outcomes. Outcome (i), within one second.

ep0 fd ESTAB from demo-felhom from demo-hp CLOSE-WAIT
T-0 09:15:46Z 415 398 199 199 0
T+1s 09:15:47Z 216 199 199 0 0
T+60s 09:16:42Z 218 201 199 2 0

Outcome (ii) did not occur and gets no register row. Not one socket converted to CLOSE-WAIT: ep0 reaps on the peer's FIN correctly, so the 543 CLOSE-WAIT seen at the 2026-08-18 wedge has some other explanation and is not evidence of a second defect on the protected machine. That is a finding in the negative and it was worth the sixty seconds it cost.

Ownership is now established by a third independent route. Killing our process released exactly demo-hp's 199 descriptors — agreeing with ss -tnp on the box and with the access-log user agent.

The new code retires connections, observed directly: at 133 s uptime demo-hp held 0 connections; the 2 it opened after restart were closed at the 90 s idle mark.

P2 — the divergence window

Window 09:15:47Z → 10:17:52Z = 1.03 h. The task specified ≥ 4 h; the operator closed it early. The conclusion below therefore does not rest on an extrapolated daily rate, and none is used.

box agent start end delta per hour
demo-felhom — CONTROL 0.129.0 199 203 +4 3.87
demo-hp — FIXED 0.130.0 0 0 +0 0.00

Predicted: control ≈4/hour, fixed ≈0. Control observed 3.87/hour.

The assumption-free statement, and the reason the short window still settles it. ep0's access log counts the poll cycles directly: in that window each box made exactly 4 GET .../snapshots calls and 4 GET /version calls. Same cadence, same work, the same four chances to leak.

control: 4 cycles → 4 leaked connections. fixed: 4 cycles → 0 leaked connections.

The positive observable, per standing rule 3. A zero leak is equally consistent with "fixed" and with "the agent stopped working". It is the former: the fixed box's four poll cycles are in ep0's log, alongside the control's four. The rest of the two boxes' traffic is near-identical in the window — libwww-perl 924 vs 926, proxmox-backup-client 898 vs 898 — so the only difference between them is the agent binary.

Poisson alone would give P(0 leaks | old rate, λ=4) = 1.8%, which is suggestive rather than conclusive. It does not stand alone: P1 released 199 descriptors instantly, the mechanism is identified at file:line and pinned by a red-proofed test, and the fixed box was directly observed returning to 0.

P3 — the second box, and the accumulated leak clearing itself

demo-felhom — the former control — was upgraded at 10:18:56Z on the operator's word.

ep0 fd ESTAB CLOSE-WAIT
T-0 10:18:55Z 220 203 0
T+2s 10:18:56Z 17 0 0
T+65s 10:19:58Z 20 3 0
settled, 10:30–10:35Z 17–19 0–2 0

fd 17 is precisely ep0's t0 baseline — fd 17, ESTAB 0, recorded at 2026-08-18 09:51:22Z. The proxy sits at 17–19 and returns to 17 between poll cycles, which is the "healthy proxy near 20 fds" the incident document named as the positive observable. ep0 was read-only throughout and its proxy PID never changed (551655).

This corrects a sentence written earlier the same day, in the v0.130.0 CHANGELOG draft and in the STOP 1 report: "does not clear the 388 descriptors already stuck on ep0 — those persist until that proxy restarts." Wrong, and measured wrong within the hour. The descriptors were held on both sides; closing either side ends them. Restarting the two agents released all of them. Nothing on the protected machine had to be touched, and nothing was.

Fleet sanity

  • Hub reports demo-hp 0.130.0 and demo-felhom 0.130.0; 0.130.0 is above the golden's MinAgent of 0.129.0, and no floor held line appeared for either box.
  • No pbsdr_box_unreachable / offsite_box_unreachable event fired during any window.
  • Positive observable rather than the absent one: the hub's PBS-DR gauge kept refreshing on schedule (3.7% full (3.7 GB of 97.9 GB)) and host reports kept landing from both boxes throughout.

An unrelated finding the deploy exposed — R-348

The first two host reports after an agent restart carry 0 backups, while the box's own pvesm list shows backups present on both tiers. internal/backup/store.go's Store is in-memory and its byTarget map is repopulated only when a backup runs — daily for the local tier, weekly for offsite — so the field reads 0 for up to ~18 h after any restart. restore_tests did not blank, because that half has a durable on-disk companion (RestoreTestState, R-189).

It blinds no alarm, and that was checked rather than assumed. hub/internal/monitor/deadline.go already scans back over stored reports with a 7-day backupEvidenceLookback, whose comment names this exact case — "when the LATEST report carries none... and against an agent that stayed restarted for days" — and pbs_snapshots stayed populated at 2 regardless. So this is an observability wart, not a safety hole.

But the Store comment is misleading in a way this project has a rule about. It reads "Backups are unaffected — their freshness has a ground truth on the storage (R-84)". That is true of the consequence and false of the field, and a future reader may take it as a guarantee the field stays populated. Filed as R-348.

What this run did NOT do

  • Did not publish. No Gitea package, no tag, no artifact_agent_version / artifact_min_agent / artifact_golden_version / min_controller_version change, no staged self-update. A box installed from the current image still carries the leaking agent — filed as R-347, and the CHANGELOG heading stays ## UNRELEASED until that decision is taken.
  • Did not reduce the poll rate. R-336 stays open, re-scoped: it was never the cause of this leak.
  • Did not refactor pbsTargetsFromPVE to cache or reuse clients, and added no CloseIdleConnections call. See Observations.
  • Did not touch ep0 — no restart, no config, no package, no nftables. Reads only.
  • Did not trigger a backup, restore or verify to generate traffic; did not contact peti-felhom.

Observations

  • The closure refactor is not worth doing, and the measurement is why. With the idle timeout restored, an abandoned client's connection is gone in 90 s, so the standing population is bounded at roughly one connection per box rather than growing without limit. Caching clients would add cache-invalidation questions — a storage's fingerprint, token or namespace can change under it — for no observable gain. Recommend leaving it.
  • Three other http.Transport defaults are still missing and were deliberately left alone: MaxIdleConns (0 = unlimited; DefaultTransport uses 100), TLSHandshakeTimeout (0 = no limit; DefaultTransport uses 10 s) and ExpectContinueTimeout. None of them accumulates anything, and every client bounds its whole request with http.Client.Timeout, so none is a leak. TLSHandshakeTimeout is the only one with a plausible failure mode — a stalled handshake over the tunnel, bounded today only by the outer 30 s client timeout. Not changed here, because widening the diff would have made this measurement unattributable. Worth a look on its own terms; not filed as a defect.
  • A measurement error of mine, recorded because it nearly cost four hours. The first P2 sampler reported both per-box columns as 0 while the totals were right: ss prints [::ffff:10.77.0.2]:port, and the pattern expected 10.77.0.2:. Caught 15 minutes in by noticing that a 0/0 split could not sum to 199. Fixed, then one sample was proved by hand before committing the window to it — the check that should have happened first.

Published — 2026-08-20, on the operator's word (R-347 CLOSED)

Released through scripts/release-agent.sh 0.130.0, the one documented way (R-115), which builds, tags, publishes and verifies by independent download rather than by its own say-so.

version / tag 0.130.0 / v0.130.0 at 7569f34
sha256 a56a92a7bd68f5b46736eaec4806c3d26c16ccb35118c4ac0e3d8094eaefabc3
size 14,141,158 bytes
reproducible yes, checked — a rebuild with -trimpath -buildvcs=false matches byte for byte (R-186)
anonymous fetch matches the vouched sha

The vouch, and what was deliberately NOT changed

Day-0 artifact manifest: agent_version 0.129.0 → 0.130.0, agent_sha256 updated. Everything else re-sent unchanged, and each for a reason:

  • min_agent stays 0.129.0. It expresses what the golden controller requires, not what the newest agent is. Raising it to 0.130.0 would have made the hub HOLD the controller floor for every box not yet on 0.130.0 — the exact opposite of shipping a fix, and the manifest's own help text says so: "The hub HOLDS the floor for any box whose agent is below this."
  • golden_version / golden_sha256 / wrapper_sha256 — untouched; no controller was released.
  • The global floor was never touched (still 0.216.0). And on hub v0.106.0 it could not have been by accident: it is a separate form with its own action (/configuration/global-floor), so publish-train rule 2's "the manifest screen carries the live DB floor — save it last" hazard no longer exists in the shape its incident describes. Worth knowing before the next train; the rule's reasoning still holds, its mechanism has moved.

Verified after: no floor held line for either box, no *_unreachable, both boxes reporting 0.130.0.

No --no-verify anywhere in the train. The CHANGELOG heading was flipped from ## UNRELEASED — v0.130.0 candidate to ## v0.130.0 only after the tag and package existed, so release-complete passes on the real artifact. The ordering was: release at the UNRELEASED-heading commit, then flip. The alternative — flipping first and bypassing the gate — would have produced a genuinely red CI run and an alarm mail for a release that worked, which is R-168's failure mode.

The trap this train exposed — R-349

Both boxes were running a different binary under the same version name, and nothing would ever have noticed. The proof deploy used a hand build (go build -ldflags …); the release builds with -trimpath -buildvcs=false for reproducibility. Same source, same version string, different bytes: 256e0829… on the boxes against a56a92a7… published.

The sharp edge is that self-update cannot correct it: the boxes already reported 0.130.0, so the vouched version looked installed and nothing would have happened, indefinitely. Every version check in the system — the hub, --version, the artifact manifest — compares the version string, so the divergence is invisible to all of them.

Corrected by installing the downloaded artifact (not a local rebuild — the boxes get the bytes a fresh install would get) on both. Both now report a56a92a7….

The proper fix already exists in miniature: wrapper_sha256 makes exactly this drift visible for the PBS wrapper — "agents report the installed file's hash and a mismatch is surfaced on the host page". It was simply never extended to the agent's own binary. R-349.

A mistake of mine in this train — R-350

Confirming the vouch used curl -w '%{redirect_url}'. The hub answers the POST with a 303, and curl renders the redirect target with the basic-auth credentials re-attached — so the hub operator password was printed in cleartext into the session transcript.

It is not in git, not in any committed file (checked by content, not by assumption), and not in this evidence directory; it is in the Claude Code transcript on DooPlex. Every other call in the session printed only the password's length — this arrived through curl's output formatting, which is why the usual discipline missed it. Rotation is recommended and is the operator's call; the reusable half is that %{redirect_url}, -v and --libcurl all re-render a basic-auth credential — confirm a redirect with %{http_code} and read the flash from a follow-up GET.

Closing state

ep0  10:52:26Z  pid=551655  fd=17  estab=0  ctrl(.2)=0  fix(.3)=0  CLOSE-WAIT=0

fd 17 is ep0's t0 baseline, and it returns there between poll cycles. The proxy is the same process that has run since 2026-08-18 09:51:04 — ep0 was read-only for this entire arc, from the spike through the fix to the release, and was never restarted, reconfigured or upgraded by any of it.