613 lines
17 KiB
TypeScript
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" },
|
|
});
|
|
}
|
|
});
|