mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): add read-only cluster copilot console
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import {
|
||||
lstatSync,
|
||||
readFileSync,
|
||||
realpathSync,
|
||||
type PathLike,
|
||||
} from 'node:fs';
|
||||
import { isAbsolute, relative, resolve, sep } from 'node:path';
|
||||
import { TextDecoder } from 'node:util';
|
||||
|
||||
export interface ClusterCopilotConsoleAssets {
|
||||
readonly html: string;
|
||||
readonly css: string;
|
||||
readonly javascript: string;
|
||||
}
|
||||
|
||||
export class ClusterCopilotConsoleAssetError extends Error {
|
||||
readonly code = 'QL3_CLUSTER_COPILOT_CONSOLE_ASSET_INVALID';
|
||||
|
||||
constructor() {
|
||||
super('Cluster Copilot Console asset is invalid');
|
||||
this.name = 'ClusterCopilotConsoleAssetError';
|
||||
}
|
||||
}
|
||||
|
||||
const ASSETS = Object.freeze([
|
||||
Object.freeze({
|
||||
name: 'index.html',
|
||||
field: 'html',
|
||||
maximumBytes: 32 * 1024,
|
||||
digest: 'f9fa959f30b92c6b000eecb744ce1d0a7fce822c62b3e17dcf10d4d579a072ac',
|
||||
}),
|
||||
Object.freeze({
|
||||
name: 'app.css',
|
||||
field: 'css',
|
||||
maximumBytes: 64 * 1024,
|
||||
digest: '200c3405e1e12329fcfb50509b31b19f1567a91552865f039ce0c2de1530032c',
|
||||
}),
|
||||
Object.freeze({
|
||||
name: 'app.js',
|
||||
field: 'javascript',
|
||||
maximumBytes: 32 * 1024,
|
||||
digest: 'd60913e725e767d9fa2cb65d60c0eae6d75d219f4bec8aad166bed8b6507fe02',
|
||||
}),
|
||||
] as const);
|
||||
|
||||
function invalid(): never {
|
||||
throw new ClusterCopilotConsoleAssetError();
|
||||
}
|
||||
|
||||
function inside(parent: string, candidate: string): boolean {
|
||||
const pathFromParent = relative(parent, candidate);
|
||||
return (
|
||||
pathFromParent !== '' &&
|
||||
pathFromParent !== '..' &&
|
||||
!pathFromParent.startsWith('..' + sep) &&
|
||||
!isAbsolute(pathFromParent)
|
||||
);
|
||||
}
|
||||
|
||||
function readAsset(
|
||||
assetRoot: string,
|
||||
name: string,
|
||||
maximumBytes: number,
|
||||
digest: string,
|
||||
): string {
|
||||
const candidate = resolve(assetRoot, name);
|
||||
const status = lstatSync(candidate, { throwIfNoEntry: false });
|
||||
if (
|
||||
status === undefined ||
|
||||
!status.isFile() ||
|
||||
status.isSymbolicLink() ||
|
||||
status.size < 1 ||
|
||||
status.size > maximumBytes
|
||||
) {
|
||||
return invalid();
|
||||
}
|
||||
const canonicalRoot = realpathSync(assetRoot);
|
||||
const canonicalCandidate = realpathSync(candidate);
|
||||
if (
|
||||
!inside(canonicalRoot, canonicalCandidate) ||
|
||||
canonicalCandidate !== resolve(canonicalRoot, name)
|
||||
) {
|
||||
return invalid();
|
||||
}
|
||||
let bytes: Buffer | undefined;
|
||||
try {
|
||||
bytes = readFileSync(candidate as PathLike);
|
||||
if (
|
||||
bytes.byteLength !== status.size ||
|
||||
createHash('sha256').update(bytes).digest('hex') !== digest ||
|
||||
bytes.includes(0)
|
||||
) {
|
||||
return invalid();
|
||||
}
|
||||
return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
||||
} catch (error) {
|
||||
if (error instanceof ClusterCopilotConsoleAssetError) throw error;
|
||||
return invalid();
|
||||
} finally {
|
||||
bytes?.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
export function loadClusterCopilotConsoleAssets(
|
||||
moduleDirectory: string,
|
||||
): Readonly<ClusterCopilotConsoleAssets> {
|
||||
if (typeof moduleDirectory !== 'string' || !isAbsolute(moduleDirectory)) {
|
||||
return invalid();
|
||||
}
|
||||
const packageRoot = resolve(moduleDirectory, '..', '..');
|
||||
const assetRoot = resolve(packageRoot, 'assets', 'copilot-console');
|
||||
const packageStatus = lstatSync(packageRoot, { throwIfNoEntry: false });
|
||||
const assetStatus = lstatSync(assetRoot, { throwIfNoEntry: false });
|
||||
if (
|
||||
packageStatus === undefined ||
|
||||
!packageStatus.isDirectory() ||
|
||||
packageStatus.isSymbolicLink() ||
|
||||
assetStatus === undefined ||
|
||||
!assetStatus.isDirectory() ||
|
||||
assetStatus.isSymbolicLink() ||
|
||||
realpathSync(assetRoot) !==
|
||||
resolve(realpathSync(packageRoot), 'assets', 'copilot-console')
|
||||
) {
|
||||
return invalid();
|
||||
}
|
||||
const result: Record<string, string> = {};
|
||||
for (const asset of ASSETS) {
|
||||
result[asset.field] = readAsset(
|
||||
assetRoot,
|
||||
asset.name,
|
||||
asset.maximumBytes,
|
||||
asset.digest,
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
html: result.html!,
|
||||
css: result.css!,
|
||||
javascript: result.javascript!,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import {
|
||||
executeClusterCopilotCommand,
|
||||
probeClusterCopilotClientReadiness,
|
||||
validateClusterCopilotClientConfiguration,
|
||||
validateClusterCopilotClientCredentialFile,
|
||||
type ClusterCopilotClientCommand,
|
||||
} from '../copilot-client/client';
|
||||
import { readCanonicalFile } from '../management-support/managementClientConfiguration';
|
||||
import { loadClusterCopilotConsoleAssets } from './assets';
|
||||
import {
|
||||
clusterCopilotConsoleSessionDigest,
|
||||
startClusterCopilotConsoleServer,
|
||||
} from './server';
|
||||
|
||||
const USAGE = [
|
||||
'Usage:',
|
||||
' ql3-copilot-console --config /absolute/client.json --credential /absolute/credential --session /absolute/session [--port=0..65535]',
|
||||
' ql3-copilot-console --check --config /absolute/client.json --credential /absolute/credential --session /absolute/session',
|
||||
'',
|
||||
'The Console binds only 127.0.0.1 and exposes inspect/output reads.',
|
||||
'The browser session key remains in a separate owner-private 0600 file.',
|
||||
].join('\n');
|
||||
|
||||
interface ClusterCopilotConsoleCliArguments {
|
||||
readonly check: boolean;
|
||||
readonly configFile: string;
|
||||
readonly credentialFile: string;
|
||||
readonly sessionFile: string;
|
||||
readonly port: number;
|
||||
}
|
||||
|
||||
const SESSION_TOKEN = /^[A-Za-z0-9_-]{43}$/;
|
||||
const MAXIMUM_SESSION_BYTES = 128;
|
||||
|
||||
function usageFailure(): never {
|
||||
process.stderr.write(USAGE + '\n');
|
||||
process.exit(64);
|
||||
}
|
||||
|
||||
function argumentValue(
|
||||
argv: readonly string[],
|
||||
index: number,
|
||||
name: string,
|
||||
): Readonly<{ value: string; consumed: number }> | null {
|
||||
const current = argv[index];
|
||||
if (current === name) {
|
||||
const next = argv[index + 1];
|
||||
if (typeof next !== 'string' || next === '' || next.startsWith('--')) {
|
||||
return usageFailure();
|
||||
}
|
||||
return Object.freeze({ value: next, consumed: 2 });
|
||||
}
|
||||
const prefix = name + '=';
|
||||
if (current?.startsWith(prefix) && current.length > prefix.length) {
|
||||
return Object.freeze({
|
||||
value: current.slice(prefix.length),
|
||||
consumed: 1,
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parseClusterCopilotConsoleCliArguments(
|
||||
argv: readonly string[],
|
||||
): Readonly<ClusterCopilotConsoleCliArguments> {
|
||||
let check = false;
|
||||
let configFile: string | undefined;
|
||||
let credentialFile: string | undefined;
|
||||
let sessionFile: string | undefined;
|
||||
let port = 0;
|
||||
let portSeen = false;
|
||||
for (let index = 0; index < argv.length; ) {
|
||||
const current = argv[index];
|
||||
if (current === '--check' && !check) {
|
||||
check = true;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
const config = argumentValue(argv, index, '--config');
|
||||
if (config) {
|
||||
if (configFile !== undefined) return usageFailure();
|
||||
configFile = config.value;
|
||||
index += config.consumed;
|
||||
continue;
|
||||
}
|
||||
const credential = argumentValue(argv, index, '--credential');
|
||||
if (credential) {
|
||||
if (credentialFile !== undefined) return usageFailure();
|
||||
credentialFile = credential.value;
|
||||
index += credential.consumed;
|
||||
continue;
|
||||
}
|
||||
const session = argumentValue(argv, index, '--session');
|
||||
if (session) {
|
||||
if (sessionFile !== undefined) return usageFailure();
|
||||
sessionFile = session.value;
|
||||
index += session.consumed;
|
||||
continue;
|
||||
}
|
||||
const portArgument = argumentValue(argv, index, '--port');
|
||||
if (portArgument) {
|
||||
if (portSeen || !/^(?:0|[1-9][0-9]{0,4})$/.test(portArgument.value)) {
|
||||
return usageFailure();
|
||||
}
|
||||
portSeen = true;
|
||||
port = Number(portArgument.value);
|
||||
if (
|
||||
!Number.isSafeInteger(port) ||
|
||||
(port !== 0 && (port < 1_024 || port > 65_535))
|
||||
) {
|
||||
return usageFailure();
|
||||
}
|
||||
index += portArgument.consumed;
|
||||
continue;
|
||||
}
|
||||
return usageFailure();
|
||||
}
|
||||
if (
|
||||
configFile === undefined ||
|
||||
credentialFile === undefined ||
|
||||
sessionFile === undefined ||
|
||||
(check && port !== 0)
|
||||
) {
|
||||
return usageFailure();
|
||||
}
|
||||
return Object.freeze({
|
||||
check,
|
||||
configFile,
|
||||
credentialFile,
|
||||
sessionFile,
|
||||
port,
|
||||
});
|
||||
}
|
||||
|
||||
function readSessionDigest(sessionFile: string): Buffer {
|
||||
let bytes: Buffer | undefined;
|
||||
try {
|
||||
bytes = readCanonicalFile(
|
||||
sessionFile,
|
||||
MAXIMUM_SESSION_BYTES,
|
||||
'private',
|
||||
);
|
||||
if (
|
||||
bytes.some((byte) => byte > 0x7f) ||
|
||||
!SESSION_TOKEN.test(bytes.toString('ascii'))
|
||||
) {
|
||||
throw new Error('invalid session token');
|
||||
}
|
||||
return clusterCopilotConsoleSessionDigest(bytes.toString('ascii'));
|
||||
} finally {
|
||||
bytes?.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
if (
|
||||
process.argv.length === 3 &&
|
||||
(process.argv[2] === '--help' || process.argv[2] === '-h')
|
||||
) {
|
||||
process.stdout.write(USAGE + '\n');
|
||||
return;
|
||||
}
|
||||
const parsed = parseClusterCopilotConsoleCliArguments(process.argv.slice(2));
|
||||
const assets = loadClusterCopilotConsoleAssets(__dirname);
|
||||
validateClusterCopilotClientConfiguration(parsed.configFile);
|
||||
validateClusterCopilotClientCredentialFile(parsed.credentialFile);
|
||||
const sessionDigest = readSessionDigest(parsed.sessionFile);
|
||||
if (parsed.check) {
|
||||
try {
|
||||
const readiness = await probeClusterCopilotClientReadiness(
|
||||
parsed.configFile,
|
||||
);
|
||||
process.stdout.write(
|
||||
JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-cluster-copilot-console',
|
||||
event: 'preflight_checked',
|
||||
ready: readiness.ready,
|
||||
listenAddress: '127.0.0.1',
|
||||
browserCredential: 'forbidden',
|
||||
clusterCredential: 'server_only',
|
||||
operations: ['inspect', 'output'],
|
||||
mutation: false,
|
||||
}) + '\n',
|
||||
);
|
||||
if (!readiness.ready) process.exitCode = 69;
|
||||
return;
|
||||
} finally {
|
||||
sessionDigest.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
const server = await startClusterCopilotConsoleServer({
|
||||
assets,
|
||||
executor: Object.freeze({
|
||||
execute(command: Readonly<ClusterCopilotClientCommand>) {
|
||||
return executeClusterCopilotCommand({
|
||||
configFile: parsed.configFile,
|
||||
credentialFile: parsed.credentialFile,
|
||||
command,
|
||||
});
|
||||
},
|
||||
}),
|
||||
port: parsed.port,
|
||||
sessionDigest,
|
||||
});
|
||||
sessionDigest.fill(0);
|
||||
process.stdout.write(
|
||||
JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-cluster-copilot-console',
|
||||
event: 'started',
|
||||
origin: server.origin,
|
||||
listenAddress: '127.0.0.1',
|
||||
browserCredential: 'forbidden',
|
||||
clusterCredential: 'server_only',
|
||||
operations: ['inspect', 'output'],
|
||||
mutation: false,
|
||||
}) + '\n',
|
||||
);
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
let stopping = false;
|
||||
const stop = (): void => {
|
||||
if (stopping) return;
|
||||
stopping = true;
|
||||
void server.close().finally(resolve);
|
||||
};
|
||||
process.once('SIGINT', stop);
|
||||
process.once('SIGTERM', stop);
|
||||
});
|
||||
}
|
||||
|
||||
void main().catch(() => {
|
||||
process.stderr.write(
|
||||
JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-cluster-copilot-console',
|
||||
event: 'process_failed',
|
||||
}) + '\n',
|
||||
);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import {
|
||||
CLUSTER_COPILOT_CLIENT_COMMAND_SCHEMA,
|
||||
type ClusterCopilotClientCommand,
|
||||
} from '../copilot-client/contracts';
|
||||
|
||||
export const CLUSTER_COPILOT_CONSOLE_READ_REQUEST_SCHEMA =
|
||||
'qinglong/cluster-copilot-console-read-request@v1' as const;
|
||||
export const CLUSTER_COPILOT_CONSOLE_READ_RESPONSE_SCHEMA =
|
||||
'qinglong/cluster-copilot-console-read-response@v1' as const;
|
||||
|
||||
export type ClusterCopilotConsoleReadOperation = 'inspect' | 'output';
|
||||
|
||||
export interface ClusterCopilotConsoleReadRequest {
|
||||
readonly schema: typeof CLUSTER_COPILOT_CONSOLE_READ_REQUEST_SCHEMA;
|
||||
readonly operation: ClusterCopilotConsoleReadOperation;
|
||||
readonly projectId: string;
|
||||
readonly sourceRunId: string;
|
||||
readonly requestId: string;
|
||||
}
|
||||
|
||||
export class InvalidClusterCopilotConsoleReadRequestError extends TypeError {
|
||||
readonly code = 'QL3_CLUSTER_COPILOT_CONSOLE_READ_REQUEST_INVALID';
|
||||
|
||||
constructor() {
|
||||
super('Cluster Copilot Console read request is invalid');
|
||||
this.name = 'InvalidClusterCopilotConsoleReadRequestError';
|
||||
}
|
||||
}
|
||||
|
||||
const IDENTITY = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const RUN_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,35}$/;
|
||||
|
||||
function invalid(): never {
|
||||
throw new InvalidClusterCopilotConsoleReadRequestError();
|
||||
}
|
||||
|
||||
export function normalizeClusterCopilotConsoleReadRequest(
|
||||
value: unknown,
|
||||
): Readonly<ClusterCopilotConsoleReadRequest> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return invalid();
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
const keys = Object.keys(record).sort();
|
||||
const expected = [
|
||||
'operation',
|
||||
'projectId',
|
||||
'requestId',
|
||||
'schema',
|
||||
'sourceRunId',
|
||||
];
|
||||
if (
|
||||
keys.length !== expected.length ||
|
||||
keys.some((key, index) => key !== expected[index]) ||
|
||||
record.schema !== CLUSTER_COPILOT_CONSOLE_READ_REQUEST_SCHEMA ||
|
||||
(record.operation !== 'inspect' && record.operation !== 'output') ||
|
||||
typeof record.projectId !== 'string' ||
|
||||
!IDENTITY.test(record.projectId) ||
|
||||
typeof record.sourceRunId !== 'string' ||
|
||||
!RUN_ID.test(record.sourceRunId) ||
|
||||
typeof record.requestId !== 'string' ||
|
||||
!IDENTITY.test(record.requestId)
|
||||
) {
|
||||
return invalid();
|
||||
}
|
||||
return Object.freeze({
|
||||
schema: CLUSTER_COPILOT_CONSOLE_READ_REQUEST_SCHEMA,
|
||||
operation: record.operation,
|
||||
projectId: record.projectId,
|
||||
sourceRunId: record.sourceRunId,
|
||||
requestId: record.requestId,
|
||||
});
|
||||
}
|
||||
|
||||
export function clusterCopilotConsoleClientCommand(
|
||||
request: Readonly<ClusterCopilotConsoleReadRequest>,
|
||||
): Readonly<ClusterCopilotClientCommand> {
|
||||
const normalized = normalizeClusterCopilotConsoleReadRequest(request);
|
||||
return Object.freeze({
|
||||
schema: CLUSTER_COPILOT_CLIENT_COMMAND_SCHEMA,
|
||||
operation: normalized.operation,
|
||||
projectId: normalized.projectId,
|
||||
sourceRunId: normalized.sourceRunId,
|
||||
requestId: normalized.requestId,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,498 @@
|
||||
import { createHash, timingSafeEqual } from 'node:crypto';
|
||||
import {
|
||||
createServer,
|
||||
type IncomingMessage,
|
||||
type ServerResponse,
|
||||
} from 'node:http';
|
||||
|
||||
import {
|
||||
ClusterCopilotClientConfigurationError,
|
||||
ClusterCopilotClientRemoteError,
|
||||
ClusterCopilotClientRequestError,
|
||||
type ClusterCopilotClientCommand,
|
||||
type ClusterCopilotClientResult,
|
||||
} from '../copilot-client/client';
|
||||
import {
|
||||
type ClusterCopilotConsoleAssets,
|
||||
} from './assets';
|
||||
import {
|
||||
CLUSTER_COPILOT_CONSOLE_READ_RESPONSE_SCHEMA,
|
||||
InvalidClusterCopilotConsoleReadRequestError,
|
||||
clusterCopilotConsoleClientCommand,
|
||||
normalizeClusterCopilotConsoleReadRequest,
|
||||
} from './contracts';
|
||||
|
||||
export const CLUSTER_COPILOT_CONSOLE_LIMITS = Object.freeze({
|
||||
maximumBodyBytes: 4 * 1024,
|
||||
maximumResponseBytes: 2 * 1024 * 1024 + 4 * 1024,
|
||||
maximumConcurrentRequests: 2,
|
||||
maximumConnections: 16,
|
||||
shutdownTimeoutMs: 2_000,
|
||||
});
|
||||
|
||||
export interface ClusterCopilotConsoleExecutor {
|
||||
execute(
|
||||
command: Readonly<ClusterCopilotClientCommand>,
|
||||
): Promise<Readonly<ClusterCopilotClientResult>>;
|
||||
}
|
||||
|
||||
export interface ClusterCopilotConsoleServerOptions {
|
||||
readonly assets: Readonly<ClusterCopilotConsoleAssets>;
|
||||
readonly executor: ClusterCopilotConsoleExecutor;
|
||||
readonly port: number;
|
||||
readonly sessionDigest: Buffer;
|
||||
}
|
||||
|
||||
export interface ClusterCopilotConsoleServer {
|
||||
readonly origin: string;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export class ClusterCopilotConsoleConfigurationError extends TypeError {
|
||||
readonly code = 'QL3_CLUSTER_COPILOT_CONSOLE_CONFIG_INVALID';
|
||||
|
||||
constructor() {
|
||||
super('Cluster Copilot Console configuration is invalid');
|
||||
this.name = 'ClusterCopilotConsoleConfigurationError';
|
||||
}
|
||||
}
|
||||
|
||||
const SESSION_TOKEN = /^[A-Za-z0-9_-]{43}$/;
|
||||
const SESSION_DIGEST_DOMAIN = Buffer.from(
|
||||
'qinglong-cluster-copilot-console-session-v1\0',
|
||||
'utf8',
|
||||
);
|
||||
const CONTENT_SECURITY_POLICY = [
|
||||
"default-src 'none'",
|
||||
"base-uri 'none'",
|
||||
"connect-src 'self'",
|
||||
"form-action 'self'",
|
||||
"frame-ancestors 'none'",
|
||||
"script-src 'self'",
|
||||
"style-src 'self'",
|
||||
"img-src 'none'",
|
||||
"font-src 'none'",
|
||||
"object-src 'none'",
|
||||
"media-src 'none'",
|
||||
"manifest-src 'none'",
|
||||
"worker-src 'none'",
|
||||
].join('; ');
|
||||
|
||||
function invalid(): never {
|
||||
throw new ClusterCopilotConsoleConfigurationError();
|
||||
}
|
||||
|
||||
function exactObject(
|
||||
value: unknown,
|
||||
keys: readonly string[],
|
||||
): Record<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return invalid();
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
const actual = Object.keys(record).sort();
|
||||
const expected = [...keys].sort();
|
||||
if (
|
||||
actual.length !== expected.length ||
|
||||
actual.some((key, index) => key !== expected[index])
|
||||
) {
|
||||
return invalid();
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
export function clusterCopilotConsoleSessionDigest(value: string): Buffer {
|
||||
if (typeof value !== 'string' || !SESSION_TOKEN.test(value)) {
|
||||
return invalid();
|
||||
}
|
||||
const decoded = Buffer.from(value, 'base64url');
|
||||
if (
|
||||
decoded.byteLength !== 32 ||
|
||||
decoded.toString('base64url') !== value
|
||||
) {
|
||||
decoded.fill(0);
|
||||
return invalid();
|
||||
}
|
||||
decoded.fill(0);
|
||||
return createHash('sha256')
|
||||
.update(SESSION_DIGEST_DOMAIN)
|
||||
.update(value, 'ascii')
|
||||
.digest();
|
||||
}
|
||||
|
||||
function securityHeaders(contentType: string): Readonly<Record<string, string>> {
|
||||
return Object.freeze({
|
||||
'cache-control': 'no-store',
|
||||
'content-security-policy': CONTENT_SECURITY_POLICY,
|
||||
'content-type': contentType,
|
||||
'cross-origin-opener-policy': 'same-origin',
|
||||
'cross-origin-resource-policy': 'same-origin',
|
||||
'origin-agent-cluster': '?1',
|
||||
'permissions-policy':
|
||||
'camera=(), display-capture=(), geolocation=(), microphone=(), payment=(), usb=()',
|
||||
'referrer-policy': 'no-referrer',
|
||||
'x-content-type-options': 'nosniff',
|
||||
'x-frame-options': 'DENY',
|
||||
});
|
||||
}
|
||||
|
||||
function send(
|
||||
response: ServerResponse,
|
||||
statusCode: number,
|
||||
contentType: string,
|
||||
body: string,
|
||||
extraHeaders: Readonly<Record<string, string>> = {},
|
||||
): void {
|
||||
const bytes = Buffer.from(body, 'utf8');
|
||||
response.writeHead(statusCode, {
|
||||
...securityHeaders(contentType),
|
||||
...extraHeaders,
|
||||
connection: 'close',
|
||||
'content-length': String(bytes.byteLength),
|
||||
});
|
||||
response.end(bytes, () => bytes.fill(0));
|
||||
}
|
||||
|
||||
function sendJson(
|
||||
response: ServerResponse,
|
||||
statusCode: number,
|
||||
body: Readonly<Record<string, unknown>>,
|
||||
extraHeaders: Readonly<Record<string, string>> = {},
|
||||
): void {
|
||||
send(
|
||||
response,
|
||||
statusCode,
|
||||
'application/json; charset=utf-8',
|
||||
JSON.stringify(body),
|
||||
extraHeaders,
|
||||
);
|
||||
}
|
||||
|
||||
function headerCount(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 targetPath(request: IncomingMessage): 'inspect' | 'output' | null {
|
||||
if (request.method !== 'POST') return null;
|
||||
if (request.url === '/api/v1/copilot/inspect') return 'inspect';
|
||||
if (request.url === '/api/v1/copilot/output') return 'output';
|
||||
return null;
|
||||
}
|
||||
|
||||
function authorize(
|
||||
request: IncomingMessage,
|
||||
expectedOrigin: string,
|
||||
sessionDigest: Buffer,
|
||||
): boolean {
|
||||
if (
|
||||
headerCount(request, 'authorization') !== 1 ||
|
||||
headerCount(request, 'origin') !== 1 ||
|
||||
request.headers.origin !== expectedOrigin ||
|
||||
request.headers.host !== expectedOrigin.slice('http://'.length)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const authorization = request.headers.authorization;
|
||||
if (
|
||||
typeof authorization !== 'string' ||
|
||||
!authorization.startsWith('QL3-Console ')
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
let candidate: Buffer | undefined;
|
||||
try {
|
||||
candidate = clusterCopilotConsoleSessionDigest(
|
||||
authorization.slice('QL3-Console '.length),
|
||||
);
|
||||
return timingSafeEqual(candidate, sessionDigest);
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
candidate?.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
async function readJsonBody(request: IncomingMessage): Promise<unknown> {
|
||||
if (
|
||||
headerCount(request, 'content-type') !== 1 ||
|
||||
headerCount(request, 'content-length') !== 1 ||
|
||||
request.headers['content-type'] !== 'application/json; charset=utf-8' ||
|
||||
request.headers['content-encoding'] !== undefined ||
|
||||
request.headers['transfer-encoding'] !== undefined ||
|
||||
typeof request.headers['content-length'] !== 'string' ||
|
||||
!/^[1-9][0-9]*$/.test(request.headers['content-length'])
|
||||
) {
|
||||
throw new InvalidClusterCopilotConsoleReadRequestError();
|
||||
}
|
||||
const expectedLength = Number(request.headers['content-length']);
|
||||
if (
|
||||
!Number.isSafeInteger(expectedLength) ||
|
||||
expectedLength < 2 ||
|
||||
expectedLength > CLUSTER_COPILOT_CONSOLE_LIMITS.maximumBodyBytes
|
||||
) {
|
||||
throw new InvalidClusterCopilotConsoleReadRequestError();
|
||||
}
|
||||
const chunks: Buffer[] = [];
|
||||
let length = 0;
|
||||
try {
|
||||
for await (const chunk of request) {
|
||||
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||
length += bytes.byteLength;
|
||||
if (
|
||||
length > expectedLength ||
|
||||
length > CLUSTER_COPILOT_CONSOLE_LIMITS.maximumBodyBytes
|
||||
) {
|
||||
throw new InvalidClusterCopilotConsoleReadRequestError();
|
||||
}
|
||||
chunks.push(bytes);
|
||||
}
|
||||
if (request.aborted || length !== expectedLength) {
|
||||
throw new InvalidClusterCopilotConsoleReadRequestError();
|
||||
}
|
||||
const body = Buffer.concat(chunks, length);
|
||||
try {
|
||||
return JSON.parse(body.toString('utf8'));
|
||||
} finally {
|
||||
body.fill(0);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidClusterCopilotConsoleReadRequestError) {
|
||||
throw error;
|
||||
}
|
||||
throw new InvalidClusterCopilotConsoleReadRequestError();
|
||||
} finally {
|
||||
for (const chunk of chunks) chunk.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
function remoteFailure(
|
||||
response: ServerResponse,
|
||||
error: ClusterCopilotClientRemoteError,
|
||||
): void {
|
||||
const statusCode =
|
||||
error.statusCode === 404
|
||||
? 404
|
||||
: error.statusCode === 429
|
||||
? 429
|
||||
: 502;
|
||||
sendJson(
|
||||
response,
|
||||
statusCode,
|
||||
Object.freeze({
|
||||
schema: CLUSTER_COPILOT_CONSOLE_READ_RESPONSE_SCHEMA,
|
||||
code: error.responseCode,
|
||||
requestId: error.requestId,
|
||||
retryAfterSeconds: error.retryAfterSeconds,
|
||||
}),
|
||||
error.retryAfterSeconds === null
|
||||
? {}
|
||||
: { 'retry-after': String(error.retryAfterSeconds) },
|
||||
);
|
||||
}
|
||||
|
||||
export async function startClusterCopilotConsoleServer(
|
||||
options: ClusterCopilotConsoleServerOptions,
|
||||
): Promise<Readonly<ClusterCopilotConsoleServer>> {
|
||||
const record = exactObject(options, [
|
||||
'assets',
|
||||
'executor',
|
||||
'port',
|
||||
'sessionDigest',
|
||||
]);
|
||||
const assets = exactObject(record.assets, ['css', 'html', 'javascript']);
|
||||
if (
|
||||
typeof assets.html !== 'string' ||
|
||||
assets.html.length < 1 ||
|
||||
typeof assets.css !== 'string' ||
|
||||
assets.css.length < 1 ||
|
||||
typeof assets.javascript !== 'string' ||
|
||||
assets.javascript.length < 1 ||
|
||||
!record.executor ||
|
||||
typeof (record.executor as ClusterCopilotConsoleExecutor).execute !==
|
||||
'function' ||
|
||||
!Number.isSafeInteger(record.port) ||
|
||||
((record.port as number) !== 0 &&
|
||||
((record.port as number) < 1_024 || (record.port as number) > 65_535)) ||
|
||||
!Buffer.isBuffer(record.sessionDigest) ||
|
||||
(record.sessionDigest as Buffer).byteLength !== 32
|
||||
) {
|
||||
return invalid();
|
||||
}
|
||||
const sessionDigest = Buffer.from(record.sessionDigest as Buffer);
|
||||
const executor = record.executor as ClusterCopilotConsoleExecutor;
|
||||
let expectedOrigin = '';
|
||||
let inFlight = 0;
|
||||
let closed = false;
|
||||
|
||||
const server = createServer(async (request, response) => {
|
||||
response.shouldKeepAlive = false;
|
||||
const hostMatches =
|
||||
expectedOrigin !== '' &&
|
||||
request.headers.host === expectedOrigin.slice('http://'.length);
|
||||
if (request.method === 'GET' && hostMatches) {
|
||||
if (request.url === '/') {
|
||||
send(response, 200, 'text/html; charset=utf-8', assets.html as string);
|
||||
return;
|
||||
}
|
||||
if (request.url === '/app.css') {
|
||||
send(response, 200, 'text/css; charset=utf-8', assets.css as string);
|
||||
return;
|
||||
}
|
||||
if (request.url === '/app.js') {
|
||||
send(
|
||||
response,
|
||||
200,
|
||||
'text/javascript; charset=utf-8',
|
||||
assets.javascript as string,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const operation = targetPath(request);
|
||||
if (
|
||||
!hostMatches ||
|
||||
operation === null ||
|
||||
!authorize(request, expectedOrigin, sessionDigest)
|
||||
) {
|
||||
sendJson(response, 404, Object.freeze({ code: 'not_found' }));
|
||||
request.resume();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
inFlight >= CLUSTER_COPILOT_CONSOLE_LIMITS.maximumConcurrentRequests
|
||||
) {
|
||||
sendJson(
|
||||
response,
|
||||
429,
|
||||
Object.freeze({
|
||||
schema: CLUSTER_COPILOT_CONSOLE_READ_RESPONSE_SCHEMA,
|
||||
code: 'cluster_copilot_console_busy',
|
||||
}),
|
||||
{ 'retry-after': '1' },
|
||||
);
|
||||
request.resume();
|
||||
return;
|
||||
}
|
||||
|
||||
inFlight += 1;
|
||||
try {
|
||||
const body = await readJsonBody(request);
|
||||
const normalized = normalizeClusterCopilotConsoleReadRequest(body);
|
||||
if (normalized.operation !== operation) {
|
||||
throw new InvalidClusterCopilotConsoleReadRequestError();
|
||||
}
|
||||
const result = await executor.execute(
|
||||
clusterCopilotConsoleClientCommand(normalized),
|
||||
);
|
||||
const envelope = Object.freeze({
|
||||
schema: CLUSTER_COPILOT_CONSOLE_READ_RESPONSE_SCHEMA,
|
||||
operation,
|
||||
requestId: result.requestId,
|
||||
result,
|
||||
});
|
||||
const encoded = JSON.stringify(envelope);
|
||||
if (
|
||||
Buffer.byteLength(encoded, 'utf8') >
|
||||
CLUSTER_COPILOT_CONSOLE_LIMITS.maximumResponseBytes
|
||||
) {
|
||||
throw new ClusterCopilotClientRequestError();
|
||||
}
|
||||
send(
|
||||
response,
|
||||
200,
|
||||
'application/json; charset=utf-8',
|
||||
encoded,
|
||||
);
|
||||
} catch (error) {
|
||||
if (response.headersSent) {
|
||||
response.destroy();
|
||||
} else if (
|
||||
error instanceof InvalidClusterCopilotConsoleReadRequestError
|
||||
) {
|
||||
sendJson(
|
||||
response,
|
||||
400,
|
||||
Object.freeze({
|
||||
schema: CLUSTER_COPILOT_CONSOLE_READ_RESPONSE_SCHEMA,
|
||||
code: 'invalid_cluster_copilot_console_read_request',
|
||||
}),
|
||||
);
|
||||
} else if (error instanceof ClusterCopilotClientRemoteError) {
|
||||
remoteFailure(response, error);
|
||||
} else if (
|
||||
error instanceof ClusterCopilotClientConfigurationError ||
|
||||
error instanceof ClusterCopilotClientRequestError
|
||||
) {
|
||||
sendJson(
|
||||
response,
|
||||
503,
|
||||
Object.freeze({
|
||||
schema: CLUSTER_COPILOT_CONSOLE_READ_RESPONSE_SCHEMA,
|
||||
code: 'cluster_copilot_console_upstream_unavailable',
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
sendJson(
|
||||
response,
|
||||
503,
|
||||
Object.freeze({
|
||||
schema: CLUSTER_COPILOT_CONSOLE_READ_RESPONSE_SCHEMA,
|
||||
code: 'cluster_copilot_console_unavailable',
|
||||
}),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
inFlight -= 1;
|
||||
}
|
||||
});
|
||||
|
||||
server.maxConnections = CLUSTER_COPILOT_CONSOLE_LIMITS.maximumConnections;
|
||||
server.headersTimeout = 5_000;
|
||||
server.requestTimeout = 5_000;
|
||||
server.keepAliveTimeout = 1;
|
||||
server.maxRequestsPerSocket = 1;
|
||||
server.on('clientError', (_error, socket) => socket.destroy());
|
||||
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(record.port as number, '127.0.0.1', () => {
|
||||
server.off('error', reject);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') return invalid();
|
||||
expectedOrigin = 'http://127.0.0.1:' + String(address.port);
|
||||
} catch (error) {
|
||||
sessionDigest.fill(0);
|
||||
server.closeAllConnections();
|
||||
if (error instanceof ClusterCopilotConsoleConfigurationError) throw error;
|
||||
throw new ClusterCopilotConsoleConfigurationError();
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
origin: expectedOrigin,
|
||||
async close(): Promise<void> {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
await new Promise<void>((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
server.closeAllConnections();
|
||||
}, CLUSTER_COPILOT_CONSOLE_LIMITS.shutdownTimeoutMs);
|
||||
timeout.unref();
|
||||
server.close(() => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
});
|
||||
server.closeIdleConnections();
|
||||
});
|
||||
sessionDigest.fill(0);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -46,6 +46,12 @@ export const QINGLONG3_CLUSTER_PRODUCT_COMMANDS: readonly QingLong3ClusterProduc
|
||||
target: 'copilot-mcp/cli.js',
|
||||
description: 'serve the bounded Cluster Copilot MCP over stdio',
|
||||
}),
|
||||
Object.freeze({
|
||||
name: 'copilot-console',
|
||||
binary: 'ql3-copilot-console',
|
||||
target: 'copilot-console/cli.js',
|
||||
description: 'open the loopback-only read-only Copilot Console',
|
||||
}),
|
||||
Object.freeze({
|
||||
name: 'package',
|
||||
binary: 'ql3-plugin-package-client',
|
||||
@@ -192,7 +198,7 @@ export function qingLong3ClusterProductHelp(): string {
|
||||
'',
|
||||
'Use `ql3-cluster-admin <command> --help` for command-specific usage.',
|
||||
'Use `--context=/absolute/operator-context.json` only with remote client commands.',
|
||||
'Keep the MCP config explicit; it contains stable paths to a separately rotated credential.',
|
||||
'Keep MCP and Console authority explicit; neither belongs in operator context.',
|
||||
'Command and short-lived assertion files always remain explicit per invocation.',
|
||||
'Server, migration, recovery, executor and key-custody authorities remain isolated.',
|
||||
].join('\n');
|
||||
|
||||
Reference in New Issue
Block a user