@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,820 @@
|
|
|
1
|
+
import { createIdentityContext } from '@robota-sdk/agent-remote-pairing';
|
|
2
|
+
const testIdentity = createIdentityContext('test-product');
|
|
3
|
+
import { createServer } from 'node:net';
|
|
4
|
+
import { hostname } from 'node:os';
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
RENDEZVOUS_EPOCH_MS,
|
|
8
|
+
derivePairRendezvous,
|
|
9
|
+
rendezvousEpoch,
|
|
10
|
+
startDeviceHandshake,
|
|
11
|
+
type IPairRendezvous,
|
|
12
|
+
} from '@robota-sdk/agent-remote-pairing';
|
|
13
|
+
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
|
14
|
+
import WebSocket from 'ws';
|
|
15
|
+
|
|
16
|
+
import { DeviceMeshNode, type IDeviceMeshLink } from '../device-mesh-node.js';
|
|
17
|
+
import { DiscoveringMeshRelay } from '../discovering-mesh-relay.js';
|
|
18
|
+
import {
|
|
19
|
+
addressCacheSource,
|
|
20
|
+
createInMemoryMeshAddressCache,
|
|
21
|
+
type IMeshAddressCache,
|
|
22
|
+
type IMeshCandidate,
|
|
23
|
+
type IMeshCandidateSource,
|
|
24
|
+
type IMeshPeerRoute,
|
|
25
|
+
} from '../mesh-discovery.js';
|
|
26
|
+
import {
|
|
27
|
+
lanAddressOf,
|
|
28
|
+
startMeshLanListener,
|
|
29
|
+
verifyLanPresenceProof,
|
|
30
|
+
type IMeshLanListener,
|
|
31
|
+
} from '../mesh-lan-listener.js';
|
|
32
|
+
import {
|
|
33
|
+
MESH_MDNS_SERVICE,
|
|
34
|
+
MeshMdns,
|
|
35
|
+
createInMemoryMdnsBus,
|
|
36
|
+
type IInMemoryMdnsBus,
|
|
37
|
+
type IMdnsPacket,
|
|
38
|
+
} from '../mesh-mdns.js';
|
|
39
|
+
import { createInMemoryMeshRelayHub, type IMeshRelay } from '../mesh-relay.js';
|
|
40
|
+
import { MeshPeerLink, type TMeshLinkEnd } from '../mesh-peer-link.js';
|
|
41
|
+
import { RtcPeer } from '../rtc-peer.js';
|
|
42
|
+
import {
|
|
43
|
+
ALL_CAPABILITIES,
|
|
44
|
+
buildMeshWorld,
|
|
45
|
+
type IMeshTestDevice,
|
|
46
|
+
type IMeshTestWorld,
|
|
47
|
+
} from './mesh-fixtures.js';
|
|
48
|
+
|
|
49
|
+
import type { IWebSocketLike } from '../ws-signaling-client.js';
|
|
50
|
+
|
|
51
|
+
const NOW = Date.now();
|
|
52
|
+
const LOCAL = '127.0.0.1';
|
|
53
|
+
|
|
54
|
+
let world: IMeshTestWorld;
|
|
55
|
+
let stranger: IMeshTestWorld;
|
|
56
|
+
const cleanups: (() => unknown)[] = [];
|
|
57
|
+
|
|
58
|
+
beforeAll(async () => {
|
|
59
|
+
world = await buildMeshWorld(NOW);
|
|
60
|
+
// Another roster under another signing key: none of its devices is on `world`'s lists.
|
|
61
|
+
stranger = await buildMeshWorld(NOW);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
afterEach(async () => {
|
|
65
|
+
for (const cleanup of cleanups.splice(0).reverse()) await cleanup();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
async function routeOf(own: IMeshTestDevice, peer: IMeshTestDevice): Promise<IMeshPeerRoute> {
|
|
69
|
+
const rendezvous = await derivePairRendezvous(testIdentity, {
|
|
70
|
+
ownKaPrivateKey: own.ka.privateKey,
|
|
71
|
+
own: own.cert,
|
|
72
|
+
peerDeviceId: peer.cert.deviceId,
|
|
73
|
+
lists: world.identity(own),
|
|
74
|
+
});
|
|
75
|
+
const topics = await rendezvous.relayInbox();
|
|
76
|
+
return { deviceId: peer.cert.deviceId, ...topics, rendezvous };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function mdnsOn(
|
|
80
|
+
bus: IInMemoryMdnsBus,
|
|
81
|
+
address: string,
|
|
82
|
+
now: () => number = () => NOW,
|
|
83
|
+
lookupTimeoutMs = 300,
|
|
84
|
+
): MeshMdns {
|
|
85
|
+
const mdns = new MeshMdns({
|
|
86
|
+
createTransport: () => bus.transport(address),
|
|
87
|
+
addresses: () => [address],
|
|
88
|
+
lookupTimeoutMs,
|
|
89
|
+
minAnswerIntervalMs: 0,
|
|
90
|
+
now,
|
|
91
|
+
});
|
|
92
|
+
cleanups.push(() => mdns.close());
|
|
93
|
+
return mdns;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function lookup(mdns: MeshMdns, route: IMeshPeerRoute): Promise<readonly IMeshCandidate[]> {
|
|
97
|
+
return mdns.candidates(route, new AbortController().signal);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const ptrs = (packet: IMdnsPacket) => (packet.answers ?? []).filter((r) => r.type === 'PTR');
|
|
101
|
+
|
|
102
|
+
function fakeRoute(index: number): IMeshPeerRoute {
|
|
103
|
+
const tag = (): Promise<Uint8Array> => {
|
|
104
|
+
const bytes = new Uint8Array(32);
|
|
105
|
+
globalThis.crypto.getRandomValues(bytes);
|
|
106
|
+
return Promise.resolve(bytes);
|
|
107
|
+
};
|
|
108
|
+
return {
|
|
109
|
+
deviceId: `device-${index}`,
|
|
110
|
+
inbound: `in-${index}`,
|
|
111
|
+
outbound: `out-${index}`,
|
|
112
|
+
rendezvous: { tag } as unknown as IPairRendezvous,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
describe('mDNS announcement', () => {
|
|
117
|
+
it('names neither the product, nor a device, nor the machine', async () => {
|
|
118
|
+
const bus = createInMemoryMdnsBus();
|
|
119
|
+
const mdns = mdnsOn(bus, '10.0.0.1');
|
|
120
|
+
await mdns.advertise(
|
|
121
|
+
[await routeOf(world.low, world.high), await routeOf(world.low, world.third)],
|
|
122
|
+
4242,
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
const announced = JSON.stringify(bus.responses).toLowerCase();
|
|
126
|
+
expect(announced).not.toContain('agent');
|
|
127
|
+
for (const device of [world.low, world.high, world.third]) {
|
|
128
|
+
expect(announced).not.toContain(device.cert.deviceId.toLowerCase());
|
|
129
|
+
expect(announced).not.toContain(device.cert.name.toLowerCase());
|
|
130
|
+
}
|
|
131
|
+
expect(announced).not.toContain(hostname().toLowerCase());
|
|
132
|
+
expect(MESH_MDNS_SERVICE.toLowerCase()).not.toContain('agent');
|
|
133
|
+
for (const ptr of ptrs(bus.responses[0]!)) {
|
|
134
|
+
expect(ptr.name).toBe(MESH_MDNS_SERVICE);
|
|
135
|
+
expect(ptr.data).toMatch(
|
|
136
|
+
new RegExp(`^[0-9a-f]{32}\\.${MESH_MDNS_SERVICE.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`),
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('pads the instance count, so the answer does not tell how many devices there are', async () => {
|
|
142
|
+
const counts: number[] = [];
|
|
143
|
+
for (const peers of [0, 1, 2, 7, 8, 9]) {
|
|
144
|
+
const bus = createInMemoryMdnsBus();
|
|
145
|
+
const mdns = mdnsOn(bus, '10.0.0.1');
|
|
146
|
+
await mdns.advertise(
|
|
147
|
+
Array.from({ length: peers }, (_, i) => fakeRoute(i)),
|
|
148
|
+
4242,
|
|
149
|
+
);
|
|
150
|
+
counts.push(ptrs(bus.responses[0]!).length);
|
|
151
|
+
}
|
|
152
|
+
expect(counts).toEqual([8, 8, 8, 8, 8, 16]);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('changes every name each epoch', async () => {
|
|
156
|
+
let clock = NOW;
|
|
157
|
+
const bus = createInMemoryMdnsBus();
|
|
158
|
+
const mdns = mdnsOn(bus, '10.0.0.1', () => clock);
|
|
159
|
+
const route = await routeOf(world.low, world.high);
|
|
160
|
+
await mdns.advertise([route], 4242);
|
|
161
|
+
clock += RENDEZVOUS_EPOCH_MS;
|
|
162
|
+
await mdns.advertise([route], 4242);
|
|
163
|
+
const [first, second] = bus.responses.map((packet) => JSON.stringify(packet));
|
|
164
|
+
const names = (json: string | undefined) => new Set(json?.match(/[0-9a-f]{32}/g));
|
|
165
|
+
const overlap = [...names(first)].filter((name) => names(second).has(name));
|
|
166
|
+
expect(overlap).toEqual([]);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
describe('mDNS announcement within one epoch', () => {
|
|
171
|
+
it('keeps its padding, so two answers from one epoch do not single out the real names', async () => {
|
|
172
|
+
const bus = createInMemoryMdnsBus();
|
|
173
|
+
const mdns = mdnsOn(bus, '10.0.0.1');
|
|
174
|
+
const toHigh = await routeOf(world.low, world.high);
|
|
175
|
+
await mdns.advertise([toHigh], 4242);
|
|
176
|
+
await mdns.advertise([toHigh], 4242);
|
|
177
|
+
// Nothing changed: nothing is announced again.
|
|
178
|
+
expect(bus.responses).toHaveLength(1);
|
|
179
|
+
await mdns.advertise([toHigh, await routeOf(world.low, world.third)], 4242);
|
|
180
|
+
expect(bus.responses).toHaveLength(2);
|
|
181
|
+
const names = (packet: IMdnsPacket) => new Set(ptrs(packet).map((r) => String(r.data)));
|
|
182
|
+
const [first, second] = bus.responses.map(names);
|
|
183
|
+
const common = [...first!].filter((name) => second!.has(name));
|
|
184
|
+
// One real name is common to both; the rest of the common names are padding.
|
|
185
|
+
expect(common.length).toBe(7);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('answers a flood of queries a bounded number of times', async () => {
|
|
189
|
+
const bus = createInMemoryMdnsBus();
|
|
190
|
+
const mdns = new MeshMdns({
|
|
191
|
+
createTransport: () => bus.transport('10.0.0.1'),
|
|
192
|
+
addresses: () => ['10.0.0.1'],
|
|
193
|
+
});
|
|
194
|
+
cleanups.push(() => mdns.close());
|
|
195
|
+
await mdns.advertise([await routeOf(world.low, world.high)], 4242);
|
|
196
|
+
const flooder = bus.transport('10.0.0.9');
|
|
197
|
+
cleanups.push(() => flooder.destroy());
|
|
198
|
+
for (let i = 0; i < 50; i += 1) {
|
|
199
|
+
flooder.query({ questions: [{ name: MESH_MDNS_SERVICE, type: 'PTR' }] });
|
|
200
|
+
}
|
|
201
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
202
|
+
// The announcement, and at most one answer so far.
|
|
203
|
+
expect(bus.responses.length).toBeLessThanOrEqual(2);
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
describe('mDNS lookup', () => {
|
|
208
|
+
it('finds the peer at the address that answered, in the adjacent epochs too, and nothing two epochs away', async () => {
|
|
209
|
+
const bus = createInMemoryMdnsBus();
|
|
210
|
+
await mdnsOn(bus, '10.0.0.1').advertise([await routeOf(world.low, world.high)], 4242);
|
|
211
|
+
const toLow = await routeOf(world.high, world.low);
|
|
212
|
+
|
|
213
|
+
for (const skew of [-1, 0, 1]) {
|
|
214
|
+
const high = mdnsOn(bus, '10.0.0.2', () => NOW + skew * RENDEZVOUS_EPOCH_MS);
|
|
215
|
+
await expect(lookup(high, toLow)).resolves.toEqual([{ host: '10.0.0.1', port: 4242 }]);
|
|
216
|
+
}
|
|
217
|
+
const far = mdnsOn(bus, '10.0.0.2', () => NOW + 2 * RENDEZVOUS_EPOCH_MS);
|
|
218
|
+
await expect(lookup(far, toLow)).resolves.toEqual([]);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('keeps the directions apart: a device does not find its own announcement, and another pair finds nothing', async () => {
|
|
222
|
+
const bus = createInMemoryMdnsBus();
|
|
223
|
+
const low = mdnsOn(bus, '10.0.0.1');
|
|
224
|
+
await low.advertise([await routeOf(world.low, world.high)], 4242);
|
|
225
|
+
|
|
226
|
+
// low announces dir(low → high); looking up dir(high → low) finds no one, since high is silent.
|
|
227
|
+
await expect(lookup(low, await routeOf(world.low, world.high))).resolves.toEqual([]);
|
|
228
|
+
const third = mdnsOn(bus, '10.0.0.3');
|
|
229
|
+
await expect(lookup(third, await routeOf(world.third, world.low))).resolves.toEqual([]);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('keeps the peer’s own answer when another host answers first with the same names', async () => {
|
|
233
|
+
const bus = createInMemoryMdnsBus();
|
|
234
|
+
// The peer answers a little after the query, as a responder may.
|
|
235
|
+
const peerTransport = bus.transport('10.0.0.1');
|
|
236
|
+
const peer = new MeshMdns({
|
|
237
|
+
createTransport: () => ({
|
|
238
|
+
...peerTransport,
|
|
239
|
+
on: peerTransport.on.bind(peerTransport),
|
|
240
|
+
respond: (packet) => setTimeout(() => peerTransport.respond(packet), 30),
|
|
241
|
+
}),
|
|
242
|
+
addresses: () => ['10.0.0.1'],
|
|
243
|
+
minAnswerIntervalMs: 0,
|
|
244
|
+
now: () => NOW,
|
|
245
|
+
});
|
|
246
|
+
cleanups.push(() => peer.close());
|
|
247
|
+
await peer.advertise([await routeOf(world.low, world.high)], 4242);
|
|
248
|
+
await vi.waitFor(() => expect(bus.responses).toHaveLength(1));
|
|
249
|
+
// Another host replays the peer's names at once, pointing at a port of its own.
|
|
250
|
+
const [announced] = bus.responses;
|
|
251
|
+
const replay = {
|
|
252
|
+
answers: announced!.answers ?? [],
|
|
253
|
+
additionals: (announced!.additionals ?? []).map((r) =>
|
|
254
|
+
r.type === 'SRV' ? { ...r, data: { ...(r.data as object), port: 9999 } } : r,
|
|
255
|
+
),
|
|
256
|
+
};
|
|
257
|
+
const other = bus.transport('10.0.0.66');
|
|
258
|
+
cleanups.push(() => other.destroy());
|
|
259
|
+
// Answering again and again, each time with another port, does not fill the lookup either.
|
|
260
|
+
other.on('query', () => {
|
|
261
|
+
for (let port = 9000; port < 9010; port += 1) {
|
|
262
|
+
other.respond({
|
|
263
|
+
...replay,
|
|
264
|
+
additionals: replay.additionals.map((r) =>
|
|
265
|
+
r.type === 'SRV' ? { ...r, data: { ...(r.data as object), port } } : r,
|
|
266
|
+
),
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
const found = await lookup(
|
|
272
|
+
mdnsOn(bus, '10.0.0.2', () => NOW, 1_000),
|
|
273
|
+
await routeOf(world.high, world.low),
|
|
274
|
+
);
|
|
275
|
+
expect(found).toContainEqual({ host: '10.0.0.1', port: 4242 });
|
|
276
|
+
expect(found.filter((c) => c.host === '10.0.0.66').length).toBeLessThanOrEqual(2);
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
/** A socket factory that records every frame this device sends on the local network. */
|
|
281
|
+
function recordingConnect(sent: string[]): (candidate: IMeshCandidate) => IWebSocketLike {
|
|
282
|
+
return (candidate) => {
|
|
283
|
+
const socket = new WebSocket(`ws://${candidate.host}:${candidate.port}`);
|
|
284
|
+
return {
|
|
285
|
+
get readyState() {
|
|
286
|
+
return socket.readyState;
|
|
287
|
+
},
|
|
288
|
+
send(data: string) {
|
|
289
|
+
sent.push(data);
|
|
290
|
+
socket.send(data);
|
|
291
|
+
},
|
|
292
|
+
close() {
|
|
293
|
+
socket.close();
|
|
294
|
+
},
|
|
295
|
+
on(event, handler) {
|
|
296
|
+
socket.on(event, handler as never);
|
|
297
|
+
},
|
|
298
|
+
};
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
interface ILanDevice {
|
|
303
|
+
readonly node: DeviceMeshNode;
|
|
304
|
+
readonly listener: IMeshLanListener;
|
|
305
|
+
readonly cache: IMeshAddressCache;
|
|
306
|
+
readonly mdns?: MeshMdns;
|
|
307
|
+
readonly sent: string[];
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
async function lanDevice(
|
|
311
|
+
device: IMeshTestDevice,
|
|
312
|
+
options: {
|
|
313
|
+
readonly bus?: IInMemoryMdnsBus;
|
|
314
|
+
readonly relay?: IMeshRelay;
|
|
315
|
+
readonly extraSources?: readonly IMeshCandidateSource[];
|
|
316
|
+
readonly cache?: IMeshAddressCache;
|
|
317
|
+
readonly admissionTimeoutMs?: number;
|
|
318
|
+
readonly connectTimeoutMs?: number;
|
|
319
|
+
readonly probeTimeoutMs?: number;
|
|
320
|
+
} = {},
|
|
321
|
+
): Promise<ILanDevice> {
|
|
322
|
+
const listener = await startMeshLanListener({ host: LOCAL });
|
|
323
|
+
const cache = options.cache ?? createInMemoryMeshAddressCache();
|
|
324
|
+
const mdns = options.bus !== undefined ? mdnsOn(options.bus, LOCAL) : undefined;
|
|
325
|
+
const sent: string[] = [];
|
|
326
|
+
const relay = new DiscoveringMeshRelay({
|
|
327
|
+
...(options.relay !== undefined ? { relay: options.relay } : {}),
|
|
328
|
+
listener,
|
|
329
|
+
sources: [
|
|
330
|
+
addressCacheSource(cache),
|
|
331
|
+
...(mdns !== undefined ? [mdns] : []),
|
|
332
|
+
...(options.extraSources ?? []),
|
|
333
|
+
],
|
|
334
|
+
cache,
|
|
335
|
+
...(mdns !== undefined ? { advertisers: [mdns] } : {}),
|
|
336
|
+
connect: recordingConnect(sent),
|
|
337
|
+
probeTimeoutMs: options.probeTimeoutMs ?? 500,
|
|
338
|
+
...(options.admissionTimeoutMs !== undefined
|
|
339
|
+
? { admissionTimeoutMs: options.admissionTimeoutMs }
|
|
340
|
+
: {}),
|
|
341
|
+
});
|
|
342
|
+
const node = new DeviceMeshNode({cryptoContext: testIdentity,
|
|
343
|
+
identity: world.identity(device),
|
|
344
|
+
sessionDescriptor: device.session,
|
|
345
|
+
localPolicy: ALL_CAPABILITIES,
|
|
346
|
+
relay,
|
|
347
|
+
connectTimeoutMs: options.connectTimeoutMs ?? 15_000,
|
|
348
|
+
});
|
|
349
|
+
cleanups.push(
|
|
350
|
+
() => node.stop(),
|
|
351
|
+
() => relay.close(),
|
|
352
|
+
);
|
|
353
|
+
return { node, listener, cache, ...(mdns !== undefined ? { mdns } : {}), sent };
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function nextMessage(link: IDeviceMeshLink): Promise<string> {
|
|
357
|
+
return new Promise((resolve) => {
|
|
358
|
+
const off = link.onMessage((body) => {
|
|
359
|
+
off();
|
|
360
|
+
resolve(body);
|
|
361
|
+
});
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** Whether `listener` says it holds `topic`. */
|
|
366
|
+
async function holds(listener: IMeshLanListener, topic: string): Promise<boolean> {
|
|
367
|
+
const socket = new WebSocket(`ws://${LOCAL}:${listener.port}`);
|
|
368
|
+
try {
|
|
369
|
+
await new Promise((resolve, reject) => {
|
|
370
|
+
socket.once('open', resolve);
|
|
371
|
+
socket.once('error', reject);
|
|
372
|
+
});
|
|
373
|
+
const nonce = 'N'.repeat(22);
|
|
374
|
+
socket.send(JSON.stringify({ type: 'probe', to: lanAddressOf(topic), nonce }));
|
|
375
|
+
const answer = await new Promise<string>((resolve) =>
|
|
376
|
+
socket.once('message', (raw) => resolve(String(raw))),
|
|
377
|
+
);
|
|
378
|
+
const frame = JSON.parse(answer) as { type: string; proof?: string };
|
|
379
|
+
const proven = verifyLanPresenceProof(topic, nonce, frame.proof);
|
|
380
|
+
return proven && frame.type === 'present';
|
|
381
|
+
} finally {
|
|
382
|
+
socket.close();
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** A local port nothing listens on. */
|
|
387
|
+
async function closedPort(): Promise<number> {
|
|
388
|
+
const server = createServer();
|
|
389
|
+
await new Promise<void>((resolve) => server.listen(0, LOCAL, resolve));
|
|
390
|
+
const address = server.address();
|
|
391
|
+
const port = typeof address === 'object' && address !== null ? address.port : 0;
|
|
392
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
393
|
+
return port;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
describe('DiscoveringMeshRelay — the device mesh without a relay', () => {
|
|
397
|
+
it('two devices find each other with mDNS, connect directly, and remember the address that worked', async () => {
|
|
398
|
+
const bus = createInMemoryMdnsBus();
|
|
399
|
+
const low = await lanDevice(world.low, { bus });
|
|
400
|
+
const high = await lanDevice(world.high, { bus });
|
|
401
|
+
await Promise.all([low.node.start(), high.node.start()]);
|
|
402
|
+
|
|
403
|
+
const [atLow, atHigh] = await Promise.all([
|
|
404
|
+
low.node.connect(world.high.cert.deviceId),
|
|
405
|
+
high.node.connect(world.low.cert.deviceId),
|
|
406
|
+
]);
|
|
407
|
+
expect(atLow.admission.deviceId).toBe(world.high.cert.deviceId);
|
|
408
|
+
const received = nextMessage(atHigh);
|
|
409
|
+
atLow.send('over the local network');
|
|
410
|
+
await expect(received).resolves.toBe('over the local network');
|
|
411
|
+
|
|
412
|
+
expect(low.cache.recall(world.high.cert.deviceId)).toEqual([
|
|
413
|
+
{ host: LOCAL, port: high.listener.port },
|
|
414
|
+
]);
|
|
415
|
+
expect(high.cache.recall(world.low.cert.deviceId)).toEqual([
|
|
416
|
+
{ host: LOCAL, port: low.listener.port },
|
|
417
|
+
]);
|
|
418
|
+
|
|
419
|
+
// What crossed the local network names no device and none of the relay's inbox topics.
|
|
420
|
+
const routes = [await routeOf(world.low, world.high), await routeOf(world.high, world.low)];
|
|
421
|
+
const wire = [...low.sent, ...high.sent].join('\n');
|
|
422
|
+
expect(low.sent.length).toBeGreaterThan(0);
|
|
423
|
+
for (const route of routes) {
|
|
424
|
+
expect(wire).not.toContain(route.inbound);
|
|
425
|
+
expect(wire).not.toContain(route.outbound);
|
|
426
|
+
}
|
|
427
|
+
for (const device of [world.low, world.high]) expect(wire).not.toContain(device.cert.deviceId);
|
|
428
|
+
}, 40_000);
|
|
429
|
+
|
|
430
|
+
it('tries the address cache first, past a stale entry, before any other source', async () => {
|
|
431
|
+
const asked: string[] = [];
|
|
432
|
+
const spy: IMeshCandidateSource = {
|
|
433
|
+
candidates: (peer) => {
|
|
434
|
+
asked.push(peer.deviceId);
|
|
435
|
+
return Promise.resolve([]);
|
|
436
|
+
},
|
|
437
|
+
};
|
|
438
|
+
const lowCache = createInMemoryMeshAddressCache();
|
|
439
|
+
const highCache = createInMemoryMeshAddressCache();
|
|
440
|
+
const low = await lanDevice(world.low, { cache: lowCache, extraSources: [spy] });
|
|
441
|
+
const high = await lanDevice(world.high, { cache: highCache });
|
|
442
|
+
// Most recent first: the stale address is tried first and fails.
|
|
443
|
+
lowCache.remember(world.high.cert.deviceId, { host: LOCAL, port: high.listener.port });
|
|
444
|
+
lowCache.remember(world.high.cert.deviceId, { host: LOCAL, port: await closedPort() });
|
|
445
|
+
highCache.remember(world.low.cert.deviceId, { host: LOCAL, port: low.listener.port });
|
|
446
|
+
// The peer is up before this device looks for it.
|
|
447
|
+
await high.node.start();
|
|
448
|
+
const toLow = await routeOf(world.low, world.high);
|
|
449
|
+
const lanTopic = Buffer.from(
|
|
450
|
+
await toLow.rendezvous.tag('lan-inbox', 'outbound', rendezvousEpoch(Date.now())),
|
|
451
|
+
).toString('base64url');
|
|
452
|
+
await expect.poll(() => holds(high.listener, lanTopic)).toBe(true);
|
|
453
|
+
await low.node.start();
|
|
454
|
+
|
|
455
|
+
await Promise.all([
|
|
456
|
+
low.node.connect(world.high.cert.deviceId),
|
|
457
|
+
high.node.connect(world.low.cert.deviceId),
|
|
458
|
+
]);
|
|
459
|
+
// Only for the third rostered device, which the cache knows nothing about.
|
|
460
|
+
expect(asked).not.toContain(world.high.cert.deviceId);
|
|
461
|
+
expect(asked).toContain(world.third.cert.deviceId);
|
|
462
|
+
expect(lowCache.recall(world.high.cert.deviceId)[0]).toEqual({
|
|
463
|
+
host: LOCAL,
|
|
464
|
+
port: high.listener.port,
|
|
465
|
+
});
|
|
466
|
+
}, 40_000);
|
|
467
|
+
|
|
468
|
+
it('falls back to the relay when no candidate answers, and remembers no address for it', async () => {
|
|
469
|
+
const hub = createInMemoryMeshRelayHub();
|
|
470
|
+
const low = await lanDevice(world.low, { relay: hub.connect() });
|
|
471
|
+
const high = await lanDevice(world.high, { relay: hub.connect() });
|
|
472
|
+
await Promise.all([low.node.start(), high.node.start()]);
|
|
473
|
+
|
|
474
|
+
await Promise.all([
|
|
475
|
+
low.node.connect(world.high.cert.deviceId),
|
|
476
|
+
high.node.connect(world.low.cert.deviceId),
|
|
477
|
+
]);
|
|
478
|
+
expect(low.cache.recall(world.high.cert.deviceId)).toEqual([]);
|
|
479
|
+
expect(low.sent).toEqual([]);
|
|
480
|
+
}, 40_000);
|
|
481
|
+
|
|
482
|
+
it('a revoked peer is no longer announced to, and its address is forgotten', async () => {
|
|
483
|
+
const bus = createInMemoryMdnsBus();
|
|
484
|
+
const low = await lanDevice(world.low, { bus });
|
|
485
|
+
await low.node.start();
|
|
486
|
+
low.cache.remember(world.high.cert.deviceId, { host: LOCAL, port: 1 });
|
|
487
|
+
const toHigh = await routeOf(world.low, world.high);
|
|
488
|
+
const tag = Buffer.from(
|
|
489
|
+
(await toHigh.rendezvous.tag('mdns', 'outbound', rendezvousEpoch(NOW))).subarray(0, 16),
|
|
490
|
+
).toString('hex');
|
|
491
|
+
await expect.poll(() => JSON.stringify(bus.responses.at(-1))).toContain(tag);
|
|
492
|
+
|
|
493
|
+
await low.node.refresh({
|
|
494
|
+
identity: world.identity(world.low, { revocation: await world.revoking(world.high) }),
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
await expect.poll(() => low.cache.recall(world.high.cert.deviceId)).toEqual([]);
|
|
498
|
+
await expect.poll(() => JSON.stringify(bus.responses.at(-1))).not.toContain(tag);
|
|
499
|
+
}, 20_000);
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
describe('discovery grants nothing', () => {
|
|
503
|
+
it('a candidate that leads to a device off the lists is refused, and not remembered', async () => {
|
|
504
|
+
// The strongest discovery attacker: it knows the pair's local-network topics, holds them on its
|
|
505
|
+
// own endpoint, and gets that endpoint offered as the peer's address.
|
|
506
|
+
const toHighTopics = await routeOf(world.high, world.low);
|
|
507
|
+
const epoch = rendezvousEpoch(NOW);
|
|
508
|
+
const lanTopic = async (route: IMeshPeerRoute, direction: 'outbound' | 'inbound') =>
|
|
509
|
+
Buffer.from(await route.rendezvous.tag('lan-inbox', direction, epoch)).toString('base64url');
|
|
510
|
+
const attackerListener = await startMeshLanListener({ host: LOCAL });
|
|
511
|
+
cleanups.push(() => attackerListener.close());
|
|
512
|
+
attackerListener.setPresence([await lanTopic(toHighTopics, 'inbound')]);
|
|
513
|
+
|
|
514
|
+
const planted: IMeshCandidateSource = {
|
|
515
|
+
candidates: () => Promise.resolve([{ host: LOCAL, port: attackerListener.port }]),
|
|
516
|
+
};
|
|
517
|
+
const low = await lanDevice(world.low, { extraSources: [planted] });
|
|
518
|
+
const refusals: TMeshLinkEnd[] = [];
|
|
519
|
+
const admitted: IDeviceMeshLink[] = [];
|
|
520
|
+
low.node.onRefusal((refusal) => refusals.push(refusal.end));
|
|
521
|
+
low.node.onLink((link) => admitted.push(link));
|
|
522
|
+
|
|
523
|
+
// The attacker answers as the peer, with a device of another roster.
|
|
524
|
+
const toLow = new WebSocket(`ws://${LOCAL}:${low.listener.port}`);
|
|
525
|
+
await new Promise((resolve) => toLow.once('open', resolve));
|
|
526
|
+
cleanups.push(() => toLow.close());
|
|
527
|
+
const outTopic = await lanTopic(toHighTopics, 'outbound');
|
|
528
|
+
const instance = 'DDDDDDDDDDDDDDDDDDDDDD';
|
|
529
|
+
const impostor = stranger.high;
|
|
530
|
+
let rogue: MeshPeerLink | undefined;
|
|
531
|
+
attackerListener.onMessage((_topic, data) => {
|
|
532
|
+
const signal = data as { kind: string; from: string; cid: string; sdp?: string };
|
|
533
|
+
const send = (s: object): void => {
|
|
534
|
+
toLow.send(
|
|
535
|
+
JSON.stringify({
|
|
536
|
+
type: 'message',
|
|
537
|
+
to: lanAddressOf(outTopic),
|
|
538
|
+
data: { v: 1, from: instance, to: signal.from, cid: signal.cid, ...s },
|
|
539
|
+
}),
|
|
540
|
+
);
|
|
541
|
+
};
|
|
542
|
+
if (signal.kind === 'hello') {
|
|
543
|
+
toLow.send(
|
|
544
|
+
JSON.stringify({
|
|
545
|
+
type: 'message',
|
|
546
|
+
to: lanAddressOf(outTopic),
|
|
547
|
+
data: { v: 1, kind: 'hello', from: instance },
|
|
548
|
+
}),
|
|
549
|
+
);
|
|
550
|
+
} else if (signal.kind === 'offer') {
|
|
551
|
+
rogue = new MeshPeerLink({
|
|
552
|
+
role: 'answerer',
|
|
553
|
+
createPeer: () => new RtcPeer(),
|
|
554
|
+
sendSignal: send,
|
|
555
|
+
startHandshake: (binding) =>
|
|
556
|
+
startDeviceHandshake(testIdentity, {
|
|
557
|
+
role: 'responder',
|
|
558
|
+
identity: stranger.identity(impostor),
|
|
559
|
+
sessionDescriptor: impostor.session,
|
|
560
|
+
localFingerprint: binding.localFingerprint,
|
|
561
|
+
remoteFingerprint: binding.remoteFingerprint,
|
|
562
|
+
locality: 'another-host',
|
|
563
|
+
localPolicy: ALL_CAPABILITIES,
|
|
564
|
+
send: binding.send,
|
|
565
|
+
}),
|
|
566
|
+
connectTimeoutMs: 10_000,
|
|
567
|
+
onAdmitted: () => undefined,
|
|
568
|
+
onEnded: () => undefined,
|
|
569
|
+
});
|
|
570
|
+
cleanups.push(() => rogue?.close());
|
|
571
|
+
rogue.onSignal({ kind: 'offer', sdp: signal.sdp! });
|
|
572
|
+
} else if (signal.kind === 'ice') {
|
|
573
|
+
rogue?.onSignal({ kind: 'ice', candidate: (data as { candidate: never }).candidate });
|
|
574
|
+
}
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
await low.node.start();
|
|
578
|
+
const attempt = low.node.connect(world.high.cert.deviceId, 12_000).then(
|
|
579
|
+
() => 'admitted',
|
|
580
|
+
() => 'refused',
|
|
581
|
+
);
|
|
582
|
+
|
|
583
|
+
await expect.poll(() => refusals.length, { timeout: 15_000 }).toBeGreaterThan(0);
|
|
584
|
+
// The impostor got as far as the handshake over a real channel, and no further.
|
|
585
|
+
expect(rogue).toBeDefined();
|
|
586
|
+
await expect(attempt).resolves.toBe('refused');
|
|
587
|
+
expect(admitted).toEqual([]);
|
|
588
|
+
// The attacker's endpoint carried the signals, and is still not remembered as the peer's.
|
|
589
|
+
expect(low.cache.recall(world.high.cert.deviceId)).toEqual([]);
|
|
590
|
+
}, 40_000);
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
describe('a hostile endpoint cannot hold a pair off the relay', () => {
|
|
594
|
+
it('an endpoint that echoes every probe is never used', async () => {
|
|
595
|
+
const { WebSocketServer } = await import('ws');
|
|
596
|
+
const echo = new WebSocketServer({ host: LOCAL, port: 0 });
|
|
597
|
+
await new Promise((resolve) => echo.once('listening', resolve));
|
|
598
|
+
cleanups.push(() => new Promise((resolve) => echo.close(resolve)));
|
|
599
|
+
let probed = 0;
|
|
600
|
+
echo.on('connection', (socket) =>
|
|
601
|
+
socket.on('message', (raw) => {
|
|
602
|
+
const frame = JSON.parse(String(raw)) as { to: string; nonce: string };
|
|
603
|
+
probed += 1;
|
|
604
|
+
socket.send(
|
|
605
|
+
JSON.stringify({ type: 'present', to: frame.to, nonce: frame.nonce, proof: 'x' }),
|
|
606
|
+
);
|
|
607
|
+
}),
|
|
608
|
+
);
|
|
609
|
+
const address = echo.address();
|
|
610
|
+
const port = typeof address === 'object' && address !== null ? address.port : 0;
|
|
611
|
+
const planted: IMeshCandidateSource = {
|
|
612
|
+
candidates: () => Promise.resolve([{ host: LOCAL, port }]),
|
|
613
|
+
};
|
|
614
|
+
const hub = createInMemoryMeshRelayHub();
|
|
615
|
+
const low = await lanDevice(world.low, { relay: hub.connect(), extraSources: [planted] });
|
|
616
|
+
const high = await lanDevice(world.high, { relay: hub.connect(), extraSources: [planted] });
|
|
617
|
+
await Promise.all([low.node.start(), high.node.start()]);
|
|
618
|
+
|
|
619
|
+
await Promise.all([
|
|
620
|
+
low.node.connect(world.high.cert.deviceId),
|
|
621
|
+
high.node.connect(world.low.cert.deviceId),
|
|
622
|
+
]);
|
|
623
|
+
expect(probed).toBeGreaterThan(0);
|
|
624
|
+
// It got probes, never a signal.
|
|
625
|
+
expect(low.sent.filter((frame) => frame.includes('"message"'))).toEqual([]);
|
|
626
|
+
expect(low.cache.recall(world.high.cert.deviceId)).toEqual([]);
|
|
627
|
+
}, 40_000);
|
|
628
|
+
|
|
629
|
+
it('an endpoint that answers a probe with anything but the proof is dropped at once', async () => {
|
|
630
|
+
const { WebSocketServer } = await import('ws');
|
|
631
|
+
const echo = new WebSocketServer({ host: LOCAL, port: 0 });
|
|
632
|
+
await new Promise((resolve) => echo.once('listening', resolve));
|
|
633
|
+
cleanups.push(() => new Promise((resolve) => echo.close(resolve)));
|
|
634
|
+
// It sends the probe straight back: a frame for the address, of a type that is neither answer.
|
|
635
|
+
const dropped = new Promise<void>((resolve) =>
|
|
636
|
+
echo.on('connection', (socket) => {
|
|
637
|
+
socket.on('message', (raw) => socket.send(String(raw)));
|
|
638
|
+
socket.on('close', () => resolve());
|
|
639
|
+
}),
|
|
640
|
+
);
|
|
641
|
+
const address = echo.address();
|
|
642
|
+
const port = typeof address === 'object' && address !== null ? address.port : 0;
|
|
643
|
+
const planted: IMeshCandidateSource = {
|
|
644
|
+
candidates: () => Promise.resolve([{ host: LOCAL, port }]),
|
|
645
|
+
};
|
|
646
|
+
const hub = createInMemoryMeshRelayHub();
|
|
647
|
+
// A probe left to its timeout would hold the socket far past this test's wait.
|
|
648
|
+
const low = await lanDevice(world.low, {
|
|
649
|
+
relay: hub.connect(),
|
|
650
|
+
extraSources: [planted],
|
|
651
|
+
probeTimeoutMs: 60_000,
|
|
652
|
+
});
|
|
653
|
+
const high = await lanDevice(world.high, { relay: hub.connect(), probeTimeoutMs: 60_000 });
|
|
654
|
+
await Promise.all([low.node.start(), high.node.start()]);
|
|
655
|
+
void low.node.connect(world.high.cert.deviceId).catch(() => undefined);
|
|
656
|
+
|
|
657
|
+
await expect(
|
|
658
|
+
Promise.race([
|
|
659
|
+
dropped.then(() => 'dropped'),
|
|
660
|
+
new Promise((resolve) => setTimeout(() => resolve('still open'), 5_000)),
|
|
661
|
+
]),
|
|
662
|
+
).resolves.toBe('dropped');
|
|
663
|
+
expect(low.sent.filter((frame) => frame.includes('"message"'))).toEqual([]);
|
|
664
|
+
}, 20_000);
|
|
665
|
+
|
|
666
|
+
it('an endpoint that holds the topic but leads to no admission is set aside for the relay', async () => {
|
|
667
|
+
// It knows the pair's topic and swallows everything sent to it.
|
|
668
|
+
const epoch = rendezvousEpoch(NOW);
|
|
669
|
+
const toHigh = await routeOf(world.low, world.high);
|
|
670
|
+
const sink = await startMeshLanListener({ host: LOCAL });
|
|
671
|
+
cleanups.push(() => sink.close());
|
|
672
|
+
let swallowed = 0;
|
|
673
|
+
sink.onMessage(() => (swallowed += 1));
|
|
674
|
+
sink.setPresence([
|
|
675
|
+
Buffer.from(await toHigh.rendezvous.tag('lan-inbox', 'outbound', epoch)).toString(
|
|
676
|
+
'base64url',
|
|
677
|
+
),
|
|
678
|
+
]);
|
|
679
|
+
const planted: IMeshCandidateSource = {
|
|
680
|
+
candidates: () => Promise.resolve([{ host: LOCAL, port: sink.port }]),
|
|
681
|
+
};
|
|
682
|
+
const hub = createInMemoryMeshRelayHub();
|
|
683
|
+
const low = await lanDevice(world.low, {
|
|
684
|
+
relay: hub.connect(),
|
|
685
|
+
extraSources: [planted],
|
|
686
|
+
admissionTimeoutMs: 1_000,
|
|
687
|
+
connectTimeoutMs: 4_000,
|
|
688
|
+
});
|
|
689
|
+
const high = await lanDevice(world.high, { relay: hub.connect() });
|
|
690
|
+
await Promise.all([low.node.start(), high.node.start()]);
|
|
691
|
+
|
|
692
|
+
// The first attempt's signals are swallowed.
|
|
693
|
+
await expect(low.node.connect(world.high.cert.deviceId, 2_000)).rejects.toThrow();
|
|
694
|
+
expect(swallowed).toBeGreaterThan(0);
|
|
695
|
+
// Once set aside, the relay carries the pair, and the sink is not remembered.
|
|
696
|
+
await expect
|
|
697
|
+
.poll(
|
|
698
|
+
async () => {
|
|
699
|
+
const [a] = await Promise.allSettled([
|
|
700
|
+
low.node.connect(world.high.cert.deviceId, 3_000),
|
|
701
|
+
high.node.connect(world.low.cert.deviceId, 3_000),
|
|
702
|
+
]);
|
|
703
|
+
return a.status;
|
|
704
|
+
},
|
|
705
|
+
{ timeout: 20_000, interval: 200 },
|
|
706
|
+
)
|
|
707
|
+
.toBe('fulfilled');
|
|
708
|
+
expect(low.cache.recall(world.high.cert.deviceId)).toEqual([]);
|
|
709
|
+
}, 40_000);
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
describe('a proxy that forwards once', () => {
|
|
713
|
+
it('cannot hold the pair off the relay by swallowing a later attempt', async () => {
|
|
714
|
+
const hub = createInMemoryMeshRelayHub();
|
|
715
|
+
const high = await lanDevice(world.high, { relay: hub.connect() });
|
|
716
|
+
// A transparent proxy to the peer's real endpoint, which later starts swallowing what it gets.
|
|
717
|
+
const { WebSocketServer } = await import('ws');
|
|
718
|
+
const proxy = new WebSocketServer({ host: LOCAL, port: 0 });
|
|
719
|
+
await new Promise((resolve) => proxy.once('listening', resolve));
|
|
720
|
+
cleanups.push(() => new Promise((resolve) => proxy.close(resolve)));
|
|
721
|
+
let swallowing = false;
|
|
722
|
+
let swallowed = 0;
|
|
723
|
+
proxy.on('connection', (client) => {
|
|
724
|
+
const upstream = new WebSocket(`ws://${LOCAL}:${high.listener.port}`);
|
|
725
|
+
const early: string[] = [];
|
|
726
|
+
upstream.on('open', () => early.splice(0).forEach((frame) => upstream.send(frame)));
|
|
727
|
+
upstream.on('message', (raw) => client.send(String(raw)));
|
|
728
|
+
client.on('message', (raw) => {
|
|
729
|
+
const frame = String(raw);
|
|
730
|
+
if (swallowing && frame.includes('"message"')) {
|
|
731
|
+
swallowed += 1;
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
734
|
+
if (upstream.readyState === WebSocket.OPEN) upstream.send(frame);
|
|
735
|
+
else early.push(frame);
|
|
736
|
+
});
|
|
737
|
+
client.on('close', () => upstream.close());
|
|
738
|
+
});
|
|
739
|
+
const address = proxy.address();
|
|
740
|
+
const port = typeof address === 'object' && address !== null ? address.port : 0;
|
|
741
|
+
const planted: IMeshCandidateSource = {
|
|
742
|
+
candidates: () => Promise.resolve([{ host: LOCAL, port }]),
|
|
743
|
+
};
|
|
744
|
+
const low = await lanDevice(world.low, {
|
|
745
|
+
relay: hub.connect(),
|
|
746
|
+
extraSources: [planted],
|
|
747
|
+
// Long enough that the first admission lands well within it, even on a slow runner.
|
|
748
|
+
admissionTimeoutMs: 5_000,
|
|
749
|
+
});
|
|
750
|
+
// The peer holds the pair's topic before this device probes through the proxy; otherwise the
|
|
751
|
+
// probe finds nobody and the first connection goes over the relay instead.
|
|
752
|
+
await high.node.start();
|
|
753
|
+
const toHigh = await routeOf(world.low, world.high);
|
|
754
|
+
const lanTopic = Buffer.from(
|
|
755
|
+
await toHigh.rendezvous.tag('lan-inbox', 'outbound', rendezvousEpoch(Date.now())),
|
|
756
|
+
).toString('base64url');
|
|
757
|
+
await expect.poll(() => holds(high.listener, lanTopic), { timeout: 10_000 }).toBe(true);
|
|
758
|
+
await low.node.start();
|
|
759
|
+
|
|
760
|
+
// The first connection goes through the proxy and is admitted.
|
|
761
|
+
const first = await low.node.connect(world.high.cert.deviceId);
|
|
762
|
+
expect(low.cache.recall(world.high.cert.deviceId)).toEqual([{ host: LOCAL, port }]);
|
|
763
|
+
|
|
764
|
+
// Now it swallows, and the connection ends: the next attempt's signals go nowhere through it.
|
|
765
|
+
swallowing = true;
|
|
766
|
+
first.close();
|
|
767
|
+
await expect
|
|
768
|
+
.poll(
|
|
769
|
+
async () => {
|
|
770
|
+
const [a] = await Promise.allSettled([
|
|
771
|
+
low.node.connect(world.high.cert.deviceId, 3_000),
|
|
772
|
+
high.node.connect(world.low.cert.deviceId, 3_000),
|
|
773
|
+
]);
|
|
774
|
+
return a.status;
|
|
775
|
+
},
|
|
776
|
+
{ timeout: 40_000, interval: 200 },
|
|
777
|
+
)
|
|
778
|
+
.toBe('fulfilled');
|
|
779
|
+
expect(swallowed).toBeGreaterThan(0);
|
|
780
|
+
expect(low.node.link(world.high.cert.deviceId)).not.toBe(first);
|
|
781
|
+
}, 60_000);
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
describe('the direct signaling endpoint', () => {
|
|
785
|
+
it('delivers only to the topics it holds, and closes a connection that floods it', async () => {
|
|
786
|
+
const listener = await startMeshLanListener({ host: LOCAL });
|
|
787
|
+
cleanups.push(() => listener.close());
|
|
788
|
+
const held = 'H'.repeat(43);
|
|
789
|
+
const other = 'O'.repeat(43);
|
|
790
|
+
listener.setPresence([held]);
|
|
791
|
+
const delivered: unknown[] = [];
|
|
792
|
+
listener.onMessage((topic, data) => delivered.push([topic, data]));
|
|
793
|
+
|
|
794
|
+
const socket = new WebSocket(`ws://${LOCAL}:${listener.port}`);
|
|
795
|
+
cleanups.push(() => socket.close());
|
|
796
|
+
const answers: string[] = [];
|
|
797
|
+
socket.on('message', (raw) => answers.push(String(raw)));
|
|
798
|
+
await new Promise((resolve) => socket.once('open', resolve));
|
|
799
|
+
// Frames are addressed by the topic's hash: the topic itself never crosses the network.
|
|
800
|
+
socket.send(JSON.stringify({ type: 'message', to: lanAddressOf(held), data: { n: 1 } }));
|
|
801
|
+
socket.send(JSON.stringify({ type: 'message', to: held, data: { n: 0 } }));
|
|
802
|
+
socket.send(JSON.stringify({ type: 'message', to: lanAddressOf(other), data: { n: 2 } }));
|
|
803
|
+
socket.send('not json');
|
|
804
|
+
socket.send(JSON.stringify({ type: 'message', to: '__proto__', data: { n: 3 } }));
|
|
805
|
+
await expect
|
|
806
|
+
.poll(() => answers)
|
|
807
|
+
.toEqual([
|
|
808
|
+
JSON.stringify({ type: 'absent', to: held }),
|
|
809
|
+
JSON.stringify({ type: 'absent', to: lanAddressOf(other) }),
|
|
810
|
+
]);
|
|
811
|
+
expect(delivered).toEqual([[held, { n: 1 }]]);
|
|
812
|
+
|
|
813
|
+
let closed = false;
|
|
814
|
+
socket.on('close', () => (closed = true));
|
|
815
|
+
for (let i = 0; i < 200; i += 1) {
|
|
816
|
+
socket.send(JSON.stringify({ type: 'probe', to: lanAddressOf(held), nonce: 'N'.repeat(22) }));
|
|
817
|
+
}
|
|
818
|
+
await expect.poll(() => closed).toBe(true);
|
|
819
|
+
});
|
|
820
|
+
});
|