@johnhenry/wsh 0.29.0 → 0.31.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 +83 -15
- package/package.json +5 -1
- package/src/auth.mjs +59 -0
- package/src/client.mjs +33 -29
- package/src/index.d.ts +44 -3
- package/src/index.mjs +1 -0
- package/src/memory-host.d.ts +48 -0
- package/src/memory-host.mjs +175 -0
- package/src/recording.mjs +18 -10
- package/src/server/connection.mjs +10 -4
- package/src/server/constants.mjs +10 -0
- package/src/server/fs.mjs +2 -1
- package/src/server/gateway.mjs +248 -0
- package/src/server/index.d.ts +30 -0
- package/src/server/index.mjs +64 -19
- package/src/server/pty.mjs +1 -1
- package/src/server/sessions.mjs +2 -1
- package/src/server/socket.mjs +73 -0
- package/src/transport-ws.mjs +18 -2
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@johnhenry/wsh/memory` -- a wsh host that lives in the same JavaScript realm as its clients, with no sockets, no
|
|
3
|
+
* ports and no Node. Browser-safe (a page, a Worker, Deno, Bun, Node): it imports nothing from `node:*`, and a test
|
|
4
|
+
* (`test/memory-host.test.mjs`) keeps it that way. For demos, docs and tests of an application that talks wsh.
|
|
5
|
+
*
|
|
6
|
+
* It is the real protocol end to end -- the same connection handler `createWshServer` runs (handshake, Ed25519 or
|
|
7
|
+
* password auth, exec channels, MCP tools, extensions), speaking QMux over a pair of in-memory WebSocket-like
|
|
8
|
+
* sockets -- so what a client does here is what it does against a server. It serves what a browser can: `exec` as a
|
|
9
|
+
* function you provide (there is no shell), `mcp` tools, `extensions`. Not available: `pty`, `fs`, `gateway`, `relay`,
|
|
10
|
+
* `sessions`, `rpc`, `hostKey`, WebTransport (they need Node; use `@johnhenry/wsh/server`).
|
|
11
|
+
*
|
|
12
|
+
* ```js
|
|
13
|
+
* import { createMemoryHost } from '@johnhenry/wsh/memory';
|
|
14
|
+
* const host = createMemoryHost({
|
|
15
|
+
* auth: { authorizedKeys: [rawPublicKey] },
|
|
16
|
+
* exec: async (command, io) => { await io.write(`you ran: ${command}\n`); return 0; },
|
|
17
|
+
* });
|
|
18
|
+
* const client = await host.connect({ username: 'demo', keyPair });
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { WshClient } from './client.mjs';
|
|
23
|
+
import { WebSocketTransport } from './transport-ws.mjs';
|
|
24
|
+
import { createConnectionFactory } from './server/connection.mjs';
|
|
25
|
+
import { buildAuthorizer, authMethods, FailureLimiter } from './server/auth.mjs';
|
|
26
|
+
import { createMcpHost } from './server/mcp.mjs';
|
|
27
|
+
import { bindSocket } from './server/socket.mjs';
|
|
28
|
+
|
|
29
|
+
const CONNECTING = 0;
|
|
30
|
+
const OPEN = 1;
|
|
31
|
+
const CLOSING = 2;
|
|
32
|
+
const CLOSED = 3;
|
|
33
|
+
|
|
34
|
+
/** The URL to hand `client.connect()` (the host ignores it; it only has to look like a WebSocket URL). */
|
|
35
|
+
const MEMORY_URL = 'ws://memory.invalid/';
|
|
36
|
+
|
|
37
|
+
function event(type, props) {
|
|
38
|
+
const ev = new Event(type);
|
|
39
|
+
Object.assign(ev, props);
|
|
40
|
+
return ev;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** One end of an in-memory socket pair: just enough WebSocket for `WebSocketTransport` and `bindSocket`. */
|
|
44
|
+
class MemorySocket extends EventTarget {
|
|
45
|
+
static CONNECTING = CONNECTING;
|
|
46
|
+
static OPEN = OPEN;
|
|
47
|
+
static CLOSING = CLOSING;
|
|
48
|
+
static CLOSED = CLOSED;
|
|
49
|
+
|
|
50
|
+
readyState = CONNECTING;
|
|
51
|
+
binaryType = 'arraybuffer';
|
|
52
|
+
/** @type {MemorySocket | null} */
|
|
53
|
+
peer = null;
|
|
54
|
+
|
|
55
|
+
send(data) {
|
|
56
|
+
if (this.readyState !== OPEN) throw new Error('WebSocket is not open');
|
|
57
|
+
if (typeof data === 'string') return; // wsh frames are binary
|
|
58
|
+
const view = data instanceof ArrayBuffer ? new Uint8Array(data) : new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
|
59
|
+
const copy = view.slice().buffer;
|
|
60
|
+
const peer = this.peer;
|
|
61
|
+
queueMicrotask(() => { if (peer.readyState === OPEN) peer.dispatchEvent(event('message', { data: copy })); });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
close(code = 1000, reason = '') {
|
|
65
|
+
if (this.readyState >= CLOSING) return;
|
|
66
|
+
this.readyState = CLOSING;
|
|
67
|
+
const peer = this.peer;
|
|
68
|
+
queueMicrotask(() => {
|
|
69
|
+
for (const s of [this, peer]) {
|
|
70
|
+
if (s.readyState === CLOSED) continue;
|
|
71
|
+
s.readyState = CLOSED;
|
|
72
|
+
s.dispatchEvent(event('close', { code, reason, wasClean: true }));
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Two connected sockets: what one sends, the other receives (asynchronously, in order). Both are open after a tick.
|
|
80
|
+
* @returns {[MemorySocket, MemorySocket]}
|
|
81
|
+
*/
|
|
82
|
+
export function createSocketPair() {
|
|
83
|
+
const a = new MemorySocket();
|
|
84
|
+
const b = new MemorySocket();
|
|
85
|
+
a.peer = b;
|
|
86
|
+
b.peer = a;
|
|
87
|
+
queueMicrotask(() => {
|
|
88
|
+
for (const s of [a, b]) { s.readyState = OPEN; s.dispatchEvent(new Event('open')); }
|
|
89
|
+
});
|
|
90
|
+
return [a, b];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* @param {object} [options]
|
|
95
|
+
* @param {object | Function} [options.auth] - As for `createWshServer`. Omitted = refuse everyone.
|
|
96
|
+
* @param {Function | { run: Function, timeoutMs?: number, clientEnv?: boolean }} [options.exec]
|
|
97
|
+
* `run(command, io) => Promise<exitCode>` (see `ExecIo` in `server/exec.mjs`): the host has no shell, so there is no `true`.
|
|
98
|
+
* @param {{ tools?: object[] | object, client?: object, authorize?: Function, maxConcurrent?: number, timeoutMs?: number }} [options.mcp]
|
|
99
|
+
* @param {Record<string, Function>} [options.extensions] - Handlers for application-defined messages, as for `createWshServer`.
|
|
100
|
+
* @param {number} [options.bindTimeoutMs=3000]
|
|
101
|
+
* @param {(line: string) => void} [options.onLog]
|
|
102
|
+
*/
|
|
103
|
+
export function createMemoryHost({ auth, exec, mcp, extensions, bindTimeoutMs = 3000, onLog = () => {} } = {}) {
|
|
104
|
+
if (exec === true) throw new TypeError('createMemoryHost: exec must be a function (run(command, io)); there is no shell to run `true` with');
|
|
105
|
+
if (exec !== undefined && typeof exec !== 'function' && typeof exec?.run !== 'function') {
|
|
106
|
+
throw new TypeError('createMemoryHost: exec must be a function or { run }');
|
|
107
|
+
}
|
|
108
|
+
if (extensions !== undefined && (extensions === null || typeof extensions !== 'object')) throw new TypeError('createMemoryHost: extensions must be an object of handlers');
|
|
109
|
+
const execOptions = typeof exec === 'function' || !exec ? {} : exec;
|
|
110
|
+
const execRunner = typeof exec === 'function' ? exec : exec?.run ?? null;
|
|
111
|
+
const rl = auth?.rateLimit ?? {};
|
|
112
|
+
const rateLimit = {
|
|
113
|
+
limiter: new FailureLimiter(rl),
|
|
114
|
+
key: typeof rl.key === 'function' ? rl.key : ({ address }) => String(address ?? 'unknown'),
|
|
115
|
+
failureDelayMs: rl.failureDelayMs ?? 250,
|
|
116
|
+
};
|
|
117
|
+
const methods = authMethods(auth);
|
|
118
|
+
const mcpHost = mcp ? createMcpHost(mcp) : null;
|
|
119
|
+
|
|
120
|
+
const ready = buildAuthorizer(auth).then((authorize) => createConnectionFactory({
|
|
121
|
+
authorize, execRunner, execOptions, pty: null, files: null, bindTimeoutMs, log: onLog,
|
|
122
|
+
methods, hostKey: null, rateLimit, extensions, mcp: mcpHost, rpc: null, gateway: null, sessions: null, relay: null,
|
|
123
|
+
}));
|
|
124
|
+
ready.catch(() => {});
|
|
125
|
+
|
|
126
|
+
const accepted = new Set();
|
|
127
|
+
let closed = false;
|
|
128
|
+
|
|
129
|
+
async function accept(socket, options) {
|
|
130
|
+
if (closed) throw new Error('createMemoryHost: host is closed');
|
|
131
|
+
const handle = await bindSocket(socket, ready, options);
|
|
132
|
+
accepted.add(handle);
|
|
133
|
+
return handle;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** A `WebSocket` constructor that dials this host: `new host.WebSocket(url)` ignores the URL. */
|
|
137
|
+
class HostWebSocket extends MemorySocket {
|
|
138
|
+
constructor() {
|
|
139
|
+
super();
|
|
140
|
+
const [client, server] = createSocketPair();
|
|
141
|
+
void accept(server, { remote: { address: 'memory' } }).catch(onLog);
|
|
142
|
+
return client;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const host = {
|
|
147
|
+
/** Pass to `client.connect()`. */
|
|
148
|
+
url: MEMORY_URL,
|
|
149
|
+
WebSocket: HostWebSocket,
|
|
150
|
+
accept,
|
|
151
|
+
socketPair: createSocketPair,
|
|
152
|
+
/** A `WshClient` whose transport is this host (`await client.connect(host.url, ...)`). */
|
|
153
|
+
createClient(clientOptions = {}) {
|
|
154
|
+
return new WshClient({
|
|
155
|
+
...clientOptions,
|
|
156
|
+
transportFactories: {
|
|
157
|
+
ws: () => new WebSocketTransport({ WebSocket: HostWebSocket }),
|
|
158
|
+
wt: () => { throw new Error('createMemoryHost: WebTransport is not available in memory'); },
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
},
|
|
162
|
+
/** `createClient()` and `connect()` in one: resolves to a connected client. */
|
|
163
|
+
async connect(credentials) {
|
|
164
|
+
const client = host.createClient();
|
|
165
|
+
await client.connect(MEMORY_URL, { transport: 'ws', ...credentials });
|
|
166
|
+
return client;
|
|
167
|
+
},
|
|
168
|
+
async close() {
|
|
169
|
+
closed = true;
|
|
170
|
+
for (const handle of accepted) handle.close();
|
|
171
|
+
accepted.clear();
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
return host;
|
|
175
|
+
}
|
package/src/recording.mjs
CHANGED
|
@@ -2,8 +2,14 @@
|
|
|
2
2
|
* Session recording and playback for wsh PTY sessions.
|
|
3
3
|
*
|
|
4
4
|
* Records all terminal I/O with timestamps for later replay.
|
|
5
|
-
* The export
|
|
6
|
-
*
|
|
5
|
+
* The export (`toJSON()`) is asciicast-v2-*shaped*, not an asciicast v2 file: the header
|
|
6
|
+
* fields (`version: 2`, `width`, `height`, `timestamp`, `env`) and the `[seconds, code, data]`
|
|
7
|
+
* events with the `o` / `i` / `r` codes are asciicast's, but it is one JSON document (asciicast
|
|
8
|
+
* v2 is newline-delimited JSON: a header line, then one event per line), the header carries
|
|
9
|
+
* wsh's own `sessionId`, `width`/`height` follow the latest resize rather than the start size,
|
|
10
|
+
* and the lifecycle events `open` / `exit` have no asciicast code. `asciinema play` therefore
|
|
11
|
+
* cannot read it as-is. The README ("Session recording") shows the short conversion to a real
|
|
12
|
+
* `.cast` file (header line + the `o`/`i`/`r` events); `fromJSON()` reads this document shape.
|
|
7
13
|
*/
|
|
8
14
|
|
|
9
15
|
// ── SessionRecorder ──────────────────────────────────────────────────
|
|
@@ -66,16 +72,17 @@ export class SessionRecorder {
|
|
|
66
72
|
/**
|
|
67
73
|
* Export the recording as a JSON-serializable object.
|
|
68
74
|
*
|
|
69
|
-
* The
|
|
75
|
+
* The shape follows asciicast v2 but this is NOT a playable asciicast file (see the file header):
|
|
70
76
|
* - version: 2
|
|
71
|
-
* - width, height: terminal dimensions at start
|
|
77
|
+
* - width, height: terminal dimensions as of the latest resize (asciicast: at start)
|
|
72
78
|
* - timestamp: Unix epoch seconds when recording started
|
|
73
|
-
* - env:
|
|
74
|
-
* -
|
|
79
|
+
* - env: environment metadata
|
|
80
|
+
* - sessionId: wsh's own field
|
|
81
|
+
* - events: array of [time_seconds, event_type, data] -- one JSON document, not one line per event
|
|
75
82
|
*
|
|
76
|
-
* Event types
|
|
77
|
-
* "o" = output, "i" = input, "r" = resize
|
|
78
|
-
*
|
|
83
|
+
* Event types from asciicast:
|
|
84
|
+
* "o" = output, "i" = input, "r" = resize ("COLSxROWS")
|
|
85
|
+
* Not in asciicast, so dropped by the conversion in the README: "open", "exit" (session lifecycle).
|
|
79
86
|
*
|
|
80
87
|
* @returns {object}
|
|
81
88
|
*/
|
|
@@ -111,7 +118,8 @@ export class SessionRecorder {
|
|
|
111
118
|
}
|
|
112
119
|
|
|
113
120
|
/**
|
|
114
|
-
* Import a recording from JSON (
|
|
121
|
+
* Import a recording from the JSON document `toJSON()` produces. An asciicast v2 `.cast` file is not read directly:
|
|
122
|
+
* parse its lines and gather them into `{ ...header, events }` first.
|
|
115
123
|
*
|
|
116
124
|
* @param {object | string} json - Recording object or JSON string
|
|
117
125
|
* @returns {SessionRecorder}
|
|
@@ -13,7 +13,6 @@
|
|
|
13
13
|
* dropped rather than forwarded to the process's stdin.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import { randomUUID } from 'node:crypto';
|
|
17
16
|
import { QMuxConnection } from '../qmux-connection.mjs';
|
|
18
17
|
import { FrameDecoder, frameEncode } from '../cbor.mjs';
|
|
19
18
|
import {
|
|
@@ -24,13 +23,13 @@ import {
|
|
|
24
23
|
import {
|
|
25
24
|
generateNonce, verifyChallenge, fingerprint, importPublicKeyRaw,
|
|
26
25
|
} from '../auth.mjs';
|
|
27
|
-
import { FILE_CHUNK_BYTES } from './
|
|
28
|
-
import { MAX_ATTACHMENTS } from './sessions.mjs';
|
|
26
|
+
import { FILE_CHUNK_BYTES, MAX_ATTACHMENTS } from './constants.mjs';
|
|
29
27
|
import { MCP_CALL_ID_FEATURE } from '../client.mjs';
|
|
30
28
|
import { RpcChannel, RPC_FEATURE, RPC_MAX_MESSAGE_PREFIX, rpcProtocolFeature } from '../rpc.mjs';
|
|
31
29
|
import { sealFrame, openFrame, ROLE_TAGS } from '../e2e-frame.mjs';
|
|
32
30
|
import { E2EResponder, E2E_FEATURE, E2E_SIGN_FEATURE } from '../e2e-exchange.mjs';
|
|
33
31
|
import { MULTI_OPERATOR_FEATURE } from './relay.mjs';
|
|
32
|
+
import { createGatewaySession } from './gateway.mjs';
|
|
34
33
|
import { HOST_KEY_PREFIX, HOST_KEY_SIG_PREFIX, findClientNonce, hostKeyProofMessage, toHex } from '../host-key.mjs';
|
|
35
34
|
|
|
36
35
|
export const STREAM_ANNOUNCE = 'stream-announce';
|
|
@@ -44,6 +43,7 @@ export const STREAM_ANNOUNCE = 'stream-announce';
|
|
|
44
43
|
export function hostFeatures(cfg, { dataStreams = true } = {}) {
|
|
45
44
|
const features = dataStreams ? [STREAM_ANNOUNCE] : [];
|
|
46
45
|
if (cfg.mcp) features.push(MCP_CALL_ID_FEATURE);
|
|
46
|
+
if (cfg.gateway) features.push('gateway');
|
|
47
47
|
if (cfg.files) features.push('file-transfer', 'file-write', 'file-rename');
|
|
48
48
|
// End-to-end encryption (#90) is the virtual-mode EncryptedFrame layer, so it is offered where there are no
|
|
49
49
|
// client-opened streams: over a relay bridge.
|
|
@@ -68,6 +68,7 @@ const REPLAY_CHUNK_BYTES = 32 * 1024;
|
|
|
68
68
|
const USERNAME_RE = /^[A-Za-z_][A-Za-z0-9_.-]{0,63}$/;
|
|
69
69
|
const enc = new TextEncoder();
|
|
70
70
|
|
|
71
|
+
const randomUUID = () => crypto.randomUUID();
|
|
71
72
|
const randomBytes = (n) => crypto.getRandomValues(new Uint8Array(n));
|
|
72
73
|
const toBytes = (d) => (typeof d === 'string' ? enc.encode(d) : d);
|
|
73
74
|
|
|
@@ -83,6 +84,7 @@ const toBytes = (d) => (typeof d === 'string' ? enc.encode(d) : d);
|
|
|
83
84
|
* @param {object | null} cfg.mcp - from createMcpHost
|
|
84
85
|
* @param {{ protocols: Map<string, Function>, maxMessageBytes: number, maxInflight: number } | null} cfg.rpc - from createRpcHost
|
|
85
86
|
* @param {{ signKey: CryptoKey | null, hybrid?: boolean } | null} cfg.e2e - answer KeyExchange and seal virtual-mode traffic (bridged connections only)
|
|
87
|
+
* @param {object | null} cfg.gateway - from normalizeGateway: TCP/DNS egress for the client (default deny)
|
|
86
88
|
* @param {Record<string, Function> | undefined} cfg.extensions - handlers for application-defined (string-typed) messages
|
|
87
89
|
* @param {object | null} cfg.relay - a RelayHub (see relay.mjs); null = not a relay
|
|
88
90
|
* @param {object | null} cfg.sessions - a SessionRegistry (see sessions.mjs); null = sessions die with their connection
|
|
@@ -124,6 +126,8 @@ export function createConnectionFactory(cfg) {
|
|
|
124
126
|
/** This connection as the relay hub sees it (created on first need). */
|
|
125
127
|
let relayHandle = null;
|
|
126
128
|
|
|
129
|
+
const gateway = cfg.gateway ? createGatewaySession(cfg.gateway, (m) => send(m), log) : null;
|
|
130
|
+
|
|
127
131
|
const qmux = sendMessage ? null : new QMuxConnection({ isClient: false, send: (b) => { if (!state.closed) sendBytes(b); } });
|
|
128
132
|
|
|
129
133
|
// ── End-to-end encryption (#90) ──────────────────────────────────
|
|
@@ -177,6 +181,7 @@ export function createConnectionFactory(cfg) {
|
|
|
177
181
|
for (const ch of [...channels.values()]) (ch.detach ?? ch.kill)?.call(ch);
|
|
178
182
|
channels.clear();
|
|
179
183
|
responder?.close();
|
|
184
|
+
gateway?.close();
|
|
180
185
|
e2eSessions.clear();
|
|
181
186
|
for (const c of mcpCalls) c.abort(new Error('connection closed'));
|
|
182
187
|
mcpCalls.clear();
|
|
@@ -225,6 +230,7 @@ export function createConnectionFactory(cfg) {
|
|
|
225
230
|
if (state.closed) return;
|
|
226
231
|
if (!state.authed) return handleAuth(m);
|
|
227
232
|
if (await handleRelay(m)) return;
|
|
233
|
+
if (gateway && await gateway.handle(m)) return;
|
|
228
234
|
switch (m.type) {
|
|
229
235
|
case MSG.PING: return send(pong({ id: m.id }));
|
|
230
236
|
case MSG.OPEN: return handleOpen(m);
|
|
@@ -824,7 +830,7 @@ export function createConnectionFactory(cfg) {
|
|
|
824
830
|
const args = m.command ? ['-c', String(m.command)] : [];
|
|
825
831
|
proc = p.spawn(p.shell, args, {
|
|
826
832
|
name: p.term, cols, rows, cwd: p.cwd,
|
|
827
|
-
env: { ...(p.env ?? process
|
|
833
|
+
env: { ...(p.env ?? globalThis.process?.env), TERM: p.term, ...(m.env && typeof m.env === 'object' ? m.env : {}) },
|
|
828
834
|
});
|
|
829
835
|
} catch (e) {
|
|
830
836
|
return send(openFail({ reason: `pty spawn failed: ${e.message}` }));
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Limits shared by the connection handler and the Node-only modules that also export them
|
|
3
|
+
* (kept here so the connection handler imports nothing from `node:*` -- see `src/memory-host.mjs`).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** Size of one FileChunk when a file is streamed to the client. */
|
|
7
|
+
export const FILE_CHUNK_BYTES = 64 * 1024;
|
|
8
|
+
|
|
9
|
+
/** Most clients attached to one detached-session at once. */
|
|
10
|
+
export const MAX_ATTACHMENTS = 16;
|
package/src/server/fs.mjs
CHANGED
|
@@ -14,7 +14,8 @@ import { constants as fsc } from 'node:fs';
|
|
|
14
14
|
import path from 'node:path';
|
|
15
15
|
|
|
16
16
|
export const DEFAULT_MAX_FILE_BYTES = 64 * 1024 * 1024;
|
|
17
|
-
|
|
17
|
+
import { FILE_CHUNK_BYTES } from './constants.mjs';
|
|
18
|
+
export { FILE_CHUNK_BYTES };
|
|
18
19
|
|
|
19
20
|
/**
|
|
20
21
|
* @param {{ root: string, readOnly?: boolean, maxFileBytes?: number }} opts
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gateway for the Node server: the TCP and DNS half of the wire protocol's
|
|
3
|
+
* gateway opcodes (0x70-0x7e), which until now only the Rust `wsh-server`
|
|
4
|
+
* answered. It lets an authenticated client (typically a browser tab running
|
|
5
|
+
* `GatewayBackend` from `@johnhenry/browsermesh-netway`) open TCP connections
|
|
6
|
+
* and resolve names FROM THE HOST, so a page can reach a plain-TCP service
|
|
7
|
+
* (an MQTT broker, a database) that a browser cannot.
|
|
8
|
+
*
|
|
9
|
+
* Off unless `createWshServer({ gateway })` is given, and then default-deny:
|
|
10
|
+
* a destination must match `allow`. Pattern forms (the same as the Rust policy):
|
|
11
|
+
* `"*"` any host and port, `"host"` that name on any port, `"host:port"`.
|
|
12
|
+
* Matching is on the NAME the client asked for, case-insensitively; with `socks`
|
|
13
|
+
* set the name is handed to the proxy unresolved, so the host never looks it up.
|
|
14
|
+
*
|
|
15
|
+
* Not implemented, and answered with a clean GatewayFail / ListenFail rather than
|
|
16
|
+
* silence: UDP (`OpenUdp`) and reverse tunnels (`ListenRequest`).
|
|
17
|
+
*
|
|
18
|
+
* `socks`: dial through a SOCKS5 proxy (`'host:port'`, `socks5://host:port` or
|
|
19
|
+
* `{ host, port }`), e.g. a local Tor client (`127.0.0.1:9050`). Commands carry the
|
|
20
|
+
* destination as a domain name (ATYP 3), so name resolution happens at the proxy
|
|
21
|
+
* (no DNS leak) and `.onion` names work. In that mode `ResolveDns` is refused:
|
|
22
|
+
* the host would have to resolve the name itself.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { MSG, gatewayOk, gatewayFail, gatewayClose, gatewayData, dnsResult, listenFail } from '../messages.gen.mjs';
|
|
26
|
+
|
|
27
|
+
// Loaded on first use so that importing the connection handler (which serves the gateway opcodes)
|
|
28
|
+
// does not pull `node:net` into a browser bundle (see `src/memory-host.mjs`).
|
|
29
|
+
let netModule = null;
|
|
30
|
+
let dnsModule = null;
|
|
31
|
+
const loadNet = async () => (netModule ??= (await import('node:net')).default);
|
|
32
|
+
const loadDns = async () => (dnsModule ??= (await import('node:dns/promises')).default);
|
|
33
|
+
|
|
34
|
+
/** Numeric codes of GatewayFail (the same table `@johnhenry/browsermesh-netway` decodes). */
|
|
35
|
+
export const GATEWAY_FAIL = Object.freeze({
|
|
36
|
+
CONNECTION_REFUSED: 1, HOST_UNREACHABLE: 2, DNS_FAILED: 3, POLICY_DENIED: 4, TIMEOUT: 5, CLOSED: 6, QUEUE_FULL: 7,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const DEFAULT_MAX_CONNECTIONS = 32;
|
|
40
|
+
const DEFAULT_CONNECT_TIMEOUT_MS = 15_000;
|
|
41
|
+
const MAX_DATA_BYTES = 1024 * 1024;
|
|
42
|
+
|
|
43
|
+
/** @param {string|{host:string,port:number}} socks */
|
|
44
|
+
export function parseSocks(socks) {
|
|
45
|
+
if (socks === undefined || socks === null || socks === false) return null;
|
|
46
|
+
if (typeof socks === 'object') {
|
|
47
|
+
if (!socks.host || !Number.isInteger(socks.port)) throw new TypeError('gateway.socks must be "host:port" or { host, port }');
|
|
48
|
+
return { host: String(socks.host), port: socks.port };
|
|
49
|
+
}
|
|
50
|
+
const m = /^(?:socks5h?:\/\/)?(\[[^\]]+\]|[^:/]+):(\d{1,5})$/.exec(String(socks));
|
|
51
|
+
if (!m) throw new TypeError('gateway.socks must be "host:port" or { host, port }');
|
|
52
|
+
return { host: m[1].replace(/^\[|\]$/g, ''), port: Number(m[2]) };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Does `host:port` match one of the allow patterns? */
|
|
56
|
+
export function destinationAllowed(allow, host, port) {
|
|
57
|
+
const h = String(host).toLowerCase();
|
|
58
|
+
for (const raw of allow) {
|
|
59
|
+
const p = String(raw).toLowerCase();
|
|
60
|
+
if (p === '*' || p === h || p === `${h}:${port}`) return true;
|
|
61
|
+
}
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* SOCKS5 CONNECT (RFC 1928) with no authentication, destination as a domain name.
|
|
67
|
+
* Resolves with the connected socket, or rejects with an Error carrying `.code`
|
|
68
|
+
* (a GATEWAY_FAIL value).
|
|
69
|
+
*/
|
|
70
|
+
export async function socksConnect(proxy, host, port, { timeoutMs = DEFAULT_CONNECT_TIMEOUT_MS } = {}) {
|
|
71
|
+
const net = await loadNet();
|
|
72
|
+
return new Promise((resolve, reject) => {
|
|
73
|
+
const name = Buffer.from(host, 'utf8');
|
|
74
|
+
const fail = (code, message, sock) => { sock?.destroy(); const e = new Error(message); e.code = code; reject(e); };
|
|
75
|
+
if (name.length === 0 || name.length > 255) return fail(GATEWAY_FAIL.DNS_FAILED, 'invalid host name');
|
|
76
|
+
const sock = net.connect({ host: proxy.host, port: proxy.port });
|
|
77
|
+
sock.setNoDelay(true);
|
|
78
|
+
const timer = setTimeout(() => fail(GATEWAY_FAIL.TIMEOUT, 'SOCKS5 connect timed out', sock), timeoutMs);
|
|
79
|
+
let stage = 'method';
|
|
80
|
+
let buf = Buffer.alloc(0);
|
|
81
|
+
sock.once('error', (e) => { clearTimeout(timer); fail(GATEWAY_FAIL.HOST_UNREACHABLE, `SOCKS5 proxy: ${e.message}`, sock); });
|
|
82
|
+
sock.once('connect', () => sock.write(Buffer.from([0x05, 0x01, 0x00])));
|
|
83
|
+
const onData = (d) => {
|
|
84
|
+
buf = Buffer.concat([buf, d]);
|
|
85
|
+
if (stage === 'method') {
|
|
86
|
+
if (buf.length < 2) return;
|
|
87
|
+
if (buf[0] !== 0x05 || buf[1] !== 0x00) { clearTimeout(timer); return fail(GATEWAY_FAIL.POLICY_DENIED, 'SOCKS5 proxy requires authentication', sock); }
|
|
88
|
+
buf = buf.subarray(2);
|
|
89
|
+
stage = 'reply';
|
|
90
|
+
const req = Buffer.alloc(7 + name.length);
|
|
91
|
+
req.set([0x05, 0x01, 0x00, 0x03, name.length], 0);
|
|
92
|
+
name.copy(req, 5);
|
|
93
|
+
req.writeUInt16BE(port, 5 + name.length);
|
|
94
|
+
sock.write(req);
|
|
95
|
+
}
|
|
96
|
+
if (stage === 'reply') {
|
|
97
|
+
if (buf.length < 5) return;
|
|
98
|
+
const atyp = buf[3];
|
|
99
|
+
const need = 4 + (atyp === 0x01 ? 4 : atyp === 0x04 ? 16 : 1 + buf[4]) + 2;
|
|
100
|
+
if (buf.length < need) return;
|
|
101
|
+
clearTimeout(timer);
|
|
102
|
+
sock.off('data', onData);
|
|
103
|
+
sock.removeAllListeners('error');
|
|
104
|
+
const rest = buf.subarray(need);
|
|
105
|
+
if (buf[1] !== 0x00) {
|
|
106
|
+
const code = buf[1] === 0x05 ? GATEWAY_FAIL.CONNECTION_REFUSED : buf[1] === 0x04 || buf[1] === 0x03 ? GATEWAY_FAIL.HOST_UNREACHABLE : buf[1] === 0x02 ? GATEWAY_FAIL.POLICY_DENIED : GATEWAY_FAIL.HOST_UNREACHABLE;
|
|
107
|
+
return fail(code, `SOCKS5 proxy refused (reply 0x${buf[1].toString(16)})`, sock);
|
|
108
|
+
}
|
|
109
|
+
if (rest.length) sock.unshift(rest);
|
|
110
|
+
resolve(sock);
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
sock.on('data', onData);
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* @param {object} opts
|
|
119
|
+
* @param {string[]} [opts.allow=[]] destination patterns; empty = nothing is reachable
|
|
120
|
+
* @param {number} [opts.maxConnections=32] concurrent gateway sockets per client connection
|
|
121
|
+
* @param {number} [opts.connectTimeoutMs=15000]
|
|
122
|
+
* @param {string|{host:string,port:number}} [opts.socks] dial through this SOCKS5 proxy (Tor)
|
|
123
|
+
* @param {(line: string) => void} [opts.log]
|
|
124
|
+
*/
|
|
125
|
+
export function normalizeGateway(opts) {
|
|
126
|
+
if (opts === true) throw new TypeError('createWshServer: gateway needs an explicit allow list (default deny): { allow: ["host:port", ...] }');
|
|
127
|
+
if (!opts || typeof opts !== 'object') return null;
|
|
128
|
+
const allow = opts.allow ?? [];
|
|
129
|
+
if (!Array.isArray(allow) || allow.some((a) => typeof a !== 'string' || !a)) throw new TypeError('gateway.allow must be an array of non-empty strings');
|
|
130
|
+
const maxConnections = opts.maxConnections ?? DEFAULT_MAX_CONNECTIONS;
|
|
131
|
+
if (!Number.isInteger(maxConnections) || maxConnections < 1) throw new TypeError('gateway.maxConnections must be a positive integer');
|
|
132
|
+
return {
|
|
133
|
+
allow: [...allow],
|
|
134
|
+
maxConnections,
|
|
135
|
+
connectTimeoutMs: opts.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS,
|
|
136
|
+
socks: parseSocks(opts.socks),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* The per-connection half: one instance per client connection, torn down with it.
|
|
142
|
+
* @param {ReturnType<typeof normalizeGateway>} cfg
|
|
143
|
+
* @param {(msg: object) => unknown} send
|
|
144
|
+
* @param {(line: string) => void} log
|
|
145
|
+
*/
|
|
146
|
+
export function createGatewaySession(cfg, send, log = () => {}) {
|
|
147
|
+
/** @type {Map<number, net.Socket>} */
|
|
148
|
+
const sockets = new Map();
|
|
149
|
+
/** gateway_ids whose connect is still in flight (they count against the cap). */
|
|
150
|
+
const pending = new Set();
|
|
151
|
+
|
|
152
|
+
const reply = (m) => { try { return send(m); } catch { return undefined; } };
|
|
153
|
+
const fail = (gatewayId, code, message) => reply(gatewayFail({ gatewayId, code, message }));
|
|
154
|
+
const active = () => sockets.size + pending.size;
|
|
155
|
+
|
|
156
|
+
async function openTcp(m) {
|
|
157
|
+
const id = m.gateway_id;
|
|
158
|
+
if (!Number.isInteger(id) || typeof m.host !== 'string' || !Number.isInteger(m.port) || m.port < 1 || m.port > 65535) {
|
|
159
|
+
return fail(id, GATEWAY_FAIL.POLICY_DENIED, 'malformed OpenTcp');
|
|
160
|
+
}
|
|
161
|
+
if (sockets.has(id) || pending.has(id)) return fail(id, GATEWAY_FAIL.POLICY_DENIED, 'gateway_id already in use');
|
|
162
|
+
if (!destinationAllowed(cfg.allow, m.host, m.port)) {
|
|
163
|
+
log(`gateway: refused ${m.host}:${m.port} (not on the allow list)`);
|
|
164
|
+
return fail(id, GATEWAY_FAIL.POLICY_DENIED, 'destination not allowed by the gateway policy');
|
|
165
|
+
}
|
|
166
|
+
if (active() >= cfg.maxConnections) return fail(id, GATEWAY_FAIL.QUEUE_FULL, 'too many gateway connections');
|
|
167
|
+
pending.add(id);
|
|
168
|
+
let sock;
|
|
169
|
+
try {
|
|
170
|
+
const net = await loadNet();
|
|
171
|
+
if (cfg.socks) sock = await socksConnect(cfg.socks, m.host, m.port, { timeoutMs: cfg.connectTimeoutMs });
|
|
172
|
+
else {
|
|
173
|
+
sock = await new Promise((resolve, reject) => {
|
|
174
|
+
const s = net.connect({ host: m.host, port: m.port });
|
|
175
|
+
const t = setTimeout(() => { s.destroy(); const e = new Error('connect timed out'); e.code = GATEWAY_FAIL.TIMEOUT; reject(e); }, cfg.connectTimeoutMs);
|
|
176
|
+
s.once('connect', () => { clearTimeout(t); s.removeAllListeners('error'); resolve(s); });
|
|
177
|
+
s.once('error', (e) => {
|
|
178
|
+
clearTimeout(t);
|
|
179
|
+
const code = e.code === 'ECONNREFUSED' ? GATEWAY_FAIL.CONNECTION_REFUSED : e.code === 'ENOTFOUND' || e.code === 'EAI_AGAIN' ? GATEWAY_FAIL.DNS_FAILED : GATEWAY_FAIL.HOST_UNREACHABLE;
|
|
180
|
+
const err = new Error(e.code === 'ENOTFOUND' ? 'name not found' : e.message);
|
|
181
|
+
err.code = code;
|
|
182
|
+
reject(err);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
} catch (e) {
|
|
187
|
+
pending.delete(id);
|
|
188
|
+
return fail(id, typeof e.code === 'number' ? e.code : GATEWAY_FAIL.HOST_UNREACHABLE, String(e.message).slice(0, 200));
|
|
189
|
+
}
|
|
190
|
+
pending.delete(id);
|
|
191
|
+
sockets.set(id, sock);
|
|
192
|
+
sock.setNoDelay(true);
|
|
193
|
+
sock.on('data', (d) => { void reply(gatewayData({ gatewayId: id, data: new Uint8Array(d.buffer, d.byteOffset, d.length) })); });
|
|
194
|
+
sock.on('error', (e) => log(`gateway ${id}: ${e.message}`));
|
|
195
|
+
sock.on('close', () => {
|
|
196
|
+
if (sockets.get(id) === sock) { sockets.delete(id); void reply(gatewayClose({ gatewayId: id })); }
|
|
197
|
+
});
|
|
198
|
+
log(`gateway: ${id} -> ${m.host}:${m.port}${cfg.socks ? ' (via SOCKS5)' : ''}`);
|
|
199
|
+
return reply(gatewayOk({ gatewayId: id }));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function resolveDns(m) {
|
|
203
|
+
const id = m.gateway_id;
|
|
204
|
+
if (cfg.socks) return fail(id, GATEWAY_FAIL.POLICY_DENIED, 'names are resolved by the SOCKS proxy; this gateway does not look them up');
|
|
205
|
+
if (typeof m.name !== 'string' || !m.name) return fail(id, GATEWAY_FAIL.DNS_FAILED, 'malformed ResolveDns');
|
|
206
|
+
// Only names the client could also connect to: the allow list is about hosts, not ports, here.
|
|
207
|
+
const hostAllowed = cfg.allow.some((a) => { const p = a.toLowerCase(); return p === '*' || p === m.name.toLowerCase() || p.startsWith(`${m.name.toLowerCase()}:`); });
|
|
208
|
+
if (!hostAllowed) return fail(id, GATEWAY_FAIL.POLICY_DENIED, 'destination not allowed by the gateway policy');
|
|
209
|
+
try {
|
|
210
|
+
const family = m.record_type === 'AAAA' ? 6 : m.record_type === 'A' || m.record_type === undefined ? 4 : 0;
|
|
211
|
+
if (!family) return fail(id, GATEWAY_FAIL.DNS_FAILED, `unsupported record type ${String(m.record_type).slice(0, 8)}`);
|
|
212
|
+
const addresses = (await (await loadDns()).lookup(m.name, { family, all: true })).map((a) => a.address);
|
|
213
|
+
return reply(dnsResult({ gatewayId: id, addresses }));
|
|
214
|
+
} catch (e) {
|
|
215
|
+
return fail(id, GATEWAY_FAIL.DNS_FAILED, String(e.code === 'ENOTFOUND' ? 'name not found' : e.message).slice(0, 200));
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return {
|
|
220
|
+
/** @returns {Promise<boolean>} true when the message was a gateway message this session consumed */
|
|
221
|
+
async handle(m) {
|
|
222
|
+
switch (m.type) {
|
|
223
|
+
case MSG.OPEN_TCP: await openTcp(m); return true;
|
|
224
|
+
case MSG.OPEN_UDP: fail(m.gateway_id, GATEWAY_FAIL.POLICY_DENIED, 'UDP is not supported by this gateway'); return true;
|
|
225
|
+
case MSG.RESOLVE_DNS: await resolveDns(m); return true;
|
|
226
|
+
case MSG.GATEWAY_DATA: {
|
|
227
|
+
const s = sockets.get(m.gateway_id);
|
|
228
|
+
const d = m.data;
|
|
229
|
+
if (s && d && d.length <= MAX_DATA_BYTES) s.write(Buffer.from(d.buffer, d.byteOffset, d.length));
|
|
230
|
+
return true;
|
|
231
|
+
}
|
|
232
|
+
case MSG.GATEWAY_CLOSE: {
|
|
233
|
+
const s = sockets.get(m.gateway_id);
|
|
234
|
+
if (s) { s.end(); setTimeout(() => s.destroy(), 1000).unref(); } // its 'close' event deletes it and acknowledges
|
|
235
|
+
return true;
|
|
236
|
+
}
|
|
237
|
+
case MSG.LISTEN_REQUEST: reply(listenFail({ listenerId: m.listener_id, reason: 'reverse tunnels are not supported by this gateway' })); return true;
|
|
238
|
+
case MSG.LISTEN_CLOSE: return true;
|
|
239
|
+
default: return false;
|
|
240
|
+
}
|
|
241
|
+
},
|
|
242
|
+
close() {
|
|
243
|
+
for (const s of sockets.values()) s.destroy();
|
|
244
|
+
sockets.clear();
|
|
245
|
+
},
|
|
246
|
+
get size() { return sockets.size; },
|
|
247
|
+
};
|
|
248
|
+
}
|
package/src/server/index.d.ts
CHANGED
|
@@ -339,6 +339,13 @@ export interface WshServerOptions {
|
|
|
339
339
|
* The WebTransport listener has its own certificate (`webTransport`). Off (plain `ws://`) by default.
|
|
340
340
|
*/
|
|
341
341
|
tls?: { cert: string | Uint8Array; key: string | Uint8Array; ca?: string | Uint8Array; passphrase?: string };
|
|
342
|
+
/**
|
|
343
|
+
* Answer the gateway opcodes (OpenTcp, ResolveDns, GatewayData, GatewayClose) so a client can open TCP connections and
|
|
344
|
+
* resolve names from this host. Default deny: a destination must match `allow` (`"*"`, `"host"` any port, `"host:port"`).
|
|
345
|
+
* `socks` dials through a SOCKS5 proxy (e.g. Tor at `127.0.0.1:9050`) with the name left unresolved; `ResolveDns` is then
|
|
346
|
+
* refused. UDP and reverse tunnels are answered with a failure. Off by default.
|
|
347
|
+
*/
|
|
348
|
+
gateway?: { allow: string[]; maxConnections?: number; connectTimeoutMs?: number; socks?: string | { host: string; port: number } };
|
|
342
349
|
/**
|
|
343
350
|
* Handlers for application-defined control messages: one whose `type` is a string (protocol messages are numeric, so
|
|
344
351
|
* there is no collision) from an authenticated connection is handed to `extensions[type]`. A throw is logged.
|
|
@@ -395,9 +402,32 @@ export interface WshServerAddress {
|
|
|
395
402
|
port: number;
|
|
396
403
|
}
|
|
397
404
|
|
|
405
|
+
/**
|
|
406
|
+
* An already-accepted WebSocket-like duplex: `send(bytes)`, `close(code?)`, `readyState` (1 = open), and either Node-style
|
|
407
|
+
* `on(event, fn)` or `addEventListener(event, fn)`. A `ws` socket, a browser/Worker `WebSocket`, or a test double.
|
|
408
|
+
*/
|
|
409
|
+
export interface WshAcceptableSocket {
|
|
410
|
+
send(data: Uint8Array): void;
|
|
411
|
+
close(code?: number): void;
|
|
412
|
+
readonly readyState: number;
|
|
413
|
+
on?(event: string, listener: (...args: any[]) => void): unknown;
|
|
414
|
+
addEventListener?(event: string, listener: (ev: any) => void): unknown;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
export interface WshAcceptOptions {
|
|
418
|
+
/** What the embedder knows of the peer; `address` keys the password-failure throttle. */
|
|
419
|
+
remote?: { address?: string; headers?: Record<string, unknown> };
|
|
420
|
+
}
|
|
421
|
+
|
|
398
422
|
export interface WshServer {
|
|
399
423
|
/** Start listening. Requires the `ws` package. */
|
|
400
424
|
listen(): Promise<WshServerAddress>;
|
|
425
|
+
/**
|
|
426
|
+
* Serve a pre-upgraded transport -- a socket your own HTTP server, Worker or test already accepted -- with this
|
|
427
|
+
* server's auth, features and sessions, so no listener of wsh's own is needed (`listen()` is optional).
|
|
428
|
+
* Resolves once the connection is attached; the handle's `close()` drops it. `close()` of the server drops all of them.
|
|
429
|
+
*/
|
|
430
|
+
accept(socket: WshAcceptableSocket, options?: WshAcceptOptions): Promise<{ close(): void }>;
|
|
401
431
|
/** Drop every connection and stop listening. */
|
|
402
432
|
close(): Promise<void>;
|
|
403
433
|
/** The bound address, or `null` when not listening. */
|