borgmcp-server 0.1.1
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/LICENSE +105 -0
- package/NOTICE +8 -0
- package/README.md +119 -0
- package/SECURITY.md +38 -0
- package/THIRD_PARTY_NOTICES.md +35 -0
- package/dist/bootstrap.d.ts +18 -0
- package/dist/bootstrap.js +104 -0
- package/dist/bootstrap.js.map +1 -0
- package/dist/cli.d.ts +7 -0
- package/dist/cli.js +96 -0
- package/dist/cli.js.map +1 -0
- package/dist/coordination-api.d.ts +25 -0
- package/dist/coordination-api.js +457 -0
- package/dist/coordination-api.js.map +1 -0
- package/dist/credentials.d.ts +58 -0
- package/dist/credentials.js +244 -0
- package/dist/credentials.js.map +1 -0
- package/dist/enrollment.d.ts +17 -0
- package/dist/enrollment.js +122 -0
- package/dist/enrollment.js.map +1 -0
- package/dist/https-server.d.ts +94 -0
- package/dist/https-server.js +814 -0
- package/dist/https-server.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/dist/main.d.ts +3 -0
- package/dist/main.js +67 -0
- package/dist/main.js.map +1 -0
- package/dist/migrations.d.ts +8 -0
- package/dist/migrations.js +366 -0
- package/dist/migrations.js.map +1 -0
- package/dist/network-policy.d.ts +13 -0
- package/dist/network-policy.js +49 -0
- package/dist/network-policy.js.map +1 -0
- package/dist/operator-error.d.ts +3 -0
- package/dist/operator-error.js +63 -0
- package/dist/operator-error.js.map +1 -0
- package/dist/principal.d.ts +32 -0
- package/dist/principal.js +64 -0
- package/dist/principal.js.map +1 -0
- package/dist/protocol-draft.d.ts +2 -0
- package/dist/protocol-draft.js +31 -0
- package/dist/protocol-draft.js.map +1 -0
- package/dist/service.d.ts +61 -0
- package/dist/service.js +455 -0
- package/dist/service.js.map +1 -0
- package/dist/start-options.d.ts +2 -0
- package/dist/start-options.js +46 -0
- package/dist/start-options.js.map +1 -0
- package/dist/store.d.ts +327 -0
- package/dist/store.js +1729 -0
- package/dist/store.js.map +1 -0
- package/npm-shrinkwrap.json +1942 -0
- package/package.json +60 -0
- package/src/bootstrap.ts +127 -0
- package/src/cli.ts +102 -0
- package/src/coordination-api.ts +508 -0
- package/src/credentials.ts +319 -0
- package/src/enrollment.ts +156 -0
- package/src/https-server.ts +962 -0
- package/src/index.ts +3 -0
- package/src/main.ts +73 -0
- package/src/migrations.ts +394 -0
- package/src/network-policy.ts +65 -0
- package/src/operator-error.ts +97 -0
- package/src/principal.ts +106 -0
- package/src/protocol-draft.ts +32 -0
- package/src/service.ts +525 -0
- package/src/start-options.ts +46 -0
- package/src/store.ts +2316 -0
|
@@ -0,0 +1,962 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import { createServer, type Server as HttpsServer } from "node:https";
|
|
3
|
+
import type { AddressInfo, Socket } from "node:net";
|
|
4
|
+
import { createHash, X509Certificate } from "node:crypto";
|
|
5
|
+
|
|
6
|
+
import { resolveBindOptions, type BindOptionsInput } from "./network-policy.js";
|
|
7
|
+
import type { CoordinationRequest, CoordinationResponse } from "./coordination-api.js";
|
|
8
|
+
import type { Principal } from "./principal.js";
|
|
9
|
+
|
|
10
|
+
export interface ProtocolInfoDocument {
|
|
11
|
+
readonly protocol_version: string;
|
|
12
|
+
readonly package: {
|
|
13
|
+
readonly name: "borgmcp-shared";
|
|
14
|
+
readonly version: string;
|
|
15
|
+
};
|
|
16
|
+
readonly capabilities: readonly string[];
|
|
17
|
+
readonly limits: {
|
|
18
|
+
readonly max_request_bytes: number;
|
|
19
|
+
readonly max_log_message_bytes: number;
|
|
20
|
+
readonly max_read_page_size: number;
|
|
21
|
+
readonly max_replay_page_size: number;
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ServiceLimits {
|
|
26
|
+
readonly maxConnections: number;
|
|
27
|
+
readonly maxConnectionsPerAddress: number;
|
|
28
|
+
readonly maxRequestsPerWindow: number;
|
|
29
|
+
readonly maxRequestsPerAddressWindow: number;
|
|
30
|
+
readonly maxRequestsGlobalWindow: number;
|
|
31
|
+
readonly rateLimitWindowMs: number;
|
|
32
|
+
readonly maxRateLimitEntries: number;
|
|
33
|
+
readonly maxStreamsPerCredential: number;
|
|
34
|
+
readonly maxHeaderBytes: number;
|
|
35
|
+
readonly maxRequestBodyBytes: number;
|
|
36
|
+
readonly maxRequestsPerSocket: number;
|
|
37
|
+
readonly requestTimeoutMs: number;
|
|
38
|
+
readonly tlsHandshakeTimeoutMs: number;
|
|
39
|
+
readonly headersTimeoutMs: number;
|
|
40
|
+
readonly keepAliveTimeoutMs: number;
|
|
41
|
+
readonly handlerTimeoutMs: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const DEFAULT_SERVICE_LIMITS: ServiceLimits = {
|
|
45
|
+
maxConnections: 100,
|
|
46
|
+
maxConnectionsPerAddress: 25,
|
|
47
|
+
maxRequestsPerWindow: 120,
|
|
48
|
+
maxRequestsPerAddressWindow: 600,
|
|
49
|
+
maxRequestsGlobalWindow: 5_000,
|
|
50
|
+
rateLimitWindowMs: 60_000,
|
|
51
|
+
maxRateLimitEntries: 1_024,
|
|
52
|
+
maxStreamsPerCredential: 8,
|
|
53
|
+
maxHeaderBytes: 16_384,
|
|
54
|
+
maxRequestBodyBytes: 65_536,
|
|
55
|
+
maxRequestsPerSocket: 100,
|
|
56
|
+
requestTimeoutMs: 15_000,
|
|
57
|
+
tlsHandshakeTimeoutMs: 10_000,
|
|
58
|
+
headersTimeoutMs: 10_000,
|
|
59
|
+
keepAliveTimeoutMs: 5_000,
|
|
60
|
+
handlerTimeoutMs: 5_000,
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export interface RequestHandlerContext {
|
|
64
|
+
readonly protocolInfo: ProtocolInfoDocument;
|
|
65
|
+
readonly authorizeProtocol: (
|
|
66
|
+
authorization: string | undefined,
|
|
67
|
+
signal: AbortSignal,
|
|
68
|
+
) => Promise<boolean | "missing" | "invalid" | "revoked">;
|
|
69
|
+
readonly exchangeEnrollment?: (
|
|
70
|
+
body: unknown,
|
|
71
|
+
) => Promise<{ readonly status: 201 | 400 | 401 | 507; readonly body?: unknown }>;
|
|
72
|
+
readonly authorizeCoordination?: (
|
|
73
|
+
authorization: string | undefined,
|
|
74
|
+
signal: AbortSignal,
|
|
75
|
+
) => Promise<Principal | "missing" | "invalid" | "revoked">;
|
|
76
|
+
readonly handleCoordination?: (request: CoordinationRequest) => Promise<CoordinationResponse>;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface HttpsServerOptions {
|
|
80
|
+
readonly bind?: BindOptionsInput;
|
|
81
|
+
readonly tls: {
|
|
82
|
+
readonly key: string | Buffer;
|
|
83
|
+
readonly cert: string | Buffer;
|
|
84
|
+
readonly ca?: string | Buffer;
|
|
85
|
+
};
|
|
86
|
+
readonly protocolInfo: ProtocolInfoDocument;
|
|
87
|
+
readonly authorizeProtocol: RequestHandlerContext["authorizeProtocol"];
|
|
88
|
+
readonly exchangeEnrollment?: RequestHandlerContext["exchangeEnrollment"];
|
|
89
|
+
readonly authorizeCoordination?: RequestHandlerContext["authorizeCoordination"];
|
|
90
|
+
readonly handleCoordination?: RequestHandlerContext["handleCoordination"];
|
|
91
|
+
readonly limits?: ServiceLimits;
|
|
92
|
+
readonly testHooks?: {
|
|
93
|
+
readonly identifyRemoteAddress?: (socket: Socket) => string;
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface RunningServer {
|
|
98
|
+
readonly origin: string;
|
|
99
|
+
readonly limits: ServiceLimits;
|
|
100
|
+
readonly close: () => Promise<void>;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function startHttpsServer(options: HttpsServerOptions): Promise<RunningServer> {
|
|
104
|
+
if (options.handleCoordination !== undefined && options.authorizeCoordination === undefined) {
|
|
105
|
+
throw new Error("Coordination routes require server-derived principal authentication.");
|
|
106
|
+
}
|
|
107
|
+
const bind = resolveBindOptions(options.bind ?? {});
|
|
108
|
+
const limits = options.limits ?? DEFAULT_SERVICE_LIMITS;
|
|
109
|
+
validateLimits(limits);
|
|
110
|
+
validateTlsCertificate(options.tls.cert, bind.host, bind.mode, options.tls.ca);
|
|
111
|
+
const handlerContext = createRequestHandlerContext(options);
|
|
112
|
+
|
|
113
|
+
const server = createServer(
|
|
114
|
+
{
|
|
115
|
+
key: options.tls.key,
|
|
116
|
+
cert: options.tls.cert,
|
|
117
|
+
minVersion: "TLSv1.3",
|
|
118
|
+
maxHeaderSize: limits.maxHeaderBytes,
|
|
119
|
+
requestTimeout: limits.requestTimeoutMs,
|
|
120
|
+
handshakeTimeout: limits.tlsHandshakeTimeoutMs,
|
|
121
|
+
headersTimeout: limits.headersTimeoutMs,
|
|
122
|
+
keepAliveTimeout: limits.keepAliveTimeoutMs,
|
|
123
|
+
},
|
|
124
|
+
createRequestListener(handlerContext, limits, options.testHooks?.identifyRemoteAddress),
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
const acceptedSockets = applyServerLimits(server, limits);
|
|
128
|
+
server.on("secureConnection", (socket) => socket.disableRenegotiation());
|
|
129
|
+
server.on("tlsClientError", (_error, socket) => socket.destroy());
|
|
130
|
+
server.on("clientError", (_error, socket) => socket.end("HTTP/1.1 400 Bad Request\r\n\r\n"));
|
|
131
|
+
server.on("checkContinue", (_request, response) => sendEmpty(response, 417, true));
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
await listen(server, bind.port, bind.host);
|
|
135
|
+
} catch (error) {
|
|
136
|
+
server.closeAllConnections();
|
|
137
|
+
try {
|
|
138
|
+
server.close();
|
|
139
|
+
} catch {
|
|
140
|
+
// Preserve the originating listen failure.
|
|
141
|
+
}
|
|
142
|
+
throw error;
|
|
143
|
+
}
|
|
144
|
+
const address = server.address() as AddressInfo;
|
|
145
|
+
const displayHost = address.family === "IPv6" ? `[${address.address}]` : address.address;
|
|
146
|
+
let closePromise: Promise<void> | undefined;
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
origin: `https://${displayHost}:${address.port}`,
|
|
150
|
+
limits,
|
|
151
|
+
close: () => {
|
|
152
|
+
closePromise ??= close(server, acceptedSockets);
|
|
153
|
+
return closePromise;
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function createRequestHandlerContext(
|
|
159
|
+
options: HttpsServerOptions,
|
|
160
|
+
): RequestHandlerContext {
|
|
161
|
+
return Object.freeze({
|
|
162
|
+
protocolInfo: options.protocolInfo,
|
|
163
|
+
authorizeProtocol: options.authorizeProtocol,
|
|
164
|
+
...(options.exchangeEnrollment === undefined
|
|
165
|
+
? {}
|
|
166
|
+
: { exchangeEnrollment: options.exchangeEnrollment }),
|
|
167
|
+
...(options.authorizeCoordination === undefined
|
|
168
|
+
? {}
|
|
169
|
+
: { authorizeCoordination: options.authorizeCoordination }),
|
|
170
|
+
...(options.handleCoordination === undefined
|
|
171
|
+
? {}
|
|
172
|
+
: { handleCoordination: options.handleCoordination }),
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function createRequestListener(
|
|
177
|
+
context: RequestHandlerContext,
|
|
178
|
+
limits: ServiceLimits,
|
|
179
|
+
identifyRemoteAddress: (socket: Socket) => string = (socket) => socket.remoteAddress ?? "unknown",
|
|
180
|
+
): (request: IncomingMessage, response: ServerResponse) => void {
|
|
181
|
+
const admissionLimiter = new PreAuthAdmissionLimiter(limits);
|
|
182
|
+
const credentialRateLimiter = new RequestRateLimiter(limits, limits.maxRequestsPerWindow);
|
|
183
|
+
const streamQuota = new ConcurrentQuota(limits.maxStreamsPerCredential);
|
|
184
|
+
return (request, response) => {
|
|
185
|
+
const controller = new AbortController();
|
|
186
|
+
response.once("close", () => controller.abort());
|
|
187
|
+
let timer: NodeJS.Timeout | undefined;
|
|
188
|
+
const deadline = new Promise<"deadline">((resolve) => {
|
|
189
|
+
timer = setTimeout(() => {
|
|
190
|
+
controller.abort();
|
|
191
|
+
resolve("deadline");
|
|
192
|
+
}, limits.handlerTimeoutMs);
|
|
193
|
+
timer.unref();
|
|
194
|
+
});
|
|
195
|
+
const handled = handleRequest(
|
|
196
|
+
request,
|
|
197
|
+
response,
|
|
198
|
+
context,
|
|
199
|
+
limits,
|
|
200
|
+
admissionLimiter,
|
|
201
|
+
credentialRateLimiter,
|
|
202
|
+
streamQuota,
|
|
203
|
+
identifyRemoteAddress,
|
|
204
|
+
controller.signal,
|
|
205
|
+
)
|
|
206
|
+
.then(() => "handled" as const);
|
|
207
|
+
|
|
208
|
+
void Promise.race([handled, deadline])
|
|
209
|
+
.then((outcome) => {
|
|
210
|
+
if (outcome === "deadline") sendEmpty(response, 503, true);
|
|
211
|
+
})
|
|
212
|
+
.catch(() => sendEmpty(response, 500, true))
|
|
213
|
+
.finally(() => {
|
|
214
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
215
|
+
});
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function handleRequest(
|
|
220
|
+
request: IncomingMessage,
|
|
221
|
+
response: ServerResponse,
|
|
222
|
+
context: RequestHandlerContext,
|
|
223
|
+
limits: ServiceLimits,
|
|
224
|
+
admissionLimiter: PreAuthAdmissionLimiter,
|
|
225
|
+
credentialRateLimiter: RequestRateLimiter,
|
|
226
|
+
streamQuota: ConcurrentQuota,
|
|
227
|
+
identifyRemoteAddress: (socket: Socket) => string,
|
|
228
|
+
signal: AbortSignal,
|
|
229
|
+
): Promise<void> {
|
|
230
|
+
const addressIdentity = `address:${identifyRemoteAddress(request.socket)}`;
|
|
231
|
+
const preAuthRetry = admissionLimiter.consume(addressIdentity);
|
|
232
|
+
if (preAuthRetry !== null) {
|
|
233
|
+
request.resume();
|
|
234
|
+
sendRateLimited(response, preAuthRetry);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
if (request.headers.origin !== undefined) {
|
|
238
|
+
request.resume();
|
|
239
|
+
sendEmpty(response, 403, true);
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const path = parseRequestPath(request.url);
|
|
244
|
+
|
|
245
|
+
const requestBody = await readRequestBody(request, limits.maxRequestBodyBytes);
|
|
246
|
+
if (requestBody === "oversized") {
|
|
247
|
+
if (isCoordinationPath(path)) {
|
|
248
|
+
sendJson(response, 413, protocolError("CONTENT_TOO_LARGE", "Request body is too large."), true);
|
|
249
|
+
} else {
|
|
250
|
+
sendEmpty(response, 413, true);
|
|
251
|
+
}
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (path === "/healthz") {
|
|
256
|
+
if (requestBody.length !== 0) return sendEmpty(response, 400, true);
|
|
257
|
+
sendEmpty(response, request.method === "GET" ? 204 : 405);
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (path === "/api/protocol") {
|
|
262
|
+
if (requestBody.length !== 0) return sendEmpty(response, 400, true);
|
|
263
|
+
const authorized = await context.authorizeProtocol(request.headers.authorization, signal);
|
|
264
|
+
if (signal.aborted) return;
|
|
265
|
+
if (authorized !== true) {
|
|
266
|
+
const code = authorized === "revoked"
|
|
267
|
+
? "SESSION_REVOKED"
|
|
268
|
+
: authorized === "missing" || request.headers.authorization === undefined
|
|
269
|
+
? "AUTH_MISSING"
|
|
270
|
+
: "AUTH_INVALID";
|
|
271
|
+
sendJson(response, 401, protocolError(code, "Authentication failed."));
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
const credentialRetry = consumeAuthenticatedRateLimit(
|
|
275
|
+
request.headers.authorization,
|
|
276
|
+
credentialRateLimiter,
|
|
277
|
+
);
|
|
278
|
+
if (credentialRetry !== null) return sendRateLimited(response, credentialRetry);
|
|
279
|
+
if (request.method !== "GET") {
|
|
280
|
+
sendEmpty(response, 405);
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
sendJson(response, 200, {
|
|
284
|
+
protocol_version: "1",
|
|
285
|
+
request_id: "protocol-info",
|
|
286
|
+
payload: context.protocolInfo,
|
|
287
|
+
});
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (path === "/api/enrollment/exchange") {
|
|
292
|
+
if (request.method !== "POST" || context.exchangeEnrollment === undefined) {
|
|
293
|
+
sendEmpty(response, 405);
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
let decoded: unknown;
|
|
297
|
+
if (requestBody.length === 0) {
|
|
298
|
+
decoded = undefined;
|
|
299
|
+
} else {
|
|
300
|
+
try {
|
|
301
|
+
decoded = JSON.parse(requestBody.toString("utf8"));
|
|
302
|
+
} catch {
|
|
303
|
+
decoded = undefined;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
const result = await context.exchangeEnrollment(decoded);
|
|
307
|
+
if (signal.aborted) return;
|
|
308
|
+
if (result.body === undefined) sendEmpty(response, result.status, result.status === 400);
|
|
309
|
+
else if (result.status === 400) sendJson(response, 400, result.body, true);
|
|
310
|
+
else if (result.status === 401 || result.status === 507) sendJson(response, result.status, result.body);
|
|
311
|
+
else sendJson(response, 201, result.body);
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (isCoordinationPath(path) && context.handleCoordination !== undefined) {
|
|
316
|
+
let decoded: unknown;
|
|
317
|
+
if (requestBody.length === 0) {
|
|
318
|
+
decoded = undefined;
|
|
319
|
+
} else {
|
|
320
|
+
try {
|
|
321
|
+
decoded = JSON.parse(requestBody.toString("utf8"));
|
|
322
|
+
} catch {
|
|
323
|
+
decoded = undefined;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
const authorization = request.headers.authorization;
|
|
327
|
+
const authorizeCoordination = context.authorizeCoordination;
|
|
328
|
+
if (authorizeCoordination === undefined) {
|
|
329
|
+
sendJson(response, 500, protocolError("INTERNAL_ERROR", "Coordination authentication is unavailable."), true);
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
const authentication = await authorizeCoordination(authorization, signal);
|
|
333
|
+
if (signal.aborted) return;
|
|
334
|
+
if (typeof authentication === "string") {
|
|
335
|
+
const code = authentication === "revoked"
|
|
336
|
+
? "SESSION_REVOKED"
|
|
337
|
+
: authentication === "missing" || authorization === undefined ? "AUTH_MISSING" : "AUTH_INVALID";
|
|
338
|
+
sendJson(response, 401, protocolError(code, "Authentication failed."));
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
const clientIdentity = `client:${authentication.kind === "drone-session"
|
|
342
|
+
? authentication.clientId
|
|
343
|
+
: authentication.id}`;
|
|
344
|
+
const credentialRetry = credentialRateLimiter.consume(clientIdentity);
|
|
345
|
+
if (credentialRetry !== null) return sendRateLimited(response, credentialRetry);
|
|
346
|
+
const cursor = parseCursorParameter(request.url, path);
|
|
347
|
+
if (cursor === INVALID_COORDINATION_QUERY) {
|
|
348
|
+
sendJson(response, 400, protocolError("INVALID_INPUT", "Invalid query parameters."), true);
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
const result = await context.handleCoordination({
|
|
352
|
+
method: request.method ?? "",
|
|
353
|
+
path,
|
|
354
|
+
principal: authentication,
|
|
355
|
+
...(decoded === undefined ? {} : { body: decoded }),
|
|
356
|
+
...(cursor === undefined ? {} : { cursor }),
|
|
357
|
+
signal,
|
|
358
|
+
});
|
|
359
|
+
if (signal.aborted) {
|
|
360
|
+
if (result.stream !== undefined) await closeRejectedStream(result.stream);
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
if (result.stream !== undefined) {
|
|
364
|
+
const release = streamQuota.acquire(credentialIdentity(authorization) ?? addressIdentity);
|
|
365
|
+
if (release === null) {
|
|
366
|
+
await closeRejectedStream(result.stream);
|
|
367
|
+
sendRateLimited(response, 1);
|
|
368
|
+
}
|
|
369
|
+
else await startEventStream(response, result.stream, release);
|
|
370
|
+
} else if (result.body === undefined) {
|
|
371
|
+
sendEmpty(response, result.status);
|
|
372
|
+
} else {
|
|
373
|
+
sendJson(response, result.status, result.body, result.status === 400 || result.status === 413);
|
|
374
|
+
}
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
sendEmpty(response, 404);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
async function closeRejectedStream(stream: AsyncIterable<string>): Promise<void> {
|
|
382
|
+
const iterator = stream[Symbol.asyncIterator]();
|
|
383
|
+
try {
|
|
384
|
+
await iterator.return?.();
|
|
385
|
+
} catch {
|
|
386
|
+
// Quota rejection must not expose stream cleanup failures.
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
async function readRequestBody(
|
|
391
|
+
request: IncomingMessage,
|
|
392
|
+
maxBytes: number,
|
|
393
|
+
): Promise<Buffer | "oversized"> {
|
|
394
|
+
const declaredLength = request.headers["content-length"];
|
|
395
|
+
if (declaredLength !== undefined) {
|
|
396
|
+
if (!/^\d+$/u.test(declaredLength)) {
|
|
397
|
+
request.resume();
|
|
398
|
+
return "oversized";
|
|
399
|
+
}
|
|
400
|
+
if (Number(declaredLength) > maxBytes) {
|
|
401
|
+
request.resume();
|
|
402
|
+
return "oversized";
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
let bytes = 0;
|
|
407
|
+
const chunks: Buffer[] = [];
|
|
408
|
+
for await (const chunk of request) {
|
|
409
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
410
|
+
bytes += buffer.length;
|
|
411
|
+
if (bytes > maxBytes) {
|
|
412
|
+
request.resume();
|
|
413
|
+
return "oversized";
|
|
414
|
+
}
|
|
415
|
+
chunks.push(buffer);
|
|
416
|
+
}
|
|
417
|
+
return Buffer.concat(chunks, bytes);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function parseRequestPath(value: string | undefined): string | null {
|
|
421
|
+
if (value === undefined || !value.startsWith("/") || value.startsWith("//")) return null;
|
|
422
|
+
try {
|
|
423
|
+
return new URL(value, "https://local.invalid").pathname;
|
|
424
|
+
} catch {
|
|
425
|
+
return null;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function sendEmpty(response: ServerResponse, status: number, closeConnection = false): void {
|
|
430
|
+
if (response.headersSent || response.destroyed) return;
|
|
431
|
+
response.writeHead(status, {
|
|
432
|
+
"cache-control": "no-store",
|
|
433
|
+
...(closeConnection ? { connection: "close" } : {}),
|
|
434
|
+
"content-length": "0",
|
|
435
|
+
});
|
|
436
|
+
response.end();
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function sendRateLimited(response: ServerResponse, retryAfter: number): void {
|
|
440
|
+
if (response.headersSent || response.destroyed) return;
|
|
441
|
+
response.writeHead(429, {
|
|
442
|
+
"cache-control": "no-store",
|
|
443
|
+
connection: "close",
|
|
444
|
+
"content-length": "0",
|
|
445
|
+
"retry-after": retryAfter.toString(),
|
|
446
|
+
});
|
|
447
|
+
response.end();
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function sendJson(
|
|
451
|
+
response: ServerResponse,
|
|
452
|
+
status: number,
|
|
453
|
+
value: unknown,
|
|
454
|
+
closeConnection = false,
|
|
455
|
+
): void {
|
|
456
|
+
const body = JSON.stringify(value);
|
|
457
|
+
response.writeHead(status, {
|
|
458
|
+
"cache-control": "no-store",
|
|
459
|
+
...(closeConnection ? { connection: "close" } : {}),
|
|
460
|
+
"content-length": Buffer.byteLength(body).toString(),
|
|
461
|
+
"content-type": "application/json; charset=utf-8",
|
|
462
|
+
"x-content-type-options": "nosniff",
|
|
463
|
+
});
|
|
464
|
+
response.end(body);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
async function startEventStream(
|
|
468
|
+
response: ServerResponse,
|
|
469
|
+
stream: AsyncIterable<string>,
|
|
470
|
+
releaseQuota: () => void,
|
|
471
|
+
): Promise<void> {
|
|
472
|
+
if (response.destroyed || response.writableEnded || response.headersSent) {
|
|
473
|
+
releaseQuota();
|
|
474
|
+
await closeRejectedStream(stream);
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
try {
|
|
478
|
+
response.writeHead(200, {
|
|
479
|
+
"cache-control": "no-store",
|
|
480
|
+
connection: "keep-alive",
|
|
481
|
+
"content-type": "text/event-stream; charset=utf-8",
|
|
482
|
+
"x-accel-buffering": "no",
|
|
483
|
+
"x-content-type-options": "nosniff",
|
|
484
|
+
});
|
|
485
|
+
} catch (error) {
|
|
486
|
+
releaseQuota();
|
|
487
|
+
await closeRejectedStream(stream);
|
|
488
|
+
throw error;
|
|
489
|
+
}
|
|
490
|
+
void (async () => {
|
|
491
|
+
try {
|
|
492
|
+
for await (const chunk of stream) {
|
|
493
|
+
if (response.destroyed || response.writableEnded) break;
|
|
494
|
+
if (!response.write(chunk)) await waitForDrain(response);
|
|
495
|
+
}
|
|
496
|
+
} catch {
|
|
497
|
+
// Stream failures terminate the connection without exposing internals.
|
|
498
|
+
} finally {
|
|
499
|
+
releaseQuota();
|
|
500
|
+
if (!response.destroyed && !response.writableEnded) response.end();
|
|
501
|
+
}
|
|
502
|
+
})();
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function waitForDrain(response: ServerResponse): Promise<void> {
|
|
506
|
+
return new Promise((resolve) => {
|
|
507
|
+
const finish = (): void => {
|
|
508
|
+
response.off("drain", finish);
|
|
509
|
+
response.off("close", finish);
|
|
510
|
+
response.off("error", finish);
|
|
511
|
+
resolve();
|
|
512
|
+
};
|
|
513
|
+
response.once("drain", finish);
|
|
514
|
+
response.once("close", finish);
|
|
515
|
+
response.once("error", finish);
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function protocolError(code: string, message: string): object {
|
|
520
|
+
return { protocol_version: "1", error: { code, message } };
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
const INVALID_COORDINATION_QUERY = Symbol("invalid-coordination-query");
|
|
524
|
+
|
|
525
|
+
function parseCursorParameter(
|
|
526
|
+
value: string | undefined,
|
|
527
|
+
path: string,
|
|
528
|
+
): string | undefined | typeof INVALID_COORDINATION_QUERY {
|
|
529
|
+
if (value === undefined) return undefined;
|
|
530
|
+
try {
|
|
531
|
+
const parsed = new URL(value, "https://local.invalid");
|
|
532
|
+
const keys = [...parsed.searchParams.keys()];
|
|
533
|
+
if (!path.endsWith("/stream")) {
|
|
534
|
+
return keys.length === 0 ? undefined : INVALID_COORDINATION_QUERY;
|
|
535
|
+
}
|
|
536
|
+
if (keys.some((key) => key !== "cursor")) return INVALID_COORDINATION_QUERY;
|
|
537
|
+
const values = parsed.searchParams.getAll("cursor");
|
|
538
|
+
if (values.length === 0) return undefined;
|
|
539
|
+
return values.length === 1 && values[0]!.length > 0
|
|
540
|
+
? values[0]
|
|
541
|
+
: INVALID_COORDINATION_QUERY;
|
|
542
|
+
} catch {
|
|
543
|
+
return INVALID_COORDINATION_QUERY;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function isCoordinationPath(path: string | null): path is string {
|
|
548
|
+
return path === "/api/client/attach" || path === "/api/cubes" ||
|
|
549
|
+
path?.startsWith("/api/cubes/") === true;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function applyServerLimits(server: HttpsServer, limits: ServiceLimits): Set<Socket> {
|
|
553
|
+
server.maxConnections = limits.maxConnections;
|
|
554
|
+
server.maxRequestsPerSocket = limits.maxRequestsPerSocket;
|
|
555
|
+
server.requestTimeout = limits.requestTimeoutMs;
|
|
556
|
+
server.headersTimeout = limits.headersTimeoutMs;
|
|
557
|
+
server.keepAliveTimeout = limits.keepAliveTimeoutMs;
|
|
558
|
+
server.setTimeout(
|
|
559
|
+
Math.min(limits.handlerTimeoutMs * 2, 2_147_483_647),
|
|
560
|
+
(socket) => socket.destroy(),
|
|
561
|
+
);
|
|
562
|
+
const addressConnections = new ConcurrentQuota(limits.maxConnectionsPerAddress);
|
|
563
|
+
const acceptedSockets = new Set<Socket>();
|
|
564
|
+
server.on("connection", (socket) => {
|
|
565
|
+
const tracked = socket as Socket;
|
|
566
|
+
acceptedSockets.add(tracked);
|
|
567
|
+
tracked.once("close", () => acceptedSockets.delete(tracked));
|
|
568
|
+
const identity = tracked.remoteAddress ?? "unknown";
|
|
569
|
+
const release = addressConnections.acquire(identity);
|
|
570
|
+
if (release === null) {
|
|
571
|
+
socket.destroy();
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
socket.once("close", release);
|
|
575
|
+
});
|
|
576
|
+
return acceptedSockets;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function validateLimits(limits: ServiceLimits): void {
|
|
580
|
+
for (const [name, value] of Object.entries(limits)) {
|
|
581
|
+
if (!Number.isSafeInteger(value) || value <= 0) {
|
|
582
|
+
throw new Error(`${name} must be a positive safe integer.`);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
if (limits.headersTimeoutMs > limits.requestTimeoutMs) {
|
|
586
|
+
throw new Error("headersTimeoutMs must not exceed requestTimeoutMs.");
|
|
587
|
+
}
|
|
588
|
+
if (limits.tlsHandshakeTimeoutMs > limits.requestTimeoutMs) {
|
|
589
|
+
throw new Error("tlsHandshakeTimeoutMs must not exceed requestTimeoutMs.");
|
|
590
|
+
}
|
|
591
|
+
if (limits.maxConnectionsPerAddress > limits.maxConnections) {
|
|
592
|
+
throw new Error("maxConnectionsPerAddress must not exceed maxConnections.");
|
|
593
|
+
}
|
|
594
|
+
if (limits.maxStreamsPerCredential > limits.maxConnectionsPerAddress) {
|
|
595
|
+
throw new Error("maxStreamsPerCredential must not exceed maxConnectionsPerAddress.");
|
|
596
|
+
}
|
|
597
|
+
if (limits.maxRequestsPerWindow > limits.maxRequestsPerAddressWindow) {
|
|
598
|
+
throw new Error("maxRequestsPerWindow must not exceed maxRequestsPerAddressWindow.");
|
|
599
|
+
}
|
|
600
|
+
if (limits.maxRequestsPerAddressWindow > limits.maxRequestsGlobalWindow) {
|
|
601
|
+
throw new Error("maxRequestsPerAddressWindow must not exceed maxRequestsGlobalWindow.");
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
export function validateTlsCertificate(
|
|
606
|
+
certificate: string | Buffer,
|
|
607
|
+
host: string,
|
|
608
|
+
mode: "loopback" | "lan",
|
|
609
|
+
caCertificate?: string | Buffer,
|
|
610
|
+
): void {
|
|
611
|
+
const [parsed, ...intermediates] = parseCertificateBundle(certificate);
|
|
612
|
+
if (parsed === undefined) throw new Error("TLS certificate is missing.");
|
|
613
|
+
const now = Date.now();
|
|
614
|
+
if (now < parsed.validFromDate.getTime() || now > parsed.validToDate.getTime()) {
|
|
615
|
+
throw new Error("TLS certificate is outside its validity period.");
|
|
616
|
+
}
|
|
617
|
+
if (parsed.ca) {
|
|
618
|
+
throw new Error("TLS certificate must be a non-CA leaf certificate.");
|
|
619
|
+
}
|
|
620
|
+
if (parsed.checkIP(host) === undefined) {
|
|
621
|
+
throw new Error("TLS certificate does not cover the bind address.");
|
|
622
|
+
}
|
|
623
|
+
const serverAuthOid = "1.3.6.1.5.5.7.3.1";
|
|
624
|
+
if (parsed.keyUsage.length > 0 && !parsed.keyUsage.includes(serverAuthOid)) {
|
|
625
|
+
throw new Error("TLS certificate does not permit server authentication.");
|
|
626
|
+
}
|
|
627
|
+
if (caCertificate === undefined) {
|
|
628
|
+
if (mode === "lan") throw new Error("A private LAN bind requires an explicit TLS trust anchor.");
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
const [trustAnchor, ...additionalIntermediates] = parseCertificateBundle(caCertificate);
|
|
632
|
+
if (trustAnchor === undefined) throw new Error("TLS trust anchor is missing.");
|
|
633
|
+
validateCertificateAuthority(trustAnchor, now, "TLS trust anchor");
|
|
634
|
+
if (!trustAnchor.verify(trustAnchor.publicKey)) {
|
|
635
|
+
throw new Error("TLS trust anchor must be self-signed.");
|
|
636
|
+
}
|
|
637
|
+
const chain = [...intermediates, ...additionalIntermediates];
|
|
638
|
+
for (const intermediate of chain) validateCertificateAuthority(intermediate, now, "TLS intermediate");
|
|
639
|
+
const path = findCertificatePath(parsed, trustAnchor, chain, new Set(), 0);
|
|
640
|
+
if (path === null) {
|
|
641
|
+
throw new Error("TLS certificate is not signed by the configured trust anchor.");
|
|
642
|
+
}
|
|
643
|
+
validatePathLengthConstraints(path);
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
function parseCertificateBundle(value: string | Buffer): X509Certificate[] {
|
|
647
|
+
const text = typeof value === "string" ? value : value.toString("utf8");
|
|
648
|
+
const pem = text.match(/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/gu);
|
|
649
|
+
return pem === null ? [new X509Certificate(value)] : pem.map((certificate) => new X509Certificate(certificate));
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
function validateCertificateAuthority(certificate: X509Certificate, now: number, label: string): void {
|
|
653
|
+
if (!certificate.ca) throw new Error(`${label} must be a CA certificate.`);
|
|
654
|
+
if (now < certificate.validFromDate.getTime() || now > certificate.validToDate.getTime()) {
|
|
655
|
+
throw new Error(`${label} is outside its validity period.`);
|
|
656
|
+
}
|
|
657
|
+
const constraints = readCaConstraints(certificate);
|
|
658
|
+
if (!constraints.basicConstraintsCritical || !constraints.ca || !constraints.keyCertSign) {
|
|
659
|
+
throw new Error(`${label} lacks required CA constraints or certificate-signing usage.`);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
function findCertificatePath(
|
|
664
|
+
certificate: X509Certificate,
|
|
665
|
+
trustAnchor: X509Certificate,
|
|
666
|
+
intermediates: readonly X509Certificate[],
|
|
667
|
+
visited: Set<string>,
|
|
668
|
+
depth: number,
|
|
669
|
+
): X509Certificate[] | null {
|
|
670
|
+
if (depth > 8) return null;
|
|
671
|
+
if (isIssuedBy(certificate, trustAnchor)) return [certificate, trustAnchor];
|
|
672
|
+
for (const intermediate of intermediates) {
|
|
673
|
+
if (visited.has(intermediate.fingerprint256) || !isIssuedBy(certificate, intermediate)) continue;
|
|
674
|
+
visited.add(intermediate.fingerprint256);
|
|
675
|
+
const path = findCertificatePath(intermediate, trustAnchor, intermediates, visited, depth + 1);
|
|
676
|
+
if (path !== null) return [certificate, ...path];
|
|
677
|
+
visited.delete(intermediate.fingerprint256);
|
|
678
|
+
}
|
|
679
|
+
return null;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
function isIssuedBy(certificate: X509Certificate, issuer: X509Certificate): boolean {
|
|
683
|
+
return certificate.checkIssued(issuer) && certificate.verify(issuer.publicKey);
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
function validatePathLengthConstraints(path: readonly X509Certificate[]): void {
|
|
687
|
+
for (let index = 1; index < path.length; index += 1) {
|
|
688
|
+
const authority = path[index]!;
|
|
689
|
+
const { pathLength } = readCaConstraints(authority);
|
|
690
|
+
if (pathLength === null) continue;
|
|
691
|
+
const subordinateAuthorities = path.slice(1, index)
|
|
692
|
+
.filter((certificate) => certificate.subject !== certificate.issuer).length;
|
|
693
|
+
if (subordinateAuthorities > pathLength) {
|
|
694
|
+
throw new Error("TLS certificate chain exceeds a CA path-length constraint.");
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
interface CaConstraints {
|
|
700
|
+
readonly basicConstraintsCritical: boolean;
|
|
701
|
+
readonly ca: boolean;
|
|
702
|
+
readonly pathLength: number | null;
|
|
703
|
+
readonly keyCertSign: boolean;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
function readCaConstraints(certificate: X509Certificate): CaConstraints {
|
|
707
|
+
const extensions = readCertificateExtensions(certificate.raw);
|
|
708
|
+
const basic = extensions.get("551d13");
|
|
709
|
+
const keyUsage = extensions.get("551d0f");
|
|
710
|
+
if (basic === undefined || keyUsage === undefined) {
|
|
711
|
+
return { basicConstraintsCritical: false, ca: false, pathLength: null, keyCertSign: false };
|
|
712
|
+
}
|
|
713
|
+
const basicSequence = readDerElement(basic.value, 0, basic.value.length);
|
|
714
|
+
if (basicSequence.tag !== 0x30 || basicSequence.end !== basic.value.length) throw new Error("Invalid CA constraints.");
|
|
715
|
+
const basicChildren = readDerChildren(basic.value, basicSequence);
|
|
716
|
+
const caElement = basicChildren.find((element) => element.tag === 0x01);
|
|
717
|
+
const pathElement = basicChildren.find((element) => element.tag === 0x02);
|
|
718
|
+
const usageBits = readDerElement(keyUsage.value, 0, keyUsage.value.length);
|
|
719
|
+
if (usageBits.tag !== 0x03 || usageBits.end !== keyUsage.value.length ||
|
|
720
|
+
usageBits.contentStart + 1 >= usageBits.end) throw new Error("Invalid CA key usage.");
|
|
721
|
+
return {
|
|
722
|
+
basicConstraintsCritical: basic.critical,
|
|
723
|
+
ca: caElement !== undefined && basic.value[caElement.contentStart] !== 0,
|
|
724
|
+
pathLength: pathElement === undefined ? null : readDerInteger(basic.value, pathElement),
|
|
725
|
+
keyCertSign: (keyUsage.value[usageBits.contentStart + 1]! & 0x04) !== 0,
|
|
726
|
+
};
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
function readCertificateExtensions(certificate: Buffer): Map<string, { critical: boolean; value: Buffer }> {
|
|
730
|
+
const outer = readDerElement(certificate, 0, certificate.length);
|
|
731
|
+
const outerChildren = readDerChildren(certificate, outer);
|
|
732
|
+
const tbs = outerChildren[0];
|
|
733
|
+
if (outer.tag !== 0x30 || tbs?.tag !== 0x30) throw new Error("Invalid X.509 certificate encoding.");
|
|
734
|
+
const extensionWrapper = readDerChildren(certificate, tbs).find((element) => element.tag === 0xa3);
|
|
735
|
+
if (extensionWrapper === undefined) return new Map();
|
|
736
|
+
const wrapperChildren = readDerChildren(certificate, extensionWrapper);
|
|
737
|
+
const extensionSequence = wrapperChildren[0];
|
|
738
|
+
if (extensionSequence?.tag !== 0x30) throw new Error("Invalid X.509 extension encoding.");
|
|
739
|
+
const result = new Map<string, { critical: boolean; value: Buffer }>();
|
|
740
|
+
for (const extension of readDerChildren(certificate, extensionSequence)) {
|
|
741
|
+
if (extension.tag !== 0x30) throw new Error("Invalid X.509 extension encoding.");
|
|
742
|
+
const fields = readDerChildren(certificate, extension);
|
|
743
|
+
const oid = fields[0];
|
|
744
|
+
const hasCriticalField = fields[1]?.tag === 0x01;
|
|
745
|
+
const critical = hasCriticalField && certificate[fields[1]!.contentStart] !== 0;
|
|
746
|
+
const value = fields[hasCriticalField ? 2 : 1];
|
|
747
|
+
if (oid?.tag !== 0x06 || value?.tag !== 0x04) throw new Error("Invalid X.509 extension encoding.");
|
|
748
|
+
result.set(
|
|
749
|
+
certificate.subarray(oid.contentStart, oid.end).toString("hex"),
|
|
750
|
+
{ critical, value: certificate.subarray(value.contentStart, value.end) },
|
|
751
|
+
);
|
|
752
|
+
}
|
|
753
|
+
return result;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
interface DerElement {
|
|
757
|
+
readonly tag: number;
|
|
758
|
+
readonly contentStart: number;
|
|
759
|
+
readonly end: number;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
function readDerElement(data: Buffer, offset: number, limit: number): DerElement {
|
|
763
|
+
if (offset + 2 > limit) throw new Error("Invalid DER encoding.");
|
|
764
|
+
const tag = data[offset]!;
|
|
765
|
+
const firstLength = data[offset + 1]!;
|
|
766
|
+
let length = firstLength;
|
|
767
|
+
let contentStart = offset + 2;
|
|
768
|
+
if ((firstLength & 0x80) !== 0) {
|
|
769
|
+
const lengthBytes = firstLength & 0x7f;
|
|
770
|
+
if (lengthBytes === 0 || lengthBytes > 4 || contentStart + lengthBytes > limit) {
|
|
771
|
+
throw new Error("Invalid DER encoding.");
|
|
772
|
+
}
|
|
773
|
+
length = 0;
|
|
774
|
+
for (let index = 0; index < lengthBytes; index += 1) {
|
|
775
|
+
length = (length * 256) + data[contentStart + index]!;
|
|
776
|
+
}
|
|
777
|
+
contentStart += lengthBytes;
|
|
778
|
+
}
|
|
779
|
+
const end = contentStart + length;
|
|
780
|
+
if (end > limit || end < contentStart) throw new Error("Invalid DER encoding.");
|
|
781
|
+
return { tag, contentStart, end };
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
function readDerChildren(data: Buffer, parent: DerElement): DerElement[] {
|
|
785
|
+
const children: DerElement[] = [];
|
|
786
|
+
let offset = parent.contentStart;
|
|
787
|
+
while (offset < parent.end) {
|
|
788
|
+
const child = readDerElement(data, offset, parent.end);
|
|
789
|
+
children.push(child);
|
|
790
|
+
offset = child.end;
|
|
791
|
+
}
|
|
792
|
+
if (offset !== parent.end) throw new Error("Invalid DER encoding.");
|
|
793
|
+
return children;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
function readDerInteger(data: Buffer, element: DerElement): number {
|
|
797
|
+
if (element.tag !== 0x02 || element.contentStart === element.end ||
|
|
798
|
+
(data[element.contentStart]! & 0x80) !== 0) throw new Error("Invalid DER integer.");
|
|
799
|
+
let value = 0;
|
|
800
|
+
for (let offset = element.contentStart; offset < element.end; offset += 1) {
|
|
801
|
+
value = (value * 256) + data[offset]!;
|
|
802
|
+
if (!Number.isSafeInteger(value)) throw new Error("DER integer exceeds policy.");
|
|
803
|
+
}
|
|
804
|
+
return value;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
export class RequestRateLimiter {
|
|
808
|
+
readonly #limits: Pick<ServiceLimits, "maxRequestsPerWindow" | "rateLimitWindowMs" | "maxRateLimitEntries">;
|
|
809
|
+
readonly #clock: () => number;
|
|
810
|
+
readonly #maxRequests: number;
|
|
811
|
+
readonly #buckets = new Map<string, { count: number; resetAt: number }>();
|
|
812
|
+
|
|
813
|
+
constructor(limits: ServiceLimits, maxRequests = limits.maxRequestsPerWindow, clock: () => number = Date.now) {
|
|
814
|
+
this.#limits = limits;
|
|
815
|
+
this.#maxRequests = maxRequests;
|
|
816
|
+
this.#clock = clock;
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
consume(identity: string): number | null {
|
|
820
|
+
const reservation = this.reserve(identity);
|
|
821
|
+
reservation.commit();
|
|
822
|
+
return reservation.retryAfter;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
reserve(identity: string): RateLimitReservation {
|
|
826
|
+
const now = this.#clock();
|
|
827
|
+
const existing = this.#buckets.get(identity);
|
|
828
|
+
if (existing !== undefined && existing.resetAt > now) {
|
|
829
|
+
if (existing.count >= this.#maxRequests) {
|
|
830
|
+
return rejectedReservation(Math.max(1, Math.ceil((existing.resetAt - now) / 1_000)));
|
|
831
|
+
}
|
|
832
|
+
existing.count += 1;
|
|
833
|
+
return acceptedReservation(() => { existing.count -= 1; });
|
|
834
|
+
}
|
|
835
|
+
if (existing !== undefined) this.#buckets.delete(identity);
|
|
836
|
+
for (const [key, bucket] of this.#buckets) {
|
|
837
|
+
if (bucket.resetAt <= now) this.#buckets.delete(key);
|
|
838
|
+
}
|
|
839
|
+
if (this.#buckets.size >= this.#limits.maxRateLimitEntries) {
|
|
840
|
+
return rejectedReservation(Math.max(1, Math.ceil(this.#limits.rateLimitWindowMs / 1_000)));
|
|
841
|
+
}
|
|
842
|
+
const bucket = { count: 1, resetAt: now + this.#limits.rateLimitWindowMs };
|
|
843
|
+
this.#buckets.set(identity, bucket);
|
|
844
|
+
return acceptedReservation(() => {
|
|
845
|
+
if (this.#buckets.get(identity) === bucket) this.#buckets.delete(identity);
|
|
846
|
+
});
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
export class PreAuthAdmissionLimiter {
|
|
851
|
+
readonly #global: RequestRateLimiter;
|
|
852
|
+
readonly #address: RequestRateLimiter;
|
|
853
|
+
|
|
854
|
+
constructor(limits: ServiceLimits, clock: () => number = Date.now) {
|
|
855
|
+
this.#global = new RequestRateLimiter(limits, limits.maxRequestsGlobalWindow, clock);
|
|
856
|
+
this.#address = new RequestRateLimiter(limits, limits.maxRequestsPerAddressWindow, clock);
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
consume(addressIdentity: string): number | null {
|
|
860
|
+
const address = this.#address.reserve(addressIdentity);
|
|
861
|
+
if (address.retryAfter !== null) return address.retryAfter;
|
|
862
|
+
const global = this.#global.reserve("global");
|
|
863
|
+
if (global.retryAfter !== null) {
|
|
864
|
+
address.rollback();
|
|
865
|
+
return global.retryAfter;
|
|
866
|
+
}
|
|
867
|
+
address.commit();
|
|
868
|
+
global.commit();
|
|
869
|
+
return null;
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
interface RateLimitReservation {
|
|
874
|
+
readonly retryAfter: number | null;
|
|
875
|
+
readonly commit: () => void;
|
|
876
|
+
readonly rollback: () => void;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
function acceptedReservation(undo: () => void): RateLimitReservation {
|
|
880
|
+
let active = true;
|
|
881
|
+
return {
|
|
882
|
+
retryAfter: null,
|
|
883
|
+
commit: () => { active = false; },
|
|
884
|
+
rollback: () => {
|
|
885
|
+
if (!active) return;
|
|
886
|
+
active = false;
|
|
887
|
+
undo();
|
|
888
|
+
},
|
|
889
|
+
};
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
function rejectedReservation(retryAfter: number): RateLimitReservation {
|
|
893
|
+
return { retryAfter, commit: () => undefined, rollback: () => undefined };
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
export class ConcurrentQuota {
|
|
897
|
+
readonly #limit: number;
|
|
898
|
+
readonly #counts = new Map<string, number>();
|
|
899
|
+
|
|
900
|
+
constructor(limit: number) {
|
|
901
|
+
this.#limit = limit;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
acquire(identity: string): (() => void) | null {
|
|
905
|
+
const count = this.#counts.get(identity) ?? 0;
|
|
906
|
+
if (count >= this.#limit) return null;
|
|
907
|
+
this.#counts.set(identity, count + 1);
|
|
908
|
+
let released = false;
|
|
909
|
+
return () => {
|
|
910
|
+
if (released) return;
|
|
911
|
+
released = true;
|
|
912
|
+
const remaining = (this.#counts.get(identity) ?? 1) - 1;
|
|
913
|
+
if (remaining <= 0) this.#counts.delete(identity);
|
|
914
|
+
else this.#counts.set(identity, remaining);
|
|
915
|
+
};
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
function credentialIdentity(authorization: string | undefined): string | null {
|
|
920
|
+
if (authorization === undefined) return null;
|
|
921
|
+
return `credential:${createHash("sha256").update(authorization).digest("base64url")}`;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
function consumeAuthenticatedRateLimit(
|
|
925
|
+
authorization: string | undefined,
|
|
926
|
+
rateLimiter: RequestRateLimiter,
|
|
927
|
+
): number | null {
|
|
928
|
+
const identity = credentialIdentity(authorization);
|
|
929
|
+
return identity === null ? null : rateLimiter.consume(identity);
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
function listen(server: HttpsServer, port: number, host: string): Promise<void> {
|
|
933
|
+
return new Promise((resolve, reject) => {
|
|
934
|
+
const onError = (error: Error): void => {
|
|
935
|
+
server.off("listening", onListening);
|
|
936
|
+
reject(error);
|
|
937
|
+
};
|
|
938
|
+
const onListening = (): void => {
|
|
939
|
+
server.off("error", onError);
|
|
940
|
+
resolve();
|
|
941
|
+
};
|
|
942
|
+
server.once("error", onError);
|
|
943
|
+
server.once("listening", onListening);
|
|
944
|
+
server.listen(port, host);
|
|
945
|
+
});
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
async function close(server: HttpsServer, acceptedSockets: Set<Socket>): Promise<void> {
|
|
949
|
+
const socketClosures = [...acceptedSockets].map((socket) => new Promise<void>((resolve) => {
|
|
950
|
+
socket.once("close", () => resolve());
|
|
951
|
+
}));
|
|
952
|
+
const listenerClosure = new Promise<void>((resolve, reject) => {
|
|
953
|
+
server.close((error) => {
|
|
954
|
+
if (error !== undefined) reject(error);
|
|
955
|
+
else resolve();
|
|
956
|
+
});
|
|
957
|
+
});
|
|
958
|
+
for (const socket of acceptedSockets) socket.destroy();
|
|
959
|
+
server.closeAllConnections();
|
|
960
|
+
await Promise.all([listenerClosure, ...socketClosures]);
|
|
961
|
+
if (acceptedSockets.size !== 0) throw new Error("HTTPS socket closure could not be confirmed.");
|
|
962
|
+
}
|