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
@@ -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();
|
||||
});
|
||||
Reference in new issue
Block a user