@runuai/host 0.9.148 → 0.9.149
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/db/migrations/0024_task_env_mode.sql +9 -0
- package/db/migrations/0025_project_env_authority.sql +147 -0
- package/db/migrations/0026_project_env_replica.sql +150 -0
- package/db/migrations/meta/_journal.json +21 -0
- package/db/schema.ts +312 -2
- package/images/standard/Dockerfile +44 -6
- package/images/standard/container/uai-init +85 -2
- package/images/standard/container/uai-task-forwarder.mjs +303 -0
- package/lib/agent-cli.ts +18 -1
- package/lib/agent.ts +99 -34
- package/lib/agents/codex.ts +9 -1
- package/lib/agents/cursor.ts +15 -1
- package/lib/agents/grok.ts +8 -0
- package/lib/agents/kimi.ts +1 -0
- package/lib/agents/machine-durable.ts +2 -0
- package/lib/agents/opencode.ts +1 -0
- package/lib/agents/proc.ts +6 -1
- package/lib/agents/transport.ts +9 -5
- package/lib/browser-testing.ts +6 -0
- package/lib/codex-auth.ts +2 -0
- package/lib/credential-store.ts +141 -6
- package/lib/engine-accounts.ts +1 -0
- package/lib/env-file.ts +7 -0
- package/lib/env-schema.ts +841 -51
- package/lib/git-diff.ts +1 -0
- package/lib/git-identity.ts +2 -0
- package/lib/github-tokens.ts +1 -0
- package/lib/host-env.ts +98 -18
- package/lib/lease.ts +11 -3
- package/lib/machine-gateway-tunnel.ts +195 -17
- package/lib/machine-project-env-loader.ts +152 -0
- package/lib/managed-runtime.ts +270 -51
- package/lib/mcp-gateway.ts +777 -26
- package/lib/orchestrator.ts +391 -69
- package/lib/project-env-authority.ts +1319 -0
- package/lib/project-env-feature.ts +74 -0
- package/lib/project-env-loader.ts +237 -0
- package/lib/project-env-replica.ts +2094 -0
- package/lib/project-env-schema-capture.ts +342 -0
- package/lib/project-env-staging.ts +148 -0
- package/lib/project-env-task-policy.ts +176 -0
- package/lib/runtime-archive.ts +79 -16
- package/lib/runtime-authority.ts +44 -0
- package/lib/runtime-state.ts +175 -10
- package/lib/secrets-proxy/address.ts +72 -0
- package/lib/secrets-proxy/audit.ts +30 -0
- package/lib/secrets-proxy/ca-store.ts +200 -0
- package/lib/secrets-proxy/ca.ts +470 -0
- package/lib/secrets-proxy/container-steps.ts +138 -0
- package/lib/secrets-proxy/der.ts +273 -0
- package/lib/secrets-proxy/engine.ts +1633 -0
- package/lib/secrets-proxy/host.ts +93 -0
- package/lib/secrets-proxy/hostname.ts +60 -0
- package/lib/secrets-proxy/placeholders.ts +196 -0
- package/lib/secrets-proxy/policy.ts +141 -0
- package/lib/secrets-proxy/readiness.ts +148 -0
- package/lib/secrets-proxy/runtime.ts +582 -0
- package/lib/secrets-proxy/scrub.ts +559 -0
- package/lib/secrets-proxy/sidecar-view.ts +139 -0
- package/lib/secrets-proxy/task-scripts.ts +262 -0
- package/lib/secrets-proxy/task-trust.ts +574 -0
- package/lib/secrets-proxy/trust-watch.ts +80 -0
- package/lib/shared-files-s3.ts +3 -0
- package/lib/sidecar-guard.ts +58 -0
- package/lib/sidecar-resume.ts +51 -3
- package/lib/standard-image.ts +50 -12
- package/lib/task-env-forwarder.ts +228 -0
- package/lib/task-env-source.ts +193 -0
- package/lib/task-environment/apple-container.ts +45 -11
- package/lib/task-environment/docker.ts +51 -12
- package/lib/task-environment/index.ts +6 -2
- package/lib/task-environment/machine-task-up.ts +258 -25
- package/lib/task-environment/machine.ts +80 -22
- package/lib/task-environment/registry.ts +3 -2
- package/lib/task-environment/self-identity.ts +20 -0
- package/lib/task-environment/self.ts +92 -16
- package/lib/task-environment/types.ts +63 -0
- package/lib/task-gateway-port.ts +22 -0
- package/lib/task-gateway-protocol.ts +190 -0
- package/lib/task-gateway-recovery.ts +44 -0
- package/lib/task-gateway-state.ts +497 -0
- package/lib/transcript.ts +1 -0
- package/package.json +13 -1
- package/scripts/agent/env-hook.sh +626 -0
- package/scripts/agent/task-up.sh +476 -27
- package/scripts/agent/uai-env-exec.sh +26 -0
- package/src/cli.ts +26 -3
- package/src/index.ts +273 -19
- package/src/main.ts +939 -48
- package/src/project-env-latest.ts +130 -0
- package/src/project-env-policy.ts +281 -0
- package/src/project-env-wording.ts +47 -0
- 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
|
+
}
|