@agentdance/node-webrtc-ice 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,175 @@
1
+ import * as crypto from 'node:crypto';
2
+ import type { CandidateType, IceCandidate, TransportProtocol } from './types.js';
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // RFC 8445 Section 5.1.2.1 – Candidate priority
6
+ // priority = (2^24) * type_pref + (2^8) * local_pref + (256 - component)
7
+ // type_pref: host=126, srflx=100, prflx=110, relay=0
8
+ // ---------------------------------------------------------------------------
9
+
10
+ const TYPE_PREFERENCES: Record<CandidateType, number> = {
11
+ host: 126,
12
+ prflx: 110,
13
+ srflx: 100,
14
+ relay: 0,
15
+ };
16
+
17
+ export function computePriority(
18
+ type: CandidateType,
19
+ localPref: number,
20
+ component: number,
21
+ ): number {
22
+ const typePref = TYPE_PREFERENCES[type];
23
+ return ((1 << 24) * typePref + (1 << 8) * localPref + (256 - component)) >>> 0;
24
+ }
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // RFC 8445 Section 5.1.1 – Candidate foundation
28
+ // Foundation is a string that groups candidates that have the same type,
29
+ // base address, protocol and STUN/TURN server.
30
+ // ---------------------------------------------------------------------------
31
+
32
+ export function computeFoundation(
33
+ type: CandidateType,
34
+ baseAddress: string,
35
+ protocol: TransportProtocol,
36
+ ): string {
37
+ const input = `${type}:${baseAddress}:${protocol}`;
38
+ return crypto.createHash('md5').update(input).digest('hex').slice(0, 8);
39
+ }
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // RFC 8445 Section 6.1.2.3 – Candidate pair priority
43
+ // priority = 2^32 * min(G,D) + 2*max(G,D) + (G>D ? 1 : 0)
44
+ // ---------------------------------------------------------------------------
45
+
46
+ export function computePairPriority(
47
+ controlling: IceCandidate,
48
+ controlled: IceCandidate,
49
+ ): bigint {
50
+ const g = BigInt(controlling.priority >>> 0);
51
+ const d = BigInt(controlled.priority >>> 0);
52
+ const minVal = g < d ? g : d;
53
+ const maxVal = g > d ? g : d;
54
+ const gtFlag = g > d ? 1n : 0n;
55
+ return (1n << 32n) * minVal + 2n * maxVal + gtFlag;
56
+ }
57
+
58
+ // ---------------------------------------------------------------------------
59
+ // Parse candidate from SDP attribute value string
60
+ // a=candidate:<foundation> <component> <transport> <priority> <address> <port> typ <type> [...]
61
+ // ---------------------------------------------------------------------------
62
+
63
+ export function parseCandidateAttribute(value: string): IceCandidate {
64
+ const parts = value.trim().split(/\s+/);
65
+
66
+ if (parts.length < 8) {
67
+ throw new Error(`Invalid candidate attribute: ${value}`);
68
+ }
69
+
70
+ const foundation = parts[0]!;
71
+ const component = parseInt(parts[1]!, 10) as 1 | 2;
72
+ const transport = parts[2]!.toLowerCase() as TransportProtocol;
73
+ const priority = parseInt(parts[3]!, 10);
74
+ const address = parts[4]!;
75
+ const port = parseInt(parts[5]!, 10);
76
+ // parts[6] should be 'typ'
77
+ const type = parts[7]! as CandidateType;
78
+
79
+ const candidate: IceCandidate = {
80
+ foundation,
81
+ component,
82
+ transport,
83
+ priority,
84
+ address,
85
+ port,
86
+ type,
87
+ };
88
+
89
+ // Parse optional extension attributes
90
+ let i = 8;
91
+ while (i < parts.length - 1) {
92
+ const key = parts[i]!;
93
+ const val = parts[i + 1]!;
94
+ switch (key) {
95
+ case 'raddr':
96
+ candidate.relatedAddress = val;
97
+ break;
98
+ case 'rport':
99
+ candidate.relatedPort = parseInt(val, 10);
100
+ break;
101
+ case 'tcptype':
102
+ candidate.tcpType = val as 'active' | 'passive' | 'so';
103
+ break;
104
+ case 'generation':
105
+ candidate.generation = parseInt(val, 10);
106
+ break;
107
+ case 'ufrag':
108
+ candidate.ufrag = val;
109
+ break;
110
+ case 'network-id':
111
+ candidate.networkId = parseInt(val, 10);
112
+ break;
113
+ }
114
+ i += 2;
115
+ }
116
+
117
+ return candidate;
118
+ }
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // Serialize candidate to SDP attribute value
122
+ // ---------------------------------------------------------------------------
123
+
124
+ export function serializeCandidateAttribute(candidate: IceCandidate): string {
125
+ let s =
126
+ `${candidate.foundation} ${candidate.component} ${candidate.transport} ` +
127
+ `${candidate.priority} ${candidate.address} ${candidate.port} typ ${candidate.type}`;
128
+
129
+ if (candidate.relatedAddress !== undefined) {
130
+ s += ` raddr ${candidate.relatedAddress}`;
131
+ }
132
+ if (candidate.relatedPort !== undefined) {
133
+ s += ` rport ${candidate.relatedPort}`;
134
+ }
135
+ if (candidate.tcpType !== undefined) {
136
+ s += ` tcptype ${candidate.tcpType}`;
137
+ }
138
+ if (candidate.generation !== undefined) {
139
+ s += ` generation ${candidate.generation}`;
140
+ }
141
+ if (candidate.ufrag !== undefined) {
142
+ s += ` ufrag ${candidate.ufrag}`;
143
+ }
144
+ if (candidate.networkId !== undefined) {
145
+ s += ` network-id ${candidate.networkId}`;
146
+ }
147
+
148
+ return s;
149
+ }
150
+
151
+ // ---------------------------------------------------------------------------
152
+ // Generate random ICE ufrag and password
153
+ // ---------------------------------------------------------------------------
154
+
155
+ const ICE_CHARS =
156
+ 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
157
+
158
+ function randomIceString(length: number): string {
159
+ const bytes = crypto.randomBytes(length);
160
+ let result = '';
161
+ for (let i = 0; i < length; i++) {
162
+ result += ICE_CHARS[bytes[i]! % ICE_CHARS.length];
163
+ }
164
+ return result;
165
+ }
166
+
167
+ // 4 chars minimum per RFC 5245 §15.4
168
+ export function generateUfrag(): string {
169
+ return randomIceString(4);
170
+ }
171
+
172
+ // 22 chars minimum per RFC 5245 §15.4
173
+ export function generatePassword(): string {
174
+ return randomIceString(22);
175
+ }
@@ -0,0 +1,142 @@
1
+ import type {
2
+ CandidatePair,
3
+ IceCandidate,
4
+ IceRole,
5
+ } from './types.js';
6
+ import { CandidatePairState } from './types.js';
7
+ import { computePairPriority } from './candidate.js';
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // Pair ID generation
11
+ // ---------------------------------------------------------------------------
12
+
13
+ export function makePairId(local: IceCandidate, remote: IceCandidate): string {
14
+ return `${local.foundation}:${local.port}|${remote.foundation}:${remote.port}`;
15
+ }
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Form candidate pairs from local × remote candidates (same component)
19
+ // sorted descending by priority
20
+ // ---------------------------------------------------------------------------
21
+
22
+ export function formCandidatePairs(
23
+ localCandidates: IceCandidate[],
24
+ remoteCandidates: IceCandidate[],
25
+ role: IceRole,
26
+ ): CandidatePair[] {
27
+ const pairs: CandidatePair[] = [];
28
+
29
+ for (const local of localCandidates) {
30
+ for (const remote of remoteCandidates) {
31
+ if (local.component !== remote.component) continue;
32
+ // ts-rtc uses UDP-only transport; skip TCP remote candidates
33
+ if (remote.transport !== 'udp') continue;
34
+ if (local.transport !== 'udp') continue;
35
+ // ts-rtc binds a udp4 socket; IPv6 candidates will silently fail — skip them
36
+ if (remote.address.includes(':')) continue;
37
+
38
+ const controlling = role === 'controlling' ? local : remote;
39
+ const controlled = role === 'controlling' ? remote : local;
40
+ const priority = computePairPriority(controlling, controlled);
41
+
42
+ const pair: CandidatePair = {
43
+ id: makePairId(local, remote),
44
+ local,
45
+ remote,
46
+ state: CandidatePairState.Frozen,
47
+ priority,
48
+ nominated: false,
49
+ valid: false,
50
+ nominateOnSuccess: false,
51
+ retransmitCount: 0,
52
+ };
53
+
54
+ pairs.push(pair);
55
+ }
56
+ }
57
+
58
+ // Sort by priority descending
59
+ pairs.sort((a, b) => {
60
+ if (b.priority > a.priority) return 1;
61
+ if (b.priority < a.priority) return -1;
62
+ return 0;
63
+ });
64
+
65
+ return pairs;
66
+ }
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // Unfreeze the highest priority pair per component (RFC 8445 §6.1.2.6)
70
+ // ---------------------------------------------------------------------------
71
+
72
+ export function unfreezeInitialPairs(pairs: CandidatePair[]): void {
73
+ const seenComponents = new Set<number>();
74
+ for (const pair of pairs) {
75
+ if (
76
+ !seenComponents.has(pair.local.component) &&
77
+ pair.state === CandidatePairState.Frozen
78
+ ) {
79
+ pair.state = CandidatePairState.Waiting;
80
+ seenComponents.add(pair.local.component);
81
+ }
82
+ }
83
+ }
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // Find pair by address/port tuples
87
+ // ---------------------------------------------------------------------------
88
+
89
+ export function findPairByAddresses(
90
+ pairs: CandidatePair[],
91
+ localAddr: string,
92
+ localPort: number,
93
+ remoteAddr: string,
94
+ remotePort: number,
95
+ ): CandidatePair | undefined {
96
+ return pairs.find(
97
+ (p) =>
98
+ p.local.address === localAddr &&
99
+ p.local.port === localPort &&
100
+ p.remote.address === remoteAddr &&
101
+ p.remote.port === remotePort,
102
+ );
103
+ }
104
+
105
+ // ---------------------------------------------------------------------------
106
+ // Get or create a candidate pair
107
+ // ---------------------------------------------------------------------------
108
+
109
+ export function getOrCreatePair(
110
+ pairs: CandidatePair[],
111
+ local: IceCandidate,
112
+ remote: IceCandidate,
113
+ role: IceRole,
114
+ ): { pair: CandidatePair; isNew: boolean } {
115
+ const existing = pairs.find(
116
+ (p) =>
117
+ p.local.address === local.address &&
118
+ p.local.port === local.port &&
119
+ p.remote.address === remote.address &&
120
+ p.remote.port === remote.port,
121
+ );
122
+ if (existing) return { pair: existing, isNew: false };
123
+
124
+ const controlling = role === 'controlling' ? local : remote;
125
+ const controlled = role === 'controlling' ? remote : local;
126
+ const priority = computePairPriority(controlling, controlled);
127
+
128
+ const pair: CandidatePair = {
129
+ id: makePairId(local, remote),
130
+ local,
131
+ remote,
132
+ state: CandidatePairState.Waiting,
133
+ priority,
134
+ nominated: false,
135
+ valid: false,
136
+ nominateOnSuccess: false,
137
+ retransmitCount: 0,
138
+ };
139
+
140
+ return { pair, isNew: true };
141
+ }
142
+
package/src/gather.ts ADDED
@@ -0,0 +1,224 @@
1
+ import * as os from 'node:os';
2
+ import * as dgram from 'node:dgram';
3
+ import {
4
+ AttributeType,
5
+ MessageClass,
6
+ decodeMessage,
7
+ encodeMessage,
8
+ createBindingRequest,
9
+ isStunMessage,
10
+ decodeXorMappedAddress,
11
+ } from '@agentdance/node-webrtc-stun';
12
+ import type { StunAttribute } from '@agentdance/node-webrtc-stun';
13
+ import { computeFoundation, computePriority } from './candidate.js';
14
+ import type { IceCandidate, TransportProtocol } from './types.js';
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Interface tier classification
18
+ //
19
+ // Mirrors pion/ice's approach (RFC 8421 §4.1 "Interface Type Preferences"):
20
+ // loopback → tier 0 (highest localPref base = 65535)
21
+ // physical → tier 1 (Ethernet / WiFi)
22
+ // virtual → tier 2 (VPN tunnels, Docker bridges, VM adapters)
23
+ //
24
+ // Within each tier, interfaces are assigned descending localPref values so
25
+ // the first enumerated interface of each tier gets the highest priority.
26
+ // localPref for interface i = tierBase - i, where tierBase is spaced 1024
27
+ // apart so tiers never overlap: loopback ≥ 65535, physical 64511..63488,
28
+ // virtual 63487..62464.
29
+ //
30
+ // This ensures:
31
+ // loopback↔loopback pair > physical↔physical pair > virtual pair
32
+ // (all host candidates; type_pref=126 is the same for all)
33
+ // ---------------------------------------------------------------------------
34
+
35
+ const TIER_BASE: Record<0 | 1 | 2, number> = {
36
+ 0: 65535, // loopback
37
+ 1: 64511, // physical (Ethernet / WiFi)
38
+ 2: 63487, // virtual (VPN, Docker, VM)
39
+ };
40
+
41
+ // Heuristics for "virtual" interface detection that work across platforms
42
+ // without requiring OS-specific syscalls.
43
+ const VIRTUAL_PREFIXES = [
44
+ 'docker',
45
+ 'br-', // Docker bridge networks
46
+ 'veth', // Docker container-side veth
47
+ 'virbr', // libvirt bridges
48
+ 'vmnet', // VMware host-only
49
+ 'vboxnet', // VirtualBox host-only
50
+ 'tun', // OpenVPN / WireGuard tun
51
+ 'tap', // tap adapters
52
+ 'utun', // macOS utun (WireGuard / built-in VPN)
53
+ 'ipsec',
54
+ 'ppp',
55
+ ];
56
+
57
+ // On macOS, en0 is typically WiFi/Ethernet. We treat the generic "en"
58
+ // prefix as physical; everything else that is non-loopback, non-virtual
59
+ // is also treated as physical.
60
+ function classifyInterface(name: string): 0 | 1 | 2 {
61
+ if (name === 'lo' || name === 'lo0') return 0;
62
+ for (const prefix of VIRTUAL_PREFIXES) {
63
+ if (name.startsWith(prefix)) return 2;
64
+ }
65
+ return 1;
66
+ }
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // Local address enumeration (includes loopback, excludes IPv6)
70
+ // ---------------------------------------------------------------------------
71
+
72
+ export interface LocalAddress {
73
+ address: string;
74
+ family: 4 | 6;
75
+ name: string;
76
+ tier: 0 | 1 | 2;
77
+ }
78
+
79
+ export function getLocalAddresses(): LocalAddress[] {
80
+ const ifaces = os.networkInterfaces();
81
+ const result: LocalAddress[] = [];
82
+
83
+ for (const [name, addrs] of Object.entries(ifaces)) {
84
+ if (!addrs) continue;
85
+ for (const addr of addrs) {
86
+ // IPv4 only – ts-rtc binds udp4 sockets
87
+ if (addr.family !== 'IPv4') continue;
88
+ const tier = addr.internal ? 0 : classifyInterface(name);
89
+ result.push({ address: addr.address, family: 4, name, tier });
90
+ }
91
+ }
92
+
93
+ return result;
94
+ }
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // Gather host candidates from all local interfaces
98
+ //
99
+ // Candidates are sorted by tier (loopback first) and within a tier by
100
+ // enumeration order. localPref is assigned so that loopback always gets the
101
+ // highest value, physical interfaces are in the middle, and virtual/VPN
102
+ // interfaces are lowest – matching pion's RFC 8421 §4.1 intent.
103
+ // ---------------------------------------------------------------------------
104
+
105
+ export async function gatherHostCandidates(
106
+ port: number,
107
+ component: 1 | 2,
108
+ protocol: TransportProtocol,
109
+ ): Promise<IceCandidate[]> {
110
+ const addrs = getLocalAddresses();
111
+
112
+ // Sort: loopback(0) < physical(1) < virtual(2) so localPref decrements
113
+ // in the right order within each tier.
114
+ addrs.sort((a, b) => a.tier - b.tier);
115
+
116
+ // Track per-tier counter for localPref assignment
117
+ const tierCount: Record<0 | 1 | 2, number> = { 0: 0, 1: 0, 2: 0 };
118
+
119
+ const candidates: IceCandidate[] = [];
120
+
121
+ for (const addr of addrs) {
122
+ const localPref = TIER_BASE[addr.tier] - tierCount[addr.tier];
123
+ tierCount[addr.tier]++;
124
+
125
+ candidates.push({
126
+ foundation: computeFoundation('host', addr.address, protocol),
127
+ component,
128
+ transport: protocol,
129
+ priority: computePriority('host', localPref, component),
130
+ address: addr.address,
131
+ port,
132
+ type: 'host',
133
+ });
134
+ }
135
+
136
+ return candidates;
137
+ }
138
+
139
+ // ---------------------------------------------------------------------------
140
+ // Gather server-reflexive candidate via STUN binding request
141
+ // ---------------------------------------------------------------------------
142
+
143
+ export async function gatherSrflxCandidate(
144
+ socket: dgram.Socket,
145
+ localCandidate: IceCandidate,
146
+ stunServer: { host: string; port: number },
147
+ ): Promise<IceCandidate | null> {
148
+ return new Promise((resolve) => {
149
+ const req = createBindingRequest();
150
+ const buf = encodeMessage(req);
151
+ const txId = req.transactionId;
152
+
153
+ const timeout = setTimeout(() => {
154
+ socket.removeListener('message', onMessage);
155
+ resolve(null);
156
+ }, 3000);
157
+
158
+ function onMessage(msg: Buffer, rinfo: dgram.RemoteInfo): void {
159
+ if (!isStunMessage(msg)) return;
160
+
161
+ let decoded;
162
+ try {
163
+ decoded = decodeMessage(msg);
164
+ } catch {
165
+ return;
166
+ }
167
+
168
+ // Match transaction ID
169
+ if (!decoded.transactionId.equals(txId)) return;
170
+ if (decoded.messageClass !== MessageClass.SuccessResponse) return;
171
+
172
+ clearTimeout(timeout);
173
+ socket.removeListener('message', onMessage);
174
+
175
+ // Extract XOR-MAPPED-ADDRESS
176
+ const xorAttr = decoded.attributes.find(
177
+ (a: StunAttribute) => a.type === AttributeType.XorMappedAddress,
178
+ );
179
+ if (!xorAttr) {
180
+ resolve(null);
181
+ return;
182
+ }
183
+
184
+ let mapped;
185
+ try {
186
+ mapped = decodeXorMappedAddress(xorAttr.value, decoded.transactionId);
187
+ } catch {
188
+ resolve(null);
189
+ return;
190
+ }
191
+
192
+ const foundation = computeFoundation(
193
+ 'srflx',
194
+ localCandidate.address,
195
+ localCandidate.transport,
196
+ );
197
+ const priority = computePriority('srflx', 65535, localCandidate.component);
198
+
199
+ const srflx: IceCandidate = {
200
+ foundation,
201
+ component: localCandidate.component,
202
+ transport: localCandidate.transport,
203
+ priority,
204
+ address: mapped.address,
205
+ port: mapped.port,
206
+ type: 'srflx',
207
+ relatedAddress: localCandidate.address,
208
+ relatedPort: localCandidate.port,
209
+ };
210
+
211
+ resolve(srflx);
212
+ }
213
+
214
+ socket.on('message', onMessage);
215
+
216
+ socket.send(buf, stunServer.port, stunServer.host, (err) => {
217
+ if (err) {
218
+ clearTimeout(timeout);
219
+ socket.removeListener('message', onMessage);
220
+ resolve(null);
221
+ }
222
+ });
223
+ });
224
+ }
package/src/index.ts ADDED
@@ -0,0 +1,6 @@
1
+ export * from './types.js';
2
+ export * from './candidate.js';
3
+ export * from './gather.js';
4
+ export * from './transport.js';
5
+ export * from './checklist.js';
6
+ export * from './agent.js';
@@ -0,0 +1,88 @@
1
+ import * as dgram from 'node:dgram';
2
+ import { EventEmitter } from 'node:events';
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // Packet type detection (RFC 7983 / RFC 5764)
6
+ // STUN: first byte 0x00-0x03
7
+ // DTLS: first byte 0x14-0x19 (ContentType: change_cipher_spec=20...23=heartbeat)
8
+ // RTP/RTCP: first byte 0x80-0xFF (version 2 flag set)
9
+ // ---------------------------------------------------------------------------
10
+
11
+ export function detectPacketType(
12
+ buf: Buffer,
13
+ ): 'stun' | 'dtls' | 'rtp' | 'unknown' {
14
+ if (buf.length === 0) return 'unknown';
15
+ const b = buf[0]!;
16
+ if (b <= 0x03) return 'stun';
17
+ if (b >= 0x14 && b <= 0x19) return 'dtls';
18
+ if (b >= 0x80) return 'rtp';
19
+ return 'unknown';
20
+ }
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // UdpTransport – single UDP socket with packet demultiplexing
24
+ // ---------------------------------------------------------------------------
25
+
26
+ export declare interface UdpTransport {
27
+ on(event: 'stun', listener: (buf: Buffer, rinfo: dgram.RemoteInfo) => void): this;
28
+ on(event: 'dtls', listener: (buf: Buffer, rinfo: dgram.RemoteInfo) => void): this;
29
+ on(event: 'rtp', listener: (buf: Buffer, rinfo: dgram.RemoteInfo) => void): this;
30
+ on(event: 'error', listener: (err: Error) => void): this;
31
+ on(event: string, listener: (...args: unknown[]) => void): this;
32
+ }
33
+
34
+ export class UdpTransport extends EventEmitter {
35
+ private _socket!: dgram.Socket;
36
+ private _localPort = 0;
37
+ private _localAddress = '0.0.0.0';
38
+
39
+ get localPort(): number {
40
+ return this._localPort;
41
+ }
42
+
43
+ get localAddress(): string {
44
+ return this._localAddress;
45
+ }
46
+
47
+ async bind(port = 0, address = '0.0.0.0'): Promise<void> {
48
+ return new Promise((resolve, reject) => {
49
+ const socket = dgram.createSocket('udp4');
50
+
51
+ socket.on('error', (err) => {
52
+ this.emit('error', err);
53
+ });
54
+
55
+ socket.on('message', (buf: Buffer, rinfo: dgram.RemoteInfo) => {
56
+ const type = detectPacketType(buf);
57
+ this.emit(type, buf, rinfo);
58
+ });
59
+
60
+ socket.bind(port, address, () => {
61
+ const addr = socket.address();
62
+ this._localPort = addr.port;
63
+ this._localAddress = addr.address;
64
+ this._socket = socket;
65
+ resolve();
66
+ });
67
+
68
+ socket.once('error', reject);
69
+ });
70
+ }
71
+
72
+ send(buf: Buffer, port: number, address: string): Promise<void> {
73
+ return new Promise((resolve, reject) => {
74
+ this._socket.send(buf, port, address, (err) => {
75
+ if (err) reject(err);
76
+ else resolve();
77
+ });
78
+ });
79
+ }
80
+
81
+ close(): void {
82
+ try {
83
+ this._socket.close();
84
+ } catch {
85
+ // already closed
86
+ }
87
+ }
88
+ }