ciphermesh 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +251 -0
  3. package/README.pt-BR.md +253 -0
  4. package/bin/ciphermesh.js +34 -0
  5. package/docs/ARCHITECTURE.md +1188 -0
  6. package/docs/SETUP.md +305 -0
  7. package/docs/demo.svg +46 -0
  8. package/package.json +87 -0
  9. package/src/client/ChatController.js +2476 -0
  10. package/src/client/Connection.js +129 -0
  11. package/src/client/FileTransfer.js +488 -0
  12. package/src/client/ImagePreview.js +88 -0
  13. package/src/client/UI.js +1830 -0
  14. package/src/client/index.js +231 -0
  15. package/src/crypto/CertPinStore.js +79 -0
  16. package/src/crypto/DeniableEncrypt.js +53 -0
  17. package/src/crypto/DoubleRatchet.js +574 -0
  18. package/src/crypto/Handshake.js +219 -0
  19. package/src/crypto/HistoryStore.js +241 -0
  20. package/src/crypto/IdentityBackup.js +70 -0
  21. package/src/crypto/KeyManager.js +134 -0
  22. package/src/crypto/MessageCrypto.js +181 -0
  23. package/src/crypto/NonceManager.js +72 -0
  24. package/src/crypto/SealedSender.js +58 -0
  25. package/src/crypto/SenderKey.js +204 -0
  26. package/src/crypto/StateManager.js +138 -0
  27. package/src/crypto/TrustStore.js +216 -0
  28. package/src/p2p/Discovery.js +80 -0
  29. package/src/p2p/P2PChatController.js +1856 -0
  30. package/src/p2p/PeerConnectionManager.js +252 -0
  31. package/src/p2p/PeerServer.js +68 -0
  32. package/src/p2p/index.js +219 -0
  33. package/src/protocol/messages.js +138 -0
  34. package/src/protocol/validators.js +175 -0
  35. package/src/server/CertManager.js +173 -0
  36. package/src/server/MessageRouter.js +80 -0
  37. package/src/server/OfflineQueue.js +124 -0
  38. package/src/server/SessionManager.js +296 -0
  39. package/src/server/WebSocketServer.js +632 -0
  40. package/src/server/index.js +89 -0
  41. package/src/shared/AuditLog.js +91 -0
  42. package/src/shared/PluginManager.js +83 -0
  43. package/src/shared/banner.js +271 -0
  44. package/src/shared/commandSuggest.js +59 -0
  45. package/src/shared/config.js +90 -0
  46. package/src/shared/constants.js +126 -0
  47. package/src/shared/coverTraffic.js +34 -0
  48. package/src/shared/dnd.js +60 -0
  49. package/src/shared/emoji.js +17 -0
  50. package/src/shared/fuzzy.js +40 -0
  51. package/src/shared/invite.js +61 -0
  52. package/src/shared/keyArt.js +66 -0
  53. package/src/shared/logger.js +38 -0
  54. package/src/shared/panic.js +38 -0
  55. package/src/shared/prompt.js +31 -0
  56. package/src/shared/terminalGraphics.js +72 -0
  57. package/src/shared/themes.js +36 -0
  58. package/src/shared/voiceNote.js +128 -0
@@ -0,0 +1,632 @@
1
+ import { createServer as createHttpsServer } from 'node:https';
2
+ import { WebSocketServer as WSServer } from 'ws';
3
+ import { createLogger } from '../shared/logger.js';
4
+ import {
5
+ HEARTBEAT_INTERVAL_MS,
6
+ MAX_PAYLOAD_SIZE,
7
+ MAX_CONNECTIONS_TOTAL,
8
+ MAX_CONNECTIONS_PER_IP,
9
+ JOIN_TIMEOUT_MS,
10
+ MESSAGE_RATE_LIMIT_PER_SECOND,
11
+ } from '../shared/constants.js';
12
+ import {
13
+ MSG,
14
+ createJoinAck,
15
+ createPeerJoined,
16
+ createPeerLeft,
17
+ createPeerKeyUpdated,
18
+ createRoomChanged,
19
+ createRoomList,
20
+ createPeerKicked,
21
+ createPeerMuted,
22
+ createError,
23
+ ERR,
24
+ } from '../protocol/messages.js';
25
+ import {
26
+ parseMessage,
27
+ validateJoin,
28
+ validateEncryptedMessage,
29
+ validateKeyUpdate,
30
+ validateChangeRoom,
31
+ validateKickPeer,
32
+ validateMutePeer,
33
+ validateBanPeer,
34
+ } from '../protocol/validators.js';
35
+
36
+ const log = createLogger('ws-server');
37
+
38
+ export class SecureWSServer {
39
+ #wss;
40
+ #httpsServer;
41
+ #sessionManager;
42
+ #messageRouter;
43
+ #offlineQueue;
44
+ #heartbeatInterval;
45
+ #connectionsByIp;
46
+
47
+ constructor(sessionManager, messageRouter, offlineQueue, port, tlsOptions) {
48
+ this.#sessionManager = sessionManager;
49
+ this.#messageRouter = messageRouter;
50
+ this.#offlineQueue = offlineQueue;
51
+ this.#connectionsByIp = new Map();
52
+
53
+ if (tlsOptions) {
54
+ this.#httpsServer = createHttpsServer(tlsOptions);
55
+ this.#wss = new WSServer({
56
+ server: this.#httpsServer,
57
+ maxPayload: MAX_PAYLOAD_SIZE,
58
+ clientTracking: true,
59
+ });
60
+ this.#httpsServer.listen(port);
61
+ log.info(`TLS enabled (wss://) on port ${port}`);
62
+ } else {
63
+ this.#wss = new WSServer({
64
+ port,
65
+ maxPayload: MAX_PAYLOAD_SIZE,
66
+ clientTracking: true,
67
+ });
68
+ }
69
+
70
+ this.#wss.on('connection', (ws, req) => this.#handleConnection(ws, req));
71
+ this.#wss.on('error', (err) => log.error(`Server error: ${err.message}`));
72
+
73
+ this.#startHeartbeat();
74
+ }
75
+
76
+ #clientIp(req) {
77
+ return req?.socket?.remoteAddress || 'unknown';
78
+ }
79
+
80
+ #handleConnection(ws, req) {
81
+ const ip = this.#clientIp(req);
82
+
83
+ // Global connection cap (the new socket is already counted in clients).
84
+ if (this.#wss.clients.size > MAX_CONNECTIONS_TOTAL) {
85
+ ws.close(1013, 'Server full');
86
+ return;
87
+ }
88
+ // Per-IP connection cap.
89
+ const ipCount = this.#connectionsByIp.get(ip) || 0;
90
+ if (ipCount >= MAX_CONNECTIONS_PER_IP) {
91
+ log.warn(`Too many connections from ${ip}, rejecting`);
92
+ ws.close(1013, 'Too many connections from this IP');
93
+ return;
94
+ }
95
+ this.#connectionsByIp.set(ip, ipCount + 1);
96
+
97
+ ws.clientIp = ip;
98
+ ws.isAlive = true;
99
+ ws.sessionId = null;
100
+ ws.hasJoined = false;
101
+ ws.msgWindowStart = Date.now();
102
+ ws.msgCount = 0;
103
+
104
+ // Drop sockets that connect but never JOIN (slowloris / resource hold).
105
+ ws.joinTimer = setTimeout(() => {
106
+ if (!ws.hasJoined) {
107
+ log.warn(`Connection from ${ip} without JOIN within ${JOIN_TIMEOUT_MS}ms, closing`);
108
+ ws.terminate();
109
+ }
110
+ }, JOIN_TIMEOUT_MS);
111
+ if (ws.joinTimer.unref) {
112
+ ws.joinTimer.unref();
113
+ }
114
+
115
+ ws.on('pong', () => {
116
+ ws.isAlive = true;
117
+ });
118
+
119
+ ws.on('message', (data) => {
120
+ this.#handleMessage(ws, data);
121
+ });
122
+
123
+ ws.on('close', () => {
124
+ clearTimeout(ws.joinTimer);
125
+ this.#releaseIp(ws.clientIp);
126
+ this.#handleDisconnect(ws);
127
+ });
128
+
129
+ ws.on('error', (err) => {
130
+ log.error(`WS error: ${err.message}`);
131
+ });
132
+
133
+ log.debug(`New WebSocket connection (${ip})`);
134
+ }
135
+
136
+ #releaseIp(ip) {
137
+ if (!ip) {
138
+ return;
139
+ }
140
+ const count = this.#connectionsByIp.get(ip) || 0;
141
+ if (count <= 1) {
142
+ this.#connectionsByIp.delete(ip);
143
+ } else {
144
+ this.#connectionsByIp.set(ip, count - 1);
145
+ }
146
+ }
147
+
148
+ // Per-connection rate limit across ALL message types (JOIN, key_update, etc.),
149
+ // not just encrypted_message. Prevents control-message floods / amplification.
150
+ #allowMessage(ws) {
151
+ const now = Date.now();
152
+ if (now - ws.msgWindowStart >= 1000) {
153
+ ws.msgWindowStart = now;
154
+ ws.msgCount = 0;
155
+ }
156
+ ws.msgCount++;
157
+ return ws.msgCount <= MESSAGE_RATE_LIMIT_PER_SECOND;
158
+ }
159
+
160
+ #handleMessage(ws, data) {
161
+ if (!this.#allowMessage(ws)) {
162
+ ws.send(JSON.stringify(createError(ERR.RATE_LIMITED, 'Too many messages per second')));
163
+ return;
164
+ }
165
+
166
+ const raw = data.toString('utf-8');
167
+ const { valid, error, msg } = parseMessage(raw);
168
+
169
+ if (!valid) {
170
+ ws.send(JSON.stringify(createError(ERR.INVALID_MESSAGE, error)));
171
+ return;
172
+ }
173
+
174
+ switch (msg.type) {
175
+ case MSG.JOIN:
176
+ this.#handleJoin(ws, msg);
177
+ break;
178
+
179
+ case MSG.ENCRYPTED_MESSAGE:
180
+ this.#handleEncryptedMessage(ws, msg);
181
+ break;
182
+
183
+ case MSG.KEY_UPDATE:
184
+ this.#handleKeyUpdate(ws, msg);
185
+ break;
186
+
187
+ case MSG.CHANGE_ROOM:
188
+ this.#handleChangeRoom(ws, msg);
189
+ break;
190
+
191
+ case MSG.LIST_ROOMS:
192
+ this.#handleListRooms(ws);
193
+ break;
194
+
195
+ case MSG.KICK_PEER:
196
+ this.#handleKickPeer(ws, msg);
197
+ break;
198
+
199
+ case MSG.MUTE_PEER:
200
+ this.#handleMutePeer(ws, msg);
201
+ break;
202
+
203
+ case MSG.BAN_PEER:
204
+ this.#handleBanPeer(ws, msg);
205
+ break;
206
+
207
+ case MSG.PING:
208
+ ws.send(JSON.stringify({ type: MSG.PONG, version: msg.version, timestamp: Date.now() }));
209
+ break;
210
+
211
+ default:
212
+ ws.send(JSON.stringify(createError(ERR.INVALID_MESSAGE, `Unknown type: ${msg.type}`)));
213
+ }
214
+ }
215
+
216
+ #handleJoin(ws, msg) {
217
+ if (ws.hasJoined) {
218
+ ws.send(JSON.stringify(createError(ERR.INVALID_MESSAGE, 'Already in the chat')));
219
+ return;
220
+ }
221
+
222
+ const validation = validateJoin(msg);
223
+ if (!validation.valid) {
224
+ ws.send(JSON.stringify(createError(ERR.INVALID_MESSAGE, validation.error)));
225
+ return;
226
+ }
227
+
228
+ if (this.#sessionManager.isNicknameTaken(validation.nickname)) {
229
+ ws.send(
230
+ JSON.stringify(
231
+ createError(ERR.NICKNAME_TAKEN, `Nickname "${validation.nickname}" is already in use`),
232
+ ),
233
+ );
234
+ return;
235
+ }
236
+
237
+ const room = 'general';
238
+ const sessionId = this.#sessionManager.addSession(ws, validation.nickname, msg.publicKey, room);
239
+ ws.sessionId = sessionId;
240
+ ws.hasJoined = true;
241
+ clearTimeout(ws.joinTimer);
242
+
243
+ // Deliver queued offline messages
244
+ const queued = this.#offlineQueue.dequeue(validation.nickname, msg.publicKey);
245
+
246
+ // Send ACK with peer list (room-scoped)
247
+ const peers = this.#sessionManager.getRoomPeers(room, sessionId);
248
+ const joinAck = createJoinAck(sessionId, peers, queued.length, room);
249
+ const ownerSid = this.#sessionManager.getRoomOwner(room);
250
+ if (ownerSid) {
251
+ const ownerSession = this.#sessionManager.getSession(ownerSid);
252
+ if (ownerSession) {
253
+ joinAck.roomOwner = ownerSession.nickname;
254
+ }
255
+ }
256
+ ws.send(JSON.stringify(joinAck));
257
+
258
+ // Deliver queued messages with updated recipient sessionId
259
+ for (const queuedMsg of queued) {
260
+ const delivered = { ...queuedMsg, to: sessionId };
261
+ ws.send(JSON.stringify(delivered));
262
+ }
263
+
264
+ // Notify others in the same room
265
+ this.#sessionManager.broadcastToRoom(
266
+ room,
267
+ createPeerJoined({
268
+ sessionId,
269
+ nickname: validation.nickname,
270
+ publicKey: msg.publicKey,
271
+ }),
272
+ sessionId,
273
+ );
274
+
275
+ log.info(`${validation.nickname} joined room ${room} | Online: ${this.#sessionManager.size}`);
276
+ }
277
+
278
+ #handleEncryptedMessage(ws, msg) {
279
+ if (!ws.hasJoined || !ws.sessionId) {
280
+ ws.send(JSON.stringify(createError(ERR.INVALID_MESSAGE, 'JOIN first')));
281
+ return;
282
+ }
283
+
284
+ // Check if sender is muted
285
+ if (this.#sessionManager.isMuted(ws.sessionId)) {
286
+ ws.send(JSON.stringify(createError(ERR.INVALID_MESSAGE, 'You are muted')));
287
+ return;
288
+ }
289
+
290
+ const validation = validateEncryptedMessage(msg);
291
+ if (!validation.valid) {
292
+ ws.send(JSON.stringify(createError(ERR.INVALID_MESSAGE, validation.error)));
293
+ return;
294
+ }
295
+
296
+ // Ensure the 'from' field matches the sender's actual session
297
+ msg.from = ws.sessionId;
298
+
299
+ this.#messageRouter.route(ws.sessionId, msg);
300
+ }
301
+
302
+ #handleKeyUpdate(ws, msg) {
303
+ if (!ws.hasJoined || !ws.sessionId) {
304
+ ws.send(JSON.stringify(createError(ERR.INVALID_MESSAGE, 'JOIN first')));
305
+ return;
306
+ }
307
+
308
+ const validation = validateKeyUpdate(msg);
309
+ if (!validation.valid) {
310
+ ws.send(JSON.stringify(createError(ERR.INVALID_MESSAGE, validation.error)));
311
+ return;
312
+ }
313
+
314
+ this.#sessionManager.updatePublicKey(ws.sessionId, msg.publicKey);
315
+
316
+ // Broadcast new key to room peers
317
+ const room = this.#sessionManager.getSessionRoom(ws.sessionId);
318
+ if (room) {
319
+ this.#sessionManager.broadcastToRoom(
320
+ room,
321
+ createPeerKeyUpdated(ws.sessionId, msg.publicKey),
322
+ ws.sessionId,
323
+ );
324
+ } else {
325
+ this.#sessionManager.broadcast(
326
+ createPeerKeyUpdated(ws.sessionId, msg.publicKey),
327
+ ws.sessionId,
328
+ );
329
+ }
330
+
331
+ log.info(`${ws.sessionId.slice(0, 8)} rotated keys`);
332
+ }
333
+
334
+ #handleChangeRoom(ws, msg) {
335
+ if (!ws.hasJoined || !ws.sessionId) {
336
+ ws.send(JSON.stringify(createError(ERR.INVALID_MESSAGE, 'JOIN first')));
337
+ return;
338
+ }
339
+
340
+ const validation = validateChangeRoom(msg);
341
+ if (!validation.valid) {
342
+ ws.send(JSON.stringify(createError(ERR.INVALID_MESSAGE, validation.error)));
343
+ return;
344
+ }
345
+
346
+ const session = this.#sessionManager.getSession(ws.sessionId);
347
+
348
+ // Check if user is banned from target room
349
+ if (this.#sessionManager.isBanned(validation.room, session.nickname)) {
350
+ ws.send(JSON.stringify(createError(ERR.INVALID_MESSAGE, 'You are banned from this room')));
351
+ return;
352
+ }
353
+ const result = this.#sessionManager.switchRoom(ws.sessionId, validation.room);
354
+ if (!result) {
355
+ // Already in this room
356
+ ws.send(JSON.stringify(createError(ERR.INVALID_MESSAGE, 'You are already in this room')));
357
+ return;
358
+ }
359
+
360
+ // Notify old room that peer left
361
+ this.#sessionManager.broadcastToRoom(
362
+ result.oldRoom,
363
+ createPeerLeft(ws.sessionId, session.nickname),
364
+ ws.sessionId,
365
+ );
366
+
367
+ // Notify new room that peer joined
368
+ this.#sessionManager.broadcastToRoom(
369
+ result.newRoom,
370
+ createPeerJoined({
371
+ sessionId: ws.sessionId,
372
+ nickname: session.nickname,
373
+ publicKey: session.publicKey,
374
+ }),
375
+ ws.sessionId,
376
+ );
377
+
378
+ // Send new room info to the client
379
+ const newPeers = this.#sessionManager.getRoomPeers(result.newRoom, ws.sessionId);
380
+ const roomChanged = createRoomChanged(result.newRoom, newPeers);
381
+ const newOwnerSid = this.#sessionManager.getRoomOwner(result.newRoom);
382
+ if (newOwnerSid) {
383
+ const ownerSess = this.#sessionManager.getSession(newOwnerSid);
384
+ if (ownerSess) {
385
+ roomChanged.roomOwner = ownerSess.nickname;
386
+ }
387
+ }
388
+ ws.send(JSON.stringify(roomChanged));
389
+
390
+ log.info(`${session.nickname} switched to room ${result.newRoom}`);
391
+ }
392
+
393
+ #handleListRooms(ws) {
394
+ if (!ws.hasJoined || !ws.sessionId) {
395
+ ws.send(JSON.stringify(createError(ERR.INVALID_MESSAGE, 'JOIN first')));
396
+ return;
397
+ }
398
+
399
+ const rooms = this.#sessionManager.listRooms();
400
+ ws.send(JSON.stringify(createRoomList(rooms)));
401
+ }
402
+
403
+ #handleKickPeer(ws, msg) {
404
+ if (!ws.hasJoined || !ws.sessionId) {
405
+ ws.send(JSON.stringify(createError(ERR.INVALID_MESSAGE, 'JOIN first')));
406
+ return;
407
+ }
408
+
409
+ const validation = validateKickPeer(msg);
410
+ if (!validation.valid) {
411
+ ws.send(JSON.stringify(createError(ERR.INVALID_MESSAGE, validation.error)));
412
+ return;
413
+ }
414
+
415
+ const room = this.#sessionManager.getSessionRoom(ws.sessionId);
416
+ if (!this.#sessionManager.isRoomOwner(room, ws.sessionId)) {
417
+ ws.send(
418
+ JSON.stringify(createError(ERR.INVALID_MESSAGE, 'Only the room owner can use /kick')),
419
+ );
420
+ return;
421
+ }
422
+
423
+ const targetSessionId = this.#sessionManager.findSessionByNickname(validation.targetNickname);
424
+ if (!targetSessionId) {
425
+ ws.send(
426
+ JSON.stringify(createError(ERR.PEER_NOT_FOUND, `"${validation.targetNickname}" not found`)),
427
+ );
428
+ return;
429
+ }
430
+
431
+ const targetRoom = this.#sessionManager.getSessionRoom(targetSessionId);
432
+ if (targetRoom !== room) {
433
+ ws.send(
434
+ JSON.stringify(
435
+ createError(ERR.PEER_NOT_FOUND, `"${validation.targetNickname}" is not in this room`),
436
+ ),
437
+ );
438
+ return;
439
+ }
440
+
441
+ // Move target to general
442
+ const result = this.#sessionManager.switchRoom(targetSessionId, 'general');
443
+ if (result) {
444
+ const targetSession = this.#sessionManager.getSession(targetSessionId);
445
+
446
+ // Notify old room
447
+ this.#sessionManager.broadcastToRoom(
448
+ room,
449
+ createPeerKicked(validation.targetNickname, validation.reason),
450
+ );
451
+
452
+ // Notify target with room change + kick reason
453
+ const newPeers = this.#sessionManager.getRoomPeers('general', targetSessionId);
454
+ targetSession.ws.send(JSON.stringify(createRoomChanged('general', newPeers)));
455
+ targetSession.ws.send(
456
+ JSON.stringify(createPeerKicked(validation.targetNickname, validation.reason)),
457
+ );
458
+
459
+ log.info(
460
+ `${validation.targetNickname} was kicked from room ${room} by ${this.#sessionManager.getSession(ws.sessionId).nickname}`,
461
+ );
462
+ }
463
+ }
464
+
465
+ #handleMutePeer(ws, msg) {
466
+ if (!ws.hasJoined || !ws.sessionId) {
467
+ ws.send(JSON.stringify(createError(ERR.INVALID_MESSAGE, 'JOIN first')));
468
+ return;
469
+ }
470
+
471
+ const validation = validateMutePeer(msg);
472
+ if (!validation.valid) {
473
+ ws.send(JSON.stringify(createError(ERR.INVALID_MESSAGE, validation.error)));
474
+ return;
475
+ }
476
+
477
+ const room = this.#sessionManager.getSessionRoom(ws.sessionId);
478
+ if (!this.#sessionManager.isRoomOwner(room, ws.sessionId)) {
479
+ ws.send(
480
+ JSON.stringify(createError(ERR.INVALID_MESSAGE, 'Only the room owner can use /mute')),
481
+ );
482
+ return;
483
+ }
484
+
485
+ const targetSessionId = this.#sessionManager.findSessionByNickname(validation.targetNickname);
486
+ if (!targetSessionId) {
487
+ ws.send(
488
+ JSON.stringify(createError(ERR.PEER_NOT_FOUND, `"${validation.targetNickname}" not found`)),
489
+ );
490
+ return;
491
+ }
492
+
493
+ const targetRoom = this.#sessionManager.getSessionRoom(targetSessionId);
494
+ if (targetRoom !== room) {
495
+ ws.send(
496
+ JSON.stringify(
497
+ createError(ERR.PEER_NOT_FOUND, `"${validation.targetNickname}" is not in this room`),
498
+ ),
499
+ );
500
+ return;
501
+ }
502
+
503
+ this.#sessionManager.mutePeer(targetSessionId, validation.durationMs);
504
+ this.#sessionManager.broadcastToRoom(
505
+ room,
506
+ createPeerMuted(validation.targetNickname, validation.durationMs),
507
+ );
508
+
509
+ log.info(
510
+ `${validation.targetNickname} was muted for ${validation.durationMs}ms in room ${room}`,
511
+ );
512
+ }
513
+
514
+ #handleBanPeer(ws, msg) {
515
+ if (!ws.hasJoined || !ws.sessionId) {
516
+ ws.send(JSON.stringify(createError(ERR.INVALID_MESSAGE, 'JOIN first')));
517
+ return;
518
+ }
519
+
520
+ const validation = validateBanPeer(msg);
521
+ if (!validation.valid) {
522
+ ws.send(JSON.stringify(createError(ERR.INVALID_MESSAGE, validation.error)));
523
+ return;
524
+ }
525
+
526
+ const room = this.#sessionManager.getSessionRoom(ws.sessionId);
527
+ if (!this.#sessionManager.isRoomOwner(room, ws.sessionId)) {
528
+ ws.send(JSON.stringify(createError(ERR.INVALID_MESSAGE, 'Only the room owner can use /ban')));
529
+ return;
530
+ }
531
+
532
+ const targetSessionId = this.#sessionManager.findSessionByNickname(validation.targetNickname);
533
+ if (!targetSessionId) {
534
+ ws.send(
535
+ JSON.stringify(createError(ERR.PEER_NOT_FOUND, `"${validation.targetNickname}" not found`)),
536
+ );
537
+ return;
538
+ }
539
+
540
+ const targetRoom = this.#sessionManager.getSessionRoom(targetSessionId);
541
+ if (targetRoom !== room) {
542
+ ws.send(
543
+ JSON.stringify(
544
+ createError(ERR.PEER_NOT_FOUND, `"${validation.targetNickname}" is not in this room`),
545
+ ),
546
+ );
547
+ return;
548
+ }
549
+
550
+ // Ban + kick to general
551
+ this.#sessionManager.banPeer(room, validation.targetNickname);
552
+
553
+ const result = this.#sessionManager.switchRoom(targetSessionId, 'general');
554
+ if (result) {
555
+ const targetSession = this.#sessionManager.getSession(targetSessionId);
556
+
557
+ this.#sessionManager.broadcastToRoom(
558
+ room,
559
+ createPeerKicked(validation.targetNickname, validation.reason || 'banned'),
560
+ );
561
+
562
+ const newPeers = this.#sessionManager.getRoomPeers('general', targetSessionId);
563
+ targetSession.ws.send(JSON.stringify(createRoomChanged('general', newPeers)));
564
+ targetSession.ws.send(
565
+ JSON.stringify(createPeerKicked(validation.targetNickname, validation.reason || 'banned')),
566
+ );
567
+
568
+ log.info(
569
+ `${validation.targetNickname} was banned from room ${room} by ${this.#sessionManager.getSession(ws.sessionId).nickname}`,
570
+ );
571
+ }
572
+ }
573
+
574
+ #handleDisconnect(ws) {
575
+ if (!ws.sessionId) {
576
+ return;
577
+ }
578
+
579
+ const room = this.#sessionManager.getSessionRoom(ws.sessionId);
580
+ const session = this.#sessionManager.removeSession(ws.sessionId);
581
+ this.#messageRouter.cleanupSession(ws.sessionId);
582
+
583
+ if (session) {
584
+ // Broadcast to former room members only
585
+ if (room) {
586
+ this.#sessionManager.broadcastToRoom(
587
+ room,
588
+ createPeerLeft(ws.sessionId, session.nickname),
589
+ ws.sessionId,
590
+ );
591
+ } else {
592
+ this.#sessionManager.broadcast(
593
+ createPeerLeft(ws.sessionId, session.nickname),
594
+ ws.sessionId,
595
+ );
596
+ }
597
+ log.info(`${session.nickname} saiu | Online: ${this.#sessionManager.size}`);
598
+ }
599
+ }
600
+
601
+ #startHeartbeat() {
602
+ this.#heartbeatInterval = setInterval(() => {
603
+ for (const ws of this.#wss.clients) {
604
+ if (!ws.isAlive) {
605
+ log.warn('Client did not respond to heartbeat, disconnecting');
606
+ ws.terminate();
607
+ continue;
608
+ }
609
+ ws.isAlive = false;
610
+ ws.ping();
611
+ }
612
+ }, HEARTBEAT_INTERVAL_MS);
613
+ }
614
+
615
+ close() {
616
+ clearInterval(this.#heartbeatInterval);
617
+
618
+ for (const ws of this.#wss.clients) {
619
+ ws.close(1001, 'Server shutting down');
620
+ }
621
+
622
+ return new Promise((resolve) => {
623
+ this.#wss.close(() => {
624
+ if (this.#httpsServer) {
625
+ this.#httpsServer.close(resolve);
626
+ } else {
627
+ resolve();
628
+ }
629
+ });
630
+ });
631
+ }
632
+ }
@@ -0,0 +1,89 @@
1
+ /* eslint-disable curly */
2
+ // @ts-nocheck
3
+ import { existsSync } from 'node:fs';
4
+ import { networkInterfaces } from 'node:os';
5
+ import { SERVER_PORT, OFFLINE_QUEUE_MAX_AGE_MS } from '../shared/constants.js';
6
+ import { createLogger } from '../shared/logger.js';
7
+ import { serverBanner } from '../shared/banner.js';
8
+ import { SessionManager } from './SessionManager.js';
9
+ import { MessageRouter } from './MessageRouter.js';
10
+ import { OfflineQueue } from './OfflineQueue.js';
11
+ import { SecureWSServer } from './WebSocketServer.js';
12
+ import { loadOrGenerateCerts } from './CertManager.js';
13
+
14
+ const log = createLogger('server');
15
+
16
+ // ── Detect LAN IPs ─────────────────────────────────────────────
17
+ // Tailscale assigns IPs in the CGNAT range 100.64.0.0/10
18
+ function isTailscaleIP(address) {
19
+ const [a, b] = address.split('.').map(Number);
20
+ return a === 100 && b >= 64 && b <= 127;
21
+ }
22
+
23
+ function getLocalIPs() {
24
+ const ips = [];
25
+ const ifaces = networkInterfaces();
26
+ const inDocker = existsSync('/.dockerenv');
27
+
28
+ for (const name of Object.keys(ifaces)) {
29
+ for (const iface of ifaces[name]) {
30
+ if (iface.family === 'IPv4' && !iface.internal) {
31
+ if (inDocker && iface.address.startsWith('172.')) continue;
32
+ const tailscale = name.toLowerCase().includes('tailscale') || isTailscaleIP(iface.address);
33
+ ips.push({ name, address: iface.address, tailscale });
34
+ }
35
+ }
36
+ }
37
+
38
+ // In Docker the container runs on the bridge (172.x) and cannot see the host
39
+ // interfaces (e.g. tailscale0, LAN). ADVERTISE_IP allows advertising one or
40
+ // more host IPs (comma-separated) so the banner shows all the correct
41
+ // connection URLs — e.g. ADVERTISE_IP=192.168.1.7,100.73.206.23
42
+ const advertised = (process.env.ADVERTISE_IP ?? '')
43
+ .split(',')
44
+ .map((ip) => ip.trim())
45
+ .filter(Boolean);
46
+ for (const address of advertised) {
47
+ if (ips.some((ip) => ip.address === address)) continue;
48
+ const tailscale = isTailscaleIP(address);
49
+ ips.push({ name: tailscale ? 'Tailscale' : 'Local', address, tailscale });
50
+ }
51
+
52
+ return { ips, inDocker };
53
+ }
54
+
55
+ // ── Bootstrap ──────────────────────────────────────────────────
56
+ const port = parseInt(process.env.PORT, 10) || SERVER_PORT;
57
+
58
+ const useTls = process.env.TLS !== 'false'; // default: true
59
+ const tlsOptions = useTls ? loadOrGenerateCerts() : null;
60
+
61
+ const sessionManager = new SessionManager();
62
+ const offlineQueue = new OfflineQueue();
63
+ const messageRouter = new MessageRouter(sessionManager, offlineQueue);
64
+ const server = new SecureWSServer(sessionManager, messageRouter, offlineQueue, port, tlsOptions);
65
+
66
+ // Cleanup offline queue and recently left peers every 5 minutes
67
+ setInterval(
68
+ () => {
69
+ offlineQueue.cleanup();
70
+ sessionManager.cleanupRecentlyLeft(OFFLINE_QUEUE_MAX_AGE_MS);
71
+ },
72
+ 5 * 60 * 1000,
73
+ );
74
+
75
+ // ── Startup banner ─────────────────────────────────────────────
76
+ serverBanner(port, getLocalIPs(), useTls);
77
+
78
+ log.info('Server started and waiting for connections');
79
+
80
+ // ── Graceful shutdown ──────────────────────────────────────────
81
+ async function shutdown(signal) {
82
+ log.info(`${signal} received, shutting down...`);
83
+ await server.close();
84
+ log.info('Server stopped');
85
+ process.exit(0);
86
+ }
87
+
88
+ process.on('SIGINT', () => shutdown('SIGINT'));
89
+ process.on('SIGTERM', () => shutdown('SIGTERM'));