0051 — Outbound dial-out (connector) transport mode
0051 — Outbound dial-out (connector) transport mode
TL;DR. A server behind NAT can dial out to a self-hosted relay and hold one persistent QUIC tunnel; the relay bridges each consumer in as one bidirectional stream, splicing bytes opaquely. Auth reuses bearer-token plus fingerprint pinning on both legs, and the server still verifies every consumer’s own token. This ADR fixes the tunnel shape and blesses a test-only spike; no production code or spec bytes change yet.
Status: Accepted Date: 2026-07-15
Update 2026-07-21: accepted on merge of PR #238 per bead phux-1cfx. Open questions 1, 4, and 5 are settled by ADR-0052; the productization epic is bead phux-8lyr.
Context
ADR-0037 dissolved reachability below the wire and scoped relay and reverse tunnels out — but it marked the outbound reverse tunnel “Deferred, not rejected,” promising the ADR-0007 invariants keep a relay drop-in additive. This ADR (bead phux-1cfx) walks through that door for the self-hosting audience: a relay anyone can run themselves, with no overlay install on the consumer side.
Every mechanism already exists in-tree: outbound QUIC dialing with pinned
cert and token preamble (phux-dial), the supervised outbound-link pattern
with backoff and fail-closed planning (hub/link.rs,
ADR-0038), byte splicing (stdio-bridge),
and consumer auth (ADR-0031).
The missing piece is purely directional: today the server only accepts.
Decision
- Topology. A connector inside the server dials a relay and holds
one persistent QUIC connection under hub-link supervision (backoff
500 ms/30 s, fail-closed
plan_link-style gate, token file re-read per redial). Consumers dial the relay, which bridges each admitted consumer through the held connection. The relay is a rendezvous, not a peer: no sessions, no minted frames, no acks. - Stream discipline (the load-bearing shape). The connector leg gets
its own ALPN,
phux-relay/1— reserved normatively here and now owned byphux_protocol::policy::QUIC_RELAY_ALPN(bead phux-zwuz). Connector-initiated stream 0 carries only the connector’s auth preamble, reserved for future control use. Every relay-initiated bidi stream is exactly one bridged consumer — QUIC stream-ID parity disambiguates control from consumers with zero in-band protocol. This discipline is the single hardest thing to retrofit: a flat pipe would make multiplexing, per-consumer identity, and inner E2E channels a connector-leg wire break against deployed relays. - What the tunnel carries. The identical length-prefixed frame stream,
byte-opaque at the relay past its own handshakes —
ADR-0007 hub semantics,
one layer down: the relay does not even parse. No new frame types, no
docs/spec/change, no version bump; HELLO and frame dispatch run unmodified. - Auth: two independent tokens, zero new primitives.
- Connector to relay: inverted ADR-0038 pairing. The relay enrolls
(mints an opaque 32-byte token, shows its cert SHA-256 fingerprint
once, out-of-band); the connector dials with
CertTrust::Pinnedand a token file (0o600, never inline or printed), fail-closed off-loopback. - Consumer: authorization is the server-minted ADR-0031 pairing
token: each consumer stream begins with the consumer’s bearer-token
preamble, passed through the relay opaquely and verified against the
server’s own
TokenStore. Relay auth never substitutes for server auth: a compromised relay holds no credential the server accepts. (Relay-side admission gating is optional defense-in-depth.)
- Connector to relay: inverted ADR-0038 pairing. The relay enrolls
(mints an opaque 32-byte token, shows its cert SHA-256 fingerprint
once, out-of-band); the connector dials with
- Identity. No new variant. The relay sits below L1 exactly like
ADR-0037’s overlay — reachability, not topology;
LOCAL/SATELLITEtagging is unaffected. A bridged consumer’sPeerIdentityis an ordinary remote consumer (transport: Quic);source_addrhonestly carries the relay’s address (client-address attestation deferred). - Trust honesty. Both TLS legs terminate at the relay: the relay sees plaintext frames — VT output, keystrokes, paste payloads. A relay host is inside the trust boundary of everyone tunneling through it; self-hosting is the only mitigation today. End-to-end encryption is deferred; item 3’s byte-opacity keeps it an additive drop-in.
- Scope: test-only spike, production delta zero. One black-box test,
crates/phux-server/tests/relay_connector_spike.rs: a stub relay (in-test quinn endpoint, pure byte splice), a connector test task (a hand-rolled in-test quinn client — string-literalphux-relay/1ALPN plus a faithful pinned-fingerprint verifier crib, becausephux_dial::quic::dialhardcoded the consumer ALPN before phux-zwuz addeddial_with_alpn— per-stream splice onto the server UDS, consumer preamble checked connector-side before splicing), and a consumer driving the full handshake. No new deps, no production source touched.
Why
- Reuse over invention: this ADR adds an arrow direction, not a mechanism.
- QUIC-native stream multiplexing isolates consumers without in-band mux
frames, keeping the relay byte-opaque and
docs/spec/untouched. - Two-token separation makes “anyone can run a relay” safe: the relay is a reachability service, not an authority.
- Byte-opacity keeps the deferred E2E door open — 0037’s move, one layer up.
- The spike de-risks the one real unknown — a two-leg splice preserving handshake and frame semantics under existing timeouts.
Tradeoffs
- The relay sees plaintext; mitigated only by self-hosting until E2E lands.
- A second always-on hop adds latency and a failure domain; supervisor backoff means reconnect, not transparent resumption.
- Server-side rate limiting and audit see the relay, not the client.
- Spike-only tolerance: bridged consumers surface with the connector’s local UDS uid — unacceptable in production (see open questions).
- No user-visible feature ships; deliberately a design record plus proof.
Alternatives
- Single flat spliced tunnel — rejected: retrofitting stream discipline is a connector-leg wire break (Decision 2).
- In-band mux frames over one stream — rejected: reinvents QUIC streams, forces a spec bump, gives the relay a reason to parse.
- Relay-minted consumer tokens — rejected: makes the relay an admission authority; changing later invalidates deployed credentials.
- New
RELAYidentity variant — rejected: conflates reachability with topology (ADR-0037 precedent). - SSH-class carve-out on the connector leg — rejected: the relay leg is a TLS transport; ADR-0038’s token-plus-pin fail-closed rule applies.
- TURN/MASQUE — deferred: heavier than needed; precludes nothing later.
- Do nothing (overlay-only) — rejected as permanent by 0037 itself.
Must-not-preclude invariants for the spike
(1) The relay never parses past its own handshakes; (2) exactly one
consumer per relay-initiated bidi stream, never on stream 0; (3) consumer
authorization is server-verified — relay admission never substitutes; (4)
no domain code names the relay; connector I/O stays behind phux-dial and
the transport seam; (5) FRAME_ACK stays protocol-layer end-to-end — the
relay never acks; (6) no identity variant added; (7) the connector leg
never reuses phux-quic/1 — phux-relay/1 even in the stub.
Related
- ADR-0007, ADR-0031, ADR-0037, ADR-0038 — all linked above where used.
- Bead phux-1cfx — this spike. Reuse surfaces:
crates/phux-dial,crates/phux-server/src/hub/link.rs.
Open questions
- Route identity on the relay (how a consumer names which tunneled server; client-URI shape) — settle before the implementation bead.
- Client-address attestation via stream 0 (an auth decision, not plumbing).
- Head-of-line coupling: streams share the tunnel’s congestion and idle state — acceptable, or connection-per-consumer on the relay leg?
- Production landing: sibling of
hub/link.rs; doesIncominggain a “reverse accept” impl overaccept_bi()? Expected shape, not mandated. - Config surface: a
[[connector]]table vs alongside[satellites].