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:
bobbanandClaude Sonnet 5 committed 2026-09-14 23:16:58 +02:00
1 parent ac3feb935d
commit 9712d611a6
17 files changed
+1616 -1

No files matched your search

+176
View File
@@ -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();
});