Line data Source code
1 5 : import { createHash, createHmac, randomBytes, X509Certificate } from "node:crypto";
2 5 : import * as https from "node:https";
3 5 : import { finished, pipeline } from "node:stream/promises";
4 5 : import { checkServerIdentity } from "node:tls";
5 5 : import { performance } from "node:perf_hooks";
6 5 :
7 5 : import {
8 5 : GetSecretValueCommand,
9 5 : SecretsManagerClient,
10 5 : } from "@aws-sdk/client-secrets-manager";
11 5 : import { GetParameterCommand, SSMClient } from "@aws-sdk/client-ssm";
12 5 : import {
13 5 : DescribeLoadBalancersCommand,
14 5 : DescribeTagsCommand,
15 5 : ElasticLoadBalancingV2Client,
16 5 : } from "@aws-sdk/client-elastic-load-balancing-v2";
17 5 :
18 5 : const HOP_BY_HOP_HEADERS = new Set([
19 5 : "connection",
20 5 : "keep-alive",
21 5 : "proxy-authenticate",
22 5 : "proxy-authorization",
23 5 : "te",
24 5 : "trailer",
25 5 : "transfer-encoding",
26 5 : "upgrade",
27 5 : ]);
28 5 : // Streaming response metadata has a single-value `headers` object. Inference
29 5 : // backends are not allowed to set caller cookies, so drop Set-Cookie instead
30 5 : // of corrupting repeated values by comma-folding them.
31 5 : const BLOCKED_RESPONSE_HEADERS = new Set(["content-length", "set-cookie"]);
32 5 : const ALLOWED_REQUEST_HEADERS = new Set([
33 5 : "accept",
34 5 : "accept-encoding",
35 5 : "cache-control",
36 5 : "content-encoding",
37 5 : "content-type",
38 5 : "idempotency-key",
39 5 : "if-match",
40 5 : "if-none-match",
41 5 : "prefer",
42 5 : "range",
43 5 : "user-agent",
44 5 : "x-request-id",
45 5 : ]);
46 5 : const INTERNAL_SIGNATURE_HEADERS = new Set([
47 5 : "x-gco-signature-version",
48 5 : "x-gco-signature",
49 5 : "x-gco-timestamp",
50 5 : "x-gco-nonce",
51 5 : "x-gco-content-sha256",
52 5 : ]);
53 5 : const ALLOWED_METHODS = new Set(["GET", "HEAD", "POST"]);
54 5 : const RETRYABLE_METHODS = new Set(["GET", "HEAD"]);
55 5 : const RETRYABLE_STATUS_CODES = new Set([429, 502, 503, 504]);
56 5 : const REGION_RE = /^[a-z]{2,4}(?:-[a-z0-9]+)+-[0-9]+$/;
57 5 : const DNS_NAME_RE = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i;
58 5 : const GLOBAL_IDLE_TIMEOUT_MS = 30_000;
59 5 : const REGIONAL_IDLE_TIMEOUT_MS = 300_000;
60 5 : const LAMBDA_MAX_FORWARD_MS = 899_000;
61 5 : const RESPONSE_HEADROOM_MS = 1_000;
62 5 : const DEFAULT_MAX_REQUEST_BODY_BYTES = 1_048_576;
63 5 : const MAX_CONFIGURABLE_REQUEST_BODY_BYTES = 10 * 1024 * 1024;
64 5 : const RESPONSE_METADATA_DELIMITER = Buffer.alloc(8);
65 5 : const MAX_RESPONSE_METADATA_PREFIX_BYTES = 16 * 1024;
66 5 :
67 84 : function boundedEnvFloat(name, defaultValue, minimum, maximum) {
68 84 : const raw = process.env[name];
69 84 : if (raw === undefined || raw.trim() === "") {
70 46 : return defaultValue;
71 46 : }
72 22 : const value = Number(raw);
73 84 : return Number.isFinite(value) && value >= minimum && value <= maximum
74 84 : ? value
75 84 : : defaultValue;
76 84 : }
77 5 :
78 21 : function boundedEnvInt(name, defaultValue, minimum, maximum) {
79 21 : const raw = process.env[name];
80 21 : if (raw === undefined || !/^[+-]?\d+$/.test(raw.trim())) {
81 7 : return defaultValue;
82 7 : }
83 6 : const value = Number(raw);
84 21 : return Number.isSafeInteger(value) && value >= minimum && value <= maximum
85 21 : ? value
86 21 : : defaultValue;
87 21 : }
88 5 :
89 5 : const MAX_REQUEST_BODY_BYTES = boundedEnvInt(
90 5 : "MAX_REQUEST_BODY_BYTES",
91 5 : DEFAULT_MAX_REQUEST_BODY_BYTES,
92 5 : 1,
93 5 : MAX_CONFIGURABLE_REQUEST_BODY_BYTES,
94 5 : );
95 5 :
96 17 : function monotonicSeconds() {
97 17 : return performance.now() / 1_000;
98 17 : }
99 5 :
100 101 : function monotonicMilliseconds() {
101 101 : return performance.now();
102 101 : }
103 5 :
104 5 : class PublicError extends Error {
105 5 : constructor(statusCode, publicMessage, headers = {}, details = {}) {
106 60 : super(publicMessage);
107 60 : this.name = "PublicError";
108 60 : this.statusCode = statusCode;
109 60 : this.publicMessage = publicMessage;
110 60 : this.headers = headers;
111 60 : this.details = details;
112 60 : }
113 5 : }
114 5 :
115 5 : class UpstreamTimeoutError extends Error {
116 5 : constructor() {
117 5 : super("Upstream timeout");
118 5 : this.name = "UpstreamTimeoutError";
119 5 : this.code = "GCO_UPSTREAM_TIMEOUT";
120 5 : }
121 5 : }
122 5 :
123 5 : class DownstreamAbortError extends Error {
124 5 : constructor() {
125 8 : super("Downstream closed");
126 8 : this.name = "DownstreamAbortError";
127 8 : this.code = "GCO_DOWNSTREAM_ABORT";
128 8 : }
129 5 : }
130 5 :
131 5 : const SECRET_CACHE_TTL_SECONDS = boundedEnvFloat(
132 5 : "SECRET_CACHE_TTL_SECONDS",
133 5 : 300,
134 5 : 1,
135 5 : 3_600,
136 5 : );
137 5 : const SECRET_CACHE_MAX_STALE_SECONDS = Math.max(
138 5 : SECRET_CACHE_TTL_SECONDS,
139 5 : boundedEnvFloat("SECRET_CACHE_MAX_STALE_SECONDS", 900, 1, 7_200),
140 5 : );
141 5 : const SECRET_CACHE_RETRY_SECONDS = boundedEnvFloat(
142 5 : "SECRET_CACHE_RETRY_SECONDS",
143 5 : 5,
144 5 : 0.1,
145 5 : 60,
146 5 : );
147 5 : const MAX_RETRIES = boundedEnvInt("PROXY_MAX_RETRIES", 3, 1, 5);
148 5 : const RETRY_BACKOFF_BASE_SECONDS = boundedEnvFloat(
149 5 : "PROXY_RETRY_BACKOFF_BASE",
150 5 : 0.3,
151 5 : 0,
152 5 : 5,
153 5 : );
154 5 :
155 5 : const secretsClients = new Map();
156 5 : const ssmClients = new Map();
157 5 : const elbClients = new Map();
158 5 :
159 5 : let cachedSecret = null;
160 5 : let secretLastSuccessfulRefresh = 0;
161 5 : let secretLastRefreshAttempt = 0;
162 5 : let secretRefreshPromise = null;
163 5 :
164 5 : let cachedTlsTransport = null;
165 5 : let tlsLastSuccessfulRefresh = 0;
166 5 : let tlsLastRefreshAttempt = 0;
167 5 : let tlsRefreshPromise = null;
168 5 :
169 5 : const regionalEndpointCache = new Map();
170 5 :
171 22 : function secretRegion(secretArn) {
172 22 : const parts = String(secretArn || "").split(":");
173 22 : return parts.length >= 6 && parts[0] === "arn" && parts[2] === "secretsmanager"
174 22 : ? parts[3] || undefined
175 22 : : undefined;
176 22 : }
177 5 :
178 16 : function getSecretsClient(secretArn) {
179 16 : const region = secretRegion(secretArn);
180 16 : const key = region || "__default__";
181 16 : if (!secretsClients.has(key)) {
182 2 : secretsClients.set(
183 2 : key,
184 2 : new SecretsManagerClient(region ? { region } : {}),
185 2 : );
186 2 : }
187 16 : return secretsClients.get(key);
188 16 : }
189 5 :
190 22 : function getSsmClient(region) {
191 22 : if (!ssmClients.has(region)) {
192 1 : ssmClients.set(region, new SSMClient({ region }));
193 1 : }
194 22 : return ssmClients.get(region);
195 22 : }
196 5 :
197 22 : function getElbClient(region) {
198 22 : if (!elbClients.has(region)) {
199 1 : elbClients.set(region, new ElasticLoadBalancingV2Client({ region }));
200 1 : }
201 22 : return elbClients.get(region);
202 22 : }
203 5 :
204 14 : async function refreshSecret(now, ageAtAttempt) {
205 14 : secretLastRefreshAttempt = now;
206 14 : try {
207 14 : const secretArn = process.env.SECRET_ARN;
208 14 : if (!secretArn) {
209 1 : throw new Error("Signing secret is not configured");
210 1 : }
211 13 : const response = await getSecretsClient(secretArn).send(
212 13 : new GetSecretValueCommand({ SecretId: secretArn }),
213 13 : );
214 14 : if (typeof response.SecretString !== "string") {
215 2 : throw new Error("Signing secret has no string value");
216 2 : }
217 7 : const secretData = JSON.parse(response.SecretString);
218 14 : if (typeof secretData?.token !== "string" || secretData.token.length === 0) {
219 4 : throw new Error("Signing token is missing");
220 4 : }
221 2 : cachedSecret = secretData.token;
222 2 : secretLastSuccessfulRefresh = now;
223 2 : return cachedSecret;
224 14 : } catch {
225 12 : if (cachedSecret !== null && ageAtAttempt <= SECRET_CACHE_MAX_STALE_SECONDS) {
226 1 : console.warn("Secrets Manager refresh failed; using bounded stale signing key");
227 1 : return cachedSecret;
228 1 : }
229 11 : throw new Error("Authentication signing key is unavailable");
230 11 : }
231 14 : }
232 5 :
233 17 : async function getSecretToken(now = monotonicSeconds()) {
234 17 : const age = now - secretLastSuccessfulRefresh;
235 17 : if (cachedSecret !== null && age < SECRET_CACHE_TTL_SECONDS) {
236 1 : return cachedSecret;
237 1 : }
238 16 : if (
239 17 : cachedSecret !== null &&
240 17 : age <= SECRET_CACHE_MAX_STALE_SECONDS &&
241 2 : now - secretLastRefreshAttempt < SECRET_CACHE_RETRY_SECONDS
242 17 : ) {
243 1 : return cachedSecret;
244 1 : }
245 17 : if (secretRefreshPromise !== null) {
246 1 : return secretRefreshPromise;
247 1 : }
248 14 :
249 14 : const refresh = refreshSecret(now, age);
250 14 : secretRefreshPromise = refresh;
251 14 : try {
252 14 : return await refresh;
253 14 : } finally {
254 14 : if (secretRefreshPromise === refresh) {
255 14 : secretRefreshPromise = null;
256 14 : }
257 14 : }
258 17 : }
259 5 :
260 22 : function tlsSettings() {
261 22 : const serverName = String(process.env.BACKEND_TLS_SERVER_NAME || "")
262 22 : .trim()
263 22 : .replace(/\.+$/, "");
264 22 : const parameterName = String(process.env.BACKEND_TLS_ROOT_CA_PARAMETER || "").trim();
265 22 : const parameterRegion = String(process.env.BACKEND_TLS_ROOT_CA_REGION || "").trim();
266 22 : if (!DNS_NAME_RE.test(serverName)) {
267 3 : throw new Error("Backend TLS server identity is not configured");
268 3 : }
269 22 : if (!parameterName.startsWith("/") || !parameterRegion) {
270 5 : throw new Error("Backend TLS trust parameter is not configured");
271 5 : }
272 14 :
273 14 : const ttl = boundedEnvFloat("BACKEND_TLS_CA_CACHE_TTL_SECONDS", 300, 1, 3_600);
274 14 : const maxStale = Math.max(
275 14 : ttl,
276 14 : boundedEnvFloat("BACKEND_TLS_CA_MAX_STALE_SECONDS", 3_600, 1, 86_400),
277 14 : );
278 14 : const retry = boundedEnvFloat("BACKEND_TLS_CA_RETRY_SECONDS", 5, 0.1, 60);
279 14 : return { serverName, parameterName, parameterRegion, ttl, maxStale, retry };
280 22 : }
281 5 :
282 12 : function validateTrustBundle(trustBundle) {
283 12 : if (
284 12 : trustBundle.includes("PRIVATE KEY") ||
285 11 : !trustBundle.includes("-----BEGIN CERTIFICATE-----")
286 12 : ) {
287 6 : throw new Error("Backend TLS trust parameter contains invalid public material");
288 6 : }
289 6 : const certificates = trustBundle.match(
290 6 : /-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g,
291 6 : );
292 12 : if (!certificates || certificates.length === 0) {
293 1 : throw new Error("Backend TLS trust parameter contains malformed certificates");
294 1 : }
295 5 : for (const certificate of certificates) {
296 5 : new X509Certificate(certificate);
297 5 : }
298 12 : }
299 5 :
300 7 : function newTlsTransport(serverName, trustBundle) {
301 7 : validateTrustBundle(trustBundle);
302 7 : const verifyServerIdentity = (_hostname, certificate) =>
303 7 : checkServerIdentity(serverName, certificate);
304 7 : const agent = new https.Agent({
305 7 : keepAlive: true,
306 7 : maxSockets: 10,
307 7 : maxFreeSockets: 4,
308 7 : rejectUnauthorized: true,
309 7 : ca: trustBundle,
310 7 : minVersion: "TLSv1.2",
311 7 : servername: serverName,
312 7 : checkServerIdentity: verifyServerIdentity,
313 7 : });
314 7 : return { agent, serverName, verifyServerIdentity };
315 7 : }
316 5 :
317 10 : async function refreshTlsTransport(settings, now, ageAtAttempt) {
318 10 : tlsLastRefreshAttempt = now;
319 10 : try {
320 10 : const response = await getSsmClient(settings.parameterRegion).send(
321 10 : new GetParameterCommand({ Name: settings.parameterName }),
322 10 : );
323 10 : const trustBundle = String(response.Parameter?.Value ?? "");
324 10 : const refreshed = newTlsTransport(settings.serverName, trustBundle);
325 10 : cachedTlsTransport = refreshed;
326 10 : tlsLastSuccessfulRefresh = now;
327 10 : return refreshed;
328 10 : } catch {
329 8 : if (cachedTlsTransport !== null && ageAtAttempt <= settings.maxStale) {
330 1 : console.warn("Backend TLS trust refresh failed; using bounded stale trust bundle");
331 1 : return cachedTlsTransport;
332 1 : }
333 7 : throw new Error("Backend TLS trust bundle is unavailable");
334 7 : }
335 10 : }
336 5 :
337 13 : async function getTlsTransport(now = monotonicSeconds()) {
338 13 : const settings = tlsSettings();
339 13 : const age = now - tlsLastSuccessfulRefresh;
340 13 : if (cachedTlsTransport !== null && age < settings.ttl) {
341 1 : return cachedTlsTransport;
342 1 : }
343 12 : if (
344 13 : cachedTlsTransport !== null &&
345 13 : age <= settings.maxStale &&
346 2 : now - tlsLastRefreshAttempt < settings.retry
347 13 : ) {
348 1 : return cachedTlsTransport;
349 1 : }
350 13 : if (tlsRefreshPromise !== null) {
351 1 : return tlsRefreshPromise;
352 1 : }
353 10 :
354 10 : const refresh = refreshTlsTransport(settings, now, age);
355 10 : tlsRefreshPromise = refresh;
356 10 : try {
357 10 : return await refresh;
358 10 : } finally {
359 10 : if (tlsRefreshPromise === refresh) {
360 10 : tlsRefreshPromise = null;
361 10 : }
362 10 : }
363 13 : }
364 5 :
365 12 : function regionalEndpointCacheTtl() {
366 12 : return boundedEnvFloat("REGIONAL_ENDPOINT_CACHE_TTL_SECONDS", 60, 0, 300);
367 12 : }
368 5 :
369 11 : function awsUrlSuffix() {
370 11 : const suffix = String(process.env.AWS_URL_SUFFIX || "").trim().toLowerCase();
371 11 : if (!DNS_NAME_RE.test(suffix)) {
372 1 : throw new Error("AWS URL suffix is not configured");
373 1 : }
374 10 : return suffix;
375 11 : }
376 5 :
377 15 : function validatedDnsName(value) {
378 15 : const endpoint = String(value ?? "").trim().replace(/\.+$/, "");
379 15 : const lower = endpoint.toLowerCase();
380 15 : if (
381 15 : !DNS_NAME_RE.test(endpoint) ||
382 9 : !lower.endsWith(`.elb.${awsUrlSuffix()}`)
383 15 : ) {
384 8 : throw new Error("Registered backend is invalid");
385 8 : }
386 7 : return endpoint;
387 15 : }
388 5 :
389 20 : async function validateRegionalEndpointOwnership(
390 20 : endpoint,
391 20 : region,
392 20 : expectedAccount,
393 20 : projectName,
394 20 : ) {
395 20 : const client = getElbClient(region);
396 20 : let marker;
397 20 : let matched = null;
398 20 : for (let page = 0; page < 20; page += 1) {
399 23 : const response = await client.send(
400 23 : new DescribeLoadBalancersCommand(marker ? { Marker: marker } : {}),
401 23 : );
402 23 : for (const loadBalancer of response.LoadBalancers || []) {
403 20 : const dnsName = String(loadBalancer.DNSName || "").replace(/\.+$/, "");
404 20 : if (dnsName.toLowerCase() === endpoint.toLowerCase()) {
405 16 : matched = loadBalancer;
406 16 : break;
407 16 : }
408 20 : }
409 23 : if (matched !== null) {
410 16 : break;
411 16 : }
412 7 : marker = response.NextMarker;
413 23 : if (!marker) {
414 4 : break;
415 4 : }
416 23 : }
417 20 :
418 20 : if (matched === null) {
419 4 : throw new Error("Registered backend does not exist");
420 4 : }
421 20 : if (matched.Type !== "application" || matched.Scheme !== "internal") {
422 2 : throw new Error("Registered backend is not an internal ALB");
423 2 : }
424 14 :
425 20 : const arn = String(matched.LoadBalancerArn || "");
426 20 : const arnParts = arn.split(":", 6);
427 20 : if (
428 20 : arnParts.length !== 6 ||
429 20 : arnParts[2] !== "elasticloadbalancing" ||
430 20 : arnParts[3] !== region ||
431 10 : arnParts[4] !== expectedAccount
432 20 : ) {
433 5 : throw new Error("Registered backend ownership is invalid");
434 5 : }
435 9 :
436 9 : const tagResponse = await client.send(
437 9 : new DescribeTagsCommand({ ResourceArns: [arn] }),
438 9 : );
439 9 : const tags = {};
440 20 : for (const description of tagResponse.TagDescriptions || []) {
441 8 : for (const tag of description.Tags || []) {
442 13 : tags[String(tag.Key)] = String(tag.Value);
443 13 : }
444 8 : }
445 9 :
446 9 : const expectedCluster = `${projectName}-${region}`;
447 9 : const clusterMatch =
448 20 : tags["eks:eks-cluster-name"] === expectedCluster ||
449 20 : tags["elbv2.k8s.aws/cluster"] === expectedCluster;
450 20 : if (!clusterMatch) {
451 3 : throw new Error("Registered backend is not owned by the GCO cluster");
452 3 : }
453 6 :
454 6 : const platformMatch = tags["gco.aws/gateway"] === "gco-system/gco-gateway";
455 20 : if (!platformMatch) {
456 3 : throw new Error("Registered backend is not the GCO Gateway");
457 3 : }
458 20 : }
459 5 :
460 19 : async function resolveRegionalEndpoint(now = monotonicSeconds()) {
461 19 : const registryRegion = String(process.env.REGISTRY_REGION || "").trim();
462 19 : const targetRegion = String(process.env.TARGET_REGION || "").trim();
463 19 : const projectName = String(process.env.PROJECT_NAME || "").trim();
464 19 : const expectedAccount = String(process.env.AWS_ACCOUNT_ID || "").trim();
465 19 : if (
466 19 : !REGION_RE.test(registryRegion) ||
467 19 : !REGION_RE.test(targetRegion) ||
468 19 : !projectName ||
469 13 : !expectedAccount
470 19 : ) {
471 8 : throw new Error("Regional endpoint registry is not configured");
472 8 : }
473 11 :
474 11 : const cacheKey = JSON.stringify([
475 11 : registryRegion,
476 11 : targetRegion,
477 11 : projectName,
478 11 : expectedAccount,
479 11 : ]);
480 11 : const ttl = regionalEndpointCacheTtl();
481 11 : const cached = regionalEndpointCache.get(cacheKey);
482 19 : if (ttl > 0 && cached && now - cached.timestamp < ttl) {
483 1 : return cached.endpoint;
484 1 : }
485 10 :
486 10 : const parameterName = `/${projectName}/alb-hostname-${targetRegion}`;
487 10 : const response = await getSsmClient(registryRegion).send(
488 10 : new GetParameterCommand({ Name: parameterName }),
489 10 : );
490 19 : const endpoint = validatedDnsName(response.Parameter?.Value);
491 19 : await validateRegionalEndpointOwnership(
492 19 : endpoint,
493 19 : targetRegion,
494 19 : expectedAccount,
495 19 : projectName,
496 19 : );
497 3 : regionalEndpointCache.set(cacheKey, {
498 3 : timestamp: now,
499 3 : endpoint,
500 3 : });
501 3 : return endpoint;
502 19 : }
503 5 :
504 28 : function parseEndpoint(endpoint) {
505 28 : const value = String(endpoint || "").trim();
506 28 : const baseUrl = value.includes("://") ? value : `https://${value}`;
507 28 : let parsed;
508 28 : try {
509 28 : parsed = new URL(baseUrl);
510 28 : } catch {
511 3 : throw new Error("Invalid proxy endpoint");
512 3 : }
513 10 : if (
514 28 : parsed.protocol.toLowerCase() !== "https:" ||
515 28 : !parsed.hostname ||
516 28 : parsed.username ||
517 28 : parsed.password ||
518 28 : (parsed.port && parsed.port !== "443") ||
519 28 : parsed.search ||
520 18 : parsed.hash
521 28 : ) {
522 8 : throw new Error("Proxy endpoint must use HTTPS on port 443");
523 8 : }
524 17 : return {
525 17 : hostname: parsed.hostname,
526 17 : endpointPath: parsed.pathname.replace(/\/+$/, ""),
527 17 : };
528 28 : }
529 5 :
530 17 : function encodeRequestPath(path) {
531 17 : const repaired = path.replace(/%(?![0-9a-fA-F]{2})/g, "%25");
532 17 : return encodeURIComponent(repaired)
533 17 : .replace(/%2F/g, "/")
534 17 : .replace(/%3A/g, ":")
535 17 : .replace(/%40/g, "@")
536 17 : .replace(/%24/g, "$")
537 17 : .replace(/%26/g, "&")
538 17 : .replace(/%2B/g, "+")
539 17 : .replace(/%2C/g, ",")
540 17 : .replace(/%3B/g, ";")
541 17 : .replace(/%3D/g, "=")
542 17 : .replace(/%25/g, "%");
543 17 : }
544 5 :
545 60 : function pythonString(value) {
546 60 : if (value === null) {
547 2 : return "None";
548 2 : }
549 60 : if (value === true) {
550 1 : return "True";
551 1 : }
552 60 : if (value === false) {
553 1 : return "False";
554 1 : }
555 22 : return String(value);
556 60 : }
557 5 :
558 60 : function encodeQueryComponent(value) {
559 60 : return encodeURIComponent(pythonString(value))
560 60 : .replace(/[!'()*]/g, (character) =>
561 60 : `%${character.charCodeAt(0).toString(16).toUpperCase()}`,
562 60 : )
563 60 : .replace(/%20/g, "+");
564 60 : }
565 5 :
566 188 : function nonEmptyMapping(value) {
567 188 : return (
568 188 : value !== null &&
569 188 : typeof value === "object" &&
570 188 : !Array.isArray(value) &&
571 87 : Object.keys(value).length > 0
572 188 : );
573 188 : }
574 5 :
575 47 : function encodedQueryFromMapping(queryParameters) {
576 47 : const pairs = [];
577 47 : for (const [name, rawValue] of Object.entries(queryParameters)) {
578 11 : const values = Array.isArray(rawValue) ? rawValue : [rawValue];
579 11 : for (const value of values) {
580 13 : pairs.push(`${encodeQueryComponent(name)}=${encodeQueryComponent(value)}`);
581 13 : }
582 11 : }
583 47 : return pairs.join("&");
584 47 : }
585 5 :
586 51 : function requestQuery(event) {
587 51 : if (typeof event.rawQueryString === "string") {
588 6 : if (/[\r\n]/.test(event.rawQueryString)) {
589 3 : throw new PublicError(400, "Invalid query string");
590 3 : }
591 2 : return event.rawQueryString;
592 2 : }
593 33 : const parameters = nonEmptyMapping(event.multiValueQueryStringParameters)
594 51 : ? event.multiValueQueryStringParameters
595 51 : : nonEmptyMapping(event.queryStringParameters)
596 32 : ? event.queryStringParameters
597 51 : : {};
598 51 : return encodedQueryFromMapping(parameters);
599 51 : }
600 5 :
601 28 : function buildTarget(endpoint, requestPath, query) {
602 28 : const parsedEndpoint = parseEndpoint(endpoint);
603 28 : const encodedPath = encodeRequestPath(requestPath);
604 28 : const requestTarget = `${parsedEndpoint.endpointPath}${encodedPath}${
605 28 : query ? `?${query}` : ""
606 28 : }`;
607 28 : return {
608 28 : hostname: parsedEndpoint.hostname,
609 28 : requestTarget,
610 28 : };
611 28 : }
612 5 :
613 53 : function eventMethod(event) {
614 53 : const value = event?.httpMethod ?? event?.requestContext?.http?.method;
615 53 : if (typeof value !== "string") {
616 6 : throw new PublicError(400, "Invalid request method");
617 6 : }
618 29 : const method = value.toUpperCase();
619 53 : if (!ALLOWED_METHODS.has(method)) {
620 1 : throw new PublicError(405, "Method not allowed", {
621 1 : allow: "GET, HEAD, POST",
622 1 : });
623 1 : }
624 28 : return method;
625 53 : }
626 5 :
627 53 : function eventPath(event) {
628 53 : const value =
629 53 : typeof event.rawPath === "string"
630 53 : ? event.rawPath
631 53 : : event.path;
632 53 : if (
633 53 : typeof value !== "string" ||
634 53 : /[\r\n\0]/.test(value) ||
635 53 : !value.startsWith("/inference/") ||
636 16 : value.length <= "/inference/".length
637 53 : ) {
638 8 : throw new PublicError(404, "Not found");
639 8 : }
640 15 : return value;
641 53 : }
642 5 :
643 46 : function eventHeaders(event) {
644 46 : const merged = {};
645 46 : if (nonEmptyMapping(event?.headers)) {
646 15 : Object.assign(merged, event.headers);
647 15 : }
648 46 : if (nonEmptyMapping(event?.multiValueHeaders)) {
649 19 : // REST API v1 supplies repeated values here. Apply this map after the
650 19 : // scalar map so it wins even when API Gateway populated both forms.
651 19 : for (const [name, value] of Object.entries(event.multiValueHeaders)) {
652 4 : merged[name] = Array.isArray(value) ? value : [value];
653 4 : }
654 19 : }
655 46 : return merged;
656 46 : }
657 5 :
658 28 : function hasHeader(headers, expectedName) {
659 28 : const lowerExpected = expectedName.toLowerCase();
660 28 : return Object.keys(headers).some(
661 28 : (name) => String(name).toLowerCase() === lowerExpected,
662 28 : );
663 28 : }
664 5 :
665 15 : function sanitizeRequestHeaders(headers) {
666 15 : const sanitized = {};
667 15 : for (const [name, value] of Object.entries(headers)) {
668 41 : const normalized = String(name).trim().toLowerCase();
669 41 : if (
670 41 : value === null ||
671 41 : value === undefined ||
672 41 : HOP_BY_HOP_HEADERS.has(normalized) ||
673 15 : !ALLOWED_REQUEST_HEADERS.has(normalized)
674 41 : ) {
675 8 : continue;
676 8 : }
677 41 : const values = Array.isArray(value) ? value : [value];
678 41 : const sanitizedValues = values
679 41 : .filter((entry) => entry !== null && entry !== undefined)
680 41 : .map(String);
681 41 : if (sanitizedValues.length === 0) {
682 1 : continue;
683 1 : }
684 9 : sanitized[normalized] = Array.isArray(value)
685 41 : ? sanitizedValues
686 41 : : sanitizedValues[0];
687 41 : }
688 15 : return sanitized;
689 15 : }
690 5 :
691 14 : function buildSignedHeaders(signingKey, method, requestTarget, bodyBuffer) {
692 14 : const timestamp = String(Math.floor(Date.now() / 1_000));
693 14 : const nonce = randomBytes(16).toString("hex");
694 14 : const contentHash = createHash("sha256").update(bodyBuffer).digest("hex");
695 14 : const canonical = [
696 14 : "v1",
697 14 : timestamp,
698 14 : nonce,
699 14 : method.toUpperCase(),
700 14 : requestTarget,
701 14 : contentHash,
702 14 : ].join("\n");
703 14 : const signature = createHmac("sha256", Buffer.from(signingKey, "utf8"))
704 14 : .update(canonical, "utf8")
705 14 : .digest("hex");
706 14 : return {
707 14 : "x-gco-signature-version": "v1",
708 14 : "x-gco-signature": signature,
709 14 : "x-gco-timestamp": timestamp,
710 14 : "x-gco-nonce": nonce,
711 14 : "x-gco-content-sha256": contentHash,
712 14 : };
713 14 : }
714 5 :
715 14 : function outboundHeaders(headers) {
716 14 : const outbound = {};
717 14 : for (const [name, value] of Object.entries(headers)) {
718 92 : const normalized = String(name).toLowerCase();
719 92 : if (
720 92 : value !== null &&
721 92 : value !== undefined &&
722 14 : (ALLOWED_REQUEST_HEADERS.has(normalized) ||
723 14 : INTERNAL_SIGNATURE_HEADERS.has(normalized))
724 92 : ) {
725 11 : outbound[normalized] = Array.isArray(value)
726 11 : ? value.map(String)
727 11 : : String(value);
728 11 : }
729 92 : }
730 14 : return outbound;
731 14 : }
732 5 :
733 9 : function sanitizeResponseHeaders(headers) {
734 9 : const sanitized = {};
735 9 : for (const [name, value] of Object.entries(headers)) {
736 19 : const normalized = String(name).toLowerCase();
737 19 : if (
738 19 : HOP_BY_HOP_HEADERS.has(normalized) ||
739 19 : BLOCKED_RESPONSE_HEADERS.has(normalized) ||
740 10 : value === undefined
741 19 : ) {
742 11 : continue;
743 11 : }
744 8 : sanitized[normalized] = Array.isArray(value)
745 19 : ? value.map(String).join(", ")
746 19 : : String(value);
747 19 : }
748 9 : return sanitized;
749 9 : }
750 5 :
751 27 : function beginStreamingResponse(responseStream, metadata) {
752 27 : const encodedMetadata = Buffer.from(JSON.stringify(metadata), "utf8");
753 27 : const prefixLength =
754 27 : encodedMetadata.byteLength + RESPONSE_METADATA_DELIMITER.byteLength;
755 27 : if (prefixLength > MAX_RESPONSE_METADATA_PREFIX_BYTES) {
756 2 : throw new PublicError(502, "Upstream response metadata is too large");
757 2 : }
758 7 : responseStream.write(
759 7 : Buffer.concat([encodedMetadata, RESPONSE_METADATA_DELIMITER], prefixLength),
760 7 : );
761 7 : return responseStream;
762 27 : }
763 5 :
764 17 : function requestBudgetMilliseconds(context) {
765 17 : let available = LAMBDA_MAX_FORWARD_MS;
766 17 : try {
767 17 : if (typeof context?.getRemainingTimeInMillis === "function") {
768 4 : const remaining = Number(context.getRemainingTimeInMillis());
769 4 : if (Number.isFinite(remaining)) {
770 3 : available = Math.max(0, remaining - RESPONSE_HEADROOM_MS);
771 3 : }
772 4 : }
773 17 : } catch {
774 1 : available = LAMBDA_MAX_FORWARD_MS;
775 1 : }
776 17 : return Math.min(LAMBDA_MAX_FORWARD_MS, available);
777 17 : }
778 5 :
779 15 : function isTlsError(error) {
780 15 : const code = String(error?.code || "");
781 15 : return (
782 15 : code === "EPROTO" ||
783 15 : code.startsWith("ERR_TLS_") ||
784 15 : code.startsWith("ERR_SSL_") ||
785 15 : code.startsWith("CERT_") ||
786 13 : [
787 13 : "UNABLE_TO_GET_ISSUER_CERT",
788 13 : "UNABLE_TO_GET_ISSUER_CERT_LOCALLY",
789 13 : "UNABLE_TO_VERIFY_LEAF_SIGNATURE",
790 13 : "DEPTH_ZERO_SELF_SIGNED_CERT",
791 13 : "SELF_SIGNED_CERT_IN_CHAIN",
792 13 : ].includes(code)
793 15 : );
794 15 : }
795 5 :
796 17 : function transportFailureKind(error) {
797 17 : if (error instanceof DownstreamAbortError || error?.code === "GCO_DOWNSTREAM_ABORT") {
798 2 : return "downstream";
799 2 : }
800 17 : if (isTlsError(error)) {
801 2 : return "tls";
802 2 : }
803 10 : if (
804 3 : error instanceof UpstreamTimeoutError ||
805 17 : error?.code === "GCO_UPSTREAM_TIMEOUT" ||
806 10 : error?.code === "ETIMEDOUT"
807 17 : ) {
808 4 : return "timeout";
809 4 : }
810 9 : if (
811 9 : [
812 9 : "ECONNREFUSED",
813 9 : "ECONNRESET",
814 9 : "ECONNABORTED",
815 9 : "EHOSTUNREACH",
816 9 : "ENETUNREACH",
817 9 : "ENETDOWN",
818 9 : "ENOTFOUND",
819 9 : "EAI_AGAIN",
820 9 : "EPIPE",
821 17 : ].includes(String(error?.code || ""))
822 17 : ) {
823 6 : return "connection";
824 6 : }
825 1 : return "unexpected";
826 17 : }
827 5 :
828 10 : function publicErrorForTransportFailure(kind, attemptsMade = 0) {
829 10 : if (kind === "tls") {
830 2 : return new PublicError(502, "Backend TLS verification failed");
831 2 : }
832 10 : if (kind === "unexpected") {
833 2 : return new PublicError(500, "Internal server error");
834 2 : }
835 10 : if (kind === "connection") {
836 2 : return new PublicError(503, "Service unavailable", {}, {
837 2 : message: `Upstream failed after ${attemptsMade} attempt(s)`,
838 2 : });
839 2 : }
840 1 : if (kind === "timeout") {
841 1 : return new PublicError(504, "Gateway timeout", {}, {
842 1 : message: `Upstream failed after ${attemptsMade} attempt(s)`,
843 1 : });
844 1 : }
845 1 : return new PublicError(504, "Gateway timeout");
846 10 : }
847 5 :
848 14 : function openUpstream({
849 14 : target,
850 14 : method,
851 14 : headers,
852 14 : bodyBuffer,
853 14 : transport,
854 14 : idleTimeoutMs,
855 14 : remainingMs,
856 14 : signal,
857 14 : }, requestFactory = https.request) {
858 14 : return new Promise((resolve, reject) => {
859 14 : let request;
860 14 : let response = null;
861 14 : let promiseSettled = false;
862 14 : let cleaned = false;
863 14 : let deadlineTimer;
864 14 :
865 14 : const cleanup = () => {
866 14 : if (cleaned) {
867 1 : return;
868 1 : }
869 13 : cleaned = true;
870 13 : clearTimeout(deadlineTimer);
871 13 : signal.removeEventListener("abort", onAbort);
872 14 : request?.setTimeout(0);
873 14 : response?.setTimeout(0);
874 14 : };
875 14 :
876 14 : const rejectBeforeResponse = (error) => {
877 14 : if (promiseSettled) {
878 6 : if (response && !response.destroyed) {
879 1 : response.destroy(error);
880 1 : }
881 6 : return;
882 6 : }
883 8 : promiseSettled = true;
884 8 : cleanup();
885 8 : reject(error);
886 14 : };
887 14 :
888 14 : const destroy = (error = new Error("Upstream request cancelled")) => {
889 7 : if (response && !response.destroyed) {
890 3 : response.destroy(error);
891 3 : }
892 7 : if (request && !request.destroyed) {
893 7 : request.destroy(error);
894 7 : }
895 14 : };
896 14 :
897 14 : const onAbort = () => {
898 1 : const error = new DownstreamAbortError();
899 1 : destroy(error);
900 1 : rejectBeforeResponse(error);
901 14 : };
902 14 :
903 14 : if (signal.aborted) {
904 1 : reject(new DownstreamAbortError());
905 1 : return;
906 1 : }
907 13 : signal.addEventListener("abort", onAbort, { once: true });
908 13 :
909 13 : try {
910 13 : request = requestFactory(
911 13 : {
912 13 : protocol: "https:",
913 13 : hostname: target.hostname,
914 13 : port: 443,
915 13 : method,
916 13 : path: target.requestTarget,
917 13 : headers,
918 13 : agent: transport.agent,
919 13 : servername: transport.serverName,
920 13 : rejectUnauthorized: true,
921 13 : minVersion: "TLSv1.2",
922 13 : checkServerIdentity: transport.verifyServerIdentity,
923 13 : },
924 13 : (incoming) => {
925 6 : if (promiseSettled) {
926 1 : incoming.destroy();
927 1 : return;
928 1 : }
929 5 : response = incoming;
930 5 : promiseSettled = true;
931 5 : response.setTimeout(idleTimeoutMs, () => {
932 1 : destroy(new UpstreamTimeoutError());
933 5 : });
934 5 : resolve({ request, response, cleanup, destroy });
935 13 : },
936 13 : );
937 14 : } catch (error) {
938 1 : rejectBeforeResponse(error);
939 1 : return;
940 1 : }
941 12 :
942 12 : request.on("error", (error) => {
943 10 : rejectBeforeResponse(error);
944 12 : });
945 12 : request.once("upgrade", (_incoming, socket) => {
946 1 : socket.destroy();
947 1 : rejectBeforeResponse(new Error("Unexpected protocol upgrade"));
948 12 : });
949 12 : request.setTimeout(idleTimeoutMs, () => {
950 1 : destroy(new UpstreamTimeoutError());
951 12 : });
952 12 : deadlineTimer = setTimeout(() => {
953 2 : destroy(new UpstreamTimeoutError());
954 12 : }, Math.max(1, Math.ceil(remainingMs)));
955 12 :
956 12 : try {
957 14 : request.end(bodyBuffer.length > 0 ? bodyBuffer : undefined);
958 14 : } catch (error) {
959 1 : destroy(error);
960 1 : rejectBeforeResponse(error);
961 1 : }
962 14 : });
963 14 : }
964 5 :
965 4 : function sleep(milliseconds, signal) {
966 4 : if (milliseconds <= 0) {
967 1 : return Promise.resolve();
968 1 : }
969 3 : return new Promise((resolve, reject) => {
970 3 : const timer = setTimeout(() => {
971 1 : signal.removeEventListener("abort", onAbort);
972 1 : resolve();
973 3 : }, milliseconds);
974 3 : const onAbort = () => {
975 1 : clearTimeout(timer);
976 1 : reject(new DownstreamAbortError());
977 3 : };
978 3 : if (signal.aborted) {
979 1 : clearTimeout(timer);
980 1 : reject(new DownstreamAbortError());
981 1 : return;
982 1 : }
983 2 : signal.addEventListener("abort", onAbort, { once: true });
984 3 : });
985 4 : }
986 5 :
987 7 : async function streamFinalResponse(resource, responseStream, state, signal) {
988 7 : let output;
989 7 : try {
990 7 : output = beginStreamingResponse(responseStream, {
991 7 : statusCode: resource.response.statusCode || 502,
992 7 : headers: sanitizeResponseHeaders(resource.response.headers),
993 7 : });
994 7 : state.started = true;
995 7 : } catch (error) {
996 2 : resource.cleanup();
997 2 : resource.destroy();
998 2 : if (error instanceof PublicError) {
999 2 : throw error;
1000 2 : }
1001 2 : throw new PublicError(500, "Internal server error");
1002 2 : }
1003 5 :
1004 5 : try {
1005 5 : await pipeline(resource.response, output, { signal });
1006 7 : } catch {
1007 1 : resource.destroy();
1008 1 : if (!signal.aborted) {
1009 1 : console.warn("Upstream response stream terminated before completion");
1010 1 : }
1011 1 : } finally {
1012 1 : resource.cleanup();
1013 1 : }
1014 7 : }
1015 5 :
1016 5 : const FORWARD_OPERATIONS = Object.freeze({
1017 5 : openUpstream,
1018 5 : sleep,
1019 5 : streamFinalResponse,
1020 5 : });
1021 5 :
1022 23 : async function forwardRequest({
1023 23 : target,
1024 23 : method,
1025 23 : headers,
1026 23 : bodyBuffer,
1027 23 : transport,
1028 23 : timeoutMs,
1029 23 : idleTimeoutMs,
1030 23 : responseStream,
1031 23 : responseState,
1032 23 : signal,
1033 23 : resignHeaders = null,
1034 23 : }, operations = FORWARD_OPERATIONS) {
1035 23 : const maxAttempts = RETRYABLE_METHODS.has(method) ? MAX_RETRIES : 1;
1036 23 : const deadline = monotonicMilliseconds() + Math.max(timeoutMs, 0);
1037 23 : let lastFailureKind = null;
1038 23 : let attemptsMade = 0;
1039 23 :
1040 23 : for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
1041 49 : const remaining = deadline - monotonicMilliseconds();
1042 49 : if (remaining <= 0) {
1043 1 : break;
1044 1 : }
1045 48 : attemptsMade = attempt + 1;
1046 49 : if (attempt > 0 && resignHeaders !== null) {
1047 2 : // The backend's HMAC envelope is single-use: its nonce is remembered
1048 2 : // for the signature window and a replay is refused with 403. A retry
1049 2 : // that re-sent the first attempt's envelope turned every retryable
1050 2 : // 5xx into a terminal 403, so each attempt signs afresh.
1051 2 : headers = { ...headers, ...resignHeaders() };
1052 2 : }
1053 48 :
1054 48 : let resource;
1055 48 : try {
1056 48 : resource = await operations.openUpstream({
1057 48 : target,
1058 48 : method,
1059 48 : headers,
1060 48 : bodyBuffer,
1061 48 : transport,
1062 48 : idleTimeoutMs,
1063 48 : remainingMs: remaining,
1064 48 : signal,
1065 48 : });
1066 49 : } catch (error) {
1067 12 : const kind = transportFailureKind(error);
1068 12 : if (kind === "downstream") {
1069 1 : throw error;
1070 1 : }
1071 12 : if (kind === "tls" || kind === "unexpected") {
1072 2 : throw publicErrorForTransportFailure(kind, attemptsMade);
1073 2 : }
1074 9 : lastFailureKind = kind;
1075 9 : console.warn(
1076 9 : `Upstream ${method} failed on attempt ${attempt + 1}/${maxAttempts}`,
1077 9 : );
1078 9 :
1079 12 : if (attempt >= maxAttempts - 1) {
1080 2 : break;
1081 2 : }
1082 7 : const backoffMs = RETRY_BACKOFF_BASE_SECONDS * 1_000 * 2 ** attempt;
1083 12 : if (deadline - monotonicMilliseconds() <= backoffMs) {
1084 1 : break;
1085 1 : }
1086 6 : await operations.sleep(backoffMs, signal);
1087 6 : continue;
1088 6 : }
1089 36 :
1090 49 : const statusCode = resource.response.statusCode || 502;
1091 49 : if (RETRYABLE_STATUS_CODES.has(statusCode) && attempt < maxAttempts - 1) {
1092 21 : const backoffMs = RETRY_BACKOFF_BASE_SECONDS * 1_000 * 2 ** attempt;
1093 21 : if (deadline - monotonicMilliseconds() > backoffMs) {
1094 20 : console.warn(
1095 20 : `Retryable upstream status ${statusCode} on attempt ${attempt + 1}/${maxAttempts} for ${method}`,
1096 20 : );
1097 20 : resource.cleanup();
1098 20 : resource.destroy();
1099 20 : await operations.sleep(backoffMs, signal);
1100 20 : continue;
1101 20 : }
1102 21 : }
1103 16 :
1104 16 : await operations.streamFinalResponse(
1105 16 : resource,
1106 16 : responseStream,
1107 16 : responseState,
1108 16 : signal,
1109 16 : );
1110 16 : return;
1111 16 : }
1112 4 :
1113 4 : throw publicErrorForTransportFailure(lastFailureKind, attemptsMade);
1114 23 : }
1115 5 :
1116 17 : async function sendJsonError(
1117 17 : responseStream,
1118 17 : statusCode,
1119 17 : message,
1120 17 : headers = {},
1121 17 : details = {},
1122 17 : ) {
1123 17 : if (responseStream.destroyed) {
1124 1 : return;
1125 1 : }
1126 3 : const body = JSON.stringify({ error: message, ...details });
1127 3 : try {
1128 3 : const output = beginStreamingResponse(responseStream, {
1129 3 : statusCode,
1130 3 : headers: {
1131 3 : "content-type": "application/json",
1132 3 : ...headers,
1133 3 : },
1134 3 : });
1135 3 : output.end(body);
1136 3 : await finished(output, { cleanup: true });
1137 17 : } catch {
1138 2 : // The caller may have disconnected before the bounded error could be sent.
1139 2 : }
1140 17 : }
1141 5 :
1142 37 : function routingMode() {
1143 37 : const mode = String(process.env.ROUTING_MODE || "").trim();
1144 37 : if (mode !== "global" && mode !== "regional") {
1145 5 : throw new PublicError(503, "Backend routing is temporarily unavailable");
1146 5 : }
1147 14 : return mode;
1148 37 : }
1149 5 :
1150 48 : function preflightRequest(event) {
1151 48 : if (event?.isBase64Encoded) {
1152 1 : throw new PublicError(415, "Base64-encoded request bodies are not supported");
1153 1 : }
1154 16 : const method = eventMethod(event);
1155 16 : const path = eventPath(event);
1156 16 : const query = requestQuery(event);
1157 16 : const incomingHeaders = eventHeaders(event);
1158 48 : const body = event?.body ?? "";
1159 48 : if (typeof body !== "string") {
1160 6 : throw new PublicError(400, "Invalid request body");
1161 6 : }
1162 12 : const bodyBuffer = Buffer.from(body, "utf8");
1163 48 : if (bodyBuffer.byteLength > MAX_REQUEST_BODY_BYTES) {
1164 5 : throw new PublicError(
1165 5 : 413,
1166 5 : `Request body exceeds maximum size of ${MAX_REQUEST_BODY_BYTES} bytes`,
1167 5 : );
1168 5 : }
1169 7 : const mode = routingMode();
1170 7 :
1171 48 : if (mode === "global" && hasHeader(incomingHeaders, "x-gco-target-region")) {
1172 1 : throw new PublicError(
1173 1 : 400,
1174 1 : "X-GCO-Target-Region is not supported by the global endpoint; use the target region's regional API endpoint if authorized for direct access",
1175 1 : );
1176 1 : }
1177 5 :
1178 5 : return { method, path, query, incomingHeaders, bodyBuffer, mode };
1179 48 : }
1180 5 :
1181 5 : const PRODUCTION_DEPENDENCIES = Object.freeze({
1182 5 : getSecretToken,
1183 5 : resolveRegionalEndpoint,
1184 5 : getTlsTransport,
1185 5 : forwardRequest,
1186 5 : });
1187 5 :
1188 22 : async function streamingHandler(
1189 22 : event,
1190 22 : responseStream,
1191 22 : context,
1192 22 : dependencies = PRODUCTION_DEPENDENCIES,
1193 22 : ) {
1194 22 : const responseState = { started: false };
1195 22 : const downstreamAbort = new AbortController();
1196 22 : const onDownstreamClose = () => {
1197 19 : if (!responseStream.writableFinished && !responseStream.writableEnded) {
1198 1 : downstreamAbort.abort(new DownstreamAbortError());
1199 1 : }
1200 22 : };
1201 22 : const onDownstreamError = () => {
1202 1 : downstreamAbort.abort(new DownstreamAbortError());
1203 22 : };
1204 22 : responseStream.once("close", onDownstreamClose);
1205 22 : responseStream.once("error", onDownstreamError);
1206 22 :
1207 22 : try {
1208 22 : const { method, path, query, incomingHeaders, bodyBuffer, mode } =
1209 22 : preflightRequest(event);
1210 22 :
1211 22 : let signingKey;
1212 22 : try {
1213 22 : signingKey = await dependencies.getSecretToken();
1214 22 : } catch {
1215 1 : throw new PublicError(
1216 1 : 503,
1217 1 : "Backend authentication is temporarily unavailable",
1218 1 : );
1219 1 : }
1220 17 :
1221 17 : let endpoint;
1222 17 : try {
1223 22 : if (mode === "global") {
1224 14 : endpoint = process.env.GLOBAL_ACCELERATOR_ENDPOINT;
1225 14 : if (!endpoint) {
1226 1 : throw new Error("Global endpoint is not configured");
1227 1 : }
1228 22 : } else {
1229 3 : endpoint = await dependencies.resolveRegionalEndpoint();
1230 2 : }
1231 22 : } catch {
1232 2 : if (mode === "regional") {
1233 1 : console.warn("Regional backend resolution failed");
1234 1 : throw new PublicError(502, "Regional backend is temporarily unavailable");
1235 1 : }
1236 1 : throw new PublicError(
1237 1 : 503,
1238 1 : "Global backend routing is temporarily unavailable",
1239 1 : );
1240 1 : }
1241 15 :
1242 15 : let target;
1243 15 : try {
1244 15 : target = buildTarget(endpoint, path, query);
1245 22 : } catch {
1246 2 : throw new PublicError(
1247 2 : mode === "global" ? 503 : 502,
1248 2 : mode === "global"
1249 2 : ? "Global backend routing is temporarily unavailable"
1250 2 : : "Regional backend is temporarily unavailable",
1251 2 : );
1252 2 : }
1253 13 :
1254 13 : let transport;
1255 13 : try {
1256 13 : transport = await dependencies.getTlsTransport();
1257 22 : } catch {
1258 1 : throw new PublicError(503, "Backend trust is temporarily unavailable");
1259 1 : }
1260 12 :
1261 12 : const requestHeaders = sanitizeRequestHeaders(incomingHeaders);
1262 12 : const signAttempt = () =>
1263 12 : buildSignedHeaders(signingKey, method, target.requestTarget, bodyBuffer);
1264 12 : Object.assign(requestHeaders, signAttempt());
1265 12 : const timeoutMs = requestBudgetMilliseconds(context);
1266 22 : if (timeoutMs <= 0) {
1267 1 : throw new PublicError(504, "Gateway timeout");
1268 1 : }
1269 11 :
1270 11 : await dependencies.forwardRequest({
1271 11 : target,
1272 11 : method,
1273 11 : headers: outboundHeaders(requestHeaders),
1274 11 : bodyBuffer,
1275 11 : transport,
1276 11 : timeoutMs,
1277 11 : idleTimeoutMs:
1278 22 : mode === "global" ? GLOBAL_IDLE_TIMEOUT_MS : REGIONAL_IDLE_TIMEOUT_MS,
1279 22 : responseStream,
1280 22 : responseState,
1281 22 : signal: downstreamAbort.signal,
1282 22 : resignHeaders: signAttempt,
1283 22 : });
1284 22 : } catch (error) {
1285 13 : if (
1286 13 : downstreamAbort.signal.aborted ||
1287 13 : error instanceof DownstreamAbortError ||
1288 10 : responseState.started
1289 13 : ) {
1290 4 : return;
1291 4 : }
1292 13 : if (error instanceof PublicError) {
1293 8 : await sendJsonError(
1294 8 : responseStream,
1295 8 : error.statusCode,
1296 8 : error.publicMessage,
1297 8 : error.headers,
1298 8 : error.details,
1299 8 : );
1300 8 : return;
1301 8 : }
1302 1 : await sendJsonError(responseStream, 500, "Internal server error");
1303 22 : } finally {
1304 22 : responseStream.removeListener("close", onDownstreamClose);
1305 22 : responseStream.removeListener("error", onDownstreamError);
1306 22 : }
1307 22 : }
1308 5 :
1309 70 : function resetRuntimeStateForTest() {
1310 70 : secretsClients.clear();
1311 70 : ssmClients.clear();
1312 70 : elbClients.clear();
1313 70 : regionalEndpointCache.clear();
1314 70 : cachedSecret = null;
1315 70 : secretLastSuccessfulRefresh = 0;
1316 70 : secretLastRefreshAttempt = 0;
1317 70 : secretRefreshPromise = null;
1318 70 : cachedTlsTransport = null;
1319 70 : tlsLastSuccessfulRefresh = 0;
1320 70 : tlsLastRefreshAttempt = 0;
1321 70 : tlsRefreshPromise = null;
1322 70 : }
1323 5 :
1324 5 : export const __test = Object.freeze({
1325 5 : MAX_REQUEST_BODY_BYTES,
1326 5 : PublicError,
1327 5 : UpstreamTimeoutError,
1328 5 : DownstreamAbortError,
1329 5 : boundedEnvFloat,
1330 5 : boundedEnvInt,
1331 5 : monotonicSeconds,
1332 5 : monotonicMilliseconds,
1333 5 : secretRegion,
1334 5 : getSecretsClient,
1335 5 : getSsmClient,
1336 5 : getElbClient,
1337 5 : refreshSecret,
1338 5 : getSecretToken,
1339 5 : tlsSettings,
1340 5 : validateTrustBundle,
1341 5 : newTlsTransport,
1342 5 : refreshTlsTransport,
1343 5 : getTlsTransport,
1344 5 : regionalEndpointCacheTtl,
1345 5 : awsUrlSuffix,
1346 5 : validatedDnsName,
1347 5 : validateRegionalEndpointOwnership,
1348 5 : resolveRegionalEndpoint,
1349 5 : parseEndpoint,
1350 5 : encodeRequestPath,
1351 5 : pythonString,
1352 5 : encodeQueryComponent,
1353 5 : nonEmptyMapping,
1354 5 : encodedQueryFromMapping,
1355 5 : eventMethod,
1356 5 : eventPath,
1357 5 : hasHeader,
1358 5 : isTlsError,
1359 5 : openUpstream,
1360 5 : sleep,
1361 5 : forwardRequest,
1362 5 : routingMode,
1363 5 : resetRuntimeStateForTest,
1364 5 : secretsClients,
1365 5 : ssmClients,
1366 5 : elbClients,
1367 5 : regionalEndpointCache,
1368 5 : preflightRequest,
1369 5 : requestQuery,
1370 5 : buildTarget,
1371 5 : eventHeaders,
1372 5 : sanitizeRequestHeaders,
1373 5 : outboundHeaders,
1374 5 : sanitizeResponseHeaders,
1375 5 : beginStreamingResponse,
1376 5 : buildSignedHeaders,
1377 5 : requestBudgetMilliseconds,
1378 5 : transportFailureKind,
1379 5 : publicErrorForTransportFailure,
1380 5 : streamFinalResponse,
1381 5 : sendJsonError,
1382 5 : streamingHandler,
1383 5 : });
1384 5 :
1385 5 : export const handler = awslambda.streamifyResponse(streamingHandler);
|