@johnhenry/wsh 0.17.1 → 0.19.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.
@@ -0,0 +1,174 @@
1
+ /**
2
+ * File access for `@johnhenry/wsh/server`: FileOp requests (list, stat, read,
3
+ * write, rename, 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, rename,
12
+ } from 'node:fs/promises';
13
+ import { constants as fsc } from 'node:fs';
14
+ import path from 'node:path';
15
+
16
+ export const DEFAULT_MAX_FILE_BYTES = 64 * 1024 * 1024;
17
+ export const FILE_CHUNK_BYTES = 64 * 1024;
18
+
19
+ /**
20
+ * @param {{ root: string, readOnly?: boolean, maxFileBytes?: number }} opts
21
+ */
22
+ export function createFileAccess({ root, readOnly = false, maxFileBytes = DEFAULT_MAX_FILE_BYTES }) {
23
+ if (typeof root !== 'string' || !root) throw new TypeError('createWshServer: fs.root is required');
24
+ const base = path.resolve(root);
25
+ let realBase = null;
26
+
27
+ const fail = (message) => Object.assign(new Error(message), { wsh: true });
28
+
29
+ /** Resolve a client path to an absolute path inside `root`, or throw. */
30
+ async function resolveInside(clientPath) {
31
+ if (typeof clientPath !== 'string') throw fail('path is required');
32
+ if (clientPath.includes('\0')) throw fail('illegal path');
33
+ realBase ??= await realpath(base);
34
+ const target = path.resolve(base, '.' + path.sep + clientPath.replace(/^[/\\]+/, ''));
35
+ if (target !== base && !target.startsWith(base + path.sep)) throw fail('path escapes the file root');
36
+ // Symlink escape: the deepest existing ancestor must still be inside the real root.
37
+ let probe = target;
38
+ for (;;) {
39
+ try {
40
+ const real = await realpath(probe);
41
+ if (real !== realBase && !real.startsWith(realBase + path.sep)) throw fail('path escapes the file root');
42
+ break;
43
+ } catch (err) {
44
+ if (err.wsh) throw err;
45
+ const parent = path.dirname(probe);
46
+ if (parent === probe) break;
47
+ probe = parent;
48
+ }
49
+ }
50
+ return target;
51
+ }
52
+
53
+ const typeOf = (st) => st.isSymbolicLink() ? 'symlink'
54
+ : st.isDirectory() ? 'directory'
55
+ : st.isFile() ? 'file'
56
+ : st.isBlockDevice() || st.isCharacterDevice() ? 'device'
57
+ : st.isFIFO() ? 'pipe'
58
+ : st.isSocket() ? 'socket' : 'file';
59
+
60
+ async function entryFor(full, name) {
61
+ const st = await lstat(full);
62
+ const entry = { name, size: st.size, modified: Math.floor(st.mtimeMs / 1000), type: typeOf(st) };
63
+ if (entry.type === 'symlink') {
64
+ try { entry.symlink_target = await readlink(full); } catch { /* raced */ }
65
+ }
66
+ return entry;
67
+ }
68
+
69
+ const assertWritable = () => { if (readOnly) throw fail('file root is read-only'); };
70
+
71
+ return {
72
+ maxFileBytes,
73
+
74
+ /** @returns {Promise<{ metadata?: object, entries?: object[] }>} */
75
+ async operate(op, clientPath, { offset, length, newPath, data } = {}) {
76
+ switch (op) {
77
+ case 'list': {
78
+ const dir = await resolveInside(clientPath || '/');
79
+ const names = await readdir(dir);
80
+ const entries = [];
81
+ for (const name of names.sort()) {
82
+ try { entries.push(await entryFor(path.join(dir, name), name)); } catch { /* raced */ }
83
+ }
84
+ return { entries, metadata: { path: clientPath || '/' } };
85
+ }
86
+ case 'stat': {
87
+ const full = await resolveInside(clientPath);
88
+ return { metadata: await entryFor(full, path.basename(full)) };
89
+ }
90
+ case 'read': {
91
+ const full = await resolveInside(clientPath);
92
+ const st = await stat(full);
93
+ if (!st.isFile()) throw fail('not a regular file');
94
+ const start = Math.max(0, Number(offset ?? 0));
95
+ const want = Math.min(Number(length ?? FILE_CHUNK_BYTES), FILE_CHUNK_BYTES);
96
+ const fh = await open(full, 'r');
97
+ try {
98
+ const buf = new Uint8Array(want);
99
+ const { bytesRead } = await fh.read(buf, 0, want, start);
100
+ return { metadata: { data: buf.subarray(0, bytesRead), size: st.size } };
101
+ } finally { await fh.close(); }
102
+ }
103
+ case 'mkdir': {
104
+ assertWritable();
105
+ await mkdir(await resolveInside(clientPath), { recursive: true });
106
+ return { metadata: {} };
107
+ }
108
+ case 'remove': {
109
+ assertWritable();
110
+ const full = await resolveInside(clientPath);
111
+ if (full === base) throw fail('refusing to remove the file root');
112
+ await ((await lstat(full)).isDirectory() ? rmdir(full) : unlink(full));
113
+ return { metadata: { removed: clientPath } };
114
+ }
115
+ case 'write': {
116
+ if (!(data instanceof Uint8Array)) throw fail('write needs data');
117
+ await this.writeAt(clientPath, data, offset);
118
+ return { metadata: { written: data.byteLength } };
119
+ }
120
+ case 'rename': {
121
+ assertWritable();
122
+ if (typeof newPath !== 'string' || !newPath) throw fail('rename needs a destination path');
123
+ const from = await resolveInside(clientPath);
124
+ const to = await resolveInside(newPath);
125
+ if (from === base || to === base) throw fail('refusing to rename the file root');
126
+ if (from === to) return { metadata: { renamed: clientPath, to: newPath } };
127
+ if (to.startsWith(from + path.sep)) throw fail('cannot move a directory into itself');
128
+ await lstat(from); // ENOENT -> "no such file or directory"
129
+ let exists = true;
130
+ try { await lstat(to); } catch (e) { if (e.code === 'ENOENT') exists = false; else throw e; }
131
+ if (exists) throw fail('destination already exists');
132
+ await rename(from, to);
133
+ return { metadata: { renamed: clientPath, to: newPath } };
134
+ }
135
+ default:
136
+ throw fail(`"${op}" is not offered by this host (list, stat, read, write, rename, mkdir, remove)`);
137
+ }
138
+ },
139
+
140
+ async readWhole(clientPath) {
141
+ const full = await resolveInside(clientPath);
142
+ const st = await stat(full);
143
+ if (!st.isFile()) throw fail('not a regular file');
144
+ if (st.size > maxFileBytes) throw fail(`file is ${st.size} bytes; the limit is ${maxFileBytes}`);
145
+ return new Uint8Array(await readFile(full));
146
+ },
147
+
148
+ async writeWhole(clientPath, data) {
149
+ return this.writeAt(clientPath, data, undefined, { mkdirs: true });
150
+ },
151
+
152
+ /**
153
+ * Write bytes into a regular file. `offset` omitted: replace the whole
154
+ * file (create/truncate). `offset` given: write in place at that byte
155
+ * offset without truncating (creates the file if missing).
156
+ */
157
+ async writeAt(clientPath, data, offset, { mkdirs = false } = {}) {
158
+ assertWritable();
159
+ const full = await resolveInside(clientPath);
160
+ if (full === base) throw fail('not a regular file');
161
+ const at = offset === undefined || offset === null ? undefined : Number(offset);
162
+ if (at !== undefined && (!Number.isSafeInteger(at) || at < 0)) throw fail('illegal offset');
163
+ if ((at ?? 0) + data.byteLength > maxFileBytes) throw fail(`write would exceed the ${maxFileBytes} byte limit`);
164
+ if (mkdirs) await mkdir(path.dirname(full), { recursive: true });
165
+ // A FIFO or device would block or misbehave on open: regular files only.
166
+ try {
167
+ if (!(await stat(full)).isFile()) throw fail('not a regular file');
168
+ } catch (e) { if (e.wsh) throw e; if (e.code !== 'ENOENT') throw e; }
169
+ if (at === undefined) { await writeFile(full, data); return; }
170
+ const fh = await open(full, fsc.O_RDWR | fsc.O_CREAT, 0o644);
171
+ try { await fh.write(data, 0, data.byteLength, at); } finally { await fh.close(); }
172
+ },
173
+ };
174
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * The server's own Ed25519 identity (see ../host-key.mjs for the wire shape).
3
+ *
4
+ * `hostKey` option forms:
5
+ * - `true` ephemeral key, new every start (TOFU clients will see "changed" after a restart -- tests/demos only)
6
+ * - `{ file: '/path/host_key' }` PKCS#8 PEM; created (mode 0600) on first start, reused after -- the normal choice
7
+ * - a `CryptoKeyPair` your own key, e.g. from `generateKeyPair()`
8
+ */
9
+
10
+ import { generateKeyPairSync, createPrivateKey, createPublicKey, sign as nodeSign } from 'node:crypto';
11
+ import { readFile, writeFile, mkdir } from 'node:fs/promises';
12
+ import path from 'node:path';
13
+ import { exportPublicKeyRaw, importPublicKeyRaw, exportPublicKeySSH, sign, fingerprint } from '../auth.mjs';
14
+
15
+ const PEM_OPTS = { type: 'pkcs8', format: 'pem' };
16
+
17
+ function fromNodePrivateKey(priv) {
18
+ const x = createPublicKey(priv).export({ format: 'jwk' }).x;
19
+ return Buffer.from(x, 'base64url');
20
+ }
21
+
22
+ /**
23
+ * @returns {Promise<{ publicKey: Uint8Array, fingerprint: string, openssh: string, sign(data: Uint8Array): Promise<Uint8Array> }>}
24
+ */
25
+ export async function loadHostKey(option) {
26
+ let raw;
27
+ let signFn;
28
+
29
+ if (option === true) {
30
+ const priv = generateKeyPairSync('ed25519').privateKey;
31
+ raw = new Uint8Array(fromNodePrivateKey(priv));
32
+ signFn = async (data) => new Uint8Array(nodeSign(null, data, priv));
33
+ } else if (option && typeof option === 'object' && typeof option.file === 'string') {
34
+ let pem;
35
+ try {
36
+ pem = await readFile(option.file, 'utf8');
37
+ } catch (err) {
38
+ if (err.code !== 'ENOENT') throw err;
39
+ await mkdir(path.dirname(path.resolve(option.file)), { recursive: true });
40
+ const fresh = generateKeyPairSync('ed25519').privateKey.export(PEM_OPTS);
41
+ try {
42
+ await writeFile(option.file, fresh, { mode: 0o600, flag: 'wx' });
43
+ pem = fresh;
44
+ } catch (e) {
45
+ if (e.code !== 'EEXIST') throw e;
46
+ pem = await readFile(option.file, 'utf8'); // another start won the race
47
+ }
48
+ }
49
+ const priv = createPrivateKey(pem);
50
+ if (priv.asymmetricKeyType !== 'ed25519') throw new TypeError(`createWshServer: hostKey.file ${option.file} is not an Ed25519 key`);
51
+ raw = new Uint8Array(fromNodePrivateKey(priv));
52
+ signFn = async (data) => new Uint8Array(nodeSign(null, data, priv));
53
+ } else if (option && option.privateKey && option.publicKey) {
54
+ raw = await exportPublicKeyRaw(option.publicKey);
55
+ signFn = (data) => sign(option.privateKey, data);
56
+ } else {
57
+ throw new TypeError('createWshServer: hostKey must be true, { file }, or a CryptoKeyPair');
58
+ }
59
+
60
+ return {
61
+ publicKey: raw,
62
+ fingerprint: await fingerprint(raw),
63
+ openssh: await exportPublicKeySSH(await importPublicKeyRaw(raw)),
64
+ sign: signFn,
65
+ };
66
+ }
@@ -0,0 +1,168 @@
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
+ * Enable password login: return `true` to admit. Sent in the clear inside the
25
+ * connection -- serve over `wss://` / a trusted link. Use a constant-time
26
+ * compare against hashes, never `===` on plaintext. Failures are throttled
27
+ * (see `rateLimit`); the callback is not consulted while a caller is locked out.
28
+ */
29
+ password?: (username: string, password: string) => boolean | Promise<boolean>;
30
+ /** Password-failure throttle. */
31
+ rateLimit?: {
32
+ /** Failures inside `windowMs` before lockout (default 5). */
33
+ maxFailures?: number;
34
+ /** Failure counting window (default 60000). */
35
+ windowMs?: number;
36
+ /** Lockout length once tripped (default 60000). */
37
+ lockoutMs?: number;
38
+ /** Pause before answering a failed attempt (default 250). */
39
+ failureDelayMs?: number;
40
+ /** Throttle bucket; default the peer address. Use this behind a proxy (e.g. read `x-forwarded-for` from `headers`). */
41
+ key?: (info: { address: string | undefined; headers: Record<string, string | string[] | undefined>; username: string }) => string;
42
+ };
43
+ }
44
+
45
+ /** The server's own identity, as advertised to clients. */
46
+ export interface WshServerHostKey {
47
+ /** Hex SHA-256 of the raw key. */
48
+ fingerprint: string;
49
+ /** Raw 32-byte Ed25519 key. */
50
+ publicKey: Uint8Array;
51
+ /** `ssh-ed25519 AAAA...` */
52
+ openssh: string;
53
+ }
54
+
55
+ /** What a custom `exec` runner receives for one session. */
56
+ export interface WshExecIo {
57
+ user: string;
58
+ /** Environment the client asked for (empty when `clientEnv: false`). */
59
+ env: Record<string, string>;
60
+ cols: number;
61
+ rows: number;
62
+ /** Aborts on client Close, disconnect, timeout, or a terminating signal nothing handled. */
63
+ signal: AbortSignal;
64
+ /** Send output; resolves when flow control has accepted it. */
65
+ write(data: Uint8Array | string): Promise<void>;
66
+ /** Client stdin. */
67
+ onInput(cb: (bytes: Uint8Array) => void): void;
68
+ /** Client closed stdin. */
69
+ onInputEnd(cb: () => void): void;
70
+ /** Named signal from the client (`INT`, `TERM`, ...). */
71
+ onSignal(cb: (name: string) => void): void;
72
+ }
73
+
74
+ /** Runs one exec session; resolve with the exit code. */
75
+ export type WshExecRunner = (command: string, io: WshExecIo) => Promise<number>;
76
+
77
+ export interface WshServerExecOptions {
78
+ /** Working directory of spawned commands. */
79
+ cwd?: string;
80
+ /** Base environment (default `process.env`). */
81
+ env?: Record<string, string | undefined>;
82
+ /** `true` (default `/bin/sh`), or a shell path. */
83
+ shell?: boolean | string;
84
+ /** Kill a session after this long (0 / omitted = never). */
85
+ timeoutMs?: number;
86
+ /** Apply `env` sent by the client (default `true`). */
87
+ clientEnv?: boolean;
88
+ /** Replace the child_process runner entirely (e.g. a restricted, no-shell host). */
89
+ run?: WshExecRunner;
90
+ }
91
+
92
+ /** The part of a pseudo-terminal process the server uses -- `node-pty`'s `IPty` fits. */
93
+ export interface WshPtyProcess {
94
+ onData(cb: (data: string | Uint8Array) => void): unknown;
95
+ onExit(cb: (e: { exitCode: number; signal?: number }) => void): unknown;
96
+ write(data: string | Uint8Array): void;
97
+ resize(cols: number, rows: number): void;
98
+ kill(signal?: string): void;
99
+ }
100
+
101
+ export interface WshServerPtyOptions {
102
+ /** `node-pty`'s `spawn(file, args, options)`, or anything shaped like it. */
103
+ spawn(file: string, args: string[], options: {
104
+ name: string; cols: number; rows: number; cwd?: string; env: Record<string, string | undefined>;
105
+ }): WshPtyProcess;
106
+ /** Shell to run (default `$SHELL` or `/bin/sh`). */
107
+ shell?: string;
108
+ cwd?: string;
109
+ env?: Record<string, string | undefined>;
110
+ /** `TERM` value (default `xterm-256color`). */
111
+ term?: string;
112
+ }
113
+
114
+ export interface WshServerFsOptions {
115
+ /** Every client path is resolved inside this directory; `..`, absolute paths and symlinks out of it are refused. */
116
+ root: string;
117
+ readOnly?: boolean;
118
+ /** Upload / download size cap in bytes (default 64 MiB). */
119
+ maxFileBytes?: number;
120
+ }
121
+
122
+ export interface WshServerOptions {
123
+ /** Bind address (default `127.0.0.1`). */
124
+ host?: string;
125
+ /** Port (default `0` = pick a free one; read it back from `address()`). */
126
+ port?: number;
127
+ /** Who may connect. Omitted = every connection is refused. A function is shorthand for `{ authorize }`. */
128
+ auth?: WshServerAuthOptions | ((who: WshServerPrincipal) => boolean | Promise<boolean>);
129
+ /** Enable `exec` sessions. `true`, options, or a custom runner. Off by default. */
130
+ exec?: true | WshServerExecOptions | WshExecRunner;
131
+ /** Enable `pty` sessions by injecting a pty implementation. Off by default. */
132
+ pty?: WshServerPtyOptions;
133
+ /**
134
+ * Advertise an Ed25519 host identity (with proof of possession) so clients can pin it.
135
+ * `{ file }` persists a PKCS#8 PEM (created 0600 on first start) -- the normal choice;
136
+ * `true` is a fresh key every start; or pass a `CryptoKeyPair`. Off by default.
137
+ */
138
+ hostKey?: true | { file: string } | CryptoKeyPair;
139
+ /** Enable file ops and uploads/downloads under one directory. Off by default. */
140
+ fs?: WshServerFsOptions;
141
+ /** How long exec output waits for a client that has not yet opened its data stream (default 3000). */
142
+ bindTimeoutMs?: number;
143
+ onLog?: (line: string) => void;
144
+ }
145
+
146
+ export interface WshServerAddress {
147
+ address: string;
148
+ port: number;
149
+ }
150
+
151
+ export interface WshServer {
152
+ /** Start listening. Requires the `ws` package. */
153
+ listen(): Promise<WshServerAddress>;
154
+ /** Drop every connection and stop listening. */
155
+ close(): Promise<void>;
156
+ /** The bound address, or `null` when not listening. */
157
+ address(): WshServerAddress | null;
158
+ /** The advertised host identity; `null` before `listen()` resolves or without a `hostKey`. */
159
+ hostKey(): WshServerHostKey | null;
160
+ }
161
+
162
+ export function createWshServer(options?: WshServerOptions): WshServer;
163
+
164
+ /** Parse `authorized_keys`-style text into raw 32-byte Ed25519 keys (malformed lines skipped). */
165
+ export function parseAuthorizedKeys(text: string): Uint8Array[];
166
+
167
+ /** ServerHello feature: the host discovers client-opened exec data streams itself (no primer byte). */
168
+ export const STREAM_ANNOUNCE: 'stream-announce';
@@ -0,0 +1,118 @@
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, authMethods, FailureLimiter } from './auth.mjs';
12
+ import { loadHostKey } from './host-key.mjs';
13
+ import { spawnRunner } from './exec.mjs';
14
+ import { normalizePty } from './pty.mjs';
15
+ import { createFileAccess } from './fs.mjs';
16
+ import { createConnectionFactory, STREAM_ANNOUNCE } from './connection.mjs';
17
+
18
+ export { parseAuthorizedKeys, STREAM_ANNOUNCE };
19
+
20
+ /**
21
+ * @param {object} [options]
22
+ * @param {string} [options.host='127.0.0.1']
23
+ * @param {number} [options.port=0] - 0 picks a free port; read it back from `address()`.
24
+ * @param {object | Function} [options.auth] - See `buildAuthorizer`. Omitted = refuse everyone.
25
+ * @param {true | object | Function} [options.exec] - `true` (shell via child_process),
26
+ * `{ cwd, env, shell, timeoutMs, clientEnv }`, or a custom `run(command, io)` function.
27
+ * @param {{ spawn: Function, shell?: string, cwd?: string, env?: object }} [options.pty]
28
+ * @param {{ root: string, readOnly?: boolean, maxFileBytes?: number }} [options.fs]
29
+ * @param {true | { file: string } | CryptoKeyPair} [options.hostKey] - The server's own Ed25519
30
+ * identity, advertised (with proof of possession) so clients can pin it. See `src/host-key.mjs`.
31
+ * @param {object} [options.auth.rateLimit] - Password-failure throttle: `{ maxFailures=5, windowMs=60000,
32
+ * lockoutMs=60000, failureDelayMs=250, key(info) }` (`key` defaults to the peer address).
33
+ * @param {number} [options.bindTimeoutMs=3000]
34
+ * @param {(line: string) => void} [options.onLog]
35
+ * @returns {{ listen(): Promise<{address: string, port: number}>, close(): Promise<void>, address(): ({address: string, port: number} | null) }}
36
+ */
37
+ export function createWshServer({
38
+ host = '127.0.0.1', port = 0, auth, exec, pty, fs, hostKey, bindTimeoutMs = 3000, onLog = () => {},
39
+ } = {}) {
40
+ let wss = null;
41
+ let bound = null;
42
+
43
+ let execRunner = null;
44
+ let execOptions = {};
45
+ if (typeof exec === 'function') {
46
+ execRunner = exec;
47
+ } else if (exec) {
48
+ execOptions = exec === true ? {} : exec;
49
+ execRunner = typeof execOptions.run === 'function' ? execOptions.run : spawnRunner(execOptions);
50
+ }
51
+ const ptyConfig = pty ? normalizePty(pty) : null;
52
+ const files = fs ? createFileAccess(fs) : null;
53
+ const methods = authMethods(auth);
54
+ const rl = auth?.rateLimit ?? {};
55
+ const rateLimit = {
56
+ limiter: new FailureLimiter(rl),
57
+ key: typeof rl.key === 'function' ? rl.key : ({ address }) => String(address ?? 'unknown'),
58
+ failureDelayMs: rl.failureDelayMs ?? 250,
59
+ };
60
+ let host_ = null;
61
+
62
+ return {
63
+ async listen() {
64
+ if (wss) throw new Error('createWshServer: already listening');
65
+ let WebSocketServer;
66
+ try {
67
+ ({ WebSocketServer } = await import('ws'));
68
+ } catch (err) {
69
+ throw new Error('@johnhenry/wsh/server needs the "ws" package: npm install ws', { cause: err });
70
+ }
71
+ const authorize = await buildAuthorizer(auth);
72
+ host_ = hostKey ? await loadHostKey(hostKey) : null;
73
+ const attach = createConnectionFactory({
74
+ authorize, execRunner, execOptions, pty: ptyConfig, files, bindTimeoutMs, log: onLog,
75
+ methods, hostKey: host_, rateLimit,
76
+ });
77
+
78
+ wss = new WebSocketServer({ host, port });
79
+ wss.on('connection', (ws, req) => {
80
+ const conn = attach({
81
+ send: (b) => { if (ws.readyState === 1) ws.send(b); },
82
+ remote: { address: req?.socket?.remoteAddress, headers: req?.headers ?? {} },
83
+ });
84
+ ws.on('message', (d) => {
85
+ const buf = Array.isArray(d) ? Buffer.concat(d) : Buffer.isBuffer(d) ? d : Buffer.from(d);
86
+ conn.receive(new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength));
87
+ });
88
+ ws.on('close', () => conn.close());
89
+ ws.on('error', () => {});
90
+ });
91
+ await new Promise((resolve, reject) => {
92
+ wss.once('listening', resolve);
93
+ wss.once('error', reject);
94
+ });
95
+ const a = wss.address();
96
+ bound = { address: a.address, port: a.port };
97
+ return bound;
98
+ },
99
+
100
+ async close() {
101
+ if (!wss) return;
102
+ const server = wss;
103
+ wss = null;
104
+ bound = null;
105
+ for (const client of server.clients) client.terminate();
106
+ await new Promise((resolve) => server.close(resolve));
107
+ },
108
+
109
+ address() {
110
+ return bound;
111
+ },
112
+
113
+ /** The advertised host identity (`null` until `listen()` resolves, or with no `hostKey`). */
114
+ hostKey() {
115
+ return host_ ? { fingerprint: host_.fingerprint, publicKey: host_.publicKey, openssh: host_.openssh } : null;
116
+ },
117
+ };
118
+ }
@@ -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
  }