mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): add owner-private cluster operator context
This commit is contained in:
@@ -4,6 +4,7 @@ import { spawn, type ChildProcess } from 'node:child_process';
|
||||
import { constants } from 'node:os';
|
||||
|
||||
import { resolveQingLong3ClusterProductCommand } from './productCommand';
|
||||
import { QingLong3ClusterProductContextError } from './productContext';
|
||||
|
||||
const FORWARDED_SIGNALS = Object.freeze([
|
||||
'SIGINT',
|
||||
@@ -124,7 +125,25 @@ function main(argv: readonly string[]): void {
|
||||
return;
|
||||
}
|
||||
invoke(resolution.targetFilePath, resolution.argv);
|
||||
} catch {
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof QingLong3ClusterProductContextError ||
|
||||
(error !== null &&
|
||||
typeof error === 'object' &&
|
||||
'code' in error &&
|
||||
error.code === 'QL3_CLUSTER_PRODUCT_CONTEXT_INVALID')
|
||||
) {
|
||||
process.stderr.write(
|
||||
`${JSON.stringify(
|
||||
lowSensitivityFailure(
|
||||
'QL3_CLUSTER_PRODUCT_CONTEXT_INVALID',
|
||||
'QingLong 3.0 Cluster operator context is invalid',
|
||||
),
|
||||
)}\n`,
|
||||
);
|
||||
process.exitCode = 78;
|
||||
return;
|
||||
}
|
||||
process.stderr.write(
|
||||
`${JSON.stringify(
|
||||
lowSensitivityFailure(
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { lstatSync, readFileSync, realpathSync } from 'node:fs';
|
||||
import { isAbsolute, relative, resolve, sep } from 'node:path';
|
||||
|
||||
import { resolveQingLong3ClusterProductContextArguments } from './productContext';
|
||||
|
||||
export interface QingLong3ClusterProductCommandDefinition {
|
||||
readonly name: string;
|
||||
readonly binary: string;
|
||||
@@ -171,6 +173,8 @@ export function qingLong3ClusterProductHelp(): string {
|
||||
commands,
|
||||
'',
|
||||
'Use `ql3-cluster-admin <command> --help` for command-specific usage.',
|
||||
'Use `--context=/absolute/operator-context.json` to inject only stable client paths.',
|
||||
'Command and short-lived assertion files always remain explicit per invocation.',
|
||||
'Server, migration, recovery, executor and key-custody authorities remain isolated.',
|
||||
].join('\n');
|
||||
}
|
||||
@@ -208,11 +212,36 @@ export function resolveQingLong3ClusterProductCommand(
|
||||
message: 'unknown QingLong 3.0 Cluster product command',
|
||||
});
|
||||
}
|
||||
const commandArguments = argv.slice(1);
|
||||
const contextArguments = commandArguments.filter(
|
||||
(argument) => argument === '--context' || argument.startsWith('--context='),
|
||||
);
|
||||
if (
|
||||
contextArguments.length > 1 ||
|
||||
contextArguments[0] === '--context' ||
|
||||
contextArguments[0] === '--context='
|
||||
) {
|
||||
return Object.freeze({
|
||||
kind: 'invalid',
|
||||
code: 'QL3_CLUSTER_PRODUCT_CLI_USAGE_INVALID',
|
||||
message: 'QingLong 3.0 Cluster product context option is invalid',
|
||||
});
|
||||
}
|
||||
const forwardedArguments =
|
||||
contextArguments.length === 0
|
||||
? Object.freeze(commandArguments)
|
||||
: resolveQingLong3ClusterProductContextArguments(
|
||||
contextArguments[0]!.slice('--context='.length),
|
||||
command.name,
|
||||
commandArguments.filter(
|
||||
(argument) => argument !== contextArguments[0],
|
||||
),
|
||||
);
|
||||
const { distRoot } = installationPaths(moduleDirectory);
|
||||
return Object.freeze({
|
||||
kind: 'invoke',
|
||||
command,
|
||||
targetFilePath: resolveInstalledTarget(distRoot, command),
|
||||
argv: Object.freeze(argv.slice(1)),
|
||||
argv: forwardedArguments,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
import {
|
||||
closeSync,
|
||||
constants,
|
||||
fstatSync,
|
||||
lstatSync,
|
||||
openSync,
|
||||
readSync,
|
||||
realpathSync,
|
||||
} from 'node:fs';
|
||||
import { isAbsolute } from 'node:path';
|
||||
import { TextDecoder } from 'node:util';
|
||||
|
||||
const MAXIMUM_CONTEXT_BYTES = 64 * 1024;
|
||||
const MAXIMUM_PATH_BYTES = 4_096;
|
||||
const CONTROL_PATTERN = /[\u0000-\u001f\u007f]/u;
|
||||
const CONTEXT_COMMANDS = Object.freeze([
|
||||
'package',
|
||||
'package-kubernetes',
|
||||
'worker-credential',
|
||||
'approval',
|
||||
'run',
|
||||
'automation',
|
||||
'model-credential',
|
||||
] as const);
|
||||
|
||||
type ContextCommandName = (typeof CONTEXT_COMMANDS)[number];
|
||||
type JsonObject = Record<string, unknown>;
|
||||
|
||||
export interface QingLong3ClusterProductContextCommand {
|
||||
readonly configFile: string;
|
||||
readonly kubernetesFile?: string;
|
||||
}
|
||||
|
||||
export interface QingLong3ClusterProductContext {
|
||||
readonly schemaVersion: 1;
|
||||
readonly commands: Readonly<
|
||||
Partial<Record<ContextCommandName, QingLong3ClusterProductContextCommand>>
|
||||
>;
|
||||
}
|
||||
|
||||
export class QingLong3ClusterProductContextError extends TypeError {
|
||||
readonly code = 'QL3_CLUSTER_PRODUCT_CONTEXT_INVALID';
|
||||
|
||||
constructor() {
|
||||
super('QingLong 3.0 Cluster operator context is invalid');
|
||||
this.name = 'QingLong3ClusterProductContextError';
|
||||
}
|
||||
}
|
||||
|
||||
function invalid(): never {
|
||||
throw new QingLong3ClusterProductContextError();
|
||||
}
|
||||
|
||||
function exactObject(value: unknown, keys: readonly string[]): JsonObject {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) invalid();
|
||||
const actual = Object.keys(value).sort();
|
||||
const expected = [...keys].sort();
|
||||
if (
|
||||
actual.length !== expected.length ||
|
||||
actual.some((key, index) => key !== expected[index])
|
||||
) {
|
||||
invalid();
|
||||
}
|
||||
return value as JsonObject;
|
||||
}
|
||||
|
||||
function object(value: unknown): JsonObject {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) invalid();
|
||||
return value as JsonObject;
|
||||
}
|
||||
|
||||
function currentUid(): number {
|
||||
if (typeof process.getuid !== 'function') invalid();
|
||||
const uid = process.getuid();
|
||||
if (!Number.isSafeInteger(uid) || uid < 0) invalid();
|
||||
return uid;
|
||||
}
|
||||
|
||||
function validatePrivatePath(filePath: unknown, uid: number): string {
|
||||
if (
|
||||
typeof filePath !== 'string' ||
|
||||
!isAbsolute(filePath) ||
|
||||
Buffer.byteLength(filePath, 'utf8') > MAXIMUM_PATH_BYTES ||
|
||||
CONTROL_PATTERN.test(filePath)
|
||||
) {
|
||||
invalid();
|
||||
}
|
||||
try {
|
||||
const status = lstatSync(filePath);
|
||||
if (
|
||||
!status.isFile() ||
|
||||
status.isSymbolicLink() ||
|
||||
status.uid !== uid ||
|
||||
(status.mode & 0o777) !== 0o600 ||
|
||||
realpathSync(filePath) !== filePath
|
||||
) {
|
||||
invalid();
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof QingLong3ClusterProductContextError) throw error;
|
||||
invalid();
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function readPrivateContext(filePath: string): Buffer {
|
||||
const uid = currentUid();
|
||||
validatePrivatePath(filePath, uid);
|
||||
let descriptor = -1;
|
||||
let bytes: Buffer | undefined;
|
||||
try {
|
||||
const before = lstatSync(filePath);
|
||||
if (before.size < 2 || before.size > MAXIMUM_CONTEXT_BYTES) invalid();
|
||||
descriptor = openSync(
|
||||
filePath,
|
||||
constants.O_RDONLY |
|
||||
((constants as unknown as Readonly<Record<string, number>>).O_CLOEXEC ??
|
||||
0) |
|
||||
(constants.O_NOFOLLOW ?? 0),
|
||||
);
|
||||
const opened = fstatSync(descriptor);
|
||||
if (
|
||||
opened.dev !== before.dev ||
|
||||
opened.ino !== before.ino ||
|
||||
opened.uid !== before.uid ||
|
||||
opened.mode !== before.mode ||
|
||||
opened.size !== before.size
|
||||
) {
|
||||
invalid();
|
||||
}
|
||||
bytes = Buffer.alloc(opened.size);
|
||||
let offset = 0;
|
||||
while (offset < bytes.length) {
|
||||
const count = readSync(
|
||||
descriptor,
|
||||
bytes,
|
||||
offset,
|
||||
bytes.length - offset,
|
||||
offset,
|
||||
);
|
||||
if (count < 1) invalid();
|
||||
offset += count;
|
||||
}
|
||||
const after = fstatSync(descriptor);
|
||||
if (
|
||||
after.dev !== opened.dev ||
|
||||
after.ino !== opened.ino ||
|
||||
after.uid !== opened.uid ||
|
||||
after.mode !== opened.mode ||
|
||||
after.size !== opened.size
|
||||
) {
|
||||
invalid();
|
||||
}
|
||||
return bytes;
|
||||
} catch (error) {
|
||||
bytes?.fill(0);
|
||||
if (error instanceof QingLong3ClusterProductContextError) throw error;
|
||||
throw new QingLong3ClusterProductContextError();
|
||||
} finally {
|
||||
if (descriptor >= 0) closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
export function loadQingLong3ClusterProductContext(
|
||||
contextFile: string,
|
||||
): Readonly<QingLong3ClusterProductContext> {
|
||||
let bytes: Buffer | undefined;
|
||||
try {
|
||||
bytes = readPrivateContext(contextFile);
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(
|
||||
new TextDecoder('utf-8', { fatal: true }).decode(bytes),
|
||||
);
|
||||
} catch {
|
||||
invalid();
|
||||
}
|
||||
const root = exactObject(parsed, ['schemaVersion', 'commands']);
|
||||
const commands = object(root.commands);
|
||||
const names = Object.keys(commands);
|
||||
if (
|
||||
root.schemaVersion !== 1 ||
|
||||
names.length < 1 ||
|
||||
names.length > CONTEXT_COMMANDS.length ||
|
||||
names.some(
|
||||
(name) => !CONTEXT_COMMANDS.includes(name as ContextCommandName),
|
||||
)
|
||||
) {
|
||||
invalid();
|
||||
}
|
||||
const uid = currentUid();
|
||||
const normalized: Partial<
|
||||
Record<ContextCommandName, QingLong3ClusterProductContextCommand>
|
||||
> = {};
|
||||
for (const name of names as ContextCommandName[]) {
|
||||
const tunnel = name === 'package-kubernetes';
|
||||
const entry = exactObject(
|
||||
commands[name],
|
||||
tunnel ? ['configFile', 'kubernetesFile'] : ['configFile'],
|
||||
);
|
||||
normalized[name] = Object.freeze({
|
||||
configFile: validatePrivatePath(entry.configFile, uid),
|
||||
...(tunnel
|
||||
? { kubernetesFile: validatePrivatePath(entry.kubernetesFile, uid) }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
commands: Object.freeze(normalized),
|
||||
});
|
||||
} finally {
|
||||
bytes?.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveQingLong3ClusterProductContextArguments(
|
||||
contextFile: string,
|
||||
commandName: string,
|
||||
argv: readonly string[],
|
||||
): readonly string[] {
|
||||
if (
|
||||
argv.some(
|
||||
(argument) =>
|
||||
argument === '--context' ||
|
||||
argument.startsWith('--context=') ||
|
||||
argument === '--config' ||
|
||||
argument.startsWith('--config=') ||
|
||||
argument === '--kubernetes' ||
|
||||
argument.startsWith('--kubernetes='),
|
||||
)
|
||||
) {
|
||||
invalid();
|
||||
}
|
||||
const context = loadQingLong3ClusterProductContext(contextFile);
|
||||
const command = context.commands[commandName as ContextCommandName];
|
||||
if (command === undefined) invalid();
|
||||
return Object.freeze([
|
||||
`--config=${command.configFile}`,
|
||||
...(command.kubernetesFile === undefined
|
||||
? []
|
||||
: [`--kubernetes=${command.kubernetesFile}`]),
|
||||
...argv,
|
||||
]);
|
||||
}
|
||||
@@ -22,6 +22,10 @@ const {
|
||||
clusterProductSignalExitCode,
|
||||
forwardClusterProductSignals,
|
||||
} = require('../dist/product-cli/cli.js');
|
||||
const {
|
||||
loadQingLong3ClusterProductContext,
|
||||
resolveQingLong3ClusterProductContextArguments,
|
||||
} = require('../dist/product-cli/productContext.js');
|
||||
|
||||
function runCli(args) {
|
||||
return spawnSync(process.execPath, [cliPath, ...args], {
|
||||
@@ -30,6 +34,37 @@ function runCli(args) {
|
||||
});
|
||||
}
|
||||
|
||||
function privateFile(directory, name, contents) {
|
||||
const filePath = path.join(directory, name);
|
||||
fs.writeFileSync(filePath, contents, { mode: 0o600 });
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function contextFixture(t) {
|
||||
const directory = fs.realpathSync(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-cluster-context-')),
|
||||
);
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||
const runConfig = privateFile(directory, 'run-client.json', '{}');
|
||||
const packageConfig = privateFile(directory, 'package-client.json', '{}');
|
||||
const kubernetes = privateFile(directory, 'kubernetes.json', '{}');
|
||||
const contextFile = privateFile(
|
||||
directory,
|
||||
'operator-context.json',
|
||||
JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
commands: {
|
||||
run: { configFile: runConfig },
|
||||
'package-kubernetes': {
|
||||
configFile: packageConfig,
|
||||
kubernetesFile: kubernetes,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
return { directory, runConfig, packageConfig, kubernetes, contextFile };
|
||||
}
|
||||
|
||||
test('catalog exposes only reviewed remote clients from the same package', () => {
|
||||
assert.equal(manifest.bin['ql3-cluster-admin'], 'dist/product-cli/cli.js');
|
||||
assert.equal(QINGLONG3_CLUSTER_PRODUCT_COMMANDS.length, 7);
|
||||
@@ -128,6 +163,162 @@ test('resolves only static remote-client targets and preserves opaque arguments'
|
||||
}
|
||||
});
|
||||
|
||||
test('injects only stable paths from an explicit owner-private operator context', (t) => {
|
||||
const fixture = contextFixture(t);
|
||||
const context = loadQingLong3ClusterProductContext(fixture.contextFile);
|
||||
assert.deepEqual(context, {
|
||||
schemaVersion: 1,
|
||||
commands: {
|
||||
run: { configFile: fixture.runConfig },
|
||||
'package-kubernetes': {
|
||||
configFile: fixture.packageConfig,
|
||||
kubernetesFile: fixture.kubernetes,
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.equal(Object.isFrozen(context), true);
|
||||
assert.equal(Object.isFrozen(context.commands), true);
|
||||
assert.equal(Object.isFrozen(context.commands.run), true);
|
||||
|
||||
const run = resolveQingLong3ClusterProductCommand(
|
||||
[
|
||||
'run',
|
||||
`--context=${fixture.contextFile}`,
|
||||
'--command=/private/command.json',
|
||||
'--assertion=/private/assertion.jwt',
|
||||
],
|
||||
moduleDirectory,
|
||||
);
|
||||
assert.equal(run.kind, 'invoke');
|
||||
assert.deepEqual(run.argv, [
|
||||
`--config=${fixture.runConfig}`,
|
||||
'--command=/private/command.json',
|
||||
'--assertion=/private/assertion.jwt',
|
||||
]);
|
||||
|
||||
const tunnel = resolveQingLong3ClusterProductContextArguments(
|
||||
fixture.contextFile,
|
||||
'package-kubernetes',
|
||||
['--command=/private/command.json', '--assertion=/private/assertion.jwt'],
|
||||
);
|
||||
assert.deepEqual(tunnel, [
|
||||
`--config=${fixture.packageConfig}`,
|
||||
`--kubernetes=${fixture.kubernetes}`,
|
||||
'--command=/private/command.json',
|
||||
'--assertion=/private/assertion.jwt',
|
||||
]);
|
||||
});
|
||||
|
||||
test('operator context rejects weak files, unknown or secret fields and argument conflicts', (t) => {
|
||||
const fixture = contextFixture(t);
|
||||
const cases = [
|
||||
{ schemaVersion: 1, commands: {} },
|
||||
{ schemaVersion: 2, commands: { run: { configFile: fixture.runConfig } } },
|
||||
{
|
||||
schemaVersion: 1,
|
||||
commands: { unknown: { configFile: fixture.runConfig } },
|
||||
},
|
||||
{
|
||||
schemaVersion: 1,
|
||||
commands: {
|
||||
run: {
|
||||
configFile: fixture.runConfig,
|
||||
assertionFile: '/private/assertion.jwt',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
schemaVersion: 1,
|
||||
commands: {
|
||||
run: { configFile: fixture.runConfig, privateKeyFile: '/private/key' },
|
||||
},
|
||||
},
|
||||
{
|
||||
schemaVersion: 1,
|
||||
commands: {
|
||||
'package-kubernetes': { configFile: fixture.packageConfig },
|
||||
},
|
||||
},
|
||||
];
|
||||
for (const [index, value] of cases.entries()) {
|
||||
const filePath = privateFile(
|
||||
fixture.directory,
|
||||
`invalid-${index}.json`,
|
||||
JSON.stringify(value),
|
||||
);
|
||||
assert.throws(
|
||||
() => loadQingLong3ClusterProductContext(filePath),
|
||||
/operator context is invalid/,
|
||||
);
|
||||
}
|
||||
|
||||
const publicContext = privateFile(
|
||||
fixture.directory,
|
||||
'public.json',
|
||||
JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
commands: { run: { configFile: fixture.runConfig } },
|
||||
}),
|
||||
);
|
||||
fs.chmodSync(publicContext, 0o644);
|
||||
assert.throws(() => loadQingLong3ClusterProductContext(publicContext));
|
||||
|
||||
const symlink = path.join(fixture.directory, 'context-link.json');
|
||||
fs.symlinkSync(fixture.contextFile, symlink);
|
||||
assert.throws(() => loadQingLong3ClusterProductContext(symlink));
|
||||
|
||||
const binaryRejected = runCli([
|
||||
'run',
|
||||
`--context=${publicContext}`,
|
||||
'--command=/private/command.json',
|
||||
'--assertion=/private/assertion.jwt',
|
||||
]);
|
||||
assert.equal(binaryRejected.status, 78);
|
||||
assert.equal(binaryRejected.stdout, '');
|
||||
assert.deepEqual(JSON.parse(binaryRejected.stderr), {
|
||||
schemaVersion: 1,
|
||||
component: 'qinglong3-cluster-product-cli',
|
||||
code: 'QL3_CLUSTER_PRODUCT_CONTEXT_INVALID',
|
||||
message: 'QingLong 3.0 Cluster operator context is invalid',
|
||||
});
|
||||
assert.equal(binaryRejected.stderr.includes(fixture.directory), false);
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
resolveQingLong3ClusterProductCommand(
|
||||
[
|
||||
'approval',
|
||||
`--context=${fixture.contextFile}`,
|
||||
'--command=/private/command.json',
|
||||
'--assertion=/private/assertion.jwt',
|
||||
],
|
||||
moduleDirectory,
|
||||
),
|
||||
/operator context is invalid/,
|
||||
);
|
||||
|
||||
for (const args of [
|
||||
['run', '--context'],
|
||||
['run', '--context='],
|
||||
[
|
||||
'run',
|
||||
`--context=${fixture.contextFile}`,
|
||||
`--context=${fixture.contextFile}`,
|
||||
],
|
||||
]) {
|
||||
const result = resolveQingLong3ClusterProductCommand(args, moduleDirectory);
|
||||
assert.equal(result.kind, 'invalid');
|
||||
}
|
||||
assert.throws(
|
||||
() =>
|
||||
resolveQingLong3ClusterProductCommand(
|
||||
['run', `--context=${fixture.contextFile}`, '--config'],
|
||||
moduleDirectory,
|
||||
),
|
||||
/operator context is invalid/,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects symlink targets and package manifests', (t) => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-cluster-product-'));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
@@ -169,6 +360,7 @@ test('binary exposes help/version and delegates without a shell', () => {
|
||||
const help = runCli(['--help']);
|
||||
assert.equal(help.status, 0);
|
||||
assert.match(help.stdout, /^Usage: ql3-cluster-admin <command>/);
|
||||
assert.match(help.stdout, /--context=\/absolute\/operator-context\.json/);
|
||||
assert.equal(help.stderr, '');
|
||||
|
||||
const version = runCli(['--version']);
|
||||
|
||||
Reference in New Issue
Block a user