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.
@@ -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
 
@@ -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 —
@@ -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
+ }