@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.
- package/dist/client/HarnessProvider.d.ts +47 -2
- package/dist/client/HarnessProvider.js +71 -12
- package/dist/client/annotate.js +8 -6
- package/dist/client/cluster.d.ts +12 -0
- package/dist/client/cluster.js +51 -0
- package/dist/client/index.d.ts +2 -0
- package/dist/client/index.js +2 -0
- package/dist/client/instrument.js +13 -3
- package/dist/client/share.d.ts +55 -0
- package/dist/client/share.js +318 -0
- package/dist/shared/events.d.ts +110 -4
- package/dist/shared/events.js +25 -1
- package/dist/tunnel/cbor.d.ts +71 -0
- package/dist/tunnel/cbor.js +403 -0
- package/dist/tunnel/client.d.ts +219 -0
- package/dist/tunnel/client.js +620 -0
- package/dist/tunnel/forwardTarget.d.ts +69 -0
- package/dist/tunnel/forwardTarget.js +174 -0
- package/dist/tunnel/frame.d.ts +126 -0
- package/dist/tunnel/frame.js +244 -0
- package/dist/tunnel/index.d.ts +11 -0
- package/dist/tunnel/index.js +11 -0
- package/package.json +15 -3
|
@@ -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;
|