ciphermesh 2.5.0 → 2.6.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/README.md +38 -10
- package/README.pt-BR.md +34 -7
- package/package.json +1 -1
- package/src/client/ChatController.js +9 -0
- package/src/server/SessionManager.js +11 -0
- package/src/server/WebSocketServer.js +58 -9
- package/src/server/config.js +112 -0
- package/src/shared/doctor.js +36 -11
package/README.md
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
[](package.json)
|
|
17
17
|
[](docs/ARCHITECTURE.md)
|
|
18
18
|
|
|
19
|
-
**[🇧🇷 Leia em Português](README.pt-BR.md)** · [Setup Guide](docs/SETUP.md) · [Architecture](docs/ARCHITECTURE.md) · [Security Policy](SECURITY.md)
|
|
19
|
+
**[🇧🇷 Leia em Português](README.pt-BR.md)** · [Setup Guide](docs/SETUP.md) · [Architecture](docs/ARCHITECTURE.md) · [Security Policy](SECURITY.md) · [Hub Terms](TERMS.md)
|
|
20
20
|
|
|
21
21
|
<img src="docs/demo.svg" alt="CipherMesh terminal demo" width="720">
|
|
22
22
|
|
|
@@ -55,17 +55,35 @@ forwarding, survives CGNAT).
|
|
|
55
55
|
| 🎞️ | **Animated UI** | Splash intro, reconnect spinner, live transfer bars (shimmer + ETA), a lock-closing handshake on connect, and a pulsing "new messages ↓" pill |
|
|
56
56
|
| 👻 | **Deniable & ephemeral** | Symmetric-crypto deniable mode; ephemeral messages *burn away* char-by-char when they expire |
|
|
57
57
|
| 🔒 | **Private rooms** | `/create <room> <password>` — zero-knowledge: the password never leaves your machine (Argon2id → Ed25519 challenge-response) and room content gets an extra symmetric layer the relay can't fake its way into |
|
|
58
|
-
|
|
|
58
|
+
| 🗂️ | **Multi-room buffers** | Be in several rooms at once — **Alt+1..9** switches, unread badges per room. Which room a message belongs to travels *inside* the encrypted payload: the relay never learns it |
|
|
59
|
+
| 🩺 | **It explains itself** | `/doctor` diagnoses a failing connection layer by layer — address, DNS, TCP, TLS, protocol — and tells you what to do about each failure |
|
|
60
|
+
| 🔐 | **Screen lock** | `/lock` and `/autolock` put the session behind your passphrase when you step away; `/panic` is still there for the worse moment |
|
|
61
|
+
| 🛰️ | **Serverless P2P mode** | mDNS peer discovery on the LAN — no relay at all, and nearly the same command set |
|
|
59
62
|
| 🧩 | **Plugins** | Drop a JS file in `~/.ciphermesh/plugins` and get new slash-commands — `/roll` and `/poll` examples included ([Plugin API](docs/PLUGINS.md)) |
|
|
60
63
|
|
|
61
64
|
## 🚀 Quick start
|
|
62
65
|
|
|
63
|
-
|
|
66
|
+
**Talk to someone in under a minute** — no install, no account, no server of
|
|
67
|
+
your own:
|
|
64
68
|
|
|
65
69
|
```bash
|
|
66
|
-
npx ciphermesh
|
|
67
|
-
|
|
68
|
-
|
|
70
|
+
npx ciphermesh@latest
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
At the `Server` prompt type **`ciphermesh.de`** — a public relay anyone can
|
|
74
|
+
use. Share a room name with whoever you want to talk to (`/join ourroom`) and
|
|
75
|
+
you are chatting end-to-end encrypted.
|
|
76
|
+
|
|
77
|
+
> The hub is run as a personal project under these **[terms](TERMS.md)**. It is
|
|
78
|
+
> a zero-knowledge relay: nobody operating it can read your messages — that is
|
|
79
|
+
> a property of the software, not a promise. Prefer your own? Every command
|
|
80
|
+
> below works the same on a relay you host.
|
|
81
|
+
|
|
82
|
+
Other modes:
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
npx ciphermesh server # run your own relay
|
|
86
|
+
npx ciphermesh p2p # serverless, mDNS peer discovery on the LAN
|
|
69
87
|
```
|
|
70
88
|
|
|
71
89
|
macOS/Linux with Homebrew (see [`Formula/ciphermesh.rb`](Formula/ciphermesh.rb)):
|
|
@@ -285,11 +303,19 @@ All keys are optional (unknown keys are ignored):
|
|
|
285
303
|
active chatting from idle. Decoys are dropped silently by the receiver.
|
|
286
304
|
- **Anti-replay** via monotonic nonces, **key rotation** every hour with a
|
|
287
305
|
grace window, **secure memory wipe** (`sodium_memzero`) after use.
|
|
288
|
-
- **Duress wipe** (`/panic
|
|
306
|
+
- **Duress wipe** (`/panic yes`): overwrites and deletes every on-disk secret
|
|
289
307
|
(session state, history, trust store, audit log), zeroes the in-memory keys,
|
|
290
308
|
and exits without saving — for a lost or seized device.
|
|
291
309
|
- Session state and local history are encrypted at rest with
|
|
292
310
|
**Argon2id + XSalsa20-Poly1305** — no passphrase, no persistence.
|
|
311
|
+
- **Hybrid post-quantum**: each pairwise session mixes an ML-KEM-768 secret
|
|
312
|
+
into the ratchet root at setup, so recorded traffic stays unreadable to a
|
|
313
|
+
future quantum adversary. It is *added* to X25519, never replaces it —
|
|
314
|
+
security is at least the classical one. `/trustlist` shows `[PQ]`.
|
|
315
|
+
- **Private rooms** never send the password anywhere: it derives an Ed25519
|
|
316
|
+
key (Argon2id) that answers a server challenge, and the room content carries
|
|
317
|
+
an extra symmetric layer — a malicious relay that let someone in without
|
|
318
|
+
verifying still could not read a word.
|
|
293
319
|
- Threat analysis and protocol details: [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
|
|
294
320
|
Found something? See [SECURITY.md](SECURITY.md).
|
|
295
321
|
|
|
@@ -297,12 +323,14 @@ All keys are optional (unknown keys are ignored):
|
|
|
297
323
|
|
|
298
324
|
```bash
|
|
299
325
|
npm run server:dev # relay with auto-reload
|
|
300
|
-
npm test #
|
|
326
|
+
npm test # 418 tests (crypto, ratchet, fuzz, controllers, transfers…)
|
|
301
327
|
npm run validate # lint + prettier + tests — what the CI runs
|
|
302
328
|
```
|
|
303
329
|
|
|
304
|
-
CI runs on every push/PR (Node 20 & 22)
|
|
305
|
-
|
|
330
|
+
CI runs on every push/PR (Node 20 & 22), plus CodeQL and a dependency audit.
|
|
331
|
+
A `v*` tag runs the full suite and then publishes: npm (via OIDC Trusted
|
|
332
|
+
Publishing — no tokens), a GitHub Release, the Docker image on GHCR, and
|
|
333
|
+
standalone relay binaries for macOS and Linux.
|
|
306
334
|
|
|
307
335
|
## 📄 License
|
|
308
336
|
|
package/README.pt-BR.md
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
[](package.json)
|
|
17
17
|
[](docs/ARCHITECTURE.md)
|
|
18
18
|
|
|
19
|
-
**[🇺🇸 Read in English](README.md)** · [Guia de Setup](docs/SETUP.md) · [Arquitetura](docs/ARCHITECTURE.md) · [Política de Segurança](SECURITY.md)
|
|
19
|
+
**[🇺🇸 Read in English](README.md)** · [Guia de Setup](docs/SETUP.md) · [Arquitetura](docs/ARCHITECTURE.md) · [Política de Segurança](SECURITY.md) · [Termos do Hub](TERMS.md)
|
|
20
20
|
|
|
21
21
|
<img src="docs/demo.svg" alt="Demo do CipherMesh no terminal" width="720">
|
|
22
22
|
|
|
@@ -55,17 +55,36 @@ forwarding, imune a CGNAT).
|
|
|
55
55
|
| 🎞️ | **Interface animada** | Splash na abertura, spinner de reconexão, barra de transferência viva (shimmer + ETA), cadeado fechando no handshake e um selo pulsante "novas mensagens ↓" |
|
|
56
56
|
| 👻 | **Deniable e efêmeras** | Modo de negação plausível (crypto simétrica); mensagens efêmeras *queimam* caractere a caractere ao expirar |
|
|
57
57
|
| 🔒 | **Salas privadas** | `/create <sala> <senha>` — zero-knowledge: a senha nunca sai da sua máquina (Argon2id → challenge-response Ed25519) e o conteúdo da sala ganha uma camada simétrica extra que nem um relay malicioso atravessa |
|
|
58
|
-
|
|
|
58
|
+
| 🗂️ | **Buffers multi-sala** | Fique em várias salas ao mesmo tempo — **Alt+1..9** alterna, com não-lidas por sala. A qual sala cada mensagem pertence viaja *dentro* do payload cifrado: o relay nunca fica sabendo |
|
|
59
|
+
| 🩺 | **Ele se explica sozinho** | `/doctor` diagnostica uma conexão que falha camada por camada — endereço, DNS, TCP, TLS, protocolo — e diz o que fazer em cada falha |
|
|
60
|
+
| 🔐 | **Trava de tela** | `/lock` e `/autolock` põem a sessão atrás da sua passphrase quando você sai da frente; o `/panic` continua ali para o pior momento |
|
|
61
|
+
| 🛰️ | **Modo P2P sem servidor** | Descoberta de peers via mDNS na LAN — sem relay nenhum, e com quase o mesmo conjunto de comandos |
|
|
59
62
|
| 🧩 | **Plugins** | Solta um arquivo JS em `~/.ciphermesh/plugins` e ganha comandos novos — exemplos `/roll` e `/poll` inclusos ([API de plugins](docs/PLUGINS.md)) |
|
|
60
63
|
|
|
61
64
|
## 🚀 Começando
|
|
62
65
|
|
|
63
|
-
|
|
66
|
+
**Converse com alguém em menos de um minuto** — sem instalar, sem conta, sem
|
|
67
|
+
servidor próprio:
|
|
64
68
|
|
|
65
69
|
```bash
|
|
66
|
-
npx ciphermesh
|
|
67
|
-
|
|
68
|
-
|
|
70
|
+
npx ciphermesh@latest
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
No prompt `Server`, digite **`ciphermesh.de`** — um relay público que qualquer
|
|
74
|
+
pessoa pode usar. Combine um nome de sala com quem você quer conversar
|
|
75
|
+
(`/join nossasala`) e pronto: vocês estão conversando com criptografia
|
|
76
|
+
ponta-a-ponta.
|
|
77
|
+
|
|
78
|
+
> O hub é mantido como projeto pessoal sob estes **[termos](TERMS.md)**. É um
|
|
79
|
+
> relay zero-knowledge: quem o opera não consegue ler suas mensagens — isso é
|
|
80
|
+
> uma propriedade do software, não uma promessa. Prefere o seu próprio? Todos
|
|
81
|
+
> os comandos abaixo funcionam igual num relay que você hospeda.
|
|
82
|
+
|
|
83
|
+
Outros modos:
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
npx ciphermesh server # rode o seu próprio relay
|
|
87
|
+
npx ciphermesh p2p # sem servidor, descoberta por mDNS na LAN
|
|
69
88
|
```
|
|
70
89
|
|
|
71
90
|
macOS/Linux com Homebrew (veja [`Formula/ciphermesh.rb`](Formula/ciphermesh.rb)):
|
|
@@ -292,6 +311,14 @@ mão. Todas as chaves são opcionais (chaves desconhecidas são ignoradas):
|
|
|
292
311
|
memória e sai sem salvar — para um device perdido ou apreendido.
|
|
293
312
|
- Estado de sessão e histórico local são cifrados em repouso com
|
|
294
313
|
**Argon2id + XSalsa20-Poly1305** — sem passphrase, nada persiste.
|
|
314
|
+
- **Pós-quântico híbrido**: cada sessão mistura um segredo ML-KEM-768 na raiz
|
|
315
|
+
do ratchet na inicialização, então tráfego gravado hoje continua ilegível
|
|
316
|
+
para um adversário quântico futuro. Ele é *somado* ao X25519, nunca o
|
|
317
|
+
substitui — a segurança é no mínimo a clássica. O `/trustlist` mostra `[PQ]`.
|
|
318
|
+
- **Salas privadas** nunca enviam a senha a lugar nenhum: ela deriva uma chave
|
|
319
|
+
Ed25519 (Argon2id) que responde a um desafio do servidor, e o conteúdo da
|
|
320
|
+
sala carrega uma camada simétrica extra — um relay malicioso que deixasse
|
|
321
|
+
alguém entrar sem verificar ainda assim não leria uma palavra.
|
|
295
322
|
- Análise de ameaças e detalhes do protocolo: [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
|
|
296
323
|
Achou algo? Veja [SECURITY.md](SECURITY.md).
|
|
297
324
|
|
|
@@ -299,7 +326,7 @@ mão. Todas as chaves são opcionais (chaves desconhecidas são ignoradas):
|
|
|
299
326
|
|
|
300
327
|
```bash
|
|
301
328
|
npm run server:dev # relay com auto-reload
|
|
302
|
-
npm test #
|
|
329
|
+
npm test # 418 testes (crypto, ratchet, fuzz, controllers, transferências…)
|
|
303
330
|
npm run validate # lint + prettier + testes — o mesmo que o CI roda
|
|
304
331
|
```
|
|
305
332
|
|
package/package.json
CHANGED
|
@@ -586,6 +586,15 @@ export class ChatController {
|
|
|
586
586
|
this.#ui.addSystemMessage(`Online: ${peerNames.join(', ')}`);
|
|
587
587
|
}
|
|
588
588
|
|
|
589
|
+
// Server notice (MOTD): the operator's only channel to everyone, since
|
|
590
|
+
// they cannot read or inject anything into the conversations themselves.
|
|
591
|
+
if (typeof msg.motd === 'string' && msg.motd.trim()) {
|
|
592
|
+
this.#ui.addInfoMessage('── Server notice ──');
|
|
593
|
+
for (const line of msg.motd.split('\n').slice(0, 10)) {
|
|
594
|
+
this.#ui.addInfoMessage(` ${line}`);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
589
598
|
if (msg.queuedCount > 0) {
|
|
590
599
|
this.#ui.addSystemMessage(`${msg.queuedCount} pending message(s) being delivered`);
|
|
591
600
|
}
|
|
@@ -303,6 +303,17 @@ export class SessionManager {
|
|
|
303
303
|
|
|
304
304
|
// ── Private rooms ────────────────────────────────────────────
|
|
305
305
|
|
|
306
|
+
/** Number of live rooms (used for the server-wide room cap). */
|
|
307
|
+
get roomCount() {
|
|
308
|
+
let n = 0;
|
|
309
|
+
for (const members of this.#rooms.values()) {
|
|
310
|
+
if (members.size > 0) {
|
|
311
|
+
n++;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return n;
|
|
315
|
+
}
|
|
316
|
+
|
|
306
317
|
roomHasMembers(room) {
|
|
307
318
|
const members = this.#rooms.get(room);
|
|
308
319
|
return !!members && members.size > 0;
|
|
@@ -5,10 +5,7 @@ import { createLogger } from '../shared/logger.js';
|
|
|
5
5
|
import {
|
|
6
6
|
HEARTBEAT_INTERVAL_MS,
|
|
7
7
|
MAX_PAYLOAD_SIZE,
|
|
8
|
-
MAX_CONNECTIONS_TOTAL,
|
|
9
|
-
MAX_CONNECTIONS_PER_IP,
|
|
10
8
|
JOIN_TIMEOUT_MS,
|
|
11
|
-
MESSAGE_RATE_LIMIT_PER_SECOND,
|
|
12
9
|
ROOM_CHALLENGE_NONCE_SIZE,
|
|
13
10
|
ROOM_CHALLENGE_TTL_MS,
|
|
14
11
|
ROOM_AUTH_MAX_FAILS,
|
|
@@ -44,6 +41,7 @@ import {
|
|
|
44
41
|
validateBanPeer,
|
|
45
42
|
} from '../protocol/validators.js';
|
|
46
43
|
import { verifyRoomChallenge } from '../crypto/RoomKey.js';
|
|
44
|
+
import { parseServerConfig, clientAddress, normalizeIp } from './config.js';
|
|
47
45
|
|
|
48
46
|
const log = createLogger('ws-server');
|
|
49
47
|
|
|
@@ -55,12 +53,14 @@ export class SecureWSServer {
|
|
|
55
53
|
#offlineQueue;
|
|
56
54
|
#heartbeatInterval;
|
|
57
55
|
#connectionsByIp;
|
|
56
|
+
#config;
|
|
58
57
|
|
|
59
|
-
constructor(sessionManager, messageRouter, offlineQueue, port, tlsOptions) {
|
|
58
|
+
constructor(sessionManager, messageRouter, offlineQueue, port, tlsOptions, config = null) {
|
|
60
59
|
this.#sessionManager = sessionManager;
|
|
61
60
|
this.#messageRouter = messageRouter;
|
|
62
61
|
this.#offlineQueue = offlineQueue;
|
|
63
62
|
this.#connectionsByIp = new Map();
|
|
63
|
+
this.#config = config || parseServerConfig();
|
|
64
64
|
|
|
65
65
|
if (tlsOptions) {
|
|
66
66
|
this.#httpsServer = createHttpsServer(tlsOptions);
|
|
@@ -86,20 +86,27 @@ export class SecureWSServer {
|
|
|
86
86
|
}
|
|
87
87
|
|
|
88
88
|
#clientIp(req) {
|
|
89
|
-
return req
|
|
89
|
+
return normalizeIp(clientAddress(req, this.#config.trustProxy));
|
|
90
90
|
}
|
|
91
91
|
|
|
92
92
|
#handleConnection(ws, req) {
|
|
93
93
|
const ip = this.#clientIp(req);
|
|
94
94
|
|
|
95
|
+
// Operator banlist — the one lever that does not require reading messages.
|
|
96
|
+
if (this.#config.bannedIps.has(ip)) {
|
|
97
|
+
log.warn(`Rejected banned address ${ip}`);
|
|
98
|
+
ws.close(1008, 'Address not allowed');
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
95
102
|
// Global connection cap (the new socket is already counted in clients).
|
|
96
|
-
if (this.#wss.clients.size >
|
|
103
|
+
if (this.#wss.clients.size > this.#config.maxConnectionsTotal) {
|
|
97
104
|
ws.close(1013, 'Server full');
|
|
98
105
|
return;
|
|
99
106
|
}
|
|
100
107
|
// Per-IP connection cap.
|
|
101
108
|
const ipCount = this.#connectionsByIp.get(ip) || 0;
|
|
102
|
-
if (ipCount >=
|
|
109
|
+
if (ipCount >= this.#config.maxConnectionsPerIp) {
|
|
103
110
|
log.warn(`Too many connections from ${ip}, rejecting`);
|
|
104
111
|
ws.close(1013, 'Too many connections from this IP');
|
|
105
112
|
return;
|
|
@@ -166,7 +173,7 @@ export class SecureWSServer {
|
|
|
166
173
|
ws.msgCount = 0;
|
|
167
174
|
}
|
|
168
175
|
ws.msgCount++;
|
|
169
|
-
return ws.msgCount <=
|
|
176
|
+
return ws.msgCount <= this.#config.messageRateLimitPerSecond;
|
|
170
177
|
}
|
|
171
178
|
|
|
172
179
|
#handleMessage(ws, data) {
|
|
@@ -283,6 +290,9 @@ export class SecureWSServer {
|
|
|
283
290
|
joinAck.roomOwner = ownerSession.nickname;
|
|
284
291
|
}
|
|
285
292
|
}
|
|
293
|
+
if (this.#config.motd) {
|
|
294
|
+
joinAck.motd = this.#config.motd;
|
|
295
|
+
}
|
|
286
296
|
ws.send(JSON.stringify(joinAck));
|
|
287
297
|
|
|
288
298
|
// Deliver queued messages with updated recipient sessionId
|
|
@@ -376,6 +386,10 @@ export class SecureWSServer {
|
|
|
376
386
|
return;
|
|
377
387
|
}
|
|
378
388
|
|
|
389
|
+
if (this.#roomCapExceeded(ws, validation.room)) {
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
|
|
379
393
|
// Creating a private room: register the password verifier, but only for
|
|
380
394
|
// a room that doesn't exist yet (rooms die when the last member leaves).
|
|
381
395
|
if (validation.roomAuthPk) {
|
|
@@ -414,6 +428,38 @@ export class SecureWSServer {
|
|
|
414
428
|
this.#finishRoomSwitch(ws, session, result);
|
|
415
429
|
}
|
|
416
430
|
|
|
431
|
+
// Room caps. Multi-room lets one connection open many rooms, so both a
|
|
432
|
+
// per-session and a server-wide ceiling are needed; without them a single
|
|
433
|
+
// client can exhaust the room table by itself.
|
|
434
|
+
#roomCapExceeded(ws, room) {
|
|
435
|
+
if (this.#sessionManager.isInRoom(ws.sessionId, room)) {
|
|
436
|
+
return false; // already there, not a new room
|
|
437
|
+
}
|
|
438
|
+
const mine = this.#sessionManager.getSessionRooms(ws.sessionId).length;
|
|
439
|
+
if (mine >= this.#config.maxRoomsPerSession) {
|
|
440
|
+
ws.send(
|
|
441
|
+
JSON.stringify(
|
|
442
|
+
createError(
|
|
443
|
+
ERR.INVALID_MESSAGE,
|
|
444
|
+
`You are in too many rooms (max ${this.#config.maxRoomsPerSession}) — /leave one first`,
|
|
445
|
+
),
|
|
446
|
+
),
|
|
447
|
+
);
|
|
448
|
+
return true;
|
|
449
|
+
}
|
|
450
|
+
// Only a room that does not exist yet adds to the server total.
|
|
451
|
+
if (
|
|
452
|
+
!this.#sessionManager.roomHasMembers(room) &&
|
|
453
|
+
this.#sessionManager.roomCount >= this.#config.maxRoomsTotal
|
|
454
|
+
) {
|
|
455
|
+
ws.send(
|
|
456
|
+
JSON.stringify(createError(ERR.INVALID_MESSAGE, 'The server has too many rooms right now')),
|
|
457
|
+
);
|
|
458
|
+
return true;
|
|
459
|
+
}
|
|
460
|
+
return false;
|
|
461
|
+
}
|
|
462
|
+
|
|
417
463
|
// Multi-room: join an ADDITIONAL room, keeping current memberships.
|
|
418
464
|
#handleJoinRoom(ws, msg) {
|
|
419
465
|
if (!ws.hasJoined || !ws.sessionId) {
|
|
@@ -432,6 +478,9 @@ export class SecureWSServer {
|
|
|
432
478
|
ws.send(JSON.stringify(createError(ERR.INVALID_MESSAGE, 'You are banned from this room')));
|
|
433
479
|
return;
|
|
434
480
|
}
|
|
481
|
+
if (this.#roomCapExceeded(ws, validation.room)) {
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
435
484
|
|
|
436
485
|
// Creating a private room additively.
|
|
437
486
|
if (validation.roomAuthPk) {
|
|
@@ -908,7 +957,7 @@ export class SecureWSServer {
|
|
|
908
957
|
ws.sessionId,
|
|
909
958
|
);
|
|
910
959
|
}
|
|
911
|
-
log.info(`${session.nickname}
|
|
960
|
+
log.info(`${session.nickname} left | Online: ${this.#sessionManager.size}`);
|
|
912
961
|
}
|
|
913
962
|
}
|
|
914
963
|
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// Server limits and operator settings, read from the environment so whoever
|
|
2
|
+
// hosts a relay can tighten it for the open internet without touching code.
|
|
3
|
+
//
|
|
4
|
+
// The defaults are the LAN-friendly ones the project always had. A public
|
|
5
|
+
// relay should lower the connection caps and set TRUST_PROXY — see deploy/.
|
|
6
|
+
import { readFileSync } from 'node:fs';
|
|
7
|
+
import {
|
|
8
|
+
MAX_CONNECTIONS_TOTAL,
|
|
9
|
+
MAX_CONNECTIONS_PER_IP,
|
|
10
|
+
MESSAGE_RATE_LIMIT_PER_SECOND,
|
|
11
|
+
} from '../shared/constants.js';
|
|
12
|
+
|
|
13
|
+
const DEFAULTS = {
|
|
14
|
+
maxConnectionsTotal: MAX_CONNECTIONS_TOTAL,
|
|
15
|
+
maxConnectionsPerIp: MAX_CONNECTIONS_PER_IP,
|
|
16
|
+
messageRateLimitPerSecond: MESSAGE_RATE_LIMIT_PER_SECOND,
|
|
17
|
+
// Multi-room means ONE connection can open many rooms; without a cap a
|
|
18
|
+
// single client could exhaust the room table on its own.
|
|
19
|
+
maxRoomsTotal: 500,
|
|
20
|
+
maxRoomsPerSession: 10,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
function positiveInt(raw, fallback) {
|
|
24
|
+
const n = Number.parseInt(raw, 10);
|
|
25
|
+
return Number.isInteger(n) && n > 0 ? n : fallback;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function readMotd(env) {
|
|
29
|
+
if (env.MOTD_FILE) {
|
|
30
|
+
try {
|
|
31
|
+
return readFileSync(env.MOTD_FILE, 'utf-8').trim().slice(0, 500);
|
|
32
|
+
} catch {
|
|
33
|
+
return ''; // a missing file must never stop the server from booting
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return String(env.MOTD || '')
|
|
37
|
+
.trim()
|
|
38
|
+
.slice(0, 500);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function readBannedIps(env) {
|
|
42
|
+
const inline = String(env.BANNED_IPS || '');
|
|
43
|
+
let fromFile = '';
|
|
44
|
+
if (env.BANNED_IPS_FILE) {
|
|
45
|
+
try {
|
|
46
|
+
fromFile = readFileSync(env.BANNED_IPS_FILE, 'utf-8');
|
|
47
|
+
} catch {
|
|
48
|
+
fromFile = '';
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
// Strip comments PER LINE before splitting: a naive split would turn
|
|
52
|
+
// "# abuse 2026-08" into three bogus banlist entries.
|
|
53
|
+
const entries = `${inline}\n${fromFile}`
|
|
54
|
+
.split('\n')
|
|
55
|
+
.map((line) => line.split('#')[0])
|
|
56
|
+
.flatMap((line) => line.split(/[\s,]+/))
|
|
57
|
+
.map((ip) => ip.trim())
|
|
58
|
+
.filter(Boolean);
|
|
59
|
+
return new Set(entries);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Build the server configuration from an environment object.
|
|
64
|
+
* Pure — exported so tests can feed it any env.
|
|
65
|
+
*/
|
|
66
|
+
export function parseServerConfig(env = process.env) {
|
|
67
|
+
return {
|
|
68
|
+
maxConnectionsTotal: positiveInt(env.MAX_CONNECTIONS_TOTAL, DEFAULTS.maxConnectionsTotal),
|
|
69
|
+
maxConnectionsPerIp: positiveInt(env.MAX_CONNECTIONS_PER_IP, DEFAULTS.maxConnectionsPerIp),
|
|
70
|
+
messageRateLimitPerSecond: positiveInt(
|
|
71
|
+
env.MESSAGE_RATE_LIMIT,
|
|
72
|
+
DEFAULTS.messageRateLimitPerSecond,
|
|
73
|
+
),
|
|
74
|
+
maxRoomsTotal: positiveInt(env.MAX_ROOMS_TOTAL, DEFAULTS.maxRoomsTotal),
|
|
75
|
+
maxRoomsPerSession: positiveInt(env.MAX_ROOMS_PER_SESSION, DEFAULTS.maxRoomsPerSession),
|
|
76
|
+
// Behind a reverse proxy every connection arrives from the proxy, so the
|
|
77
|
+
// per-IP cap would apply to the proxy itself and protect nobody. Only
|
|
78
|
+
// trust the forwarded header when the operator says there IS a proxy —
|
|
79
|
+
// otherwise any client could forge its own address.
|
|
80
|
+
trustProxy: String(env.TRUST_PROXY || '').toLowerCase() === 'true',
|
|
81
|
+
motd: readMotd(env),
|
|
82
|
+
bannedIps: readBannedIps(env),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The client address to rate-limit and ban on.
|
|
88
|
+
* @param {object} req - the upgrade request
|
|
89
|
+
* @param {boolean} trustProxy
|
|
90
|
+
*/
|
|
91
|
+
export function clientAddress(req, trustProxy) {
|
|
92
|
+
const socketIp = req?.socket?.remoteAddress || 'unknown';
|
|
93
|
+
if (!trustProxy) {
|
|
94
|
+
return socketIp;
|
|
95
|
+
}
|
|
96
|
+
// X-Forwarded-For is "client, proxy1, proxy2" — the FIRST entry is the
|
|
97
|
+
// original client. It is only trustworthy because the operator declared
|
|
98
|
+
// that a proxy sits in front and overwrites it.
|
|
99
|
+
const forwarded = req?.headers?.['x-forwarded-for'];
|
|
100
|
+
if (typeof forwarded === 'string' && forwarded.length > 0) {
|
|
101
|
+
const first = forwarded.split(',')[0].trim();
|
|
102
|
+
if (first) {
|
|
103
|
+
return first;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return socketIp;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Normalize an address for comparison (strips the IPv6-mapped IPv4 prefix). */
|
|
110
|
+
export function normalizeIp(ip) {
|
|
111
|
+
return String(ip || '').replace(/^::ffff:/, '');
|
|
112
|
+
}
|
package/src/shared/doctor.js
CHANGED
|
@@ -20,7 +20,12 @@ export function parseTarget(raw) {
|
|
|
20
20
|
const withScheme = /^wss?:\/\//.test(value) ? value : `wss://${value}`;
|
|
21
21
|
try {
|
|
22
22
|
const url = new URL(withScheme);
|
|
23
|
-
|
|
23
|
+
// No explicit port: mirror what the WebSocket client will actually do —
|
|
24
|
+
// wss:// goes to 443 and ws:// to 80. Assuming 3600 here would have made
|
|
25
|
+
// /doctor test a different port than the one the chat connects to, which
|
|
26
|
+
// is exactly the confusion the command exists to prevent.
|
|
27
|
+
const defaultPort = url.protocol === 'wss:' ? 443 : 80;
|
|
28
|
+
const port = Number(url.port) || defaultPort;
|
|
24
29
|
if (!url.hostname || !Number.isInteger(port) || port < 1 || port > 65535) {
|
|
25
30
|
return null;
|
|
26
31
|
}
|
|
@@ -81,6 +86,11 @@ function probeTls(host, port, timeoutMs, deps) {
|
|
|
81
86
|
done({
|
|
82
87
|
ok: true,
|
|
83
88
|
authorized: socket.authorized === true,
|
|
89
|
+
// WHY it failed matters: a self-signed LAN cert is expected, but a
|
|
90
|
+
// hostname mismatch means you reached a different server entirely —
|
|
91
|
+
// usually stale DNS. Reporting both as "self-signed" hides that.
|
|
92
|
+
reason: socket.authorizationError || null,
|
|
93
|
+
subject: cert.subject?.CN || null,
|
|
84
94
|
issuer: cert.issuer?.O || cert.issuer?.CN || 'unknown',
|
|
85
95
|
fingerprint: cert.fingerprint256 || null,
|
|
86
96
|
});
|
|
@@ -182,16 +192,31 @@ export async function diagnose(target, opts = {}) {
|
|
|
182
192
|
);
|
|
183
193
|
return steps;
|
|
184
194
|
}
|
|
185
|
-
|
|
186
|
-
tls.
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
+
if (tls.authorized) {
|
|
196
|
+
steps.push(step('TLS', true, `verified against a public CA (${tls.issuer})`));
|
|
197
|
+
} else if (tls.reason === 'ERR_TLS_CERT_ALTNAME_INVALID') {
|
|
198
|
+
// The certificate is valid but belongs to someone else: you are talking
|
|
199
|
+
// to the wrong machine. Almost always a stale or wrong DNS record.
|
|
200
|
+
steps.push(
|
|
201
|
+
step(
|
|
202
|
+
'TLS',
|
|
203
|
+
false,
|
|
204
|
+
`the certificate is for "${tls.subject || '?'}", not ${parsed.host}`,
|
|
205
|
+
'You reached a different server than you meant to. Check the DNS record for this name, ' +
|
|
206
|
+
'and flush your resolver cache if it was changed recently.',
|
|
207
|
+
),
|
|
208
|
+
);
|
|
209
|
+
return steps;
|
|
210
|
+
} else {
|
|
211
|
+
steps.push(
|
|
212
|
+
step(
|
|
213
|
+
'TLS',
|
|
214
|
+
true,
|
|
215
|
+
`self-signed certificate (normal on a LAN)${tls.reason ? ` — ${tls.reason}` : ''}`,
|
|
216
|
+
'Trust is pinned on first use — compare fingerprints out-of-band if you want certainty.',
|
|
217
|
+
),
|
|
218
|
+
);
|
|
219
|
+
}
|
|
195
220
|
}
|
|
196
221
|
|
|
197
222
|
steps.push(
|