diff --git a/agent/linux/install.sh b/agent/linux/install.sh new file mode 100644 index 0000000..5f37979 --- /dev/null +++ b/agent/linux/install.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +# Installs the Homelab Manager task-reporting agent as a systemd timer. +# +# Usage (must run as root — if not already root, put sudo right after the +# pipe so it applies to bash, not to curl): +# curl -fsSL https://homelab.example.lan/agent/linux/install.sh | \ +# sudo API_URL=https://homelab.example.lan API_TOKEN=hlm_xxx bash +# +set -euo pipefail + +: "${API_URL:?Set API_URL to your Homelab Manager URL, e.g. https://homelab.example.lan}" +: "${API_TOKEN:?Set API_TOKEN to the per-server token generated on the Servers & Tasks page}" + +INSTALL_DIR="/usr/local/bin" +CONFIG_DIR="/etc" +SYSTEMD_DIR="/etc/systemd/system" +INTERVAL_MINUTES="${INTERVAL_MINUTES:-15}" + +if [[ "$EUID" -ne 0 ]]; then + echo "This installer must be run as root (it installs a systemd timer)." >&2 + echo "If you're piping from curl, put sudo right after the pipe so it elevates bash, not curl:" >&2 + echo " curl -fsSL ... | sudo API_URL=... API_TOKEN=... bash" >&2 + exit 1 +fi + +suggest_package_install() { + local pkg="$1" + if command -v apt-get >/dev/null 2>&1; then + echo " sudo apt-get update && sudo apt-get install -y $pkg" + elif command -v dnf >/dev/null 2>&1; then + echo " sudo dnf install -y $pkg" + elif command -v yum >/dev/null 2>&1; then + echo " sudo yum install -y $pkg" + elif command -v apk >/dev/null 2>&1; then + echo " sudo apk add $pkg" + elif command -v pacman >/dev/null 2>&1; then + echo " sudo pacman -S $pkg" + elif command -v zypper >/dev/null 2>&1; then + echo " sudo zypper install -y $pkg" + fi +} + +for bin in curl jq; do + if ! command -v "$bin" >/dev/null 2>&1; then + echo "Required dependency '$bin' is not installed. Try:" >&2 + suggest_package_install "$bin" >&2 + exit 1 + fi +done + +if ! command -v systemctl >/dev/null 2>&1; then + echo "systemctl was not found — this installer requires a systemd-based Linux distribution." >&2 + exit 1 +fi + +echo "Installing Homelab Manager agent from $API_URL ..." + +curl -fsSL "$API_URL/agent/linux/report-tasks.sh" -o "$INSTALL_DIR/homelab-manager-agent.sh" +chmod 755 "$INSTALL_DIR/homelab-manager-agent.sh" + +umask 077 +cat > "$CONFIG_DIR/homelab-manager-agent.env" < "$SYSTEMD_DIR/homelab-manager-agent.service" < "$SYSTEMD_DIR/homelab-manager-agent.timer" <&2 + exit 1 +fi + +suggest_package_install() { + local pkg="$1" + if command -v apt-get >/dev/null 2>&1; then + echo " sudo apt-get update && sudo apt-get install -y $pkg" + elif command -v dnf >/dev/null 2>&1; then + echo " sudo dnf install -y $pkg" + elif command -v yum >/dev/null 2>&1; then + echo " sudo yum install -y $pkg" + elif command -v apk >/dev/null 2>&1; then + echo " sudo apk add $pkg" + elif command -v pacman >/dev/null 2>&1; then + echo " sudo pacman -S $pkg" + elif command -v zypper >/dev/null 2>&1; then + echo " sudo zypper install -y $pkg" + fi +} + +for bin in curl jq; do + if ! command -v "$bin" >/dev/null 2>&1; then + echo "Required dependency '$bin' is not installed. Try:" >&2 + suggest_package_install "$bin" >&2 + exit 1 + fi +done + +TASKS_JSON="[]" + +add_task() { + local schedule_type="$1" name="$2" command="$3" schedule_expression="$4" source="$5" enabled="$6" + TASKS_JSON=$(jq -c \ + --arg schedule_type "$schedule_type" \ + --arg name "$name" \ + --arg command "$command" \ + --arg schedule_expression "$schedule_expression" \ + --arg source "$source" \ + --argjson enabled "$enabled" \ + '. + [{ + schedule_type: $schedule_type, + name: $name, + command: $command, + schedule_expression: $schedule_expression, + source: $source, + enabled: $enabled + }]' <<<"$TASKS_JSON") +} + +add_task_with_next_run() { + local schedule_type="$1" name="$2" command="$3" schedule_expression="$4" source="$5" enabled="$6" next_run_at="$7" + TASKS_JSON=$(jq -c \ + --arg schedule_type "$schedule_type" \ + --arg name "$name" \ + --arg command "$command" \ + --arg schedule_expression "$schedule_expression" \ + --arg source "$source" \ + --argjson enabled "$enabled" \ + --arg next_run_at "$next_run_at" \ + '. + [{ + schedule_type: $schedule_type, + name: $name, + command: $command, + schedule_expression: $schedule_expression, + source: $source, + enabled: $enabled, + next_run_at: $next_run_at + }]' <<<"$TASKS_JSON") +} + +parse_crontab_lines() { + # Reads 5-field-schedule + command cron lines from stdin. + # $1 = source label, $2 = "system" (7-field, includes a user column) or "user" (6-field) + local source="$1" mode="$2" + while IFS= read -r line; do + line="${line%%$'\r'}" + [[ -z "$line" ]] && continue + [[ "$line" =~ ^[[:space:]]*# ]] && continue + [[ "$line" =~ ^[[:space:]]*[A-Za-z_][A-Za-z0-9_]*= ]] && continue + + if [[ "$mode" == "system" ]]; then + # min hour dom mon dow user command... + if [[ "$line" =~ ^[[:space:]]*([^[:space:]]+)[[:space:]]+([^[:space:]]+)[[:space:]]+([^[:space:]]+)[[:space:]]+([^[:space:]]+)[[:space:]]+([^[:space:]]+)[[:space:]]+[^[:space:]]+[[:space:]]+(.+)$ ]]; then + local sched="${BASH_REMATCH[1]} ${BASH_REMATCH[2]} ${BASH_REMATCH[3]} ${BASH_REMATCH[4]} ${BASH_REMATCH[5]}" + local cmd="${BASH_REMATCH[6]}" + add_task "cron" "$cmd" "$cmd" "$sched" "$source" true + fi + else + # min hour dom mon dow command... + if [[ "$line" =~ ^[[:space:]]*([^[:space:]]+)[[:space:]]+([^[:space:]]+)[[:space:]]+([^[:space:]]+)[[:space:]]+([^[:space:]]+)[[:space:]]+([^[:space:]]+)[[:space:]]+(.+)$ ]]; then + local sched="${BASH_REMATCH[1]} ${BASH_REMATCH[2]} ${BASH_REMATCH[3]} ${BASH_REMATCH[4]} ${BASH_REMATCH[5]}" + local cmd="${BASH_REMATCH[6]}" + add_task "cron" "$cmd" "$cmd" "$sched" "$source" true + fi + fi + done +} + +collect_cron() { + [[ -r /etc/crontab ]] && parse_crontab_lines "/etc/crontab" "system" < /etc/crontab + + if [[ -d /etc/cron.d ]]; then + for f in /etc/cron.d/*; do + [[ -f "$f" && -r "$f" ]] || continue + parse_crontab_lines "$f" "system" < "$f" + done + fi + + if command -v crontab >/dev/null 2>&1 && [[ -r /etc/passwd ]]; then + while IFS=: read -r user _ uid _ _ _ shell; do + case "$shell" in + */nologin|*/false|"") continue ;; + esac + [[ "$uid" -lt 1000 && "$uid" != "0" ]] && continue + local_crontab=$(crontab -l -u "$user" 2>/dev/null || true) + [[ -z "$local_crontab" ]] && continue + parse_crontab_lines "crontab:$user" "user" <<<"$local_crontab" + done < /etc/passwd + fi +} + +collect_systemd_timers() { + command -v systemctl >/dev/null 2>&1 || return 0 + + local timers_json + timers_json=$(systemctl list-timers --all --output=json 2>/dev/null || echo "[]") + + while IFS= read -r entry; do + local unit activates next_usec enabled_state schedule_expr next_run_at + unit=$(jq -r '.unit' <<<"$entry") + activates=$(jq -r '.activates // ""' <<<"$entry") + next_usec=$(jq -r '.next // 0' <<<"$entry") + + enabled_state=$(systemctl is-enabled "$unit" 2>/dev/null || echo "unknown") + local enabled_bool="false" + [[ "$enabled_state" == "enabled" || "$enabled_state" == "static" ]] && enabled_bool="true" + + schedule_expr=$(systemctl show "$unit" -p TimersCalendar --value 2>/dev/null | sed -n 's/.*OnCalendar=\([^;]*\);.*/\1/p' | sed 's/[[:space:]]*$//') + [[ -z "$schedule_expr" ]] && schedule_expr="(see: systemctl status $unit)" + + next_run_at="" + if [[ "$next_usec" =~ ^[0-9]+$ && "$next_usec" -gt 0 ]]; then + next_run_at=$(date -u -d "@$((next_usec / 1000000))" +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || echo "") + fi + + if [[ -n "$next_run_at" ]]; then + add_task_with_next_run "systemd_timer" "$unit" "$activates" "$schedule_expr" "$unit" "$enabled_bool" "$next_run_at" + else + add_task "systemd_timer" "$unit" "$activates" "$schedule_expr" "$unit" "$enabled_bool" + fi + done < <(jq -c '.[]' <<<"$timers_json") +} + +collect_cron +collect_systemd_timers + +HOSTNAME_VALUE=$(hostname -f 2>/dev/null || hostname) +PAYLOAD=$(jq -n \ + --arg hostname "$HOSTNAME_VALUE" \ + --arg os_type "linux" \ + --arg reported_at "$(date -u +"%Y-%m-%dT%H:%M:%SZ")" \ + --argjson tasks "$TASKS_JSON" \ + '{hostname: $hostname, os_type: $os_type, reported_at: $reported_at, tasks: $tasks}') + +if [[ "$DRY_RUN" == "1" ]]; then + echo "$PAYLOAD" | jq . + exit 0 +fi + +response=$(curl -sS -o /tmp/hlm-agent-response.json -w "%{http_code}" \ + -X POST "$API_URL/api/agent/report" \ + -H "Authorization: Bearer $API_TOKEN" \ + -H "Content-Type: application/json" \ + -d "$PAYLOAD") + +if [[ "$response" -lt 200 || "$response" -ge 300 ]]; then + echo "Report failed with HTTP $response:" >&2 + cat /tmp/hlm-agent-response.json >&2 + exit 1 +fi + +echo "Reported $(jq 'length' <<<"$TASKS_JSON") task(s) successfully." diff --git a/agent/linux/uninstall.sh b/agent/linux/uninstall.sh new file mode 100644 index 0000000..3b908ad --- /dev/null +++ b/agent/linux/uninstall.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# Removes the Homelab Manager task-reporting agent from this server: stops +# and deletes its systemd timer/service, the installed script, and its +# credentials file. +# +# Usage (must run as root — if not already root, put sudo right after the +# pipe so it applies to bash, not to curl): +# curl -fsSL https://homelab.example.lan/agent/linux/uninstall.sh | sudo bash +# +set -euo pipefail + +INSTALL_DIR="/usr/local/bin" +CONFIG_DIR="/etc" +SYSTEMD_DIR="/etc/systemd/system" + +if [[ "$EUID" -ne 0 ]]; then + echo "This uninstaller must be run as root." >&2 + echo "If you're piping from curl, put sudo right after the pipe so it elevates bash, not curl:" >&2 + echo " curl -fsSL ... | sudo bash" >&2 + exit 1 +fi + +echo "Removing Homelab Manager agent..." + +if command -v systemctl >/dev/null 2>&1; then + systemctl disable --now homelab-manager-agent.timer >/dev/null 2>&1 || true +fi + +rm -f \ + "$SYSTEMD_DIR/homelab-manager-agent.timer" \ + "$SYSTEMD_DIR/homelab-manager-agent.service" \ + "$CONFIG_DIR/homelab-manager-agent.env" \ + "$INSTALL_DIR/homelab-manager-agent.sh" \ + /tmp/hlm-agent-response.json + +if command -v systemctl >/dev/null 2>&1; then + systemctl daemon-reload +fi + +echo "Done. The agent no longer runs or reports from this server." +echo "Its entry (and task history) in Homelab Manager is untouched —" \ + "delete it from the Servers & Tasks page if you no longer want it tracked." diff --git a/agent/windows/README.md b/agent/windows/README.md new file mode 100644 index 0000000..6c9cf0a --- /dev/null +++ b/agent/windows/README.md @@ -0,0 +1,14 @@ +# Windows agent (planned, not yet implemented) + +v1 of the Servers & Tasks module only supports Linux servers (cron + systemd +timers) — this covers the Debian and Raspbian hosts in the homelab. A Windows +agent is a natural future addition and would follow the same contract as the +Linux agent in [`../linux/report-tasks.sh`](../linux/report-tasks.sh): + +- Collect tasks with `Get-ScheduledTask | Get-ScheduledTaskInfo` (name, action/command, + trigger description, next run time, enabled state). +- POST the same JSON shape to `POST /api/agent/report` with `schedule_type: "windows_task"` + (the server and UI already treat `schedule_type` as an open string in storage; only the + `tasks` API and UI schedule-type filter would need the new value added). +- Ship as a scheduled task (naturally) or a small Windows service that runs on a timer, + configured via the same `API_URL` / `API_TOKEN` environment variables as the Linux agent. diff --git a/package-lock.json b/package-lock.json index c0568ef..5f93b8a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2343,6 +2343,27 @@ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", "license": "MIT" }, + "node_modules/cron-parser": { + "version": "5.10.1", + "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-5.10.1.tgz", + "integrity": "sha512-pKRrRagItwk9rGIStcUyMxFX34x56zoX3KDf9HJ4ttwkXIK1qxK63EvXvEDmlxI9AdPJ+Y7TEKRftRlW5eSS7A==", + "license": "MIT", + "dependencies": { + "luxon": "^3.7.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/cronstrue": { + "version": "2.59.0", + "resolved": "https://registry.npmjs.org/cronstrue/-/cronstrue-2.59.0.tgz", + "integrity": "sha512-YKGmAy84hKH+hHIIER07VCAHf9u0Ldelx1uU6EBxsRPDXIA1m5fsKmJfyC3xBhw6cVC/1i83VdbL4PvepTrt8A==", + "license": "MIT", + "bin": { + "cronstrue": "bin/cli.js" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -3203,6 +3224,15 @@ "yallist": "^3.0.2" } }, + "node_modules/luxon": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -4803,6 +4833,8 @@ "dependencies": { "@tabler/core": "^1.5.1", "@tabler/icons-react": "^3.46.0", + "cron-parser": "^5.10.0", + "cronstrue": "^2.53.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^7.1.1" diff --git a/server/src/index.ts b/server/src/index.ts index c78bb50..78e26b1 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -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)); diff --git a/server/src/routes/agentReport.ts b/server/src/routes/agentReport.ts new file mode 100644 index 0000000..11090e0 --- /dev/null +++ b/server/src/routes/agentReport.ts @@ -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 }); +}); diff --git a/server/src/routes/servers.ts b/server/src/routes/servers.ts new file mode 100644 index 0000000..365d099 --- /dev/null +++ b/server/src/routes/servers.ts @@ -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(); +}); diff --git a/server/src/routes/tasks.ts b/server/src/routes/tasks.ts new file mode 100644 index 0000000..0a675bd --- /dev/null +++ b/server/src/routes/tasks.ts @@ -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(); +}); diff --git a/server/src/services/taskSync.ts b/server/src/services/taskSync.ts new file mode 100644 index 0000000..bb5442b --- /dev/null +++ b/server/src/services/taskSync.ts @@ -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)); +} diff --git a/server/src/services/tokens.ts b/server/src/services/tokens.ts new file mode 100644 index 0000000..7e103b7 --- /dev/null +++ b/server/src/services/tokens.ts @@ -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); +} diff --git a/web/package.json b/web/package.json index 60ec83d..182d654 100644 --- a/web/package.json +++ b/web/package.json @@ -11,6 +11,8 @@ "dependencies": { "@tabler/core": "^1.5.1", "@tabler/icons-react": "^3.46.0", + "cron-parser": "^5.10.0", + "cronstrue": "^2.53.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^7.1.1" diff --git a/web/src/App.tsx b/web/src/App.tsx index e157b0e..ce2fce2 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -8,6 +8,7 @@ import AuditLog from "./pages/AuditLog"; import Secrets from "./pages/Secrets"; import Ipam from "./pages/Ipam"; import Dns from "./pages/Dns"; +import ServersTasks from "./pages/ServersTasks"; import ComingSoon from "./pages/ComingSoon"; import AppShell from "./layout/AppShell"; @@ -57,7 +58,7 @@ export default function App() { } /> - } /> + } /> } /> } /> } /> diff --git a/web/src/api/client.ts b/web/src/api/client.ts index dd7c17a..d6d665c 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -120,6 +120,46 @@ export interface DnsRecordInput { proxied?: boolean; } +export interface ServerRecord { + id: number; + name: string; + hostname: string | null; + osType: string; + description: string | null; + apiTokenPrefix: string; + createdAt: string; + lastSeenAt: string | null; +} + +export type ScheduleType = "cron" | "systemd_timer" | "docker" | "backup" | "update" | "n8n_workflow" | "manual"; + +export interface TaskRecord { + id: number; + serverId: number; + serverName: string; + scheduleType: ScheduleType; + origin: "agent" | "manual"; + name: string; + command: string | null; + scheduleExpression: string | null; + source: string | null; + enabled: boolean; + nextRunAt: string | null; + isStale: boolean; + firstSeenAt: string; + lastSeenAt: string; +} + +export interface ManualTaskInput { + serverId: number; + scheduleType: ScheduleType; + name: string; + command?: string; + scheduleExpression?: string; + nextRunAt?: string; + enabled?: boolean; +} + export class UnauthorizedError extends Error {} export class ForbiddenError extends Error {} @@ -227,4 +267,35 @@ export const api = { ), }, }, + servers: { + list: () => request<{ servers: ServerRecord[] }>("/api/servers"), + create: (data: { name: string; hostname?: string; description?: string }) => + request<{ server: ServerRecord; token: string }>("/api/servers", { + method: "POST", + body: JSON.stringify(data), + }), + rotateToken: (id: number) => + request<{ server: ServerRecord; token: string }>(`/api/servers/${id}/rotate-token`, { + method: "POST", + }), + remove: (id: number) => request(`/api/servers/${id}`, { method: "DELETE" }), + }, + tasks: { + list: ( + params: { serverId?: number; scheduleType?: string; search?: string; includeStale?: boolean } = {}, + ) => { + const qs = new URLSearchParams(); + if (params.serverId) qs.set("serverId", String(params.serverId)); + if (params.scheduleType) qs.set("scheduleType", params.scheduleType); + if (params.search) qs.set("search", params.search); + if (params.includeStale) qs.set("includeStale", "true"); + const query = qs.toString(); + return request<{ tasks: TaskRecord[] }>(`/api/tasks${query ? `?${query}` : ""}`); + }, + create: (data: ManualTaskInput) => + request<{ task: TaskRecord }>("/api/tasks", { method: "POST", body: JSON.stringify(data) }), + update: (id: number, data: Partial>) => + request<{ task: TaskRecord }>(`/api/tasks/${id}`, { method: "PATCH", body: JSON.stringify(data) }), + remove: (id: number) => request(`/api/tasks/${id}`, { method: "DELETE" }), + }, }; diff --git a/web/src/components/CopyButton.tsx b/web/src/components/CopyButton.tsx new file mode 100644 index 0000000..152b71b --- /dev/null +++ b/web/src/components/CopyButton.tsx @@ -0,0 +1,21 @@ +import { useState } from "react"; + +export default function CopyButton({ text }: { text: string }) { + const [copied, setCopied] = useState(false); + + async function handleCopy() { + try { + await navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } catch { + // Clipboard API unavailable (e.g. insecure context) — nothing more we can do. + } + } + + return ( + + ); +} diff --git a/web/src/pages/ServersTasks.tsx b/web/src/pages/ServersTasks.tsx new file mode 100644 index 0000000..8f430c6 --- /dev/null +++ b/web/src/pages/ServersTasks.tsx @@ -0,0 +1,658 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import cronstrue from "cronstrue"; +import { CronExpressionParser } from "cron-parser"; +import { + api, + type CurrentUser, + type ManualTaskInput, + type ScheduleType, + type ServerRecord, + type TaskRecord, +} from "../api/client"; +import CopyButton from "../components/CopyButton"; +import { formatDateTime } from "../utils/date"; + +const SCHEDULE_TYPE_LABELS: Record = { + cron: "Cron jobs", + systemd_timer: "systemd timers", + docker: "Docker jobs", + backup: "Backups", + update: "Updates", + n8n_workflow: "n8n workflows", + manual: "Other", +}; + +const SCHEDULE_TYPE_OPTIONS: { value: ScheduleType; label: string }[] = [ + { value: "docker", label: "Docker" }, + { value: "cron", label: "Cron" }, + { value: "systemd_timer", label: "systemd timer" }, + { value: "backup", label: "Backup" }, + { value: "update", label: "Update" }, + { value: "n8n_workflow", label: "n8n workflow" }, + { value: "manual", label: "Other / manual" }, +]; + +function installCommand(token: string): string { + return `curl -fsSL ${window.location.origin}/agent/linux/install.sh | sudo API_URL=${window.location.origin} API_TOKEN=${token} bash`; +} + +function uninstallCommand(): string { + return `curl -fsSL ${window.location.origin}/agent/linux/uninstall.sh | sudo bash`; +} + +function describeSchedule(task: TaskRecord): string { + if (!task.scheduleExpression) return "—"; + if (task.scheduleType === "cron") { + try { + return cronstrue.toString(task.scheduleExpression, { verbose: false }); + } catch { + return task.scheduleExpression; + } + } + return task.scheduleExpression; +} + +interface NextRun { + date: Date; + estimated: boolean; +} + +function computeNextRun(task: TaskRecord): NextRun | null { + if (task.nextRunAt) return { date: new Date(task.nextRunAt), estimated: false }; + if (task.scheduleType !== "cron" || !task.scheduleExpression || task.isStale || !task.enabled) return null; + try { + const date = CronExpressionParser.parse(task.scheduleExpression).next().toDate(); + return { date, estimated: true }; + } catch { + return null; + } +} + +const emptyTaskForm = { + scheduleType: "docker" as ScheduleType, + name: "", + command: "", + scheduleExpression: "", + enabled: true, +}; + +export default function ServersTasks({ user }: { user: CurrentUser }) { + const isAdmin = user.role === "admin"; + const canEditTasks = user.role === "admin" || user.role === "operator"; + + const [servers, setServers] = useState([]); + const [tasks, setTasks] = useState(null); + const [error, setError] = useState(null); + const [managingServers, setManagingServers] = useState(false); + + const [filters, setFilters] = useState({ + serverId: undefined as number | undefined, + scheduleType: undefined as string | undefined, + search: "", + includeStale: false, + }); + + const [taskForm, setTaskForm] = useState(null); + const [editingTask, setEditingTask] = useState(null); + const [newTaskServerId, setNewTaskServerId] = useState(undefined); + const [savingTask, setSavingTask] = useState(false); + + const [serverName, setServerName] = useState(""); + const [serverHostname, setServerHostname] = useState(""); + const [serverDescription, setServerDescription] = useState(""); + const [newToken, setNewToken] = useState<{ server: ServerRecord; token: string } | null>(null); + const [uninstallFor, setUninstallFor] = useState(null); + + const loadServers = useCallback(() => { + return api.servers + .list() + .then((res) => setServers(res.servers)) + .catch((err) => setError(String(err))); + }, []); + + const loadTasks = useCallback(() => { + return api.tasks + .list(filters) + .then((res) => setTasks(res.tasks)) + .catch((err) => setError(String(err))); + }, [filters]); + + useEffect(() => { + loadServers(); + }, [loadServers]); + + useEffect(() => { + const handle = setTimeout(loadTasks, 200); + return () => clearTimeout(handle); + }, [loadTasks]); + + const groups = useMemo(() => { + const byServer = new Map(); + for (const task of tasks ?? []) { + const entry = byServer.get(task.serverId) ?? { serverId: task.serverId, serverName: task.serverName, tasks: [] }; + entry.tasks.push(task); + byServer.set(task.serverId, entry); + } + return [...byServer.values()]; + }, [tasks]); + + function openAddTask() { + setEditingTask(null); + setNewTaskServerId(filters.serverId ?? servers[0]?.id); + setTaskForm({ ...emptyTaskForm }); + } + + function openEditTask(task: TaskRecord) { + setEditingTask(task); + setTaskForm({ + scheduleType: task.scheduleType, + name: task.name, + command: task.command ?? "", + scheduleExpression: task.scheduleExpression ?? "", + enabled: task.enabled, + }); + } + + async function submitTask(e: React.FormEvent) { + e.preventDefault(); + if (!taskForm) return; + setSavingTask(true); + setError(null); + try { + const serverId = editingTask?.serverId ?? newTaskServerId; + if (!serverId) { + setError("Choose a server first."); + return; + } + const data: ManualTaskInput = { + serverId, + scheduleType: taskForm.scheduleType, + name: taskForm.name, + command: taskForm.command || undefined, + scheduleExpression: taskForm.scheduleExpression || undefined, + enabled: taskForm.enabled, + }; + if (editingTask) { + await api.tasks.update(editingTask.id, data); + } else { + await api.tasks.create(data); + } + setTaskForm(null); + setEditingTask(null); + await loadTasks(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setSavingTask(false); + } + } + + async function deleteTask(task: TaskRecord) { + if (!confirm(`Delete "${task.name}"?`)) return; + try { + await api.tasks.remove(task.id); + await loadTasks(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + } + + async function createServer(e: React.FormEvent) { + e.preventDefault(); + setError(null); + try { + const result = await api.servers.create({ + name: serverName, + hostname: serverHostname || undefined, + description: serverDescription || undefined, + }); + setNewToken(result); + setServerName(""); + setServerHostname(""); + setServerDescription(""); + await loadServers(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + } + + async function rotateToken(id: number) { + try { + const result = await api.servers.rotateToken(id); + setNewToken(result); + await loadServers(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + } + + async function deleteServer(id: number) { + if (!confirm("Remove this server and all its tracked tasks?")) return; + try { + await api.servers.remove(id); + await loadServers(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + } + + return ( + <> +
+

Servers & Tasks

+ {isAdmin && ( + + )} +
+ {error &&
{error}
} + + {managingServers ? ( + <> +
+
+

Add a server

+
+
+
+
+ + setServerName(e.target.value)} + /> +
+
+ + setServerHostname(e.target.value)} + /> +
+
+ + setServerDescription(e.target.value)} + /> +
+
+
+ +
+
+
+ + {newToken && ( +
+
+

Agent token for {newToken.server.name}

+
+
+

This token is only shown once — copy it now.

+
+ {newToken.token} + +
+

+ Install the agent on the server (run as root — put sudo right after the pipe, not before curl): +

+
+
{installCommand(newToken.token)}
+ +
+
+
+ +
+
+ )} + + {uninstallFor && ( +
+
+

Uninstall agent from {uninstallFor.name}

+
+
+

Run this on the server as root to stop and remove the agent (its entry here is kept):

+
+
{uninstallCommand()}
+ +
+
+
+ +
+
+ )} + +
+
+ + + + + + + + + + + + {servers.map((s) => ( + + + + + + + + ))} + {servers.length === 0 && ( + + + + )} + +
NameHostnameTokenLast seenActions
{s.name}{s.hostname ?? "—"}{s.apiTokenPrefix}…{s.lastSeenAt ? formatDateTime(new Date(s.lastSeenAt)) : "never"} +
+ + + +
+
+ No servers registered yet. +
+
+
+ + ) : ( + <> +
+
+
+
+ + +
+
+ + +
+
+ + setFilters({ ...filters, search: e.target.value })} + /> +
+
+ + {canEditTasks && ( + + )} +
+
+
+
+ + {taskForm && ( +
+
+

{editingTask ? "Edit task" : "Add a task manually"}

+
+
+
+ {!editingTask && ( +
+ + +
+ )} +
+ + +
+
+ + setTaskForm({ ...taskForm, name: e.target.value })} + /> +
+
+ + setTaskForm({ ...taskForm, command: e.target.value })} + /> +
+
+ + setTaskForm({ ...taskForm, scheduleExpression: e.target.value })} + /> +
+
+ +
+
+
+ + +
+
+
+ )} + + {tasks && groups.length === 0 && ( +
+
+ No scheduled tasks found yet. Install the agent on a server to auto-report cron/systemd tasks, or use + "Add task" above for things like Docker-based backup jobs. +
+
+ )} + + {groups.map((group) => { + const byType = new Map(); + for (const task of group.tasks) { + const list = byType.get(task.scheduleType) ?? []; + list.push(task); + byType.set(task.scheduleType, list); + } + return ( +
+
+

{group.serverName}

+
+ {[...byType.entries()].map(([scheduleType, groupTasks]) => ( +
+
+ {SCHEDULE_TYPE_LABELS[scheduleType] ?? scheduleType} +
+
+ + + + + + + + + {canEditTasks && } + + + + {groupTasks.map((task) => { + const nextRun = computeNextRun(task); + return ( + + + + + + + {canEditTasks && ( + + )} + + ); + })} + +
NameCommandScheduleNext runStatusActions
{task.name}{task.command ?? "—"} +
{describeSchedule(task)}
+ {task.scheduleExpression && ( + {task.scheduleExpression} + )} +
+ {nextRun ? ( + + {nextRun.estimated ? "~" : ""} + {formatDateTime(nextRun.date)} + + ) : ( + "—" + )} + + {task.isStale ? ( + Missing + ) : task.enabled ? ( + Enabled + ) : ( + Disabled + )} + {task.origin === "manual" && Manual} + + {task.origin === "manual" && ( +
+ + +
+ )} +
+
+
+ ))} +
+ ); + })} + + )} + + ); +} diff --git a/web/src/utils/date.ts b/web/src/utils/date.ts new file mode 100644 index 0000000..e6ba9e7 --- /dev/null +++ b/web/src/utils/date.ts @@ -0,0 +1,12 @@ +/** Formats a date as "YYYY-MM-DD HH:mm:ss" on a 24-hour clock. */ +export function formatDateTime(date: Date): string { + return date.toLocaleString("sv-SE", { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + }); +}