Files
Termix/plugins/automations/backend/routes.ts
T

833 lines
24 KiB
TypeScript

import crypto from "node:crypto";
import express, { type Request, type Response } from "express";
import type { AuthenticatedRequest } from "../../../src/types/index.js";
import type {
AutomationDefinition,
Step,
Trigger,
} from "../../../src/types/automations.js";
import { AUTOMATION_DEFINITION_VERSION } from "../../../src/types/automations.js";
import { PermissionManager } from "../../../src/backend/utils/permission-manager.js";
import { AuthManager } from "../../../src/backend/utils/auth-manager.js";
import { databaseLogger } from "../../../src/backend/utils/logger.js";
import {
getAuditUsername,
getRequestMeta,
logAudit,
} from "../../../src/backend/utils/audit-logger.js";
import { createCurrentAutomationRepository } from "../../../src/backend/database/repositories/factory.js";
import type { AutomationRow } from "../../../src/backend/database/repositories/automation-repository.js";
import { AutomationEngine } from "./engine.js";
import { computeNextDueAt, isValidCron, isValidTimezone } from "./cron.js";
import {
registerAutomationsRouter,
unregisterAutomationsRouter,
} from "../../../src/backend/database/routes/automation-dispatch.js";
export const router = express.Router();
const authManager = AuthManager.getInstance();
const permissionManager = PermissionManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware();
const requireDataAccess = authManager.createDataAccessMiddleware();
const TRIGGER_KINDS = new Set([
"metric_threshold",
"host_status",
"health_check",
"schedule",
"docker_event",
"internal_event",
"webhook",
]);
const STEP_TYPES = new Set([
"notify",
"http",
"run_snippet",
"run_command",
"docker",
"tunnel",
"wol",
"wait",
"set_var",
"if",
"run_automation",
"stop",
]);
const OPERATORS = new Set([
">",
"<",
">=",
"<=",
"==",
"!=",
"contains",
"not_contains",
"changed",
]);
function parseId(raw: unknown): number | null {
const id = typeof raw === "string" ? parseInt(raw, 10) : NaN;
return Number.isInteger(id) && id > 0 ? id : null;
}
function isNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
}
/**
* Validates a definition before it is stored. The engine treats the stored
* blob as trusted, so everything it relies on is checked once here.
*/
export function validateDefinition(value: unknown): {
ok: boolean;
error?: string;
definition?: AutomationDefinition;
} {
if (typeof value !== "object" || value === null) {
return { ok: false, error: "Definition must be an object" };
}
const candidate = value as Partial<AutomationDefinition>;
const trigger = candidate.trigger as Trigger | undefined;
if (!trigger || !TRIGGER_KINDS.has(trigger.kind)) {
return { ok: false, error: "Unknown or missing trigger kind" };
}
if (trigger.kind === "metric_threshold") {
if (!OPERATORS.has(trigger.operator)) {
return { ok: false, error: "Unknown comparison operator" };
}
if (typeof trigger.value !== "number" || !Number.isFinite(trigger.value)) {
return { ok: false, error: "Threshold value must be a number" };
}
if (!trigger.metric?.path) {
return { ok: false, error: "Trigger is missing a metric" };
}
}
if (trigger.kind === "schedule") {
const hasInterval =
typeof trigger.intervalSeconds === "number" &&
trigger.intervalSeconds > 0;
const hasCron = isNonEmptyString(trigger.cron);
if (!hasInterval && !hasCron) {
return { ok: false, error: "Schedule needs an interval or a cron" };
}
if (hasCron && !isValidCron(trigger.cron as string)) {
return { ok: false, error: "Cron expression is not valid" };
}
if (hasInterval && (trigger.intervalSeconds as number) < 60) {
return { ok: false, error: "Interval must be at least 60 seconds" };
}
if (
isNonEmptyString(trigger.timezone) &&
!isValidTimezone(trigger.timezone)
) {
return { ok: false, error: "Time zone is not valid" };
}
}
const steps = candidate.steps;
if (!Array.isArray(steps)) {
return { ok: false, error: "Definition must include a steps array" };
}
const seen = new Set<string>();
const stepError = validateSteps(steps as Step[], seen);
if (stepError) return { ok: false, error: stepError };
return {
ok: true,
definition: {
version: candidate.version ?? AUTOMATION_DEFINITION_VERSION,
trigger,
steps: steps as Step[],
},
};
}
function validateSteps(steps: Step[], seen: Set<string>): string | null {
for (const step of steps) {
if (!step || typeof step !== "object") return "Step must be an object";
if (!isNonEmptyString(step.id)) return "Every step needs an id";
if (seen.has(step.id)) return `Duplicate step id: ${step.id}`;
seen.add(step.id);
if (!STEP_TYPES.has(step.type)) {
return `Unknown step type: ${step.type}`;
}
if (step.type === "if") {
if (!step.condition || !OPERATORS.has(step.condition.operator)) {
return "Condition needs a valid operator";
}
const thenError = validateSteps(step.then ?? [], seen);
if (thenError) return thenError;
const elseError = validateSteps(step.else ?? [], seen);
if (elseError) return elseError;
}
if (step.type === "http" && !isNonEmptyString(step.url)) {
return "HTTP steps need a URL";
}
if (step.type === "run_command" && !isNonEmptyString(step.command)) {
return "Command steps need a command";
}
if (step.type === "wait" && typeof step.seconds !== "number") {
return "Wait steps need a number of seconds";
}
if (step.type === "set_var" && !isNonEmptyString(step.name)) {
return "Variable steps need a name";
}
}
return null;
}
/** Never leak a webhook token hash to the client. */
function serialize(row: AutomationRow) {
let definition: AutomationDefinition | null = null;
try {
definition = JSON.parse(row.definition) as AutomationDefinition;
} catch {
definition = null;
}
if (definition?.trigger?.kind === "webhook") {
definition = {
...definition,
trigger: { ...definition.trigger, tokenHash: "" },
};
}
return { ...row, definition };
}
async function syncSchedule(
automationId: number,
definition: AutomationDefinition,
): Promise<void> {
const repository = createCurrentAutomationRepository();
if (definition.trigger?.kind !== "schedule") {
await repository.deleteSchedule(automationId);
return;
}
const trigger = definition.trigger;
await repository.upsertSchedule({
automationId,
cron: trigger.cron ?? null,
intervalSeconds: trigger.intervalSeconds ?? null,
timezone: trigger.timezone ?? null,
nextDueAt: computeNextDueAt({
cron: trigger.cron,
intervalSeconds: trigger.intervalSeconds,
timezone: trigger.timezone,
}),
});
}
/**
* @openapi
* /automations:
* get:
* summary: List the current user's automations
* description: Returns every automation the caller owns, with its parsed definition and linked notification channels.
* tags:
* - Automations
* responses:
* 200:
* description: List of automations.
* 403:
* description: Missing the automations.view permission.
*/
router.get(
"/",
authenticateJWT,
permissionManager.requirePermission("automations.view"),
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const rows = await createCurrentAutomationRepository().list(userId);
res.json(rows.map(serialize));
} catch (error) {
databaseLogger.error("Failed to list automations", error, {
operation: "automation_list_error",
userId,
});
res.status(500).json({ error: "Failed to list automations" });
}
},
);
/**
* @openapi
* /automations/{id}:
* get:
* summary: Fetch a single automation
* tags:
* - Automations
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: The automation.
* 404:
* description: Automation not found.
*/
router.get(
"/:id",
authenticateJWT,
permissionManager.requirePermission("automations.view"),
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: "Invalid id" });
try {
const row = await createCurrentAutomationRepository().findForUser(
id,
userId,
);
if (!row) return res.status(404).json({ error: "Automation not found" });
res.json(serialize(row));
} catch (error) {
databaseLogger.error("Failed to fetch automation", error, {
operation: "automation_get_error",
userId,
});
res.status(500).json({ error: "Failed to fetch automation" });
}
},
);
/**
* @openapi
* /automations:
* post:
* summary: Create an automation
* description: Validates the trigger and every step before storing the definition. A schedule trigger also registers its next due time.
* tags:
* - Automations
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* name:
* type: string
* definition:
* type: object
* responses:
* 201:
* description: The created automation.
* 400:
* description: Validation failed.
*/
router.post(
"/",
authenticateJWT,
permissionManager.requirePermission("automations.create"),
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { name, description, enabled, definition, concurrencyPolicy } =
req.body ?? {};
if (!isNonEmptyString(name)) {
return res.status(400).json({ error: "Name is required" });
}
const validated = validateDefinition(definition);
if (!validated.ok || !validated.definition) {
return res.status(400).json({ error: validated.error });
}
// A webhook trigger's token is shown once here and only stored hashed.
let webhookToken: string | undefined;
if (validated.definition.trigger.kind === "webhook") {
webhookToken = crypto.randomBytes(32).toString("hex");
validated.definition = {
...validated.definition,
trigger: {
kind: "webhook",
tokenHash: hashToken(webhookToken),
},
};
}
try {
const repository = createCurrentAutomationRepository();
const created = await repository.create({
userId,
name: name.trim(),
description: description ?? null,
enabled: enabled !== false,
definition: JSON.stringify(validated.definition),
concurrencyPolicy,
channels: Array.isArray(req.body?.channels) ? req.body.channels : [],
});
await syncSchedule(created.id, validated.definition);
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId,
username: await getAuditUsername(userId),
action: "create_automation",
resourceType: "automation",
resourceId: String(created.id),
resourceName: created.name,
ipAddress,
userAgent,
success: true,
});
res.status(201).json({ ...serialize(created), webhookToken });
} catch (error) {
databaseLogger.error("Failed to create automation", error, {
operation: "automation_create_error",
userId,
});
res.status(500).json({ error: "Failed to create automation" });
}
},
);
/**
* @openapi
* /automations/{id}:
* put:
* summary: Update an automation
* tags:
* - Automations
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: The updated automation.
* 400:
* description: Validation failed.
* 404:
* description: Automation not found.
*/
router.put(
"/:id",
authenticateJWT,
permissionManager.requirePermission("automations.edit"),
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: "Invalid id" });
const repository = createCurrentAutomationRepository();
const existing = await repository.findForUser(id, userId);
if (!existing) {
return res.status(404).json({ error: "Automation not found" });
}
const update: Record<string, unknown> = {};
if (req.body?.name !== undefined) {
if (!isNonEmptyString(req.body.name)) {
return res.status(400).json({ error: "Name cannot be empty" });
}
update.name = req.body.name.trim();
}
if (req.body?.description !== undefined) {
update.description = req.body.description;
}
if (req.body?.enabled !== undefined) update.enabled = !!req.body.enabled;
if (req.body?.concurrencyPolicy !== undefined) {
update.concurrencyPolicy = req.body.concurrencyPolicy;
}
if (Array.isArray(req.body?.channels)) update.channels = req.body.channels;
let parsedDefinition: AutomationDefinition | null = null;
if (req.body?.definition !== undefined) {
const validated = validateDefinition(req.body.definition);
if (!validated.ok || !validated.definition) {
return res.status(400).json({ error: validated.error });
}
// Keep the stored token hash: the raw token is only ever shown once.
if (validated.definition.trigger.kind === "webhook") {
const previous = JSON.parse(
existing.definition,
) as AutomationDefinition;
const previousHash =
previous.trigger?.kind === "webhook"
? previous.trigger.tokenHash
: "";
validated.definition = {
...validated.definition,
trigger: { kind: "webhook", tokenHash: previousHash },
};
}
parsedDefinition = validated.definition;
update.definition = JSON.stringify(validated.definition);
}
try {
const updated = await repository.update(id, userId, update);
if (!updated) {
return res.status(404).json({ error: "Automation not found" });
}
if (parsedDefinition) await syncSchedule(id, parsedDefinition);
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId,
username: await getAuditUsername(userId),
action: "update_automation",
resourceType: "automation",
resourceId: String(id),
resourceName: updated.name,
ipAddress,
userAgent,
success: true,
});
res.json(serialize(updated));
} catch (error) {
databaseLogger.error("Failed to update automation", error, {
operation: "automation_update_error",
userId,
});
res.status(500).json({ error: "Failed to update automation" });
}
},
);
/**
* @openapi
* /automations/{id}:
* delete:
* summary: Delete an automation
* tags:
* - Automations
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Deleted.
* 404:
* description: Automation not found.
*/
router.delete(
"/:id",
authenticateJWT,
permissionManager.requirePermission("automations.delete"),
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: "Invalid id" });
try {
const deleted = await createCurrentAutomationRepository().delete(
id,
userId,
);
if (!deleted) {
return res.status(404).json({ error: "Automation not found" });
}
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId,
username: await getAuditUsername(userId),
action: "delete_automation",
resourceType: "automation",
resourceId: String(id),
ipAddress,
userAgent,
success: true,
});
res.json({ success: true });
} catch (error) {
databaseLogger.error("Failed to delete automation", error, {
operation: "automation_delete_error",
userId,
});
res.status(500).json({ error: "Failed to delete automation" });
}
},
);
/**
* @openapi
* /automations/{id}/run:
* post:
* summary: Run an automation now
* description: Runs immediately, as the automation's owner. Pass dryRun to record what each step would do without touching anything outside Termix.
* tags:
* - Automations
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* requestBody:
* content:
* application/json:
* schema:
* type: object
* properties:
* dryRun:
* type: boolean
* responses:
* 200:
* description: The run outcome.
* 404:
* description: Automation not found.
*/
router.post(
"/:id/run",
authenticateJWT,
permissionManager.requirePermission("automations.run"),
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const id = parseId(req.params.id);
if (id === null) return res.status(400).json({ error: "Invalid id" });
const existing = await createCurrentAutomationRepository().findForUser(
id,
userId,
);
if (!existing) {
return res.status(404).json({ error: "Automation not found" });
}
try {
const outcome = await AutomationEngine.getInstance().run({
automationId: id,
triggerType: "manual",
triggerContext: { manual: true, requestedBy: userId },
dryRun: req.body?.dryRun === true,
});
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId,
username: await getAuditUsername(userId),
action: "run_automation",
resourceType: "automation",
resourceId: String(id),
resourceName: existing.name,
details: JSON.stringify({
status: outcome.status,
dryRun: req.body?.dryRun === true,
}),
ipAddress,
userAgent,
success: outcome.status === "success",
});
res.json(outcome);
} catch (error) {
databaseLogger.error("Failed to run automation", error, {
operation: "automation_run_error",
userId,
});
res.status(500).json({ error: "Failed to run automation" });
}
},
);
/**
* @openapi
* /automations/runs:
* get:
* summary: List automation runs
* tags:
* - Automations
* parameters:
* - in: query
* name: automationId
* schema:
* type: integer
* - in: query
* name: limit
* schema:
* type: integer
* responses:
* 200:
* description: Recent runs, newest first.
*/
router.get(
"/runs/history",
authenticateJWT,
permissionManager.requirePermission("automations.view"),
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const runs = await createCurrentAutomationRepository().listRuns(userId, {
automationId: parseId(req.query.automationId) ?? undefined,
limit: Number(req.query.limit) || 50,
offset: Number(req.query.offset) || 0,
});
res.json(runs);
} catch (error) {
databaseLogger.error("Failed to list automation runs", error, {
operation: "automation_runs_error",
userId,
});
res.status(500).json({ error: "Failed to list runs" });
}
},
);
/**
* @openapi
* /automations/runs/{runId}/steps:
* get:
* summary: Step-by-step results for a run
* tags:
* - Automations
* parameters:
* - in: path
* name: runId
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: The run's steps in order.
* 404:
* description: Run not found.
*/
router.get(
"/runs/:runId/steps",
authenticateJWT,
permissionManager.requirePermission("automations.view"),
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const runId = parseId(req.params.runId);
if (runId === null) return res.status(400).json({ error: "Invalid id" });
try {
const repository = createCurrentAutomationRepository();
const run = await repository.findRunForUser(runId, userId);
if (!run) return res.status(404).json({ error: "Run not found" });
res.json(await repository.listRunSteps(runId));
} catch (error) {
databaseLogger.error("Failed to list run steps", error, {
operation: "automation_run_steps_error",
userId,
});
res.status(500).json({ error: "Failed to list run steps" });
}
},
);
/**
* @openapi
* /automations/webhook/{token}:
* post:
* summary: Trigger an automation from an external system
* description: Unauthenticated by design; the 32-byte token in the path is the credential and is compared against a stored hash in constant time.
* tags:
* - Automations
* parameters:
* - in: path
* name: token
* required: true
* schema:
* type: string
* responses:
* 202:
* description: The run was accepted.
* 404:
* description: No automation matches that token.
*/
router.post("/webhook/:token", async (req: Request, res: Response) => {
const token = req.params.token;
if (!isNonEmptyString(token) || token.length < 32) {
return res.status(404).json({ error: "Not found" });
}
try {
const repository = createCurrentAutomationRepository();
const candidates = await repository.listAllEnabled();
const wanted = hashToken(token);
const match = candidates.find((row) => {
try {
const definition = JSON.parse(row.definition) as AutomationDefinition;
if (definition.trigger?.kind !== "webhook") return false;
return timingSafeEqual(definition.trigger.tokenHash, wanted);
} catch {
return false;
}
});
if (!match) return res.status(404).json({ error: "Not found" });
const outcome = await AutomationEngine.getInstance().run({
automationId: match.id,
triggerType: "webhook",
triggerContext: {
body: req.body ?? {},
receivedAt: new Date().toISOString(),
},
});
res.status(202).json({ runId: outcome.runId, status: outcome.status });
} catch (error) {
databaseLogger.error("Webhook automation failed", error, {
operation: "automation_webhook_error",
});
res.status(500).json({ error: "Failed to run automation" });
}
});
function hashToken(token: string): string {
return crypto.createHash("sha256").update(token).digest("hex");
}
function timingSafeEqual(a: string, b: string): boolean {
const left = Buffer.from(a || "", "utf8");
const right = Buffer.from(b || "", "utf8");
if (left.length !== right.length) return false;
return crypto.timingSafeEqual(left, right);
}
/** Called from activate(). Mounts this router at /automations via the shared
* dispatcher. */
export function startAutomationsService(): void {
registerAutomationsRouter(router);
}
/** Called from deactivate(). /automations/* falls back to 404 until
* reactivated. */
export function stopAutomationsService(): void {
unregisterAutomationsRouter();
}