diff --git a/packages/tsconnect/src/helpers.test.ts b/packages/tsconnect/src/helpers.test.ts index 3af1d58..2dfd260 100644 --- a/packages/tsconnect/src/helpers.test.ts +++ b/packages/tsconnect/src/helpers.test.ts @@ -1,6 +1,7 @@ import test, { suite } from "node:test" import assert from "node:assert/strict" -import { InMemoryFileOps, InMemoryState } from "./helpers.js" +import { InMemoryFileOps, InMemoryState, getIceServers } from "./helpers.js" +import type { IPN } from "./types.js" async function readAll(stream: ReadableStream): Promise { const reader = stream.getReader() @@ -315,3 +316,98 @@ suite("InMemoryFileOps", () => { }) }) }) + +suite("getIceServers", () => { + function fakeIPN(response: { status: number; body: string }): IPN { + return { localAPI: async () => response } as unknown as IPN + } + + function derpMap(nodes: { HostName: string; STUNPort: number; STUNOnly: boolean }[]) { + return JSON.stringify({ + Regions: { + "1": { RegionID: 1, RegionCode: "nyc", RegionName: "New York City", Nodes: nodes }, + }, + }) + } + + test("returns stun: URLs for nodes with STUN enabled", async () => { + const body = JSON.stringify({ + Regions: { + "1": { + RegionID: 1, + RegionCode: "nyc", + RegionName: "New York City", + Nodes: [ + { HostName: "derp1.example.com", STUNPort: 3478, STUNOnly: false }, + { HostName: "derp1b.example.com", STUNPort: 0, STUNOnly: false }, + ], + }, + "2": { + RegionID: 2, + RegionCode: "lax", + RegionName: "Los Angeles", + Nodes: [{ HostName: "derp2.example.com", STUNPort: 1234, STUNOnly: true }], + }, + }, + }) + const servers = await getIceServers(fakeIPN({ status: 200, body })) + assert.deepEqual(servers, [ + { urls: "stun:derp1.example.com:3478" }, + { urls: "stun:derp1b.example.com:3478" }, + { urls: "stun:derp2.example.com:1234" }, + ]) + }) + + test("STUNPort 0 maps to default port 3478", async () => { + const servers = await getIceServers( + fakeIPN({ + status: 200, + body: derpMap([{ HostName: "derp.example.com", STUNPort: 0, STUNOnly: false }]), + }), + ) + assert.deepEqual(servers, [{ urls: "stun:derp.example.com:3478" }]) + }) + + test("skips nodes with STUNPort < 0 (STUN disabled on this node)", async () => { + const servers = await getIceServers( + fakeIPN({ + status: 200, + body: derpMap([ + { HostName: "no-stun.example.com", STUNPort: -1, STUNOnly: false }, + { HostName: "yes-stun.example.com", STUNPort: 3478, STUNOnly: false }, + ]), + }), + ) + assert.deepEqual(servers, [{ urls: "stun:yes-stun.example.com:3478" }]) + }) + + test("skips nodes with empty HostName", async () => { + const servers = await getIceServers( + fakeIPN({ + status: 200, + body: derpMap([ + { HostName: "", STUNPort: 3478, STUNOnly: false }, + { HostName: "derp.example.com", STUNPort: 3478, STUNOnly: false }, + ]), + }), + ) + assert.deepEqual(servers, [{ urls: "stun:derp.example.com:3478" }]) + }) + + test("returns empty array when all nodes are filtered out", async () => { + const servers = await getIceServers( + fakeIPN({ + status: 200, + body: derpMap([{ HostName: "", STUNPort: -1, STUNOnly: false }]), + }), + ) + assert.deepEqual(servers, []) + }) + + test("throws on non-200 status", async () => { + await assert.rejects( + getIceServers(fakeIPN({ status: 500, body: "internal error" })), + /localapi derpmap: 500/, + ) + }) +}) diff --git a/packages/tsconnect/src/helpers.ts b/packages/tsconnect/src/helpers.ts index 6096b53..00716a5 100644 --- a/packages/tsconnect/src/helpers.ts +++ b/packages/tsconnect/src/helpers.ts @@ -1,4 +1,4 @@ -import type { IPNStateStorage, UserIPNFileOps } from "./types.js" +import type { DERPMap, IPN, IPNStateStorage, UserIPNFileOps } from "./types.js" export class InMemoryFileOps implements UserIPNFileOps { #map: Map @@ -221,3 +221,30 @@ export class FsaFileOps implements UserIPNFileOps { await this.#dir.removeEntry(oldPath) } } + +/** + * Returns an `RTCIceServer[]` list built from the tailnet's DERP map, so the + * DERP servers' integrated STUN endpoints can be used as ICE STUN servers when + * establishing a WebRTC peer connection. + * + * Pass the result directly to `new RTCPeerConnection({ iceServers })`. + * + * Note: in the browser, `ipn.dial()` / `ipn.listen()` always relay through + * DERP — WebRTC is the only way to achieve a direct UDP path between peers. + */ +export async function getIceServers(ipn: IPN): Promise { + const result = await ipn.localAPI("GET", "/localapi/v0/derpmap") + if (result.status !== 200) { + throw new Error(`localapi derpmap: ${result.status} ${result.body}`) + } + const map: DERPMap = JSON.parse(result.body) + const servers: RTCIceServer[] = [] + for (const region of Object.values(map.Regions)) { + for (const node of region.Nodes) { + if (!node.HostName || node.STUNPort < 0) continue + const port = node.STUNPort === 0 ? 3478 : node.STUNPort + servers.push({ urls: `stun:${node.HostName}:${port}` }) + } + } + return servers +} diff --git a/packages/tsconnect/src/index.ts b/packages/tsconnect/src/index.ts index aaa2099..a052744 100644 --- a/packages/tsconnect/src/index.ts +++ b/packages/tsconnect/src/index.ts @@ -21,7 +21,7 @@ export { } from "./ipn.js" export type { DialerOptions, IPNRunOptions } from "./ipn.js" -export { InMemoryFileOps, InMemoryState, WebStorageState } from "./helpers.js" +export { InMemoryFileOps, InMemoryState, WebStorageState, getIceServers } from "./helpers.js" export type { TLSDialOptions, @@ -41,6 +41,9 @@ export type { IPNOutgoingFile, IPNWaitingFile, UserIPNFileOps, + DERPMap, + DERPRegion, + DERPNode, } from "./types.js" type GlobalWithIPN = typeof globalThis & { diff --git a/packages/tsconnect/src/ipn.test.ts b/packages/tsconnect/src/ipn.test.ts index c860dd7..e8bb979 100644 --- a/packages/tsconnect/src/ipn.test.ts +++ b/packages/tsconnect/src/ipn.test.ts @@ -4,7 +4,7 @@ import { existsSync } from "node:fs" import { readFile } from "node:fs/promises" import { fileURLToPath } from "node:url" import { join, dirname } from "node:path" -import { InMemoryState } from "./helpers.js" +import { InMemoryState, getIceServers } from "./helpers.js" import type { IPN, Conn } from "./ipn.js" import type { IPNConfig, UserIPNFileOps } from "./types.js" @@ -151,6 +151,14 @@ suite( const parsed = JSON.parse(body) as { Self?: { TailscaleIPs?: string[] } } assert.ok(parsed.Self?.TailscaleIPs?.length, "status has no self TailscaleIPs") }) + + test("getIceServers returns at least one stun: URL", async () => { + const servers = await getIceServers(ipn) + assert.ok(servers.length > 0, "expected at least one ICE server from DERP map") + for (const s of servers) { + assert.match(s.urls as string, /^stun:/, `unexpected URL format: ${s.urls}`) + } + }) }) suite("two-node dial / listen", () => { diff --git a/packages/tsconnect/src/types.ts b/packages/tsconnect/src/types.ts index ef86d56..e2a463d 100644 --- a/packages/tsconnect/src/types.ts +++ b/packages/tsconnect/src/types.ts @@ -234,6 +234,27 @@ export type IPNWaitingFile = { size: number } +/** A node entry in a {@link DERPRegion}. */ +export type DERPNode = { + HostName: string + /** Negative means STUN is disabled on this node; 0 means use the default port (3478). */ + STUNPort: number + STUNOnly: boolean +} + +/** A geographic region in the {@link DERPMap}. */ +export type DERPRegion = { + RegionID: number + RegionCode: string + RegionName: string + Nodes: DERPNode[] +} + +/** The complete DERP relay map returned by the LocalAPI `/localapi/v0/derpmap` endpoint. */ +export type DERPMap = { + Regions: Record +} + /** A TLS certificate and private key pair, both in PEM format. */ export type TLSCertKeyPair = { certPEM: string