feat(tsconnect): add getIceServers() to use DERP nodes as WebRTC STUN

DERP servers run an integrated RFC 5389 STUN server (UDP 3478 by
default). Since tsconnect in the browser relays all traffic through
DERP over WebSocket and has no direct UDP path, WebRTC is the only
way to establish a direct peer-to-peer connection. getIceServers()
fetches the tailnet's DERPMap via LocalAPI and converts it to an
RTCIceServer[] list suitable for RTCPeerConnection({ iceServers }).

Unit tests cover parsing, port defaulting, and all filter conditions
(STUNPort < 0, empty HostName, non-200 status) — no WASM required.
Integration test verifies a live IPN returns at least one stun: URL.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-15 21:51:36 +00:00
co-authored by Claude
parent a502735fd3
commit 0790908ea2
5 changed files with 159 additions and 4 deletions
+97 -1
View File
@@ -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<Uint8Array>): Promise<Uint8Array> {
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/,
)
})
})
+28 -1
View File
@@ -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<string, Uint8Array[]>
@@ -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<RTCIceServer[]> {
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
}
+4 -1
View File
@@ -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 & {
+9 -1
View File
@@ -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", () => {
+21
View File
@@ -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<string, DERPRegion>
}
/** A TLS certificate and private key pair, both in PEM format. */
export type TLSCertKeyPair = {
certPEM: string