Ctrl K
ComunidadGuías…

proposals/2026-07-23-blind-relay.md

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# Blind relay - the off-LAN backstop for a genuinely-0%-punch host

## Goal

Make PearTune reach its host **even when the hole-punch never lands** (fully symmetric NAT / CGNAT
on both ends, a ~0%-punch user). The shipped Hyperswarm transport (#154-159) fixes the common
~12% carrier case by retrying forever and holding on, but no retry budget rescues a 0% punch. A
**blind relay** - one public box both ends can reach outbound - is the only thing that turns
"eventually, maybe" into "always". The phone<->host stream stays Noise-encrypted end to end, so the
relay carries only ciphertext plus metadata (which two keys talk, bytes flowing). It is transient
encrypted transit, never a copy of the library.

## Why - who this is for

DECISIONS 2026-07-22 (evening) measured the punch at 12% per attempt on Tim's Pixel over Google Fi,
each failure aborting at a deterministic ~10.5s with code `HOLEPUNCH_ABORTED`. Retries turned
"never" into "usually within a minute". But 12% is the *average*; a user on a genuinely symmetric
NAT sits at ~0% and the persistent swarm never lands. For them the app is simply broken off-LAN,
and the pitch ("your music, anywhere, no VPN") is false. The relay is the floor under that promise.

## Tier

**T3.** It is a new transport path for the live media stream and it introduces PeerLoom-run
infrastructure that carries user traffic (encrypted). Proposal + rollback + RCA readiness required.
It does **not** touch the wire protocol, the grant store, the firewall, or revoke - see below.

## The load-bearing question, resolved against the source

The draft asked: does hyperdht's `relayThrough` compose with Hyperswarm's `swarm.join`, or must the
phone drop to a raw `dht.connect(hostKey, {relayThrough})` fallback? **Answer: it composes natively,
and better than hoped.** Verified in `node_modules/hyperswarm@4.17.0/index.js`:

- The constructor takes `relayThrough` (line 28) and stores it as `toRelayFunction(relayThrough)`
  (line 62). For a plain key that becomes `(force, swarm) => (force || swarm.dht.randomized ? key : null)`
  (line 683) - i.e. **null unless forced**, so direct is always tried first.
- `_connect` passes `relayThrough: this._maybeRelayConnection(peerInfo.forceRelaying)` into
  `dht.connect` (lines 210-214).
- On connect error it sets `peerInfo.forceRelaying = true` and resets attempts when
  `shouldForceRelaying(err.code)` (lines 225-227). That predicate (line 670) fires on exactly
  **`HOLEPUNCH_ABORTED`**, `HOLEPUNCH_DOUBLE_RANDOMIZED_NATS`, and `REMOTE_NOT_HOLEPUNCHABLE` - the
  precise failure our diagnostics recorded.

So Hyperswarm already implements the exact escalation the draft wanted us to build by hand:
**direct-first per peer, and on a hole-punch abort, retry through the relay.** The phone change is
one option on the existing swarm - no raw `dht.connect` fallback, no second media path.

## The other finding: the host needs NO code change

The draft's phase 2 was "host: `dht.createServer({ relayThrough: RELAY_KEY })`". Verifying the
hyperdht relay handshake shows that is **unnecessary and counterproductive**:

- When the phone escalates it puts `relayThrough: {publicKey, token}` in its handshake payload
  (`lib/connect.js:455`). The host's server sees `remotePayload.relayThrough` and relays on that
  alone - `if (relayThrough || remotePayload.relayThrough)` (`lib/server.js:410`). It dials the
  relay itself (`hs.relaySocket = this.dht.connect(publicKey)`, `:664`) using the phone's token.
- `_relayConnection` sets `hs.relayToken = token` (`:663`) **before** the prepunch timer is armed,
  so the holepunch-abort path's teardown - `if (hs.relayToken === null) hs.rawStream.destroy()`
  (`:428`) - is a no-op for a relayed stream, and the successful-pair handler clears the prepunch
  timeout (`:682`). A stock host correctly *sustains* a relayed connection it never configured.
- If instead the host set its own `relayThrough`, `:410` is always true, so it would relay **every**
  connection - burning a relay dial even when the direct punch works. We do not want that.

So the phone requesting the relay is both **sufficient and optimal**, and the direct-first decision
lives entirely on the phone where Hyperswarm already makes it. **No host redeploy is on the critical
path for the relay to work** (any host already on hyperdht 6.33 follows the phone's request). This
removes the host-image / Umbrel-redeploy gate the draft assumed.

## The relay mechanism (blind-relay), precisely

`relayThrough` is not "a plain DHT node forwards by default" - a plain node does **not** relay. It
is [`blind-relay`](holepunchto/blind-relay), a Protomux-channel TURN-like pairing service that
hyperdht drives. On escalation both ends `dht.connect(RELAY_KEY)` and run a `blind-relay.Client`
that `.pair(isInitiator, token, rawStream)`s on a shared token (`lib/connect.js:793-838`,
`lib/server.js:646-705`). The relay matches the two half-connections by token, allocates a UDX
stream pair, and forwards bytes. It never holds a key to the Noise session, so it sees ciphertext
only.

Direct-first is preserved even *after* escalation: `confirmDirectUpgrade`
(`lib/relay-connection.js:20`) watches for a direct path and tears the relay down if a punch later
lands, so a relayed session upgrades itself to direct for free if the network ever allows it.

## The relay NODE (the actual new work)

A small always-on daemon on a box with a **public routable IP** (a VPS - not an Umbrel/Start9,
which are behind home NAT and cannot be the relay). It is:

- A `hyperdht` node listening on a fixed keypair (the **PeerLoom relay key**), plus a
  `blind-relay.Server` whose `createStream` allocates raw UDX streams (`dht.createRawStream()`).
  On each inbound `createServer` connection it calls `server.accept(conn, { id: conn.remotePublicKey })`
  and lets blind-relay pair by token. ~100-150 lines of Node; no Autobase, no per-user state, no
  music - strictly simpler than PearCircle's blind *seeder*.
- **App-agnostic.** One relay key can back PearTune, PearCal, and PearCircle - a blind byte-forwarder
  cares nothing about the app. We build and own it for PearTune first; sharing it later is a config
  change, not a rebuild.
- Deployed with the **seeder-launcher operational pattern** (an always-on Node service, a tiny
  status page showing `server.stats` / `dht.stats.relaying`, a pinned redeploy script per
  `[[deploy-via-runnable-script]]`), but as a plain Node daemon - the relay needs no Bare worklet.
- Its **public key is a baked-in constant** in the app (and available to the suite). One key,
  discovered by nobody, dialed by both ends.
- **Abuse posture for v1:** it forwards ciphertext for anyone who presents a token, so it is an open
  relay by construction. v1 ships with a connection/bandwidth cap and stat visibility; a per-key
  allow-list or accounting is a later iteration, called out in RCA-readiness, not built now.

## What stays byte-for-byte identical

Everything above the socket. The `blind-relay` connection hands back the **same** UDX stream and
`remotePublicKey` a direct `dht.connect` does - identical to why the Hyperswarm swap was tractable.
So Protomux, the PAIR and MEDIA channels, `client.attach`, grant gating, `serveMedia`, the
revoke-kill registry, and streaming are all unchanged. **Only socket acquisition changes**, and only
on the fail path.

## Revoke and the grant model still hold

The host's `firewall` hook runs on the relayed connection exactly as on a direct one (admission is
end-to-end Noise; the relay cannot forge a device key). The `deviceKey -> Set<connection>` revoke
registry destroys a relayed connection the same way it destroys a direct one. The relay weakens
neither of "the two rules that matter most". A relay carries encrypted bytes transiently; the "no
cloud copy of your files" line stays true and should be stated precisely as *transient encrypted
transit, not storage*.

## Phases

1. **Relay node.** Build the `hyperdht` + `blind-relay.Server` daemon. Generate the PeerLoom relay
   keypair (public key -> constant; private key -> the node only). Deploy to a public VPS via a
   pinned redeploy script. **Verify:** two NAT'd test peers that cannot punch each other connect and
   move bytes through the relay (a standalone script on a testnet, then over the real DHT).
2. **Phone.** Add `relayThrough: RELAY_KEY` to the one `new Hyperswarm(...)` at `src/bare.js:1226`.
   Bake the relay public key as a constant. Nothing else - Hyperswarm's `forceRelaying` does the
   direct-first escalation. Surface `dht.stats.relaying { attempts, successes, aborts }` in the
   Settings -> Connection diagnostics (#148) so the escalation is observable. **No host change.**
3. **Verify on a genuinely-0%-punch scenario.** The Pixel on the worst cell NAT, or a forced
   symmetric NAT. Success = it connects **via the relay** when direct never lands, and streams a
   track; and when direct *does* work, `relaying.successes` stays 0 (we did not relay a punchable
   host). Confirm revoke still cuts a relayed phone within a second.

## Rollback

Phase 2 is one option on the swarm: remove `relayThrough` and the phone stops relaying. The relay
node can be taken down independently - with no `relayThrough` requested, nothing ever routes through
it, so a dead relay degrades exactly to today's behavior (0%-punch users stay unreachable, everyone
else unaffected). No wire or grant change to unwind.

## RCA readiness / risks

- **The relay is a single point and an open forwarder.** If it is down, 0%-punch users lose the
  backstop (others unaffected). If it is abused, it burns our bandwidth. v1 mitigations: caps + stat
  visibility; a per-key allow-list is the named next step if abuse appears.
- **Metadata disclosure.** The relay sees device-key <-> host-key pairs and byte volumes. This is
  the standard relay disclosure and matches the "blind" precedent; state it honestly in user-facing
  copy, do not imply zero-knowledge.
- **`swarm.dht.randomized` always-relays.** A phone whose NAT is double-randomized relays every
  connection (not just on force). That is correct (it cannot punch anyway) but worth watching in the
  stats - it is the one case where direct-first yields immediately.

## Verify

`npm run verify` green before every PR. Phase 1 adds a relay-node smoke test (two-peer relayed
connect on a local testnet). Phase 3 is the hardware gate on the Pixel per the manual smoke in
CLAUDE.md, plus the revoke check on a relayed connection.