mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-22 19:29:13 +08:00
feat(ql3): add bounded local web console
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { lstatSync, readFileSync, realpathSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const MAX_ASSET_BYTES = 96 * 1_024;
|
||||
const MAX_TOTAL_BYTES = 192 * 1_024;
|
||||
|
||||
const DEFINITIONS = Object.freeze([
|
||||
Object.freeze({
|
||||
requestPath: '/',
|
||||
file: 'index.html',
|
||||
contentType: 'text/html; charset=utf-8',
|
||||
}),
|
||||
Object.freeze({
|
||||
requestPath: '/console.css',
|
||||
file: 'console.css',
|
||||
contentType: 'text/css; charset=utf-8',
|
||||
}),
|
||||
Object.freeze({
|
||||
requestPath: '/console.js',
|
||||
file: 'console.js',
|
||||
contentType: 'text/javascript; charset=utf-8',
|
||||
}),
|
||||
]);
|
||||
|
||||
export interface LocalConsoleAsset {
|
||||
readonly contentType: string;
|
||||
readonly etag: string;
|
||||
readonly body: Buffer;
|
||||
}
|
||||
|
||||
export type LocalConsoleAssets = ReadonlyMap<string, LocalConsoleAsset>;
|
||||
|
||||
export class LocalConsoleAssetError extends Error {
|
||||
readonly code = 'QL3_LOCAL_CONSOLE_ASSET_INVALID';
|
||||
|
||||
constructor(message: string, options?: ErrorOptions) {
|
||||
super(`Local Console asset is invalid: ${message}`, options);
|
||||
this.name = 'LocalConsoleAssetError';
|
||||
}
|
||||
}
|
||||
|
||||
function loadAsset(
|
||||
root: string,
|
||||
definition: (typeof DEFINITIONS)[number],
|
||||
): Readonly<LocalConsoleAsset> {
|
||||
const filePath = path.join(root, definition.file);
|
||||
let stat;
|
||||
let body: Buffer;
|
||||
try {
|
||||
stat = lstatSync(filePath);
|
||||
if (
|
||||
!stat.isFile() ||
|
||||
stat.isSymbolicLink() ||
|
||||
stat.size < 2 ||
|
||||
stat.size > MAX_ASSET_BYTES ||
|
||||
realpathSync(filePath) !== filePath
|
||||
) {
|
||||
throw new TypeError('asset identity is incompatible');
|
||||
}
|
||||
body = readFileSync(filePath);
|
||||
} catch (error) {
|
||||
throw new LocalConsoleAssetError(definition.file, { cause: error });
|
||||
}
|
||||
if (body.byteLength !== stat.size) {
|
||||
throw new LocalConsoleAssetError(
|
||||
`${definition.file} changed while loading`,
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
contentType: definition.contentType,
|
||||
etag: `"${createHash('sha256').update(body).digest('hex')}"`,
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
export function loadLocalConsoleAssets(): LocalConsoleAssets {
|
||||
const root = path.resolve(__dirname, '../../assets/console');
|
||||
let canonicalRoot: string;
|
||||
try {
|
||||
const stat = lstatSync(root);
|
||||
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new TypeError();
|
||||
canonicalRoot = realpathSync(root);
|
||||
} catch (error) {
|
||||
throw new LocalConsoleAssetError('asset root', { cause: error });
|
||||
}
|
||||
const assets = new Map<string, Readonly<LocalConsoleAsset>>();
|
||||
let totalBytes = 0;
|
||||
for (const definition of DEFINITIONS) {
|
||||
const asset = loadAsset(canonicalRoot, definition);
|
||||
totalBytes += asset.body.byteLength;
|
||||
if (totalBytes > MAX_TOTAL_BYTES) {
|
||||
throw new LocalConsoleAssetError('asset set exceeds its byte budget');
|
||||
}
|
||||
assets.set(definition.requestPath, asset);
|
||||
}
|
||||
return assets;
|
||||
}
|
||||
@@ -13,6 +13,10 @@ import type { BoundedRunListInput } from '@qinglong/runtime-core/bounded-run-lis
|
||||
import type { BoundedRunEventListInput } from '@qinglong/runtime-core/bounded-run-event-list-projection';
|
||||
import type { BoundedRunStepListInput } from '@qinglong/runtime-core/bounded-run-step-list-projection';
|
||||
import type { BoundedTaskListInput } from '@qinglong/runtime-core/bounded-task-list-projection';
|
||||
import {
|
||||
loadLocalConsoleAssets,
|
||||
type LocalConsoleAsset,
|
||||
} from '../console/localConsoleAssets';
|
||||
import type { LocalApiResponse } from './contract';
|
||||
|
||||
const MAX_HEADER_BYTES = 8 * 1_024;
|
||||
@@ -38,6 +42,8 @@ const TASK_START_ROUTE_PATTERN =
|
||||
/^\/api\/v3\/projects\/([A-Za-z0-9][A-Za-z0-9._:-]{0,127})\/tasks\/([A-Za-z0-9][A-Za-z0-9._:-]{0,127})\/runs$/;
|
||||
const RUN_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const TASK_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const LOCAL_CONSOLE_CONTENT_SECURITY_POLICY =
|
||||
"default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'";
|
||||
|
||||
type LocalApiRouteResolution =
|
||||
| LocalApiAdmissionOperation
|
||||
@@ -559,6 +565,45 @@ function send(
|
||||
response.end(body);
|
||||
}
|
||||
|
||||
function sendConsoleAsset(
|
||||
response: ServerResponse,
|
||||
requestId: string,
|
||||
asset: Readonly<LocalConsoleAsset>,
|
||||
): void {
|
||||
if (response.destroyed || response.headersSent) return;
|
||||
response.statusCode = 200;
|
||||
response.setHeader('content-type', asset.contentType);
|
||||
response.setHeader('cache-control', 'no-store');
|
||||
response.setHeader(
|
||||
'content-security-policy',
|
||||
LOCAL_CONSOLE_CONTENT_SECURITY_POLICY,
|
||||
);
|
||||
response.setHeader('cross-origin-opener-policy', 'same-origin');
|
||||
response.setHeader('cross-origin-resource-policy', 'same-origin');
|
||||
response.setHeader(
|
||||
'permissions-policy',
|
||||
'camera=(), geolocation=(), microphone=()',
|
||||
);
|
||||
response.setHeader('referrer-policy', 'no-referrer');
|
||||
response.setHeader('x-content-type-options', 'nosniff');
|
||||
response.setHeader('x-frame-options', 'DENY');
|
||||
response.setHeader('x-request-id', requestId);
|
||||
response.setHeader('etag', asset.etag);
|
||||
response.setHeader('content-length', asset.body.byteLength);
|
||||
response.end(asset.body);
|
||||
}
|
||||
|
||||
function sendConsoleFavicon(response: ServerResponse, requestId: string): void {
|
||||
if (response.destroyed || response.headersSent) return;
|
||||
response.statusCode = 204;
|
||||
response.setHeader('cache-control', 'no-store');
|
||||
response.setHeader('cross-origin-resource-policy', 'same-origin');
|
||||
response.setHeader('referrer-policy', 'no-referrer');
|
||||
response.setHeader('x-content-type-options', 'nosniff');
|
||||
response.setHeader('x-request-id', requestId);
|
||||
response.end();
|
||||
}
|
||||
|
||||
function errorResponse(statusCode: number, code: string): LocalApiResponse {
|
||||
return Object.freeze({
|
||||
statusCode,
|
||||
@@ -588,6 +633,7 @@ export async function startLocalApiHttpSurface(
|
||||
options: LocalApiHttpSurfaceOptions,
|
||||
): Promise<Readonly<ActiveLocalApiHttpSurface>> {
|
||||
validateOptions(options);
|
||||
const consoleAssets = loadLocalConsoleAssets();
|
||||
const uuid = options.randomUuid ?? randomUUID;
|
||||
const maxConcurrentRequests = options.profile === 'edge' ? 4 : 32;
|
||||
const drainTimeoutMs = options.profile === 'edge' ? 5_000 : 10_000;
|
||||
@@ -611,6 +657,28 @@ export async function startLocalApiHttpSurface(
|
||||
send(response, requestId, errorResponse(503, 'server_overloaded'));
|
||||
return;
|
||||
}
|
||||
const consoleAsset =
|
||||
request.method === 'GET' && typeof request.url === 'string'
|
||||
? consoleAssets.get(request.url)
|
||||
: undefined;
|
||||
if (consoleAsset) {
|
||||
if (hasRequestBody(request)) {
|
||||
send(response, requestId, errorResponse(400, 'invalid_request_body'));
|
||||
request.resume();
|
||||
return;
|
||||
}
|
||||
sendConsoleAsset(response, requestId, consoleAsset);
|
||||
return;
|
||||
}
|
||||
if (request.method === 'GET' && request.url === '/favicon.ico') {
|
||||
if (hasRequestBody(request)) {
|
||||
send(response, requestId, errorResponse(400, 'invalid_request_body'));
|
||||
request.resume();
|
||||
return;
|
||||
}
|
||||
sendConsoleFavicon(response, requestId);
|
||||
return;
|
||||
}
|
||||
const resolvedRoute = route(request, options.profile);
|
||||
if (!resolvedRoute) {
|
||||
send(response, requestId, errorResponse(404, 'route_not_found'));
|
||||
|
||||
Reference in New Issue
Block a user