first commit
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from "drizzle-kit";
|
||||
|
||||
export default defineConfig({
|
||||
dialect: "sqlite",
|
||||
schema: "./src/db/schema.ts",
|
||||
out: "./drizzle",
|
||||
dbCredentials: {
|
||||
url: `file:${process.env.DATABASE_PATH ?? "../data/schedule-task-manager.sqlite"}`,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
CREATE TABLE `scheduled_tasks` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`server_id` integer NOT NULL,
|
||||
`schedule_type` text NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`command` text,
|
||||
`schedule_expression` text,
|
||||
`source` text,
|
||||
`enabled` integer DEFAULT true NOT NULL,
|
||||
`next_run_at` text,
|
||||
`raw_metadata` text,
|
||||
`is_stale` integer DEFAULT false NOT NULL,
|
||||
`first_seen_at` text DEFAULT (current_timestamp) NOT NULL,
|
||||
`last_seen_at` text DEFAULT (current_timestamp) NOT NULL,
|
||||
FOREIGN KEY (`server_id`) REFERENCES `servers`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `servers` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`hostname` text,
|
||||
`os_type` text DEFAULT 'linux' NOT NULL,
|
||||
`description` text,
|
||||
`api_token_hash` text NOT NULL,
|
||||
`api_token_prefix` text NOT NULL,
|
||||
`created_at` text DEFAULT (current_timestamp) NOT NULL,
|
||||
`last_seen_at` text
|
||||
);
|
||||
@@ -0,0 +1,212 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "81d34268-977d-459b-b4a1-8d62b2d81a56",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"tables": {
|
||||
"scheduled_tasks": {
|
||||
"name": "scheduled_tasks",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"server_id": {
|
||||
"name": "server_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"schedule_type": {
|
||||
"name": "schedule_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"command": {
|
||||
"name": "command",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"schedule_expression": {
|
||||
"name": "schedule_expression",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"source": {
|
||||
"name": "source",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"enabled": {
|
||||
"name": "enabled",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": true
|
||||
},
|
||||
"next_run_at": {
|
||||
"name": "next_run_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"raw_metadata": {
|
||||
"name": "raw_metadata",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"is_stale": {
|
||||
"name": "is_stale",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"first_seen_at": {
|
||||
"name": "first_seen_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(current_timestamp)"
|
||||
},
|
||||
"last_seen_at": {
|
||||
"name": "last_seen_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(current_timestamp)"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"scheduled_tasks_server_id_servers_id_fk": {
|
||||
"name": "scheduled_tasks_server_id_servers_id_fk",
|
||||
"tableFrom": "scheduled_tasks",
|
||||
"tableTo": "servers",
|
||||
"columnsFrom": [
|
||||
"server_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"servers": {
|
||||
"name": "servers",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"hostname": {
|
||||
"name": "hostname",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"os_type": {
|
||||
"name": "os_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'linux'"
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"api_token_hash": {
|
||||
"name": "api_token_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"api_token_prefix": {
|
||||
"name": "api_token_prefix",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(current_timestamp)"
|
||||
},
|
||||
"last_seen_at": {
|
||||
"name": "last_seen_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "6",
|
||||
"when": 1783628241015,
|
||||
"tag": "0000_aspiring_blink",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "server",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"start": "node dist/index.js",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "tsx src/db/migrate.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@libsql/client": "^0.14.0",
|
||||
"drizzle-orm": "^0.38.4",
|
||||
"express": "^4.21.2",
|
||||
"express-session": "^1.18.1",
|
||||
"openid-client": "^6.1.7",
|
||||
"session-file-store": "^1.5.0",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/express-session": "^1.18.1",
|
||||
"@types/node": "^22.10.5",
|
||||
"@types/session-file-store": "^1.2.5",
|
||||
"drizzle-kit": "^0.30.2",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { Request, Response, NextFunction } from "express";
|
||||
|
||||
export function requireAuth(req: Request, res: Response, next: NextFunction) {
|
||||
if (req.session.user) {
|
||||
return next();
|
||||
}
|
||||
return res.status(401).json({ error: "unauthorized" });
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import * as client from "openid-client";
|
||||
import { env } from "../env.js";
|
||||
|
||||
let configPromise: Promise<client.Configuration> | null = null;
|
||||
|
||||
export function getOidcConfig(): Promise<client.Configuration> {
|
||||
if (!configPromise) {
|
||||
configPromise = client.discovery(
|
||||
new URL(env.authentik.issuerUrl),
|
||||
env.authentik.clientId,
|
||||
env.authentik.clientSecret,
|
||||
);
|
||||
}
|
||||
return configPromise;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { Router } from "express";
|
||||
import * as client from "openid-client";
|
||||
import { getOidcConfig } from "./oidc.js";
|
||||
import { env } from "../env.js";
|
||||
|
||||
export const authRouter = Router();
|
||||
|
||||
authRouter.get("/login", async (req, res, next) => {
|
||||
try {
|
||||
const config = await getOidcConfig();
|
||||
const codeVerifier = client.randomPKCECodeVerifier();
|
||||
const codeChallenge = await client.calculatePKCECodeChallenge(codeVerifier);
|
||||
const state = client.randomState();
|
||||
|
||||
req.session.pendingAuth = { codeVerifier, state };
|
||||
|
||||
const redirectUri = new URL("/auth/callback", env.appBaseUrl).toString();
|
||||
const authUrl = client.buildAuthorizationUrl(config, {
|
||||
redirect_uri: redirectUri,
|
||||
scope: "openid email profile",
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: "S256",
|
||||
state,
|
||||
});
|
||||
|
||||
req.session.save((err) => {
|
||||
if (err) return next(err);
|
||||
res.redirect(authUrl.href);
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
authRouter.get("/callback", async (req, res, next) => {
|
||||
try {
|
||||
const pending = req.session.pendingAuth;
|
||||
if (!pending) {
|
||||
return res.status(400).send("Login session expired. Please try signing in again.");
|
||||
}
|
||||
|
||||
const config = await getOidcConfig();
|
||||
const currentUrl = new URL(req.originalUrl, env.appBaseUrl);
|
||||
|
||||
const tokens = await client.authorizationCodeGrant(config, currentUrl, {
|
||||
pkceCodeVerifier: pending.codeVerifier,
|
||||
expectedState: pending.state,
|
||||
});
|
||||
|
||||
const claims = tokens.claims();
|
||||
if (!claims?.sub) {
|
||||
return res.status(400).send("Identity provider did not return a valid identity.");
|
||||
}
|
||||
|
||||
let email: string | undefined = typeof claims.email === "string" ? claims.email : undefined;
|
||||
let name: string | undefined = typeof claims.name === "string" ? claims.name : undefined;
|
||||
try {
|
||||
const userinfo = await client.fetchUserInfo(config, tokens.access_token, claims.sub);
|
||||
email = userinfo.email ?? email;
|
||||
name = userinfo.name ?? userinfo.preferred_username ?? name;
|
||||
} catch {
|
||||
// fall back to ID token claims already captured above
|
||||
}
|
||||
|
||||
delete req.session.pendingAuth;
|
||||
req.session.user = { sub: claims.sub, email, name, idToken: tokens.id_token };
|
||||
req.session.save((err) => {
|
||||
if (err) return next(err);
|
||||
res.redirect("/");
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
authRouter.get("/logout", async (req, res, next) => {
|
||||
const idToken = req.session.user?.idToken;
|
||||
try {
|
||||
const config = await getOidcConfig();
|
||||
let endSessionUrl: URL | undefined;
|
||||
try {
|
||||
endSessionUrl = client.buildEndSessionUrl(config, {
|
||||
post_logout_redirect_uri: env.appBaseUrl,
|
||||
...(idToken ? { id_token_hint: idToken } : {}),
|
||||
});
|
||||
} catch {
|
||||
// Provider doesn't advertise RP-Initiated Logout; just clear our own session.
|
||||
}
|
||||
|
||||
req.session.destroy((err) => {
|
||||
if (err) return next(err);
|
||||
res.redirect(endSessionUrl ? endSessionUrl.href : "/");
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import { createClient } from "@libsql/client";
|
||||
import { drizzle } from "drizzle-orm/libsql";
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
import { resolveDataPath } from "../paths.js";
|
||||
import * as schema from "./schema.js";
|
||||
|
||||
const dbPath = resolveDataPath(process.env.DATABASE_PATH ?? "../data/schedule-task-manager.sqlite");
|
||||
mkdirSync(dirname(dbPath), { recursive: true });
|
||||
|
||||
const client = createClient({ url: `file:${dbPath}` });
|
||||
await client.execute("PRAGMA foreign_keys = ON;");
|
||||
|
||||
export const db = drizzle(client, { schema });
|
||||
export { client };
|
||||
@@ -0,0 +1,17 @@
|
||||
import { migrate } from "drizzle-orm/libsql/migrator";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
import { db, client } from "./client.js";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export async function runMigrations() {
|
||||
await migrate(db, { migrationsFolder: join(__dirname, "..", "..", "drizzle") });
|
||||
}
|
||||
|
||||
// Allow running directly via `npm run db:migrate`
|
||||
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
||||
await runMigrations();
|
||||
client.close();
|
||||
console.log("Migrations applied.");
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
|
||||
|
||||
export const servers = sqliteTable("servers", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
name: text("name").notNull(),
|
||||
hostname: text("hostname"),
|
||||
osType: text("os_type").notNull().default("linux"),
|
||||
description: text("description"),
|
||||
apiTokenHash: text("api_token_hash").notNull(),
|
||||
apiTokenPrefix: text("api_token_prefix").notNull(),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`(current_timestamp)`),
|
||||
lastSeenAt: text("last_seen_at"),
|
||||
});
|
||||
|
||||
export const scheduledTasks = sqliteTable("scheduled_tasks", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
serverId: integer("server_id")
|
||||
.notNull()
|
||||
.references(() => servers.id, { onDelete: "cascade" }),
|
||||
scheduleType: text("schedule_type").notNull(), // 'cron' | 'systemd_timer'
|
||||
name: text("name").notNull(),
|
||||
command: text("command"),
|
||||
scheduleExpression: text("schedule_expression"),
|
||||
source: text("source"),
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||
nextRunAt: text("next_run_at"),
|
||||
rawMetadata: text("raw_metadata"),
|
||||
isStale: integer("is_stale", { mode: "boolean" }).notNull().default(false),
|
||||
firstSeenAt: text("first_seen_at")
|
||||
.notNull()
|
||||
.default(sql`(current_timestamp)`),
|
||||
lastSeenAt: text("last_seen_at")
|
||||
.notNull()
|
||||
.default(sql`(current_timestamp)`),
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
export const env = {
|
||||
port: Number(process.env.PORT ?? 3000),
|
||||
nodeEnv: process.env.NODE_ENV ?? "development",
|
||||
appBaseUrl: process.env.APP_BASE_URL ?? "http://localhost:3000",
|
||||
sessionSecret: process.env.SESSION_SECRET ?? "dev-insecure-session-secret-change-me",
|
||||
sessionDir: process.env.SESSION_DIR ?? "../data/sessions",
|
||||
databasePath: process.env.DATABASE_PATH ?? "../data/schedule-task-manager.sqlite",
|
||||
authentik: {
|
||||
issuerUrl: process.env.AUTHENTIK_ISSUER_URL ?? "",
|
||||
clientId: process.env.AUTHENTIK_CLIENT_ID ?? "",
|
||||
clientSecret: process.env.AUTHENTIK_CLIENT_SECRET ?? "",
|
||||
},
|
||||
get authEnabled() {
|
||||
return Boolean(this.authentik.issuerUrl && this.authentik.clientId && this.authentik.clientSecret);
|
||||
},
|
||||
};
|
||||
|
||||
export function warnIfAuthNotConfigured() {
|
||||
if (!env.authEnabled) {
|
||||
console.warn(
|
||||
"AUTHENTIK_ISSUER_URL / AUTHENTIK_CLIENT_ID / AUTHENTIK_CLIENT_SECRET are not fully set. " +
|
||||
"The app will boot, but /auth/login and /auth/logout will fail until they are configured.",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import express from "express";
|
||||
import session from "express-session";
|
||||
import FileStoreFactory from "session-file-store";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
import { mkdirSync, existsSync } from "node:fs";
|
||||
import { env, warnIfAuthNotConfigured } from "./env.js";
|
||||
import { resolveDataPath } from "./paths.js";
|
||||
import { runMigrations } from "./db/migrate.js";
|
||||
import { authRouter } from "./auth/router.js";
|
||||
import { meRouter } from "./routes/me.js";
|
||||
import { serversRouter } from "./routes/servers.js";
|
||||
import { tasksRouter } from "./routes/tasks.js";
|
||||
import { agentReportRouter } from "./routes/agentReport.js";
|
||||
|
||||
warnIfAuthNotConfigured();
|
||||
await runMigrations();
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const webDist = join(__dirname, "..", "..", "web", "dist");
|
||||
const agentDir = join(__dirname, "..", "..", "agent");
|
||||
|
||||
const FileStore = FileStoreFactory(session);
|
||||
const sessionDir = resolveDataPath(env.sessionDir);
|
||||
mkdirSync(sessionDir, { recursive: true });
|
||||
|
||||
const app = express();
|
||||
app.set("trust proxy", 1);
|
||||
app.use(express.json());
|
||||
app.use(
|
||||
session({
|
||||
store: new FileStore({ path: sessionDir, logFn: () => {} }),
|
||||
secret: env.sessionSecret,
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
cookie: {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: env.nodeEnv === "production",
|
||||
maxAge: 7 * 24 * 60 * 60 * 1000,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
app.get("/health", (_req, res) => res.json({ ok: true }));
|
||||
|
||||
// Publicly readable so `curl .../agent/linux/install.sh | bash` works from a
|
||||
// freshly provisioned server with no prior session. Contains no secrets.
|
||||
if (existsSync(agentDir)) {
|
||||
app.use("/agent", express.static(agentDir));
|
||||
}
|
||||
|
||||
app.use("/auth", authRouter);
|
||||
app.use("/api/me", meRouter);
|
||||
app.use("/api/servers", serversRouter);
|
||||
app.use("/api/tasks", tasksRouter);
|
||||
app.use("/api/agent/report", agentReportRouter);
|
||||
|
||||
if (existsSync(webDist)) {
|
||||
app.use(express.static(webDist));
|
||||
app.get("*", (_req, res) => res.sendFile(join(webDist, "index.html")));
|
||||
}
|
||||
|
||||
app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: "internal_error" });
|
||||
});
|
||||
|
||||
app.listen(env.port, () => {
|
||||
console.log(`schedule-task-manager listening on port ${env.port}`);
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, resolve, isAbsolute } from "node:path";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const serverRoot = resolve(__dirname, ".."); // server/
|
||||
|
||||
/** Resolves a config path relative to the server package root, regardless of process.cwd(). */
|
||||
export function resolveDataPath(path: string): string {
|
||||
return isAbsolute(path) ? path : resolve(serverRoot, path);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Router } from "express";
|
||||
import { z } from "zod";
|
||||
import { db } from "../db/client.js";
|
||||
import { hashToken } from "../services/tokens.js";
|
||||
import { syncServerTasks } from "../services/taskSync.js";
|
||||
|
||||
export const agentReportRouter = Router();
|
||||
|
||||
const reportSchema = z.object({
|
||||
hostname: z.string().max(255).optional(),
|
||||
os_type: z.string().optional(),
|
||||
reported_at: z.string().optional(),
|
||||
tasks: z.array(
|
||||
z.object({
|
||||
schedule_type: z.enum(["cron", "systemd_timer"]),
|
||||
name: z.string().min(1),
|
||||
command: z.string().optional(),
|
||||
schedule_expression: z.string().optional(),
|
||||
source: z.string().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
next_run_at: z.string().optional(),
|
||||
metadata: z.unknown().optional(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
agentReportRouter.post("/", async (req, res) => {
|
||||
const authHeader = req.header("authorization") ?? "";
|
||||
const match = authHeader.match(/^Bearer\s+(.+)$/i);
|
||||
if (!match) {
|
||||
return res.status(401).json({ error: "missing_token" });
|
||||
}
|
||||
|
||||
const tokenHash = hashToken(match[1]);
|
||||
const server = await db.query.servers.findFirst({
|
||||
where: (s, { eq }) => eq(s.apiTokenHash, tokenHash),
|
||||
});
|
||||
if (!server) {
|
||||
return res.status(401).json({ error: "invalid_token" });
|
||||
}
|
||||
|
||||
const parsed = reportSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() });
|
||||
}
|
||||
|
||||
await syncServerTasks(server.id, {
|
||||
hostname: parsed.data.hostname,
|
||||
tasks: parsed.data.tasks.map((t) => ({
|
||||
scheduleType: t.schedule_type,
|
||||
name: t.name,
|
||||
command: t.command,
|
||||
scheduleExpression: t.schedule_expression,
|
||||
source: t.source,
|
||||
enabled: t.enabled,
|
||||
nextRunAt: t.next_run_at,
|
||||
metadata: t.metadata,
|
||||
})),
|
||||
});
|
||||
|
||||
res.status(202).json({ ok: true, taskCount: parsed.data.tasks.length });
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Router } from "express";
|
||||
import { requireAuth } from "../auth/middleware.js";
|
||||
|
||||
export const meRouter = Router();
|
||||
|
||||
meRouter.get("/", requireAuth, (req, res) => {
|
||||
const { sub, email, name } = req.session.user!;
|
||||
res.json({ user: { sub, email, name } });
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Router } from "express";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { db } from "../db/client.js";
|
||||
import { servers } from "../db/schema.js";
|
||||
import { requireAuth } from "../auth/middleware.js";
|
||||
import { generateApiToken } from "../services/tokens.js";
|
||||
|
||||
export const serversRouter = Router();
|
||||
serversRouter.use(requireAuth);
|
||||
|
||||
const createServerSchema = z.object({
|
||||
name: z.string().min(1).max(100),
|
||||
hostname: z.string().max(255).optional(),
|
||||
osType: z.literal("linux").default("linux"),
|
||||
description: z.string().max(500).optional(),
|
||||
});
|
||||
|
||||
serversRouter.get("/", async (_req, res) => {
|
||||
const rows = await db.query.servers.findMany({ orderBy: (s, { asc }) => [asc(s.name)] });
|
||||
res.json({
|
||||
servers: rows.map(({ apiTokenHash, ...rest }) => rest),
|
||||
});
|
||||
});
|
||||
|
||||
serversRouter.post("/", async (req, res) => {
|
||||
const parsed = createServerSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() });
|
||||
}
|
||||
|
||||
const { token, prefix, hash } = generateApiToken();
|
||||
|
||||
const [created] = await db
|
||||
.insert(servers)
|
||||
.values({
|
||||
name: parsed.data.name,
|
||||
hostname: parsed.data.hostname,
|
||||
osType: parsed.data.osType,
|
||||
description: parsed.data.description,
|
||||
apiTokenHash: hash,
|
||||
apiTokenPrefix: prefix,
|
||||
})
|
||||
.returning();
|
||||
|
||||
const { apiTokenHash, ...serverOut } = created;
|
||||
// The full token is only ever shown once, at creation time.
|
||||
res.status(201).json({ server: serverOut, token });
|
||||
});
|
||||
|
||||
serversRouter.post("/:id/rotate-token", async (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isInteger(id)) return res.status(400).json({ error: "invalid_id" });
|
||||
|
||||
const { token, prefix, hash } = generateApiToken();
|
||||
const [updated] = await db
|
||||
.update(servers)
|
||||
.set({ apiTokenHash: hash, apiTokenPrefix: prefix })
|
||||
.where(eq(servers.id, id))
|
||||
.returning();
|
||||
|
||||
if (!updated) return res.status(404).json({ error: "not_found" });
|
||||
|
||||
const { apiTokenHash, ...serverOut } = updated;
|
||||
res.json({ server: serverOut, token });
|
||||
});
|
||||
|
||||
serversRouter.delete("/:id", async (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isInteger(id)) return res.status(400).json({ error: "invalid_id" });
|
||||
|
||||
const deleted = await db.delete(servers).where(eq(servers.id, id)).returning();
|
||||
if (deleted.length === 0) return res.status(404).json({ error: "not_found" });
|
||||
|
||||
res.status(204).end();
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Router } from "express";
|
||||
import { and, eq, like, or } from "drizzle-orm";
|
||||
import { db } from "../db/client.js";
|
||||
import { scheduledTasks, servers } from "../db/schema.js";
|
||||
import { requireAuth } from "../auth/middleware.js";
|
||||
|
||||
export const tasksRouter = Router();
|
||||
tasksRouter.use(requireAuth);
|
||||
|
||||
tasksRouter.get("/", async (req, res) => {
|
||||
const serverId = req.query.serverId ? Number(req.query.serverId) : undefined;
|
||||
const scheduleType = typeof req.query.scheduleType === "string" ? req.query.scheduleType : undefined;
|
||||
const search = typeof req.query.search === "string" ? req.query.search.trim() : undefined;
|
||||
const includeStale = req.query.includeStale === "true";
|
||||
|
||||
const conditions = [];
|
||||
if (serverId && Number.isInteger(serverId)) {
|
||||
conditions.push(eq(scheduledTasks.serverId, serverId));
|
||||
}
|
||||
if (scheduleType === "cron" || scheduleType === "systemd_timer") {
|
||||
conditions.push(eq(scheduledTasks.scheduleType, scheduleType));
|
||||
}
|
||||
if (!includeStale) {
|
||||
conditions.push(eq(scheduledTasks.isStale, false));
|
||||
}
|
||||
if (search) {
|
||||
const pattern = `%${search}%`;
|
||||
conditions.push(or(like(scheduledTasks.name, pattern), like(scheduledTasks.command, pattern)));
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
id: scheduledTasks.id,
|
||||
serverId: scheduledTasks.serverId,
|
||||
serverName: servers.name,
|
||||
scheduleType: scheduledTasks.scheduleType,
|
||||
name: scheduledTasks.name,
|
||||
command: scheduledTasks.command,
|
||||
scheduleExpression: scheduledTasks.scheduleExpression,
|
||||
source: scheduledTasks.source,
|
||||
enabled: scheduledTasks.enabled,
|
||||
nextRunAt: scheduledTasks.nextRunAt,
|
||||
isStale: scheduledTasks.isStale,
|
||||
firstSeenAt: scheduledTasks.firstSeenAt,
|
||||
lastSeenAt: scheduledTasks.lastSeenAt,
|
||||
})
|
||||
.from(scheduledTasks)
|
||||
.innerJoin(servers, eq(scheduledTasks.serverId, servers.id))
|
||||
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
||||
.orderBy(servers.name, scheduledTasks.scheduleType, scheduledTasks.name);
|
||||
|
||||
res.json({ tasks: rows });
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { and, eq, notInArray } from "drizzle-orm";
|
||||
import { db } from "../db/client.js";
|
||||
import { scheduledTasks, servers } from "../db/schema.js";
|
||||
|
||||
export interface IncomingTask {
|
||||
scheduleType: "cron" | "systemd_timer";
|
||||
name: string;
|
||||
command?: string;
|
||||
scheduleExpression?: string;
|
||||
source?: string;
|
||||
enabled?: boolean;
|
||||
nextRunAt?: string;
|
||||
metadata?: unknown;
|
||||
}
|
||||
|
||||
export interface AgentReport {
|
||||
hostname?: string;
|
||||
tasks: IncomingTask[];
|
||||
}
|
||||
|
||||
export async function syncServerTasks(serverId: number, report: AgentReport) {
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const existing = await db.query.scheduledTasks.findMany({
|
||||
where: eq(scheduledTasks.serverId, serverId),
|
||||
});
|
||||
|
||||
const seenIds: number[] = [];
|
||||
|
||||
for (const task of report.tasks) {
|
||||
const match = existing.find(
|
||||
(t) =>
|
||||
t.scheduleType === task.scheduleType &&
|
||||
t.name === task.name &&
|
||||
(t.source ?? "") === (task.source ?? ""),
|
||||
);
|
||||
|
||||
if (match) {
|
||||
const [updated] = await db
|
||||
.update(scheduledTasks)
|
||||
.set({
|
||||
command: task.command,
|
||||
scheduleExpression: task.scheduleExpression,
|
||||
enabled: task.enabled ?? true,
|
||||
nextRunAt: task.nextRunAt,
|
||||
rawMetadata: task.metadata ? JSON.stringify(task.metadata) : null,
|
||||
isStale: false,
|
||||
lastSeenAt: now,
|
||||
})
|
||||
.where(eq(scheduledTasks.id, match.id))
|
||||
.returning({ id: scheduledTasks.id });
|
||||
seenIds.push(updated.id);
|
||||
} else {
|
||||
const [created] = await db
|
||||
.insert(scheduledTasks)
|
||||
.values({
|
||||
serverId,
|
||||
scheduleType: task.scheduleType,
|
||||
name: task.name,
|
||||
command: task.command,
|
||||
scheduleExpression: task.scheduleExpression,
|
||||
source: task.source,
|
||||
enabled: task.enabled ?? true,
|
||||
nextRunAt: task.nextRunAt,
|
||||
rawMetadata: task.metadata ? JSON.stringify(task.metadata) : null,
|
||||
firstSeenAt: now,
|
||||
lastSeenAt: now,
|
||||
})
|
||||
.returning({ id: scheduledTasks.id });
|
||||
seenIds.push(created.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Anything belonging to this server that wasn't in this report is now stale,
|
||||
// rather than deleted, so a bad/partial agent run doesn't wipe history.
|
||||
if (seenIds.length > 0) {
|
||||
await db
|
||||
.update(scheduledTasks)
|
||||
.set({ isStale: true })
|
||||
.where(and(eq(scheduledTasks.serverId, serverId), notInArray(scheduledTasks.id, seenIds)));
|
||||
} else {
|
||||
await db.update(scheduledTasks).set({ isStale: true }).where(eq(scheduledTasks.serverId, serverId));
|
||||
}
|
||||
|
||||
await db
|
||||
.update(servers)
|
||||
.set({ lastSeenAt: now, ...(report.hostname ? { hostname: report.hostname } : {}) })
|
||||
.where(eq(servers.id, serverId));
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { randomBytes, createHash, timingSafeEqual } from "node:crypto";
|
||||
|
||||
const TOKEN_PREFIX_LENGTH = 8;
|
||||
|
||||
export function generateApiToken(): { token: string; prefix: string; hash: string } {
|
||||
const token = `stm_${randomBytes(24).toString("hex")}`;
|
||||
const prefix = token.slice(0, TOKEN_PREFIX_LENGTH);
|
||||
return { token, prefix, hash: hashToken(token) };
|
||||
}
|
||||
|
||||
export function hashToken(token: string): string {
|
||||
return createHash("sha256").update(token).digest("hex");
|
||||
}
|
||||
|
||||
export function tokensMatch(hashA: string, hashB: string): boolean {
|
||||
const bufA = Buffer.from(hashA, "hex");
|
||||
const bufB = Buffer.from(hashB, "hex");
|
||||
if (bufA.length !== bufB.length) return false;
|
||||
return timingSafeEqual(bufA, bufB);
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
import "express-session";
|
||||
|
||||
declare module "express-session" {
|
||||
interface SessionData {
|
||||
user?: {
|
||||
sub: string;
|
||||
email?: string;
|
||||
name?: string;
|
||||
idToken?: string;
|
||||
};
|
||||
pendingAuth?: {
|
||||
codeVerifier: string;
|
||||
state: string;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": false,
|
||||
"sourceMap": false
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user