mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-28 09:02:12 +08:00
* feat(cli): add unified Commander CLI for QingLong 2.x * fix(cli): publish via npm and address security review feedback * ci(cli): package npm artifacts and remove evaluation collateral * test(cli): use a fixed shell fixture for log retention * refactor(cli): separate remote npm client from panel tools * feat(cli): cover active panel OpenAPI resources * docs(cli): unify authentication and skill guidance * refactor(cli): isolate internal commands and generate Commander help * refactor(cli): organize remote and internal modules by responsibility * ci(cli): publish verified npm archives from master * fix(cli): publish under the whyour npm scope * ci: use npm trusted publishing for both packages * docs: introduce the published CLI on the project homepage * fix(cli): preserve server log truncation and correct login hints * fix(cli): accept dashboard record request bodies * fix(cli): preserve stdin for local task execution * fix(cli): resolve task executables after changing directory * fix(cli): preserve shell function tasks and sanitize test failures * fix(cli): preserve shell hook state and resolve workdir after hooks * fix(cli): preserve cleanup across shared shell task timeouts * fix(cli): isolate shell control descriptors and reap timed-out descendants
336 lines
12 KiB
JavaScript
336 lines
12 KiB
JavaScript
const test = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs/promises');
|
|
const path = require('node:path');
|
|
const os = require('node:os');
|
|
const http = require('node:http');
|
|
const https = require('node:https');
|
|
const net = require('node:net');
|
|
const { randomUUID } = require('node:crypto');
|
|
const { execFileSync, spawn } = require('node:child_process');
|
|
const { createContext } = require('../../dist/internal/runtime/context');
|
|
const { syncRepository } = require('../../dist/internal/subscription/subscriptionRunner');
|
|
const git = process.platform === 'darwin' ? '/usr/bin/git' : 'git';
|
|
|
|
for (const tls of [false, true])
|
|
test(
|
|
`authenticated Git ${
|
|
tls ? 'HTTPS/CONNECT' : 'HTTP/proxy'
|
|
} transport preserves checkout after access failure`,
|
|
{ timeout: 30000 },
|
|
async (t) => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-git-http-'));
|
|
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
|
const execPath = execFileSync(git, ['--exec-path'], {
|
|
encoding: 'utf8',
|
|
}).trim();
|
|
await fs.access(path.join(execPath, 'git-http-backend')).catch(() => {
|
|
throw new Error(
|
|
'Test prerequisite missing: git-http-backend (Alpine: git-daemon).',
|
|
);
|
|
});
|
|
const source = path.join(root, 'owner/repo.git');
|
|
await fs.mkdir(source, { recursive: true });
|
|
const run = (...args) =>
|
|
execFileSync(git, ['-C', source, ...args], { stdio: 'pipe' });
|
|
run('init', '-b', 'main');
|
|
run('config', 'user.name', 'Fixture');
|
|
run('config', 'user.email', 'fixture@example.invalid');
|
|
await fs.writeFile(path.join(source, 'wrong.js'), 'wrong branch');
|
|
run('add', '.');
|
|
run('commit', '-m', 'main');
|
|
run('checkout', '-b', 'selected');
|
|
await fs.rm(path.join(source, 'wrong.js'));
|
|
await fs.writeFile(path.join(source, 'job.js'), 'version one');
|
|
await fs.writeFile(path.join(source, 'helper.js'), 'dependency');
|
|
run('add', '-A');
|
|
run('commit', '-m', 'selected');
|
|
const authorization = `Basic ${Buffer.from(
|
|
`fixture:${randomUUID()}`,
|
|
).toString('base64')}`;
|
|
let failure = false;
|
|
const requests = [];
|
|
const children = new Set();
|
|
let certificate;
|
|
let tlsOptions;
|
|
if (tls) {
|
|
certificate = path.join(root, 'certificate.pem');
|
|
const key = path.join(root, 'key.pem');
|
|
execFileSync(
|
|
'openssl',
|
|
[
|
|
'req',
|
|
'-x509',
|
|
'-newkey',
|
|
'rsa:2048',
|
|
'-nodes',
|
|
'-keyout',
|
|
key,
|
|
'-out',
|
|
certificate,
|
|
'-subj',
|
|
'/CN=127.0.0.1',
|
|
'-addext',
|
|
'subjectAltName=IP:127.0.0.1',
|
|
'-days',
|
|
'1',
|
|
],
|
|
{ stdio: 'pipe' },
|
|
);
|
|
tlsOptions = {
|
|
key: await fs.readFile(key),
|
|
cert: await fs.readFile(certificate),
|
|
};
|
|
}
|
|
const serveGit = (req, res) => {
|
|
const url = new URL(req.url, 'http://fixture.invalid');
|
|
requests.push({
|
|
method: req.method,
|
|
path: url.pathname,
|
|
proxy: req.url.startsWith('http://'),
|
|
});
|
|
if (failure) {
|
|
res.writeHead(503).end();
|
|
req.resume();
|
|
return;
|
|
}
|
|
if (req.headers.authorization !== authorization) {
|
|
res
|
|
.writeHead(401, { 'WWW-Authenticate': 'Basic realm="fixture"' })
|
|
.end();
|
|
req.resume();
|
|
return;
|
|
}
|
|
const child = spawn(git, ['http-backend'], {
|
|
env: {
|
|
PATH: process.env.PATH,
|
|
GIT_PROJECT_ROOT: root,
|
|
GIT_HTTP_EXPORT_ALL: '1',
|
|
REQUEST_METHOD: req.method,
|
|
PATH_INFO: url.pathname,
|
|
QUERY_STRING: url.search.slice(1),
|
|
CONTENT_TYPE: req.headers['content-type'] || '',
|
|
CONTENT_LENGTH: req.headers['content-length'] || '',
|
|
REMOTE_USER: 'fixture',
|
|
},
|
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
});
|
|
children.add(child);
|
|
child.on('error', () => res.destroy());
|
|
child.on('close', () => children.delete(child));
|
|
child.stderr.resume();
|
|
child.stdin.on('error', () => {});
|
|
req.pipe(child.stdin);
|
|
let pending = Buffer.alloc(0),
|
|
headersSent = false;
|
|
child.stdout.on('data', (chunk) => {
|
|
if (headersSent) {
|
|
res.write(chunk);
|
|
return;
|
|
}
|
|
pending = Buffer.concat([pending, chunk]);
|
|
const boundary = pending.indexOf('\r\n\r\n');
|
|
if (boundary < 0) return;
|
|
let status = 200;
|
|
const headers = {};
|
|
for (const line of pending
|
|
.subarray(0, boundary)
|
|
.toString()
|
|
.split('\r\n')) {
|
|
const colon = line.indexOf(':');
|
|
const key = line.slice(0, colon),
|
|
value = line.slice(colon + 1).trim();
|
|
if (key.toLowerCase() === 'status')
|
|
status = Number(value.split(' ')[0]);
|
|
else headers[key] = value;
|
|
}
|
|
res.writeHead(status, headers);
|
|
headersSent = true;
|
|
res.write(pending.subarray(boundary + 4));
|
|
});
|
|
child.stdout.on('end', () => res.end());
|
|
};
|
|
const server = tls
|
|
? https.createServer(tlsOptions, serveGit)
|
|
: http.createServer(serveGit);
|
|
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
t.after(async () => {
|
|
for (const child of children) child.kill('SIGKILL');
|
|
server.closeAllConnections();
|
|
await new Promise((resolve) => server.close(resolve));
|
|
});
|
|
const endpoint = `${tls ? 'https' : 'http'}://127.0.0.1:${
|
|
server.address().port
|
|
}`;
|
|
let proxyEndpoint = endpoint;
|
|
let tunnels = 0;
|
|
if (tls) {
|
|
const sockets = new Set();
|
|
const tunnel = http.createServer((req, res) =>
|
|
res.writeHead(405).end(),
|
|
);
|
|
tunnel.on('connect', (req, client, head) => {
|
|
if (req.url !== `127.0.0.1:${server.address().port}`) {
|
|
client.end('HTTP/1.1 403 Forbidden\r\n\r\n');
|
|
return;
|
|
}
|
|
tunnels++;
|
|
const upstream = net.connect(
|
|
server.address().port,
|
|
'127.0.0.1',
|
|
() => {
|
|
client.write('HTTP/1.1 200 Connection Established\r\n\r\n');
|
|
if (head.length) upstream.write(head);
|
|
client.pipe(upstream);
|
|
upstream.pipe(client);
|
|
},
|
|
);
|
|
for (const socket of [client, upstream]) {
|
|
sockets.add(socket);
|
|
socket.on('close', () => sockets.delete(socket));
|
|
}
|
|
client.on('error', () => upstream.destroy());
|
|
upstream.on('error', () => client.destroy());
|
|
client.on('close', () => upstream.destroy());
|
|
upstream.on('close', () => client.destroy());
|
|
});
|
|
await new Promise((resolve) => tunnel.listen(0, '127.0.0.1', resolve));
|
|
proxyEndpoint = `http://127.0.0.1:${tunnel.address().port}`;
|
|
t.after(async () => {
|
|
for (const socket of sockets) socket.destroy();
|
|
await new Promise((resolve) => tunnel.close(resolve));
|
|
});
|
|
}
|
|
for (const [proxy, credentialMode] of [
|
|
[false, 'header'],
|
|
[true, 'header'],
|
|
[false, 'helper'],
|
|
[true, 'helper'],
|
|
]) {
|
|
failure = false;
|
|
const panel = path.join(
|
|
root,
|
|
`${credentialMode}-${proxy ? 'proxied-panel' : 'direct-panel'}`,
|
|
);
|
|
await fs.mkdir(panel);
|
|
const env = {
|
|
PATH: `/usr/bin:/bin:${process.env.PATH}`,
|
|
GIT_TERMINAL_PROMPT: '0',
|
|
...(tls ? { GIT_SSL_CAINFO: certificate } : {}),
|
|
GIT_CONFIG_NOSYSTEM: '1',
|
|
GIT_CONFIG_GLOBAL: os.devNull,
|
|
GIT_CONFIG_COUNT: '1',
|
|
GIT_CONFIG_KEY_0: 'http.extraHeader',
|
|
GIT_CONFIG_VALUE_0: `Authorization: ${authorization}`,
|
|
http_proxy: '',
|
|
https_proxy: '',
|
|
all_proxy: '',
|
|
HTTP_PROXY: '',
|
|
HTTPS_PROXY: '',
|
|
ALL_PROXY: '',
|
|
NO_PROXY: '',
|
|
no_proxy: '',
|
|
};
|
|
if (credentialMode === 'helper') {
|
|
const credentialFile = path.join(panel, 'credentials');
|
|
const credential = new URL(
|
|
proxy && !tls ? 'http://repository.invalid' : endpoint,
|
|
);
|
|
const [username, password] = Buffer.from(
|
|
authorization.slice(6),
|
|
'base64',
|
|
)
|
|
.toString()
|
|
.split(':');
|
|
credential.username = username;
|
|
credential.password = password;
|
|
await fs.writeFile(credentialFile, credential.toString() + '\n', {
|
|
mode: 0o600,
|
|
});
|
|
env.GIT_CONFIG_KEY_0 = 'credential.helper';
|
|
env.GIT_CONFIG_VALUE_0 =
|
|
"store --file='" + credentialFile.replaceAll("'", "'\"'\"'") + "'";
|
|
}
|
|
const context = createContext({ root: panel }, env);
|
|
const input = {
|
|
url: `${
|
|
proxy && !tls ? 'http://repository.invalid' : endpoint
|
|
}/owner/repo.git`,
|
|
branch: 'selected',
|
|
include: '^job',
|
|
dependencies: '^helper',
|
|
autoAdd: false,
|
|
autoDelete: false,
|
|
...(proxy ? { proxy: proxyEndpoint } : {}),
|
|
};
|
|
const start = requests.length;
|
|
const previousTunnels = tunnels;
|
|
const result = await syncRepository(context, input);
|
|
const destination = path.join(
|
|
context.paths.dir_scripts,
|
|
result.repository,
|
|
);
|
|
assert.equal(
|
|
await fs.readFile(path.join(destination, 'job.js'), 'utf8'),
|
|
'version one',
|
|
);
|
|
assert.equal(
|
|
await fs.readFile(path.join(destination, 'helper.js'), 'utf8'),
|
|
'dependency',
|
|
);
|
|
await assert.rejects(fs.access(path.join(destination, 'wrong.js')));
|
|
assert.ok(
|
|
requests
|
|
.slice(start)
|
|
.some(
|
|
(row) =>
|
|
row.method === 'POST' &&
|
|
row.path.endsWith('/git-upload-pack') &&
|
|
row.proxy === (proxy && !tls),
|
|
),
|
|
);
|
|
if (tls && proxy)
|
|
assert.ok(
|
|
tunnels > previousTunnels,
|
|
'HTTPS must traverse the CONNECT proxy',
|
|
);
|
|
const checkout = path.join(context.paths.dir_repo, result.repository);
|
|
const head = execFileSync(git, [
|
|
'-C',
|
|
checkout,
|
|
'rev-parse',
|
|
'HEAD',
|
|
]).toString();
|
|
for (const kind of [
|
|
'unauthorized',
|
|
'unavailable',
|
|
...(tls ? ['untrusted'] : []),
|
|
]) {
|
|
failure = kind === 'unavailable';
|
|
const failedContext =
|
|
kind === 'unauthorized'
|
|
? createContext(
|
|
{ root: panel },
|
|
{ ...env, GIT_CONFIG_COUNT: '0' },
|
|
)
|
|
: kind === 'untrusted'
|
|
? createContext(
|
|
{ root: panel },
|
|
{ ...env, GIT_SSL_CAINFO: undefined },
|
|
)
|
|
: context;
|
|
await assert.rejects(syncRepository(failedContext, input));
|
|
assert.equal(
|
|
await fs.readFile(path.join(destination, 'job.js'), 'utf8'),
|
|
'version one',
|
|
);
|
|
assert.equal(
|
|
execFileSync(git, ['-C', checkout, 'rev-parse', 'HEAD']).toString(),
|
|
head,
|
|
);
|
|
assert.deepEqual(await fs.readdir(context.paths.dir_tmp), []);
|
|
}
|
|
}
|
|
},
|
|
);
|