@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
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session registry for `@johnhenry/wsh/server`: what lets an exec/pty session
|
|
3
|
+
* outlive the connection that opened it (`createWshServer({ sessions })`).
|
|
4
|
+
*
|
|
5
|
+
* The registry is per server and holds a `HostedSession` for every pty/exec
|
|
6
|
+
* session. A connection's channel is an *attachment* to one: output is
|
|
7
|
+
* appended to the session's ring buffer and fanned out to every attachment,
|
|
8
|
+
* so when a socket drops the process keeps running and its newest output keeps
|
|
9
|
+
* accumulating until `detachTtlMs` elapses or a client resumes.
|
|
10
|
+
*
|
|
11
|
+
* `seq` -- the one design question the wire does not answer -- is defined as
|
|
12
|
+
* the cumulative count of output BYTES the host has produced for a session.
|
|
13
|
+
* A client counts what it has received (it sees every byte), passes that as
|
|
14
|
+
* `Resume.last_seq`, and the host replays `ring[last_seq - ringStart ..]`.
|
|
15
|
+
* See spec/wsh-v1.yaml (`Resume`, `AttachmentInfo`).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { createHmac, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
|
|
19
|
+
|
|
20
|
+
export const DEFAULT_RING_BYTES = 1024 * 1024;
|
|
21
|
+
export const DEFAULT_DETACH_TTL_MS = 300_000;
|
|
22
|
+
export const DEFAULT_MAX_DETACHED = 16;
|
|
23
|
+
export const MAX_ATTACHMENTS = 16;
|
|
24
|
+
|
|
25
|
+
const NO_EXPIRY = Buffer.alloc(8, 0xff);
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A bounded byte history addressed by absolute stream position.
|
|
29
|
+
* `start` is the seq of the oldest retained byte, `end` the seq one past the newest.
|
|
30
|
+
*/
|
|
31
|
+
export class ByteRing {
|
|
32
|
+
#chunks = [];
|
|
33
|
+
#size = 0;
|
|
34
|
+
start = 0;
|
|
35
|
+
end = 0;
|
|
36
|
+
|
|
37
|
+
constructor(maxBytes) {
|
|
38
|
+
this.max = maxBytes;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
push(bytes) {
|
|
42
|
+
if (!bytes.byteLength) return;
|
|
43
|
+
this.#chunks.push(Uint8Array.from(bytes));
|
|
44
|
+
this.#size += bytes.byteLength;
|
|
45
|
+
this.end += bytes.byteLength;
|
|
46
|
+
while (this.#size > this.max) {
|
|
47
|
+
const excess = this.#size - this.max;
|
|
48
|
+
const first = this.#chunks[0];
|
|
49
|
+
if (first.byteLength <= excess) {
|
|
50
|
+
this.#chunks.shift();
|
|
51
|
+
this.#size -= first.byteLength;
|
|
52
|
+
this.start += first.byteLength;
|
|
53
|
+
} else {
|
|
54
|
+
this.#chunks[0] = first.subarray(excess);
|
|
55
|
+
this.#size -= excess;
|
|
56
|
+
this.start += excess;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Bytes from absolute position `from` (clamped to `start`) to the end. */
|
|
62
|
+
read(from = this.start) {
|
|
63
|
+
const skip = Math.max(0, from - this.start);
|
|
64
|
+
const out = new Uint8Array(Math.max(0, this.#size - skip));
|
|
65
|
+
let at = 0;
|
|
66
|
+
let seen = 0;
|
|
67
|
+
for (const c of this.#chunks) {
|
|
68
|
+
if (seen + c.byteLength > skip) {
|
|
69
|
+
const part = c.subarray(Math.max(0, skip - seen));
|
|
70
|
+
out.set(part, at);
|
|
71
|
+
at += part.byteLength;
|
|
72
|
+
}
|
|
73
|
+
seen += c.byteLength;
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Mint a session token in the spec's format: `[8B expiry][32B HMAC-SHA256(secret, session_id || expiry)]`.
|
|
81
|
+
* A session's lifetime is governed by `detachTtlMs`, not by its token, so the expiry is the maximum.
|
|
82
|
+
*/
|
|
83
|
+
export function mintToken(secret, sessionId) {
|
|
84
|
+
const mac = createHmac('sha256', secret).update(sessionId).update(NO_EXPIRY).digest();
|
|
85
|
+
return new Uint8Array(Buffer.concat([NO_EXPIRY, mac]));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Constant-time verification of a token minted by `mintToken` for exactly this session id. */
|
|
89
|
+
export function verifyToken(secret, sessionId, token) {
|
|
90
|
+
if (!(token instanceof Uint8Array) || token.byteLength !== 40) return false;
|
|
91
|
+
const buf = Buffer.from(token.buffer, token.byteOffset, token.byteLength);
|
|
92
|
+
const expiry = buf.subarray(0, 8);
|
|
93
|
+
if (expiry.readBigUInt64BE() < BigInt(Math.floor(Date.now() / 1000))) return false;
|
|
94
|
+
const want = createHmac('sha256', secret).update(sessionId).update(expiry).digest();
|
|
95
|
+
return timingSafeEqual(want, buf.subarray(8));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* One attached connection's view of a session; `connection.mjs` builds them.
|
|
100
|
+
* @typedef {object} Attachment
|
|
101
|
+
* @property {number} connId
|
|
102
|
+
* @property {number} channelId
|
|
103
|
+
* @property {'control' | 'readonly'} mode
|
|
104
|
+
* @property {string} username
|
|
105
|
+
* @property {string} principal - who this connection authenticated as (see `principalKey`)
|
|
106
|
+
* @property {(bytes: Uint8Array) => unknown} write - live output
|
|
107
|
+
* @property {(msg: object) => unknown} notify - an ordered control message (e.g. Presence)
|
|
108
|
+
* @property {(code: number) => unknown} exit - the session ended
|
|
109
|
+
*/
|
|
110
|
+
|
|
111
|
+
export class HostedSession {
|
|
112
|
+
/** @type {Set<Attachment>} */
|
|
113
|
+
attachments = new Set();
|
|
114
|
+
state = 'attached';
|
|
115
|
+
exitCode = null;
|
|
116
|
+
createdAt = Date.now();
|
|
117
|
+
lastActivity = Date.now();
|
|
118
|
+
detachedAt = 0;
|
|
119
|
+
name;
|
|
120
|
+
#timer = null;
|
|
121
|
+
|
|
122
|
+
constructor(registry, { id, token, owner, kind, command, ringBytes }) {
|
|
123
|
+
this.registry = registry;
|
|
124
|
+
this.id = id;
|
|
125
|
+
this.token = token;
|
|
126
|
+
this.owner = owner;
|
|
127
|
+
this.kind = kind;
|
|
128
|
+
this.command = command;
|
|
129
|
+
this.ring = new ByteRing(ringBytes);
|
|
130
|
+
/** Set by the connection once the process exists: `{ input, inputEnd, resize, signal, kill }`. */
|
|
131
|
+
this.handlers = {};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Output from the process: retained in the ring, delivered to every attachment. */
|
|
135
|
+
push(bytes) {
|
|
136
|
+
this.lastActivity = Date.now();
|
|
137
|
+
this.ring.push(bytes);
|
|
138
|
+
for (const a of this.attachments) a.write(bytes);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
attach(att) {
|
|
142
|
+
this.attachments.add(att);
|
|
143
|
+
this.state = 'attached';
|
|
144
|
+
this.#clearTimer();
|
|
145
|
+
this.broadcastPresence(att);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
detach(att) {
|
|
149
|
+
if (!this.attachments.delete(att)) return;
|
|
150
|
+
this.broadcastPresence();
|
|
151
|
+
if (this.attachments.size === 0) this.#markDetached();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
roster() {
|
|
155
|
+
return [...this.attachments].map((a) => ({ session_id: this.id, mode: a.mode, username: a.username }));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Tell every attachment (except `except`) who is attached now. */
|
|
159
|
+
broadcastPresence(except) {
|
|
160
|
+
if (this.attachments.size === 0) return;
|
|
161
|
+
const msg = { attachments: this.roster() };
|
|
162
|
+
for (const a of this.attachments) if (a !== except) a.notify(msg);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** The process ended. Everyone attached hears Exit + Close; the record lingers for late resumes. */
|
|
166
|
+
finish(code) {
|
|
167
|
+
if (this.state === 'exited') return;
|
|
168
|
+
this.exitCode = code;
|
|
169
|
+
this.state = 'exited';
|
|
170
|
+
const att = [...this.attachments];
|
|
171
|
+
this.attachments.clear();
|
|
172
|
+
for (const a of att) a.exit(code);
|
|
173
|
+
this.#markDetached();
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
kill() {
|
|
177
|
+
try { this.handlers.kill?.(); } catch { /* already gone */ }
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Forget the session (and stop it if it is still running). */
|
|
181
|
+
dispose() {
|
|
182
|
+
this.#clearTimer();
|
|
183
|
+
this.registry._forget(this);
|
|
184
|
+
if (this.state !== 'exited') {
|
|
185
|
+
this.kill();
|
|
186
|
+
this.state = 'exited';
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
#markDetached() {
|
|
191
|
+
this.detachedAt = Date.now();
|
|
192
|
+
if (this.state !== 'exited') this.state = 'detached';
|
|
193
|
+
this.#clearTimer();
|
|
194
|
+
const ttl = this.registry.detachTtlMs;
|
|
195
|
+
if (ttl <= 0) { this.dispose(); return; }
|
|
196
|
+
this.#timer = setTimeout(() => {
|
|
197
|
+
this.registry.log(`session ${this.id} expired after ${ttl}ms detached`);
|
|
198
|
+
this.dispose();
|
|
199
|
+
}, ttl);
|
|
200
|
+
this.#timer.unref?.();
|
|
201
|
+
this.registry._enforceMaxDetached(this);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
#clearTimer() {
|
|
205
|
+
clearTimeout(this.#timer);
|
|
206
|
+
this.#timer = null;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export class SessionRegistry {
|
|
211
|
+
/** @type {Map<string, HostedSession>} */
|
|
212
|
+
#sessions = new Map();
|
|
213
|
+
|
|
214
|
+
constructor({ secret, detachTtlMs = DEFAULT_DETACH_TTL_MS, maxDetached = DEFAULT_MAX_DETACHED, ringBytes = DEFAULT_RING_BYTES, log = () => {} } = {}) {
|
|
215
|
+
this.secret = secret ?? randomBytes(32);
|
|
216
|
+
this.detachTtlMs = detachTtlMs;
|
|
217
|
+
this.maxDetached = maxDetached;
|
|
218
|
+
this.ringBytes = ringBytes;
|
|
219
|
+
this.log = log;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** @param {{ owner: { username: string, principal: string, fingerprint: string | null }, kind: 'pty' | 'exec', command?: string }} spec */
|
|
223
|
+
create({ owner, kind, command }) {
|
|
224
|
+
const id = randomUUID();
|
|
225
|
+
const s = new HostedSession(this, { id, token: mintToken(this.secret, id), owner, kind, command, ringBytes: this.ringBytes });
|
|
226
|
+
this.#sessions.set(id, s);
|
|
227
|
+
return s;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
get(id) {
|
|
231
|
+
return typeof id === 'string' ? this.#sessions.get(id) : undefined;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
checkToken(session, token) {
|
|
235
|
+
return verifyToken(this.secret, session.id, token);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Sessions this principal owns. */
|
|
239
|
+
listFor(principal) {
|
|
240
|
+
return [...this.#sessions.values()].filter((s) => s.owner.principal === principal);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
get size() { return this.#sessions.size; }
|
|
244
|
+
|
|
245
|
+
/** Kill everything (server shutdown). */
|
|
246
|
+
closeAll() {
|
|
247
|
+
for (const s of [...this.#sessions.values()]) s.dispose();
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
_forget(session) {
|
|
251
|
+
this.#sessions.delete(session.id);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Keep at most `maxDetached` unattended sessions: the longest-detached are killed first. */
|
|
255
|
+
_enforceMaxDetached() {
|
|
256
|
+
const detached = [...this.#sessions.values()].filter((s) => s.attachments.size === 0).sort((a, b) => a.detachedAt - b.detachedAt);
|
|
257
|
+
while (detached.length > this.maxDetached) {
|
|
258
|
+
const victim = detached.shift();
|
|
259
|
+
this.log(`session ${victim.id} evicted (more than ${this.maxDetached} detached)`);
|
|
260
|
+
victim.dispose();
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
package/src/session.mjs
CHANGED
|
@@ -76,6 +76,12 @@ export class WshSession {
|
|
|
76
76
|
*/
|
|
77
77
|
#resumeToken;
|
|
78
78
|
|
|
79
|
+
/**
|
|
80
|
+
* Cumulative session OUTPUT BYTES received so far (see `seq`).
|
|
81
|
+
* @type {number}
|
|
82
|
+
*/
|
|
83
|
+
#seq = 0;
|
|
84
|
+
|
|
79
85
|
/**
|
|
80
86
|
* Stream IDs returned by the server in OPEN_OK.
|
|
81
87
|
* Typically { stdin: number, stdout: number } or a single bidirectional ID.
|
|
@@ -270,6 +276,27 @@ export class WshSession {
|
|
|
270
276
|
return this.#resumeToken;
|
|
271
277
|
}
|
|
272
278
|
|
|
279
|
+
/**
|
|
280
|
+
* How many bytes of this session's output this client has received: the
|
|
281
|
+
* host's `seq` for the next byte. Pass it as `lastSeq` to
|
|
282
|
+
* `WshClient.resumeSession()` so a resume replays only what was missed. For a
|
|
283
|
+
* session obtained from `attachSession()`/`resumeSession()` it starts at the
|
|
284
|
+
* host's position of the first replayed byte, so it is an absolute position
|
|
285
|
+
* either way. Counts bytes delivered to `onData`.
|
|
286
|
+
* @returns {number}
|
|
287
|
+
*/
|
|
288
|
+
get seq() {
|
|
289
|
+
return this.#seq;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Set the starting position of a session obtained by attach/resume.
|
|
294
|
+
* @internal
|
|
295
|
+
*/
|
|
296
|
+
_setSeq(n) {
|
|
297
|
+
this.#seq = n;
|
|
298
|
+
}
|
|
299
|
+
|
|
273
300
|
/** Current session state. */
|
|
274
301
|
get state() {
|
|
275
302
|
return this.#state;
|
|
@@ -657,6 +684,7 @@ export class WshSession {
|
|
|
657
684
|
if (msg.data && msg.data.byteLength > 0) {
|
|
658
685
|
this.#virtualBackend?.pushData(msg.data);
|
|
659
686
|
try {
|
|
687
|
+
this.#seq += msg.data.byteLength;
|
|
660
688
|
this.onData?.(msg.data);
|
|
661
689
|
} catch (err) {
|
|
662
690
|
console.error('[wsh:session] onData handler error:', err);
|
|
@@ -688,6 +716,7 @@ export class WshSession {
|
|
|
688
716
|
.then((plaintext) => {
|
|
689
717
|
this.#virtualBackend?.pushData(plaintext);
|
|
690
718
|
try {
|
|
719
|
+
this.#seq += plaintext.byteLength;
|
|
691
720
|
this.onData?.(plaintext);
|
|
692
721
|
} catch (err) {
|
|
693
722
|
console.error('[wsh:session] onData handler error:', err);
|
|
@@ -898,6 +927,7 @@ export class WshSession {
|
|
|
898
927
|
}
|
|
899
928
|
for (const plaintext of deliverable) {
|
|
900
929
|
try {
|
|
930
|
+
this.#seq += plaintext.byteLength;
|
|
901
931
|
this.onData?.(plaintext);
|
|
902
932
|
} catch (err) {
|
|
903
933
|
console.error('[wsh:session] onData handler error:', err);
|
|
@@ -905,6 +935,7 @@ export class WshSession {
|
|
|
905
935
|
}
|
|
906
936
|
} else {
|
|
907
937
|
try {
|
|
938
|
+
this.#seq += value.byteLength;
|
|
908
939
|
this.onData?.(value);
|
|
909
940
|
} catch (err) {
|
|
910
941
|
console.error('[wsh:session] onData handler error:', err);
|