@hisoka-io/anon-rpc-worker 0.2.0 → 0.4.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/CHANGELOG.md +51 -0
- package/README.md +120 -15
- package/dist/anon-rpc-worker.js +5955 -3361
- package/dist/anon-rpc-worker.js.keccak256 +1 -1
- package/dist/anon-rpc-worker.provenance.json +19 -8
- package/package.json +2 -2
- package/snapshot/capabilities.json +7 -4
- package/snapshot/nox-snapshot.json +6 -3
- package/snapshot/nox-snapshot.json.keccak256 +1 -1
- package/src/config.ts +77 -0
- package/src/core.ts +96 -4
- package/src/embed-modules.d.ts +16 -0
- package/src/errors.ts +7 -1
- package/src/fetch-map.ts +109 -13
- package/src/local-answers.ts +238 -0
- package/src/tls/channel.ts +183 -0
- package/src/tls/module.ts +98 -0
- package/src/tls/pool.ts +358 -0
- package/src/tls/transport.ts +257 -0
- package/src/tls/tunnel.ts +360 -0
- package/src/worker.ts +12 -4
package/src/fetch-map.ts
CHANGED
|
@@ -27,11 +27,36 @@ export interface NoxHttpPort {
|
|
|
27
27
|
): Promise<Uint8Array>;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
/** TLS tunnels for fetch hops (src/tls/transport.ts). */
|
|
31
|
+
export interface TunnelHop {
|
|
32
|
+
/** `"tunnel"` or `"http"` for this hop; throws the call's rejection when the hop may not leave. */
|
|
33
|
+
route(request: PreparedRequest, hop: number, startedOnTunnel: boolean): "tunnel" | "http";
|
|
34
|
+
exchange(request: PreparedRequest, budget: CallBudget): Promise<DecodedHttpResponse>;
|
|
35
|
+
}
|
|
36
|
+
|
|
30
37
|
/** Limits from the worker config. */
|
|
31
38
|
export interface FetchSettings {
|
|
32
39
|
readonly attemptTimeoutMs: number;
|
|
33
40
|
readonly maxRequestBytes: number;
|
|
34
41
|
readonly maxResponseBytes: number;
|
|
42
|
+
/**
|
|
43
|
+
* `accept-encoding` sent upstream. Default: `gzip` where the runtime has
|
|
44
|
+
* `DecompressionStream`, else `identity`.
|
|
45
|
+
*/
|
|
46
|
+
readonly acceptEncoding?: ReplyEncoding;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Reply encodings the worker can ask upstreams for. */
|
|
50
|
+
export type ReplyEncoding = "gzip" | "identity";
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* `gzip` when this runtime can inflate replies itself. Exits pass bodies
|
|
54
|
+
* through as the upstream sent them (their HTTP client has no decompression),
|
|
55
|
+
* so a gzip reply crosses the mixnet compressed: a 414 KB `eth_getLogs` reply
|
|
56
|
+
* takes about 2 reply packets instead of 14.
|
|
57
|
+
*/
|
|
58
|
+
export function defaultReplyEncoding(): ReplyEncoding {
|
|
59
|
+
return typeof DecompressionStream === "function" ? "gzip" : "identity";
|
|
35
60
|
}
|
|
36
61
|
|
|
37
62
|
/** A validated request, ready for the mixnet. */
|
|
@@ -74,7 +99,7 @@ const CROSS_ORIGIN_DROPPED_HEADERS = new Set(["authorization", "cookie"]);
|
|
|
74
99
|
* Request headers never forwarded: the exit drops `host` and `user-agent`
|
|
75
100
|
* itself, framing and hop-by-hop fields belong to the exit's own connection,
|
|
76
101
|
* and cookies, origin and referer would tie calls together or to a page.
|
|
77
|
-
* `accept-encoding` is replaced (see `
|
|
102
|
+
* `accept-encoding` is replaced (see `defaultReplyEncoding`).
|
|
78
103
|
*/
|
|
79
104
|
const DROPPED_REQUEST_HEADERS = new Set([
|
|
80
105
|
"host",
|
|
@@ -93,8 +118,8 @@ const DROPPED_REQUEST_HEADERS = new Set([
|
|
|
93
118
|
"accept-encoding",
|
|
94
119
|
]);
|
|
95
120
|
|
|
96
|
-
/**
|
|
97
|
-
const
|
|
121
|
+
/** Content codings the worker inflates. */
|
|
122
|
+
const GZIP_CODINGS: ReadonlySet<string> = new Set(["gzip", "x-gzip"]);
|
|
98
123
|
|
|
99
124
|
/** Response fields that describe the exit's hop, or that a worker must not hand on. */
|
|
100
125
|
const DROPPED_RESPONSE_HEADERS = new Set([
|
|
@@ -126,7 +151,7 @@ export async function prepareRequest(
|
|
|
126
151
|
if (redirect !== "follow" && redirect !== "manual" && redirect !== "error") {
|
|
127
152
|
throw callError(CALL_CODES.unsupported, 'redirect must be "follow", "manual" or "error"');
|
|
128
153
|
}
|
|
129
|
-
const headers = prepareHeaders(init?.headers);
|
|
154
|
+
const headers = prepareHeaders(init?.headers, settings.acceptEncoding ?? defaultReplyEncoding());
|
|
130
155
|
const body = await readBody(init?.body, settings.maxRequestBytes, signal);
|
|
131
156
|
if ((method === "GET" || method === "HEAD") && body.length > 0) {
|
|
132
157
|
throw callError(CALL_CODES.unsupported, `A ${method} request cannot carry a body`);
|
|
@@ -167,20 +192,30 @@ export interface CallBudget {
|
|
|
167
192
|
* need time and writes are never resent (ARCHITECTURE §4.7). The SDK still
|
|
168
193
|
* resends any request whose packet was certainly never sent (a KPS dial or
|
|
169
194
|
* stream-open failure) through another entry.
|
|
195
|
+
*
|
|
196
|
+
* With `tunnels`, each hop first asks it for its transport: a hop that goes
|
|
197
|
+
* through a TLS tunnel is exchanged there, and once one hop did, every later
|
|
198
|
+
* hop must too.
|
|
170
199
|
*/
|
|
171
200
|
export async function sendPrepared(
|
|
172
201
|
request: PreparedRequest,
|
|
173
202
|
port: NoxHttpPort,
|
|
174
203
|
settings: FetchSettings,
|
|
175
204
|
budget: CallBudget,
|
|
205
|
+
tunnels?: TunnelHop,
|
|
176
206
|
): Promise<AnonFetchResponse> {
|
|
177
207
|
if (budget.remainingMs() <= 0) throw callError(CALL_CODES.timeout, "The call deadline passed before sending");
|
|
178
208
|
let current = request;
|
|
209
|
+
let onTunnel = false;
|
|
179
210
|
for (let hop = 0; ; hop++) {
|
|
180
|
-
const
|
|
211
|
+
const via = tunnels?.route(current, hop, onTunnel) ?? "http";
|
|
212
|
+
const reply = tunnels !== undefined && via === "tunnel"
|
|
213
|
+
? await tunnels.exchange(current, budget)
|
|
214
|
+
: await exchange(current, port, settings, budget);
|
|
215
|
+
if (via === "tunnel") onTunnel = true;
|
|
181
216
|
checkStatus(reply);
|
|
182
217
|
if (!REDIRECT_STATUSES.has(reply.status) || current.redirect === "manual") {
|
|
183
|
-
return
|
|
218
|
+
return finishResponse(reply, current, settings);
|
|
184
219
|
}
|
|
185
220
|
if (current.redirect === "error") {
|
|
186
221
|
throw callError(
|
|
@@ -190,9 +225,9 @@ export async function sendPrepared(
|
|
|
190
225
|
}
|
|
191
226
|
const location = reply.headers.find(([name]) => name.toLowerCase() === "location")?.[1];
|
|
192
227
|
// fetch returns a redirect status without a Location as an ordinary response.
|
|
193
|
-
if (location === undefined) return
|
|
228
|
+
if (location === undefined) return finishResponse(reply, current, settings);
|
|
194
229
|
const next = redirectedRequest(current, reply.status, location);
|
|
195
|
-
if (next === null) return
|
|
230
|
+
if (next === null) return finishResponse(reply, current, settings);
|
|
196
231
|
if (hop + 1 > MAX_REDIRECT_HOPS) {
|
|
197
232
|
throw callError(CALL_CODES.networkError, `Too many redirects: more than ${MAX_REDIRECT_HOPS} hops`);
|
|
198
233
|
}
|
|
@@ -290,6 +325,67 @@ function checkStatus(reply: DecodedHttpResponse): void {
|
|
|
290
325
|
}
|
|
291
326
|
}
|
|
292
327
|
|
|
328
|
+
/** Inflate a gzip reply (see `inflateReply`), then map it with `toAnonResponse`. */
|
|
329
|
+
export async function finishResponse(
|
|
330
|
+
reply: DecodedHttpResponse,
|
|
331
|
+
request: Pick<PreparedRequest, "url">,
|
|
332
|
+
settings: Pick<FetchSettings, "maxResponseBytes">,
|
|
333
|
+
): Promise<AnonFetchResponse> {
|
|
334
|
+
checkStatus(reply);
|
|
335
|
+
if (reply.truncated) {
|
|
336
|
+
throw callError(CALL_CODES.tooLarge, "The exit cut the response body at its response size limit");
|
|
337
|
+
}
|
|
338
|
+
return toAnonResponse(await inflateReply(reply, settings.maxResponseBytes), request, settings);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* The reply with a `gzip` body inflated and its `content-encoding` removed;
|
|
343
|
+
* any other reply unchanged. Inflation stops past `maxBytes` (`too-large`),
|
|
344
|
+
* so a small compressed reply cannot expand without bound; a corrupt body is
|
|
345
|
+
* a `protocol-error`.
|
|
346
|
+
*/
|
|
347
|
+
export async function inflateReply(reply: DecodedHttpResponse, maxBytes: number): Promise<DecodedHttpResponse> {
|
|
348
|
+
const coding = reply.headers.find(([name]) => name.toLowerCase() === "content-encoding")?.[1]?.trim().toLowerCase();
|
|
349
|
+
if (coding === undefined || !GZIP_CODINGS.has(coding)) return reply;
|
|
350
|
+
const headers = reply.headers.filter(([name]) => name.toLowerCase() !== "content-encoding");
|
|
351
|
+
if (reply.body.length === 0) return { ...reply, headers };
|
|
352
|
+
if (typeof DecompressionStream !== "function") {
|
|
353
|
+
throw callError(CALL_CODES.protocolError, "The upstream sent a gzip body and this runtime cannot inflate it");
|
|
354
|
+
}
|
|
355
|
+
const source = reply.body;
|
|
356
|
+
const inflated = new ReadableStream<Uint8Array>({
|
|
357
|
+
start(controller) {
|
|
358
|
+
controller.enqueue(source);
|
|
359
|
+
controller.close();
|
|
360
|
+
},
|
|
361
|
+
}).pipeThrough(new DecompressionStream("gzip") as unknown as TransformStream<Uint8Array, Uint8Array>);
|
|
362
|
+
const reader = inflated.getReader();
|
|
363
|
+
const chunks: Uint8Array[] = [];
|
|
364
|
+
let total = 0;
|
|
365
|
+
for (;;) {
|
|
366
|
+
let result: Awaited<ReturnType<typeof reader.read>>;
|
|
367
|
+
try {
|
|
368
|
+
result = await reader.read();
|
|
369
|
+
} catch (error) {
|
|
370
|
+
throw callError(CALL_CODES.protocolError, "The upstream's gzip body is corrupt", error);
|
|
371
|
+
}
|
|
372
|
+
if (result.done) break;
|
|
373
|
+
total += result.value.length;
|
|
374
|
+
if (total > maxBytes) {
|
|
375
|
+
void reader.cancel().catch(() => undefined);
|
|
376
|
+
throw callError(CALL_CODES.tooLarge, `The response body inflates past ${maxBytes} bytes`);
|
|
377
|
+
}
|
|
378
|
+
chunks.push(result.value);
|
|
379
|
+
}
|
|
380
|
+
const body = new Uint8Array(total);
|
|
381
|
+
let offset = 0;
|
|
382
|
+
for (const chunk of chunks) {
|
|
383
|
+
body.set(chunk, offset);
|
|
384
|
+
offset += chunk.length;
|
|
385
|
+
}
|
|
386
|
+
return { ...reply, headers, body };
|
|
387
|
+
}
|
|
388
|
+
|
|
293
389
|
/** Map the exit's decoded reply to an `AnonFetchResponse`; `request.url` becomes `url`. */
|
|
294
390
|
export function toAnonResponse(
|
|
295
391
|
reply: DecodedHttpResponse,
|
|
@@ -312,7 +408,7 @@ export function toAnonResponse(
|
|
|
312
408
|
if (lower === "content-encoding" && value.trim().toLowerCase() !== "identity") {
|
|
313
409
|
throw callError(
|
|
314
410
|
CALL_CODES.protocolError,
|
|
315
|
-
|
|
411
|
+
`The upstream sent a ${value.trim()} body; this worker accepts identity and gzip`,
|
|
316
412
|
);
|
|
317
413
|
}
|
|
318
414
|
if (DROPPED_RESPONSE_HEADERS.has(lower) || !TOKEN_RE.test(name) || !FIELD_VALUE_RE.test(value)) continue;
|
|
@@ -403,9 +499,9 @@ function normalizeMethod(method: unknown): string {
|
|
|
403
499
|
return NORMALIZED_METHODS.has(upper) ? upper : method;
|
|
404
500
|
}
|
|
405
501
|
|
|
406
|
-
/** Keep order and duplicates (the wire type is a list), drop
|
|
407
|
-
function prepareHeaders(list: HeaderList | undefined): [string, string][] {
|
|
408
|
-
if (list === undefined) return
|
|
502
|
+
/** Keep order and duplicates (the wire type is a list), drop per policy, then set `accept-encoding`. */
|
|
503
|
+
function prepareHeaders(list: HeaderList | undefined, encoding: ReplyEncoding): [string, string][] {
|
|
504
|
+
if (list === undefined) return [["accept-encoding", encoding]];
|
|
409
505
|
if (!Array.isArray(list)) throw callError(CALL_CODES.unsupported, "headers must be a list of [name, value] pairs");
|
|
410
506
|
const out: [string, string][] = [];
|
|
411
507
|
let bytes = 0;
|
|
@@ -427,7 +523,7 @@ function prepareHeaders(list: HeaderList | undefined): [string, string][] {
|
|
|
427
523
|
}
|
|
428
524
|
out.push([name, value]);
|
|
429
525
|
}
|
|
430
|
-
|
|
526
|
+
out.push(["accept-encoding", encoding]);
|
|
431
527
|
return out;
|
|
432
528
|
}
|
|
433
529
|
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Worker-local answers: JSON-RPC calls whose answer
|
|
3
|
+
* cannot change are answered from memory instead of crossing the mixnet
|
|
4
|
+
* again. Fewer queries leave the device, and a hit costs no round trip.
|
|
5
|
+
*
|
|
6
|
+
* What is answered locally, per upstream URL and exact params:
|
|
7
|
+
*
|
|
8
|
+
* - `eth_chainId` and `net_version`: memoised once a second, independently
|
|
9
|
+
* routed request through the mixnet returned the same result (the worker
|
|
10
|
+
* sends that check in the background after the first answer).
|
|
11
|
+
* - Only with `blockHashReads` (off by default, because an answer is not yet
|
|
12
|
+
* checked against the block hash): reads addressed by block hash, whose
|
|
13
|
+
* result is fixed by the hash:
|
|
14
|
+
* `eth_getBlockByHash`, `eth_getBlockReceipts`, `eth_getLogs` with a
|
|
15
|
+
* `blockHash` filter, the `…ByBlockHash…` lookups, and EIP-1898 state reads
|
|
16
|
+
* (`eth_call`, `eth_getBalance`, `eth_getCode`, `eth_getStorageAt`,
|
|
17
|
+
* `eth_getTransactionCount`, `eth_getProof`) whose block is
|
|
18
|
+
* `{"blockHash": …}` without `requireCanonical`. They are kept once two
|
|
19
|
+
* separate calls returned the same result. Each call takes its own route,
|
|
20
|
+
* so a wrong answer is kept only when both calls went through exits that
|
|
21
|
+
* returned the same wrong result.
|
|
22
|
+
*
|
|
23
|
+
* Only single calls (not batches) with a `result` that is not null are kept;
|
|
24
|
+
* errors never are, and neither is an empty list for a block-hash read: an
|
|
25
|
+
* upstream that has not seen the block yet may answer `[]` for its logs or
|
|
26
|
+
* receipts, and a later call returns the full list. A request with `Cache-Control: no-cache` or `no-store`
|
|
27
|
+
* always goes through the mixnet. The reply carries the caller's own
|
|
28
|
+
* JSON-RPC `id`. Memory is bounded by entry count and bytes (oldest out).
|
|
29
|
+
*/
|
|
30
|
+
import type { AnonFetchResponse, HeaderList } from "./spec-types.js";
|
|
31
|
+
import type { PreparedRequest } from "./fetch-map.js";
|
|
32
|
+
|
|
33
|
+
/** Most answers kept. */
|
|
34
|
+
export const LOCAL_ANSWER_MAX_ENTRIES = 512;
|
|
35
|
+
/** Most result bytes kept in total. */
|
|
36
|
+
export const LOCAL_ANSWER_MAX_BYTES = 4 * 1024 * 1024;
|
|
37
|
+
/** Largest single result kept. */
|
|
38
|
+
export const LOCAL_ANSWER_MAX_ENTRY_BYTES = 512 * 1024;
|
|
39
|
+
/** Matching answers needed before a content-addressed result is served locally. */
|
|
40
|
+
export const LOCAL_ANSWER_CONFIRMATIONS = 2;
|
|
41
|
+
|
|
42
|
+
const MEMO_METHODS: ReadonlySet<string> = new Set(["eth_chainId", "net_version"]);
|
|
43
|
+
|
|
44
|
+
/** Methods whose first param is a block hash. */
|
|
45
|
+
const BY_BLOCK_HASH: ReadonlySet<string> = new Set([
|
|
46
|
+
"eth_getBlockByHash",
|
|
47
|
+
"eth_getBlockTransactionCountByHash",
|
|
48
|
+
"eth_getTransactionByBlockHashAndIndex",
|
|
49
|
+
"eth_getUncleByBlockHashAndIndex",
|
|
50
|
+
"eth_getUncleCountByBlockHash",
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
/** EIP-1898 state reads: the block selector is the last param. */
|
|
54
|
+
const STATE_READS: ReadonlySet<string> = new Set([
|
|
55
|
+
"eth_call",
|
|
56
|
+
"eth_getBalance",
|
|
57
|
+
"eth_getCode",
|
|
58
|
+
"eth_getStorageAt",
|
|
59
|
+
"eth_getTransactionCount",
|
|
60
|
+
"eth_getProof",
|
|
61
|
+
]);
|
|
62
|
+
|
|
63
|
+
const HASH_RE = /^0x[0-9a-fA-F]{64}$/u;
|
|
64
|
+
|
|
65
|
+
/** Why a call was or was not answered locally (for debug logs). */
|
|
66
|
+
export type LocalKind = "memo" | "block-hash";
|
|
67
|
+
|
|
68
|
+
interface Entry {
|
|
69
|
+
readonly kind: LocalKind;
|
|
70
|
+
/** The JSON text of `result`. */
|
|
71
|
+
readonly result: string;
|
|
72
|
+
readonly headers: HeaderList;
|
|
73
|
+
confirmations: number;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** A single JSON-RPC call read from a prepared request. */
|
|
77
|
+
interface Call {
|
|
78
|
+
readonly key: string;
|
|
79
|
+
readonly kind: LocalKind;
|
|
80
|
+
readonly id: unknown;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
84
|
+
const encoder = new TextEncoder();
|
|
85
|
+
|
|
86
|
+
/** Options for {@link LocalAnswers}. */
|
|
87
|
+
export interface LocalAnswersOptions {
|
|
88
|
+
/** Also keep reads addressed by block hash (see above). Default false. */
|
|
89
|
+
readonly blockHashReads?: boolean;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export class LocalAnswers {
|
|
93
|
+
private readonly entries = new Map<string, Entry>();
|
|
94
|
+
private bytes = 0;
|
|
95
|
+
private readonly blockHashReads: boolean;
|
|
96
|
+
|
|
97
|
+
constructor(options: LocalAnswersOptions = {}) {
|
|
98
|
+
this.blockHashReads = options.blockHashReads ?? false;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The cacheable call in `request`, honouring the enabled kinds. */
|
|
102
|
+
private call(request: PreparedRequest): Call | undefined {
|
|
103
|
+
const call = cacheableCall(request);
|
|
104
|
+
if (call === undefined) return undefined;
|
|
105
|
+
return call.kind === "block-hash" && !this.blockHashReads ? undefined : call;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The local answer for `request`, if one is confirmed. */
|
|
109
|
+
lookup(request: PreparedRequest): { response: AnonFetchResponse; kind: LocalKind } | undefined {
|
|
110
|
+
const call = this.call(request);
|
|
111
|
+
if (call === undefined) return undefined;
|
|
112
|
+
const entry = this.entries.get(call.key);
|
|
113
|
+
if (entry === undefined || !this.served(entry)) return undefined;
|
|
114
|
+
this.entries.delete(call.key);
|
|
115
|
+
this.entries.set(call.key, entry);
|
|
116
|
+
const body = `{"jsonrpc":"2.0","id":${JSON.stringify(call.id ?? null)},"result":${entry.result}}`;
|
|
117
|
+
return {
|
|
118
|
+
kind: call.kind,
|
|
119
|
+
response: { status: 200, headers: entry.headers.map(([name, value]) => [name, value]), body: encoder.encode(body), url: request.url },
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Record a mixnet answer. Returns `verify` when the call is a memo method
|
|
125
|
+
* seen for the first time: the caller sends it once more through the
|
|
126
|
+
* mixnet and passes that answer here too.
|
|
127
|
+
*/
|
|
128
|
+
observe(request: PreparedRequest, response: AnonFetchResponse): "verify" | undefined {
|
|
129
|
+
const call = this.call(request);
|
|
130
|
+
if (call === undefined || response.status !== 200 || !(response.body instanceof Uint8Array)) return undefined;
|
|
131
|
+
const result = resultOf(response.body);
|
|
132
|
+
if (result === undefined || result.length > LOCAL_ANSWER_MAX_ENTRY_BYTES) return undefined;
|
|
133
|
+
if (call.kind === "block-hash" && result === "[]") return undefined;
|
|
134
|
+
const existing = this.entries.get(call.key);
|
|
135
|
+
if (existing !== undefined) {
|
|
136
|
+
if (existing.result === result) {
|
|
137
|
+
existing.confirmations += 1;
|
|
138
|
+
} else {
|
|
139
|
+
// Two answers disagree: keep neither.
|
|
140
|
+
this.remove(call.key, existing);
|
|
141
|
+
}
|
|
142
|
+
return undefined;
|
|
143
|
+
}
|
|
144
|
+
const headers = response.headers.filter(([name]) => name.toLowerCase() === "content-type");
|
|
145
|
+
this.entries.set(call.key, { kind: call.kind, result, headers, confirmations: 1 });
|
|
146
|
+
this.bytes += result.length;
|
|
147
|
+
this.evict();
|
|
148
|
+
return call.kind === "memo" ? "verify" : undefined;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Number of answers held (served or awaiting confirmation). */
|
|
152
|
+
get size(): number {
|
|
153
|
+
return this.entries.size;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
private served(entry: Entry): boolean {
|
|
157
|
+
return entry.confirmations >= LOCAL_ANSWER_CONFIRMATIONS;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
private remove(key: string, entry: Entry): void {
|
|
161
|
+
this.entries.delete(key);
|
|
162
|
+
this.bytes -= entry.result.length;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
private evict(): void {
|
|
166
|
+
for (const [key, entry] of this.entries) {
|
|
167
|
+
if (this.entries.size <= LOCAL_ANSWER_MAX_ENTRIES && this.bytes <= LOCAL_ANSWER_MAX_BYTES) return;
|
|
168
|
+
this.remove(key, entry);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** The call's cache key and kind, when `request` is one call whose answer is fixed. */
|
|
174
|
+
function cacheableCall(request: PreparedRequest): Call | undefined {
|
|
175
|
+
if (request.method !== "POST" || request.profile.method === undefined || request.profile.method === "batch") {
|
|
176
|
+
return undefined;
|
|
177
|
+
}
|
|
178
|
+
for (const [name, value] of request.headers) {
|
|
179
|
+
if (name.toLowerCase() === "cache-control" && /no-cache|no-store/iu.test(value)) return undefined;
|
|
180
|
+
}
|
|
181
|
+
let value: unknown;
|
|
182
|
+
try {
|
|
183
|
+
value = JSON.parse(decoder.decode(request.body));
|
|
184
|
+
} catch {
|
|
185
|
+
return undefined;
|
|
186
|
+
}
|
|
187
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
|
|
188
|
+
const call = value as { jsonrpc?: unknown; method?: unknown; params?: unknown; id?: unknown };
|
|
189
|
+
if (call.jsonrpc !== "2.0" || typeof call.method !== "string" || !("id" in call)) return undefined;
|
|
190
|
+
const id = call.id;
|
|
191
|
+
if (id !== null && typeof id !== "string" && typeof id !== "number") return undefined;
|
|
192
|
+
const params = call.params ?? [];
|
|
193
|
+
const kind = classify(call.method, params);
|
|
194
|
+
if (kind === undefined) return undefined;
|
|
195
|
+
return { key: `${request.url}\n${call.method}\n${JSON.stringify(params)}`, kind, id };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function classify(method: string, params: unknown): LocalKind | undefined {
|
|
199
|
+
if (MEMO_METHODS.has(method)) return Array.isArray(params) && params.length === 0 ? "memo" : undefined;
|
|
200
|
+
if (!Array.isArray(params) || params.length === 0) return undefined;
|
|
201
|
+
const first: unknown = params[0];
|
|
202
|
+
if (BY_BLOCK_HASH.has(method)) return isHash(first) ? "block-hash" : undefined;
|
|
203
|
+
if (method === "eth_getBlockReceipts") return isHash(first) || isBlockHashSelector(first) ? "block-hash" : undefined;
|
|
204
|
+
if (method === "eth_getLogs") {
|
|
205
|
+
if (typeof first !== "object" || first === null) return undefined;
|
|
206
|
+
const filter = first as Record<string, unknown>;
|
|
207
|
+
return isHash(filter["blockHash"]) && filter["fromBlock"] === undefined && filter["toBlock"] === undefined
|
|
208
|
+
? "block-hash"
|
|
209
|
+
: undefined;
|
|
210
|
+
}
|
|
211
|
+
if (STATE_READS.has(method)) return isBlockHashSelector(params[params.length - 1]) ? "block-hash" : undefined;
|
|
212
|
+
return undefined;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function isHash(value: unknown): boolean {
|
|
216
|
+
return typeof value === "string" && HASH_RE.test(value);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** EIP-1898 `{"blockHash": …}` without `requireCanonical: true` (whose answer can turn into an error after a reorg). */
|
|
220
|
+
function isBlockHashSelector(value: unknown): boolean {
|
|
221
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
222
|
+
const selector = value as Record<string, unknown>;
|
|
223
|
+
return isHash(selector["blockHash"]) && selector["requireCanonical"] !== true && selector["blockNumber"] === undefined;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** The JSON text of a successful reply's `result` (not null), else undefined. */
|
|
227
|
+
function resultOf(body: Uint8Array): string | undefined {
|
|
228
|
+
let value: unknown;
|
|
229
|
+
try {
|
|
230
|
+
value = JSON.parse(decoder.decode(body));
|
|
231
|
+
} catch {
|
|
232
|
+
return undefined;
|
|
233
|
+
}
|
|
234
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
|
|
235
|
+
const reply = value as { result?: unknown; error?: unknown };
|
|
236
|
+
if (reply.error !== undefined || reply.result === undefined || reply.result === null) return undefined;
|
|
237
|
+
return JSON.stringify(reply.result);
|
|
238
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A TLS session to one host over one tunnel: the handshake exchange, then
|
|
3
|
+
* HTTP/1.1 requests. Completeness of a response comes from HTTP framing or,
|
|
4
|
+
* for a close-delimited body, from TLS close_notify; an exit that ends the
|
|
5
|
+
* stream early produces a truncation error, never a short response.
|
|
6
|
+
*/
|
|
7
|
+
import type { TopologyNode } from "@hisoka-io/nox-client";
|
|
8
|
+
import type { HttpParserLike, HttpReplyHead, NoxTlsBindings, TlsSessionLike } from "./module.js";
|
|
9
|
+
import { Tunnel, TunnelProtocolError, type CopyPolicy, type TunnelDeps, type TunnelPort } from "./tunnel.js";
|
|
10
|
+
|
|
11
|
+
/** Reply blocks for the handshake exchange: one carries the server flight, the other is the exit's reserve. */
|
|
12
|
+
export const HANDSHAKE_SURBS = 2;
|
|
13
|
+
/** The TLS port every tunnel uses. */
|
|
14
|
+
export const TLS_PORT = 443;
|
|
15
|
+
|
|
16
|
+
export interface ExchangeTiming {
|
|
17
|
+
readonly deadlineAt: number;
|
|
18
|
+
readonly signal?: AbortSignal;
|
|
19
|
+
readonly background: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface RequestSpec extends ExchangeTiming {
|
|
23
|
+
/** The encoded HTTP/1.1 request. */
|
|
24
|
+
readonly http: Uint8Array;
|
|
25
|
+
readonly headRequest: boolean;
|
|
26
|
+
readonly maxHeadBytes: number;
|
|
27
|
+
readonly maxBodyBytes: number;
|
|
28
|
+
/** Reply blocks for the exchange that carries the request. */
|
|
29
|
+
readonly surbs: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class TlsChannel {
|
|
33
|
+
readonly tunnel: Tunnel;
|
|
34
|
+
readonly openedAt: number;
|
|
35
|
+
/** Requests sent on this session. */
|
|
36
|
+
calls = 0;
|
|
37
|
+
/** Milliseconds spent in the TLS module processing the server's handshake flight. */
|
|
38
|
+
handshakeCpuMs = 0;
|
|
39
|
+
/** False once a response asked to close or the stream ended. */
|
|
40
|
+
private keepAlive = true;
|
|
41
|
+
private parser: HttpParserLike | undefined;
|
|
42
|
+
private parserDone = false;
|
|
43
|
+
private freed = false;
|
|
44
|
+
private readonly tls: TlsSessionLike;
|
|
45
|
+
|
|
46
|
+
constructor(
|
|
47
|
+
port: TunnelPort,
|
|
48
|
+
exit: TopologyNode,
|
|
49
|
+
readonly host: string,
|
|
50
|
+
policy: CopyPolicy,
|
|
51
|
+
deps: TunnelDeps,
|
|
52
|
+
private readonly bindings: NoxTlsBindings,
|
|
53
|
+
) {
|
|
54
|
+
this.tls = bindings.newSession(host);
|
|
55
|
+
this.openedAt = deps.now();
|
|
56
|
+
this.tunnel = new Tunnel(port, exit, host, TLS_PORT, policy, deps, (bytes) => this.onBytes(bytes));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
get exit(): TopologyNode {
|
|
60
|
+
return this.tunnel.exit;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** TLS version negotiated (`TLSv1_3`, `TLSv1_2`). */
|
|
64
|
+
get protocol(): string {
|
|
65
|
+
return this.tls.protocol();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The session can carry another request. */
|
|
69
|
+
get reusable(): boolean {
|
|
70
|
+
return !this.freed && this.keepAlive && !this.tunnel.eof && !this.tls.isClosed();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Seq 0: open the tunnel with the ClientHello and process the server's
|
|
75
|
+
* flight. Afterwards the client's next flight (the TLS 1.3 Finished) waits
|
|
76
|
+
* in the session until the first request, which travels with it.
|
|
77
|
+
*/
|
|
78
|
+
async handshake(timing: ExchangeTiming & { readonly openTimeoutMs: number }): Promise<void> {
|
|
79
|
+
const hello = this.tls.takeOutgoing();
|
|
80
|
+
const outcome = await this.tunnel.exchange({
|
|
81
|
+
data: hello,
|
|
82
|
+
surbs: HANDSHAKE_SURBS,
|
|
83
|
+
deadlineAt: timing.deadlineAt,
|
|
84
|
+
background: timing.background,
|
|
85
|
+
openTimeoutMs: timing.openTimeoutMs,
|
|
86
|
+
...(timing.signal === undefined ? {} : { signal: timing.signal }),
|
|
87
|
+
satisfied: () => this.tls.wantsWrite() || !this.tls.isHandshaking(),
|
|
88
|
+
});
|
|
89
|
+
if (outcome === "eof") throw new TunnelProtocolError("The upstream closed the connection during the TLS handshake");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** One HTTP request and its complete response. */
|
|
93
|
+
async request(spec: RequestSpec): Promise<HttpReplyHead> {
|
|
94
|
+
this.calls += 1;
|
|
95
|
+
const parser = this.bindings.newParser(spec.headRequest, spec.maxHeadBytes, spec.maxBodyBytes);
|
|
96
|
+
this.parser = parser;
|
|
97
|
+
this.parserDone = false;
|
|
98
|
+
try {
|
|
99
|
+
this.tls.writePlaintext(spec.http);
|
|
100
|
+
for (;;) {
|
|
101
|
+
const data = this.tls.takeOutgoing();
|
|
102
|
+
const outcome = await this.tunnel.exchange({
|
|
103
|
+
data,
|
|
104
|
+
surbs: spec.surbs,
|
|
105
|
+
deadlineAt: spec.deadlineAt,
|
|
106
|
+
background: spec.background,
|
|
107
|
+
...(spec.signal === undefined ? {} : { signal: spec.signal }),
|
|
108
|
+
satisfied: () => this.parserDone || this.tls.wantsWrite(),
|
|
109
|
+
});
|
|
110
|
+
if (outcome === "eof") {
|
|
111
|
+
this.keepAlive = false;
|
|
112
|
+
this.tls.pushTransportEof();
|
|
113
|
+
this.feedParser(this.tls.takePlaintext());
|
|
114
|
+
if (!this.parserDone) {
|
|
115
|
+
parser.finishOnClose(this.tls.closeNotifyReceived());
|
|
116
|
+
this.parserDone = true;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (this.parserDone) break;
|
|
120
|
+
}
|
|
121
|
+
const response = parser.response();
|
|
122
|
+
if (response === null) throw new TunnelProtocolError("The HTTP parser finished without a response");
|
|
123
|
+
if (!response.keepAlive) this.keepAlive = false;
|
|
124
|
+
return response;
|
|
125
|
+
} finally {
|
|
126
|
+
this.parser = undefined;
|
|
127
|
+
parser.free();
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Close the tunnel: close_notify when the TLS session can still send one,
|
|
133
|
+
* and the acknowledgement of every byte received, so the exit drops the
|
|
134
|
+
* session at once. Nothing waits for it.
|
|
135
|
+
*/
|
|
136
|
+
teardown(deadlineAt: number): Promise<void> {
|
|
137
|
+
if (this.freed) return Promise.resolve();
|
|
138
|
+
this.keepAlive = false;
|
|
139
|
+
const data = this.tunnel.eof ? new Uint8Array(0) : this.closeNotify();
|
|
140
|
+
this.free();
|
|
141
|
+
return this.tunnel.teardown(data, deadlineAt);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The close_notify record, or nothing when the session can no longer send one. */
|
|
145
|
+
private closeNotify(): Uint8Array {
|
|
146
|
+
try {
|
|
147
|
+
this.tls.close();
|
|
148
|
+
return this.tls.takeOutgoing();
|
|
149
|
+
} catch {
|
|
150
|
+
return new Uint8Array(0);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Release the TLS session's WebAssembly memory. */
|
|
155
|
+
free(): void {
|
|
156
|
+
if (this.freed) return;
|
|
157
|
+
this.freed = true;
|
|
158
|
+
this.tls.free();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
private onBytes(bytes: Uint8Array): void {
|
|
162
|
+
if (this.tls.isHandshaking()) {
|
|
163
|
+
const started = performance.now();
|
|
164
|
+
this.tls.pushIncoming(bytes);
|
|
165
|
+
this.handshakeCpuMs += performance.now() - started;
|
|
166
|
+
} else {
|
|
167
|
+
this.tls.pushIncoming(bytes);
|
|
168
|
+
}
|
|
169
|
+
this.feedParser(this.tls.takePlaintext());
|
|
170
|
+
if (this.tls.closeNotifyReceived() && this.parser !== undefined && !this.parserDone) {
|
|
171
|
+
this.parser.finishOnClose(true);
|
|
172
|
+
this.parserDone = true;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
private feedParser(plaintext: Uint8Array): void {
|
|
177
|
+
if (plaintext.length === 0) return;
|
|
178
|
+
if (this.parser === undefined || this.parserDone) {
|
|
179
|
+
throw new TunnelProtocolError(`The upstream sent ${plaintext.length} bytes with no request outstanding`);
|
|
180
|
+
}
|
|
181
|
+
this.parserDone = this.parser.push(plaintext);
|
|
182
|
+
}
|
|
183
|
+
}
|