feat: Fleet Forge UEM - device management, agent, remote desktop (VNC), TLS reverse proxy
This commit is contained in:
@@ -0,0 +1,612 @@
|
||||
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" },
|
||||
});
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user