feat: Fleet Forge UEM - device management, agent, remote desktop (VNC), TLS reverse proxy
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
import type { Request } from "express";
|
||||
import { db } from "../db/index.js";
|
||||
import { auditEvents } from "../db/schema.js";
|
||||
import type { AuthenticatedUser } from "@fleetforge/shared";
|
||||
|
||||
export async function auditLog(params: {
|
||||
req?: Request;
|
||||
userId?: string;
|
||||
organizationId: string;
|
||||
action: string;
|
||||
targetType: string;
|
||||
targetId?: string;
|
||||
deviceIds?: string[];
|
||||
result: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
correlationId?: string;
|
||||
}) {
|
||||
const correlationId =
|
||||
params.correlationId ||
|
||||
(params.req ? ((params.req as any).correlationId as string) : "unknown");
|
||||
const ipAddress = params.req?.ip || params.req?.socket?.remoteAddress;
|
||||
const user = params.req
|
||||
? ((params.req as any).user as AuthenticatedUser)
|
||||
: undefined;
|
||||
|
||||
await db.insert(auditEvents).values({
|
||||
organizationId: params.organizationId,
|
||||
userId: params.userId || user?.userId || null,
|
||||
action: params.action,
|
||||
targetType: params.targetType,
|
||||
targetId: params.targetId || null,
|
||||
deviceIds: params.deviceIds || [],
|
||||
result: params.result,
|
||||
ipAddress: ipAddress || null,
|
||||
metadata: params.metadata || {},
|
||||
correlationId,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,492 @@
|
||||
import pg from "pg";
|
||||
|
||||
interface PolicyRule {
|
||||
type: string;
|
||||
condition: string;
|
||||
value: string | number | boolean;
|
||||
}
|
||||
|
||||
interface DeviceRow {
|
||||
id: string;
|
||||
organization_id: string;
|
||||
os_name: string;
|
||||
os_version: string;
|
||||
platform: string;
|
||||
architecture: string;
|
||||
agent_version: string;
|
||||
cpu_cores: number | null;
|
||||
total_memory_bytes: number | null;
|
||||
total_disk_bytes: number | null;
|
||||
status: string;
|
||||
tags: string[];
|
||||
kernel_version: string | null;
|
||||
}
|
||||
|
||||
interface MetricRow {
|
||||
cpu_usage_percent: number | null;
|
||||
memory_usage_percent: number | null;
|
||||
disk_usage_percent: number | null;
|
||||
load_average_1m: number | null;
|
||||
uptime_seconds: number | null;
|
||||
}
|
||||
|
||||
interface PolicyRow {
|
||||
id: string;
|
||||
organization_id: string;
|
||||
name: string;
|
||||
rules: PolicyRule[];
|
||||
}
|
||||
|
||||
interface InventoryRow {
|
||||
packages: { name: string; version: string }[];
|
||||
services: { name: string; status: string; enabled: boolean }[];
|
||||
}
|
||||
|
||||
function compareVersions(a: string, b: string): number {
|
||||
const pa = a.split(".").map(Number);
|
||||
const pb = b.split(".").map(Number);
|
||||
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
||||
const na = pa[i] || 0;
|
||||
const nb = pb[i] || 0;
|
||||
if (na > nb) return 1;
|
||||
if (na < nb) return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function getDeviceField(
|
||||
device: DeviceRow,
|
||||
metrics: MetricRow | null,
|
||||
inventory: InventoryRow | null,
|
||||
fieldType: string
|
||||
): unknown {
|
||||
switch (fieldType) {
|
||||
case "os_version":
|
||||
return device.os_version;
|
||||
case "os_name":
|
||||
return device.os_name;
|
||||
case "platform":
|
||||
return device.platform;
|
||||
case "architecture":
|
||||
return device.architecture;
|
||||
case "agent_version":
|
||||
return device.agent_version;
|
||||
case "cpu_cores":
|
||||
return device.cpu_cores;
|
||||
case "memory_total":
|
||||
return device.total_memory_bytes;
|
||||
case "disk_total":
|
||||
return device.total_disk_bytes;
|
||||
case "status":
|
||||
return device.status;
|
||||
case "tags":
|
||||
return device.tags || [];
|
||||
case "kernel_version":
|
||||
return device.kernel_version;
|
||||
case "cpu_usage":
|
||||
return metrics?.cpu_usage_percent;
|
||||
case "memory_usage":
|
||||
return metrics?.memory_usage_percent;
|
||||
case "disk_usage":
|
||||
return metrics?.disk_usage_percent;
|
||||
case "load_average":
|
||||
return metrics?.load_average_1m;
|
||||
case "uptime":
|
||||
return metrics?.uptime_seconds;
|
||||
case "package_count":
|
||||
return inventory?.packages?.length ?? 0;
|
||||
case "has_package":
|
||||
return inventory?.packages?.map((p) => p.name) ?? [];
|
||||
case "service_count":
|
||||
return inventory?.services?.length ?? 0;
|
||||
case "service_status":
|
||||
return inventory?.services?.map((s) => ({
|
||||
name: s.name,
|
||||
status: s.status,
|
||||
enabled: s.enabled,
|
||||
})) ?? [];
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function evaluateRule(
|
||||
actual: unknown,
|
||||
condition: string,
|
||||
expected: string | number | boolean
|
||||
): boolean {
|
||||
if (actual === undefined || actual === null) {
|
||||
return condition === "not_equals" && expected !== null;
|
||||
}
|
||||
|
||||
switch (condition) {
|
||||
case "equals":
|
||||
return String(actual) === String(expected);
|
||||
case "not_equals":
|
||||
return String(actual) !== String(expected);
|
||||
case "greater_than":
|
||||
return Number(actual) > Number(expected);
|
||||
case "less_than":
|
||||
return Number(actual) < Number(expected);
|
||||
case "greater_than_or_equal":
|
||||
return Number(actual) >= Number(expected);
|
||||
case "less_than_or_equal":
|
||||
return Number(actual) <= Number(expected);
|
||||
case "contains":
|
||||
if (Array.isArray(actual)) {
|
||||
return actual.includes(String(expected));
|
||||
}
|
||||
return String(actual).includes(String(expected));
|
||||
case "not_contains":
|
||||
if (Array.isArray(actual)) {
|
||||
return !actual.includes(String(expected));
|
||||
}
|
||||
return !String(actual).includes(String(expected));
|
||||
case "in":
|
||||
if (Array.isArray(expected)) {
|
||||
return expected.map(String).includes(String(actual));
|
||||
}
|
||||
return String(actual) === String(expected);
|
||||
case "not_in":
|
||||
if (Array.isArray(expected)) {
|
||||
return !expected.map(String).includes(String(actual));
|
||||
}
|
||||
return String(actual) !== String(expected);
|
||||
case "version_gte":
|
||||
return compareVersions(String(actual), String(expected)) >= 0;
|
||||
case "version_lte":
|
||||
return compareVersions(String(actual), String(expected)) <= 0;
|
||||
case "version_gt":
|
||||
return compareVersions(String(actual), String(expected)) > 0;
|
||||
case "version_lt":
|
||||
return compareVersions(String(actual), String(expected)) < 0;
|
||||
case "is_true":
|
||||
return actual === true || actual === "true";
|
||||
case "is_false":
|
||||
return actual === false || actual === "false";
|
||||
case "is_empty":
|
||||
if (Array.isArray(actual)) return actual.length === 0;
|
||||
if (typeof actual === "string") return actual.length === 0;
|
||||
return actual === null || actual === undefined;
|
||||
case "is_not_empty":
|
||||
if (Array.isArray(actual)) return actual.length > 0;
|
||||
if (typeof actual === "string") return actual.length > 0;
|
||||
return actual !== null && actual !== undefined;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function evaluatePolicies(
|
||||
pool: pg.Pool,
|
||||
organizationId: string
|
||||
): Promise<{ evaluated: number; compliant: number; nonCompliant: number; unknown: number }> {
|
||||
const client = await pool.connect();
|
||||
|
||||
let evaluated = 0;
|
||||
let compliant = 0;
|
||||
let nonCompliant = 0;
|
||||
let unknown = 0;
|
||||
|
||||
try {
|
||||
const { rows: policies } = await client.query<PolicyRow>(
|
||||
`SELECT id, organization_id, name, rules
|
||||
FROM policies
|
||||
WHERE organization_id = $1 AND is_enabled = true`,
|
||||
[organizationId]
|
||||
);
|
||||
|
||||
if (policies.length === 0) {
|
||||
return { evaluated: 0, compliant: 0, nonCompliant: 0, unknown: 0 };
|
||||
}
|
||||
|
||||
const { rows: devices } = await client.query<DeviceRow>(
|
||||
`SELECT id, organization_id, os_name, os_version, platform, architecture,
|
||||
agent_version, cpu_cores, total_memory_bytes, total_disk_bytes,
|
||||
status, tags, kernel_version
|
||||
FROM devices
|
||||
WHERE organization_id = $1
|
||||
AND is_disabled = false
|
||||
AND is_archived = false`,
|
||||
[organizationId]
|
||||
);
|
||||
|
||||
if (devices.length === 0) {
|
||||
return { evaluated: 0, compliant: 0, nonCompliant: 0, unknown: 0 };
|
||||
}
|
||||
|
||||
const deviceIds = devices.map((d) => d.id);
|
||||
const metricsMap = new Map<string, MetricRow>();
|
||||
const inventoryMap = new Map<string, InventoryRow>();
|
||||
|
||||
if (deviceIds.length > 0) {
|
||||
const { rows: metricsRows } = await client.query(
|
||||
`SELECT DISTINCT ON (device_id)
|
||||
device_id, cpu_usage_percent, memory_usage_percent,
|
||||
disk_usage_percent, load_average_1m, uptime_seconds
|
||||
FROM metric_snapshots
|
||||
WHERE device_id = ANY($1)
|
||||
ORDER BY device_id, reported_at DESC`,
|
||||
[deviceIds]
|
||||
);
|
||||
|
||||
for (const row of metricsRows) {
|
||||
metricsMap.set(row.device_id, row);
|
||||
}
|
||||
|
||||
const { rows: inventoryRows } = await client.query(
|
||||
`SELECT DISTINCT ON (device_id)
|
||||
device_id, packages, services
|
||||
FROM inventory_snapshots
|
||||
WHERE device_id = ANY($1)
|
||||
ORDER BY device_id, reported_at DESC`,
|
||||
[deviceIds]
|
||||
);
|
||||
|
||||
for (const row of inventoryRows) {
|
||||
inventoryMap.set(row.device_id, row);
|
||||
}
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const upserts: Array<{
|
||||
deviceId: string;
|
||||
policyId: string;
|
||||
status: string;
|
||||
details: Record<string, unknown>;
|
||||
}> = [];
|
||||
|
||||
for (const device of devices) {
|
||||
const metrics = metricsMap.get(device.id) || null;
|
||||
const inventory = inventoryMap.get(device.id) || null;
|
||||
|
||||
for (const policy of policies) {
|
||||
const rules = (policy.rules || []) as PolicyRule[];
|
||||
|
||||
if (rules.length === 0) {
|
||||
upserts.push({
|
||||
deviceId: device.id,
|
||||
policyId: policy.id,
|
||||
status: "compliant",
|
||||
details: { message: "No rules defined, defaulting to compliant" },
|
||||
});
|
||||
compliant++;
|
||||
evaluated++;
|
||||
continue;
|
||||
}
|
||||
|
||||
let allPassed = true;
|
||||
let hasUnknown = false;
|
||||
const ruleResults: Record<string, unknown> = {};
|
||||
|
||||
for (const rule of rules) {
|
||||
const actual = getDeviceField(device, metrics, inventory, rule.type);
|
||||
|
||||
if (actual === undefined) {
|
||||
ruleResults[rule.type] = {
|
||||
condition: rule.condition,
|
||||
expected: rule.value,
|
||||
actual: null,
|
||||
passed: false,
|
||||
reason: "field_not_found",
|
||||
};
|
||||
hasUnknown = true;
|
||||
allPassed = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
const passed = evaluateRule(actual, rule.condition, rule.value);
|
||||
ruleResults[rule.type] = {
|
||||
condition: rule.condition,
|
||||
expected: rule.value,
|
||||
actual,
|
||||
passed,
|
||||
};
|
||||
|
||||
if (!passed) {
|
||||
allPassed = false;
|
||||
}
|
||||
}
|
||||
|
||||
let status: string;
|
||||
if (hasUnknown && !allPassed) {
|
||||
status = "unknown";
|
||||
unknown++;
|
||||
} else if (allPassed) {
|
||||
status = "compliant";
|
||||
compliant++;
|
||||
} else {
|
||||
status = "non_compliant";
|
||||
nonCompliant++;
|
||||
}
|
||||
|
||||
upserts.push({
|
||||
deviceId: device.id,
|
||||
policyId: policy.id,
|
||||
status,
|
||||
details: ruleResults,
|
||||
});
|
||||
|
||||
evaluated++;
|
||||
}
|
||||
}
|
||||
|
||||
for (const u of upserts) {
|
||||
await client.query(
|
||||
`INSERT INTO compliance_results (device_id, policy_id, status, details, evaluated_at)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (device_id, policy_id) DO UPDATE SET
|
||||
status = EXCLUDED.status,
|
||||
details = EXCLUDED.details,
|
||||
evaluated_at = EXCLUDED.evaluated_at`,
|
||||
[u.deviceId, u.policyId, u.status, JSON.stringify(u.details), now]
|
||||
);
|
||||
}
|
||||
|
||||
return { evaluated, compliant, nonCompliant, unknown };
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
export async function evaluatePoliciesForDevice(
|
||||
pool: pg.Pool,
|
||||
deviceId: string,
|
||||
organizationId: string
|
||||
): Promise<{ evaluated: number; compliant: number; nonCompliant: number; unknown: number }> {
|
||||
const client = await pool.connect();
|
||||
|
||||
let evaluated = 0;
|
||||
let compliant = 0;
|
||||
let nonCompliant = 0;
|
||||
let unknown = 0;
|
||||
|
||||
try {
|
||||
const { rows: devices } = await client.query<DeviceRow>(
|
||||
`SELECT id, organization_id, os_name, os_version, platform, architecture,
|
||||
agent_version, cpu_cores, total_memory_bytes, total_disk_bytes,
|
||||
status, tags, kernel_version
|
||||
FROM devices
|
||||
WHERE id = $1 AND organization_id = $2`,
|
||||
[deviceId, organizationId]
|
||||
);
|
||||
|
||||
if (devices.length === 0) {
|
||||
return { evaluated: 0, compliant: 0, nonCompliant: 0, unknown: 0 };
|
||||
}
|
||||
|
||||
const device = devices[0];
|
||||
|
||||
const { rows: policies } = await client.query<PolicyRow>(
|
||||
`SELECT id, organization_id, name, rules
|
||||
FROM policies
|
||||
WHERE organization_id = $1 AND is_enabled = true`,
|
||||
[organizationId]
|
||||
);
|
||||
|
||||
if (policies.length === 0) {
|
||||
return { evaluated: 0, compliant: 0, nonCompliant: 0, unknown: 0 };
|
||||
}
|
||||
|
||||
const { rows: metricsRows } = await client.query(
|
||||
`SELECT cpu_usage_percent, memory_usage_percent,
|
||||
disk_usage_percent, load_average_1m, uptime_seconds
|
||||
FROM metric_snapshots
|
||||
WHERE device_id = $1
|
||||
ORDER BY reported_at DESC
|
||||
LIMIT 1`,
|
||||
[deviceId]
|
||||
);
|
||||
|
||||
const metrics: MetricRow | null = metricsRows[0] || null;
|
||||
|
||||
const { rows: inventoryRows } = await client.query(
|
||||
`SELECT packages, services
|
||||
FROM inventory_snapshots
|
||||
WHERE device_id = $1
|
||||
ORDER BY reported_at DESC
|
||||
LIMIT 1`,
|
||||
[deviceId]
|
||||
);
|
||||
|
||||
const inventory: InventoryRow | null = inventoryRows[0] || null;
|
||||
|
||||
const now = new Date();
|
||||
|
||||
for (const policy of policies) {
|
||||
const rules = (policy.rules || []) as PolicyRule[];
|
||||
|
||||
if (rules.length === 0) {
|
||||
await client.query(
|
||||
`INSERT INTO compliance_results (device_id, policy_id, status, details, evaluated_at)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (device_id, policy_id) DO UPDATE SET
|
||||
status = EXCLUDED.status, details = EXCLUDED.details, evaluated_at = EXCLUDED.evaluated_at`,
|
||||
[deviceId, policy.id, "compliant", JSON.stringify({ message: "No rules defined" }), now]
|
||||
);
|
||||
compliant++;
|
||||
evaluated++;
|
||||
continue;
|
||||
}
|
||||
|
||||
let allPassed = true;
|
||||
let hasUnknown = false;
|
||||
const ruleResults: Record<string, unknown> = {};
|
||||
|
||||
for (const rule of rules) {
|
||||
const actual = getDeviceField(device, metrics, inventory, rule.type);
|
||||
|
||||
if (actual === undefined) {
|
||||
ruleResults[rule.type] = {
|
||||
condition: rule.condition,
|
||||
expected: rule.value,
|
||||
actual: null,
|
||||
passed: false,
|
||||
reason: "field_not_found",
|
||||
};
|
||||
hasUnknown = true;
|
||||
allPassed = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
const passed = evaluateRule(actual, rule.condition, rule.value);
|
||||
ruleResults[rule.type] = {
|
||||
condition: rule.condition,
|
||||
expected: rule.value,
|
||||
actual,
|
||||
passed,
|
||||
};
|
||||
|
||||
if (!passed) {
|
||||
allPassed = false;
|
||||
}
|
||||
}
|
||||
|
||||
let status: string;
|
||||
if (hasUnknown && !allPassed) {
|
||||
status = "unknown";
|
||||
unknown++;
|
||||
} else if (allPassed) {
|
||||
status = "compliant";
|
||||
compliant++;
|
||||
} else {
|
||||
status = "non_compliant";
|
||||
nonCompliant++;
|
||||
}
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO compliance_results (device_id, policy_id, status, details, evaluated_at)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (device_id, policy_id) DO UPDATE SET
|
||||
status = EXCLUDED.status, details = EXCLUDED.details, evaluated_at = EXCLUDED.evaluated_at`,
|
||||
[deviceId, policy.id, status, JSON.stringify(ruleResults), now]
|
||||
);
|
||||
|
||||
evaluated++;
|
||||
}
|
||||
|
||||
return { evaluated, compliant, nonCompliant, unknown };
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
type LogLevel = "debug" | "info" | "warn" | "error";
|
||||
|
||||
interface LogEntry {
|
||||
timestamp: string;
|
||||
level: LogLevel;
|
||||
message: string;
|
||||
correlationId?: string;
|
||||
context?: Record<string, unknown>;
|
||||
error?: {
|
||||
name: string;
|
||||
message: string;
|
||||
stack?: string;
|
||||
};
|
||||
}
|
||||
|
||||
function formatEntry(entry: LogEntry): string {
|
||||
return JSON.stringify(entry);
|
||||
}
|
||||
|
||||
function createLogger(defaultContext?: Record<string, unknown>) {
|
||||
const log = (level: LogLevel, message: string, context?: Record<string, unknown>, correlationId?: string, error?: Error) => {
|
||||
const entry: LogEntry = {
|
||||
timestamp: new Date().toISOString(),
|
||||
level,
|
||||
message,
|
||||
...((defaultContext || context) && { context: { ...defaultContext, ...context } }),
|
||||
};
|
||||
|
||||
if (correlationId) entry.correlationId = correlationId;
|
||||
if (error) {
|
||||
entry.error = {
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
};
|
||||
}
|
||||
|
||||
const line = formatEntry(entry);
|
||||
|
||||
switch (level) {
|
||||
case "error":
|
||||
console.error(line);
|
||||
break;
|
||||
case "warn":
|
||||
console.warn(line);
|
||||
break;
|
||||
default:
|
||||
console.log(line);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
debug: (message: string, context?: Record<string, unknown>, correlationId?: string) =>
|
||||
log("debug", message, context, correlationId),
|
||||
info: (message: string, context?: Record<string, unknown>, correlationId?: string) =>
|
||||
log("info", message, context, correlationId),
|
||||
warn: (message: string, context?: Record<string, unknown>, correlationId?: string) =>
|
||||
log("warn", message, context, correlationId),
|
||||
error: (message: string, context?: Record<string, unknown>, correlationId?: string, error?: Error) =>
|
||||
log("error", message, context, correlationId, error),
|
||||
child: (childContext: Record<string, unknown>) =>
|
||||
createLogger({ ...defaultContext, ...childContext }),
|
||||
};
|
||||
}
|
||||
|
||||
export const logger = createLogger({ service: "fleetforge-api" });
|
||||
export const workerLogger = createLogger({ service: "fleetforge-worker" });
|
||||
export { createLogger };
|
||||
@@ -0,0 +1,17 @@
|
||||
import crypto from "crypto";
|
||||
|
||||
export function hashToken(token: string): string {
|
||||
return crypto.createHash("sha256").update(token).digest("hex");
|
||||
}
|
||||
|
||||
export function generateEnrollmentToken(): string {
|
||||
return `ff-${crypto.randomBytes(32).toString("hex")}`;
|
||||
}
|
||||
|
||||
export function generateDeviceCredential(): string {
|
||||
return `fc-${crypto.randomBytes(32).toString("hex")}`;
|
||||
}
|
||||
|
||||
export function hashCredential(credential: string): string {
|
||||
return crypto.createHash("sha256").update(credential).digest("hex");
|
||||
}
|
||||
Reference in New Issue
Block a user