Files
MIKROTIK-RMM/backend/src/routes/commands.ts
T

236 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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;