mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-22 02:27:44 +08:00
feat(ql3): establish 3.0 incubation baseline
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
/** Shared bounded process-configuration authority for cluster management planes. */
|
||||
import { closeSync, constants, fstatSync, openSync, readSync } from 'node:fs';
|
||||
import { isAbsolute } from 'node:path';
|
||||
|
||||
const MAX_TLS_FILE_BYTES = 256 * 1024;
|
||||
const CONTROL_PATTERN = /[\u0000-\u001f\u007f]/;
|
||||
|
||||
export type ClusterManagementProcessEnvironment = Readonly<
|
||||
Record<string, string | undefined>
|
||||
>;
|
||||
|
||||
export type ClusterManagementProcessConfigurationFailure = (
|
||||
message: string,
|
||||
) => Error;
|
||||
|
||||
export function boundedManagementEnvironmentValue(
|
||||
environment: ClusterManagementProcessEnvironment,
|
||||
name: string,
|
||||
maximumLength: number,
|
||||
failure: ClusterManagementProcessConfigurationFailure,
|
||||
required = false,
|
||||
): string | undefined {
|
||||
const value = environment[name];
|
||||
if (value === undefined || value === '') {
|
||||
if (required) throw failure(`${name} is required`);
|
||||
return undefined;
|
||||
}
|
||||
if (value.length > maximumLength || CONTROL_PATTERN.test(value)) {
|
||||
throw failure(`${name} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function booleanManagementEnvironmentValue(
|
||||
environment: ClusterManagementProcessEnvironment,
|
||||
name: string,
|
||||
failure: ClusterManagementProcessConfigurationFailure,
|
||||
): boolean {
|
||||
const value = environment[name];
|
||||
if (value === undefined || value === '') return false;
|
||||
if (value === 'true') return true;
|
||||
if (value === 'false') return false;
|
||||
throw failure(`${name} must be true or false`);
|
||||
}
|
||||
|
||||
export function integerManagementEnvironmentValue(
|
||||
environment: ClusterManagementProcessEnvironment,
|
||||
name: string,
|
||||
fallback: number,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
failure: ClusterManagementProcessConfigurationFailure,
|
||||
): number {
|
||||
const value = environment[name];
|
||||
if (value === undefined || value === '') return fallback;
|
||||
if (!/^(?:0|[1-9][0-9]*)$/.test(value)) {
|
||||
throw failure(`${name} must be an integer`);
|
||||
}
|
||||
const parsed = Number(value);
|
||||
if (!Number.isSafeInteger(parsed) || parsed < minimum || parsed > maximum) {
|
||||
throw failure(`${name} must be between ${minimum} and ${maximum}`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function absoluteManagementEnvironmentFile(
|
||||
environment: ClusterManagementProcessEnvironment,
|
||||
name: string,
|
||||
failure: ClusterManagementProcessConfigurationFailure,
|
||||
): string {
|
||||
const value = boundedManagementEnvironmentValue(
|
||||
environment,
|
||||
name,
|
||||
4_096,
|
||||
failure,
|
||||
true,
|
||||
)!;
|
||||
if (!isAbsolute(value)) {
|
||||
throw failure(`${name} must be an absolute path`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function readManagementTlsFile(
|
||||
filePath: string,
|
||||
privateMaterial: boolean,
|
||||
failure: ClusterManagementProcessConfigurationFailure,
|
||||
): Buffer {
|
||||
let descriptor: number | undefined;
|
||||
let bytes: Buffer | undefined;
|
||||
try {
|
||||
descriptor = openSync(filePath, constants.O_RDONLY);
|
||||
const stat = fstatSync(descriptor);
|
||||
if (
|
||||
!stat.isFile() ||
|
||||
stat.size < 1 ||
|
||||
stat.size > MAX_TLS_FILE_BYTES ||
|
||||
(stat.mode & 0o022) !== 0 ||
|
||||
(privateMaterial && (stat.mode & 0o007) !== 0)
|
||||
) {
|
||||
throw failure('TLS file authority is invalid');
|
||||
}
|
||||
bytes = Buffer.alloc(stat.size + 1);
|
||||
let offset = 0;
|
||||
while (offset < bytes.length) {
|
||||
const read = readSync(
|
||||
descriptor,
|
||||
bytes,
|
||||
offset,
|
||||
bytes.length - offset,
|
||||
offset,
|
||||
);
|
||||
if (read === 0) break;
|
||||
offset += read;
|
||||
}
|
||||
const after = fstatSync(descriptor);
|
||||
if (
|
||||
offset !== stat.size ||
|
||||
offset > MAX_TLS_FILE_BYTES ||
|
||||
stat.dev !== after.dev ||
|
||||
stat.ino !== after.ino ||
|
||||
stat.size !== after.size ||
|
||||
stat.mtimeMs !== after.mtimeMs ||
|
||||
stat.ctimeMs !== after.ctimeMs
|
||||
) {
|
||||
throw failure('TLS file changed while being read');
|
||||
}
|
||||
return bytes.subarray(0, offset);
|
||||
} catch (error) {
|
||||
if (privateMaterial) bytes?.fill(0);
|
||||
throw error;
|
||||
} finally {
|
||||
if (descriptor !== undefined) closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,755 @@
|
||||
/** Shared authenticated identity assertion boundary for cluster management planes. */
|
||||
import {
|
||||
constants,
|
||||
createHash,
|
||||
createPublicKey,
|
||||
verify as verifySignature,
|
||||
type KeyObject,
|
||||
} from 'node:crypto';
|
||||
|
||||
import {
|
||||
normalizeSecurityPrincipal,
|
||||
type SecurityAuthenticationAssurance,
|
||||
type SecurityPrincipal,
|
||||
} from '@qinglong/runtime-core/security';
|
||||
|
||||
const ASSERTION_ALGORITHMS = ['EdDSA', 'ES256', 'RS256'] as const;
|
||||
const MAX_KEYS = 8;
|
||||
const MAX_ASSURANCE_MAPPINGS = 8;
|
||||
const MAX_AMR_VALUES = 8;
|
||||
const MIN_ASSERTION_BYTES = 512;
|
||||
const MAX_ASSERTION_BYTES = 16 * 1024;
|
||||
const DEFAULT_ASSERTION_BYTES = 8 * 1024;
|
||||
const MIN_LIFETIME_MS = 30_000;
|
||||
const MAX_LIFETIME_MS = 15 * 60_000;
|
||||
const DEFAULT_LIFETIME_MS = 5 * 60_000;
|
||||
const MAX_AUTHENTICATION_AGE_MS = 15 * 60_000;
|
||||
const DEFAULT_AUTHENTICATION_AGE_MS = 5 * 60_000;
|
||||
const MAX_CLOCK_SKEW_MS = 60_000;
|
||||
const DEFAULT_CLOCK_SKEW_MS = 5_000;
|
||||
const TOKEN_VALUE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const ASSERTION_TYPE_PATTERN = /^ql3-[a-z0-9]+(?:-[a-z0-9]+)*\+jwt$/;
|
||||
const ASSERTION_PURPOSE_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
||||
const BASE64URL_PATTERN = /^[A-Za-z0-9_-]+$/;
|
||||
const CONTROL_PATTERN = /[\u0000-\u001f\u007f]/;
|
||||
|
||||
type AssertionAlgorithm = (typeof ASSERTION_ALGORITHMS)[number];
|
||||
type AssertionAssurance = Extract<
|
||||
SecurityAuthenticationAssurance,
|
||||
'multi_factor' | 'hardware'
|
||||
>;
|
||||
|
||||
export interface ClusterPluginPackageIdentityAssertionAssuranceMapping {
|
||||
readonly acr: string;
|
||||
readonly assurance: AssertionAssurance;
|
||||
readonly requiredAmr: readonly string[];
|
||||
}
|
||||
|
||||
export interface ClusterManagementIdentityAssertionProfile {
|
||||
readonly type: string;
|
||||
readonly purpose: string;
|
||||
}
|
||||
|
||||
export const CLUSTER_PLUGIN_PACKAGE_MANAGEMENT_IDENTITY_ASSERTION_PROFILE =
|
||||
Object.freeze({
|
||||
type: 'ql3-plugin-package-management+jwt',
|
||||
purpose: 'plugin-package-management',
|
||||
});
|
||||
|
||||
export const CLUSTER_WORKER_CREDENTIAL_MANAGEMENT_IDENTITY_ASSERTION_PROFILE =
|
||||
Object.freeze({
|
||||
type: 'ql3-worker-credential-management+jwt',
|
||||
purpose: 'worker-credential-management',
|
||||
});
|
||||
|
||||
export const CLUSTER_AUTOMATION_MANAGEMENT_IDENTITY_ASSERTION_PROFILE =
|
||||
Object.freeze({
|
||||
type: 'ql3-automation-management+jwt',
|
||||
purpose: 'automation-management',
|
||||
});
|
||||
|
||||
export const CLUSTER_APPROVAL_MANAGEMENT_IDENTITY_ASSERTION_PROFILE =
|
||||
Object.freeze({
|
||||
type: 'ql3-approval-management+jwt',
|
||||
purpose: 'approval-management',
|
||||
});
|
||||
|
||||
export const CLUSTER_MODEL_PROVIDER_CREDENTIAL_MANAGEMENT_IDENTITY_ASSERTION_PROFILE =
|
||||
Object.freeze({
|
||||
type: 'ql3-model-provider-credential-management+jwt',
|
||||
purpose: 'model-provider-credential-management',
|
||||
});
|
||||
|
||||
export interface ClusterPluginPackageIdentityAssertionVerifierOptions {
|
||||
readonly issuer: string;
|
||||
readonly audience: string;
|
||||
readonly keys: readonly Readonly<Record<string, unknown>>[];
|
||||
readonly assuranceMappings: readonly ClusterPluginPackageIdentityAssertionAssuranceMapping[];
|
||||
readonly assertionProfile?: Readonly<ClusterManagementIdentityAssertionProfile>;
|
||||
readonly maxAssertionBytes?: number;
|
||||
readonly maxLifetimeMs?: number;
|
||||
readonly maxAuthenticationAgeMs?: number;
|
||||
readonly clockSkewMs?: number;
|
||||
readonly now?: () => number;
|
||||
}
|
||||
|
||||
export interface ClusterPluginPackageIdentityAssertionAuthentication {
|
||||
authenticate(): Promise<Readonly<SecurityPrincipal>>;
|
||||
}
|
||||
|
||||
export interface ClusterPluginPackageIdentityAssertionVerifier {
|
||||
verify(assertion: unknown): Readonly<SecurityPrincipal>;
|
||||
bind(
|
||||
assertion: unknown,
|
||||
): Readonly<ClusterPluginPackageIdentityAssertionAuthentication>;
|
||||
}
|
||||
|
||||
export class ClusterPluginPackageIdentityAssertionConfigurationError extends TypeError {
|
||||
readonly code =
|
||||
'CLUSTER_PLUGIN_PACKAGE_IDENTITY_ASSERTION_CONFIGURATION_INVALID';
|
||||
|
||||
constructor(message: string) {
|
||||
super(
|
||||
`Cluster Plugin Package identity assertion configuration is invalid: ${message}`,
|
||||
);
|
||||
this.name = 'ClusterPluginPackageIdentityAssertionConfigurationError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ClusterPluginPackageIdentityAssertionAuthenticationError extends Error {
|
||||
readonly code = 'CLUSTER_PLUGIN_PACKAGE_IDENTITY_ASSERTION_INVALID';
|
||||
|
||||
constructor() {
|
||||
super('Cluster Plugin Package identity assertion is invalid');
|
||||
this.name = 'ClusterPluginPackageIdentityAssertionAuthenticationError';
|
||||
}
|
||||
}
|
||||
|
||||
interface ReviewedAssertionKey {
|
||||
readonly kid: string;
|
||||
readonly algorithm: AssertionAlgorithm;
|
||||
readonly key: KeyObject;
|
||||
}
|
||||
|
||||
interface ReviewedAssuranceMapping {
|
||||
readonly assurance: AssertionAssurance;
|
||||
readonly requiredAmr: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
function configurationFailure(
|
||||
message: string,
|
||||
): ClusterPluginPackageIdentityAssertionConfigurationError {
|
||||
return new ClusterPluginPackageIdentityAssertionConfigurationError(message);
|
||||
}
|
||||
|
||||
function exactObject(
|
||||
value: unknown,
|
||||
expectedKeys: readonly string[],
|
||||
label: string,
|
||||
): asserts value is Record<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw configurationFailure(`${label} must be an object`);
|
||||
}
|
||||
const actual = Object.keys(value).sort();
|
||||
const expected = [...expectedKeys].sort();
|
||||
if (
|
||||
actual.length !== expected.length ||
|
||||
actual.some((key, index) => key !== expected[index])
|
||||
) {
|
||||
throw configurationFailure(`${label} shape is invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
function reviewedAssertionProfile(
|
||||
value: unknown,
|
||||
): Readonly<ClusterManagementIdentityAssertionProfile> {
|
||||
exactObject(value, ['type', 'purpose'], 'assertion profile');
|
||||
if (
|
||||
typeof value.type !== 'string' ||
|
||||
value.type.length > 128 ||
|
||||
!ASSERTION_TYPE_PATTERN.test(value.type) ||
|
||||
typeof value.purpose !== 'string' ||
|
||||
value.purpose.length > 96 ||
|
||||
!ASSERTION_PURPOSE_PATTERN.test(value.purpose)
|
||||
) {
|
||||
throw configurationFailure('assertion profile is invalid');
|
||||
}
|
||||
return Object.freeze({ type: value.type, purpose: value.purpose });
|
||||
}
|
||||
|
||||
function boundedInteger(
|
||||
value: number | undefined,
|
||||
fallback: number,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
label: string,
|
||||
): number {
|
||||
const candidate = value ?? fallback;
|
||||
if (
|
||||
!Number.isSafeInteger(candidate) ||
|
||||
candidate < minimum ||
|
||||
candidate > maximum
|
||||
) {
|
||||
throw configurationFailure(`${label} is invalid`);
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
function reviewedIssuer(value: unknown): string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
value.length < 1 ||
|
||||
value.length > 512 ||
|
||||
CONTROL_PATTERN.test(value)
|
||||
) {
|
||||
throw configurationFailure('issuer is invalid');
|
||||
}
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(value);
|
||||
} catch {
|
||||
throw configurationFailure('issuer is invalid');
|
||||
}
|
||||
if (
|
||||
parsed.protocol !== 'https:' ||
|
||||
parsed.username !== '' ||
|
||||
parsed.password !== '' ||
|
||||
parsed.search !== '' ||
|
||||
parsed.hash !== '' ||
|
||||
parsed.toString() !== value
|
||||
) {
|
||||
throw configurationFailure('issuer must be one canonical HTTPS URL');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function reviewedTokenValue(
|
||||
value: unknown,
|
||||
label: string,
|
||||
maximumLength = 128,
|
||||
): string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
value.length < 1 ||
|
||||
value.length > maximumLength ||
|
||||
CONTROL_PATTERN.test(value)
|
||||
) {
|
||||
throw configurationFailure(`${label} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function reviewedJwkComponent(
|
||||
value: unknown,
|
||||
minimumBytes: number,
|
||||
maximumBytes: number,
|
||||
label: string,
|
||||
): void {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
value.length < 1 ||
|
||||
!BASE64URL_PATTERN.test(value)
|
||||
) {
|
||||
throw configurationFailure(`${label} is invalid`);
|
||||
}
|
||||
const bytes = Buffer.from(value, 'base64url');
|
||||
if (
|
||||
bytes.length < minimumBytes ||
|
||||
bytes.length > maximumBytes ||
|
||||
bytes.toString('base64url') !== value
|
||||
) {
|
||||
throw configurationFailure(`${label} is invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
function reviewedJwk(
|
||||
value: unknown,
|
||||
seenKids: Set<string>,
|
||||
): Readonly<ReviewedAssertionKey> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw configurationFailure('key must be an object');
|
||||
}
|
||||
const candidate = value as Record<string, unknown>;
|
||||
const algorithm = candidate.alg;
|
||||
if (
|
||||
typeof algorithm !== 'string' ||
|
||||
!ASSERTION_ALGORITHMS.includes(algorithm as AssertionAlgorithm)
|
||||
) {
|
||||
throw configurationFailure('key algorithm is invalid');
|
||||
}
|
||||
const reviewedAlgorithm = algorithm as AssertionAlgorithm;
|
||||
const expectedKeys =
|
||||
reviewedAlgorithm === 'RS256'
|
||||
? ['alg', 'e', 'kid', 'kty', 'n', 'use']
|
||||
: reviewedAlgorithm === 'ES256'
|
||||
? ['alg', 'crv', 'kid', 'kty', 'use', 'x', 'y']
|
||||
: ['alg', 'crv', 'kid', 'kty', 'use', 'x'];
|
||||
exactObject(candidate, expectedKeys, 'key');
|
||||
const kid = candidate.kid;
|
||||
if (
|
||||
typeof kid !== 'string' ||
|
||||
!TOKEN_VALUE_PATTERN.test(kid) ||
|
||||
seenKids.has(kid)
|
||||
) {
|
||||
throw configurationFailure('key id is invalid or duplicated');
|
||||
}
|
||||
if (candidate.use !== 'sig') {
|
||||
throw configurationFailure('key use must be sig');
|
||||
}
|
||||
if (
|
||||
(reviewedAlgorithm === 'RS256' && candidate.kty !== 'RSA') ||
|
||||
(reviewedAlgorithm === 'ES256' &&
|
||||
(candidate.kty !== 'EC' || candidate.crv !== 'P-256')) ||
|
||||
(reviewedAlgorithm === 'EdDSA' &&
|
||||
(candidate.kty !== 'OKP' || candidate.crv !== 'Ed25519'))
|
||||
) {
|
||||
throw configurationFailure('key type does not match its algorithm');
|
||||
}
|
||||
for (const name of expectedKeys) {
|
||||
if (
|
||||
['alg', 'kid', 'kty', 'use', 'crv'].includes(name) ||
|
||||
(typeof candidate[name] === 'string' &&
|
||||
BASE64URL_PATTERN.test(candidate[name] as string))
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
throw configurationFailure(`key ${name} is invalid`);
|
||||
}
|
||||
if (reviewedAlgorithm === 'RS256') {
|
||||
reviewedJwkComponent(candidate.n, 256, 512, 'RSA modulus');
|
||||
reviewedJwkComponent(candidate.e, 3, 4, 'RSA exponent');
|
||||
} else if (reviewedAlgorithm === 'ES256') {
|
||||
reviewedJwkComponent(candidate.x, 32, 32, 'EC x coordinate');
|
||||
reviewedJwkComponent(candidate.y, 32, 32, 'EC y coordinate');
|
||||
} else {
|
||||
reviewedJwkComponent(candidate.x, 32, 32, 'Ed25519 public key');
|
||||
}
|
||||
let key: KeyObject;
|
||||
try {
|
||||
key = createPublicKey({
|
||||
key: candidate,
|
||||
format: 'jwk',
|
||||
});
|
||||
} catch {
|
||||
throw configurationFailure('key material is invalid');
|
||||
}
|
||||
if (
|
||||
reviewedAlgorithm === 'RS256' &&
|
||||
(key.asymmetricKeyType !== 'rsa' ||
|
||||
(key.asymmetricKeyDetails?.modulusLength ?? 0) < 2048 ||
|
||||
(key.asymmetricKeyDetails?.modulusLength ?? 0) > 4096 ||
|
||||
key.asymmetricKeyDetails?.publicExponent !== 65_537n)
|
||||
) {
|
||||
throw configurationFailure('RSA key strength is invalid');
|
||||
}
|
||||
if (
|
||||
reviewedAlgorithm === 'ES256' &&
|
||||
(key.asymmetricKeyType !== 'ec' ||
|
||||
key.asymmetricKeyDetails?.namedCurve !== 'prime256v1')
|
||||
) {
|
||||
throw configurationFailure('EC key strength is invalid');
|
||||
}
|
||||
if (reviewedAlgorithm === 'EdDSA' && key.asymmetricKeyType !== 'ed25519') {
|
||||
throw configurationFailure('Ed25519 key is invalid');
|
||||
}
|
||||
seenKids.add(kid);
|
||||
return Object.freeze({ kid, algorithm: reviewedAlgorithm, key });
|
||||
}
|
||||
|
||||
function reviewedKeys(
|
||||
value: unknown,
|
||||
): ReadonlyMap<string, Readonly<ReviewedAssertionKey>> {
|
||||
if (!Array.isArray(value) || value.length < 1 || value.length > MAX_KEYS) {
|
||||
throw configurationFailure('keys must contain between one and eight keys');
|
||||
}
|
||||
const seenKids = new Set<string>();
|
||||
const keys = new Map<string, Readonly<ReviewedAssertionKey>>();
|
||||
for (const candidate of value) {
|
||||
const reviewed = reviewedJwk(candidate, seenKids);
|
||||
keys.set(reviewed.kid, reviewed);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
function reviewedAssuranceMappings(
|
||||
value: unknown,
|
||||
): ReadonlyMap<string, Readonly<ReviewedAssuranceMapping>> {
|
||||
if (
|
||||
!Array.isArray(value) ||
|
||||
value.length < 1 ||
|
||||
value.length > MAX_ASSURANCE_MAPPINGS
|
||||
) {
|
||||
throw configurationFailure(
|
||||
'assurance mappings must contain between one and eight entries',
|
||||
);
|
||||
}
|
||||
const mappings = new Map<string, Readonly<ReviewedAssuranceMapping>>();
|
||||
for (const candidate of value) {
|
||||
exactObject(candidate, ['acr', 'assurance', 'requiredAmr'], 'mapping');
|
||||
const acr = reviewedTokenValue(candidate.acr, 'mapping acr', 256);
|
||||
if (
|
||||
mappings.has(acr) ||
|
||||
(candidate.assurance !== 'multi_factor' &&
|
||||
candidate.assurance !== 'hardware') ||
|
||||
!Array.isArray(candidate.requiredAmr) ||
|
||||
candidate.requiredAmr.length < 1 ||
|
||||
candidate.requiredAmr.length > MAX_AMR_VALUES
|
||||
) {
|
||||
throw configurationFailure('assurance mapping is invalid');
|
||||
}
|
||||
const requiredAmr = new Set<string>();
|
||||
for (const entry of candidate.requiredAmr) {
|
||||
if (
|
||||
typeof entry !== 'string' ||
|
||||
!TOKEN_VALUE_PATTERN.test(entry) ||
|
||||
requiredAmr.has(entry)
|
||||
) {
|
||||
throw configurationFailure('mapping AMR is invalid or duplicated');
|
||||
}
|
||||
requiredAmr.add(entry);
|
||||
}
|
||||
mappings.set(
|
||||
acr,
|
||||
Object.freeze({
|
||||
assurance: candidate.assurance,
|
||||
requiredAmr,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return mappings;
|
||||
}
|
||||
|
||||
function canonicalBase64Url(segment: string, maximumBytes: number): Buffer {
|
||||
if (
|
||||
segment.length < 1 ||
|
||||
segment.length > Math.ceil((maximumBytes * 4) / 3) ||
|
||||
!BASE64URL_PATTERN.test(segment)
|
||||
) {
|
||||
throw new ClusterPluginPackageIdentityAssertionAuthenticationError();
|
||||
}
|
||||
const decoded = Buffer.from(segment, 'base64url');
|
||||
if (
|
||||
decoded.length < 1 ||
|
||||
decoded.length > maximumBytes ||
|
||||
decoded.toString('base64url') !== segment
|
||||
) {
|
||||
throw new ClusterPluginPackageIdentityAssertionAuthenticationError();
|
||||
}
|
||||
return decoded;
|
||||
}
|
||||
|
||||
function jsonObject(
|
||||
segment: string,
|
||||
maximumBytes: number,
|
||||
): Record<string, unknown> {
|
||||
const bytes = canonicalBase64Url(segment, maximumBytes);
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(bytes.toString('utf8'));
|
||||
} catch {
|
||||
throw new ClusterPluginPackageIdentityAssertionAuthenticationError();
|
||||
}
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new ClusterPluginPackageIdentityAssertionAuthenticationError();
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function assertionExactObject(
|
||||
value: Record<string, unknown>,
|
||||
expectedKeys: readonly string[],
|
||||
): void {
|
||||
const actual = Object.keys(value).sort();
|
||||
const expected = [...expectedKeys].sort();
|
||||
if (
|
||||
actual.length !== expected.length ||
|
||||
actual.some((key, index) => key !== expected[index])
|
||||
) {
|
||||
throw new ClusterPluginPackageIdentityAssertionAuthenticationError();
|
||||
}
|
||||
}
|
||||
|
||||
function numericDate(value: unknown): number {
|
||||
if (
|
||||
!Number.isSafeInteger(value) ||
|
||||
(value as number) < 0 ||
|
||||
!Number.isSafeInteger((value as number) * 1_000)
|
||||
) {
|
||||
throw new ClusterPluginPackageIdentityAssertionAuthenticationError();
|
||||
}
|
||||
return (value as number) * 1_000;
|
||||
}
|
||||
|
||||
function assertionTokenValue(value: unknown, maximumLength: number): string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
value.length < 1 ||
|
||||
value.length > maximumLength ||
|
||||
CONTROL_PATTERN.test(value)
|
||||
) {
|
||||
throw new ClusterPluginPackageIdentityAssertionAuthenticationError();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function verifyReviewedSignature(
|
||||
algorithm: AssertionAlgorithm,
|
||||
key: KeyObject,
|
||||
signed: Buffer,
|
||||
signature: Buffer,
|
||||
): boolean {
|
||||
switch (algorithm) {
|
||||
case 'EdDSA':
|
||||
return (
|
||||
signature.length === 64 && verifySignature(null, signed, key, signature)
|
||||
);
|
||||
case 'ES256':
|
||||
return (
|
||||
signature.length === 64 &&
|
||||
verifySignature(
|
||||
'sha256',
|
||||
signed,
|
||||
{ key, dsaEncoding: 'ieee-p1363' },
|
||||
signature,
|
||||
)
|
||||
);
|
||||
case 'RS256':
|
||||
return verifySignature(
|
||||
'RSA-SHA256',
|
||||
signed,
|
||||
{ key, padding: constants.RSA_PKCS1_PADDING },
|
||||
signature,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function authenticationId(issuer: string, jti: string): string {
|
||||
return `ql3oidc.${createHash('sha256')
|
||||
.update(issuer)
|
||||
.update('\0')
|
||||
.update(jti)
|
||||
.digest('base64url')}`;
|
||||
}
|
||||
|
||||
export function createClusterPluginPackageIdentityAssertionVerifier(
|
||||
options: ClusterPluginPackageIdentityAssertionVerifierOptions,
|
||||
): Readonly<ClusterPluginPackageIdentityAssertionVerifier> {
|
||||
if (
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
Object.keys(options).some(
|
||||
(key) =>
|
||||
![
|
||||
'issuer',
|
||||
'audience',
|
||||
'keys',
|
||||
'assuranceMappings',
|
||||
'assertionProfile',
|
||||
'maxAssertionBytes',
|
||||
'maxLifetimeMs',
|
||||
'maxAuthenticationAgeMs',
|
||||
'clockSkewMs',
|
||||
'now',
|
||||
].includes(key),
|
||||
)
|
||||
) {
|
||||
throw configurationFailure('options shape is invalid');
|
||||
}
|
||||
const issuer = reviewedIssuer(options.issuer);
|
||||
const audience = reviewedTokenValue(options.audience, 'audience', 256);
|
||||
const assertionProfile = reviewedAssertionProfile(
|
||||
options.assertionProfile ??
|
||||
CLUSTER_PLUGIN_PACKAGE_MANAGEMENT_IDENTITY_ASSERTION_PROFILE,
|
||||
);
|
||||
const keys = reviewedKeys(options.keys);
|
||||
const assuranceMappings = reviewedAssuranceMappings(
|
||||
options.assuranceMappings,
|
||||
);
|
||||
const maxAssertionBytes = boundedInteger(
|
||||
options.maxAssertionBytes,
|
||||
DEFAULT_ASSERTION_BYTES,
|
||||
MIN_ASSERTION_BYTES,
|
||||
MAX_ASSERTION_BYTES,
|
||||
'max assertion bytes',
|
||||
);
|
||||
const maxLifetimeMs = boundedInteger(
|
||||
options.maxLifetimeMs,
|
||||
DEFAULT_LIFETIME_MS,
|
||||
MIN_LIFETIME_MS,
|
||||
MAX_LIFETIME_MS,
|
||||
'max lifetime',
|
||||
);
|
||||
const maxAuthenticationAgeMs = boundedInteger(
|
||||
options.maxAuthenticationAgeMs,
|
||||
DEFAULT_AUTHENTICATION_AGE_MS,
|
||||
MIN_LIFETIME_MS,
|
||||
MAX_AUTHENTICATION_AGE_MS,
|
||||
'max authentication age',
|
||||
);
|
||||
const clockSkewMs = boundedInteger(
|
||||
options.clockSkewMs,
|
||||
DEFAULT_CLOCK_SKEW_MS,
|
||||
0,
|
||||
MAX_CLOCK_SKEW_MS,
|
||||
'clock skew',
|
||||
);
|
||||
if (options.now !== undefined && typeof options.now !== 'function') {
|
||||
throw configurationFailure('clock is invalid');
|
||||
}
|
||||
const now = options.now ?? Date.now;
|
||||
|
||||
const verify = (assertion: unknown): Readonly<SecurityPrincipal> => {
|
||||
try {
|
||||
if (
|
||||
typeof assertion !== 'string' ||
|
||||
Buffer.byteLength(assertion, 'utf8') > maxAssertionBytes ||
|
||||
CONTROL_PATTERN.test(assertion)
|
||||
) {
|
||||
throw new ClusterPluginPackageIdentityAssertionAuthenticationError();
|
||||
}
|
||||
const segments = assertion.split('.');
|
||||
if (segments.length !== 3) {
|
||||
throw new ClusterPluginPackageIdentityAssertionAuthenticationError();
|
||||
}
|
||||
const [protectedSegment, payloadSegment, signatureSegment] = segments;
|
||||
if (
|
||||
protectedSegment === undefined ||
|
||||
payloadSegment === undefined ||
|
||||
signatureSegment === undefined
|
||||
) {
|
||||
throw new ClusterPluginPackageIdentityAssertionAuthenticationError();
|
||||
}
|
||||
const header = jsonObject(protectedSegment, 1_024);
|
||||
assertionExactObject(header, ['alg', 'kid', 'typ']);
|
||||
if (
|
||||
header.typ !== assertionProfile.type ||
|
||||
typeof header.kid !== 'string' ||
|
||||
typeof header.alg !== 'string'
|
||||
) {
|
||||
throw new ClusterPluginPackageIdentityAssertionAuthenticationError();
|
||||
}
|
||||
const reviewedKey = keys.get(header.kid);
|
||||
if (!reviewedKey || reviewedKey.algorithm !== header.alg) {
|
||||
throw new ClusterPluginPackageIdentityAssertionAuthenticationError();
|
||||
}
|
||||
const signature = canonicalBase64Url(signatureSegment, 512);
|
||||
const signed = Buffer.from(
|
||||
`${protectedSegment}.${payloadSegment}`,
|
||||
'ascii',
|
||||
);
|
||||
if (
|
||||
!verifyReviewedSignature(
|
||||
reviewedKey.algorithm,
|
||||
reviewedKey.key,
|
||||
signed,
|
||||
signature,
|
||||
)
|
||||
) {
|
||||
throw new ClusterPluginPackageIdentityAssertionAuthenticationError();
|
||||
}
|
||||
|
||||
const claims = jsonObject(payloadSegment, 8 * 1_024);
|
||||
const claimKeys = [
|
||||
'acr',
|
||||
'amr',
|
||||
'aud',
|
||||
'auth_time',
|
||||
'exp',
|
||||
'iat',
|
||||
'iss',
|
||||
'jti',
|
||||
'ql3_purpose',
|
||||
'sub',
|
||||
];
|
||||
if (Object.hasOwn(claims, 'nbf')) claimKeys.push('nbf');
|
||||
assertionExactObject(claims, claimKeys);
|
||||
if (
|
||||
claims.iss !== issuer ||
|
||||
claims.aud !== audience ||
|
||||
claims.ql3_purpose !== assertionProfile.purpose
|
||||
) {
|
||||
throw new ClusterPluginPackageIdentityAssertionAuthenticationError();
|
||||
}
|
||||
const subjectId = assertionTokenValue(claims.sub, 255);
|
||||
const jti = assertionTokenValue(claims.jti, 255);
|
||||
const issuedAtMs = numericDate(claims.iat);
|
||||
const authenticatedAtMs = numericDate(claims.auth_time);
|
||||
const expiresAtMs = numericDate(claims.exp);
|
||||
const notBeforeAtMs = Object.hasOwn(claims, 'nbf')
|
||||
? numericDate(claims.nbf)
|
||||
: issuedAtMs;
|
||||
const observedAtMs = now();
|
||||
if (
|
||||
!Number.isSafeInteger(observedAtMs) ||
|
||||
observedAtMs < 0 ||
|
||||
authenticatedAtMs > issuedAtMs ||
|
||||
issuedAtMs > observedAtMs + clockSkewMs ||
|
||||
authenticatedAtMs > observedAtMs ||
|
||||
notBeforeAtMs < issuedAtMs ||
|
||||
notBeforeAtMs >= expiresAtMs ||
|
||||
observedAtMs + clockSkewMs < notBeforeAtMs ||
|
||||
expiresAtMs <= observedAtMs ||
|
||||
expiresAtMs - issuedAtMs > maxLifetimeMs ||
|
||||
observedAtMs - authenticatedAtMs > maxAuthenticationAgeMs
|
||||
) {
|
||||
throw new ClusterPluginPackageIdentityAssertionAuthenticationError();
|
||||
}
|
||||
const acr = assertionTokenValue(claims.acr, 256);
|
||||
const mapping = assuranceMappings.get(acr);
|
||||
if (
|
||||
!mapping ||
|
||||
!Array.isArray(claims.amr) ||
|
||||
claims.amr.length < 1 ||
|
||||
claims.amr.length > MAX_AMR_VALUES
|
||||
) {
|
||||
throw new ClusterPluginPackageIdentityAssertionAuthenticationError();
|
||||
}
|
||||
const amr = new Set<string>();
|
||||
for (const entry of claims.amr) {
|
||||
if (
|
||||
typeof entry !== 'string' ||
|
||||
!TOKEN_VALUE_PATTERN.test(entry) ||
|
||||
amr.has(entry)
|
||||
) {
|
||||
throw new ClusterPluginPackageIdentityAssertionAuthenticationError();
|
||||
}
|
||||
amr.add(entry);
|
||||
}
|
||||
if ([...mapping.requiredAmr].some((entry) => !amr.has(entry))) {
|
||||
throw new ClusterPluginPackageIdentityAssertionAuthenticationError();
|
||||
}
|
||||
return normalizeSecurityPrincipal(
|
||||
{
|
||||
subject: { type: 'user', id: subjectId },
|
||||
authenticationId: authenticationId(issuer, jti),
|
||||
authenticatedAtMs,
|
||||
expiresAtMs,
|
||||
assurance: mapping.assurance,
|
||||
},
|
||||
observedAtMs,
|
||||
);
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof
|
||||
ClusterPluginPackageIdentityAssertionAuthenticationError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
throw new ClusterPluginPackageIdentityAssertionAuthenticationError();
|
||||
}
|
||||
};
|
||||
|
||||
return Object.freeze({
|
||||
verify,
|
||||
bind(
|
||||
assertion: unknown,
|
||||
): Readonly<ClusterPluginPackageIdentityAssertionAuthentication> {
|
||||
return Object.freeze({
|
||||
async authenticate(): Promise<Readonly<SecurityPrincipal>> {
|
||||
return verify(assertion);
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,509 @@
|
||||
/** Shared bounded identity keyset and rotation boundary for cluster management planes. */
|
||||
import { constants } from 'node:fs';
|
||||
import { open } from 'node:fs/promises';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { isAbsolute } from 'node:path';
|
||||
|
||||
import {
|
||||
CLUSTER_WORKER_CREDENTIAL_MANAGEMENT_IDENTITY_ASSERTION_PROFILE,
|
||||
CLUSTER_AUTOMATION_MANAGEMENT_IDENTITY_ASSERTION_PROFILE,
|
||||
CLUSTER_APPROVAL_MANAGEMENT_IDENTITY_ASSERTION_PROFILE,
|
||||
CLUSTER_MODEL_PROVIDER_CREDENTIAL_MANAGEMENT_IDENTITY_ASSERTION_PROFILE,
|
||||
createClusterPluginPackageIdentityAssertionVerifier,
|
||||
type ClusterManagementIdentityAssertionProfile,
|
||||
type ClusterPluginPackageIdentityAssertionAuthentication,
|
||||
type ClusterPluginPackageIdentityAssertionVerifier,
|
||||
} from './pluginPackageIdentityAssertion';
|
||||
|
||||
const DEFAULT_MAX_FILE_BYTES = 64 * 1024;
|
||||
const MIN_MAX_FILE_BYTES = 4 * 1024;
|
||||
const HARD_MAX_FILE_BYTES = 256 * 1024;
|
||||
const MAX_REVOKED_KEYS = 64;
|
||||
const KEY_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const CONTROL_PATTERN = /[\u0000-\u001f\u007f]/;
|
||||
|
||||
export interface ClusterPluginPackageIdentityKeysetFileOptions {
|
||||
readonly filePath: string;
|
||||
readonly maxFileBytes?: number;
|
||||
readonly now?: () => number;
|
||||
readonly ledger?: ClusterPluginPackageIdentityKeysetLedger;
|
||||
readonly assertionProfile?: Readonly<ClusterManagementIdentityAssertionProfile>;
|
||||
}
|
||||
|
||||
export type ClusterWorkerCredentialIdentityKeysetFileOptions = Omit<
|
||||
ClusterPluginPackageIdentityKeysetFileOptions,
|
||||
'assertionProfile'
|
||||
>;
|
||||
|
||||
export interface ClusterPluginPackageIdentityKeysetSnapshot {
|
||||
readonly schemaVersion: 1;
|
||||
readonly generation: number;
|
||||
readonly digest: string;
|
||||
readonly issuer: string;
|
||||
readonly audience: string;
|
||||
readonly activeKeyIds: readonly string[];
|
||||
readonly revokedKeyIds: readonly string[];
|
||||
}
|
||||
|
||||
export interface ClusterPluginPackageIdentityKeysetFile {
|
||||
reload(): Promise<Readonly<ClusterPluginPackageIdentityKeysetSnapshot>>;
|
||||
bind(
|
||||
assertion: unknown,
|
||||
): Readonly<ClusterPluginPackageIdentityAssertionAuthentication>;
|
||||
}
|
||||
|
||||
export interface ClusterPluginPackageIdentityKeysetLedger {
|
||||
observe(
|
||||
snapshot: Readonly<ClusterPluginPackageIdentityKeysetSnapshot>,
|
||||
): Promise<void>;
|
||||
}
|
||||
|
||||
export class ClusterPluginPackageIdentityKeysetConfigurationError extends TypeError {
|
||||
readonly code =
|
||||
'CLUSTER_PLUGIN_PACKAGE_IDENTITY_KEYSET_CONFIGURATION_INVALID';
|
||||
|
||||
constructor(message: string) {
|
||||
super(
|
||||
`Cluster Plugin Package identity keyset configuration is invalid: ${message}`,
|
||||
);
|
||||
this.name = 'ClusterPluginPackageIdentityKeysetConfigurationError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ClusterPluginPackageIdentityKeysetUnavailableError extends Error {
|
||||
readonly code = 'CLUSTER_PLUGIN_PACKAGE_IDENTITY_KEYSET_UNAVAILABLE';
|
||||
|
||||
constructor(readonly cause?: unknown) {
|
||||
super('Cluster Plugin Package identity keyset is unavailable');
|
||||
this.name = 'ClusterPluginPackageIdentityKeysetUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
interface LoadedKeyset {
|
||||
readonly generation: number;
|
||||
readonly digest: string;
|
||||
readonly verifier: Readonly<ClusterPluginPackageIdentityAssertionVerifier>;
|
||||
readonly activeKeyIds: ReadonlySet<string>;
|
||||
readonly revokedKeyIds: ReadonlySet<string>;
|
||||
readonly snapshot: Readonly<ClusterPluginPackageIdentityKeysetSnapshot>;
|
||||
}
|
||||
|
||||
function configurationFailure(
|
||||
message: string,
|
||||
): ClusterPluginPackageIdentityKeysetConfigurationError {
|
||||
return new ClusterPluginPackageIdentityKeysetConfigurationError(message);
|
||||
}
|
||||
|
||||
function exactObject(
|
||||
value: unknown,
|
||||
expectedKeys: readonly string[],
|
||||
label: string,
|
||||
): asserts value is Record<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw configurationFailure(`${label} must be an object`);
|
||||
}
|
||||
const actual = Object.keys(value).sort();
|
||||
const expected = [...expectedKeys].sort();
|
||||
if (
|
||||
actual.length !== expected.length ||
|
||||
actual.some((key, index) => key !== expected[index])
|
||||
) {
|
||||
throw configurationFailure(`${label} shape is invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
function boundedInteger(
|
||||
value: unknown,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
label: string,
|
||||
): number {
|
||||
if (
|
||||
!Number.isSafeInteger(value) ||
|
||||
(value as number) < minimum ||
|
||||
(value as number) > maximum
|
||||
) {
|
||||
throw configurationFailure(`${label} is invalid`);
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function sameFileState(
|
||||
left: Readonly<{
|
||||
dev: number;
|
||||
ino: number;
|
||||
size: number;
|
||||
mtimeMs: number;
|
||||
ctimeMs: number;
|
||||
}>,
|
||||
right: Readonly<{
|
||||
dev: number;
|
||||
ino: number;
|
||||
size: number;
|
||||
mtimeMs: number;
|
||||
ctimeMs: number;
|
||||
}>,
|
||||
): boolean {
|
||||
return (
|
||||
left.dev === right.dev &&
|
||||
left.ino === right.ino &&
|
||||
left.size === right.size &&
|
||||
left.mtimeMs === right.mtimeMs &&
|
||||
left.ctimeMs === right.ctimeMs
|
||||
);
|
||||
}
|
||||
|
||||
async function readBoundedRegularFile(
|
||||
filePath: string,
|
||||
maxFileBytes: number,
|
||||
): Promise<Buffer> {
|
||||
const handle = await open(filePath, constants.O_RDONLY);
|
||||
try {
|
||||
const before = await handle.stat();
|
||||
if (
|
||||
!before.isFile() ||
|
||||
before.size < 1 ||
|
||||
before.size > maxFileBytes ||
|
||||
(before.mode & 0o022) !== 0
|
||||
) {
|
||||
throw configurationFailure(
|
||||
'keyset file must be a bounded non-writable regular file',
|
||||
);
|
||||
}
|
||||
const buffer = Buffer.allocUnsafe(maxFileBytes + 1);
|
||||
let offset = 0;
|
||||
while (offset < buffer.length) {
|
||||
const { bytesRead } = await handle.read(
|
||||
buffer,
|
||||
offset,
|
||||
buffer.length - offset,
|
||||
offset,
|
||||
);
|
||||
if (bytesRead === 0) break;
|
||||
offset += bytesRead;
|
||||
}
|
||||
const after = await handle.stat();
|
||||
if (
|
||||
offset !== before.size ||
|
||||
offset > maxFileBytes ||
|
||||
!sameFileState(before, after)
|
||||
) {
|
||||
throw configurationFailure('keyset file changed while being read');
|
||||
}
|
||||
return buffer.subarray(0, offset);
|
||||
} finally {
|
||||
await handle.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
function parseJson(bytes: Buffer): Record<string, unknown> {
|
||||
let text: string;
|
||||
try {
|
||||
text = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
||||
} catch {
|
||||
throw configurationFailure('keyset file must be strict UTF-8');
|
||||
}
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(text);
|
||||
} catch {
|
||||
throw configurationFailure('keyset file must contain JSON');
|
||||
}
|
||||
exactObject(
|
||||
value,
|
||||
[
|
||||
'schemaVersion',
|
||||
'generation',
|
||||
'issuer',
|
||||
'audience',
|
||||
'keys',
|
||||
'revokedKids',
|
||||
'assuranceMappings',
|
||||
'constraints',
|
||||
],
|
||||
'keyset',
|
||||
);
|
||||
return value;
|
||||
}
|
||||
|
||||
function reviewedRevokedKeyIds(value: unknown): ReadonlySet<string> {
|
||||
if (!Array.isArray(value) || value.length > MAX_REVOKED_KEYS) {
|
||||
throw configurationFailure('revoked key ids are invalid');
|
||||
}
|
||||
const ids = new Set<string>();
|
||||
for (const candidate of value) {
|
||||
if (
|
||||
typeof candidate !== 'string' ||
|
||||
!KEY_ID_PATTERN.test(candidate) ||
|
||||
ids.has(candidate)
|
||||
) {
|
||||
throw configurationFailure('revoked key id is invalid or duplicated');
|
||||
}
|
||||
ids.add(candidate);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
function activeKeys(
|
||||
value: unknown,
|
||||
revokedKeyIds: ReadonlySet<string>,
|
||||
): {
|
||||
readonly all: readonly Readonly<Record<string, unknown>>[];
|
||||
readonly active: readonly Readonly<Record<string, unknown>>[];
|
||||
readonly activeKeyIds: ReadonlySet<string>;
|
||||
} {
|
||||
if (!Array.isArray(value)) {
|
||||
throw configurationFailure('keys must be an array');
|
||||
}
|
||||
const all = value as readonly Readonly<Record<string, unknown>>[];
|
||||
const active: Readonly<Record<string, unknown>>[] = [];
|
||||
const activeKeyIds = new Set<string>();
|
||||
for (const candidate of all) {
|
||||
if (
|
||||
!candidate ||
|
||||
typeof candidate !== 'object' ||
|
||||
Array.isArray(candidate)
|
||||
) {
|
||||
throw configurationFailure('key must be an object');
|
||||
}
|
||||
const kid = candidate.kid;
|
||||
if (typeof kid !== 'string') {
|
||||
throw configurationFailure('key id is invalid');
|
||||
}
|
||||
if (!revokedKeyIds.has(kid)) {
|
||||
active.push(candidate);
|
||||
activeKeyIds.add(kid);
|
||||
}
|
||||
}
|
||||
if (active.length < 1) {
|
||||
throw configurationFailure('at least one key must remain active');
|
||||
}
|
||||
return Object.freeze({ all, active, activeKeyIds });
|
||||
}
|
||||
|
||||
function loadDocument(
|
||||
bytes: Buffer,
|
||||
now: (() => number) | undefined,
|
||||
digest: string,
|
||||
assertionProfile:
|
||||
| Readonly<ClusterManagementIdentityAssertionProfile>
|
||||
| undefined,
|
||||
): LoadedKeyset {
|
||||
const document = parseJson(bytes);
|
||||
if (document.schemaVersion !== 1) {
|
||||
throw configurationFailure('schemaVersion is invalid');
|
||||
}
|
||||
const generation = boundedInteger(
|
||||
document.generation,
|
||||
1,
|
||||
Number.MAX_SAFE_INTEGER,
|
||||
'generation',
|
||||
);
|
||||
const revokedKeyIds = reviewedRevokedKeyIds(document.revokedKids);
|
||||
const keySelection = activeKeys(document.keys, revokedKeyIds);
|
||||
exactObject(
|
||||
document.constraints,
|
||||
[
|
||||
'maxAssertionBytes',
|
||||
'maxLifetimeMs',
|
||||
'maxAuthenticationAgeMs',
|
||||
'clockSkewMs',
|
||||
],
|
||||
'constraints',
|
||||
);
|
||||
const verifierOptions = {
|
||||
issuer: document.issuer as string,
|
||||
audience: document.audience as string,
|
||||
assuranceMappings: document.assuranceMappings as never,
|
||||
maxAssertionBytes: document.constraints.maxAssertionBytes as number,
|
||||
maxLifetimeMs: document.constraints.maxLifetimeMs as number,
|
||||
maxAuthenticationAgeMs: document.constraints
|
||||
.maxAuthenticationAgeMs as number,
|
||||
clockSkewMs: document.constraints.clockSkewMs as number,
|
||||
...(assertionProfile === undefined ? {} : { assertionProfile }),
|
||||
...(now === undefined ? {} : { now }),
|
||||
};
|
||||
// Validate revoked definitions too; revocation must not become a channel for
|
||||
// retaining malformed or private key material in the trust document.
|
||||
createClusterPluginPackageIdentityAssertionVerifier({
|
||||
...verifierOptions,
|
||||
keys: keySelection.all,
|
||||
});
|
||||
const verifier = createClusterPluginPackageIdentityAssertionVerifier({
|
||||
...verifierOptions,
|
||||
keys: keySelection.active,
|
||||
});
|
||||
const issuer = document.issuer as string;
|
||||
const audience = document.audience as string;
|
||||
const snapshot = Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
generation,
|
||||
digest,
|
||||
issuer,
|
||||
audience,
|
||||
activeKeyIds: Object.freeze([...keySelection.activeKeyIds].sort()),
|
||||
revokedKeyIds: Object.freeze([...revokedKeyIds].sort()),
|
||||
});
|
||||
return Object.freeze({
|
||||
generation,
|
||||
digest,
|
||||
verifier,
|
||||
activeKeyIds: keySelection.activeKeyIds,
|
||||
revokedKeyIds,
|
||||
snapshot,
|
||||
});
|
||||
}
|
||||
|
||||
function assertForwardRotation(
|
||||
current: LoadedKeyset,
|
||||
candidate: LoadedKeyset,
|
||||
): void {
|
||||
if (candidate.generation < current.generation) {
|
||||
throw configurationFailure('keyset generation rollback is forbidden');
|
||||
}
|
||||
if (
|
||||
candidate.generation === current.generation &&
|
||||
candidate.digest !== current.digest
|
||||
) {
|
||||
throw configurationFailure('keyset generation rewrite is forbidden');
|
||||
}
|
||||
if (candidate.generation === current.generation) return;
|
||||
for (const kid of current.revokedKeyIds) {
|
||||
if (!candidate.revokedKeyIds.has(kid)) {
|
||||
throw configurationFailure('revoked key ids are append-only');
|
||||
}
|
||||
}
|
||||
for (const kid of current.activeKeyIds) {
|
||||
if (!candidate.activeKeyIds.has(kid) && !candidate.revokedKeyIds.has(kid)) {
|
||||
throw configurationFailure(
|
||||
'removed active keys must be explicitly revoked',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createClusterPluginPackageIdentityKeysetFile(
|
||||
options: ClusterPluginPackageIdentityKeysetFileOptions,
|
||||
): Readonly<ClusterPluginPackageIdentityKeysetFile> {
|
||||
if (
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
Object.keys(options).some(
|
||||
(key) =>
|
||||
key !== 'filePath' &&
|
||||
key !== 'maxFileBytes' &&
|
||||
key !== 'now' &&
|
||||
key !== 'ledger' &&
|
||||
key !== 'assertionProfile',
|
||||
) ||
|
||||
typeof options.filePath !== 'string' ||
|
||||
options.filePath.length < 1 ||
|
||||
options.filePath.length > 4_096 ||
|
||||
CONTROL_PATTERN.test(options.filePath) ||
|
||||
!isAbsolute(options.filePath) ||
|
||||
(options.now !== undefined && typeof options.now !== 'function') ||
|
||||
(options.ledger !== undefined &&
|
||||
(!options.ledger ||
|
||||
typeof options.ledger !== 'object' ||
|
||||
typeof options.ledger.observe !== 'function'))
|
||||
) {
|
||||
throw configurationFailure('options are invalid');
|
||||
}
|
||||
const maxFileBytes =
|
||||
options.maxFileBytes === undefined
|
||||
? DEFAULT_MAX_FILE_BYTES
|
||||
: boundedInteger(
|
||||
options.maxFileBytes,
|
||||
MIN_MAX_FILE_BYTES,
|
||||
HARD_MAX_FILE_BYTES,
|
||||
'maximum file bytes',
|
||||
);
|
||||
let current: LoadedKeyset | undefined;
|
||||
|
||||
const reload = async (): Promise<
|
||||
Readonly<ClusterPluginPackageIdentityKeysetSnapshot>
|
||||
> => {
|
||||
try {
|
||||
const bytes = await readBoundedRegularFile(
|
||||
options.filePath,
|
||||
maxFileBytes,
|
||||
);
|
||||
const digest = createHash('sha256').update(bytes).digest('base64url');
|
||||
if (current?.digest === digest) {
|
||||
await options.ledger?.observe(current.snapshot);
|
||||
return current.snapshot;
|
||||
}
|
||||
const candidate = loadDocument(
|
||||
bytes,
|
||||
options.now,
|
||||
digest,
|
||||
options.assertionProfile,
|
||||
);
|
||||
if (current) {
|
||||
assertForwardRotation(current, candidate);
|
||||
}
|
||||
await options.ledger?.observe(candidate.snapshot);
|
||||
current = candidate;
|
||||
return candidate.snapshot;
|
||||
} catch (error) {
|
||||
if (error instanceof ClusterPluginPackageIdentityKeysetUnavailableError) {
|
||||
throw error;
|
||||
}
|
||||
throw new ClusterPluginPackageIdentityKeysetUnavailableError(error);
|
||||
}
|
||||
};
|
||||
|
||||
return Object.freeze({
|
||||
reload,
|
||||
bind(assertion: unknown) {
|
||||
return Object.freeze({
|
||||
async authenticate() {
|
||||
await reload();
|
||||
if (!current) {
|
||||
throw new ClusterPluginPackageIdentityKeysetUnavailableError();
|
||||
}
|
||||
return current.verifier.verify(assertion);
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function createClusterWorkerCredentialIdentityKeysetFile(
|
||||
options: ClusterWorkerCredentialIdentityKeysetFileOptions,
|
||||
): Readonly<ClusterPluginPackageIdentityKeysetFile> {
|
||||
return createClusterPluginPackageIdentityKeysetFile({
|
||||
...options,
|
||||
assertionProfile:
|
||||
CLUSTER_WORKER_CREDENTIAL_MANAGEMENT_IDENTITY_ASSERTION_PROFILE,
|
||||
});
|
||||
}
|
||||
|
||||
export function createClusterAutomationIdentityKeysetFile(
|
||||
options: ClusterWorkerCredentialIdentityKeysetFileOptions,
|
||||
): Readonly<ClusterPluginPackageIdentityKeysetFile> {
|
||||
return createClusterPluginPackageIdentityKeysetFile({
|
||||
...options,
|
||||
assertionProfile: CLUSTER_AUTOMATION_MANAGEMENT_IDENTITY_ASSERTION_PROFILE,
|
||||
});
|
||||
}
|
||||
|
||||
export function createClusterApprovalIdentityKeysetFile(
|
||||
options: ClusterWorkerCredentialIdentityKeysetFileOptions,
|
||||
): Readonly<ClusterPluginPackageIdentityKeysetFile> {
|
||||
return createClusterPluginPackageIdentityKeysetFile({
|
||||
...options,
|
||||
assertionProfile: CLUSTER_APPROVAL_MANAGEMENT_IDENTITY_ASSERTION_PROFILE,
|
||||
});
|
||||
}
|
||||
|
||||
export function createClusterModelProviderCredentialIdentityKeysetFile(
|
||||
options: ClusterWorkerCredentialIdentityKeysetFileOptions,
|
||||
): Readonly<ClusterPluginPackageIdentityKeysetFile> {
|
||||
return createClusterPluginPackageIdentityKeysetFile({
|
||||
...options,
|
||||
assertionProfile:
|
||||
CLUSTER_MODEL_PROVIDER_CREDENTIAL_MANAGEMENT_IDENTITY_ASSERTION_PROFILE,
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,919 @@
|
||||
/** Shared bounded TLS HTTP host boundary for cluster management planes. */
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { createServer, type Server as HttpsServer } from 'node:https';
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||
import type { Duplex } from 'node:stream';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import type { TLSSocket } from 'node:tls';
|
||||
|
||||
import {
|
||||
PluginPackageManagementAuthorizationError,
|
||||
PluginPackageManagementConflictError,
|
||||
PluginPackageManagementQuotaExceededError,
|
||||
PluginPackageManagementRequestError,
|
||||
PluginPackageManagementUnavailableError,
|
||||
} from '@qinglong/runtime-core/plugin-package-management';
|
||||
import { ClusterPluginPackageIdentityAssertionAuthenticationError } from './pluginPackageIdentityAssertion';
|
||||
import {
|
||||
ClusterPluginPackageIdentityKeysetUnavailableError,
|
||||
type ClusterPluginPackageIdentityKeysetFile,
|
||||
} from './pluginPackageIdentityKeyset';
|
||||
import {
|
||||
ClusterPluginPackageManagementTransportAuthenticationError,
|
||||
ClusterPluginPackageManagementTransportRequestError,
|
||||
ClusterPluginPackageManagementTransportUnavailableError,
|
||||
} from '../plugin-package/management/pluginPackageManagementTransport';
|
||||
import {
|
||||
WorkerCredentialManagementAuthorizationError,
|
||||
WorkerCredentialManagementConflictError,
|
||||
WorkerCredentialManagementQuotaExceededError,
|
||||
WorkerCredentialManagementRequestError,
|
||||
WorkerCredentialManagementUnavailableError,
|
||||
} from '../worker-credential/management-server/workerCredentialManagement';
|
||||
import {
|
||||
ClusterWorkerCredentialManagementTransportAuthenticationError,
|
||||
ClusterWorkerCredentialManagementTransportRequestError,
|
||||
ClusterWorkerCredentialManagementTransportUnavailableError,
|
||||
} from '../worker-credential/management-server/workerCredentialManagementTransport';
|
||||
import {
|
||||
ClusterAutomationManagementAuthorizationError,
|
||||
ClusterAutomationManagementConflictError,
|
||||
ClusterAutomationManagementRequestError,
|
||||
ClusterAutomationManagementUnavailableError,
|
||||
} from '../automation-management/automationManagement';
|
||||
import {
|
||||
ClusterAutomationManagementTransportAuthenticationError,
|
||||
ClusterAutomationManagementTransportRequestError,
|
||||
ClusterAutomationManagementTransportUnavailableError,
|
||||
} from '../automation-management/automationManagementTransport';
|
||||
import {
|
||||
ClusterApprovalManagementTransportAuthenticationError,
|
||||
ClusterApprovalManagementTransportAuthorizationError,
|
||||
ClusterApprovalManagementTransportConflictError,
|
||||
ClusterApprovalManagementTransportRequestError,
|
||||
ClusterApprovalManagementTransportTargetUnavailableError,
|
||||
ClusterApprovalManagementTransportUnavailableError,
|
||||
} from '../approval-management/approvalManagementTransport';
|
||||
import {
|
||||
ClusterModelProviderCredentialManagementAuthenticationError,
|
||||
ClusterModelProviderCredentialManagementAuthorizationError,
|
||||
ClusterModelProviderCredentialManagementConflictError,
|
||||
ClusterModelProviderCredentialManagementQuotaExceededError,
|
||||
ClusterModelProviderCredentialManagementRequestError,
|
||||
ClusterModelProviderCredentialManagementUnavailableError,
|
||||
} from '../model-provider-credential/modelProviderCredentialManagement';
|
||||
import {
|
||||
ClusterModelProviderCredentialManagementTransportAuthenticationError,
|
||||
ClusterModelProviderCredentialManagementTransportRequestError,
|
||||
ClusterModelProviderCredentialManagementTransportUnavailableError,
|
||||
} from '../model-provider-credential/modelProviderCredentialManagementTransport';
|
||||
|
||||
export const CLUSTER_PLUGIN_PACKAGE_MANAGEMENT_PATH =
|
||||
'/api/v3/plugin-packages/management';
|
||||
export const CLUSTER_WORKER_CREDENTIAL_MANAGEMENT_PATH =
|
||||
'/api/v3/worker-credentials/management';
|
||||
export const CLUSTER_AUTOMATION_MANAGEMENT_PATH =
|
||||
'/api/v3/automations/management';
|
||||
export const CLUSTER_APPROVAL_MANAGEMENT_PATH = '/api/v3/approvals/management';
|
||||
export const CLUSTER_MODEL_PROVIDER_CREDENTIAL_MANAGEMENT_PATH =
|
||||
'/api/v3/provider-credentials/management';
|
||||
export type ClusterAuthenticatedManagementPath =
|
||||
| typeof CLUSTER_PLUGIN_PACKAGE_MANAGEMENT_PATH
|
||||
| typeof CLUSTER_WORKER_CREDENTIAL_MANAGEMENT_PATH
|
||||
| typeof CLUSTER_AUTOMATION_MANAGEMENT_PATH
|
||||
| typeof CLUSTER_APPROVAL_MANAGEMENT_PATH
|
||||
| typeof CLUSTER_MODEL_PROVIDER_CREDENTIAL_MANAGEMENT_PATH;
|
||||
const MANAGEMENT_PATHS = new Set<ClusterAuthenticatedManagementPath>([
|
||||
CLUSTER_PLUGIN_PACKAGE_MANAGEMENT_PATH,
|
||||
CLUSTER_WORKER_CREDENTIAL_MANAGEMENT_PATH,
|
||||
CLUSTER_AUTOMATION_MANAGEMENT_PATH,
|
||||
CLUSTER_APPROVAL_MANAGEMENT_PATH,
|
||||
CLUSTER_MODEL_PROVIDER_CREDENTIAL_MANAGEMENT_PATH,
|
||||
]);
|
||||
const DEFAULT_MAX_BODY_BYTES = 64 * 1024;
|
||||
const DEFAULT_MAX_CONNECTIONS = 64;
|
||||
const DEFAULT_MAX_CONCURRENT_REQUESTS = 32;
|
||||
const DEFAULT_REQUEST_TIMEOUT_MS = 10_000;
|
||||
const DEFAULT_DRAIN_TIMEOUT_MS = 5_000;
|
||||
const DEFAULT_RATE_WINDOW_MS = 60_000;
|
||||
const DEFAULT_PEER_REQUEST_LIMIT = 60;
|
||||
const DEFAULT_GLOBAL_REQUEST_LIMIT = 600;
|
||||
const DEFAULT_MAX_RATE_LIMIT_PEERS = 1_024;
|
||||
const MAX_AUTHORIZATION_BYTES = 16 * 1024;
|
||||
const MAX_RESPONSE_BYTES = 128 * 1024;
|
||||
const CONTROL_PATTERN = /[\u0000-\u001f\u007f]/;
|
||||
|
||||
export interface ClusterPluginPackageManagementHttpLimits {
|
||||
readonly maxBodyBytes?: number;
|
||||
readonly maxConnections?: number;
|
||||
readonly maxConcurrentRequests?: number;
|
||||
readonly requestTimeoutMs?: number;
|
||||
readonly drainTimeoutMs?: number;
|
||||
readonly rateWindowMs?: number;
|
||||
readonly peerRequestLimit?: number;
|
||||
readonly globalRequestLimit?: number;
|
||||
readonly maxRateLimitPeers?: number;
|
||||
}
|
||||
|
||||
export interface StartClusterPluginPackageManagementHttpOptions {
|
||||
readonly host: string;
|
||||
readonly port: number;
|
||||
readonly tls: Readonly<{
|
||||
readonly privateKey: Buffer;
|
||||
readonly certificate: Buffer;
|
||||
readonly clientCertificateAuthority?: Buffer;
|
||||
readonly clientCertificateRevocationList?: Buffer;
|
||||
}>;
|
||||
readonly transport: ClusterAuthenticatedManagementTransport;
|
||||
readonly identities: ClusterPluginPackageIdentityKeysetFile;
|
||||
readonly managementPath?: ClusterAuthenticatedManagementPath;
|
||||
readonly limits?: ClusterPluginPackageManagementHttpLimits;
|
||||
readonly now?: () => number;
|
||||
readonly createRequestId?: () => string;
|
||||
readonly onError?: (error: unknown) => void;
|
||||
}
|
||||
|
||||
export interface ClusterAuthenticatedManagementTransport {
|
||||
execute(
|
||||
command: unknown,
|
||||
authentication: Readonly<{
|
||||
authenticate(): Promise<unknown>;
|
||||
}>,
|
||||
): Promise<unknown>;
|
||||
}
|
||||
|
||||
export interface ClusterPluginPackageManagementHttpApplication {
|
||||
readonly status: 'active';
|
||||
readonly address: Readonly<{ host: string; port: number }>;
|
||||
availabilityStatus(): 'ready' | 'unavailable' | 'stopped';
|
||||
withdraw(error?: unknown): void;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export class ClusterPluginPackageManagementHttpConfigurationError extends TypeError {
|
||||
readonly code = 'CLUSTER_PLUGIN_PACKAGE_MANAGEMENT_HTTP_CONFIG_INVALID';
|
||||
|
||||
constructor(message: string) {
|
||||
super(
|
||||
`Cluster Plugin Package management HTTP configuration is invalid: ${message}`,
|
||||
);
|
||||
this.name = 'ClusterPluginPackageManagementHttpConfigurationError';
|
||||
}
|
||||
}
|
||||
|
||||
class HttpRequestError extends Error {
|
||||
constructor(
|
||||
readonly statusCode: number,
|
||||
readonly responseCode: string,
|
||||
readonly retryAfterMs?: number,
|
||||
) {
|
||||
super(responseCode);
|
||||
}
|
||||
}
|
||||
|
||||
interface ReviewedLimits {
|
||||
readonly maxBodyBytes: number;
|
||||
readonly maxConnections: number;
|
||||
readonly maxConcurrentRequests: number;
|
||||
readonly requestTimeoutMs: number;
|
||||
readonly drainTimeoutMs: number;
|
||||
readonly rateWindowMs: number;
|
||||
readonly peerRequestLimit: number;
|
||||
readonly globalRequestLimit: number;
|
||||
readonly maxRateLimitPeers: number;
|
||||
}
|
||||
|
||||
interface RateBucket {
|
||||
windowStartedAtMs: number;
|
||||
count: number;
|
||||
lastSeenAtMs: number;
|
||||
}
|
||||
|
||||
class BoundedRateLimiter {
|
||||
readonly #peers = new Map<string, RateBucket>();
|
||||
#global: RateBucket;
|
||||
|
||||
constructor(
|
||||
private readonly limits: ReviewedLimits,
|
||||
private readonly now: () => number,
|
||||
) {
|
||||
const nowMs = this.currentTime();
|
||||
this.#global = {
|
||||
windowStartedAtMs: nowMs,
|
||||
count: 0,
|
||||
lastSeenAtMs: nowMs,
|
||||
};
|
||||
}
|
||||
|
||||
private currentTime(): number {
|
||||
const value = this.now();
|
||||
if (!Number.isSafeInteger(value) || value < 0) {
|
||||
throw new Error('HTTP rate-limit clock is invalid');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private retryAfter(
|
||||
bucket: RateBucket,
|
||||
limit: number,
|
||||
nowMs: number,
|
||||
): number | null {
|
||||
if (nowMs >= bucket.windowStartedAtMs + this.limits.rateWindowMs) {
|
||||
bucket.windowStartedAtMs = nowMs;
|
||||
bucket.count = 0;
|
||||
}
|
||||
bucket.lastSeenAtMs = nowMs;
|
||||
if (bucket.count >= limit) {
|
||||
return Math.max(
|
||||
1,
|
||||
bucket.windowStartedAtMs + this.limits.rateWindowMs - nowMs,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private evictOldestPeer(): void {
|
||||
let oldestKey: string | undefined;
|
||||
let oldestAtMs = Number.POSITIVE_INFINITY;
|
||||
for (const [key, bucket] of this.#peers) {
|
||||
if (bucket.lastSeenAtMs < oldestAtMs) {
|
||||
oldestAtMs = bucket.lastSeenAtMs;
|
||||
oldestKey = key;
|
||||
}
|
||||
}
|
||||
if (oldestKey !== undefined) this.#peers.delete(oldestKey);
|
||||
}
|
||||
|
||||
consume(peerValue: string | undefined): number | null {
|
||||
const nowMs = this.currentTime();
|
||||
const globalRetry = this.retryAfter(
|
||||
this.#global,
|
||||
this.limits.globalRequestLimit,
|
||||
nowMs,
|
||||
);
|
||||
if (globalRetry !== null) return globalRetry;
|
||||
const peer =
|
||||
typeof peerValue === 'string' &&
|
||||
peerValue.length >= 1 &&
|
||||
peerValue.length <= 128 &&
|
||||
!CONTROL_PATTERN.test(peerValue)
|
||||
? peerValue
|
||||
: '<unknown>';
|
||||
let bucket = this.#peers.get(peer);
|
||||
if (!bucket) {
|
||||
if (this.#peers.size >= this.limits.maxRateLimitPeers) {
|
||||
this.evictOldestPeer();
|
||||
}
|
||||
bucket = {
|
||||
windowStartedAtMs: nowMs,
|
||||
count: 0,
|
||||
lastSeenAtMs: nowMs,
|
||||
};
|
||||
this.#peers.set(peer, bucket);
|
||||
}
|
||||
const peerRetry = this.retryAfter(
|
||||
bucket,
|
||||
this.limits.peerRequestLimit,
|
||||
nowMs,
|
||||
);
|
||||
if (peerRetry !== null) return peerRetry;
|
||||
this.#global.count += 1;
|
||||
bucket.count += 1;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function configurationFailure(
|
||||
message: string,
|
||||
): ClusterPluginPackageManagementHttpConfigurationError {
|
||||
return new ClusterPluginPackageManagementHttpConfigurationError(message);
|
||||
}
|
||||
|
||||
function integer(
|
||||
value: number | undefined,
|
||||
fallback: number,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
label: string,
|
||||
): number {
|
||||
const candidate = value ?? fallback;
|
||||
if (
|
||||
!Number.isSafeInteger(candidate) ||
|
||||
candidate < minimum ||
|
||||
candidate > maximum
|
||||
) {
|
||||
throw configurationFailure(`${label} is invalid`);
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
function reviewedLimits(
|
||||
value: ClusterPluginPackageManagementHttpLimits | undefined,
|
||||
): ReviewedLimits {
|
||||
if (
|
||||
value !== undefined &&
|
||||
(!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
Object.keys(value).some(
|
||||
(key) =>
|
||||
![
|
||||
'maxBodyBytes',
|
||||
'maxConnections',
|
||||
'maxConcurrentRequests',
|
||||
'requestTimeoutMs',
|
||||
'drainTimeoutMs',
|
||||
'rateWindowMs',
|
||||
'peerRequestLimit',
|
||||
'globalRequestLimit',
|
||||
'maxRateLimitPeers',
|
||||
].includes(key),
|
||||
))
|
||||
) {
|
||||
throw configurationFailure('limits are invalid');
|
||||
}
|
||||
const limits = value ?? {};
|
||||
const reviewed = {
|
||||
maxBodyBytes: integer(
|
||||
limits.maxBodyBytes,
|
||||
DEFAULT_MAX_BODY_BYTES,
|
||||
1_024,
|
||||
256 * 1024,
|
||||
'maximum body bytes',
|
||||
),
|
||||
maxConnections: integer(
|
||||
limits.maxConnections,
|
||||
DEFAULT_MAX_CONNECTIONS,
|
||||
1,
|
||||
512,
|
||||
'maximum connections',
|
||||
),
|
||||
maxConcurrentRequests: integer(
|
||||
limits.maxConcurrentRequests,
|
||||
DEFAULT_MAX_CONCURRENT_REQUESTS,
|
||||
1,
|
||||
256,
|
||||
'maximum concurrent requests',
|
||||
),
|
||||
requestTimeoutMs: integer(
|
||||
limits.requestTimeoutMs,
|
||||
DEFAULT_REQUEST_TIMEOUT_MS,
|
||||
1_000,
|
||||
60_000,
|
||||
'request timeout',
|
||||
),
|
||||
drainTimeoutMs: integer(
|
||||
limits.drainTimeoutMs,
|
||||
DEFAULT_DRAIN_TIMEOUT_MS,
|
||||
100,
|
||||
60_000,
|
||||
'drain timeout',
|
||||
),
|
||||
rateWindowMs: integer(
|
||||
limits.rateWindowMs,
|
||||
DEFAULT_RATE_WINDOW_MS,
|
||||
1_000,
|
||||
5 * 60_000,
|
||||
'rate window',
|
||||
),
|
||||
peerRequestLimit: integer(
|
||||
limits.peerRequestLimit,
|
||||
DEFAULT_PEER_REQUEST_LIMIT,
|
||||
1,
|
||||
10_000,
|
||||
'peer request limit',
|
||||
),
|
||||
globalRequestLimit: integer(
|
||||
limits.globalRequestLimit,
|
||||
DEFAULT_GLOBAL_REQUEST_LIMIT,
|
||||
1,
|
||||
100_000,
|
||||
'global request limit',
|
||||
),
|
||||
maxRateLimitPeers: integer(
|
||||
limits.maxRateLimitPeers,
|
||||
DEFAULT_MAX_RATE_LIMIT_PEERS,
|
||||
1,
|
||||
16_384,
|
||||
'maximum rate-limit peers',
|
||||
),
|
||||
};
|
||||
if (reviewed.globalRequestLimit < reviewed.peerRequestLimit) {
|
||||
throw configurationFailure(
|
||||
'global request limit cannot be below the peer limit',
|
||||
);
|
||||
}
|
||||
return Object.freeze(reviewed);
|
||||
}
|
||||
|
||||
function rawHeaderCount(request: IncomingMessage, name: string): number {
|
||||
let count = 0;
|
||||
for (let index = 0; index < request.rawHeaders.length; index += 2) {
|
||||
if (request.rawHeaders[index]?.toLowerCase() === name) count += 1;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function bearerAssertion(request: IncomingMessage): string {
|
||||
if (
|
||||
rawHeaderCount(request, 'authorization') !== 1 ||
|
||||
typeof request.headers.authorization !== 'string'
|
||||
) {
|
||||
throw new HttpRequestError(401, 'authentication_required');
|
||||
}
|
||||
const value = request.headers.authorization;
|
||||
if (
|
||||
!value.startsWith('Bearer ') ||
|
||||
value.length <= 7 ||
|
||||
Buffer.byteLength(value, 'utf8') > MAX_AUTHORIZATION_BYTES ||
|
||||
CONTROL_PATTERN.test(value)
|
||||
) {
|
||||
throw new HttpRequestError(401, 'authentication_required');
|
||||
}
|
||||
return value.slice(7);
|
||||
}
|
||||
|
||||
function assertRequestHeaders(
|
||||
request: IncomingMessage,
|
||||
maxBodyBytes: number,
|
||||
): void {
|
||||
if (
|
||||
rawHeaderCount(request, 'content-type') !== 1 ||
|
||||
request.headers['content-type'] !== 'application/json'
|
||||
) {
|
||||
throw new HttpRequestError(415, 'unsupported_media_type');
|
||||
}
|
||||
if (
|
||||
request.headers['content-encoding'] !== undefined ||
|
||||
request.headers.expect !== undefined
|
||||
) {
|
||||
throw new HttpRequestError(400, 'request_invalid');
|
||||
}
|
||||
if (rawHeaderCount(request, 'content-length') > 1) {
|
||||
throw new HttpRequestError(400, 'request_invalid');
|
||||
}
|
||||
const contentLength = request.headers['content-length'];
|
||||
if (contentLength !== undefined) {
|
||||
if (
|
||||
!/^(?:0|[1-9][0-9]*)$/.test(contentLength) ||
|
||||
Number(contentLength) > maxBodyBytes
|
||||
) {
|
||||
throw new HttpRequestError(413, 'request_too_large');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function readJsonBody(
|
||||
request: IncomingMessage,
|
||||
maxBodyBytes: number,
|
||||
): Promise<unknown> {
|
||||
const chunks: Buffer[] = [];
|
||||
let length = 0;
|
||||
for await (const value of request) {
|
||||
const chunk = Buffer.isBuffer(value) ? value : Buffer.from(value);
|
||||
length += chunk.length;
|
||||
if (length > maxBodyBytes) {
|
||||
throw new HttpRequestError(413, 'request_too_large');
|
||||
}
|
||||
chunks.push(chunk);
|
||||
}
|
||||
if (length < 1) {
|
||||
throw new HttpRequestError(400, 'request_invalid');
|
||||
}
|
||||
let text: string;
|
||||
try {
|
||||
text = new TextDecoder('utf-8', { fatal: true }).decode(
|
||||
Buffer.concat(chunks, length),
|
||||
);
|
||||
} catch {
|
||||
throw new HttpRequestError(400, 'request_invalid');
|
||||
}
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
throw new HttpRequestError(400, 'request_invalid');
|
||||
}
|
||||
}
|
||||
|
||||
function writeJson(
|
||||
response: ServerResponse,
|
||||
statusCode: number,
|
||||
value: Readonly<Record<string, unknown>>,
|
||||
retryAfterMs?: number,
|
||||
): void {
|
||||
const body = Buffer.from(JSON.stringify(value));
|
||||
if (body.length > MAX_RESPONSE_BYTES) {
|
||||
throw new Error('management response exceeds its hard limit');
|
||||
}
|
||||
response.statusCode = statusCode;
|
||||
response.setHeader('content-type', 'application/json; charset=utf-8');
|
||||
response.setHeader('content-length', String(body.length));
|
||||
response.setHeader('cache-control', 'no-store');
|
||||
response.setHeader('x-content-type-options', 'nosniff');
|
||||
if (retryAfterMs !== undefined) {
|
||||
response.setHeader(
|
||||
'retry-after',
|
||||
String(Math.max(1, Math.ceil(retryAfterMs / 1_000))),
|
||||
);
|
||||
}
|
||||
response.end(body);
|
||||
}
|
||||
|
||||
function responseError(error: unknown): HttpRequestError {
|
||||
if (error instanceof HttpRequestError) return error;
|
||||
if (
|
||||
error instanceof ClusterPluginPackageIdentityAssertionAuthenticationError ||
|
||||
error instanceof
|
||||
ClusterPluginPackageManagementTransportAuthenticationError ||
|
||||
error instanceof
|
||||
ClusterWorkerCredentialManagementTransportAuthenticationError ||
|
||||
error instanceof ClusterAutomationManagementTransportAuthenticationError ||
|
||||
error instanceof ClusterApprovalManagementTransportAuthenticationError ||
|
||||
error instanceof
|
||||
ClusterModelProviderCredentialManagementTransportAuthenticationError ||
|
||||
error instanceof ClusterModelProviderCredentialManagementAuthenticationError
|
||||
) {
|
||||
return new HttpRequestError(401, 'authentication_required');
|
||||
}
|
||||
if (
|
||||
error instanceof ClusterPluginPackageManagementTransportRequestError ||
|
||||
error instanceof ClusterWorkerCredentialManagementTransportRequestError ||
|
||||
error instanceof ClusterAutomationManagementTransportRequestError ||
|
||||
error instanceof ClusterApprovalManagementTransportRequestError ||
|
||||
error instanceof ClusterAutomationManagementRequestError ||
|
||||
error instanceof
|
||||
ClusterModelProviderCredentialManagementTransportRequestError ||
|
||||
error instanceof ClusterModelProviderCredentialManagementRequestError ||
|
||||
error instanceof PluginPackageManagementRequestError ||
|
||||
error instanceof WorkerCredentialManagementRequestError
|
||||
) {
|
||||
return new HttpRequestError(400, 'request_invalid');
|
||||
}
|
||||
if (
|
||||
error instanceof PluginPackageManagementAuthorizationError ||
|
||||
error instanceof WorkerCredentialManagementAuthorizationError ||
|
||||
error instanceof ClusterAutomationManagementAuthorizationError ||
|
||||
error instanceof ClusterApprovalManagementTransportAuthorizationError ||
|
||||
error instanceof ClusterModelProviderCredentialManagementAuthorizationError
|
||||
) {
|
||||
return new HttpRequestError(403, 'forbidden');
|
||||
}
|
||||
if (
|
||||
error instanceof PluginPackageManagementConflictError ||
|
||||
error instanceof WorkerCredentialManagementConflictError ||
|
||||
error instanceof ClusterAutomationManagementConflictError ||
|
||||
error instanceof ClusterApprovalManagementTransportConflictError ||
|
||||
error instanceof ClusterModelProviderCredentialManagementConflictError
|
||||
) {
|
||||
return new HttpRequestError(409, 'conflict');
|
||||
}
|
||||
if (error instanceof PluginPackageManagementQuotaExceededError) {
|
||||
return new HttpRequestError(429, 'quota_exceeded', error.retryAfterMs);
|
||||
}
|
||||
if (error instanceof WorkerCredentialManagementQuotaExceededError) {
|
||||
return new HttpRequestError(429, 'quota_exceeded', error.retryAfterMs);
|
||||
}
|
||||
if (
|
||||
error instanceof ClusterModelProviderCredentialManagementQuotaExceededError
|
||||
) {
|
||||
return new HttpRequestError(429, 'quota_exceeded', error.retryAfterMs);
|
||||
}
|
||||
if (
|
||||
error instanceof ClusterApprovalManagementTransportTargetUnavailableError
|
||||
) {
|
||||
return new HttpRequestError(404, 'not_found');
|
||||
}
|
||||
if (
|
||||
error instanceof ClusterPluginPackageIdentityKeysetUnavailableError ||
|
||||
error instanceof ClusterPluginPackageManagementTransportUnavailableError ||
|
||||
error instanceof
|
||||
ClusterWorkerCredentialManagementTransportUnavailableError ||
|
||||
error instanceof ClusterAutomationManagementTransportUnavailableError ||
|
||||
error instanceof ClusterApprovalManagementTransportUnavailableError ||
|
||||
error instanceof ClusterAutomationManagementUnavailableError ||
|
||||
error instanceof
|
||||
ClusterModelProviderCredentialManagementTransportUnavailableError ||
|
||||
error instanceof ClusterModelProviderCredentialManagementUnavailableError ||
|
||||
error instanceof PluginPackageManagementUnavailableError ||
|
||||
error instanceof WorkerCredentialManagementUnavailableError
|
||||
) {
|
||||
return new HttpRequestError(503, 'unavailable');
|
||||
}
|
||||
return new HttpRequestError(500, 'internal_error');
|
||||
}
|
||||
|
||||
async function listen(
|
||||
server: HttpsServer,
|
||||
port: number,
|
||||
host: string,
|
||||
): Promise<void> {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const onError = (error: Error) => {
|
||||
server.removeListener('listening', onListening);
|
||||
reject(error);
|
||||
};
|
||||
const onListening = () => {
|
||||
server.removeListener('error', onError);
|
||||
resolve();
|
||||
};
|
||||
server.once('error', onError);
|
||||
server.once('listening', onListening);
|
||||
server.listen(port, host);
|
||||
});
|
||||
}
|
||||
|
||||
export async function startClusterPluginPackageManagementHttp(
|
||||
options: StartClusterPluginPackageManagementHttpOptions,
|
||||
): Promise<Readonly<ClusterPluginPackageManagementHttpApplication>> {
|
||||
if (
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
Object.keys(options).some(
|
||||
(key) =>
|
||||
![
|
||||
'host',
|
||||
'port',
|
||||
'tls',
|
||||
'transport',
|
||||
'identities',
|
||||
'managementPath',
|
||||
'limits',
|
||||
'now',
|
||||
'createRequestId',
|
||||
'onError',
|
||||
].includes(key),
|
||||
) ||
|
||||
typeof options.host !== 'string' ||
|
||||
options.host.length < 1 ||
|
||||
options.host.length > 255 ||
|
||||
CONTROL_PATTERN.test(options.host) ||
|
||||
!Number.isInteger(options.port) ||
|
||||
options.port < 0 ||
|
||||
options.port > 65_535 ||
|
||||
!options.tls ||
|
||||
typeof options.tls !== 'object' ||
|
||||
Array.isArray(options.tls) ||
|
||||
Object.keys(options.tls).some(
|
||||
(key) =>
|
||||
key !== 'privateKey' &&
|
||||
key !== 'certificate' &&
|
||||
key !== 'clientCertificateAuthority' &&
|
||||
key !== 'clientCertificateRevocationList',
|
||||
) ||
|
||||
!Buffer.isBuffer(options.tls.privateKey) ||
|
||||
options.tls.privateKey.length < 1 ||
|
||||
options.tls.privateKey.length > 256 * 1024 ||
|
||||
!Buffer.isBuffer(options.tls.certificate) ||
|
||||
options.tls.certificate.length < 1 ||
|
||||
options.tls.certificate.length > 256 * 1024 ||
|
||||
(options.tls.clientCertificateAuthority !== undefined &&
|
||||
(!Buffer.isBuffer(options.tls.clientCertificateAuthority) ||
|
||||
options.tls.clientCertificateAuthority.length < 1 ||
|
||||
options.tls.clientCertificateAuthority.length > 256 * 1024)) ||
|
||||
(options.tls.clientCertificateRevocationList !== undefined &&
|
||||
(!Buffer.isBuffer(options.tls.clientCertificateRevocationList) ||
|
||||
options.tls.clientCertificateRevocationList.length < 1 ||
|
||||
options.tls.clientCertificateRevocationList.length > 256 * 1024)) ||
|
||||
(options.tls.clientCertificateAuthority === undefined) !==
|
||||
(options.tls.clientCertificateRevocationList === undefined) ||
|
||||
!options.transport ||
|
||||
typeof options.transport.execute !== 'function' ||
|
||||
!options.identities ||
|
||||
typeof options.identities.bind !== 'function' ||
|
||||
typeof options.identities.reload !== 'function' ||
|
||||
(options.managementPath !== undefined &&
|
||||
!MANAGEMENT_PATHS.has(options.managementPath)) ||
|
||||
(options.now !== undefined && typeof options.now !== 'function') ||
|
||||
(options.createRequestId !== undefined &&
|
||||
typeof options.createRequestId !== 'function') ||
|
||||
(options.onError !== undefined && typeof options.onError !== 'function')
|
||||
) {
|
||||
throw configurationFailure('options are invalid');
|
||||
}
|
||||
const limits = reviewedLimits(options.limits);
|
||||
const managementPath =
|
||||
options.managementPath ?? CLUSTER_PLUGIN_PACKAGE_MANAGEMENT_PATH;
|
||||
const now = options.now ?? Date.now;
|
||||
const createRequestId = options.createRequestId ?? randomUUID;
|
||||
const clientCertificateRequired =
|
||||
options.tls.clientCertificateAuthority !== undefined;
|
||||
const rateLimiter = new BoundedRateLimiter(limits, now);
|
||||
let availability: 'ready' | 'unavailable' | 'stopped' = 'ready';
|
||||
let inFlight = 0;
|
||||
const sockets = new Set<Duplex>();
|
||||
|
||||
let server: HttpsServer;
|
||||
try {
|
||||
server = createServer({
|
||||
key: options.tls.privateKey,
|
||||
cert: options.tls.certificate,
|
||||
minVersion: 'TLSv1.3',
|
||||
maxVersion: 'TLSv1.3',
|
||||
honorCipherOrder: true,
|
||||
...(clientCertificateRequired
|
||||
? {
|
||||
ca: options.tls.clientCertificateAuthority,
|
||||
crl: options.tls.clientCertificateRevocationList,
|
||||
}
|
||||
: {}),
|
||||
requestCert: clientCertificateRequired,
|
||||
// Health probes intentionally remain reachable without a client
|
||||
// certificate. Every non-health route checks TLSSocket.authorized before
|
||||
// reading Authorization or request body bytes.
|
||||
rejectUnauthorized: false,
|
||||
});
|
||||
} finally {
|
||||
options.tls.privateKey.fill(0);
|
||||
}
|
||||
server.maxHeadersCount = 32;
|
||||
server.maxConnections = limits.maxConnections;
|
||||
server.requestTimeout = limits.requestTimeoutMs;
|
||||
server.headersTimeout = Math.min(5_000, limits.requestTimeoutMs);
|
||||
server.keepAliveTimeout = 5_000;
|
||||
server.maxRequestsPerSocket = 100;
|
||||
server.on('connection', (socket) => {
|
||||
sockets.add(socket);
|
||||
socket.once('close', () => sockets.delete(socket));
|
||||
});
|
||||
|
||||
const report = (error: unknown): void => {
|
||||
try {
|
||||
options.onError?.(error);
|
||||
} catch {
|
||||
// Diagnostics must never replace the stable HTTP response.
|
||||
}
|
||||
};
|
||||
|
||||
server.on('checkContinue', (request, response) => {
|
||||
response.setHeader('connection', 'close');
|
||||
response.once('finish', () => request.destroy());
|
||||
writeJson(response, 417, {
|
||||
schemaVersion: 1,
|
||||
error: { code: 'request_invalid' },
|
||||
});
|
||||
});
|
||||
server.on('clientError', (_error, socket) => {
|
||||
if (socket.writable) {
|
||||
socket.end(
|
||||
'HTTP/1.1 400 Bad Request\r\nConnection: close\r\nContent-Length: 0\r\n\r\n',
|
||||
);
|
||||
}
|
||||
});
|
||||
server.on('tlsClientError', () => {
|
||||
// TLS failures are unauthenticated network noise, not diagnostics.
|
||||
});
|
||||
|
||||
server.on('request', (request, response) => {
|
||||
void (async () => {
|
||||
const requestId = createRequestId();
|
||||
if (
|
||||
typeof requestId !== 'string' ||
|
||||
requestId.length < 1 ||
|
||||
requestId.length > 128 ||
|
||||
CONTROL_PATTERN.test(requestId)
|
||||
) {
|
||||
throw new Error('HTTP request id is invalid');
|
||||
}
|
||||
response.setHeader('x-request-id', requestId);
|
||||
const url = request.url;
|
||||
if (request.method === 'GET' && url === '/livez') {
|
||||
writeJson(response, 200, {
|
||||
schemaVersion: 1,
|
||||
status: 'live',
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (request.method === 'GET' && url === '/readyz') {
|
||||
writeJson(response, availability === 'ready' ? 200 : 503, {
|
||||
schemaVersion: 1,
|
||||
status: availability === 'ready' ? 'ready' : 'not_ready',
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (
|
||||
clientCertificateRequired &&
|
||||
!(request.socket as TLSSocket).authorized
|
||||
) {
|
||||
throw new HttpRequestError(401, 'client_certificate_required');
|
||||
}
|
||||
if (request.method !== 'POST' || url !== managementPath) {
|
||||
throw new HttpRequestError(404, 'not_found');
|
||||
}
|
||||
if (availability !== 'ready') {
|
||||
throw new HttpRequestError(503, 'unavailable');
|
||||
}
|
||||
const retryAfterMs = rateLimiter.consume(request.socket.remoteAddress);
|
||||
if (retryAfterMs !== null) {
|
||||
writeJson(
|
||||
response,
|
||||
429,
|
||||
{
|
||||
schemaVersion: 1,
|
||||
requestId,
|
||||
error: { code: 'rate_limited' },
|
||||
},
|
||||
retryAfterMs,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (inFlight >= limits.maxConcurrentRequests) {
|
||||
throw new HttpRequestError(503, 'overloaded');
|
||||
}
|
||||
inFlight += 1;
|
||||
try {
|
||||
const assertion = bearerAssertion(request);
|
||||
const authentication = options.identities.bind(assertion);
|
||||
const principal = await authentication.authenticate();
|
||||
assertRequestHeaders(request, limits.maxBodyBytes);
|
||||
const command = await readJsonBody(request, limits.maxBodyBytes);
|
||||
const result = await options.transport.execute(
|
||||
command,
|
||||
Object.freeze({
|
||||
async authenticate() {
|
||||
return principal;
|
||||
},
|
||||
}),
|
||||
);
|
||||
writeJson(response, 200, {
|
||||
schemaVersion: 1,
|
||||
requestId,
|
||||
result,
|
||||
});
|
||||
} finally {
|
||||
inFlight -= 1;
|
||||
}
|
||||
})().catch((error) => {
|
||||
const mapped = responseError(error);
|
||||
if (mapped.statusCode === 500) report(error);
|
||||
if (!request.destroyed) {
|
||||
response.once('finish', () => request.destroy());
|
||||
}
|
||||
if (!response.headersSent && !response.destroyed) {
|
||||
response.setHeader('connection', 'close');
|
||||
writeJson(
|
||||
response,
|
||||
mapped.statusCode,
|
||||
{
|
||||
schemaVersion: 1,
|
||||
requestId:
|
||||
typeof response.getHeader('x-request-id') === 'string'
|
||||
? response.getHeader('x-request-id')
|
||||
: 'unavailable',
|
||||
error: { code: mapped.responseCode },
|
||||
},
|
||||
mapped.retryAfterMs,
|
||||
);
|
||||
} else if (!response.destroyed) {
|
||||
response.destroy();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
await listen(server, options.port, options.host);
|
||||
} catch (error) {
|
||||
for (const socket of sockets) socket.destroy();
|
||||
throw error;
|
||||
}
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
for (const socket of sockets) socket.destroy();
|
||||
throw new Error('management HTTP server address is unavailable');
|
||||
}
|
||||
const networkAddress = address as AddressInfo;
|
||||
let closePromise: Promise<void> | undefined;
|
||||
|
||||
return Object.freeze({
|
||||
status: 'active' as const,
|
||||
address: Object.freeze({
|
||||
host: networkAddress.address,
|
||||
port: networkAddress.port,
|
||||
}),
|
||||
availabilityStatus: () => availability,
|
||||
withdraw(error?: unknown) {
|
||||
if (availability !== 'ready') return;
|
||||
availability = 'unavailable';
|
||||
if (error !== undefined) report(error);
|
||||
},
|
||||
close(): Promise<void> {
|
||||
if (closePromise) return closePromise;
|
||||
availability = 'stopped';
|
||||
closePromise = new Promise<void>((resolve) => {
|
||||
let settled = false;
|
||||
const finish = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
};
|
||||
const timer = setTimeout(() => {
|
||||
for (const socket of sockets) socket.destroy();
|
||||
finish();
|
||||
}, limits.drainTimeoutMs);
|
||||
server.close(finish);
|
||||
});
|
||||
return closePromise;
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user