ciphermesh 2.8.0 → 2.10.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.
@@ -61,6 +61,7 @@ import {
61
61
  } from '../shared/dnd.js';
62
62
  import { saveLastSession } from '../shared/lastSession.js';
63
63
  import { diagnose, formatDiagnosis } from '../shared/doctor.js';
64
+ import { pluginsCommand } from '../shared/pluginCommand.js';
64
65
  import { COMMANDS } from './UI.js';
65
66
 
66
67
  const TYPING_SEND_INTERVAL = 2000; // debounce: max 1 typing event per 2s
@@ -1518,7 +1519,9 @@ export class ChatController {
1518
1519
  this.#ui.addInfoMessage(
1519
1520
  ' /panic [yes] - Wipe EVERYTHING from disk and exit (duress)',
1520
1521
  );
1521
- this.#ui.addInfoMessage(' /plugins - List loaded plugins');
1522
+ this.#ui.addInfoMessage(
1523
+ ' /plugins [allow <file>] - List plugins; approve one before it runs',
1524
+ );
1522
1525
  this.#ui.addInfoMessage(' /quit - Leave the chat');
1523
1526
  this.#ui.addInfoMessage('Tip: PageUp/PageDown scroll the chat history');
1524
1527
  this.#ui.addInfoMessage('Tip: shortcodes like :fire: become emoji — Tab autocompletes');
@@ -2603,16 +2606,17 @@ export class ChatController {
2603
2606
  }
2604
2607
 
2605
2608
  case '/plugins': {
2606
- if (!this.#pluginManager || this.#pluginManager.pluginCount === 0) {
2607
- this.#ui.addInfoMessage('No plugins loaded. Place .js files in ~/.ciphermesh/plugins/');
2608
- } else {
2609
- const names = this.#pluginManager.getPluginNames();
2610
- this.#ui.addInfoMessage(`Plugins loaded (${names.length}): ${names.join(', ')}`);
2611
- const cmds = this.#pluginManager.getCommandNames();
2612
- if (cmds.length > 0) {
2613
- this.#ui.addInfoMessage(`Commands: ${cmds.join(', ')}`);
2609
+ pluginsCommand(this.#pluginManager, parts.slice(1)).then((lines) => {
2610
+ for (const { kind, text } of lines) {
2611
+ if (kind === 'error') {
2612
+ this.#ui.addErrorMessage(text);
2613
+ } else if (kind === 'system') {
2614
+ this.#ui.addSystemMessage(text);
2615
+ } else {
2616
+ this.#ui.addInfoMessage(text);
2617
+ }
2614
2618
  }
2615
- }
2619
+ });
2616
2620
  break;
2617
2621
  }
2618
2622
 
package/src/client/UI.js CHANGED
@@ -1798,7 +1798,10 @@ export class UI extends EventEmitter {
1798
1798
  this.#lines.push(line);
1799
1799
  this.#chatLog.log(line);
1800
1800
  this.#screen.render();
1801
- this.#lastSender = isSelfNow ? 'self' : nickname;
1801
+ // The sentinel is written as an escape, not typed as a raw byte: a bare
1802
+ // NUL anywhere in the source makes this entire file count as binary, and
1803
+ // a binary file is skipped by grep and shown without a diff on GitHub.
1804
+ this.#lastSender = isSelfNow ? '\u0000self' : nickname;
1802
1805
  if (!isSelfNow) {
1803
1806
  this.#noteIncoming(mentioned || isDM);
1804
1807
  }
@@ -210,7 +210,12 @@ await bootSequence([
210
210
  'XSalsa20-Poly1305 cipher',
211
211
  'Double Ratchet — forward secrecy',
212
212
  'TOFU trust store',
213
- { label: 'Loading plugins', task: () => pluginManager.loadAll() },
213
+ {
214
+ label: 'Loading plugins',
215
+ // Only what the user approved. An unapproved file is left alone —
216
+ // importing it would already be running it.
217
+ task: () => pluginManager.loadAll(undefined, config.pluginsAllowed),
218
+ },
214
219
  {
215
220
  label: 'Connecting to relay',
216
221
  timeoutMs: 3500,
@@ -41,6 +41,7 @@ import {
41
41
  } from '../shared/dnd.js';
42
42
  import { trustBadge } from '../shared/trust.js';
43
43
  import { tipAt, TIPS } from '../shared/tips.js';
44
+ import { pluginsCommand } from '../shared/pluginCommand.js';
44
45
  import { COMMANDS } from '../client/UI.js';
45
46
 
46
47
  const TYPING_SEND_INTERVAL = 2000;
@@ -328,6 +329,17 @@ export class P2PChatController {
328
329
 
329
330
  // ── Handle message from peer ───────────────────────────────────
330
331
  #onPeerMessage(fromNickname, msg) {
332
+ // Blocked: drop it before anything else, so it covers direct and group
333
+ // messages alike. Nothing goes back, so the sender cannot tell.
334
+ //
335
+ // This matters more here than on a relay: P2P has no room owners at all,
336
+ // which means no /kick, no /ban and no moderation of any kind. Refusing to
337
+ // listen is the only protection there is.
338
+ const fromKey = this.#handshake.getPeerPublicKey(fromNickname);
339
+ if (fromKey && this.#trustStore.isBlocked(fromKey)) {
340
+ return;
341
+ }
342
+
331
343
  if (msg.type === 'p2p_group') {
332
344
  this.#onGroupMessage(fromNickname, msg);
333
345
  return;
@@ -1369,12 +1381,17 @@ export class P2PChatController {
1369
1381
  this.#ui.addInfoMessage(' /pins - List pinned messages');
1370
1382
  this.#ui.addInfoMessage(' /deniable [on|off] - Deniable mode (symmetric crypto)');
1371
1383
  this.#ui.addInfoMessage(' /cover [on|constant|off] - Cover traffic (masks timing/volume)');
1384
+ this.#ui.addInfoMessage(' /block <nick> - Stop seeing someone, just for you');
1385
+ this.#ui.addInfoMessage(' /unblock <nick> - Undo a block');
1386
+ this.#ui.addInfoMessage(' /blocklist - Who you have blocked');
1372
1387
  this.#ui.addInfoMessage(' /kick, /mute, /ban - (server mode only)');
1373
1388
  this.#ui.addInfoMessage(' /theme [name] - Nick color theme');
1374
1389
  this.#ui.addInfoMessage(
1375
1390
  ' /panic [yes] - Wipe EVERYTHING from disk and exit (duress)',
1376
1391
  );
1377
- this.#ui.addInfoMessage(' /plugins - List loaded plugins');
1392
+ this.#ui.addInfoMessage(
1393
+ ' /plugins [allow <file>] - List plugins; approve one before it runs',
1394
+ );
1378
1395
  this.#ui.addInfoMessage(' /quit - Exit the chat');
1379
1396
  break;
1380
1397
 
@@ -1987,24 +2004,74 @@ export class P2PChatController {
1987
2004
  break;
1988
2005
  }
1989
2006
 
2007
+ // Moderation needs a room owner, and P2P has none — there is nobody with
2008
+ // authority over anybody. Blocking needs no authority at all, which is
2009
+ // exactly why it works here when nothing else does.
2010
+ case '/block': {
2011
+ const blockNick = parts[1];
2012
+ if (!blockNick) {
2013
+ this.#ui.addErrorMessage('Usage: /block <nick>');
2014
+ break;
2015
+ }
2016
+ if (this.#trustStore.blockPeer(blockNick)) {
2017
+ this.#ui.addInfoMessage(
2018
+ `Blocked ${blockNick}. You will not see their messages; they are not told.`,
2019
+ );
2020
+ } else {
2021
+ this.#ui.addErrorMessage(`No record of "${blockNick}" — you can only block someone seen`);
2022
+ }
2023
+ break;
2024
+ }
2025
+
2026
+ case '/unblock': {
2027
+ const unblockNick = parts[1];
2028
+ if (!unblockNick) {
2029
+ this.#ui.addErrorMessage('Usage: /unblock <nick>');
2030
+ break;
2031
+ }
2032
+ if (this.#trustStore.unblockPeer(unblockNick)) {
2033
+ this.#ui.addInfoMessage(`Unblocked ${unblockNick}`);
2034
+ } else {
2035
+ this.#ui.addErrorMessage(`"${unblockNick}" was not blocked`);
2036
+ }
2037
+ break;
2038
+ }
2039
+
2040
+ case '/blocklist': {
2041
+ const blocked = this.#trustStore.listBlocked();
2042
+ if (blocked.length === 0) {
2043
+ this.#ui.addInfoMessage('Nobody blocked');
2044
+ break;
2045
+ }
2046
+ this.#ui.addInfoMessage(`Blocked (${blocked.length}):`);
2047
+ for (const entry of blocked) {
2048
+ const label = entry.alias ? `${entry.nickname} (${entry.alias})` : entry.nickname;
2049
+ this.#ui.addInfoMessage(` ${label} ${entry.fingerprint}`);
2050
+ }
2051
+ break;
2052
+ }
2053
+
1990
2054
  case '/kick':
1991
2055
  case '/mute':
1992
2056
  case '/ban':
1993
2057
  case '/owner':
1994
- this.#ui.addErrorMessage('Moderation not available in P2P mode');
2058
+ this.#ui.addErrorMessage(
2059
+ 'Moderation needs a room owner, and P2P has none. Use /block to stop seeing someone.',
2060
+ );
1995
2061
  break;
1996
2062
 
1997
2063
  case '/plugins': {
1998
- if (!this.#pluginManager || this.#pluginManager.pluginCount === 0) {
1999
- this.#ui.addInfoMessage('No plugins loaded. Put .js files in ~/.ciphermesh/plugins/');
2000
- } else {
2001
- const names = this.#pluginManager.getPluginNames();
2002
- this.#ui.addInfoMessage(`Plugins loaded (${names.length}): ${names.join(', ')}`);
2003
- const cmds = this.#pluginManager.getCommandNames();
2004
- if (cmds.length > 0) {
2005
- this.#ui.addInfoMessage(`Commands: ${cmds.join(', ')}`);
2064
+ pluginsCommand(this.#pluginManager, parts.slice(1)).then((lines) => {
2065
+ for (const { kind, text } of lines) {
2066
+ if (kind === 'error') {
2067
+ this.#ui.addErrorMessage(text);
2068
+ } else if (kind === 'system') {
2069
+ this.#ui.addSystemMessage(text);
2070
+ } else {
2071
+ this.#ui.addInfoMessage(text);
2072
+ }
2006
2073
  }
2007
- }
2074
+ });
2008
2075
  break;
2009
2076
  }
2010
2077
 
package/src/p2p/index.js CHANGED
@@ -165,7 +165,12 @@ await bootSequence([
165
165
  }
166
166
  },
167
167
  },
168
- { label: 'Loading plugins', task: () => pluginManager.loadAll() },
168
+ {
169
+ label: 'Loading plugins',
170
+ // Only what the user approved. An unapproved file is left alone —
171
+ // importing it would already be running it.
172
+ task: () => pluginManager.loadAll(undefined, config.pluginsAllowed),
173
+ },
169
174
  ]);
170
175
 
171
176
  // ── Initialize components ──────────────────────────────────────
@@ -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
+ }
@@ -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,
@@ -54,6 +55,7 @@ export class SecureWSServer {
54
55
  #heartbeatInterval;
55
56
  #connectionsByIp;
56
57
  #config;
58
+ #rateLimiter;
57
59
 
58
60
  constructor(sessionManager, messageRouter, offlineQueue, port, tlsOptions, config = null) {
59
61
  this.#sessionManager = sessionManager;
@@ -61,6 +63,7 @@ export class SecureWSServer {
61
63
  this.#offlineQueue = offlineQueue;
62
64
  this.#connectionsByIp = new Map();
63
65
  this.#config = config || parseServerConfig();
66
+ this.#rateLimiter = new ConnectionRateLimiter(this.#config.connectionRatePerMinute);
64
67
 
65
68
  if (tlsOptions) {
66
69
  this.#httpsServer = createHttpsServer(tlsOptions);
@@ -99,6 +102,21 @@ export class SecureWSServer {
99
102
  return;
100
103
  }
101
104
 
105
+ // How FAST this source is connecting, before how many it holds. Churn —
106
+ // connect, handshake, disconnect, repeat — never trips the concurrency cap
107
+ // below, and each attempt costs an X25519 and an ML-KEM-768 operation. The
108
+ // socket is already upgraded by the time we get here, but closing now is
109
+ // what matters: the expensive work happens at JOIN, and this never reaches
110
+ // it.
111
+ const rate = this.#rateLimiter.check(ip);
112
+ if (!rate.allowed) {
113
+ log.warn(`Connecting too fast from ${ip}, refusing for ${rate.retryAfterMs}ms`);
114
+ // Tell them how long, so a well-behaved client backs off instead of
115
+ // hammering and extending its own ban.
116
+ ws.close(1013, `Try again in ${Math.ceil(rate.retryAfterMs / 1000)}s`);
117
+ return;
118
+ }
119
+
102
120
  // Global connection cap (the new socket is already counted in clients).
103
121
  if (this.#wss.clients.size > this.#config.maxConnectionsTotal) {
104
122
  ws.close(1013, 'Server full');
@@ -119,6 +137,8 @@ export class SecureWSServer {
119
137
  ws.hasJoined = false;
120
138
  ws.msgWindowStart = Date.now();
121
139
  ws.msgCount = 0;
140
+ // Bytes, not messages. Lives on the socket, so it goes away with it.
141
+ ws.byteBudget = new ByteBudget(this.#config.maxBytesPerSecond, this.#config.maxBytesBurst);
122
142
 
123
143
  // Drop sockets that connect but never JOIN (slowloris / resource hold).
124
144
  ws.joinTimer = setTimeout(() => {
@@ -182,6 +202,17 @@ export class SecureWSServer {
182
202
  return;
183
203
  }
184
204
 
205
+ // Charged before parsing: the bytes have already been received and buffered
206
+ // by this point, so refusing to spend effort on them is the only saving
207
+ // left, and a sender that ignores the warning is disconnected rather than
208
+ // allowed to keep paying nothing.
209
+ if (!ws.byteBudget.allow(data.length ?? 0)) {
210
+ log.warn(`Byte budget exhausted for ${ws.clientIp}, closing`);
211
+ ws.send(JSON.stringify(createError(ERR.RATE_LIMITED, 'Sending too much, too fast')));
212
+ ws.close(1008, 'Byte budget exhausted');
213
+ return;
214
+ }
215
+
185
216
  const raw = data.toString('utf-8');
186
217
  const { valid, error, msg } = parseMessage(raw);
187
218
 
@@ -971,6 +1002,10 @@ export class SecureWSServer {
971
1002
  ws.isAlive = false;
972
1003
  ws.ping();
973
1004
  }
1005
+ // Forget quiet sources. Without this the rate limiter's map is keyed by
1006
+ // everything that ever connected, which is a slow leak an attacker can
1007
+ // drive on purpose.
1008
+ this.#rateLimiter.prune();
974
1009
  }, HEARTBEAT_INTERVAL_MS);
975
1010
  }
976
1011
 
@@ -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
+ }