@johnhenry/wsh 0.29.0 → 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -186,6 +186,13 @@ restricted host with no shell. `fs.root` confines every path: `..`, absolute
186
186
  paths and symlinks pointing out of it are refused -- for `write` and `rename`
187
187
  (both paths) exactly as for `read`.
188
188
 
189
+ ### Gateway (TCP and DNS egress from the host)
190
+
191
+ `gateway: { allow: ['broker.lan:1883'], socks: '127.0.0.1:9050' }` makes the Node server answer the gateway opcodes, so a
192
+ client (a browser tab via netway's `GatewayBackend`) can open TCP connections and resolve names from the host. Default
193
+ deny; `allow` takes `"*"`, `"host"` or `"host:port"`. `socks` routes every dial through a SOCKS5 proxy (Tor) with the name
194
+ unresolved. UDP and reverse tunnels are refused.
195
+
189
196
  ### TLS (`wss://`) and host extensions
190
197
 
191
198
  `tls: { cert, key }` (PEM, the options of `https.createServer`) serves the WebSocket listener over TLS on the same
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@johnhenry/wsh",
3
- "version": "0.29.0",
3
+ "version": "0.30.0",
4
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",
@@ -31,6 +31,7 @@ import { RpcChannel, RPC_FEATURE, RPC_MAX_MESSAGE_PREFIX, rpcProtocolFeature } f
31
31
  import { sealFrame, openFrame, ROLE_TAGS } from '../e2e-frame.mjs';
32
32
  import { E2EResponder, E2E_FEATURE, E2E_SIGN_FEATURE } from '../e2e-exchange.mjs';
33
33
  import { MULTI_OPERATOR_FEATURE } from './relay.mjs';
34
+ import { createGatewaySession } from './gateway.mjs';
34
35
  import { HOST_KEY_PREFIX, HOST_KEY_SIG_PREFIX, findClientNonce, hostKeyProofMessage, toHex } from '../host-key.mjs';
35
36
 
36
37
  export const STREAM_ANNOUNCE = 'stream-announce';
@@ -44,6 +45,7 @@ export const STREAM_ANNOUNCE = 'stream-announce';
44
45
  export function hostFeatures(cfg, { dataStreams = true } = {}) {
45
46
  const features = dataStreams ? [STREAM_ANNOUNCE] : [];
46
47
  if (cfg.mcp) features.push(MCP_CALL_ID_FEATURE);
48
+ if (cfg.gateway) features.push('gateway');
47
49
  if (cfg.files) features.push('file-transfer', 'file-write', 'file-rename');
48
50
  // End-to-end encryption (#90) is the virtual-mode EncryptedFrame layer, so it is offered where there are no
49
51
  // client-opened streams: over a relay bridge.
@@ -83,6 +85,7 @@ const toBytes = (d) => (typeof d === 'string' ? enc.encode(d) : d);
83
85
  * @param {object | null} cfg.mcp - from createMcpHost
84
86
  * @param {{ protocols: Map<string, Function>, maxMessageBytes: number, maxInflight: number } | null} cfg.rpc - from createRpcHost
85
87
  * @param {{ signKey: CryptoKey | null, hybrid?: boolean } | null} cfg.e2e - answer KeyExchange and seal virtual-mode traffic (bridged connections only)
88
+ * @param {object | null} cfg.gateway - from normalizeGateway: TCP/DNS egress for the client (default deny)
86
89
  * @param {Record<string, Function> | undefined} cfg.extensions - handlers for application-defined (string-typed) messages
87
90
  * @param {object | null} cfg.relay - a RelayHub (see relay.mjs); null = not a relay
88
91
  * @param {object | null} cfg.sessions - a SessionRegistry (see sessions.mjs); null = sessions die with their connection
@@ -124,6 +127,8 @@ export function createConnectionFactory(cfg) {
124
127
  /** This connection as the relay hub sees it (created on first need). */
125
128
  let relayHandle = null;
126
129
 
130
+ const gateway = cfg.gateway ? createGatewaySession(cfg.gateway, (m) => send(m), log) : null;
131
+
127
132
  const qmux = sendMessage ? null : new QMuxConnection({ isClient: false, send: (b) => { if (!state.closed) sendBytes(b); } });
128
133
 
129
134
  // ── End-to-end encryption (#90) ──────────────────────────────────
@@ -177,6 +182,7 @@ export function createConnectionFactory(cfg) {
177
182
  for (const ch of [...channels.values()]) (ch.detach ?? ch.kill)?.call(ch);
178
183
  channels.clear();
179
184
  responder?.close();
185
+ gateway?.close();
180
186
  e2eSessions.clear();
181
187
  for (const c of mcpCalls) c.abort(new Error('connection closed'));
182
188
  mcpCalls.clear();
@@ -225,6 +231,7 @@ export function createConnectionFactory(cfg) {
225
231
  if (state.closed) return;
226
232
  if (!state.authed) return handleAuth(m);
227
233
  if (await handleRelay(m)) return;
234
+ if (gateway && await gateway.handle(m)) return;
228
235
  switch (m.type) {
229
236
  case MSG.PING: return send(pong({ id: m.id }));
230
237
  case MSG.OPEN: return handleOpen(m);
@@ -0,0 +1,241 @@
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 net from 'node:net';
26
+ import dns from 'node:dns/promises';
27
+ import { MSG, gatewayOk, gatewayFail, gatewayClose, gatewayData, dnsResult, listenFail } from '../messages.gen.mjs';
28
+
29
+ /** Numeric codes of GatewayFail (the same table `@johnhenry/browsermesh-netway` decodes). */
30
+ export const GATEWAY_FAIL = Object.freeze({
31
+ CONNECTION_REFUSED: 1, HOST_UNREACHABLE: 2, DNS_FAILED: 3, POLICY_DENIED: 4, TIMEOUT: 5, CLOSED: 6, QUEUE_FULL: 7,
32
+ });
33
+
34
+ const DEFAULT_MAX_CONNECTIONS = 32;
35
+ const DEFAULT_CONNECT_TIMEOUT_MS = 15_000;
36
+ const MAX_DATA_BYTES = 1024 * 1024;
37
+
38
+ /** @param {string|{host:string,port:number}} socks */
39
+ export function parseSocks(socks) {
40
+ if (socks === undefined || socks === null || socks === false) return null;
41
+ if (typeof socks === 'object') {
42
+ if (!socks.host || !Number.isInteger(socks.port)) throw new TypeError('gateway.socks must be "host:port" or { host, port }');
43
+ return { host: String(socks.host), port: socks.port };
44
+ }
45
+ const m = /^(?:socks5h?:\/\/)?(\[[^\]]+\]|[^:/]+):(\d{1,5})$/.exec(String(socks));
46
+ if (!m) throw new TypeError('gateway.socks must be "host:port" or { host, port }');
47
+ return { host: m[1].replace(/^\[|\]$/g, ''), port: Number(m[2]) };
48
+ }
49
+
50
+ /** Does `host:port` match one of the allow patterns? */
51
+ export function destinationAllowed(allow, host, port) {
52
+ const h = String(host).toLowerCase();
53
+ for (const raw of allow) {
54
+ const p = String(raw).toLowerCase();
55
+ if (p === '*' || p === h || p === `${h}:${port}`) return true;
56
+ }
57
+ return false;
58
+ }
59
+
60
+ /**
61
+ * SOCKS5 CONNECT (RFC 1928) with no authentication, destination as a domain name.
62
+ * Resolves with the connected socket, or rejects with an Error carrying `.code`
63
+ * (a GATEWAY_FAIL value).
64
+ */
65
+ export function socksConnect(proxy, host, port, { timeoutMs = DEFAULT_CONNECT_TIMEOUT_MS } = {}) {
66
+ return new Promise((resolve, reject) => {
67
+ const name = Buffer.from(host, 'utf8');
68
+ const fail = (code, message, sock) => { sock?.destroy(); const e = new Error(message); e.code = code; reject(e); };
69
+ if (name.length === 0 || name.length > 255) return fail(GATEWAY_FAIL.DNS_FAILED, 'invalid host name');
70
+ const sock = net.connect({ host: proxy.host, port: proxy.port });
71
+ sock.setNoDelay(true);
72
+ const timer = setTimeout(() => fail(GATEWAY_FAIL.TIMEOUT, 'SOCKS5 connect timed out', sock), timeoutMs);
73
+ let stage = 'method';
74
+ let buf = Buffer.alloc(0);
75
+ sock.once('error', (e) => { clearTimeout(timer); fail(GATEWAY_FAIL.HOST_UNREACHABLE, `SOCKS5 proxy: ${e.message}`, sock); });
76
+ sock.once('connect', () => sock.write(Buffer.from([0x05, 0x01, 0x00])));
77
+ const onData = (d) => {
78
+ buf = Buffer.concat([buf, d]);
79
+ if (stage === 'method') {
80
+ if (buf.length < 2) return;
81
+ if (buf[0] !== 0x05 || buf[1] !== 0x00) { clearTimeout(timer); return fail(GATEWAY_FAIL.POLICY_DENIED, 'SOCKS5 proxy requires authentication', sock); }
82
+ buf = buf.subarray(2);
83
+ stage = 'reply';
84
+ const req = Buffer.alloc(7 + name.length);
85
+ req.set([0x05, 0x01, 0x00, 0x03, name.length], 0);
86
+ name.copy(req, 5);
87
+ req.writeUInt16BE(port, 5 + name.length);
88
+ sock.write(req);
89
+ }
90
+ if (stage === 'reply') {
91
+ if (buf.length < 5) return;
92
+ const atyp = buf[3];
93
+ const need = 4 + (atyp === 0x01 ? 4 : atyp === 0x04 ? 16 : 1 + buf[4]) + 2;
94
+ if (buf.length < need) return;
95
+ clearTimeout(timer);
96
+ sock.off('data', onData);
97
+ sock.removeAllListeners('error');
98
+ const rest = buf.subarray(need);
99
+ if (buf[1] !== 0x00) {
100
+ 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;
101
+ return fail(code, `SOCKS5 proxy refused (reply 0x${buf[1].toString(16)})`, sock);
102
+ }
103
+ if (rest.length) sock.unshift(rest);
104
+ resolve(sock);
105
+ }
106
+ };
107
+ sock.on('data', onData);
108
+ });
109
+ }
110
+
111
+ /**
112
+ * @param {object} opts
113
+ * @param {string[]} [opts.allow=[]] destination patterns; empty = nothing is reachable
114
+ * @param {number} [opts.maxConnections=32] concurrent gateway sockets per client connection
115
+ * @param {number} [opts.connectTimeoutMs=15000]
116
+ * @param {string|{host:string,port:number}} [opts.socks] dial through this SOCKS5 proxy (Tor)
117
+ * @param {(line: string) => void} [opts.log]
118
+ */
119
+ export function normalizeGateway(opts) {
120
+ if (opts === true) throw new TypeError('createWshServer: gateway needs an explicit allow list (default deny): { allow: ["host:port", ...] }');
121
+ if (!opts || typeof opts !== 'object') return null;
122
+ const allow = opts.allow ?? [];
123
+ if (!Array.isArray(allow) || allow.some((a) => typeof a !== 'string' || !a)) throw new TypeError('gateway.allow must be an array of non-empty strings');
124
+ const maxConnections = opts.maxConnections ?? DEFAULT_MAX_CONNECTIONS;
125
+ if (!Number.isInteger(maxConnections) || maxConnections < 1) throw new TypeError('gateway.maxConnections must be a positive integer');
126
+ return {
127
+ allow: [...allow],
128
+ maxConnections,
129
+ connectTimeoutMs: opts.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS,
130
+ socks: parseSocks(opts.socks),
131
+ };
132
+ }
133
+
134
+ /**
135
+ * The per-connection half: one instance per client connection, torn down with it.
136
+ * @param {ReturnType<typeof normalizeGateway>} cfg
137
+ * @param {(msg: object) => unknown} send
138
+ * @param {(line: string) => void} log
139
+ */
140
+ export function createGatewaySession(cfg, send, log = () => {}) {
141
+ /** @type {Map<number, net.Socket>} */
142
+ const sockets = new Map();
143
+ /** gateway_ids whose connect is still in flight (they count against the cap). */
144
+ const pending = new Set();
145
+
146
+ const reply = (m) => { try { return send(m); } catch { return undefined; } };
147
+ const fail = (gatewayId, code, message) => reply(gatewayFail({ gatewayId, code, message }));
148
+ const active = () => sockets.size + pending.size;
149
+
150
+ async function openTcp(m) {
151
+ const id = m.gateway_id;
152
+ if (!Number.isInteger(id) || typeof m.host !== 'string' || !Number.isInteger(m.port) || m.port < 1 || m.port > 65535) {
153
+ return fail(id, GATEWAY_FAIL.POLICY_DENIED, 'malformed OpenTcp');
154
+ }
155
+ if (sockets.has(id) || pending.has(id)) return fail(id, GATEWAY_FAIL.POLICY_DENIED, 'gateway_id already in use');
156
+ if (!destinationAllowed(cfg.allow, m.host, m.port)) {
157
+ log(`gateway: refused ${m.host}:${m.port} (not on the allow list)`);
158
+ return fail(id, GATEWAY_FAIL.POLICY_DENIED, 'destination not allowed by the gateway policy');
159
+ }
160
+ if (active() >= cfg.maxConnections) return fail(id, GATEWAY_FAIL.QUEUE_FULL, 'too many gateway connections');
161
+ pending.add(id);
162
+ let sock;
163
+ try {
164
+ if (cfg.socks) sock = await socksConnect(cfg.socks, m.host, m.port, { timeoutMs: cfg.connectTimeoutMs });
165
+ else {
166
+ sock = await new Promise((resolve, reject) => {
167
+ const s = net.connect({ host: m.host, port: m.port });
168
+ const t = setTimeout(() => { s.destroy(); const e = new Error('connect timed out'); e.code = GATEWAY_FAIL.TIMEOUT; reject(e); }, cfg.connectTimeoutMs);
169
+ s.once('connect', () => { clearTimeout(t); s.removeAllListeners('error'); resolve(s); });
170
+ s.once('error', (e) => {
171
+ clearTimeout(t);
172
+ const code = e.code === 'ECONNREFUSED' ? GATEWAY_FAIL.CONNECTION_REFUSED : e.code === 'ENOTFOUND' || e.code === 'EAI_AGAIN' ? GATEWAY_FAIL.DNS_FAILED : GATEWAY_FAIL.HOST_UNREACHABLE;
173
+ const err = new Error(e.code === 'ENOTFOUND' ? 'name not found' : e.message);
174
+ err.code = code;
175
+ reject(err);
176
+ });
177
+ });
178
+ }
179
+ } catch (e) {
180
+ pending.delete(id);
181
+ return fail(id, typeof e.code === 'number' ? e.code : GATEWAY_FAIL.HOST_UNREACHABLE, String(e.message).slice(0, 200));
182
+ }
183
+ pending.delete(id);
184
+ sockets.set(id, sock);
185
+ sock.setNoDelay(true);
186
+ sock.on('data', (d) => { void reply(gatewayData({ gatewayId: id, data: new Uint8Array(d.buffer, d.byteOffset, d.length) })); });
187
+ sock.on('error', (e) => log(`gateway ${id}: ${e.message}`));
188
+ sock.on('close', () => {
189
+ if (sockets.get(id) === sock) { sockets.delete(id); void reply(gatewayClose({ gatewayId: id })); }
190
+ });
191
+ log(`gateway: ${id} -> ${m.host}:${m.port}${cfg.socks ? ' (via SOCKS5)' : ''}`);
192
+ return reply(gatewayOk({ gatewayId: id }));
193
+ }
194
+
195
+ async function resolveDns(m) {
196
+ const id = m.gateway_id;
197
+ if (cfg.socks) return fail(id, GATEWAY_FAIL.POLICY_DENIED, 'names are resolved by the SOCKS proxy; this gateway does not look them up');
198
+ if (typeof m.name !== 'string' || !m.name) return fail(id, GATEWAY_FAIL.DNS_FAILED, 'malformed ResolveDns');
199
+ // Only names the client could also connect to: the allow list is about hosts, not ports, here.
200
+ const hostAllowed = cfg.allow.some((a) => { const p = a.toLowerCase(); return p === '*' || p === m.name.toLowerCase() || p.startsWith(`${m.name.toLowerCase()}:`); });
201
+ if (!hostAllowed) return fail(id, GATEWAY_FAIL.POLICY_DENIED, 'destination not allowed by the gateway policy');
202
+ try {
203
+ const family = m.record_type === 'AAAA' ? 6 : m.record_type === 'A' || m.record_type === undefined ? 4 : 0;
204
+ if (!family) return fail(id, GATEWAY_FAIL.DNS_FAILED, `unsupported record type ${String(m.record_type).slice(0, 8)}`);
205
+ const addresses = (await dns.lookup(m.name, { family, all: true })).map((a) => a.address);
206
+ return reply(dnsResult({ gatewayId: id, addresses }));
207
+ } catch (e) {
208
+ return fail(id, GATEWAY_FAIL.DNS_FAILED, String(e.code === 'ENOTFOUND' ? 'name not found' : e.message).slice(0, 200));
209
+ }
210
+ }
211
+
212
+ return {
213
+ /** @returns {Promise<boolean>} true when the message was a gateway message this session consumed */
214
+ async handle(m) {
215
+ switch (m.type) {
216
+ case MSG.OPEN_TCP: await openTcp(m); return true;
217
+ case MSG.OPEN_UDP: fail(m.gateway_id, GATEWAY_FAIL.POLICY_DENIED, 'UDP is not supported by this gateway'); return true;
218
+ case MSG.RESOLVE_DNS: await resolveDns(m); return true;
219
+ case MSG.GATEWAY_DATA: {
220
+ const s = sockets.get(m.gateway_id);
221
+ const d = m.data;
222
+ if (s && d && d.length <= MAX_DATA_BYTES) s.write(Buffer.from(d.buffer, d.byteOffset, d.length));
223
+ return true;
224
+ }
225
+ case MSG.GATEWAY_CLOSE: {
226
+ const s = sockets.get(m.gateway_id);
227
+ if (s) { s.end(); setTimeout(() => s.destroy(), 1000).unref(); } // its 'close' event deletes it and acknowledges
228
+ return true;
229
+ }
230
+ case MSG.LISTEN_REQUEST: reply(listenFail({ listenerId: m.listener_id, reason: 'reverse tunnels are not supported by this gateway' })); return true;
231
+ case MSG.LISTEN_CLOSE: return true;
232
+ default: return false;
233
+ }
234
+ },
235
+ close() {
236
+ for (const s of sockets.values()) s.destroy();
237
+ sockets.clear();
238
+ },
239
+ get size() { return sockets.size; },
240
+ };
241
+ }
@@ -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.
@@ -15,6 +15,7 @@ import { SessionRegistry } from './sessions.mjs';
15
15
  import { RelayHub } from './relay.mjs';
16
16
  import { startWebTransport } from './webtransport.mjs';
17
17
  import { createConnectionFactory, STREAM_ANNOUNCE } from './connection.mjs';
18
+ import { normalizeGateway } from './gateway.mjs';
18
19
  import { createRpcHost, mcpServerAdapter } from './rpc.mjs';
19
20
 
20
21
  export { parseAuthorizedKeys, STREAM_ANNOUNCE, mcpServerAdapter };
@@ -67,12 +68,16 @@ export { generateSelfSignedCertificate } from './self-signed.mjs';
67
68
  * Handlers for application-defined messages: a control message whose `type` is a *string* (every protocol message
68
69
  * has a numeric type, so these cannot collide) from an authenticated connection is passed to `extensions[type]`.
69
70
  * A handler that throws is logged and ignored. Off unless given.
71
+ * @param {{ allow: string[], maxConnections?: number, connectTimeoutMs?: number, socks?: string | { host: string, port: number } }} [options.gateway]
72
+ * Answer the gateway opcodes (OpenTcp, ResolveDns, GatewayData/Close): the client opens TCP connections and resolves
73
+ * names from this host. Default deny: a destination must match `allow` (`"*"`, `"host"`, `"host:port"`). `socks` dials
74
+ * through a SOCKS5 proxy (Tor) with the name unresolved. UDP and reverse tunnels are refused. Off unless given. See `gateway.mjs`.
70
75
  * @param {number} [options.bindTimeoutMs=3000]
71
76
  * @param {(line: string) => void} [options.onLog]
72
77
  * @returns {{ listen(): Promise<{address: string, port: number}>, close(): Promise<void>, address(): ({address: string, port: number} | null) }}
73
78
  */
74
79
  export function createWshServer({
75
- host = '127.0.0.1', port = 0, tls, auth, exec, pty, fs, hostKey, mcp, rpc, rpcMaxMessageBytes, rpcMaxInflight, sessions, sessionSecret, relay, extensions, webTransport, bindTimeoutMs = 3000, onLog = () => {},
80
+ host = '127.0.0.1', port = 0, tls, auth, exec, pty, fs, hostKey, mcp, rpc, rpcMaxMessageBytes, rpcMaxInflight, sessions, sessionSecret, relay, extensions, gateway, webTransport, bindTimeoutMs = 3000, onLog = () => {},
76
81
  } = {}) {
77
82
  let wss = null;
78
83
  let httpsServer = null;
@@ -94,6 +99,7 @@ export function createWshServer({
94
99
  let registry = null;
95
100
  let hub = null;
96
101
  let wt = null;
102
+ const gatewayCfg = gateway === undefined ? null : normalizeGateway(gateway);
97
103
  const sessionOptions = sessions ? (sessions === true ? {} : sessions) : null;
98
104
 
99
105
  return {
@@ -121,7 +127,7 @@ export function createWshServer({
121
127
  if (extensions !== undefined && (extensions === null || typeof extensions !== 'object')) throw new TypeError('createWshServer: extensions must be an object of handlers');
122
128
  const attach = createConnectionFactory({
123
129
  authorize, execRunner, execOptions, pty: ptyConfig, files, bindTimeoutMs, log: onLog,
124
- methods, hostKey: host_, rateLimit, extensions, mcp: mcpHost, rpc: rpcHost, sessions: registry, relay: hub,
130
+ methods, hostKey: host_, rateLimit, extensions, mcp: mcpHost, rpc: rpcHost, gateway: gatewayCfg, sessions: registry, relay: hub,
125
131
  });
126
132
 
127
133
  if (tls) {