@johnhenry/wsh 0.27.1 → 0.29.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 +89 -36
- package/package.json +1 -1
- package/src/client.mjs +51 -47
- package/src/e2e-exchange.mjs +227 -0
- package/src/index.d.ts +50 -3
- package/src/index.mjs +4 -1
- package/src/messages.gen.mjs +13 -7
- package/src/server/connection.mjs +94 -4
- package/src/server/index.d.ts +46 -0
- package/src/server/index.mjs +33 -7
- package/src/server/relay.mjs +113 -25
- package/src/server/reverse-host.mjs +93 -28
package/src/server/relay.mjs
CHANGED
|
@@ -12,15 +12,22 @@
|
|
|
12
12
|
* Nothing is open by default: `canRegister` and `canConnect` both default to
|
|
13
13
|
* "no". `from_fingerprint` is always the sender's own authenticated key, set
|
|
14
14
|
* here and never taken from a payload; only the spec's `forwardable` message
|
|
15
|
-
* types cross a bridge.
|
|
15
|
+
* types cross a bridge.
|
|
16
|
+
*
|
|
17
|
+
* By default there is one operator per peer, and the bridge lasts as long as
|
|
16
18
|
* both connections: when either ends the hub closes the other, so no
|
|
17
19
|
* half-bridged state (and no processes belonging to a departed operator)
|
|
18
|
-
* outlives it.
|
|
20
|
+
* outlives it. With `maxOperatorsPerPeer` > 1 a peer that states
|
|
21
|
+
* `relay-multi-operator` in its ReverseAccept may be bridged to several
|
|
22
|
+
* operators at once (#89): it addresses each reply with
|
|
23
|
+
* `RelayForward.to_fingerprint`, and is told with a `ReverseClose` when one
|
|
24
|
+
* operator leaves (instead of being closed). A peer that never stated it is
|
|
25
|
+
* still held to one operator, and still closed when its operator leaves.
|
|
19
26
|
*/
|
|
20
27
|
|
|
21
28
|
import { cborEncode, cborDecode } from '../cbor.mjs';
|
|
22
29
|
import { fingerprint as fingerprintOf, importPublicKeyRaw, verifyPeerRecord } from '../auth.mjs';
|
|
23
|
-
import { MSG, isRelayForwardable, relayForward, reversePeers, reverseReject } from '../messages.gen.mjs';
|
|
30
|
+
import { MSG, isRelayForwardable, relayForward, reversePeers, reverseReject, reverseClose } from '../messages.gen.mjs';
|
|
24
31
|
|
|
25
32
|
export const DEFAULT_MAX_PEERS = 1024;
|
|
26
33
|
export const DEFAULT_CONNECT_TIMEOUT_MS = 8000;
|
|
@@ -32,7 +39,16 @@ const MAX_FEATURE_LENGTH = 128;
|
|
|
32
39
|
const SHORT_PREFIX_MIN = 8;
|
|
33
40
|
|
|
34
41
|
/** `ReverseReject.reason` for a peer that already has an operator (or a request awaiting its answer). */
|
|
42
|
+
/** `ReverseAccept.features` entry by which a peer says it serves several operators (addresses replies, understands `ReverseClose`). */
|
|
43
|
+
export const MULTI_OPERATOR_FEATURE = 'relay-multi-operator';
|
|
44
|
+
/** `ReverseReject.reason` for a peer that is answering another operator's request right now. */
|
|
45
|
+
export const BUSY_ANSWERING = 'busy: this peer is answering another operator\'s request (retry in a moment)';
|
|
46
|
+
|
|
35
47
|
export const BUSY_PEER = 'busy: this peer already has an operator (the relay bridges one operator per peer at a time; retry when it leaves)';
|
|
48
|
+
/** `ReverseReject.reason` for a multi-operator peer already at the relay's operator cap. */
|
|
49
|
+
export function busyPeerFull(max) {
|
|
50
|
+
return `busy: this peer already has its maximum of ${max} operators (retry when one leaves)`;
|
|
51
|
+
}
|
|
36
52
|
/** `ReverseReject.reason` for an operator that already has a bridge, or a request in flight. */
|
|
37
53
|
export const BUSY_OPERATOR = 'busy: you already have a bridge or a pending connect on this relay (one at a time)';
|
|
38
54
|
|
|
@@ -50,21 +66,28 @@ const isStr = (v, max) => typeof v === 'string' && v.length <= max;
|
|
|
50
66
|
export class RelayHub {
|
|
51
67
|
/** @type {Map<string, { handle: RelayHandle, meta: object }>} */
|
|
52
68
|
#peers = new Map();
|
|
53
|
-
/** @type {Map<RelayHandle, RelayHandle>} */
|
|
54
|
-
#
|
|
69
|
+
/** operator -> the peer it is bridged to (an operator has one bridge). @type {Map<RelayHandle, RelayHandle>} */
|
|
70
|
+
#operatorPeer = new Map();
|
|
71
|
+
/** peer -> its bridged operators, by the operator's fingerprint. @type {Map<RelayHandle, Map<string, RelayHandle>>} */
|
|
72
|
+
#peerOperators = new Map();
|
|
73
|
+
/** peers that stated `relay-multi-operator` in their last ReverseAccept. @type {WeakSet<RelayHandle>} */
|
|
74
|
+
#multiPeers = new WeakSet();
|
|
55
75
|
/** target handle -> { operator, timer } while a ReverseConnect awaits its answer */
|
|
56
76
|
#pending = new Map();
|
|
57
77
|
/** fingerprint -> the highest record seq ever accepted (survives disconnects, bounded). */
|
|
58
78
|
#lastSeq = new Map();
|
|
59
79
|
|
|
60
|
-
constructor({ canRegister, canConnect, maxPeers = DEFAULT_MAX_PEERS, connectTimeoutMs = DEFAULT_CONNECT_TIMEOUT_MS, log = () => {} } = {}) {
|
|
61
|
-
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]]) {
|
|
62
82
|
if (fn !== undefined && typeof fn !== 'function') throw new TypeError(`createWshServer: relay.${name} must be a function`);
|
|
63
83
|
}
|
|
84
|
+
if (!Number.isSafeInteger(maxOperatorsPerPeer) || maxOperatorsPerPeer < 1) throw new TypeError('createWshServer: relay.maxOperatorsPerPeer must be a positive integer');
|
|
85
|
+
this.maxOperatorsPerPeer = maxOperatorsPerPeer;
|
|
64
86
|
this.canRegister = canRegister ?? (() => false);
|
|
65
87
|
this.canConnect = canConnect ?? (() => false);
|
|
66
88
|
this.maxPeers = maxPeers;
|
|
67
89
|
this.connectTimeoutMs = connectTimeoutMs;
|
|
90
|
+
this.onUnreachable = onUnreachable;
|
|
68
91
|
this.log = log;
|
|
69
92
|
}
|
|
70
93
|
|
|
@@ -187,20 +210,33 @@ export class RelayHub {
|
|
|
187
210
|
async connect(operator, msg) {
|
|
188
211
|
const reject = (reason) => operator.send(reverseReject({ targetFingerprint: String(msg.target_fingerprint ?? ''), username: msg.username ?? '', reason }));
|
|
189
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
|
+
}
|
|
190
221
|
// Unknown, unreachable-by-policy and self look the same to the caller.
|
|
191
222
|
if (!target || target.handle === operator
|
|
192
223
|
|| !(await this.#allowed(this.canConnect, { username: operator.username, fingerprint: operator.fingerprint }, {
|
|
193
224
|
fingerprint: target.meta.fingerprint, username: target.meta.username, capabilities: target.meta.capabilities,
|
|
194
225
|
}))) return reject('no such peer');
|
|
195
226
|
if (this.#peers.get(target.meta.fingerprint) !== target) return reject('no such peer');
|
|
196
|
-
// One bridge per peer
|
|
197
|
-
//
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
}
|
|
201
|
-
if (this.#pairs.has(operator) || [...this.#pending.values()].some((p) => p.operator === operator)) {
|
|
227
|
+
// One bridge per operator. A peer serves one operator unless the relay allows more AND the peer said it can
|
|
228
|
+
// address them (RelayForward.to_fingerprint) and be told when one leaves (ReverseClose); it also answers one
|
|
229
|
+
// request at a time, since a ReverseAccept does not name the operator it answers.
|
|
230
|
+
if (this.bridged(operator) || [...this.#pending.values()].some((p) => p.operator === operator)) {
|
|
202
231
|
return reject(BUSY_OPERATOR);
|
|
203
232
|
}
|
|
233
|
+
if (this.#pending.has(target.handle)) return reject(this.maxOperatorsPerPeer > 1 ? BUSY_ANSWERING : BUSY_PEER);
|
|
234
|
+
const serving = this.#peerOperators.get(target.handle);
|
|
235
|
+
if (serving?.size) {
|
|
236
|
+
if (this.maxOperatorsPerPeer < 2 || !this.#multiPeers.has(target.handle)) return reject(BUSY_PEER);
|
|
237
|
+
if (serving.has(operator.fingerprint)) return reject(BUSY_OPERATOR);
|
|
238
|
+
if (serving.size >= this.maxOperatorsPerPeer) return reject(busyPeerFull(this.maxOperatorsPerPeer));
|
|
239
|
+
}
|
|
204
240
|
const timer = setTimeout(() => {
|
|
205
241
|
if (this.#pending.get(target.handle)?.operator !== operator) return;
|
|
206
242
|
this.#pending.delete(target.handle);
|
|
@@ -232,26 +268,39 @@ export class RelayHub {
|
|
|
232
268
|
}
|
|
233
269
|
p.operator.send(answer);
|
|
234
270
|
if (msg.type === MSG.REVERSE_ACCEPT) {
|
|
235
|
-
this.#
|
|
236
|
-
this.#
|
|
271
|
+
if (Array.isArray(answer.features) && answer.features.includes(MULTI_OPERATOR_FEATURE)) this.#multiPeers.add(peer);
|
|
272
|
+
else this.#multiPeers.delete(peer);
|
|
273
|
+
this.#operatorPeer.set(p.operator, peer);
|
|
274
|
+
let ops = this.#peerOperators.get(peer);
|
|
275
|
+
if (!ops) this.#peerOperators.set(peer, ops = new Map());
|
|
276
|
+
ops.set(p.operator.fingerprint, p.operator);
|
|
237
277
|
this.log(`relay: bridged ${p.operator.fingerprint.slice(0, 12)} <-> ${peer.fingerprint.slice(0, 12)}`);
|
|
238
278
|
}
|
|
239
279
|
}
|
|
240
280
|
|
|
241
281
|
/** Is `handle` one end of a bridge? */
|
|
242
282
|
bridged(handle) {
|
|
243
|
-
return this.#
|
|
283
|
+
return this.#operatorPeer.has(handle) || (this.#peerOperators.get(handle)?.size ?? 0) > 0;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** Fingerprints of the operators bridged to the peer with this fingerprint. */
|
|
287
|
+
operatorsOf(peerFingerprint) {
|
|
288
|
+
const peer = this.#peers.get(peerFingerprint)?.handle;
|
|
289
|
+
return peer ? [...(this.#peerOperators.get(peer)?.keys() ?? [])] : [];
|
|
244
290
|
}
|
|
245
291
|
|
|
246
292
|
/**
|
|
247
293
|
* Carry a message across `handle`'s bridge. A plain forwardable message is
|
|
248
294
|
* wrapped; a `RelayForward` the client wrote itself is unwrapped, its inner
|
|
249
295
|
* checked, and re-wrapped -- either way `from_fingerprint` is the sender's
|
|
250
|
-
* authenticated key.
|
|
296
|
+
* authenticated key. A peer serving several operators names the recipient
|
|
297
|
+
* with `to_fingerprint` (never set on what is delivered); with one operator
|
|
298
|
+
* it may leave it out. Returns false when there is no bridge.
|
|
251
299
|
*/
|
|
252
300
|
forward(handle, msg) {
|
|
253
|
-
const partner = this.#
|
|
254
|
-
if (
|
|
301
|
+
const partner = this.#recipient(handle, msg);
|
|
302
|
+
if (partner === undefined) return false;
|
|
303
|
+
if (partner === null) return true; // bridged, but the message could not be addressed: dropped (and logged)
|
|
255
304
|
let inner;
|
|
256
305
|
if (msg.type === MSG.RELAY_FORWARD) {
|
|
257
306
|
if (!(msg.inner instanceof Uint8Array) || msg.inner.byteLength > MAX_FORWARD_BYTES) { this.log('relay: dropped an oversized or malformed RelayForward'); return true; }
|
|
@@ -270,6 +319,27 @@ export class RelayHub {
|
|
|
270
319
|
return true;
|
|
271
320
|
}
|
|
272
321
|
|
|
322
|
+
/**
|
|
323
|
+
* Who `msg` from `handle` goes to: the partner handle; `undefined` if `handle` has no bridge (the caller
|
|
324
|
+
* treats the message as an ordinary one); `null` if it has one but the recipient cannot be determined.
|
|
325
|
+
*/
|
|
326
|
+
#recipient(handle, msg) {
|
|
327
|
+
const peer = this.#operatorPeer.get(handle);
|
|
328
|
+
if (peer) return peer; // an operator has exactly one bridge
|
|
329
|
+
const operators = this.#peerOperators.get(handle);
|
|
330
|
+
if (!operators || operators.size === 0) return undefined;
|
|
331
|
+
const to = msg.type === MSG.RELAY_FORWARD ? msg.to_fingerprint : undefined;
|
|
332
|
+
if (to !== undefined) {
|
|
333
|
+
const operator = typeof to === 'string' ? operators.get(to) : undefined;
|
|
334
|
+
if (operator) return operator;
|
|
335
|
+
this.log(`relay: dropped a forward from ${handle.fingerprint.slice(0, 12)} addressed to ${typeof to === 'string' ? to.slice(0, 12) : '?'}, which is not one of its operators`);
|
|
336
|
+
return null;
|
|
337
|
+
}
|
|
338
|
+
if (operators.size === 1) return operators.values().next().value;
|
|
339
|
+
this.log(`relay: dropped an unaddressed forward from ${handle.fingerprint.slice(0, 12)}, which serves ${operators.size} operators (to_fingerprint is required)`);
|
|
340
|
+
return null;
|
|
341
|
+
}
|
|
342
|
+
|
|
273
343
|
/** A connection ended: forget it, and end the bridge it was part of. */
|
|
274
344
|
drop(handle) {
|
|
275
345
|
const entry = this.#peers.get(handle.fingerprint);
|
|
@@ -279,11 +349,28 @@ export class RelayHub {
|
|
|
279
349
|
for (const [target, pend] of this.#pending) {
|
|
280
350
|
if (pend.operator === handle) { clearTimeout(pend.timer); this.#pending.delete(target); }
|
|
281
351
|
}
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
this.#
|
|
286
|
-
|
|
352
|
+
// An operator leaving.
|
|
353
|
+
const peer = this.#operatorPeer.get(handle);
|
|
354
|
+
if (peer) {
|
|
355
|
+
this.#operatorPeer.delete(handle);
|
|
356
|
+
const operators = this.#peerOperators.get(peer);
|
|
357
|
+
operators?.delete(handle.fingerprint);
|
|
358
|
+
if (this.maxOperatorsPerPeer > 1 && this.#multiPeers.has(peer)) {
|
|
359
|
+
// The peer serves others too (or may serve more): tell it which operator left instead of closing it.
|
|
360
|
+
peer.send(reverseClose({ targetFingerprint: handle.fingerprint, reason: 'operator left' }));
|
|
361
|
+
} else {
|
|
362
|
+
this.#peerOperators.delete(peer);
|
|
363
|
+
peer.close();
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
// A peer leaving: every operator bridged to it goes with it.
|
|
367
|
+
const operators = this.#peerOperators.get(handle);
|
|
368
|
+
if (operators) {
|
|
369
|
+
this.#peerOperators.delete(handle);
|
|
370
|
+
for (const operator of operators.values()) {
|
|
371
|
+
this.#operatorPeer.delete(operator);
|
|
372
|
+
operator.close();
|
|
373
|
+
}
|
|
287
374
|
}
|
|
288
375
|
}
|
|
289
376
|
|
|
@@ -291,7 +378,8 @@ export class RelayHub {
|
|
|
291
378
|
closeAll() {
|
|
292
379
|
for (const p of this.#pending.values()) clearTimeout(p.timer);
|
|
293
380
|
this.#pending.clear();
|
|
294
|
-
this.#
|
|
381
|
+
this.#operatorPeer.clear();
|
|
382
|
+
this.#peerOperators.clear();
|
|
295
383
|
this.#peers.clear();
|
|
296
384
|
}
|
|
297
385
|
}
|
|
@@ -7,11 +7,18 @@
|
|
|
7
7
|
* The relay has already authenticated the operator (its key), so the bridged
|
|
8
8
|
* connection is pre-authenticated; what a relay-asserted identity is worth is
|
|
9
9
|
* yours to decide in `accept`, which defaults to refusing everyone.
|
|
10
|
+
*
|
|
11
|
+
* One operator at a time by default. With `maxOperators` > 1 (and a relay
|
|
12
|
+
* configured with `relay.maxOperatorsPerPeer`) several operators are served at
|
|
13
|
+
* once, each with its own connection state -- channels, processes, files -- and
|
|
14
|
+
* when one leaves only what belongs to it is cleaned up (wsh #89).
|
|
10
15
|
*/
|
|
11
16
|
|
|
12
17
|
import { WshClient } from '../client.mjs';
|
|
13
18
|
import { exportPublicKeyRaw, fingerprint } from '../auth.mjs';
|
|
14
19
|
import { reverseAccept, reverseReject } from '../messages.gen.mjs';
|
|
20
|
+
import { MULTI_OPERATOR_FEATURE } from './relay.mjs';
|
|
21
|
+
import { E2E_SIGN_FEATURE } from '../e2e-exchange.mjs';
|
|
15
22
|
import { buildBackends } from './backends.mjs';
|
|
16
23
|
import { createConnectionFactory, hostFeatures } from './connection.mjs';
|
|
17
24
|
|
|
@@ -32,18 +39,37 @@ const BACKOFF_MAX_MS = 30_000;
|
|
|
32
39
|
* @param {boolean} [options.reconnect=true] - Dial again (with backoff) after the relay connection ends. A relay ends it when a bridge does.
|
|
33
40
|
* @param {object} [options.connect] - Extra options for the relay connection (`expectHostKey`, `knownHosts`, ...).
|
|
34
41
|
* @param {string} [options.peerType='host']
|
|
42
|
+
* @param {false | { sign?: boolean | 'auto', hybrid?: boolean }} [options.e2e] - End-to-end encryption for the bridged operator (wsh #90).
|
|
43
|
+
* On by default: this host answers an operator's `KeyExchange` (for a session it has opened), then seals that
|
|
44
|
+
* session's output and opens its input as `EncryptedFrame`s the relay cannot read, and ignores plaintext input for it.
|
|
45
|
+
* `sign` signs the reply with `keyPair` so the operator can authenticate this host
|
|
46
|
+
* (`initiateE2E(..., { verifyPeer })`). Default `'auto'`: sign when the relay says (`e2e-sign` in its ServerHello) it
|
|
47
|
+
* carries a signed `KeyExchange` -- a relay that predates the `signature` field (a Rust relay built before it)
|
|
48
|
+
* would drop the connection on one. `true` signs regardless; `false` never does. `hybrid` (default true) offers
|
|
49
|
+
* X25519+ML-KEM-768 when asked.
|
|
50
|
+
* `false` turns the layer off: no `e2e` feature, `KeyExchange` unanswered.
|
|
51
|
+
* @param {number} [options.maxOperators=1] - Operators served at once. Above 1 this host states `relay-multi-operator`
|
|
52
|
+
* (it addresses each reply to its operator and handles `ReverseClose`), so it needs `reportFeatures` and a relay with
|
|
53
|
+
* `relay.maxOperatorsPerPeer` above 1 (which says so in its ServerHello); against any other relay it serves one
|
|
54
|
+
* operator, as before. Each operator is passed through `accept` on its own identity.
|
|
35
55
|
* @param {boolean} [options.reportFeatures=true] - State this host's features in `ReverseAccept.features`, so the operator's
|
|
36
56
|
* feature gates follow this host rather than the relay. Turn off only for a relay (a Rust `wsh-server` older than the field)
|
|
37
57
|
* that rejects a `ReverseAccept` carrying it.
|
|
38
58
|
* @param {(line: string) => void} [options.onLog]
|
|
39
59
|
*/
|
|
40
60
|
export function createReverseHost({
|
|
41
|
-
url, username, keyPair, accept, exec, pty, fs, mcp, reconnect = true, connect = {}, peerType = 'host', reportFeatures = true, onLog = () => {},
|
|
61
|
+
url, username, keyPair, accept, exec, pty, fs, mcp, reconnect = true, connect = {}, peerType = 'host', reportFeatures = true, maxOperators = 1, e2e = {}, onLog = () => {},
|
|
42
62
|
} = {}) {
|
|
43
63
|
if (!url) throw new TypeError('createReverseHost: url is required');
|
|
44
64
|
if (!username) throw new TypeError('createReverseHost: username is required');
|
|
45
65
|
if (!keyPair) throw new TypeError('createReverseHost: keyPair is required (the relay keys peers by fingerprint)');
|
|
46
66
|
if (accept !== undefined && typeof accept !== 'function') throw new TypeError('createReverseHost: accept must be a function');
|
|
67
|
+
if (!Number.isSafeInteger(maxOperators) || maxOperators < 1) throw new TypeError('createReverseHost: maxOperators must be a positive integer');
|
|
68
|
+
if (maxOperators > 1 && !reportFeatures) throw new TypeError('createReverseHost: maxOperators > 1 needs reportFeatures (the relay learns "relay-multi-operator" from ReverseAccept.features)');
|
|
69
|
+
if (e2e !== false && (e2e === null || typeof e2e !== 'object')) throw new TypeError('createReverseHost: e2e must be false or an options object');
|
|
70
|
+
const multi = maxOperators > 1;
|
|
71
|
+
if (e2e !== false && e2e.sign !== undefined && e2e.sign !== 'auto' && typeof e2e.sign !== 'boolean') throw new TypeError("createReverseHost: e2e.sign must be true, false or 'auto'");
|
|
72
|
+
const e2eSign = e2e === false ? false : (e2e.sign ?? 'auto');
|
|
47
73
|
|
|
48
74
|
const backends = buildBackends({ exec, pty, fs, mcp });
|
|
49
75
|
const expose = { shell: !!backends.pty, exec: !!backends.execRunner, fs: !!backends.files, tools: !!backends.mcp };
|
|
@@ -51,14 +77,27 @@ export function createReverseHost({
|
|
|
51
77
|
const shellBackend = backends.pty ? 'pty' : 'exec-only';
|
|
52
78
|
// What this host can do over a bridge, for the operator's feature gates (`ReverseAccept.features`).
|
|
53
79
|
// `reportFeatures: false` leaves them out (a Rust peer or relay older than the field rejects a ReverseAccept that has it).
|
|
54
|
-
|
|
80
|
+
/** What this host does with end-to-end encryption on a given relay connection (null = off). */
|
|
81
|
+
const e2eFor = (c) => (e2e === false ? null : {
|
|
82
|
+
signKey: e2eSign === true || (e2eSign === 'auto' && c.hasFeature(E2E_SIGN_FEATURE)) ? keyPair.privateKey : null,
|
|
83
|
+
hybrid: e2e.hybrid !== false,
|
|
84
|
+
});
|
|
85
|
+
/** Features for `ReverseAccept` on this relay connection. */
|
|
86
|
+
const bridgeFeatures = (c, e2eConfig, manyOperators) => [
|
|
87
|
+
...hostFeatures({ mcp: backends.mcp, files: backends.files, e2e: e2eConfig }, { dataStreams: false }),
|
|
88
|
+
...(manyOperators ? [MULTI_OPERATOR_FEATURE] : []),
|
|
89
|
+
];
|
|
55
90
|
const factory = createConnectionFactory({
|
|
56
91
|
authorize: async () => false, methods: { pubkey: false, password: null }, hostKey: null, rateLimit: null,
|
|
57
92
|
execRunner: backends.execRunner, execOptions: backends.execOptions, pty: backends.pty, files: backends.files, mcp: backends.mcp,
|
|
58
|
-
sessions: null, relay: null, bindTimeoutMs: 3000, log: (l) => onLog(`[reverse] ${l}`),
|
|
93
|
+
sessions: null, relay: null, e2e: null, bindTimeoutMs: 3000, log: (l) => onLog(`[reverse] ${l}`),
|
|
59
94
|
});
|
|
60
95
|
|
|
61
96
|
let client = null;
|
|
97
|
+
/** The live connection's bridges (operator fingerprint -> connection), for `operators`. */
|
|
98
|
+
let operatorsNow = new Map();
|
|
99
|
+
/** Ends every bridge of the live connection (the client's own `disconnect()` does not call its `onClose`). */
|
|
100
|
+
let endAllBridges = () => {};
|
|
62
101
|
let myFingerprint = null;
|
|
63
102
|
let closed = false;
|
|
64
103
|
let timer = null;
|
|
@@ -66,40 +105,63 @@ export function createReverseHost({
|
|
|
66
105
|
|
|
67
106
|
async function dial() {
|
|
68
107
|
const c = new WshClient();
|
|
69
|
-
|
|
70
|
-
|
|
108
|
+
/** operator fingerprint -> that operator's connection (its own channels, processes, files). */
|
|
109
|
+
const bridges = new Map();
|
|
110
|
+
// Several operators only on a relay that says it can route to them; anywhere else, one, and bare messages.
|
|
111
|
+
const manyOperators = () => multi && c.hasFeature(MULTI_OPERATOR_FEATURE);
|
|
112
|
+
const operatorLimit = () => (manyOperators() ? maxOperators : 1);
|
|
113
|
+
/** Operators whose `accept` is still running: they count towards the cap. */
|
|
114
|
+
const reserved = new Set();
|
|
115
|
+
operatorsNow = bridges;
|
|
71
116
|
|
|
72
|
-
const endBridge = () => {
|
|
73
|
-
|
|
74
|
-
bridge
|
|
75
|
-
|
|
76
|
-
|
|
117
|
+
const endBridge = (fp) => {
|
|
118
|
+
const bridge = bridges.get(fp);
|
|
119
|
+
if (!bridge) return;
|
|
120
|
+
bridges.delete(fp);
|
|
121
|
+
c.untrustRelayPeer(fp);
|
|
122
|
+
bridge.close();
|
|
123
|
+
onLog(`[reverse] operator ${fp.slice(0, 12)} left (${bridges.size} still bridged)`);
|
|
77
124
|
};
|
|
78
|
-
|
|
125
|
+
const endAll = () => { for (const fp of [...bridges.keys()]) endBridge(fp); };
|
|
126
|
+
endAllBridges = endAll;
|
|
127
|
+
c.onRelayMessage = (msg, from) => bridges.get(from)?.receiveMessage(msg);
|
|
128
|
+
// The relay says one operator left; the others carry on (it sends this only to a host that stated multi-operator support).
|
|
129
|
+
c.onReverseClose = (m) => endBridge(String(m.target_fingerprint ?? ''));
|
|
79
130
|
c.onReverseConnect = async (req) => {
|
|
80
131
|
const from = req.from_fingerprint;
|
|
81
132
|
const answer = (m) => c.sendRelayControl(m).catch(() => {});
|
|
82
133
|
const refuse = (reason) => answer(reverseReject({ targetFingerprint: myFingerprint, username, reason }));
|
|
83
|
-
if (
|
|
134
|
+
if (typeof from !== 'string' || !from) { await refuse('busy: this host is already serving an operator'); return; }
|
|
135
|
+
if (bridges.has(from) || reserved.has(from)) { await refuse('busy: you already have a bridge with this host'); return; }
|
|
136
|
+
if (bridges.size + reserved.size >= operatorLimit()) {
|
|
137
|
+
await refuse(manyOperators() ? `busy: this host is already serving its maximum of ${maxOperators} operators` : 'busy: this host is already serving an operator');
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
reserved.add(from);
|
|
84
141
|
let ok = false;
|
|
85
|
-
try {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
142
|
+
try {
|
|
143
|
+
try { ok = (await accept?.({ fingerprint: from, username: String(req.username ?? '') })) === true; } catch (e) { onLog(`[reverse] accept threw: ${e.message}`); }
|
|
144
|
+
if (!ok || bridges.size >= operatorLimit()) { await refuse('not accepted'); return; }
|
|
145
|
+
c.trustRelayPeer(from);
|
|
146
|
+
const bridge = factory({
|
|
147
|
+
// With several operators every message names its recipient; with one the relay knows.
|
|
148
|
+
sendMessage: (m) => c.sendRelayControl(m, manyOperators() ? { to: from } : undefined),
|
|
149
|
+
authenticated: { username: String(req.username ?? 'operator'), fingerprint: from },
|
|
150
|
+
dataStreams: false,
|
|
151
|
+
e2e: e2eFor(c),
|
|
152
|
+
});
|
|
153
|
+
bridges.set(from, bridge);
|
|
154
|
+
onLog(`[reverse] bridged to ${from.slice(0, 12)}`);
|
|
155
|
+
await answer(reverseAccept({
|
|
156
|
+
targetFingerprint: myFingerprint, username, capabilities, peerType, shellBackend,
|
|
157
|
+
supportsAttach: false, supportsReplay: false, supportsEcho: false, supportsTermSync: false,
|
|
158
|
+
features: reportFeatures ? bridgeFeatures(c, e2eFor(c), manyOperators()) : undefined,
|
|
159
|
+
}));
|
|
160
|
+
} finally { reserved.delete(from); }
|
|
100
161
|
};
|
|
101
162
|
c.onClose = () => {
|
|
102
|
-
|
|
163
|
+
endAll();
|
|
164
|
+
if (operatorsNow === bridges) operatorsNow = new Map();
|
|
103
165
|
if (client === c) client = null;
|
|
104
166
|
if (closed || !reconnect) return;
|
|
105
167
|
onLog(`[reverse] relay connection ended; redialling in ${backoff}ms`);
|
|
@@ -144,11 +206,14 @@ export function createReverseHost({
|
|
|
144
206
|
clearTimeout(timer);
|
|
145
207
|
const c = client;
|
|
146
208
|
client = null;
|
|
209
|
+
endAllBridges(); // no operator's process may outlive the host
|
|
147
210
|
await c?.disconnect().catch(() => {});
|
|
148
211
|
},
|
|
149
212
|
/** This host's fingerprint (what operators `reverseConnect()` to); `null` before `start()`. */
|
|
150
213
|
get fingerprint() { return myFingerprint; },
|
|
151
214
|
/** Is the relay connection up right now? */
|
|
152
215
|
get connected() { return client !== null; },
|
|
216
|
+
/** Fingerprints of the operators bridged right now. */
|
|
217
|
+
get operators() { return [...operatorsNow.keys()]; },
|
|
153
218
|
};
|
|
154
219
|
}
|