@johnhenry/wsh 0.17.0 → 0.18.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 +186 -19
- package/package.json +21 -4
- package/src/client.mjs +70 -4
- package/src/file-transfer.mjs +45 -156
- package/src/index.d.ts +149 -6
- package/src/index.mjs +5 -1
- package/src/known-hosts.mjs +139 -0
- package/src/messages.gen.mjs +22 -9
- package/src/qmux-connection.mjs +21 -0
- package/src/server/auth.mjs +75 -0
- package/src/server/connection.mjs +381 -0
- package/src/server/exec.mjs +60 -0
- package/src/server/fs.mjs +134 -0
- package/src/server/index.d.ts +130 -0
- package/src/server/index.mjs +95 -0
- package/src/server/pty.mjs +25 -0
- package/src/transport-ws.mjs +4 -0
package/src/messages.gen.mjs
CHANGED
|
@@ -145,6 +145,10 @@ export const MSG = Object.freeze({
|
|
|
145
145
|
|
|
146
146
|
// Terminal
|
|
147
147
|
TERMINAL_CONFIG: 0x9e,
|
|
148
|
+
|
|
149
|
+
// Keys
|
|
150
|
+
AUTHORIZED_KEY_ADD: 0x9f,
|
|
151
|
+
AUTHORIZED_KEY_RESULT: 0xa0,
|
|
148
152
|
});
|
|
149
153
|
|
|
150
154
|
// Reverse lookup: number → name
|
|
@@ -187,13 +191,10 @@ export function hello({ username, features = [], authMethod = AUTH_METHOD.PUBKEY
|
|
|
187
191
|
};
|
|
188
192
|
}
|
|
189
193
|
|
|
190
|
-
export function serverHello({ sessionId, features = [], fingerprints = [] } = {}) {
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
features,
|
|
195
|
-
fingerprints,
|
|
196
|
-
};
|
|
194
|
+
export function serverHello({ sessionId, features = [], fingerprints = [], hostFingerprint } = {}) {
|
|
195
|
+
const msg = { type: MSG.SERVER_HELLO, session_id: sessionId, features, fingerprints };
|
|
196
|
+
if (hostFingerprint !== undefined) msg.host_fingerprint = hostFingerprint;
|
|
197
|
+
return msg;
|
|
197
198
|
}
|
|
198
199
|
|
|
199
200
|
export function challenge({ nonce, sessionId } = {}) {
|
|
@@ -864,8 +865,8 @@ export function fileOp({ channelId, op, path, offset, length } = {}) {
|
|
|
864
865
|
return msg;
|
|
865
866
|
}
|
|
866
867
|
|
|
867
|
-
export function fileResult({ channelId, success, metadata = {}, errorMessage } = {}) {
|
|
868
|
-
const msg = { type: MSG.FILE_RESULT, channel_id: channelId, success, metadata };
|
|
868
|
+
export function fileResult({ channelId, success, metadata = {}, entries = [], errorMessage } = {}) {
|
|
869
|
+
const msg = { type: MSG.FILE_RESULT, channel_id: channelId, success, metadata, entries };
|
|
869
870
|
if (errorMessage !== undefined) msg.error_message = errorMessage;
|
|
870
871
|
return msg;
|
|
871
872
|
}
|
|
@@ -915,6 +916,18 @@ export function terminalConfig({ channelId, frontend, options = {} } = {}) {
|
|
|
915
916
|
};
|
|
916
917
|
}
|
|
917
918
|
|
|
919
|
+
export function authorizedKeyAdd({ publicKey, comment } = {}) {
|
|
920
|
+
const msg = { type: MSG.AUTHORIZED_KEY_ADD, public_key: publicKey };
|
|
921
|
+
if (comment !== undefined) msg.comment = comment;
|
|
922
|
+
return msg;
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
export function authorizedKeyResult({ success, added = false, errorMessage } = {}) {
|
|
926
|
+
const msg = { type: MSG.AUTHORIZED_KEY_RESULT, success, added };
|
|
927
|
+
if (errorMessage !== undefined) msg.error_message = errorMessage;
|
|
928
|
+
return msg;
|
|
929
|
+
}
|
|
930
|
+
|
|
918
931
|
// ── Relay forwarding ─────────────────────────────────────────────────
|
|
919
932
|
|
|
920
933
|
// Message types a relay may forward inside a RelayForward wrapper.
|
package/src/qmux-connection.mjs
CHANGED
|
@@ -134,6 +134,27 @@ class QMuxStream {
|
|
|
134
134
|
}
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
+
/**
|
|
138
|
+
* Make this locally-initiated stream visible to the peer without sending
|
|
139
|
+
* any payload. A QMux/QUIC stream is created lazily on the wire: opening
|
|
140
|
+
* one only allocates an id locally, and the peer first learns it exists
|
|
141
|
+
* from the first STREAM frame it sees. A zero-length STREAM frame (no FIN)
|
|
142
|
+
* is legal QUIC and is exactly that first frame, so the peer's
|
|
143
|
+
* `onStreamOpen` fires now instead of whenever (if ever) the first real
|
|
144
|
+
* byte is written -- which for an exec session, with no stdin, is never.
|
|
145
|
+
* No-op once anything has been sent on the stream.
|
|
146
|
+
*/
|
|
147
|
+
announce() {
|
|
148
|
+
if (this.#sendState !== SEND_STATE.READY) return;
|
|
149
|
+
this.#sendState = SEND_STATE.SEND;
|
|
150
|
+
this.#conn._sendFrame(encodeStream({
|
|
151
|
+
streamId: this.id,
|
|
152
|
+
offset: this.#sendOffset,
|
|
153
|
+
data: new Uint8Array(0),
|
|
154
|
+
fin: false,
|
|
155
|
+
}), 0);
|
|
156
|
+
}
|
|
157
|
+
|
|
137
158
|
/** Half-close the send side: send FIN. No more write() calls after this. */
|
|
138
159
|
async close() {
|
|
139
160
|
if (this.#sendState === SEND_STATE.RESET_SENT || this.#sendState === SEND_STATE.DATA_SENT) return;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-side authentication for `@johnhenry/wsh/server`: an allowlist of
|
|
3
|
+
* Ed25519 public keys (SSH `authorized_keys` style) and/or a custom callback.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
parseSSHPublicKey, extractRawFromSSHWire, fingerprint,
|
|
8
|
+
} from '../auth.mjs';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Parse an `authorized_keys`-style blob (one `ssh-ed25519 AAAA... [comment]`
|
|
12
|
+
* per line; blank lines and `#` comments ignored) into raw 32-byte keys.
|
|
13
|
+
* Malformed lines are skipped rather than failing the whole list.
|
|
14
|
+
*
|
|
15
|
+
* @param {string} text
|
|
16
|
+
* @returns {Uint8Array[]}
|
|
17
|
+
*/
|
|
18
|
+
export function parseAuthorizedKeys(text) {
|
|
19
|
+
const keys = [];
|
|
20
|
+
for (const raw of String(text ?? '').split('\n')) {
|
|
21
|
+
const line = raw.trim();
|
|
22
|
+
if (!line || line.startsWith('#')) continue;
|
|
23
|
+
try {
|
|
24
|
+
const parsed = parseSSHPublicKey(line);
|
|
25
|
+
keys.push(extractRawFromSSHWire(parsed.data));
|
|
26
|
+
} catch {
|
|
27
|
+
// skip malformed line
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return keys;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Normalize the `auth` option into one `authorize()` function.
|
|
35
|
+
*
|
|
36
|
+
* `auth` is an `authorize` function itself, or an object with
|
|
37
|
+
* `authorizedKeys` (a string of `ssh-ed25519` lines, a raw 32-byte
|
|
38
|
+
* `Uint8Array`, or an array of either) and/or `authorize`. A key must be on
|
|
39
|
+
* the list AND pass `authorize` (when both are given). With no `auth` at all
|
|
40
|
+
* every connection is refused -- a server never defaults to open.
|
|
41
|
+
*
|
|
42
|
+
* @param {undefined | Function | {
|
|
43
|
+
* authorizedKeys?: string | Uint8Array | Array<string | Uint8Array>,
|
|
44
|
+
* authorize?: (who: {username: string, fingerprint: string, publicKey: Uint8Array}) => boolean | Promise<boolean>,
|
|
45
|
+
* }} auth
|
|
46
|
+
* @returns {Promise<(who: {username: string, fingerprint: string, publicKey: Uint8Array}) => Promise<boolean>>}
|
|
47
|
+
*/
|
|
48
|
+
export async function buildAuthorizer(auth) {
|
|
49
|
+
if (typeof auth === 'function') return async (who) => (await auth(who)) === true;
|
|
50
|
+
if (!auth) return async () => false;
|
|
51
|
+
|
|
52
|
+
/** @type {Set<string> | null} */
|
|
53
|
+
let allowed = null;
|
|
54
|
+
if (auth.authorizedKeys !== undefined) {
|
|
55
|
+
allowed = new Set();
|
|
56
|
+
const items = Array.isArray(auth.authorizedKeys) ? auth.authorizedKeys : [auth.authorizedKeys];
|
|
57
|
+
for (const item of items) {
|
|
58
|
+
const raws = typeof item === 'string' ? parseAuthorizedKeys(item) : [item];
|
|
59
|
+
for (const raw of raws) {
|
|
60
|
+
if (!(raw instanceof Uint8Array) || raw.byteLength !== 32) {
|
|
61
|
+
throw new TypeError('createWshServer: auth.authorizedKeys entries must be ssh-ed25519 lines or raw 32-byte Ed25519 keys');
|
|
62
|
+
}
|
|
63
|
+
allowed.add(await fingerprint(raw));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const custom = typeof auth.authorize === 'function' ? auth.authorize : null;
|
|
68
|
+
if (!allowed && !custom) return async () => false;
|
|
69
|
+
|
|
70
|
+
return async (who) => {
|
|
71
|
+
if (allowed && !allowed.has(who.fingerprint)) return false;
|
|
72
|
+
if (custom) return (await custom(who)) === true;
|
|
73
|
+
return true;
|
|
74
|
+
};
|
|
75
|
+
}
|
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One wsh-v1 server connection, written against a byte pipe so it knows
|
|
3
|
+
* nothing about WebSocket: `attach({ send })` returns `{ receive, close }`.
|
|
4
|
+
* Speaks the same QMux-over-WebSocket framing the stock client's
|
|
5
|
+
* `WebSocketTransport` does.
|
|
6
|
+
*
|
|
7
|
+
* Exec data plane: exec sessions use `data_mode: 'stream'` -- output flows on
|
|
8
|
+
* the second, client-opened QMux stream. The client's transport announces that
|
|
9
|
+
* stream the moment it opens it (an empty STREAM frame), so the host sees it
|
|
10
|
+
* with no help; the host advertises `stream-announce` in ServerHello so the
|
|
11
|
+
* client knows not to write a primer byte. A client that does write one anyway
|
|
12
|
+
* (hosts that predate the announce required it) has the lone leading `0x00`
|
|
13
|
+
* dropped rather than forwarded to the process's stdin.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { randomUUID } from 'node:crypto';
|
|
17
|
+
import { QMuxConnection } from '../qmux-connection.mjs';
|
|
18
|
+
import { FrameDecoder, frameEncode } from '../cbor.mjs';
|
|
19
|
+
import {
|
|
20
|
+
MSG, serverHello, challenge, authOk, authFail, openOk, openFail, sessionData,
|
|
21
|
+
exit as exitMsg, close as closeMsg, pong, fileResult, fileChunk,
|
|
22
|
+
} from '../messages.gen.mjs';
|
|
23
|
+
import {
|
|
24
|
+
generateNonce, verifyChallenge, fingerprint, importPublicKeyRaw,
|
|
25
|
+
} from '../auth.mjs';
|
|
26
|
+
import { FILE_CHUNK_BYTES } from './fs.mjs';
|
|
27
|
+
|
|
28
|
+
export const STREAM_ANNOUNCE = 'stream-announce';
|
|
29
|
+
|
|
30
|
+
const MAX_CHANNELS = 64;
|
|
31
|
+
const USERNAME_RE = /^[A-Za-z_][A-Za-z0-9_.-]{0,63}$/;
|
|
32
|
+
const enc = new TextEncoder();
|
|
33
|
+
|
|
34
|
+
const randomBytes = (n) => crypto.getRandomValues(new Uint8Array(n));
|
|
35
|
+
const toBytes = (d) => (typeof d === 'string' ? enc.encode(d) : d);
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @param {object} cfg
|
|
39
|
+
* @param {(who: object) => Promise<boolean>} cfg.authorize
|
|
40
|
+
* @param {Function | null} cfg.execRunner
|
|
41
|
+
* @param {object} cfg.execOptions
|
|
42
|
+
* @param {object | null} cfg.pty - normalized (see pty.mjs)
|
|
43
|
+
* @param {object | null} cfg.files - from createFileAccess
|
|
44
|
+
* @param {number} cfg.bindTimeoutMs - how long exec output waits for the data stream
|
|
45
|
+
* @param {(line: string) => void} cfg.log
|
|
46
|
+
*/
|
|
47
|
+
export function createConnectionFactory(cfg) {
|
|
48
|
+
let counter = 0;
|
|
49
|
+
|
|
50
|
+
return function attach({ send: sendBytes }) {
|
|
51
|
+
const cid = ++counter;
|
|
52
|
+
const log = (m) => cfg.log(`[conn ${cid}] ${m}`);
|
|
53
|
+
const state = {
|
|
54
|
+
sessionId: randomUUID(), nonce: null, username: null, authed: false,
|
|
55
|
+
closed: false, nextChannel: 0,
|
|
56
|
+
};
|
|
57
|
+
/** @type {Map<number, any>} */
|
|
58
|
+
const channels = new Map();
|
|
59
|
+
/** Channel ids awaiting their client-opened data stream, in OpenOk order. */
|
|
60
|
+
const pendingStreams = [];
|
|
61
|
+
const decoder = new FrameDecoder();
|
|
62
|
+
const pendingSends = [];
|
|
63
|
+
let control = null;
|
|
64
|
+
let chain = Promise.resolve();
|
|
65
|
+
|
|
66
|
+
const qmux = new QMuxConnection({ isClient: false, send: (b) => { if (!state.closed) sendBytes(b); } });
|
|
67
|
+
|
|
68
|
+
const send = (msg) => {
|
|
69
|
+
if (state.closed) return Promise.resolve();
|
|
70
|
+
const bytes = frameEncode(msg);
|
|
71
|
+
if (control) return control.write(bytes).catch(() => {});
|
|
72
|
+
pendingSends.push(bytes);
|
|
73
|
+
return Promise.resolve();
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
function shutdown(why) {
|
|
77
|
+
if (state.closed) return;
|
|
78
|
+
state.closed = true;
|
|
79
|
+
for (const ch of channels.values()) ch.kill?.();
|
|
80
|
+
channels.clear();
|
|
81
|
+
try { qmux.destroy(new Error(why)); } catch { /* already gone */ }
|
|
82
|
+
log(`closed (${why})`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
qmux.onError = (e) => log(`qmux error: ${e.message}`);
|
|
86
|
+
qmux.onClose = () => shutdown('peer sent CONNECTION_CLOSE');
|
|
87
|
+
qmux.onStreamOpen = (s) => {
|
|
88
|
+
if (s.id !== 0) { bindDataStream(s); return; }
|
|
89
|
+
control = s;
|
|
90
|
+
s.onData = (d) => {
|
|
91
|
+
let msgs;
|
|
92
|
+
try { msgs = decoder.feed(d); } catch (e) { log(`frame decode error: ${e.message}`); return; }
|
|
93
|
+
for (const m of msgs) chain = chain.then(() => handle(m)).catch((e) => log(`handler error: ${e.message}`));
|
|
94
|
+
};
|
|
95
|
+
s.onEnd = () => shutdown('control stream FIN');
|
|
96
|
+
s.onReset = () => shutdown('control stream reset');
|
|
97
|
+
for (const b of pendingSends.splice(0)) s.write(b).catch(() => {});
|
|
98
|
+
};
|
|
99
|
+
qmux.sendHandshake();
|
|
100
|
+
|
|
101
|
+
function bindDataStream(s) {
|
|
102
|
+
const channelId = pendingStreams.shift();
|
|
103
|
+
const ch = channelId !== undefined ? channels.get(channelId) : undefined;
|
|
104
|
+
if (!ch?.bind) { s.close().catch(() => {}); return; }
|
|
105
|
+
ch.bind(s);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ── Auth ──────────────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
async function handle(m) {
|
|
111
|
+
if (state.closed) return;
|
|
112
|
+
if (!state.authed) return handleAuth(m);
|
|
113
|
+
switch (m.type) {
|
|
114
|
+
case MSG.PING: return send(pong({ id: m.id }));
|
|
115
|
+
case MSG.OPEN: return handleOpen(m);
|
|
116
|
+
case MSG.SESSION_DATA: channels.get(m.channel_id)?.input?.(m.data); return;
|
|
117
|
+
case MSG.RESIZE: channels.get(m.channel_id)?.resize?.(m.cols, m.rows); return;
|
|
118
|
+
case MSG.SIGNAL: channels.get(m.channel_id)?.signal?.(String(m.signal ?? '')); return;
|
|
119
|
+
case MSG.CLOSE: { const ch = channels.get(m.channel_id); if (ch) { ch.kill?.(); } return; }
|
|
120
|
+
case MSG.FILE_OP: return handleFileOp(m);
|
|
121
|
+
case MSG.FILE_CHUNK: return channels.get(m.channel_id)?.chunk?.(m);
|
|
122
|
+
default: log(`ignored message type 0x${m.type.toString(16)}`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function refuse(reason) {
|
|
127
|
+
log(`AUTH_FAIL: ${reason}`);
|
|
128
|
+
await send(authFail({ reason }));
|
|
129
|
+
setTimeout(() => shutdown('auth failed'), 50);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function handleAuth(m) {
|
|
133
|
+
if (m.type === MSG.HELLO) {
|
|
134
|
+
const user = String(m.username ?? '');
|
|
135
|
+
if (!USERNAME_RE.test(user)) return refuse(`bad username ${JSON.stringify(user)}`);
|
|
136
|
+
if (m.auth_method && m.auth_method !== 'pubkey') return refuse('this host only accepts Ed25519 pubkey auth');
|
|
137
|
+
state.username = user;
|
|
138
|
+
state.nonce = generateNonce();
|
|
139
|
+
const features = [STREAM_ANNOUNCE];
|
|
140
|
+
if (cfg.files) features.push('file-transfer');
|
|
141
|
+
await send(serverHello({ sessionId: state.sessionId, features }));
|
|
142
|
+
await send(challenge({ nonce: state.nonce, sessionId: state.sessionId }));
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (m.type === MSG.AUTH) {
|
|
146
|
+
if (!state.nonce) return;
|
|
147
|
+
if (m.method !== 'pubkey' || !(m.public_key instanceof Uint8Array) || !(m.signature instanceof Uint8Array)) {
|
|
148
|
+
return refuse('expected a pubkey AUTH with public_key and signature');
|
|
149
|
+
}
|
|
150
|
+
let fp;
|
|
151
|
+
let key;
|
|
152
|
+
try {
|
|
153
|
+
fp = await fingerprint(m.public_key);
|
|
154
|
+
key = await importPublicKeyRaw(m.public_key);
|
|
155
|
+
} catch { return refuse('malformed public key'); }
|
|
156
|
+
// Verify the signature first: nothing about who is on the allowlist
|
|
157
|
+
// is revealed to a caller who cannot even prove they hold a key.
|
|
158
|
+
let ok = false;
|
|
159
|
+
try {
|
|
160
|
+
ok = await verifyChallenge(key, m.signature, state.sessionId, state.nonce, { username: state.username });
|
|
161
|
+
} catch { ok = false; }
|
|
162
|
+
if (!ok) return refuse('signature does not verify over the transcript');
|
|
163
|
+
let allowed = false;
|
|
164
|
+
try {
|
|
165
|
+
allowed = await cfg.authorize({ username: state.username, fingerprint: fp, publicKey: m.public_key });
|
|
166
|
+
} catch (e) { log(`authorize threw: ${e.message}`); }
|
|
167
|
+
if (!allowed) return refuse('key not authorized');
|
|
168
|
+
state.authed = true;
|
|
169
|
+
state.nonce = null;
|
|
170
|
+
await send(authOk({ sessionId: state.sessionId, token: randomBytes(16), ttl: 3600 }));
|
|
171
|
+
log(`authenticated ${state.username} (${fp.slice(0, 12)}...)`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ── Open ──────────────────────────────────────────────────────────
|
|
176
|
+
|
|
177
|
+
function handleOpen(m) {
|
|
178
|
+
if (channels.size >= MAX_CHANNELS) return send(openFail({ reason: 'too many open channels' }));
|
|
179
|
+
switch (m.kind) {
|
|
180
|
+
case 'exec': return openExec(m);
|
|
181
|
+
case 'pty': return openPty(m);
|
|
182
|
+
case 'file': return openFile(m);
|
|
183
|
+
default: return send(openFail({ reason: `kind "${m.kind}" is not supported by this server` }));
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function finishChannel(channelId, code) {
|
|
188
|
+
if (!channels.has(channelId) || state.closed) return Promise.resolve();
|
|
189
|
+
channels.delete(channelId);
|
|
190
|
+
return send(exitMsg({ channelId, code })).then(() => send(closeMsg({ channelId })));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function openExec(m) {
|
|
194
|
+
if (!cfg.execRunner) return send(openFail({ reason: 'exec is not enabled on this server' }));
|
|
195
|
+
const command = String(m.command ?? '').trim();
|
|
196
|
+
if (!command) return send(openFail({ reason: 'command is required for kind "exec"' }));
|
|
197
|
+
|
|
198
|
+
const channelId = ++state.nextChannel;
|
|
199
|
+
const abort = new AbortController();
|
|
200
|
+
let stream = null;
|
|
201
|
+
let primerChecked = false;
|
|
202
|
+
let wq = Promise.resolve();
|
|
203
|
+
const buffered = [];
|
|
204
|
+
const boundWaiters = [];
|
|
205
|
+
const inputCbs = [];
|
|
206
|
+
const endCbs = [];
|
|
207
|
+
const signalCbs = [];
|
|
208
|
+
|
|
209
|
+
const ch = {
|
|
210
|
+
kill: () => abort.abort(),
|
|
211
|
+
signal: (name) => {
|
|
212
|
+
const n = name.replace(/^SIG/, '').toUpperCase();
|
|
213
|
+
for (const cb of signalCbs) cb(n);
|
|
214
|
+
if (/^(INT|TERM|KILL|HUP)$/.test(n) && signalCbs.length === 0) abort.abort();
|
|
215
|
+
},
|
|
216
|
+
bind(s) {
|
|
217
|
+
stream = s;
|
|
218
|
+
s.onData = (d) => {
|
|
219
|
+
// A client that primes (see header) sends one lone 0x00 first.
|
|
220
|
+
if (!primerChecked) {
|
|
221
|
+
primerChecked = true;
|
|
222
|
+
if (d.byteLength === 1 && d[0] === 0) return;
|
|
223
|
+
}
|
|
224
|
+
if (d.byteLength) for (const cb of inputCbs) cb(d);
|
|
225
|
+
};
|
|
226
|
+
s.onEnd = () => { for (const cb of endCbs) cb(); };
|
|
227
|
+
s.onReset = () => abort.abort();
|
|
228
|
+
const pending = buffered.splice(0);
|
|
229
|
+
wq = wq.then(async () => { for (const b of pending) await s.write(b).catch(() => {}); });
|
|
230
|
+
for (const r of boundWaiters.splice(0)) r();
|
|
231
|
+
},
|
|
232
|
+
};
|
|
233
|
+
channels.set(channelId, ch);
|
|
234
|
+
pendingStreams.push(channelId);
|
|
235
|
+
|
|
236
|
+
let timer = null;
|
|
237
|
+
if (cfg.execOptions.timeoutMs > 0) timer = setTimeout(() => abort.abort(), cfg.execOptions.timeoutMs);
|
|
238
|
+
|
|
239
|
+
const io = {
|
|
240
|
+
user: state.username,
|
|
241
|
+
env: m.env && typeof m.env === 'object' && cfg.execOptions.clientEnv !== false ? m.env : {},
|
|
242
|
+
cols: m.cols || 80,
|
|
243
|
+
rows: m.rows || 24,
|
|
244
|
+
signal: abort.signal,
|
|
245
|
+
write(data) {
|
|
246
|
+
const bytes = toBytes(data);
|
|
247
|
+
if (!stream) { buffered.push(bytes); return Promise.resolve(); }
|
|
248
|
+
const s = stream;
|
|
249
|
+
wq = wq.then(() => s.write(bytes).catch(() => {}));
|
|
250
|
+
return wq;
|
|
251
|
+
},
|
|
252
|
+
onInput: (cb) => inputCbs.push(cb),
|
|
253
|
+
onInputEnd: (cb) => endCbs.push(cb),
|
|
254
|
+
onSignal: (cb) => signalCbs.push(cb),
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
await send(openOk({
|
|
258
|
+
channelId, dataMode: 'stream', capabilities: ['signal'],
|
|
259
|
+
sessionId: randomUUID(), token: randomBytes(16),
|
|
260
|
+
}));
|
|
261
|
+
log(`exec channel ${channelId}: ${command}`);
|
|
262
|
+
|
|
263
|
+
// Detached: the message chain must stay free to deliver this
|
|
264
|
+
// session's Signal/Close/SessionData while the command runs.
|
|
265
|
+
runExec().catch((e) => log(`exec channel ${channelId}: ${e.message}`));
|
|
266
|
+
|
|
267
|
+
async function runExec() {
|
|
268
|
+
let code;
|
|
269
|
+
try { code = await cfg.execRunner(command, io); } catch (e) {
|
|
270
|
+
await io.write(`wsh: ${e.message}\n`);
|
|
271
|
+
code = 1;
|
|
272
|
+
}
|
|
273
|
+
if (timer) clearTimeout(timer);
|
|
274
|
+
// Output is ready but the client's stream may not have been bound yet
|
|
275
|
+
// (a host without announce support waits for the first byte): give it
|
|
276
|
+
// a bounded chance rather than dropping the output or hanging forever.
|
|
277
|
+
if (!stream) await Promise.race([new Promise((r) => boundWaiters.push(r)), new Promise((r) => setTimeout(r, cfg.bindTimeoutMs))]);
|
|
278
|
+
if (!stream) log(`exec channel ${channelId}: data stream never appeared; output dropped`);
|
|
279
|
+
await wq;
|
|
280
|
+
try { await stream?.close(); } catch { /* peer gone */ }
|
|
281
|
+
await finishChannel(channelId, Number.isInteger(code) ? code : 0);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
async function openPty(m) {
|
|
286
|
+
if (!cfg.pty) return send(openFail({ reason: 'pty is not enabled on this server' }));
|
|
287
|
+
const channelId = ++state.nextChannel;
|
|
288
|
+
const cols = m.cols || 80;
|
|
289
|
+
const rows = m.rows || 24;
|
|
290
|
+
const p = cfg.pty;
|
|
291
|
+
let proc;
|
|
292
|
+
try {
|
|
293
|
+
const args = m.command ? ['-c', String(m.command)] : [];
|
|
294
|
+
proc = p.spawn(p.shell, args, {
|
|
295
|
+
name: p.term, cols, rows, cwd: p.cwd,
|
|
296
|
+
env: { ...(p.env ?? process.env), TERM: p.term, ...(m.env && typeof m.env === 'object' ? m.env : {}) },
|
|
297
|
+
});
|
|
298
|
+
} catch (e) {
|
|
299
|
+
return send(openFail({ reason: `pty spawn failed: ${e.message}` }));
|
|
300
|
+
}
|
|
301
|
+
const ch = {
|
|
302
|
+
input: (data) => { try { proc.write(Buffer.from(data)); } catch { /* exited */ } },
|
|
303
|
+
resize: (c, r) => { try { proc.resize(c || cols, r || rows); } catch { /* exited */ } },
|
|
304
|
+
signal: (name) => { try { proc.kill(`SIG${name.replace(/^SIG/, '').toUpperCase()}`); } catch { /* exited */ } },
|
|
305
|
+
kill: () => { try { proc.kill(); } catch { /* exited */ } },
|
|
306
|
+
};
|
|
307
|
+
channels.set(channelId, ch);
|
|
308
|
+
let out = Promise.resolve();
|
|
309
|
+
proc.onData((d) => { out = out.then(() => send(sessionData({ channelId, data: toBytes(d) }))); });
|
|
310
|
+
proc.onExit(({ exitCode }) => { out.then(() => finishChannel(channelId, exitCode ?? 0)); });
|
|
311
|
+
await send(openOk({
|
|
312
|
+
channelId, dataMode: 'virtual', capabilities: ['resize', 'signal'],
|
|
313
|
+
sessionId: randomUUID(), token: randomBytes(16),
|
|
314
|
+
}));
|
|
315
|
+
log(`pty channel ${channelId} opened (${cols}x${rows})`);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
async function openFile(m) {
|
|
319
|
+
if (!cfg.files) return send(openFail({ reason: 'file access is not enabled on this server' }));
|
|
320
|
+
const spec = String(m.command ?? '');
|
|
321
|
+
const mm = spec.match(/^(upload|download):(.*)$/s);
|
|
322
|
+
if (!mm) return send(openFail({ reason: 'file channel expects upload:<path> or download:<path>' }));
|
|
323
|
+
const [, direction, filePath] = mm;
|
|
324
|
+
const channelId = ++state.nextChannel;
|
|
325
|
+
|
|
326
|
+
if (direction === 'download') {
|
|
327
|
+
let data;
|
|
328
|
+
try { data = await cfg.files.readWhole(filePath); } catch (e) { return send(openFail({ reason: e.message })); }
|
|
329
|
+
channels.set(channelId, { kill: () => channels.delete(channelId) });
|
|
330
|
+
await send(openOk({ channelId, dataMode: 'virtual', capabilities: [] }));
|
|
331
|
+
let off = 0;
|
|
332
|
+
do {
|
|
333
|
+
const end = Math.min(off + FILE_CHUNK_BYTES, data.byteLength);
|
|
334
|
+
await send(fileChunk({ channelId, offset: off, data: data.subarray(off, end), isFinal: end >= data.byteLength, totalSize: data.byteLength }));
|
|
335
|
+
off = end;
|
|
336
|
+
} while (off < data.byteLength);
|
|
337
|
+
log(`download ${filePath} (${data.byteLength} B)`);
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const ch = { buf: null, kill: () => channels.delete(channelId) };
|
|
342
|
+
channels.set(channelId, ch);
|
|
343
|
+
const done = async (code, why) => {
|
|
344
|
+
if (why) log(`upload ${filePath}: ${why}`);
|
|
345
|
+
await finishChannel(channelId, code);
|
|
346
|
+
};
|
|
347
|
+
ch.chunk = async (c) => {
|
|
348
|
+
if (ch.failed) return;
|
|
349
|
+
const total = Number(c.total_size ?? 0);
|
|
350
|
+
if (total > cfg.files.maxFileBytes) { ch.failed = true; return done(1, `refused: ${total} B exceeds ${cfg.files.maxFileBytes} B`); }
|
|
351
|
+
ch.buf ??= new Uint8Array(total);
|
|
352
|
+
const data = c.data instanceof Uint8Array ? c.data : new Uint8Array(0);
|
|
353
|
+
if (c.offset + data.byteLength > ch.buf.byteLength) { ch.failed = true; return done(1, 'chunk past total_size'); }
|
|
354
|
+
ch.buf.set(data, c.offset);
|
|
355
|
+
if (c.is_final) {
|
|
356
|
+
try { await cfg.files.writeWhole(filePath, ch.buf); } catch (e) { ch.failed = true; return done(1, e.message); }
|
|
357
|
+
return done(0, `stored ${ch.buf.byteLength} B`);
|
|
358
|
+
}
|
|
359
|
+
};
|
|
360
|
+
await send(openOk({ channelId, dataMode: 'virtual', capabilities: [] }));
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
async function handleFileOp(m) {
|
|
364
|
+
const channelId = m.channel_id;
|
|
365
|
+
if (!cfg.files) return send(fileResult({ channelId, success: false, errorMessage: 'file access is not enabled on this server' }));
|
|
366
|
+
try {
|
|
367
|
+
const r = await cfg.files.operate(m.op, m.path, { offset: m.offset, length: m.length });
|
|
368
|
+
return send(fileResult({ channelId, success: true, metadata: r.metadata ?? {}, entries: r.entries ?? [] }));
|
|
369
|
+
} catch (e) {
|
|
370
|
+
const message = e.wsh ? e.message : (e.code === 'ENOENT' ? 'no such file or directory' : `${m.op} failed`);
|
|
371
|
+
if (!e.wsh && e.code !== 'ENOENT') log(`file ${m.op} ${m.path}: ${e.message}`);
|
|
372
|
+
return send(fileResult({ channelId, success: false, errorMessage: message }));
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
return {
|
|
377
|
+
receive(bytes) { if (!state.closed) qmux.receiveBytes(bytes); },
|
|
378
|
+
close() { shutdown('transport closed'); },
|
|
379
|
+
};
|
|
380
|
+
};
|
|
381
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default `exec` runner for `@johnhenry/wsh/server`: runs the command through
|
|
3
|
+
* a shell with `node:child_process` and wires it to the session `io`.
|
|
4
|
+
*
|
|
5
|
+
* A custom runner has the same shape -- `run(command, io) => Promise<exitCode>`
|
|
6
|
+
* -- so a restricted host (no real shell) can replace this wholesale.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { spawn } from 'node:child_process';
|
|
10
|
+
import { constants } from 'node:os';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @param {{ cwd?: string, env?: object, shell?: boolean | string }} [opts]
|
|
14
|
+
* @returns {(command: string, io: ExecIo) => Promise<number>}
|
|
15
|
+
*/
|
|
16
|
+
export function spawnRunner({ cwd, env, shell = true } = {}) {
|
|
17
|
+
return (command, io) => new Promise((resolve) => {
|
|
18
|
+
let proc;
|
|
19
|
+
try {
|
|
20
|
+
proc = spawn(command, {
|
|
21
|
+
shell,
|
|
22
|
+
cwd,
|
|
23
|
+
env: { ...(env ?? process.env), ...io.env },
|
|
24
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
25
|
+
});
|
|
26
|
+
} catch (err) {
|
|
27
|
+
io.write(`wsh: ${err.message}\n`).finally(() => resolve(127));
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const out = (chunk) => { io.write(new Uint8Array(chunk)); };
|
|
32
|
+
proc.stdout.on('data', out);
|
|
33
|
+
proc.stderr.on('data', out);
|
|
34
|
+
proc.stdin.on('error', () => { /* process exited before stdin drained */ });
|
|
35
|
+
io.onInput((bytes) => { try { proc.stdin.write(bytes); } catch { /* exited */ } });
|
|
36
|
+
io.onInputEnd(() => { try { proc.stdin.end(); } catch { /* exited */ } });
|
|
37
|
+
io.onSignal((name) => { try { proc.kill(`SIG${name}`); } catch { /* exited or unknown */ } });
|
|
38
|
+
io.signal.addEventListener('abort', () => { try { proc.kill('SIGTERM'); } catch { /* exited */ } }, { once: true });
|
|
39
|
+
|
|
40
|
+
proc.on('error', (err) => {
|
|
41
|
+
io.write(`wsh: ${err.message}\n`).finally(() => resolve(127));
|
|
42
|
+
});
|
|
43
|
+
proc.on('close', (code, signal) => {
|
|
44
|
+
resolve(code ?? (signal ? 128 + (constants.signals[signal] ?? 0) : 0));
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* @typedef {object} ExecIo
|
|
51
|
+
* @property {string} user - Authenticated username.
|
|
52
|
+
* @property {Record<string,string>} env - Environment requested by the client.
|
|
53
|
+
* @property {number} cols
|
|
54
|
+
* @property {number} rows
|
|
55
|
+
* @property {AbortSignal} signal - Aborts on client Close, disconnect or timeout.
|
|
56
|
+
* @property {(data: Uint8Array | string) => Promise<void>} write - Send output; resolves when flow control accepts it.
|
|
57
|
+
* @property {(cb: (bytes: Uint8Array) => void) => void} onInput - Client stdin.
|
|
58
|
+
* @property {(cb: () => void) => void} onInputEnd - Client closed stdin.
|
|
59
|
+
* @property {(cb: (name: string) => void) => void} onSignal - Named signal (`INT`, `TERM`, ...).
|
|
60
|
+
*/
|