@johnhenry/wsh 0.20.0 → 0.22.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 +161 -5
- package/package.json +1 -1
- package/src/client.mjs +76 -28
- package/src/index.d.ts +23 -3
- package/src/server/backends.mjs +32 -0
- package/src/server/connection.mjs +302 -39
- package/src/server/index.d.ts +95 -0
- package/src/server/index.mjs +40 -17
- package/src/server/relay.mjs +281 -0
- package/src/server/reverse-host.mjs +147 -0
- package/src/server/sessions.mjs +263 -0
- package/src/session.mjs +31 -0
package/src/server/index.mjs
CHANGED
|
@@ -10,13 +10,13 @@
|
|
|
10
10
|
|
|
11
11
|
import { buildAuthorizer, parseAuthorizedKeys, authMethods, FailureLimiter } from './auth.mjs';
|
|
12
12
|
import { loadHostKey } from './host-key.mjs';
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import { createMcpHost } from './mcp.mjs';
|
|
13
|
+
import { buildBackends } from './backends.mjs';
|
|
14
|
+
import { SessionRegistry } from './sessions.mjs';
|
|
15
|
+
import { RelayHub } from './relay.mjs';
|
|
17
16
|
import { createConnectionFactory, STREAM_ANNOUNCE } from './connection.mjs';
|
|
18
17
|
|
|
19
18
|
export { parseAuthorizedKeys, STREAM_ANNOUNCE };
|
|
19
|
+
export { createReverseHost } from './reverse-host.mjs';
|
|
20
20
|
|
|
21
21
|
/**
|
|
22
22
|
* @param {object} [options]
|
|
@@ -30,6 +30,15 @@ export { parseAuthorizedKeys, STREAM_ANNOUNCE };
|
|
|
30
30
|
* @param {{ tools?: object[] | object, client?: object, authorize?: Function, maxConcurrent?: number, timeoutMs?: number }} [options.mcp]
|
|
31
31
|
* MCP tools served over `McpDiscover` / `McpCall`: `tools` (`{ name, description, inputSchema, call(args, { user, signal }) }`),
|
|
32
32
|
* and/or `client` (an `@modelcontextprotocol/sdk` Client to proxy). Off unless given.
|
|
33
|
+
* @param {{ canRegister?: Function, canConnect?: Function, maxPeers?: number, connectTimeoutMs?: number }} [options.relay]
|
|
34
|
+
* Act as a relay: peers register (`ReverseRegister`, a signed record), operators list and connect to them
|
|
35
|
+
* (`ReverseList` / `ReverseConnect`) and traffic is carried between them as `RelayForward`. Default deny:
|
|
36
|
+
* `canRegister(who, record)` and `canConnect(from, to)` must both be given and return true. See `createReverseHost`.
|
|
37
|
+
* @param {true | { detachTtlMs?: number, maxDetached?: number, ringBytes?: number, sessionSecret?: string | Uint8Array }} [options.sessions]
|
|
38
|
+
* Keep pty/exec sessions alive across disconnects so a client can `resumeSession()` / `attachSession()`
|
|
39
|
+
* them: a per-server registry, a ring buffer of the newest `ringBytes` (default 1 MiB) of output, and
|
|
40
|
+
* `detachTtlMs` (default 300000; 0 = kill on disconnect) / `maxDetached` (default 16) bounds. Off unless given.
|
|
41
|
+
* @param {string | Uint8Array} [options.sessionSecret] - Alias for `sessions.sessionSecret`.
|
|
33
42
|
* @param {true | { file: string } | CryptoKeyPair} [options.hostKey] - The server's own Ed25519
|
|
34
43
|
* identity, advertised (with proof of possession) so clients can pin it. See `src/host-key.mjs`.
|
|
35
44
|
* @param {object} [options.auth.rateLimit] - Password-failure throttle: `{ maxFailures=5, windowMs=60000,
|
|
@@ -39,22 +48,12 @@ export { parseAuthorizedKeys, STREAM_ANNOUNCE };
|
|
|
39
48
|
* @returns {{ listen(): Promise<{address: string, port: number}>, close(): Promise<void>, address(): ({address: string, port: number} | null) }}
|
|
40
49
|
*/
|
|
41
50
|
export function createWshServer({
|
|
42
|
-
host = '127.0.0.1', port = 0, auth, exec, pty, fs, hostKey, mcp, bindTimeoutMs = 3000, onLog = () => {},
|
|
51
|
+
host = '127.0.0.1', port = 0, auth, exec, pty, fs, hostKey, mcp, sessions, sessionSecret, relay, bindTimeoutMs = 3000, onLog = () => {},
|
|
43
52
|
} = {}) {
|
|
44
53
|
let wss = null;
|
|
45
54
|
let bound = null;
|
|
46
55
|
|
|
47
|
-
|
|
48
|
-
let execOptions = {};
|
|
49
|
-
if (typeof exec === 'function') {
|
|
50
|
-
execRunner = exec;
|
|
51
|
-
} else if (exec) {
|
|
52
|
-
execOptions = exec === true ? {} : exec;
|
|
53
|
-
execRunner = typeof execOptions.run === 'function' ? execOptions.run : spawnRunner(execOptions);
|
|
54
|
-
}
|
|
55
|
-
const ptyConfig = pty ? normalizePty(pty) : null;
|
|
56
|
-
const files = fs ? createFileAccess(fs) : null;
|
|
57
|
-
const mcpHost = mcp ? createMcpHost(mcp) : null;
|
|
56
|
+
const { execRunner, execOptions, pty: ptyConfig, files, mcp: mcpHost } = buildBackends({ exec, pty, fs, mcp });
|
|
58
57
|
const methods = authMethods(auth);
|
|
59
58
|
const rl = auth?.rateLimit ?? {};
|
|
60
59
|
const rateLimit = {
|
|
@@ -63,6 +62,9 @@ export function createWshServer({
|
|
|
63
62
|
failureDelayMs: rl.failureDelayMs ?? 250,
|
|
64
63
|
};
|
|
65
64
|
let host_ = null;
|
|
65
|
+
let registry = null;
|
|
66
|
+
let hub = null;
|
|
67
|
+
const sessionOptions = sessions ? (sessions === true ? {} : sessions) : null;
|
|
66
68
|
|
|
67
69
|
return {
|
|
68
70
|
async listen() {
|
|
@@ -75,9 +77,20 @@ export function createWshServer({
|
|
|
75
77
|
}
|
|
76
78
|
const authorize = await buildAuthorizer(auth);
|
|
77
79
|
host_ = hostKey ? await loadHostKey(hostKey) : null;
|
|
80
|
+
if (sessionOptions) {
|
|
81
|
+
const secret = sessionOptions.sessionSecret ?? sessionSecret;
|
|
82
|
+
registry = new SessionRegistry({
|
|
83
|
+
secret: secret === undefined ? undefined : Buffer.from(secret),
|
|
84
|
+
detachTtlMs: sessionOptions.detachTtlMs,
|
|
85
|
+
maxDetached: sessionOptions.maxDetached,
|
|
86
|
+
ringBytes: sessionOptions.ringBytes,
|
|
87
|
+
log: onLog,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
if (relay) hub = new RelayHub({ ...relay, log: onLog });
|
|
78
91
|
const attach = createConnectionFactory({
|
|
79
92
|
authorize, execRunner, execOptions, pty: ptyConfig, files, bindTimeoutMs, log: onLog,
|
|
80
|
-
methods, hostKey: host_, rateLimit, mcp: mcpHost,
|
|
93
|
+
methods, hostKey: host_, rateLimit, mcp: mcpHost, sessions: registry, relay: hub,
|
|
81
94
|
});
|
|
82
95
|
|
|
83
96
|
wss = new WebSocketServer({ host, port });
|
|
@@ -85,6 +98,7 @@ export function createWshServer({
|
|
|
85
98
|
const conn = attach({
|
|
86
99
|
send: (b) => { if (ws.readyState === 1) ws.send(b); },
|
|
87
100
|
remote: { address: req?.socket?.remoteAddress, headers: req?.headers ?? {} },
|
|
101
|
+
closeTransport: () => ws.close(1000),
|
|
88
102
|
});
|
|
89
103
|
ws.on('message', (d) => {
|
|
90
104
|
const buf = Array.isArray(d) ? Buffer.concat(d) : Buffer.isBuffer(d) ? d : Buffer.from(d);
|
|
@@ -109,12 +123,21 @@ export function createWshServer({
|
|
|
109
123
|
bound = null;
|
|
110
124
|
for (const client of server.clients) client.terminate();
|
|
111
125
|
await new Promise((resolve) => server.close(resolve));
|
|
126
|
+
registry?.closeAll();
|
|
127
|
+
registry = null;
|
|
128
|
+
hub?.closeAll();
|
|
129
|
+
hub = null;
|
|
112
130
|
},
|
|
113
131
|
|
|
114
132
|
address() {
|
|
115
133
|
return bound;
|
|
116
134
|
},
|
|
117
135
|
|
|
136
|
+
/** Fingerprints of the peers currently registered with this relay (`[]` when it is not one). */
|
|
137
|
+
peerFingerprints() {
|
|
138
|
+
return hub ? hub.peerFingerprints() : [];
|
|
139
|
+
},
|
|
140
|
+
|
|
118
141
|
/** The advertised host identity (`null` until `listen()` resolves, or with no `hostKey`). */
|
|
119
142
|
hostKey() {
|
|
120
143
|
return host_ ? { fingerprint: host_.fingerprint, publicKey: host_.publicKey, openssh: host_.openssh } : null;
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Relay (reverse-mode) hub for `@johnhenry/wsh/server`: what lets a host that
|
|
3
|
+
* cannot accept connections (behind NAT, or a browser tab) register with this
|
|
4
|
+
* server, and an operator reach it through it.
|
|
5
|
+
*
|
|
6
|
+
* peer --ReverseRegister--> hub (a signed peer record, verified here)
|
|
7
|
+
* operator --ReverseList-----> hub (peers it may connect to)
|
|
8
|
+
* operator --ReverseConnect--> hub --ReverseConnect(from_fingerprint)--> peer
|
|
9
|
+
* peer --ReverseAccept----> hub --ReverseAccept------------------> operator
|
|
10
|
+
* operator <==RelayForward{from_fingerprint, inner}==> peer (bridged)
|
|
11
|
+
*
|
|
12
|
+
* Nothing is open by default: `canRegister` and `canConnect` both default to
|
|
13
|
+
* "no". `from_fingerprint` is always the sender's own authenticated key, set
|
|
14
|
+
* here and never taken from a payload; only the spec's `forwardable` message
|
|
15
|
+
* types cross a bridge. One bridge per peer at a time, and it lasts as long as
|
|
16
|
+
* both connections: when either ends the hub closes the other, so no
|
|
17
|
+
* half-bridged state (and no processes belonging to a departed operator)
|
|
18
|
+
* outlives it.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { cborEncode, cborDecode } from '../cbor.mjs';
|
|
22
|
+
import { fingerprint as fingerprintOf, importPublicKeyRaw, verifyPeerRecord } from '../auth.mjs';
|
|
23
|
+
import { MSG, isRelayForwardable, relayForward, reversePeers, reverseReject } from '../messages.gen.mjs';
|
|
24
|
+
|
|
25
|
+
export const DEFAULT_MAX_PEERS = 1024;
|
|
26
|
+
export const DEFAULT_CONNECT_TIMEOUT_MS = 8000;
|
|
27
|
+
const MAX_FORWARD_BYTES = 8 * 1024 * 1024;
|
|
28
|
+
const MAX_REMEMBERED_SEQS = 4096;
|
|
29
|
+
const MAX_CAPABILITIES = 32;
|
|
30
|
+
const SHORT_PREFIX_MIN = 8;
|
|
31
|
+
|
|
32
|
+
const isStr = (v, max) => typeof v === 'string' && v.length <= max;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* A connection as the hub sees it.
|
|
36
|
+
* @typedef {object} RelayHandle
|
|
37
|
+
* @property {(msg: object) => unknown} send - deliver a message to that connection
|
|
38
|
+
* @property {() => void} close - end that connection
|
|
39
|
+
* @property {string} fingerprint - its authenticated key
|
|
40
|
+
* @property {string} username
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
export class RelayHub {
|
|
44
|
+
/** @type {Map<string, { handle: RelayHandle, meta: object }>} */
|
|
45
|
+
#peers = new Map();
|
|
46
|
+
/** @type {Map<RelayHandle, RelayHandle>} */
|
|
47
|
+
#pairs = new Map();
|
|
48
|
+
/** target handle -> { operator, timer } while a ReverseConnect awaits its answer */
|
|
49
|
+
#pending = new Map();
|
|
50
|
+
/** fingerprint -> the highest record seq ever accepted (survives disconnects, bounded). */
|
|
51
|
+
#lastSeq = new Map();
|
|
52
|
+
|
|
53
|
+
constructor({ canRegister, canConnect, maxPeers = DEFAULT_MAX_PEERS, connectTimeoutMs = DEFAULT_CONNECT_TIMEOUT_MS, log = () => {} } = {}) {
|
|
54
|
+
for (const [name, fn] of [['canRegister', canRegister], ['canConnect', canConnect]]) {
|
|
55
|
+
if (fn !== undefined && typeof fn !== 'function') throw new TypeError(`createWshServer: relay.${name} must be a function`);
|
|
56
|
+
}
|
|
57
|
+
this.canRegister = canRegister ?? (() => false);
|
|
58
|
+
this.canConnect = canConnect ?? (() => false);
|
|
59
|
+
this.maxPeers = maxPeers;
|
|
60
|
+
this.connectTimeoutMs = connectTimeoutMs;
|
|
61
|
+
this.log = log;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
get peerCount() { return this.#peers.size; }
|
|
65
|
+
|
|
66
|
+
/** Fingerprints currently registered. */
|
|
67
|
+
peerFingerprints() { return [...this.#peers.keys()]; }
|
|
68
|
+
|
|
69
|
+
async #allowed(fn, ...args) {
|
|
70
|
+
try { return (await fn(...args)) === true; } catch (e) { this.log(`relay policy threw: ${e.message}`); return false; }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Verify and record a `ReverseRegister`. Resolves to `null` on success or a reason.
|
|
75
|
+
* @param {RelayHandle} handle
|
|
76
|
+
* @param {object} msg
|
|
77
|
+
* @param {{ username: string, fingerprint: string }} who - the authenticated connection
|
|
78
|
+
*/
|
|
79
|
+
async register(handle, msg, who) {
|
|
80
|
+
// Cheap structural checks first, then policy, then the signature (the expensive part).
|
|
81
|
+
if (!(msg.public_key instanceof Uint8Array) || !(msg.record_signature instanceof Uint8Array)) return 'missing signed-record fields';
|
|
82
|
+
const seq = typeof msg.seq === 'bigint' ? Number(msg.seq) : msg.seq;
|
|
83
|
+
if (!Number.isSafeInteger(seq) || seq < 0) return 'missing or invalid record seq';
|
|
84
|
+
if (!isStr(msg.username, 64) || !isStr(msg.peer_type ?? 'host', 64) || !isStr(msg.shell_backend ?? 'pty', 64)) return 'malformed record';
|
|
85
|
+
const caps = msg.capabilities ?? [];
|
|
86
|
+
if (!Array.isArray(caps) || caps.length > MAX_CAPABILITIES || !caps.every((c) => isStr(c, 64))) return 'malformed capabilities';
|
|
87
|
+
if ((await fingerprintOf(msg.public_key)) !== who.fingerprint) return 'public_key does not match the authenticated identity';
|
|
88
|
+
|
|
89
|
+
if (!(await this.#allowed(this.canRegister, { username: who.username, fingerprint: who.fingerprint }, {
|
|
90
|
+
username: msg.username, capabilities: caps, peerType: msg.peer_type ?? 'host', shellBackend: msg.shell_backend ?? 'pty',
|
|
91
|
+
}))) return 'registration not permitted';
|
|
92
|
+
|
|
93
|
+
const previous = this.#lastSeq.get(who.fingerprint);
|
|
94
|
+
if (previous !== undefined && seq <= previous) return `stale record (seq ${seq} <= ${previous})`;
|
|
95
|
+
|
|
96
|
+
let key;
|
|
97
|
+
try { key = await importPublicKeyRaw(msg.public_key); } catch { return 'malformed public_key'; }
|
|
98
|
+
let ok = false;
|
|
99
|
+
try {
|
|
100
|
+
ok = await verifyPeerRecord(key, msg.record_signature, {
|
|
101
|
+
username: msg.username,
|
|
102
|
+
peerType: msg.peer_type,
|
|
103
|
+
shellBackend: msg.shell_backend,
|
|
104
|
+
capabilities: msg.capabilities,
|
|
105
|
+
supportsAttach: msg.supports_attach,
|
|
106
|
+
supportsReplay: msg.supports_replay,
|
|
107
|
+
supportsEcho: msg.supports_echo,
|
|
108
|
+
supportsTermSync: msg.supports_term_sync,
|
|
109
|
+
seq,
|
|
110
|
+
});
|
|
111
|
+
} catch { ok = false; }
|
|
112
|
+
if (!ok) return 'record signature does not verify';
|
|
113
|
+
|
|
114
|
+
// Re-check after the awaits: a concurrent registration may have moved the counter.
|
|
115
|
+
const latest = this.#lastSeq.get(who.fingerprint);
|
|
116
|
+
if (latest !== undefined && seq <= latest) return `stale record (seq ${seq} <= ${latest})`;
|
|
117
|
+
const existing = this.#peers.get(who.fingerprint);
|
|
118
|
+
if (!existing && this.#peers.size >= this.maxPeers) return 'relay is full';
|
|
119
|
+
|
|
120
|
+
this.#lastSeq.delete(who.fingerprint);
|
|
121
|
+
this.#lastSeq.set(who.fingerprint, seq);
|
|
122
|
+
while (this.#lastSeq.size > MAX_REMEMBERED_SEQS) this.#lastSeq.delete(this.#lastSeq.keys().next().value);
|
|
123
|
+
|
|
124
|
+
if (existing && existing.handle !== handle) {
|
|
125
|
+
// The same key came back on a new connection: the old one is dead or superseded.
|
|
126
|
+
this.#peers.delete(who.fingerprint);
|
|
127
|
+
this.drop(existing.handle);
|
|
128
|
+
existing.handle.close();
|
|
129
|
+
}
|
|
130
|
+
this.#peers.set(who.fingerprint, {
|
|
131
|
+
handle,
|
|
132
|
+
meta: {
|
|
133
|
+
fingerprint: who.fingerprint,
|
|
134
|
+
fingerprint_short: who.fingerprint.slice(0, 12),
|
|
135
|
+
username: msg.username,
|
|
136
|
+
capabilities: [...caps],
|
|
137
|
+
peer_type: msg.peer_type ?? 'host',
|
|
138
|
+
shell_backend: msg.shell_backend ?? 'pty',
|
|
139
|
+
source: 'wsh-relay',
|
|
140
|
+
supports_attach: !!msg.supports_attach,
|
|
141
|
+
supports_replay: !!msg.supports_replay,
|
|
142
|
+
supports_echo: !!msg.supports_echo,
|
|
143
|
+
supports_term_sync: !!msg.supports_term_sync,
|
|
144
|
+
last_seen: Date.now(),
|
|
145
|
+
public_key: msg.public_key,
|
|
146
|
+
seq,
|
|
147
|
+
record_signature: msg.record_signature,
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
this.log(`relay: peer registered ${who.fingerprint.slice(0, 12)} (${msg.username})`);
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Is this connection a registered peer? Refreshes its `last_seen` when it is. */
|
|
155
|
+
touch(handle) {
|
|
156
|
+
const e = this.#peers.get(handle.fingerprint);
|
|
157
|
+
if (e && e.handle === handle) e.meta.last_seen = Date.now();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** The peers `who` is allowed to connect to -- an operator sees only what it could reach. */
|
|
161
|
+
async list(who) {
|
|
162
|
+
const out = [];
|
|
163
|
+
for (const { meta, handle } of this.#peers.values()) {
|
|
164
|
+
if (handle.fingerprint === who.fingerprint) continue;
|
|
165
|
+
if (await this.#allowed(this.canConnect, who, { fingerprint: meta.fingerprint, username: meta.username, capabilities: meta.capabilities })) out.push({ ...meta });
|
|
166
|
+
}
|
|
167
|
+
return reversePeers({ peers: out });
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
#find(target) {
|
|
171
|
+
if (typeof target !== 'string') return undefined;
|
|
172
|
+
const exact = this.#peers.get(target);
|
|
173
|
+
if (exact) return exact;
|
|
174
|
+
if (target.length < SHORT_PREFIX_MIN) return undefined;
|
|
175
|
+
const hits = [...this.#peers.values()].filter((p) => p.meta.fingerprint.startsWith(target));
|
|
176
|
+
return hits.length === 1 ? hits[0] : undefined;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** `ReverseConnect` from `operator`: forward it to the target, with the identity the hub knows. */
|
|
180
|
+
async connect(operator, msg) {
|
|
181
|
+
const reject = (reason) => operator.send(reverseReject({ targetFingerprint: String(msg.target_fingerprint ?? ''), username: msg.username ?? '', reason }));
|
|
182
|
+
const target = this.#find(msg.target_fingerprint);
|
|
183
|
+
// Unknown, unreachable-by-policy and self look the same to the caller.
|
|
184
|
+
if (!target || target.handle === operator
|
|
185
|
+
|| !(await this.#allowed(this.canConnect, { username: operator.username, fingerprint: operator.fingerprint }, {
|
|
186
|
+
fingerprint: target.meta.fingerprint, username: target.meta.username, capabilities: target.meta.capabilities,
|
|
187
|
+
}))) return reject('no such peer');
|
|
188
|
+
if (this.#peers.get(target.meta.fingerprint) !== target) return reject('no such peer');
|
|
189
|
+
if (this.#pairs.has(operator) || this.#pairs.has(target.handle)
|
|
190
|
+
|| [...this.#pending.values()].some((p) => p.operator === operator) || this.#pending.has(target.handle)) {
|
|
191
|
+
return reject('busy');
|
|
192
|
+
}
|
|
193
|
+
const timer = setTimeout(() => {
|
|
194
|
+
if (this.#pending.get(target.handle)?.operator !== operator) return;
|
|
195
|
+
this.#pending.delete(target.handle);
|
|
196
|
+
reject('peer did not respond');
|
|
197
|
+
}, this.connectTimeoutMs);
|
|
198
|
+
timer.unref?.();
|
|
199
|
+
this.#pending.set(target.handle, { operator, timer });
|
|
200
|
+
// The relay speaks for the operator's identity; whatever the client wrote in these fields is discarded.
|
|
201
|
+
target.handle.send({
|
|
202
|
+
type: MSG.REVERSE_CONNECT,
|
|
203
|
+
target_fingerprint: target.meta.fingerprint,
|
|
204
|
+
username: operator.username,
|
|
205
|
+
from_fingerprint: operator.fingerprint,
|
|
206
|
+
});
|
|
207
|
+
this.log(`relay: ${operator.fingerprint.slice(0, 12)} -> ${target.meta.fingerprint.slice(0, 12)} requested`);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** `ReverseAccept` / `ReverseReject` from a peer, answering a pending `connect`. */
|
|
211
|
+
answer(peer, msg) {
|
|
212
|
+
const p = this.#pending.get(peer);
|
|
213
|
+
if (!p) return;
|
|
214
|
+
clearTimeout(p.timer);
|
|
215
|
+
this.#pending.delete(peer);
|
|
216
|
+
// The answer names the answering peer, not whatever it wrote.
|
|
217
|
+
p.operator.send({ ...msg, target_fingerprint: peer.fingerprint });
|
|
218
|
+
if (msg.type === MSG.REVERSE_ACCEPT) {
|
|
219
|
+
this.#pairs.set(p.operator, peer);
|
|
220
|
+
this.#pairs.set(peer, p.operator);
|
|
221
|
+
this.log(`relay: bridged ${p.operator.fingerprint.slice(0, 12)} <-> ${peer.fingerprint.slice(0, 12)}`);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Is `handle` one end of a bridge? */
|
|
226
|
+
bridged(handle) {
|
|
227
|
+
return this.#pairs.has(handle);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Carry a message across `handle`'s bridge. A plain forwardable message is
|
|
232
|
+
* wrapped; a `RelayForward` the client wrote itself is unwrapped, its inner
|
|
233
|
+
* checked, and re-wrapped -- either way `from_fingerprint` is the sender's
|
|
234
|
+
* authenticated key. Returns false when there is no bridge.
|
|
235
|
+
*/
|
|
236
|
+
forward(handle, msg) {
|
|
237
|
+
const partner = this.#pairs.get(handle);
|
|
238
|
+
if (!partner) return false;
|
|
239
|
+
let inner;
|
|
240
|
+
if (msg.type === MSG.RELAY_FORWARD) {
|
|
241
|
+
if (!(msg.inner instanceof Uint8Array) || msg.inner.byteLength > MAX_FORWARD_BYTES) { this.log('relay: dropped an oversized or malformed RelayForward'); return true; }
|
|
242
|
+
let decoded;
|
|
243
|
+
try { decoded = cborDecode(msg.inner); } catch { this.log('relay: dropped a RelayForward with an undecodable inner'); return true; }
|
|
244
|
+
if (!decoded || typeof decoded.type !== 'number' || !isRelayForwardable(decoded.type)) {
|
|
245
|
+
this.log(`relay: dropped a RelayForward wrapping a non-forwardable type ${decoded?.type}`);
|
|
246
|
+
return true;
|
|
247
|
+
}
|
|
248
|
+
inner = msg.inner;
|
|
249
|
+
} else {
|
|
250
|
+
if (!isRelayForwardable(msg.type)) return false;
|
|
251
|
+
inner = cborEncode(msg);
|
|
252
|
+
}
|
|
253
|
+
partner.send(relayForward({ fromFingerprint: handle.fingerprint, inner }));
|
|
254
|
+
return true;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** A connection ended: forget it, and end the bridge it was part of. */
|
|
258
|
+
drop(handle) {
|
|
259
|
+
const entry = this.#peers.get(handle.fingerprint);
|
|
260
|
+
if (entry && entry.handle === handle) this.#peers.delete(handle.fingerprint);
|
|
261
|
+
const p = this.#pending.get(handle);
|
|
262
|
+
if (p) { clearTimeout(p.timer); this.#pending.delete(handle); p.operator.send(reverseReject({ targetFingerprint: handle.fingerprint, username: '', reason: 'peer went away' })); }
|
|
263
|
+
for (const [target, pend] of this.#pending) {
|
|
264
|
+
if (pend.operator === handle) { clearTimeout(pend.timer); this.#pending.delete(target); }
|
|
265
|
+
}
|
|
266
|
+
const partner = this.#pairs.get(handle);
|
|
267
|
+
if (partner) {
|
|
268
|
+
this.#pairs.delete(handle);
|
|
269
|
+
this.#pairs.delete(partner);
|
|
270
|
+
partner.close();
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Server shutdown. */
|
|
275
|
+
closeAll() {
|
|
276
|
+
for (const p of this.#pending.values()) clearTimeout(p.timer);
|
|
277
|
+
this.#pending.clear();
|
|
278
|
+
this.#pairs.clear();
|
|
279
|
+
this.#peers.clear();
|
|
280
|
+
}
|
|
281
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A reverse host: this process dials OUT to a relay (`createWshServer({ relay })`),
|
|
3
|
+
* registers itself as a peer, and serves whichever operator the relay bridges to
|
|
4
|
+
* it -- with the same exec / pty / fs / mcp backends `createWshServer` serves
|
|
5
|
+
* direct clients with. Use it for a machine that cannot accept connections.
|
|
6
|
+
*
|
|
7
|
+
* The relay has already authenticated the operator (its key), so the bridged
|
|
8
|
+
* connection is pre-authenticated; what a relay-asserted identity is worth is
|
|
9
|
+
* yours to decide in `accept`, which defaults to refusing everyone.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { WshClient } from '../client.mjs';
|
|
13
|
+
import { exportPublicKeyRaw, fingerprint } from '../auth.mjs';
|
|
14
|
+
import { reverseAccept, reverseReject } from '../messages.gen.mjs';
|
|
15
|
+
import { buildBackends } from './backends.mjs';
|
|
16
|
+
import { createConnectionFactory } from './connection.mjs';
|
|
17
|
+
|
|
18
|
+
const BACKOFF_START_MS = 500;
|
|
19
|
+
const BACKOFF_MAX_MS = 30_000;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @param {object} options
|
|
23
|
+
* @param {string} options.url - The relay (`ws://` or `wss://`).
|
|
24
|
+
* @param {string} options.username
|
|
25
|
+
* @param {CryptoKeyPair} options.keyPair - This host's identity; its fingerprint is what operators connect to.
|
|
26
|
+
* @param {(operator: { fingerprint: string, username: string }) => boolean | Promise<boolean>} [options.accept]
|
|
27
|
+
* Who may be bridged to this host. Default: nobody.
|
|
28
|
+
* @param {true | object | Function} [options.exec]
|
|
29
|
+
* @param {object} [options.pty]
|
|
30
|
+
* @param {object} [options.fs]
|
|
31
|
+
* @param {object} [options.mcp]
|
|
32
|
+
* @param {boolean} [options.reconnect=true] - Dial again (with backoff) after the relay connection ends. A relay ends it when a bridge does.
|
|
33
|
+
* @param {object} [options.connect] - Extra options for the relay connection (`expectHostKey`, `knownHosts`, ...).
|
|
34
|
+
* @param {string} [options.peerType='host']
|
|
35
|
+
* @param {(line: string) => void} [options.onLog]
|
|
36
|
+
*/
|
|
37
|
+
export function createReverseHost({
|
|
38
|
+
url, username, keyPair, accept, exec, pty, fs, mcp, reconnect = true, connect = {}, peerType = 'host', onLog = () => {},
|
|
39
|
+
} = {}) {
|
|
40
|
+
if (!url) throw new TypeError('createReverseHost: url is required');
|
|
41
|
+
if (!username) throw new TypeError('createReverseHost: username is required');
|
|
42
|
+
if (!keyPair) throw new TypeError('createReverseHost: keyPair is required (the relay keys peers by fingerprint)');
|
|
43
|
+
if (accept !== undefined && typeof accept !== 'function') throw new TypeError('createReverseHost: accept must be a function');
|
|
44
|
+
|
|
45
|
+
const backends = buildBackends({ exec, pty, fs, mcp });
|
|
46
|
+
const expose = { shell: !!backends.pty, exec: !!backends.execRunner, fs: !!backends.files, tools: !!backends.mcp };
|
|
47
|
+
const capabilities = Object.entries(expose).filter(([, on]) => on).map(([k]) => k);
|
|
48
|
+
const shellBackend = backends.pty ? 'pty' : 'exec-only';
|
|
49
|
+
const factory = createConnectionFactory({
|
|
50
|
+
authorize: async () => false, methods: { pubkey: false, password: null }, hostKey: null, rateLimit: null,
|
|
51
|
+
execRunner: backends.execRunner, execOptions: backends.execOptions, pty: backends.pty, files: backends.files, mcp: backends.mcp,
|
|
52
|
+
sessions: null, relay: null, bindTimeoutMs: 3000, log: (l) => onLog(`[reverse] ${l}`),
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
let client = null;
|
|
56
|
+
let myFingerprint = null;
|
|
57
|
+
let closed = false;
|
|
58
|
+
let timer = null;
|
|
59
|
+
let backoff = BACKOFF_START_MS;
|
|
60
|
+
|
|
61
|
+
async function dial() {
|
|
62
|
+
const c = new WshClient();
|
|
63
|
+
let bridge = null;
|
|
64
|
+
let operator = null;
|
|
65
|
+
|
|
66
|
+
const endBridge = () => {
|
|
67
|
+
if (operator) c.untrustRelayPeer(operator);
|
|
68
|
+
bridge?.close();
|
|
69
|
+
bridge = null;
|
|
70
|
+
operator = null;
|
|
71
|
+
};
|
|
72
|
+
c.onRelayMessage = (msg) => bridge?.receiveMessage(msg);
|
|
73
|
+
c.onReverseConnect = async (req) => {
|
|
74
|
+
const from = req.from_fingerprint;
|
|
75
|
+
const answer = (m) => c.sendRelayControl(m).catch(() => {});
|
|
76
|
+
const refuse = (reason) => answer(reverseReject({ targetFingerprint: myFingerprint, username, reason }));
|
|
77
|
+
if (bridge || typeof from !== 'string' || !from) { await refuse('busy'); return; }
|
|
78
|
+
let ok = false;
|
|
79
|
+
try { ok = (await accept?.({ fingerprint: from, username: String(req.username ?? '') })) === true; } catch (e) { onLog(`[reverse] accept threw: ${e.message}`); }
|
|
80
|
+
if (!ok || bridge) { await refuse('not accepted'); return; }
|
|
81
|
+
c.trustRelayPeer(from);
|
|
82
|
+
operator = from;
|
|
83
|
+
bridge = factory({
|
|
84
|
+
sendMessage: (m) => c.sendRelayControl(m),
|
|
85
|
+
authenticated: { username: String(req.username ?? 'operator'), fingerprint: from },
|
|
86
|
+
dataStreams: false,
|
|
87
|
+
});
|
|
88
|
+
onLog(`[reverse] bridged to ${from.slice(0, 12)}`);
|
|
89
|
+
await answer(reverseAccept({
|
|
90
|
+
targetFingerprint: myFingerprint, username, capabilities, peerType, shellBackend,
|
|
91
|
+
supportsAttach: false, supportsReplay: false, supportsEcho: false, supportsTermSync: false,
|
|
92
|
+
}));
|
|
93
|
+
};
|
|
94
|
+
c.onClose = () => {
|
|
95
|
+
endBridge();
|
|
96
|
+
if (client === c) client = null;
|
|
97
|
+
if (closed || !reconnect) return;
|
|
98
|
+
onLog(`[reverse] relay connection ended; redialling in ${backoff}ms`);
|
|
99
|
+
timer = setTimeout(() => { redial().catch(() => {}); }, backoff);
|
|
100
|
+
timer.unref?.();
|
|
101
|
+
backoff = Math.min(backoff * 2, BACKOFF_MAX_MS);
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
await c.connectReverse(url, {
|
|
105
|
+
username, keyPair, expose, peerType, shellBackend,
|
|
106
|
+
supportsAttach: false, supportsReplay: false, supportsEcho: false, supportsTermSync: false,
|
|
107
|
+
...connect,
|
|
108
|
+
});
|
|
109
|
+
client = c;
|
|
110
|
+
backoff = BACKOFF_START_MS;
|
|
111
|
+
onLog(`[reverse] registered ${myFingerprint.slice(0, 12)} on ${url}`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function redial() {
|
|
115
|
+
if (closed) return;
|
|
116
|
+
try { await dial(); } catch (e) {
|
|
117
|
+
onLog(`[reverse] redial failed: ${e.message}`);
|
|
118
|
+
if (closed || !reconnect) return;
|
|
119
|
+
timer = setTimeout(() => { redial().catch(() => {}); }, backoff);
|
|
120
|
+
timer.unref?.();
|
|
121
|
+
backoff = Math.min(backoff * 2, BACKOFF_MAX_MS);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
/** Dial the relay and register. Rejects if the first attempt fails; later drops are retried. */
|
|
127
|
+
async start() {
|
|
128
|
+
if (client) throw new Error('createReverseHost: already started');
|
|
129
|
+
closed = false;
|
|
130
|
+
myFingerprint = await fingerprint(await exportPublicKeyRaw(keyPair.publicKey));
|
|
131
|
+
await dial();
|
|
132
|
+
return { fingerprint: myFingerprint };
|
|
133
|
+
},
|
|
134
|
+
/** Leave the relay and end any bridged session. */
|
|
135
|
+
async close() {
|
|
136
|
+
closed = true;
|
|
137
|
+
clearTimeout(timer);
|
|
138
|
+
const c = client;
|
|
139
|
+
client = null;
|
|
140
|
+
await c?.disconnect().catch(() => {});
|
|
141
|
+
},
|
|
142
|
+
/** This host's fingerprint (what operators `reverseConnect()` to); `null` before `start()`. */
|
|
143
|
+
get fingerprint() { return myFingerprint; },
|
|
144
|
+
/** Is the relay connection up right now? */
|
|
145
|
+
get connected() { return client !== null; },
|
|
146
|
+
};
|
|
147
|
+
}
|