- CommandDef now uses cmd[] array instead of exec string — no shell splitting - validateArgs() checks every request arg against allowed list; rejects unknown values - spawn() called with shell:false (execFile semantics); cwd from config - Audit log (JSON) per call to stdout → captured by systemd journal - commands.yml.example updated to new schema with 4 read-only commands Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
87 lines
2.3 KiB
TypeScript
87 lines
2.3 KiB
TypeScript
import { FastifyInstance, FastifyRequest } from 'fastify';
|
|
import { spawn } from 'child_process';
|
|
import { getCommand, validateArgs } from '../whitelist.js';
|
|
|
|
interface ExecBody {
|
|
command_key: string;
|
|
args?: string[];
|
|
}
|
|
|
|
function auditLog(
|
|
command_key: string,
|
|
args: string[],
|
|
exit_code: number | null,
|
|
duration_ms: number,
|
|
): void {
|
|
// systemd captures stdout/stderr into the journal
|
|
console.log(
|
|
JSON.stringify({
|
|
audit: true,
|
|
command_key,
|
|
args,
|
|
exit_code,
|
|
duration_ms,
|
|
}),
|
|
);
|
|
}
|
|
|
|
export async function execRoutes(app: FastifyInstance): Promise<void> {
|
|
app.post('/agent/v1/exec', async (req: FastifyRequest<{ Body: ExecBody }>, reply) => {
|
|
const { command_key, args = [] } = req.body;
|
|
|
|
const def = getCommand(command_key);
|
|
if (!def) {
|
|
return reply
|
|
.status(403)
|
|
.send({ error: `command_key '${command_key}' is not in the whitelist` });
|
|
}
|
|
|
|
const argError = validateArgs(def, args);
|
|
if (argError) {
|
|
return reply.status(400).send({ error: argError });
|
|
}
|
|
|
|
reply.raw.writeHead(200, {
|
|
'Content-Type': 'text/event-stream',
|
|
'Cache-Control': 'no-cache',
|
|
Connection: 'keep-alive',
|
|
});
|
|
|
|
const sendEvent = (event: string, data: string) => {
|
|
reply.raw.write(`event: ${event}\ndata: ${JSON.stringify({ data })}\n\n`);
|
|
};
|
|
|
|
const [bin, ...staticArgs] = def.cmd;
|
|
// shell: false ensures no shell interpolation — args are passed as-is (execFile semantics)
|
|
const child = spawn(bin, [...staticArgs, ...args], {
|
|
shell: false,
|
|
cwd: def.cwd,
|
|
});
|
|
|
|
const startedAt = Date.now();
|
|
|
|
child.stdout.on('data', (chunk: Buffer) => {
|
|
sendEvent('stdout', chunk.toString());
|
|
});
|
|
|
|
child.stderr.on('data', (chunk: Buffer) => {
|
|
sendEvent('stderr', chunk.toString());
|
|
});
|
|
|
|
child.on('close', (code) => {
|
|
auditLog(command_key, args, code, Date.now() - startedAt);
|
|
reply.raw.write(`event: exit\ndata: ${JSON.stringify({ code })}\n\n`);
|
|
reply.raw.end();
|
|
});
|
|
|
|
child.on('error', (err) => {
|
|
auditLog(command_key, args, null, Date.now() - startedAt);
|
|
reply.raw.write(`event: error\ndata: ${JSON.stringify({ message: err.message })}\n\n`);
|
|
reply.raw.end();
|
|
});
|
|
|
|
req.raw.on('close', () => {
|
|
child.kill();
|
|
});
|
|
});
|
|
}
|