@vincentt-xr/harness 1.0.0 → 1.2.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.
@@ -0,0 +1,620 @@
1
+ /**
2
+ * The tunnel client: the creator's side of the wire.
3
+ *
4
+ * It opens ONE outbound WSS connection to the edge and multiplexes every
5
+ * viewer's request onto it. Nothing dials INTO the creator's machine; the
6
+ * client never opens a stream, and the edge rejects an even streamId from it as
7
+ * a protocol violation.
8
+ *
9
+ * What this file does NOT do, deliberately:
10
+ * - it does not parse a DATA payload (they are opaque here too);
11
+ * - it does not follow a redirect on the creator's behalf;
12
+ * - it does not trust the edge's viewerLabel or ua-class without
13
+ * re-validating both against the same regex and the same closed enum.
14
+ */
15
+ import { request as httpRequest } from "node:http";
16
+ import { connect as netConnect } from "node:net";
17
+ import { WebSocket } from "ws";
18
+ import { FrameDecoder, FrameType, MAX_FRAME_LEN, ProtocolError, ResetReason, RELAY_BUF_SIZE, TUNNEL_PING_INTERVAL_MS, TUNNEL_PING_TIMEOUT_MS, decodeCredit, encodeCredit, encodeFrame, } from "./frame.js";
19
+ import { decodeControl, decodeOpen, encodeHead, parseUAClass, parseViewerLabel, } from "./cbor.js";
20
+ import { FOLLOW_REDIRECTS, ForwardTargetError, assertStillLoopback, normalizeRequestTarget, sanitizeHeaders, } from "./forwardTarget.js";
21
+ /** RECONNECT_BACKOFF_MS is 0.5s -> 8s with jitter (2.2). */
22
+ const BACKOFF_BASE_MS = 500;
23
+ const BACKOFF_MAX_MS = 8_000;
24
+ /**
25
+ * The dial statuses no retry can fix, so the client stops rather than looping.
26
+ *
27
+ * 401 the resume token was refused (consumed, expired, or the spend window
28
+ * is shut) — `preview.resume_token_rejected` fired at the edge
29
+ * 409 another tunnel holds this preview, or the grace cap is spent
30
+ * 410 the session is gone
31
+ *
32
+ * A 503 is deliberately ABSENT: it is the edge's admission refusal
33
+ * (`ingress.go:152-156`, the 2001st session) and it is genuinely transient, so
34
+ * it keeps the backoff. Treating it as terminal would kill a healthy preview
35
+ * over a momentarily full edge.
36
+ */
37
+ const TERMINAL_DIAL_STATUS = new Set([401, 409, 410]);
38
+ /**
39
+ * VIEWER_STAMP_HEADER carries the VALIDATED label to the creator's own local
40
+ * server, so the relay can key its ring buffer by viewer.
41
+ *
42
+ * The stamp happens HERE, on the creator's side of the wire, which is the whole
43
+ * point of 2.8: the device never chooses its own label, and the value written
44
+ * has already passed parseViewerLabel. It is added AFTER sanitizeHeaders, so a
45
+ * viewer-supplied header of the same name cannot survive to shadow it.
46
+ */
47
+ export const VIEWER_STAMP_HEADER = "x-vincentt-viewer";
48
+ export const UA_CLASS_STAMP_HEADER = "x-vincentt-ua-class";
49
+ /**
50
+ * RESUME_HEADER is ONE spelling for both halves of the wire: the edge writes it
51
+ * on the 101 (`ResumeHeader` in `internal/edge/websocket.go`), and this client
52
+ * sends it back on the next dial.
53
+ *
54
+ * It is lower-case here because Node lower-cases every response header name,
55
+ * and `res.headers["X-Vincentt-Resume"]` is silently `undefined`. That mistake
56
+ * would reproduce the exact defect this constant exists to fix — a client that
57
+ * never learns its next credential — with a fully green suite, so the casing is
58
+ * asserted by a test rather than trusted.
59
+ */
60
+ export const RESUME_HEADER = "x-vincentt-resume";
61
+ /**
62
+ * SESSION_HEADER carries the sessionId hex on the RECONNECT dial only.
63
+ *
64
+ * It exists because the reconnect path carries NO GRANT
65
+ * (`D-The-reconnect-path-carries-no-grant`), and the grant is what used to carry
66
+ * the session id in its signed `sid` claim. With the grant gone the edge has no
67
+ * other way to name the row, so the id travels as its own header.
68
+ *
69
+ * It is NOT a secret — it is an ObjectID the edge immediately parses with
70
+ * `primitive.ObjectIDFromHex` before it touches anything (`ingress.go:288`) —
71
+ * but exactly one must be sent: the edge refuses duplicates on all three
72
+ * credential-bearing headers with `Header.Values()` rather than `Get`, because
73
+ * `Get` silently returns the first value and that is the bypass being closed
74
+ * (`ingress.go:120-126`).
75
+ *
76
+ * Lower-case for the same reason as RESUME_HEADER: Node normalizes header names.
77
+ */
78
+ export const SESSION_HEADER = "x-vincentt-session";
79
+ /** The Authorization header, named once so the never-send-both rule cites it. */
80
+ export const AUTHORIZATION_HEADER = "authorization";
81
+ /**
82
+ * RESUME_TOKEN_LEN is base64url(32 random bytes), unpadded. It mirrors
83
+ * `resumeTokenLen` in the Go edge so the two ends of this wire state the same
84
+ * fact rather than each carrying a magic number.
85
+ */
86
+ export const RESUME_TOKEN_LEN = 43;
87
+ const RESUME_TOKEN_RE = /^[A-Za-z0-9_-]{43}$/;
88
+ /**
89
+ * parseResumeToken accepts only the grammar the API's issuer produces.
90
+ *
91
+ * ABSENCE IS LEGITIMATE, not an error: the edge OMITS the header rather than
92
+ * emitting it empty when it has no token to hand over, and the handshake still
93
+ * succeeds. The cost is one reconnect, which is recoverable — treating it as
94
+ * fatal would turn a token problem into a dead preview.
95
+ *
96
+ * A malformed value is discarded for the same reason it is validated at the
97
+ * edge before being written: this value is concatenated into an outbound header
98
+ * on the next dial, and a CR or LF in it would not be a bad credential, it
99
+ * would be a second request.
100
+ */
101
+ export function parseResumeToken(raw) {
102
+ if (typeof raw !== "string")
103
+ return null;
104
+ return RESUME_TOKEN_RE.test(raw) ? raw : null;
105
+ }
106
+ export class TunnelClient {
107
+ opts;
108
+ ws = null;
109
+ decoder = new FrameDecoder(true);
110
+ streams = new Map();
111
+ /**
112
+ * resumeToken is 32 random bytes, single-use, rotated on every attach, bound
113
+ * to the session, 5-minute TTL. It is held in memory only and NEVER logged:
114
+ * "single-use" is enforced by the API's atomic consume, and a token in a log
115
+ * is a credential in a log.
116
+ */
117
+ resumeToken = null;
118
+ attempt = 0;
119
+ closed = false;
120
+ state = "connecting";
121
+ pingTimer = null;
122
+ lastPong = Date.now();
123
+ constructor(opts) {
124
+ this.opts = opts;
125
+ }
126
+ start() {
127
+ this.closed = false;
128
+ this.open();
129
+ }
130
+ close() {
131
+ this.closed = true;
132
+ this.setState("closed");
133
+ this.stopPing();
134
+ this.ws?.close();
135
+ this.ws = null;
136
+ }
137
+ setState(s) {
138
+ if (this.state === s)
139
+ return;
140
+ this.state = s;
141
+ this.opts.onStateChange?.(s);
142
+ }
143
+ /**
144
+ * THE DIAL RULE. One boolean, two disjoint states, one credential each.
145
+ *
146
+ * first dial (resumeToken === null): Authorization: Bearer <mint grant>
147
+ * every redial (resumeToken !== null): X-Vincentt-Resume + X-Vincentt-Session
148
+ *
149
+ * This function is the WIRING of `D-Client-holds-one-credential-per-state`:
150
+ * every dial's header set is built here and nowhere else, so the never-send-both
151
+ * rule is a property of one `if/else` rather than of a convention.
152
+ *
153
+ * NEVER BOTH, and this is the rule most likely to be "helpfully" broken later.
154
+ * The edge REFUSES a request carrying a grant alongside a valid resume token
155
+ * (`ingress.go:144-148`) — it does not ignore the grant — so adding
156
+ * `Authorization` here "for robustness" would break every reconnect while
157
+ * passing every functional test in the suite except the negative that exists
158
+ * for exactly this. The reason it is a refusal rather than a preference: a
159
+ * header that is sometimes meaningful and sometimes ignored is a branch waiting
160
+ * to be written.
161
+ *
162
+ * There is no per-door judgement to get wrong because the states are disjoint:
163
+ * the grant is spent by the attach CAS the moment the first dial succeeds, and
164
+ * a resume token only ever exists after a 101 has delivered one.
165
+ */
166
+ dialHeaders() {
167
+ if (this.resumeToken === null) {
168
+ // FIRST ATTACH. The grant is a BEARER credential on the upgrade request,
169
+ // never in the URL: a capability in a query string lands in every
170
+ // intermediary's log.
171
+ return { [AUTHORIZATION_HEADER]: `Bearer ${this.opts.grant}` };
172
+ }
173
+ // RECONNECT. The resume pair, and no Authorization header at all.
174
+ return {
175
+ [RESUME_HEADER]: this.resumeToken,
176
+ [SESSION_HEADER]: this.opts.sessionId,
177
+ };
178
+ }
179
+ open() {
180
+ const headers = this.dialHeaders();
181
+ const ws = this.opts.connect
182
+ ? this.opts.connect(this.opts.edgeUrl, headers)
183
+ : new WebSocket(this.opts.edgeUrl, { headers, maxPayload: MAX_FRAME_LEN + 64 });
184
+ this.ws = ws;
185
+ this.decoder = new FrameDecoder(true);
186
+ // `ws` emits BOTH `error` and `close` for a single failed connection, so
187
+ // the handler is bound once per socket and guarded: without the guard each
188
+ // failure schedules TWO retries, the next failure four, and the backoff
189
+ // inverts into an accelerating storm against the platform's only
190
+ // unauthenticated listener. Observed against a real edge, not theorised.
191
+ const closeOnce = () => this.onClosed(ws);
192
+ /**
193
+ * THE ROTATION. `ws` hands us the raw 101 here, which is the only place the
194
+ * edge's next resume token is available.
195
+ *
196
+ * Without this assignment the field stayed `null` forever, the request
197
+ * header above was never sent, and every reconnect presented its
198
+ * already-consumed single-use grant — so the edge refused it at grant
199
+ * verification and `/reattached` was never reached. Both repos' suites
200
+ * stayed green across that gap for the whole build, because each asserted
201
+ * its own half: the Go side that `/reattached` works when a token arrives,
202
+ * this side that the header is sent when the field is set. Nothing asserted
203
+ * that the field is ever ASSIGNED.
204
+ */
205
+ ws.on("upgrade", (res) => {
206
+ const next = parseResumeToken(res.headers[RESUME_HEADER]);
207
+ // A missing or malformed token leaves the PREVIOUS one in place rather
208
+ // than clearing it: the old token is already spent after a successful
209
+ // attach, but keeping it costs nothing and dropping a good one because a
210
+ // later handshake omitted the header would forfeit a working reconnect.
211
+ if (next !== null)
212
+ this.resumeToken = next;
213
+ });
214
+ /**
215
+ * A REFUSED RECONNECT — the edge answered with a status instead of a 101.
216
+ *
217
+ * This is a REAL SIGNAL, not routine noise. The edge emits
218
+ * `preview.resume_token_rejected` here, which QA and security made an
219
+ * alertable theft detector, and an honest client is not supposed to fire it.
220
+ * Retrying silently forever against a 401/409/410 both buries that signal
221
+ * under the client's own backoff and leaves the creator watching
222
+ * "Reconnecting…" for a preview that can never come back.
223
+ *
224
+ * The statuses are NOT collapsed, because the creator's action differs:
225
+ * 409 means another tunnel holds this preview, 410 means it is gone.
226
+ *
227
+ * REGISTERING THIS LISTENER CHANGES `ws`'s BEHAVIOUR, which is why the
228
+ * retry is scheduled here explicitly. With no `unexpected-response` handler
229
+ * `ws` emits `error` and the close path below schedules the next dial; with
230
+ * one, it emits NEITHER `error` NOR `close` and hands the response over
231
+ * instead. Adding this handler without the `closeOnce()` below therefore
232
+ * silently kills the backoff for EVERY non-101 status — including the
233
+ * transient ones — which is a worse failure than the one it fixes, and it
234
+ * was caught by asserting the dial COUNT rather than the callback.
235
+ */
236
+ ws.on("unexpected-response", (_req, res) => {
237
+ const status = res.statusCode ?? 0;
238
+ this.opts.onReconnectRefused?.(status);
239
+ // TERMINAL statuses stop the loop. A refused credential does not become
240
+ // valid by being presented again, and re-dialling a 401 is what turns
241
+ // one theft signal into an unbounded stream of them.
242
+ if (TERMINAL_DIAL_STATUS.has(status))
243
+ this.closed = true;
244
+ res.destroy?.();
245
+ // A transient status (503 admission refusal, 502) still gets the
246
+ // backoff; a terminal one lands on the `this.closed` guard inside.
247
+ closeOnce();
248
+ });
249
+ ws.on("open", () => {
250
+ this.attempt = 0;
251
+ this.lastPong = Date.now();
252
+ this.setState("live");
253
+ this.startPing();
254
+ });
255
+ ws.on("message", (data) => this.onBytes(new Uint8Array(data)));
256
+ ws.on("close", closeOnce);
257
+ ws.on("error", (err) => {
258
+ // The CODE only, never the message or the stack: an error string from a
259
+ // TLS or HTTP layer can carry a URL, and this URL is the capability.
260
+ //
261
+ // It is surfaced at all because a tunnel that cannot re-establish
262
+ // otherwise produces NOTHING — not for the creator, not for triage. That
263
+ // silence cost a full rig investigation: "the client stopped dialling"
264
+ // with no reason attached is the least actionable failure in the feature.
265
+ this.opts.onTunnelError?.(err.code ?? "tunnel_error");
266
+ closeOnce();
267
+ });
268
+ }
269
+ onClosed(ws) {
270
+ // A late event from a socket we have already replaced must not schedule a
271
+ // second retry, nor tear down the stream table of its successor.
272
+ if (this.ws !== ws)
273
+ return;
274
+ this.stopPing();
275
+ // Streams do NOT survive a reconnect: in-flight requests are RESET and the
276
+ // browser retries. That is the correct trade — an AR page reload is already
277
+ // the accepted cost of this loop.
278
+ for (const s of this.streams.values())
279
+ s.abort?.();
280
+ this.streams.clear();
281
+ this.ws = null;
282
+ if (this.closed)
283
+ return;
284
+ this.setState("reconnecting");
285
+ // 0.5s -> 8s with jitter.
286
+ const base = Math.min(BACKOFF_BASE_MS * 2 ** this.attempt, BACKOFF_MAX_MS);
287
+ const delay = base / 2 + Math.random() * (base / 2);
288
+ this.attempt++;
289
+ setTimeout(() => {
290
+ if (!this.closed)
291
+ this.open();
292
+ }, delay);
293
+ }
294
+ startPing() {
295
+ this.stopPing();
296
+ this.pingTimer = setInterval(() => {
297
+ if (Date.now() - this.lastPong > TUNNEL_PING_TIMEOUT_MS) {
298
+ // A dead tunnel is detected BEFORE the edge's grace window opens.
299
+ this.ws?.terminate?.();
300
+ return;
301
+ }
302
+ this.send(FrameType.PING, 0);
303
+ }, TUNNEL_PING_INTERVAL_MS);
304
+ }
305
+ stopPing() {
306
+ if (this.pingTimer !== null)
307
+ clearInterval(this.pingTimer);
308
+ this.pingTimer = null;
309
+ }
310
+ send(type, streamId, payload) {
311
+ const ws = this.ws;
312
+ if (ws === null || ws.readyState !== WebSocket.OPEN)
313
+ return;
314
+ try {
315
+ ws.send(encodeFrame(type, streamId, payload));
316
+ }
317
+ catch {
318
+ // An encode refusal is our own bug, not the peer's. Drop the frame rather
319
+ // than crashing the tunnel; the stream's idle timeout collects it.
320
+ }
321
+ }
322
+ onBytes(chunk) {
323
+ let frames;
324
+ try {
325
+ frames = this.decoder.push(chunk);
326
+ }
327
+ catch (err) {
328
+ // A FATAL protocol error closes the tunnel; a stream-level one cannot
329
+ // reach here (the decoder only throws fatal ones).
330
+ if (err instanceof ProtocolError && err.fatal) {
331
+ this.ws?.close();
332
+ return;
333
+ }
334
+ this.ws?.close();
335
+ return;
336
+ }
337
+ for (const f of frames)
338
+ this.dispatch(f.type, f.streamId, f.payload);
339
+ }
340
+ dispatch(type, streamId, payload) {
341
+ switch (type) {
342
+ case FrameType.PING:
343
+ this.send(FrameType.PONG, 0);
344
+ return;
345
+ case FrameType.PONG:
346
+ this.lastPong = Date.now();
347
+ return;
348
+ case FrameType.CONTROL: {
349
+ try {
350
+ const ctrl = decodeControl(payload);
351
+ if (ctrl.kind === "viewer_gone") {
352
+ // Routed AWAY from onControl deliberately. The other three kinds
353
+ // all mean "this session is ending" and the CLI prints an ending
354
+ // screen for them; a viewer leaving means the opposite — the
355
+ // session is fine and one device went away.
356
+ this.opts.onViewerGone?.(ctrl.viewerLabel);
357
+ }
358
+ else {
359
+ this.opts.onControl?.(ctrl.kind);
360
+ }
361
+ }
362
+ catch {
363
+ // A malformed CONTROL is a stream-level fault on stream 0, which
364
+ // means it is simply ignored rather than killing a live tunnel.
365
+ }
366
+ return;
367
+ }
368
+ case FrameType.OPEN: {
369
+ let open;
370
+ try {
371
+ open = decodeOpen(payload);
372
+ }
373
+ catch {
374
+ this.send(FrameType.RESET, streamId, new Uint8Array([ResetReason.PROTOCOL_ERROR]));
375
+ return;
376
+ }
377
+ void this.serveOpen(streamId, open);
378
+ return;
379
+ }
380
+ case FrameType.DATA: {
381
+ const s = this.streams.get(streamId);
382
+ if (s === undefined) {
383
+ this.send(FrameType.RESET, streamId, new Uint8Array([ResetReason.STREAM_CLOSED]));
384
+ return;
385
+ }
386
+ // OPAQUE. Written straight through to the local server; nothing here
387
+ // reads it, branches on it, or keeps it.
388
+ s.writeBody?.(payload);
389
+ return;
390
+ }
391
+ case FrameType.END: {
392
+ const s = this.streams.get(streamId);
393
+ if (s === undefined)
394
+ return;
395
+ if (payload.length === 3) {
396
+ // A credit replenishment for bytes we sent.
397
+ s.credit = Math.min(s.credit + decodeCredit(payload), MAX_FRAME_LEN);
398
+ const w = s.waiters.shift();
399
+ w?.();
400
+ return;
401
+ }
402
+ s.ended = true;
403
+ return;
404
+ }
405
+ case FrameType.RESET:
406
+ this.streams.get(streamId)?.abort?.();
407
+ this.streams.delete(streamId);
408
+ return;
409
+ }
410
+ }
411
+ /**
412
+ * serveOpen forwards ONE viewer request to the single configured target.
413
+ *
414
+ * Every 2.15.4 rule executes here, in order: the target is the configured one
415
+ * (never from the request), the request target is normalized-then-checked,
416
+ * the viewer's routing headers are dropped, loopback is RE-CHECKED at connect,
417
+ * and no redirect is followed.
418
+ */
419
+ async serveOpen(streamId, open) {
420
+ // RE-VALIDATE the label and the class before either reaches the relay
421
+ // handshake. This is the second of three closures, and a Go-only check
422
+ // would pass on a client that trusted the edge.
423
+ const label = parseViewerLabel(open.viewerLabel);
424
+ if (label === null) {
425
+ this.send(FrameType.RESET, streamId, new Uint8Array([ResetReason.PROTOCOL_ERROR]));
426
+ return;
427
+ }
428
+ const uaClass = parseUAClass(open.headers.find(([k]) => k.toLowerCase() === "x-vincentt-ua-class")?.[1] ??
429
+ "unknown");
430
+ this.opts.onViewer?.(label, uaClass);
431
+ let path;
432
+ try {
433
+ path = normalizeRequestTarget(open.path);
434
+ await assertStillLoopback(this.opts.target);
435
+ }
436
+ catch (err) {
437
+ const code = err instanceof ForwardTargetError ? err.code : "invalid_target";
438
+ // The DETAIL goes to the creator's terminal, which is the only safe place
439
+ // for it and the place the person who can fix it is already looking. The
440
+ // viewer gets the edge's fixed 502.
441
+ this.opts.onOriginError?.(label, code);
442
+ this.send(FrameType.RESET, streamId, new Uint8Array([ResetReason.PROTOCOL_ERROR]));
443
+ return;
444
+ }
445
+ const headers = sanitizeHeaders(open.headers, this.opts.target);
446
+ // Stamped AFTER sanitizeHeaders so a viewer-supplied header of the same
447
+ // name cannot shadow ours. Both values are already validated: the label
448
+ // against VIEWER_LABEL_RE, the class against the closed enum of six.
449
+ headers.push([VIEWER_STAMP_HEADER, label], [UA_CLASS_STAMP_HEADER, uaClass]);
450
+ const stream = {
451
+ id: streamId,
452
+ label,
453
+ credit: MAX_FRAME_LEN,
454
+ waiters: [],
455
+ ended: false,
456
+ };
457
+ this.streams.set(streamId, stream);
458
+ if (open.upgrade !== undefined) {
459
+ // `upgrade` is CARRIED, NOT INTERPRETED, by the protocol — but the local
460
+ // hop still has to BE an upgrade, or the diagnostics WebSocket for
461
+ // /__harness never establishes and the whole diagnostics half of the
462
+ // feature is dead. The bytes stay opaque on both sides of this splice.
463
+ this.serveUpgrade(streamId, stream, open, path, headers);
464
+ return;
465
+ }
466
+ const req = httpRequest({
467
+ host: this.opts.target.address,
468
+ port: this.opts.target.port,
469
+ method: open.method,
470
+ path,
471
+ headers: Object.fromEntries(headers),
472
+ // The BROWSER follows a redirect, on the viewer's own origin. There is
473
+ // no option to change this.
474
+ ...(FOLLOW_REDIRECTS ? {} : {}),
475
+ }, (res) => {
476
+ const respHeaders = [];
477
+ for (const [k, v] of Object.entries(res.headers)) {
478
+ if (v === undefined)
479
+ continue;
480
+ for (const one of Array.isArray(v) ? v : [v])
481
+ respHeaders.push([k, one]);
482
+ }
483
+ try {
484
+ this.send(FrameType.HEAD, streamId, encodeHead({ status: res.statusCode ?? 502, headers: respHeaders }));
485
+ }
486
+ catch {
487
+ this.send(FrameType.RESET, streamId, new Uint8Array([ResetReason.PROTOCOL_ERROR]));
488
+ res.destroy();
489
+ return;
490
+ }
491
+ res.on("data", (chunk) => {
492
+ // Chunked at MAX_FRAME_LEN so one frame can never exceed the window.
493
+ for (let off = 0; off < chunk.length; off += RELAY_BUF_SIZE) {
494
+ this.send(FrameType.DATA, streamId, new Uint8Array(chunk.subarray(off, Math.min(off + RELAY_BUF_SIZE, chunk.length))));
495
+ }
496
+ });
497
+ res.on("end", () => {
498
+ this.send(FrameType.END, streamId);
499
+ this.streams.delete(streamId);
500
+ });
501
+ });
502
+ stream.abort = () => req.destroy();
503
+ stream.writeBody = (b) => {
504
+ req.write(b);
505
+ // Replenish the edge's window on DRAIN — the write returned, so the local
506
+ // server has taken the bytes.
507
+ this.send(FrameType.END, streamId, encodeCredit(b.length));
508
+ };
509
+ req.on("error", (e) => {
510
+ // The creator's dev server is down. The code goes to THEIR terminal; the
511
+ // viewer gets a fixed 502 generated by the edge with no upstream detail.
512
+ this.opts.onOriginError?.(label, e.code ?? "origin_error");
513
+ this.send(FrameType.RESET, streamId, new Uint8Array([ResetReason.PROTOCOL_ERROR]));
514
+ this.streams.delete(streamId);
515
+ });
516
+ req.end();
517
+ }
518
+ /**
519
+ * serveUpgrade splices ONE viewer's WebSocket to the creator's local server.
520
+ *
521
+ * It re-issues the handshake over a raw socket rather than using an HTTP
522
+ * client, because Node's `http` module owns the connection after a 101 and
523
+ * will not hand back a usable duplex without the same hijack this does
524
+ * explicitly. The response's status line and headers are parsed only far
525
+ * enough to build the HEAD frame; every byte after the blank line is OPAQUE
526
+ * and is forwarded without being read.
527
+ */
528
+ serveUpgrade(streamId, stream, open, path, headers) {
529
+ const sock = netConnect(this.opts.target.port, this.opts.target.address);
530
+ let handshakeDone = false;
531
+ let buffered = Buffer.alloc(0);
532
+ stream.abort = () => sock.destroy();
533
+ stream.writeBody = (b) => {
534
+ sock.write(b);
535
+ this.send(FrameType.END, streamId, encodeCredit(b.length));
536
+ };
537
+ sock.on("connect", () => {
538
+ const lines = [`${open.method} ${path} HTTP/1.1`];
539
+ for (const [k, v] of headers)
540
+ lines.push(`${k}: ${v}`);
541
+ // The hop-by-hop pair sanitizeHeaders dropped has to be reinstated here,
542
+ // because on THIS hop the upgrade is real rather than relayed.
543
+ lines.push(`Upgrade: ${open.upgrade ?? "websocket"}`, "Connection: Upgrade", "", "");
544
+ sock.write(lines.join("\r\n"));
545
+ });
546
+ sock.on("data", (chunk) => {
547
+ if (handshakeDone) {
548
+ for (let off = 0; off < chunk.length; off += RELAY_BUF_SIZE) {
549
+ this.send(FrameType.DATA, streamId, new Uint8Array(chunk.subarray(off, Math.min(off + RELAY_BUF_SIZE, chunk.length))));
550
+ }
551
+ return;
552
+ }
553
+ buffered = Buffer.concat([buffered, chunk]);
554
+ const end = buffered.indexOf("\r\n\r\n");
555
+ if (end === -1) {
556
+ // A response head larger than one control payload is not a handshake
557
+ // this client will complete; refusing bounds the buffer.
558
+ if (buffered.length > MAX_FRAME_LEN) {
559
+ this.send(FrameType.RESET, streamId, new Uint8Array([ResetReason.PROTOCOL_ERROR]));
560
+ sock.destroy();
561
+ }
562
+ return;
563
+ }
564
+ const head = parseResponseHead(buffered.subarray(0, end).toString("latin1"));
565
+ if (head === null) {
566
+ this.send(FrameType.RESET, streamId, new Uint8Array([ResetReason.PROTOCOL_ERROR]));
567
+ sock.destroy();
568
+ return;
569
+ }
570
+ handshakeDone = true;
571
+ try {
572
+ this.send(FrameType.HEAD, streamId, encodeHead(head));
573
+ }
574
+ catch {
575
+ this.send(FrameType.RESET, streamId, new Uint8Array([ResetReason.PROTOCOL_ERROR]));
576
+ sock.destroy();
577
+ return;
578
+ }
579
+ const rest = buffered.subarray(end + 4);
580
+ buffered = Buffer.alloc(0);
581
+ for (let off = 0; off < rest.length; off += RELAY_BUF_SIZE) {
582
+ this.send(FrameType.DATA, streamId, new Uint8Array(rest.subarray(off, Math.min(off + RELAY_BUF_SIZE, rest.length))));
583
+ }
584
+ });
585
+ sock.on("error", (e) => {
586
+ this.opts.onOriginError?.(stream.label, e.code ?? "origin_error");
587
+ this.send(FrameType.RESET, streamId, new Uint8Array([ResetReason.PROTOCOL_ERROR]));
588
+ this.streams.delete(streamId);
589
+ });
590
+ sock.on("close", () => {
591
+ this.send(FrameType.END, streamId);
592
+ this.streams.delete(streamId);
593
+ });
594
+ }
595
+ }
596
+ /**
597
+ * parseResponseHead reads a status line and headers. It returns null rather
598
+ * than throwing on anything it does not recognise, because the caller's only
599
+ * response is a RESET either way and an exception here would carry the
600
+ * creator's server's bytes into a stack trace.
601
+ */
602
+ function parseResponseHead(raw) {
603
+ const lines = raw.split("\r\n");
604
+ const statusLine = lines[0] ?? "";
605
+ const m = /^HTTP\/1\.[01] (\d{3})/.exec(statusLine);
606
+ if (m === null)
607
+ return null;
608
+ const status = Number(m[1]);
609
+ if (status < 100 || status > 599)
610
+ return null;
611
+ const headers = [];
612
+ for (const line of lines.slice(1)) {
613
+ const i = line.indexOf(":");
614
+ if (i <= 0)
615
+ continue;
616
+ headers.push([line.slice(0, i).trim(), line.slice(i + 1).trim()]);
617
+ }
618
+ return { status, headers };
619
+ }
620
+ export { encodeCredit };
@@ -0,0 +1,69 @@
1
+ /**
2
+ * The forward target — what the tunnel client is allowed to reach (2.15.4).
3
+ *
4
+ * READ THIS BEFORE CHANGING ANYTHING HERE.
5
+ *
6
+ * THIS FILE IS A DEFENSE, NOT A SECURITY CONTROL. Every rule in it executes on
7
+ * the CREATOR'S MACHINE, in software the creator can replace. It stops the
8
+ * ACCIDENT — a viewer-controlled path reaching a second local service — and it
9
+ * stops NOTHING a hostile creator wants to do. The controls that bind a hostile
10
+ * creator are at the edge: the abuse budget and the header set.
11
+ *
12
+ * The earlier draft's argument that "one outbound connection, nothing dials into
13
+ * the laptop" made this safe was about CONNECTION DIRECTION, which is not an
14
+ * authorization boundary for what the laptop does with a request once it
15
+ * arrives.
16
+ */
17
+ export declare class ForwardTargetError extends Error {
18
+ readonly code: ForwardTargetErrorCode;
19
+ constructor(code: ForwardTargetErrorCode);
20
+ }
21
+ export type ForwardTargetErrorCode = "absolute_form_target" | "path_traversal" | "non_loopback_target" | "invalid_target";
22
+ export interface ForwardTarget {
23
+ readonly host: string;
24
+ readonly port: number;
25
+ /** The resolved address asserted at start. Re-checked at connect. */
26
+ readonly address: string;
27
+ }
28
+ /**
29
+ * Resolver is the DNS seam. It is injectable so a unit test can drive the
30
+ * rebind case — the same name resolving to loopback at start and to a public
31
+ * address at connect — which is the whole point of rule 5's re-check and is
32
+ * unreachable against the real resolver.
33
+ */
34
+ export type Resolver = (host: string) => Promise<string>;
35
+ /**
36
+ * resolveForwardTarget captures the ONE configured target at
37
+ * `vincentt preview` start, from the creator's own flag or config — NEVER from
38
+ * a request.
39
+ */
40
+ export declare function resolveForwardTarget(host: string, port: number, resolve?: Resolver): Promise<ForwardTarget>;
41
+ /**
42
+ * assertStillLoopback is the CONNECT-TIME re-check. Resolving once at start and
43
+ * trusting it forever is exactly the window a DNS rebind uses.
44
+ */
45
+ export declare function assertStillLoopback(t: ForwardTarget, resolve?: Resolver): Promise<void>;
46
+ /**
47
+ * normalizeRequestTarget applies rules 2 and 3.
48
+ *
49
+ * Rule 2: an ABSOLUTE-FORM target (`GET http://... HTTP/1.1`) is refused
50
+ * outright. That is the shape that turns a forwarding proxy into an open relay.
51
+ *
52
+ * Rule 3: NORMALIZE, THEN CHECK — never check-then-normalize. Percent-encoded
53
+ * traversal (`%2e%2e%2f`) passes a naive `includes('..')` check and then decodes
54
+ * into one, so the decode has to happen FIRST, and it has to happen repeatedly
55
+ * because double-encoding (`%252e`) decodes into `%2e`.
56
+ */
57
+ export declare function normalizeRequestTarget(target: string): string;
58
+ /**
59
+ * sanitizeHeaders drops the viewer's routing headers and every hop-by-hop
60
+ * header, and substitutes the client's own Host.
61
+ */
62
+ export declare function sanitizeHeaders(headers: [string, string][], target: ForwardTarget): [string, string][];
63
+ /**
64
+ * FOLLOW_REDIRECTS is false and there is no option to change it (rule 6). The
65
+ * BROWSER follows a redirect, on the viewer's own origin, where the viewer's
66
+ * own security context applies. A proxy that followed one on the creator's
67
+ * behalf would resolve a Location against the LOCAL machine.
68
+ */
69
+ export declare const FOLLOW_REDIRECTS: false;