@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.
@@ -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. One bridge per peer at a time, and it lasts as long as
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
- #pairs = new Map();
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, and one per operator: a RelayForward names its sender but not its recipient, so a
197
- // peer's reply could not be addressed to one of several operators (see the README's relay section).
198
- if (this.#pairs.has(target.handle) || this.#pending.has(target.handle)) {
199
- return reject(BUSY_PEER);
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.#pairs.set(p.operator, peer);
236
- this.#pairs.set(peer, p.operator);
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.#pairs.has(handle);
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. Returns false when there is no bridge.
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.#pairs.get(handle);
254
- if (!partner) return false;
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
- const partner = this.#pairs.get(handle);
283
- if (partner) {
284
- this.#pairs.delete(handle);
285
- this.#pairs.delete(partner);
286
- partner.close();
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.#pairs.clear();
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
- const bridgeFeatures = hostFeatures({ mcp: backends.mcp, files: backends.files }, { dataStreams: false });
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
- let bridge = null;
70
- let operator = null;
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
- if (operator) c.untrustRelayPeer(operator);
74
- bridge?.close();
75
- bridge = null;
76
- operator = null;
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
- c.onRelayMessage = (msg) => bridge?.receiveMessage(msg);
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 (bridge || typeof from !== 'string' || !from) { await refuse('busy: this host is already serving an operator'); return; }
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 { ok = (await accept?.({ fingerprint: from, username: String(req.username ?? '') })) === true; } catch (e) { onLog(`[reverse] accept threw: ${e.message}`); }
86
- if (!ok || bridge) { await refuse('not accepted'); return; }
87
- c.trustRelayPeer(from);
88
- operator = from;
89
- bridge = factory({
90
- sendMessage: (m) => c.sendRelayControl(m),
91
- authenticated: { username: String(req.username ?? 'operator'), fingerprint: from },
92
- dataStreams: false,
93
- });
94
- onLog(`[reverse] bridged to ${from.slice(0, 12)}`);
95
- await answer(reverseAccept({
96
- targetFingerprint: myFingerprint, username, capabilities, peerType, shellBackend,
97
- supportsAttach: false, supportsReplay: false, supportsEcho: false, supportsTermSync: false,
98
- features: reportFeatures ? bridgeFeatures : undefined,
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
- endBridge();
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
  }