Ops-dashboard/ops-agent/src/routes/exec.ts
Scrum4Me Agent 4bccbf28f3 feat: ops-agent Fastify service met SSE, whitelist en systemd-unit
- ops-agent/: Node.js Fastify+TypeScript service
  - GET /agent/v1/health
  - POST /agent/v1/exec → SSE stream (stdout/stderr/exit events)
  - Whitelist geladen uit /etc/ops-agent/commands.yml bij opstart
  - Auth via Bearer shared secret (/etc/ops-agent/secret, mode 0640)
  - Vier standaard commando's: docker_ps, git_status, systemctl_status,
    caddy_show_config
- deploy/ops-agent/ops-agent.service: systemd-unit (User=ops-agent,
  Restart=on-failure, StandardOutput=journal)
- deploy/ops-agent/setup.sh: aanmaken system-user, build, deploy,
  systemctl enable --now ops-agent

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 17:15:44 +02:00

56 lines
1.6 KiB
TypeScript

import { FastifyInstance, FastifyRequest } from 'fastify';
import { spawn } from 'child_process';
import { getCommand } from '../whitelist.js';
interface ExecBody {
command_key: string;
args?: string[];
}
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` });
}
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.exec.split(' ');
const child = spawn(bin, [...staticArgs, ...args], { shell: false });
child.stdout.on('data', (chunk: Buffer) => {
sendEvent('stdout', chunk.toString());
});
child.stderr.on('data', (chunk: Buffer) => {
sendEvent('stderr', chunk.toString());
});
child.on('close', (code) => {
reply.raw.write(`event: exit\ndata: ${JSON.stringify({ code })}\n\n`);
reply.raw.end();
});
child.on('error', (err) => {
reply.raw.write(`event: error\ndata: ${JSON.stringify({ message: err.message })}\n\n`);
reply.raw.end();
});
req.raw.on('close', () => {
child.kill();
});
});
}