ciphermesh 2.9.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.
- package/CHANGELOG.md +41 -0
- package/README.md +4 -2
- package/README.pt-BR.md +2 -2
- package/docs/PLUGINS.md +53 -14
- package/docs/commands.json +640 -0
- package/docs/demo.svg +2 -2
- package/package.json +3 -2
- package/src/client/ChatController.js +14 -10
- package/src/client/UI.js +4 -1
- package/src/client/index.js +6 -1
- package/src/p2p/P2PChatController.js +14 -10
- package/src/p2p/index.js +6 -1
- package/src/server/ConnectionGuard.js +158 -0
- package/src/server/WebSocketServer.js +35 -0
- 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 +15 -0
- package/src/shared/pluginCommand.js +106 -0
|
@@ -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(
|
|
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
|
-
|
|
2607
|
-
|
|
2608
|
-
|
|
2609
|
-
|
|
2610
|
-
|
|
2611
|
-
|
|
2612
|
-
|
|
2613
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/client/index.js
CHANGED
|
@@ -210,7 +210,12 @@ await bootSequence([
|
|
|
210
210
|
'XSalsa20-Poly1305 cipher',
|
|
211
211
|
'Double Ratchet — forward secrecy',
|
|
212
212
|
'TOFU trust store',
|
|
213
|
-
{
|
|
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;
|
|
@@ -1388,7 +1389,9 @@ export class P2PChatController {
|
|
|
1388
1389
|
this.#ui.addInfoMessage(
|
|
1389
1390
|
' /panic [yes] - Wipe EVERYTHING from disk and exit (duress)',
|
|
1390
1391
|
);
|
|
1391
|
-
this.#ui.addInfoMessage(
|
|
1392
|
+
this.#ui.addInfoMessage(
|
|
1393
|
+
' /plugins [allow <file>] - List plugins; approve one before it runs',
|
|
1394
|
+
);
|
|
1392
1395
|
this.#ui.addInfoMessage(' /quit - Exit the chat');
|
|
1393
1396
|
break;
|
|
1394
1397
|
|
|
@@ -2058,16 +2061,17 @@ export class P2PChatController {
|
|
|
2058
2061
|
break;
|
|
2059
2062
|
|
|
2060
2063
|
case '/plugins': {
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
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
|
+
}
|
|
2069
2073
|
}
|
|
2070
|
-
}
|
|
2074
|
+
});
|
|
2071
2075
|
break;
|
|
2072
2076
|
}
|
|
2073
2077
|
|
package/src/p2p/index.js
CHANGED
|
@@ -165,7 +165,12 @@ await bootSequence([
|
|
|
165
165
|
}
|
|
166
166
|
},
|
|
167
167
|
},
|
|
168
|
-
{
|
|
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
|
|
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
|
+
}
|