Add Servers & Tasks module ported from Schedule Task Manager
Ports cron/systemd task tracking across Debian/Raspbian servers, including the Linux push agent (install/report/uninstall scripts, rebranded from "schedule-task-manager-agent" to "homelab-manager-agent") and the manual-task-entry flow for things an agent can't see (Docker jobs, backups). The schema (servers/scheduled_tasks tables) was already in place from the foundation pass, so this is mostly a straight port of the original's services/routes. Deliberate change from the original: server/token management (which mints agent credentials) is now admin-only rather than open to any logged-in user, and manual task CRUD is gated to operator+ — consistent with how Secrets, IPAM, and DNS already split "configure credentials" from "everyday edits" across roles. All mutations are audit-logged. - server/src/services/tokens.ts, taskSync.ts: ported near-verbatim (agent token hashing, the agent-sync-marks-missing-as-stale-not-deleted logic). - server/src/routes/servers.ts, tasks.ts, agentReport.ts: same contract as the original (agent auth is a per-server bearer token, independent of the session-based requireAuth used everywhere else). - web: a single Servers & Tasks page (filter bar, task table grouped by server/schedule type, manual task form, and an admin-only server management panel with token reveal + copyable install/uninstall commands), replacing the original's two separate pages/apps. Verified: full build passes; a scripted HTTP test against a running server covers unauthenticated access, role gating at each tier (admin-only server mgmt, operator+ task mgmt), agent bearer-token auth (valid/invalid/rotated), manual-vs-agent task edit protection, stale-marking on re-sync, and cascade delete — 22/22 checks passing. Real agent installation on an actual Debian/Raspbian host still needs to be tried on the user's network. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ac3feb935d
commit
9712d611a6
17 files changed
+1616
-1
No files matched your search
@@ -14,6 +14,9 @@ import { auditLogRouter } from "./routes/auditLog.js";
|
||||
import { secretsRouter } from "./routes/secrets.js";
|
||||
import { ipamRouter } from "./routes/ipam.js";
|
||||
import { dnsRouter } from "./routes/dns.js";
|
||||
import { serversRouter } from "./routes/servers.js";
|
||||
import { tasksRouter } from "./routes/tasks.js";
|
||||
import { agentReportRouter } from "./routes/agentReport.js";
|
||||
|
||||
warnIfAuthNotConfigured();
|
||||
await runMigrations();
|
||||
@@ -59,6 +62,9 @@ app.use("/api/audit-log", auditLogRouter);
|
||||
app.use("/api/secrets", secretsRouter);
|
||||
app.use("/api/ipam", ipamRouter);
|
||||
app.use("/api/dns", dnsRouter);
|
||||
app.use("/api/servers", serversRouter);
|
||||
app.use("/api/tasks", tasksRouter);
|
||||
app.use("/api/agent/report", agentReportRouter);
|
||||
|
||||
if (existsSync(webDist)) {
|
||||
app.use(express.static(webDist));
|
||||
|
||||
@@ -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,104 @@
|
||||
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, requireRole } from "../auth/middleware.js";
|
||||
import { generateApiToken } from "../services/tokens.js";
|
||||
import { recordAudit } from "../services/audit.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("/", requireRole("admin"), 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();
|
||||
|
||||
await recordAudit({
|
||||
actor: req.currentUser!,
|
||||
category: "server",
|
||||
action: "create",
|
||||
targetType: "server",
|
||||
targetId: created.id,
|
||||
detail: { name: created.name },
|
||||
});
|
||||
|
||||
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", requireRole("admin"), 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" });
|
||||
|
||||
await recordAudit({
|
||||
actor: req.currentUser!,
|
||||
category: "server",
|
||||
action: "rotate_token",
|
||||
targetType: "server",
|
||||
targetId: id,
|
||||
detail: { name: updated.name },
|
||||
});
|
||||
|
||||
const { apiTokenHash, ...serverOut } = updated;
|
||||
res.json({ server: serverOut, token });
|
||||
});
|
||||
|
||||
serversRouter.delete("/:id", requireRole("admin"), 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" });
|
||||
|
||||
await recordAudit({
|
||||
actor: req.currentUser!,
|
||||
category: "server",
|
||||
action: "delete",
|
||||
targetType: "server",
|
||||
targetId: id,
|
||||
detail: { name: deleted[0].name },
|
||||
});
|
||||
|
||||
res.status(204).end();
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
import { Router } from "express";
|
||||
import { and, eq, like, or } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { db } from "../db/client.js";
|
||||
import { scheduledTasks, servers } from "../db/schema.js";
|
||||
import { requireAuth, requireRole } from "../auth/middleware.js";
|
||||
import { recordAudit } from "../services/audit.js";
|
||||
|
||||
export const tasksRouter = Router();
|
||||
tasksRouter.use(requireAuth);
|
||||
|
||||
const TASK_COLUMNS = {
|
||||
id: scheduledTasks.id,
|
||||
serverId: scheduledTasks.serverId,
|
||||
serverName: servers.name,
|
||||
scheduleType: scheduledTasks.scheduleType,
|
||||
origin: scheduledTasks.origin,
|
||||
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,
|
||||
};
|
||||
|
||||
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) {
|
||||
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(TASK_COLUMNS)
|
||||
.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 });
|
||||
});
|
||||
|
||||
const manualTaskSchema = z.object({
|
||||
serverId: z.number().int(),
|
||||
scheduleType: z.enum(["cron", "systemd_timer", "docker", "backup", "update", "n8n_workflow", "manual"]),
|
||||
name: z.string().min(1).max(200),
|
||||
command: z.string().max(1000).optional(),
|
||||
scheduleExpression: z.string().max(200).optional(),
|
||||
nextRunAt: z.string().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
});
|
||||
|
||||
tasksRouter.post("/", requireRole("operator"), async (req, res) => {
|
||||
const parsed = manualTaskSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() });
|
||||
}
|
||||
|
||||
const server = await db.query.servers.findFirst({ where: eq(servers.id, parsed.data.serverId) });
|
||||
if (!server) {
|
||||
return res.status(400).json({ error: "unknown_server" });
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const [created] = await db
|
||||
.insert(scheduledTasks)
|
||||
.values({
|
||||
serverId: parsed.data.serverId,
|
||||
scheduleType: parsed.data.scheduleType,
|
||||
origin: "manual",
|
||||
name: parsed.data.name,
|
||||
command: parsed.data.command,
|
||||
scheduleExpression: parsed.data.scheduleExpression,
|
||||
nextRunAt: parsed.data.nextRunAt,
|
||||
enabled: parsed.data.enabled ?? true,
|
||||
firstSeenAt: now,
|
||||
lastSeenAt: now,
|
||||
})
|
||||
.returning({ id: scheduledTasks.id });
|
||||
|
||||
await recordAudit({
|
||||
actor: req.currentUser!,
|
||||
category: "task",
|
||||
action: "create",
|
||||
targetType: "scheduled_task",
|
||||
targetId: created.id,
|
||||
detail: { name: parsed.data.name, serverId: parsed.data.serverId },
|
||||
});
|
||||
|
||||
const [task] = await db
|
||||
.select(TASK_COLUMNS)
|
||||
.from(scheduledTasks)
|
||||
.innerJoin(servers, eq(scheduledTasks.serverId, servers.id))
|
||||
.where(eq(scheduledTasks.id, created.id));
|
||||
res.status(201).json({ task });
|
||||
});
|
||||
|
||||
const manualTaskUpdateSchema = manualTaskSchema.omit({ serverId: true }).partial();
|
||||
|
||||
tasksRouter.patch("/:id", requireRole("operator"), async (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isInteger(id)) return res.status(400).json({ error: "invalid_id" });
|
||||
|
||||
const parsed = manualTaskUpdateSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() });
|
||||
}
|
||||
|
||||
const existing = await db.query.scheduledTasks.findFirst({ where: eq(scheduledTasks.id, id) });
|
||||
if (!existing) return res.status(404).json({ error: "not_found" });
|
||||
if (existing.origin !== "manual") {
|
||||
return res.status(403).json({ error: "not_editable", message: "Only manually entered tasks can be edited." });
|
||||
}
|
||||
|
||||
await db
|
||||
.update(scheduledTasks)
|
||||
.set({ ...parsed.data, lastSeenAt: new Date().toISOString() })
|
||||
.where(eq(scheduledTasks.id, id));
|
||||
|
||||
await recordAudit({
|
||||
actor: req.currentUser!,
|
||||
category: "task",
|
||||
action: "update",
|
||||
targetType: "scheduled_task",
|
||||
targetId: id,
|
||||
detail: { name: existing.name },
|
||||
});
|
||||
|
||||
const [task] = await db
|
||||
.select(TASK_COLUMNS)
|
||||
.from(scheduledTasks)
|
||||
.innerJoin(servers, eq(scheduledTasks.serverId, servers.id))
|
||||
.where(eq(scheduledTasks.id, id));
|
||||
res.json({ task });
|
||||
});
|
||||
|
||||
tasksRouter.delete("/:id", requireRole("operator"), async (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isInteger(id)) return res.status(400).json({ error: "invalid_id" });
|
||||
|
||||
const existing = await db.query.scheduledTasks.findFirst({ where: eq(scheduledTasks.id, id) });
|
||||
if (!existing) return res.status(404).json({ error: "not_found" });
|
||||
if (existing.origin !== "manual") {
|
||||
return res.status(403).json({ error: "not_editable", message: "Only manually entered tasks can be deleted." });
|
||||
}
|
||||
|
||||
await db.delete(scheduledTasks).where(eq(scheduledTasks.id, id));
|
||||
|
||||
await recordAudit({
|
||||
actor: req.currentUser!,
|
||||
category: "task",
|
||||
action: "delete",
|
||||
targetType: "scheduled_task",
|
||||
targetId: id,
|
||||
detail: { name: existing.name },
|
||||
});
|
||||
|
||||
res.status(204).end();
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
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: and(eq(scheduledTasks.serverId, serverId), eq(scheduledTasks.origin, "agent")),
|
||||
});
|
||||
|
||||
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,
|
||||
origin: "agent",
|
||||
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 agent-sourced for this server that wasn't in this report is now stale,
|
||||
// rather than deleted, so a bad/partial agent run doesn't wipe history. Manually
|
||||
// entered tasks (origin = 'manual') are never touched by agent sync.
|
||||
const agentScope = and(eq(scheduledTasks.serverId, serverId), eq(scheduledTasks.origin, "agent"));
|
||||
if (seenIds.length > 0) {
|
||||
await db
|
||||
.update(scheduledTasks)
|
||||
.set({ isStale: true })
|
||||
.where(and(agentScope, notInArray(scheduledTasks.id, seenIds)));
|
||||
} else {
|
||||
await db.update(scheduledTasks).set({ isStale: true }).where(agentScope);
|
||||
}
|
||||
|
||||
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 = `hlm_${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);
|
||||
}
|
||||
Reference in new issue
Block a user