@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 +45 -1
- package/package.json +14 -2
- package/src/client.mjs +30 -4
- package/src/index.d.ts +24 -4
- package/src/index.mjs +1 -1
- 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/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.
|
|
4
|
-
"description": "Web Shell
|
|
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,
|
|
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,
|
|
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';
|
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
|
+
*/
|
|
@@ -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
|
+
}
|
package/src/transport-ws.mjs
CHANGED
|
@@ -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
|
}
|