@johnhenry/wsh 0.27.1 → 0.28.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.
@@ -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
  }