mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): distribute copilot console via signed admin image
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
'use strict';
|
||||
|
||||
const { execFileSync } = require('node:child_process');
|
||||
const { execFileSync, spawnSync } = require('node:child_process');
|
||||
const { resolve } = require('node:path');
|
||||
|
||||
const IMAGE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._/:@-]{0,255}$/u;
|
||||
@@ -63,6 +63,18 @@ function docker(args, options = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
function dockerLogs(container) {
|
||||
const result = spawnSync('docker', ['logs', container], {
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 1024 * 1024,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
if (result.error || result.status !== 0) {
|
||||
fail('published Console logs are unavailable');
|
||||
}
|
||||
return `${result.stdout}${result.stderr}`;
|
||||
}
|
||||
|
||||
function runImage(image, args) {
|
||||
return docker([
|
||||
'run',
|
||||
@@ -301,6 +313,174 @@ child.stdout.on('data', (chunk) => {
|
||||
}
|
||||
}
|
||||
|
||||
function runPublishedConsoleContract(image) {
|
||||
const suffix = `${process.pid}-${Date.now()}`;
|
||||
const network = `ql3-console-live-${suffix}`;
|
||||
const container = `ql3-console-live-${suffix}`;
|
||||
const containerPort = Number(
|
||||
execFileSync(
|
||||
process.execPath,
|
||||
[
|
||||
'-e',
|
||||
"const s=require('node:net').createServer();s.listen(0,'127.0.0.1',()=>{process.stdout.write(String(s.address().port));s.close();});",
|
||||
],
|
||||
{ encoding: 'utf8', timeout: 5_000 },
|
||||
),
|
||||
);
|
||||
if (!Number.isSafeInteger(containerPort) || containerPort < 1_024) {
|
||||
fail('published Console test port is invalid');
|
||||
}
|
||||
const source = String.raw`
|
||||
const { spawn } = require('node:child_process');
|
||||
const { statSync, writeFileSync } = require('node:fs');
|
||||
const { rootCertificates } = require('node:tls');
|
||||
const facade = '/opt/qinglong/node_modules/@qinglong/cluster-admin/dist/product-cli/cli.js';
|
||||
const share = '/opt/qinglong/share/ql3-copilot-console';
|
||||
for (const [file, mode] of [['docker-loopback.sh', 0o555], ['verify-release.sh', 0o555], ['README.md', 0o444], ['client-config.example.json', 0o444], ['host-environment.example.json', 0o444]]) {
|
||||
if ((statSync(share + '/' + file).mode & 0o777) !== mode) process.exit(51);
|
||||
}
|
||||
writeFileSync('/tmp/ca.pem', rootCertificates[0], { mode: 0o600 });
|
||||
writeFileSync('/tmp/client.json', JSON.stringify({ schema: 'qinglong/cluster-copilot-client-config@v1', endpoint: 'https://localhost:65535/', servername: 'localhost', caFile: '/tmp/ca.pem', requestTimeoutMs: 1000 }), { mode: 0o600 });
|
||||
writeFileSync('/tmp/credential', 'ql3c_console_' + Buffer.alloc(32, 7).toString('base64url'), { mode: 0o600 });
|
||||
writeFileSync('/tmp/session', Buffer.alloc(32, 11).toString('base64url'), { mode: 0o600 });
|
||||
const child = spawn(process.execPath, [facade, 'copilot-console', '--container-published-loopback', '--port=${containerPort}', '--config', '/tmp/client.json', '--credential', '/tmp/credential', '--session', '/tmp/session'], { stdio: 'inherit' });
|
||||
child.once('exit', (code, signal) => {
|
||||
if (signal) process.kill(process.pid, signal);
|
||||
else process.exit(code ?? 1);
|
||||
});
|
||||
process.once('SIGTERM', () => child.kill('SIGTERM'));
|
||||
process.once('SIGINT', () => child.kill('SIGINT'));
|
||||
`;
|
||||
let createdNetwork = false;
|
||||
let createdContainer = false;
|
||||
try {
|
||||
docker(['network', 'create', '--driver', 'bridge', network]);
|
||||
createdNetwork = true;
|
||||
docker([
|
||||
'run',
|
||||
'--detach',
|
||||
'--name',
|
||||
container,
|
||||
'--read-only',
|
||||
'--network',
|
||||
network,
|
||||
'--cap-drop',
|
||||
'ALL',
|
||||
'--security-opt',
|
||||
'no-new-privileges',
|
||||
'--user',
|
||||
'10001:10001',
|
||||
'--pids-limit',
|
||||
'32',
|
||||
'--memory',
|
||||
'192m',
|
||||
'--cpus',
|
||||
'0.25',
|
||||
'--stop-timeout',
|
||||
'3',
|
||||
'--tmpfs',
|
||||
'/tmp:rw,noexec,nosuid,nodev,size=8m,mode=700,uid=10001,gid=10001',
|
||||
'--publish',
|
||||
`127.0.0.1:${containerPort}:${containerPort}/tcp`,
|
||||
'--entrypoint',
|
||||
'node',
|
||||
image,
|
||||
'-e',
|
||||
source,
|
||||
]);
|
||||
createdContainer = true;
|
||||
|
||||
const waitArray = new Int32Array(new SharedArrayBuffer(4));
|
||||
let logs = '';
|
||||
for (let attempt = 0; attempt < 200; attempt += 1) {
|
||||
logs = dockerLogs(container);
|
||||
if (logs.includes('"event":"started"')) break;
|
||||
Atomics.wait(waitArray, 0, 0, 25);
|
||||
}
|
||||
const startedLine = logs
|
||||
.split('\n')
|
||||
.find((line) => line.includes('"event":"started"'));
|
||||
if (!startedLine) {
|
||||
const state = JSON.parse(docker(['inspect', container]))[0]?.State;
|
||||
let terminalCode = 'absent';
|
||||
for (const line of logs.trim().split('\n').reverse()) {
|
||||
try {
|
||||
const fact = JSON.parse(line);
|
||||
terminalCode = fact.code ?? fact.event ?? 'unknown';
|
||||
break;
|
||||
} catch {}
|
||||
}
|
||||
fail(
|
||||
`published Console did not start (running=${String(state?.Running)}, exit=${String(state?.ExitCode)}, code=${terminalCode})`,
|
||||
);
|
||||
}
|
||||
let started;
|
||||
try {
|
||||
started = JSON.parse(startedLine);
|
||||
} catch {
|
||||
fail('published Console start fact is invalid');
|
||||
}
|
||||
if (
|
||||
started?.origin !== `http://127.0.0.1:${containerPort}` ||
|
||||
started?.networkBoundary !== 'container-published-loopback' ||
|
||||
started?.publishedHostAddress !== '127.0.0.1'
|
||||
) {
|
||||
fail('published Console boundary fact drifted');
|
||||
}
|
||||
|
||||
const published = docker([
|
||||
'port',
|
||||
container,
|
||||
`${containerPort}/tcp`,
|
||||
]).trim();
|
||||
const publishedMatch = /^127\.0\.0\.1:([1-9][0-9]{0,4})$/u.exec(
|
||||
published,
|
||||
);
|
||||
if (!publishedMatch) fail('published Console escaped host loopback');
|
||||
const origin = `http://127.0.0.1:${publishedMatch[1]}`;
|
||||
const probe = execFileSync(
|
||||
process.execPath,
|
||||
[
|
||||
'-e',
|
||||
"require('node:http').get(process.argv[1],(r)=>{const c=[];r.on('data',(x)=>c.push(x));r.on('end',()=>{const b=Buffer.concat(c).toString('utf8');if(r.statusCode!==200||!b.includes('Cluster field console'))process.exit(2);process.stdout.write(JSON.stringify({status:r.statusCode,assets:b.includes('/app.css')&&b.includes('/app.js')}));});}).on('error',()=>process.exit(3));",
|
||||
origin,
|
||||
],
|
||||
{ encoding: 'utf8', timeout: 5_000 },
|
||||
);
|
||||
const probeFact = JSON.parse(probe);
|
||||
if (probeFact.status !== 200 || probeFact.assets !== true) {
|
||||
fail('published Console host read drifted');
|
||||
}
|
||||
|
||||
const inspected = JSON.parse(docker(['inspect', container]))[0];
|
||||
const binding =
|
||||
inspected?.HostConfig?.PortBindings?.[`${containerPort}/tcp`]?.[0];
|
||||
if (
|
||||
inspected?.HostConfig?.ReadonlyRootfs !== true ||
|
||||
inspected?.HostConfig?.NetworkMode !== network ||
|
||||
binding?.HostIp !== '127.0.0.1' ||
|
||||
inspected?.HostConfig?.Privileged !== false ||
|
||||
!inspected?.HostConfig?.CapDrop?.includes('ALL')
|
||||
) {
|
||||
fail('published Console container authority drifted');
|
||||
}
|
||||
} finally {
|
||||
if (createdContainer) {
|
||||
try {
|
||||
docker(['stop', '--time', '3', container]);
|
||||
} catch {}
|
||||
try {
|
||||
docker(['rm', '--force', container]);
|
||||
} catch {}
|
||||
}
|
||||
if (createdNetwork) {
|
||||
try {
|
||||
docker(['network', 'rm', network]);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
if (process.env.QL3_CLUSTER_ADMIN_PRODUCT_LIVE !== '1') {
|
||||
fail('QL3_CLUSTER_ADMIN_PRODUCT_LIVE=1 is required');
|
||||
@@ -341,6 +521,7 @@ function main() {
|
||||
if (version !== '3.0.0-alpha.0') fail('product version contract drifted');
|
||||
runOperatorContextContract(image);
|
||||
runConsoleContract(image);
|
||||
runPublishedConsoleContract(image);
|
||||
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({
|
||||
@@ -355,6 +536,8 @@ function main() {
|
||||
contextReadiness: true,
|
||||
consoleLoopback: true,
|
||||
consoleAssets: true,
|
||||
consolePublishedHostAddress: '127.0.0.1',
|
||||
consoleDistributionEmbedded: true,
|
||||
isolation: Object.freeze({
|
||||
readOnlyRoot: true,
|
||||
network: 'none',
|
||||
|
||||
@@ -106,7 +106,9 @@ function auditClusterCopilotConsole(options = {}) {
|
||||
'credential',
|
||||
]);
|
||||
expectFragments(CONSOLE_ROOT + '/server.ts', [
|
||||
"server.listen(record.port as number, '127.0.0.1'",
|
||||
"networkBoundary === 'host-loopback' ? '127.0.0.1' : '0.0.0.0'",
|
||||
"networkBoundary === 'container-published-loopback'",
|
||||
'server.listen(record.port as number, listenAddress',
|
||||
'request.headers.origin !== expectedOrigin',
|
||||
"request.headers.host !== expectedOrigin.slice('http://'.length)",
|
||||
'maximumConcurrentRequests: 2',
|
||||
@@ -117,7 +119,6 @@ function auditClusterCopilotConsole(options = {}) {
|
||||
"'cache-control': 'no-store'",
|
||||
]);
|
||||
rejectFragments(CONSOLE_ROOT + '/server.ts', [
|
||||
"'0.0.0.0'",
|
||||
'createSecureServer',
|
||||
'WebSocket',
|
||||
'set-cookie',
|
||||
@@ -133,6 +134,8 @@ function auditClusterCopilotConsole(options = {}) {
|
||||
"'private'",
|
||||
'validateClusterCopilotClientCredentialFile',
|
||||
"clusterCredential: 'server_only'",
|
||||
"networkBoundary: parsed.networkBoundary",
|
||||
"publishedHostAddress: '127.0.0.1'",
|
||||
"operations: ['inspect', 'output']",
|
||||
'mutation: false',
|
||||
]);
|
||||
@@ -195,8 +198,12 @@ function auditClusterCopilotConsole(options = {}) {
|
||||
"started.event !== 'started'",
|
||||
"body.includes('Cluster field console')",
|
||||
'runConsoleContract(image);',
|
||||
'function runPublishedConsoleContract(image)',
|
||||
'runPublishedConsoleContract(image);',
|
||||
'consoleLoopback: true',
|
||||
'consoleAssets: true',
|
||||
"consolePublishedHostAddress: '127.0.0.1'",
|
||||
'consoleDistributionEmbedded: true',
|
||||
]);
|
||||
|
||||
let manifest;
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const FILES = Object.freeze({
|
||||
launcher: 'deploy/console/ql3-cluster-copilot/docker-loopback.sh',
|
||||
verifier: 'deploy/console/ql3-cluster-copilot/verify-release.sh',
|
||||
environment:
|
||||
'deploy/console/ql3-cluster-copilot/host-environment.example.json',
|
||||
image: 'deploy/containers/ql3-cluster-admin/Dockerfile',
|
||||
workflow: '.github/workflows/ql3-image-release.yml',
|
||||
cli: 'packages/ql3-cluster-admin/src/copilot-console/cli.ts',
|
||||
server: 'packages/ql3-cluster-admin/src/copilot-console/server.ts',
|
||||
});
|
||||
|
||||
function finding(code, target, detail) {
|
||||
return Object.freeze({ code, target, detail });
|
||||
}
|
||||
|
||||
function auditClusterCopilotConsoleDistribution(options = {}) {
|
||||
const root = options.root || path.resolve(__dirname, '..');
|
||||
const readFile =
|
||||
options.readFile ||
|
||||
((relativePath) => fs.readFileSync(path.join(root, relativePath), 'utf8'));
|
||||
const findings = [];
|
||||
const source = {};
|
||||
for (const [name, relativePath] of Object.entries(FILES)) {
|
||||
try {
|
||||
source[name] = readFile(relativePath);
|
||||
} catch (error) {
|
||||
findings.push(
|
||||
finding(
|
||||
'QL3_COPILOT_CONSOLE_DISTRIBUTION_FILE_MISSING',
|
||||
relativePath,
|
||||
error instanceof Error ? error.name : 'Error',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const requireFragments = (name, fragments, code) => {
|
||||
const contents = source[name];
|
||||
if (typeof contents !== 'string') return;
|
||||
for (const fragment of fragments) {
|
||||
if (!contents.includes(fragment)) {
|
||||
findings.push(finding(code, FILES[name], fragment));
|
||||
}
|
||||
}
|
||||
};
|
||||
const rejectFragments = (name, fragments, code) => {
|
||||
const contents = source[name];
|
||||
if (typeof contents !== 'string') return;
|
||||
for (const fragment of fragments) {
|
||||
if (contents.includes(fragment)) {
|
||||
findings.push(finding(code, FILES[name], fragment));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
requireFragments(
|
||||
'launcher',
|
||||
[
|
||||
'docker run --rm --pull never --init --read-only',
|
||||
'--network "$network"',
|
||||
'--cap-drop ALL',
|
||||
'--security-opt no-new-privileges',
|
||||
'--user 10001:10001',
|
||||
'--pids-limit "$pids"',
|
||||
'--memory "$memory"',
|
||||
'--cpus "$cpus"',
|
||||
'--tmpfs /tmp:rw,noexec,nosuid,nodev,size=8m,mode=700,uid=10001,gid=10001',
|
||||
'--mount "type=bind,src=$private_root,dst=/var/run/secrets/qinglong3/copilot-console,readonly"',
|
||||
'--publish "127.0.0.1:$port:$port/tcp"',
|
||||
'--container-published-loopback',
|
||||
'bridge|default|host|none) fail',
|
||||
'compact)',
|
||||
'memory=192m',
|
||||
'standard)',
|
||||
'memory=512m',
|
||||
],
|
||||
'QL3_COPILOT_CONSOLE_LAUNCHER_CONTRACT_DRIFT',
|
||||
);
|
||||
rejectFragments(
|
||||
'launcher',
|
||||
['--privileged', '--network host', '/var/run/docker.sock', '--pull always'],
|
||||
'QL3_COPILOT_CONSOLE_LAUNCHER_AUTHORITY_WIDENED',
|
||||
);
|
||||
|
||||
requireFragments(
|
||||
'verifier',
|
||||
[
|
||||
'qinglong3-cluster-admin@sha256:',
|
||||
'cosign verify',
|
||||
'--certificate-identity "$certificate_identity"',
|
||||
'--certificate-oidc-issuer https://token.actions.githubusercontent.com',
|
||||
'gh attestation verify "oci://$image"',
|
||||
'--signer-workflow "$workflow"',
|
||||
'--source-digest "$source_revision"',
|
||||
'--source-ref "$source_ref"',
|
||||
'--deny-self-hosted-runners',
|
||||
'--bundle-from-oci',
|
||||
'https://cyclonedx.org/bom',
|
||||
'https://qinglong.dev/attestations/image-os-vulnerability/v1',
|
||||
],
|
||||
'QL3_CLUSTER_ADMIN_RELEASE_VERIFIER_DRIFT',
|
||||
);
|
||||
rejectFragments(
|
||||
'verifier',
|
||||
[':latest', 'refs/heads/', '--insecure-ignore-tlog', '--certificate-identity-regexp'],
|
||||
'QL3_CLUSTER_ADMIN_RELEASE_VERIFIER_WIDENED',
|
||||
);
|
||||
|
||||
let environment;
|
||||
try {
|
||||
environment = JSON.parse(source.environment);
|
||||
} catch (error) {
|
||||
if (typeof source.environment === 'string') {
|
||||
findings.push(
|
||||
finding(
|
||||
'QL3_COPILOT_CONSOLE_HOST_ENVIRONMENT_INVALID',
|
||||
FILES.environment,
|
||||
error instanceof Error ? error.name : 'Error',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
const expectedEnvironment = {
|
||||
QL3_COPILOT_CONSOLE_IMAGE:
|
||||
'ghcr.io/replace-owner/qinglong3-cluster-admin@sha256:' + '0'.repeat(64),
|
||||
QL3_COPILOT_CONSOLE_PRIVATE_ROOT:
|
||||
'/absolute/private/ql3-copilot-console',
|
||||
QL3_COPILOT_CONSOLE_NETWORK: 'qinglong3-copilot-console-egress',
|
||||
QL3_COPILOT_CONSOLE_PORT: '5701',
|
||||
QL3_COPILOT_CONSOLE_RESOURCE_CLASS: 'compact',
|
||||
};
|
||||
if (
|
||||
environment &&
|
||||
JSON.stringify(environment) !== JSON.stringify(expectedEnvironment)
|
||||
) {
|
||||
findings.push(
|
||||
finding(
|
||||
'QL3_COPILOT_CONSOLE_HOST_ENVIRONMENT_INVALID',
|
||||
FILES.environment,
|
||||
'exact digest, private-root, named-network, port and resource-class keys are required',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
requireFragments(
|
||||
'image',
|
||||
[
|
||||
'COPY --chmod=0555 deploy/console/ql3-cluster-copilot/docker-loopback.sh',
|
||||
'share/ql3-copilot-console/docker-loopback.sh',
|
||||
'COPY --chmod=0555 deploy/console/ql3-cluster-copilot/verify-release.sh',
|
||||
'share/ql3-copilot-console/verify-release.sh',
|
||||
'COPY --chmod=0444 deploy/console/ql3-cluster-copilot/host-environment.example.json',
|
||||
'share/ql3-copilot-console/host-environment.example.json',
|
||||
],
|
||||
'QL3_COPILOT_CONSOLE_IMAGE_DISTRIBUTION_DRIFT',
|
||||
);
|
||||
requireFragments(
|
||||
'workflow',
|
||||
[
|
||||
'image: admin',
|
||||
'image_arch: amd64',
|
||||
'image_arch: arm64',
|
||||
'cosign sign --yes "${IMAGE}@${DIGEST}"',
|
||||
'predicate-type: https://qinglong.dev/attestations/image-os-vulnerability/v1',
|
||||
'gh attestation verify "oci://${IMAGE}@${DIGEST}"',
|
||||
'--predicate-type "https://cyclonedx.org/bom"',
|
||||
'--deny-self-hosted-runners',
|
||||
'--bundle-from-oci',
|
||||
'Promote only the verified digest to immutable release tags',
|
||||
],
|
||||
'QL3_CLUSTER_ADMIN_RELEASE_WORKFLOW_DRIFT',
|
||||
);
|
||||
requireFragments(
|
||||
'cli',
|
||||
[
|
||||
"'container-published-loopback'",
|
||||
"publishedHostAddress: '127.0.0.1'",
|
||||
'(containerPublishedLoopback && port === 0)',
|
||||
],
|
||||
'QL3_COPILOT_CONSOLE_NETWORK_BOUNDARY_DRIFT',
|
||||
);
|
||||
requireFragments(
|
||||
'server',
|
||||
[
|
||||
"networkBoundary === 'host-loopback' ? '127.0.0.1' : '0.0.0.0'",
|
||||
"networkBoundary === 'container-published-loopback'",
|
||||
'server.listen(record.port as number, listenAddress',
|
||||
],
|
||||
'QL3_COPILOT_CONSOLE_NETWORK_BOUNDARY_DRIFT',
|
||||
);
|
||||
|
||||
const kubernetesRoot = path.join(root, 'deploy/kubernetes');
|
||||
const pending = [kubernetesRoot];
|
||||
while (pending.length > 0) {
|
||||
const directory = pending.pop();
|
||||
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
||||
const absolute = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) pending.push(absolute);
|
||||
else if (
|
||||
entry.isFile() &&
|
||||
/\.ya?ml$/u.test(entry.name) &&
|
||||
fs.readFileSync(absolute, 'utf8').includes('ql3-copilot-console')
|
||||
) {
|
||||
findings.push(
|
||||
finding(
|
||||
'QL3_COPILOT_CONSOLE_KUBERNETES_RESIDENT',
|
||||
path.relative(root, absolute),
|
||||
'workstation Console must remain outside the Cluster workload graph',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
component: 'cluster-copilot-console-distribution',
|
||||
artifact: 'signed-admin-oci',
|
||||
architectures: Object.freeze(['amd64', 'arm64']),
|
||||
hostPublication: '127.0.0.1',
|
||||
kubernetesResident: false,
|
||||
additionalWorkspacePackages: 0,
|
||||
findings: Object.freeze(findings),
|
||||
compatible: findings.length === 0,
|
||||
});
|
||||
}
|
||||
|
||||
function main() {
|
||||
const report = auditClusterCopilotConsoleDistribution();
|
||||
process.stdout.write(JSON.stringify(report) + '\n');
|
||||
if (!report.compatible) process.exitCode = 1;
|
||||
}
|
||||
|
||||
if (require.main === module) main();
|
||||
|
||||
module.exports = { auditClusterCopilotConsoleDistribution };
|
||||
Reference in New Issue
Block a user