@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,174 @@
|
|
|
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
|
+
import { isIP } from "node:net";
|
|
18
|
+
import { lookup } from "node:dns/promises";
|
|
19
|
+
export class ForwardTargetError extends Error {
|
|
20
|
+
code;
|
|
21
|
+
constructor(code) {
|
|
22
|
+
// The code and nothing else. A rejected path in an error message would put
|
|
23
|
+
// a viewer-controlled string into the creator's terminal.
|
|
24
|
+
super(code);
|
|
25
|
+
this.code = code;
|
|
26
|
+
this.name = "ForwardTargetError";
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* LOOPBACK is the only address family the client will connect to. It is checked
|
|
31
|
+
* at START and RE-CHECKED AT CONNECT TIME, so a DNS rebind cannot move the
|
|
32
|
+
* target between the two.
|
|
33
|
+
*/
|
|
34
|
+
function isLoopback(addr) {
|
|
35
|
+
if (isIP(addr) === 4)
|
|
36
|
+
return addr.startsWith("127.");
|
|
37
|
+
if (isIP(addr) === 6)
|
|
38
|
+
return addr === "::1" || addr === "::ffff:127.0.0.1";
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
const defaultResolver = async (host) => (await lookup(host)).address;
|
|
42
|
+
/**
|
|
43
|
+
* resolveForwardTarget captures the ONE configured target at
|
|
44
|
+
* `vincentt preview` start, from the creator's own flag or config — NEVER from
|
|
45
|
+
* a request.
|
|
46
|
+
*/
|
|
47
|
+
export async function resolveForwardTarget(host, port, resolve = defaultResolver) {
|
|
48
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
49
|
+
throw new ForwardTargetError("invalid_target");
|
|
50
|
+
}
|
|
51
|
+
const address = isIP(host) ? host : await resolve(host);
|
|
52
|
+
if (!isLoopback(address))
|
|
53
|
+
throw new ForwardTargetError("non_loopback_target");
|
|
54
|
+
return { host, port, address };
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* assertStillLoopback is the CONNECT-TIME re-check. Resolving once at start and
|
|
58
|
+
* trusting it forever is exactly the window a DNS rebind uses.
|
|
59
|
+
*/
|
|
60
|
+
export async function assertStillLoopback(t, resolve = defaultResolver) {
|
|
61
|
+
// An IP literal cannot be rebound, so it needs no second lookup — which also
|
|
62
|
+
// keeps the common case (127.0.0.1) off the resolver entirely.
|
|
63
|
+
const address = isIP(t.host) ? t.host : await resolve(t.host);
|
|
64
|
+
if (!isLoopback(address))
|
|
65
|
+
throw new ForwardTargetError("non_loopback_target");
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* normalizeRequestTarget applies rules 2 and 3.
|
|
69
|
+
*
|
|
70
|
+
* Rule 2: an ABSOLUTE-FORM target (`GET http://... HTTP/1.1`) is refused
|
|
71
|
+
* outright. That is the shape that turns a forwarding proxy into an open relay.
|
|
72
|
+
*
|
|
73
|
+
* Rule 3: NORMALIZE, THEN CHECK — never check-then-normalize. Percent-encoded
|
|
74
|
+
* traversal (`%2e%2e%2f`) passes a naive `includes('..')` check and then decodes
|
|
75
|
+
* into one, so the decode has to happen FIRST, and it has to happen repeatedly
|
|
76
|
+
* because double-encoding (`%252e`) decodes into `%2e`.
|
|
77
|
+
*/
|
|
78
|
+
export function normalizeRequestTarget(target) {
|
|
79
|
+
if (target === "" || !target.startsWith("/")) {
|
|
80
|
+
// Anything not origin-form: absolute-form, authority-form, asterisk-form.
|
|
81
|
+
throw new ForwardTargetError("absolute_form_target");
|
|
82
|
+
}
|
|
83
|
+
// A scheme-relative target (`//evil.example/x`) is origin-form by the leading
|
|
84
|
+
// slash but is treated as a network path by many URL parsers.
|
|
85
|
+
if (target.startsWith("//"))
|
|
86
|
+
throw new ForwardTargetError("absolute_form_target");
|
|
87
|
+
const [rawPath = "", ...rest] = target.split("?");
|
|
88
|
+
const query = rest.length > 0 ? rest.join("?") : undefined;
|
|
89
|
+
// Decode repeatedly until it stops changing, so a double-encoded traversal
|
|
90
|
+
// cannot survive by decoding one level less than we check.
|
|
91
|
+
let decoded = rawPath;
|
|
92
|
+
for (let i = 0; i < 4; i++) {
|
|
93
|
+
let next;
|
|
94
|
+
try {
|
|
95
|
+
next = decodeURIComponent(decoded);
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
// A malformed escape sequence. Refuse rather than guess.
|
|
99
|
+
throw new ForwardTargetError("path_traversal");
|
|
100
|
+
}
|
|
101
|
+
if (next === decoded)
|
|
102
|
+
break;
|
|
103
|
+
decoded = next;
|
|
104
|
+
}
|
|
105
|
+
// Backslash is a path separator on Windows and is normalized by some servers.
|
|
106
|
+
const probe = decoded.replace(/\\/g, "/");
|
|
107
|
+
for (const segment of probe.split("/")) {
|
|
108
|
+
if (segment === "..")
|
|
109
|
+
throw new ForwardTargetError("path_traversal");
|
|
110
|
+
}
|
|
111
|
+
// NUL and control bytes in a path are a request-smuggling primitive.
|
|
112
|
+
// eslint-disable-next-line no-control-regex
|
|
113
|
+
if (/[\x00-\x1f\x7f]/.test(probe))
|
|
114
|
+
throw new ForwardTargetError("path_traversal");
|
|
115
|
+
// The ORIGINAL (still-encoded) path is what is forwarded: re-encoding could
|
|
116
|
+
// change the creator's app's routing. Only the CHECK used the decoded form.
|
|
117
|
+
return query === undefined ? rawPath : `${rawPath}?${query}`;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* VIEWER_ROUTING_HEADERS are DROPPED and replaced with the client's own
|
|
121
|
+
* (rule 4). A Host-honoring proxy is a routing primitive, and the forwarded-for
|
|
122
|
+
* family would carry the viewer's IP onto the creator's machine — a viewer
|
|
123
|
+
* identity the platform has no business handing over.
|
|
124
|
+
*
|
|
125
|
+
* The edge already drops these; dropping them again here is deliberate defense
|
|
126
|
+
* in depth, because this list is the one that runs closest to the local server.
|
|
127
|
+
*/
|
|
128
|
+
const VIEWER_ROUTING_HEADERS = new Set([
|
|
129
|
+
"host",
|
|
130
|
+
"forwarded",
|
|
131
|
+
"x-forwarded-for",
|
|
132
|
+
"x-forwarded-host",
|
|
133
|
+
"x-forwarded-proto",
|
|
134
|
+
"x-forwarded-port",
|
|
135
|
+
"x-forwarded-server",
|
|
136
|
+
"x-real-ip",
|
|
137
|
+
]);
|
|
138
|
+
const HOP_BY_HOP = new Set([
|
|
139
|
+
"connection",
|
|
140
|
+
"keep-alive",
|
|
141
|
+
"proxy-authenticate",
|
|
142
|
+
"proxy-authorization",
|
|
143
|
+
"te",
|
|
144
|
+
"trailer",
|
|
145
|
+
"transfer-encoding",
|
|
146
|
+
"upgrade",
|
|
147
|
+
]);
|
|
148
|
+
/**
|
|
149
|
+
* sanitizeHeaders drops the viewer's routing headers and every hop-by-hop
|
|
150
|
+
* header, and substitutes the client's own Host.
|
|
151
|
+
*/
|
|
152
|
+
export function sanitizeHeaders(headers, target) {
|
|
153
|
+
const out = [];
|
|
154
|
+
for (const [name, value] of headers) {
|
|
155
|
+
const lower = name.toLowerCase();
|
|
156
|
+
if (VIEWER_ROUTING_HEADERS.has(lower))
|
|
157
|
+
continue;
|
|
158
|
+
if (lower.startsWith("x-forwarded-"))
|
|
159
|
+
continue;
|
|
160
|
+
if (HOP_BY_HOP.has(lower))
|
|
161
|
+
continue;
|
|
162
|
+
out.push([name, value]);
|
|
163
|
+
}
|
|
164
|
+
// ITS OWN Host, never the viewer's.
|
|
165
|
+
out.push(["host", `${target.host}:${target.port}`]);
|
|
166
|
+
return out;
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* FOLLOW_REDIRECTS is false and there is no option to change it (rule 6). The
|
|
170
|
+
* BROWSER follows a redirect, on the viewer's own origin, where the viewer's
|
|
171
|
+
* own security context applies. A proxy that followed one on the creator's
|
|
172
|
+
* behalf would resolve a Location against the LOCAL machine.
|
|
173
|
+
*/
|
|
174
|
+
export const FOLLOW_REDIRECTS = false;
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The tunnel protocol's Node half.
|
|
3
|
+
*
|
|
4
|
+
* This is the SECOND implementation of one binary protocol, in a second
|
|
5
|
+
* language — the highest-risk contract in the product to date. Two green
|
|
6
|
+
* per-language suites do not prove a contract, so every limit and every
|
|
7
|
+
* refusal here is pinned against a SHARED CHECKED-IN FIXTURE that neither
|
|
8
|
+
* implementation generates (`fixtures/`), and the malformed fixture's row count
|
|
9
|
+
* is asserted in both languages: a row present in one runner and absent in the
|
|
10
|
+
* other must FAIL.
|
|
11
|
+
*
|
|
12
|
+
* A Go/Node divergence on what to REFUSE is exactly how one implementation
|
|
13
|
+
* becomes a bypass for the other's limits.
|
|
14
|
+
*
|
|
15
|
+
* As on the Go side, DATA IS OPAQUE. Nothing in this file reads a DATA payload,
|
|
16
|
+
* branches on it, or keeps it.
|
|
17
|
+
*/
|
|
18
|
+
export declare enum FrameType {
|
|
19
|
+
/** edge->client. payload: CBOR {viewerLabel, method, path, headers, upgrade?} */
|
|
20
|
+
OPEN = 1,
|
|
21
|
+
/** both. payload: OPAQUE bytes. Never parsed. */
|
|
22
|
+
DATA = 2,
|
|
23
|
+
/** both. half-close a stream (or, with a u24 payload, a credit replenishment) */
|
|
24
|
+
END = 3,
|
|
25
|
+
/** both. payload: u8 reason */
|
|
26
|
+
RESET = 4,
|
|
27
|
+
/** client->edge. payload: CBOR {status, headers} */
|
|
28
|
+
HEAD = 5,
|
|
29
|
+
/** both, streamId MUST be 0 */
|
|
30
|
+
PING = 16,
|
|
31
|
+
PONG = 17,
|
|
32
|
+
/** edge->client, streamId MUST be 0 */
|
|
33
|
+
CONTROL = 18
|
|
34
|
+
}
|
|
35
|
+
export declare enum ResetReason {
|
|
36
|
+
PROTOCOL_ERROR = 1,
|
|
37
|
+
STREAM_CLOSED = 2,
|
|
38
|
+
REFUSED = 3,
|
|
39
|
+
IDLE = 4,
|
|
40
|
+
SESSION_ENDED = 5,
|
|
41
|
+
UNKNOWN = 255
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* MAX_FRAME_LEN is checked against the HEADER, before any allocation. The
|
|
45
|
+
* `length` field is u24 — 16 MiB of attacker-controlled allocation per frame if
|
|
46
|
+
* a decoder does `Buffer.alloc(length)` before dispatch. It equals the initial
|
|
47
|
+
* credit window so a single frame can never exceed a stream's credit.
|
|
48
|
+
*/
|
|
49
|
+
export declare const MAX_FRAME_LEN: number;
|
|
50
|
+
/** The ONE decode path: OPEN/HEAD CBOR control payloads. Never DATA. */
|
|
51
|
+
export declare const MAX_OPEN_PAYLOAD: number;
|
|
52
|
+
export declare const FRAME_HEADER_LEN = 8;
|
|
53
|
+
export declare const MAX_HEADERS = 64;
|
|
54
|
+
export declare const MAX_HEADER_BYTES: number;
|
|
55
|
+
export declare const MAX_PATH_LEN: number;
|
|
56
|
+
export declare const MAX_CBOR_DEPTH = 4;
|
|
57
|
+
export declare const MAX_CONCURRENT_STREAMS = 256;
|
|
58
|
+
export declare const INITIAL_CREDIT: number;
|
|
59
|
+
export declare const RELAY_BUF_SIZE: number;
|
|
60
|
+
export declare const IDLE_STREAM_TIMEOUT_MS = 60000;
|
|
61
|
+
export declare const TUNNEL_PING_INTERVAL_MS = 15000;
|
|
62
|
+
export declare const TUNNEL_PING_TIMEOUT_MS = 45000;
|
|
63
|
+
/** Protocol errors. They are for the CLIENT's own log, never for a wire body. */
|
|
64
|
+
export declare class ProtocolError extends Error {
|
|
65
|
+
readonly code: ProtocolErrorCode;
|
|
66
|
+
readonly fatal: boolean;
|
|
67
|
+
/**
|
|
68
|
+
* `fatal` is the 2.15.3 behavior table expressed ONCE, so the Go edge and
|
|
69
|
+
* this client cannot drift on which violations close the tunnel and which
|
|
70
|
+
* only reset a stream.
|
|
71
|
+
*/
|
|
72
|
+
constructor(code: ProtocolErrorCode, fatal: boolean);
|
|
73
|
+
}
|
|
74
|
+
export type ProtocolErrorCode = "frame_too_large" | "unknown_frame_type" | "even_stream_id" | "zero_stream_id" | "nonzero_stream_id" | "short_payload" | "open_payload_too_large" | "cbor_malformed" | "cbor_depth" | "cbor_headers" | "cbor_header_bytes" | "cbor_path_len" | "cbor_status" | "cbor_viewer";
|
|
75
|
+
export interface FrameHeader {
|
|
76
|
+
type: FrameType;
|
|
77
|
+
streamId: number;
|
|
78
|
+
length: number;
|
|
79
|
+
}
|
|
80
|
+
export interface Frame extends FrameHeader {
|
|
81
|
+
payload: Uint8Array;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* validateFrameShape is the single place the 2.15.2 invariants live, shared by
|
|
85
|
+
* the encoder and the decoder so the two cannot disagree about what is legal.
|
|
86
|
+
*
|
|
87
|
+
* `fromEdge` selects the parity rule: a frame arriving FROM the edge may carry
|
|
88
|
+
* any id, but a frame this client WRITES must carry an odd one, because the
|
|
89
|
+
* edge assigns odd ids and the client never opens a stream. There is no path
|
|
90
|
+
* from the platform into this machine that this machine did not open.
|
|
91
|
+
*/
|
|
92
|
+
export declare function validateFrameShape(type: number, streamId: number, length: number, fromEdge: boolean): void;
|
|
93
|
+
/** encodeHeader writes the 8-byte header: type u8 | streamId u32 | length u24. */
|
|
94
|
+
export declare function encodeHeader(type: FrameType, streamId: number, length: number): Uint8Array;
|
|
95
|
+
/** encodeFrame serializes one whole frame. */
|
|
96
|
+
export declare function encodeFrame(type: FrameType, streamId: number, payload?: Uint8Array): Uint8Array;
|
|
97
|
+
/**
|
|
98
|
+
* decodeHeader reads and VALIDATES the header. Every limit the declared length
|
|
99
|
+
* can trip is checked here — this is the "before the byte that triggers them
|
|
100
|
+
* can allocate" boundary.
|
|
101
|
+
*/
|
|
102
|
+
export declare function decodeHeader(b: Uint8Array, fromEdge: boolean): FrameHeader;
|
|
103
|
+
/**
|
|
104
|
+
* FrameDecoder is a streaming demultiplexer over a byte stream.
|
|
105
|
+
*
|
|
106
|
+
* It NEVER sizes a buffer from a declared length before that length has passed
|
|
107
|
+
* decodeHeader — the accumulator only ever grows by bytes that actually
|
|
108
|
+
* arrived, so a frame declaring 16 MiB costs a comparison, not 16 MiB.
|
|
109
|
+
*/
|
|
110
|
+
export declare class FrameDecoder {
|
|
111
|
+
private readonly fromEdge;
|
|
112
|
+
private buf;
|
|
113
|
+
private header;
|
|
114
|
+
constructor(fromEdge: boolean);
|
|
115
|
+
/** push appends bytes and returns every complete frame they produced. */
|
|
116
|
+
push(chunk: Uint8Array): Frame[];
|
|
117
|
+
/**
|
|
118
|
+
* end reports whether the stream closed mid-frame. A declared length longer
|
|
119
|
+
* than the bytes that arrived is a violation, and THE PARTIAL IS DROPPED —
|
|
120
|
+
* never returned, never logged.
|
|
121
|
+
*/
|
|
122
|
+
end(): void;
|
|
123
|
+
}
|
|
124
|
+
/** encodeCredit/decodeCredit carry a u24 byte count on an END frame. */
|
|
125
|
+
export declare function encodeCredit(n: number): Uint8Array;
|
|
126
|
+
export declare function decodeCredit(b: Uint8Array): number;
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The tunnel protocol's Node half.
|
|
3
|
+
*
|
|
4
|
+
* This is the SECOND implementation of one binary protocol, in a second
|
|
5
|
+
* language — the highest-risk contract in the product to date. Two green
|
|
6
|
+
* per-language suites do not prove a contract, so every limit and every
|
|
7
|
+
* refusal here is pinned against a SHARED CHECKED-IN FIXTURE that neither
|
|
8
|
+
* implementation generates (`fixtures/`), and the malformed fixture's row count
|
|
9
|
+
* is asserted in both languages: a row present in one runner and absent in the
|
|
10
|
+
* other must FAIL.
|
|
11
|
+
*
|
|
12
|
+
* A Go/Node divergence on what to REFUSE is exactly how one implementation
|
|
13
|
+
* becomes a bypass for the other's limits.
|
|
14
|
+
*
|
|
15
|
+
* As on the Go side, DATA IS OPAQUE. Nothing in this file reads a DATA payload,
|
|
16
|
+
* branches on it, or keeps it.
|
|
17
|
+
*/
|
|
18
|
+
export var FrameType;
|
|
19
|
+
(function (FrameType) {
|
|
20
|
+
/** edge->client. payload: CBOR {viewerLabel, method, path, headers, upgrade?} */
|
|
21
|
+
FrameType[FrameType["OPEN"] = 1] = "OPEN";
|
|
22
|
+
/** both. payload: OPAQUE bytes. Never parsed. */
|
|
23
|
+
FrameType[FrameType["DATA"] = 2] = "DATA";
|
|
24
|
+
/** both. half-close a stream (or, with a u24 payload, a credit replenishment) */
|
|
25
|
+
FrameType[FrameType["END"] = 3] = "END";
|
|
26
|
+
/** both. payload: u8 reason */
|
|
27
|
+
FrameType[FrameType["RESET"] = 4] = "RESET";
|
|
28
|
+
/** client->edge. payload: CBOR {status, headers} */
|
|
29
|
+
FrameType[FrameType["HEAD"] = 5] = "HEAD";
|
|
30
|
+
/** both, streamId MUST be 0 */
|
|
31
|
+
FrameType[FrameType["PING"] = 16] = "PING";
|
|
32
|
+
FrameType[FrameType["PONG"] = 17] = "PONG";
|
|
33
|
+
/** edge->client, streamId MUST be 0 */
|
|
34
|
+
FrameType[FrameType["CONTROL"] = 18] = "CONTROL";
|
|
35
|
+
})(FrameType || (FrameType = {}));
|
|
36
|
+
export var ResetReason;
|
|
37
|
+
(function (ResetReason) {
|
|
38
|
+
ResetReason[ResetReason["PROTOCOL_ERROR"] = 1] = "PROTOCOL_ERROR";
|
|
39
|
+
ResetReason[ResetReason["STREAM_CLOSED"] = 2] = "STREAM_CLOSED";
|
|
40
|
+
ResetReason[ResetReason["REFUSED"] = 3] = "REFUSED";
|
|
41
|
+
ResetReason[ResetReason["IDLE"] = 4] = "IDLE";
|
|
42
|
+
ResetReason[ResetReason["SESSION_ENDED"] = 5] = "SESSION_ENDED";
|
|
43
|
+
ResetReason[ResetReason["UNKNOWN"] = 255] = "UNKNOWN";
|
|
44
|
+
})(ResetReason || (ResetReason = {}));
|
|
45
|
+
/**
|
|
46
|
+
* MAX_FRAME_LEN is checked against the HEADER, before any allocation. The
|
|
47
|
+
* `length` field is u24 — 16 MiB of attacker-controlled allocation per frame if
|
|
48
|
+
* a decoder does `Buffer.alloc(length)` before dispatch. It equals the initial
|
|
49
|
+
* credit window so a single frame can never exceed a stream's credit.
|
|
50
|
+
*/
|
|
51
|
+
export const MAX_FRAME_LEN = 64 * 1024;
|
|
52
|
+
/** The ONE decode path: OPEN/HEAD CBOR control payloads. Never DATA. */
|
|
53
|
+
export const MAX_OPEN_PAYLOAD = 4 * 1024;
|
|
54
|
+
export const FRAME_HEADER_LEN = 8;
|
|
55
|
+
export const MAX_HEADERS = 64;
|
|
56
|
+
export const MAX_HEADER_BYTES = 8 * 1024;
|
|
57
|
+
export const MAX_PATH_LEN = 2 * 1024;
|
|
58
|
+
export const MAX_CBOR_DEPTH = 4;
|
|
59
|
+
export const MAX_CONCURRENT_STREAMS = 256;
|
|
60
|
+
export const INITIAL_CREDIT = 64 * 1024;
|
|
61
|
+
export const RELAY_BUF_SIZE = 32 * 1024;
|
|
62
|
+
export const IDLE_STREAM_TIMEOUT_MS = 60_000;
|
|
63
|
+
export const TUNNEL_PING_INTERVAL_MS = 15_000;
|
|
64
|
+
export const TUNNEL_PING_TIMEOUT_MS = 45_000;
|
|
65
|
+
/** Protocol errors. They are for the CLIENT's own log, never for a wire body. */
|
|
66
|
+
export class ProtocolError extends Error {
|
|
67
|
+
code;
|
|
68
|
+
fatal;
|
|
69
|
+
/**
|
|
70
|
+
* `fatal` is the 2.15.3 behavior table expressed ONCE, so the Go edge and
|
|
71
|
+
* this client cannot drift on which violations close the tunnel and which
|
|
72
|
+
* only reset a stream.
|
|
73
|
+
*/
|
|
74
|
+
constructor(code, fatal) {
|
|
75
|
+
// NOTE: the message is the CODE and nothing else. It never carries a
|
|
76
|
+
// payload, a length echo, or an offset — a payload in an error string
|
|
77
|
+
// reaches the process log and any error-reporting integration.
|
|
78
|
+
super(code);
|
|
79
|
+
this.code = code;
|
|
80
|
+
this.fatal = fatal;
|
|
81
|
+
this.name = "ProtocolError";
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/** The fatal set, matching Go's TunnelFatal exactly. */
|
|
85
|
+
const FATAL = new Set([
|
|
86
|
+
"frame_too_large",
|
|
87
|
+
"unknown_frame_type",
|
|
88
|
+
"even_stream_id",
|
|
89
|
+
"zero_stream_id",
|
|
90
|
+
"nonzero_stream_id",
|
|
91
|
+
"short_payload",
|
|
92
|
+
]);
|
|
93
|
+
function fail(code) {
|
|
94
|
+
throw new ProtocolError(code, FATAL.has(code));
|
|
95
|
+
}
|
|
96
|
+
const KNOWN_TYPES = new Set([
|
|
97
|
+
FrameType.OPEN,
|
|
98
|
+
FrameType.DATA,
|
|
99
|
+
FrameType.END,
|
|
100
|
+
FrameType.RESET,
|
|
101
|
+
FrameType.HEAD,
|
|
102
|
+
FrameType.PING,
|
|
103
|
+
FrameType.PONG,
|
|
104
|
+
FrameType.CONTROL,
|
|
105
|
+
]);
|
|
106
|
+
/** streamTyped frames address a STREAM and therefore require a non-zero id. */
|
|
107
|
+
function streamTyped(t) {
|
|
108
|
+
return (t === FrameType.OPEN ||
|
|
109
|
+
t === FrameType.DATA ||
|
|
110
|
+
t === FrameType.END ||
|
|
111
|
+
t === FrameType.RESET ||
|
|
112
|
+
t === FrameType.HEAD);
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* validateFrameShape is the single place the 2.15.2 invariants live, shared by
|
|
116
|
+
* the encoder and the decoder so the two cannot disagree about what is legal.
|
|
117
|
+
*
|
|
118
|
+
* `fromEdge` selects the parity rule: a frame arriving FROM the edge may carry
|
|
119
|
+
* any id, but a frame this client WRITES must carry an odd one, because the
|
|
120
|
+
* edge assigns odd ids and the client never opens a stream. There is no path
|
|
121
|
+
* from the platform into this machine that this machine did not open.
|
|
122
|
+
*/
|
|
123
|
+
export function validateFrameShape(type, streamId, length, fromEdge) {
|
|
124
|
+
// FIRST. Nothing downstream may allocate from a length that failed here.
|
|
125
|
+
if (length > MAX_FRAME_LEN)
|
|
126
|
+
fail("frame_too_large");
|
|
127
|
+
if (!KNOWN_TYPES.has(type)) {
|
|
128
|
+
// NOT ignored. "Ignore unknown for forward-compat" is explicitly REJECTED:
|
|
129
|
+
// this is a closed protocol between two implementations we both own, and
|
|
130
|
+
// tolerance here would be a place to smuggle bytes.
|
|
131
|
+
fail("unknown_frame_type");
|
|
132
|
+
}
|
|
133
|
+
if (streamTyped(type)) {
|
|
134
|
+
if (streamId === 0)
|
|
135
|
+
fail("zero_stream_id");
|
|
136
|
+
if (!fromEdge && streamId % 2 === 0)
|
|
137
|
+
fail("even_stream_id");
|
|
138
|
+
}
|
|
139
|
+
else if (streamId !== 0) {
|
|
140
|
+
fail("nonzero_stream_id");
|
|
141
|
+
}
|
|
142
|
+
if ((type === FrameType.OPEN || type === FrameType.HEAD) && length > MAX_OPEN_PAYLOAD) {
|
|
143
|
+
// Refused BEFORE any CBOR decode is attempted.
|
|
144
|
+
fail("open_payload_too_large");
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
/** encodeHeader writes the 8-byte header: type u8 | streamId u32 | length u24. */
|
|
148
|
+
export function encodeHeader(type, streamId, length) {
|
|
149
|
+
if (length < 0 || length > MAX_FRAME_LEN)
|
|
150
|
+
fail("frame_too_large");
|
|
151
|
+
const h = new Uint8Array(FRAME_HEADER_LEN);
|
|
152
|
+
h[0] = type;
|
|
153
|
+
h[1] = (streamId >>> 24) & 0xff;
|
|
154
|
+
h[2] = (streamId >>> 16) & 0xff;
|
|
155
|
+
h[3] = (streamId >>> 8) & 0xff;
|
|
156
|
+
h[4] = streamId & 0xff;
|
|
157
|
+
h[5] = (length >>> 16) & 0xff;
|
|
158
|
+
h[6] = (length >>> 8) & 0xff;
|
|
159
|
+
h[7] = length & 0xff;
|
|
160
|
+
return h;
|
|
161
|
+
}
|
|
162
|
+
/** encodeFrame serializes one whole frame. */
|
|
163
|
+
export function encodeFrame(type, streamId, payload = new Uint8Array(0)) {
|
|
164
|
+
validateFrameShape(type, streamId, payload.length, false);
|
|
165
|
+
const out = new Uint8Array(FRAME_HEADER_LEN + payload.length);
|
|
166
|
+
out.set(encodeHeader(type, streamId, payload.length), 0);
|
|
167
|
+
out.set(payload, FRAME_HEADER_LEN);
|
|
168
|
+
return out;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* decodeHeader reads and VALIDATES the header. Every limit the declared length
|
|
172
|
+
* can trip is checked here — this is the "before the byte that triggers them
|
|
173
|
+
* can allocate" boundary.
|
|
174
|
+
*/
|
|
175
|
+
export function decodeHeader(b, fromEdge) {
|
|
176
|
+
if (b.length < FRAME_HEADER_LEN)
|
|
177
|
+
fail("short_payload");
|
|
178
|
+
const type = b[0];
|
|
179
|
+
const streamId = ((b[1] << 24) >>> 0) + (b[2] << 16) + (b[3] << 8) + b[4];
|
|
180
|
+
const length = (b[5] << 16) + (b[6] << 8) + b[7];
|
|
181
|
+
validateFrameShape(type, streamId, length, fromEdge);
|
|
182
|
+
return { type: type, streamId, length };
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* FrameDecoder is a streaming demultiplexer over a byte stream.
|
|
186
|
+
*
|
|
187
|
+
* It NEVER sizes a buffer from a declared length before that length has passed
|
|
188
|
+
* decodeHeader — the accumulator only ever grows by bytes that actually
|
|
189
|
+
* arrived, so a frame declaring 16 MiB costs a comparison, not 16 MiB.
|
|
190
|
+
*/
|
|
191
|
+
export class FrameDecoder {
|
|
192
|
+
fromEdge;
|
|
193
|
+
buf = new Uint8Array(0);
|
|
194
|
+
header = null;
|
|
195
|
+
constructor(fromEdge) {
|
|
196
|
+
this.fromEdge = fromEdge;
|
|
197
|
+
}
|
|
198
|
+
/** push appends bytes and returns every complete frame they produced. */
|
|
199
|
+
push(chunk) {
|
|
200
|
+
// The accumulator is bounded by one header plus one max-size frame. It can
|
|
201
|
+
// never hold a declared length, only bytes that arrived.
|
|
202
|
+
const next = new Uint8Array(this.buf.length + chunk.length);
|
|
203
|
+
next.set(this.buf, 0);
|
|
204
|
+
next.set(chunk, this.buf.length);
|
|
205
|
+
this.buf = next;
|
|
206
|
+
const out = [];
|
|
207
|
+
for (;;) {
|
|
208
|
+
if (this.header === null) {
|
|
209
|
+
if (this.buf.length < FRAME_HEADER_LEN)
|
|
210
|
+
return out;
|
|
211
|
+
// Throws on any 2.15.2 violation, BEFORE the payload is read at all.
|
|
212
|
+
this.header = decodeHeader(this.buf, this.fromEdge);
|
|
213
|
+
this.buf = this.buf.subarray(FRAME_HEADER_LEN);
|
|
214
|
+
}
|
|
215
|
+
if (this.buf.length < this.header.length)
|
|
216
|
+
return out;
|
|
217
|
+
const payload = this.buf.subarray(0, this.header.length);
|
|
218
|
+
this.buf = this.buf.subarray(this.header.length);
|
|
219
|
+
out.push({ ...this.header, payload });
|
|
220
|
+
this.header = null;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* end reports whether the stream closed mid-frame. A declared length longer
|
|
225
|
+
* than the bytes that arrived is a violation, and THE PARTIAL IS DROPPED —
|
|
226
|
+
* never returned, never logged.
|
|
227
|
+
*/
|
|
228
|
+
end() {
|
|
229
|
+
if (this.header !== null || this.buf.length > 0) {
|
|
230
|
+
this.buf = new Uint8Array(0);
|
|
231
|
+
this.header = null;
|
|
232
|
+
fail("short_payload");
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
/** encodeCredit/decodeCredit carry a u24 byte count on an END frame. */
|
|
237
|
+
export function encodeCredit(n) {
|
|
238
|
+
return new Uint8Array([(n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff]);
|
|
239
|
+
}
|
|
240
|
+
export function decodeCredit(b) {
|
|
241
|
+
if (b.length !== 3)
|
|
242
|
+
return 0;
|
|
243
|
+
return (b[0] << 16) + (b[1] << 8) + b[2];
|
|
244
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@vincentt-xr/harness/tunnel` — the Node half of the tunnel protocol.
|
|
3
|
+
*
|
|
4
|
+
* It is a SUBPATH EXPORT rather than part of the package root deliberately: the
|
|
5
|
+
* root entry is the browser-side diagnostics provider and must stay free of
|
|
6
|
+
* `node:` imports. Nothing under `src/client` imports anything here.
|
|
7
|
+
*/
|
|
8
|
+
export { FrameDecoder, FrameType, ProtocolError, ResetReason, FRAME_HEADER_LEN, IDLE_STREAM_TIMEOUT_MS, INITIAL_CREDIT, MAX_CBOR_DEPTH, MAX_CONCURRENT_STREAMS, MAX_FRAME_LEN, MAX_HEADERS, MAX_HEADER_BYTES, MAX_OPEN_PAYLOAD, MAX_PATH_LEN, RELAY_BUF_SIZE, TUNNEL_PING_INTERVAL_MS, TUNNEL_PING_TIMEOUT_MS, decodeCredit, decodeHeader, encodeCredit, encodeFrame, encodeHeader, validateFrameShape, type Frame, type FrameHeader, type ProtocolErrorCode, } from "./frame.js";
|
|
9
|
+
export { UA_CLASSES, VIEWER_LABEL_RE, decodeControl, decodeHead, decodeOpen, encodeControl, encodeHead, encodeOpen, parseUAClass, parseViewerLabel, type ControlKind, type ControlPayload, type HeadPayload, type OpenPayload, type UAClass, } from "./cbor.js";
|
|
10
|
+
export { FOLLOW_REDIRECTS, ForwardTargetError, assertStillLoopback, normalizeRequestTarget, resolveForwardTarget, sanitizeHeaders, type ForwardTarget, type ForwardTargetErrorCode, } from "./forwardTarget.js";
|
|
11
|
+
export { TunnelClient, RESUME_HEADER, RESUME_TOKEN_LEN, UA_CLASS_STAMP_HEADER, VIEWER_STAMP_HEADER, parseResumeToken, type TunnelClientOptions, type TunnelState, } from "./client.js";
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@vincentt-xr/harness/tunnel` — the Node half of the tunnel protocol.
|
|
3
|
+
*
|
|
4
|
+
* It is a SUBPATH EXPORT rather than part of the package root deliberately: the
|
|
5
|
+
* root entry is the browser-side diagnostics provider and must stay free of
|
|
6
|
+
* `node:` imports. Nothing under `src/client` imports anything here.
|
|
7
|
+
*/
|
|
8
|
+
export { FrameDecoder, FrameType, ProtocolError, ResetReason, FRAME_HEADER_LEN, IDLE_STREAM_TIMEOUT_MS, INITIAL_CREDIT, MAX_CBOR_DEPTH, MAX_CONCURRENT_STREAMS, MAX_FRAME_LEN, MAX_HEADERS, MAX_HEADER_BYTES, MAX_OPEN_PAYLOAD, MAX_PATH_LEN, RELAY_BUF_SIZE, TUNNEL_PING_INTERVAL_MS, TUNNEL_PING_TIMEOUT_MS, decodeCredit, decodeHeader, encodeCredit, encodeFrame, encodeHeader, validateFrameShape, } from "./frame.js";
|
|
9
|
+
export { UA_CLASSES, VIEWER_LABEL_RE, decodeControl, decodeHead, decodeOpen, encodeControl, encodeHead, encodeOpen, parseUAClass, parseViewerLabel, } from "./cbor.js";
|
|
10
|
+
export { FOLLOW_REDIRECTS, ForwardTargetError, assertStillLoopback, normalizeRequestTarget, resolveForwardTarget, sanitizeHeaders, } from "./forwardTarget.js";
|
|
11
|
+
export { TunnelClient, RESUME_HEADER, RESUME_TOKEN_LEN, UA_CLASS_STAMP_HEADER, VIEWER_STAMP_HEADER, parseResumeToken, } from "./client.js";
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vincentt-xr/harness",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Vincentt AR dev-loop harness
|
|
3
|
+
"version": "1.2.0",
|
|
4
|
+
"description": "Vincentt AR dev-loop harness - in-app diagnostics provider + wire contract",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"sideEffects": false,
|
|
@@ -19,13 +19,25 @@
|
|
|
19
19
|
"./events": {
|
|
20
20
|
"types": "./dist/shared/events.d.ts",
|
|
21
21
|
"import": "./dist/shared/events.js"
|
|
22
|
+
},
|
|
23
|
+
"./tunnel": {
|
|
24
|
+
"types": "./dist/tunnel/index.d.ts",
|
|
25
|
+
"import": "./dist/tunnel/index.js"
|
|
22
26
|
}
|
|
23
27
|
},
|
|
24
28
|
"peerDependencies": {
|
|
25
29
|
"react": "^18.2.0"
|
|
26
30
|
},
|
|
27
31
|
"devDependencies": {
|
|
28
|
-
"@types/react": "^18.3.12"
|
|
32
|
+
"@types/react": "^18.3.12",
|
|
33
|
+
"@types/ws": "^8.5.13",
|
|
34
|
+
"@types/node": "^22.9.0",
|
|
35
|
+
"@types/qrcode": "^1.5.5",
|
|
36
|
+
"jsdom": "^25.0.1"
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"ws": "^8.18.0",
|
|
40
|
+
"qrcode": "^1.5.4"
|
|
29
41
|
},
|
|
30
42
|
"scripts": {
|
|
31
43
|
"build": "tsc -p tsconfig.build.json",
|