Skip to content

Commit 7127791

Browse files
yyq1025claude
andcommitted
turn: wire Cloudflare TURN relay via daemon-sole-minter (#6)
STUN-only couldn't connect external testers on cellular/symmetric-NAT/ corporate networks. Add a TURN relay fallback, gated so minting (a billable resource) only happens for a party we can cryptographically verify. The signaling worker can prove a daemon's Ed25519 identity but NOT a client's, so the daemon is the SOLE minter and relays the creds to its clients over the existing offer: - signaling worker: POST /turn-credentials verifies a sig over `turn/v1/<pubkey>/<ts>` (distinct domain tag from the connect sig), mints from Cloudflare Realtime TURN (12h TTL), guarded by a separate RL_TURN limiter (10/60s by pubkey). Unset secrets → 503 → STUN fallback. - daemon: getIceServers() mints+caches (10h TTL, 60s negative-cache, single-flight, prewarmed in start()), uses the creds for its pc AND relays the same list in the offer envelope. - app: handleOffer applies the relayed iceServers via setConfiguration before createAnswer; the client never mints its own. fpSig still pins DTLS identity, so a tampering worker can't swap in a hostile relay. Wire-neutral (offer envelope is worker-forwarded freeform, not a protocol schema); rollout order-independent (old peers ignore the new field, unset secrets degrade to STUN). Activate by setting TURN_KEY_ID + TURN_API_TOKEN secrets, deploying the worker, and shipping daemon (menubar) + app. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent f46918a commit 7127791

6 files changed

Lines changed: 299 additions & 21 deletions

File tree

packages/app/src/lib/daemon-client.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -515,10 +515,13 @@ export class Transport {
515515
onPeerJoined: (peer) => {
516516
if (peer.role === "daemon") onDaemonAvailable(peer);
517517
},
518-
onOffer: (from, sdp, fpSig) => {
518+
onOffer: (from, sdp, fpSig, iceServers) => {
519519
daemonPeerId = from;
520+
// `iceServers` carries the daemon-minted TURN creds (it's the sole
521+
// minter). handleOffer applies them before it gathers ICE so our
522+
// candidates include the relay; absent them we stay STUN-only.
520523
void peer
521-
.handleOffer(sdp, fpSig)
524+
.handleOffer(sdp, fpSig, iceServers)
522525
.then(({ answerSdp, fpSig: ourSig }) => {
523526
signaling.send(from, "answer", { sdp: answerSdp, fpSig: ourSig });
524527
})

packages/app/src/lib/signaling-client.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,16 @@ export interface SignalingClientCallbacks {
3939
onPeerLeft?: (peer: SignalingPeer) => void;
4040
/** Daemon → client SDP offer. `fpSig` is the daemon's Ed25519 signature
4141
* over the SDP's DTLS fingerprint; the owner MUST verify it against
42-
* the QR-known daemon pubkey before calling setRemoteDescription. */
43-
onOffer?: (from: string, sdp: string, fpSig: string) => void;
42+
* the QR-known daemon pubkey before calling setRemoteDescription.
43+
* `iceServers` carries the daemon-minted TURN creds (the daemon is the
44+
* sole minter); the owner builds its peer connection with them. Absent
45+
* when the daemon couldn't mint — owner falls back to its STUN default. */
46+
onOffer?: (
47+
from: string,
48+
sdp: string,
49+
fpSig: string,
50+
iceServers?: RTCIceServer[],
51+
) => void;
4452
/** Trickle-ICE candidate from a peer. */
4553
onCandidate?: (from: string, candidate: unknown) => void;
4654
/** Worker-side error (`peer_not_found`, `missing_to`, etc.). */
@@ -193,7 +201,12 @@ export class SignalingClient {
193201
typeof msg.sdp === "string" &&
194202
typeof msg.fpSig === "string"
195203
) {
196-
this.opts.onOffer?.(msg.from, msg.sdp, msg.fpSig);
204+
// Daemon-relayed TURN creds (optional). Worker forwards the offer
205+
// envelope verbatim, so this is whatever the daemon attached.
206+
const iceServers = Array.isArray(msg.iceServers)
207+
? (msg.iceServers as RTCIceServer[])
208+
: undefined;
209+
this.opts.onOffer?.(msg.from, msg.sdp, msg.fpSig, iceServers);
197210
}
198211
return;
199212
}

packages/app/src/lib/webrtc-peer.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,10 +142,22 @@ export class WebRTCPeer {
142142
async handleOffer(
143143
offerSdp: string,
144144
daemonFpSig: string,
145+
iceServers?: RTCIceServer[],
145146
): Promise<{ answerSdp: string; fpSig: string }> {
146147
if (this.closed) throw new Error("WebRTCPeer is closed");
147148
this.setState("received-offer");
148149

150+
// 0. Apply the daemon-relayed TURN creds BEFORE we createAnswer — that's
151+
// when this (answerer) side starts ICE gathering, so the relay must be
152+
// in the config by now for our candidates to include it. The daemon is
153+
// the sole minter; absent creds (mint unavailable) we keep the
154+
// STUN-only default the constructor set. The fpSig check below still
155+
// pins DTLS identity, so a tampering worker can't swap in a hostile
156+
// relay without invalidating the signature.
157+
if (iceServers && iceServers.length > 0) {
158+
this.pc.setConfiguration({ iceServers });
159+
}
160+
149161
// 1. Verify daemon's signature over the SDP fingerprint before we
150162
// even acknowledge the offer. If signaling DO was compromised and
151163
// swapped the fingerprint, this catches it pre-DTLS.

packages/daemon/src/webrtc-peer.ts

Lines changed: 119 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,20 @@ const DEFAULT_ICE_SERVERS: RTCIceServer[] = [
6666
{ urls: "stun:stun.cloudflare.com:3478" },
6767
];
6868

69+
/**
70+
* How long the daemon reuses a minted TURN cred before re-minting. The
71+
* worker requests a 12h TTL from Cloudflare; we refresh at 10h so a
72+
* connection never gets a cred that's about to expire mid-session.
73+
*/
74+
const TURN_CACHE_TTL_MS = 10 * 60 * 60 * 1000;
75+
/**
76+
* Negative-cache window when minting fails (TURN unconfigured / outage).
77+
* Without it every `peer.joined` would re-hit `/turn-credentials` and pay
78+
* a round-trip before falling back to STUN; 60s lets an outage self-heal
79+
* fast while sparing the common steady-state from per-connection minting.
80+
*/
81+
const TURN_NEGATIVE_TTL_MS = 60_000;
82+
6983
/**
7084
* Max time from `peer.joined` to `authenticated` (= DataChannel open).
7185
* Normal path completes in well under 5s on a clean network; 30s gives
@@ -146,7 +160,15 @@ export class WebRTCPeerServer {
146160
private readonly isPairing: () => boolean;
147161
private readonly signalingHost: string;
148162
private readonly signalingScheme: "ws" | "wss";
163+
/** STUN-only fallback list used when TURN minting is unavailable. */
149164
private readonly iceServers: RTCIceServer[];
165+
/** Cached minted TURN ICE-server list (+ expiry). Null until first
166+
* fetch; on mint failure holds the STUN fallback with a short TTL. */
167+
private turnCache: { iceServers: RTCIceServer[]; expiresAt: number } | null =
168+
null;
169+
/** Single-flight guard so concurrent `peer.joined` events share one
170+
* mint round-trip instead of each firing their own. */
171+
private turnInFlight: Promise<RTCIceServer[]> | null = null;
150172

151173
constructor(options: WebRTCPeerServerOptions) {
152174
this.identity = options.identity;
@@ -198,6 +220,12 @@ export class WebRTCPeerServer {
198220
});
199221
});
200222

223+
// Warm the TURN cache so the first `peer.joined` doesn't eat a mint
224+
// round-trip in its setup budget. Fire-and-forget — getIceServers never
225+
// throws (degrades to STUN), and a failure here just means the first
226+
// connection re-attempts the mint.
227+
void this.getIceServers();
228+
201229
// Fire-and-forget. Daemon shouldn't refuse to start just because
202230
// signaling.sidecode.app is momentarily unreachable — PartySocket's
203231
// infinite retry will eventually connect when the network heals, and
@@ -313,7 +341,11 @@ export class WebRTCPeerServer {
313341
this.log("peer.paired", { clientId: peer.id, fingerprint });
314342
}
315343

316-
const pc = new RTCPeerConnection({ iceServers: this.iceServers });
344+
// Mint/reuse TURN creds for THIS connection and reuse the exact same
345+
// list for the client (relayed in the offer below) so both ends agree
346+
// on relays. Falls back to STUN-only if minting is unavailable.
347+
const iceServers = await this.getIceServers();
348+
const pc = new RTCPeerConnection({ iceServers });
317349
const slot: PeerSlot = {
318350
clientId: peer.id,
319351
clientPubkey: peer.pubkey,
@@ -383,8 +415,13 @@ export class WebRTCPeerServer {
383415
Buffer.from(dtlsFingerprintTranscript(fp)),
384416
this.identity.privateKey,
385417
).toString("base64url");
418+
// Relay the SAME ICE-server list (incl. minted TURN creds) the daemon
419+
// used — the client is not a verified party so it never mints its own;
420+
// it just uses what the signed daemon hands it. fpSig still pins the
421+
// DTLS identity, so a tampering signaling worker can't swap in a
422+
// malicious relay without breaking the fingerprint signature.
386423
this.signaling?.send(
387-
JSON.stringify({ to: peer.id, type: "offer", sdp, fpSig }),
424+
JSON.stringify({ to: peer.id, type: "offer", sdp, fpSig, iceServers }),
388425
);
389426
} catch (err) {
390427
this.log("peer.offer.error", {
@@ -653,6 +690,86 @@ export class WebRTCPeerServer {
653690
this.log("peer.closed", { clientId: slot.clientId, reason });
654691
}
655692

693+
// ─── TURN credentials ───────────────────────────────────────────
694+
695+
/**
696+
* Effective ICE-server list for a new peer connection: minted TURN
697+
* (STUN + relay) when available, else the STUN-only fallback. Cached +
698+
* single-flighted so a burst of `peer.joined` events shares one mint.
699+
* Never throws — a TURN outage degrades to STUN, it doesn't break
700+
* connections (they just won't traverse symmetric NAT).
701+
*/
702+
private getIceServers(): Promise<RTCIceServer[]> {
703+
const now = Date.now();
704+
if (this.turnCache && this.turnCache.expiresAt > now) {
705+
return Promise.resolve(this.turnCache.iceServers);
706+
}
707+
if (this.turnInFlight) return this.turnInFlight;
708+
709+
const p = this.fetchTurnCredentials().then((turn) => {
710+
if (turn) {
711+
this.turnCache = {
712+
iceServers: turn,
713+
expiresAt: Date.now() + TURN_CACHE_TTL_MS,
714+
};
715+
return turn;
716+
}
717+
// Negative-cache the STUN fallback briefly so an outage / unconfigured
718+
// TURN doesn't re-mint on every connection.
719+
this.turnCache = {
720+
iceServers: this.iceServers,
721+
expiresAt: Date.now() + TURN_NEGATIVE_TTL_MS,
722+
};
723+
return this.iceServers;
724+
});
725+
this.turnInFlight = p;
726+
void p.finally(() => {
727+
if (this.turnInFlight === p) this.turnInFlight = null;
728+
});
729+
return p;
730+
}
731+
732+
/**
733+
* Mint TURN credentials from the signaling worker's `/turn-credentials`
734+
* endpoint. The daemon is the SOLE minter (clients get creds relayed in
735+
* the offer), so we prove pubkey ownership with an Ed25519 signature over
736+
* a `turn/v1/...` domain-tagged message — distinct from the signaling-
737+
* connect signature so neither is replayable as the other. Returns the
738+
* one-element ICE-server list on success, or null on any failure.
739+
*/
740+
private async fetchTurnCredentials(): Promise<RTCIceServer[] | null> {
741+
const scheme = this.signalingScheme === "wss" ? "https" : "http";
742+
const url = `${scheme}://${this.signalingHost}/turn-credentials`;
743+
const ts = Date.now();
744+
const sig = cryptoSign(
745+
null,
746+
Buffer.from(`turn/v1/${this.identity.publicKeyB64}/${ts}`),
747+
this.identity.privateKey,
748+
).toString("base64url");
749+
try {
750+
const res = await fetch(url, {
751+
method: "POST",
752+
headers: { "content-type": "application/json" },
753+
body: JSON.stringify({
754+
pubkey: this.identity.publicKeyB64,
755+
ts,
756+
sig,
757+
}),
758+
});
759+
if (!res.ok) {
760+
this.log("turn.mint_unavailable", { status: res.status });
761+
return null;
762+
}
763+
const data = (await res.json()) as { iceServers?: RTCIceServer };
764+
if (!data.iceServers) return null;
765+
this.log("turn.minted");
766+
return [data.iceServers];
767+
} catch (err) {
768+
this.log("turn.fetch_error", { error: (err as Error).message });
769+
return null;
770+
}
771+
}
772+
656773
// ─── Helpers ────────────────────────────────────────────────────
657774

658775
private knownClientByPubkey(pubkey: string) {

0 commit comments

Comments
 (0)