feat(ql3): ship bounded legacy panel console

This commit is contained in:
whyour
2026-09-02 13:34:46 +08:00
parent ca41640794
commit 1b223ff2ad
14 changed files with 1144 additions and 63 deletions
@@ -1,9 +1,35 @@
import { createHash } from 'node:crypto';
import { lstatSync, readFileSync, realpathSync } from 'node:fs';
import {
existsSync,
lstatSync,
readFileSync,
readdirSync,
realpathSync,
} from 'node:fs';
import path from 'node:path';
const MAX_ASSET_BYTES = 96 * 1_024;
const MAX_TOTAL_BYTES = 192 * 1_024;
const MAX_LITE_ASSET_BYTES = 96 * 1_024;
const MAX_LITE_TOTAL_BYTES = 192 * 1_024;
const MAX_PANEL_FILES = 256;
const MAX_PANEL_TOTAL_BYTES = 13 * 1_024 * 1_024;
const MAX_PANEL_FILE_BYTES = 3 * 1_024 * 1_024;
const MAX_MANIFEST_BYTES = 128 * 1_024;
const PANEL_SCHEMA = 'qinglong/local-legacy-panel-assets@v1';
const IMMUTABLE_CACHE = 'public, max-age=31536000, immutable';
const NO_STORE_CACHE = 'no-store';
const LITE_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'";
const PANEL_CONTENT_SECURITY_POLICY =
"default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; font-src 'self' data:; base-uri 'none'; form-action 'none'; frame-ancestors 'none'; object-src 'none'; worker-src 'none'; manifest-src 'none'";
const PANEL_CONTENT_TYPES = new Set([
'text/css; charset=utf-8',
'text/html; charset=utf-8',
'text/javascript; charset=utf-8',
'font/ttf',
'font/woff',
'font/woff2',
]);
const PANEL_SUPPORTED_ROUTES = Object.freeze(['/login', '/crontab', '/error']);
const DEFINITIONS = Object.freeze([
Object.freeze({
@@ -23,12 +49,26 @@ const DEFINITIONS = Object.freeze([
}),
]);
export interface LocalConsoleAsset {
interface LocalConsoleAssetBase {
readonly contentType: string;
readonly etag: string;
readonly body: Buffer;
readonly byteLength: number;
readonly cacheControl: string;
readonly contentSecurityPolicy: string;
}
export type LocalConsoleAsset =
| (LocalConsoleAssetBase &
Readonly<{
body: Buffer;
filePath?: never;
}>)
| (LocalConsoleAssetBase &
Readonly<{
body?: never;
filePath: string;
}>);
export type LocalConsoleAssets = ReadonlyMap<string, LocalConsoleAsset>;
export class LocalConsoleAssetError extends Error {
@@ -40,7 +80,34 @@ export class LocalConsoleAssetError extends Error {
}
}
function loadAsset(
function exactKeys(value: unknown, expected: readonly string[]): boolean {
return (
!!value &&
typeof value === 'object' &&
!Array.isArray(value) &&
JSON.stringify(Object.keys(value).sort()) ===
JSON.stringify([...expected].sort())
);
}
function canonicalDirectory(directory: string, label: string): string {
const resolved = path.resolve(directory);
try {
const stat = lstatSync(resolved);
if (
!stat.isDirectory() ||
stat.isSymbolicLink() ||
realpathSync(resolved) !== resolved
) {
throw new TypeError();
}
} catch (error) {
throw new LocalConsoleAssetError(label, { cause: error });
}
return resolved;
}
function loadLiteAsset(
root: string,
definition: (typeof DEFINITIONS)[number],
): Readonly<LocalConsoleAsset> {
@@ -53,7 +120,7 @@ function loadAsset(
!stat.isFile() ||
stat.isSymbolicLink() ||
stat.size < 2 ||
stat.size > MAX_ASSET_BYTES ||
stat.size > MAX_LITE_ASSET_BYTES ||
realpathSync(filePath) !== filePath
) {
throw new TypeError('asset identity is incompatible');
@@ -70,29 +137,231 @@ function loadAsset(
return Object.freeze({
contentType: definition.contentType,
etag: `"${createHash('sha256').update(body).digest('hex')}"`,
byteLength: body.byteLength,
cacheControl: NO_STORE_CACHE,
contentSecurityPolicy: LITE_CONTENT_SECURITY_POLICY,
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 });
}
function loadLiteAssets(root: string): LocalConsoleAssets {
const canonicalRoot = canonicalDirectory(root, 'asset root');
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) {
const asset = loadLiteAsset(canonicalRoot, definition);
totalBytes += asset.byteLength;
if (totalBytes > MAX_LITE_TOTAL_BYTES) {
throw new LocalConsoleAssetError('asset set exceeds its byte budget');
}
assets.set(definition.requestPath, asset);
}
return assets;
}
function parsePanelManifest(root: string): Record<string, unknown> {
const manifestPath = path.join(root, 'manifest.json');
try {
const stat = lstatSync(manifestPath);
if (
!stat.isFile() ||
stat.isSymbolicLink() ||
stat.size < 256 ||
stat.size > MAX_MANIFEST_BYTES ||
realpathSync(manifestPath) !== manifestPath
) {
throw new TypeError();
}
const value: unknown = JSON.parse(readFileSync(manifestPath, 'utf8'));
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new TypeError();
}
return value as Record<string, unknown>;
} catch (error) {
throw new LocalConsoleAssetError('panel manifest', { cause: error });
}
}
function panelDiskFiles(root: string): readonly string[] {
const result: string[] = [];
const pending = [root];
while (pending.length > 0) {
const directory = pending.pop()!;
for (const entry of readdirSync(directory, { withFileTypes: true })) {
const target = path.join(directory, entry.name);
const stat = lstatSync(target);
if (stat.isSymbolicLink()) {
throw new LocalConsoleAssetError('panel closure contains a symlink');
}
if (stat.isDirectory()) pending.push(target);
else if (stat.isFile()) {
result.push(path.relative(root, target).split(path.sep).join('/'));
} else {
throw new LocalConsoleAssetError(
'panel closure contains a special file',
);
}
}
}
return result.sort();
}
export function loadLocalConsolePanelAssets(
directory: string,
): LocalConsoleAssets {
const root = canonicalDirectory(directory, 'panel asset root');
const manifest = parsePanelManifest(root);
if (
!exactKeys(manifest, [
'schema',
'source',
'supportedRoutes',
'fileCount',
'totalBytes',
'limits',
'files',
]) ||
manifest.schema !== PANEL_SCHEMA ||
manifest.source !== 'qinglong-2.x-capability-gated-panel' ||
JSON.stringify(manifest.supportedRoutes) !==
JSON.stringify(PANEL_SUPPORTED_ROUTES) ||
!exactKeys(manifest.limits, [
'maxFiles',
'maxTotalBytes',
'maxFileBytes',
]) ||
(manifest.limits as Record<string, unknown>).maxFiles !== MAX_PANEL_FILES ||
(manifest.limits as Record<string, unknown>).maxTotalBytes !==
MAX_PANEL_TOTAL_BYTES ||
(manifest.limits as Record<string, unknown>).maxFileBytes !==
MAX_PANEL_FILE_BYTES ||
!Array.isArray(manifest.files) ||
manifest.files.length < 4 ||
manifest.files.length > MAX_PANEL_FILES
) {
throw new LocalConsoleAssetError('panel manifest contract drifted');
}
const assets = new Map<string, LocalConsoleAsset>();
const seenFiles = new Set<string>(['manifest.json']);
let previousRequestPath = '';
let totalBytes = 0;
for (const raw of manifest.files) {
if (
!exactKeys(raw, [
'requestPath',
'file',
'bytes',
'sha256',
'contentType',
'cacheControl',
])
) {
throw new LocalConsoleAssetError('panel asset entry shape drifted');
}
const entry = raw as Record<string, unknown>;
if (
typeof entry.requestPath !== 'string' ||
!entry.requestPath.startsWith('/') ||
entry.requestPath.includes('?') ||
entry.requestPath <= previousRequestPath ||
(entry.requestPath.startsWith('/api/') &&
entry.requestPath !== '/api/env.js') ||
typeof entry.file !== 'string' ||
!/^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*$/u.test(entry.file) ||
entry.file.split('/').includes('..') ||
seenFiles.has(entry.file) ||
!Number.isSafeInteger(entry.bytes) ||
Number(entry.bytes) < 0 ||
Number(entry.bytes) > MAX_PANEL_FILE_BYTES ||
typeof entry.sha256 !== 'string' ||
!/^[0-9a-f]{64}$/u.test(entry.sha256) ||
typeof entry.contentType !== 'string' ||
!PANEL_CONTENT_TYPES.has(entry.contentType) ||
(entry.cacheControl !== NO_STORE_CACHE &&
entry.cacheControl !== IMMUTABLE_CACHE)
) {
throw new LocalConsoleAssetError('panel asset entry is invalid');
}
previousRequestPath = entry.requestPath;
seenFiles.add(entry.file);
const filePath = path.join(root, ...entry.file.split('/'));
let body: Buffer;
let stat;
try {
stat = lstatSync(filePath);
if (
!stat.isFile() ||
stat.isSymbolicLink() ||
stat.size !== entry.bytes ||
realpathSync(filePath) !== filePath
) {
throw new TypeError();
}
body = readFileSync(filePath);
} catch (error) {
throw new LocalConsoleAssetError(`panel asset ${entry.file}`, {
cause: error,
});
}
if (
body.byteLength !== entry.bytes ||
createHash('sha256').update(body).digest('hex') !== entry.sha256
) {
throw new LocalConsoleAssetError(`panel asset ${entry.file} drifted`);
}
totalBytes += body.byteLength;
assets.set(
entry.requestPath,
Object.freeze({
contentType: entry.contentType,
etag: `"${entry.sha256}"`,
byteLength: body.byteLength,
cacheControl: entry.cacheControl,
contentSecurityPolicy: PANEL_CONTENT_SECURITY_POLICY,
filePath,
}),
);
}
if (
manifest.fileCount !== assets.size ||
manifest.totalBytes !== totalBytes ||
totalBytes > MAX_PANEL_TOTAL_BYTES ||
!assets.has('/') ||
!assets.has('/api/env.js')
) {
throw new LocalConsoleAssetError('panel asset closure drifted');
}
const diskFiles = panelDiskFiles(root);
if (
diskFiles.length !== seenFiles.size ||
diskFiles.some((file) => !seenFiles.has(file))
) {
throw new LocalConsoleAssetError('panel disk closure drifted');
}
const index = assets.get('/')!;
for (const route of PANEL_SUPPORTED_ROUTES) assets.set(route, index);
return assets;
}
export function loadLocalConsoleAssets(): LocalConsoleAssets {
const assetsRoot = path.resolve(__dirname, '../../assets');
const panelRoot = path.join(assetsRoot, 'panel');
const liteAssets = loadLiteAssets(path.join(assetsRoot, 'console'));
if (!existsSync(path.join(panelRoot, 'manifest.json'))) return liteAssets;
const assets = new Map(loadLocalConsolePanelAssets(panelRoot));
for (const requestPath of ['/console.css', '/console.js']) {
if (assets.has(requestPath)) {
throw new LocalConsoleAssetError(
`panel conflicts with native Console asset ${requestPath}`,
);
}
assets.set(requestPath, liteAssets.get(requestPath)!);
}
if (assets.has('/console')) {
throw new LocalConsoleAssetError(
'panel conflicts with native Console route /console',
);
}
assets.set('/console', liteAssets.get('/')!);
return assets;
}
@@ -1,6 +1,8 @@
import { randomUUID } from 'node:crypto';
import { createReadStream } from 'node:fs';
import http, { type IncomingMessage, type ServerResponse } from 'node:http';
import type { Socket } from 'node:net';
import { pipeline } from 'node:stream/promises';
import type { LocalApplicationProfile } from '@qinglong/local-application';
@@ -52,9 +54,6 @@ const SECRET_ROUTE_PATTERN =
/^\/api\/v3\/projects\/([A-Za-z0-9][A-Za-z0-9._:-]{0,127})\/secrets$/;
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
| Readonly<{
@@ -895,19 +894,22 @@ function send(
response.end(body);
}
function sendConsoleAsset(
async function sendConsoleAsset(
request: IncomingMessage,
response: ServerResponse,
requestId: string,
asset: Readonly<LocalConsoleAsset>,
): void {
): Promise<void> {
if (response.destroyed || response.headersSent) return;
response.statusCode = 200;
const ifNoneMatch = rawHeaderValues(request, 'if-none-match');
const notModified =
asset.cacheControl !== 'no-store' &&
ifNoneMatch.length === 1 &&
ifNoneMatch[0] === asset.etag;
response.statusCode = notModified ? 304 : 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('cache-control', asset.cacheControl);
response.setHeader('content-security-policy', asset.contentSecurityPolicy);
response.setHeader('cross-origin-opener-policy', 'same-origin');
response.setHeader('cross-origin-resource-policy', 'same-origin');
response.setHeader(
@@ -919,8 +921,28 @@ function sendConsoleAsset(
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);
if (notModified) {
response.end();
return;
}
response.setHeader('content-length', asset.byteLength);
if ('body' in asset) {
await new Promise<void>((resolve, reject) => {
const finish = () => {
response.off('error', reject);
response.off('close', finish);
resolve();
};
response.once('error', reject);
response.once('close', finish);
response.end(asset.body, finish);
});
return;
}
await pipeline(
createReadStream(asset.filePath, { highWaterMark: 64 * 1_024 }),
response,
);
}
function sendConsoleFavicon(response: ServerResponse, requestId: string): void {
@@ -965,10 +987,13 @@ export async function startLocalApiHttpSurface(
validateOptions(options);
const consoleAssets = loadLocalConsoleAssets();
const uuid = options.randomUuid ?? randomUUID;
const maxConcurrentRequests = options.profile === 'edge' ? 4 : 32;
const maxConcurrentApiRequests = options.profile === 'edge' ? 4 : 32;
const maxConcurrentAssetRequests = options.profile === 'edge' ? 16 : 64;
const drainTimeoutMs = options.profile === 'edge' ? 5_000 : 10_000;
let accepting = true;
const inFlight = new Set<Promise<void>>();
const apiInFlight = new Set<Promise<void>>();
const assetInFlight = new Set<Promise<void>>();
const sockets = new Set<Socket>();
const server = http.createServer(
@@ -983,21 +1008,39 @@ export async function startLocalApiHttpSurface(
send(response, requestId, errorResponse(503, 'server_draining'));
return;
}
if (inFlight.size >= maxConcurrentRequests) {
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 (assetInFlight.size >= maxConcurrentAssetRequests) {
send(response, requestId, errorResponse(503, 'server_overloaded'));
return;
}
if (hasRequestBody(request)) {
send(response, requestId, errorResponse(400, 'invalid_request_body'));
request.resume();
return;
}
sendConsoleAsset(response, requestId, consoleAsset);
let operation: Promise<void>;
operation = sendConsoleAsset(request, response, requestId, consoleAsset)
.catch(() => {
if (!response.headersSent) {
send(
response,
requestId,
errorResponse(503, 'response_unavailable'),
);
} else if (!response.destroyed) {
response.destroy();
}
})
.finally(() => {
assetInFlight.delete(operation);
inFlight.delete(operation);
});
assetInFlight.add(operation);
inFlight.add(operation);
return;
}
if (request.method === 'GET' && request.url === '/favicon.ico') {
@@ -1025,6 +1068,10 @@ export async function startLocalApiHttpSurface(
);
return;
}
if (apiInFlight.size >= maxConcurrentApiRequests) {
send(response, requestId, errorResponse(503, 'server_overloaded'));
return;
}
const resolvedRoute = route(request, options.profile);
if (!resolvedRoute) {
send(response, requestId, errorResponse(404, 'route_not_found'));
@@ -1126,15 +1173,17 @@ export async function startLocalApiHttpSurface(
send(response, requestId, errorResponse(503, 'request_unavailable')),
)
.finally(() => {
apiInFlight.delete(operation);
inFlight.delete(operation);
});
apiInFlight.add(operation);
inFlight.add(operation);
},
);
server.headersTimeout = 5_000;
server.keepAliveTimeout = 5_000;
server.maxRequestsPerSocket = 100;
server.maxConnections = maxConcurrentRequests * 2;
server.maxConnections = maxConcurrentApiRequests + maxConcurrentAssetRequests;
server.on('connection', (socket) => {
sockets.add(socket);
socket.once('close', () => sockets.delete(socket));
@@ -1,14 +1,56 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const http = require('node:http');
const net = require('node:net');
const os = require('node:os');
const path = require('node:path');
const { test } = require('node:test');
const {
loadLocalConsoleAssets,
loadLocalConsolePanelAssets,
} = require('../dist/console/localConsoleAssets.js');
const {
startLocalApiHttpSurface,
} = require('../dist/transport/httpSurface.js');
const {
bundleLegacyPanel,
} = require('../../../scripts/ql3-legacy-panel-bundle.cjs');
function panelFixture() {
const root = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-local-api-panel-')),
);
const source = path.join(root, 'source');
const output = path.join(root, 'output');
fs.mkdirSync(source);
fs.writeFileSync(
path.join(source, 'index.html'),
'<!DOCTYPE html>\n' +
'<html><head>\n' +
'<link rel="shortcut icon" href="https://qn.whyour.cn/favicon.svg">\n' +
'<link rel="stylesheet" href="./umi.1234abcd.css">\n' +
'<script src="./api/env.js"></script>\n' +
'</head><body><div id="root"></div>\n' +
'<script src="./umi.1234abcd.js"></script></body></html>\n',
);
fs.writeFileSync(
path.join(source, 'umi.1234abcd.css'),
'body { color: #123; }\n',
);
fs.writeFileSync(
path.join(source, 'umi.1234abcd.js'),
'globalThis.__panel = true;\n',
);
bundleLegacyPanel(source, output);
return {
root,
output,
close() {
fs.rmSync(root, { recursive: true, force: true });
},
};
}
function reservePort() {
return new Promise((resolve, reject) => {
@@ -117,6 +159,63 @@ test('loads one bounded offline Console asset closure', () => {
assert.ok(totalBytes <= 192 * 1024);
});
test('keeps the native Console route beside a manifested legacy panel', () => {
const source = fs.readFileSync(
path.join(__dirname, '../dist/console/localConsoleAssets.js'),
'utf8',
);
assert.match(source, /assets\.set\('\/console', liteAssets\.get\('\/'\)\)/u);
assert.match(source, /panel conflicts with native Console asset/u);
});
test('loads the manifested legacy panel as a streamed bounded closure', (t) => {
const current = panelFixture();
t.after(() => current.close());
const assets = loadLocalConsolePanelAssets(current.output);
assert.deepEqual(
[...assets.keys()],
[
'/',
'/api/env.js',
'/umi.1234abcd.css',
'/umi.1234abcd.js',
'/login',
'/crontab',
'/error',
],
);
const index = assets.get('/');
assert.equal(index, assets.get('/login'));
assert.equal(index, assets.get('/crontab'));
assert.equal(index, assets.get('/error'));
assert.equal(index.body, undefined);
assert.equal(path.isAbsolute(index.filePath), true);
assert.equal(index.cacheControl, 'no-store');
assert.match(
index.contentSecurityPolicy,
/style-src 'self' 'unsafe-inline'/u,
);
assert.match(index.contentSecurityPolicy, /connect-src 'self'/u);
const script = assets.get('/umi.1234abcd.js');
assert.equal(script.body, undefined);
assert.equal(script.cacheControl, 'public, max-age=31536000, immutable');
assert.equal(script.byteLength, 27);
assert.match(script.etag, /^"[0-9a-f]{64}"$/u);
assert.equal(assets.get('/api/env.js').cacheControl, 'no-store');
});
test('rejects a manifested panel whose immutable asset changed', (t) => {
const current = panelFixture();
t.after(() => current.close());
const scriptPath = path.join(current.output, 'umi.1234abcd.js');
fs.chmodSync(scriptPath, 0o600);
fs.appendFileSync(scriptPath, 'drift');
assert.throws(
() => loadLocalConsolePanelAssets(current.output),
/panel asset /u,
);
});
test('serves the Console without authentication and preserves API admission', async (t) => {
const calls = [];
const port = await reservePort();
@@ -177,6 +276,31 @@ test('serves the Console without authentication and preserves API admission', as
assert.deepEqual(calls, ['task.list']);
});
test('serves an Edge browser asset burst without consuming API admission slots', async (t) => {
const port = await reservePort();
const active = await startLocalApiHttpSurface({
profile: 'edge',
host: '127.0.0.1',
port,
admission: {
async prepare() {
throw new Error('static assets must not reach admission');
},
},
});
t.after(() => active.stopAndDrain());
const responses = await Promise.all(
Array.from({ length: 12 }, (_, index) =>
request(port, index % 2 === 0 ? '/console.js' : '/console.css'),
),
);
assert.deepEqual(
responses.map(({ statusCode }) => statusCode),
Array.from({ length: 12 }, () => 200),
);
});
test('rejects request bodies and query aliases on Console assets', async (t) => {
const port = await reservePort();
const active = await startLocalApiHttpSurface({