R-182 CLOSED (controller v0.194.0 + hub v0.90.0/.1), proven live on demo-hp. The hub's notification_log for the run reads: two per-app failures RECORDED, one digest SENT naming both, and the customer channel SKIPPED with operator_only. Against the measured previous behaviour — two failures, one email naming one app, one leaving no trace anywhere. Scenario D proved itself on an event I had not planned: disk_critical alarmed on two filesystems, the second was collapsed by the cooldown, and that collapse is now visible WITH ITS KEY. Yesterday it would have left nothing at all. A gap the spec did not anticipate is recorded with its fix: the per-app event also fires from the periodic sweep, outside any run, so making it record-only would have created a NEW silence. The sweep emits a digest too, with no run_id, so it stays under the ordinary hourly cooldown. ep0: MEASURED on the box — 7757 MB (8 GB), 4 vCPU, and the 4 GiB swapfile SURVIVED the resize and is active (checked, because a resize is a stop/start). The 40 GB local disk is UNCHANGED, so no disk figure was touched anywhere. Five documents corrected — three of which the task's list did not name, found by searching. Two audit/evidence documents ANNOTATED, body untouched: they record what was true when written and that is their value. R-90 CLOSED. R-86 unblocked and re-ranked, stated honestly: 8 GB is comfortable, not unbounded — the original OOM was a 14.46 GB restore — so the restore-test cadence should still be paced, just not by fear of the endpoint. target-selection.md's "D-d did not name ep0 either way" is deliberately left standing. It is the operator's question, not CC's. STATUS.md 127 -> 83 lines, items rather than sentences.
23 KiB
SPIKE — offsite-backup connectivity: WireGuard vs the home-NAT reality (2026-07-03)
STATUS: COMPLETE — verdict below. This spike validates the offsite-backup transport before any production connectivity spec is written. No production code shipped; no Headscale on the real hub; no agent binary change; no persistent config on felhom-pve. All probes ran on a throwaway Hetzner box (
felhom-hetzner, destroyed-or-parked after — see §6) and as additive, fully-removed interfaces/services on the demo-felhom host. The real DooPlex PBS and its customer snapshots were never a probe target.
Class: SPIKE (empirical validation; no product code). Repos: felhom.eu (this doc only);
felhom-agent read-only for grounding (internal/pbs/{client,pin}.go, configs/build-golden.sh,
internal/hub/cloudflared.go, internal/escrow/identity.go).
Probe ends: felhom-hetzner = Hetzner CX23 (note added 2026-08-03: rescaled to a CX33, 8 GB RAM — this spike records the machine as probed and its body is deliberately unchanged), Debian 13.4, public IPv4 167.233.158.164,
global IPv6 2a01:4f8:...::/64 (throwaway — NOT the live jarrs.eu box) ⟷ demo-felhom =
the real PVE 9.2.2 host on the operator's home line (One Hungary fixed cable, Budapest), driven
over the existing LAN SSH path; the tunnel itself always dialed out.
Verdict (one line): GO at the lowest rung — H1: plain WireGuard, box-dials-out to a small public endpoint, holds through the home NAT with zero coordinator/relay, carries the PBS API with the TLS pin intact at ~wire-speed of the home uplink; Headscale is a separable fleet-management option, not a traversal necessity — with the honest caveat that the operator's line turned out to be plain-NAT (public IP), not CGNAT, so the CGNAT-traversal claim rests on mechanism + a deferred second-ISP confirmation (Peti's VM 110), not on this line's evidence.
1. Why this spike exists (the gap, grounded)
Offsite backup — the paid DR substrate (architecture/01-topology-and-trust.md §8) — is
structurally unreachable for a real customer today:
- Backup is outbound push to PBS (§7/§8), and the only PBS lives at a LAN address,
192.168.0.180:8007(felhom-agent/internal/pbs/client.go). - The public edge is Cloudflare-Tunnel →
nginx-internal— HTTP(S) only, no public inbound UDP anywhere; DooPlex has no public IP. - So offsite backup works only because the demo box shares a LAN with PBS. A real customer's box
cannot reach
192.168.0.180at all. The DR crypto/identity side is done and drilled (SPIKE-dr-recipe-2026-06-16.md); transport is the only missing piece.
Architectural corollary (recorded as a finding): production offsite backup requires a publicly-reachable endpoint that today's infrastructure does not provide. The throwaway Hetzner box in this spike is a preview of that production endpoint, not just a test rig (§4.4).
The hypothesis ladder tested, cheapest-first (the roadmap's "Headscale + DERP" sketch assumes the hard problem — peer-to-peer traversal between two NAT'd hosts — but backup is hub-and-spoke to a public endpoint, which any NAT'd box can reach outbound):
| Rung | Hypothesis | Result |
|---|---|---|
| H0 | both ends global IPv6 → direct, no NAT | N/A — the home side has no IPv6 at all (P0/P1) |
| H1 | plain WG over IPv4, box→public endpoint, keepalive | CONFIRMED on this line (P2/P3) |
| H2 | outbound UDP blocked → TCP/443 fallback needed | not needed — UDP 51820 and 443 both pass (P4) |
| H3 | fleet needs → Headscale | separable — fleet layer, not traversal (P5) |
2. Probe results (verbatim, redacted)
P0 — NAT classification of the demo-felhom line — plain single-NAT with a public IPv4, no IPv6
-
Egress IP seen by
ifconfig.mefrom the host:37.191.56.193. -
Source IP observed at the Hetzner box for an inbound TCP connection from demo-felhom (HTTP listener log):
37.191.56.193— same address, i.e. one NAT, stable mapping. -
ipinfo.io:37.191.56.193=catv-37-191-56-193.catv.fixed.one.hu, AS21334 One Hungary Ltd. — a fixed (subscriber-routable) cable address, not shared-pool space. -
Traceroute from the host (first hops):
1 192.168.0.1 0.239 ms ← home router 2 89.133.164.70 12.485 ms ← already PUBLIC ISP space (catv...one.hu, same AS) 3 89.135.220.78 22.316 msNo
100.64.0.0/10(or private) hop after the home router — no CGNAT tier on the path. -
IPv6:
ip -6 addrshows link-local only;curl -6fails (NO-IPV6-EGRESS); a 15-minute passivetcpdumpcapture for router advertisements onvmbr0caught zero RAs. (Caveat noted:accept_ra=1+forwarding=1means the kernel would ignore RAs anyway, which is why the wire-level capture, not the address absence, is the evidence.) No IPv6 exists on this LAN.
Consequences: the operator's line is a softer premise than the task assumed — H1 success here does not prove CGNAT traversal (see §5). H0 (IPv6-direct) is dead on arrival for this line and cannot be the plan's foundation: production must assume v4-only homes.
P1 — H0 IPv6-direct — N/A (recorded gap)
Not testable: the home side has no global IPv6 (P0). The Hetzner side has native v6, so the server-side of a dual-stack endpoint is free — worth keeping the endpoint dual-stack so v6-capable customer lines use it, but it solves nothing this spike needed solving.
P2 — H1: plain WG over IPv4, outbound-initiated — CONFIRMED, first try, no coordinator/relay
Setup: WG keypair per side (private keys generated on-box, 0600, never transited or logged);
Hetzner wgspike = 10.99.0.1/24, ListenPort 51820; demo-felhom wgspike = 10.99.0.2/24,
Endpoint 167.233.158.164:51820, PersistentKeepalive = 25, config under /tmp (nothing in
/etc/wireguard). PVE 9.2 needed nothing beyond modprobe wireguard (module ships in the kernel)
- the
wireguard-toolspackage.
demo-felhom# wg-quick up /tmp/wgspike.conf && ping -c 3 10.99.0.1
64 bytes from 10.99.0.1: icmp_seq=1 ttl=64 time=51.2 ms
3 packets transmitted, 3 received, 0% packet loss
rtt min/avg/max/mdev = 33.411/40.659/51.204/7.628 ms
Server-side view: peer endpoint 37.191.56.193:32873 (the NAT-translated source port), i.e. a
classic outbound UDP mapping — exactly the mechanism that also works through CGNAT.
Keepalive hold (idle test): the link carried zero data traffic for 11.4 minutes
(keepalives only — total transfer counters after the window: 1 888 B rx / 3 744 B tx). Result:
handshake stayed fresh throughout (age 120 s at check — keepalive-driven rekeys continuing on
schedule), the NAT mapping never moved (37.191.56.193:32873 unchanged), and the first ping after
the idle window went straight through:
3 packets transmitted, 3 received, 0% packet loss
rtt min/avg/max/mdev = 30.119/30.812/31.279/0.499 ms
The tunnel holds through idle with no help — the single mechanism CGNAT UDP timeouts would threaten, working as designed.
P2b — PBS :8007 through the tunnel, TLS pin intact — CONFIRMED (positive + negative)
A real PBS 4.2.2 was installed on the throwaway box (datastore scratch at /srv/pbs-scratch,
API token root@pam!spike, secret 36 chars — never logged; box firewalled to SSH + WG/iperf ports
only, so :8007 is not publicly reachable — tunnel-only by construction). From demo-felhom,
with PBS_FINGERPRINT = the scratch PBS leaf-cert SHA-256 (the exact pin model of
felhom-agent/internal/pbs/pin.go):
=== public :8007 reachability (must FAIL, firewall proof):
000 / UNREACHABLE (as intended)
=== tunnel :8007 API version (pin intact):
client version: 4.2.0
server version: 4.2.2
=== NEGATIVE pin test (wrong fingerprint, must FAIL):
WARNING: certificate fingerprint does not match expected fingerprint!
The exact-cert pin validates through the tunnel (WG is below TLS; the pinned leaf is
unchanged), and a wrong pin is detected — the red-proof for the pin surviving the transport
change. (Deploy gotcha found + fixed on the scratch box: a datastore under /root fails with
Permission denied — the PBS backup user can't traverse /root; use a world-traversable
parent like /srv.)
P3 — throughput — home uplink is the bottleneck; tunnel overhead ≈ 5% on the backup direction
Direct (no tunnel) iperf3 demo-felhom ⟷ Hetzner (two runs each):
UPLOAD (home→Hetzner): 42.1 / 42.0 Mbit/s sender (receiver 40.7 both runs)
DOWNLOAD (Hetzner→home): 203 / 230 Mbit/s (retransmits: 3 / 285)
Through the WG tunnel:
UPLOAD (tunnel): 39.1 Mbit/s sender / 38.4 receiver ← ~5% under direct
DOWNLOAD (tunnel): 565 Mbit/s, 0 retransmits ← FASTER than direct TCP
The download anomaly is real and repeatable-in-session: plain TCP from Hetzner→home tops out at ~200–230 Mbit/s with hundreds of retransmits, while the same TCP stream inside the WG/UDP encapsulation ran at 565 Mbit/s with zero retransmits — whatever middlebox/shaping hurts inbound TCP on this cable line does not see the UDP tunnel. Download is the restore direction; a pleasant surprise, not a design input. (Single-session observation; not load-tested further.)
PBS-native measurements over the tunnel (the actual workload):
proxmox-backup-client benchmark: TLS (maximal backup upload speed): 4.94 MB/s
AES256-GCM 3 904 MB/s, SHA256 2 152 MB/s (CPU is never the limit)
REAL backup, 2 GiB of /dev/urandom (worst case — incompressible, non-dedupable):
spike.pxar: had to backup 2 GiB of 2 GiB in 480.75 s (average 4.26 MiB/s)
Duration: 481.26s — sustained, zero stalls, saturating the uplink for 8 minutes straight
4.94 MB/s TLS-in-tunnel ≈ 41 Mbit/s = the full measured uplink: the tunnel + TLS stack costs effectively nothing against the line's own ceiling.
First-backup window math (at the measured ~40 Mbit/s ≈ 5 MB/s effective uplink): 10 GB ≈ 35 min, 50 GB ≈ 2.8 h, 100 GB ≈ 5.6 h — a realistic first backup fits an overnight window; incrementals (PBS dedup) are far smaller. The download direction (restore) is ~5× faster on this line.
P4 — H2: outbound UDP reality — 51820 AND 443/UDP both pass
UDP 51820 egress is proven by P2 itself. The same tunnel re-pointed at :443/UDP
(wg set … listen-port 443 server-side, endpoint updated client-side) handshook immediately and
carried pings at the same RTT:
3 packets transmitted, 3 received, 0% packet loss
rtt min/avg/max/mdev = 30.300/32.612/35.044/1.938 ms
endpoint: 167.233.158.164:443
No TCP/443 fallback (DERP-over-HTTPS etc.) is needed on this ISP; and 443/UDP working means even a hypothetical 51820-blocking ISP has a first, zero-cost escalation (same protocol, friendlier port) before any TCP fallback machinery is justified.
P5 — H3: what Headscale actually buys — a fleet layer, not traversal
Headscale 0.29.2 stood up on the throwaway box (systemd service + /etc/headscale/config.yaml +
sqlite at /var/lib/headscale/db.sqlite): created users customer-a/customer-b, minted a
preauth key, inspected the config surface. Observations:
- Operational surface: a public control-plane service (TLS required — the embedded DERP
server refuses a non-HTTPS
server_url), a database to back up, user + preauthkey + node lifecycle, an ACL policy file, plus a client-side daemon (tailscaled) on every box. The default DERP config points at Tailscale's public derpmap (controlplane.tailscale.com/derpmap/default) — a third-party dependency unless you run your own DERP. - For hub-and-spoke backup it is redundant: traversal is already solved by rung H1 (the spoke dials a public endpoint). Headscale's real value is fleet mechanics — key rotation, node expiry, per-customer users/ACLs, automatic endpoint re-resolution, and DERP fallback for UDP-hostile lines (H2 — which this ISP does not need).
- Recorded gap: no client was joined to the throwaway headscale (joining = installing tailscaled on the production PVE host; declined). Surface inspection + docs only.
P6 — per-customer isolation
- Plain WG (recommended rung): isolation is inherent in hub-and-spoke: the server holds one
peer entry per box with
AllowedIPs = <that box's /32>— cross-peer traffic requires the server to forward between WG peers, which the endpoint's firewall simply does not allow (forwarding stays off; onlyINPUTto the PBS port is open on the WG interface). A box can reach the PBS API and nothing else; boxes cannot see each other. PBS-side tenancy = the existing per-customer namespace + token model (unchanged). - Headscale (if ever adopted): the same property must be written as ACL policy (per-customer users + a policy file allowing only spoke→PBS) — more expressive, but a policy to maintain and test rather than a topology that cannot express the attack.
P7 — guest-side WG refutation — hypothesis REFUTED: kernel WG works in the unprivileged guest; host placement stands on architecture, not infeasibility
Against the real guest 9201 (features: nesting=1,keyctl=1, unprivileged: 1):
# pct exec 9201 -- ls -la /dev/net/
ls: cannot access '/dev/net/': No such file or directory ← no TUN, as expected
# pct exec 9201 -- ip link add wgtest type wireguard
rc=0 ← SUCCEEDS
wgtest DOWN <POINTOPOINT,NOARP>
# pct exec 9201 -- ip link del wgtest ← removed immediately
Kernel WireGuard is not a TUN consumer — an unprivileged LXC with the stock golden features can
create a WG interface as long as the host kernel has the module. So the "no TUN → guest can't"
argument is dead. The host-placement recommendation stands on architecture instead (§4.2):
backup traffic originates on the host (the agent drives vzdump/PBS push with host-side
credentials), the tunnel must survive guest death/rebuild by construction (the cloudflared
precedent, 01 §7), and the guest must keep holding zero infra credentials (01 §3). A guest-side
WG key would violate the trust model, not the kernel. (/dev/net/tun absence does still block a
guest-side tailscaled, relevant only if the Headscale rung were ever chosen.)
3. The twelve §7 answers
- CGNAT or plain-NAT? Plain single-NAT with a fixed public IPv4 (
37.191.56.193, One Hungary catv-fixed); no IPv6 at all (no address, no egress, no RAs on the wire). The CGNAT premise did not hold on this line. - H0 (v6-direct)? N/A — no home IPv6. Keep the production endpoint dual-stack, but v4 is the planning baseline.
- H1 (plain WG through the NAT)? Yes — outbound-initiated,
PersistentKeepalive=25, zero coordinator/relay, held through an 11.4-minute fully-idle window with a stable NAT mapping, and carried the PBS API with the exact-cert pin validating through the tunnel (positive + negative pin proof, P2b). - Throughput? Tunnel costs ~5% on the ~40 Mbit/s uplink bottleneck; PBS-native TLS speed through the tunnel = the full uplink (4.94 MB/s); a real worst-case 2 GiB backup sustained 4.26 MiB/s for 8 min with zero stalls. First backup of 50–100 GB ≈ 3–6 h (overnight window); incrementals trivial.
- H2 (UDP blocked)? No — UDP 51820 and 443 both pass on this ISP. TCP/443 fallback is a contingency for other ISPs, not a launch requirement; note it as the escalation if a customer line ever proves UDP-hostile.
- H3 (Headscale needed)? Not for backup. It is a separable fleet-management layer (key lifecycle, ACLs, endpoint re-resolution, DERP fallback) purchasable later without re-architecting — the spoke config is the same WG primitive either way. Its cost: a public TLS control plane, a DB, tailscaled on every box, and (by default) a Tailscale-operated DERP dependency.
- Isolation? Plain WG hub-and-spoke: per-peer
/32AllowedIPs+ no forwarding on the endpoint = no lateral path by topology (P6). - Placement? Host — same placement as
cloudflared(agent-managed systemd service). The P7 probe refuted the infeasibility argument (kernel WG works in the guest), so the decision rests on: backups originate host-side, tunnel survives guest death, guest keeps zero infra creds. - Key custody + DR: mint the box's WG keypair at install into the agent state dir (0600,
felhom-agentuser), register the pubkey with the hub, and add the private key to the escrowedIdentityBundle(felhom-agent/internal/escrow/identity.go) so a re-provisioned box re-establishes its tunnel from the same recovery flow that already restores PBS identity. The endpoint's own pubkey+address are non-secret config in the hub record. - Where does the production endpoint live? A small public relay VM (this spike's CX23-class box ≈ €4–8/mo) — see §4.4 for the two variants (PBS-on-VM vs VM-as-rendezvous-relay with DooPlex PBS dialing out too). DooPlex itself cannot be the endpoint (no public IP/UDP).
- Trust model preserved? Yes — the box only ever dials out (UDP to the endpoint); no new inbound listener, no new inbound attack surface on the box; the endpoint exposes exactly one UDP port publicly. Operator-inbound access (B) and game-server ingress (C — public players are not tunnel members) are explicitly separate problems, out of scope here, and nothing in this design presumes them.
- Go/no-go: GO on rung H1 — plain WireGuard, host-side, box-dials-out, to a small public endpoint carrying PBS. Headscale/DERP only if (a) a real customer ISP proves UDP-hostile (H2) or (b) fleet size makes static peer config painful (H3). No pivot to an outbound-mTLS PBS proxy needed — the simplest thing worked.
4. Architecture recommendation (input to the production spec — NOT the spec)
4.1 Transport rung
Plain kernel WireGuard, one tunnel per box, box-initiated, PersistentKeepalive=25, endpoint =
a public relay VM. No coordinator, no relay, no mesh.
4.2 Placement
On the Proxmox host, as an agent-managed systemd service — the exact cloudflared
precedent (01 §7, internal/hub/cloudflared.go prober pattern). Interface + config under the
agent's ownership; guest untouched.
4.3 Identity & custody
Per-box keypair minted at install; private key 0600 in the agent state dir; pubkey → hub record;
private key → escrowed IdentityBundle. Server pubkey/endpoint = plain config from the hub.
Revocation = delete the peer entry at the endpoint (hub-driven, auditable).
4.4 The production public endpoint (the §1 corollary)
Two viable shapes, decision deferred to the production spec:
- (a) PBS on the public VM — simplest ops; storage cost scales on the VM; client-side encryption means at-rest exposure is ciphertext-only anyway.
- (b) VM as dumb rendezvous relay — DooPlex PBS also dials out a WG spoke to the VM; the VM forwards spoke↔PBS traffic and stores nothing. Keeps bulk storage on DooPlex's existing disks; the VM needs only bandwidth; both sides remain outbound-only (this also solves DooPlex's own no-public-UDP problem). Ciphertext-only transits the VM. Either way the endpoint is small, dual-stack, firewalled to WG-UDP-only, and is the thing the subscription's offsite promise runs through — it becomes managed infrastructure, not a lab box.
4.5 Isolation
Per-peer /32 AllowedIPs; endpoint forwarding off; WG-interface firewall admits only the PBS port.
Per-customer PBS namespaces/tokens unchanged on top.
5. Scope caveats + NOT-validated list
- The CGNAT claim is NOT proven by this spike — the operator's line turned out to be plain-NAT. What is proven: outbound-initiated WG through a NAT with keepalive, which is the same mechanism CGNAT permits (CGNAT breaks inbound and p2p, not outbound-to-public). The honest residual risk is an ISP that breaks long-lived UDP mappings aggressively — exactly what the deferred second-ISP vantage (Peti's Debian VM 110, currently unreachable — "No route to host") should confirm once fixed. Follow-up recorded.
- Single ISP (One Hungary catv), single evening, single geography. No long-horizon (days) hold data; no home-IP-rotation event observed (WG roaming handles it by design — untested here).
- No Headscale client join (tailscaled kept off the production host); Headscale assessed on server-side surface + docs only.
- No real vzdump→PBS run over the tunnel (synthetic
proxmox-backup-clienttraffic only — real guest snapshots stay on the real PBS). - IPv6 absence is a One-Hungary-line fact, not a market fact; the endpoint should stay dual-stack.
- The tunnel-download anomaly (UDP encapsulation outrunning direct TCP) is a single-session observation — noted, not relied on.
- Restore-direction (PBS → box) bulk transfer was measured only as iperf3, not as a
proxmox-backup-client restorerun. - Relayed-mode (DERP) throughput was NOT measured — moot, since no relay is recommended.
6. Cleanup assertion
demo-felhom host (all verified after removal): wgspike interface deleted (wg-quick down,
"Device does not exist" confirmed); WG private key + PBS token secret shredded; /tmp spike
files (config, RA capture log, 2 GiB dataset) removed; the iperf3 package I installed purged;
wireguard kernel module unloaded (lsmod count 0); /etc/wireguard/ empty and untouched
(mtime Apr 2025); guest 9201's probe link wgtest deleted seconds after creation ("Device does
not exist" confirmed); no persistent config, unit, or package change remains from this spike
(wireguard-tools was already installed before the spike and was left as found).
Throwaway Hetzner box (felhom-hetzner, 167.233.158.164): WG interface down + config/private
key/PBS token shredded; headscale + PBS services stopped and disabled; headscale DB
(/var/lib/headscale) and the scratch datastore (/srv/pbs-scratch, random-data chunks only)
deleted; then the box was powered off (shutdown -h). Disposition: kept powered-off for
the deferred second-ISP follow-up (Peti's VM 110) — the operator can start it from the Hetzner
console for that probe or destroy it outright; nothing secret survives on it either way. The live
jarrs.eu Hetzner server was never touched.
No repo/hub/agent/manifest production change — this commit is docs-only.
Backlog resolution
runbooks/day0-install.md "PBS host is LAN-only until the Headscale/WireGuard work lands" → now
points at this spike: the transport decision is made (plain WG to a public endpoint); what
remains is the production connectivity spec + implementation (endpoint VM, agent-managed WG
service, escrow join, hub peer registry).