ciphermesh 2.9.0 → 2.11.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/CHANGELOG.md +126 -0
- package/README.md +5 -3
- package/README.pt-BR.md +2 -2
- package/docs/ARCHITECTURE.md +127 -4
- package/docs/PLUGINS.md +53 -14
- package/docs/PROTOCOL.md +486 -0
- package/docs/commands.json +640 -0
- package/docs/demo.svg +2 -2
- package/docs/design/sender-keys-on-relay.md +118 -0
- package/package.json +3 -2
- package/src/client/ChatController.js +245 -68
- package/src/client/UI.js +4 -1
- package/src/client/index.js +6 -1
- package/src/crypto/SenderKey.js +159 -3
- package/src/p2p/P2PChatController.js +16 -10
- package/src/p2p/index.js +6 -1
- package/src/protocol/capabilities.js +74 -0
- package/src/protocol/messages.js +39 -2
- package/src/protocol/validators.js +73 -1
- package/src/server/ConnectionGuard.js +158 -0
- package/src/server/MessageRouter.js +9 -0
- package/src/server/SessionManager.js +3 -1
- package/src/server/WebSocketServer.js +101 -1
- package/src/server/config.js +15 -0
- package/src/server/index.js +18 -0
- package/src/server/preflight.js +96 -0
- package/src/shared/PluginManager.js +103 -30
- package/src/shared/config.js +12 -0
- package/src/shared/constants.js +50 -0
- package/src/shared/pluginCommand.js +106 -0
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// Limits on how *fast* a source may connect, and how much it may push once in.
|
|
2
|
+
//
|
|
3
|
+
// The existing caps bound how many sockets exist at once and how many messages
|
|
4
|
+
// a session sends per second. Neither bounds the two things that actually cost
|
|
5
|
+
// the relay:
|
|
6
|
+
//
|
|
7
|
+
// 1. Handshake churn. Connect, run the hybrid handshake, disconnect, repeat.
|
|
8
|
+
// Every attempt makes the relay do X25519 and ML-KEM-768 work while the
|
|
9
|
+
// attacker does almost nothing, and a concurrency cap never trips because
|
|
10
|
+
// the sockets are never held open.
|
|
11
|
+
//
|
|
12
|
+
// 2. Bandwidth. The message limit counts messages, and messages are padded
|
|
13
|
+
// into buckets up to 32 KiB, so a session at the limit is a multi-megabit
|
|
14
|
+
// stream. The resource that runs out is bytes, not messages.
|
|
15
|
+
//
|
|
16
|
+
// Both are token buckets, and both take their clock as an argument so the tests
|
|
17
|
+
// can move time without sleeping.
|
|
18
|
+
import { MAX_PAYLOAD_SIZE } from '../shared/constants.js';
|
|
19
|
+
|
|
20
|
+
/** A bucket that refills continuously rather than resetting on a boundary. */
|
|
21
|
+
class TokenBucket {
|
|
22
|
+
#capacity;
|
|
23
|
+
#perMs;
|
|
24
|
+
#tokens;
|
|
25
|
+
#last;
|
|
26
|
+
|
|
27
|
+
constructor(capacity, perMs, now) {
|
|
28
|
+
this.#capacity = capacity;
|
|
29
|
+
this.#perMs = perMs;
|
|
30
|
+
this.#tokens = capacity;
|
|
31
|
+
this.#last = now;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Spend `cost` if it is there. Returns false without spending if it is not. */
|
|
35
|
+
take(cost, now) {
|
|
36
|
+
// A fixed window lets a caller spend the whole allowance at the end of one
|
|
37
|
+
// window and again at the start of the next, which is twice the intended
|
|
38
|
+
// rate for a moment. Refilling by elapsed time has no such seam.
|
|
39
|
+
this.#tokens = Math.min(this.#capacity, this.#tokens + (now - this.#last) * this.#perMs);
|
|
40
|
+
this.#last = now;
|
|
41
|
+
|
|
42
|
+
if (this.#tokens < cost) {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
this.#tokens -= cost;
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** True once the bucket is full again — the entry is then worth forgetting. */
|
|
50
|
+
idle(now) {
|
|
51
|
+
return this.#tokens + (now - this.#last) * this.#perMs >= this.#capacity;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* How long a source is refused after going too fast, by how many times it has
|
|
57
|
+
* done so. Short enough that a burst of reconnects after a relay restart is
|
|
58
|
+
* forgiven; long enough that a script grinding away gets nothing done.
|
|
59
|
+
*/
|
|
60
|
+
const BAN_STEPS_MS = [60_000, 300_000, 1_800_000];
|
|
61
|
+
|
|
62
|
+
/** Strikes are forgotten after this long behaving, so the ban never ratchets up forever. */
|
|
63
|
+
const STRIKE_DECAY_MS = 3_600_000;
|
|
64
|
+
|
|
65
|
+
export class ConnectionRateLimiter {
|
|
66
|
+
#perMinute;
|
|
67
|
+
#entries = new Map();
|
|
68
|
+
|
|
69
|
+
constructor(perMinute) {
|
|
70
|
+
this.#perMinute = perMinute;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* @returns {{allowed: boolean, retryAfterMs: number}} — `retryAfterMs` is
|
|
75
|
+
* worth telling the client, since a well-behaved one can then back off
|
|
76
|
+
* instead of hammering and extending its own ban.
|
|
77
|
+
*/
|
|
78
|
+
check(ip, now = Date.now()) {
|
|
79
|
+
let entry = this.#entries.get(ip);
|
|
80
|
+
if (!entry) {
|
|
81
|
+
entry = {
|
|
82
|
+
bucket: new TokenBucket(this.#perMinute, this.#perMinute / 60_000, now),
|
|
83
|
+
strikes: 0,
|
|
84
|
+
bannedUntil: 0,
|
|
85
|
+
lastStrike: 0,
|
|
86
|
+
};
|
|
87
|
+
this.#entries.set(ip, entry);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (now < entry.bannedUntil) {
|
|
91
|
+
return { allowed: false, retryAfterMs: entry.bannedUntil - now };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (entry.bucket.take(1, now)) {
|
|
95
|
+
return { allowed: true, retryAfterMs: 0 };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Behaving for an hour wipes the record. Without this a long-lived NAT
|
|
99
|
+
// gateway would collect strikes over months and end up permanently at the
|
|
100
|
+
// longest ban for one bad afternoon.
|
|
101
|
+
if (entry.lastStrike && now - entry.lastStrike > STRIKE_DECAY_MS) {
|
|
102
|
+
entry.strikes = 0;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const step = BAN_STEPS_MS[Math.min(entry.strikes, BAN_STEPS_MS.length - 1)];
|
|
106
|
+
entry.strikes += 1;
|
|
107
|
+
entry.lastStrike = now;
|
|
108
|
+
entry.bannedUntil = now + step;
|
|
109
|
+
|
|
110
|
+
return { allowed: false, retryAfterMs: step };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Forget sources that have gone quiet. Called on a timer — without it the map
|
|
115
|
+
* is a slow memory leak keyed by anything that ever connected, which is a
|
|
116
|
+
* denial of service of its own.
|
|
117
|
+
*/
|
|
118
|
+
prune(now = Date.now()) {
|
|
119
|
+
for (const [ip, entry] of this.#entries) {
|
|
120
|
+
if (now < entry.bannedUntil) {
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (entry.lastStrike && now - entry.lastStrike <= STRIKE_DECAY_MS) {
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
if (entry.bucket.idle(now)) {
|
|
127
|
+
this.#entries.delete(ip);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
get size() {
|
|
133
|
+
return this.#entries.size;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* A sustained bytes-per-second budget for one connection, with a burst
|
|
139
|
+
* allowance so a legitimate file transfer is not mistaken for an attack.
|
|
140
|
+
*
|
|
141
|
+
* One of these lives on the socket, so it disappears with it and there is
|
|
142
|
+
* nothing to prune.
|
|
143
|
+
*/
|
|
144
|
+
export class ByteBudget {
|
|
145
|
+
#bucket;
|
|
146
|
+
|
|
147
|
+
constructor(perSecond, burst, now = Date.now()) {
|
|
148
|
+
// A burst smaller than one frame could never be paid for, so the connection
|
|
149
|
+
// would wedge shut instead of being throttled. Callers get the larger of
|
|
150
|
+
// the two rather than a silently broken socket.
|
|
151
|
+
this.#bucket = new TokenBucket(Math.max(burst, MAX_PAYLOAD_SIZE), perSecond / 1000, now);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** @returns {boolean} false when the connection has outrun its budget. */
|
|
155
|
+
allow(bytes, now = Date.now()) {
|
|
156
|
+
return this.#bucket.take(bytes, now);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
@@ -59,6 +59,15 @@ export class MessageRouter {
|
|
|
59
59
|
}
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Spend one unit of a sender's per-second budget without routing anything.
|
|
64
|
+
* For paths that deliver themselves (the room fan-out) but must not get a
|
|
65
|
+
* cheaper allowance than route() gives the unicast path.
|
|
66
|
+
*/
|
|
67
|
+
allowFrom(senderSessionId) {
|
|
68
|
+
return this.#checkRateLimit(senderSessionId);
|
|
69
|
+
}
|
|
70
|
+
|
|
62
71
|
#checkRateLimit(sessionId) {
|
|
63
72
|
const now = Date.now();
|
|
64
73
|
const entry = this.#rateCounts.get(sessionId);
|
|
@@ -30,13 +30,14 @@ export class SessionManager {
|
|
|
30
30
|
return this.#nicknames.has(nickname.toLowerCase());
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
addSession(ws, nickname, publicKey, room = 'general', pqPublicKey = null) {
|
|
33
|
+
addSession(ws, nickname, publicKey, room = 'general', pqPublicKey = null, capabilities = []) {
|
|
34
34
|
const sessionId = randomUUID();
|
|
35
35
|
const session = {
|
|
36
36
|
ws,
|
|
37
37
|
nickname,
|
|
38
38
|
publicKey,
|
|
39
39
|
pqPublicKey, // ML-KEM-768 key, relayed verbatim (server never uses it)
|
|
40
|
+
capabilities, // advertised in JOIN, relayed verbatim — the relay never acts on these
|
|
40
41
|
connectedAt: Date.now(),
|
|
41
42
|
rooms: new Set(),
|
|
42
43
|
};
|
|
@@ -104,6 +105,7 @@ export class SessionManager {
|
|
|
104
105
|
nickname: session.nickname,
|
|
105
106
|
publicKey: session.publicKey,
|
|
106
107
|
...(session.pqPublicKey ? { pqPublicKey: session.pqPublicKey } : {}),
|
|
108
|
+
...(session.capabilities?.length ? { caps: [...session.capabilities] } : {}),
|
|
107
109
|
});
|
|
108
110
|
}
|
|
109
111
|
}
|
|
@@ -2,6 +2,7 @@ import { createServer as createHttpsServer } from 'node:https';
|
|
|
2
2
|
import { randomBytes } from 'node:crypto';
|
|
3
3
|
import { WebSocketServer as WSServer } from 'ws';
|
|
4
4
|
import { createLogger } from '../shared/logger.js';
|
|
5
|
+
import { ByteBudget, ConnectionRateLimiter } from './ConnectionGuard.js';
|
|
5
6
|
import {
|
|
6
7
|
HEARTBEAT_INTERVAL_MS,
|
|
7
8
|
MAX_PAYLOAD_SIZE,
|
|
@@ -10,6 +11,7 @@ import {
|
|
|
10
11
|
ROOM_CHALLENGE_TTL_MS,
|
|
11
12
|
ROOM_AUTH_MAX_FAILS,
|
|
12
13
|
ROOM_AUTH_FAIL_WINDOW_MS,
|
|
14
|
+
SERVER_CAPABILITIES,
|
|
13
15
|
} from '../shared/constants.js';
|
|
14
16
|
import {
|
|
15
17
|
MSG,
|
|
@@ -31,6 +33,7 @@ import {
|
|
|
31
33
|
parseMessage,
|
|
32
34
|
validateJoin,
|
|
33
35
|
validateEncryptedMessage,
|
|
36
|
+
validateGroupMessage,
|
|
34
37
|
validateKeyUpdate,
|
|
35
38
|
validateChangeRoom,
|
|
36
39
|
validateJoinRoom,
|
|
@@ -54,6 +57,7 @@ export class SecureWSServer {
|
|
|
54
57
|
#heartbeatInterval;
|
|
55
58
|
#connectionsByIp;
|
|
56
59
|
#config;
|
|
60
|
+
#rateLimiter;
|
|
57
61
|
|
|
58
62
|
constructor(sessionManager, messageRouter, offlineQueue, port, tlsOptions, config = null) {
|
|
59
63
|
this.#sessionManager = sessionManager;
|
|
@@ -61,6 +65,7 @@ export class SecureWSServer {
|
|
|
61
65
|
this.#offlineQueue = offlineQueue;
|
|
62
66
|
this.#connectionsByIp = new Map();
|
|
63
67
|
this.#config = config || parseServerConfig();
|
|
68
|
+
this.#rateLimiter = new ConnectionRateLimiter(this.#config.connectionRatePerMinute);
|
|
64
69
|
|
|
65
70
|
if (tlsOptions) {
|
|
66
71
|
this.#httpsServer = createHttpsServer(tlsOptions);
|
|
@@ -99,6 +104,21 @@ export class SecureWSServer {
|
|
|
99
104
|
return;
|
|
100
105
|
}
|
|
101
106
|
|
|
107
|
+
// How FAST this source is connecting, before how many it holds. Churn —
|
|
108
|
+
// connect, handshake, disconnect, repeat — never trips the concurrency cap
|
|
109
|
+
// below, and each attempt costs an X25519 and an ML-KEM-768 operation. The
|
|
110
|
+
// socket is already upgraded by the time we get here, but closing now is
|
|
111
|
+
// what matters: the expensive work happens at JOIN, and this never reaches
|
|
112
|
+
// it.
|
|
113
|
+
const rate = this.#rateLimiter.check(ip);
|
|
114
|
+
if (!rate.allowed) {
|
|
115
|
+
log.warn(`Connecting too fast from ${ip}, refusing for ${rate.retryAfterMs}ms`);
|
|
116
|
+
// Tell them how long, so a well-behaved client backs off instead of
|
|
117
|
+
// hammering and extending its own ban.
|
|
118
|
+
ws.close(1013, `Try again in ${Math.ceil(rate.retryAfterMs / 1000)}s`);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
102
122
|
// Global connection cap (the new socket is already counted in clients).
|
|
103
123
|
if (this.#wss.clients.size > this.#config.maxConnectionsTotal) {
|
|
104
124
|
ws.close(1013, 'Server full');
|
|
@@ -119,6 +139,8 @@ export class SecureWSServer {
|
|
|
119
139
|
ws.hasJoined = false;
|
|
120
140
|
ws.msgWindowStart = Date.now();
|
|
121
141
|
ws.msgCount = 0;
|
|
142
|
+
// Bytes, not messages. Lives on the socket, so it goes away with it.
|
|
143
|
+
ws.byteBudget = new ByteBudget(this.#config.maxBytesPerSecond, this.#config.maxBytesBurst);
|
|
122
144
|
|
|
123
145
|
// Drop sockets that connect but never JOIN (slowloris / resource hold).
|
|
124
146
|
ws.joinTimer = setTimeout(() => {
|
|
@@ -182,6 +204,17 @@ export class SecureWSServer {
|
|
|
182
204
|
return;
|
|
183
205
|
}
|
|
184
206
|
|
|
207
|
+
// Charged before parsing: the bytes have already been received and buffered
|
|
208
|
+
// by this point, so refusing to spend effort on them is the only saving
|
|
209
|
+
// left, and a sender that ignores the warning is disconnected rather than
|
|
210
|
+
// allowed to keep paying nothing.
|
|
211
|
+
if (!ws.byteBudget.allow(data.length ?? 0)) {
|
|
212
|
+
log.warn(`Byte budget exhausted for ${ws.clientIp}, closing`);
|
|
213
|
+
ws.send(JSON.stringify(createError(ERR.RATE_LIMITED, 'Sending too much, too fast')));
|
|
214
|
+
ws.close(1008, 'Byte budget exhausted');
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
|
|
185
218
|
const raw = data.toString('utf-8');
|
|
186
219
|
const { valid, error, msg } = parseMessage(raw);
|
|
187
220
|
|
|
@@ -199,6 +232,10 @@ export class SecureWSServer {
|
|
|
199
232
|
this.#handleEncryptedMessage(ws, msg);
|
|
200
233
|
break;
|
|
201
234
|
|
|
235
|
+
case MSG.GROUP_MESSAGE:
|
|
236
|
+
this.#handleGroupMessage(ws, msg);
|
|
237
|
+
break;
|
|
238
|
+
|
|
202
239
|
case MSG.KEY_UPDATE:
|
|
203
240
|
this.#handleKeyUpdate(ws, msg);
|
|
204
241
|
break;
|
|
@@ -272,6 +309,7 @@ export class SecureWSServer {
|
|
|
272
309
|
msg.publicKey,
|
|
273
310
|
room,
|
|
274
311
|
validation.pqPublicKey,
|
|
312
|
+
validation.capabilities,
|
|
275
313
|
);
|
|
276
314
|
ws.sessionId = sessionId;
|
|
277
315
|
ws.hasJoined = true;
|
|
@@ -282,7 +320,7 @@ export class SecureWSServer {
|
|
|
282
320
|
|
|
283
321
|
// Send ACK with peer list (room-scoped)
|
|
284
322
|
const peers = this.#sessionManager.getRoomPeers(room, sessionId);
|
|
285
|
-
const joinAck = createJoinAck(sessionId, peers, queued.length, room);
|
|
323
|
+
const joinAck = createJoinAck(sessionId, peers, queued.length, room, SERVER_CAPABILITIES);
|
|
286
324
|
const ownerSid = this.#sessionManager.getRoomOwner(room);
|
|
287
325
|
if (ownerSid) {
|
|
288
326
|
const ownerSession = this.#sessionManager.getSession(ownerSid);
|
|
@@ -309,6 +347,7 @@ export class SecureWSServer {
|
|
|
309
347
|
nickname: validation.nickname,
|
|
310
348
|
publicKey: msg.publicKey,
|
|
311
349
|
...(validation.pqPublicKey ? { pqPublicKey: validation.pqPublicKey } : {}),
|
|
350
|
+
...(validation.capabilities.length ? { caps: [...validation.capabilities] } : {}),
|
|
312
351
|
}),
|
|
313
352
|
sessionId,
|
|
314
353
|
);
|
|
@@ -343,6 +382,63 @@ export class SecureWSServer {
|
|
|
343
382
|
this.#messageRouter.route(ws.sessionId, msg);
|
|
344
383
|
}
|
|
345
384
|
|
|
385
|
+
// One ciphertext in, one copy to each member of the room. The relay reads the
|
|
386
|
+
// room and nothing else: no `to` to route by, and no `from` to stamp on the
|
|
387
|
+
// way out — a room-addressed envelope must not become the one place the relay
|
|
388
|
+
// asserts who is speaking.
|
|
389
|
+
#handleGroupMessage(ws, msg) {
|
|
390
|
+
if (!ws.hasJoined || !ws.sessionId) {
|
|
391
|
+
ws.send(JSON.stringify(createError(ERR.INVALID_MESSAGE, 'JOIN first')));
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
if (this.#sessionManager.isMuted(ws.sessionId)) {
|
|
396
|
+
ws.send(JSON.stringify(createError(ERR.INVALID_MESSAGE, 'You are muted')));
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const validation = validateGroupMessage(msg);
|
|
401
|
+
if (!validation.valid) {
|
|
402
|
+
ws.send(JSON.stringify(createError(ERR.INVALID_MESSAGE, validation.error)));
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// A member may only address a room it is in. Without this, one connection
|
|
407
|
+
// could inject into every room on the hub at once — the per-peer path has no
|
|
408
|
+
// equivalent, because it needs a sessionId it could only have been told.
|
|
409
|
+
if (!this.#sessionManager.isInRoom(ws.sessionId, validation.room)) {
|
|
410
|
+
ws.send(JSON.stringify(createError(ERR.INVALID_MESSAGE, 'Not in that room')));
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// Same per-sender budget as the unicast path. One frame in now costs the
|
|
415
|
+
// relay N frames out instead of one, so the limit that used to be applied
|
|
416
|
+
// N times per line is applied once — charging less here than there would
|
|
417
|
+
// turn the saving into an amplifier.
|
|
418
|
+
if (!this.#messageRouter.allowFrom(ws.sessionId)) {
|
|
419
|
+
ws.send(JSON.stringify(createError(ERR.RATE_LIMITED, 'Too many messages per second')));
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Deliberately NOT queued for absent members, unlike the unicast path.
|
|
424
|
+
//
|
|
425
|
+
// A member who was away could not read it anyway: a sender key handed over
|
|
426
|
+
// on their return serialises the chain at its *current* counter, so anything
|
|
427
|
+
// sent while they were gone stays shut — the forward secrecy the ratchet
|
|
428
|
+
// buys, pinned in chat-controller.test.js. Queueing would therefore store
|
|
429
|
+
// ciphertext on the relay that provably nobody can open: all of the cost and
|
|
430
|
+
// the liability of holding it, and none of the delivery.
|
|
431
|
+
//
|
|
432
|
+
// The unicast queue survives because an envelope addressed to a peer is
|
|
433
|
+
// still openable when they come back with the same key. That is not true
|
|
434
|
+
// here, and it is the difference that decides it.
|
|
435
|
+
delete msg.from;
|
|
436
|
+
this.#sessionManager.broadcastToRoom(validation.room, msg, ws.sessionId);
|
|
437
|
+
// Never log the sender or the room membership this reveals — only that a
|
|
438
|
+
// fan-out happened. Correlating would defeat what sealed sender buys.
|
|
439
|
+
log.debug('group message fanned out');
|
|
440
|
+
}
|
|
441
|
+
|
|
346
442
|
#handleKeyUpdate(ws, msg) {
|
|
347
443
|
if (!ws.hasJoined || !ws.sessionId) {
|
|
348
444
|
ws.send(JSON.stringify(createError(ERR.INVALID_MESSAGE, 'JOIN first')));
|
|
@@ -971,6 +1067,10 @@ export class SecureWSServer {
|
|
|
971
1067
|
ws.isAlive = false;
|
|
972
1068
|
ws.ping();
|
|
973
1069
|
}
|
|
1070
|
+
// Forget quiet sources. Without this the rate limiter's map is keyed by
|
|
1071
|
+
// everything that ever connected, which is a slow leak an attacker can
|
|
1072
|
+
// drive on purpose.
|
|
1073
|
+
this.#rateLimiter.prune();
|
|
974
1074
|
}, HEARTBEAT_INTERVAL_MS);
|
|
975
1075
|
}
|
|
976
1076
|
|
package/src/server/config.js
CHANGED
|
@@ -8,6 +8,9 @@ import {
|
|
|
8
8
|
MAX_CONNECTIONS_TOTAL,
|
|
9
9
|
MAX_CONNECTIONS_PER_IP,
|
|
10
10
|
MESSAGE_RATE_LIMIT_PER_SECOND,
|
|
11
|
+
CONNECTION_RATE_PER_MINUTE,
|
|
12
|
+
MAX_BYTES_PER_SECOND,
|
|
13
|
+
MAX_BYTES_BURST,
|
|
11
14
|
} from '../shared/constants.js';
|
|
12
15
|
|
|
13
16
|
const DEFAULTS = {
|
|
@@ -18,6 +21,12 @@ const DEFAULTS = {
|
|
|
18
21
|
// single client could exhaust the room table on its own.
|
|
19
22
|
maxRoomsTotal: 500,
|
|
20
23
|
maxRoomsPerSession: 10,
|
|
24
|
+
// How fast one source may open connections, as opposed to how many it may
|
|
25
|
+
// hold at once. The concurrency cap above never trips against churn.
|
|
26
|
+
connectionRatePerMinute: CONNECTION_RATE_PER_MINUTE,
|
|
27
|
+
// Bytes, not messages — see ConnectionGuard.
|
|
28
|
+
maxBytesPerSecond: MAX_BYTES_PER_SECOND,
|
|
29
|
+
maxBytesBurst: MAX_BYTES_BURST,
|
|
21
30
|
};
|
|
22
31
|
|
|
23
32
|
function positiveInt(raw, fallback) {
|
|
@@ -73,6 +82,12 @@ export function parseServerConfig(env = process.env) {
|
|
|
73
82
|
),
|
|
74
83
|
maxRoomsTotal: positiveInt(env.MAX_ROOMS_TOTAL, DEFAULTS.maxRoomsTotal),
|
|
75
84
|
maxRoomsPerSession: positiveInt(env.MAX_ROOMS_PER_SESSION, DEFAULTS.maxRoomsPerSession),
|
|
85
|
+
connectionRatePerMinute: positiveInt(
|
|
86
|
+
env.CONNECTION_RATE_PER_MINUTE,
|
|
87
|
+
DEFAULTS.connectionRatePerMinute,
|
|
88
|
+
),
|
|
89
|
+
maxBytesPerSecond: positiveInt(env.MAX_BYTES_PER_SECOND, DEFAULTS.maxBytesPerSecond),
|
|
90
|
+
maxBytesBurst: positiveInt(env.MAX_BYTES_BURST, DEFAULTS.maxBytesBurst),
|
|
76
91
|
// Behind a reverse proxy every connection arrives from the proxy, so the
|
|
77
92
|
// per-IP cap would apply to the proxy itself and protect nobody. Only
|
|
78
93
|
// trust the forwarded header when the operator says there IS a proxy —
|
package/src/server/index.js
CHANGED
|
@@ -11,6 +11,7 @@ import { OfflineQueue } from './OfflineQueue.js';
|
|
|
11
11
|
import { SecureWSServer } from './WebSocketServer.js';
|
|
12
12
|
import { startPresenceServer } from './presence.js';
|
|
13
13
|
import { loadOrGenerateCerts } from './CertManager.js';
|
|
14
|
+
import { preflight, formatPreflight } from './preflight.js';
|
|
14
15
|
|
|
15
16
|
const log = createLogger('server');
|
|
16
17
|
|
|
@@ -53,6 +54,23 @@ function getLocalIPs() {
|
|
|
53
54
|
return { ips, inDocker };
|
|
54
55
|
}
|
|
55
56
|
|
|
57
|
+
// ── Configuration check ────────────────────────────────────────
|
|
58
|
+
// `--check` validates and exits without opening a socket, so it is safe to run
|
|
59
|
+
// against a live host. The same findings are printed at every startup too,
|
|
60
|
+
// because a warning you have to ask for is a warning nobody sees.
|
|
61
|
+
const findings = preflight();
|
|
62
|
+
if (process.argv.includes('--check')) {
|
|
63
|
+
for (const line of formatPreflight(findings)) console.log(line);
|
|
64
|
+
// Non-zero on an error so a deploy script can gate on it. Warnings do not
|
|
65
|
+
// fail, or the check becomes something people learn to ignore.
|
|
66
|
+
process.exit(findings.some((f) => f.level === 'error') ? 1 : 0);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
for (const finding of findings) {
|
|
70
|
+
if (finding.level === 'error') log.error(finding.text);
|
|
71
|
+
else log.warn(finding.text);
|
|
72
|
+
}
|
|
73
|
+
|
|
56
74
|
// ── Bootstrap ──────────────────────────────────────────────────
|
|
57
75
|
const port = parseInt(process.env.PORT, 10) || SERVER_PORT;
|
|
58
76
|
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { parseServerConfig } from './config.js';
|
|
2
|
+
import { CONNECTION_RATE_PER_MINUTE, MAX_CONNECTIONS_PER_IP } from '../shared/constants.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* What is wrong with this relay's configuration, before it starts serving.
|
|
6
|
+
*
|
|
7
|
+
* The deploy guide already explains every footgun here. That is the problem: a
|
|
8
|
+
* document is read once, by whoever set the machine up, and never again — while
|
|
9
|
+
* the misconfiguration lasts as long as the machine does. The worst of them,
|
|
10
|
+
* `TRUST_PROXY` left off behind a reverse proxy, is invisible from the outside:
|
|
11
|
+
* everything works, the per-IP cap and the banlist simply apply to the proxy
|
|
12
|
+
* and protect nobody. Nothing breaks, so nobody looks.
|
|
13
|
+
*
|
|
14
|
+
* Pure and exported so `--check` and startup share one implementation and
|
|
15
|
+
* cannot disagree about what counts as a problem.
|
|
16
|
+
*
|
|
17
|
+
* @returns {Array<{level: 'error'|'warn', text: string}>}
|
|
18
|
+
*/
|
|
19
|
+
export function preflight(env = process.env) {
|
|
20
|
+
const config = parseServerConfig(env);
|
|
21
|
+
const findings = [];
|
|
22
|
+
|
|
23
|
+
const error = (text) => findings.push({ level: 'error', text });
|
|
24
|
+
const warn = (text) => findings.push({ level: 'warn', text });
|
|
25
|
+
|
|
26
|
+
// Behind a proxy, every connection arrives from the proxy. The per-IP limits
|
|
27
|
+
// then bound the proxy's own traffic, which is all of it.
|
|
28
|
+
const behindProxy = Boolean(env.CIPHERMESH_DOMAIN || env.BEHIND_PROXY);
|
|
29
|
+
if (behindProxy && !config.trustProxy) {
|
|
30
|
+
error(
|
|
31
|
+
'TRUST_PROXY is off but this looks like it is behind a reverse proxy. ' +
|
|
32
|
+
'Every connection will appear to come from the proxy, so the per-IP cap, ' +
|
|
33
|
+
'the connection rate limit and the banlist protect nobody.',
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// And the mirror image, which is worse: trusting a header anyone can set.
|
|
38
|
+
if (config.trustProxy && !behindProxy) {
|
|
39
|
+
warn(
|
|
40
|
+
'TRUST_PROXY is on. If this relay is reachable directly, a client can forge ' +
|
|
41
|
+
'X-Forwarded-For and walk straight past the per-IP cap and the banlist.',
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const isPublic = behindProxy || env.PUBLIC_RELAY === 'true';
|
|
46
|
+
if (isPublic) {
|
|
47
|
+
// The defaults are generous because the project started on LANs, where the
|
|
48
|
+
// people connecting are in the same building as the machine.
|
|
49
|
+
if (config.maxConnectionsPerIp >= MAX_CONNECTIONS_PER_IP) {
|
|
50
|
+
warn(
|
|
51
|
+
`MAX_CONNECTIONS_PER_IP is ${config.maxConnectionsPerIp}, the LAN default. ` +
|
|
52
|
+
'On the open internet 3-5 is plenty and costs real users nothing.',
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
if (config.connectionRatePerMinute >= CONNECTION_RATE_PER_MINUTE) {
|
|
56
|
+
warn(
|
|
57
|
+
`CONNECTION_RATE_PER_MINUTE is ${config.connectionRatePerMinute}, the LAN default. ` +
|
|
58
|
+
'10-20 still leaves room for a reconnect storm after a restart.',
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
if (env.TLS === 'false' && !behindProxy) {
|
|
62
|
+
error('TLS is disabled and nothing appears to be terminating it in front.');
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (env.LOG_LEVEL?.toLowerCase() === 'debug') {
|
|
67
|
+
warn(
|
|
68
|
+
'LOG_LEVEL is debug. Nothing logs message content at any level — there is a ' +
|
|
69
|
+
'test that proves it — but debug writes more metadata to disk than a ' +
|
|
70
|
+
'running relay needs to.',
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// A banlist file that is not there is almost always a mount that is not
|
|
75
|
+
// there, and it fails open: everyone gets in and nothing says otherwise.
|
|
76
|
+
if (env.BANNED_IPS_FILE && config.bannedIps.size === 0) {
|
|
77
|
+
warn(
|
|
78
|
+
'BANNED_IPS_FILE is set but no addresses were loaded from it. ' +
|
|
79
|
+
'If the file is missing or unreadable the banlist is simply empty.',
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (env.MOTD_FILE && !config.motd) {
|
|
84
|
+
warn('MOTD_FILE is set but nothing was read from it.');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return findings;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Human-readable lines for `--check`. */
|
|
91
|
+
export function formatPreflight(findings) {
|
|
92
|
+
if (findings.length === 0) {
|
|
93
|
+
return ['Configuration looks fine.'];
|
|
94
|
+
}
|
|
95
|
+
return findings.map(({ level, text }) => `${level === 'error' ? 'ERROR' : 'warn '} ${text}`);
|
|
96
|
+
}
|