Files
fleetforge/apps/api/src/routes/agent.ts
T

613 lines
17 KiB
TypeScript

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<string, any> = {
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" },
});
}
});