import { Router } from "express"; import { z } from "zod"; import { db } from "../db/index.js"; import { enrollmentTokens, devices, deviceCredentials, inventorySnapshots, metricSnapshots, jobTargets, jobs, softwarePackages, } from "../db/schema.js"; import { hashToken, generateDeviceCredential, hashCredential, } from "../lib/tokens.js"; import { auditLog } from "../lib/audit.js"; import { eq, and, gt, inArray, notInArray } from "drizzle-orm"; import path from "path"; import fs from "fs"; import { fileURLToPath } from "url"; declare global { namespace Express { interface Request { device?: any; deviceCredential?: any; } } } export const agentRouter = Router(); const agentAuthMiddleware = async (req: any, res: any, next: any) => { const authHeader = req.headers.authorization; if (!authHeader?.startsWith("Bearer ")) { res.status(401).json({ success: false, error: { code: "UNAUTHORIZED", message: "Missing device credential" }, }); return; } const credential = authHeader.substring(7); const credHash = hashCredential(credential); const [deviceCred] = await db .select() .from(deviceCredentials) .where( and( eq(deviceCredentials.credentialHash, credHash), eq(deviceCredentials.credentialType, "device_key") ) ) .limit(1); if (!deviceCred) { res.status(401).json({ success: false, error: { code: "UNAUTHORIZED", message: "Invalid device credential" }, }); return; } if (deviceCred.expiresAt && new Date(deviceCred.expiresAt) < new Date()) { res.status(401).json({ success: false, error: { code: "CREDENTIAL_EXPIRED", message: "Device credential expired" }, }); return; } const [device] = await db .select() .from(devices) .where(eq(devices.id, deviceCred.deviceId)) .limit(1); if (!device || device.isDisabled) { res.status(403).json({ success: false, error: { code: "DEVICE_DISABLED", message: "Device is disabled or not found" }, }); return; } req.device = device; req.deviceCredential = deviceCred; next(); }; const enrollSchema = z.object({ token: z.string().min(1), hostname: z.string().min(1).max(255), platform: z.string().min(1).max(50), os_name: z.string().min(1).max(100), os_version: z.string().max(100).default("unknown"), architecture: z.string().min(1).max(50), kernel_version: z.string().max(100).optional(), cpu_model: z.string().max(255).optional(), cpu_cores: z.number().int().positive().optional(), total_memory_bytes: z.number().positive().optional(), total_disk_bytes: z.number().positive().optional(), ip_address: z.string().max(45).optional(), agent_version: z.string().min(1).max(50), }); agentRouter.post("/enroll", async (req, res) => { try { const body = enrollSchema.parse(req.body); const tokenHash = hashToken(body.token); const [enrollmentToken] = await db .select() .from(enrollmentTokens) .where( and( eq(enrollmentTokens.tokenHash, tokenHash), eq(enrollmentTokens.isRevoked, false), gt(enrollmentTokens.expiresAt, new Date()) ) ) .limit(1); if (!enrollmentToken) { res.status(401).json({ success: false, error: { code: "INVALID_TOKEN", message: "Invalid, expired, or revoked enrollment token" }, }); return; } if ( enrollmentToken.maxUses && enrollmentToken.useCount >= enrollmentToken.maxUses ) { res.status(401).json({ success: false, error: { code: "TOKEN_EXHAUSTED", message: "Enrollment token has been used up" }, }); return; } let deviceType = "linux_server"; if (body.platform === "windows") deviceType = "windows_server"; if (body.platform === "darwin") deviceType = "macos"; const [device] = await db .insert(devices) .values({ organizationId: enrollmentToken.organizationId, name: body.hostname, deviceType, platform: body.platform, osName: body.os_name, osVersion: body.os_version, architecture: body.architecture, kernelVersion: body.kernel_version || null, cpuModel: body.cpu_model || null, cpuCores: body.cpu_cores || null, totalMemoryBytes: body.total_memory_bytes || null, totalDiskBytes: body.total_disk_bytes || null, ipAddress: body.ip_address || null, agentVersion: body.agent_version, status: "online", lastHeartbeatAt: new Date(), }) .returning(); const rawCredential = generateDeviceCredential(); const credentialHash = hashCredential(rawCredential); await db.insert(deviceCredentials).values({ deviceId: device.id, credentialType: "device_key", credentialHash, expiresAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), }); await db .update(enrollmentTokens) .set({ useCount: enrollmentToken.useCount + 1 }) .where(eq(enrollmentTokens.id, enrollmentToken.id)); await auditLog({ organizationId: enrollmentToken.organizationId, action: "device.enroll", targetType: "device", targetId: device.id, deviceIds: [device.id], result: "success", metadata: { hostname: body.hostname, platform: body.platform, os: body.os_name, agentVersion: body.agent_version, }, correlationId: req.headers["x-correlation-id"] as string || "enrollment", }); res.status(201).json({ success: true, data: { device_id: device.id, credential: rawCredential, }, }); } catch (err) { if (err instanceof z.ZodError) { res.status(400).json({ success: false, error: { code: "VALIDATION_ERROR", message: "Invalid enrollment data", details: err.errors, }, }); return; } console.error("[enroll] Error:", err); res.status(500).json({ success: false, error: { code: "INTERNAL_ERROR", message: "Enrollment failed" }, }); } }); const heartbeatSchema = z.object({ hostname: z.string().optional(), os_version: z.string().optional(), kernel_version: z.string().optional(), agent_version: z.string().optional(), ip_address: z.string().optional(), metrics: z .object({ cpu_usage_percent: z.number().min(0).max(100).optional(), memory_usage_percent: z.number().min(0).max(100).optional(), memory_used_bytes: z.number().optional(), memory_total_bytes: z.number().optional(), disk_usage_percent: z.number().min(0).max(100).optional(), disk_used_bytes: z.number().optional(), disk_total_bytes: z.number().optional(), load_average_1m: z.number().optional(), load_average_5m: z.number().optional(), load_average_15m: z.number().optional(), uptime_seconds: z.number().optional(), }) .optional(), inventory: z .object({ packages: z .array( z.object({ name: z.string(), version: z.string(), manager: z.string(), }) ) .optional(), services: z .array( z.object({ name: z.string(), status: z.string(), enabled: z.boolean(), }) ) .optional(), network_interfaces: z .array( z.object({ name: z.string(), addresses: z.array(z.string()), mac_address: z.string().nullable().optional(), }) ) .optional(), }) .optional(), }); agentRouter.post("/heartbeat", agentAuthMiddleware, async (req, res) => { try { const body = heartbeatSchema.parse(req.body); const device = req.device; const updateData: Record = { status: "online", lastHeartbeatAt: new Date(), }; if (body.hostname) updateData.name = body.hostname; if (body.os_version) updateData.osVersion = body.os_version; if (body.kernel_version) updateData.kernelVersion = body.kernel_version; if (body.agent_version) updateData.agentVersion = body.agent_version; if (body.ip_address) updateData.ipAddress = body.ip_address; await db.update(devices).set(updateData).where(eq(devices.id, device.id)); if (body.metrics) { await db.insert(metricSnapshots).values({ deviceId: device.id, reportedAt: new Date(), cpuUsagePercent: body.metrics.cpu_usage_percent ?? null, memoryUsagePercent: body.metrics.memory_usage_percent ?? null, memoryUsedBytes: body.metrics.memory_used_bytes ?? null, memoryTotalBytes: body.metrics.memory_total_bytes ?? null, diskUsagePercent: body.metrics.disk_usage_percent ?? null, diskUsedBytes: body.metrics.disk_used_bytes ?? null, diskTotalBytes: body.metrics.disk_total_bytes ?? null, loadAverage1m: body.metrics.load_average_1m ?? null, loadAverage5m: body.metrics.load_average_5m ?? null, loadAverage15m: body.metrics.load_average_15m ?? null, uptimeSeconds: body.metrics.uptime_seconds ?? null, }); } if (body.inventory) { await db.insert(inventorySnapshots).values({ deviceId: device.id, reportedAt: new Date(), packages: body.inventory.packages || [], services: body.inventory.services || [], networkInterfaces: body.inventory.network_interfaces || [], platformData: {}, }); } res.json({ success: true }); } catch (err) { if (err instanceof z.ZodError) { res.status(400).json({ success: false, error: { code: "VALIDATION_ERROR", message: "Invalid heartbeat data", details: err.errors, }, }); return; } console.error("[heartbeat] Error:", err); res.status(500).json({ success: false, error: { code: "INTERNAL_ERROR", message: "Heartbeat processing failed" }, }); } }); agentRouter.get("/jobs", agentAuthMiddleware, async (req, res) => { const device = req.device; const pendingTargets = await db .select({ jobId: jobTargets.jobId, targetId: jobTargets.id, jobType: jobs.jobType, parameters: jobs.parameters, }) .from(jobTargets) .innerJoin(jobs, eq(jobTargets.jobId, jobs.id)) .where( and( eq(jobTargets.deviceId, device.id), inArray(jobTargets.status, ["queued", "dispatched"]), eq(jobs.status, "dispatched") ) ) .limit(10); res.json({ success: true, data: pendingTargets }); }); const startJobSchema = z.object({ target_id: z.string().uuid(), }); agentRouter.post("/jobs/:jobId/start", agentAuthMiddleware, async (req, res) => { try { const body = startJobSchema.parse(req.body); const device = req.device; const [target] = await db .select() .from(jobTargets) .where( and( eq(jobTargets.id, body.target_id), eq(jobTargets.deviceId, device.id), eq(jobTargets.jobId, req.params.jobId as string) ) ) .limit(1); if (!target) { res.status(404).json({ success: false, error: { code: "NOT_FOUND", message: "Job target not found" }, }); return; } if (target.status !== "queued" && target.status !== "dispatched") { res.json({ success: true }); return; } await db .update(jobTargets) .set({ status: "running", startedAt: new Date() }) .where(eq(jobTargets.id, body.target_id)); await db .update(jobs) .set({ status: "running", startedAt: new Date() }) .where( and(eq(jobs.id, req.params.jobId as string), eq(jobs.status, "dispatched")) ); res.json({ success: true }); } catch (err) { if (err instanceof z.ZodError) { res.status(400).json({ success: false, error: { code: "VALIDATION_ERROR", message: "Invalid start data", details: err.errors }, }); return; } console.error("[jobs/start] Error:", err); res.status(500).json({ success: false, error: { code: "INTERNAL_ERROR", message: "Start processing failed" }, }); } }); agentRouter.get("/jobs/:jobId/status", agentAuthMiddleware, async (req, res) => { const device = req.device; const [row] = await db .select({ jobStatus: jobs.status, targetStatus: jobTargets.status, }) .from(jobTargets) .innerJoin(jobs, eq(jobTargets.jobId, jobs.id)) .where( and( eq(jobTargets.jobId, req.params.jobId as string), eq(jobTargets.deviceId, device.id) ) ) .limit(1); if (!row) { res.status(404).json({ success: false, error: { code: "NOT_FOUND", message: "Job target not found" }, }); return; } res.json({ success: true, data: { job_status: row.jobStatus, target_status: row.targetStatus } }); }); const resultSchema = z.object({ job_id: z.string().uuid(), target_id: z.string().uuid(), status: z.enum(["succeeded", "failed", "cancelled"]), result: z.record(z.unknown()).optional(), error_message: z.string().optional(), }); agentRouter.post("/jobs/:jobId/result", agentAuthMiddleware, async (req, res) => { try { const body = resultSchema.parse({ ...req.body, job_id: req.params.jobId, }); const device = req.device; const [target] = await db .select() .from(jobTargets) .where( and( eq(jobTargets.id, body.target_id), eq(jobTargets.deviceId, device.id), eq(jobTargets.jobId, body.job_id) ) ) .limit(1); if (!target) { res.status(404).json({ success: false, error: { code: "NOT_FOUND", message: "Job target not found" }, }); return; } const resultData = body.result || {}; if (body.error_message) { resultData.error = body.error_message; } await db .update(jobTargets) .set({ status: body.status, result: resultData, completedAt: new Date(), }) .where(eq(jobTargets.id, body.target_id)); const allTargets = await db .select() .from(jobTargets) .where(eq(jobTargets.jobId, body.job_id)); const allComplete = allTargets.every( (t) => t.status !== "queued" && t.status !== "dispatched" && t.status !== "running" ); if (allComplete) { const anyFailed = allTargets.some((t) => t.status === "failed"); await db .update(jobs) .set({ status: anyFailed ? "failed" : "succeeded", completedAt: new Date(), }) .where( and(eq(jobs.id, body.job_id), notInArray(jobs.status, ["cancelled", "expired"])) ); } res.json({ success: true }); } catch (err) { if (err instanceof z.ZodError) { res.status(400).json({ success: false, error: { code: "VALIDATION_ERROR", message: "Invalid result data", details: err.errors }, }); return; } console.error("[jobs/result] Error:", err); res.status(500).json({ success: false, error: { code: "INTERNAL_ERROR", message: "Result processing failed" }, }); } }); const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const SOFTWARE_DIR = path.resolve( process.env.SOFTWARE_DIR || path.join(__dirname, "../../../../infrastructure/software-releases") ); agentRouter.get("/software/:packageId/download", agentAuthMiddleware, async (req, res) => { try { const device = req.device; const packageId = req.params.packageId as string; const [pkg] = await db .select() .from(softwarePackages) .where( and( eq(softwarePackages.id, packageId), eq(softwarePackages.organizationId, device.organizationId) ) ) .limit(1); if (!pkg) { res.status(404).json({ success: false, error: { code: "NOT_FOUND", message: "Package not found" }, }); return; } const orgDir = path.join(SOFTWARE_DIR, device.organizationId); const filePath = path.join(orgDir, pkg.filename); if (!fs.existsSync(filePath)) { res.status(404).json({ success: false, error: { code: "NOT_FOUND", message: "File not found on disk" }, }); return; } res.setHeader("Content-Disposition", `attachment; filename="${pkg.filename}"`); res.setHeader("Content-Type", "application/octet-stream"); res.setHeader("Content-Length", pkg.fileSize); fs.createReadStream(filePath).pipe(res); } catch (err) { console.error("[agent/software] Error:", err); res.status(500).json({ success: false, error: { code: "INTERNAL_ERROR", message: "Download failed" }, }); } });