/** * Bulk command execution across devices (#118). * * The dry run is not decoration. This endpoint family can misconfigure an entire * fleet in one call, and the difference between a tool and an accident is being * able to see what will happen first. */ import { Router } from 'express'; import type { Request, Response } from 'express'; import { requireAuth, requireWrite } from '../middleware/auth'; import { siteScopeByDevice } from '../utils/siteScope'; import { activeSite, enforceSiteAccess, unauthorizedDeviceIds } from '../middleware/site'; import { query, queryOne } from '../config/database'; import { commandRunner } from '../services/CommandRunner'; const router = Router(); router.use(requireAuth); router.use(enforceSiteAccess); /** Commands that change how a device is reached, and so deserve a warning. */ const RISKY_PATTERNS: { pattern: RegExp; why: string }[] = [ { pattern: /\/ip\/?\s*address\s+(remove|set)/i, why: 'changes IP addressing, which can remove the management address' }, { pattern: /\/ip\/?\s*route\s+(remove|set)/i, why: 'changes routing, which can cut the path back to this server' }, { pattern: /\/ip\/?\s*firewall/i, why: 'edits firewall rules, which can block management access' }, { pattern: /\/interface\/?\s*\w*\s*(disable|remove)/i, why: 'disables or removes an interface, possibly the one in use' }, { pattern: /\/system\/?\s*(reboot|shutdown|reset-configuration)/i, why: 'reboots, shuts down or resets the device' }, { pattern: /\/user\s+(remove|set|disable)/i, why: 'changes user accounts, which can revoke this server’s access' }, { pattern: /\/ip\/?\s*service\s+(disable|set|remove)/i, why: 'changes management services such as the API or SSH' }, ]; function assessCommand(command: string): { risky: boolean; reasons: string[] } { const reasons = RISKY_PATTERNS.filter((r) => r.pattern.test(command)).map((r) => r.why); return { risky: reasons.length > 0, reasons }; } /** * POST /api/commands/preview — what would happen, without doing it. * * Returns the devices, their wave assignment, and any reason the command looks * capable of severing management. Warnings only: the operator decides. */ router.post('/preview', async (req: Request, res: Response) => { const { command, device_ids, wave_size } = req.body as { command?: string; device_ids?: number[]; wave_size?: number }; if (!command?.trim()) return res.status(400).json({ error: 'command is required' }); if (!Array.isArray(device_ids) || device_ids.length === 0) { return res.status(400).json({ error: 'device_ids array is required' }); } const forbidden = await unauthorizedDeviceIds(req, device_ids); if (forbidden.length) { return res.status(403).json({ error: 'One or more devices are outside your authorized sites', device_ids: forbidden }); } const size = Math.max(1, Math.min(50, Number(wave_size) || 1)); const devices = await query<{ id: number; name: string; ip_address: string; status: string }>( `SELECT id, name, ip_address, status FROM devices WHERE id = ANY($1::int[]) ORDER BY name`, [device_ids] ); // Devices without any SSH credential cannot be reached at all; saying so now // is better than a wave of identical authentication failures. const unreachable = await query<{ id: number; name: string }>( `SELECT d.id, d.name FROM devices d WHERE d.id = ANY($1::int[]) AND d.ssh_username IS NULL AND d.api_username IS NULL`, [device_ids] ); res.json({ command, ...assessCommand(command), wave_size: size, devices: devices.map((d, i) => ({ ...d, wave: Math.floor(i / size) + 1 })), waves: Math.ceil(devices.length / size), unreachable, }); }); // POST /api/commands/runs — create a run, optionally starting it immediately router.post('/runs', requireWrite, async (req: Request, res: Response) => { const { name, command, device_ids, wave_size, halt_on_failure, use_change_guard, start, } = req.body as { name?: string; command?: string; device_ids?: number[]; wave_size?: number; halt_on_failure?: boolean; use_change_guard?: boolean; start?: boolean; }; if (!command?.trim()) return res.status(400).json({ error: 'command is required' }); if (!Array.isArray(device_ids) || device_ids.length === 0) { return res.status(400).json({ error: 'device_ids array is required' }); } const forbidden = await unauthorizedDeviceIds(req, device_ids); if (forbidden.length) { return res.status(403).json({ error: 'One or more devices are outside your authorized sites', device_ids: forbidden }); } const size = Math.max(1, Math.min(50, Number(wave_size) || 1)); const run = await queryOne<{ id: number }>( `INSERT INTO command_runs (name, command, wave_size, halt_on_failure, use_change_guard, created_by) VALUES ($1,$2,$3,$4,$5,$6) RETURNING id`, [ (name?.trim() || command.trim()).slice(0, 120), command.trim(), size, halt_on_failure !== false, // Guards are opt-out, never opt-in. Someone who has not thought about it // gets the protected behaviour. use_change_guard !== false, (req as unknown as { user?: { username?: string } }).user?.username ?? null, ] ); const ordered = await query<{ id: number }>( `SELECT id FROM devices WHERE id = ANY($1::int[]) ORDER BY name`, [device_ids] ); for (const [i, d] of ordered.entries()) { await query( `INSERT INTO command_run_devices (run_id, device_id, wave) VALUES ($1,$2,$3)`, [run!.id, d.id, Math.floor(i / size) + 1] ); } if (start) { try { await commandRunner.start(run!.id); } catch (e) { return res.status(409).json({ error: (e as Error).message, id: run!.id }); } } res.status(201).json({ id: run!.id }); }); // GET /api/commands/runs — recent runs with progress router.get('/runs', async (req: Request, res: Response) => { // A run is shown when it touched at least one device in the active site. const memberFilter = siteScopeByDevice(activeSite(req), 'crd.device_id'); const runs = await query( `SELECT r.*, COUNT(d.*)::int AS total, COUNT(*) FILTER (WHERE d.status = 'success')::int AS succeeded, COUNT(*) FILTER (WHERE d.status IN ('failed','reverted'))::int AS failed FROM command_runs r LEFT JOIN command_run_devices d ON d.run_id = r.id ${memberFilter ? `WHERE EXISTS (SELECT 1 FROM command_run_devices crd WHERE crd.run_id = r.id AND ${memberFilter})` : ''} GROUP BY r.id ORDER BY r.id DESC LIMIT 25` ); res.json(runs); }); // GET /api/commands/runs/:id — one run with per-device output router.get('/runs/:id', async (req: Request, res: Response) => { const run = await queryOne(`SELECT * FROM command_runs WHERE id = $1`, [req.params.id]); if (!run) return res.status(404).json({ error: 'Run not found' }); const devices = await query( `SELECT rd.*, d.name AS device_name, d.ip_address FROM command_run_devices rd JOIN devices d ON d.id = rd.device_id WHERE rd.run_id = $1 ORDER BY rd.wave, d.name`, [req.params.id] ); res.json({ ...run, devices }); }); /** * GET /api/commands/runs/:id/export — the run as a downloadable record. * * Asked for on #118 for archiving and audit. CSV rather than JSON because the * audience is a spreadsheet or a ticket attachment, and output is quoted so a * multi-line device response survives the round trip intact. */ router.get('/runs/:id/export', async (req: Request, res: Response) => { const run = await queryOne<{ id: number; name: string; command: string; status: string; created_at: string }>( `SELECT id, name, command, status, created_at FROM command_runs WHERE id = $1`, [req.params.id] ); if (!run) return res.status(404).json({ error: 'Run not found' }); const rows = await query<{ device_name: string; ip_address: string; wave: number; status: string; output: string | null; error: string | null; guarded: boolean; started_at: string | null; finished_at: string | null; }>( `SELECT d.name AS device_name, d.ip_address, rd.wave, rd.status, rd.output, rd.error, rd.guarded, rd.started_at, rd.finished_at FROM command_run_devices rd JOIN devices d ON d.id = rd.device_id WHERE rd.run_id = $1 ORDER BY rd.wave, d.name`, [req.params.id] ); // Timestamps arrive from the driver as Date objects, whose default string form // ("Thu Sep 03 2026 15:45:29 GMT+0000 (Coordinated Universal Time)") no // spreadsheet will parse. ISO is both sortable and machine-readable. const esc = (v: unknown) => { const t = v == null ? '' : v instanceof Date ? v.toISOString() : String(v); // Always quote: output routinely contains commas, quotes and newlines, and // a record that cannot be reopened is not an archive. return `"${t.replace(/"/g, '""')}"`; }; const lines = [ ['run_id', 'run_name', 'command', 'run_status', 'device', 'ip_address', 'wave', 'status', 'guarded', 'started_at', 'finished_at', 'output', 'error'].join(','), ...rows.map((r) => [ run.id, run.name, run.command, run.status, r.device_name.trim(), r.ip_address, r.wave, r.status, r.guarded, r.started_at ?? '', r.finished_at ?? '', r.output ?? '', r.error ?? '', ].map(esc).join(',')), ]; const stamp = new Date(run.created_at).toISOString().slice(0, 19).replace(/[:T]/g, '-'); res.setHeader('Content-Type', 'text/csv; charset=utf-8'); res.setHeader('Content-Disposition', `attachment; filename="command-run-${run.id}-${stamp}.csv"`); res.send(lines.join('\n')); }); // POST /api/commands/runs/:id/start router.post('/runs/:id/start', requireWrite, async (req: Request, res: Response) => { try { await commandRunner.start(Number(req.params.id)); res.json({ message: 'Run started' }); } catch (e) { res.status(409).json({ error: (e as Error).message }); } }); // POST /api/commands/runs/:id/cancel — stop before the next wave router.post('/runs/:id/cancel', requireWrite, async (req: Request, res: Response) => { commandRunner.cancel(Number(req.params.id)); // Devices already running finish; cancellation prevents the next wave, which // is the only point at which stopping is safe. res.json({ message: 'Cancellation requested. Waves already running will finish.' }); }); export default router;