@johnhenry/wsh 0.20.0 → 0.22.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 +161 -5
- package/package.json +1 -1
- package/src/client.mjs +76 -28
- package/src/index.d.ts +23 -3
- package/src/server/backends.mjs +32 -0
- package/src/server/connection.mjs +302 -39
- package/src/server/index.d.ts +95 -0
- package/src/server/index.mjs +40 -17
- package/src/server/relay.mjs +281 -0
- package/src/server/reverse-host.mjs +147 -0
- package/src/server/sessions.mjs +263 -0
- package/src/session.mjs +31 -0
|
@@ -19,11 +19,13 @@ import { FrameDecoder, frameEncode } from '../cbor.mjs';
|
|
|
19
19
|
import {
|
|
20
20
|
MSG, serverHello, challenge, authOk, authFail, openOk, openFail, sessionData,
|
|
21
21
|
exit as exitMsg, close as closeMsg, pong, fileResult, fileChunk, mcpTools, mcpResult,
|
|
22
|
+
presence as presenceMsg, error as errorMsg, sessionList, detachOk, detachFail, reversePeers, reverseReject,
|
|
22
23
|
} from '../messages.gen.mjs';
|
|
23
24
|
import {
|
|
24
25
|
generateNonce, verifyChallenge, fingerprint, importPublicKeyRaw,
|
|
25
26
|
} from '../auth.mjs';
|
|
26
27
|
import { FILE_CHUNK_BYTES } from './fs.mjs';
|
|
28
|
+
import { MAX_ATTACHMENTS } from './sessions.mjs';
|
|
27
29
|
import { MCP_CALL_ID_FEATURE } from '../client.mjs';
|
|
28
30
|
import { HOST_KEY_PREFIX, HOST_KEY_SIG_PREFIX, findClientNonce, hostKeyProofMessage, toHex } from '../host-key.mjs';
|
|
29
31
|
|
|
@@ -33,6 +35,7 @@ const MAX_RENAME_PATH_BYTES = 4096;
|
|
|
33
35
|
const WRITE_IDLE_MS = 30_000;
|
|
34
36
|
|
|
35
37
|
const MAX_CHANNELS = 64;
|
|
38
|
+
const REPLAY_CHUNK_BYTES = 32 * 1024;
|
|
36
39
|
const USERNAME_RE = /^[A-Za-z_][A-Za-z0-9_.-]{0,63}$/;
|
|
37
40
|
const enc = new TextEncoder();
|
|
38
41
|
|
|
@@ -49,6 +52,8 @@ const toBytes = (d) => (typeof d === 'string' ? enc.encode(d) : d);
|
|
|
49
52
|
* @param {{ pubkey: boolean, password: Function | null }} cfg.methods - which auth methods are on
|
|
50
53
|
* @param {object | null} cfg.hostKey - from loadHostKey
|
|
51
54
|
* @param {object | null} cfg.mcp - from createMcpHost
|
|
55
|
+
* @param {object | null} cfg.relay - a RelayHub (see relay.mjs); null = not a relay
|
|
56
|
+
* @param {object | null} cfg.sessions - a SessionRegistry (see sessions.mjs); null = sessions die with their connection
|
|
52
57
|
* @param {{ limiter: object, key: Function, failureDelayMs: number } | null} cfg.rateLimit - password throttle
|
|
53
58
|
* @param {number} cfg.bindTimeoutMs - how long exec output waits for the data stream
|
|
54
59
|
* @param {(line: string) => void} cfg.log
|
|
@@ -56,12 +61,20 @@ const toBytes = (d) => (typeof d === 'string' ? enc.encode(d) : d);
|
|
|
56
61
|
export function createConnectionFactory(cfg) {
|
|
57
62
|
let counter = 0;
|
|
58
63
|
|
|
59
|
-
|
|
64
|
+
/**
|
|
65
|
+
* Attach one connection. Two shapes:
|
|
66
|
+
* - bytes (the normal one): `{ send(bytes), remote, closeTransport() }` -> `{ receive(bytes), close() }`, speaking QMux.
|
|
67
|
+
* - messages: `{ sendMessage(msg), authenticated, dataStreams }` -> `{ receiveMessage(msg), bindStream(s), close() }`,
|
|
68
|
+
* for a transport that already frames messages itself (a relay bridge, WebTransport). `authenticated`
|
|
69
|
+
* (`{ username, fingerprint }`) skips the handshake for a peer something else has already authenticated;
|
|
70
|
+
* `dataStreams: false` means there are no client-opened streams to bind, so exec output rides SessionData.
|
|
71
|
+
*/
|
|
72
|
+
return function attach({ send: sendBytes, sendMessage = null, remote = {}, authenticated = null, dataStreams = true, closeTransport = null }) {
|
|
60
73
|
const cid = ++counter;
|
|
61
74
|
const log = (m) => cfg.log(`[conn ${cid}] ${m}`);
|
|
62
75
|
const state = {
|
|
63
|
-
sessionId: randomUUID(), nonce: null, username: null, authed:
|
|
64
|
-
closed: false, nextChannel: 0, fingerprint: null,
|
|
76
|
+
sessionId: randomUUID(), nonce: null, username: authenticated?.username ?? null, authed: !!authenticated,
|
|
77
|
+
closed: false, nextChannel: 0, fingerprint: authenticated?.fingerprint ?? null,
|
|
65
78
|
};
|
|
66
79
|
/** @type {Map<number, any>} */
|
|
67
80
|
const channels = new Map();
|
|
@@ -75,11 +88,16 @@ export function createConnectionFactory(cfg) {
|
|
|
75
88
|
const pendingSends = [];
|
|
76
89
|
let control = null;
|
|
77
90
|
let chain = Promise.resolve();
|
|
91
|
+
/** This connection as the relay hub sees it (created on first need). */
|
|
92
|
+
let relayHandle = null;
|
|
78
93
|
|
|
79
|
-
const qmux = new QMuxConnection({ isClient: false, send: (b) => { if (!state.closed) sendBytes(b); } });
|
|
94
|
+
const qmux = sendMessage ? null : new QMuxConnection({ isClient: false, send: (b) => { if (!state.closed) sendBytes(b); } });
|
|
80
95
|
|
|
81
96
|
const send = (msg) => {
|
|
82
97
|
if (state.closed) return Promise.resolve();
|
|
98
|
+
if (sendMessage) {
|
|
99
|
+
try { return Promise.resolve(sendMessage(msg)).catch(() => {}); } catch { return Promise.resolve(); }
|
|
100
|
+
}
|
|
83
101
|
const bytes = frameEncode(msg);
|
|
84
102
|
if (control) return control.write(bytes).catch(() => {});
|
|
85
103
|
pendingSends.push(bytes);
|
|
@@ -89,18 +107,29 @@ export function createConnectionFactory(cfg) {
|
|
|
89
107
|
function shutdown(why) {
|
|
90
108
|
if (state.closed) return;
|
|
91
109
|
state.closed = true;
|
|
92
|
-
|
|
110
|
+
// A hosted session survives its connection (it is detached, then expires or is resumed);
|
|
111
|
+
// everything else dies with it.
|
|
112
|
+
for (const ch of [...channels.values()]) (ch.detach ?? ch.kill)?.call(ch);
|
|
93
113
|
channels.clear();
|
|
94
114
|
for (const c of mcpCalls) c.abort(new Error('connection closed'));
|
|
95
115
|
mcpCalls.clear();
|
|
96
116
|
for (const w of fileWrites.values()) clearTimeout(w.timer);
|
|
97
117
|
fileWrites.clear();
|
|
98
|
-
try { qmux
|
|
118
|
+
try { qmux?.destroy(new Error(why)); } catch { /* already gone */ }
|
|
119
|
+
// The host ending the connection (a relay bridge ending, a refused login) must end the socket too.
|
|
120
|
+
try { closeTransport?.(); } catch { /* already gone */ }
|
|
121
|
+
relayHandle && cfg.relay?.drop(relayHandle);
|
|
99
122
|
log(`closed (${why})`);
|
|
100
123
|
}
|
|
101
124
|
|
|
125
|
+
// A transport that closes right behind a message (a client's `disconnect()` sends Close for
|
|
126
|
+
// every channel and then goes) must not make the host forget that message: it is processed
|
|
127
|
+
// first, then the connection is torn down.
|
|
128
|
+
const shutdownAfterQueue = (why) => { chain = chain.then(() => shutdown(why)); };
|
|
129
|
+
|
|
130
|
+
if (qmux) {
|
|
102
131
|
qmux.onError = (e) => log(`qmux error: ${e.message}`);
|
|
103
|
-
qmux.onClose = () =>
|
|
132
|
+
qmux.onClose = () => shutdownAfterQueue('peer sent CONNECTION_CLOSE');
|
|
104
133
|
qmux.onStreamOpen = (s) => {
|
|
105
134
|
if (s.id !== 0) { bindDataStream(s); return; }
|
|
106
135
|
control = s;
|
|
@@ -109,11 +138,12 @@ export function createConnectionFactory(cfg) {
|
|
|
109
138
|
try { msgs = decoder.feed(d); } catch (e) { log(`frame decode error: ${e.message}`); return; }
|
|
110
139
|
for (const m of msgs) chain = chain.then(() => handle(m)).catch((e) => log(`handler error: ${e.message}`));
|
|
111
140
|
};
|
|
112
|
-
s.onEnd = () =>
|
|
113
|
-
s.onReset = () =>
|
|
141
|
+
s.onEnd = () => shutdownAfterQueue('control stream FIN');
|
|
142
|
+
s.onReset = () => shutdownAfterQueue('control stream reset');
|
|
114
143
|
for (const b of pendingSends.splice(0)) s.write(b).catch(() => {});
|
|
115
144
|
};
|
|
116
145
|
qmux.sendHandshake();
|
|
146
|
+
}
|
|
117
147
|
|
|
118
148
|
function bindDataStream(s) {
|
|
119
149
|
const channelId = pendingStreams.shift();
|
|
@@ -127,6 +157,7 @@ export function createConnectionFactory(cfg) {
|
|
|
127
157
|
async function handle(m) {
|
|
128
158
|
if (state.closed) return;
|
|
129
159
|
if (!state.authed) return handleAuth(m);
|
|
160
|
+
if (await handleRelay(m)) return;
|
|
130
161
|
switch (m.type) {
|
|
131
162
|
case MSG.PING: return send(pong({ id: m.id }));
|
|
132
163
|
case MSG.OPEN: return handleOpen(m);
|
|
@@ -136,6 +167,10 @@ export function createConnectionFactory(cfg) {
|
|
|
136
167
|
case MSG.CLOSE: { const ch = channels.get(m.channel_id); if (ch) { ch.kill?.(); } return; }
|
|
137
168
|
case MSG.FILE_OP: return handleFileOp(m);
|
|
138
169
|
case MSG.FILE_CHUNK: return (fileWrites.has(m.channel_id) ? handleWriteChunk(m) : channels.get(m.channel_id)?.chunk?.(m));
|
|
170
|
+
case MSG.ATTACH: return handleAttach(m, false);
|
|
171
|
+
case MSG.RESUME: return handleAttach(m, true);
|
|
172
|
+
case MSG.DETACH: return handleDetach(m);
|
|
173
|
+
case MSG.SESSION_LIST_REQUEST: return handleSessionList();
|
|
139
174
|
case MSG.MCP_DISCOVER: return handleMcpDiscover();
|
|
140
175
|
// Not awaited: the handler chain is serial, and a slow tool must not hold up
|
|
141
176
|
// the next call (or a Close) behind it.
|
|
@@ -240,6 +275,56 @@ export function createConnectionFactory(cfg) {
|
|
|
240
275
|
log(`authenticated ${state.username} (password)`);
|
|
241
276
|
}
|
|
242
277
|
|
|
278
|
+
// ── Relay (createWshServer({ relay })) ────────────────────────────
|
|
279
|
+
|
|
280
|
+
const RELAY_TYPES = new Set([MSG.REVERSE_REGISTER, MSG.REVERSE_LIST, MSG.REVERSE_CONNECT, MSG.REVERSE_ACCEPT, MSG.REVERSE_REJECT, MSG.RELAY_FORWARD]);
|
|
281
|
+
|
|
282
|
+
function hubHandle() {
|
|
283
|
+
relayHandle ??= {
|
|
284
|
+
send, fingerprint: state.fingerprint, username: state.username,
|
|
285
|
+
close: () => shutdownAfterQueue('relay bridge ended'),
|
|
286
|
+
};
|
|
287
|
+
return relayHandle;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** @returns {Promise<boolean>} true when the message was the relay's and has been dealt with */
|
|
291
|
+
async function handleRelay(m) {
|
|
292
|
+
const hub = cfg.relay;
|
|
293
|
+
if (!hub) {
|
|
294
|
+
// Not a relay: answer instead of leaving the client to time out.
|
|
295
|
+
if (m.type === MSG.REVERSE_LIST) { await send(reversePeers({ peers: [] })); return true; }
|
|
296
|
+
if (m.type === MSG.REVERSE_CONNECT) { await send(reverseReject({ targetFingerprint: String(m.target_fingerprint ?? ''), username: '', reason: 'relay is not enabled on this server' })); return true; }
|
|
297
|
+
if (m.type === MSG.REVERSE_REGISTER) { await send(errorMsg({ code: 3, message: 'relay is not enabled on this server' })); return true; }
|
|
298
|
+
return false;
|
|
299
|
+
}
|
|
300
|
+
if (!RELAY_TYPES.has(m.type)) {
|
|
301
|
+
// Anything else a bridged connection sends that may cross a bridge, does.
|
|
302
|
+
if (!relayHandle) return false;
|
|
303
|
+
hub.touch(relayHandle);
|
|
304
|
+
return hub.forward(relayHandle, m);
|
|
305
|
+
}
|
|
306
|
+
// Relay roles are keyed by an authenticated key: from_fingerprint means nothing for a password login.
|
|
307
|
+
if (!state.fingerprint) {
|
|
308
|
+
await send(errorMsg({ code: 2, message: 'relay operations need a key login' }));
|
|
309
|
+
return true;
|
|
310
|
+
}
|
|
311
|
+
const h = hubHandle();
|
|
312
|
+
switch (m.type) {
|
|
313
|
+
case MSG.REVERSE_REGISTER: {
|
|
314
|
+
const reason = await hub.register(h, m, { username: state.username, fingerprint: state.fingerprint });
|
|
315
|
+
if (reason) {
|
|
316
|
+
log(`ReverseRegister refused: ${reason}`);
|
|
317
|
+
await send(errorMsg({ code: 2, message: `registration refused: ${reason}` }));
|
|
318
|
+
}
|
|
319
|
+
return true;
|
|
320
|
+
}
|
|
321
|
+
case MSG.REVERSE_LIST: await send(await hub.list({ username: state.username, fingerprint: state.fingerprint })); return true;
|
|
322
|
+
case MSG.REVERSE_CONNECT: await hub.connect(h, m); return true;
|
|
323
|
+
case MSG.REVERSE_ACCEPT: case MSG.REVERSE_REJECT: hub.answer(h, m); return true;
|
|
324
|
+
default: hub.forward(h, m); return true; // a RelayForward the client wrote itself
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
243
328
|
// ── MCP ───────────────────────────────────────────────────────────
|
|
244
329
|
|
|
245
330
|
const principal = () => ({ username: state.username, fingerprint: state.fingerprint });
|
|
@@ -315,6 +400,118 @@ export function createConnectionFactory(cfg) {
|
|
|
315
400
|
return send(exitMsg({ channelId, code })).then(() => send(closeMsg({ channelId })));
|
|
316
401
|
}
|
|
317
402
|
|
|
403
|
+
// ── Hosted sessions (createWshServer({ sessions })) ───────────────
|
|
404
|
+
|
|
405
|
+
/** Who this connection authenticated as: its key, or its username for a password login. */
|
|
406
|
+
const principalKey = () => state.fingerprint ?? `password:${state.username}`;
|
|
407
|
+
const ownerRecord = () => ({ username: state.username, fingerprint: state.fingerprint, principal: principalKey() });
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* This connection's message-backed attachment to a hosted session: output, Presence and the
|
|
411
|
+
* final Exit/Close all go through one ordered chain, so a replay is never overtaken by live output.
|
|
412
|
+
*/
|
|
413
|
+
function virtualAttachment(hosted, channelId, mode) {
|
|
414
|
+
let out = Promise.resolve();
|
|
415
|
+
const enqueue = (fn) => { out = out.then(fn).catch(() => {}); return out; };
|
|
416
|
+
return {
|
|
417
|
+
connId: cid, channelId, mode, username: state.username, principal: principalKey(),
|
|
418
|
+
write: (bytes) => enqueue(() => send(sessionData({ channelId, data: bytes }))),
|
|
419
|
+
notify: (msg) => enqueue(() => send(presenceMsg(msg))),
|
|
420
|
+
exit: (code) => enqueue(async () => {
|
|
421
|
+
channels.delete(channelId);
|
|
422
|
+
await send(exitMsg({ channelId, code }));
|
|
423
|
+
await send(closeMsg({ channelId }));
|
|
424
|
+
}),
|
|
425
|
+
flush: () => out,
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** The `channels` entry for a virtual attachment (an Attach/Resume, or a pty's own channel). */
|
|
430
|
+
function attachedChannel(hosted, att) {
|
|
431
|
+
const control = () => att.mode === 'control';
|
|
432
|
+
const ch = {
|
|
433
|
+
session: hosted, attachment: att,
|
|
434
|
+
input: (d) => { if (control()) hosted.handlers.input?.(d); },
|
|
435
|
+
resize: (c, r) => { if (control()) hosted.handlers.resize?.(c, r); },
|
|
436
|
+
signal: (name) => { if (control()) hosted.handlers.signal?.(name); },
|
|
437
|
+
// The client closed the channel: the session's owner ends it, anyone else just leaves it.
|
|
438
|
+
kill: () => {
|
|
439
|
+
if (att.principal === hosted.owner.principal && control()) hosted.kill();
|
|
440
|
+
else ch.detach();
|
|
441
|
+
},
|
|
442
|
+
detach: () => { channels.delete(att.channelId); hosted.detach(att); },
|
|
443
|
+
};
|
|
444
|
+
channels.set(att.channelId, ch);
|
|
445
|
+
return ch;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
async function handleAttach(m, isResume) {
|
|
449
|
+
const reg = cfg.sessions;
|
|
450
|
+
const fail = (code, message) => send(errorMsg({ code, message }));
|
|
451
|
+
if (!reg) return fail(3, `${isResume ? 'resume' : 'attach'} is not enabled on this server (no "sessions" option)`);
|
|
452
|
+
const s = reg.get(m.session_id);
|
|
453
|
+
const mine = !!s && s.owner.principal === principalKey();
|
|
454
|
+
const tokenOk = !!s && m.token !== undefined && reg.checkToken(s, m.token);
|
|
455
|
+
// Attach: the token OR ownership. Resume: the token AND ownership (the credentialed connection coming back).
|
|
456
|
+
if (!s || !(isResume ? tokenOk && mine : tokenOk || mine)) {
|
|
457
|
+
log(`${isResume ? 'resume' : 'attach'} refused for ${String(m.session_id).slice(0, 8)}`);
|
|
458
|
+
return fail(2, 'unknown session or not authorized');
|
|
459
|
+
}
|
|
460
|
+
if ([...channels.values()].some((c) => c.session === s)) return fail(3, 'already attached to this session on this connection');
|
|
461
|
+
if (s.attachments.size >= MAX_ATTACHMENTS) return fail(3, 'too many attachments to this session');
|
|
462
|
+
if (channels.size >= MAX_CHANNELS) return fail(3, 'too many open channels');
|
|
463
|
+
|
|
464
|
+
let from = s.ring.start;
|
|
465
|
+
if (isResume) {
|
|
466
|
+
const last = m.last_seq;
|
|
467
|
+
if (!Number.isSafeInteger(last) || last < 0) return fail(3, 'last_seq must be a non-negative integer');
|
|
468
|
+
if (last > s.ring.end) return fail(4, `last_seq ${last} is ahead of the session (it has produced ${s.ring.end} bytes)`);
|
|
469
|
+
if (last < s.ring.start) {
|
|
470
|
+
return fail(4, `output gap: this session's retained output starts at seq ${s.ring.start} but last_seq is ${last}; attach for the retained output instead`);
|
|
471
|
+
}
|
|
472
|
+
from = last;
|
|
473
|
+
}
|
|
474
|
+
const mode = !isResume && /^(readonly|read|view|ro)$/i.test(String(m.mode ?? '')) ? 'readonly' : 'control';
|
|
475
|
+
const channelId = ++state.nextChannel;
|
|
476
|
+
const att = virtualAttachment(s, channelId, mode);
|
|
477
|
+
attachedChannel(s, att);
|
|
478
|
+
|
|
479
|
+
// Reply, replay, then (if the process is already over) its exit -- all before any live output,
|
|
480
|
+
// which only reaches `att` once `s.attach(att)` registers it, after these are queued.
|
|
481
|
+
const self = { session_id: s.id, mode, username: state.username, channel_id: channelId, seq: from };
|
|
482
|
+
att.notify({ attachments: [self, ...s.roster()] });
|
|
483
|
+
const replay = s.ring.read(from);
|
|
484
|
+
for (let off = 0; off < replay.byteLength; off += REPLAY_CHUNK_BYTES) att.write(replay.subarray(off, off + REPLAY_CHUNK_BYTES));
|
|
485
|
+
if (s.state === 'exited') att.exit(s.exitCode ?? 0);
|
|
486
|
+
else s.attach(att);
|
|
487
|
+
log(`${isResume ? 'resumed' : `attached (${mode})`} session ${s.id.slice(0, 8)} on channel ${channelId} from seq ${from}`);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
async function handleDetach(m) {
|
|
491
|
+
const s = cfg.sessions?.get(m.session_id);
|
|
492
|
+
const entry = s && [...channels.entries()].find(([, c]) => c.session === s);
|
|
493
|
+
if (!entry) return send(detachFail({ reason: 'not attached to this session on this connection' }));
|
|
494
|
+
const [channelId, ch] = entry;
|
|
495
|
+
const flushed = ch.attachment.flush();
|
|
496
|
+
ch.detach();
|
|
497
|
+
await flushed;
|
|
498
|
+
await send(closeMsg({ channelId }));
|
|
499
|
+
return send(detachOk({ sessionId: s.id }));
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function handleSessionList() {
|
|
503
|
+
const now = Date.now();
|
|
504
|
+
const sessions = (cfg.sessions?.listFor(principalKey()) ?? []).map((s) => ({
|
|
505
|
+
session_id: s.id,
|
|
506
|
+
username: s.owner.username,
|
|
507
|
+
fingerprint_short: (s.owner.fingerprint ?? '').slice(0, 8),
|
|
508
|
+
created_at_secs: Math.floor(s.createdAt / 1000),
|
|
509
|
+
idle_secs: Math.max(0, Math.floor((now - s.lastActivity) / 1000)),
|
|
510
|
+
attached_count: s.attachments.size,
|
|
511
|
+
}));
|
|
512
|
+
return send(sessionList({ sessions }));
|
|
513
|
+
}
|
|
514
|
+
|
|
318
515
|
async function openExec(m) {
|
|
319
516
|
if (!cfg.execRunner) return send(openFail({ reason: 'exec is not enabled on this server' }));
|
|
320
517
|
const command = String(m.command ?? '').trim();
|
|
@@ -322,6 +519,7 @@ export function createConnectionFactory(cfg) {
|
|
|
322
519
|
|
|
323
520
|
const channelId = ++state.nextChannel;
|
|
324
521
|
const abort = new AbortController();
|
|
522
|
+
const hosted = cfg.sessions ? cfg.sessions.create({ owner: ownerRecord(), kind: 'exec', command }) : null;
|
|
325
523
|
let stream = null;
|
|
326
524
|
let primerChecked = false;
|
|
327
525
|
let wq = Promise.resolve();
|
|
@@ -331,13 +529,14 @@ export function createConnectionFactory(cfg) {
|
|
|
331
529
|
const endCbs = [];
|
|
332
530
|
const signalCbs = [];
|
|
333
531
|
|
|
532
|
+
const signalTo = (name) => {
|
|
533
|
+
const n = name.replace(/^SIG/, '').toUpperCase();
|
|
534
|
+
for (const cb of signalCbs) cb(n);
|
|
535
|
+
if (/^(INT|TERM|KILL|HUP)$/.test(n) && signalCbs.length === 0) abort.abort();
|
|
536
|
+
};
|
|
334
537
|
const ch = {
|
|
335
538
|
kill: () => abort.abort(),
|
|
336
|
-
signal:
|
|
337
|
-
const n = name.replace(/^SIG/, '').toUpperCase();
|
|
338
|
-
for (const cb of signalCbs) cb(n);
|
|
339
|
-
if (/^(INT|TERM|KILL|HUP)$/.test(n) && signalCbs.length === 0) abort.abort();
|
|
340
|
-
},
|
|
539
|
+
signal: signalTo,
|
|
341
540
|
bind(s) {
|
|
342
541
|
stream = s;
|
|
343
542
|
s.onData = (d) => {
|
|
@@ -349,18 +548,68 @@ export function createConnectionFactory(cfg) {
|
|
|
349
548
|
if (d.byteLength) for (const cb of inputCbs) cb(d);
|
|
350
549
|
};
|
|
351
550
|
s.onEnd = () => { for (const cb of endCbs) cb(); };
|
|
352
|
-
|
|
551
|
+
// A hosted session outlives its stream; an unhosted one dies with it.
|
|
552
|
+
s.onReset = () => { if (!hosted) abort.abort(); };
|
|
353
553
|
const pending = buffered.splice(0);
|
|
354
554
|
wq = wq.then(async () => { for (const b of pending) await s.write(b).catch(() => {}); });
|
|
355
555
|
for (const r of boundWaiters.splice(0)) r();
|
|
356
556
|
},
|
|
357
557
|
};
|
|
358
558
|
channels.set(channelId, ch);
|
|
359
|
-
pendingStreams.push(channelId);
|
|
559
|
+
if (dataStreams) pendingStreams.push(channelId);
|
|
560
|
+
// No client-opened streams on this connection (a relay bridge): stdin arrives as SessionData.
|
|
561
|
+
else ch.input = (d) => { for (const cb of inputCbs) cb(d); };
|
|
360
562
|
|
|
361
563
|
let timer = null;
|
|
362
564
|
if (cfg.execOptions.timeoutMs > 0) timer = setTimeout(() => abort.abort(), cfg.execOptions.timeoutMs);
|
|
363
565
|
|
|
566
|
+
// Output to the opening connection's data stream (held until the client has opened it).
|
|
567
|
+
const toStream = (bytes) => {
|
|
568
|
+
if (!dataStreams) {
|
|
569
|
+
wq = wq.then(() => send(sessionData({ channelId, data: bytes })));
|
|
570
|
+
return wq;
|
|
571
|
+
}
|
|
572
|
+
if (!stream) { buffered.push(bytes); return Promise.resolve(); }
|
|
573
|
+
const s = stream;
|
|
574
|
+
wq = wq.then(() => s.write(bytes).catch(() => {}));
|
|
575
|
+
return wq;
|
|
576
|
+
};
|
|
577
|
+
// Output is ready but the client's stream may not have been bound yet (a host without
|
|
578
|
+
// announce support waits for the first byte): give it a bounded chance rather than
|
|
579
|
+
// dropping the output or hanging forever.
|
|
580
|
+
const closeStream = async (code) => {
|
|
581
|
+
if (dataStreams && !stream) await Promise.race([new Promise((r) => boundWaiters.push(r)), new Promise((r) => setTimeout(r, cfg.bindTimeoutMs))]);
|
|
582
|
+
if (dataStreams && !stream) log(`exec channel ${channelId}: data stream never appeared; output dropped`);
|
|
583
|
+
await wq;
|
|
584
|
+
try { await stream?.close(); } catch { /* peer gone */ }
|
|
585
|
+
await finishChannel(channelId, Number.isInteger(code) ? code : 0);
|
|
586
|
+
};
|
|
587
|
+
|
|
588
|
+
if (hosted) {
|
|
589
|
+
const att = {
|
|
590
|
+
connId: cid, channelId, mode: 'control', username: state.username, principal: principalKey(),
|
|
591
|
+
write: toStream,
|
|
592
|
+
notify: (msg) => send(presenceMsg(msg)),
|
|
593
|
+
exit: (code) => closeStream(code),
|
|
594
|
+
flush: () => wq,
|
|
595
|
+
};
|
|
596
|
+
hosted.handlers = {
|
|
597
|
+
input: (d) => { for (const cb of inputCbs) cb(d); },
|
|
598
|
+
inputEnd: () => { for (const cb of endCbs) cb(); },
|
|
599
|
+
signal: signalTo,
|
|
600
|
+
resize: () => {},
|
|
601
|
+
kill: () => abort.abort(),
|
|
602
|
+
};
|
|
603
|
+
ch.session = hosted;
|
|
604
|
+
ch.attachment = att;
|
|
605
|
+
ch.detach = () => {
|
|
606
|
+
channels.delete(channelId);
|
|
607
|
+
hosted.detach(att);
|
|
608
|
+
try { stream?.close().catch(() => {}); } catch { /* gone */ }
|
|
609
|
+
};
|
|
610
|
+
hosted.attach(att);
|
|
611
|
+
}
|
|
612
|
+
|
|
364
613
|
const io = {
|
|
365
614
|
user: state.username,
|
|
366
615
|
env: m.env && typeof m.env === 'object' && cfg.execOptions.clientEnv !== false ? m.env : {},
|
|
@@ -369,9 +618,8 @@ export function createConnectionFactory(cfg) {
|
|
|
369
618
|
signal: abort.signal,
|
|
370
619
|
write(data) {
|
|
371
620
|
const bytes = toBytes(data);
|
|
372
|
-
if (!
|
|
373
|
-
|
|
374
|
-
wq = wq.then(() => s.write(bytes).catch(() => {}));
|
|
621
|
+
if (!hosted) return toStream(bytes);
|
|
622
|
+
hosted.push(bytes);
|
|
375
623
|
return wq;
|
|
376
624
|
},
|
|
377
625
|
onInput: (cb) => inputCbs.push(cb),
|
|
@@ -380,8 +628,8 @@ export function createConnectionFactory(cfg) {
|
|
|
380
628
|
};
|
|
381
629
|
|
|
382
630
|
await send(openOk({
|
|
383
|
-
channelId, dataMode: 'stream', capabilities: ['signal'],
|
|
384
|
-
sessionId: randomUUID(), token: randomBytes(16),
|
|
631
|
+
channelId, dataMode: dataStreams ? 'stream' : 'virtual', capabilities: ['signal'],
|
|
632
|
+
sessionId: hosted?.id ?? randomUUID(), token: hosted?.token ?? randomBytes(16),
|
|
385
633
|
}));
|
|
386
634
|
log(`exec channel ${channelId}: ${command}`);
|
|
387
635
|
|
|
@@ -390,20 +638,15 @@ export function createConnectionFactory(cfg) {
|
|
|
390
638
|
runExec().catch((e) => log(`exec channel ${channelId}: ${e.message}`));
|
|
391
639
|
|
|
392
640
|
async function runExec() {
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
if (!stream) await Promise.race([new Promise((r) => boundWaiters.push(r)), new Promise((r) => setTimeout(r, cfg.bindTimeoutMs))]);
|
|
403
|
-
if (!stream) log(`exec channel ${channelId}: data stream never appeared; output dropped`);
|
|
404
|
-
await wq;
|
|
405
|
-
try { await stream?.close(); } catch { /* peer gone */ }
|
|
406
|
-
await finishChannel(channelId, Number.isInteger(code) ? code : 0);
|
|
641
|
+
let code;
|
|
642
|
+
try { code = await cfg.execRunner(command, io); } catch (e) {
|
|
643
|
+
await io.write(`wsh: ${e.message}\n`);
|
|
644
|
+
code = 1;
|
|
645
|
+
}
|
|
646
|
+
if (timer) clearTimeout(timer);
|
|
647
|
+
// Hosted: everyone attached (this connection's stream included, if it is still there) hears the exit.
|
|
648
|
+
if (hosted) { hosted.finish(Number.isInteger(code) ? code : 0); return; }
|
|
649
|
+
await closeStream(code);
|
|
407
650
|
}
|
|
408
651
|
}
|
|
409
652
|
|
|
@@ -423,13 +666,31 @@ export function createConnectionFactory(cfg) {
|
|
|
423
666
|
} catch (e) {
|
|
424
667
|
return send(openFail({ reason: `pty spawn failed: ${e.message}` }));
|
|
425
668
|
}
|
|
426
|
-
const
|
|
669
|
+
const ctl = {
|
|
427
670
|
input: (data) => { try { proc.write(Buffer.from(data)); } catch { /* exited */ } },
|
|
428
671
|
resize: (c, r) => { try { proc.resize(c || cols, r || rows); } catch { /* exited */ } },
|
|
429
672
|
signal: (name) => { try { proc.kill(`SIG${name.replace(/^SIG/, '').toUpperCase()}`); } catch { /* exited */ } },
|
|
430
673
|
kill: () => { try { proc.kill(); } catch { /* exited */ } },
|
|
431
674
|
};
|
|
432
|
-
|
|
675
|
+
|
|
676
|
+
if (cfg.sessions) {
|
|
677
|
+
const hosted = cfg.sessions.create({ owner: ownerRecord(), kind: 'pty', command: m.command });
|
|
678
|
+
hosted.handlers = ctl;
|
|
679
|
+
const att = virtualAttachment(hosted, channelId, 'control');
|
|
680
|
+
attachedChannel(hosted, att);
|
|
681
|
+
proc.onData((d) => hosted.push(Uint8Array.from(toBytes(d))));
|
|
682
|
+
proc.onExit(({ exitCode }) => hosted.finish(exitCode ?? 0));
|
|
683
|
+
const opened = send(openOk({
|
|
684
|
+
channelId, dataMode: 'virtual', capabilities: ['resize', 'signal'],
|
|
685
|
+
sessionId: hosted.id, token: hosted.token,
|
|
686
|
+
}));
|
|
687
|
+
hosted.attach(att);
|
|
688
|
+
await opened;
|
|
689
|
+
log(`pty channel ${channelId} opened (${cols}x${rows}), session ${hosted.id.slice(0, 8)}`);
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
channels.set(channelId, ctl);
|
|
433
694
|
let out = Promise.resolve();
|
|
434
695
|
proc.onData((d) => { out = out.then(() => send(sessionData({ channelId, data: toBytes(d) }))); });
|
|
435
696
|
proc.onExit(({ exitCode }) => { out.then(() => finishChannel(channelId, exitCode ?? 0)); });
|
|
@@ -555,8 +816,10 @@ export function createConnectionFactory(cfg) {
|
|
|
555
816
|
}
|
|
556
817
|
|
|
557
818
|
return {
|
|
558
|
-
receive(bytes) { if (!state.closed) qmux
|
|
559
|
-
|
|
819
|
+
receive(bytes) { if (!state.closed) qmux?.receiveBytes(bytes); },
|
|
820
|
+
receiveMessage(msg) { if (!state.closed) chain = chain.then(() => handle(msg)).catch((e) => log(`handler error: ${e.message}`)); },
|
|
821
|
+
bindStream: bindDataStream,
|
|
822
|
+
close() { shutdownAfterQueue('transport closed'); },
|
|
560
823
|
};
|
|
561
824
|
};
|
|
562
825
|
}
|
package/src/server/index.d.ts
CHANGED
|
@@ -158,6 +158,48 @@ export interface WshServerMcpOptions {
|
|
|
158
158
|
timeoutMs?: number;
|
|
159
159
|
}
|
|
160
160
|
|
|
161
|
+
export interface WshServerSessionsOptions {
|
|
162
|
+
/** How long a session with nobody attached keeps running (default 300000). `0` kills it the moment its last connection goes. */
|
|
163
|
+
detachTtlMs?: number;
|
|
164
|
+
/** Unattended sessions kept at once (default 16); beyond that the longest-detached is killed. */
|
|
165
|
+
maxDetached?: number;
|
|
166
|
+
/** Output history kept per session, in bytes (default 1 MiB) -- what a resume can replay. Older output is dropped; a Resume older than it is refused with an "output gap" error. */
|
|
167
|
+
ringBytes?: number;
|
|
168
|
+
/** Secret the session tokens are minted with (default: random per process). Fixing it makes tokens verifiable across restarts. */
|
|
169
|
+
sessionSecret?: string | Uint8Array;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** A peer as the relay's policy callbacks see it. */
|
|
173
|
+
export interface WshRelayPeer {
|
|
174
|
+
/** Hex SHA-256 of the peer's key. */
|
|
175
|
+
fingerprint: string;
|
|
176
|
+
username: string;
|
|
177
|
+
capabilities: string[];
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export interface WshServerRelayOptions {
|
|
181
|
+
/**
|
|
182
|
+
* May this authenticated connection register as a peer? `who` is the connection; `record` what it asks to
|
|
183
|
+
* advertise. Default: no (a relay with no policy admits nobody).
|
|
184
|
+
*/
|
|
185
|
+
canRegister?: (
|
|
186
|
+
who: { username: string; fingerprint: string },
|
|
187
|
+
record: { username: string; capabilities: string[]; peerType: string; shellBackend: string },
|
|
188
|
+
) => boolean | Promise<boolean>;
|
|
189
|
+
/**
|
|
190
|
+
* May `from` list and connect to `to`? A peer `from` may not connect to is neither listed nor distinguishable
|
|
191
|
+
* from an absent one. Default: no.
|
|
192
|
+
*/
|
|
193
|
+
canConnect?: (
|
|
194
|
+
from: { username: string; fingerprint: string },
|
|
195
|
+
to: WshRelayPeer,
|
|
196
|
+
) => boolean | Promise<boolean>;
|
|
197
|
+
/** Registered peers at once (default 1024). */
|
|
198
|
+
maxPeers?: number;
|
|
199
|
+
/** How long a `ReverseConnect` waits for its peer to answer before the operator is rejected (default 8000). */
|
|
200
|
+
connectTimeoutMs?: number;
|
|
201
|
+
}
|
|
202
|
+
|
|
161
203
|
export interface WshServerOptions {
|
|
162
204
|
/** Bind address (default `127.0.0.1`). */
|
|
163
205
|
host?: string;
|
|
@@ -177,6 +219,20 @@ export interface WshServerOptions {
|
|
|
177
219
|
hostKey?: true | { file: string } | CryptoKeyPair;
|
|
178
220
|
/** Enable file ops and uploads/downloads under one directory. Off by default. */
|
|
179
221
|
fs?: WshServerFsOptions;
|
|
222
|
+
/**
|
|
223
|
+
* Keep pty/exec sessions alive across disconnects so `resumeSession()` /
|
|
224
|
+
* `attachSession()` / `detach()` / `listRemoteSessions()` work against them.
|
|
225
|
+
* `true` for the defaults. Off by default: sessions then die with their connection.
|
|
226
|
+
*/
|
|
227
|
+
sessions?: true | WshServerSessionsOptions;
|
|
228
|
+
/** Alias for `sessions.sessionSecret`. */
|
|
229
|
+
sessionSecret?: string | Uint8Array;
|
|
230
|
+
/**
|
|
231
|
+
* Act as a relay: peers (`createReverseHost`, or any client's `connectReverse()`) register a signed record,
|
|
232
|
+
* operators list and `reverseConnect()` to them, and traffic is carried between the two as `RelayForward`.
|
|
233
|
+
* Default deny -- give both `canRegister` and `canConnect`. Off by default.
|
|
234
|
+
*/
|
|
235
|
+
relay?: WshServerRelayOptions;
|
|
180
236
|
/** Serve MCP tools (`McpDiscover` / `McpCall`) and advertise `mcp-call-id`. Off by default. */
|
|
181
237
|
mcp?: WshServerMcpOptions;
|
|
182
238
|
/** How long exec output waits for a client that has not yet opened its data stream (default 3000). */
|
|
@@ -196,6 +252,8 @@ export interface WshServer {
|
|
|
196
252
|
close(): Promise<void>;
|
|
197
253
|
/** The bound address, or `null` when not listening. */
|
|
198
254
|
address(): WshServerAddress | null;
|
|
255
|
+
/** Fingerprints of the peers currently registered with this relay (`[]` when it is not one). */
|
|
256
|
+
peerFingerprints(): string[];
|
|
199
257
|
/** The advertised host identity; `null` before `listen()` resolves or without a `hostKey`. */
|
|
200
258
|
hostKey(): WshServerHostKey | null;
|
|
201
259
|
}
|
|
@@ -207,3 +265,40 @@ export function parseAuthorizedKeys(text: string): Uint8Array[];
|
|
|
207
265
|
|
|
208
266
|
/** ServerHello feature: the host discovers client-opened exec data streams itself (no primer byte). */
|
|
209
267
|
export const STREAM_ANNOUNCE: 'stream-announce';
|
|
268
|
+
|
|
269
|
+
export interface WshReverseHostOptions {
|
|
270
|
+
/** The relay to register with (`ws://` / `wss://`). */
|
|
271
|
+
url: string;
|
|
272
|
+
username: string;
|
|
273
|
+
/** This host's identity; its fingerprint is what operators connect to. */
|
|
274
|
+
keyPair: CryptoKeyPair;
|
|
275
|
+
/** Who may be bridged to this host. Default: nobody. */
|
|
276
|
+
accept?: (operator: { fingerprint: string; username: string }) => boolean | Promise<boolean>;
|
|
277
|
+
exec?: true | WshServerExecOptions | WshExecRunner;
|
|
278
|
+
pty?: WshServerPtyOptions;
|
|
279
|
+
fs?: WshServerFsOptions;
|
|
280
|
+
mcp?: WshServerMcpOptions;
|
|
281
|
+
/** Dial again, with backoff, after the relay connection ends -- which a relay does when a bridge does (default true). */
|
|
282
|
+
reconnect?: boolean;
|
|
283
|
+
/** Extra options for the relay connection (`expectHostKey`, `knownHosts`, `trustOnFirstUse`, ...). */
|
|
284
|
+
connect?: Record<string, unknown>;
|
|
285
|
+
peerType?: string;
|
|
286
|
+
onLog?: (line: string) => void;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export interface WshReverseHost {
|
|
290
|
+
/** Dial the relay and register (rejects if the first attempt fails). Registration is not acknowledged on the wire. */
|
|
291
|
+
start(): Promise<{ fingerprint: string }>;
|
|
292
|
+
/** Leave the relay and end any bridged session. */
|
|
293
|
+
close(): Promise<void>;
|
|
294
|
+
/** What operators connect to; `null` before `start()`. */
|
|
295
|
+
readonly fingerprint: string | null;
|
|
296
|
+
/** Is the relay connection up right now? */
|
|
297
|
+
readonly connected: boolean;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* A host that dials OUT to a relay and serves the operator it bridges to it, with the same exec / pty / fs / mcp
|
|
302
|
+
* backends `createWshServer` serves direct clients with. For a machine that cannot accept connections.
|
|
303
|
+
*/
|
|
304
|
+
export function createReverseHost(options: WshReverseHostOptions): WshReverseHost;
|