mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
128 lines
4.3 KiB
TypeScript
128 lines
4.3 KiB
TypeScript
import type { Express } from "express";
|
|
import { execCommand } from "../../../../src/backend/hosts/metrics-shared/common-utils.js";
|
|
import { managerHandler, ManagerInputError } from "./route-helpers.js";
|
|
import { shellSingleQuote } from "../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
|
|
import type { ManagerRoutesDeps } from "./types.js";
|
|
|
|
export interface CronEntry {
|
|
raw: string;
|
|
enabled: boolean;
|
|
schedule: string;
|
|
command: string;
|
|
}
|
|
|
|
const READ_CRONTAB_CMD = "crontab -l 2>/dev/null";
|
|
|
|
/** Parse a crontab into entries (comments/blank lines are dropped except as toggles). */
|
|
export function parseCrontab(output: string): CronEntry[] {
|
|
const entries: CronEntry[] = [];
|
|
for (const raw of output.split("\n")) {
|
|
const line = raw.replace(/\s+$/, "");
|
|
if (!line.trim()) continue;
|
|
// A commented-out job: "# <schedule> <command>" (our toggle convention).
|
|
const disabled = line.match(
|
|
/^#\s*((?:@\w+|\S+\s+\S+\s+\S+\s+\S+\s+\S+)\s+.+)$/,
|
|
);
|
|
if (disabled) {
|
|
const { schedule, command } = splitScheduleCommand(disabled[1]);
|
|
if (command) {
|
|
entries.push({ raw: line, enabled: false, schedule, command });
|
|
continue;
|
|
}
|
|
}
|
|
// Skip pure comments / env assignments.
|
|
if (line.trimStart().startsWith("#")) continue;
|
|
if (/^\s*[A-Z_]+=/.test(line)) continue;
|
|
const { schedule, command } = splitScheduleCommand(line.trim());
|
|
if (!command) continue;
|
|
entries.push({ raw: line, enabled: true, schedule, command });
|
|
}
|
|
return entries;
|
|
}
|
|
|
|
function splitScheduleCommand(line: string): {
|
|
schedule: string;
|
|
command: string;
|
|
} {
|
|
if (line.startsWith("@")) {
|
|
const [sched, ...rest] = line.split(/\s+/);
|
|
return { schedule: sched, command: rest.join(" ") };
|
|
}
|
|
const parts = line.split(/\s+/);
|
|
if (parts.length < 6) return { schedule: "", command: "" };
|
|
return {
|
|
schedule: parts.slice(0, 5).join(" "),
|
|
command: parts.slice(5).join(" "),
|
|
};
|
|
}
|
|
|
|
const CRON_SCHEDULE_RE =
|
|
/^(@(reboot|yearly|annually|monthly|weekly|daily|midnight|hourly)|[\d*/,-]+\s+[\d*/,-]+\s+[\d*/,-]+\s+[\d*/,A-Za-z-]+\s+[\d*/,A-Za-z-]+)$/;
|
|
|
|
export function isValidCronSchedule(schedule: string): boolean {
|
|
return CRON_SCHEDULE_RE.test(schedule.trim());
|
|
}
|
|
|
|
/** Serialize entries into a full crontab body (toggled entries are commented). */
|
|
export function serializeCrontab(entries: CronEntry[]): string {
|
|
const lines = entries.map((e) => {
|
|
const body = `${e.schedule} ${e.command}`.trim();
|
|
return e.enabled ? body : `# ${body}`;
|
|
});
|
|
return lines.join("\n") + (lines.length ? "\n" : "");
|
|
}
|
|
|
|
/** Build a command that atomically replaces the user crontab from the given body. */
|
|
export function buildApplyCrontabCommand(body: string): string {
|
|
// printf the exact bytes into `crontab -` (reads new crontab from stdin).
|
|
return `printf '%s' ${shellSingleQuote(body)} | crontab -`;
|
|
}
|
|
|
|
export function registerCronRoutes(
|
|
app: Express,
|
|
{ validateHostId, runOnHost }: ManagerRoutesDeps,
|
|
): void {
|
|
app.get(
|
|
"/host-metrics/managers/cron/:id",
|
|
validateHostId,
|
|
managerHandler(runOnHost, "connect", "cron_list", async (client) => {
|
|
const { stdout } = await execCommand(client, READ_CRONTAB_CMD, 15000);
|
|
return { entries: parseCrontab(stdout) };
|
|
}),
|
|
);
|
|
|
|
app.post(
|
|
"/host-metrics/managers/cron/:id",
|
|
validateHostId,
|
|
managerHandler(
|
|
runOnHost,
|
|
"connect",
|
|
"cron_replace",
|
|
async (client, _host, req) => {
|
|
const { entries } = req.body as { entries?: CronEntry[] };
|
|
if (!Array.isArray(entries)) {
|
|
throw new ManagerInputError("entries must be an array");
|
|
}
|
|
for (const e of entries) {
|
|
if (typeof e?.command !== "string" || !e.command.trim()) {
|
|
throw new ManagerInputError("Each entry needs a command");
|
|
}
|
|
if (e.command.includes("\n")) {
|
|
throw new ManagerInputError("Commands cannot contain newlines");
|
|
}
|
|
if (!isValidCronSchedule(String(e.schedule))) {
|
|
throw new ManagerInputError(`Invalid schedule: ${e.schedule}`);
|
|
}
|
|
}
|
|
const body = serializeCrontab(entries);
|
|
const { stdout, stderr, code } = await execCommand(
|
|
client,
|
|
buildApplyCrontabCommand(body),
|
|
15000,
|
|
);
|
|
return { success: code === 0, output: stdout || stderr };
|
|
},
|
|
),
|
|
);
|
|
}
|