@johnhenry/wsh 0.17.1 → 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 CHANGED
@@ -16,7 +16,7 @@ Full documentation: [opensource.johnhenry.me/wsh](https://opensource.johnhenry.m
16
16
 
17
17
  Browser-native remote command execution over WebTransport/WebSocket with Ed25519 authentication.
18
18
 
19
- wsh is a pure-JS client library that connects browsers to remote shells. It implements its own binary protocol — CBOR messages over QMux-multiplexed WebSocket or native WebTransport streams — with Ed25519 challenge-response auth, session management, and MCP tool bridging.
19
+ wsh is a pure-JS client library (with an optional Node server at `@johnhenry/wsh/server`) that connects browsers to remote shells. It implements its own binary protocol — CBOR messages over QMux-multiplexed WebSocket or native WebTransport streams — with Ed25519 challenge-response auth, session management, and MCP tool bridging.
20
20
 
21
21
  ## Contents
22
22
 
@@ -25,6 +25,7 @@ wsh is a pure-JS client library that connects browsers to remote shells. It impl
25
25
  - [Wire Protocol: QMux](#wire-protocol-qmux)
26
26
  - [Quick Start](#quick-start)
27
27
  - [One-Shot Command Execution](#one-shot-command-execution)
28
+ - [Node Server](#node-server)
28
29
  - [Attach and Resume](#attach-and-resume)
29
30
  - [Pinning a Self-Signed Certificate](#pinning-a-self-signed-certificate)
30
31
  - [API Overview](#api-overview)
@@ -145,6 +146,48 @@ console.log(new TextDecoder().decode(stdout));
145
146
  console.log('Exit code:', exitCode);
146
147
  ```
147
148
 
149
+ `exec` and `openSession({ type: 'exec' })` need no extra step to receive
150
+ output. Older hosts only discover the client-opened data stream once a byte
151
+ arrives on it, so against a host that does not advertise `stream-announce`
152
+ the client writes a one-byte primer for you (a host that strips it, as those
153
+ hosts do). Pass `primer: false` to never send it.
154
+
155
+ ## Node Server
156
+
157
+ `@johnhenry/wsh/server` is a Node host for the same protocol, over WebSocket
158
+ (QMux). It is a separate subpath -- the package root stays browser-safe and
159
+ never imports it -- and needs the optional peer dependency `ws`
160
+ (`npm install ws`).
161
+
162
+ ```js
163
+ import { createWshServer } from '@johnhenry/wsh/server';
164
+ import { readFileSync } from 'node:fs';
165
+
166
+ const server = createWshServer({
167
+ host: '127.0.0.1',
168
+ port: 4422, // 0 = pick a free port
169
+ auth: { authorizedKeys: readFileSync('authorized_keys', 'utf8') }, // ssh-ed25519 lines
170
+ exec: true, // opt in: run commands via /bin/sh
171
+ fs: { root: '/srv/share', readOnly: false }, // opt in: list/stat/read/mkdir/remove/upload/download
172
+ // pty: { spawn: nodePty.spawn }, // opt in: bring your own node-pty
173
+ });
174
+ const { port } = await server.listen(); // later: server.address(), await server.close()
175
+ ```
176
+
177
+ Everything that touches the machine -- `exec`, `pty`, `fs` -- is **off unless
178
+ you pass it**, and with no `auth` every connection is refused. `auth` is
179
+ `{ authorizedKeys, authorize }` (a key must be on the list and pass
180
+ `authorize({ username, fingerprint, publicKey })` when both are given) or
181
+ just an `authorize` function. `exec` can also be `{ cwd, env, shell,
182
+ timeoutMs, clientEnv }` or a custom `run(command, io)` for a restricted host
183
+ with no shell. `fs.root` confines every path: `..`, absolute paths and
184
+ symlinks pointing out of it are refused. The server advertises
185
+ `stream-announce`, so a current client writes no primer byte; a lone leading
186
+ `0x00` from a client that primes anyway is dropped rather than reaching stdin.
187
+
188
+ Not implemented: WebTransport, relay/reverse mode, attach/resume, MCP,
189
+ password auth.
190
+
148
191
  ## Attach and Resume
149
192
 
150
193
  Opening a PTY/exec session returns a session-scoped credential alongside the channel:
@@ -249,6 +292,7 @@ so options the platform gains later need no change here.
249
292
  | `SessionRecorder` | Record PTY I/O with timestamps (own schema, not asciicast v2) |
250
293
  | `SessionPlayer` | Replay recordings with original timing |
251
294
  | `generateKeyPair()` | Create Ed25519 key pair via Web Crypto |
295
+ | `sign(privateKey, message)` / `verify(publicKey, signature, message)` | Raw Ed25519 sign / verify. This positional order (WebCrypto's) is the one canonical form across wsh, raijin and browsermesh |
252
296
  | `signChallenge()` | Build transcript + sign for auth handshake |
253
297
  | `signPeerRecord()` / `verifyPeerRecord()` | Sign / verify reverse-mode peer records |
254
298
  | `fingerprint()` | SHA-256 hex fingerprint of a public key |
package/package.json CHANGED
@@ -1,13 +1,17 @@
1
1
  {
2
2
  "name": "@johnhenry/wsh",
3
- "version": "0.17.1",
4
- "description": "Web Shell — browser-native remote command execution over WebTransport and WebSocket with Ed25519 authentication",
3
+ "version": "0.18.0",
4
+ "description": "Web Shell \u2014 browser-native remote command execution over WebTransport and WebSocket with Ed25519 authentication",
5
5
  "type": "module",
6
6
  "main": "./src/index.mjs",
7
7
  "exports": {
8
8
  ".": {
9
9
  "types": "./src/index.d.ts",
10
10
  "import": "./src/index.mjs"
11
+ },
12
+ "./server": {
13
+ "types": "./src/server/index.d.ts",
14
+ "import": "./src/server/index.mjs"
11
15
  }
12
16
  },
13
17
  "types": "./src/index.d.ts",
@@ -19,6 +23,14 @@
19
23
  "optionalDependencies": {
20
24
  "@noble/post-quantum": "^0.7.0"
21
25
  },
26
+ "peerDependencies": {
27
+ "ws": "^8.0.0"
28
+ },
29
+ "peerDependenciesMeta": {
30
+ "ws": {
31
+ "optional": true
32
+ }
33
+ },
22
34
  "devDependencies": {
23
35
  "@fails-components/webtransport": "^1.6.4",
24
36
  "@fails-components/webtransport-transport-http3-quiche": "^1.6.4",
package/src/client.mjs CHANGED
@@ -63,6 +63,13 @@ const DEFAULT_OPEN_TIMEOUT = 10_000; // ms
63
63
  */
64
64
  export const MCP_CALL_ID_FEATURE = 'mcp-call-id';
65
65
 
66
+ /**
67
+ * ServerHello feature: the host discovers a client-opened exec data stream
68
+ * on its own (the transport announces it), so the client must NOT write the
69
+ * one-byte "primer" -- against such a host it would reach the process's stdin.
70
+ */
71
+ export const STREAM_ANNOUNCE_FEATURE = 'stream-announce';
72
+
66
73
  /**
67
74
  * Reject a missing argument that maps to a `required: true` wire field.
68
75
  *
@@ -587,9 +594,14 @@ export class WshClient {
587
594
  * @param {number} [opts.rows=24] - Initial terminal rows
588
595
  * @param {object} [opts.env] - Environment variables
589
596
  * @param {number} [opts.timeout] - Timeout in ms
597
+ * @param {boolean} [opts.primer=true] - For stream-mode `exec` sessions: write
598
+ * the one-byte "primer" some hosts need to discover the client-opened data
599
+ * stream (without it they never bind the stream and drop all output).
600
+ * Skipped automatically against a host advertising `stream-announce`; pass
601
+ * `false` to never send it (e.g. a host that forwards stdin verbatim).
590
602
  * @returns {Promise<WshSession>}
591
603
  */
592
- async openSession({ type = 'pty', command, cols = 80, rows = 24, env, timeout = DEFAULT_OPEN_TIMEOUT } = {}) {
604
+ async openSession({ type = 'pty', command, cols = 80, rows = 24, env, timeout = DEFAULT_OPEN_TIMEOUT, primer = true } = {}) {
593
605
  this.#assertAuthenticated('openSession');
594
606
  const requestedChannelId = this._nextChannelId();
595
607
 
@@ -605,6 +617,7 @@ export class WshClient {
605
617
  const entry = {
606
618
  mode: 'session',
607
619
  kind: type,
620
+ primer,
608
621
  requestedChannelId,
609
622
  resolve,
610
623
  reject,
@@ -842,9 +855,10 @@ export class WshClient {
842
855
  * @param {CryptoKeyPair} [opts.keyPair]
843
856
  * @param {string} [opts.password]
844
857
  * @param {number} [opts.timeout=60000] - Overall timeout in ms
858
+ * @param {boolean} [opts.primer=true] - See `openSession({ primer })`
845
859
  * @returns {Promise<{stdout: Uint8Array, exitCode: number}>}
846
860
  */
847
- static async exec(url, command, { username, keyPair, password, timeout = DEFAULT_EXEC_TIMEOUT } = {}) {
861
+ static async exec(url, command, { username, keyPair, password, timeout = DEFAULT_EXEC_TIMEOUT, primer = true } = {}) {
848
862
  const client = new WshClient();
849
863
  const chunks = [];
850
864
  let exitCode = -1;
@@ -852,7 +866,7 @@ export class WshClient {
852
866
  try {
853
867
  await client.connect(url, { username, keyPair, password });
854
868
 
855
- const session = await client.openSession({ type: 'exec', command });
869
+ const session = await client.openSession({ type: 'exec', command, primer });
856
870
 
857
871
  await new Promise((resolve, reject) => {
858
872
  const timer = setTimeout(() => {
@@ -2145,8 +2159,20 @@ export class WshClient {
2145
2159
  // so channel-scoped control messages arriving before the stream
2146
2160
  // finishes binding still route correctly.
2147
2161
  this.#transport.openStream().then(
2148
- (stream) => {
2162
+ async (stream) => {
2149
2163
  session._bind(stream.readable, stream.writable);
2164
+ // The transport has already announced the stream, which is all a
2165
+ // current host needs. Older hosts only bind a stream on its first
2166
+ // byte, so exec (which has no stdin to send) writes a one-byte
2167
+ // primer they strip -- unless the host says it needs none.
2168
+ if (pending.kind === 'exec' && pending.primer && !this.hasFeature(STREAM_ANNOUNCE_FEATURE)) {
2169
+ try {
2170
+ await session.write(new Uint8Array([0]));
2171
+ } catch (err) {
2172
+ pending.reject(err);
2173
+ return;
2174
+ }
2175
+ }
2150
2176
  pending.resolve(session);
2151
2177
  },
2152
2178
  (err) => pending.reject(err)
package/src/index.d.ts CHANGED
@@ -238,6 +238,13 @@ export const PROTOCOL_VERSION: 'wsh-v1';
238
238
  */
239
239
  export const MCP_CALL_ID_FEATURE: 'mcp-call-id';
240
240
 
241
+ /**
242
+ * ServerHello feature: the host discovers a client-opened exec data stream
243
+ * without help (the transport announces it), so the client writes no
244
+ * one-byte primer against it.
245
+ */
246
+ export const STREAM_ANNOUNCE_FEATURE: 'stream-announce';
247
+
241
248
  /** A wsh protocol control message (all messages have a numeric `type`). */
242
249
  export interface WshMessage {
243
250
  type: number;
@@ -895,15 +902,19 @@ export function exportPrivateKeyPKCS8(privateKey: CryptoKey): Promise<Uint8Array
895
902
  export function importPrivateKeyPKCS8(pkcs8: Uint8Array, extractable?: boolean): Promise<CryptoKey>;
896
903
 
897
904
  /**
898
- * Sign data with an Ed25519 private key.
905
+ * Sign data with an Ed25519 private key. Positional order mirrors WebCrypto
906
+ * (`sign(privateKey, message)`) and is the canonical form across wsh, raijin
907
+ * and browsermesh.
899
908
  * @returns 64-byte signature
900
909
  */
901
- export function sign(privateKey: CryptoKey, data: Uint8Array): Promise<Uint8Array>;
910
+ export function sign(privateKey: CryptoKey, message: Uint8Array): Promise<Uint8Array>;
902
911
 
903
912
  /**
904
- * Verify an Ed25519 signature.
913
+ * Verify an Ed25519 signature. Positional order mirrors WebCrypto
914
+ * (`verify(publicKey, signature, message)`) and is the canonical form across
915
+ * wsh, raijin and browsermesh.
905
916
  */
906
- export function verify(publicKey: CryptoKey, signature: Uint8Array, data: Uint8Array): Promise<boolean>;
917
+ export function verify(publicKey: CryptoKey, signature: Uint8Array, message: Uint8Array): Promise<boolean>;
907
918
 
908
919
  /** Options binding extra context into the auth transcript. */
909
920
  export interface WshTranscriptOptions {
@@ -1561,6 +1572,13 @@ export interface WshOpenSessionOptions {
1561
1572
  rows?: number;
1562
1573
  env?: Record<string, string>;
1563
1574
  timeout?: number;
1575
+ /**
1576
+ * Stream-mode `exec` only (default `true`): write the one-byte "primer"
1577
+ * that older hosts need to discover the client-opened data stream. Skipped
1578
+ * automatically against a host advertising `stream-announce`; `false`
1579
+ * never sends it.
1580
+ */
1581
+ primer?: boolean;
1564
1582
  }
1565
1583
 
1566
1584
  /** Options for WshClient.attachSession(). */
@@ -1611,6 +1629,8 @@ export interface WshExecOptions {
1611
1629
  keyPair?: CryptoKeyPair;
1612
1630
  password?: string;
1613
1631
  timeout?: number;
1632
+ /** See {@link WshOpenSessionOptions.primer}. */
1633
+ primer?: boolean;
1614
1634
  }
1615
1635
 
1616
1636
  /** Result from WshClient.exec(). */
package/src/index.mjs CHANGED
@@ -73,7 +73,7 @@ export {
73
73
  // Session + Client
74
74
  export { WshSession } from './session.mjs';
75
75
  export { WshVirtualSessionBackend, normalizeSessionData } from './virtual-session.mjs';
76
- export { WshClient, MCP_CALL_ID_FEATURE } from './client.mjs';
76
+ export { WshClient, MCP_CALL_ID_FEATURE, STREAM_ANNOUNCE_FEATURE } from './client.mjs';
77
77
 
78
78
  // Key storage
79
79
  export { WshKeyStore } from './keystore.mjs';
@@ -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
+ */
@@ -0,0 +1,134 @@
1
+ /**
2
+ * File access for `@johnhenry/wsh/server`: FileOp requests (list, stat, read,
3
+ * mkdir, remove) and `upload:`/`download:` file channels, confined to one
4
+ * root directory.
5
+ *
6
+ * Every path a client sends is resolved against `root`; a path that would
7
+ * leave it -- by `..`, an absolute path, or a symlink -- is refused.
8
+ */
9
+
10
+ import {
11
+ readdir, lstat, stat, readFile, writeFile, mkdir, rmdir, unlink, readlink, realpath, open,
12
+ } from 'node:fs/promises';
13
+ import path from 'node:path';
14
+
15
+ export const DEFAULT_MAX_FILE_BYTES = 64 * 1024 * 1024;
16
+ export const FILE_CHUNK_BYTES = 64 * 1024;
17
+
18
+ /**
19
+ * @param {{ root: string, readOnly?: boolean, maxFileBytes?: number }} opts
20
+ */
21
+ export function createFileAccess({ root, readOnly = false, maxFileBytes = DEFAULT_MAX_FILE_BYTES }) {
22
+ if (typeof root !== 'string' || !root) throw new TypeError('createWshServer: fs.root is required');
23
+ const base = path.resolve(root);
24
+ let realBase = null;
25
+
26
+ const fail = (message) => Object.assign(new Error(message), { wsh: true });
27
+
28
+ /** Resolve a client path to an absolute path inside `root`, or throw. */
29
+ async function resolveInside(clientPath) {
30
+ if (typeof clientPath !== 'string') throw fail('path is required');
31
+ if (clientPath.includes('\0')) throw fail('illegal path');
32
+ realBase ??= await realpath(base);
33
+ const target = path.resolve(base, '.' + path.sep + clientPath.replace(/^[/\\]+/, ''));
34
+ if (target !== base && !target.startsWith(base + path.sep)) throw fail('path escapes the file root');
35
+ // Symlink escape: the deepest existing ancestor must still be inside the real root.
36
+ let probe = target;
37
+ for (;;) {
38
+ try {
39
+ const real = await realpath(probe);
40
+ if (real !== realBase && !real.startsWith(realBase + path.sep)) throw fail('path escapes the file root');
41
+ break;
42
+ } catch (err) {
43
+ if (err.wsh) throw err;
44
+ const parent = path.dirname(probe);
45
+ if (parent === probe) break;
46
+ probe = parent;
47
+ }
48
+ }
49
+ return target;
50
+ }
51
+
52
+ const typeOf = (st) => st.isSymbolicLink() ? 'symlink'
53
+ : st.isDirectory() ? 'directory'
54
+ : st.isFile() ? 'file'
55
+ : st.isBlockDevice() || st.isCharacterDevice() ? 'device'
56
+ : st.isFIFO() ? 'pipe'
57
+ : st.isSocket() ? 'socket' : 'file';
58
+
59
+ async function entryFor(full, name) {
60
+ const st = await lstat(full);
61
+ const entry = { name, size: st.size, modified: Math.floor(st.mtimeMs / 1000), type: typeOf(st) };
62
+ if (entry.type === 'symlink') {
63
+ try { entry.symlink_target = await readlink(full); } catch { /* raced */ }
64
+ }
65
+ return entry;
66
+ }
67
+
68
+ const assertWritable = () => { if (readOnly) throw fail('file root is read-only'); };
69
+
70
+ return {
71
+ maxFileBytes,
72
+
73
+ /** @returns {Promise<{ metadata?: object, entries?: object[] }>} */
74
+ async operate(op, clientPath, { offset, length } = {}) {
75
+ switch (op) {
76
+ case 'list': {
77
+ const dir = await resolveInside(clientPath || '/');
78
+ const names = await readdir(dir);
79
+ const entries = [];
80
+ for (const name of names.sort()) {
81
+ try { entries.push(await entryFor(path.join(dir, name), name)); } catch { /* raced */ }
82
+ }
83
+ return { entries, metadata: { path: clientPath || '/' } };
84
+ }
85
+ case 'stat': {
86
+ const full = await resolveInside(clientPath);
87
+ return { metadata: await entryFor(full, path.basename(full)) };
88
+ }
89
+ case 'read': {
90
+ const full = await resolveInside(clientPath);
91
+ const st = await stat(full);
92
+ if (!st.isFile()) throw fail('not a regular file');
93
+ const start = Math.max(0, Number(offset ?? 0));
94
+ const want = Math.min(Number(length ?? FILE_CHUNK_BYTES), FILE_CHUNK_BYTES);
95
+ const fh = await open(full, 'r');
96
+ try {
97
+ const buf = new Uint8Array(want);
98
+ const { bytesRead } = await fh.read(buf, 0, want, start);
99
+ return { metadata: { data: buf.subarray(0, bytesRead), size: st.size } };
100
+ } finally { await fh.close(); }
101
+ }
102
+ case 'mkdir': {
103
+ assertWritable();
104
+ await mkdir(await resolveInside(clientPath), { recursive: true });
105
+ return { metadata: {} };
106
+ }
107
+ case 'remove': {
108
+ assertWritable();
109
+ const full = await resolveInside(clientPath);
110
+ if (full === base) throw fail('refusing to remove the file root');
111
+ await ((await lstat(full)).isDirectory() ? rmdir(full) : unlink(full));
112
+ return { metadata: { removed: clientPath } };
113
+ }
114
+ default:
115
+ throw fail(`"${op}" is not offered by this host (list, stat, read, mkdir, remove; use upload/download to write)`);
116
+ }
117
+ },
118
+
119
+ async readWhole(clientPath) {
120
+ const full = await resolveInside(clientPath);
121
+ const st = await stat(full);
122
+ if (!st.isFile()) throw fail('not a regular file');
123
+ if (st.size > maxFileBytes) throw fail(`file is ${st.size} bytes; the limit is ${maxFileBytes}`);
124
+ return new Uint8Array(await readFile(full));
125
+ },
126
+
127
+ async writeWhole(clientPath, data) {
128
+ assertWritable();
129
+ const full = await resolveInside(clientPath);
130
+ await mkdir(path.dirname(full), { recursive: true });
131
+ await writeFile(full, data);
132
+ },
133
+ };
134
+ }
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Types for `@johnhenry/wsh/server` -- the Node host. Node-only; the package
3
+ * root (`@johnhenry/wsh`) never imports it.
4
+ */
5
+
6
+ /** Who is asking to authenticate. */
7
+ export interface WshServerPrincipal {
8
+ username: string;
9
+ /** Hex SHA-256 of the raw public key (see `fingerprint()`). */
10
+ fingerprint: string;
11
+ /** Raw 32-byte Ed25519 public key. */
12
+ publicKey: Uint8Array;
13
+ }
14
+
15
+ export interface WshServerAuthOptions {
16
+ /**
17
+ * Keys allowed to connect: `ssh-ed25519 AAAA... [comment]` lines (an
18
+ * `authorized_keys` file's text works), raw 32-byte keys, or an array of either.
19
+ */
20
+ authorizedKeys?: string | Uint8Array | Array<string | Uint8Array>;
21
+ /** Extra policy, run after the signature verifies (and after the allowlist, when given). */
22
+ authorize?: (who: WshServerPrincipal) => boolean | Promise<boolean>;
23
+ }
24
+
25
+ /** What a custom `exec` runner receives for one session. */
26
+ export interface WshExecIo {
27
+ user: string;
28
+ /** Environment the client asked for (empty when `clientEnv: false`). */
29
+ env: Record<string, string>;
30
+ cols: number;
31
+ rows: number;
32
+ /** Aborts on client Close, disconnect, timeout, or a terminating signal nothing handled. */
33
+ signal: AbortSignal;
34
+ /** Send output; resolves when flow control has accepted it. */
35
+ write(data: Uint8Array | string): Promise<void>;
36
+ /** Client stdin. */
37
+ onInput(cb: (bytes: Uint8Array) => void): void;
38
+ /** Client closed stdin. */
39
+ onInputEnd(cb: () => void): void;
40
+ /** Named signal from the client (`INT`, `TERM`, ...). */
41
+ onSignal(cb: (name: string) => void): void;
42
+ }
43
+
44
+ /** Runs one exec session; resolve with the exit code. */
45
+ export type WshExecRunner = (command: string, io: WshExecIo) => Promise<number>;
46
+
47
+ export interface WshServerExecOptions {
48
+ /** Working directory of spawned commands. */
49
+ cwd?: string;
50
+ /** Base environment (default `process.env`). */
51
+ env?: Record<string, string | undefined>;
52
+ /** `true` (default `/bin/sh`), or a shell path. */
53
+ shell?: boolean | string;
54
+ /** Kill a session after this long (0 / omitted = never). */
55
+ timeoutMs?: number;
56
+ /** Apply `env` sent by the client (default `true`). */
57
+ clientEnv?: boolean;
58
+ /** Replace the child_process runner entirely (e.g. a restricted, no-shell host). */
59
+ run?: WshExecRunner;
60
+ }
61
+
62
+ /** The part of a pseudo-terminal process the server uses -- `node-pty`'s `IPty` fits. */
63
+ export interface WshPtyProcess {
64
+ onData(cb: (data: string | Uint8Array) => void): unknown;
65
+ onExit(cb: (e: { exitCode: number; signal?: number }) => void): unknown;
66
+ write(data: string | Uint8Array): void;
67
+ resize(cols: number, rows: number): void;
68
+ kill(signal?: string): void;
69
+ }
70
+
71
+ export interface WshServerPtyOptions {
72
+ /** `node-pty`'s `spawn(file, args, options)`, or anything shaped like it. */
73
+ spawn(file: string, args: string[], options: {
74
+ name: string; cols: number; rows: number; cwd?: string; env: Record<string, string | undefined>;
75
+ }): WshPtyProcess;
76
+ /** Shell to run (default `$SHELL` or `/bin/sh`). */
77
+ shell?: string;
78
+ cwd?: string;
79
+ env?: Record<string, string | undefined>;
80
+ /** `TERM` value (default `xterm-256color`). */
81
+ term?: string;
82
+ }
83
+
84
+ export interface WshServerFsOptions {
85
+ /** Every client path is resolved inside this directory; `..`, absolute paths and symlinks out of it are refused. */
86
+ root: string;
87
+ readOnly?: boolean;
88
+ /** Upload / download size cap in bytes (default 64 MiB). */
89
+ maxFileBytes?: number;
90
+ }
91
+
92
+ export interface WshServerOptions {
93
+ /** Bind address (default `127.0.0.1`). */
94
+ host?: string;
95
+ /** Port (default `0` = pick a free one; read it back from `address()`). */
96
+ port?: number;
97
+ /** Who may connect. Omitted = every connection is refused. A function is shorthand for `{ authorize }`. */
98
+ auth?: WshServerAuthOptions | ((who: WshServerPrincipal) => boolean | Promise<boolean>);
99
+ /** Enable `exec` sessions. `true`, options, or a custom runner. Off by default. */
100
+ exec?: true | WshServerExecOptions | WshExecRunner;
101
+ /** Enable `pty` sessions by injecting a pty implementation. Off by default. */
102
+ pty?: WshServerPtyOptions;
103
+ /** Enable file ops and uploads/downloads under one directory. Off by default. */
104
+ fs?: WshServerFsOptions;
105
+ /** How long exec output waits for a client that has not yet opened its data stream (default 3000). */
106
+ bindTimeoutMs?: number;
107
+ onLog?: (line: string) => void;
108
+ }
109
+
110
+ export interface WshServerAddress {
111
+ address: string;
112
+ port: number;
113
+ }
114
+
115
+ export interface WshServer {
116
+ /** Start listening. Requires the `ws` package. */
117
+ listen(): Promise<WshServerAddress>;
118
+ /** Drop every connection and stop listening. */
119
+ close(): Promise<void>;
120
+ /** The bound address, or `null` when not listening. */
121
+ address(): WshServerAddress | null;
122
+ }
123
+
124
+ export function createWshServer(options?: WshServerOptions): WshServer;
125
+
126
+ /** Parse `authorized_keys`-style text into raw 32-byte Ed25519 keys (malformed lines skipped). */
127
+ export function parseAuthorizedKeys(text: string): Uint8Array[];
128
+
129
+ /** ServerHello feature: the host discovers client-opened exec data streams itself (no primer byte). */
130
+ export const STREAM_ANNOUNCE: 'stream-announce';
@@ -0,0 +1,95 @@
1
+ /**
2
+ * `@johnhenry/wsh/server` -- a Node host for the wsh-v1 protocol, built on the
3
+ * same primitives the client is. Node-only (`ws`, `node:child_process`,
4
+ * `node:fs`); the package root stays browser-safe and never imports this.
5
+ *
6
+ * Everything that grants a client access to the machine -- `exec`, `pty`,
7
+ * `fs` -- is OFF unless you pass it, and with no `auth` every connection is
8
+ * refused.
9
+ */
10
+
11
+ import { buildAuthorizer, parseAuthorizedKeys } from './auth.mjs';
12
+ import { spawnRunner } from './exec.mjs';
13
+ import { normalizePty } from './pty.mjs';
14
+ import { createFileAccess } from './fs.mjs';
15
+ import { createConnectionFactory, STREAM_ANNOUNCE } from './connection.mjs';
16
+
17
+ export { parseAuthorizedKeys, STREAM_ANNOUNCE };
18
+
19
+ /**
20
+ * @param {object} [options]
21
+ * @param {string} [options.host='127.0.0.1']
22
+ * @param {number} [options.port=0] - 0 picks a free port; read it back from `address()`.
23
+ * @param {object | Function} [options.auth] - See `buildAuthorizer`. Omitted = refuse everyone.
24
+ * @param {true | object | Function} [options.exec] - `true` (shell via child_process),
25
+ * `{ cwd, env, shell, timeoutMs, clientEnv }`, or a custom `run(command, io)` function.
26
+ * @param {{ spawn: Function, shell?: string, cwd?: string, env?: object }} [options.pty]
27
+ * @param {{ root: string, readOnly?: boolean, maxFileBytes?: number }} [options.fs]
28
+ * @param {number} [options.bindTimeoutMs=3000]
29
+ * @param {(line: string) => void} [options.onLog]
30
+ * @returns {{ listen(): Promise<{address: string, port: number}>, close(): Promise<void>, address(): ({address: string, port: number} | null) }}
31
+ */
32
+ export function createWshServer({
33
+ host = '127.0.0.1', port = 0, auth, exec, pty, fs, bindTimeoutMs = 3000, onLog = () => {},
34
+ } = {}) {
35
+ let wss = null;
36
+ let bound = null;
37
+
38
+ let execRunner = null;
39
+ let execOptions = {};
40
+ if (typeof exec === 'function') {
41
+ execRunner = exec;
42
+ } else if (exec) {
43
+ execOptions = exec === true ? {} : exec;
44
+ execRunner = typeof execOptions.run === 'function' ? execOptions.run : spawnRunner(execOptions);
45
+ }
46
+ const ptyConfig = pty ? normalizePty(pty) : null;
47
+ const files = fs ? createFileAccess(fs) : null;
48
+
49
+ return {
50
+ async listen() {
51
+ if (wss) throw new Error('createWshServer: already listening');
52
+ let WebSocketServer;
53
+ try {
54
+ ({ WebSocketServer } = await import('ws'));
55
+ } catch (err) {
56
+ throw new Error('@johnhenry/wsh/server needs the "ws" package: npm install ws', { cause: err });
57
+ }
58
+ const authorize = await buildAuthorizer(auth);
59
+ const attach = createConnectionFactory({
60
+ authorize, execRunner, execOptions, pty: ptyConfig, files, bindTimeoutMs, log: onLog,
61
+ });
62
+
63
+ wss = new WebSocketServer({ host, port });
64
+ wss.on('connection', (ws) => {
65
+ const conn = attach({ send: (b) => { if (ws.readyState === 1) ws.send(b); } });
66
+ ws.on('message', (d) => {
67
+ const buf = Array.isArray(d) ? Buffer.concat(d) : Buffer.isBuffer(d) ? d : Buffer.from(d);
68
+ conn.receive(new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength));
69
+ });
70
+ ws.on('close', () => conn.close());
71
+ ws.on('error', () => {});
72
+ });
73
+ await new Promise((resolve, reject) => {
74
+ wss.once('listening', resolve);
75
+ wss.once('error', reject);
76
+ });
77
+ const a = wss.address();
78
+ bound = { address: a.address, port: a.port };
79
+ return bound;
80
+ },
81
+
82
+ async close() {
83
+ if (!wss) return;
84
+ const server = wss;
85
+ wss = null;
86
+ bound = null;
87
+ for (const client of server.clients) client.terminate();
88
+ await new Promise((resolve) => server.close(resolve));
89
+ },
90
+
91
+ address() {
92
+ return bound;
93
+ },
94
+ };
95
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * `pty` support for `@johnhenry/wsh/server`.
3
+ *
4
+ * A pseudo-terminal needs a native addon, which this package deliberately
5
+ * does not depend on: the host application injects one. `pty.spawn` is
6
+ * `node-pty`'s `spawn(file, args, options)` (or anything shaped like it):
7
+ * it returns `{ onData(cb), onExit(cb), write(data), resize(cols, rows),
8
+ * kill(signal?) }`.
9
+ */
10
+
11
+ /**
12
+ * @param {{ spawn: Function, shell?: string, cwd?: string, env?: object, term?: string }} pty
13
+ */
14
+ export function normalizePty(pty) {
15
+ if (!pty || typeof pty.spawn !== 'function') {
16
+ throw new TypeError('createWshServer: pty.spawn must be a function (e.g. node-pty\'s spawn)');
17
+ }
18
+ return {
19
+ spawn: pty.spawn,
20
+ shell: pty.shell ?? process.env.SHELL ?? '/bin/sh',
21
+ cwd: pty.cwd,
22
+ env: pty.env,
23
+ term: pty.term ?? 'xterm-256color',
24
+ };
25
+ }
@@ -256,6 +256,10 @@ export class WebSocketTransport extends WshTransport {
256
256
  /** @override */
257
257
  async _doOpenStream() {
258
258
  const qs = await this.#qmux.openStream();
259
+ // A stream is invisible to the peer until its first frame; a data stream
260
+ // that may never carry client bytes (exec has no stdin) must announce
261
+ // itself or the host never discovers it.
262
+ qs.announce();
259
263
  const adapter = new QMuxStreamAdapter(qs);
260
264
  return { readable: adapter.readable, writable: adapter.writable, id: qs.id };
261
265
  }