@runuai/host 0.9.148 → 0.9.150

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.
Files changed (93) hide show
  1. package/db/migrations/0024_task_env_mode.sql +9 -0
  2. package/db/migrations/0025_project_env_authority.sql +147 -0
  3. package/db/migrations/0026_project_env_replica.sql +150 -0
  4. package/db/migrations/meta/_journal.json +21 -0
  5. package/db/schema.ts +312 -2
  6. package/images/standard/Dockerfile +44 -6
  7. package/images/standard/container/uai-init +85 -2
  8. package/images/standard/container/uai-task-forwarder.mjs +303 -0
  9. package/lib/agent-cli.ts +18 -1
  10. package/lib/agent.ts +99 -34
  11. package/lib/agents/codex.ts +9 -1
  12. package/lib/agents/cursor.ts +15 -1
  13. package/lib/agents/grok.ts +8 -0
  14. package/lib/agents/kimi.ts +1 -0
  15. package/lib/agents/machine-durable.ts +2 -0
  16. package/lib/agents/opencode.ts +1 -0
  17. package/lib/agents/proc.ts +6 -1
  18. package/lib/agents/transport.ts +9 -5
  19. package/lib/browser-testing.ts +6 -0
  20. package/lib/codex-auth.ts +2 -0
  21. package/lib/credential-store.ts +141 -6
  22. package/lib/engine-accounts.ts +1 -0
  23. package/lib/env-file.ts +7 -0
  24. package/lib/env-schema.ts +841 -51
  25. package/lib/git-diff.ts +1 -0
  26. package/lib/git-identity.ts +2 -0
  27. package/lib/github-tokens.ts +1 -0
  28. package/lib/host-env.ts +98 -18
  29. package/lib/lease.ts +11 -3
  30. package/lib/machine-gateway-tunnel.ts +195 -17
  31. package/lib/machine-project-env-loader.ts +152 -0
  32. package/lib/managed-runtime.ts +270 -51
  33. package/lib/mcp-gateway.ts +777 -26
  34. package/lib/orchestrator.ts +408 -81
  35. package/lib/project-env-authority.ts +1319 -0
  36. package/lib/project-env-feature.ts +74 -0
  37. package/lib/project-env-loader.ts +237 -0
  38. package/lib/project-env-replica.ts +2094 -0
  39. package/lib/project-env-schema-capture.ts +342 -0
  40. package/lib/project-env-staging.ts +148 -0
  41. package/lib/project-env-task-policy.ts +176 -0
  42. package/lib/runtime-archive.ts +79 -16
  43. package/lib/runtime-authority.ts +44 -0
  44. package/lib/runtime-state.ts +175 -10
  45. package/lib/secrets-proxy/address.ts +72 -0
  46. package/lib/secrets-proxy/audit.ts +30 -0
  47. package/lib/secrets-proxy/ca-store.ts +200 -0
  48. package/lib/secrets-proxy/ca.ts +470 -0
  49. package/lib/secrets-proxy/container-steps.ts +138 -0
  50. package/lib/secrets-proxy/der.ts +273 -0
  51. package/lib/secrets-proxy/engine.ts +1633 -0
  52. package/lib/secrets-proxy/host.ts +93 -0
  53. package/lib/secrets-proxy/hostname.ts +60 -0
  54. package/lib/secrets-proxy/placeholders.ts +196 -0
  55. package/lib/secrets-proxy/policy.ts +141 -0
  56. package/lib/secrets-proxy/readiness.ts +148 -0
  57. package/lib/secrets-proxy/runtime.ts +582 -0
  58. package/lib/secrets-proxy/scrub.ts +559 -0
  59. package/lib/secrets-proxy/sidecar-view.ts +139 -0
  60. package/lib/secrets-proxy/task-scripts.ts +262 -0
  61. package/lib/secrets-proxy/task-trust.ts +574 -0
  62. package/lib/secrets-proxy/trust-watch.ts +80 -0
  63. package/lib/shared-files-s3.ts +3 -0
  64. package/lib/sidecar-guard.ts +58 -0
  65. package/lib/sidecar-resume.ts +51 -3
  66. package/lib/standard-image.ts +50 -12
  67. package/lib/task-env-forwarder.ts +228 -0
  68. package/lib/task-env-source.ts +193 -0
  69. package/lib/task-environment/apple-container.ts +45 -11
  70. package/lib/task-environment/docker.ts +51 -12
  71. package/lib/task-environment/index.ts +6 -2
  72. package/lib/task-environment/machine-task-up.ts +258 -25
  73. package/lib/task-environment/machine.ts +80 -22
  74. package/lib/task-environment/registry.ts +3 -2
  75. package/lib/task-environment/self-identity.ts +20 -0
  76. package/lib/task-environment/self.ts +92 -16
  77. package/lib/task-environment/types.ts +63 -0
  78. package/lib/task-gateway-port.ts +22 -0
  79. package/lib/task-gateway-protocol.ts +190 -0
  80. package/lib/task-gateway-recovery.ts +44 -0
  81. package/lib/task-gateway-state.ts +497 -0
  82. package/lib/transcript.ts +1 -0
  83. package/package.json +13 -1
  84. package/scripts/agent/env-hook.sh +626 -0
  85. package/scripts/agent/task-up.sh +476 -27
  86. package/scripts/agent/uai-env-exec.sh +26 -0
  87. package/src/cli.ts +26 -3
  88. package/src/index.ts +273 -19
  89. package/src/main.ts +939 -48
  90. package/src/project-env-latest.ts +130 -0
  91. package/src/project-env-policy.ts +281 -0
  92. package/src/project-env-wording.ts +47 -0
  93. package/src/protocol.ts +2219 -15
@@ -0,0 +1,1633 @@
1
+ /**
2
+ * Secrets proxy engine (ADR-153 §3 "The boundary proxy").
3
+ *
4
+ * The gateway listener has already established which task a connection
5
+ * belongs to (the forwarder's HMAC handshake, or a sidecar's single task) and
6
+ * hands over the raw socket. Its next bytes are a TLS ClientHello for a
7
+ * domain that task's /etc/hosts pins to the proxy. Per connection:
8
+ *
9
+ * TLS server — a leaf minted for the SNI name, only if the task declares it
10
+ * → HTTP/1.1 through Node's strict parser (a non-listening http.Server)
11
+ * → the task's placeholders swapped for real values in eligible headers
12
+ * → vetted upstream: runner-side DNS, public addresses only, TLS verified
13
+ * against public roots for the declared domain
14
+ * → the response scrubbed back to placeholders whenever anything was
15
+ * injected into its request.
16
+ *
17
+ * The invariant: a real value leaves this process only toward its key's bound
18
+ * domain, over verified TLS, and never flows back to the task. A step that
19
+ * cannot uphold it refuses instead ("withhold means never send"), and every
20
+ * refusal and skip is audited — never with a value, placeholder, path or
21
+ * query in the entry.
22
+ */
23
+
24
+ import {
25
+ STATUS_CODES,
26
+ createServer as createHttpServer,
27
+ type IncomingMessage,
28
+ type ServerResponse,
29
+ } from "node:http";
30
+ import { request as httpsRequest, type RequestOptions as HttpsRequestOptions } from "node:https";
31
+ import { isIP, type LookupFunction, type Socket } from "node:net";
32
+ import { Transform, Writable, pipeline, type Duplex } from "node:stream";
33
+ import {
34
+ connect as tlsConnect,
35
+ createSecureContext,
36
+ createServer as createTlsServer,
37
+ rootCertificates,
38
+ type ConnectionOptions,
39
+ type SecureContext,
40
+ type TLSSocket,
41
+ } from "node:tls";
42
+ import * as zlib from "node:zlib";
43
+
44
+ import type { TaskEnvHead } from "../project-env-task-policy";
45
+ import { isPublicAddress } from "./address";
46
+ import type { LeafCertificate, LeafCertificateCache } from "./ca";
47
+ import { authorityMatches, canonicalHostname, isProxiableDomain } from "./hostname";
48
+ import { PreparedRules, ScrubError, createScrubStream, scrubHeaderText, scrubRules, type ScrubRule } from "./scrub";
49
+
50
+ export interface ProxyKeyRef {
51
+ projectId: string;
52
+ key: string;
53
+ domains: readonly string[];
54
+ /** The head the key's value must have, carried to the value read. */
55
+ head?: TaskEnvHead;
56
+ }
57
+
58
+ export interface ProxyTaskState {
59
+ /** Canonical domains this task may intercept. */
60
+ domains(): ReadonlySet<string>;
61
+ /** This task's placeholder → key it stands for, or null. */
62
+ lookup(placeholder: string): ProxyKeyRef | null;
63
+ /** All of this task's placeholders (to find them in headers). */
64
+ placeholders(): readonly string[];
65
+ /** The CURRENT real value, read per request (so rotation applies to the next request). Null = withhold. */
66
+ realValue(ref: ProxyKeyRef): string | null;
67
+ }
68
+
69
+ export interface SecretsProxyLimits {
70
+ /** TLS handshake, from hand-over to the end of the handshake (absolute). */
71
+ handshakeMs: number;
72
+ /** A request head, from its first byte to its last (absolute). */
73
+ headersMs: number;
74
+ /** A connection with no request in flight and no bytes of the next one. */
75
+ idleMs: number;
76
+ maxHeaderBytes: number;
77
+ maxConnectionsPerTask: number;
78
+ /** Responses up to this Content-Length are buffered, scrubbed, verified
79
+ * and sent with an exact length; larger or unsized ones are streamed. */
80
+ maxBufferedBody: number;
81
+ /** Runner-side resolution, and separately the upstream dial plus TLS
82
+ * handshake. Never a bound on the response: an SSE stream may stay quiet
83
+ * for as long as the client keeps it open. */
84
+ upstreamConnectMs: number;
85
+ }
86
+
87
+ export interface SecretsProxyDeps {
88
+ getTaskState(taskId: string): ProxyTaskState | null;
89
+ /** The runner CA's leaf cache: a leaf per declared domain, minted on demand. */
90
+ ca: LeafCertificateCache;
91
+ /** A + AAAA, resolved runner-side, never through the task's pins. */
92
+ resolveHost(hostname: string): Promise<string[]>;
93
+ /** Default: the strict check in ./address (tests inject a loopback allowance). */
94
+ isPublicAddress?(ip: string): boolean;
95
+ /** Default: Node's bundled Mozilla roots — never the system store, never
96
+ * NODE_EXTRA_CA_CERTS, so it can never include Uai's own CA. */
97
+ upstreamCa?: string | Buffer | Array<string | Buffer>;
98
+ /** One entry per injection, refusal or skip. Never a value, placeholder,
99
+ * path or query. */
100
+ audit(entry: Record<string, unknown>): void;
101
+ now?(): number;
102
+ limits?: Partial<SecretsProxyLimits>;
103
+ /** Test seam: production always dials 443, the only port a pin redirects. */
104
+ upstreamPort?: number;
105
+ }
106
+
107
+ export interface SecretsProxy {
108
+ /** Take over a socket whose task the caller has already authenticated; its
109
+ * next bytes must be a TLS ClientHello. `isCurrent` is the gateway's
110
+ * generation check, required so no caller can forget it: once it says
111
+ * false — the task stopped or was restarted — the connection is served
112
+ * nothing more. Never throws. */
113
+ handleConnection(connection: { taskId: string; socket: Socket; isCurrent: () => boolean }): void;
114
+ /** Tear down every open connection of one task, in-flight upstream requests
115
+ * and tunnels included. Revocation (stop, teardown, a new generation)
116
+ * drops the task's state first, which refuses NEW requests; this ends the
117
+ * streams already under way, so nothing opened under the old generation
118
+ * outlives it. */
119
+ closeTask(taskId: string): void;
120
+ /** Refuse new connections and tear down every open one. */
121
+ close(): Promise<void>;
122
+ }
123
+
124
+ export const DEFAULT_SECRETS_PROXY_LIMITS: Readonly<SecretsProxyLimits> = Object.freeze({
125
+ handshakeMs: 10_000,
126
+ headersMs: 30_000,
127
+ idleMs: 60_000,
128
+ maxHeaderBytes: 32 * 1024,
129
+ maxConnectionsPerTask: 64,
130
+ maxBufferedBody: 2 * 1024 * 1024,
131
+ upstreamConnectMs: 30_000,
132
+ });
133
+
134
+ /** A compressed body chosen for buffering is decoded at most this far: a
135
+ * 2 MB gzip can inflate a thousandfold. */
136
+ const MAX_DECODED_BUFFERED_BYTES = 64 * 1024 * 1024;
137
+ /** Pipelined requests one connection may have in flight; each is an upstream
138
+ * request, and the per-task cap counts connections, not requests. */
139
+ const MAX_IN_FLIGHT_PER_CONNECTION = 16;
140
+ const PLACEHOLDER_SHAPE = "uai_proxied_";
141
+ const MAX_PROXIED_VALUE_CHARS = 4_096;
142
+
143
+ const HOP_BY_HOP = new Set([
144
+ "connection",
145
+ "keep-alive",
146
+ "proxy-connection",
147
+ "proxy-authorization",
148
+ "proxy-authenticate",
149
+ "te",
150
+ "trailer",
151
+ "transfer-encoding",
152
+ "upgrade",
153
+ ]);
154
+ /** Never swapped into (ADR-153 §3 rule 4): they are echoed back, logged, or
155
+ * describe the client rather than authenticate it. */
156
+ const NEVER_SWAPPED = new Set(["cookie", "host", "forwarded", "via", "referer", "origin", "user-agent"]);
157
+ /** Set by the proxy itself: Host is the declared domain, framing follows
158
+ * Node's parse (never the client's header list), identity encoding keeps
159
+ * responses scannable, and `expect` was already answered by Node. */
160
+ const PROXY_SET = new Set(["host", "content-length", "expect", "accept-encoding"]);
161
+ /** Dropped from an injected request: a byte range of a response could hold a
162
+ * fragment of a value that no scan can recognise. */
163
+ const RANGE_HEADERS = new Set(["range", "if-range"]);
164
+ /** Dropped from a scrubbed response: they describe bytes the task no longer
165
+ * receives. */
166
+ const VALIDATOR_HEADERS = new Set(["content-md5", "digest", "content-digest", "repr-digest", "etag", "last-modified"]);
167
+ /** Visible ASCII and space: anything else is ambiguous in a header (latin1 vs
168
+ * UTF-8) or a header-injection vector (CR/LF). */
169
+ const HEADER_SAFE_VALUE = /^[\x20-\x7e]*$/;
170
+
171
+ type Phase = "handshake" | "idle" | "head" | "active" | "tunnel";
172
+
173
+ interface Connection {
174
+ readonly taskId: string;
175
+ readonly raw: Socket;
176
+ /** False once the generation that opened the connection is revoked. */
177
+ readonly isCurrent: () => boolean;
178
+ tls: TLSSocket | null;
179
+ /** The canonical SNI name, fixed during the handshake. */
180
+ domain: string | null;
181
+ phase: Phase;
182
+ inFlight: number;
183
+ timer: NodeJS.Timeout | null;
184
+ onData: (() => void) | null;
185
+ /** Upstream requests and tunnels to tear down with the connection. */
186
+ readonly children: Set<{ destroy(): unknown }>;
187
+ closed: boolean;
188
+ }
189
+
190
+ interface Injected {
191
+ readonly ref: ProxyKeyRef;
192
+ readonly value: string;
193
+ readonly placeholder: string;
194
+ }
195
+
196
+ interface VettedAddress {
197
+ readonly address: string;
198
+ readonly family: 4 | 6;
199
+ }
200
+
201
+ interface Codec {
202
+ decode(): Transform;
203
+ encode(streaming: boolean): Transform;
204
+ }
205
+
206
+ type ZstdZlib = {
207
+ createZstdDecompress?: () => Transform;
208
+ createZstdCompress?: (options?: Record<string, unknown>) => Transform;
209
+ constants: { ZSTD_e_flush?: number };
210
+ };
211
+
212
+ function codecFor(coding: string): Codec | null {
213
+ switch (coding) {
214
+ case "gzip":
215
+ case "x-gzip":
216
+ return {
217
+ decode: () => zlib.createGunzip(),
218
+ // A sync flush per write keeps a streamed (SSE) body flowing.
219
+ encode: (streaming) => zlib.createGzip(streaming ? { flush: zlib.constants.Z_SYNC_FLUSH } : {}),
220
+ };
221
+ case "deflate":
222
+ // The zlib-wrapped form RFC 9110 defines; a server sending raw deflate
223
+ // fails to decode, and the response fails closed.
224
+ return {
225
+ decode: () => zlib.createInflate(),
226
+ encode: (streaming) => zlib.createDeflate(streaming ? { flush: zlib.constants.Z_SYNC_FLUSH } : {}),
227
+ };
228
+ case "br":
229
+ return {
230
+ decode: () => zlib.createBrotliDecompress(),
231
+ encode: (streaming) =>
232
+ zlib.createBrotliCompress({
233
+ ...(streaming ? { flush: zlib.constants.BROTLI_OPERATION_FLUSH } : {}),
234
+ params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 5 },
235
+ }),
236
+ };
237
+ case "zstd": {
238
+ // Only where the runtime has it (Node 22.15+/23.8+); otherwise a 502.
239
+ const zstd = zlib as unknown as ZstdZlib;
240
+ const decompress = zstd.createZstdDecompress;
241
+ const compress = zstd.createZstdCompress;
242
+ if (typeof decompress !== "function" || typeof compress !== "function") return null;
243
+ const flush = zstd.constants.ZSTD_e_flush;
244
+ return {
245
+ decode: () => decompress(),
246
+ encode: (streaming) => compress(streaming && flush !== undefined ? { flush } : {}),
247
+ };
248
+ }
249
+ default:
250
+ return null;
251
+ }
252
+ }
253
+
254
+ /** "identity" when the body is unencoded, the single coding's name, or null
255
+ * for stacked codings, which are refused rather than unwound. */
256
+ function contentCoding(header: string | undefined): string | null {
257
+ if (header === undefined) return "identity";
258
+ const codings = header
259
+ .split(",")
260
+ .map((coding) => coding.trim().toLowerCase())
261
+ .filter((coding) => coding !== "" && coding !== "identity");
262
+ if (codings.length === 0) return "identity";
263
+ return codings.length === 1 ? (codings[0] ?? null) : null;
264
+ }
265
+
266
+ function* headerPairs(raw: readonly string[]): Generator<[string, string]> {
267
+ for (let index = 0; index + 1 < raw.length; index += 2) {
268
+ yield [raw[index] ?? "", raw[index + 1] ?? ""];
269
+ }
270
+ }
271
+
272
+ function headerValues(raw: readonly string[], name: string): string[] {
273
+ const values: string[] = [];
274
+ for (const [key, value] of headerPairs(raw)) {
275
+ if (key.toLowerCase() === name) values.push(value);
276
+ }
277
+ return values;
278
+ }
279
+
280
+ function connectionTokens(raw: readonly string[]): Set<string> {
281
+ const tokens = new Set<string>();
282
+ for (const value of headerValues(raw, "connection")) {
283
+ for (const token of value.split(",")) {
284
+ const name = token.trim().toLowerCase();
285
+ if (name !== "") tokens.add(name);
286
+ }
287
+ }
288
+ return tokens;
289
+ }
290
+
291
+ /** Response headers minus hop-by-hop ones and whatever `Connection` lists. */
292
+ function endToEndHeaders(raw: readonly string[]): string[] {
293
+ const listed = connectionTokens(raw);
294
+ const headers: string[] = [];
295
+ for (const [name, value] of headerPairs(raw)) {
296
+ const lower = name.toLowerCase();
297
+ if (!HOP_BY_HOP.has(lower) && !listed.has(lower)) headers.push(name, value);
298
+ }
299
+ return headers;
300
+ }
301
+
302
+ function declaredLength(raw: readonly string[]): number | null {
303
+ const lengths = headerValues(raw, "content-length");
304
+ if (lengths.length !== 1 || !/^[0-9]{1,15}$/.test(lengths[0] ?? "")) return null;
305
+ return Number(lengths[0]);
306
+ }
307
+
308
+ /** False only for no Transfer-Encoding, or exactly one field naming exactly
309
+ * `chunked` — the single framing Node removes. Anything else leaves coded
310
+ * bytes in the body: `gzip, chunked` arrives still compressed, and a
311
+ * repeated `chunked, chunked` leaves an inner chunk stream whose framing
312
+ * can split a value into pieces no scan sees whole. */
313
+ function hasForeignTransferCoding(raw: readonly string[]): boolean {
314
+ const fields = headerValues(raw, "transfer-encoding");
315
+ if (fields.length === 0) return false;
316
+ if (fields.length > 1) return true;
317
+ const codings = (fields[0] ?? "").split(",").map((coding) => coding.trim().toLowerCase());
318
+ return !(codings.length === 1 && codings[0] === "chunked");
319
+ }
320
+
321
+ /** Passes a request body through unchanged, calling `onFound` once when one
322
+ * of `needles` appears in it, across chunk boundaries too. */
323
+ function observePlaceholders(needles: readonly string[], onFound: () => void): Transform {
324
+ const buffers = needles.filter((needle) => needle.length > 0).map((needle) => Buffer.from(needle, "latin1"));
325
+ const keep = Math.max(0, ...buffers.map((needle) => needle.length - 1));
326
+ let tail = Buffer.alloc(0);
327
+ let found = buffers.length === 0;
328
+ return new Transform({
329
+ transform(chunk: Buffer, _encoding, callback) {
330
+ if (!found) {
331
+ const window = tail.length === 0 ? chunk : Buffer.concat([tail, chunk]);
332
+ if (buffers.some((needle) => window.includes(needle))) {
333
+ found = true;
334
+ onFound();
335
+ } else {
336
+ tail = Buffer.from(window.subarray(Math.max(0, window.length - keep)));
337
+ }
338
+ }
339
+ callback(null, chunk);
340
+ },
341
+ });
342
+ }
343
+
344
+ function escapeRegExp(text: string): string {
345
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
346
+ }
347
+
348
+ /** The credential of an `Authorization: Basic` value, decoded, or null when
349
+ * the value is not Basic. One grammar for swapping and for the upgrade
350
+ * refusal, so neither can accept a form the other misses. */
351
+ function decodeBasic(value: string): { scheme: string; token: string; decoded: string } | null {
352
+ const basic = /^(basic[ \t]+)([A-Za-z0-9+/_-]+={0,2})$/i.exec(value);
353
+ if (basic === null) return null;
354
+ const [, scheme = "", token = ""] = basic;
355
+ return { scheme, token, decoded: Buffer.from(token, "base64").toString("latin1") };
356
+ }
357
+
358
+ type HeadCheck =
359
+ | { ok: true; path: string }
360
+ | { ok: false; status: number; reason: string; message: string };
361
+
362
+ /** ADR-153 §3 rule 3, beyond what Node's strict parser already refuses. */
363
+ function checkRequestHead(req: IncomingMessage, domain: string): HeadCheck {
364
+ const hosts = headerValues(req.rawHeaders, "host");
365
+ if (hosts.length !== 1) {
366
+ // RFC 9112 §3.2 makes a missing or repeated Host a 400, not a 421.
367
+ return {
368
+ ok: false,
369
+ status: 400,
370
+ reason: "host_missing_or_duplicate",
371
+ message: "exactly one Host header is required",
372
+ };
373
+ }
374
+ if (!authorityMatches(hosts[0] ?? "", domain)) {
375
+ return { ok: false, status: 421, reason: "host_mismatch", message: "Host does not match the TLS server name" };
376
+ }
377
+ // llhttp already rejects Content-Length with Transfer-Encoding and repeated
378
+ // lengths, but it emits `request` before failing on some codings (a bare
379
+ // `Transfer-Encoding: gzip`), so framing is checked again here.
380
+ const lengths = headerValues(req.rawHeaders, "content-length");
381
+ const codings = headerValues(req.rawHeaders, "transfer-encoding");
382
+ if (
383
+ lengths.length > 1 ||
384
+ codings.length > 1 ||
385
+ (lengths.length > 0 && codings.length > 0) ||
386
+ (codings.length === 1 && codings[0]?.trim().toLowerCase() !== "chunked")
387
+ ) {
388
+ return { ok: false, status: 400, reason: "ambiguous_framing", message: "ambiguous request framing" };
389
+ }
390
+ const target = req.url ?? "";
391
+ if (target.startsWith("/")) return { ok: true, path: target };
392
+ if (target === "*" && req.method === "OPTIONS") return { ok: true, path: target };
393
+ const absolute = /^([A-Za-z][A-Za-z0-9+.-]*):\/\/([^/?#]*)([^#]*)$/.exec(target);
394
+ if (absolute === null) {
395
+ return { ok: false, status: 400, reason: "request_target", message: "unsupported request target" };
396
+ }
397
+ const [, scheme = "", authority = "", rest = ""] = absolute;
398
+ if (scheme.toLowerCase() !== "https" || !authorityMatches(authority, domain)) {
399
+ return {
400
+ ok: false,
401
+ status: 400,
402
+ reason: "absolute_form_authority",
403
+ message: "the request URL names another authority",
404
+ };
405
+ }
406
+ // The client's own path bytes: a WHATWG URL round trip would collapse
407
+ // `/a/../b` and re-escape characters the upstream may care about.
408
+ return { ok: true, path: rest === "" ? "/" : rest.startsWith("/") ? rest : `/${rest}` };
409
+ }
410
+
411
+ /**
412
+ * Finds this task's placeholders in text and decides, per occurrence, whether
413
+ * it is swapped (ADR-153 §3 rule 4). One instance per request: the real
414
+ * value of each key is read once, so a rotation mid-request cannot put two
415
+ * different values into one request, and the scrub set matches what was sent.
416
+ */
417
+ class Swapper {
418
+ readonly injected = new Map<string, Injected>();
419
+ /** Re-encoded Basic credentials → the task's original token. */
420
+ readonly echoes: Array<{ from: string; to: string }> = [];
421
+ readonly skipped = new Set<string>();
422
+ refusal: "withheld" | "unencodable_value" | "value_too_long" | null = null;
423
+ /** This task's placeholders, longest first. */
424
+ readonly all: readonly string[];
425
+ private readonly matcher: RegExp | null;
426
+ private readonly values = new Map<string, string | null>();
427
+
428
+ constructor(
429
+ private readonly state: ProxyTaskState,
430
+ private readonly domain: string,
431
+ ) {
432
+ let placeholders: readonly string[] = [];
433
+ try {
434
+ placeholders = state.placeholders();
435
+ } catch {
436
+ // Unenumerable placeholders are never swapped: they go upstream as-is.
437
+ }
438
+ const usable = [...new Set(placeholders.filter((entry) => typeof entry === "string" && entry.length > 0))]
439
+ // Longest first, so a placeholder that contains another wins.
440
+ .sort((left, right) => right.length - left.length);
441
+ this.all = usable;
442
+ this.matcher = usable.length > 0 ? new RegExp(usable.map(escapeRegExp).join("|"), "g") : null;
443
+ }
444
+
445
+ /** A header value with eligible placeholders swapped. */
446
+ text(value: string): string {
447
+ return this.walk(value, (placeholder) => this.resolve(placeholder));
448
+ }
449
+
450
+ /** `Authorization: Basic` is swapped inside the decoded `user:pass`
451
+ * (Stripe and Twilio authenticate this way); anything else as text. */
452
+ authorization(value: string): string {
453
+ const basic = decodeBasic(value);
454
+ if (basic !== null) {
455
+ const swapped = this.text(basic.decoded);
456
+ if (swapped !== basic.decoded) {
457
+ const encoded = Buffer.from(swapped, "latin1").toString("base64");
458
+ // An upstream that echoes the header echoes the base64 form, which
459
+ // no scan for the raw value would recognise.
460
+ this.echoes.push({ from: encoded, to: basic.token });
461
+ return `${basic.scheme}${encoded}`;
462
+ }
463
+ }
464
+ return this.text(value);
465
+ }
466
+
467
+ /** Records placeholders where they are never swapped (an excluded header,
468
+ * the URL), leaving the text as it is. */
469
+ observe(value: string, reason: string): void {
470
+ this.walk(value, (placeholder) => {
471
+ this.skipped.add(reason);
472
+ return placeholder;
473
+ });
474
+ }
475
+
476
+ /** Whether one of this task's placeholders occurs at all, bound to this
477
+ * domain or not — the test for refusing an upgrade. */
478
+ contains(value: string): boolean {
479
+ let found = false;
480
+ this.walk(value, (placeholder) => {
481
+ found = true;
482
+ return placeholder;
483
+ });
484
+ return found;
485
+ }
486
+
487
+ private walk(value: string, onPlaceholder: (placeholder: string) => string): string {
488
+ if (this.matcher === null) {
489
+ this.noteForeign(value);
490
+ return value;
491
+ }
492
+ let output = "";
493
+ let last = 0;
494
+ for (const match of value.matchAll(this.matcher)) {
495
+ const at = match.index ?? 0;
496
+ const between = value.slice(last, at);
497
+ this.noteForeign(between);
498
+ output += between + onPlaceholder(match[0]);
499
+ last = at + match[0].length;
500
+ }
501
+ const rest = value.slice(last);
502
+ this.noteForeign(rest);
503
+ return last === 0 ? value : output + rest;
504
+ }
505
+
506
+ /** Another task's placeholder (or a stale one) is forwarded unchanged. */
507
+ private noteForeign(text: string): void {
508
+ if (text.includes(PLACEHOLDER_SHAPE)) this.skipped.add("foreign_placeholder");
509
+ }
510
+
511
+ private resolve(placeholder: string): string {
512
+ let ref: ProxyKeyRef | null = null;
513
+ try {
514
+ ref = this.state.lookup(placeholder);
515
+ } catch {
516
+ ref = null;
517
+ }
518
+ if (ref === null) {
519
+ this.skipped.add("unknown_placeholder");
520
+ return placeholder;
521
+ }
522
+ const domains = Array.isArray(ref.domains) ? ref.domains : [];
523
+ if (!domains.some((domain) => canonicalHostname(domain) === this.domain)) {
524
+ this.skipped.add("other_domain");
525
+ return placeholder;
526
+ }
527
+ const id = `${ref.projectId}\u0000${ref.key}`;
528
+ let value: string | null;
529
+ if (this.values.has(id)) {
530
+ value = this.values.get(id) ?? null;
531
+ } else {
532
+ try {
533
+ const current = this.state.realValue(ref);
534
+ value = typeof current === "string" ? current : null;
535
+ } catch {
536
+ value = null;
537
+ }
538
+ this.values.set(id, value);
539
+ }
540
+ if (value === null) {
541
+ this.refusal ??= "withheld";
542
+ return placeholder;
543
+ }
544
+ if (!HEADER_SAFE_VALUE.test(value)) {
545
+ this.refusal ??= "unencodable_value";
546
+ return placeholder;
547
+ }
548
+ // A header credential beyond this is not one; it would also make every
549
+ // scan of its responses proportionally costlier.
550
+ if (value.length > MAX_PROXIED_VALUE_CHARS) {
551
+ this.refusal ??= "value_too_long";
552
+ return placeholder;
553
+ }
554
+ this.injected.set(id, { ref, value, placeholder });
555
+ return value;
556
+ }
557
+ }
558
+
559
+ function sendError(res: ServerResponse, status: number, message: string): void {
560
+ if (res.headersSent || res.destroyed) {
561
+ res.destroy();
562
+ return;
563
+ }
564
+ const body = JSON.stringify({ error: `uai secrets proxy: ${message}` });
565
+ res.writeHead(status, {
566
+ "content-type": "application/json",
567
+ "content-length": Buffer.byteLength(body),
568
+ // A refused exchange ends the connection: the rest of its request body
569
+ // may still be arriving, and nothing after it is worth parsing.
570
+ connection: "close",
571
+ });
572
+ res.end(body);
573
+ }
574
+
575
+ /** A refusal on a socket the HTTP server has let go of (upgrade, CONNECT,
576
+ * a parse error). */
577
+ function writeRawRefusal(socket: Duplex, status: number, message: string): void {
578
+ if (socket.destroyed || !socket.writable) {
579
+ socket.destroy();
580
+ return;
581
+ }
582
+ const body = JSON.stringify({ error: `uai secrets proxy: ${message}` });
583
+ socket.end(
584
+ `HTTP/1.1 ${status} ${STATUS_CODES[status] ?? "Error"}\r\n` +
585
+ `content-type: application/json\r\ncontent-length: ${Buffer.byteLength(body)}\r\n` +
586
+ `connection: close\r\n\r\n${body}`,
587
+ () => socket.destroy(),
588
+ );
589
+ }
590
+
591
+ /** writeHead validates names and values and throws synchronously; inside an
592
+ * event handler that would be an uncaught exception in the runner. */
593
+ function writeHead(res: ServerResponse, status: number, message: string | undefined, headers: string[]): boolean {
594
+ try {
595
+ res.writeHead(status, message, headers);
596
+ return true;
597
+ } catch {
598
+ res.destroy();
599
+ return false;
600
+ }
601
+ }
602
+
603
+ /** The request head as parsed — names, values, order and duplicates as the
604
+ * client sent them (header strings are latin1, as Node decoded them). */
605
+ function serializeHead(req: IncomingMessage): Buffer {
606
+ let head = `${req.method ?? "GET"} ${req.url ?? "/"} HTTP/${req.httpVersion}\r\n`;
607
+ for (const [name, value] of headerPairs(req.rawHeaders)) head += `${name}: ${value}\r\n`;
608
+ return Buffer.from(`${head}\r\n`, "latin1");
609
+ }
610
+
611
+ function positive(value: number | undefined, fallback: number): number {
612
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback;
613
+ }
614
+
615
+ export function createSecretsProxy(deps: SecretsProxyDeps): SecretsProxy {
616
+ const limits: SecretsProxyLimits = {
617
+ handshakeMs: positive(deps.limits?.handshakeMs, DEFAULT_SECRETS_PROXY_LIMITS.handshakeMs),
618
+ headersMs: positive(deps.limits?.headersMs, DEFAULT_SECRETS_PROXY_LIMITS.headersMs),
619
+ idleMs: positive(deps.limits?.idleMs, DEFAULT_SECRETS_PROXY_LIMITS.idleMs),
620
+ maxHeaderBytes: positive(deps.limits?.maxHeaderBytes, DEFAULT_SECRETS_PROXY_LIMITS.maxHeaderBytes),
621
+ maxConnectionsPerTask: positive(
622
+ deps.limits?.maxConnectionsPerTask,
623
+ DEFAULT_SECRETS_PROXY_LIMITS.maxConnectionsPerTask,
624
+ ),
625
+ maxBufferedBody: positive(deps.limits?.maxBufferedBody, DEFAULT_SECRETS_PROXY_LIMITS.maxBufferedBody),
626
+ upstreamConnectMs: positive(deps.limits?.upstreamConnectMs, DEFAULT_SECRETS_PROXY_LIMITS.upstreamConnectMs),
627
+ };
628
+ const now = deps.now ?? Date.now;
629
+ const publicAddress = deps.isPublicAddress ?? isPublicAddress;
630
+ const upstreamPort = deps.upstreamPort ?? 443;
631
+ // Built once: https.request would otherwise parse ~150 root PEMs per call.
632
+ // An explicit context also means NODE_EXTRA_CA_CERTS can never widen it.
633
+ const upstreamContext = createSecureContext({
634
+ ca: deps.upstreamCa ?? [...rootCertificates],
635
+ minVersion: "TLSv1.2",
636
+ });
637
+ const connections = new Set<Connection>();
638
+ const openPerTask = new Map<string, number>();
639
+ const bySocket = new WeakMap<Socket, Connection>();
640
+ const contexts = new WeakMap<LeafCertificate, SecureContext>();
641
+ let closing = false;
642
+
643
+ const audit = (entry: Record<string, unknown>): void => {
644
+ try {
645
+ deps.audit({ ts: now(), ...entry });
646
+ } catch {
647
+ // Audit is best-effort, as for mcp-audit; it never breaks a connection.
648
+ }
649
+ };
650
+ const refuse = (taskId: string, domain: string | null, reason: string): void => {
651
+ audit({ taskId, domain, event: "refused", reason });
652
+ };
653
+ const skip = (taskId: string, domain: string, reason: string): void => {
654
+ audit({ taskId, domain, event: "skipped", reason });
655
+ };
656
+
657
+ const taskState = (taskId: string): ProxyTaskState | null => {
658
+ try {
659
+ return deps.getTaskState(taskId);
660
+ } catch {
661
+ return null;
662
+ }
663
+ };
664
+ const current = (conn: Pick<Connection, "isCurrent">): boolean => {
665
+ try {
666
+ return conn.isCurrent() === true;
667
+ } catch {
668
+ return false;
669
+ }
670
+ };
671
+ /** A connection's task state: none once its generation is revoked, even
672
+ * if the task (a later generation of it) is still served. */
673
+ const stateOf = (conn: Connection): ProxyTaskState | null => (current(conn) ? taskState(conn.taskId) : null);
674
+ const declares = (state: ProxyTaskState, domain: string): boolean => {
675
+ try {
676
+ return state.domains().has(domain);
677
+ } catch {
678
+ return false;
679
+ }
680
+ };
681
+ const vetted = (address: string): boolean => {
682
+ try {
683
+ return isIP(address) !== 0 && publicAddress(address) === true;
684
+ } catch {
685
+ return false;
686
+ }
687
+ };
688
+
689
+ const clearTimer = (conn: Connection): void => {
690
+ if (conn.timer !== null) {
691
+ clearTimeout(conn.timer);
692
+ conn.timer = null;
693
+ }
694
+ };
695
+ const arm = (conn: Connection, ms: number, onExpire: () => void): void => {
696
+ clearTimer(conn);
697
+ conn.timer = setTimeout(onExpire, ms);
698
+ conn.timer.unref();
699
+ };
700
+
701
+ const closeConnection = (conn: Connection): void => {
702
+ if (conn.closed) return;
703
+ conn.closed = true;
704
+ clearTimer(conn);
705
+ for (const child of conn.children) child.destroy();
706
+ conn.children.clear();
707
+ conn.tls?.destroy();
708
+ conn.raw.destroy();
709
+ connections.delete(conn);
710
+ const open = (openPerTask.get(conn.taskId) ?? 1) - 1;
711
+ if (open > 0) openPerTask.set(conn.taskId, open);
712
+ else openPerTask.delete(conn.taskId);
713
+ };
714
+
715
+ /** The generation fence, first in every HTTP event and every failure
716
+ * path: a connection whose generation was revoked is answered nothing —
717
+ * no 100, 417, 400 or 502 — and the audit says why, whatever else was
718
+ * wrong with the exchange. True when the connection is (now) closed. */
719
+ const fenced = (conn: Connection, domain: string | null): boolean => {
720
+ if (conn.closed) return true;
721
+ if (current(conn)) return false;
722
+ refuse(conn.taskId, domain, "task_revoked");
723
+ closeConnection(conn);
724
+ return true;
725
+ };
726
+
727
+ const idle = (conn: Connection): void => {
728
+ conn.phase = "idle";
729
+ arm(conn, limits.idleMs, () => closeConnection(conn));
730
+ };
731
+ const beginHead = (conn: Connection): void => {
732
+ conn.phase = "head";
733
+ arm(conn, limits.headersMs, () => {
734
+ refuse(conn.taskId, conn.domain, "headers_timeout");
735
+ closeConnection(conn);
736
+ });
737
+ };
738
+ const detach = (conn: Connection, socket: Duplex): void => {
739
+ conn.phase = "tunnel";
740
+ clearTimer(conn);
741
+ if (conn.onData !== null) {
742
+ socket.removeListener("data", conn.onData);
743
+ conn.onData = null;
744
+ }
745
+ };
746
+
747
+ const secureContextFor = (leaf: LeafCertificate): SecureContext => {
748
+ let context = contexts.get(leaf);
749
+ if (context === undefined) {
750
+ context = createSecureContext({ key: leaf.privateKeyPem, cert: leaf.certificatePem });
751
+ contexts.set(leaf, context);
752
+ }
753
+ return context;
754
+ };
755
+
756
+ const vetUpstream = async (domain: string): Promise<VettedAddress | { refusal: string }> => {
757
+ let answer: unknown;
758
+ let timer: NodeJS.Timeout | undefined;
759
+ try {
760
+ // A resolver that never answers must not pin the connection.
761
+ answer = await Promise.race([
762
+ deps.resolveHost(domain),
763
+ new Promise<never>((_resolve, reject) => {
764
+ timer = setTimeout(() => reject(new Error("resolve timed out")), limits.upstreamConnectMs);
765
+ timer.unref();
766
+ }),
767
+ ]);
768
+ } catch {
769
+ return { refusal: "resolve_failed" };
770
+ } finally {
771
+ clearTimeout(timer);
772
+ }
773
+ if (!Array.isArray(answer) || answer.length === 0) return { refusal: "resolve_failed" };
774
+ // One non-public address rejects the whole answer: a rebinding record
775
+ // cannot win by hiding a private address among public ones.
776
+ for (const address of answer) {
777
+ if (typeof address !== "string" || !vetted(address)) return { refusal: "non_public_address" };
778
+ }
779
+ const address = answer[0] as string;
780
+ return { address, family: isIP(address) === 6 ? 6 : 4 };
781
+ };
782
+
783
+ /** Pins Node's connect to the vetted address while `host`/`servername`
784
+ * stay the domain, so hostname verification still checks the domain. */
785
+ const lookupFor = (target: VettedAddress): LookupFunction =>
786
+ (_hostname, options, callback) => {
787
+ if (options.all === true) callback(null, [{ address: target.address, family: target.family }]);
788
+ else callback(null, target.address, target.family);
789
+ };
790
+
791
+ /** Whether every injected value is still exactly what the task may send to
792
+ * `domain` now: the generation and the domain grant still stand, each
793
+ * placeholder still maps to its key, the key still reaches the domain, and
794
+ * its current value (null once withheld, as when the task is no longer
795
+ * ready) is the one about to be sent. The refusal reason, or null. */
796
+ const stillAdmitted = (conn: Connection, domain: string, injected: readonly Injected[]): string | null => {
797
+ const state = stateOf(conn);
798
+ if (state === null) return "task_revoked";
799
+ if (!declares(state, domain)) return "domain_revoked";
800
+ for (const { ref, value, placeholder } of injected) {
801
+ let current: ProxyKeyRef | null;
802
+ try {
803
+ current = state.lookup(placeholder);
804
+ } catch {
805
+ current = null;
806
+ }
807
+ if (current === null || current.projectId !== ref.projectId || current.key !== ref.key) return "withdrawn";
808
+ const domains = Array.isArray(current.domains) ? current.domains : [];
809
+ if (!domains.some((candidate) => canonicalHostname(candidate) === domain)) return "withdrawn";
810
+ let valueNow: string | null;
811
+ try {
812
+ const read = state.realValue(current);
813
+ valueNow = typeof read === "string" ? read : null;
814
+ } catch {
815
+ valueNow = null;
816
+ }
817
+ if (valueNow !== value) return "withdrawn";
818
+ }
819
+ return null;
820
+ };
821
+
822
+ /** One record per injected key, written BEFORE the value leaves. The audit
823
+ * is part of the invariant (ADR-153 §1): when the record cannot be
824
+ * written, the request is refused and nothing is sent. */
825
+ const recordInjections = (conn: Connection, domain: string, method: string, injected: Iterable<Injected>): boolean => {
826
+ try {
827
+ for (const { ref } of injected) {
828
+ deps.audit({ ts: now(), taskId: conn.taskId, event: "injected", key: ref.key, projectId: ref.projectId, domain, method });
829
+ }
830
+ return true;
831
+ } catch {
832
+ return false;
833
+ }
834
+ };
835
+ /** How the upstream answered an injected request; best-effort, since the
836
+ * value has already gone by now. */
837
+ const recordResponses = (
838
+ conn: Connection,
839
+ domain: string,
840
+ method: string,
841
+ injected: Iterable<Injected>,
842
+ status: number | null,
843
+ ): void => {
844
+ for (const { ref } of injected) {
845
+ audit({ taskId: conn.taskId, event: "response", key: ref.key, projectId: ref.projectId, domain, method, status });
846
+ }
847
+ };
848
+
849
+ const http = createHttpServer({
850
+ maxHeaderSize: limits.maxHeaderBytes,
851
+ insecureHTTPParser: false,
852
+ // Our handler answers (and audits) a missing Host; Node's built-in 400
853
+ // would skip the audit.
854
+ requireHostHeader: false,
855
+ // Header and idle timers are ours: Node only runs its headersTimeout and
856
+ // requestTimeout checks once a server has emitted 'listening', and this
857
+ // one never listens.
858
+ keepAliveTimeout: 0,
859
+ headersTimeout: 0,
860
+ requestTimeout: 0,
861
+ });
862
+
863
+ const passThrough = (res: ServerResponse, upstreamRes: IncomingMessage): void => {
864
+ const headers = endToEndHeaders(upstreamRes.rawHeaders);
865
+ if (!writeHead(res, upstreamRes.statusCode ?? 502, upstreamRes.statusMessage, headers)) {
866
+ upstreamRes.destroy();
867
+ return;
868
+ }
869
+ pipeline(upstreamRes, res, (error) => {
870
+ if (error) {
871
+ upstreamRes.destroy();
872
+ res.destroy();
873
+ }
874
+ });
875
+ };
876
+
877
+ const respondScrubbed = (
878
+ conn: Connection,
879
+ domain: string,
880
+ req: IncomingMessage,
881
+ res: ServerResponse,
882
+ upstreamRes: IncomingMessage,
883
+ rules: readonly ScrubRule[],
884
+ ): void => {
885
+ const fail = (reason: string): void => {
886
+ refuse(conn.taskId, domain, reason);
887
+ upstreamRes.destroy();
888
+ sendError(res, 502, "the upstream response could not be scrubbed");
889
+ };
890
+ const status = upstreamRes.statusCode ?? 502;
891
+ // Without Range (dropped when injecting) a 206 is unexpected, and a
892
+ // fragment could hold part of a value no scan can see.
893
+ if (status === 206) return fail("partial_content");
894
+ // Sorted once for the status line and every header of this response.
895
+ const prepared = new PreparedRules(rules);
896
+ const statusMessage = scrubHeaderText(upstreamRes.statusMessage ?? "", prepared);
897
+ if (statusMessage === null) return fail("scrub_failed");
898
+ const headers: string[] = [];
899
+ for (const [name, value] of headerPairs(endToEndHeaders(upstreamRes.rawHeaders))) {
900
+ const lower = name.toLowerCase();
901
+ if (VALIDATOR_HEADERS.has(lower) || lower === "content-length") continue;
902
+ const scrubbedName = scrubHeaderText(name, prepared);
903
+ const scrubbedValue = scrubHeaderText(value, prepared);
904
+ if (scrubbedName === null || scrubbedValue === null) return fail("scrub_failed");
905
+ headers.push(scrubbedName, scrubbedValue);
906
+ }
907
+ if (req.method === "HEAD" || status === 204 || status === 304 || status < 200) {
908
+ // No body follows, so the upstream's length still describes the
909
+ // entity (a HEAD size probe); only a body we rewrite loses it.
910
+ const length = declaredLength(upstreamRes.rawHeaders);
911
+ const bodyless = length !== null && status !== 204 && status >= 200 ? [...headers, "Content-Length", String(length)] : headers;
912
+ if (writeHead(res, status, statusMessage, bodyless)) res.end();
913
+ upstreamRes.resume();
914
+ return;
915
+ }
916
+ const coding = contentCoding(upstreamRes.headers["content-encoding"]);
917
+ const codec = coding === null || coding === "identity" ? null : codecFor(coding);
918
+ // Nothing unscannable passes after an injection.
919
+ if (coding === null || (coding !== "identity" && codec === null)) return fail("unsupported_encoding");
920
+ const length = declaredLength(upstreamRes.rawHeaders);
921
+ const buffered = length !== null && length <= limits.maxBufferedBody;
922
+ let failure = "upstream_failed";
923
+ const stages: Transform[] = [];
924
+ if (codec !== null) {
925
+ const decoder = codec.decode();
926
+ decoder.once("error", () => {
927
+ failure = "decode_failed";
928
+ });
929
+ stages.push(decoder);
930
+ }
931
+ const scrubber = createScrubStream(prepared, buffered ? { maxInputBytes: MAX_DECODED_BUFFERED_BYTES } : {});
932
+ scrubber.once("error", (error) => {
933
+ failure = error instanceof ScrubError && error.code === "too_large" ? "body_too_large" : "scrub_failed";
934
+ });
935
+ stages.push(scrubber);
936
+ if (codec !== null) stages.push(codec.encode(!buffered));
937
+
938
+ if (buffered) {
939
+ const chunks: Buffer[] = [];
940
+ const sink = new Writable({
941
+ write(chunk: Buffer, _encoding, callback) {
942
+ chunks.push(chunk);
943
+ callback();
944
+ },
945
+ });
946
+ pipeline([upstreamRes, ...stages, sink], (error) => {
947
+ if (error) {
948
+ if (failure !== "upstream_failed") refuse(conn.taskId, domain, failure);
949
+ sendError(res, 502, "the upstream response could not be scrubbed");
950
+ return;
951
+ }
952
+ if (res.destroyed) return;
953
+ const body = Buffer.concat(chunks);
954
+ if (writeHead(res, status, statusMessage, [...headers, "Content-Length", String(body.length)])) res.end(body);
955
+ });
956
+ return;
957
+ }
958
+ // Unsized or large: chunked, scrubbed as it streams.
959
+ if (!writeHead(res, status, statusMessage, headers)) {
960
+ upstreamRes.destroy();
961
+ return;
962
+ }
963
+ res.flushHeaders();
964
+ pipeline([upstreamRes, ...stages, res], (error) => {
965
+ if (error) {
966
+ if (failure !== "upstream_failed") refuse(conn.taskId, domain, failure);
967
+ res.destroy();
968
+ }
969
+ });
970
+ };
971
+
972
+ const forward = (
973
+ conn: Connection,
974
+ req: IncomingMessage,
975
+ res: ServerResponse,
976
+ domain: string,
977
+ path: string,
978
+ headers: string[],
979
+ swapper: Swapper,
980
+ target: VettedAddress,
981
+ ): void => {
982
+ const method = req.method ?? "GET";
983
+ const injected = [...swapper.injected.values()];
984
+ let tcpConnected = false;
985
+ let secured = false;
986
+ let answered = false;
987
+ let responseRecorded = injected.length === 0;
988
+ const recordOnce = (status: number | null): void => {
989
+ if (responseRecorded) return;
990
+ responseRecorded = true;
991
+ recordResponses(conn, domain, method, injected, status);
992
+ };
993
+
994
+ // `secureContext` and `ALPNProtocols` are not in https.RequestOptions'
995
+ // type, but the agent hands every option to tls.connect, which honours
996
+ // them.
997
+ const options: HttpsRequestOptions & Pick<ConnectionOptions, "secureContext" | "ALPNProtocols"> = {
998
+ host: domain,
999
+ port: upstreamPort,
1000
+ servername: domain,
1001
+ lookup: lookupFor(target),
1002
+ method,
1003
+ path,
1004
+ headers,
1005
+ setHost: false,
1006
+ // Never pooled (ADR-153 Limits): some upstreams bind authentication
1007
+ // to the CONNECTION (NTLM/Negotiate), and every way of sharing one —
1008
+ // across tasks, across a rotation, across Basic wrappers around the same
1009
+ // value — let an identity the current request does not carry, and its
1010
+ // scrub rules do not know, answer through it. A connection per request
1011
+ // makes that impossible; the price is one handshake per proxied call.
1012
+ agent: false,
1013
+ secureContext: upstreamContext,
1014
+ rejectUnauthorized: true,
1015
+ ALPNProtocols: ["http/1.1"],
1016
+ };
1017
+ const upstreamReq = httpsRequest(options);
1018
+ conn.children.add(upstreamReq);
1019
+ const send = (): void => {
1020
+ const hasBody = req.headers["transfer-encoding"] !== undefined || req.headers["content-length"] !== undefined;
1021
+ if (!hasBody) {
1022
+ upstreamReq.end();
1023
+ return;
1024
+ }
1025
+ // .pipe rather than pipeline(): an upstream failure must not destroy
1026
+ // the client socket before our 502 has gone out on it. Bodies are never
1027
+ // swapped into, but a placeholder there is recorded as a skip.
1028
+ req
1029
+ // The generic shape too, so another task's (or a stale) placeholder
1030
+ // is recorded like it is in a header.
1031
+ .pipe(observePlaceholders([...swapper.all, PLACEHOLDER_SHAPE], () => skip(conn.taskId, domain, "in_body")))
1032
+ .pipe(upstreamReq);
1033
+ };
1034
+ const connectTimer = setTimeout(() => {
1035
+ upstreamReq.destroy(Object.assign(new Error("upstream connect timed out"), { code: "UAI_UPSTREAM_TIMEOUT" }));
1036
+ }, limits.upstreamConnectMs);
1037
+ connectTimer.unref();
1038
+ upstreamReq.on("socket", (socket) => {
1039
+ socket.once("connect", () => {
1040
+ tcpConnected = true;
1041
+ });
1042
+ // Nothing is written to the request before this point, so nothing —
1043
+ // least of all a value — leaves before verification passed and the
1044
+ // generation is re-checked, the last moment before it would.
1045
+ socket.once("secureConnect", () => {
1046
+ secured = true;
1047
+ clearTimeout(connectTimer);
1048
+ if (fenced(conn, domain)) {
1049
+ answered = true;
1050
+ return;
1051
+ }
1052
+ if (injected.length > 0) {
1053
+ // The last moment before a byte leaves. The lookup and the upstream
1054
+ // handshake took time: the policy may have narrowed, the task's
1055
+ // readiness failed, or the value rotated. Every value must still be
1056
+ // exactly what may be sent here now, and its injection is recorded
1057
+ // here, where it is admitted.
1058
+ const withdrawn = stillAdmitted(conn, domain, injected);
1059
+ const refusal =
1060
+ withdrawn ?? (recordInjections(conn, domain, method, injected) ? null : "audit_failed");
1061
+ if (refusal !== null) {
1062
+ answered = true;
1063
+ // Nothing was recorded as injected, so no response is either.
1064
+ responseRecorded = true;
1065
+ refuse(conn.taskId, domain, refusal);
1066
+ upstreamReq.destroy();
1067
+ sendError(
1068
+ res,
1069
+ 502,
1070
+ refusal === "audit_failed"
1071
+ ? "the injection could not be audited, so it was not sent"
1072
+ : "a proxied secret was withdrawn while the request was under way, so it was not sent",
1073
+ );
1074
+ return;
1075
+ }
1076
+ }
1077
+ send();
1078
+ });
1079
+ });
1080
+ upstreamReq.on("error", (error: NodeJS.ErrnoException) => {
1081
+ clearTimeout(connectTimer);
1082
+ conn.children.delete(upstreamReq);
1083
+ recordOnce(null);
1084
+ // Once a response is under way its pipeline owns teardown: an EPIPE on
1085
+ // the request side (an upstream that answered early and stopped
1086
+ // reading) must not cut a healthy response short. A connection already
1087
+ // closed (the client left, or the task was revoked) was not failed by
1088
+ // the upstream, and is not audited as if it had been.
1089
+ if (answered || conn.closed) return;
1090
+ answered = true;
1091
+ if (fenced(conn, domain)) return;
1092
+ const reason =
1093
+ error.code === "UAI_UPSTREAM_TIMEOUT"
1094
+ ? "upstream_timeout"
1095
+ : secured
1096
+ ? "upstream_failed"
1097
+ : tcpConnected
1098
+ ? "upstream_tls"
1099
+ : "upstream_unreachable";
1100
+ refuse(conn.taskId, domain, reason);
1101
+ sendError(res, 502, "the upstream request failed");
1102
+ });
1103
+ upstreamReq.on("response", (upstreamRes) => {
1104
+ clearTimeout(connectTimer);
1105
+ // Teardown belongs to the pipelines below (a buffered response can
1106
+ // still answer 502); this only keeps a late error from going unhandled.
1107
+ upstreamRes.on("error", () => undefined);
1108
+ upstreamRes.once("close", () => conn.children.delete(upstreamReq));
1109
+ recordOnce(upstreamRes.statusCode ?? null);
1110
+ if (answered || res.destroyed) {
1111
+ upstreamRes.destroy();
1112
+ return;
1113
+ }
1114
+ answered = true;
1115
+ if (fenced(conn, domain)) {
1116
+ upstreamRes.destroy();
1117
+ return;
1118
+ }
1119
+ // Node decodes chunked framing and nothing else: under
1120
+ // `Transfer-Encoding: gzip, chunked` the body arrives still gzipped,
1121
+ // with no Content-Encoding to say so — unscannable, and unrelayable as
1122
+ // it stands once the hop-by-hop header is gone.
1123
+ if (hasForeignTransferCoding(upstreamRes.rawHeaders)) {
1124
+ refuse(conn.taskId, domain, "unsupported_transfer_coding");
1125
+ upstreamRes.destroy();
1126
+ sendError(res, 502, "the upstream response used a transfer coding the proxy cannot relay");
1127
+ return;
1128
+ }
1129
+ if (injected.length === 0) passThrough(res, upstreamRes);
1130
+ else respondScrubbed(conn, domain, req, res, upstreamRes, scrubRules(injected, swapper.echoes));
1131
+ });
1132
+ // A client that leaves mid-exchange takes the upstream request with it.
1133
+ res.once("close", () => {
1134
+ if (!res.writableFinished) upstreamReq.destroy();
1135
+ });
1136
+
1137
+ req.on("error", () => upstreamReq.destroy());
1138
+ };
1139
+
1140
+ const handleRequest = async (conn: Connection, req: IncomingMessage, res: ServerResponse): Promise<void> => {
1141
+ const domain = conn.domain ?? "";
1142
+ const refuseRequest = (status: number, reason: string, message: string): void => {
1143
+ refuse(conn.taskId, domain, reason);
1144
+ sendError(res, status, message);
1145
+ };
1146
+ const state = stateOf(conn);
1147
+ if (state === null) {
1148
+ // The task's generation was revoked: nothing of it is served any more.
1149
+ refuse(conn.taskId, domain, "task_revoked");
1150
+ closeConnection(conn);
1151
+ return;
1152
+ }
1153
+ if (!declares(state, domain)) {
1154
+ refuseRequest(421, "domain_revoked", "this domain is no longer proxied for the task");
1155
+ return;
1156
+ }
1157
+ const head = checkRequestHead(req, domain);
1158
+ if (!head.ok) {
1159
+ refuseRequest(head.status, head.reason, head.message);
1160
+ return;
1161
+ }
1162
+
1163
+ const swapper = new Swapper(state, domain);
1164
+ const listed = connectionTokens(req.rawHeaders);
1165
+ const forwarded: string[] = [];
1166
+ for (const [name, value] of headerPairs(req.rawHeaders)) {
1167
+ const lower = name.toLowerCase();
1168
+ if (HOP_BY_HOP.has(lower) || PROXY_SET.has(lower) || listed.has(lower)) continue;
1169
+ swapper.observe(name, "header_name");
1170
+ if (NEVER_SWAPPED.has(lower) || lower.startsWith("x-forwarded-")) {
1171
+ swapper.observe(value, "excluded_header");
1172
+ forwarded.push(name, value);
1173
+ continue;
1174
+ }
1175
+ forwarded.push(name, lower === "authorization" ? swapper.authorization(value) : swapper.text(value));
1176
+ }
1177
+ // Query and path are never swapped (varlock's default since 1.14): a URL
1178
+ // lets an agent write the secret into a resource it can read back.
1179
+ swapper.observe(head.path, "in_url");
1180
+ try {
1181
+ swapper.observe(decodeURIComponent(head.path), "in_url");
1182
+ } catch {
1183
+ // Malformed escapes: the raw form was already checked.
1184
+ }
1185
+ for (const reason of swapper.skipped) skip(conn.taskId, domain, reason);
1186
+ if (swapper.refusal === "withheld") {
1187
+ refuseRequest(502, "withheld", "a proxied secret is withheld for this task");
1188
+ return;
1189
+ }
1190
+ if (swapper.refusal === "unencodable_value") {
1191
+ refuseRequest(502, "unencodable_value", "a proxied secret cannot be carried in a header");
1192
+ return;
1193
+ }
1194
+ if (swapper.refusal === "value_too_long") {
1195
+ refuseRequest(502, "value_too_long", "a proxied secret is too long to carry");
1196
+ return;
1197
+ }
1198
+
1199
+ const injecting = swapper.injected.size > 0;
1200
+ const headers = ["Host", domain];
1201
+ for (const [name, value] of headerPairs(forwarded)) {
1202
+ if (injecting && RANGE_HEADERS.has(name.toLowerCase())) continue;
1203
+ headers.push(name, value);
1204
+ }
1205
+ headers.push("Accept-Encoding", "identity");
1206
+ // Framing from Node's parse, re-emitted explicitly: with no length and no
1207
+ // chunking, Node would write a GET's body raw — a smuggled request.
1208
+ if (req.headers["transfer-encoding"] !== undefined) headers.push("Transfer-Encoding", "chunked");
1209
+ else if (req.headers["content-length"] !== undefined) headers.push("Content-Length", req.headers["content-length"]);
1210
+
1211
+ const target = await vetUpstream(domain);
1212
+ if (conn.closed || res.destroyed) return;
1213
+ // The lookup awaited: the generation may have been revoked meanwhile, and
1214
+ // the gateway's contract is to re-check right before using the task's
1215
+ // authority — before a failed lookup is answered, too.
1216
+ if (fenced(conn, domain)) return;
1217
+ if ("refusal" in target) {
1218
+ refuseRequest(502, target.refusal, "the upstream address was refused");
1219
+ return;
1220
+ }
1221
+ // The injection is admitted, and recorded, at the upstream's handshake.
1222
+ forward(conn, req, res, domain, head.path, headers, swapper, target);
1223
+ };
1224
+
1225
+ const tunnel = (
1226
+ conn: Connection,
1227
+ req: IncomingMessage,
1228
+ client: TLSSocket,
1229
+ head: Buffer,
1230
+ domain: string,
1231
+ target: VettedAddress,
1232
+ ): void => {
1233
+ let tcpConnected = false;
1234
+ let secured = false;
1235
+ const upstream = tlsConnect({
1236
+ host: domain,
1237
+ port: upstreamPort,
1238
+ servername: domain,
1239
+ lookup: lookupFor(target),
1240
+ secureContext: upstreamContext,
1241
+ rejectUnauthorized: true,
1242
+ ALPNProtocols: ["http/1.1"],
1243
+ });
1244
+ conn.children.add(upstream);
1245
+ const connectTimer = setTimeout(() => {
1246
+ upstream.destroy(Object.assign(new Error("upstream connect timed out"), { code: "UAI_UPSTREAM_TIMEOUT" }));
1247
+ }, limits.upstreamConnectMs);
1248
+ connectTimer.unref();
1249
+ upstream.once("connect", () => {
1250
+ tcpConnected = true;
1251
+ });
1252
+ upstream.on("error", (error: NodeJS.ErrnoException) => {
1253
+ clearTimeout(connectTimer);
1254
+ if (secured) {
1255
+ closeConnection(conn);
1256
+ return;
1257
+ }
1258
+ if (fenced(conn, domain)) return;
1259
+ const reason =
1260
+ error.code === "UAI_UPSTREAM_TIMEOUT"
1261
+ ? "upstream_timeout"
1262
+ : tcpConnected
1263
+ ? "upstream_tls"
1264
+ : "upstream_unreachable";
1265
+ refuse(conn.taskId, domain, reason);
1266
+ writeRawRefusal(client, 502, "the upstream connection failed");
1267
+ });
1268
+ upstream.once("secureConnect", () => {
1269
+ clearTimeout(connectTimer);
1270
+ secured = true;
1271
+ // Nothing of the client's has been sent yet: a generation revoked
1272
+ // during the connect gets no tunnel.
1273
+ if (fenced(conn, domain)) return;
1274
+ // Two pipelines plus the TLS and HTTP plumbing already on the task-side
1275
+ // socket pass Node's default ceiling of ten 'close' listeners. That is
1276
+ // a fixed count per tunnel, not growth, so it gets a fixed allowance
1277
+ // rather than a warning on every upgrade.
1278
+ client.setMaxListeners(Math.max(client.getMaxListeners(), 20));
1279
+ // Byte for byte from here: the head, anything the parser had already
1280
+ // read past it, then both directions. pipeline() propagates a FIN
1281
+ // (`end`) as well as errors, so neither side is left streaming into a
1282
+ // peer that has gone.
1283
+ upstream.write(serializeHead(req));
1284
+ if (head.length > 0) upstream.write(head);
1285
+ pipeline(client, upstream, (error) => {
1286
+ if (error) closeConnection(conn);
1287
+ });
1288
+ pipeline(upstream, client, (error) => {
1289
+ if (error) closeConnection(conn);
1290
+ });
1291
+ });
1292
+ };
1293
+
1294
+ const handleUpgrade = async (
1295
+ conn: Connection,
1296
+ req: IncomingMessage,
1297
+ client: TLSSocket,
1298
+ head: Buffer,
1299
+ ): Promise<void> => {
1300
+ const domain = conn.domain ?? "";
1301
+ const refuseUpgrade = (status: number, reason: string, message: string): void => {
1302
+ refuse(conn.taskId, domain, reason);
1303
+ writeRawRefusal(client, status, message);
1304
+ };
1305
+ const state = stateOf(conn);
1306
+ if (state === null) {
1307
+ refuse(conn.taskId, domain, "task_revoked");
1308
+ closeConnection(conn);
1309
+ return;
1310
+ }
1311
+ if (!declares(state, domain)) {
1312
+ refuseUpgrade(421, "domain_revoked", "this domain is no longer proxied for the task");
1313
+ return;
1314
+ }
1315
+ const checked = checkRequestHead(req, domain);
1316
+ if (!checked.ok) {
1317
+ refuseUpgrade(checked.status, checked.reason, checked.message);
1318
+ return;
1319
+ }
1320
+ // Upgrades are never injected (ADR-153 §3 rule 5). One that carries this
1321
+ // task's placeholder anywhere — request line, any header, a Basic
1322
+ // credential — is refused; one without passes byte for byte.
1323
+ const swapper = new Swapper(state, domain);
1324
+ const texts = [req.url ?? ""];
1325
+ for (const [name, value] of headerPairs(req.rawHeaders)) {
1326
+ texts.push(name, value);
1327
+ const basic = name.toLowerCase() === "authorization" ? decodeBasic(value) : null;
1328
+ if (basic !== null) texts.push(basic.decoded);
1329
+ }
1330
+ const carries = texts.map((text) => swapper.contains(text)).some(Boolean);
1331
+ for (const reason of swapper.skipped) skip(conn.taskId, domain, reason);
1332
+ if (carries) {
1333
+ refuseUpgrade(403, "upgrade_with_placeholder", "upgrades are never injected; remove the proxied secret");
1334
+ return;
1335
+ }
1336
+ const target = await vetUpstream(domain);
1337
+ // As for a request: the lookup awaited, and the generation may have been
1338
+ // revoked meanwhile.
1339
+ if (fenced(conn, domain)) return;
1340
+ if ("refusal" in target) {
1341
+ refuseUpgrade(502, target.refusal, "the upstream address was refused");
1342
+ return;
1343
+ }
1344
+ tunnel(conn, req, client, head, domain, target);
1345
+ };
1346
+
1347
+ /** The connection an HTTP event belongs to, or null (the socket is gone, or
1348
+ * the generation fence closed it). */
1349
+ const connectionFor = (socket: Duplex): Connection | null => {
1350
+ const conn = bySocket.get(socket as Socket);
1351
+ if (conn === undefined || conn.closed) {
1352
+ socket.destroy();
1353
+ return null;
1354
+ }
1355
+ return fenced(conn, conn.domain) ? null : conn;
1356
+ };
1357
+
1358
+ const onRequest = (conn: Connection, req: IncomingMessage, res: ServerResponse): void => {
1359
+ if (conn.inFlight >= MAX_IN_FLIGHT_PER_CONNECTION) {
1360
+ refuse(conn.taskId, conn.domain, "pipeline_limit");
1361
+ closeConnection(conn);
1362
+ return;
1363
+ }
1364
+ conn.inFlight += 1;
1365
+ conn.phase = "active";
1366
+ clearTimer(conn);
1367
+ res.once("close", () => {
1368
+ conn.inFlight -= 1;
1369
+ if (conn.closed) return;
1370
+ // Answered before its body finished arriving (an early 401 to an
1371
+ // upload): the rest of that body is not a next request head, so the
1372
+ // connection ends here rather than timing it out as one later.
1373
+ if (!req.complete) {
1374
+ closeConnection(conn);
1375
+ return;
1376
+ }
1377
+ if (conn.inFlight === 0 && conn.phase === "active") idle(conn);
1378
+ });
1379
+ handleRequest(conn, req, res).catch(() => {
1380
+ refuse(conn.taskId, conn.domain, "internal_error");
1381
+ sendError(res, 502, "internal error");
1382
+ });
1383
+ };
1384
+
1385
+ http.on("request", (req: IncomingMessage, res: ServerResponse) => {
1386
+ req.on("error", () => undefined);
1387
+ res.on("error", () => undefined);
1388
+ const conn = connectionFor(req.socket);
1389
+ if (conn !== null) onRequest(conn, req, res);
1390
+ });
1391
+
1392
+ // `Expect: 100-continue`. Node would write the 100 itself, before any
1393
+ // check of ours; it goes out only to a generation still current.
1394
+ http.on("checkContinue", (req: IncomingMessage, res: ServerResponse) => {
1395
+ req.on("error", () => undefined);
1396
+ res.on("error", () => undefined);
1397
+ const conn = connectionFor(req.socket);
1398
+ if (conn === null) return;
1399
+ res.writeContinue();
1400
+ onRequest(conn, req, res);
1401
+ });
1402
+
1403
+ // Any other expectation. Node would answer 417 itself, unaudited.
1404
+ http.on("checkExpectation", (req: IncomingMessage, res: ServerResponse) => {
1405
+ req.on("error", () => undefined);
1406
+ res.on("error", () => undefined);
1407
+ const conn = connectionFor(req.socket);
1408
+ if (conn === null) return;
1409
+ refuse(conn.taskId, conn.domain, "unsupported_expectation");
1410
+ sendError(res, 417, "only Expect: 100-continue is supported");
1411
+ });
1412
+
1413
+ http.on("upgrade", (req: IncomingMessage, socket: Duplex, head: Buffer) => {
1414
+ const conn = connectionFor(socket);
1415
+ if (conn === null) return;
1416
+ if (conn.inFlight > 0) {
1417
+ // An upgrade pipelined behind a request still being answered: tunnel
1418
+ // bytes and that response would interleave on one socket, and no
1419
+ // refusal could be written without the same interleaving.
1420
+ refuse(conn.taskId, conn.domain, "pipelined_upgrade");
1421
+ closeConnection(conn);
1422
+ return;
1423
+ }
1424
+ detach(conn, socket);
1425
+ handleUpgrade(conn, req, socket as TLSSocket, head).catch(() => closeConnection(conn));
1426
+ });
1427
+
1428
+ http.on("connect", (_req: IncomingMessage, socket: Duplex) => {
1429
+ const conn = connectionFor(socket);
1430
+ if (conn === null) return;
1431
+ detach(conn, socket);
1432
+ refuse(conn.taskId, conn.domain, "connect_method");
1433
+ writeRawRefusal(socket, 400, "CONNECT is not supported");
1434
+ });
1435
+
1436
+ http.on("clientError", (error: NodeJS.ErrnoException, socket: Duplex) => {
1437
+ const conn = bySocket.get(socket as Socket);
1438
+ if (conn === undefined) {
1439
+ socket.destroy();
1440
+ return;
1441
+ }
1442
+ const code = error.code ?? "";
1443
+ if (!code.startsWith("HPE_")) {
1444
+ closeConnection(conn);
1445
+ return;
1446
+ }
1447
+ if (fenced(conn, conn.domain)) return;
1448
+ detach(conn, socket);
1449
+ if (conn.inFlight > 0) {
1450
+ // Node emits `request` before failing on some heads (a bare
1451
+ // `Transfer-Encoding: gzip`), so the exchange in flight owns the
1452
+ // response and its audit: let what it wrote flush, then close.
1453
+ socket.end(() => closeConnection(conn));
1454
+ return;
1455
+ }
1456
+ const tooLarge = code === "HPE_HEADER_OVERFLOW";
1457
+ refuse(conn.taskId, conn.domain, tooLarge ? "headers_too_large" : "malformed_request");
1458
+ writeRawRefusal(socket, tooLarge ? 431 : 400, tooLarge ? "request headers too large" : "malformed request");
1459
+ });
1460
+
1461
+ const onSecure = (conn: Connection, tlsSocket: TLSSocket): void => {
1462
+ tlsSocket.on("error", () => closeConnection(conn));
1463
+ tlsSocket.once("close", () => closeConnection(conn));
1464
+ if (conn.closed) {
1465
+ tlsSocket.destroy();
1466
+ return;
1467
+ }
1468
+ conn.tls = tlsSocket;
1469
+ clearTimer(conn);
1470
+ const servername = typeof tlsSocket.servername === "string" ? canonicalHostname(tlsSocket.servername) : null;
1471
+ // Node skips SNICallback on a resumed session, so a ticket minted for one
1472
+ // domain could otherwise open another (checked live on Node 22.11 with a
1473
+ // shared tls.Server). Every connection gets its own tls.Server — fresh
1474
+ // ticket keys, nothing to resume — and a session that resumes anyway, or
1475
+ // whose name drifted from the one checked, is refused.
1476
+ const alpn = tlsSocket.alpnProtocol;
1477
+ if (
1478
+ conn.domain === null ||
1479
+ servername !== conn.domain ||
1480
+ tlsSocket.isSessionReused() ||
1481
+ (typeof alpn === "string" && alpn !== "http/1.1")
1482
+ ) {
1483
+ refuse(conn.taskId, conn.domain, "tls_state_mismatch");
1484
+ closeConnection(conn);
1485
+ return;
1486
+ }
1487
+ bySocket.set(tlsSocket, conn);
1488
+ http.emit("connection", tlsSocket);
1489
+ // Attached AFTER the parser took the socket, so it only observes (Node
1490
+ // then feeds its parser from 'data' events rather than natively). It
1491
+ // marks the first byte of a request head, where the header clock starts.
1492
+ const onData = (): void => {
1493
+ if (conn.inFlight === 0 && conn.phase === "idle") beginHead(conn);
1494
+ };
1495
+ conn.onData = onData;
1496
+ tlsSocket.on("data", onData);
1497
+ idle(conn);
1498
+ };
1499
+
1500
+ const startTls = (conn: Connection): void => {
1501
+ arm(conn, limits.handshakeMs, () => {
1502
+ refuse(conn.taskId, conn.domain, "handshake_timeout");
1503
+ closeConnection(conn);
1504
+ });
1505
+ const tlsServer = createTlsServer({
1506
+ ALPNProtocols: ["http/1.1"],
1507
+ minVersion: "TLSv1.2",
1508
+ // Only a backstop: Node's handshakeTimeout emits tlsClientError but
1509
+ // never closes the socket, and it cannot be switched off (0 means the
1510
+ // 120 s default). The absolute timer above is the real bound.
1511
+ handshakeTimeout: limits.handshakeMs * 2,
1512
+ SNICallback: (servername, callback) => {
1513
+ const domain = canonicalHostname(servername);
1514
+ const state = stateOf(conn);
1515
+ if (state === null) {
1516
+ refuse(conn.taskId, null, "task_revoked");
1517
+ callback(new Error("the task is no longer served"));
1518
+ return;
1519
+ }
1520
+ if (domain === null || !declares(state, domain)) {
1521
+ // The undeclared name is task-chosen text: never audited.
1522
+ refuse(conn.taskId, null, "sni_not_declared");
1523
+ callback(new Error("server name is not proxied for this task"));
1524
+ return;
1525
+ }
1526
+ // Defence in depth: the declaration parser already withholds keys
1527
+ // for these, so a state that still lists one is refused here too.
1528
+ if (!isProxiableDomain(domain)) {
1529
+ refuse(conn.taskId, domain, "domain_not_proxiable");
1530
+ callback(new Error("this server name is never proxied"));
1531
+ return;
1532
+ }
1533
+ let context: SecureContext;
1534
+ try {
1535
+ context = secureContextFor(deps.ca.leafFor(domain));
1536
+ } catch {
1537
+ refuse(conn.taskId, domain, "certificate_unavailable");
1538
+ callback(new Error("no certificate for this server name"));
1539
+ return;
1540
+ }
1541
+ conn.domain = domain;
1542
+ callback(null, context);
1543
+ },
1544
+ });
1545
+ tlsServer.on("tlsClientError", () => closeConnection(conn));
1546
+ tlsServer.on("secureConnection", (tlsSocket: TLSSocket) => onSecure(conn, tlsSocket));
1547
+ tlsServer.emit("connection", conn.raw);
1548
+ };
1549
+
1550
+ const handleConnection = ({
1551
+ taskId,
1552
+ socket,
1553
+ isCurrent,
1554
+ }: {
1555
+ taskId: string;
1556
+ socket: Socket;
1557
+ isCurrent: () => boolean;
1558
+ }): void => {
1559
+ try {
1560
+ // First, before anything can fail: an 'error' with no listener would
1561
+ // take the whole runner down.
1562
+ socket.on("error", () => socket.destroy());
1563
+ if (closing || socket.destroyed) {
1564
+ socket.destroy();
1565
+ return;
1566
+ }
1567
+ if (!current({ isCurrent })) {
1568
+ refuse(taskId, null, "task_revoked");
1569
+ socket.destroy();
1570
+ return;
1571
+ }
1572
+ if (taskState(taskId) === null) {
1573
+ refuse(taskId, null, "unknown_task");
1574
+ socket.destroy();
1575
+ return;
1576
+ }
1577
+ const open = openPerTask.get(taskId) ?? 0;
1578
+ if (open >= limits.maxConnectionsPerTask) {
1579
+ refuse(taskId, null, "connection_limit");
1580
+ socket.destroy();
1581
+ return;
1582
+ }
1583
+ openPerTask.set(taskId, open + 1);
1584
+ const conn: Connection = {
1585
+ taskId,
1586
+ raw: socket,
1587
+ isCurrent,
1588
+ tls: null,
1589
+ domain: null,
1590
+ phase: "handshake",
1591
+ inFlight: 0,
1592
+ timer: null,
1593
+ onData: null,
1594
+ children: new Set(),
1595
+ closed: false,
1596
+ };
1597
+ connections.add(conn);
1598
+ socket.once("close", () => closeConnection(conn));
1599
+ if (typeof socket.setNoDelay === "function") socket.setNoDelay(true);
1600
+ startTls(conn);
1601
+ } catch {
1602
+ socket.destroy();
1603
+ }
1604
+ };
1605
+
1606
+ const closeTask = (taskId: string): void => {
1607
+ for (const conn of [...connections]) {
1608
+ if (conn.taskId !== taskId) continue;
1609
+ // Work under way (a request awaiting DNS or its upstream, a tunnel)
1610
+ // ends because the task was revoked, and the audit says so, rather
1611
+ // than reading as a transport failure or as nothing at all.
1612
+ if (conn.phase === "active" || conn.phase === "tunnel") refuse(conn.taskId, conn.domain, "task_revoked");
1613
+ closeConnection(conn);
1614
+ }
1615
+ };
1616
+
1617
+ const close = async (): Promise<void> => {
1618
+ closing = true;
1619
+ const open = [...connections];
1620
+ const closed = open.map(
1621
+ (conn) =>
1622
+ new Promise<void>((resolve) => {
1623
+ if (conn.raw.destroyed) resolve();
1624
+ else conn.raw.once("close", () => resolve());
1625
+ }),
1626
+ );
1627
+ for (const conn of open) closeConnection(conn);
1628
+
1629
+ await Promise.all(closed);
1630
+ };
1631
+
1632
+ return { handleConnection, closeTask, close };
1633
+ }