@johnhenry/wsh 0.28.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 +15 -0
- package/package.json +1 -1
- package/src/server/connection.mjs +14 -1
- package/src/server/gateway.mjs +241 -0
- package/src/server/index.d.ts +28 -0
- package/src/server/index.mjs +33 -6
- package/src/server/relay.mjs +11 -2
package/README.md
CHANGED
|
@@ -186,6 +186,21 @@ 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
|
+
|
|
196
|
+
### TLS (`wss://`) and host extensions
|
|
197
|
+
|
|
198
|
+
`tls: { cert, key }` (PEM, the options of `https.createServer`) serves the WebSocket listener over TLS on the same
|
|
199
|
+
port. `extensions: { [type]: (msg, { username, fingerprint, send }) => void }` handles application-defined control
|
|
200
|
+
messages whose `type` is a string, from authenticated connections only. On a relay,
|
|
201
|
+
`relay: { onUnreachable(from, targetFingerprint, request) }` is told when an operator asked for a peer that is not
|
|
202
|
+
registered (the operator still gets `no such peer`), so the host can reach that peer another way, such as a Web Push.
|
|
203
|
+
|
|
189
204
|
### WebTransport listener
|
|
190
205
|
|
|
191
206
|
The client's ladder prefers WebTransport (`https://`) and falls back to `wss://`;
|
package/package.json
CHANGED
|
@@ -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,8 @@ 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)
|
|
89
|
+
* @param {Record<string, Function> | undefined} cfg.extensions - handlers for application-defined (string-typed) messages
|
|
86
90
|
* @param {object | null} cfg.relay - a RelayHub (see relay.mjs); null = not a relay
|
|
87
91
|
* @param {object | null} cfg.sessions - a SessionRegistry (see sessions.mjs); null = sessions die with their connection
|
|
88
92
|
* @param {{ limiter: object, key: Function, failureDelayMs: number } | null} cfg.rateLimit - password throttle
|
|
@@ -123,6 +127,8 @@ export function createConnectionFactory(cfg) {
|
|
|
123
127
|
/** This connection as the relay hub sees it (created on first need). */
|
|
124
128
|
let relayHandle = null;
|
|
125
129
|
|
|
130
|
+
const gateway = cfg.gateway ? createGatewaySession(cfg.gateway, (m) => send(m), log) : null;
|
|
131
|
+
|
|
126
132
|
const qmux = sendMessage ? null : new QMuxConnection({ isClient: false, send: (b) => { if (!state.closed) sendBytes(b); } });
|
|
127
133
|
|
|
128
134
|
// ── End-to-end encryption (#90) ──────────────────────────────────
|
|
@@ -176,6 +182,7 @@ export function createConnectionFactory(cfg) {
|
|
|
176
182
|
for (const ch of [...channels.values()]) (ch.detach ?? ch.kill)?.call(ch);
|
|
177
183
|
channels.clear();
|
|
178
184
|
responder?.close();
|
|
185
|
+
gateway?.close();
|
|
179
186
|
e2eSessions.clear();
|
|
180
187
|
for (const c of mcpCalls) c.abort(new Error('connection closed'));
|
|
181
188
|
mcpCalls.clear();
|
|
@@ -224,6 +231,7 @@ export function createConnectionFactory(cfg) {
|
|
|
224
231
|
if (state.closed) return;
|
|
225
232
|
if (!state.authed) return handleAuth(m);
|
|
226
233
|
if (await handleRelay(m)) return;
|
|
234
|
+
if (gateway && await gateway.handle(m)) return;
|
|
227
235
|
switch (m.type) {
|
|
228
236
|
case MSG.PING: return send(pong({ id: m.id }));
|
|
229
237
|
case MSG.OPEN: return handleOpen(m);
|
|
@@ -246,7 +254,12 @@ export function createConnectionFactory(cfg) {
|
|
|
246
254
|
// Not awaited: the handler chain is serial, and a slow tool must not hold up
|
|
247
255
|
// the next call (or a Close) behind it.
|
|
248
256
|
case MSG.MCP_CALL: handleMcpCall(m); return;
|
|
249
|
-
default:
|
|
257
|
+
default:
|
|
258
|
+
if (typeof m.type === 'string' && Object.hasOwn(cfg.extensions ?? {}, m.type) && typeof cfg.extensions[m.type] === 'function') {
|
|
259
|
+
try { await cfg.extensions[m.type](m, { username: state.username, fingerprint: state.fingerprint, send }); } catch (e) { log(`extension "${m.type}" threw: ${e.message}`); }
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
log(`ignored message type ${typeof m.type === 'number' ? `0x${m.type.toString(16)}` : JSON.stringify(String(m.type)).slice(0, 80)}`);
|
|
250
263
|
}
|
|
251
264
|
}
|
|
252
265
|
|
|
@@ -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
|
+
}
|
package/src/server/index.d.ts
CHANGED
|
@@ -196,6 +196,17 @@ export interface WshServerRelayOptions {
|
|
|
196
196
|
from: { username: string; fingerprint: string },
|
|
197
197
|
to: WshRelayPeer,
|
|
198
198
|
) => boolean | Promise<boolean>;
|
|
199
|
+
/**
|
|
200
|
+
* Called (not awaited; a throw is logged) when an authenticated operator asks to connect to a peer that is not
|
|
201
|
+
* registered, with the operator, the fingerprint it asked for and the `ReverseConnect`'s claimed `username`. It
|
|
202
|
+
* never changes the answer (the operator is still told `no such peer`); it lets the host reach the peer another
|
|
203
|
+
* way, e.g. a Web Push notification.
|
|
204
|
+
*/
|
|
205
|
+
onUnreachable?: (
|
|
206
|
+
from: { username: string; fingerprint: string },
|
|
207
|
+
targetFingerprint: string,
|
|
208
|
+
request: { username?: string },
|
|
209
|
+
) => unknown;
|
|
199
210
|
/** Registered peers at once (default 1024). */
|
|
200
211
|
maxPeers?: number;
|
|
201
212
|
/**
|
|
@@ -323,6 +334,23 @@ export interface WshServerOptions {
|
|
|
323
334
|
* Default deny -- give both `canRegister` and `canConnect`. Off by default.
|
|
324
335
|
*/
|
|
325
336
|
relay?: WshServerRelayOptions;
|
|
337
|
+
/**
|
|
338
|
+
* Serve the WebSocket listener over TLS (`wss://`) on the same port; these are the options of `https.createServer`.
|
|
339
|
+
* The WebTransport listener has its own certificate (`webTransport`). Off (plain `ws://`) by default.
|
|
340
|
+
*/
|
|
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 } };
|
|
349
|
+
/**
|
|
350
|
+
* Handlers for application-defined control messages: one whose `type` is a string (protocol messages are numeric, so
|
|
351
|
+
* there is no collision) from an authenticated connection is handed to `extensions[type]`. A throw is logged.
|
|
352
|
+
*/
|
|
353
|
+
extensions?: Record<string, (msg: Record<string, unknown>, ctx: { username: string; fingerprint: string | null; send: (msg: object) => unknown }) => unknown>;
|
|
326
354
|
/**
|
|
327
355
|
* Also listen for WebTransport (HTTP/3 over UDP) clients -- the same auth, exec, pty, fs, mcp, sessions and relay as the
|
|
328
356
|
* WebSocket listener, with real independent streams. Needs the optional peers `@fails-components/webtransport` and
|
package/src/server/index.mjs
CHANGED
|
@@ -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 };
|
|
@@ -60,14 +61,26 @@ export { generateSelfSignedCertificate } from './self-signed.mjs';
|
|
|
60
61
|
* identity, advertised (with proof of possession) so clients can pin it. See `src/host-key.mjs`.
|
|
61
62
|
* @param {object} [options.auth.rateLimit] - Password-failure throttle: `{ maxFailures=5, windowMs=60000,
|
|
62
63
|
* lockoutMs=60000, failureDelayMs=250, key(info) }` (`key` defaults to the peer address).
|
|
64
|
+
* @param {{ cert: string | Buffer, key: string | Buffer, ca?: string | Buffer, passphrase?: string }} [options.tls] - Serve the
|
|
65
|
+
* WebSocket listener over TLS (`wss://`) on the same port: the options of `https.createServer`. Off (plain `ws://`) unless given.
|
|
66
|
+
* The WebTransport listener has its own certificate (`webTransport.cert` / `selfSigned`).
|
|
67
|
+
* @param {Record<string, (msg: object, ctx: { username: string, fingerprint: string | null, send: Function }) => unknown>} [options.extensions]
|
|
68
|
+
* Handlers for application-defined messages: a control message whose `type` is a *string* (every protocol message
|
|
69
|
+
* has a numeric type, so these cannot collide) from an authenticated connection is passed to `extensions[type]`.
|
|
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`.
|
|
63
75
|
* @param {number} [options.bindTimeoutMs=3000]
|
|
64
76
|
* @param {(line: string) => void} [options.onLog]
|
|
65
77
|
* @returns {{ listen(): Promise<{address: string, port: number}>, close(): Promise<void>, address(): ({address: string, port: number} | null) }}
|
|
66
78
|
*/
|
|
67
79
|
export function createWshServer({
|
|
68
|
-
host = '127.0.0.1', port = 0, auth, exec, pty, fs, hostKey, mcp, rpc, rpcMaxMessageBytes, rpcMaxInflight, sessions, sessionSecret, relay, 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 = () => {},
|
|
69
81
|
} = {}) {
|
|
70
82
|
let wss = null;
|
|
83
|
+
let httpsServer = null;
|
|
71
84
|
let bound = null;
|
|
72
85
|
|
|
73
86
|
const { execRunner, execOptions, pty: ptyConfig, files, mcp: mcpHost } = buildBackends({ exec, pty, fs, mcp });
|
|
@@ -86,6 +99,7 @@ export function createWshServer({
|
|
|
86
99
|
let registry = null;
|
|
87
100
|
let hub = null;
|
|
88
101
|
let wt = null;
|
|
102
|
+
const gatewayCfg = gateway === undefined ? null : normalizeGateway(gateway);
|
|
89
103
|
const sessionOptions = sessions ? (sessions === true ? {} : sessions) : null;
|
|
90
104
|
|
|
91
105
|
return {
|
|
@@ -110,12 +124,19 @@ export function createWshServer({
|
|
|
110
124
|
});
|
|
111
125
|
}
|
|
112
126
|
if (relay) hub = new RelayHub({ ...relay, log: onLog });
|
|
127
|
+
if (extensions !== undefined && (extensions === null || typeof extensions !== 'object')) throw new TypeError('createWshServer: extensions must be an object of handlers');
|
|
113
128
|
const attach = createConnectionFactory({
|
|
114
129
|
authorize, execRunner, execOptions, pty: ptyConfig, files, bindTimeoutMs, log: onLog,
|
|
115
|
-
methods, hostKey: host_, rateLimit, mcp: mcpHost, rpc: rpcHost, sessions: registry, relay: hub,
|
|
130
|
+
methods, hostKey: host_, rateLimit, extensions, mcp: mcpHost, rpc: rpcHost, gateway: gatewayCfg, sessions: registry, relay: hub,
|
|
116
131
|
});
|
|
117
132
|
|
|
118
|
-
|
|
133
|
+
if (tls) {
|
|
134
|
+
const { createServer } = await import('node:https');
|
|
135
|
+
httpsServer = createServer(tls);
|
|
136
|
+
wss = new WebSocketServer({ server: httpsServer });
|
|
137
|
+
} else {
|
|
138
|
+
wss = new WebSocketServer({ host, port });
|
|
139
|
+
}
|
|
119
140
|
wss.on('connection', (ws, req) => {
|
|
120
141
|
const conn = attach({
|
|
121
142
|
send: (b) => { if (ws.readyState === 1) ws.send(b); },
|
|
@@ -130,10 +151,15 @@ export function createWshServer({
|
|
|
130
151
|
ws.on('error', () => {});
|
|
131
152
|
});
|
|
132
153
|
await new Promise((resolve, reject) => {
|
|
133
|
-
|
|
134
|
-
|
|
154
|
+
if (httpsServer) {
|
|
155
|
+
httpsServer.once('error', reject);
|
|
156
|
+
httpsServer.listen(port, host, () => { httpsServer.off('error', reject); resolve(); });
|
|
157
|
+
} else {
|
|
158
|
+
wss.once('listening', resolve);
|
|
159
|
+
wss.once('error', reject);
|
|
160
|
+
}
|
|
135
161
|
});
|
|
136
|
-
const a = wss.address();
|
|
162
|
+
const a = (httpsServer ?? wss).address();
|
|
137
163
|
bound = { address: a.address, port: a.port };
|
|
138
164
|
if (webTransport) {
|
|
139
165
|
try {
|
|
@@ -155,6 +181,7 @@ export function createWshServer({
|
|
|
155
181
|
await wt?.close();
|
|
156
182
|
wt = null;
|
|
157
183
|
await new Promise((resolve) => server.close(resolve));
|
|
184
|
+
if (httpsServer) { const h = httpsServer; httpsServer = null; await new Promise((resolve) => h.close(resolve)); }
|
|
158
185
|
registry?.closeAll();
|
|
159
186
|
registry = null;
|
|
160
187
|
hub?.closeAll();
|
package/src/server/relay.mjs
CHANGED
|
@@ -77,8 +77,8 @@ export class RelayHub {
|
|
|
77
77
|
/** fingerprint -> the highest record seq ever accepted (survives disconnects, bounded). */
|
|
78
78
|
#lastSeq = new Map();
|
|
79
79
|
|
|
80
|
-
constructor({ canRegister, canConnect, maxPeers = DEFAULT_MAX_PEERS, maxOperatorsPerPeer = 1, connectTimeoutMs = DEFAULT_CONNECT_TIMEOUT_MS, log = () => {} } = {}) {
|
|
81
|
-
for (const [name, fn] of [['canRegister', canRegister], ['canConnect', canConnect]]) {
|
|
80
|
+
constructor({ canRegister, canConnect, maxPeers = DEFAULT_MAX_PEERS, maxOperatorsPerPeer = 1, connectTimeoutMs = DEFAULT_CONNECT_TIMEOUT_MS, onUnreachable, log = () => {} } = {}) {
|
|
81
|
+
for (const [name, fn] of [['canRegister', canRegister], ['canConnect', canConnect], ['onUnreachable', onUnreachable]]) {
|
|
82
82
|
if (fn !== undefined && typeof fn !== 'function') throw new TypeError(`createWshServer: relay.${name} must be a function`);
|
|
83
83
|
}
|
|
84
84
|
if (!Number.isSafeInteger(maxOperatorsPerPeer) || maxOperatorsPerPeer < 1) throw new TypeError('createWshServer: relay.maxOperatorsPerPeer must be a positive integer');
|
|
@@ -87,6 +87,7 @@ export class RelayHub {
|
|
|
87
87
|
this.canConnect = canConnect ?? (() => false);
|
|
88
88
|
this.maxPeers = maxPeers;
|
|
89
89
|
this.connectTimeoutMs = connectTimeoutMs;
|
|
90
|
+
this.onUnreachable = onUnreachable;
|
|
90
91
|
this.log = log;
|
|
91
92
|
}
|
|
92
93
|
|
|
@@ -209,6 +210,14 @@ export class RelayHub {
|
|
|
209
210
|
async connect(operator, msg) {
|
|
210
211
|
const reject = (reason) => operator.send(reverseReject({ targetFingerprint: String(msg.target_fingerprint ?? ''), username: msg.username ?? '', reason }));
|
|
211
212
|
const target = this.#find(msg.target_fingerprint);
|
|
213
|
+
if (!target && this.onUnreachable) {
|
|
214
|
+
// Tell the host application nobody is registered under that name (it may know another way to reach them, e.g. a
|
|
215
|
+
// push notification). Fire and forget: it never changes the answer, and a throw is logged, not propagated.
|
|
216
|
+
try {
|
|
217
|
+
Promise.resolve(this.onUnreachable({ username: operator.username, fingerprint: operator.fingerprint }, String(msg.target_fingerprint ?? ''), { username: msg.username }))
|
|
218
|
+
.catch((e) => this.log(`relay onUnreachable threw: ${e.message}`));
|
|
219
|
+
} catch (e) { this.log(`relay onUnreachable threw: ${e.message}`); }
|
|
220
|
+
}
|
|
212
221
|
// Unknown, unreachable-by-policy and self look the same to the caller.
|
|
213
222
|
if (!target || target.handle === operator
|
|
214
223
|
|| !(await this.#allowed(this.canConnect, { username: operator.username, fingerprint: operator.fingerprint }, {
|