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.
- package/LICENSE +21 -0
- package/README.md +251 -0
- package/README.pt-BR.md +253 -0
- package/bin/ciphermesh.js +34 -0
- package/docs/ARCHITECTURE.md +1188 -0
- package/docs/SETUP.md +305 -0
- package/docs/demo.svg +46 -0
- package/package.json +87 -0
- package/src/client/ChatController.js +2476 -0
- package/src/client/Connection.js +129 -0
- package/src/client/FileTransfer.js +488 -0
- package/src/client/ImagePreview.js +88 -0
- package/src/client/UI.js +1830 -0
- package/src/client/index.js +231 -0
- package/src/crypto/CertPinStore.js +79 -0
- package/src/crypto/DeniableEncrypt.js +53 -0
- package/src/crypto/DoubleRatchet.js +574 -0
- package/src/crypto/Handshake.js +219 -0
- package/src/crypto/HistoryStore.js +241 -0
- package/src/crypto/IdentityBackup.js +70 -0
- package/src/crypto/KeyManager.js +134 -0
- package/src/crypto/MessageCrypto.js +181 -0
- package/src/crypto/NonceManager.js +72 -0
- package/src/crypto/SealedSender.js +58 -0
- package/src/crypto/SenderKey.js +204 -0
- package/src/crypto/StateManager.js +138 -0
- package/src/crypto/TrustStore.js +216 -0
- package/src/p2p/Discovery.js +80 -0
- package/src/p2p/P2PChatController.js +1856 -0
- package/src/p2p/PeerConnectionManager.js +252 -0
- package/src/p2p/PeerServer.js +68 -0
- package/src/p2p/index.js +219 -0
- package/src/protocol/messages.js +138 -0
- package/src/protocol/validators.js +175 -0
- package/src/server/CertManager.js +173 -0
- package/src/server/MessageRouter.js +80 -0
- package/src/server/OfflineQueue.js +124 -0
- package/src/server/SessionManager.js +296 -0
- package/src/server/WebSocketServer.js +632 -0
- package/src/server/index.js +89 -0
- package/src/shared/AuditLog.js +91 -0
- package/src/shared/PluginManager.js +83 -0
- package/src/shared/banner.js +271 -0
- package/src/shared/commandSuggest.js +59 -0
- package/src/shared/config.js +90 -0
- package/src/shared/constants.js +126 -0
- package/src/shared/coverTraffic.js +34 -0
- package/src/shared/dnd.js +60 -0
- package/src/shared/emoji.js +17 -0
- package/src/shared/fuzzy.js +40 -0
- package/src/shared/invite.js +61 -0
- package/src/shared/keyArt.js +66 -0
- package/src/shared/logger.js +38 -0
- package/src/shared/panic.js +38 -0
- package/src/shared/prompt.js +31 -0
- package/src/shared/terminalGraphics.js +72 -0
- package/src/shared/themes.js +36 -0
- 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'));
|