@sequenceholdings/artifact-studio 0.6.4 → 0.6.6
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/README.md +183 -6
- package/VERSION-PIN.md +56 -1
- package/dist/gateway/admission.d.ts +23 -1
- package/dist/gateway/admission.js +30 -13
- package/dist/gateway/control.d.ts +40 -0
- package/dist/gateway/control.js +76 -7
- package/dist/gateway/hosted-proxy.d.ts +11 -8
- package/dist/gateway/hosted-proxy.js +99 -91
- package/dist/gateway/ingress-admission.d.ts +77 -0
- package/dist/gateway/ingress-admission.js +150 -0
- package/dist/gateway/ingress-audit.d.ts +46 -0
- package/dist/gateway/ingress-audit.js +84 -0
- package/dist/gateway/ingress-context.d.ts +57 -0
- package/dist/gateway/ingress-context.js +57 -0
- package/dist/gateway/ingress-development.d.ts +12 -0
- package/dist/gateway/ingress-development.js +23 -0
- package/dist/gateway/ingress-handler.d.ts +44 -0
- package/dist/gateway/ingress-handler.js +320 -0
- package/dist/gateway/ingress-pass.d.ts +83 -0
- package/dist/gateway/ingress-pass.js +143 -0
- package/dist/gateway/ingress-response.d.ts +74 -0
- package/dist/gateway/ingress-response.js +98 -0
- package/dist/gateway/ingress-runtime.d.ts +86 -0
- package/dist/gateway/ingress-runtime.js +126 -0
- package/dist/gateway/ingress-snapshot.d.ts +54 -0
- package/dist/gateway/ingress-snapshot.js +63 -0
- package/dist/gateway/ingress-stages.d.ts +60 -0
- package/dist/gateway/ingress-stages.js +387 -0
- package/dist/gateway/ingress.d.ts +259 -0
- package/dist/gateway/ingress.js +204 -0
- package/dist/gateway/lattice-invoke.js +2 -0
- package/dist/gateway/lattice-upstream.d.ts +3 -4
- package/dist/gateway/lattice-upstream.js +11 -9
- package/dist/gateway/runtime-transport.d.ts +17 -0
- package/dist/gateway/runtime-transport.js +26 -0
- package/dist/gateway/server.d.ts +3 -0
- package/dist/gateway/server.js +21 -0
- package/dist/gateway/session-response.d.ts +15 -0
- package/dist/gateway/session-response.js +40 -0
- package/dist/gateway/usage.d.ts +8 -1
- package/dist/gateway-main.js +32 -8
- package/dist/manifest-next-ingress.d.ts +5 -2
- package/dist/manifest-next-ingress.js +27 -2
- package/dist/manifest-next.d.ts +20 -6
- package/dist/manifest-next.js +13 -5
- package/dist/manifest.d.ts +7 -2
- package/dist/next-project.d.ts +4 -0
- package/dist/sdk.d.ts +12 -3
- package/dist/server/broker-contract.d.ts +62 -0
- package/dist/server/broker-contract.js +43 -1
- package/dist/server/client.js +38 -13
- package/dist/server/external-context.d.ts +8 -1
- package/dist/server/external-context.js +7 -2
- package/dist/server/internal/v1-context.js +17 -1
- package/dist/server/internal/verify-any.d.ts +11 -2
- package/dist/server/internal/verify-any.js +11 -3
- package/dist/server/process-run-sharing.d.ts +86 -0
- package/dist/server/process-run-sharing.js +29 -0
- package/dist/server/process-runs-page.d.ts +39 -0
- package/dist/server/process-runs-page.js +10 -0
- package/dist/server/processes.d.ts +8 -9
- package/dist/server/processes.js +4 -0
- package/dist/server/request-context.js +10 -3
- package/dist/server/runtime-config.d.ts +10 -1
- package/dist/server/runtime-config.js +11 -1
- package/dist/server/testing.d.ts +1 -1
- package/dist/server.d.ts +2 -2
- package/dist/server.js +1 -1
- package/dist/tunnel/broker.d.ts +18 -0
- package/dist/tunnel/broker.js +175 -17
- package/dist/tunnel/client.d.ts +13 -0
- package/dist/tunnel/client.js +40 -2
- package/dist/tunnel/external.d.ts +51 -0
- package/dist/tunnel/external.js +10 -0
- package/dist/tunnel/index.d.ts +1 -1
- package/dist/tunnel/protocol.d.ts +20 -2
- package/dist/tunnel/protocol.js +42 -6
- package/package.json +4 -4
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
import { type IncomingMessage, type ServerResponse } from 'node:http';
|
|
2
|
+
import { type HostedArtifactIngressMode } from './ingress-response.js';
|
|
3
|
+
import { type HostedRuntimeTarget } from './runtime-transport.js';
|
|
4
|
+
export { cloudRunOrigin, resolveHostedTarget, type HostedRuntimeTarget } from './runtime-transport.js';
|
|
2
5
|
export declare const HOSTED_ARTIFACT_PROXY_LIMITS: Readonly<{
|
|
3
|
-
|
|
6
|
+
maxRequestBytes: number;
|
|
7
|
+
maxResponseBytes: number;
|
|
4
8
|
timeoutMs: 60000;
|
|
5
9
|
maxTimeoutMs: 300000;
|
|
6
10
|
}>;
|
|
7
11
|
export interface HostedArtifactProxyOptions {
|
|
8
12
|
request: IncomingMessage;
|
|
9
13
|
response: ServerResponse;
|
|
10
|
-
target: {
|
|
11
|
-
upstreamUrl: string;
|
|
12
|
-
/** Canonical service URL from the same provider record as the revision URL. */
|
|
13
|
-
upstreamAudience?: string;
|
|
14
|
+
target: HostedRuntimeTarget & {
|
|
14
15
|
publicOrigin: string;
|
|
15
16
|
projectId: string;
|
|
16
17
|
deploymentId: string;
|
|
@@ -26,16 +27,18 @@ export interface HostedArtifactProxyOptions {
|
|
|
26
27
|
resolveUpstream?: (input: {
|
|
27
28
|
url: URL;
|
|
28
29
|
}) => URL;
|
|
29
|
-
/** Approved request timeouts may exceed the default up to the platform cap. */
|
|
30
|
+
/** Approved request timeouts may exceed the default up to the platform cap (55 s on an ingress). */
|
|
30
31
|
limits?: {
|
|
31
|
-
|
|
32
|
+
maxRequestBytes?: number;
|
|
33
|
+
maxResponseBytes?: number;
|
|
32
34
|
timeoutMs?: number;
|
|
33
35
|
};
|
|
36
|
+
/** Present only for an external ingress request. */
|
|
37
|
+
ingress?: HostedArtifactIngressMode;
|
|
34
38
|
}
|
|
35
39
|
export declare function hostedIdentityToken(input: {
|
|
36
40
|
audience: string;
|
|
37
41
|
signal: AbortSignal;
|
|
38
42
|
}): Promise<string>;
|
|
39
|
-
export declare function cloudRunOrigin(value: string): URL;
|
|
40
43
|
/** HTTP-only streaming hop. Authentication, CSRF and release admission happen before calling this. */
|
|
41
44
|
export declare function proxyHostedArtifact(options: HostedArtifactProxyOptions): Promise<void>;
|
|
@@ -3,9 +3,13 @@ import { request as httpsRequest } from 'node:https';
|
|
|
3
3
|
import { Duplex, Readable, Transform } from 'node:stream';
|
|
4
4
|
import { createBrotliDecompress, createGunzip, createInflate } from 'node:zlib';
|
|
5
5
|
import { GoogleAuth } from 'google-auth-library';
|
|
6
|
-
import { assertSafeRequest,
|
|
6
|
+
import { assertSafeRequest, normalizePublicOrigin, sanitizeRequestHeaders, sanitizeResponseHeaders } from '../tunnel/protocol.js';
|
|
7
|
+
import { enforceBrowserCachePolicy, mergeFramePolicy, rewriteLocation } from './session-response.js';
|
|
7
8
|
import { createReleaseFormTransform } from '../server/release-forms.js';
|
|
8
|
-
|
|
9
|
+
import { declaredContentLength, INGRESS_PROXY_ERROR_CODES, INGRESS_PROXY_LIMITS, writeIngressResponseHead } from './ingress-response.js';
|
|
10
|
+
import { resolveHostedTarget } from './runtime-transport.js';
|
|
11
|
+
export { cloudRunOrigin, resolveHostedTarget } from './runtime-transport.js';
|
|
12
|
+
export const HOSTED_ARTIFACT_PROXY_LIMITS = Object.freeze({ maxRequestBytes: 32 * 1024 * 1024, maxResponseBytes: 32 * 1024 * 1024, timeoutMs: 60_000, maxTimeoutMs: 300_000 });
|
|
9
13
|
const googleAuth = new GoogleAuth();
|
|
10
14
|
const identityClients = new Map();
|
|
11
15
|
async function defaultIdentityTokenProvider({ audience }) {
|
|
@@ -35,58 +39,12 @@ async function defaultIdentityTokenProvider({ audience }) {
|
|
|
35
39
|
export function hostedIdentityToken(input) {
|
|
36
40
|
return defaultIdentityTokenProvider(input);
|
|
37
41
|
}
|
|
38
|
-
export function cloudRunOrigin(value) {
|
|
39
|
-
const url = new URL(value);
|
|
40
|
-
if (!/^https:\/\/[a-z0-9.-]+\.run\.app\/?$/.test(value) || url.protocol !== 'https:' ||
|
|
41
|
-
url.username || url.password || url.port || url.pathname !== '/' || url.search || url.hash ||
|
|
42
|
-
!url.hostname.split('.').every((part) => /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(part)))
|
|
43
|
-
throw new Error('Invalid hosted deployment origin');
|
|
44
|
-
return url;
|
|
45
|
-
}
|
|
46
42
|
function stripCloudHeaders(headers) {
|
|
47
43
|
for (const name of Object.keys(headers)) {
|
|
48
44
|
if (name === 'x-serverless-authorization' || name.startsWith('x-goog-'))
|
|
49
45
|
delete headers[name];
|
|
50
46
|
}
|
|
51
47
|
}
|
|
52
|
-
function rewriteLocation({ location, upstream, publicOrigin }) {
|
|
53
|
-
if (!/^(?:https?:)?\/\//i.test(location))
|
|
54
|
-
return location;
|
|
55
|
-
const url = new URL(location, upstream);
|
|
56
|
-
return url.origin === upstream.origin ? `${publicOrigin}${url.pathname}${url.search}${url.hash}` : location;
|
|
57
|
-
}
|
|
58
|
-
function mergeFramePolicy({ application, trusted }) {
|
|
59
|
-
const policies = Array.isArray(application) ? application : [application];
|
|
60
|
-
const frames = Array.isArray(trusted) ? trusted : [trusted];
|
|
61
|
-
return [...policies.map((policy) => policy.split(';')
|
|
62
|
-
.filter((directive) => directive.trim().split(/\s/, 1)[0].toLowerCase() !== 'frame-ancestors')
|
|
63
|
-
.map((directive) => directive.trim()).filter(Boolean).join('; ')).filter(Boolean), ...frames];
|
|
64
|
-
}
|
|
65
|
-
function enforceBrowserCachePolicy({ request, response }) {
|
|
66
|
-
const path = (request.url ?? '/').split('?', 1)[0];
|
|
67
|
-
let staticAsset = false;
|
|
68
|
-
try {
|
|
69
|
-
const segments = path.slice(1).split('/').map((segment) => decodeURIComponent(segment));
|
|
70
|
-
staticAsset = path.startsWith('/_next/static/') && /\.(?:js|css)$/.test(path) &&
|
|
71
|
-
segments.every((segment) => segment !== '' && segment !== '.' && segment !== '..' && !/[\\/]/.test(segment));
|
|
72
|
-
}
|
|
73
|
-
catch { /* Malformed or ambiguous asset paths retain the default no-store policy. */ }
|
|
74
|
-
const contentType = response.getHeader('content-type');
|
|
75
|
-
const mediaType = typeof contentType === 'string' ? contentType.split(';', 1)[0].trim().toLowerCase() : undefined;
|
|
76
|
-
const assetType = path.endsWith('.css') ? mediaType === 'text/css' : mediaType === 'text/javascript' || mediaType === 'application/javascript';
|
|
77
|
-
const revalidateAsset = staticAsset && ['GET', 'HEAD'].includes(request.method ?? 'GET') &&
|
|
78
|
-
(response.statusCode === 200 || response.statusCode === 304) &&
|
|
79
|
-
(assetType || (response.statusCode === 304 && contentType === undefined));
|
|
80
|
-
// Every protected reuse must reach authorization. Retain only private static
|
|
81
|
-
// JS/CSS bytes and validators; app output may never opt into browser/CDN TTLs.
|
|
82
|
-
for (const name of response.getHeaderNames()) {
|
|
83
|
-
if (isSharedCacheDirective(name))
|
|
84
|
-
response.removeHeader(name);
|
|
85
|
-
}
|
|
86
|
-
response.setHeader('cache-control', revalidateAsset ? 'private, no-cache' : 'private, no-store');
|
|
87
|
-
for (const [name, value] of Object.entries(SHARED_CACHE_REFUSAL))
|
|
88
|
-
response.setHeader(name, value);
|
|
89
|
-
}
|
|
90
48
|
/** HTTP-only streaming hop. Authentication, CSRF and release admission happen before calling this. */
|
|
91
49
|
export function proxyHostedArtifact(options) {
|
|
92
50
|
return new HostedExchange(options).run();
|
|
@@ -94,9 +52,11 @@ export function proxyHostedArtifact(options) {
|
|
|
94
52
|
class HostedExchange {
|
|
95
53
|
options;
|
|
96
54
|
controller = new AbortController();
|
|
97
|
-
|
|
55
|
+
requestMaximum;
|
|
56
|
+
responseMaximum;
|
|
98
57
|
timeoutMs;
|
|
99
58
|
timer;
|
|
59
|
+
idleWriteTimer;
|
|
100
60
|
upload;
|
|
101
61
|
outgoing;
|
|
102
62
|
incoming;
|
|
@@ -108,8 +68,10 @@ class HostedExchange {
|
|
|
108
68
|
resolve;
|
|
109
69
|
constructor(options) {
|
|
110
70
|
this.options = options;
|
|
111
|
-
this.
|
|
112
|
-
this.
|
|
71
|
+
this.requestMaximum = this.boundLimit(options.limits?.maxRequestBytes, HOSTED_ARTIFACT_PROXY_LIMITS.maxRequestBytes);
|
|
72
|
+
this.responseMaximum = this.boundLimit(options.limits?.maxResponseBytes, HOSTED_ARTIFACT_PROXY_LIMITS.maxResponseBytes);
|
|
73
|
+
const maxTimeoutMs = options.ingress ? INGRESS_PROXY_LIMITS.maxTimeoutMs : HOSTED_ARTIFACT_PROXY_LIMITS.maxTimeoutMs;
|
|
74
|
+
this.timeoutMs = this.boundLimit(options.limits?.timeoutMs, maxTimeoutMs, Math.min(HOSTED_ARTIFACT_PROXY_LIMITS.timeoutMs, maxTimeoutMs));
|
|
113
75
|
}
|
|
114
76
|
boundLimit(value, maximum, fallback = maximum) {
|
|
115
77
|
return Number.isSafeInteger(value) && value !== undefined && value > 0 ? Math.min(value, maximum) : fallback;
|
|
@@ -129,16 +91,15 @@ class HostedExchange {
|
|
|
129
91
|
request.once('error', this.finish);
|
|
130
92
|
this.timer = setTimeout(() => this.fail(504), this.timeoutMs);
|
|
131
93
|
this.timer.unref();
|
|
132
|
-
void this.prepare().catch(() => this.fail(
|
|
94
|
+
void this.prepare().catch(() => this.fail(this.unavailableStatus));
|
|
133
95
|
});
|
|
134
96
|
}
|
|
135
97
|
async prepare() {
|
|
136
98
|
const { request, response, target, requestContext } = this.options;
|
|
137
|
-
const upstream =
|
|
99
|
+
const { upstream, audience } = resolveHostedTarget({ target });
|
|
138
100
|
// Cloud Run revision tags select immutable transport destinations, but IAM
|
|
139
101
|
// requires the canonical service audience. Both are control-plane owned;
|
|
140
102
|
// never derive this from a browser host, header or query parameter.
|
|
141
|
-
const audience = target.upstreamAudience === undefined ? upstream : cloudRunOrigin(target.upstreamAudience);
|
|
142
103
|
const publicOrigin = normalizePublicOrigin(target.publicOrigin);
|
|
143
104
|
try {
|
|
144
105
|
assertSafeRequest(request.method ?? 'GET', request.url ?? '/');
|
|
@@ -151,30 +112,41 @@ class HostedExchange {
|
|
|
151
112
|
return;
|
|
152
113
|
}
|
|
153
114
|
const length = request.headers['content-length'];
|
|
154
|
-
if (length !== undefined && (!/^(0|[1-9][0-9]*)$/.test(length) || Number(length) > this.
|
|
115
|
+
if (length !== undefined && (!/^(0|[1-9][0-9]*)$/.test(length) || Number(length) > this.requestMaximum)) {
|
|
155
116
|
this.fail(413);
|
|
156
117
|
return;
|
|
157
118
|
}
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
119
|
+
let headers;
|
|
120
|
+
try {
|
|
121
|
+
headers = sanitizeRequestHeaders(request.headers, publicOrigin, this.options.ingress ? { mode: 'ingress', appHeaders: this.options.ingress.requestHeaders } : {});
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
// A header map the schema refuses is the caller's malformed request: an ingress caller must
|
|
125
|
+
// not retry it as an outage (§(i) `bad_request`); a session keeps its 502.
|
|
126
|
+
this.fail(this.options.ingress ? 400 : 502);
|
|
163
127
|
return;
|
|
164
|
-
|
|
165
|
-
|
|
128
|
+
}
|
|
129
|
+
stripCloudHeaders(headers);
|
|
130
|
+
if (audience) {
|
|
131
|
+
const provider = this.options.identityTokenProvider ?? defaultIdentityTokenProvider;
|
|
132
|
+
const token = await provider({ audience: audience.origin, signal: this.controller.signal });
|
|
133
|
+
if (this.stopped)
|
|
134
|
+
return;
|
|
135
|
+
if (!/^[A-Za-z0-9._~-]+$/.test(token) || token.length > 16384)
|
|
136
|
+
throw new Error('Invalid platform token');
|
|
137
|
+
// Cloud Run removes this token's signature before passing it to user code.
|
|
138
|
+
// https://cloud.google.com/run/docs/authenticating/service-to-service
|
|
139
|
+
headers['x-serverless-authorization'] = `Bearer ${token}`;
|
|
140
|
+
}
|
|
166
141
|
headers.host = upstream.host;
|
|
167
142
|
// HTML forms receive release markers. Avoid compression when possible;
|
|
168
143
|
// runtimes that still compress HTML are decoded as a bounded stream below.
|
|
169
144
|
headers['accept-encoding'] = 'identity';
|
|
170
|
-
// Cloud Run removes this token's signature before passing it to user code.
|
|
171
|
-
// https://cloud.google.com/run/docs/authenticating/service-to-service
|
|
172
|
-
headers['x-serverless-authorization'] = `Bearer ${token}`;
|
|
173
145
|
headers['x-sequence-artifact-context'] = requestContext;
|
|
174
146
|
const destination = this.resolveDestination(upstream);
|
|
175
147
|
const transport = destination.protocol === 'https:' ? httpsRequest : httpRequest;
|
|
176
148
|
this.outgoing = transport(destination, { method: request.method, path: request.url, headers, maxHeaderSize: 32 * 1024 }, (incoming) => this.receive({ incoming, upstream, publicOrigin }));
|
|
177
|
-
this.outgoing.on('error', () => this.fail(
|
|
149
|
+
this.outgoing.on('error', () => this.fail(this.unavailableStatus));
|
|
178
150
|
this.outgoing.once('upgrade', (_incoming, socket) => { socket.destroy(); this.fail(502); });
|
|
179
151
|
this.outgoing.on('drain', () => { if (!this.stopped)
|
|
180
152
|
this.upload?.resume(); });
|
|
@@ -182,8 +154,16 @@ class HostedExchange {
|
|
|
182
154
|
this.upload.on('data', this.sendChunk);
|
|
183
155
|
this.upload.once('end', () => this.outgoing?.end());
|
|
184
156
|
this.upload.once('error', () => this.fail(502));
|
|
185
|
-
response.on('drain', () => {
|
|
186
|
-
this.
|
|
157
|
+
response.on('drain', () => {
|
|
158
|
+
clearTimeout(this.idleWriteTimer);
|
|
159
|
+
this.idleWriteTimer = undefined;
|
|
160
|
+
if (!this.stopped)
|
|
161
|
+
this.responseSource?.resume();
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
/** An unreachable app is an outage an ingress caller retries; a session keeps its 502. */
|
|
165
|
+
get unavailableStatus() {
|
|
166
|
+
return this.options.ingress ? 503 : 502;
|
|
187
167
|
}
|
|
188
168
|
resolveDestination(upstream) {
|
|
189
169
|
const destination = this.options.resolveUpstream?.({ url: upstream }) ?? upstream;
|
|
@@ -197,7 +177,7 @@ class HostedExchange {
|
|
|
197
177
|
if (this.stopped)
|
|
198
178
|
return;
|
|
199
179
|
this.sentBytes += chunk.byteLength;
|
|
200
|
-
if (this.sentBytes > this.
|
|
180
|
+
if (this.sentBytes > this.requestMaximum) {
|
|
201
181
|
this.fail(413);
|
|
202
182
|
return;
|
|
203
183
|
}
|
|
@@ -211,29 +191,24 @@ class HostedExchange {
|
|
|
211
191
|
return;
|
|
212
192
|
}
|
|
213
193
|
try {
|
|
194
|
+
// Read before sanitising, which removes content-length as a hop header.
|
|
195
|
+
const declaredLength = declaredContentLength(incoming.headers['content-length']);
|
|
214
196
|
const headers = sanitizeResponseHeaders(incoming.headers);
|
|
215
197
|
stripCloudHeaders(headers);
|
|
216
|
-
delete headers['x-frame-options'];
|
|
217
|
-
if (typeof headers.location === 'string')
|
|
218
|
-
headers.location = rewriteLocation({ location: headers.location, upstream, publicOrigin });
|
|
219
198
|
const response = this.options.response;
|
|
220
|
-
this.
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
if (typeof existing === 'string' || Array.isArray(existing)) {
|
|
229
|
-
// Rolling gateway leases and application cookies share the response,
|
|
230
|
-
// but application cookies were stripped of reserved names above.
|
|
231
|
-
response.setHeader(name, [...(Array.isArray(existing) ? existing : [existing]), ...(Array.isArray(value) ? value : [value])]);
|
|
199
|
+
if (this.options.ingress) {
|
|
200
|
+
// Decided on the head, before any body byte; never the HTML release-marker path.
|
|
201
|
+
if (!writeIngressResponseHead({
|
|
202
|
+
response, status: incoming.statusCode ?? 502, method: this.options.request.method ?? 'GET', headers, declaredLength,
|
|
203
|
+
responses: this.options.ingress.responses, maxResponseBytes: this.responseMaximum,
|
|
204
|
+
})) {
|
|
205
|
+
this.fail(502);
|
|
206
|
+
return;
|
|
232
207
|
}
|
|
233
|
-
|
|
234
|
-
response.setHeader(name, value);
|
|
208
|
+
this.responseSource = incoming;
|
|
235
209
|
}
|
|
236
|
-
|
|
210
|
+
else
|
|
211
|
+
this.responseSource = this.writeSessionResponseHead({ incoming, headers, upstream, publicOrigin });
|
|
237
212
|
this.responseSource.on('data', this.receiveChunk);
|
|
238
213
|
this.responseSource.once('end', () => response.end());
|
|
239
214
|
this.responseSource.once('error', () => this.fail(502));
|
|
@@ -244,6 +219,30 @@ class HostedExchange {
|
|
|
244
219
|
this.fail(502);
|
|
245
220
|
}
|
|
246
221
|
}
|
|
222
|
+
writeSessionResponseHead({ incoming, headers, upstream, publicOrigin }) {
|
|
223
|
+
delete headers['x-frame-options'];
|
|
224
|
+
if (typeof headers.location === 'string')
|
|
225
|
+
headers.location = rewriteLocation({ location: headers.location, upstream, publicOrigin });
|
|
226
|
+
const response = this.options.response;
|
|
227
|
+
const source = this.prepareResponseSource({ incoming, headers, publicOrigin });
|
|
228
|
+
const framePolicy = response.getHeader('content-security-policy');
|
|
229
|
+
if (headers['content-security-policy'] && (typeof framePolicy === 'string' || Array.isArray(framePolicy))) {
|
|
230
|
+
headers['content-security-policy'] = mergeFramePolicy({ application: headers['content-security-policy'], trusted: framePolicy });
|
|
231
|
+
}
|
|
232
|
+
response.statusCode = incoming.statusCode ?? 502;
|
|
233
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
234
|
+
const existing = name === 'set-cookie' ? response.getHeader(name) : undefined;
|
|
235
|
+
if (typeof existing === 'string' || Array.isArray(existing)) {
|
|
236
|
+
// Rolling gateway leases and application cookies share the response,
|
|
237
|
+
// but application cookies were stripped of reserved names above.
|
|
238
|
+
response.setHeader(name, [...(Array.isArray(existing) ? existing : [existing]), ...(Array.isArray(value) ? value : [value])]);
|
|
239
|
+
}
|
|
240
|
+
else
|
|
241
|
+
response.setHeader(name, value);
|
|
242
|
+
}
|
|
243
|
+
enforceBrowserCachePolicy({ request: this.options.request, response });
|
|
244
|
+
return source;
|
|
245
|
+
}
|
|
247
246
|
prepareResponseSource({ incoming, headers, publicOrigin }) {
|
|
248
247
|
const contentType = headers['content-type'];
|
|
249
248
|
if (typeof contentType !== 'string' || contentType.split(';', 1)[0].trim().toLowerCase() !== 'text/html')
|
|
@@ -269,7 +268,7 @@ class HostedExchange {
|
|
|
269
268
|
source = source.pipe(decoder);
|
|
270
269
|
}
|
|
271
270
|
let decodedBytes = 0;
|
|
272
|
-
const maximum = this.
|
|
271
|
+
const maximum = this.responseMaximum;
|
|
273
272
|
const limit = new Transform({ transform(chunk, _encoding, callback) {
|
|
274
273
|
decodedBytes += chunk.byteLength;
|
|
275
274
|
if (decodedBytes > maximum)
|
|
@@ -287,12 +286,18 @@ class HostedExchange {
|
|
|
287
286
|
if (this.stopped)
|
|
288
287
|
return;
|
|
289
288
|
this.receivedBytes += chunk.byteLength;
|
|
290
|
-
if (this.receivedBytes > this.
|
|
289
|
+
if (this.receivedBytes > this.responseMaximum) {
|
|
291
290
|
this.fail(502);
|
|
292
291
|
return;
|
|
293
292
|
}
|
|
294
|
-
if (
|
|
295
|
-
|
|
293
|
+
if (this.options.response.write(chunk))
|
|
294
|
+
return;
|
|
295
|
+
this.responseSource?.pause();
|
|
296
|
+
// Backpressure would otherwise hold the admission permit until the request timeout.
|
|
297
|
+
if (this.options.ingress && !this.idleWriteTimer) {
|
|
298
|
+
this.idleWriteTimer = setTimeout(() => this.fail(504), this.options.ingress.idleWriteTimeoutMs ?? INGRESS_PROXY_LIMITS.idleWriteTimeoutMs);
|
|
299
|
+
this.idleWriteTimer.unref();
|
|
300
|
+
}
|
|
296
301
|
};
|
|
297
302
|
fail(status) {
|
|
298
303
|
if (this.stopped)
|
|
@@ -303,6 +308,8 @@ class HostedExchange {
|
|
|
303
308
|
const response = this.options.response;
|
|
304
309
|
if (response.headersSent)
|
|
305
310
|
response.destroy();
|
|
311
|
+
else if (this.options.ingress)
|
|
312
|
+
this.options.ingress.errorWriter({ status, code: INGRESS_PROXY_ERROR_CODES[status] });
|
|
306
313
|
else {
|
|
307
314
|
// Do not send application cookies, encodings or redirects with a gateway error.
|
|
308
315
|
for (const name of response.getHeaderNames()) {
|
|
@@ -321,6 +328,7 @@ class HostedExchange {
|
|
|
321
328
|
return;
|
|
322
329
|
this.stopped = true;
|
|
323
330
|
clearTimeout(this.timer);
|
|
331
|
+
clearTimeout(this.idleWriteTimer);
|
|
324
332
|
this.controller.abort();
|
|
325
333
|
this.upload?.off('data', this.sendChunk);
|
|
326
334
|
this.incoming?.off('data', this.receiveChunk);
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { type AdmissionRedis } from './admission.js';
|
|
2
|
+
/**
|
|
3
|
+
* Rate limiting and in-flight pools for the external ingress (design §(e) "Rate limiting and
|
|
4
|
+
* admission"). A separate instance from `GatewayControlAdmission`, with its own in-flight pools,
|
|
5
|
+
* so a flood of borrower exchanges can never return 503 on an Atlas user's `/_sequence/session`.
|
|
6
|
+
* The same fail-closed fixed-window counters: when Redis cannot answer, nothing is admitted.
|
|
7
|
+
*/
|
|
8
|
+
export type IngressInFlightPool = 'ingress-public' | 'ingress-grant';
|
|
9
|
+
export interface IngressAdmissionLimits {
|
|
10
|
+
/** Fixed Redis-clock window shared by every class. */
|
|
11
|
+
windowMs: number;
|
|
12
|
+
preflight: {
|
|
13
|
+
perClient: number;
|
|
14
|
+
};
|
|
15
|
+
exchange: {
|
|
16
|
+
perClient: number;
|
|
17
|
+
perHost: number;
|
|
18
|
+
environment: number;
|
|
19
|
+
};
|
|
20
|
+
logout: {
|
|
21
|
+
perSubject: number;
|
|
22
|
+
perClient: number;
|
|
23
|
+
};
|
|
24
|
+
subject: {
|
|
25
|
+
perSubject: number;
|
|
26
|
+
};
|
|
27
|
+
app: {
|
|
28
|
+
perApp: number;
|
|
29
|
+
};
|
|
30
|
+
/** Outstanding Atlas calls one gateway process may hold per pool. */
|
|
31
|
+
pools: Record<IngressInFlightPool, number>;
|
|
32
|
+
}
|
|
33
|
+
/** The design's defaults; bindings may lower them in Atlas, never raise them here. */
|
|
34
|
+
export declare const INGRESS_ADMISSION_LIMITS: Readonly<IngressAdmissionLimits>;
|
|
35
|
+
/**
|
|
36
|
+
* The address an ingress caller is budgeted against: an IPv4 address as is, an IPv6 address as its
|
|
37
|
+
* /48 (design §(e)), since one IPv6 customer commonly controls a /48 or a /56.
|
|
38
|
+
*/
|
|
39
|
+
export declare function ingressClientBucket(address: string): string;
|
|
40
|
+
export declare class IngressAdmission {
|
|
41
|
+
private readonly options;
|
|
42
|
+
private readonly namespace;
|
|
43
|
+
private readonly limits;
|
|
44
|
+
private readonly inFlight;
|
|
45
|
+
constructor(options: {
|
|
46
|
+
redis: AdmissionRedis;
|
|
47
|
+
namespace: string;
|
|
48
|
+
limits?: IngressAdmissionLimits;
|
|
49
|
+
});
|
|
50
|
+
/** `ingress-preflight`: per client address only, no in-flight permit (no Atlas call follows). */
|
|
51
|
+
preflight({ clientIp }: {
|
|
52
|
+
clientIp: string;
|
|
53
|
+
}): Promise<void>;
|
|
54
|
+
/** `ingress-exchange`: per client, per host and environment-wide; holds an `ingress-public` permit. */
|
|
55
|
+
exchange({ clientIp, host }: {
|
|
56
|
+
clientIp: string;
|
|
57
|
+
host: string;
|
|
58
|
+
}): Promise<() => void>;
|
|
59
|
+
/** `ingress-logout`: per subject and per client, after the pass verified; holds an `ingress-public` permit. */
|
|
60
|
+
logout({ provider, subjectHash, clientIp }: {
|
|
61
|
+
provider: string;
|
|
62
|
+
subjectHash: string;
|
|
63
|
+
clientIp: string;
|
|
64
|
+
}): Promise<() => void>;
|
|
65
|
+
/** `ingress-subject` and `ingress-app`, on verified pass fields; plain counters, no in-flight permit. */
|
|
66
|
+
request({ provider, subjectHash, projectId, ingressId }: {
|
|
67
|
+
provider: string;
|
|
68
|
+
subjectHash: string;
|
|
69
|
+
projectId: string;
|
|
70
|
+
ingressId: string;
|
|
71
|
+
}): Promise<void>;
|
|
72
|
+
/** Runs one Atlas call under a permit from `pool`; a full pool is 503, never a queue. */
|
|
73
|
+
withPool<T>(pool: IngressInFlightPool, work: () => Promise<T>): Promise<T>;
|
|
74
|
+
private withPermit;
|
|
75
|
+
private take;
|
|
76
|
+
private count;
|
|
77
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { isIPv6 } from 'node:net';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { redisNamespaceSchema } from '../tunnel/redis-ownership.js';
|
|
5
|
+
import { ArtifactAdmissionError } from './admission.js';
|
|
6
|
+
/** The design's defaults; bindings may lower them in Atlas, never raise them here. */
|
|
7
|
+
export const INGRESS_ADMISSION_LIMITS = Object.freeze({
|
|
8
|
+
windowMs: 10_000,
|
|
9
|
+
// Sized for many borrowers behind one carrier-grade NAT; no Atlas call follows a preflight.
|
|
10
|
+
preflight: Object.freeze({ perClient: 600 }),
|
|
11
|
+
exchange: Object.freeze({ perClient: 30, perHost: 300, environment: 3000 }),
|
|
12
|
+
logout: Object.freeze({ perSubject: 5, perClient: 30 }),
|
|
13
|
+
subject: Object.freeze({ perSubject: 60 }),
|
|
14
|
+
app: Object.freeze({ perApp: 2000 }),
|
|
15
|
+
pools: Object.freeze({ 'ingress-public': 32, 'ingress-grant': 64 }),
|
|
16
|
+
});
|
|
17
|
+
const limit = z.number().int().min(1).max(1_000_000);
|
|
18
|
+
const limitsSchema = z.strictObject({
|
|
19
|
+
windowMs: z.number().int().min(1_000).max(3_600_000),
|
|
20
|
+
preflight: z.strictObject({ perClient: limit }),
|
|
21
|
+
exchange: z.strictObject({ perClient: limit, perHost: limit, environment: limit }),
|
|
22
|
+
logout: z.strictObject({ perSubject: limit, perClient: limit }),
|
|
23
|
+
subject: z.strictObject({ perSubject: limit }),
|
|
24
|
+
app: z.strictObject({ perApp: limit }),
|
|
25
|
+
pools: z.strictObject({ 'ingress-public': z.number().int().min(1).max(10_000), 'ingress-grant': z.number().int().min(1).max(10_000) }),
|
|
26
|
+
});
|
|
27
|
+
// Every key of one call shares the `{namespace}` hash tag, so the script stays single-slot on Redis
|
|
28
|
+
// Cluster. Limits are ARGV[1..n], the window ARGV[n+1]. A refused attempt consumes nothing, so one
|
|
29
|
+
// noisy client cannot spend a host's or the environment's budget.
|
|
30
|
+
const ADMIT = `-- artifact-gateway-ingress-admission-v1
|
|
31
|
+
local window = tonumber(ARGV[#KEYS + 1])
|
|
32
|
+
for index, key in ipairs(KEYS) do
|
|
33
|
+
if tonumber(redis.call('GET', key) or '0') >= tonumber(ARGV[index]) then
|
|
34
|
+
local ttl = redis.call('PTTL', key)
|
|
35
|
+
if ttl < 0 then
|
|
36
|
+
redis.call('PEXPIRE', key, window)
|
|
37
|
+
ttl = window
|
|
38
|
+
end
|
|
39
|
+
return {0, ttl}
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
for _, key in ipairs(KEYS) do
|
|
43
|
+
if redis.call('INCR', key) == 1 or redis.call('PTTL', key) < 0 then redis.call('PEXPIRE', key, window) end
|
|
44
|
+
end
|
|
45
|
+
return {1, 0}`;
|
|
46
|
+
const replySchema = z.tuple([z.number().int().min(0).max(1), z.number().int().min(0)]);
|
|
47
|
+
/** A bounded, opaque Redis key segment: no caller-chosen bytes reach a key. */
|
|
48
|
+
function keyPart(...values) {
|
|
49
|
+
return createHash('sha256').update(values.join('\u0000')).digest('hex').slice(0, 32);
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The address an ingress caller is budgeted against: an IPv4 address as is, an IPv6 address as its
|
|
53
|
+
* /48 (design §(e)), since one IPv6 customer commonly controls a /48 or a /56.
|
|
54
|
+
*/
|
|
55
|
+
export function ingressClientBucket(address) {
|
|
56
|
+
const mapped = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i.exec(address);
|
|
57
|
+
if (mapped)
|
|
58
|
+
return mapped[1];
|
|
59
|
+
if (!isIPv6(address))
|
|
60
|
+
return address;
|
|
61
|
+
const [head = '', tail] = address.toLowerCase().split('::');
|
|
62
|
+
const leading = head ? head.split(':') : [];
|
|
63
|
+
const trailing = tail ? tail.split(':') : [];
|
|
64
|
+
const groups = tail === undefined ? leading : [...leading, ...Array(Math.max(0, 8 - leading.length - trailing.length)).fill('0'), ...trailing];
|
|
65
|
+
return `${groups.slice(0, 3).map((group) => group.padStart(4, '0')).join(':')}::/48`;
|
|
66
|
+
}
|
|
67
|
+
export class IngressAdmission {
|
|
68
|
+
options;
|
|
69
|
+
namespace;
|
|
70
|
+
limits;
|
|
71
|
+
inFlight = { 'ingress-public': 0, 'ingress-grant': 0 };
|
|
72
|
+
constructor(options) {
|
|
73
|
+
this.options = options;
|
|
74
|
+
this.namespace = redisNamespaceSchema.parse(options.namespace);
|
|
75
|
+
this.limits = limitsSchema.parse(options.limits ?? INGRESS_ADMISSION_LIMITS);
|
|
76
|
+
const policy = options.redis.options;
|
|
77
|
+
if (policy.enableOfflineQueue !== false || policy.autoResendUnfulfilledCommands !== false || policy.maxRetriesPerRequest !== 0) {
|
|
78
|
+
throw new Error('Ingress admission Redis must disable offline queues, command replay and retries');
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/** `ingress-preflight`: per client address only, no in-flight permit (no Atlas call follows). */
|
|
82
|
+
async preflight({ clientIp }) {
|
|
83
|
+
await this.count({ name: 'preflight', counters: [[`client:${keyPart(ingressClientBucket(clientIp))}`, this.limits.preflight.perClient]] });
|
|
84
|
+
}
|
|
85
|
+
/** `ingress-exchange`: per client, per host and environment-wide; holds an `ingress-public` permit. */
|
|
86
|
+
async exchange({ clientIp, host }) {
|
|
87
|
+
const { perClient, perHost, environment } = this.limits.exchange;
|
|
88
|
+
return this.withPermit('ingress-public', () => this.count({ name: 'exchange', counters: [
|
|
89
|
+
[`client:${keyPart(ingressClientBucket(clientIp))}`, perClient], [`host:${keyPart(host)}`, perHost], ['environment', environment],
|
|
90
|
+
] }));
|
|
91
|
+
}
|
|
92
|
+
/** `ingress-logout`: per subject and per client, after the pass verified; holds an `ingress-public` permit. */
|
|
93
|
+
async logout({ provider, subjectHash, clientIp }) {
|
|
94
|
+
const { perSubject, perClient } = this.limits.logout;
|
|
95
|
+
return this.withPermit('ingress-public', () => this.count({ name: 'logout', counters: [
|
|
96
|
+
[`subject:${keyPart(provider, subjectHash)}`, perSubject], [`client:${keyPart(ingressClientBucket(clientIp))}`, perClient],
|
|
97
|
+
] }));
|
|
98
|
+
}
|
|
99
|
+
/** `ingress-subject` and `ingress-app`, on verified pass fields; plain counters, no in-flight permit. */
|
|
100
|
+
async request({ provider, subjectHash, projectId, ingressId }) {
|
|
101
|
+
await this.count({ name: 'request', counters: [
|
|
102
|
+
[`subject:${keyPart(provider, subjectHash)}`, this.limits.subject.perSubject], [`app:${keyPart(projectId, ingressId)}`, this.limits.app.perApp],
|
|
103
|
+
] });
|
|
104
|
+
}
|
|
105
|
+
/** Runs one Atlas call under a permit from `pool`; a full pool is 503, never a queue. */
|
|
106
|
+
async withPool(pool, work) {
|
|
107
|
+
const release = this.take(pool);
|
|
108
|
+
try {
|
|
109
|
+
return await work();
|
|
110
|
+
}
|
|
111
|
+
finally {
|
|
112
|
+
release();
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
async withPermit(pool, admit) {
|
|
116
|
+
const release = this.take(pool);
|
|
117
|
+
try {
|
|
118
|
+
await admit();
|
|
119
|
+
return release;
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
release();
|
|
123
|
+
throw error;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
take(pool) {
|
|
127
|
+
if (this.inFlight[pool] >= this.limits.pools[pool])
|
|
128
|
+
throw new ArtifactAdmissionError(503, 'admission_unavailable');
|
|
129
|
+
this.inFlight[pool]++;
|
|
130
|
+
let released = false;
|
|
131
|
+
return () => {
|
|
132
|
+
if (released)
|
|
133
|
+
return;
|
|
134
|
+
released = true;
|
|
135
|
+
this.inFlight[pool]--;
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
async count({ name, counters }) {
|
|
139
|
+
const prefix = `artifact-gateway-ingress:{${this.namespace}}:${name}`;
|
|
140
|
+
let reply;
|
|
141
|
+
try {
|
|
142
|
+
reply = replySchema.parse(await this.options.redis.eval(ADMIT, counters.length, ...counters.map(([key]) => `${prefix}:${key}`), ...counters.map(([, maximum]) => maximum), this.limits.windowMs));
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
throw new ArtifactAdmissionError(503, 'admission_unavailable');
|
|
146
|
+
}
|
|
147
|
+
if (reply[0] !== 1)
|
|
148
|
+
throw new ArtifactAdmissionError(429, 'capacity_exceeded', Math.max(1, Math.ceil(reply[1] / 1000)));
|
|
149
|
+
}
|
|
150
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { type IngressAuditBatch, type IngressAuditEvent } from '../server/ingress-control-contract.js';
|
|
2
|
+
export declare const INGRESS_AUDIT_FLUSH_INTERVAL_MS = 5000;
|
|
3
|
+
/** Refusals buffered between flushes; past this they are dropped and counted, never queued without bound. */
|
|
4
|
+
export declare const INGRESS_AUDIT_CAPACITY = 5000;
|
|
5
|
+
/** Shutdown spends at most this on the final batch, so audit delivery never holds up tunnel cleanup. */
|
|
6
|
+
export declare const INGRESS_AUDIT_CLOSE_BUDGET_MS = 2000;
|
|
7
|
+
export type IngressAuditFailure = {
|
|
8
|
+
kind: 'ingress_audit_dropped' | 'ingress_audit_delivery_failed';
|
|
9
|
+
count: number;
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* Batches the refusals the gateway decided itself (edge, client address, CORS, a refused authentic
|
|
13
|
+
* pass, freshness) to `control/ingress-audit` every 5 s, at most 500 per call. Garbage and
|
|
14
|
+
* admission refusals are counted elsewhere and never reach it. Refusals an Atlas control
|
|
15
|
+
* route returned are never recorded here: Atlas already audited them. Best effort and bounded: a
|
|
16
|
+
* refusal flood can cost Atlas at most one batch per interval per gateway process, and a failed
|
|
17
|
+
* delivery is counted, not retried into a backlog.
|
|
18
|
+
*/
|
|
19
|
+
export declare class IngressDenialAuditor {
|
|
20
|
+
private readonly options;
|
|
21
|
+
private buffer;
|
|
22
|
+
private dropped;
|
|
23
|
+
private timer;
|
|
24
|
+
private flushing;
|
|
25
|
+
private readonly intervalMs;
|
|
26
|
+
private readonly capacity;
|
|
27
|
+
constructor(options: {
|
|
28
|
+
send: (batch: IngressAuditBatch) => Promise<void>;
|
|
29
|
+
onFailure?: (failure: IngressAuditFailure) => void;
|
|
30
|
+
intervalMs?: number;
|
|
31
|
+
capacity?: number;
|
|
32
|
+
closeBudgetMs?: number;
|
|
33
|
+
});
|
|
34
|
+
record(event: IngressAuditEvent): void;
|
|
35
|
+
/** Flushes every interval; the timer never keeps the process alive. */
|
|
36
|
+
start(): void;
|
|
37
|
+
/** One batch of at most 500 refusals, single-flight. Never rejects. */
|
|
38
|
+
flush(): Promise<void>;
|
|
39
|
+
/**
|
|
40
|
+
* Stops the timer and makes one final delivery attempt within the close budget: a slow or failing
|
|
41
|
+
* Atlas costs shutdown at most that long. Whatever is still buffered is dropped and counted.
|
|
42
|
+
*/
|
|
43
|
+
close(): Promise<void>;
|
|
44
|
+
private deliver;
|
|
45
|
+
private reportDropped;
|
|
46
|
+
}
|