- Added SCRUNNER_SECRETS_KEY to README as a required variable for production. - Implemented redaction of sensitive information in logs across various components. - Enhanced script execution functions to include an owner parameter for better secret management. - Introduced a secrets API in the script sandbox for retrieving and managing secrets. - Updated UI components to support owner selection for script execution and secret management.
183 lines
4.8 KiB
JavaScript
183 lines
4.8 KiB
JavaScript
import fs from "fs";
|
|
import fastify from "fastify";
|
|
import cookie from "@fastify/cookie";
|
|
import cors from "@fastify/cors";
|
|
import jwt from "@fastify/jwt";
|
|
import fastifyStatic from "@fastify/static";
|
|
import { migrate, db } from "./db.js";
|
|
import { log, enableLogPersistence, flushLogs } from "./logger.js";
|
|
import * as store from "./store.js";
|
|
import { createRegistry } from "./registry.js";
|
|
import { COOKIE, OPEN_API_ROUTES } from "./src/api/auth.js";
|
|
import authPlugin from "./src/api/auth.js";
|
|
import usersPlugin from "./src/api/users.js";
|
|
import scriptsPluginFactory from "./src/api/scripts.js";
|
|
import workflowsPluginFactory from "./src/api/workflows.js";
|
|
import runsPlugin from "./src/api/runs.js";
|
|
import dashboardPluginFactory from "./src/api/dashboard.js";
|
|
import secretsPlugin from "./src/api/secrets.js";
|
|
import { WEB_DIST } from "./paths.js";
|
|
import { resolveSecretsKeyMaterial } from "./secrets.js";
|
|
|
|
await migrate();
|
|
enableLogPersistence();
|
|
|
|
const jwtSecret =
|
|
process.env.SCRUNNER_JWT_SECRET ??
|
|
(process.env.NODE_ENV === "production" ? "" : "scrunner-dev-secret");
|
|
|
|
if (!jwtSecret) {
|
|
log.error("SCRUNNER_JWT_SECRET is required in production");
|
|
process.exit(1);
|
|
}
|
|
|
|
try {
|
|
resolveSecretsKeyMaterial();
|
|
} catch (err) {
|
|
log.error(err instanceof Error ? err.message : String(err));
|
|
process.exit(1);
|
|
}
|
|
|
|
const server = fastify({ loggerInstance: log });
|
|
|
|
await server.register(cookie);
|
|
await server.register(jwt, {
|
|
secret: jwtSecret,
|
|
cookie: {
|
|
cookieName: COOKIE,
|
|
signed: false,
|
|
},
|
|
});
|
|
await server.register(cors, {
|
|
origin: process.env.SCRUNNER_CORS_ORIGIN ?? "http://localhost:5173",
|
|
credentials: true,
|
|
});
|
|
|
|
server.decorate("authenticate", async function authenticate(req, reply) {
|
|
try {
|
|
await req.jwtVerify();
|
|
} catch {
|
|
return reply.code(401).send({ error: "unauthorized" });
|
|
}
|
|
});
|
|
|
|
server.decorate("requireAdmin", async function requireAdmin(req, reply) {
|
|
if (req.user?.role !== "admin") {
|
|
return reply.code(403).send({ error: "forbidden" });
|
|
}
|
|
});
|
|
|
|
const registry = createRegistry(server);
|
|
registry.registerWorkflows();
|
|
registry.registerHttpTriggers();
|
|
registry.registerCronTriggers();
|
|
registry.registerPruneJob();
|
|
|
|
await server.register(
|
|
async (api) => {
|
|
api.addHook("onRequest", async (req, reply) => {
|
|
const raw = (req.url || "").split("?")[0];
|
|
const stripped = raw.replace(/^\/api/, "") || "/";
|
|
const routeUrl = req.routeOptions?.url || stripped;
|
|
const open =
|
|
OPEN_API_ROUTES.has(`${req.method} ${routeUrl}`) ||
|
|
OPEN_API_ROUTES.has(`${req.method} ${stripped}`);
|
|
if (open) return;
|
|
await server.authenticate(req, reply);
|
|
});
|
|
await api.register(authPlugin);
|
|
await api.register(usersPlugin);
|
|
await api.register(secretsPlugin);
|
|
await api.register(scriptsPluginFactory(registry));
|
|
await api.register(workflowsPluginFactory(registry));
|
|
await api.register(runsPlugin);
|
|
await api.register(dashboardPluginFactory(registry));
|
|
},
|
|
{ prefix: "/api" },
|
|
);
|
|
|
|
server.post(
|
|
"/admin/workflows/reregister",
|
|
{ onRequest: [server.authenticate] },
|
|
async (_req, reply) => {
|
|
registry.reregister();
|
|
return reply.send({ message: "Workflows refreshed" });
|
|
},
|
|
);
|
|
|
|
server.get(
|
|
"/admin/runs",
|
|
{ onRequest: [server.authenticate] },
|
|
async (req, reply) => {
|
|
const q = /** @type {Record<string, string | undefined>} */ (req.query);
|
|
const limit = q.limit ? Number(q.limit) : undefined;
|
|
const runs = await store.listRuns({
|
|
owner: q.owner,
|
|
workflow: q.workflow,
|
|
status: q.status,
|
|
limit: Number.isFinite(limit) ? limit : undefined,
|
|
before: q.before,
|
|
});
|
|
return reply.send({ runs });
|
|
},
|
|
);
|
|
|
|
server.get(
|
|
"/admin/runs/:id",
|
|
{ onRequest: [server.authenticate] },
|
|
async (req, reply) => {
|
|
const { id } = /** @type {{ id: string }} */ (req.params);
|
|
const run = await store.getRun(id);
|
|
if (!run) {
|
|
return reply.code(404).send({ error: "run not found" });
|
|
}
|
|
return reply.send(run);
|
|
},
|
|
);
|
|
|
|
if (fs.existsSync(WEB_DIST)) {
|
|
await server.register(fastifyStatic, {
|
|
root: WEB_DIST,
|
|
wildcard: false,
|
|
});
|
|
server.setNotFoundHandler((req, reply) => {
|
|
const url = req.raw.url ?? "";
|
|
if (
|
|
url.startsWith("/api") ||
|
|
url.startsWith("/u/") ||
|
|
url.startsWith("/admin")
|
|
) {
|
|
return reply.code(404).send({ error: "not found" });
|
|
}
|
|
return reply.sendFile("index.html");
|
|
});
|
|
}
|
|
|
|
async function shutdown() {
|
|
try {
|
|
await flushLogs();
|
|
await db.destroy();
|
|
} catch (err) {
|
|
log.error({ err }, "shutdown error");
|
|
}
|
|
process.exit(0);
|
|
}
|
|
|
|
process.on("SIGINT", shutdown);
|
|
process.on("SIGTERM", shutdown);
|
|
|
|
const port = Number(process.env.PORT ?? 9000);
|
|
|
|
server
|
|
.listen({
|
|
host: "0.0.0.0",
|
|
port,
|
|
})
|
|
.then(() => {
|
|
log.info(`Server is running on port ${port}`);
|
|
})
|
|
.catch((err) => {
|
|
log.error({ err }, "failed to start server");
|
|
process.exit(1);
|
|
});
|