@livedesk/hub 0.1.36 → 0.1.38

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.
@@ -1,574 +1,408 @@
1
- import dgram from 'node:dgram';
2
- import crypto from 'node:crypto';
3
- import {
4
- decodeRendezvousMessage,
5
- encodeRendezvousMessage,
6
- UDP_MAX_DATAGRAM_BYTES,
7
- UDP_P2P_PROTOCOL
8
- } from './udp-protocol.js';
9
-
10
- const DEFAULT_LIMITS = Object.freeze({
11
- roomTtlMs: 60_000,
12
- sourceTtlMs: 120_000,
13
- sweepIntervalMs: 10_000,
14
- statusIntervalMs: 60_000,
15
- maxRooms: 4_096,
16
- maxSources: 8_192,
17
- maxRoomsPerSource: 128,
18
- perSourceRate: 20,
19
- perSourceBurst: 40,
20
- globalRate: 1_000,
21
- globalBurst: 2_000
22
- });
23
-
24
- function safeText(value, max = 160) {
25
- return String(value || '').replace(/[\r\n\t]/g, ' ').trim().slice(0, max);
26
- }
27
-
28
- function normalizePort(value, fallback) {
29
- const port = Number(value);
30
- return Number.isInteger(port) && port >= 0 && port <= 65535 ? port : fallback;
31
- }
32
-
33
- function normalizeInteger(value, fallback, min = 1, max = Number.MAX_SAFE_INTEGER) {
34
- const number = Number(value);
35
- return Number.isInteger(number) && number >= min && number <= max ? number : fallback;
36
- }
37
-
38
- function normalizeRate(value, fallback) {
39
- const number = Number(value);
40
- return Number.isFinite(number) && number > 0 ? number : fallback;
41
- }
42
-
43
- function createTokenBucket(capacity, now) {
44
- return { tokens: capacity, updatedAt: now };
45
- }
46
-
47
- function consumeToken(bucket, rate, capacity, now) {
48
- const elapsedMs = Math.max(0, now - bucket.updatedAt);
49
- bucket.tokens = Math.min(capacity, bucket.tokens + (elapsedMs / 1_000) * rate);
50
- bucket.updatedAt = Math.max(bucket.updatedAt, now);
51
- if (bucket.tokens < 1) return false;
52
- bucket.tokens -= 1;
53
- return true;
54
- }
55
-
56
- function decodeCanonicalBase64Url(value, minimum, maximum = minimum) {
57
- const text = String(value || '');
58
- if (!/^[A-Za-z0-9_-]+$/u.test(text)) return null;
59
- const bytes = Buffer.from(text, 'base64url');
60
- return bytes.length >= minimum && bytes.length <= maximum && bytes.toString('base64url') === text
61
- ? bytes
62
- : null;
63
- }
64
-
65
- function trustedIssuerMap(values) {
66
- const entries = Array.isArray(values)
67
- ? values
68
- : String(values || '').split(/[\s,]+/u).filter(Boolean);
69
- if (entries.length > 10_000) throw new Error('udp-rendezvous-trusted-issuer-capacity');
70
- const issuers = new Map();
71
- for (const entry of entries) {
72
- const publicKeyBytes = decodeCanonicalBase64Url(entry, 80, 160);
73
- if (!publicKeyBytes) throw new Error('udp-rendezvous-trusted-issuer-invalid');
74
- let publicKey;
75
- try {
76
- publicKey = crypto.createPublicKey({ key: publicKeyBytes, format: 'der', type: 'spki' });
77
- } catch {
78
- throw new Error('udp-rendezvous-trusted-issuer-invalid');
79
- }
80
- if (publicKey.asymmetricKeyType !== 'ec'
81
- || publicKey.asymmetricKeyDetails?.namedCurve !== 'prime256v1') {
82
- throw new Error('udp-rendezvous-trusted-issuer-invalid');
83
- }
84
- const keyId = crypto.createHash('sha256').update(publicKeyBytes).digest('base64url');
85
- issuers.set(keyId, publicKey);
86
- }
87
- return issuers;
88
- }
89
-
90
- function exactSafeText(value, maximum) {
91
- return typeof value === 'string' && value.length > 0 && safeText(value, maximum) === value;
92
- }
93
-
94
- function verifyRendezvousProof(proof, roomId, role, current, trustedIssuers) {
95
- try {
96
- const parts = String(proof || '').split('.');
97
- if (parts.length !== 2) return { ok: false, reason: 'invalid-proof' };
98
- const payloadBytes = decodeCanonicalBase64Url(parts[0], 64, 1536);
99
- const signature = decodeCanonicalBase64Url(parts[1], 64, 64);
100
- if (!payloadBytes || !signature) return { ok: false, reason: 'invalid-proof' };
101
- const payload = JSON.parse(payloadBytes.toString('utf8'));
102
- if (!payload || payload.version !== 2
103
- || payload.protocol !== 'livedesk.udp.rendezvous-proof.v2'
104
- || payload.roomId !== roomId
105
- || !exactSafeText(payload.issuerKeyId, 64)
106
- || !exactSafeText(payload.roomId, 160)
107
- || !exactSafeText(payload.role, 16)
108
- || !exactSafeText(payload.accountId, 128)
109
- || !exactSafeText(payload.hubId, 128)
110
- || !exactSafeText(payload.deviceId, 128)
111
- || !Number.isSafeInteger(payload.issuedAt)
112
- || !Number.isSafeInteger(payload.expiresAt)
113
- || payload.issuedAt > current + 30_000
114
- || payload.expiresAt <= current
115
- || payload.expiresAt <= payload.issuedAt
116
- || payload.expiresAt - payload.issuedAt > 120_000
117
- || !decodeCanonicalBase64Url(payload.nonce, 16, 32)) {
118
- return { ok: false, reason: 'invalid-proof' };
119
- }
120
- if (payload.role !== role) return { ok: false, reason: 'role-mismatch' };
121
- const issuerKeyIdBytes = decodeCanonicalBase64Url(payload.issuerKeyId, 32, 32);
122
- if (!issuerKeyIdBytes) return { ok: false, reason: 'invalid-proof' };
123
- const issuerKey = trustedIssuers.get(payload.issuerKeyId);
124
- if (!issuerKey) return { ok: false, reason: 'untrusted-issuer' };
125
- const valid = crypto.verify('sha256', Buffer.from(parts[0], 'utf8'), {
126
- key: issuerKey,
127
- dsaEncoding: 'ieee-p1363'
128
- }, signature);
129
- if (!valid) return { ok: false, reason: 'invalid-signature' };
130
- return {
131
- ok: true,
132
- payload,
133
- proofKey: `${payload.issuerKeyId}:${payload.nonce}:${payload.roomId}:${payload.role}`,
134
- proofHash: crypto.createHash('sha256').update(String(proof), 'utf8').digest('base64url')
135
- };
136
- } catch {
137
- return { ok: false, reason: 'invalid-proof' };
138
- }
139
- }
140
-
141
- export function createUdpRendezvousServer({
142
- host = '0.0.0.0',
143
- port = 5199,
144
- log = () => {},
145
- version = 'dev',
146
- now = () => Date.now(),
147
- roomTtlMs = DEFAULT_LIMITS.roomTtlMs,
148
- sourceTtlMs = DEFAULT_LIMITS.sourceTtlMs,
149
- sweepIntervalMs = DEFAULT_LIMITS.sweepIntervalMs,
150
- statusIntervalMs = DEFAULT_LIMITS.statusIntervalMs,
151
- maxRooms = DEFAULT_LIMITS.maxRooms,
152
- maxSources = DEFAULT_LIMITS.maxSources,
153
- maxRoomsPerSource = DEFAULT_LIMITS.maxRoomsPerSource,
154
- perSourceRate = DEFAULT_LIMITS.perSourceRate,
155
- perSourceBurst = DEFAULT_LIMITS.perSourceBurst,
156
- globalRate = DEFAULT_LIMITS.globalRate,
157
- globalBurst = DEFAULT_LIMITS.globalBurst,
158
- trustedIssuerPublicKeys = [],
159
- allowLegacyTokens = false
160
- } = {}) {
161
- const socket = dgram.createSocket('udp4');
162
- const rooms = new Map();
163
- const unpairedRooms = new Map();
164
- const sources = new Map();
165
- const evictableSources = new Map();
166
- const consumedProofs = new Map();
167
- const trustedIssuers = trustedIssuerMap(trustedIssuerPublicKeys);
168
- const limits = Object.freeze({
169
- roomTtlMs: normalizeInteger(roomTtlMs, DEFAULT_LIMITS.roomTtlMs),
170
- sourceTtlMs: normalizeInteger(sourceTtlMs, DEFAULT_LIMITS.sourceTtlMs),
171
- sweepIntervalMs: normalizeInteger(sweepIntervalMs, DEFAULT_LIMITS.sweepIntervalMs),
172
- statusIntervalMs: normalizeInteger(statusIntervalMs, DEFAULT_LIMITS.statusIntervalMs, 0),
173
- maxRooms: normalizeInteger(maxRooms, DEFAULT_LIMITS.maxRooms),
174
- maxConsumedProofs: Math.min(200_000, normalizeInteger(maxRooms, DEFAULT_LIMITS.maxRooms) * 2),
175
- maxSources: normalizeInteger(maxSources, DEFAULT_LIMITS.maxSources),
176
- maxRoomsPerSource: normalizeInteger(maxRoomsPerSource, DEFAULT_LIMITS.maxRoomsPerSource),
177
- perSourceRate: normalizeRate(perSourceRate, DEFAULT_LIMITS.perSourceRate),
178
- perSourceBurst: normalizeRate(perSourceBurst, DEFAULT_LIMITS.perSourceBurst),
179
- globalRate: normalizeRate(globalRate, DEFAULT_LIMITS.globalRate),
180
- globalBurst: normalizeRate(globalBurst, DEFAULT_LIMITS.globalBurst)
181
- });
182
- const counters = {
183
- receivedDatagrams: 0,
184
- receivedBytes: 0,
185
- acceptedRegistrations: 0,
186
- peerNotifications: 0,
187
- sentBytes: 0,
188
- sendErrors: 0,
189
- invalidDatagrams: 0,
190
- oversizeDatagrams: 0,
191
- globalRateDrops: 0,
192
- sourceRateDrops: 0,
193
- sourceCapacityDrops: 0,
194
- sourceRoomLimitDrops: 0,
195
- roomCapacityDrops: 0,
196
- tokenMismatchDrops: 0,
197
- untrustedIssuerDrops: 0,
198
- roleMismatchDrops: 0,
199
- bindingMismatchDrops: 0,
200
- proofReplayDrops: 0,
201
- endpointOverwriteDrops: 0,
202
- idempotentRefreshes: 0,
203
- proofCapacityDrops: 0,
204
- expiredRooms: 0,
205
- evictedRooms: 0,
206
- expiredSources: 0,
207
- evictedSources: 0,
208
- sweeps: 0,
209
- socketErrors: 0
210
- };
211
- const globalBucket = createTokenBucket(limits.globalBurst, now());
212
- const serviceVersion = safeText(version, 64) || 'dev';
213
- let started = false;
214
- let closing = false;
215
- let startedAt = 0;
216
- let boundPort = normalizePort(port, 5199);
217
- let sweepTimer = null;
218
- let statusTimer = null;
219
- let lastError = '';
220
-
221
- function sourceKey(rinfo) {
222
- return safeText(rinfo?.address, 128);
223
- }
224
-
225
- function deleteRoom(roomId, reason = '') {
226
- const room = rooms.get(roomId);
227
- if (!room) return false;
228
- rooms.delete(roomId);
229
- unpairedRooms.delete(roomId);
230
- const owner = sources.get(room.ownerSource);
231
- if (owner) {
232
- owner.activeRooms = Math.max(0, owner.activeRooms - 1);
233
- if (owner.activeRooms === 0) {
234
- evictableSources.delete(owner.key);
235
- evictableSources.set(owner.key, owner);
236
- }
237
- }
238
- if (reason === 'expired') counters.expiredRooms += 1;
239
- if (reason === 'capacity') counters.evictedRooms += 1;
240
- return true;
241
- }
242
-
243
- function sweep(at = now()) {
244
- for (const [roomId, room] of rooms) {
245
- if (at - room.updatedAt >= limits.roomTtlMs) deleteRoom(roomId, 'expired');
246
- }
247
- for (const [proofKey, consumed] of consumedProofs) {
248
- if (consumed.expiresAt <= at) consumedProofs.delete(proofKey);
249
- }
250
- for (const [key, source] of sources) {
251
- if (source.activeRooms === 0 && at - source.lastSeenAt >= limits.sourceTtlMs) {
252
- sources.delete(key);
253
- evictableSources.delete(key);
254
- counters.expiredSources += 1;
255
- }
256
- }
257
- counters.sweeps += 1;
258
- return getStatus();
259
- }
260
-
261
- function getOrCreateSource(rinfo, at) {
262
- const key = sourceKey(rinfo);
263
- if (!key) return null;
264
- let source = sources.get(key);
265
- if (!source) {
266
- if (sources.size >= limits.maxSources) {
267
- const oldestEvictableSource = evictableSources.keys().next().value;
268
- if (!oldestEvictableSource) {
269
- counters.sourceCapacityDrops += 1;
270
- return null;
271
- }
272
- evictableSources.delete(oldestEvictableSource);
273
- sources.delete(oldestEvictableSource);
274
- counters.evictedSources += 1;
275
- }
276
- source = {
277
- key,
278
- bucket: createTokenBucket(limits.perSourceBurst, at),
279
- lastSeenAt: at,
280
- activeRooms: 0
281
- };
282
- sources.set(key, source);
283
- evictableSources.set(key, source);
284
- }
285
- source.lastSeenAt = at;
286
- if (source.activeRooms === 0) {
287
- evictableSources.delete(key);
288
- evictableSources.set(key, source);
289
- }
290
- if (!consumeToken(source.bucket, limits.perSourceRate, limits.perSourceBurst, at)) {
291
- counters.sourceRateDrops += 1;
292
- return null;
293
- }
294
- return source;
295
- }
296
-
297
- function send(message, address, targetPort) {
298
- let payload;
299
- try {
300
- payload = encodeRendezvousMessage(message);
301
- } catch (error) {
302
- counters.sendErrors += 1;
303
- lastError = safeText(error?.message || error, 240);
304
- return;
305
- }
306
- counters.peerNotifications += 1;
307
- counters.sentBytes += payload.length;
308
- socket.send(payload, targetPort, address, error => {
309
- if (!error) return;
310
- counters.sendErrors += 1;
311
- lastError = safeText(error?.message || error, 240);
312
- });
313
- }
314
-
315
- function createRoom(roomId, binding, source, at, legacyToken = '') {
316
- if (source.activeRooms >= limits.maxRoomsPerSource) {
317
- counters.sourceRoomLimitDrops += 1;
318
- return null;
319
- }
320
- if (rooms.size >= limits.maxRooms) {
321
- const oldestUnpairedRoomId = unpairedRooms.keys().next().value;
322
- if (oldestUnpairedRoomId) {
323
- deleteRoom(oldestUnpairedRoomId, 'capacity');
324
- } else {
325
- counters.roomCapacityDrops += 1;
326
- return null;
327
- }
328
- }
329
- const room = {
330
- legacyToken,
331
- binding,
332
- ownerSource: source.key,
333
- peers: new Map(),
334
- createdAt: at,
335
- updatedAt: at
336
- };
337
- rooms.set(roomId, room);
338
- unpairedRooms.set(roomId, room);
339
- source.activeRooms += 1;
340
- evictableSources.delete(source.key);
341
- return room;
342
- }
343
-
344
- function onMessage(payload, rinfo) {
345
- const at = now();
346
- counters.receivedDatagrams += 1;
347
- counters.receivedBytes += Buffer.isBuffer(payload) ? payload.length : 0;
348
- if (!Buffer.isBuffer(payload)) {
349
- counters.invalidDatagrams += 1;
350
- return;
351
- }
352
- if (payload.length > UDP_MAX_DATAGRAM_BYTES) {
353
- counters.oversizeDatagrams += 1;
354
- return;
355
- }
356
- if (!consumeToken(globalBucket, limits.globalRate, limits.globalBurst, at)) {
357
- counters.globalRateDrops += 1;
358
- return;
359
- }
360
- const source = getOrCreateSource(rinfo, at);
361
- if (!source) return;
362
- const message = decodeRendezvousMessage(payload);
363
- if (message?.type !== 'rendezvous.register') {
364
- counters.invalidDatagrams += 1;
365
- return;
366
- }
367
- const roomId = safeText(message.roomId, 160);
368
- const role = safeText(message.role, 16).toLowerCase();
369
- const token = safeText(message.token, 2048);
370
- if (!roomId || !token || !['hub', 'client'].includes(role)) {
371
- counters.invalidDatagrams += 1;
372
- return;
373
- }
374
- const verification = allowLegacyTokens
375
- ? { ok: true, legacy: true, token }
376
- : verifyRendezvousProof(token, roomId, role, at, trustedIssuers);
377
- if (!verification.ok) {
378
- counters.invalidDatagrams += 1;
379
- if (verification.reason === 'untrusted-issuer') counters.untrustedIssuerDrops += 1;
380
- if (verification.reason === 'role-mismatch') counters.roleMismatchDrops += 1;
381
- return;
382
- }
383
- let room = rooms.get(roomId);
384
- if (room && at - room.updatedAt >= limits.roomTtlMs) {
385
- deleteRoom(roomId, 'expired');
386
- room = null;
387
- }
388
- if (verification.legacy) {
389
- if (room && room.legacyToken !== token) {
390
- counters.tokenMismatchDrops += 1;
391
- return;
392
- }
393
- if (!room) {
394
- room = createRoom(roomId, null, source, at, token);
395
- if (!room) return;
396
- }
397
- room.updatedAt = at;
398
- room.peers.set(role, { address: rinfo.address, port: rinfo.port });
399
- } else {
400
- const binding = {
401
- issuerKeyId: verification.payload.issuerKeyId,
402
- accountId: verification.payload.accountId,
403
- hubId: verification.payload.hubId,
404
- deviceId: verification.payload.deviceId
405
- };
406
- if (room && JSON.stringify(room.binding) !== JSON.stringify(binding)) {
407
- counters.bindingMismatchDrops += 1;
408
- return;
409
- }
410
- const endpoint = { address: rinfo.address, port: rinfo.port };
411
- const consumed = consumedProofs.get(verification.proofKey);
412
- const existingPeer = room?.peers.get(role);
413
- if (consumed) {
414
- const sameEndpoint = consumed.address === endpoint.address && consumed.port === endpoint.port;
415
- const sameOwner = existingPeer?.proofKey === verification.proofKey
416
- && existingPeer.address === endpoint.address
417
- && existingPeer.port === endpoint.port;
418
- if (sameEndpoint && sameOwner) {
419
- room.updatedAt = at;
420
- counters.idempotentRefreshes += 1;
421
- return;
422
- }
423
- counters.proofReplayDrops += 1;
424
- if (existingPeer && !sameEndpoint) counters.endpointOverwriteDrops += 1;
425
- return;
426
- }
427
- if (consumedProofs.size >= limits.maxConsumedProofs) {
428
- counters.proofCapacityDrops += 1;
429
- return;
430
- }
431
- if (existingPeer) {
432
- counters.endpointOverwriteDrops += 1;
433
- return;
434
- }
435
- if (!room) {
436
- room = createRoom(roomId, binding, source, at);
437
- if (!room) return;
438
- }
439
- room.updatedAt = at;
440
- room.peers.set(role, { ...endpoint, proofKey: verification.proofKey, proofHash: verification.proofHash });
441
- consumedProofs.set(verification.proofKey, {
442
- ...endpoint,
443
- expiresAt: verification.payload.expiresAt
444
- });
445
- }
446
- counters.acceptedRegistrations += 1;
447
- if (room.peers.size < 2) {
448
- unpairedRooms.delete(roomId);
449
- unpairedRooms.set(roomId, room);
450
- return;
451
- }
452
- unpairedRooms.delete(roomId);
453
- const hub = room.peers.get('hub');
454
- const client = room.peers.get('client');
455
- if (!hub || !client) return;
456
- send({
457
- type: 'rendezvous.peer',
458
- protocol: UDP_P2P_PROTOCOL,
459
- roomId,
460
- role: 'client',
461
- host: client.address,
462
- port: client.port,
463
- observedHost: hub.address,
464
- observedPort: hub.port
465
- }, hub.address, hub.port);
466
- send({
467
- type: 'rendezvous.peer',
468
- protocol: UDP_P2P_PROTOCOL,
469
- roomId,
470
- role: 'hub',
471
- host: hub.address,
472
- port: hub.port,
473
- observedHost: client.address,
474
- observedPort: client.port
475
- }, client.address, client.port);
476
- }
477
-
478
- socket.on('message', onMessage);
479
- socket.on('error', error => {
480
- counters.socketErrors += 1;
481
- lastError = safeText(error?.message || error, 240);
482
- log(`UDP rendezvous error: ${lastError}`);
483
- });
484
-
485
- function getStatus() {
486
- const current = now();
487
- return {
488
- service: 'livedesk-udp-rendezvous',
489
- version: serviceVersion,
490
- protocol: UDP_P2P_PROTOCOL,
491
- healthy: started && !closing,
492
- started,
493
- host,
494
- port: boundPort,
495
- uptimeMs: startedAt ? Math.max(0, current - startedAt) : 0,
496
- rooms: rooms.size,
497
- pairedRooms: rooms.size - unpairedRooms.size,
498
- unpairedRooms: unpairedRooms.size,
499
- sources: sources.size,
500
- evictableSources: evictableSources.size,
501
- consumedProofs: consumedProofs.size,
502
- trustedIssuerCount: trustedIssuers.size,
503
- securityReady: allowLegacyTokens || trustedIssuers.size > 0,
504
- limits: { ...limits },
505
- counters: { ...counters },
506
- lastError
507
- };
508
- }
509
-
510
- function logStatus(reason) {
511
- log(`UDP rendezvous status ${JSON.stringify({ reason, ...getStatus() })}`);
512
- }
513
-
514
- function startTimers() {
515
- sweepTimer = setInterval(() => sweep(), limits.sweepIntervalMs);
516
- sweepTimer.unref?.();
517
- if (limits.statusIntervalMs > 0) {
518
- statusTimer = setInterval(() => logStatus('periodic'), limits.statusIntervalMs);
519
- statusTimer.unref?.();
520
- }
521
- }
522
-
523
- function stopTimers() {
524
- if (sweepTimer) clearInterval(sweepTimer);
525
- if (statusTimer) clearInterval(statusTimer);
526
- sweepTimer = null;
527
- statusTimer = null;
528
- }
529
-
530
- async function start() {
531
- if (started) return { host, port: boundPort };
532
- if (!allowLegacyTokens && trustedIssuers.size === 0) {
533
- throw new Error('udp-rendezvous-trusted-issuer-required');
534
- }
535
- await new Promise((resolve, reject) => {
536
- const onError = error => { socket.off('listening', onListening); reject(error); };
537
- const onListening = () => { socket.off('error', onError); resolve(); };
538
- socket.once('error', onError);
539
- socket.once('listening', onListening);
540
- socket.bind(boundPort, host);
541
- });
542
- started = true;
543
- closing = false;
544
- startedAt = now();
545
- boundPort = socket.address().port;
546
- startTimers();
547
- log(`UDP rendezvous listening on udp://${host}:${boundPort} version=${serviceVersion}`);
548
- log(`UDP rendezvous limits ${JSON.stringify(limits)}`);
549
- return { host, port: boundPort };
550
- }
551
-
552
- async function close() {
553
- closing = true;
554
- stopTimers();
555
- rooms.clear();
556
- unpairedRooms.clear();
557
- sources.clear();
558
- evictableSources.clear();
559
- consumedProofs.clear();
560
- if (started) {
561
- await new Promise(resolve => socket.close(() => resolve()));
562
- started = false;
563
- }
564
- closing = false;
565
- }
566
-
567
- return {
568
- start,
569
- close,
570
- getStatus,
571
- sweepNow: () => sweep(),
572
- logStatus: (reason = 'requested') => logStatus(safeText(reason, 48) || 'requested')
573
- };
574
- }
1
+ import dgram from 'node:dgram';
2
+ import {
3
+ decodeRendezvousMessage,
4
+ encodeRendezvousMessage,
5
+ UDP_MAX_DATAGRAM_BYTES,
6
+ UDP_P2P_PROTOCOL
7
+ } from './udp-protocol.js';
8
+
9
+ const DEFAULT_LIMITS = Object.freeze({
10
+ roomTtlMs: 60_000,
11
+ sourceTtlMs: 120_000,
12
+ sweepIntervalMs: 10_000,
13
+ statusIntervalMs: 60_000,
14
+ maxRooms: 4_096,
15
+ maxSources: 8_192,
16
+ maxRoomsPerSource: 128,
17
+ perSourceRate: 20,
18
+ perSourceBurst: 40,
19
+ globalRate: 1_000,
20
+ globalBurst: 2_000
21
+ });
22
+
23
+ function safeText(value, max = 160) {
24
+ return String(value || '').replace(/[\r\n\t]/g, ' ').trim().slice(0, max);
25
+ }
26
+
27
+ function normalizePort(value, fallback) {
28
+ const port = Number(value);
29
+ return Number.isInteger(port) && port >= 0 && port <= 65535 ? port : fallback;
30
+ }
31
+
32
+ function normalizeInteger(value, fallback, min = 1, max = Number.MAX_SAFE_INTEGER) {
33
+ const number = Number(value);
34
+ return Number.isInteger(number) && number >= min && number <= max ? number : fallback;
35
+ }
36
+
37
+ function normalizeRate(value, fallback) {
38
+ const number = Number(value);
39
+ return Number.isFinite(number) && number > 0 ? number : fallback;
40
+ }
41
+
42
+ function createTokenBucket(capacity, now) {
43
+ return { tokens: capacity, updatedAt: now };
44
+ }
45
+
46
+ function consumeToken(bucket, rate, capacity, now) {
47
+ const elapsedMs = Math.max(0, now - bucket.updatedAt);
48
+ bucket.tokens = Math.min(capacity, bucket.tokens + (elapsedMs / 1_000) * rate);
49
+ bucket.updatedAt = Math.max(bucket.updatedAt, now);
50
+ if (bucket.tokens < 1) return false;
51
+ bucket.tokens -= 1;
52
+ return true;
53
+ }
54
+
55
+ export function createUdpRendezvousServer({
56
+ host = '0.0.0.0',
57
+ port = 5199,
58
+ log = () => {},
59
+ version = 'dev',
60
+ now = () => Date.now(),
61
+ roomTtlMs = DEFAULT_LIMITS.roomTtlMs,
62
+ sourceTtlMs = DEFAULT_LIMITS.sourceTtlMs,
63
+ sweepIntervalMs = DEFAULT_LIMITS.sweepIntervalMs,
64
+ statusIntervalMs = DEFAULT_LIMITS.statusIntervalMs,
65
+ maxRooms = DEFAULT_LIMITS.maxRooms,
66
+ maxSources = DEFAULT_LIMITS.maxSources,
67
+ maxRoomsPerSource = DEFAULT_LIMITS.maxRoomsPerSource,
68
+ perSourceRate = DEFAULT_LIMITS.perSourceRate,
69
+ perSourceBurst = DEFAULT_LIMITS.perSourceBurst,
70
+ globalRate = DEFAULT_LIMITS.globalRate,
71
+ globalBurst = DEFAULT_LIMITS.globalBurst
72
+ } = {}) {
73
+ const socket = dgram.createSocket('udp4');
74
+ const rooms = new Map();
75
+ const unpairedRooms = new Map();
76
+ const sources = new Map();
77
+ const evictableSources = new Map();
78
+ const limits = Object.freeze({
79
+ roomTtlMs: normalizeInteger(roomTtlMs, DEFAULT_LIMITS.roomTtlMs),
80
+ sourceTtlMs: normalizeInteger(sourceTtlMs, DEFAULT_LIMITS.sourceTtlMs),
81
+ sweepIntervalMs: normalizeInteger(sweepIntervalMs, DEFAULT_LIMITS.sweepIntervalMs),
82
+ statusIntervalMs: normalizeInteger(statusIntervalMs, DEFAULT_LIMITS.statusIntervalMs, 0),
83
+ maxRooms: normalizeInteger(maxRooms, DEFAULT_LIMITS.maxRooms),
84
+ maxSources: normalizeInteger(maxSources, DEFAULT_LIMITS.maxSources),
85
+ maxRoomsPerSource: normalizeInteger(maxRoomsPerSource, DEFAULT_LIMITS.maxRoomsPerSource),
86
+ perSourceRate: normalizeRate(perSourceRate, DEFAULT_LIMITS.perSourceRate),
87
+ perSourceBurst: normalizeRate(perSourceBurst, DEFAULT_LIMITS.perSourceBurst),
88
+ globalRate: normalizeRate(globalRate, DEFAULT_LIMITS.globalRate),
89
+ globalBurst: normalizeRate(globalBurst, DEFAULT_LIMITS.globalBurst)
90
+ });
91
+ const counters = {
92
+ receivedDatagrams: 0,
93
+ receivedBytes: 0,
94
+ acceptedRegistrations: 0,
95
+ peerNotifications: 0,
96
+ sentBytes: 0,
97
+ sendErrors: 0,
98
+ invalidDatagrams: 0,
99
+ oversizeDatagrams: 0,
100
+ globalRateDrops: 0,
101
+ sourceRateDrops: 0,
102
+ sourceCapacityDrops: 0,
103
+ sourceRoomLimitDrops: 0,
104
+ roomCapacityDrops: 0,
105
+ tokenMismatchDrops: 0,
106
+ expiredRooms: 0,
107
+ evictedRooms: 0,
108
+ expiredSources: 0,
109
+ evictedSources: 0,
110
+ sweeps: 0,
111
+ socketErrors: 0
112
+ };
113
+ const globalBucket = createTokenBucket(limits.globalBurst, now());
114
+ const serviceVersion = safeText(version, 64) || 'dev';
115
+ let started = false;
116
+ let closing = false;
117
+ let startedAt = 0;
118
+ let boundPort = normalizePort(port, 5199);
119
+ let sweepTimer = null;
120
+ let statusTimer = null;
121
+ let lastError = '';
122
+
123
+ function sourceKey(rinfo) {
124
+ return safeText(rinfo?.address, 128);
125
+ }
126
+
127
+ function deleteRoom(roomId, reason = '') {
128
+ const room = rooms.get(roomId);
129
+ if (!room) return false;
130
+ rooms.delete(roomId);
131
+ unpairedRooms.delete(roomId);
132
+ const owner = sources.get(room.ownerSource);
133
+ if (owner) {
134
+ owner.activeRooms = Math.max(0, owner.activeRooms - 1);
135
+ if (owner.activeRooms === 0) {
136
+ evictableSources.delete(owner.key);
137
+ evictableSources.set(owner.key, owner);
138
+ }
139
+ }
140
+ if (reason === 'expired') counters.expiredRooms += 1;
141
+ if (reason === 'capacity') counters.evictedRooms += 1;
142
+ return true;
143
+ }
144
+
145
+ function sweep(at = now()) {
146
+ for (const [roomId, room] of rooms) {
147
+ if (at - room.updatedAt >= limits.roomTtlMs) deleteRoom(roomId, 'expired');
148
+ }
149
+ for (const [key, source] of sources) {
150
+ if (source.activeRooms === 0 && at - source.lastSeenAt >= limits.sourceTtlMs) {
151
+ sources.delete(key);
152
+ evictableSources.delete(key);
153
+ counters.expiredSources += 1;
154
+ }
155
+ }
156
+ counters.sweeps += 1;
157
+ return getStatus();
158
+ }
159
+
160
+ function getOrCreateSource(rinfo, at) {
161
+ const key = sourceKey(rinfo);
162
+ if (!key) return null;
163
+ let source = sources.get(key);
164
+ if (!source) {
165
+ if (sources.size >= limits.maxSources) {
166
+ const oldestEvictableSource = evictableSources.keys().next().value;
167
+ if (!oldestEvictableSource) {
168
+ counters.sourceCapacityDrops += 1;
169
+ return null;
170
+ }
171
+ evictableSources.delete(oldestEvictableSource);
172
+ sources.delete(oldestEvictableSource);
173
+ counters.evictedSources += 1;
174
+ }
175
+ source = {
176
+ key,
177
+ bucket: createTokenBucket(limits.perSourceBurst, at),
178
+ lastSeenAt: at,
179
+ activeRooms: 0
180
+ };
181
+ sources.set(key, source);
182
+ evictableSources.set(key, source);
183
+ }
184
+ source.lastSeenAt = at;
185
+ if (source.activeRooms === 0) {
186
+ evictableSources.delete(key);
187
+ evictableSources.set(key, source);
188
+ }
189
+ if (!consumeToken(source.bucket, limits.perSourceRate, limits.perSourceBurst, at)) {
190
+ counters.sourceRateDrops += 1;
191
+ return null;
192
+ }
193
+ return source;
194
+ }
195
+
196
+ function send(message, address, targetPort) {
197
+ let payload;
198
+ try {
199
+ payload = encodeRendezvousMessage(message);
200
+ } catch (error) {
201
+ counters.sendErrors += 1;
202
+ lastError = safeText(error?.message || error, 240);
203
+ return;
204
+ }
205
+ counters.peerNotifications += 1;
206
+ counters.sentBytes += payload.length;
207
+ socket.send(payload, targetPort, address, error => {
208
+ if (!error) return;
209
+ counters.sendErrors += 1;
210
+ lastError = safeText(error?.message || error, 240);
211
+ });
212
+ }
213
+
214
+ function createRoom(roomId, token, source, at) {
215
+ if (source.activeRooms >= limits.maxRoomsPerSource) {
216
+ counters.sourceRoomLimitDrops += 1;
217
+ return null;
218
+ }
219
+ if (rooms.size >= limits.maxRooms) {
220
+ const oldestUnpairedRoomId = unpairedRooms.keys().next().value;
221
+ if (oldestUnpairedRoomId) {
222
+ deleteRoom(oldestUnpairedRoomId, 'capacity');
223
+ } else {
224
+ counters.roomCapacityDrops += 1;
225
+ return null;
226
+ }
227
+ }
228
+ const room = {
229
+ token,
230
+ ownerSource: source.key,
231
+ peers: new Map(),
232
+ createdAt: at,
233
+ updatedAt: at
234
+ };
235
+ rooms.set(roomId, room);
236
+ unpairedRooms.set(roomId, room);
237
+ source.activeRooms += 1;
238
+ evictableSources.delete(source.key);
239
+ return room;
240
+ }
241
+
242
+ function onMessage(payload, rinfo) {
243
+ const at = now();
244
+ counters.receivedDatagrams += 1;
245
+ counters.receivedBytes += Buffer.isBuffer(payload) ? payload.length : 0;
246
+ if (!Buffer.isBuffer(payload)) {
247
+ counters.invalidDatagrams += 1;
248
+ return;
249
+ }
250
+ if (payload.length > UDP_MAX_DATAGRAM_BYTES) {
251
+ counters.oversizeDatagrams += 1;
252
+ return;
253
+ }
254
+ if (!consumeToken(globalBucket, limits.globalRate, limits.globalBurst, at)) {
255
+ counters.globalRateDrops += 1;
256
+ return;
257
+ }
258
+ const source = getOrCreateSource(rinfo, at);
259
+ if (!source) return;
260
+ const message = decodeRendezvousMessage(payload);
261
+ if (message?.type !== 'rendezvous.register') {
262
+ counters.invalidDatagrams += 1;
263
+ return;
264
+ }
265
+ const roomId = safeText(message.roomId, 160);
266
+ const role = safeText(message.role, 16).toLowerCase();
267
+ const token = safeText(message.token, 256);
268
+ if (!roomId || !token || !['hub', 'client'].includes(role)) {
269
+ counters.invalidDatagrams += 1;
270
+ return;
271
+ }
272
+ let room = rooms.get(roomId);
273
+ if (room && at - room.updatedAt >= limits.roomTtlMs) {
274
+ deleteRoom(roomId, 'expired');
275
+ room = null;
276
+ }
277
+ if (room && room.token !== token) {
278
+ counters.tokenMismatchDrops += 1;
279
+ return;
280
+ }
281
+ if (!room) {
282
+ room = createRoom(roomId, token, source, at);
283
+ if (!room) return;
284
+ }
285
+ room.updatedAt = at;
286
+ room.peers.set(role, { address: rinfo.address, port: rinfo.port });
287
+ counters.acceptedRegistrations += 1;
288
+ if (room.peers.size < 2) {
289
+ unpairedRooms.delete(roomId);
290
+ unpairedRooms.set(roomId, room);
291
+ return;
292
+ }
293
+ unpairedRooms.delete(roomId);
294
+ const hub = room.peers.get('hub');
295
+ const client = room.peers.get('client');
296
+ if (!hub || !client) return;
297
+ send({
298
+ type: 'rendezvous.peer',
299
+ protocol: UDP_P2P_PROTOCOL,
300
+ roomId,
301
+ role: 'client',
302
+ host: client.address,
303
+ port: client.port,
304
+ observedHost: hub.address,
305
+ observedPort: hub.port
306
+ }, hub.address, hub.port);
307
+ send({
308
+ type: 'rendezvous.peer',
309
+ protocol: UDP_P2P_PROTOCOL,
310
+ roomId,
311
+ role: 'hub',
312
+ host: hub.address,
313
+ port: hub.port,
314
+ observedHost: client.address,
315
+ observedPort: client.port
316
+ }, client.address, client.port);
317
+ }
318
+
319
+ socket.on('message', onMessage);
320
+ socket.on('error', error => {
321
+ counters.socketErrors += 1;
322
+ lastError = safeText(error?.message || error, 240);
323
+ log(`UDP rendezvous error: ${lastError}`);
324
+ });
325
+
326
+ function getStatus() {
327
+ const current = now();
328
+ return {
329
+ service: 'livedesk-udp-rendezvous',
330
+ version: serviceVersion,
331
+ protocol: UDP_P2P_PROTOCOL,
332
+ healthy: started && !closing,
333
+ started,
334
+ host,
335
+ port: boundPort,
336
+ uptimeMs: startedAt ? Math.max(0, current - startedAt) : 0,
337
+ rooms: rooms.size,
338
+ pairedRooms: rooms.size - unpairedRooms.size,
339
+ unpairedRooms: unpairedRooms.size,
340
+ sources: sources.size,
341
+ evictableSources: evictableSources.size,
342
+ limits: { ...limits },
343
+ counters: { ...counters },
344
+ lastError
345
+ };
346
+ }
347
+
348
+ function logStatus(reason) {
349
+ log(`UDP rendezvous status ${JSON.stringify({ reason, ...getStatus() })}`);
350
+ }
351
+
352
+ function startTimers() {
353
+ sweepTimer = setInterval(() => sweep(), limits.sweepIntervalMs);
354
+ sweepTimer.unref?.();
355
+ if (limits.statusIntervalMs > 0) {
356
+ statusTimer = setInterval(() => logStatus('periodic'), limits.statusIntervalMs);
357
+ statusTimer.unref?.();
358
+ }
359
+ }
360
+
361
+ function stopTimers() {
362
+ if (sweepTimer) clearInterval(sweepTimer);
363
+ if (statusTimer) clearInterval(statusTimer);
364
+ sweepTimer = null;
365
+ statusTimer = null;
366
+ }
367
+
368
+ async function start() {
369
+ if (started) return { host, port: boundPort };
370
+ await new Promise((resolve, reject) => {
371
+ const onError = error => { socket.off('listening', onListening); reject(error); };
372
+ const onListening = () => { socket.off('error', onError); resolve(); };
373
+ socket.once('error', onError);
374
+ socket.once('listening', onListening);
375
+ socket.bind(boundPort, host);
376
+ });
377
+ started = true;
378
+ closing = false;
379
+ startedAt = now();
380
+ boundPort = socket.address().port;
381
+ startTimers();
382
+ log(`UDP rendezvous listening on udp://${host}:${boundPort} version=${serviceVersion}`);
383
+ log(`UDP rendezvous limits ${JSON.stringify(limits)}`);
384
+ return { host, port: boundPort };
385
+ }
386
+
387
+ async function close() {
388
+ closing = true;
389
+ stopTimers();
390
+ rooms.clear();
391
+ unpairedRooms.clear();
392
+ sources.clear();
393
+ evictableSources.clear();
394
+ if (started) {
395
+ await new Promise(resolve => socket.close(() => resolve()));
396
+ started = false;
397
+ }
398
+ closing = false;
399
+ }
400
+
401
+ return {
402
+ start,
403
+ close,
404
+ getStatus,
405
+ sweepNow: () => sweep(),
406
+ logStatus: (reason = 'requested') => logStatus(safeText(reason, 48) || 'requested')
407
+ };
408
+ }