@robota-sdk/agent-transport-webrtc 3.0.0-beta.100
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.
- package/CHANGELOG.md +694 -0
- package/LICENSE +661 -0
- package/README.md +111 -0
- package/dist/node/index.cjs +1 -0
- package/dist/node/index.d.cts +1650 -0
- package/dist/node/index.d.cts.map +1 -0
- package/dist/node/index.d.ts +1650 -0
- package/dist/node/index.d.ts.map +1 -0
- package/dist/node/index.js +2 -0
- package/dist/node/index.js.map +1 -0
- package/package.json +93 -0
- package/src/__tests__/admission-secure-by-default.test.ts +63 -0
- package/src/__tests__/admission-steps.test.ts +46 -0
- package/src/__tests__/device-mesh-relay.test.ts +441 -0
- package/src/__tests__/device-mesh.test.ts +682 -0
- package/src/__tests__/discovering-mesh-relay.test.ts +234 -0
- package/src/__tests__/enrollment-link.test.ts +103 -0
- package/src/__tests__/fake-datachannel.ts +83 -0
- package/src/__tests__/handoff-grant-gate.test.ts +403 -0
- package/src/__tests__/local-peer-proof-gate.test.ts +215 -0
- package/src/__tests__/mesh-admission-contract.test.ts +38 -0
- package/src/__tests__/mesh-discovery.test.ts +820 -0
- package/src/__tests__/mesh-fixtures.ts +148 -0
- package/src/__tests__/mesh-internet.test.ts +942 -0
- package/src/__tests__/mesh-turn-relay.test.ts +161 -0
- package/src/__tests__/negotiated-certificate.test.ts +76 -0
- package/src/__tests__/nostr-source-condition.test.ts +27 -0
- package/src/__tests__/operator-approval-gate.test.ts +308 -0
- package/src/__tests__/outbound-delivery-carrier.test.ts +155 -0
- package/src/__tests__/pairing-e2e.test.ts +237 -0
- package/src/__tests__/pairing-gate-e3.test.ts +194 -0
- package/src/__tests__/pairing-gate-e4.test.ts +110 -0
- package/src/__tests__/pairing-gate.test.ts +296 -0
- package/src/__tests__/pairing-relay-in-the-middle.test.ts +225 -0
- package/src/__tests__/rtc-channel-close.test.ts +52 -0
- package/src/__tests__/rtc-peer-candidates.test.ts +61 -0
- package/src/__tests__/session-attachment-app-view.test.ts +66 -0
- package/src/__tests__/session-attachment-budget.test.ts +136 -0
- package/src/__tests__/turn-server-bind.test.ts +59 -0
- package/src/__tests__/turn-server.test.ts +335 -0
- package/src/__tests__/turn-test-client.ts +256 -0
- package/src/__tests__/webrtc-implementation.test.ts +83 -0
- package/src/__tests__/webrtc-transport.test.ts +349 -0
- package/src/__tests__/ws-mesh-relay-client.test.ts +95 -0
- package/src/__tests__/ws-signaling-client.test.ts +108 -0
- package/src/admission-steps.ts +55 -0
- package/src/channel-delivery.ts +44 -0
- package/src/datachannel-loader.ts +86 -0
- package/src/device-mesh-node.ts +1019 -0
- package/src/discovering-mesh-relay.ts +675 -0
- package/src/enrollment-link.ts +465 -0
- package/src/handoff-grant-gate.ts +186 -0
- package/src/index.ts +139 -0
- package/src/local-peer-proof.ts +135 -0
- package/src/mainline-dht-store.ts +174 -0
- package/src/mesh-dht.ts +446 -0
- package/src/mesh-discovery.ts +87 -0
- package/src/mesh-item-store.ts +287 -0
- package/src/mesh-lan-listener.ts +193 -0
- package/src/mesh-mdns.ts +406 -0
- package/src/mesh-peer-link.ts +600 -0
- package/src/mesh-public-relays.ts +25 -0
- package/src/mesh-records.ts +357 -0
- package/src/mesh-relay.ts +98 -0
- package/src/mesh-signal.ts +116 -0
- package/src/mesh-turn-relay.ts +238 -0
- package/src/negotiated-certificate.ts +72 -0
- package/src/nostr-mesh-relay.ts +391 -0
- package/src/pairing-channel-lifecycle.ts +31 -0
- package/src/pairing-controllers.ts +81 -0
- package/src/pairing-frames.ts +34 -0
- package/src/pairing-gate-options.ts +133 -0
- package/src/pairing-gate.ts +397 -0
- package/src/rtc-peer.ts +429 -0
- package/src/session-attachment.ts +96 -0
- package/src/signaling.ts +62 -0
- package/src/stun-message.ts +341 -0
- package/src/transport-lifecycle-error.ts +11 -0
- package/src/turn-server.ts +1028 -0
- package/src/webrtc-delivery-lifecycle.ts +54 -0
- package/src/webrtc-transport-options.ts +87 -0
- package/src/webrtc-transport.ts +368 -0
- package/src/ws-mesh-relay-client.ts +117 -0
- package/src/ws-signaling-client.ts +139 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An offering peer hands out its ICE candidates only once the answer is applied. With them earlier,
|
|
3
|
+
* the answerer can reach this side and start DTLS while the answer is still being applied, and the
|
|
4
|
+
* DTLS layer then refuses the peer's certificate for want of a fingerprint to check it against.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, it } from 'vitest';
|
|
7
|
+
|
|
8
|
+
import { RtcPeer } from '../rtc-peer.js';
|
|
9
|
+
|
|
10
|
+
import { fakeDataChannel } from './fake-datachannel.js';
|
|
11
|
+
|
|
12
|
+
describe('RtcPeer local candidates', () => {
|
|
13
|
+
it('an offerer holds its candidates until the answer is applied, then hands them out in order', async () => {
|
|
14
|
+
const fake = fakeDataChannel();
|
|
15
|
+
const peer = new RtcPeer({ loadDataChannel: () => fake.module });
|
|
16
|
+
const handedOut: string[] = [];
|
|
17
|
+
peer.onLocalCandidate(({ candidate }) => handedOut.push(candidate));
|
|
18
|
+
peer.createDataChannel('x');
|
|
19
|
+
await peer.createOffer();
|
|
20
|
+
const native = fake.connections[0]!;
|
|
21
|
+
|
|
22
|
+
native.localCandidate!('a=candidate:1 1 UDP 1 10.0.0.1 1000 typ host', '0');
|
|
23
|
+
native.localCandidate!('a=candidate:2 1 UDP 1 10.0.0.2 2000 typ host', '0');
|
|
24
|
+
expect(handedOut).toEqual([]);
|
|
25
|
+
|
|
26
|
+
peer.acceptAnswer('a=fingerprint:sha-256 BB');
|
|
27
|
+
expect(handedOut).toEqual([
|
|
28
|
+
'candidate:1 1 UDP 1 10.0.0.1 1000 typ host',
|
|
29
|
+
'candidate:2 1 UDP 1 10.0.0.2 2000 typ host',
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
native.localCandidate!('a=candidate:3 1 UDP 1 10.0.0.3 3000 typ host', '0');
|
|
33
|
+
expect(handedOut).toHaveLength(3);
|
|
34
|
+
expect(peer.diagnostics().localCandidates).toBe(3);
|
|
35
|
+
peer.close();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('an answerer hands out its candidates as they come, the offer being applied already', async () => {
|
|
39
|
+
const fake = fakeDataChannel();
|
|
40
|
+
const peer = new RtcPeer({ loadDataChannel: () => fake.module });
|
|
41
|
+
const handedOut: string[] = [];
|
|
42
|
+
peer.onLocalCandidate(({ candidate }) => handedOut.push(candidate));
|
|
43
|
+
await peer.acceptOffer('a=fingerprint:sha-256 AA');
|
|
44
|
+
|
|
45
|
+
fake.connections[0]!.localCandidate!('a=candidate:1 1 UDP 1 10.0.0.1 1000 typ host', '0');
|
|
46
|
+
expect(handedOut).toEqual(['candidate:1 1 UDP 1 10.0.0.1 1000 typ host']);
|
|
47
|
+
peer.close();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('a closed peer never hands out the candidates it held', async () => {
|
|
51
|
+
const fake = fakeDataChannel();
|
|
52
|
+
const peer = new RtcPeer({ loadDataChannel: () => fake.module });
|
|
53
|
+
const handedOut: string[] = [];
|
|
54
|
+
peer.onLocalCandidate(({ candidate }) => handedOut.push(candidate));
|
|
55
|
+
await peer.createOffer();
|
|
56
|
+
fake.connections[0]!.localCandidate!('a=candidate:1 1 UDP 1 10.0.0.1 1000 typ host', '0');
|
|
57
|
+
peer.close();
|
|
58
|
+
peer.acceptAnswer('a=fingerprint:sha-256 BB');
|
|
59
|
+
expect(handedOut).toEqual([]);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A data channel carries a fraction of the inbound frame limit, and `channel.send` throws and closes
|
|
3
|
+
* the channel on a frame it cannot carry. An app view or app tool result is the one reply a session
|
|
4
|
+
* can make arbitrarily large, so the direct-handler path refuses one over a history page's worth
|
|
5
|
+
* before it reaches the channel.
|
|
6
|
+
*/
|
|
7
|
+
import { HISTORY_PAGE_MAX_BYTES } from '@robota-sdk/agent-transport';
|
|
8
|
+
import { createTestInteractiveSession } from '@robota-sdk/agent-interface-session/testing';
|
|
9
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
10
|
+
|
|
11
|
+
import { attachSession } from '../session-attachment.js';
|
|
12
|
+
|
|
13
|
+
import type { IPairingChannel } from '../pairing-gate-options.js';
|
|
14
|
+
|
|
15
|
+
const view = {
|
|
16
|
+
sourceId: 'files',
|
|
17
|
+
resourceUri: 'ui://files/list',
|
|
18
|
+
mimeType: 'text/html;profile=mcp-app',
|
|
19
|
+
html: '<main>rows</main>',
|
|
20
|
+
callableTools: { list_files: 'mcp__files__list_files' },
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
function channel(): IPairingChannel & { sent: string[] } {
|
|
24
|
+
const sent: string[] = [];
|
|
25
|
+
return { sent, send: (data: string) => void sent.push(data), close: () => undefined };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function attachWith(readToolAppView: () => unknown) {
|
|
29
|
+
const target = channel();
|
|
30
|
+
const session = createTestInteractiveSession({
|
|
31
|
+
readToolAppView: vi.fn(readToolAppView) as never,
|
|
32
|
+
});
|
|
33
|
+
const attached = attachSession({ channel: target, session }, false, () => undefined);
|
|
34
|
+
return { target, attached };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe('attachSession and the data channel frame limit', () => {
|
|
38
|
+
it('refuses an app view larger than a history page instead of sending it to the channel', () => {
|
|
39
|
+
const { target, attached } = attachWith(() => ({
|
|
40
|
+
...view,
|
|
41
|
+
html: 'x'.repeat(HISTORY_PAGE_MAX_BYTES),
|
|
42
|
+
}));
|
|
43
|
+
|
|
44
|
+
attached.onSessionMessage(
|
|
45
|
+
JSON.stringify({ type: 'read-app-view', requestId: 'v1', viewId: 'view-1' }),
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
expect(target.sent.map((frame) => JSON.parse(frame))).toEqual([
|
|
49
|
+
{ type: 'protocol_error', message: 'This app view is too large to send.', requestId: 'v1' },
|
|
50
|
+
]);
|
|
51
|
+
attached.cleanup();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('still sends an app view that fits', () => {
|
|
55
|
+
const { target, attached } = attachWith(() => view);
|
|
56
|
+
|
|
57
|
+
attached.onSessionMessage(
|
|
58
|
+
JSON.stringify({ type: 'read-app-view', requestId: 'v1', viewId: 'view-1' }),
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
expect(target.sent.map((frame) => JSON.parse(frame))).toEqual([
|
|
62
|
+
{ type: 'app_view', requestId: 'v1', view },
|
|
63
|
+
]);
|
|
64
|
+
attached.cleanup();
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ARCH-030 / issue #1734 — the replay path's backpressure budget REACHES production.
|
|
3
|
+
*
|
|
4
|
+
* `IAttachOptions.pendingBytes` existed with zero production callers: the bridge could take a
|
|
5
|
+
* carrier's backpressure reading and nothing gave it one, so the replay burst — the case the
|
|
6
|
+
* reopened scope names — ran unbudgeted while every other outbound path on the same connection was
|
|
7
|
+
* guarded.
|
|
8
|
+
*
|
|
9
|
+
* This case asserts the BEHAVIOUR, not the wiring. A test that checked the option reaches
|
|
10
|
+
* `bridge.attach` would have passed on the day the defect existed, because the option was reaching
|
|
11
|
+
* nothing on the way in. What can fail is a peer over budget going unclosed.
|
|
12
|
+
*/
|
|
13
|
+
import { DEFAULT_MAX_PENDING_BYTES, SessionResumeBridge } from '@robota-sdk/agent-transport';
|
|
14
|
+
import { createTestInteractiveSession } from '@robota-sdk/agent-interface-session/testing';
|
|
15
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
16
|
+
|
|
17
|
+
import { attachSession } from '../session-attachment.js';
|
|
18
|
+
|
|
19
|
+
import type { IPairingChannel } from '../pairing-gate-options.js';
|
|
20
|
+
import type { IInteractiveSession } from '@robota-sdk/agent-interface-session';
|
|
21
|
+
|
|
22
|
+
function fakeSession(): {
|
|
23
|
+
session: IInteractiveSession;
|
|
24
|
+
fire: (event: string, arg: unknown) => void;
|
|
25
|
+
} {
|
|
26
|
+
const handlers = new Map<string, (arg: unknown) => void>();
|
|
27
|
+
const session = createTestInteractiveSession({
|
|
28
|
+
on: ((event: string, handler: (arg: unknown) => void) =>
|
|
29
|
+
handlers.set(event, handler)) as IInteractiveSession['on'],
|
|
30
|
+
off: ((event: string) => {
|
|
31
|
+
handlers.delete(event);
|
|
32
|
+
}) as IInteractiveSession['off'],
|
|
33
|
+
});
|
|
34
|
+
return { session, fire: (event, arg) => handlers.get(event)?.(arg) };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A channel whose backpressure the case controls, matching the optional structural slice. */
|
|
38
|
+
function channelHolding(bytes: () => number): IPairingChannel & { sent: string[] } {
|
|
39
|
+
const sent: string[] = [];
|
|
40
|
+
return {
|
|
41
|
+
sent,
|
|
42
|
+
send: (data: string) => void sent.push(data),
|
|
43
|
+
close: () => undefined,
|
|
44
|
+
get bufferedAmount(): number {
|
|
45
|
+
return bytes();
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe('attachSession + the replay budget (ARCH-030)', () => {
|
|
51
|
+
it('attributes direct admitted WebRTC submissions to the remote surface', async () => {
|
|
52
|
+
const base = fakeSession().session;
|
|
53
|
+
const submit = vi.fn().mockResolvedValue(undefined);
|
|
54
|
+
const session = Object.assign(base, { submit });
|
|
55
|
+
const channel = channelHolding(() => 0);
|
|
56
|
+
const attached = attachSession({ channel, session, surface: 'remote' }, false, () => undefined);
|
|
57
|
+
|
|
58
|
+
attached.onSessionMessage(JSON.stringify({ type: 'submit', prompt: 'remote turn' }));
|
|
59
|
+
await vi.waitFor(() => expect(submit).toHaveBeenCalled());
|
|
60
|
+
|
|
61
|
+
expect(submit).toHaveBeenCalledWith('remote turn', undefined, undefined, {
|
|
62
|
+
surface: 'remote',
|
|
63
|
+
});
|
|
64
|
+
attached.cleanup();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('exposes host usage reporting on the admitted direct handler path', async () => {
|
|
68
|
+
const { session } = fakeSession();
|
|
69
|
+
const channel = channelHolding(() => 0);
|
|
70
|
+
const report = { schemaVersion: 1 } as never;
|
|
71
|
+
const attached = attachSession(
|
|
72
|
+
{ channel, session, personalUsageReporter: async () => report },
|
|
73
|
+
false,
|
|
74
|
+
() => undefined,
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
attached.onSessionMessage(
|
|
78
|
+
JSON.stringify({
|
|
79
|
+
type: 'get-personal-usage-report',
|
|
80
|
+
requestId: 'direct-usage',
|
|
81
|
+
period: '30d',
|
|
82
|
+
timezone: 'UTC',
|
|
83
|
+
}),
|
|
84
|
+
);
|
|
85
|
+
await expect.poll(() => channel.sent.length).toBeGreaterThan(0);
|
|
86
|
+
|
|
87
|
+
expect(channel.sent.map((frame) => JSON.parse(frame))).toContainEqual({
|
|
88
|
+
type: 'personal_usage_report',
|
|
89
|
+
requestId: 'direct-usage',
|
|
90
|
+
report,
|
|
91
|
+
});
|
|
92
|
+
attached.cleanup();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('closes a peer that is over budget when the replay burst is attempted', () => {
|
|
96
|
+
const { session, fire } = fakeSession();
|
|
97
|
+
const bridge = new SessionResumeBridge({ session });
|
|
98
|
+
for (const delta of ['a', 'b', 'c']) fire('text_delta', delta);
|
|
99
|
+
|
|
100
|
+
let pending = 0;
|
|
101
|
+
const channel = channelHolding(() => pending);
|
|
102
|
+
const failures: string[] = [];
|
|
103
|
+
const attached = attachSession({ channel, session, resumeBridge: bridge }, true, (error) =>
|
|
104
|
+
failures.push(error.message),
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
// The peer has accepted a backlog it is not reading.
|
|
108
|
+
pending = DEFAULT_MAX_PENDING_BYTES + 1;
|
|
109
|
+
attached.onSessionMessage(JSON.stringify({ type: 'resume', lastSeq: 0 }));
|
|
110
|
+
|
|
111
|
+
expect(channel.sent).toHaveLength(0);
|
|
112
|
+
expect(failures).toHaveLength(1);
|
|
113
|
+
expect(failures[0]).toContain('pending');
|
|
114
|
+
attached.cleanup();
|
|
115
|
+
bridge.dispose();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('replays normally while the peer is reading', () => {
|
|
119
|
+
// The companion the case above needs: without it, a budget that refused everything would pass.
|
|
120
|
+
const { session, fire } = fakeSession();
|
|
121
|
+
const bridge = new SessionResumeBridge({ session });
|
|
122
|
+
for (const delta of ['a', 'b', 'c']) fire('text_delta', delta);
|
|
123
|
+
|
|
124
|
+
const channel = channelHolding(() => 0);
|
|
125
|
+
const failures: string[] = [];
|
|
126
|
+
const attached = attachSession({ channel, session, resumeBridge: bridge }, true, (error) =>
|
|
127
|
+
failures.push(error.message),
|
|
128
|
+
);
|
|
129
|
+
attached.onSessionMessage(JSON.stringify({ type: 'resume', lastSeq: 0 }));
|
|
130
|
+
|
|
131
|
+
expect(channel.sent).toHaveLength(3);
|
|
132
|
+
expect(failures).toHaveLength(0);
|
|
133
|
+
attached.cleanup();
|
|
134
|
+
bridge.dispose();
|
|
135
|
+
});
|
|
136
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Why the relay could not listen decides what the user changes, so a failed bind says its cause.
|
|
3
|
+
* The causes a test machine cannot reliably produce are injected here.
|
|
4
|
+
*/
|
|
5
|
+
import { EventEmitter } from 'node:events';
|
|
6
|
+
|
|
7
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
8
|
+
|
|
9
|
+
const bindErrors: NodeJS.ErrnoException[] = [];
|
|
10
|
+
|
|
11
|
+
vi.mock('node:dgram', async (importOriginal) => {
|
|
12
|
+
const actual = await importOriginal<typeof import('node:dgram')>();
|
|
13
|
+
return {
|
|
14
|
+
...actual,
|
|
15
|
+
createSocket: (type: 'udp4') => {
|
|
16
|
+
const failure = bindErrors.shift();
|
|
17
|
+
if (failure === undefined) return actual.createSocket(type);
|
|
18
|
+
const fake = new EventEmitter() as EventEmitter & {
|
|
19
|
+
bind: (port: number, host: string, done: () => void) => void;
|
|
20
|
+
close: () => void;
|
|
21
|
+
};
|
|
22
|
+
fake.bind = () => {
|
|
23
|
+
setImmediate(() => fake.emit('error', failure));
|
|
24
|
+
};
|
|
25
|
+
fake.close = () => undefined;
|
|
26
|
+
return fake;
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const { TurnServer } = await import('../turn-server.js');
|
|
32
|
+
|
|
33
|
+
function errno(code: string): NodeJS.ErrnoException {
|
|
34
|
+
return Object.assign(new Error(`bind ${code}`), { code, syscall: 'bind' });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
afterEach(() => {
|
|
38
|
+
bindErrors.length = 0;
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe('a relay that cannot listen', () => {
|
|
42
|
+
const authorize = (): Promise<undefined> => Promise.resolve(undefined);
|
|
43
|
+
|
|
44
|
+
it('says a privileged port needs privileges, keeping the error as its cause', async () => {
|
|
45
|
+
const failure = errno('EACCES');
|
|
46
|
+
bindErrors.push(failure);
|
|
47
|
+
const started = TurnServer.start({ host: '127.0.0.1', port: 80, authorize });
|
|
48
|
+
await expect(started).rejects.toThrow(/UDP 127\.0\.0\.1:80: .*privilege/);
|
|
49
|
+
await expect(started).rejects.toHaveProperty('cause', failure);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('passes on a cause it has no words for', async () => {
|
|
53
|
+
const failure = errno('EPERM');
|
|
54
|
+
bindErrors.push(failure);
|
|
55
|
+
const started = TurnServer.start({ host: '127.0.0.1', port: 3478, authorize });
|
|
56
|
+
await expect(started).rejects.toThrow(/UDP 127\.0\.0\.1:3478: bind EPERM/);
|
|
57
|
+
await expect(started).rejects.toHaveProperty('cause', failure);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { randomBytes } from 'node:crypto';
|
|
4
|
+
import { createSocket } from 'node:dgram';
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
StunAttr,
|
|
8
|
+
StunClass,
|
|
9
|
+
StunMethod,
|
|
10
|
+
attribute,
|
|
11
|
+
decodeStun,
|
|
12
|
+
encodeStun,
|
|
13
|
+
readErrorCode,
|
|
14
|
+
} from '../stun-message.js';
|
|
15
|
+
import { TurnServer, type ITurnServerOptions } from '../turn-server.js';
|
|
16
|
+
import { TurnTestClient, lifetimeOf, relayedAddress, udpPeer } from './turn-test-client.js';
|
|
17
|
+
|
|
18
|
+
const servers: TurnServer[] = [];
|
|
19
|
+
const closers: (() => void)[] = [];
|
|
20
|
+
|
|
21
|
+
afterEach(async () => {
|
|
22
|
+
for (const close of closers.splice(0)) close();
|
|
23
|
+
for (const server of servers.splice(0)) await server.close();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
async function serve(over: Partial<ITurnServerOptions> = {}): Promise<TurnServer> {
|
|
27
|
+
const server = await TurnServer.start({
|
|
28
|
+
host: '127.0.0.1',
|
|
29
|
+
port: 0,
|
|
30
|
+
authorize: (username) =>
|
|
31
|
+
Promise.resolve(
|
|
32
|
+
username.startsWith('user-') ? { owner: username.slice(5, 6), password: 'pw' } : undefined,
|
|
33
|
+
),
|
|
34
|
+
...over,
|
|
35
|
+
});
|
|
36
|
+
servers.push(server);
|
|
37
|
+
return server;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function client(
|
|
41
|
+
server: TurnServer,
|
|
42
|
+
username: string,
|
|
43
|
+
password = 'pw',
|
|
44
|
+
): Promise<TurnTestClient> {
|
|
45
|
+
const created = await TurnTestClient.connect(server.address, username, password);
|
|
46
|
+
closers.push(() => created.close());
|
|
47
|
+
return created;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function peer(): Promise<Awaited<ReturnType<typeof udpPeer>>> {
|
|
51
|
+
const created = await udpPeer();
|
|
52
|
+
closers.push(() => created.close());
|
|
53
|
+
return created;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe('TURN server (RFC 8656 over UDP)', () => {
|
|
57
|
+
it('relays both ways: Send/Data indications, then ChannelData once a channel is bound', async () => {
|
|
58
|
+
const server = await serve();
|
|
59
|
+
const turn = await client(server, 'user-a1');
|
|
60
|
+
const allocated = await turn.allocate();
|
|
61
|
+
expect(allocated.ok).toBe(true);
|
|
62
|
+
const relayed = relayedAddress(allocated)!;
|
|
63
|
+
expect(relayed.address).toBe('127.0.0.1');
|
|
64
|
+
const remote = await peer();
|
|
65
|
+
|
|
66
|
+
// Nothing is relayed to or from a peer without a permission.
|
|
67
|
+
turn.sendTo(remote.address, Buffer.from('early'));
|
|
68
|
+
remote.send(relayed, Buffer.from('unasked'));
|
|
69
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
70
|
+
expect(remote.received).toEqual([]);
|
|
71
|
+
expect(turn.received()).toEqual([]);
|
|
72
|
+
|
|
73
|
+
expect((await turn.permit(remote.address)).ok).toBe(true);
|
|
74
|
+
turn.sendTo(remote.address, Buffer.from('to peer'));
|
|
75
|
+
await expect.poll(() => remote.received.map(String)).toEqual(['to peer']);
|
|
76
|
+
remote.send(relayed, Buffer.from('to client'));
|
|
77
|
+
await expect.poll(() => turn.received().map((d) => String(d.payload))).toEqual(['to client']);
|
|
78
|
+
expect(turn.received()[0]!.from).toEqual(remote.address);
|
|
79
|
+
|
|
80
|
+
expect((await turn.bindChannel(0x4001, remote.address)).ok).toBe(true);
|
|
81
|
+
turn.sendOnChannel(0x4001, Buffer.from('channel out'));
|
|
82
|
+
await expect.poll(() => remote.received.map(String)).toContain('channel out');
|
|
83
|
+
remote.send(relayed, Buffer.from('channel in'));
|
|
84
|
+
await expect
|
|
85
|
+
.poll(() => turn.received().map((d) => `${d.from.port}:${String(d.payload)}`))
|
|
86
|
+
.toContain(`${0x4001}:channel in`);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('refuses what it does not do rather than half-doing it', async () => {
|
|
90
|
+
const server = await serve();
|
|
91
|
+
const turn = await client(server, 'user-a1');
|
|
92
|
+
expect((await turn.allocate([], 6)).code).toBe(442); // TCP relaying
|
|
93
|
+
expect(
|
|
94
|
+
(await turn.allocate([{ type: StunAttr.EvenPort, value: Buffer.from([0x80, 0, 0, 0]) }]))
|
|
95
|
+
.code,
|
|
96
|
+
).toBe(420);
|
|
97
|
+
expect(
|
|
98
|
+
(
|
|
99
|
+
await turn.allocate([
|
|
100
|
+
{ type: StunAttr.RequestedAddressFamily, value: Buffer.from([2, 0, 0, 0]) },
|
|
101
|
+
])
|
|
102
|
+
).code,
|
|
103
|
+
).toBe(440);
|
|
104
|
+
expect((await turn.allocate()).ok).toBe(true);
|
|
105
|
+
// One allocation per 5-tuple.
|
|
106
|
+
expect((await turn.allocate()).code).toBe(437);
|
|
107
|
+
// Multicast is never a peer; loopback is only because this relay is on loopback.
|
|
108
|
+
expect((await turn.permit({ address: '224.0.0.251', port: 5353 })).code).toBe(403);
|
|
109
|
+
expect((await turn.bindChannel(0x3fff, { address: '127.0.0.1', port: 9 })).code).toBe(400);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('attributes appended after MESSAGE-INTEGRITY count for nothing', async () => {
|
|
113
|
+
const server = await serve();
|
|
114
|
+
const turn = await client(server, 'user-a1');
|
|
115
|
+
expect((await turn.allocate()).ok).toBe(true);
|
|
116
|
+
// A genuine Refresh asks for the default lifetime; an attacker on the path appends LIFETIME 0.
|
|
117
|
+
const refreshed = await turn.request(StunMethod.Refresh, [], undefined, [
|
|
118
|
+
{ type: StunAttr.Lifetime, value: Buffer.alloc(4) },
|
|
119
|
+
]);
|
|
120
|
+
expect(refreshed.ok).toBe(true);
|
|
121
|
+
expect(lifetimeOf(refreshed)).toBe(600);
|
|
122
|
+
expect(server.allocationCount()).toBe(1);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("never relays into the server's own port", async () => {
|
|
126
|
+
// A relay on loopback would otherwise let a client loop TURN into the server itself.
|
|
127
|
+
const server = await serve();
|
|
128
|
+
const turn = await client(server, 'user-a1');
|
|
129
|
+
expect((await turn.allocate()).ok).toBe(true);
|
|
130
|
+
expect((await turn.permit(server.address)).ok).toBe(true);
|
|
131
|
+
const before = server.allocationCount();
|
|
132
|
+
turn.sendTo(
|
|
133
|
+
server.address,
|
|
134
|
+
Buffer.from([0x00, 0x03, 0x00, 0x00, 0x21, 0x12, 0xa4, 0x42, ...Array<number>(12).fill(7)]),
|
|
135
|
+
);
|
|
136
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
137
|
+
// The server's answer to itself would have come back as data from the server's own address.
|
|
138
|
+
expect(turn.received()).toEqual([]);
|
|
139
|
+
expect(server.allocationCount()).toBe(before);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('takes relayed ports from the configured range, and refuses once it is used up', async () => {
|
|
143
|
+
const probe = await udpPeer();
|
|
144
|
+
const min = probe.address.port;
|
|
145
|
+
probe.close();
|
|
146
|
+
const server = await serve({ relayPorts: { min, max: min + 1 } });
|
|
147
|
+
const ports: number[] = [];
|
|
148
|
+
for (const name of ['user-a1', 'user-b1']) {
|
|
149
|
+
const answer = await (await client(server, name)).allocate();
|
|
150
|
+
expect(answer.ok).toBe(true);
|
|
151
|
+
ports.push(relayedAddress(answer)!.port);
|
|
152
|
+
}
|
|
153
|
+
expect(ports.sort()).toEqual([min, min + 1]);
|
|
154
|
+
expect((await (await client(server, 'user-c1')).allocate()).code).toBe(508);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('private and link-local peers can be ruled out; they are allowed by default', async () => {
|
|
158
|
+
const lan = { address: '192.168.1.20', port: 5000 };
|
|
159
|
+
const open = await client(await serve(), 'user-a1');
|
|
160
|
+
expect((await open.allocate()).ok).toBe(true);
|
|
161
|
+
expect((await open.permit(lan)).ok).toBe(true);
|
|
162
|
+
|
|
163
|
+
const closed = await client(await serve({ allowPrivatePeers: false }), 'user-a1');
|
|
164
|
+
expect((await closed.allocate()).ok).toBe(true);
|
|
165
|
+
for (const address of ['10.0.0.5', '172.16.4.4', '192.168.1.20', '169.254.1.1', '100.64.0.9']) {
|
|
166
|
+
expect((await closed.permit({ address, port: 5000 })).code).toBe(403);
|
|
167
|
+
expect((await closed.bindChannel(0x4002, { address, port: 5000 })).code).toBe(403);
|
|
168
|
+
}
|
|
169
|
+
expect((await closed.permit({ address: '203.0.113.9', port: 5000 })).ok).toBe(true);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('an unknown username or a wrong password gets no allocation', async () => {
|
|
173
|
+
const server = await serve();
|
|
174
|
+
expect((await (await client(server, 'nobody')).allocate()).code).toBe(401);
|
|
175
|
+
expect((await (await client(server, 'user-a1', 'wrong')).allocate()).code).toBe(401);
|
|
176
|
+
expect(server.allocationCount()).toBe(0);
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
describe('TURN server — requests nobody authenticated', () => {
|
|
181
|
+
/** A raw datagram from a socket of its own; resolves with the answers it got. */
|
|
182
|
+
async function probe(
|
|
183
|
+
server: TurnServer,
|
|
184
|
+
datagrams: readonly Buffer[],
|
|
185
|
+
): Promise<{ readonly answers: Buffer[] }> {
|
|
186
|
+
const socket = createSocket('udp4');
|
|
187
|
+
await new Promise<void>((resolve) => socket.bind(0, '127.0.0.1', resolve));
|
|
188
|
+
closers.push(() => socket.close());
|
|
189
|
+
const answers: Buffer[] = [];
|
|
190
|
+
socket.on('message', (data) => answers.push(data));
|
|
191
|
+
for (const datagram of datagrams) {
|
|
192
|
+
socket.send(datagram, server.address.port, server.address.address);
|
|
193
|
+
}
|
|
194
|
+
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
195
|
+
return { answers };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function allocateRequest(padding: number): Buffer {
|
|
199
|
+
return encodeStun(StunMethod.Allocate, StunClass.Request, randomBytes(12), [
|
|
200
|
+
{ type: StunAttr.RequestedTransport, value: Buffer.from([17, 0, 0, 0]) },
|
|
201
|
+
...(padding > 0 ? [{ type: 0x8022, value: Buffer.alloc(padding, 0x61) }] : []),
|
|
202
|
+
]);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
it('are never answered with more than they carry: the relay cannot amplify a forged source', async () => {
|
|
206
|
+
const server = await serve();
|
|
207
|
+
// A bare request is smaller than any challenge: it goes unanswered.
|
|
208
|
+
const bare = allocateRequest(0);
|
|
209
|
+
expect((await probe(server, [bare])).answers).toEqual([]);
|
|
210
|
+
const bareBinding = encodeStun(StunMethod.Binding, StunClass.Request, randomBytes(12), [], {
|
|
211
|
+
fingerprint: false,
|
|
212
|
+
});
|
|
213
|
+
expect((await probe(server, [bareBinding])).answers).toEqual([]);
|
|
214
|
+
|
|
215
|
+
// One the size a WebRTC client sends gets its challenge, no larger than itself.
|
|
216
|
+
const sized = allocateRequest(20);
|
|
217
|
+
const { answers } = await probe(server, [sized]);
|
|
218
|
+
expect(answers).toHaveLength(1);
|
|
219
|
+
expect(answers[0]!.length).toBeLessThanOrEqual(sized.length);
|
|
220
|
+
expect(readErrorCode(attribute(decodeStun(answers[0]!)!, StunAttr.ErrorCode))).toBe(401);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('are answered at a limited rate per source address and in all; the rest are dropped', async () => {
|
|
224
|
+
const burst = Array.from({ length: 10 }, () => allocateRequest(40));
|
|
225
|
+
// Every probe here comes from 127.0.0.1, one source address whatever its port; the clock
|
|
226
|
+
// stands still, so no budget refills between probes.
|
|
227
|
+
const now = Date.now();
|
|
228
|
+
const perSource = await serve({
|
|
229
|
+
now: () => now,
|
|
230
|
+
unauthenticatedLimits: { perSourcePerSecond: 3, totalPerSecond: 100 },
|
|
231
|
+
});
|
|
232
|
+
expect((await probe(perSource, burst)).answers).toHaveLength(3);
|
|
233
|
+
expect((await probe(perSource, burst)).answers).toHaveLength(0);
|
|
234
|
+
|
|
235
|
+
const inAll = await serve({
|
|
236
|
+
now: () => now,
|
|
237
|
+
unauthenticatedLimits: { perSourcePerSecond: 100, totalPerSecond: 4 },
|
|
238
|
+
});
|
|
239
|
+
expect((await probe(inAll, burst)).answers).toHaveLength(4);
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
describe('TURN server — a clock that steps back', () => {
|
|
244
|
+
it('drains no budget: unauthenticated answers and relayed data go on', async () => {
|
|
245
|
+
let now = Date.now();
|
|
246
|
+
const server = await serve({
|
|
247
|
+
now: () => now,
|
|
248
|
+
quotas: { bytesPerSecondPerOwner: 1_000 },
|
|
249
|
+
unauthenticatedLimits: { perSourcePerSecond: 3, totalPerSecond: 3 },
|
|
250
|
+
});
|
|
251
|
+
const turn = await client(server, 'user-a1');
|
|
252
|
+
expect((await turn.allocate()).ok).toBe(true);
|
|
253
|
+
const remote = await peer();
|
|
254
|
+
expect((await turn.permit(remote.address)).ok).toBe(true);
|
|
255
|
+
|
|
256
|
+
now -= 60 * 60 * 1000;
|
|
257
|
+
turn.sendTo(remote.address, Buffer.alloc(400, 1));
|
|
258
|
+
await expect.poll(() => remote.received.length).toBe(1);
|
|
259
|
+
now += 2_000;
|
|
260
|
+
// A new client still gets its challenge, and so an allocation.
|
|
261
|
+
expect((await (await client(server, 'user-b1')).allocate()).ok).toBe(true);
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
describe('TURN server — quotas', () => {
|
|
266
|
+
it('allocations per owner, and in all', async () => {
|
|
267
|
+
const server = await serve({ quotas: { allocationsPerOwner: 2, totalAllocations: 3 } });
|
|
268
|
+
for (const name of ['user-a1', 'user-a2']) {
|
|
269
|
+
expect((await (await client(server, name)).allocate()).ok).toBe(true);
|
|
270
|
+
}
|
|
271
|
+
expect((await (await client(server, 'user-a3')).allocate()).code).toBe(486);
|
|
272
|
+
expect((await (await client(server, 'user-b1')).allocate()).ok).toBe(true);
|
|
273
|
+
expect((await (await client(server, 'user-c1')).allocate()).code).toBe(508);
|
|
274
|
+
expect(server.allocationCount('a')).toBe(2);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('relayed bytes per owner per second: what exceeds the budget is dropped', async () => {
|
|
278
|
+
const server = await serve({ quotas: { bytesPerSecondPerOwner: 1_000 } });
|
|
279
|
+
const turn = await client(server, 'user-a1');
|
|
280
|
+
expect((await turn.allocate()).ok).toBe(true);
|
|
281
|
+
const remote = await peer();
|
|
282
|
+
expect((await turn.permit(remote.address)).ok).toBe(true);
|
|
283
|
+
for (let i = 0; i < 10; i += 1) turn.sendTo(remote.address, Buffer.alloc(400, i));
|
|
284
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
285
|
+
expect(remote.received).toHaveLength(2);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it('lifetime: a grant never exceeds the quota, and an allocation never outlives its age limit', async () => {
|
|
289
|
+
let now = Date.now();
|
|
290
|
+
const server = await serve({
|
|
291
|
+
now: () => now,
|
|
292
|
+
quotas: { maxLifetimeSeconds: 60, maxAllocationAgeMs: 90_000 },
|
|
293
|
+
});
|
|
294
|
+
const turn = await client(server, 'user-a1');
|
|
295
|
+
const allocated = await turn.allocate([
|
|
296
|
+
{ type: StunAttr.Lifetime, value: Buffer.from([0, 0, 0x0e, 0x10]) },
|
|
297
|
+
]);
|
|
298
|
+
expect(lifetimeOf(allocated)).toBe(60);
|
|
299
|
+
now += 50_000;
|
|
300
|
+
// 40 s of age are left; the refresh grants no more.
|
|
301
|
+
expect(lifetimeOf(await turn.refresh(3600))).toBe(40);
|
|
302
|
+
now += 41_000;
|
|
303
|
+
// Expired: refreshing now finds nothing of its own.
|
|
304
|
+
expect((await turn.refresh(3600)).ok).toBe(false);
|
|
305
|
+
expect(server.allocationCount()).toBe(0);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it('retain: an owner no longer named loses its allocations and cannot allocate again', async () => {
|
|
309
|
+
const server = await serve();
|
|
310
|
+
const turn = await client(server, 'user-a1');
|
|
311
|
+
expect((await turn.allocate()).ok).toBe(true);
|
|
312
|
+
server.retain(new Set(['b']));
|
|
313
|
+
expect(server.allocationCount()).toBe(0);
|
|
314
|
+
expect((await (await client(server, 'user-a2')).allocate()).code).toBe(403);
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
describe('TURN server — a bind that fails says why', () => {
|
|
319
|
+
it('the port is taken', async () => {
|
|
320
|
+
const holder = createSocket('udp4');
|
|
321
|
+
closers.push(() => holder.close());
|
|
322
|
+
await new Promise<void>((resolve) => holder.bind(0, '127.0.0.1', resolve));
|
|
323
|
+
const { port } = holder.address();
|
|
324
|
+
const started = serve({ port });
|
|
325
|
+
await expect(started).rejects.toThrow(`UDP 127.0.0.1:${port}: the port is taken`);
|
|
326
|
+
await expect(started).rejects.toHaveProperty('cause.code', 'EADDRINUSE');
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it('the host is not an address of this machine', async () => {
|
|
330
|
+
// 192.0.2.0/24 is reserved for documentation (RFC 5737); no machine running this has it.
|
|
331
|
+
const started = serve({ host: '192.0.2.1' });
|
|
332
|
+
await expect(started).rejects.toThrow(/192\.0\.2\.1 is not an address of this machine/);
|
|
333
|
+
await expect(started).rejects.toHaveProperty('cause.code', 'EADDRNOTAVAIL');
|
|
334
|
+
});
|
|
335
|
+
});
|