ciphermesh 2.4.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 +41 -12
- package/README.pt-BR.md +37 -9
- package/package.json +1 -1
- package/src/client/ChatController.js +145 -12
- package/src/client/UI.js +66 -15
- package/src/p2p/P2PChatController.js +132 -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 +250 -0
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)):
|
|
@@ -212,6 +230,7 @@ A green **✓** next to a name marks a SAS-verified peer; a red **✗** flags a
|
|
|
212
230
|
| `/img [path]` | Render the last received image in **full resolution** (kitty/iTerm2) |
|
|
213
231
|
| `/search <term>` | Search the encrypted local history (on disk, across sessions) |
|
|
214
232
|
| `/find [term]` — **Ctrl+F** | Search **this room's scrollback** and press Enter to **jump to the message**, highlighted |
|
|
233
|
+
| `/doctor [host:port]` | Diagnose why a connection fails: address, DNS, TCP port, TLS (CA vs self-signed) and protocol version — each failure with what to do about it |
|
|
215
234
|
| `/history [n]` | Last n messages from history |
|
|
216
235
|
| `/retention <7d\|24h\|30m>` | Purge local history older than the given age |
|
|
217
236
|
| `/export [path]` | Export history as .txt or .json (plaintext!) |
|
|
@@ -226,8 +245,8 @@ A green **✓** next to a name marks a SAS-verified peer; a red **✗** flags a
|
|
|
226
245
|
| `/away [reason]` / `/back` | Mark yourself away — while away, unreads are counted (`[away · N new]`) and `/back` shows a summary |
|
|
227
246
|
| `/mentions [n]` | Recent mentions of you this session (who, where, when) |
|
|
228
247
|
| `/status <text\|off>` | Free-form status — emojis welcome (`/status :fire: coding`) |
|
|
229
|
-
| `/react <emoji>` | React to the last message |
|
|
230
|
-
| `/edit` `/delete` | Edit
|
|
248
|
+
| `/react <emoji>` | React to the last message — the emoji lands **on the message**, with a count when several people react |
|
|
249
|
+
| `/edit` `/delete` | Edit or delete your last message — the **original line is rewritten in place** (marked *(edited)*) or replaced by a tombstone, instead of a new line you have to mentally staple to it |
|
|
231
250
|
| `/pin` `/unpin` `/pins` | Pin messages |
|
|
232
251
|
| `/sound` `/notify` | Sound / desktop notifications |
|
|
233
252
|
| `/dnd [on\|off\|mentions\|HH:MM-HH:MM]` | Do-not-disturb, mentions-only, or quiet hours |
|
|
@@ -284,11 +303,19 @@ All keys are optional (unknown keys are ignored):
|
|
|
284
303
|
active chatting from idle. Decoys are dropped silently by the receiver.
|
|
285
304
|
- **Anti-replay** via monotonic nonces, **key rotation** every hour with a
|
|
286
305
|
grace window, **secure memory wipe** (`sodium_memzero`) after use.
|
|
287
|
-
- **Duress wipe** (`/panic
|
|
306
|
+
- **Duress wipe** (`/panic yes`): overwrites and deletes every on-disk secret
|
|
288
307
|
(session state, history, trust store, audit log), zeroes the in-memory keys,
|
|
289
308
|
and exits without saving — for a lost or seized device.
|
|
290
309
|
- Session state and local history are encrypted at rest with
|
|
291
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.
|
|
292
319
|
- Threat analysis and protocol details: [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
|
|
293
320
|
Found something? See [SECURITY.md](SECURITY.md).
|
|
294
321
|
|
|
@@ -296,12 +323,14 @@ All keys are optional (unknown keys are ignored):
|
|
|
296
323
|
|
|
297
324
|
```bash
|
|
298
325
|
npm run server:dev # relay with auto-reload
|
|
299
|
-
npm test #
|
|
326
|
+
npm test # 418 tests (crypto, ratchet, fuzz, controllers, transfers…)
|
|
300
327
|
npm run validate # lint + prettier + tests — what the CI runs
|
|
301
328
|
```
|
|
302
329
|
|
|
303
|
-
CI runs on every push/PR (Node 20 & 22)
|
|
304
|
-
|
|
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.
|
|
305
334
|
|
|
306
335
|
## 📄 License
|
|
307
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)):
|
|
@@ -213,6 +232,7 @@ Um **✓** verde ao lado de um nome indica um peer verificado por SAS; um **✗*
|
|
|
213
232
|
| `/retention <7d\|24h\|30m>` | Purga o histórico local mais antigo que o tempo dado |
|
|
214
233
|
| `/search <termo>` | Busca no histórico local cifrado (em disco, entre sessões) |
|
|
215
234
|
| `/find [termo]` — **Ctrl+F** | Busca **no histórico da sala na tela** e, com Enter, **salta para a mensagem** destacada |
|
|
235
|
+
| `/doctor [host:porta]` | Diagnostica por que a conexão falha: endereço, DNS, porta TCP, TLS (CA ou self-signed) e versão de protocolo — cada falha com o que fazer |
|
|
216
236
|
| `/history [n]` | Últimas n mensagens do histórico |
|
|
217
237
|
| `/export [caminho]` | Exporta o histórico em .txt ou .json (texto plano!) |
|
|
218
238
|
|
|
@@ -226,8 +246,8 @@ Um **✓** verde ao lado de um nome indica um peer verificado por SAS; um **✗*
|
|
|
226
246
|
| `/away [motivo]` / `/back` | Marca/remove ausência — enquanto ausente, não-lidas são contadas (`[away · N new]`) e o `/back` mostra um resumo |
|
|
227
247
|
| `/mentions [n]` | Menções recentes a você na sessão (quem, onde, quando) |
|
|
228
248
|
| `/status <texto\|off>` | Status livre — emoji à vontade (`/status :fire: codando`) |
|
|
229
|
-
| `/react <emoji>` | Reage à última mensagem |
|
|
230
|
-
| `/edit` `/delete` | Edita
|
|
249
|
+
| `/react <emoji>` | Reage à última mensagem — o emoji aparece **na própria mensagem**, com contagem quando várias pessoas reagem |
|
|
250
|
+
| `/edit` `/delete` | Edita ou apaga sua última mensagem — a **linha original é reescrita no lugar** (marcada *(edited)*) ou vira uma lápide, em vez de uma linha nova que você precisa juntar mentalmente à original |
|
|
231
251
|
| `/pin` `/unpin` `/pins` | Fixa mensagens |
|
|
232
252
|
| `/sound` `/notify` | Notificações sonoras / desktop |
|
|
233
253
|
| `/dnd [on\|off\|mentions\|HH:MM-HH:MM]` | Não perturbe, só menções, ou horário silencioso |
|
|
@@ -291,6 +311,14 @@ mão. Todas as chaves são opcionais (chaves desconhecidas são ignoradas):
|
|
|
291
311
|
memória e sai sem salvar — para um device perdido ou apreendido.
|
|
292
312
|
- Estado de sessão e histórico local são cifrados em repouso com
|
|
293
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.
|
|
294
322
|
- Análise de ameaças e detalhes do protocolo: [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
|
|
295
323
|
Achou algo? Veja [SECURITY.md](SECURITY.md).
|
|
296
324
|
|
|
@@ -298,7 +326,7 @@ mão. Todas as chaves são opcionais (chaves desconhecidas são ignoradas):
|
|
|
298
326
|
|
|
299
327
|
```bash
|
|
300
328
|
npm run server:dev # relay com auto-reload
|
|
301
|
-
npm test #
|
|
329
|
+
npm test # 418 testes (crypto, ratchet, fuzz, controllers, transferências…)
|
|
302
330
|
npm run validate # lint + prettier + testes — o mesmo que o CI roda
|
|
303
331
|
```
|
|
304
332
|
|
package/package.json
CHANGED
|
@@ -60,6 +60,7 @@ import {
|
|
|
60
60
|
matchesKeyword,
|
|
61
61
|
} from '../shared/dnd.js';
|
|
62
62
|
import { saveLastSession } from '../shared/lastSession.js';
|
|
63
|
+
import { diagnose, formatDiagnosis } from '../shared/doctor.js';
|
|
63
64
|
import { COMMANDS } from './UI.js';
|
|
64
65
|
|
|
65
66
|
const TYPING_SEND_INTERVAL = 2000; // debounce: max 1 typing event per 2s
|
|
@@ -120,6 +121,9 @@ export class ChatController {
|
|
|
120
121
|
#watchWords = new Set(); // /watch — keywords that alert like a mention does
|
|
121
122
|
#awayUnread = 0; // messages received while away
|
|
122
123
|
#awayMentions = 0; // …of which mentioned me
|
|
124
|
+
#messageLines = new Map(); // messageId → { lineIndex, nickname, text, opts, room }
|
|
125
|
+
#reactions = new Map(); // messageId → Map<emoji, count>
|
|
126
|
+
#reconnectAttempts = 0; // consecutive reconnects, for the /doctor nudge
|
|
123
127
|
#autoLockMs = 0; // idle screen-lock timeout (0 = off)
|
|
124
128
|
#autoLockTimer = null;
|
|
125
129
|
// Multi-room buffers (IRC style): lines live in the UI; membership, unread
|
|
@@ -212,6 +216,7 @@ export class ChatController {
|
|
|
212
216
|
// the initial JOIN would be lost — the 'connected' event fired before we
|
|
213
217
|
// attached the listener).
|
|
214
218
|
#onConnected() {
|
|
219
|
+
this.#reconnectAttempts = 0;
|
|
215
220
|
this.#ui.setConnectionState('online');
|
|
216
221
|
this.#connection.send(
|
|
217
222
|
createJoin(this.#nickname, this.#keyManager.publicKeyB64, this.#keyManager.pqPublicKeyB64),
|
|
@@ -237,6 +242,12 @@ export class ChatController {
|
|
|
237
242
|
this.#connection.on('reconnecting', (delay) => {
|
|
238
243
|
this.#ui.setConnectionState('reconnecting');
|
|
239
244
|
this.#ui.addSystemMessage(`Reconnecting in ${delay / 1000}s...`);
|
|
245
|
+
// After a few failures this is not a blip — point at the tool that can
|
|
246
|
+
// actually explain it, once, instead of looping silently forever.
|
|
247
|
+
this.#reconnectAttempts++;
|
|
248
|
+
if (this.#reconnectAttempts === 3) {
|
|
249
|
+
this.#ui.addInfoMessage('Still failing? Run /doctor to find out where it breaks.');
|
|
250
|
+
}
|
|
240
251
|
});
|
|
241
252
|
|
|
242
253
|
this.#connection.on('cert-ca-valid', ({ issuer }) => {
|
|
@@ -575,6 +586,15 @@ export class ChatController {
|
|
|
575
586
|
this.#ui.addSystemMessage(`Online: ${peerNames.join(', ')}`);
|
|
576
587
|
}
|
|
577
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
|
+
|
|
578
598
|
if (msg.queuedCount > 0) {
|
|
579
599
|
this.#ui.addSystemMessage(`${msg.queuedCount} pending message(s) being delivered`);
|
|
580
600
|
}
|
|
@@ -1159,9 +1179,17 @@ export class ChatController {
|
|
|
1159
1179
|
}
|
|
1160
1180
|
|
|
1161
1181
|
if (data.action === 'reaction') {
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1182
|
+
// Hang the reaction off the message itself; only fall back to a log
|
|
1183
|
+
// line when the target is not on screen (older or another room).
|
|
1184
|
+
const applied =
|
|
1185
|
+
roomActive && data.targetMessageId
|
|
1186
|
+
? this.#applyReaction(data.targetMessageId, data.emoji)
|
|
1187
|
+
: false;
|
|
1188
|
+
if (!applied) {
|
|
1189
|
+
this.#ui.toBuffer(msgRoom, () => {
|
|
1190
|
+
this.#ui.addSystemMessage(`${data.emoji} ${peer.nickname} reacted to a message`);
|
|
1191
|
+
});
|
|
1192
|
+
}
|
|
1165
1193
|
if (roomActive) {
|
|
1166
1194
|
this.#ui.playNotification();
|
|
1167
1195
|
}
|
|
@@ -1170,7 +1198,14 @@ export class ChatController {
|
|
|
1170
1198
|
|
|
1171
1199
|
if (data.action === 'edit_message') {
|
|
1172
1200
|
const author = this.#messageAuthors.get(data.messageId);
|
|
1173
|
-
if (author
|
|
1201
|
+
if (!author || author !== peer.nickname) {
|
|
1202
|
+
return; // only the author may rewrite their own message
|
|
1203
|
+
}
|
|
1204
|
+
const entry = this.#editableMessage(data.messageId);
|
|
1205
|
+
if (entry) {
|
|
1206
|
+
entry.text = data.newText;
|
|
1207
|
+
this.#ui.replaceMessageText(entry.lineIndex, entry.nickname, data.newText, entry.opts);
|
|
1208
|
+
} else {
|
|
1174
1209
|
this.#ui.toBuffer(msgRoom, () => {
|
|
1175
1210
|
this.#ui.addSystemMessage(`${peer.nickname} edited: ${data.newText} (edited)`);
|
|
1176
1211
|
});
|
|
@@ -1180,7 +1215,14 @@ export class ChatController {
|
|
|
1180
1215
|
|
|
1181
1216
|
if (data.action === 'delete_message') {
|
|
1182
1217
|
const author = this.#messageAuthors.get(data.messageId);
|
|
1183
|
-
if (author
|
|
1218
|
+
if (!author || author !== peer.nickname) {
|
|
1219
|
+
return;
|
|
1220
|
+
}
|
|
1221
|
+
const entry = this.#editableMessage(data.messageId);
|
|
1222
|
+
if (entry) {
|
|
1223
|
+
this.#ui.tombstoneMessage(entry.lineIndex, peer.nickname);
|
|
1224
|
+
this.#messageLines.delete(data.messageId);
|
|
1225
|
+
} else {
|
|
1184
1226
|
this.#ui.toBuffer(msgRoom, () => {
|
|
1185
1227
|
this.#ui.addSystemMessage(`${peer.nickname} deleted a message`);
|
|
1186
1228
|
});
|
|
@@ -1299,11 +1341,12 @@ export class ChatController {
|
|
|
1299
1341
|
const trust = trustBadge(this.#trustStore.getPeerRecord(peer.nickname), peer.publicKey);
|
|
1300
1342
|
// File the message into its buffer (live log when active, stored otherwise).
|
|
1301
1343
|
let lineIndex = -1;
|
|
1344
|
+
let renderInfo = null;
|
|
1302
1345
|
this.#ui.toBuffer(msgRoom, () => {
|
|
1303
1346
|
if (data.replyTo?.nickname && typeof data.replyTo.excerpt === 'string') {
|
|
1304
1347
|
this.#ui.addQuoteLine(String(data.replyTo.nickname), data.replyTo.excerpt.slice(0, 80));
|
|
1305
1348
|
}
|
|
1306
|
-
({ lineIndex } = data.isAction
|
|
1349
|
+
({ lineIndex, render: renderInfo } = data.isAction
|
|
1307
1350
|
? this.#ui.addActionMessage(peer.nickname, data.text)
|
|
1308
1351
|
: this.#ui.addMessage(
|
|
1309
1352
|
peer.nickname,
|
|
@@ -1315,6 +1358,9 @@ export class ChatController {
|
|
|
1315
1358
|
trust,
|
|
1316
1359
|
));
|
|
1317
1360
|
});
|
|
1361
|
+
if (roomActive && data.messageId) {
|
|
1362
|
+
this.#rememberMessage(data.messageId, lineIndex, peer.nickname, data.text, renderInfo);
|
|
1363
|
+
}
|
|
1318
1364
|
this.#noteBufferUnread(msgRoom, mentioned);
|
|
1319
1365
|
const notify = shouldNotify(this.#dndMode, this.#dndWindow, nowMinutes(), mentioned);
|
|
1320
1366
|
if (notify) {
|
|
@@ -1440,6 +1486,7 @@ export class ChatController {
|
|
|
1440
1486
|
);
|
|
1441
1487
|
this.#ui.addInfoMessage(' /search <term> - Search the encrypted local history');
|
|
1442
1488
|
this.#ui.addInfoMessage(' /find [term] - Find in this room and jump (Ctrl+F)');
|
|
1489
|
+
this.#ui.addInfoMessage(' /doctor [host:port] - Diagnose why a connection fails');
|
|
1443
1490
|
this.#ui.addInfoMessage(' /history [n] - Last n messages from history');
|
|
1444
1491
|
this.#ui.addInfoMessage(' /export [path] - Export the history (.txt or .json)');
|
|
1445
1492
|
this.#ui.addInfoMessage(' /audit [N] - Show the last N audit events');
|
|
@@ -2171,6 +2218,21 @@ export class ChatController {
|
|
|
2171
2218
|
break;
|
|
2172
2219
|
}
|
|
2173
2220
|
|
|
2221
|
+
case '/doctor': {
|
|
2222
|
+
const target = parts.slice(1).join(' ').trim() || this.#connection.url || '';
|
|
2223
|
+
this.#ui.addInfoMessage(`Diagnosing ${target} …`);
|
|
2224
|
+
diagnose(target)
|
|
2225
|
+
.then((steps) => {
|
|
2226
|
+
for (const line of formatDiagnosis(steps)) {
|
|
2227
|
+
this.#ui.addInfoMessage(line);
|
|
2228
|
+
}
|
|
2229
|
+
})
|
|
2230
|
+
.catch((err) => {
|
|
2231
|
+
this.#ui.addErrorMessage(`Diagnostics failed to run: ${err.message}`);
|
|
2232
|
+
});
|
|
2233
|
+
break;
|
|
2234
|
+
}
|
|
2235
|
+
|
|
2174
2236
|
case '/find': {
|
|
2175
2237
|
const term = parts.slice(1).join(' ').trim();
|
|
2176
2238
|
// Opens the same overlay as Ctrl+F, pre-filled when a term is given.
|
|
@@ -2283,9 +2345,12 @@ export class ChatController {
|
|
|
2283
2345
|
sentAt: Date.now(),
|
|
2284
2346
|
});
|
|
2285
2347
|
this.#broadcastPayload(reactionPayload);
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2348
|
+
// Show it on the message right away instead of announcing it.
|
|
2349
|
+
if (!this.#applyReaction(this.#lastReceivedMessageId, emoji)) {
|
|
2350
|
+
this.#ui.addSystemMessage(
|
|
2351
|
+
`${emoji} You reacted to ${this.#lastReceivedNickname}'s message`,
|
|
2352
|
+
);
|
|
2353
|
+
}
|
|
2289
2354
|
break;
|
|
2290
2355
|
}
|
|
2291
2356
|
|
|
@@ -2306,7 +2371,19 @@ export class ChatController {
|
|
|
2306
2371
|
sentAt: Date.now(),
|
|
2307
2372
|
});
|
|
2308
2373
|
this.#broadcastPayload(editPayload);
|
|
2309
|
-
|
|
2374
|
+
// Rewrite our own line in place, like the peers will.
|
|
2375
|
+
const editEntry = this.#editableMessage(this.#lastSentMessageId);
|
|
2376
|
+
if (editEntry) {
|
|
2377
|
+
editEntry.text = editText;
|
|
2378
|
+
this.#ui.replaceMessageText(
|
|
2379
|
+
editEntry.lineIndex,
|
|
2380
|
+
editEntry.nickname,
|
|
2381
|
+
editText,
|
|
2382
|
+
editEntry.opts,
|
|
2383
|
+
);
|
|
2384
|
+
} else {
|
|
2385
|
+
this.#ui.addSystemMessage(`You edited: ${editText} (edited)`);
|
|
2386
|
+
}
|
|
2310
2387
|
break;
|
|
2311
2388
|
}
|
|
2312
2389
|
|
|
@@ -2321,8 +2398,14 @@ export class ChatController {
|
|
|
2321
2398
|
sentAt: Date.now(),
|
|
2322
2399
|
});
|
|
2323
2400
|
this.#broadcastPayload(deletePayload);
|
|
2401
|
+
const delEntry = this.#editableMessage(this.#lastSentMessageId);
|
|
2402
|
+
if (delEntry) {
|
|
2403
|
+
this.#ui.tombstoneMessage(delEntry.lineIndex, this.#nickname);
|
|
2404
|
+
this.#messageLines.delete(this.#lastSentMessageId);
|
|
2405
|
+
} else {
|
|
2406
|
+
this.#ui.addSystemMessage('You deleted a message');
|
|
2407
|
+
}
|
|
2324
2408
|
this.#lastSentMessageId = null;
|
|
2325
|
-
this.#ui.addSystemMessage('You deleted a message');
|
|
2326
2409
|
break;
|
|
2327
2410
|
}
|
|
2328
2411
|
|
|
@@ -3019,6 +3102,55 @@ export class ChatController {
|
|
|
3019
3102
|
this.#ui.appendBadge(tracked.lineIndex, tracked.baseLine, `{green-fg}${marker}{/green-fg}`);
|
|
3020
3103
|
}
|
|
3021
3104
|
|
|
3105
|
+
// Remember where a message was drawn so reactions/edits/deletes can change
|
|
3106
|
+
// it in place. Bounded like the receipt tracker.
|
|
3107
|
+
#rememberMessage(messageId, lineIndex, nickname, text, render) {
|
|
3108
|
+
if (!messageId || lineIndex === undefined || lineIndex < 0) {
|
|
3109
|
+
return;
|
|
3110
|
+
}
|
|
3111
|
+
this.#messageLines.set(messageId, {
|
|
3112
|
+
lineIndex,
|
|
3113
|
+
nickname,
|
|
3114
|
+
text,
|
|
3115
|
+
opts: render?.opts || {},
|
|
3116
|
+
room: this.#currentRoom,
|
|
3117
|
+
});
|
|
3118
|
+
if (this.#messageLines.size > 200) {
|
|
3119
|
+
const oldest = this.#messageLines.keys().next().value;
|
|
3120
|
+
this.#messageLines.delete(oldest);
|
|
3121
|
+
this.#reactions.delete(oldest);
|
|
3122
|
+
}
|
|
3123
|
+
}
|
|
3124
|
+
|
|
3125
|
+
// A message can only be redrawn while its room is the one on screen.
|
|
3126
|
+
#editableMessage(messageId) {
|
|
3127
|
+
const entry = this.#messageLines.get(messageId);
|
|
3128
|
+
if (!entry || entry.room !== this.#currentRoom) {
|
|
3129
|
+
return null;
|
|
3130
|
+
}
|
|
3131
|
+
return entry;
|
|
3132
|
+
}
|
|
3133
|
+
|
|
3134
|
+
#applyReaction(messageId, emoji) {
|
|
3135
|
+
const entry = this.#editableMessage(messageId);
|
|
3136
|
+
if (!entry) {
|
|
3137
|
+
return false;
|
|
3138
|
+
}
|
|
3139
|
+
const counts = this.#reactions.get(messageId) || new Map();
|
|
3140
|
+
counts.set(emoji, (counts.get(emoji) || 0) + 1);
|
|
3141
|
+
this.#reactions.set(messageId, counts);
|
|
3142
|
+
|
|
3143
|
+
// Re-render the line, then hang the reactions off the end of it.
|
|
3144
|
+
const badge = [...counts.entries()].map(([e, n]) => (n > 1 ? `${e}${n}` : e)).join(' ');
|
|
3145
|
+
this.#ui.replaceMessageText(entry.lineIndex, entry.nickname, entry.text, {
|
|
3146
|
+
...entry.opts,
|
|
3147
|
+
edited: entry.opts?.edited,
|
|
3148
|
+
});
|
|
3149
|
+
const rebuilt = this.#ui.getLine(entry.lineIndex);
|
|
3150
|
+
this.#ui.appendBadge(entry.lineIndex, rebuilt, badge);
|
|
3151
|
+
return true;
|
|
3152
|
+
}
|
|
3153
|
+
|
|
3022
3154
|
#trackSentMessage(messageId, lineIndex) {
|
|
3023
3155
|
const baseLine = this.#ui.getLine(lineIndex);
|
|
3024
3156
|
if (baseLine === null || baseLine === undefined) {
|
|
@@ -3321,9 +3453,10 @@ export class ChatController {
|
|
|
3321
3453
|
this.#ui.addQuoteLine(replyTo.nickname, replyTo.excerpt, true);
|
|
3322
3454
|
}
|
|
3323
3455
|
const ephLabel = this.#ephemeralMode ? this.#formatDuration(this.#ephemeralDurationMs) : null;
|
|
3324
|
-
const { lineIndex } = isAction
|
|
3456
|
+
const { lineIndex, render } = isAction
|
|
3325
3457
|
? this.#ui.addActionMessage(this.#nickname, text)
|
|
3326
3458
|
: this.#ui.addMessage(this.#nickname, text, false, ephLabel, this.#deniableMode);
|
|
3459
|
+
this.#rememberMessage(messageId, lineIndex, this.#nickname, text, render);
|
|
3327
3460
|
|
|
3328
3461
|
if (this.#ephemeralMode) {
|
|
3329
3462
|
this.#scheduleEphemeralRemoval(lineIndex, this.#ephemeralDurationMs, this.#nickname);
|
package/src/client/UI.js
CHANGED
|
@@ -46,6 +46,7 @@ const COMMAND_INFO = [
|
|
|
46
46
|
['/notify', 'Desktop notifications'],
|
|
47
47
|
['/search', 'Search history (on disk)'],
|
|
48
48
|
['/find', 'Find in this room and jump to it'],
|
|
49
|
+
['/doctor', 'Diagnose why a connection fails'],
|
|
49
50
|
['/history', 'Recent messages from history'],
|
|
50
51
|
['/export', 'Export history'],
|
|
51
52
|
['/backup', 'Back up identity + trust'],
|
|
@@ -112,6 +113,7 @@ export const COMMANDS = [
|
|
|
112
113
|
'/invite',
|
|
113
114
|
'/search',
|
|
114
115
|
'/find',
|
|
116
|
+
'/doctor',
|
|
115
117
|
'/history',
|
|
116
118
|
'/export',
|
|
117
119
|
'/backup',
|
|
@@ -1781,6 +1783,64 @@ export class UI extends EventEmitter {
|
|
|
1781
1783
|
trust = 'none',
|
|
1782
1784
|
) {
|
|
1783
1785
|
this.#daySeparator();
|
|
1786
|
+
const isSelfNow = nickname === this.#nickname || nickname.includes('\u2192');
|
|
1787
|
+
const opts = {
|
|
1788
|
+
isDM,
|
|
1789
|
+
ephemeralLabel,
|
|
1790
|
+
deniable,
|
|
1791
|
+
mentioned,
|
|
1792
|
+
trust,
|
|
1793
|
+
grouped: !isSelfNow && !isDM && this.#lastSender === nickname,
|
|
1794
|
+
stamp: time(),
|
|
1795
|
+
};
|
|
1796
|
+
const line = this.#composeMessageLine(nickname, text, opts);
|
|
1797
|
+
|
|
1798
|
+
this.#lines.push(line);
|
|
1799
|
+
this.#chatLog.log(line);
|
|
1800
|
+
this.#screen.render();
|
|
1801
|
+
this.#lastSender = isSelfNow ? 'self' : nickname;
|
|
1802
|
+
if (!isSelfNow) {
|
|
1803
|
+
this.#noteIncoming(mentioned || isDM);
|
|
1804
|
+
}
|
|
1805
|
+
return { lineIndex: this.#lines.length - 1, render: { nickname, opts } };
|
|
1806
|
+
}
|
|
1807
|
+
|
|
1808
|
+
/**
|
|
1809
|
+
* Rewrite an existing message line with new text — used by /edit, so an
|
|
1810
|
+
* edited message changes IN PLACE instead of arriving as a separate line the
|
|
1811
|
+
* reader has to mentally staple to the original.
|
|
1812
|
+
*/
|
|
1813
|
+
replaceMessageText(lineIndex, nickname, newText, opts) {
|
|
1814
|
+
this.updateLine(
|
|
1815
|
+
lineIndex,
|
|
1816
|
+
this.#composeMessageLine(nickname, newText, { ...opts, edited: true }),
|
|
1817
|
+
);
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1820
|
+
/** Replace a message with a tombstone (used by /delete). */
|
|
1821
|
+
tombstoneMessage(lineIndex, nickname) {
|
|
1822
|
+
this.updateLine(
|
|
1823
|
+
lineIndex,
|
|
1824
|
+
` {white-fg}[${time()}]{/white-fg} {#666666-fg}\ud83d\udeab ${blessed.escape(
|
|
1825
|
+
nickname,
|
|
1826
|
+
)} deleted a message{/#666666-fg}`,
|
|
1827
|
+
);
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1830
|
+
// Builds a message line. Shared by addMessage and replaceMessageText so an
|
|
1831
|
+
// edited message keeps exactly the layout it had (alignment, grouping,
|
|
1832
|
+
// badges) instead of drifting into a different shape.
|
|
1833
|
+
#composeMessageLine(nickname, text, opts) {
|
|
1834
|
+
const {
|
|
1835
|
+
isDM = false,
|
|
1836
|
+
ephemeralLabel = null,
|
|
1837
|
+
deniable = false,
|
|
1838
|
+
mentioned = false,
|
|
1839
|
+
trust = 'none',
|
|
1840
|
+
grouped = false,
|
|
1841
|
+
edited = false,
|
|
1842
|
+
stamp = time(),
|
|
1843
|
+
} = opts || {};
|
|
1784
1844
|
|
|
1785
1845
|
const color = nickColor(nickname);
|
|
1786
1846
|
const isSelf = nickname === this.#nickname || nickname.includes('\u2192');
|
|
@@ -1802,24 +1862,15 @@ export class UI extends EventEmitter {
|
|
|
1802
1862
|
|
|
1803
1863
|
// Consecutive messages from the same peer collapse the avatar/name into a
|
|
1804
1864
|
// compact continuation bullet (cleaner layout).
|
|
1805
|
-
const
|
|
1865
|
+
const editedMark = edited ? ' {#8888aa-fg}(edited){/#8888aa-fg}' : '';
|
|
1806
1866
|
const core = grouped
|
|
1807
|
-
? `{${tag}}\u00b7{/${tag}} ${renderMarkdown(text)}`
|
|
1808
|
-
: `${avatar} {${tag}}${nickname}{/${tag}}${trustGlyph}${dmLabel}: ${renderMarkdown(text)}`;
|
|
1867
|
+
? `{${tag}}\u00b7{/${tag}} ${renderMarkdown(text)}${editedMark}`
|
|
1868
|
+
: `${avatar} {${tag}}${nickname}{/${tag}}${trustGlyph}${dmLabel}: ${renderMarkdown(text)}${editedMark}`;
|
|
1809
1869
|
|
|
1810
1870
|
// My own messages on the right (timestamp at the end), others on the left
|
|
1811
|
-
|
|
1812
|
-
? this.#alignRight(`${core}${ephLabel}${denLabel} {white-fg}[${
|
|
1813
|
-
: `${bar}{white-fg}[${
|
|
1814
|
-
|
|
1815
|
-
this.#lines.push(line);
|
|
1816
|
-
this.#chatLog.log(line);
|
|
1817
|
-
this.#screen.render();
|
|
1818
|
-
this.#lastSender = isSelf ? 'self' : nickname;
|
|
1819
|
-
if (!isSelf) {
|
|
1820
|
-
this.#noteIncoming(mentioned || isDM);
|
|
1821
|
-
}
|
|
1822
|
-
return { lineIndex: this.#lines.length - 1 };
|
|
1871
|
+
return isSelf
|
|
1872
|
+
? this.#alignRight(`${core}${ephLabel}${denLabel} {white-fg}[${stamp}]{/white-fg}`)
|
|
1873
|
+
: `${bar}{white-fg}[${stamp}]{/white-fg}${ephLabel}${denLabel} ${mentionMark}${core}`;
|
|
1823
1874
|
}
|
|
1824
1875
|
|
|
1825
1876
|
#daySeparator() {
|
|
@@ -23,6 +23,7 @@ import { isImageFile, renderImagePreview, loadImageBuffers } from '../client/Ima
|
|
|
23
23
|
import { detectImageProtocol, encodeInlineImage } from '../shared/terminalGraphics.js';
|
|
24
24
|
import { AuditLog, AuditEvent } from '../shared/AuditLog.js';
|
|
25
25
|
import { applyShortcodes } from '../shared/emoji.js';
|
|
26
|
+
import { diagnose, formatDiagnosis } from '../shared/doctor.js';
|
|
26
27
|
import { deriveSharedKey, encryptDeniable, decryptDeniable } from '../crypto/DeniableEncrypt.js';
|
|
27
28
|
import { GroupSession } from '../crypto/SenderKey.js';
|
|
28
29
|
import { suggestCommand } from '../shared/commandSuggest.js';
|
|
@@ -90,6 +91,10 @@ export class P2PChatController {
|
|
|
90
91
|
#autoLockTimer = null;
|
|
91
92
|
#roomTopics = new Map(); // room → { text, by, at } (E2EE among peers)
|
|
92
93
|
#historyStore; // encrypted local history (opt-in, needs a passphrase)
|
|
94
|
+
#receiptsEnabled = true; // /receipts — send read confirmations
|
|
95
|
+
#sentMessageLines = new Map(); // messageId → { lineIndex, baseLine, room }
|
|
96
|
+
#messageReaders = new Map(); // messageId → Set<nickname>
|
|
97
|
+
#pendingReceipts = new Map(); // messageId → Set<nickname> acked before we tracked it
|
|
93
98
|
#lastReceivedMessageId;
|
|
94
99
|
#lastReceivedNickname;
|
|
95
100
|
#lastSentMessageId;
|
|
@@ -610,6 +615,11 @@ export class P2PChatController {
|
|
|
610
615
|
return;
|
|
611
616
|
}
|
|
612
617
|
|
|
618
|
+
if (data.action === 'read_receipt') {
|
|
619
|
+
this.#onReadReceipt(fromNickname, data.messageId);
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
|
|
613
623
|
if (data.action === 'set_topic') {
|
|
614
624
|
const room = typeof data.room === 'string' ? data.room : this.#currentRoom;
|
|
615
625
|
if (typeof data.text === 'string') {
|
|
@@ -690,6 +700,23 @@ export class P2PChatController {
|
|
|
690
700
|
this.#ui.playNotification();
|
|
691
701
|
}
|
|
692
702
|
|
|
703
|
+
// Confirm the read to the author — an ordinary E2EE payload, sent only to
|
|
704
|
+
// them. Never for ephemeral or deniable messages: acknowledging those
|
|
705
|
+
// would defeat the point of not leaving a trace.
|
|
706
|
+
if (
|
|
707
|
+
this.#receiptsEnabled &&
|
|
708
|
+
data.messageId &&
|
|
709
|
+
!data.ephemeral &&
|
|
710
|
+
!isDeniable &&
|
|
711
|
+
!data.deniable
|
|
712
|
+
) {
|
|
713
|
+
this.#broadcastPayload(
|
|
714
|
+
JSON.stringify({ action: 'read_receipt', messageId: data.messageId, sentAt: Date.now() }),
|
|
715
|
+
false,
|
|
716
|
+
fromNickname,
|
|
717
|
+
);
|
|
718
|
+
}
|
|
719
|
+
|
|
693
720
|
if (data.ephemeral && data.ephemeral > 0) {
|
|
694
721
|
this.#scheduleEphemeralRemoval(lineIndex, data.ephemeral, fromNickname);
|
|
695
722
|
}
|
|
@@ -906,6 +933,49 @@ export class P2PChatController {
|
|
|
906
933
|
const cmd = parts[0].toLowerCase();
|
|
907
934
|
|
|
908
935
|
switch (cmd) {
|
|
936
|
+
case '/doctor': {
|
|
937
|
+
const target = parts.slice(1).join(' ').trim();
|
|
938
|
+
if (!target) {
|
|
939
|
+
this.#ui.addErrorMessage(
|
|
940
|
+
'Usage: /doctor <host:port> — P2P finds peers over mDNS, so give an address to test',
|
|
941
|
+
);
|
|
942
|
+
break;
|
|
943
|
+
}
|
|
944
|
+
this.#ui.addInfoMessage(`Diagnosing ${target} …`);
|
|
945
|
+
diagnose(target)
|
|
946
|
+
.then((steps) => {
|
|
947
|
+
for (const line of formatDiagnosis(steps)) {
|
|
948
|
+
this.#ui.addInfoMessage(line);
|
|
949
|
+
}
|
|
950
|
+
})
|
|
951
|
+
.catch((err) => {
|
|
952
|
+
this.#ui.addErrorMessage(`Diagnostics failed to run: ${err.message}`);
|
|
953
|
+
});
|
|
954
|
+
break;
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
case '/find': {
|
|
958
|
+
// Pure UI: searches the lines on screen, so it works the same here.
|
|
959
|
+
this.#ui.openFinder(parts.slice(1).join(' ').trim());
|
|
960
|
+
break;
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
case '/receipts': {
|
|
964
|
+
const arg = parts[1]?.toLowerCase();
|
|
965
|
+
if (arg === 'off') {
|
|
966
|
+
this.#receiptsEnabled = false;
|
|
967
|
+
this.#ui.addInfoMessage('Read receipts disabled — you no longer send read confirmations');
|
|
968
|
+
} else if (arg === 'on') {
|
|
969
|
+
this.#receiptsEnabled = true;
|
|
970
|
+
this.#ui.addInfoMessage('Read receipts enabled');
|
|
971
|
+
} else {
|
|
972
|
+
this.#ui.addInfoMessage(
|
|
973
|
+
`Read receipts: ${this.#receiptsEnabled ? 'enabled' : 'disabled'}. Use /receipts on or /receipts off`,
|
|
974
|
+
);
|
|
975
|
+
}
|
|
976
|
+
break;
|
|
977
|
+
}
|
|
978
|
+
|
|
909
979
|
case '/search': {
|
|
910
980
|
if (!this.#historyStore?.isOpen) {
|
|
911
981
|
this.#ui.addErrorMessage('History disabled — start with a passphrase');
|
|
@@ -1263,6 +1333,9 @@ export class P2PChatController {
|
|
|
1263
1333
|
this.#ui.addInfoMessage(' /history [n] - Last n messages from history');
|
|
1264
1334
|
this.#ui.addInfoMessage(' /export [path] - Export the history');
|
|
1265
1335
|
this.#ui.addInfoMessage(' /retention <time> - Local history retention');
|
|
1336
|
+
this.#ui.addInfoMessage(' /receipts [on|off] - Read receipts (✓✓)');
|
|
1337
|
+
this.#ui.addInfoMessage(' /find [term] - Find in this room and jump (Ctrl+F)');
|
|
1338
|
+
this.#ui.addInfoMessage(' /doctor <host:port> - Diagnose why a connection fails');
|
|
1266
1339
|
this.#ui.addInfoMessage(' /watch [add|remove|clear] - Alert on a keyword');
|
|
1267
1340
|
this.#ui.addInfoMessage(' /help - Show this help');
|
|
1268
1341
|
this.#ui.addInfoMessage(' /tips - Show a security/UX tip');
|
|
@@ -2363,9 +2436,68 @@ export class P2PChatController {
|
|
|
2363
2436
|
|
|
2364
2437
|
if (this.#ephemeralMode) {
|
|
2365
2438
|
this.#scheduleEphemeralRemoval(lineIndex, this.#ephemeralDurationMs, this.#nickname);
|
|
2439
|
+
} else if (!this.#deniableMode) {
|
|
2440
|
+
this.#trackSentMessage(messageId, lineIndex);
|
|
2441
|
+
}
|
|
2442
|
+
}
|
|
2443
|
+
|
|
2444
|
+
// ── Read receipts ────────────────────────────────────────────
|
|
2445
|
+
#trackSentMessage(messageId, lineIndex) {
|
|
2446
|
+
const baseLine = this.#ui.getLine(lineIndex);
|
|
2447
|
+
if (baseLine === null || baseLine === undefined) {
|
|
2448
|
+
return;
|
|
2449
|
+
}
|
|
2450
|
+
this.#sentMessageLines.set(messageId, { lineIndex, baseLine, room: this.#currentRoom });
|
|
2451
|
+
|
|
2452
|
+
// A peer on the same machine (or a very fast link) can acknowledge before
|
|
2453
|
+
// we finish rendering our own echo. Apply anything that arrived early.
|
|
2454
|
+
const early = this.#pendingReceipts.get(messageId);
|
|
2455
|
+
if (early) {
|
|
2456
|
+
this.#pendingReceipts.delete(messageId);
|
|
2457
|
+
for (const nickname of early) {
|
|
2458
|
+
this.#onReadReceipt(nickname, messageId);
|
|
2459
|
+
}
|
|
2460
|
+
}
|
|
2461
|
+
|
|
2462
|
+
// Bound memory: keep only the most recent 200 tracked messages
|
|
2463
|
+
if (this.#sentMessageLines.size > 200) {
|
|
2464
|
+
const oldest = this.#sentMessageLines.keys().next().value;
|
|
2465
|
+
this.#sentMessageLines.delete(oldest);
|
|
2466
|
+
this.#messageReaders.delete(oldest);
|
|
2366
2467
|
}
|
|
2367
2468
|
}
|
|
2368
2469
|
|
|
2470
|
+
#onReadReceipt(nickname, messageId) {
|
|
2471
|
+
const tracked = this.#sentMessageLines.get(messageId);
|
|
2472
|
+
if (!tracked) {
|
|
2473
|
+
// Arrived before we tracked our own message — remember it (bounded) and
|
|
2474
|
+
// let #trackSentMessage apply it a moment later.
|
|
2475
|
+
if (messageId && this.#pendingReceipts.size < 200) {
|
|
2476
|
+
const set = this.#pendingReceipts.get(messageId) || new Set();
|
|
2477
|
+
set.add(nickname);
|
|
2478
|
+
this.#pendingReceipts.set(messageId, set);
|
|
2479
|
+
}
|
|
2480
|
+
return;
|
|
2481
|
+
}
|
|
2482
|
+
// Line indexes only address the room currently on screen.
|
|
2483
|
+
if (tracked.room && tracked.room !== this.#currentRoom) {
|
|
2484
|
+
return;
|
|
2485
|
+
}
|
|
2486
|
+
|
|
2487
|
+
let readers = this.#messageReaders.get(messageId);
|
|
2488
|
+
if (!readers) {
|
|
2489
|
+
readers = new Set();
|
|
2490
|
+
this.#messageReaders.set(messageId, readers);
|
|
2491
|
+
}
|
|
2492
|
+
if (readers.has(nickname)) {
|
|
2493
|
+
return;
|
|
2494
|
+
}
|
|
2495
|
+
readers.add(nickname);
|
|
2496
|
+
|
|
2497
|
+
const marker = readers.size > 1 ? `✓✓ ${readers.size}` : '✓✓';
|
|
2498
|
+
this.#ui.appendBadge(tracked.lineIndex, tracked.baseLine, `{green-fg}${marker}{/green-fg}`);
|
|
2499
|
+
}
|
|
2500
|
+
|
|
2369
2501
|
// ── Send encrypted DM to one peer ────────────────────────────
|
|
2370
2502
|
#sendMessageToPeer(peerNickname, text) {
|
|
2371
2503
|
const peerPublicKey = this.#handshake.getPeerPublicKey(peerNickname);
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
// Connection doctor: answers "why can't I connect?" in the order the failures
|
|
2
|
+
// actually happen, so a user without a terminal debugger can fix it alone.
|
|
3
|
+
//
|
|
4
|
+
// Every step reports what was checked, whether it passed, and — when it fails
|
|
5
|
+
// — what to do about it. A failing step stops the run: there is no point
|
|
6
|
+
// testing TLS when the TCP port never opened.
|
|
7
|
+
import { connect as netConnect } from 'node:net';
|
|
8
|
+
import { connect as tlsConnect } from 'node:tls';
|
|
9
|
+
import { lookup as dnsLookup } from 'node:dns';
|
|
10
|
+
import { PROTOCOL_VERSION } from './constants.js';
|
|
11
|
+
|
|
12
|
+
const DEFAULT_TIMEOUT_MS = 6000;
|
|
13
|
+
|
|
14
|
+
/** Split "wss://host:3600" (or "host:3600") into its parts. */
|
|
15
|
+
export function parseTarget(raw) {
|
|
16
|
+
const value = String(raw || '').trim();
|
|
17
|
+
if (!value) {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
const withScheme = /^wss?:\/\//.test(value) ? value : `wss://${value}`;
|
|
21
|
+
try {
|
|
22
|
+
const url = new URL(withScheme);
|
|
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;
|
|
29
|
+
if (!url.hostname || !Number.isInteger(port) || port < 1 || port > 65535) {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
return { host: url.hostname, port, tls: url.protocol === 'wss:', url: withScheme };
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const isIpLiteral = (host) => /^[\d.]+$/.test(host) || host.includes(':');
|
|
39
|
+
|
|
40
|
+
function step(name, ok, detail, hint = null) {
|
|
41
|
+
return hint ? { name, ok, detail, hint } : { name, ok, detail };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function resolveHost(host, deps) {
|
|
45
|
+
return new Promise((resolve) => {
|
|
46
|
+
deps.lookup(host, (err, address) => resolve(err ? null : address));
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function probeTcp(host, port, timeoutMs, deps) {
|
|
51
|
+
return new Promise((resolve) => {
|
|
52
|
+
const socket = deps.netConnect({ host, port });
|
|
53
|
+
const done = (result) => {
|
|
54
|
+
socket.removeAllListeners();
|
|
55
|
+
socket.destroy();
|
|
56
|
+
resolve(result);
|
|
57
|
+
};
|
|
58
|
+
socket.setTimeout(timeoutMs);
|
|
59
|
+
socket.on('connect', () => done({ ok: true }));
|
|
60
|
+
socket.on('timeout', () => done({ ok: false, reason: 'timeout' }));
|
|
61
|
+
socket.on('error', (err) => done({ ok: false, reason: err.code || err.message }));
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function probeTls(host, port, timeoutMs, deps) {
|
|
66
|
+
return new Promise((resolve) => {
|
|
67
|
+
// SNI is a hostname field: Node throws outright if given an IP, and on a
|
|
68
|
+
// LAN the target is almost always an IP. Send it only when we have a name.
|
|
69
|
+
const sni = isIpLiteral(host) ? {} : { servername: host };
|
|
70
|
+
// Diagnostic socket only: it completes the handshake, reads the
|
|
71
|
+
// certificate, reports it and closes — no data is ever sent over it.
|
|
72
|
+
// rejectUnauthorized is off precisely so a self-signed LAN certificate
|
|
73
|
+
// still reaches this point and can be DESCRIBED to the user instead of
|
|
74
|
+
// failing opaquely. The real chat connection does not run this way: it
|
|
75
|
+
// enforces strict verification for hosts that ever presented a CA-valid
|
|
76
|
+
// certificate (see crypto/CertPinStore.js).
|
|
77
|
+
const socket = deps.tlsConnect({ host, port, rejectUnauthorized: false, ...sni });
|
|
78
|
+
const done = (result) => {
|
|
79
|
+
socket.removeAllListeners();
|
|
80
|
+
socket.destroy();
|
|
81
|
+
resolve(result);
|
|
82
|
+
};
|
|
83
|
+
socket.setTimeout(timeoutMs);
|
|
84
|
+
socket.on('secureConnect', () => {
|
|
85
|
+
const cert = socket.getPeerCertificate?.() || {};
|
|
86
|
+
done({
|
|
87
|
+
ok: true,
|
|
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,
|
|
94
|
+
issuer: cert.issuer?.O || cert.issuer?.CN || 'unknown',
|
|
95
|
+
fingerprint: cert.fingerprint256 || null,
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
socket.on('timeout', () => done({ ok: false, reason: 'timeout' }));
|
|
99
|
+
socket.on('error', (err) => done({ ok: false, reason: err.code || err.message }));
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Run the connection checks against `target`.
|
|
105
|
+
*
|
|
106
|
+
* @param {string} target - what the user typed at the Server prompt
|
|
107
|
+
* @param {object} [opts]
|
|
108
|
+
* @param {number} [opts.timeoutMs]
|
|
109
|
+
* @param {object} [opts.deps] - injectable node primitives (tests)
|
|
110
|
+
* @returns {Promise<Array<{name, ok, detail, hint?}>>}
|
|
111
|
+
*/
|
|
112
|
+
export async function diagnose(target, opts = {}) {
|
|
113
|
+
const timeoutMs = opts.timeoutMs || DEFAULT_TIMEOUT_MS;
|
|
114
|
+
const deps = {
|
|
115
|
+
lookup: dnsLookup,
|
|
116
|
+
netConnect,
|
|
117
|
+
tlsConnect,
|
|
118
|
+
...(opts.deps || {}),
|
|
119
|
+
};
|
|
120
|
+
const steps = [];
|
|
121
|
+
|
|
122
|
+
const parsed = parseTarget(target);
|
|
123
|
+
if (!parsed) {
|
|
124
|
+
steps.push(
|
|
125
|
+
step(
|
|
126
|
+
'Address',
|
|
127
|
+
false,
|
|
128
|
+
`cannot parse "${target}"`,
|
|
129
|
+
'Use host:port — for example 192.168.1.10:3600 — or a ciphermesh:// invite.',
|
|
130
|
+
),
|
|
131
|
+
);
|
|
132
|
+
return steps;
|
|
133
|
+
}
|
|
134
|
+
steps.push(
|
|
135
|
+
step('Address', true, `${parsed.host}:${parsed.port} (${parsed.tls ? 'TLS' : 'plain'})`),
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
if (/^(localhost|127\.|::1)/.test(parsed.host)) {
|
|
139
|
+
steps.push(
|
|
140
|
+
step(
|
|
141
|
+
'Target',
|
|
142
|
+
true,
|
|
143
|
+
'localhost — this points at YOUR machine',
|
|
144
|
+
'To reach someone else, use THEIR address. localhost never leaves this computer.',
|
|
145
|
+
),
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (!isIpLiteral(parsed.host)) {
|
|
150
|
+
const address = await resolveHost(parsed.host, deps);
|
|
151
|
+
steps.push(
|
|
152
|
+
address
|
|
153
|
+
? step('DNS', true, `${parsed.host} → ${address}`)
|
|
154
|
+
: step(
|
|
155
|
+
'DNS',
|
|
156
|
+
false,
|
|
157
|
+
`cannot resolve ${parsed.host}`,
|
|
158
|
+
'Check the name, or use the IP directly.',
|
|
159
|
+
),
|
|
160
|
+
);
|
|
161
|
+
if (!address) {
|
|
162
|
+
return steps;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const tcp = await probeTcp(parsed.host, parsed.port, timeoutMs, deps);
|
|
167
|
+
if (!tcp.ok) {
|
|
168
|
+
steps.push(
|
|
169
|
+
step(
|
|
170
|
+
'TCP port',
|
|
171
|
+
false,
|
|
172
|
+
`port ${parsed.port} unreachable (${tcp.reason})`,
|
|
173
|
+
tcp.reason === 'timeout'
|
|
174
|
+
? 'Something is dropping the packets: a firewall on either side, or client isolation on the Wi-Fi router. If you can ping the host but not open the port, that is the usual cause.'
|
|
175
|
+
: 'Nothing is listening there. Is the server running, and is the port right?',
|
|
176
|
+
),
|
|
177
|
+
);
|
|
178
|
+
return steps;
|
|
179
|
+
}
|
|
180
|
+
steps.push(step('TCP port', true, `${parsed.host}:${parsed.port} is open`));
|
|
181
|
+
|
|
182
|
+
if (parsed.tls) {
|
|
183
|
+
const tls = await probeTls(parsed.host, parsed.port, timeoutMs, deps);
|
|
184
|
+
if (!tls.ok) {
|
|
185
|
+
steps.push(
|
|
186
|
+
step(
|
|
187
|
+
'TLS',
|
|
188
|
+
false,
|
|
189
|
+
`handshake failed (${tls.reason})`,
|
|
190
|
+
'The port answered but did not negotiate TLS. Is that really a CipherMesh relay?',
|
|
191
|
+
),
|
|
192
|
+
);
|
|
193
|
+
return steps;
|
|
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
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
steps.push(
|
|
223
|
+
step(
|
|
224
|
+
'Protocol',
|
|
225
|
+
true,
|
|
226
|
+
`this client speaks v${PROTOCOL_VERSION}`,
|
|
227
|
+
'If the server refuses with a protocol mismatch, update BOTH sides: npx ciphermesh@latest, or git pull && npm install from source.',
|
|
228
|
+
),
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
return steps;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Render diagnose() output as lines ready for the chat log. */
|
|
235
|
+
export function formatDiagnosis(steps) {
|
|
236
|
+
const lines = [];
|
|
237
|
+
for (const s of steps) {
|
|
238
|
+
lines.push(`${s.ok ? '✓' : '✗'} ${s.name}: ${s.detail}`);
|
|
239
|
+
if (s.hint) {
|
|
240
|
+
lines.push(` ↳ ${s.hint}`);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
const failed = steps.find((s) => !s.ok);
|
|
244
|
+
lines.push(
|
|
245
|
+
failed
|
|
246
|
+
? `Blocked at "${failed.name}". Fix that first — the checks after it were skipped.`
|
|
247
|
+
: 'All checks passed. If the chat still fails, the problem is above the network layer.',
|
|
248
|
+
);
|
|
249
|
+
return lines;
|
|
250
|
+
}
|