57ba3c7c8c
- ROADMAP: R-85 row. Code SHIPPED; rotation NOT YET OBSERVED LIVE, stated as such rather than written as done. - Capability map: a new row for UNATTENDED restore-proof, IMPLEMENTED not PROVEN-LIVE, kept distinct from the R-82 row that a MANUAL selftest earned. That distinction is the same one the activation-vs-arrival split made. - 03-host-agent §8: the scheduler covers every tier, oldest-proven first; the spec is per-run; a restore-test joins the one-heavy-op gate. The safety properties that must not be re-derived are listed. - 07: restore-proof recorded as a per-tier property. Doc still NOT ratified. - 06: corrects S4.1's 'the offsite restore-test now runs unattended' — it silently stopped being true when local_backup_target was retargeted to 'local', the SECOND time in that doc that a correct mechanism was broken by its input changing underneath it. - CONTEXT + REUSE. Hub gate green (17 packages, rc=0).
334 lines
32 KiB
Markdown
334 lines
32 KiB
Markdown
# Architecture Part 6 — Offsite Connectivity (the backup transport)
|
||
|
||
> Status: **design-of-record** (2026-07-03). Records the settled offsite-backup-transport
|
||
> decisions; grounded against felhom.eu @ `bf099f6` and felhom-agent @ `4ba1b14` (v0.63.0).
|
||
> Evidence base: `documentation/audits/SPIKE-connectivity-wireguard-2026-07-03.md` (all
|
||
> measurements cited below as "spike Pn"). This document ends in an implementation slice roadmap
|
||
> (§8); **no slice is implemented yet** — each is a separate future task. Consistent with and
|
||
> extending Part 01 (`01-topology-and-trust.md`); where this doc names a component's duty it uses
|
||
> Part 01 §3 vocabulary.
|
||
|
||
---
|
||
|
||
## 1. Why (the gap)
|
||
|
||
Offsite backup — the paid DR substrate (01 §8) — is structurally unreachable for a real customer
|
||
today. Backup is an **outbound push to PBS** (01 §7: "Outbound only for control/report/backup"),
|
||
but the only PBS lives at a **LAN address** — `internal/pbs/client.go:25-26` documents the
|
||
`Server` field as literally `"192.168.0.180"`, port 8007. The operator's public edge is
|
||
Cloudflare-Tunnel → `nginx-internal` (`felhom.eu/manifests/hub.yaml:233`,
|
||
`ingressClassName: nginx-internal`) — HTTP(S) only; DooPlex has **no public IP and no public UDP**.
|
||
So offsite backup currently works only because the demo box shares a LAN with the PBS host; a
|
||
customer box cannot reach it at all. The DR crypto/identity side is done and drilled
|
||
(`SPIKE-dr-recipe-2026-06-16.md`); **this transport is the last missing piece**, and the spike
|
||
proved the cheapest transport suffices.
|
||
|
||
---
|
||
|
||
## 2. Decisions (settled — recorded, not re-litigated)
|
||
|
||
| # | Decision | Rationale (one line) | Rejected alternative |
|
||
|---|----------|----------------------|----------------------|
|
||
| D1 | **Transport = plain WireGuard**, box-dials-outbound, `PersistentKeepalive=25` | Backup is hub-and-spoke to a *public* endpoint; outbound UDP + keepalive traverses any NAT tier — spike P2 (first-try up, 11.4-min idle hold), P4 (UDP 51820 *and* 443 pass) | Headscale/DERP **for traversal** — solves the p2p problem we don't have; a TLS control plane + DB + tailscaled-per-box + third-party DERP dependency for nothing (spike P5). Stays available as a *separable future fleet layer*. |
|
||
| D2 | **Placement = customer host, AGENT-MANAGED** (`wg-felhom` systemd service reconciled from hub desired-state) | WG config is *ongoing reconciled desired-state* — the agent's job; the host-install script is one-shot and cannot react to hub changes (endpoint move, key rotation). Pattern exists: the agent already installs+enables host units via `configs/felhom-agent.sudoers:16-18` (`install … /etc/systemd/system/*.mount` + `systemctl enable --now`) | (a) install-script-owned static config — can't reconcile; (b) in-guest WG — *works* technically (spike P7: kernel WG needs no TUN) but violates 01 §3 (guest holds zero infra creds) and dies with the guest; backups originate host-side anyway. |
|
||
| D3 | **Endpoint = ONE shared public dual-stack cloud VM** (Hetzner/EU class; HU optional later) running **WG server + the offsite PBS**. It is **dumb and hub-driven** — no agent on it; the hub is the only thing that mutates its WG peer list | Mirrors the proven "hub holds the credential and drives external infra" pattern (`hub/internal/cloudflare/unblock.go` — hub-held CF token mutating WAF rules). Zero-knowledge client-side encryption (01 §8) makes the VM's location privacy-neutral | An agent on the endpoint — a whole trust tier for a box that only needs a peer list and a datastore. |
|
||
| D4 | **Hub = source of truth** for endpoint coordinates (DNS name, port, server pubkey) + the per-customer peer registry (customer pubkey → assigned tunnel `/32`), delivered over the **existing desired-state channel** | The channel is built for exactly this: heartbeat carries `DesiredGeneration` (`internal/hub/report.go:270`); the agent fetches the heavy state only on generation advance (`internal/desired/syncer.go:44-61`); `WireDesiredState` (`report.go:289-296`) already carries forward-compat sibling blocks (`RestoreDirective`, `StorageManifest`) — a `wireguard` block joins them | A parallel bespoke config channel. |
|
||
| D5 | **PBS layout = one datastore, per-customer NAMESPACES**, each namespace under its own client-side encryption key (already escrowed — 01 §8 key custody) | PBS-native multi-tenancy; crypto isolation without datastore sprawl; the hub already holds "PBS namespace" in the customer record (01 §6) and `WireDesiredState.PBSNamespace` already exists on the wire (`report.go:294`) | Per-customer datastores — operational sprawl with no isolation gain over namespace+key. |
|
||
| D6 | **Offsite PBS lives ON the cloud VM — NOT relayed through DooPlex** | Relaying funnels *every* customer's backup through the operator's one shared ~40 Mbit home uplink and couples all customer DR to home-connection uptime — non-scaling and a single point of failure. DC uplink is never the bottleneck (§5) | The spike's §4.4(b) rendezvous-relay shape — rejected by the operator for production. **DooPlex PBS → dev/demo only.** |
|
||
|
||
---
|
||
|
||
## 3. Architecture
|
||
|
||
### 3.1 Components & trust (extends 01 §3 / §5)
|
||
|
||
| | Customer host | Endpoint VM | Hub |
|
||
|---|---|---|---|
|
||
| Runs | `wg-felhom` systemd service (agent-reconciled) + the existing agent | WG server + PBS (one datastore, per-customer namespaces) + a minimal hub-driven peer-sync surface | peer registry + endpoint coords in the customer/host record |
|
||
| Holds | its own WG **private key** (0600, agent state dir) + PBS token + the pinned PBS fingerprint | WG server private key; the PBS ciphertext (zero-knowledge — 01 §10) | every box's WG **public** key + assigned `/32`; the endpoint-mutation credential; escrowed key material (unchanged, 01 §8) |
|
||
| Mutates | only its own WG interface/service | nothing on its own — peer list changes arrive from the hub | the endpoint's peer list; the desired-state the agent consumes |
|
||
| Never | accepts inbound (dials out only) | initiates into a box or the hub; holds plaintext | connects into a box (01 §4 — unchanged) |
|
||
|
||
New trust-boundary rows (extends 01 §5):
|
||
|
||
| Boundary | What crosses | Mechanism | Blast radius if breached |
|
||
|---|---|---|---|
|
||
| box ↔ endpoint VM | WG-encapsulated, TLS-pinned, client-side-encrypted PBS traffic | outbound UDP; per-peer `/32`; no inter-peer routing (§4.5) | that box's tunnel; ciphertext only |
|
||
| hub ↔ endpoint VM | peer-list mutations (add/remove pubkey+`/32`) | hub-held credential, minimal surface (mechanism = slice-1 design point, §7) | the peer list — an attacker can *disconnect* backups, not read them |
|
||
|
||
### 3.2 Hub data model (conceptual fields — not SQL, not wire-final)
|
||
|
||
- **Endpoint record** (one, for now): `dns_name`, `wg_port`, `server_pubkey`, `tunnel_subnet`,
|
||
`pbs_tunnel_ip` (the endpoint's in-tunnel address the PBS client dials), plus the
|
||
endpoint-mutation credential (out-of-band secret, never in desired-state).
|
||
- **Per-host peer entry** (joins the customer/host record, 01 §6): `wg_pubkey` (box-generated,
|
||
box-registered), `assigned_ip` (the box's tunnel `/32`, hub-assigned), `status`.
|
||
- **On the wire (S2-final — the shape S3 consumes; golden:
|
||
`hub/internal/api/testdata/desired-state-wireguard.golden.json`, agent copy must stay
|
||
byte-identical):** a `wireguard` key on the served desired-state, **merged at READ time** —
|
||
the stored `desired_json` stays a pure operator blob (the hub REJECTS an admin PUT containing
|
||
a top-level `wireguard` key), hosts without a peer get byte-identical pass-through, and old
|
||
agents drop the unknown key in `encoding/json` (live-proven on v0.63.0):
|
||
|
||
```json
|
||
"wireguard": {
|
||
"endpoint": {
|
||
"dns_name": "ep0.felhom.eu",
|
||
"wg_port": 443,
|
||
"server_pubkey": "<44-char base64>",
|
||
"pbs_tunnel_ip": "10.77.0.1"
|
||
},
|
||
"pubkey": "<the box's registered pubkey>",
|
||
"assigned_ip": "10.77.0.2/32"
|
||
}
|
||
```
|
||
|
||
Client-side `AllowedIPs`, `PersistentKeepalive=25`, and `MTU 1280` are deliberately NOT wire
|
||
fields — S3 agent constants derived from `pbs_tunnel_ip` + §4. Delivery needs **zero new
|
||
plumbing** — registration/re-key/unbind bump `DesiredGeneration`, the agent's existing syncer
|
||
fetches (`syncer.go:44-61`). Re-key replaces the pubkey IN PLACE and keeps the `/32` (stable
|
||
addressing); one bound peer per host is index-enforced.
|
||
|
||
### 3.3 Provisioning handshake (Day-0 join)
|
||
|
||
Plugs into the existing Day-0 flow (`runbooks/day0-install.md`) after agent enrollment (01 §6 —
|
||
"the agent enrolls first"); offsite stays retrofittable via the same path for boxes installed
|
||
before this ships (the drilled `--rescope-acl` posture is unaffected).
|
||
|
||
1. **Box (agent):** generates the WG keypair; private key 0600 in the agent state dir, never
|
||
leaves the box (except escrowed — §3.5).
|
||
2. **Box → hub:** registers `wg_pubkey` (new box-facing endpoint, slice-2; authenticated by the
|
||
box's existing hub identity).
|
||
3. **Hub:** assigns the `/32` from the tunnel subnet, stores the peer entry, bumps
|
||
`DesiredGeneration`.
|
||
4. **Hub → endpoint VM:** adds `{pubkey, /32}` to the WG server peer list (slice-1 surface).
|
||
5. **Box (agent):** next heartbeat sees the generation advance → fetches desired-state → writes
|
||
the WG config → `install` + `systemctl enable --now wg-felhom` (the sudoers `*.mount` pattern
|
||
of `felhom-agent.sudoers:16-18`, extended to this unit — slice-3).
|
||
6. **Box:** tunnel handshakes (outbound, keepalive); PBS storage is (re)pointed at
|
||
`pbs_tunnel_ip:8007` (§3.4); first backup runs.
|
||
|
||
Steps 3-4 and 5-6 are order-independent (WG tolerates either side appearing first); the reconcile
|
||
loop retries until both halves exist.
|
||
|
||
### 3.4 PBS over the tunnel — **SHIPPED 2026-07-04 (S4, agent v0.65-0.67)**
|
||
|
||
The PBS client config changes **only its `Server` value**: the endpoint's tunnel-internal address
|
||
(`pbs_tunnel_ip`, i.e. `10.77.0.1`) instead of a LAN IP. The TLS exact-cert pin
|
||
(`internal/pbs/pin.go:17-35`) sits **above** WG and is unchanged through the tunnel —
|
||
spike-proven both ways (P2b: pin validated through the tunnel; wrong pin rejected). Per-customer
|
||
namespace + per-customer box-born encryption key exactly as today (D5); the endpoint sees
|
||
ciphertext only (01 §10).
|
||
|
||
**Live-proven end-to-end 2026-07-04** (runbook §4a/§4b): a real vzdump of guest 9201 lands as
|
||
ciphertext (`root.pxar` crypt-mode `encrypt`) in ns `demo-felhom-01` on `felhom-offsite` over the
|
||
tunnel; the box lists + verifies **only its own namespace** under a `DatastoreBackup` token
|
||
(`felhom@pbs!demo-felhom-01`), cross-tenant access 403s. **One agent addition was required and made
|
||
(v0.67.0):** the PBS client became **namespace-aware** — `Snapshots` passes `?ns=`, `Verify` passes
|
||
`ns=` — because the ns-unaware datastore-root calls 403 for a per-tenant token; a whole-datastore
|
||
verify would have needed `Datastore.Verify` (~admin), breaking isolation. The confirmed minimal
|
||
tenant ACL (`DatastoreBackup` on `/datastore/<ds>/<ns>` to BOTH the user and the token — PBS privsep
|
||
= intersection) and its gotchas live in runbook §4a.
|
||
|
||
### 3.5 Key custody + DR re-establishment
|
||
|
||
The WG private key joins the **escrowed IdentityBundle**
|
||
(`internal/escrow/identity.go:24-27`, today `{TunnelToken, PBSToken}` → `+ WGPrivateKey`) — the
|
||
same R-wrapped, zero-knowledge age blob the DR recipe already consumes. On host-loss
|
||
re-provisioning (01 §9 restore mode): the box recovers the bundle with the customer's recovery
|
||
code, restores the WG key, and re-establishes the tunnel; hub-side the peer entry still exists
|
||
(or is re-added on re-registration — same handshake, §3.3). Revocation = hub deletes the peer at
|
||
the endpoint: the box is off the offsite network immediately, auditable, no box cooperation
|
||
needed.
|
||
|
||
**Offsite escrow CREATED — S4 Part 5 DONE (2026-07-04, operator-present; runbook
|
||
`RUNBOOK-offsite-escrow-create`).** `escrow-create -storage felhom-offsite -identity-bundle
|
||
-directive -upload` on felhom-pve minted a fresh R (operator-recorded off-transcript), wrapped the
|
||
offsite client key K + the auto-injected WG private key + the offsite PBS token under it, and
|
||
uploaded the opaque blobs to the hub. Independently verified in `host_escrow` (host `demo-felhom-01`:
|
||
`key_fingerprint` = the offsite key `b0:fe:2a…`, identity blob 499 B, directive with the non-secret
|
||
DR coords, `created_at` = now) — all with **zero knowledge of R**. `tunnel_token` intentionally empty
|
||
(the Cloudflare edge lives in the guest/controller, re-provisioned separately in DR). **S5 (DR
|
||
consume) is now UNBLOCKED:** R + the hub-stored blobs reconstruct K + the WG key (+ the offsite PBS
|
||
token). Note: `host_escrow` is one slot per host (last-write-wins) — the offsite escrow is the one
|
||
that matters for real DR (D6); the dev `felhom-pbs` key still lives on the box.
|
||
|
||
**Revocation semantics, agent side (S3-final):** the agent registers ONCE (key exists + no local
|
||
marker). A `wireguard` block absent from a PRESENT desired-state = revocation → the agent
|
||
disables the service, KEEPS its marker, and never re-registers on its own — revoked stays
|
||
revoked until the operator re-adds the peer (the heartbeat report carries the box's pubkey as
|
||
the recovery handle; **re-add via `POST /hosts/{id}/wg` with the global key** — the raw registry
|
||
add does not bump the host's generation). The only automatic re-registration is the
|
||
pubkey-mismatch (DR/re-key) path, backoff-bounded. Absent DATA (a failed fetch) is never a
|
||
teardown signal. **Rollout gate:** `wg_tunnel.enabled` defaults FALSE agent-side until the
|
||
production endpoint exists.
|
||
|
||
---
|
||
|
||
## 4. Robustness (production details beyond the spike)
|
||
|
||
- **4.1 Customer IP change = free, and explicitly NOT a DynDNS dependency.** The box dials out;
|
||
WG roaming re-handshakes from the new source address automatically. Nothing anywhere references
|
||
the customer's IP — the hub peer registry stores pubkey+`/32` only (§3.2). No customer-side DNS,
|
||
ever.
|
||
- **4.2 Endpoint referenced by DNS + a re-resolve watchdog — SHIPPED v0.66.0 (v4-pinned).** The
|
||
endpoint is `dns_name`, not a bare IP, so it can move. Trap: `wg-quick` resolves `Endpoint=`
|
||
**once** at service start — a later endpoint re-IP would strand running tunnels. The agent now
|
||
**resolves the A record itself and writes the v4 LITERAL into the conf** (`internal/wgtunnel`
|
||
`Resolver` seam, `LookupNetIP "ip4"` → lowest address, deterministic fleet-wide), and a
|
||
**watchdog** (`Manager.Watchdog`, loop-driven) re-resolves when the handshake age exceeds
|
||
`wg_tunnel.stale_after_seconds` (default 180): IP changed → re-render + restart (re-IP recovery);
|
||
IP same → no churn. Steady state does **zero DNS + zero execs** (the resolved IP is cached); a
|
||
DNS failure keeps the last conf (never a teardown). This also **closes the dual-stack trap
|
||
below** — pinning the A literal means the tunnel can never silently ride un-NATed IPv6. Endpoint
|
||
moves are rare, hub-announced, and self-heal on service restart even without the watchdog.
|
||
**Dual-stack resolution trap (2026-07-04 CGNAT smoke test).** The endpoint is dual-stack (A +
|
||
AAAA). On a box with working IPv6, `wg-quick` resolves and pins the **AAAA** at bring-up and the
|
||
tunnel rides **un-NATed IPv6** — functionally fine (often better: no NAT), but it means the
|
||
v4/NAT path is only exercised when v6 is absent. Two consequences: reasoning about "CGNAT
|
||
traversal" applies to the v4 fallback only; and the re-resolve watchdog must not silently flip
|
||
families mid-life in a way that strands a half-open v6 mapping. `AI_ADDRCONFIG` already gives the
|
||
safe default — a v4-only box never sees the AAAA — but a v6-capable box would prefer it. The
|
||
client MTU floor (§4.3) is family-agnostic (1280) so MTU correctness never depended on this;
|
||
and the **v4-pin above (v0.66.0) now resolves it outright** — the agent writes the A literal, so
|
||
the tunnel always takes the v4/NAT path regardless of the box's v6 capability. (Was tracked as an
|
||
open determinism question through S3.1; closed by S4's v4-pin.)
|
||
- **4.3 MTU/MSS — DECIDED: fleet-wide client MTU 1280 (agent v0.65.0, S3.1).** WG costs 60 bytes
|
||
on v4 / 80 on IPv6. The unit ships explicit **`MTU 1280`** — the **IPv6-minimum link MTU** (RFC
|
||
8200 guarantees every path carries ≥ 1280), so outer = 1280+60 = 1340 (v4) / 1280+80 = 1360 (v6),
|
||
both fitting every realistic path (mobile ~1400, DS-Lite ~1452, PPPoE 1492, clean 1500). Because
|
||
only the box's own PBS client uses the tunnel (locally-terminated TCP, no forwarded flows), the
|
||
low **interface MTU bounds both directions** — it caps box→PBS directly and advertises MSS =
|
||
MTU−40 to cap PBS→box — so **no MSS clamp is needed** and, critically, **the endpoint's `wg0` is
|
||
left untouched** (its sends are already capped by the box's advertised MSS). Zero live-endpoint
|
||
risk; one client constant. `1280` is also **family-agnostic**, so MTU correctness does **not**
|
||
depend on whether `wg-quick` resolves the endpoint's A or AAAA (§4.2). Noted so nobody adds
|
||
forwarding without an MSS clamp.
|
||
**Why 1280 and not 1420 (2026-07-04 CGNAT smoke test).** On a mobile-carrier uplink the outer
|
||
path MTU measured **~1400 B** (DF probe: 1400-byte packet passes, 1414 fails), so the previously
|
||
shipped **1420 black-holed bulk data** — ping and the WG handshake stayed healthy (small packets)
|
||
while the PBS TLS page returned **empty**. Field data: `MTU 1340` (= 1400 − 60) restored the full
|
||
PBS page, `1360` still failed; `1280` sits safely below both. This is **not a fixed-line-vs-mobile
|
||
split**: sub-1500 fixed-line paths — notably **DS-Lite (~1452 AFTR path)** and some PPPoE — would
|
||
also have black-holed at 1420, so one conservative floor covers all customers. It "looked green,
|
||
lost backups": the handshake and ping succeed at any MTU, so the failure is invisible until bulk
|
||
TCP (the backup) flows — which is exactly S4.
|
||
**Rejected alternatives.** *Auto-probe path MTU / per-connection-type policy* — a fragile moving
|
||
part (a probe that can fail, and must re-run on every path change: ISP switch, mobile failover) to
|
||
optimize **throughput**, which doesn't matter here (overnight backup, bound by the customer's home
|
||
uplink, not by ~10% MTU overhead). *MSS clamp* — unnecessary without forwarded flows. A fixed
|
||
conservative floor is strictly more robust. The interface MTU lives in the agent-rendered
|
||
`wg-felhom.conf` (`internal/wgtunnel` `clientMTU`); revisiting it is a deliberate constant change,
|
||
not an operational knob.
|
||
- **4.4 Keepalive.** `PersistentKeepalive=25` — spike-proven to hold the NAT mapping through an
|
||
11.4-minute fully-idle window (P2) at ~150 B/s of overhead traffic; **further proven through a
|
||
live mobile-carrier NAT for a 32-minute fully-idle soak, zero stalls** (2026-07-04 CGNAT smoke
|
||
test, §7).
|
||
- **4.5 Isolation.** Per-peer `/32` `AllowedIPs`; boxes cannot see each other (spike P6). PBS tenancy
|
||
on top: namespace + per-customer token + per-customer key (D5).
|
||
**AMENDED 2026-07-05 (TASK H1 — OOB operator access).** Forwarding is no longer blanket-**off**; it
|
||
is **ON but per-pair allow-listed**. The endpoint runs `net.ipv4.ip_forward=1` (sysctl.d) and a
|
||
static forward posture: `ct established,related accept`; **per (operator, box) pair** `ip saddr
|
||
<operator/32> ip daddr <box/32> accept`; and **box↔box `iifname wg0 oifname wg0` DROP is now an
|
||
EXPLICIT rule** (previously implicit under the absent capability), backed by the base-chain `policy
|
||
drop`. These rules live in the endpoint's static nftables (NOT in `felhom-peersync`, which still
|
||
manages only the peer *list*). Net effect: the operator peer reaches a box's `felhom-sshd`; boxes
|
||
still cannot reach each other or the operator (only conntrack replies flow). The box side adds a
|
||
second layer independent of the endpoint: a dedicated `felhom-sshd` on a claimed non-22 port, gated
|
||
by the host-local `inet felhom_oob` belt (reachable only from the operator `/32` over `wg-felhom`;
|
||
the customer's stock sshd on :22 is never touched). Live-proven: operator→box SSH works; a dummy
|
||
tunnel peer is dropped box↔box (counter); the operator `/32` is **rendered** into the box's
|
||
`wg-felhom` AllowedIPs so it survives the agent's self-heal ([OF-1]); PBS unaffected.
|
||
- **4.6 Tunnel health → hub.** The tunnel is a storage dependency, so it reports like one: no handshake
|
||
within ~3 keepalive periods → the offsite target reports unreachable → the existing alerting path
|
||
carries it. No new alarm channel.
|
||
**EXTENDED 2026-07-05 (TASK H1).** The agent's heartbeat now also carries an **`oob` stanza**
|
||
(`felhom_sshd_active`, `felhom_sshd_port`, `reachable`, `config_invalid`, `operator_peer_configured`,
|
||
`operator_key_configured`, `wg_handshake_age_s`) — the operator's "can I get into this box right
|
||
now, and if not, why" signal. It reaches the hub over HTTPS even when `felhom-sshd` or the tunnel is
|
||
DOWN (channel independence). The hub raises a transition-based **`oob_degraded`/`oob_recovered`**
|
||
warning (felhom-sshd down while the operator peer is configured, OR config invalid).
|
||
|
||
---
|
||
|
||
## 5. The endpoint VM (the one new managed-infra piece)
|
||
|
||
Runs three things, nothing else: the WG server (peer list = hub-written), PBS (one datastore,
|
||
per-customer namespaces), and the minimal peer-sync surface the hub drives. **How** the hub
|
||
mutates the peer list — a tiny authenticated push API on the VM vs the VM pulling a signed peer
|
||
manifest from the hub — is deliberately **left as the slice-1 design point** (§7); the trust
|
||
shape is fixed either way (hub holds the credential; the VM trusts only the hub; mirroring
|
||
`hub/internal/cloudflare/unblock.go`). Firewall posture per §4.5. Dual-stack (spike P1: keep v6
|
||
available even though the pilot line had none). No agent, no controller, no customer plaintext.
|
||
|
||
Bandwidth reality (why D6 is safe): the operator line measured 1 Gbit-class down / ~40 Mbit up
|
||
(spike P3) — a customer backup is bound by *that customer's own uplink*, and a restore by their
|
||
downlink; a DC-grade endpoint uplink is never the bottleneck, and customers never contend with
|
||
the operator's home line (the rejected relay's flaw).
|
||
|
||
Spike-earned deploy note for slice-1: a PBS datastore under `/root` fails (`backup` user cannot
|
||
traverse `/root`) — create datastores under a world-traversable parent (`/srv`).
|
||
|
||
---
|
||
|
||
## 6. Trust-model conformance (01 §3/§4 preserved)
|
||
|
||
- The box **only ever dials out** (UDP to the endpoint; the same posture as hub-poll and
|
||
PBS-push, 01 §4/§7). No inbound listener is added to any customer box; the hub still never
|
||
initiates into a box.
|
||
- The endpoint VM cannot reach into boxes either: it terminates tunnels whose per-peer routes
|
||
(§4.5) admit only box→PBS traffic; it holds no box credentials.
|
||
- **Out of scope, explicitly:** (a) operator-inbound access to boxes — a *separate* future
|
||
problem with its own trust decision; nothing here creates or presumes it; (b) game-server
|
||
ingress — public players are not tunnel peers; that is a different ingress problem (public
|
||
reachability), not a backup-transport one.
|
||
|
||
---
|
||
|
||
## 7. Open validation + open sub-decisions (honest ledger)
|
||
|
||
**Open validation — CLOSED (2026-07-04, mobile-hotspot smoke test).** A phone-tethered
|
||
felhom-pve (agent v0.64.0) carried the live tunnel out through a mobile-carrier NAT and **held the
|
||
mapping across a 32-minute fully-idle soak** on `PersistentKeepalive=25` alone: 64 samples, max
|
||
handshake age 125 s (normal ~120 s rekey cadence), **zero stalls** (no sample > 180 s), rx counter
|
||
growing on keepalive traffic only, and a final in-tunnel ping after 32 min idle succeeding with **no
|
||
manual bounce**. PBS login page reachable over the tunnel once MTU was corrected (§4.3). Two honest
|
||
caveats, recorded not papered over: **(1)** this SIM was handed a **public mobile IPv4**
|
||
(`84.225.188.131`, Magyar Telekom), so the run exercised the phone's tether NAT + keepalive survival
|
||
— a real single-NAT traversal — but did **not** reproduce true CGNAT `100.64/10` double-NAT; that
|
||
stays a *retest-on-a-CGNAT-SIM-when-available* follow-up (low risk: mapping-hold is NAT-tier-agnostic
|
||
by mechanism). **(2)** a dual-stack mobile uplink made `wg-quick` prefer the endpoint **AAAA and ride
|
||
un-NATed IPv6** until v4 was forced — functionally fine, but see §4.2. The deferred second-ISP
|
||
vantage (Peti VM 110) remains the thorough confirmation but no longer gates anything. Runbook:
|
||
`RUNBOOK-s3-cgnat-smoke`.
|
||
|
||
**Open sub-decisions (deferred by design):**
|
||
- Endpoint peer-sync mechanism (push-API vs signed-manifest pull) — **slice-1 design point** (§5).
|
||
- WG key rotation cadence/mechanics — the reconcile channel can carry it (new pubkey → hub →
|
||
endpoint swap); design when fleet size warrants, possibly the moment the "separable fleet
|
||
layer" (D1) conversation reopens.
|
||
- Endpoint scaling/sharding (N customers per VM, second region, HU-hosted option) — capacity
|
||
planning, not architecture; the peer registry already keys per-host.
|
||
- Backup/DR **of the endpoint VM itself** (datastore redundancy, re-provision runbook) — slice-1
|
||
must at least write the re-provision runbook; PBS-side redundancy is a later economics call.
|
||
|
||
---
|
||
|
||
## 8. Implementation slice roadmap (each = a separate future task)
|
||
|
||
| Slice | Scope | Done = |
|
||
|---|---|---|
|
||
| **S1 — endpoint provisioning + peer-sync surface** — **SHIPPED 2026-07-04** (dev endpoint; hub v0.32.1). Peer-sync design point RESOLVED: **hub pushes over SSH** (`x/crypto/ssh`, `FixedHostKey` pin + pinned-type `HostKeyAlgorithms`) to a forced-command reconcile script; runbook `documentation/runbooks/offsite-endpoint.md`. Done-criterion met live: hub add/remove → `wg show` on the endpoint; tunnel `ep0.felhom.eu:443` → PBS through the wg0-only 8007 rule. | ~~provision the production VM…~~ (see runbook; production endpoint = a later re-run) | hub can add/remove a peer on the live endpoint via its credential; runbook re-creates the VM from nothing ✓ |
|
||
| **S2 — hub schema + desired-state** — **SHIPPED 2026-07-04** (hub v0.33.0). `POST /hosts/{id}/wg` (self-scoped) + merge-at-read `wireguard` block (§3.2 shape = the golden) + one-per-host index + re-key-in-place + `/offsite` UI page. Live-proven against the real felhom-pve record incl. old-agent (v0.63.0) tolerance. | ~~endpoint coords + peer registry…~~ | a registered box's desired-state carries its assignment ✓; hub UI shows the peer registry ✓ |
|
||
| **S3 — agent WG service (the agent-managed-unit pilot)** — **SHIPPED 2026-07-04** (agent v0.64.0; `wg_tunnel.enabled` DEFAULT FALSE until the production endpoint exists). `internal/wgtunnel`: keygen → marker-gated registration → `wg-quick@wg-felhom` reconciled from the served `wireguard` block (restart-not-reload; hash-gated zero-exec steady state); **revocation completed §3.5**: block absent from a PRESENT desired-state → disable + marker kept + never re-register (operator re-adds via the reported pubkey); absent DATA never tears down. `FELHOM_WG` sudoers (latest-handshakes is the ONLY wg read — `dump` leaks the private key); `IdentityBundle.WGPrivateKey` (pre-S3 blobs can't be retrofitted — S5 falls back to fresh-key re-register, /32 kept); report stanza = §4.6's tunnel-health input. Endpoint re-resolution: wg-quick resolves at unit (re)start; the manager restarts on conf change and the operator path covers re-IP (full §4.2 watchdog → S6 with tunnel-health alerting). Live on felhom-pve: up in 3 s, reboot-persistent, revocation drill, 30-min soak. | ~~keygen + registration…~~ | fresh box joins by §3.3 end-to-end ✓; tunnel survives agent restart + host reboot ✓; unhealthy-tunnel ALERTING → S6 (stanza shipped) |
|
||
| **S4 — PBS over the tunnel — SHIPPED 2026-07-04** (agent v0.65-0.67; runbook §4a/§4b). Endpoint per-customer tenancy (ns + privsep `DatastoreBackup` token, dual-grant user+token on `/datastore/<ds>/<ns>`, cross-tenant 403); box `felhom-offsite` PVE storage `Server=10.77.0.1`, box-born encryption key, token-secret WARN fix, `local_backup_target` retarget; agent **v0.66.0** v4-pin + re-resolve watchdog + FELHOM_WG Critical, **v0.67.0** namespace-aware PBS client. | ~~point the box's PBS storage at `pbs_tunnel_ip:8007`~~ | real vzdump of 9201 lands as ciphertext in ns over the tunnel ✓; ns-scoped verify=ok under the box's own token ✓; WARN gone ✓; restore decrypts with the box-born key ✓. Follow-ups: ~~restore-test scheduler WAN deadline + scratch `VM.Allocate`~~ **RESOLVED S4.1 (agent v0.68.0)** — tier-aware restore-task timeout (pbs→120m, local→10m); the offsite restore-test now runs unattended on the agent-token path (`pass:true`, teardown clean). **⚠️ That claim SILENTLY STOPPED BEING TRUE and was restored 2026-07-27 (R-85, agent v0.104.0) — for the second time in this doc, by the same mechanism: a correct fix whose INPUT changed underneath it.** S4.1 was verified while `local_backup_target` **was** `felhom-pbs`, so "the scheduler's one tier" and "the offsite tier" were the same thing. The R-82 target split retargeted it to `"local"`, and from then on `buildRestoreTestScheduler` built its only runner on the LOCAL tier: the offsite tier's archives were never candidates and it was **never restore-tested again**, unattended or otherwise, while this note still said it was. R-85 rotates across every configured tier (oldest-proven first) and makes a failure audible. The lesson is the same one the timeout taught: **a mechanism keyed on "the configured target" stops holding the moment there is more than one target.** The `VM.Allocate` item was a **phantom** — the earlier 403 was the 10m-timeout consequence (guest not yet in `/pool/felhom`); no ACL change. ~~Remaining: Tier-1-local-vs-Tier-2-offsite target split~~ **SHIPPED 2026-07-26 (R-82)** — agent v0.97.0 gave `BackupConfig` per-target tiers (`backup_targets[]`, each with its OWN cadence + retention), controller v0.174.0 reconciles them into ONE quiesce window, hub v0.76.0 judges each tier against its own threshold, host-install 1.20.0 defaults a fresh box to local-daily + offsite-weekly. Live: local 24h + `felhom-pbs` 168h on both demo boxes.
|
||
|
||
> ⚠️ **S4.1's tier-aware timeout silently REGRESSED and was restored 2026-07-26 (agent v0.100.0) — the mechanism was never removed, its INPUT changed.** S4.1 derived the tier with `storageTier(BackupTarget())`, which was correct while `local_backup_target` **was** `felhom-pbs`. The target split above retargeted it to `"local"`, so a `felhom-pbs:` archive was thereafter classified **local** and got the 10-minute bound again — reproducing the S4.1 symptom exactly (wait expires mid-restore → teardown fires at a not-yet-pool-associated guest → the same `403 missing privilege VM.Allocate`, and the same **phantom** reading of it). v0.100.0 derives the tier from **the archive's own storage** (`restoreTierForArchive`), which is input-independent. The lesson is not "add a timeout" — it was already there — but that a fix keyed on *"the configured target"* stops holding the moment more than one target exists. |
|
||
| **S5 — DR re-establishment — SAFE HALVES SHIPPED 2026-07-04 (agent v0.69.0); destructive drill operator-gated.** Part 1: `wgtunnel.InstallRecoveredKey` (escrow-recovered WG key → same identity/pubkey/`/32`, no keygen; create-only; `--selftest=identity-consume -install-wg-key`). Part 2: `internal/dr` consumes the host_loss `restore_directive` into an inspectable RestorePlan (per-guest restore coords+sizing, per-drive durable_id→mount) via the AddConsumer seam — derive-and-surface, execute nothing. Re-attach wrong-disk safety = `ResolveStorageDevice` (match resolves, mismatch REFUSED — unit-proven). | ~~consume `WGPrivateKey` in restore mode~~ | the tunnel re-establishes from the recovered key (same `/32`) ✓ (unit); the host_loss directive yields the correct PLAN ✓ (unit). **Operator-gated:** the destructive in-place 9201 restore (destroy→restore-from-offsite→re-attach-by-durable_id) is operator-present + STOP-gated — the operator runs the R-consume steps (CC never does). Pre-flight green (9201 offsite-restorable, S4.1). |
|
||
| **S6 — monitoring + isolation hardening** | endpoint-side alerting, per-peer firewall audit; ~~CGNAT smoke-test closure~~ **DONE 2026-07-04 (§7)** — mobile-carrier NAT traversal + 32-min keepalive hold proven; ~~§4.3 MTU decision~~ **RESOLVED (client MTU 1280, agent v0.65.0, §4.3)**; residual = true-CGNAT-SIM retest (low risk) | tunnel-down and peer-anomaly both alert; isolation verified adversarially |
|
||
|
||
**MVP that puts offsite backup in a real customer's hands = S1 → S2 → S3; then S4.** S5/S6
|
||
complete the story. The cloudflared service (`internal/hub/cloudflared.go:9-12` — today a
|
||
read-only prober that names itself "the seam for the tunnel-management slice") adopts the
|
||
S3-piloted agent-managed-service pattern in a later, separate slice.
|