@johnhenry/wsh 0.27.0 → 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.
- package/README.md +112 -40
- 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 +87 -3
- package/src/server/index.d.ts +25 -0
- package/src/server/index.mjs +6 -1
- package/src/server/relay.mjs +103 -24
- package/src/server/reverse-host.mjs +93 -28
|
@@ -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
|
}
|