mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45:24 +00:00
619 lines
19 KiB
TypeScript
619 lines
19 KiB
TypeScript
import crypto from "node:crypto";
|
|
import type { Request, Response, Router } from "express";
|
|
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
|
import type { AutomationDefinition } from "../types.js";
|
|
import { computeNextDueAt } from "./cron.js";
|
|
import type { Deps } from "./deps.js";
|
|
import type { AutomationEngine } from "./engine.js";
|
|
import type { AutomationRepository, AutomationRow } from "./repository.js";
|
|
import { missingPlugins, parseDefinition } from "./requirements.js";
|
|
import { isNonEmptyString, validateDefinition } from "./validate.js";
|
|
|
|
export interface RouteDeps {
|
|
ctx: PluginContext;
|
|
repository: AutomationRepository;
|
|
engine: Pick<AutomationEngine, "run">;
|
|
deps: Deps;
|
|
}
|
|
|
|
function parseId(raw: unknown): number | null {
|
|
const id = typeof raw === "string" ? parseInt(raw, 10) : NaN;
|
|
return Number.isInteger(id) && id > 0 ? id : null;
|
|
}
|
|
|
|
export 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);
|
|
}
|
|
|
|
/**
|
|
* The wire shape of an automation: the definition parsed, the webhook token
|
|
* hash blanked, and the plugins it needs that are not running.
|
|
*/
|
|
export function serializeAutomation(row: AutomationRow, deps: Deps) {
|
|
let definition: AutomationDefinition | null = parseDefinition(row.definition);
|
|
if (definition?.trigger?.kind === "webhook") {
|
|
definition = {
|
|
...definition,
|
|
trigger: { ...definition.trigger, tokenHash: "" },
|
|
};
|
|
}
|
|
return {
|
|
...row,
|
|
definition,
|
|
missingPlugins: missingPlugins(definition, deps),
|
|
};
|
|
}
|
|
|
|
export async function syncSchedule(
|
|
repository: AutomationRepository,
|
|
automationId: number,
|
|
definition: AutomationDefinition,
|
|
): Promise<void> {
|
|
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,
|
|
}),
|
|
});
|
|
}
|
|
|
|
export function registerRoutes(
|
|
router: Router,
|
|
{ ctx, repository, engine, deps }: RouteDeps,
|
|
): void {
|
|
const actor = () => ctx.currentActor() as string;
|
|
const fail = (res: Response, message: string, error: unknown) => {
|
|
ctx.log.error(
|
|
message,
|
|
error instanceof Error ? error : new Error(String(error)),
|
|
);
|
|
res.status(500).json({ error: message });
|
|
};
|
|
const audit = (
|
|
action: string,
|
|
id: number,
|
|
name: string | undefined,
|
|
success = true,
|
|
details?: string,
|
|
) =>
|
|
ctx.audit.record({
|
|
action,
|
|
resourceType: "automation",
|
|
resourceId: String(id),
|
|
resourceName: name,
|
|
details,
|
|
success,
|
|
});
|
|
|
|
/**
|
|
* @openapi
|
|
* /plugin-api/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 wanted = hashToken(token);
|
|
const match = (await repository.listAllEnabled()).find((row) => {
|
|
const definition = parseDefinition(row.definition);
|
|
if (definition?.trigger?.kind !== "webhook") return false;
|
|
return timingSafeEqual(definition.trigger.tokenHash, wanted);
|
|
});
|
|
if (!match) return res.status(404).json({ error: "Not found" });
|
|
|
|
const outcome = await engine.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) {
|
|
fail(res, "Failed to run automation", error);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @openapi
|
|
* /plugin-api/automations:
|
|
* get:
|
|
* summary: List the current user's automations
|
|
* description: Returns every automation the caller owns, with its parsed definition, linked notification channels and the plugins it needs that are not running.
|
|
* tags:
|
|
* - Automations
|
|
* responses:
|
|
* 200:
|
|
* description: List of automations.
|
|
* 403:
|
|
* description: Missing the automations.view permission.
|
|
*/
|
|
router.get(
|
|
"/",
|
|
ctx.rbac.require("view") as never,
|
|
async (_req: Request, res: Response) => {
|
|
try {
|
|
const rows = await repository.list(actor());
|
|
res.json(rows.map((row) => serializeAutomation(row, deps)));
|
|
} catch (error) {
|
|
fail(res, "Failed to list automations", error);
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /plugin-api/automations/editor-options:
|
|
* get:
|
|
* summary: What the automation editor can offer
|
|
* description: The caller's hosts, snippets, fleets and notification channels, and which optional plugins are running, so the editor only offers triggers and steps it can run.
|
|
* tags:
|
|
* - Automations
|
|
* responses:
|
|
* 200:
|
|
* description: Lookup lists and the running providers.
|
|
* 403:
|
|
* description: Missing the automations.view permission.
|
|
*/
|
|
router.get(
|
|
"/editor-options",
|
|
ctx.rbac.require("view") as never,
|
|
async (_req: Request, res: Response) => {
|
|
const userId = actor();
|
|
const settle = async <T>(fn: () => Promise<T>, fallback: T) => {
|
|
try {
|
|
return await fn();
|
|
} catch {
|
|
return fallback;
|
|
}
|
|
};
|
|
const snippets = deps.snippets();
|
|
const fleets = deps.fleets();
|
|
|
|
const [hosts, snippetList, fleetList, channels] = await Promise.all([
|
|
settle(() => ctx.hosts.list(), []),
|
|
settle(
|
|
async () =>
|
|
typeof snippets.list === "function" ? snippets.list() : [],
|
|
[],
|
|
),
|
|
settle(
|
|
async () => (typeof fleets.list === "function" ? fleets.list() : []),
|
|
[],
|
|
),
|
|
settle(() => ctx.notify.channels(), []),
|
|
]);
|
|
|
|
res.json({
|
|
hosts: hosts
|
|
.filter((host) => host.userId === userId)
|
|
.map((host) => ({ id: host.id, name: host.name || host.ip })),
|
|
snippets: snippetList
|
|
.filter((snippet) => !snippet.isNote)
|
|
.map((snippet) => ({ id: snippet.id, name: snippet.name })),
|
|
fleets: fleetList.map((fleet) => ({ id: fleet.id, name: fleet.name })),
|
|
channels: channels.map((channel) => ({
|
|
id: channel.id,
|
|
name: channel.name,
|
|
})),
|
|
providers: deps.providers(),
|
|
});
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /plugin-api/automations/runs/history:
|
|
* get:
|
|
* summary: List automation runs
|
|
* tags:
|
|
* - Automations
|
|
* parameters:
|
|
* - in: query
|
|
* name: automationId
|
|
* schema:
|
|
* type: integer
|
|
* - in: query
|
|
* name: limit
|
|
* schema:
|
|
* type: integer
|
|
* - in: query
|
|
* name: offset
|
|
* schema:
|
|
* type: integer
|
|
* responses:
|
|
* 200:
|
|
* description: Recent runs, newest first.
|
|
*/
|
|
router.get(
|
|
"/runs/history",
|
|
ctx.rbac.require("view") as never,
|
|
async (req: Request, res: Response) => {
|
|
try {
|
|
res.json(
|
|
await repository.listRuns(actor(), {
|
|
automationId: parseId(req.query.automationId) ?? undefined,
|
|
limit: Number(req.query.limit) || 50,
|
|
offset: Number(req.query.offset) || 0,
|
|
}),
|
|
);
|
|
} catch (error) {
|
|
fail(res, "Failed to list runs", error);
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /plugin-api/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",
|
|
ctx.rbac.require("view") as never,
|
|
async (req: Request, res: Response) => {
|
|
const runId = parseId(req.params.runId);
|
|
if (runId === null) return res.status(400).json({ error: "Invalid id" });
|
|
try {
|
|
const run = await repository.findRunForUser(runId, actor());
|
|
if (!run) return res.status(404).json({ error: "Run not found" });
|
|
res.json(await repository.listRunSteps(runId));
|
|
} catch (error) {
|
|
fail(res, "Failed to list run steps", error);
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /plugin-api/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",
|
|
ctx.rbac.require("view") as never,
|
|
async (req: Request, res: Response) => {
|
|
const id = parseId(req.params.id);
|
|
if (id === null) return res.status(400).json({ error: "Invalid id" });
|
|
try {
|
|
const row = await repository.findForUser(id, actor());
|
|
if (!row)
|
|
return res.status(404).json({ error: "Automation not found" });
|
|
res.json(serializeAutomation(row, deps));
|
|
} catch (error) {
|
|
fail(res, "Failed to fetch automation", error);
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /plugin-api/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, and a webhook trigger returns its token once.
|
|
* 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(
|
|
"/",
|
|
ctx.rbac.require("create") as never,
|
|
async (req: Request, res: Response) => {
|
|
const userId = actor();
|
|
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 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(repository, created.id, validated.definition);
|
|
await audit("create_automation", created.id, created.name);
|
|
res
|
|
.status(201)
|
|
.json({ ...serializeAutomation(created, deps), webhookToken });
|
|
} catch (error) {
|
|
fail(res, "Failed to create automation", error);
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /plugin-api/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",
|
|
ctx.rbac.require("edit") as never,
|
|
async (req: Request, res: Response) => {
|
|
const userId = actor();
|
|
const id = parseId(req.params.id);
|
|
if (id === null) return res.status(400).json({ error: "Invalid id" });
|
|
|
|
try {
|
|
const existing = await repository.findForUser(id, userId);
|
|
if (!existing) {
|
|
return res.status(404).json({ error: "Automation not found" });
|
|
}
|
|
|
|
const update: Parameters<AutomationRepository["update"]>[2] = {};
|
|
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 shown once.
|
|
if (validated.definition.trigger.kind === "webhook") {
|
|
const previous = parseDefinition(existing.definition);
|
|
validated.definition = {
|
|
...validated.definition,
|
|
trigger: {
|
|
kind: "webhook",
|
|
tokenHash:
|
|
previous?.trigger?.kind === "webhook"
|
|
? previous.trigger.tokenHash
|
|
: "",
|
|
},
|
|
};
|
|
}
|
|
parsedDefinition = validated.definition;
|
|
update.definition = JSON.stringify(validated.definition);
|
|
}
|
|
|
|
const updated = await repository.update(id, userId, update);
|
|
if (!updated) {
|
|
return res.status(404).json({ error: "Automation not found" });
|
|
}
|
|
if (parsedDefinition) {
|
|
await syncSchedule(repository, id, parsedDefinition);
|
|
}
|
|
await audit("update_automation", id, updated.name);
|
|
res.json(serializeAutomation(updated, deps));
|
|
} catch (error) {
|
|
fail(res, "Failed to update automation", error);
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /plugin-api/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",
|
|
ctx.rbac.require("delete") as never,
|
|
async (req: Request, res: Response) => {
|
|
const id = parseId(req.params.id);
|
|
if (id === null) return res.status(400).json({ error: "Invalid id" });
|
|
try {
|
|
const deleted = await repository.delete(id, actor());
|
|
if (!deleted) {
|
|
return res.status(404).json({ error: "Automation not found" });
|
|
}
|
|
await audit("delete_automation", id, undefined);
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
fail(res, "Failed to delete automation", error);
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /plugin-api/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. An automation that needs a plugin that is off is recorded as skipped.
|
|
* 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",
|
|
ctx.rbac.require("run") as never,
|
|
async (req: Request, res: Response) => {
|
|
const userId = actor();
|
|
const id = parseId(req.params.id);
|
|
if (id === null) return res.status(400).json({ error: "Invalid id" });
|
|
|
|
try {
|
|
const existing = await repository.findForUser(id, userId);
|
|
if (!existing) {
|
|
return res.status(404).json({ error: "Automation not found" });
|
|
}
|
|
const dryRun = req.body?.dryRun === true;
|
|
const outcome = await engine.run({
|
|
automationId: id,
|
|
triggerType: "manual",
|
|
triggerContext: { manual: true, requestedBy: userId },
|
|
dryRun,
|
|
});
|
|
await audit(
|
|
"run_automation",
|
|
id,
|
|
existing.name,
|
|
outcome.status === "success",
|
|
JSON.stringify({ status: outcome.status, dryRun }),
|
|
);
|
|
res.json(outcome);
|
|
} catch (error) {
|
|
fail(res, "Failed to run automation", error);
|
|
}
|
|
},
|
|
);
|
|
}
|