@claudecollab/cli 0.1.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/LICENSE +21 -0
- package/README.md +109 -0
- package/package.json +36 -0
- package/packages/cli/bin/claude-share.js +1319 -0
- package/packages/cli/package.json +15 -0
- package/packages/cli/src/brain/card.js +157 -0
- package/packages/cli/src/brain/commands.js +122 -0
- package/packages/cli/src/brain/drafts.js +557 -0
- package/packages/cli/src/brain/gate.js +134 -0
- package/packages/cli/src/brain/knocks.js +27 -0
- package/packages/cli/src/brain/log.js +123 -0
- package/packages/cli/src/brain/queue.js +81 -0
- package/packages/cli/src/brain/state.js +245 -0
- package/packages/cli/src/hooks.js +243 -0
- package/packages/cli/src/invite.js +43 -0
- package/packages/cli/src/known-relays.js +66 -0
- package/packages/cli/src/pty.js +175 -0
- package/packages/cli/src/relay-client.js +271 -0
- package/packages/cli/src/renderer.js +230 -0
- package/packages/cli/src/screen-snapshot.js +139 -0
- package/packages/cli/src/term-chatter.js +89 -0
- package/packages/relay/auth.js +18 -0
- package/packages/relay/bin/serve.js +73 -0
- package/packages/relay/names.js +27 -0
- package/packages/relay/package.json +13 -0
- package/packages/relay/public/client.js +1553 -0
- package/packages/relay/public/index.html +110 -0
- package/packages/relay/public/style.css +952 -0
- package/packages/relay/rooms.js +149 -0
- package/packages/relay/server.js +652 -0
- package/packages/relay/web.js +334 -0
- package/packages/relay/wordlists.js +38 -0
- package/packages/shared/package.json +8 -0
- package/packages/shared/protocol.js +189 -0
|
@@ -0,0 +1,652 @@
|
|
|
1
|
+
// The relay ssh server — a dumb front door (spec §architecture: "the relay
|
|
2
|
+
// understands nothing"). It hands out room codes, forwards bytes stamped with
|
|
3
|
+
// who sent them, and stores nothing durable. All product intelligence lives in
|
|
4
|
+
// the host brain; this file only routes.
|
|
5
|
+
//
|
|
6
|
+
// Two connection kinds, told apart by the ssh username (the spike-proven trick):
|
|
7
|
+
// • username 'host' → speaks the JSON-lines protocol (packages/shared)
|
|
8
|
+
// • username '<code>' → a guest terminal, raw bytes only
|
|
9
|
+
//
|
|
10
|
+
// Guest join flow (spec §knock): reject `none` auth so stock clients proceed to
|
|
11
|
+
// publickey (keyboard-interactive for keyless) → name prompt for an unseen key →
|
|
12
|
+
// knock the host → 60s waiting screen → on admit, mirror the host's screen and
|
|
13
|
+
// forward the guest's keystrokes/resizes labeled by id. Kicks ban fingerprints.
|
|
14
|
+
|
|
15
|
+
import ssh2 from 'ssh2';
|
|
16
|
+
import { readFileSync } from 'node:fs';
|
|
17
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
18
|
+
import { createRegistry } from './rooms.js';
|
|
19
|
+
import { secretsMatch } from './auth.js';
|
|
20
|
+
import { startWebDoor } from './web.js';
|
|
21
|
+
import { sanitizeName } from './names.js';
|
|
22
|
+
import { TYPES, encode, validate, Decoder } from '../shared/protocol.js';
|
|
23
|
+
|
|
24
|
+
const { Server, utils } = ssh2;
|
|
25
|
+
const { parseKey } = utils;
|
|
26
|
+
|
|
27
|
+
const DEFAULT_KNOCK_TIMEOUT_MS = 60 * 1000; // spec: 60s waiting screen
|
|
28
|
+
const DEFAULT_TTL_MS = 10 * 60 * 1000; // spec: hold the code 10 min on host drop
|
|
29
|
+
|
|
30
|
+
// Standard OpenSSH SHA256 fingerprint over the raw public-key blob.
|
|
31
|
+
function fingerprint(keyData) {
|
|
32
|
+
return 'SHA256:' + createHash('sha256').update(keyData).digest('base64').replace(/=+$/, '');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Copy — kept plain and friendly (spec frames). CRLF for raw terminals.
|
|
36
|
+
const COPY = {
|
|
37
|
+
connecting: (host, code) => `connecting to ${host}'s room ${code}…\r\n`,
|
|
38
|
+
namePrompt: 'pick a name: ',
|
|
39
|
+
passPrompt: 'room password: ',
|
|
40
|
+
badPass: '\r\nwrong password. \u{1F44B}\r\n',
|
|
41
|
+
knocking: '\u{1F6AA} knocking…\r\n',
|
|
42
|
+
denied: "\r\nthe host didn't let you in this time. no hard feelings \u{1F44B}\r\n",
|
|
43
|
+
timeout: "\r\nno answer — the host isn't available right now. try again later \u{1F44B}\r\n",
|
|
44
|
+
banned: '\r\nyou\'ve been removed from this room.\r\n',
|
|
45
|
+
kicked: '\r\nyou were removed from the room by the host.\r\n',
|
|
46
|
+
noRoom: (code) => `no room "${code}" — it may have ended or the code is wrong. \u{1F44B}\r\n`,
|
|
47
|
+
lockout: 'too many attempts — try again in a minute. \u{1F44B}\r\n',
|
|
48
|
+
ended: '\r\nthe session has ended. \u{1F44B}\r\n',
|
|
49
|
+
// Honest now that reclaim exists (spec failure table: guests see "host
|
|
50
|
+
// reconnecting…"; the relay holds the room for the TTL grace window).
|
|
51
|
+
hostGone: '\r\nhost reconnecting…\r\n',
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
function safeWrite(stream, data) {
|
|
55
|
+
try {
|
|
56
|
+
stream?.write(data);
|
|
57
|
+
} catch {
|
|
58
|
+
/* stream already gone */
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
function safeEnd(obj) {
|
|
62
|
+
try {
|
|
63
|
+
obj?.end();
|
|
64
|
+
} catch {
|
|
65
|
+
/* already closed */
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Start the relay. Resolves once it is listening.
|
|
71
|
+
*
|
|
72
|
+
* @param {object} opts
|
|
73
|
+
* @param {number} [opts.port] listen port (0 ⇒ ephemeral; spec prod: 22 & 443)
|
|
74
|
+
* @param {number} [opts.webPort] when set, also open the browser door (http + ws) on this port
|
|
75
|
+
* @param {string} [opts.host] bind address (default 127.0.0.1)
|
|
76
|
+
* @param {string} [opts.hostKeyPath] path to the ssh host private key
|
|
77
|
+
* @param {string|Buffer} [opts.hostKey] host private key inline (alternative to hostKeyPath)
|
|
78
|
+
* @param {string} [opts.hostName] name shown to guests ("<hostName>'s room …")
|
|
79
|
+
* @param {number} [opts.knockTimeoutMs] waiting-screen timeout
|
|
80
|
+
* @param {number} [opts.ttlMs] host-drop grace before the room closes
|
|
81
|
+
* @param {object} [opts.registry] extra options forwarded to createRegistry (tests)
|
|
82
|
+
* @returns {Promise<{close():void, port:number, address:object}>}
|
|
83
|
+
*/
|
|
84
|
+
export function startRelay(opts = {}) {
|
|
85
|
+
const {
|
|
86
|
+
port = 0,
|
|
87
|
+
webPort,
|
|
88
|
+
host = '127.0.0.1',
|
|
89
|
+
hostKeyPath,
|
|
90
|
+
hostKey,
|
|
91
|
+
hostName = 'the host',
|
|
92
|
+
knockTimeoutMs = DEFAULT_KNOCK_TIMEOUT_MS,
|
|
93
|
+
ttlMs = DEFAULT_TTL_MS,
|
|
94
|
+
// The public browser-facing base URL of a DEPLOYED relay (e.g.
|
|
95
|
+
// "https://claude-share.fly.dev"). Sent to the host in the room grant so its
|
|
96
|
+
// printed/copied links carry the real https origin instead of host:webPort.
|
|
97
|
+
publicUrl,
|
|
98
|
+
// Relay-wide room ceiling. Room creation is unauthenticated (any ssh client
|
|
99
|
+
// can HELLO), so a public relay needs a lid on how many rooms strangers can
|
|
100
|
+
// pile up. Far above any real usage; a flood just gets its connection closed.
|
|
101
|
+
maxRooms = 50,
|
|
102
|
+
// Room-creation credential. When set, a HELLO must carry a matching `secret`
|
|
103
|
+
// or it is REFUSED — strangers can't create rooms at all. RECLAIM stays
|
|
104
|
+
// ungated on purpose: it is already bound to the creating host's key
|
|
105
|
+
// fingerprint, and must keep working mid-session if the secret rotates.
|
|
106
|
+
roomSecret,
|
|
107
|
+
registry: registryOpts = {},
|
|
108
|
+
} = opts;
|
|
109
|
+
|
|
110
|
+
const hostKeyData = hostKey ?? (hostKeyPath ? readFileSync(hostKeyPath) : undefined);
|
|
111
|
+
if (!hostKeyData) throw new Error('startRelay: hostKey or hostKeyPath is required');
|
|
112
|
+
|
|
113
|
+
const registry = createRegistry({ ttlMs, ...registryOpts });
|
|
114
|
+
|
|
115
|
+
// Live routing state, kept out of the pure registry. code -> room.
|
|
116
|
+
// guests: Map<id, rec> admitted, mirroring
|
|
117
|
+
// pending: Map<id, rec> knocking, awaiting admit/deny
|
|
118
|
+
// seen: Map<fp, name> fingerprints seen this room (for the `seen` field)
|
|
119
|
+
const live = new Map();
|
|
120
|
+
const conns = new Set(); // every open ssh connection, for a clean shutdown
|
|
121
|
+
let webDoor = null; // the browser door (http + ws), started below when opts.webPort is set
|
|
122
|
+
|
|
123
|
+
function findRec(room, id) {
|
|
124
|
+
return room.guests.get(id) ?? room.pending.get(id);
|
|
125
|
+
}
|
|
126
|
+
function allRecs(room) {
|
|
127
|
+
return [...room.guests.values(), ...room.pending.values()];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// A guest connection is finished (kick, leave, timeout, room close, wifi drop).
|
|
131
|
+
function endGuest(rec) {
|
|
132
|
+
safeEnd(rec.stream);
|
|
133
|
+
safeEnd(rec.conn);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function onGuestGone(rec) {
|
|
137
|
+
if (rec.gone) return;
|
|
138
|
+
rec.gone = true;
|
|
139
|
+
clearTimeout(rec.knockTimer);
|
|
140
|
+
const room = live.get(rec.code);
|
|
141
|
+
if (room) {
|
|
142
|
+
room.guests.delete(rec.id);
|
|
143
|
+
room.pending.delete(rec.id);
|
|
144
|
+
}
|
|
145
|
+
registry.removeGuest(rec.code, rec.id);
|
|
146
|
+
// Tell the host so it can free the seat / clear the stale knock.
|
|
147
|
+
if (room?.hostPresent && rec.announced) safeWrite(room.hostStream, encode({ t: TYPES.LEFT, id: rec.id }));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Tear a whole room down: notify + disconnect everyone, drop registry state.
|
|
151
|
+
function closeRoom(code, msg) {
|
|
152
|
+
const room = live.get(code);
|
|
153
|
+
if (!room) return;
|
|
154
|
+
clearTimeout(room.hostTimer);
|
|
155
|
+
for (const rec of allRecs(room)) {
|
|
156
|
+
rec.gone = true; // suppress LEFT spam during teardown
|
|
157
|
+
clearTimeout(rec.knockTimer);
|
|
158
|
+
if (msg) safeWrite(rec.stream, msg);
|
|
159
|
+
endGuest(rec);
|
|
160
|
+
}
|
|
161
|
+
safeEnd(room.hostStream);
|
|
162
|
+
safeEnd(room.hostConn);
|
|
163
|
+
registry.close(code);
|
|
164
|
+
live.delete(code);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// The host's control connection closed. `conn` is the connection that closed;
|
|
168
|
+
// if it is no longer the room's host connection, a newer one has already
|
|
169
|
+
// reclaimed the room (wifi-drop race — the old socket closes late), so ignore.
|
|
170
|
+
function onHostGone(code, conn) {
|
|
171
|
+
const room = live.get(code);
|
|
172
|
+
if (!room || !room.hostPresent) return;
|
|
173
|
+
if (conn && room.hostConn !== conn) return; // superseded by a reclaim; not really gone
|
|
174
|
+
room.hostPresent = false;
|
|
175
|
+
registry.hostDropped(code); // registry starts its own grace timer
|
|
176
|
+
for (const rec of allRecs(room)) safeWrite(rec.stream, COPY.hostGone);
|
|
177
|
+
// Guests wait, visibly (spec). If the host reclaims with its key before the
|
|
178
|
+
// grace window elapses, RECLAIM cancels this timer; otherwise the room ends.
|
|
179
|
+
room.hostTimer = setTimeout(() => closeRoom(code, COPY.ended), ttlMs);
|
|
180
|
+
room.hostTimer.unref?.();
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ---- host protocol channel ------------------------------------------------
|
|
184
|
+
|
|
185
|
+
function setupHostChannel(conn, stream, hostFp) {
|
|
186
|
+
const dec = new Decoder();
|
|
187
|
+
let code = null; // this host owns exactly one room, set on `hello`/`reclaim`
|
|
188
|
+
|
|
189
|
+
const handle = (msg) => {
|
|
190
|
+
if (!validate(msg)) return; // drop anything malformed (spec: fail closed)
|
|
191
|
+
switch (msg.t) {
|
|
192
|
+
case TYPES.HELLO: {
|
|
193
|
+
if (code) return; // one room per host connection
|
|
194
|
+
if (roomSecret && !secretsMatch(msg.secret, roomSecret)) {
|
|
195
|
+
// Machine-readable refusal so the CLI can say "bad secret" instead
|
|
196
|
+
// of mistaking this for a dead relay and reconnect-looping.
|
|
197
|
+
safeWrite(stream, encode({ t: TYPES.REFUSED, reason: 'secret' }));
|
|
198
|
+
safeEnd(stream);
|
|
199
|
+
safeEnd(conn);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
if (live.size >= maxRooms) {
|
|
203
|
+
// Full: refuse by closing. The CLI's reconnect loop backs off; real
|
|
204
|
+
// rooms expire on their 10-min TTL, so capacity frees itself.
|
|
205
|
+
safeEnd(stream);
|
|
206
|
+
safeEnd(conn);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
const room = registry.create();
|
|
210
|
+
code = room.code;
|
|
211
|
+
live.set(code, {
|
|
212
|
+
code,
|
|
213
|
+
hostConn: conn,
|
|
214
|
+
hostStream: stream,
|
|
215
|
+
hostPresent: true,
|
|
216
|
+
hostFp, // gates reclaim: only this key may take the room back
|
|
217
|
+
// Optional join password (set by the host in HELLO). Both doors
|
|
218
|
+
// pre-gate every guest against it BEFORE the knock reaches the host;
|
|
219
|
+
// knock/admit stays the final gate behind it. Survives reclaim (the
|
|
220
|
+
// room object persists across a host drop).
|
|
221
|
+
pass: typeof msg.pass === 'string' && msg.pass.length ? msg.pass : null,
|
|
222
|
+
guests: new Map(),
|
|
223
|
+
pending: new Map(),
|
|
224
|
+
seen: new Map(),
|
|
225
|
+
hostTimer: null,
|
|
226
|
+
});
|
|
227
|
+
safeWrite(stream, encode({ t: TYPES.ROOM, code, ...(publicUrl ? { webUrl: publicUrl } : {}) }));
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
case TYPES.RECLAIM: {
|
|
231
|
+
if (code) return; // this connection already owns a room
|
|
232
|
+
const target = msg.code;
|
|
233
|
+
const room = live.get(target);
|
|
234
|
+
// registry.get honors TTL expiry; hostFp must match the original host
|
|
235
|
+
// key so a code-guesser cannot hijack the room brain (spec §trust).
|
|
236
|
+
if (!room || !registry.get(target) || !hostFp || room.hostFp !== hostFp) {
|
|
237
|
+
safeWrite(stream, encode({ t: TYPES.GONE, code: target }));
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
// Reattach this connection as the room's host. Newest wins: bump any
|
|
241
|
+
// still-open prior host socket (the drop may not be detected yet).
|
|
242
|
+
const oldConn = room.hostConn;
|
|
243
|
+
const oldStream = room.hostStream;
|
|
244
|
+
room.hostConn = conn;
|
|
245
|
+
room.hostStream = stream;
|
|
246
|
+
room.hostPresent = true;
|
|
247
|
+
clearTimeout(room.hostTimer); // cancel the pending end-room timer
|
|
248
|
+
room.hostTimer = null;
|
|
249
|
+
registry.hostReturned(target); // cancel the registry grace timer too
|
|
250
|
+
code = target;
|
|
251
|
+
if (oldConn && oldConn !== conn) {
|
|
252
|
+
safeEnd(oldStream);
|
|
253
|
+
safeEnd(oldConn);
|
|
254
|
+
}
|
|
255
|
+
safeWrite(stream, encode({ t: TYPES.ROOM, code, ...(publicUrl ? { webUrl: publicUrl } : {}) }));
|
|
256
|
+
// Re-sync every still-present guest's terminal size so the brain can
|
|
257
|
+
// re-clamp the shared view (sizes may have changed during the outage).
|
|
258
|
+
for (const rec of room.guests.values()) {
|
|
259
|
+
safeWrite(stream, encode({ t: TYPES.RESIZE, id: rec.id, cols: rec.cols, rows: rec.rows }));
|
|
260
|
+
}
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
case TYPES.ADMIT: {
|
|
264
|
+
const room = live.get(code);
|
|
265
|
+
const rec = room && room.pending.get(msg.id);
|
|
266
|
+
if (!rec) return; // unknown / already resolved knock
|
|
267
|
+
clearTimeout(rec.knockTimer);
|
|
268
|
+
// Registry enforces cap + ban; a race that fails here reads as a deny.
|
|
269
|
+
const ok = registry.addGuest(code, rec.id, { name: rec.name, fp: rec.fp ?? undefined, role: 'prompter' });
|
|
270
|
+
if (!ok) {
|
|
271
|
+
room.pending.delete(rec.id);
|
|
272
|
+
safeWrite(rec.stream, COPY.denied);
|
|
273
|
+
endGuest(rec);
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
room.pending.delete(rec.id);
|
|
277
|
+
room.guests.set(rec.id, rec);
|
|
278
|
+
rec.phase = 'live';
|
|
279
|
+
// A web participant has no terminal knock screen to clear, so it gets an
|
|
280
|
+
// explicit 'joined' text frame — its cue to switch from the knock view to
|
|
281
|
+
// the live session (screen bytes + state follow). ssh guests need none.
|
|
282
|
+
if (rec.kind === 'web') rec.sendText(JSON.stringify({ t: TYPES.JOINED, id: rec.id }));
|
|
283
|
+
// No "you're live" seam here: the host's join context card (sent next, via
|
|
284
|
+
// TO) already ends with one. Writing it here too printed the separator
|
|
285
|
+
// twice around the card (finding 4) — the card owns the single seam now.
|
|
286
|
+
safeWrite(stream, encode({ t: TYPES.JOINED, id: rec.id }));
|
|
287
|
+
// Hand the host the guest's terminal size so it can clamp the shared view.
|
|
288
|
+
safeWrite(stream, encode({ t: TYPES.RESIZE, id: rec.id, cols: rec.cols, rows: rec.rows }));
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
case TYPES.DENY: {
|
|
292
|
+
const room = live.get(code);
|
|
293
|
+
const rec = room && findRec(room, msg.id);
|
|
294
|
+
if (!rec) return;
|
|
295
|
+
clearTimeout(rec.knockTimer);
|
|
296
|
+
room.pending.delete(rec.id);
|
|
297
|
+
room.guests.delete(rec.id);
|
|
298
|
+
// A web rec needs a machine-readable reason — terminal copy would land as
|
|
299
|
+
// mirror bytes and the silent close then read as "host offline" (wrong).
|
|
300
|
+
if (rec.kind === 'web' && rec.sendText) rec.sendText(JSON.stringify({ t: 'error', reason: 'denied' }));
|
|
301
|
+
else safeWrite(rec.stream, COPY.denied);
|
|
302
|
+
endGuest(rec);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
case TYPES.SCREEN: {
|
|
306
|
+
const room = live.get(code);
|
|
307
|
+
if (!room) return;
|
|
308
|
+
const buf = Buffer.from(msg.data, 'base64');
|
|
309
|
+
for (const rec of room.guests.values()) if (rec.phase === 'live') safeWrite(rec.stream, buf);
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
case TYPES.TO: {
|
|
313
|
+
const room = live.get(code);
|
|
314
|
+
const rec = room && room.guests.get(msg.id);
|
|
315
|
+
if (rec) safeWrite(rec.stream, Buffer.from(msg.data, 'base64'));
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
case TYPES.STATE: {
|
|
319
|
+
// The overlay snapshot is for browser views only (ssh guests render raw
|
|
320
|
+
// screen bytes). Fan it out to every live web participant as a text frame.
|
|
321
|
+
const room = live.get(code);
|
|
322
|
+
if (!room) return;
|
|
323
|
+
const line = JSON.stringify(msg);
|
|
324
|
+
for (const rec of room.guests.values()) {
|
|
325
|
+
if (rec.kind === 'web' && rec.phase === 'live') rec.sendText(line);
|
|
326
|
+
}
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
case TYPES.DROP: {
|
|
330
|
+
const room = live.get(code);
|
|
331
|
+
const rec = room && findRec(room, msg.id);
|
|
332
|
+
if (!rec) return;
|
|
333
|
+
clearTimeout(rec.knockTimer);
|
|
334
|
+
if (msg.ban && rec.fp) registry.ban(code, rec.fp); // evicts + blocklists the fp
|
|
335
|
+
room.guests.delete(rec.id);
|
|
336
|
+
room.pending.delete(rec.id);
|
|
337
|
+
rec.gone = true; // host already knows; don't echo a LEFT back
|
|
338
|
+
// A web rec needs a machine-readable reason (the client shows a removed
|
|
339
|
+
// panel and hides the mirror); terminal copy is for ssh guests only.
|
|
340
|
+
if (rec.kind === 'web' && rec.sendText) rec.sendText(JSON.stringify({ t: 'error', reason: 'kicked' }));
|
|
341
|
+
else safeWrite(rec.stream, COPY.kicked);
|
|
342
|
+
endGuest(rec);
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
case TYPES.END: {
|
|
346
|
+
if (code) closeRoom(code, COPY.ended);
|
|
347
|
+
safeEnd(stream);
|
|
348
|
+
safeEnd(conn);
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
stream.on('data', (chunk) => {
|
|
355
|
+
for (const msg of dec.push(chunk)) handle(msg);
|
|
356
|
+
});
|
|
357
|
+
const drop = () => {
|
|
358
|
+
if (code) onHostGone(code, conn);
|
|
359
|
+
};
|
|
360
|
+
stream.on('close', drop);
|
|
361
|
+
conn.on('close', drop);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// ---- guest terminal channel ----------------------------------------------
|
|
365
|
+
|
|
366
|
+
function startGuestFlow(conn, stream, code, ident, ip, ptyInfo) {
|
|
367
|
+
const room = live.get(code);
|
|
368
|
+
// registry.get is the source of truth for existence (honors TTL expiry).
|
|
369
|
+
if (!room || !registry.get(code)) {
|
|
370
|
+
safeWrite(stream, COPY.noRoom(code));
|
|
371
|
+
safeEnd(stream);
|
|
372
|
+
safeEnd(conn);
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
safeWrite(stream, COPY.connecting(hostName, code));
|
|
377
|
+
|
|
378
|
+
if (!room.hostPresent) {
|
|
379
|
+
safeWrite(stream, COPY.hostGone);
|
|
380
|
+
safeEnd(stream);
|
|
381
|
+
safeEnd(conn);
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const fp = ident.fp; // null for keyless
|
|
386
|
+
if (fp && registry.get(code).banned.has(fp)) {
|
|
387
|
+
safeWrite(stream, COPY.banned);
|
|
388
|
+
safeEnd(stream);
|
|
389
|
+
safeEnd(conn);
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
if (!registry.tryKnock(code, ip)) {
|
|
394
|
+
safeWrite(stream, COPY.lockout);
|
|
395
|
+
safeEnd(stream);
|
|
396
|
+
safeEnd(conn);
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const rec = {
|
|
401
|
+
id: randomUUID(),
|
|
402
|
+
code,
|
|
403
|
+
conn,
|
|
404
|
+
stream,
|
|
405
|
+
fp,
|
|
406
|
+
name: null,
|
|
407
|
+
phase: room.pass ? 'pass' : 'name',
|
|
408
|
+
nameBuf: '',
|
|
409
|
+
passBuf: '',
|
|
410
|
+
cols: ptyInfo?.cols ?? 80, // spec floor; refined by window-change
|
|
411
|
+
rows: ptyInfo?.rows ?? 24,
|
|
412
|
+
knockTimer: null,
|
|
413
|
+
announced: false,
|
|
414
|
+
gone: false,
|
|
415
|
+
};
|
|
416
|
+
room.pending.set(rec.id, rec);
|
|
417
|
+
|
|
418
|
+
// A passworded room challenges EVERY guest first — known keys included (a
|
|
419
|
+
// remembered name is a convenience, not a credential). Open rooms flow as before.
|
|
420
|
+
if (room.pass) {
|
|
421
|
+
safeWrite(stream, COPY.passPrompt);
|
|
422
|
+
} else {
|
|
423
|
+
afterPass(room, rec);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
stream.on('data', (chunk) => {
|
|
427
|
+
if (rec.phase === 'pass') feedPass(room, rec, chunk);
|
|
428
|
+
else if (rec.phase === 'name') feedName(room, rec, chunk);
|
|
429
|
+
else if (rec.phase === 'live') safeWrite(room.hostStream, encode({ t: TYPES.KEY, id: rec.id, data: chunk.toString('base64') }));
|
|
430
|
+
// 'knocking': input ignored until admitted
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
const gone = () => onGuestGone(rec);
|
|
434
|
+
stream.on('close', gone);
|
|
435
|
+
conn.on('close', gone);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// The post-password continuation: a known key skips the name prompt and
|
|
439
|
+
// carries its prior name in `seen`; everyone else picks a name.
|
|
440
|
+
function afterPass(room, rec) {
|
|
441
|
+
const priorName = rec.fp ? room.seen.get(rec.fp) : undefined;
|
|
442
|
+
if (priorName !== undefined) {
|
|
443
|
+
rec.name = priorName;
|
|
444
|
+
sendKnock(room, rec, priorName); // seen = the prior name
|
|
445
|
+
} else {
|
|
446
|
+
rec.phase = 'name';
|
|
447
|
+
safeWrite(rec.stream, COPY.namePrompt);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// Line editor for the password prompt: masked echo, backspace, Enter. A wrong
|
|
452
|
+
// guess ends the connection — retrying costs a fresh connection, and each one
|
|
453
|
+
// burns a tryKnock slot, so brute force hits the per-ip lockout fast.
|
|
454
|
+
function feedPass(room, rec, chunk) {
|
|
455
|
+
for (const byte of chunk) {
|
|
456
|
+
if (byte === 0x0d || byte === 0x0a) {
|
|
457
|
+
if (secretsMatch(rec.passBuf, room.pass)) {
|
|
458
|
+
rec.passBuf = '';
|
|
459
|
+
safeWrite(rec.stream, '\r\n');
|
|
460
|
+
afterPass(room, rec);
|
|
461
|
+
} else {
|
|
462
|
+
safeWrite(rec.stream, COPY.badPass);
|
|
463
|
+
endGuest(rec);
|
|
464
|
+
}
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
if (byte === 0x7f || byte === 0x08) {
|
|
468
|
+
if (rec.passBuf.length) {
|
|
469
|
+
rec.passBuf = rec.passBuf.slice(0, -1);
|
|
470
|
+
safeWrite(rec.stream, '\b \b');
|
|
471
|
+
}
|
|
472
|
+
continue;
|
|
473
|
+
}
|
|
474
|
+
if (byte >= 0x20 && byte < 0x7f) {
|
|
475
|
+
rec.passBuf += String.fromCharCode(byte);
|
|
476
|
+
safeWrite(rec.stream, '•'); // masked echo (raw pty, no local echo)
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// Minimal line editor for the name prompt (echo, backspace, Enter).
|
|
482
|
+
function feedName(room, rec, chunk) {
|
|
483
|
+
for (const byte of chunk) {
|
|
484
|
+
if (byte === 0x0d || byte === 0x0a) {
|
|
485
|
+
finalizeName(room, rec);
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
if (byte === 0x7f || byte === 0x08) {
|
|
489
|
+
if (rec.nameBuf.length) {
|
|
490
|
+
rec.nameBuf = rec.nameBuf.slice(0, -1);
|
|
491
|
+
safeWrite(rec.stream, '\b \b');
|
|
492
|
+
}
|
|
493
|
+
continue;
|
|
494
|
+
}
|
|
495
|
+
if (byte >= 0x20 && byte < 0x7f) {
|
|
496
|
+
rec.nameBuf += String.fromCharCode(byte);
|
|
497
|
+
safeWrite(rec.stream, String.fromCharCode(byte)); // echo (raw pty, no local echo)
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function finalizeName(room, rec) {
|
|
503
|
+
// feedName already dropped non-printable bytes as it echoed; sanitizeName is the
|
|
504
|
+
// shared trim/cap/fallback (and the single source of truth both doors run).
|
|
505
|
+
const name = sanitizeName(rec.nameBuf);
|
|
506
|
+
rec.name = name;
|
|
507
|
+
if (rec.fp) room.seen.set(rec.fp, name); // remember real keys only
|
|
508
|
+
safeWrite(rec.stream, '\r\n');
|
|
509
|
+
sendKnock(room, rec, null); // first sighting ⇒ seen=null
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function sendKnock(room, rec, seen) {
|
|
513
|
+
rec.phase = 'knocking';
|
|
514
|
+
rec.announced = true;
|
|
515
|
+
safeWrite(rec.stream, COPY.knocking);
|
|
516
|
+
safeWrite(room.hostStream, encode({ t: TYPES.KNOCK, id: rec.id, name: rec.name, fp: rec.fp ?? '', seen }));
|
|
517
|
+
rec.knockTimer = setTimeout(() => {
|
|
518
|
+
if (rec.gone) return;
|
|
519
|
+
room.pending.delete(rec.id);
|
|
520
|
+
safeWrite(rec.stream, COPY.timeout);
|
|
521
|
+
endGuest(rec);
|
|
522
|
+
}, knockTimeoutMs);
|
|
523
|
+
rec.knockTimer.unref?.();
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// ---- connection + auth ----------------------------------------------------
|
|
527
|
+
|
|
528
|
+
const server = new Server({ hostKeys: [hostKeyData] }, (conn, info) => {
|
|
529
|
+
conns.add(conn);
|
|
530
|
+
const ip = info?.ip ?? 'unknown';
|
|
531
|
+
const ident = { username: null, fp: null }; // filled on successful auth
|
|
532
|
+
|
|
533
|
+
conn.on('authentication', (ctx) => {
|
|
534
|
+
switch (ctx.method) {
|
|
535
|
+
case 'publickey': {
|
|
536
|
+
const fp = fingerprint(ctx.key.data);
|
|
537
|
+
// Signature phase: prove possession before trusting the fingerprint.
|
|
538
|
+
if (ctx.signature) {
|
|
539
|
+
let key;
|
|
540
|
+
try {
|
|
541
|
+
key = parseKey(`${ctx.key.algo} ${ctx.key.data.toString('base64')}`);
|
|
542
|
+
} catch {
|
|
543
|
+
return ctx.reject();
|
|
544
|
+
}
|
|
545
|
+
if (key instanceof Error || key.verify(ctx.blob, ctx.signature, ctx.hashAlgo) !== true) {
|
|
546
|
+
return ctx.reject();
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
// Query phase (no signature) or verified: this key is acceptable.
|
|
550
|
+
ident.username = ctx.username;
|
|
551
|
+
ident.fp = fp;
|
|
552
|
+
return ctx.accept();
|
|
553
|
+
}
|
|
554
|
+
case 'keyboard-interactive': {
|
|
555
|
+
// Keyless fallback → session-only identity, no fingerprint (spec §identity).
|
|
556
|
+
ident.username = ctx.username;
|
|
557
|
+
ident.fp = null;
|
|
558
|
+
return ctx.accept();
|
|
559
|
+
}
|
|
560
|
+
default:
|
|
561
|
+
// Reject `none` and passwords; advertise the methods we actually take.
|
|
562
|
+
// (spike finding: rejecting `none` forces stock clients onto publickey.)
|
|
563
|
+
return ctx.reject(['publickey', 'keyboard-interactive']);
|
|
564
|
+
}
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
conn.on('ready', () => {
|
|
568
|
+
const isHost = ident.username === 'host';
|
|
569
|
+
conn.on('session', (accept) => {
|
|
570
|
+
const session = accept();
|
|
571
|
+
let ptyInfo = null;
|
|
572
|
+
let started = false;
|
|
573
|
+
|
|
574
|
+
session.on('pty', (a, r, i) => {
|
|
575
|
+
ptyInfo = i;
|
|
576
|
+
if (typeof a === 'function') a();
|
|
577
|
+
});
|
|
578
|
+
session.on('window-change', (a, r, i) => {
|
|
579
|
+
if (typeof a === 'function') a();
|
|
580
|
+
// Route a live guest's resize to the host.
|
|
581
|
+
const room = ident._code && live.get(ident._code);
|
|
582
|
+
const rec = room && room.guests.get(ident._rid);
|
|
583
|
+
if (rec) {
|
|
584
|
+
rec.cols = i.cols;
|
|
585
|
+
rec.rows = i.rows;
|
|
586
|
+
safeWrite(room.hostStream, encode({ t: TYPES.RESIZE, id: rec.id, cols: i.cols, rows: i.rows }));
|
|
587
|
+
}
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
const begin = (accept) => {
|
|
591
|
+
const stream = typeof accept === 'function' ? accept() : accept;
|
|
592
|
+
if (started) return;
|
|
593
|
+
started = true;
|
|
594
|
+
if (isHost) {
|
|
595
|
+
setupHostChannel(conn, stream, ident.fp);
|
|
596
|
+
} else {
|
|
597
|
+
// Stash ids so window-change can find this guest's record.
|
|
598
|
+
const before = new Set(live.get(ident.username)?.pending.keys() ?? []);
|
|
599
|
+
startGuestFlow(conn, stream, ident.username, ident, ip, ptyInfo);
|
|
600
|
+
const room = live.get(ident.username);
|
|
601
|
+
if (room) {
|
|
602
|
+
for (const id of room.pending.keys()) if (!before.has(id)) ident._rid = id;
|
|
603
|
+
ident._code = ident.username;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
};
|
|
607
|
+
session.on('shell', begin);
|
|
608
|
+
session.on('exec', (accept) => begin(accept));
|
|
609
|
+
});
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
conn.on('close', () => conns.delete(conn));
|
|
613
|
+
conn.on('error', () => {}); // client resets are routine; never crash the relay
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
function close() {
|
|
617
|
+
for (const code of [...live.keys()]) closeRoom(code, COPY.ended);
|
|
618
|
+
for (const conn of [...conns]) safeEnd(conn);
|
|
619
|
+
server.close();
|
|
620
|
+
webDoor?.close();
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
return new Promise((resolve, reject) => {
|
|
624
|
+
server.once('error', reject);
|
|
625
|
+
server.listen(port, host, function () {
|
|
626
|
+
server.removeListener('error', reject);
|
|
627
|
+
server.on('error', () => {}); // post-listen errors shouldn't crash the process
|
|
628
|
+
const address = this.address();
|
|
629
|
+
const done = (web) => {
|
|
630
|
+
webDoor = web;
|
|
631
|
+
resolve({
|
|
632
|
+
close,
|
|
633
|
+
port: address.port,
|
|
634
|
+
address,
|
|
635
|
+
webPort: web ? web.port : null,
|
|
636
|
+
webAddress: web ? web.address : null,
|
|
637
|
+
});
|
|
638
|
+
};
|
|
639
|
+
// The browser door shares this relay's live rooms + registry, so web
|
|
640
|
+
// participants are real room members subject to the same knock/ban/cap.
|
|
641
|
+
if (webPort == null) return done(null);
|
|
642
|
+
startWebDoor({ port: webPort, host, live, registry, knockTimeoutMs, onGuestGone, safeWrite }).then(done, (err) => {
|
|
643
|
+
try {
|
|
644
|
+
close();
|
|
645
|
+
} catch {
|
|
646
|
+
/* nothing to unwind */
|
|
647
|
+
}
|
|
648
|
+
reject(err);
|
|
649
|
+
});
|
|
650
|
+
});
|
|
651
|
+
});
|
|
652
|
+
}
|