feat: Fleet Forge UEM - device management, agent, remote desktop (VNC), TLS reverse proxy

This commit is contained in:
Pepe44DEV
2026-09-06 22:09:28 +00:00
commit db16ad5cc1
154 changed files with 22920 additions and 0 deletions
+105
View File
@@ -0,0 +1,105 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest";
import {
getTestPool,
setupTestDb,
cleanTestDb,
teardownTestDb,
} from "./helpers.js";
import pg from "pg";
describe("Authentication", () => {
let pool: pg.Pool;
let orgId: string;
let userId: string;
beforeAll(async () => {
pool = await getTestPool();
});
afterAll(async () => {
await teardownTestDb();
});
beforeEach(async () => {
await cleanTestDb(pool);
const seeded = await setupTestDb();
orgId = seeded.orgId;
userId = seeded.userId;
});
it("should return user for valid credentials", async () => {
const bcrypt = await import("bcrypt");
const passwordHash = await bcrypt.hash("testpass123", 10);
const { rows } = await pool.query(
"SELECT id, email, role FROM users WHERE email = $1 AND is_active = true",
["admin@test.local"]
);
expect(rows.length).toBe(1);
expect(rows[0].email).toBe("admin@test.local");
expect(rows[0].role).toBe("owner");
const valid = await bcrypt.compare("testpass123", passwordHash);
expect(valid).toBe(true);
});
it("should reject invalid password", async () => {
const bcrypt = await import("bcrypt");
const { rows } = await pool.query(
"SELECT password_hash FROM users WHERE email = $1",
["admin@test.local"]
);
const valid = await bcrypt.compare("wrongpassword", rows[0].password_hash);
expect(valid).toBe(false);
});
it("should reject non-existent user", async () => {
const { rows } = await pool.query(
"SELECT id FROM users WHERE email = $1 AND is_active = true",
["nonexistent@test.local"]
);
expect(rows.length).toBe(0);
});
it("should reject inactive user", async () => {
await pool.query(
"UPDATE users SET is_active = false WHERE id = $1",
[userId]
);
const { rows } = await pool.query(
"SELECT id FROM users WHERE email = $1 AND is_active = true",
["admin@test.local"]
);
expect(rows.length).toBe(0);
});
it("should generate and verify JWT token", async () => {
const jwt = await import("jsonwebtoken");
const token = jwt.default.sign(
{ userId, organizationId: orgId, role: "owner", email: "admin@test.local" },
process.env.JWT_SECRET || "test-secret",
{ expiresIn: "1h" }
);
const decoded = jwt.default.verify(token, process.env.JWT_SECRET || "test-secret") as any;
expect(decoded.userId).toBe(userId);
expect(decoded.organizationId).toBe(orgId);
expect(decoded.role).toBe("owner");
});
it("should reject expired JWT", async () => {
const jwt = await import("jsonwebtoken");
const token = jwt.default.sign(
{ userId, organizationId: orgId, role: "owner" },
process.env.JWT_SECRET || "test-secret",
{ expiresIn: "0s" }
);
expect(() => {
jwt.default.verify(token, process.env.JWT_SECRET || "test-secret");
}).toThrow();
});
});
@@ -0,0 +1,140 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest";
import {
getTestPool,
setupTestDb,
cleanTestDb,
teardownTestDb,
uuidv4,
} from "./helpers.js";
import pg from "pg";
describe("Authorization & Roles", () => {
let pool: pg.Pool;
let orgId: string;
beforeAll(async () => {
pool = await getTestPool();
});
afterAll(async () => {
await teardownTestDb();
});
beforeEach(async () => {
await cleanTestDb(pool);
const seeded = await setupTestDb();
orgId = seeded.orgId;
});
const ROLE_HIERARCHY: Record<string, number> = {
owner: 4,
admin: 3,
operator: 2,
read_only: 1,
};
it("should define correct role hierarchy", () => {
expect(ROLE_HIERARCHY.owner).toBeGreaterThan(ROLE_HIERARCHY.admin);
expect(ROLE_HIERARCHY.admin).toBeGreaterThan(ROLE_HIERARCHY.operator);
expect(ROLE_HIERARCHY.operator).toBeGreaterThan(ROLE_HIERARCHY.read_only);
});
it("should create users with different roles", async () => {
const bcrypt = await import("bcrypt");
const hash = await bcrypt.hash("password123", 10);
const roles = ["admin", "operator", "read_only"];
for (const role of roles) {
await pool.query(
`INSERT INTO users (organization_id, email, password_hash, display_name, role)
VALUES ($1, $2, $3, $4, $5)`,
[orgId, `${role}@roles.test.local`, hash, `${role} User`, role]
);
}
const { rows } = await pool.query(
"SELECT role FROM users WHERE organization_id = $1 ORDER BY role",
[orgId]
);
expect(rows.length).toBe(4);
const userRoles = rows.map((r: any) => r.role);
expect(userRoles).toContain("admin");
expect(userRoles).toContain("operator");
expect(userRoles).toContain("read_only");
expect(userRoles).toContain("owner");
});
it("should enforce unique email per organization", async () => {
const bcrypt = await import("bcrypt");
const hash = await bcrypt.hash("password123", 10);
await pool.query(
`INSERT INTO users (organization_id, email, password_hash, display_name, role)
VALUES ($1, 'unique@test.local', $2, 'User 1', 'admin')`,
[orgId, hash]
);
await expect(
pool.query(
`INSERT INTO users (organization_id, email, password_hash, display_name, role)
VALUES ($1, 'unique@test.local', $2, 'User 2', 'operator')`,
[orgId, hash]
)
).rejects.toThrow();
});
it("should isolate organizations", async () => {
const bcrypt = await import("bcrypt");
const hash = await bcrypt.hash("password123", 10);
const otherOrgId = uuidv4();
await pool.query(
`INSERT INTO organizations (id, name, slug) VALUES ($1, 'Other Org', 'other')`,
[otherOrgId]
);
await pool.query(
`INSERT INTO users (organization_id, email, password_hash, display_name, role)
VALUES ($1, 'user@org1.com', $2, 'Org1 User', 'admin')`,
[orgId, hash]
);
await pool.query(
`INSERT INTO users (organization_id, email, password_hash, display_name, role)
VALUES ($1, 'user@org2.com', $2, 'Org2 User', 'admin')`,
[otherOrgId, hash]
);
const { rows: org1Users } = await pool.query(
"SELECT id FROM users WHERE organization_id = $1",
[orgId]
);
const { rows: org2Users } = await pool.query(
"SELECT id FROM users WHERE organization_id = $1",
[otherOrgId]
);
expect(org1Users.length).toBe(2);
expect(org2Users.length).toBe(1);
});
it("should allow deactivating users", async () => {
const userId = uuidv4();
const bcrypt = await import("bcrypt");
const hash = await bcrypt.hash("password123", 10);
await pool.query(
`INSERT INTO users (id, organization_id, email, password_hash, display_name, role, is_active)
VALUES ($1, $2, 'deactivate@test.local', $3, 'Deactivate Me', 'operator', true)`,
[userId, orgId, hash]
);
await pool.query("UPDATE users SET is_active = false WHERE id = $1", [userId]);
const { rows } = await pool.query(
"SELECT is_active FROM users WHERE id = $1",
[userId]
);
expect(rows[0].is_active).toBe(false);
});
});
+204
View File
@@ -0,0 +1,204 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest";
import {
getTestPool,
setupTestDb,
cleanTestDb,
teardownTestDb,
generateEnrollmentToken,
hashToken,
generateDeviceCredential,
hashCredential,
uuidv4,
} from "./helpers.js";
import pg from "pg";
describe("Device Registration E2E", () => {
let pool: pg.Pool;
let orgId: string;
let userId: string;
beforeAll(async () => {
pool = await getTestPool();
});
afterAll(async () => {
await teardownTestDb();
});
beforeEach(async () => {
await cleanTestDb(pool);
const seeded = await setupTestDb();
orgId = seeded.orgId;
userId = seeded.userId;
});
it("should enroll a device via enrollment token", async () => {
const rawToken = generateEnrollmentToken();
const tokenHash = hashToken(rawToken);
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000);
await pool.query(
`INSERT INTO enrollment_tokens (organization_id, token_hash, expires_at, created_by_user_id)
VALUES ($1, $2, $3, $4)`,
[orgId, tokenHash, expiresAt, userId]
);
const { rows: tokenRows } = await pool.query(
`SELECT * FROM enrollment_tokens
WHERE token_hash = $1 AND is_revoked = false AND expires_at > NOW()`,
[tokenHash]
);
expect(tokenRows.length).toBe(1);
const deviceId = uuidv4();
await pool.query(
`INSERT INTO devices (id, organization_id, name, device_type, platform, os_name, os_version, architecture, agent_version, status, last_heartbeat_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW())`,
[deviceId, orgId, "test-server-01", "linux_server", "linux", "Ubuntu", "22.04", "amd64", "0.1.0", "online"]
);
const rawCred = generateDeviceCredential();
const credHash = hashCredential(rawCred);
await pool.query(
`INSERT INTO device_credentials (device_id, credential_type, credential_hash, expires_at)
VALUES ($1, 'device_key', $2, NOW() + INTERVAL '1 year')`,
[deviceId, credHash]
);
await pool.query(
"UPDATE enrollment_tokens SET use_count = use_count + 1 WHERE id = $1",
[tokenRows[0].id]
);
const { rows: devices } = await pool.query(
"SELECT * FROM devices WHERE id = $1",
[deviceId]
);
expect(devices.length).toBe(1);
expect(devices[0].name).toBe("test-server-01");
expect(devices[0].status).toBe("online");
const { rows: creds } = await pool.query(
"SELECT * FROM device_credentials WHERE device_id = $1",
[deviceId]
);
expect(creds.length).toBe(1);
expect(creds[0].credential_type).toBe("device_key");
const verified = await pool.query(
"SELECT * FROM device_credentials WHERE credential_hash = $1",
[credHash]
);
expect(verified.rows.length).toBe(1);
});
it("should record heartbeat and metrics", async () => {
const deviceId = uuidv4();
await pool.query(
`INSERT INTO devices (id, organization_id, name, device_type, platform, os_name, os_version, architecture, agent_version, status)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'online')`,
[deviceId, orgId, "hb-test", "linux_server", "linux", "Ubuntu", "22.04", "amd64", "0.1.0"]
);
await pool.query(
`INSERT INTO metric_snapshots (device_id, reported_at, cpu_usage_percent, memory_usage_percent, disk_usage_percent, uptime_seconds)
VALUES ($1, NOW(), 45, 67, 32, 86400)`,
[deviceId]
);
await pool.query(
`INSERT INTO inventory_snapshots (device_id, reported_at, packages, services, network_interfaces, platform_data)
VALUES ($1, NOW(), $2, $3, $4, $5)`,
[
deviceId,
JSON.stringify([{ name: "nginx", version: "1.18.0", manager: "apt" }]),
JSON.stringify([{ name: "nginx", status: "active", enabled: true }]),
JSON.stringify([{ name: "eth0", addresses: ["192.168.1.100"], mac_address: "00:11:22:33:44:55" }]),
JSON.stringify({}),
]
);
await pool.query(
"UPDATE devices SET status = 'online', last_heartbeat_at = NOW() WHERE id = $1",
[deviceId]
);
const { rows: metrics } = await pool.query(
"SELECT * FROM metric_snapshots WHERE device_id = $1 ORDER BY reported_at DESC",
[deviceId]
);
expect(metrics.length).toBe(1);
expect(metrics[0].cpu_usage_percent).toBe(45);
expect(metrics[0].memory_usage_percent).toBe(67);
const { rows: inventory } = await pool.query(
"SELECT * FROM inventory_snapshots WHERE device_id = $1",
[deviceId]
);
expect(inventory.length).toBe(1);
expect(inventory[0].packages.length).toBe(1);
});
it("should handle full enrollment flow", async () => {
const rawToken = generateEnrollmentToken();
const tokenHash = hashToken(rawToken);
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000);
const { rows: tokenRows } = await pool.query(
`INSERT INTO enrollment_tokens (organization_id, token_hash, expires_at, created_by_user_id)
VALUES ($1, $2, $3, $4) RETURNING id`,
[orgId, tokenHash, expiresAt, userId]
);
const tokenId = tokenRows[0].id;
const { rows: foundToken } = await pool.query(
`SELECT * FROM enrollment_tokens
WHERE token_hash = $1 AND is_revoked = false AND expires_at > NOW()`,
[tokenHash]
);
expect(foundToken.length).toBe(1);
const deviceId = uuidv4();
const rawCred = generateDeviceCredential();
const credHash = hashCredential(rawCred);
await pool.query(
`INSERT INTO devices (id, organization_id, name, device_type, platform, os_name, os_version, architecture, agent_version, status, last_heartbeat_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'online', NOW())`,
[deviceId, orgId, "new-host", "linux_server", "linux", "Ubuntu", "24.04", "amd64", "0.1.0"]
);
await pool.query(
`INSERT INTO device_credentials (device_id, credential_type, credential_hash, expires_at)
VALUES ($1, 'device_key', $2, NOW() + INTERVAL '1 year')`,
[deviceId, credHash]
);
await pool.query(
"UPDATE enrollment_tokens SET use_count = use_count + 1 WHERE id = $1",
[tokenId]
);
await pool.query(
`INSERT INTO metric_snapshots (device_id, reported_at, cpu_usage_percent, memory_usage_percent, disk_usage_percent, uptime_seconds)
VALUES ($1, NOW(), 10, 30, 25, 3600)`,
[deviceId]
);
const { rows: device } = await pool.query("SELECT * FROM devices WHERE id = $1", [deviceId]);
expect(device.length).toBe(1);
expect(device[0].organization_id).toBe(orgId);
const { rows: cred } = await pool.query(
"SELECT * FROM device_credentials WHERE credential_hash = $1",
[credHash]
);
expect(cred.length).toBe(1);
const { rows: metrics } = await pool.query(
"SELECT * FROM metric_snapshots WHERE device_id = $1",
[deviceId]
);
expect(metrics.length).toBe(1);
});
});
+175
View File
@@ -0,0 +1,175 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest";
import {
getTestPool,
setupTestDb,
cleanTestDb,
teardownTestDb,
generateEnrollmentToken,
hashToken,
} from "./helpers.js";
import pg from "pg";
describe("Enrollment Tokens", () => {
let pool: pg.Pool;
let orgId: string;
let userId: string;
beforeAll(async () => {
pool = await getTestPool();
});
afterAll(async () => {
await teardownTestDb();
});
beforeEach(async () => {
await cleanTestDb(pool);
const seeded = await setupTestDb();
orgId = seeded.orgId;
userId = seeded.userId;
});
it("should create an enrollment token", async () => {
const rawToken = generateEnrollmentToken();
const tokenHash = hashToken(rawToken);
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000);
const { rows } = await pool.query(
`INSERT INTO enrollment_tokens (organization_id, token_hash, expires_at, max_uses, label, created_by_user_id)
VALUES ($1, $2, $3, $4, $5, $6) RETURNING id, token_hash`,
[orgId, tokenHash, expiresAt, 1, "test-token", userId]
);
expect(rows.length).toBe(1);
expect(rows[0].token_hash).toBe(tokenHash);
});
it("should validate a valid enrollment token", async () => {
const rawToken = generateEnrollmentToken();
const tokenHash = hashToken(rawToken);
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000);
await pool.query(
`INSERT INTO enrollment_tokens (organization_id, token_hash, expires_at, created_by_user_id)
VALUES ($1, $2, $3, $4)`,
[orgId, tokenHash, expiresAt, userId]
);
const { rows } = await pool.query(
`SELECT * FROM enrollment_tokens
WHERE token_hash = $1 AND is_revoked = false AND expires_at > NOW()`,
[tokenHash]
);
expect(rows.length).toBe(1);
});
it("should reject expired enrollment token", async () => {
const rawToken = generateEnrollmentToken();
const tokenHash = hashToken(rawToken);
const expiresAt = new Date(Date.now() - 1000);
await pool.query(
`INSERT INTO enrollment_tokens (organization_id, token_hash, expires_at, created_by_user_id)
VALUES ($1, $2, $3, $4)`,
[orgId, tokenHash, expiresAt, userId]
);
const { rows } = await pool.query(
`SELECT * FROM enrollment_tokens
WHERE token_hash = $1 AND is_revoked = false AND expires_at > NOW()`,
[tokenHash]
);
expect(rows.length).toBe(0);
});
it("should reject revoked enrollment token", async () => {
const rawToken = generateEnrollmentToken();
const tokenHash = hashToken(rawToken);
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000);
await pool.query(
`INSERT INTO enrollment_tokens (organization_id, token_hash, expires_at, is_revoked, created_by_user_id)
VALUES ($1, $2, $3, true, $4)`,
[orgId, tokenHash, expiresAt, userId]
);
const { rows } = await pool.query(
`SELECT * FROM enrollment_tokens
WHERE token_hash = $1 AND is_revoked = false AND expires_at > NOW()`,
[tokenHash]
);
expect(rows.length).toBe(0);
});
it("should enforce max_uses limit", async () => {
const rawToken = generateEnrollmentToken();
const tokenHash = hashToken(rawToken);
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000);
await pool.query(
`INSERT INTO enrollment_tokens (organization_id, token_hash, expires_at, max_uses, use_count, created_by_user_id)
VALUES ($1, $2, $3, 2, 2, $4)`,
[orgId, tokenHash, expiresAt, userId]
);
const { rows } = await pool.query(
`SELECT * FROM enrollment_tokens
WHERE token_hash = $1 AND is_revoked = false
AND (max_uses IS NULL OR use_count < max_uses)`,
[tokenHash]
);
expect(rows.length).toBe(0);
});
it("should revoke a token", async () => {
const rawToken = generateEnrollmentToken();
const tokenHash = hashToken(rawToken);
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000);
const { rows: inserted } = await pool.query(
`INSERT INTO enrollment_tokens (organization_id, token_hash, expires_at, created_by_user_id)
VALUES ($1, $2, $3, $4) RETURNING id`,
[orgId, tokenHash, expiresAt, userId]
);
await pool.query(
"UPDATE enrollment_tokens SET is_revoked = true WHERE id = $1",
[inserted[0].id]
);
const { rows } = await pool.query(
"SELECT is_revoked FROM enrollment_tokens WHERE id = $1",
[inserted[0].id]
);
expect(rows[0].is_revoked).toBe(true);
});
it("should increment use_count on enrollment", async () => {
const rawToken = generateEnrollmentToken();
const tokenHash = hashToken(rawToken);
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000);
const { rows: inserted } = await pool.query(
`INSERT INTO enrollment_tokens (organization_id, token_hash, expires_at, use_count, created_by_user_id)
VALUES ($1, $2, $3, 0, $4) RETURNING id`,
[orgId, tokenHash, expiresAt, userId]
);
await pool.query(
"UPDATE enrollment_tokens SET use_count = use_count + 1 WHERE id = $1",
[inserted[0].id]
);
const { rows } = await pool.query(
"SELECT use_count FROM enrollment_tokens WHERE id = $1",
[inserted[0].id]
);
expect(rows[0].use_count).toBe(1);
});
});
+64
View File
@@ -0,0 +1,64 @@
import dotenv from "dotenv";
dotenv.config({ path: ".env.test" });
import pg from "pg";
import bcrypt from "bcrypt";
import { v4 as uuidv4 } from "uuid";
import { hashToken, generateEnrollmentToken, generateDeviceCredential, hashCredential } from "../lib/tokens.js";
const TEST_DB_URL = process.env.DATABASE_URL || "postgresql://fleetforge:fleetforge@localhost:5432/fleetforge_test?schema=public";
let pool: pg.Pool;
export async function getTestPool(): Promise<pg.Pool> {
if (!pool) {
pool = new pg.Pool({ connectionString: TEST_DB_URL });
}
return pool;
}
export async function setupTestDb(): Promise<{ orgId: string; userId: string }> {
const p = await getTestPool();
await cleanTestDb(p);
return await seedTestDb(p);
}
export async function cleanTestDb(p: pg.Pool) {
const tables = [
"audit_events", "api_tokens", "alerts", "compliance_results",
"job_targets", "jobs", "policies", "metric_snapshots",
"inventory_snapshots", "device_credentials", "devices",
"device_groups", "enrollment_tokens", "users", "organizations",
];
for (const table of tables) {
await p.query(`TRUNCATE TABLE ${table} CASCADE`);
}
}
export async function seedTestDb(p: pg.Pool): Promise<{ orgId: string; userId: string }> {
const orgId = uuidv4();
await p.query(
`INSERT INTO organizations (id, name, slug) VALUES ($1, $2, $3)`,
[orgId, "Test Org", "test-org"]
);
const passwordHash = await bcrypt.hash("testpass123", 10);
const userId = uuidv4();
await p.query(
`INSERT INTO users (id, organization_id, email, password_hash, display_name, role, is_active)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
[userId, orgId, "admin@test.local", passwordHash, "Test Admin", "owner", true]
);
return { orgId, userId };
}
export async function teardownTestDb() {
if (pool) {
await cleanTestDb(pool);
await pool.end();
pool = undefined as any;
}
}
export { hashToken, generateEnrollmentToken, generateDeviceCredential, hashCredential, uuidv4 };
+228
View File
@@ -0,0 +1,228 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest";
import {
getTestPool,
setupTestDb,
cleanTestDb,
teardownTestDb,
uuidv4,
} from "./helpers.js";
import pg from "pg";
describe("Jobs", () => {
let pool: pg.Pool;
let orgId: string;
let userId: string;
beforeAll(async () => {
pool = await getTestPool();
});
afterAll(async () => {
await teardownTestDb();
});
beforeEach(async () => {
await cleanTestDb(pool);
const seeded = await setupTestDb();
orgId = seeded.orgId;
userId = seeded.userId;
});
it("should create a job with targets", async () => {
const deviceId = uuidv4();
await pool.query(
`INSERT INTO devices (id, organization_id, name, device_type, platform, os_name, os_version, architecture, agent_version, status)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
[deviceId, orgId, "job-test", "linux_server", "linux", "Ubuntu", "22.04", "amd64", "0.1.0", "online"]
);
const { rows: jobRows } = await pool.query(
`INSERT INTO jobs (organization_id, created_by_user_id, job_type, status, parameters)
VALUES ($1, $2, 'update_inventory', 'queued', '{}') RETURNING id`,
[orgId, userId]
);
const jobId = jobRows[0].id;
await pool.query(
`INSERT INTO job_targets (job_id, device_id, status) VALUES ($1, $2, 'queued')`,
[jobId, deviceId]
);
const { rows: jobs } = await pool.query(
"SELECT * FROM jobs WHERE id = $1",
[jobId]
);
expect(jobs.length).toBe(1);
expect(jobs[0].job_type).toBe("update_inventory");
expect(jobs[0].status).toBe("queued");
const { rows: targets } = await pool.query(
"SELECT * FROM job_targets WHERE job_id = $1",
[jobId]
);
expect(targets.length).toBe(1);
expect(targets[0].device_id).toBe(deviceId);
});
it("should transition job through states", async () => {
const { rows: jobRows } = await pool.query(
`INSERT INTO jobs (organization_id, created_by_user_id, job_type, status, parameters)
VALUES ($1, $2, 'update_system_info', 'queued', '{}') RETURNING id`,
[orgId, userId]
);
const jobId = jobRows[0].id;
await pool.query(
"UPDATE jobs SET status = 'dispatched', dispatched_at = NOW() WHERE id = $1",
[jobId]
);
const { rows: step1 } = await pool.query("SELECT status FROM jobs WHERE id = $1", [jobId]);
expect(step1[0].status).toBe("dispatched");
await pool.query(
"UPDATE jobs SET status = 'running', started_at = NOW() WHERE id = $1",
[jobId]
);
const { rows: step2 } = await pool.query("SELECT status FROM jobs WHERE id = $1", [jobId]);
expect(step2[0].status).toBe("running");
await pool.query(
"UPDATE jobs SET status = 'succeeded', completed_at = NOW() WHERE id = $1",
[jobId]
);
const { rows: step3 } = await pool.query("SELECT status FROM jobs WHERE id = $1", [jobId]);
expect(step3[0].status).toBe("succeeded");
});
it("should cancel queued jobs", async () => {
const { rows: jobRows } = await pool.query(
`INSERT INTO jobs (organization_id, created_by_user_id, job_type, status, parameters)
VALUES ($1, $2, 'reboot_system', 'queued', '{}') RETURNING id`,
[orgId, userId]
);
const jobId = jobRows[0].id;
const { rows: updated } = await pool.query(
`UPDATE jobs SET status = 'cancelled', completed_at = NOW()
WHERE id = $1 AND status = 'queued' RETURNING id`,
[jobId]
);
expect(updated.length).toBe(1);
const { rows: step } = await pool.query("SELECT status FROM jobs WHERE id = $1", [jobId]);
expect(step[0].status).toBe("cancelled");
});
it("should not cancel running jobs", async () => {
const { rows: jobRows } = await pool.query(
`INSERT INTO jobs (organization_id, created_by_user_id, job_type, status, parameters)
VALUES ($1, $2, 'update_inventory', 'running', '{}') RETURNING id`,
[orgId, userId]
);
const jobId = jobRows[0].id;
const { rows: updated } = await pool.query(
`UPDATE jobs SET status = 'cancelled', completed_at = NOW()
WHERE id = $1 AND status = 'queued' RETURNING id`,
[jobId]
);
expect(updated.length).toBe(0);
const { rows: step } = await pool.query("SELECT status FROM jobs WHERE id = $1", [jobId]);
expect(step[0].status).toBe("running");
});
it("should complete job when all targets finish", async () => {
const deviceId1 = uuidv4();
const deviceId2 = uuidv4();
await pool.query(
`INSERT INTO devices (id, organization_id, name, device_type, platform, os_name, os_version, architecture, agent_version, status)
VALUES ($1, $2, 'multi-1', 'linux_server', 'linux', 'Ubuntu', '22.04', 'amd64', '0.1.0', 'online')`,
[deviceId1, orgId]
);
await pool.query(
`INSERT INTO devices (id, organization_id, name, device_type, platform, os_name, os_version, architecture, agent_version, status)
VALUES ($1, $2, 'multi-2', 'linux_server', 'linux', 'Ubuntu', '22.04', 'amd64', '0.1.0', 'online')`,
[deviceId2, orgId]
);
const { rows: jobRows } = await pool.query(
`INSERT INTO jobs (organization_id, created_by_user_id, job_type, status, parameters)
VALUES ($1, $2, 'update_inventory', 'dispatched', '{}') RETURNING id`,
[orgId, userId]
);
const jobId = jobRows[0].id;
await pool.query(
`INSERT INTO job_targets (job_id, device_id, status) VALUES ($1, $2, 'dispatched'), ($1, $3, 'dispatched')`,
[jobId, deviceId1, deviceId2]
);
await pool.query(
"UPDATE job_targets SET status = 'succeeded', completed_at = NOW() WHERE job_id = $1 AND device_id = $2",
[jobId, deviceId1]
);
const { rows: partial } = await pool.query(
"SELECT status FROM jobs WHERE id = $1",
[jobId]
);
expect(partial[0].status).toBe("dispatched");
await pool.query(
"UPDATE job_targets SET status = 'succeeded', completed_at = NOW() WHERE job_id = $1 AND device_id = $2",
[jobId, deviceId2]
);
const { rows: targets } = await pool.query(
"SELECT status FROM job_targets WHERE job_id = $1",
[jobId]
);
const allDone = targets.every((t: any) => !["queued", "dispatched", "running"].includes(t.status));
expect(allDone).toBe(true);
});
it("should fail job when any target fails", async () => {
const deviceId1 = uuidv4();
const deviceId2 = uuidv4();
await pool.query(
`INSERT INTO devices (id, organization_id, name, device_type, platform, os_name, os_version, architecture, agent_version, status)
VALUES ($1, $2, 'fail-1', 'linux_server', 'linux', 'Ubuntu', '22.04', 'amd64', '0.1.0', 'online'),
($3, $2, 'fail-2', 'linux_server', 'linux', 'Ubuntu', '22.04', 'amd64', '0.1.0', 'online')`,
[deviceId1, orgId, deviceId2]
);
const { rows: jobRows } = await pool.query(
`INSERT INTO jobs (organization_id, created_by_user_id, job_type, status, parameters)
VALUES ($1, $2, 'reboot_system', 'dispatched', '{}') RETURNING id`,
[orgId, userId]
);
const jobId = jobRows[0].id;
await pool.query(
`INSERT INTO job_targets (job_id, device_id, status) VALUES ($1, $2, 'dispatched'), ($1, $3, 'dispatched')`,
[jobId, deviceId1, deviceId2]
);
await pool.query(
"UPDATE job_targets SET status = 'succeeded', completed_at = NOW() WHERE job_id = $1 AND device_id = $2",
[jobId, deviceId1]
);
await pool.query(
`UPDATE job_targets SET status = 'failed', completed_at = NOW(), result = '{"error": "timeout"}' WHERE job_id = $1 AND device_id = $2`,
[jobId, deviceId2]
);
const { rows: targets } = await pool.query(
"SELECT status FROM job_targets WHERE job_id = $1",
[jobId]
);
const anyFailed = targets.some((t: any) => t.status === "failed");
expect(anyFailed).toBe(true);
});
});
+122
View File
@@ -0,0 +1,122 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest";
import {
getTestPool,
cleanTestDb,
teardownTestDb,
uuidv4,
} from "./helpers.js";
import pg from "pg";
describe("Multi-tenancy", () => {
let pool: pg.Pool;
beforeAll(async () => {
pool = await getTestPool();
});
afterAll(async () => {
await teardownTestDb();
});
beforeEach(async () => {
await cleanTestDb(pool);
});
it("should isolate devices between organizations", async () => {
const org1 = uuidv4();
const org2 = uuidv4();
await pool.query(
`INSERT INTO organizations (id, name, slug) VALUES ($1, 'Org 1', 'org1'), ($2, 'Org 2', 'org2')`,
[org1, org2]
);
await pool.query(
`INSERT INTO devices (id, organization_id, name, device_type, platform, os_name, os_version, architecture, agent_version, status)
VALUES ($1, $2, 'org1-device', 'linux_server', 'linux', 'Ubuntu', '22.04', 'amd64', '0.1.0', 'online'),
($3, $4, 'org2-device', 'linux_server', 'linux', 'CentOS', '8', 'amd64', '0.1.0', 'online')`,
[uuidv4(), org1, uuidv4(), org2]
);
const { rows: org1Devices } = await pool.query(
"SELECT * FROM devices WHERE organization_id = $1",
[org1]
);
const { rows: org2Devices } = await pool.query(
"SELECT * FROM devices WHERE organization_id = $1",
[org2]
);
expect(org1Devices.length).toBe(1);
expect(org2Devices.length).toBe(1);
expect(org1Devices[0].name).toBe("org1-device");
expect(org2Devices[0].name).toBe("org2-device");
});
it("should isolate audit events between organizations", async () => {
const org1 = uuidv4();
const org2 = uuidv4();
await pool.query(
`INSERT INTO organizations (id, name, slug) VALUES ($1, 'Org A', 'orga'), ($2, 'Org B', 'orgb')`,
[org1, org2]
);
await pool.query(
`INSERT INTO audit_events (organization_id, action, target_type, result, correlation_id)
VALUES ($1, 'test.action', 'test', 'success', 'corr-1'),
($2, 'test.action', 'test', 'success', 'corr-2')`,
[org1, org2]
);
const { rows: org1Events } = await pool.query(
"SELECT * FROM audit_events WHERE organization_id = $1",
[org1]
);
const { rows: org2Events } = await pool.query(
"SELECT * FROM audit_events WHERE organization_id = $1",
[org2]
);
expect(org1Events.length).toBe(1);
expect(org2Events.length).toBe(1);
});
it("should isolate enrollment tokens between organizations", async () => {
const org1 = uuidv4();
const org2 = uuidv4();
const userId = uuidv4();
await pool.query(
`INSERT INTO organizations (id, name, slug) VALUES ($1, 'Org X', 'orgx'), ($2, 'Org Y', 'orgy')`,
[org1, org2]
);
const bcrypt = await import("bcrypt");
const hash = await bcrypt.hash("pass", 10);
await pool.query(
`INSERT INTO users (id, organization_id, email, password_hash, display_name, role)
VALUES ($1, $2, 'admin@x.com', $3, 'Admin X', 'owner')`,
[userId, org1, hash]
);
await pool.query(
`INSERT INTO enrollment_tokens (organization_id, token_hash, expires_at, created_by_user_id)
VALUES ($1, 'hash1', NOW() + INTERVAL '1 day', $3),
($2, 'hash2', NOW() + INTERVAL '1 day', $3)`,
[org1, org2, userId]
);
const { rows: org1Tokens } = await pool.query(
"SELECT * FROM enrollment_tokens WHERE organization_id = $1",
[org1]
);
const { rows: org2Tokens } = await pool.query(
"SELECT * FROM enrollment_tokens WHERE organization_id = $1",
[org2]
);
expect(org1Tokens.length).toBe(1);
expect(org2Tokens.length).toBe(1);
});
});
+2
View File
@@ -0,0 +1,2 @@
import dotenv from "dotenv";
dotenv.config();
+11
View File
@@ -0,0 +1,11 @@
import { drizzle } from "drizzle-orm/node-postgres";
import pg from "pg";
import * as schema from "./schema.js";
const pool = new pg.Pool({
connectionString: process.env.DATABASE_URL,
});
export const db = drizzle(pool, { schema });
export { pool };
export function getPool() { return pool; }
+73
View File
@@ -0,0 +1,73 @@
import dotenv from "dotenv";
dotenv.config();
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
import pg from "pg";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
async function main() {
const client = new pg.Client({
connectionString: process.env.DATABASE_URL,
});
await client.connect();
try {
console.log("Checking if migrations table exists...");
await client.query(`
CREATE TABLE IF NOT EXISTS _migrations (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL UNIQUE,
applied_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL
);
`);
const migrationsDir = path.resolve(__dirname, "../../../../infrastructure/migrations");
const files = fs.readdirSync(migrationsDir)
.filter(f => f.endsWith(".sql"))
.sort();
for (const file of files) {
const { rows } = await client.query(
"SELECT 1 FROM _migrations WHERE name = $1",
[file]
);
if (rows.length > 0) {
console.log(`Migration ${file} already applied, skipping.`);
continue;
}
console.log(`Applying migration: ${file}`);
const sql = fs.readFileSync(path.join(migrationsDir, file), "utf-8");
const statements = sql
.split("--> statement-breakpoint")
.map(s => s.trim())
.filter(s => s.length > 0);
for (const stmt of statements) {
await client.query(stmt);
}
await client.query(
"INSERT INTO _migrations (name) VALUES ($1)",
[file]
);
console.log(`Migration ${file} applied successfully.`);
}
console.log("All migrations complete.");
} finally {
await client.end();
}
}
main().catch((err) => {
console.error("Migration failed:", err);
process.exit(1);
});
+574
View File
@@ -0,0 +1,574 @@
import {
pgTable,
uuid,
varchar,
text,
boolean,
integer,
real,
bigint,
timestamp,
jsonb,
index,
uniqueIndex,
} from "drizzle-orm/pg-core";
export const organizations = pgTable("organizations", {
id: uuid("id").primaryKey().defaultRandom(),
name: varchar("name", { length: 255 }).notNull(),
slug: varchar("slug", { length: 255 }).notNull().unique(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow()
.$onUpdateFn(() => new Date()),
});
export const users = pgTable(
"users",
{
id: uuid("id").primaryKey().defaultRandom(),
organizationId: uuid("organization_id")
.notNull()
.references(() => organizations.id),
email: varchar("email", { length: 255 }).notNull(),
passwordHash: varchar("password_hash", { length: 255 }).notNull(),
displayName: varchar("display_name", { length: 255 }).notNull(),
role: varchar("role", { length: 50 }).notNull().default("read_only"),
isActive: boolean("is_active").notNull().default(true),
lastLoginAt: timestamp("last_login_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow()
.$onUpdateFn(() => new Date()),
},
(table) => [
uniqueIndex("idx_users_email_org").on(table.email, table.organizationId),
index("idx_users_organization_id").on(table.organizationId),
]
);
export const enrollmentTokens = pgTable(
"enrollment_tokens",
{
id: uuid("id").primaryKey().defaultRandom(),
organizationId: uuid("organization_id")
.notNull()
.references(() => organizations.id),
tokenHash: varchar("token_hash", { length: 255 }).notNull().unique(),
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
maxUses: integer("max_uses"),
useCount: integer("use_count").notNull().default(0),
isRevoked: boolean("is_revoked").notNull().default(false),
label: varchar("label", { length: 255 }),
createdByUserId: uuid("created_by_user_id")
.notNull()
.references(() => users.id),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(table) => [
index("idx_enrollment_tokens_org").on(table.organizationId),
index("idx_enrollment_tokens_hash").on(table.tokenHash),
]
);
export const deviceGroups = pgTable(
"device_groups",
{
id: uuid("id").primaryKey().defaultRandom(),
organizationId: uuid("organization_id")
.notNull()
.references(() => organizations.id),
name: varchar("name", { length: 255 }).notNull(),
description: text("description"),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow()
.$onUpdateFn(() => new Date()),
},
(table) => [
uniqueIndex("idx_device_groups_name_org").on(
table.name,
table.organizationId
),
]
);
export const devices = pgTable(
"devices",
{
id: uuid("id").primaryKey().defaultRandom(),
organizationId: uuid("organization_id")
.notNull()
.references(() => organizations.id),
deviceGroupId: uuid("device_group_id").references(() => deviceGroups.id),
name: varchar("name", { length: 255 }).notNull(),
deviceType: varchar("device_type", { length: 50 }).notNull(),
platform: varchar("platform", { length: 50 }).notNull(),
osName: varchar("os_name", { length: 100 }).notNull(),
osVersion: varchar("os_version", { length: 100 }).notNull(),
architecture: varchar("architecture", { length: 50 }).notNull(),
kernelVersion: varchar("kernel_version", { length: 100 }),
cpuModel: varchar("cpu_model", { length: 255 }),
cpuCores: integer("cpu_cores"),
totalMemoryBytes: bigint("total_memory_bytes", { mode: "number" }),
totalDiskBytes: bigint("total_disk_bytes", { mode: "number" }),
ipAddress: varchar("ip_address", { length: 45 }),
agentVersion: varchar("agent_version", { length: 50 }).notNull(),
status: varchar("status", { length: 20 }).notNull().default("offline"),
lastHeartbeatAt: timestamp("last_heartbeat_at", { withTimezone: true }),
enrolledAt: timestamp("enrolled_at", { withTimezone: true })
.notNull()
.defaultNow(),
isDisabled: boolean("is_disabled").notNull().default(false),
isArchived: boolean("is_archived").notNull().default(false),
tags: jsonb("tags").$type<string[]>().default([]).notNull(),
metadata: jsonb("metadata").$type<Record<string, unknown>>().default({}).notNull(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow()
.$onUpdateFn(() => new Date()),
},
(table) => [
index("idx_devices_org").on(table.organizationId),
index("idx_devices_status").on(table.status),
index("idx_devices_group").on(table.deviceGroupId),
index("idx_devices_type").on(table.deviceType),
index("idx_devices_last_heartbeat").on(table.lastHeartbeatAt),
]
);
export const deviceCredentials = pgTable(
"device_credentials",
{
id: uuid("id").primaryKey().defaultRandom(),
deviceId: uuid("device_id")
.notNull()
.references(() => devices.id, { onDelete: "cascade" }),
credentialType: varchar("credential_type", { length: 50 }).notNull(),
credentialHash: varchar("credential_hash", { length: 255 }).notNull(),
expiresAt: timestamp("expires_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(table) => [
index("idx_device_credentials_device").on(table.deviceId),
]
);
export const inventorySnapshots = pgTable(
"inventory_snapshots",
{
id: uuid("id").primaryKey().defaultRandom(),
deviceId: uuid("device_id")
.notNull()
.references(() => devices.id, { onDelete: "cascade" }),
reportedAt: timestamp("reported_at", { withTimezone: true }).notNull(),
packages: jsonb("packages").$type<unknown[]>().default([]).notNull(),
services: jsonb("services").$type<unknown[]>().default([]).notNull(),
networkInterfaces: jsonb("network_interfaces").$type<unknown[]>().default([]).notNull(),
platformData: jsonb("platform_data").$type<Record<string, unknown>>().default({}).notNull(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(table) => [
index("idx_inventory_device").on(table.deviceId),
index("idx_inventory_reported_at").on(table.reportedAt),
]
);
export const metricSnapshots = pgTable(
"metric_snapshots",
{
id: uuid("id").primaryKey().defaultRandom(),
deviceId: uuid("device_id")
.notNull()
.references(() => devices.id, { onDelete: "cascade" }),
reportedAt: timestamp("reported_at", { withTimezone: true }).notNull(),
cpuUsagePercent: real("cpu_usage_percent"),
memoryUsagePercent: real("memory_usage_percent"),
memoryUsedBytes: bigint("memory_used_bytes", { mode: "number" }),
memoryTotalBytes: bigint("memory_total_bytes", { mode: "number" }),
diskUsagePercent: real("disk_usage_percent"),
diskUsedBytes: bigint("disk_used_bytes", { mode: "number" }),
diskTotalBytes: bigint("disk_total_bytes", { mode: "number" }),
loadAverage1m: real("load_average_1m"),
loadAverage5m: real("load_average_5m"),
loadAverage15m: real("load_average_15m"),
uptimeSeconds: bigint("uptime_seconds", { mode: "number" }),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(table) => [
index("idx_metrics_device").on(table.deviceId),
index("idx_metrics_reported_at").on(table.reportedAt),
]
);
export const policies = pgTable(
"policies",
{
id: uuid("id").primaryKey().defaultRandom(),
organizationId: uuid("organization_id")
.notNull()
.references(() => organizations.id),
name: varchar("name", { length: 255 }).notNull(),
description: text("description"),
rules: jsonb("rules").$type<unknown[]>().default([]).notNull(),
isEnabled: boolean("is_enabled").notNull().default(true),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow()
.$onUpdateFn(() => new Date()),
},
(table) => [
index("idx_policies_org").on(table.organizationId),
]
);
export const complianceResults = pgTable(
"compliance_results",
{
id: uuid("id").primaryKey().defaultRandom(),
deviceId: uuid("device_id")
.notNull()
.references(() => devices.id, { onDelete: "cascade" }),
policyId: uuid("policy_id")
.notNull()
.references(() => policies.id, { onDelete: "cascade" }),
status: varchar("status", { length: 50 }).notNull(),
details: jsonb("details").$type<Record<string, unknown>>().default({}).notNull(),
evaluatedAt: timestamp("evaluated_at", { withTimezone: true }).notNull(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(table) => [
index("idx_compliance_device").on(table.deviceId),
index("idx_compliance_policy").on(table.policyId),
]
);
export const jobs = pgTable(
"jobs",
{
id: uuid("id").primaryKey().defaultRandom(),
organizationId: uuid("organization_id")
.notNull()
.references(() => organizations.id),
createdByUserId: uuid("created_by_user_id")
.notNull()
.references(() => users.id),
jobType: varchar("job_type", { length: 100 }).notNull(),
status: varchar("status", { length: 50 }).notNull().default("queued"),
parameters: jsonb("parameters").$type<Record<string, unknown>>().default({}).notNull(),
dispatchedAt: timestamp("dispatched_at", { withTimezone: true }),
startedAt: timestamp("started_at", { withTimezone: true }),
completedAt: timestamp("completed_at", { withTimezone: true }),
expiresAt: timestamp("expires_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(table) => [
index("idx_jobs_org").on(table.organizationId),
index("idx_jobs_status").on(table.status),
index("idx_jobs_type").on(table.jobType),
]
);
export const jobTargets = pgTable(
"job_targets",
{
id: uuid("id").primaryKey().defaultRandom(),
jobId: uuid("job_id")
.notNull()
.references(() => jobs.id, { onDelete: "cascade" }),
deviceId: uuid("device_id")
.notNull()
.references(() => devices.id, { onDelete: "cascade" }),
status: varchar("status", { length: 50 }).notNull().default("queued"),
result: jsonb("result").$type<Record<string, unknown>>(),
startedAt: timestamp("started_at", { withTimezone: true }),
completedAt: timestamp("completed_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(table) => [
index("idx_job_targets_job").on(table.jobId),
index("idx_job_targets_device").on(table.deviceId),
index("idx_job_targets_status").on(table.status),
]
);
export const remoteSessions = pgTable(
"remote_sessions",
{
id: uuid("id").primaryKey().defaultRandom(),
organizationId: uuid("organization_id")
.notNull()
.references(() => organizations.id),
deviceId: uuid("device_id")
.notNull()
.references(() => devices.id, { onDelete: "cascade" }),
createdByUserId: uuid("created_by_user_id")
.notNull()
.references(() => users.id),
jobId: uuid("job_id").references(() => jobs.id),
status: varchar("status", { length: 50 }).notNull().default("pending"),
vncPort: integer("vnc_port").notNull().default(5900),
vncPassword: varchar("vnc_password", { length: 64 }),
errorMessage: text("error_message"),
startedAt: timestamp("started_at", { withTimezone: true }),
endedAt: timestamp("ended_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }),
},
(table) => [
index("idx_remote_sessions_org").on(table.organizationId),
index("idx_remote_sessions_device").on(table.deviceId),
index("idx_remote_sessions_status").on(table.status),
]
);
export const alerts = pgTable(
"alerts",
{
id: uuid("id").primaryKey().defaultRandom(),
organizationId: uuid("organization_id")
.notNull()
.references(() => organizations.id),
deviceId: uuid("device_id").references(() => devices.id, {
onDelete: "set null",
}),
alertType: varchar("alert_type", { length: 100 }).notNull(),
severity: varchar("severity", { length: 20 }).notNull(),
title: varchar("title", { length: 255 }).notNull(),
message: text("message").notNull(),
isResolved: boolean("is_resolved").notNull().default(false),
resolvedAt: timestamp("resolved_at", { withTimezone: true }),
resolvedByUserId: uuid("resolved_by_user_id"),
metadata: jsonb("metadata").$type<Record<string, unknown>>().default({}).notNull(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(table) => [
index("idx_alerts_org").on(table.organizationId),
index("idx_alerts_device").on(table.deviceId),
index("idx_alerts_resolved").on(table.isResolved),
]
);
export const auditEvents = pgTable(
"audit_events",
{
id: uuid("id").primaryKey().defaultRandom(),
organizationId: uuid("organization_id")
.notNull()
.references(() => organizations.id),
userId: uuid("user_id").references(() => users.id),
action: varchar("action", { length: 255 }).notNull(),
targetType: varchar("target_type", { length: 100 }).notNull(),
targetId: uuid("target_id"),
deviceIds: jsonb("device_ids").$type<string[]>().default([]).notNull(),
result: varchar("result", { length: 50 }).notNull(),
ipAddress: varchar("ip_address", { length: 45 }),
metadata: jsonb("metadata").$type<Record<string, unknown>>().default({}).notNull(),
correlationId: varchar("correlation_id", { length: 255 }).notNull(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(table) => [
index("idx_audit_org").on(table.organizationId),
index("idx_audit_user").on(table.userId),
index("idx_audit_action").on(table.action),
index("idx_audit_created_at").on(table.createdAt),
index("idx_audit_correlation").on(table.correlationId),
]
);
export const apiTokens = pgTable(
"api_tokens",
{
id: uuid("id").primaryKey().defaultRandom(),
organizationId: uuid("organization_id")
.notNull()
.references(() => organizations.id),
userId: uuid("user_id")
.notNull()
.references(() => users.id),
name: varchar("name", { length: 255 }).notNull(),
tokenHash: varchar("token_hash", { length: 255 }).notNull().unique(),
expiresAt: timestamp("expires_at", { withTimezone: true }),
lastUsedAt: timestamp("last_used_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(table) => [
index("idx_api_tokens_org").on(table.organizationId),
index("idx_api_tokens_hash").on(table.tokenHash),
]
);
export const updateRequests = pgTable(
"update_requests",
{
id: uuid("id").primaryKey().defaultRandom(),
organizationId: uuid("organization_id")
.notNull()
.references(() => organizations.id),
deviceId: uuid("device_id")
.notNull()
.references(() => devices.id, { onDelete: "cascade" }),
packageName: varchar("package_name", { length: 255 }).notNull(),
packageManager: varchar("package_manager", { length: 50 }).notNull().default("apt"),
currentVersion: varchar("current_version", { length: 100 }),
targetVersion: varchar("target_version", { length: 100 }).notNull(),
requestedByUserId: uuid("requested_by_user_id")
.notNull()
.references(() => users.id),
status: varchar("status", { length: 50 }).notNull().default("pending"),
approvedByUserId: uuid("approved_by_user_id").references(() => users.id),
approvedAt: timestamp("approved_at", { withTimezone: true }),
rejectionReason: text("rejection_reason"),
deploymentId: uuid("deployment_id"),
notes: text("notes"),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow()
.$onUpdateFn(() => new Date()),
},
(table) => [
index("idx_update_requests_org").on(table.organizationId),
index("idx_update_requests_device").on(table.deviceId),
index("idx_update_requests_status").on(table.status),
]
);
export const deployments = pgTable(
"deployments",
{
id: uuid("id").primaryKey().defaultRandom(),
organizationId: uuid("organization_id")
.notNull()
.references(() => organizations.id),
name: varchar("name", { length: 255 }).notNull(),
description: text("description"),
jobType: varchar("job_type", { length: 100 }).notNull(),
parameters: jsonb("parameters").$type<Record<string, unknown>>().default({}).notNull(),
status: varchar("status", { length: 50 }).notNull().default("draft"),
createdByUserId: uuid("created_by_user_id")
.notNull()
.references(() => users.id),
approvedByUserId: uuid("approved_by_user_id").references(() => users.id),
approvedAt: timestamp("approved_at", { withTimezone: true }),
scheduledAt: timestamp("scheduled_at", { withTimezone: true }),
startedAt: timestamp("started_at", { withTimezone: true }),
completedAt: timestamp("completed_at", { withTimezone: true }),
totalTargets: integer("total_targets").notNull().default(0),
completedTargets: integer("completed_targets").notNull().default(0),
failedTargets: integer("failed_targets").notNull().default(0),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow()
.$onUpdateFn(() => new Date()),
},
(table) => [
index("idx_deployments_org").on(table.organizationId),
index("idx_deployments_status").on(table.status),
]
);
export const softwarePackages = pgTable(
"software_packages",
{
id: uuid("id").primaryKey().defaultRandom(),
organizationId: uuid("organization_id")
.notNull()
.references(() => organizations.id),
name: varchar("name", { length: 255 }).notNull(),
description: text("description"),
version: varchar("version", { length: 100 }).notNull(),
packageType: varchar("package_type", { length: 50 }).notNull(),
platform: varchar("platform", { length: 50 }).notNull(),
architecture: varchar("architecture", { length: 50 }).notNull(),
filename: varchar("filename", { length: 500 }).notNull(),
fileSize: bigint("file_size", { mode: "number" }).notNull(),
checksum: varchar("checksum", { length: 255 }),
installCommand: text("install_command"),
tags: jsonb("tags").$type<string[]>().default([]).notNull(),
metadata: jsonb("metadata").$type<Record<string, unknown>>().default({}).notNull(),
uploadedByUserId: uuid("uploaded_by_user_id")
.notNull()
.references(() => users.id),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow()
.$onUpdateFn(() => new Date()),
},
(table) => [
index("idx_software_packages_org").on(table.organizationId),
index("idx_software_packages_platform").on(table.platform),
index("idx_software_packages_name").on(table.name),
]
);
export const deploymentTargets = pgTable(
"deployment_targets",
{
id: uuid("id").primaryKey().defaultRandom(),
deploymentId: uuid("deployment_id")
.notNull()
.references(() => deployments.id, { onDelete: "cascade" }),
deviceId: uuid("device_id")
.notNull()
.references(() => devices.id, { onDelete: "cascade" }),
jobId: uuid("job_id").references(() => jobs.id),
status: varchar("status", { length: 50 }).notNull().default("pending"),
result: jsonb("result").$type<Record<string, unknown>>(),
startedAt: timestamp("started_at", { withTimezone: true }),
completedAt: timestamp("completed_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(table) => [
index("idx_deployment_targets_deployment").on(table.deploymentId),
index("idx_deployment_targets_device").on(table.deviceId),
index("idx_deployment_targets_status").on(table.status),
]
);
+67
View File
@@ -0,0 +1,67 @@
import dotenv from "dotenv";
dotenv.config();
import bcrypt from "bcrypt";
import { db } from "./index.js";
import { organizations, users } from "./schema.js";
import { eq } from "drizzle-orm";
async function seed() {
console.log("Seeding database...");
const [org] = await db
.insert(organizations)
.values({
name: "Default Organization",
slug: "default",
})
.onConflictDoNothing()
.returning();
let organizationId: string;
if (org) {
organizationId = org.id;
console.log(`Created organization: ${org.name} (${org.id})`);
} else {
const [existing] = await db
.select()
.from(organizations)
.where(eq(organizations.slug, "default"))
.limit(1);
organizationId = existing!.id;
console.log(`Organization already exists: ${existing!.name}`);
}
const [existingUser] = await db
.select()
.from(users)
.where(eq(users.email, "admin@fleetforge.local"))
.limit(1);
if (!existingUser) {
const passwordHash = await bcrypt.hash("admin123", 12);
const [user] = await db
.insert(users)
.values({
organizationId,
email: "admin@fleetforge.local",
passwordHash,
displayName: "Fleet Admin",
role: "owner",
})
.returning();
console.log(`Created admin user: ${user.email}`);
console.log("Login credentials: admin@fleetforge.local / admin123");
} else {
console.log("Admin user already exists");
}
console.log("Seed complete.");
}
seed().catch((err) => {
console.error("Seed failed:", err);
process.exit(1);
});
+112
View File
@@ -0,0 +1,112 @@
import dotenv from "dotenv";
dotenv.config();
import express from "express";
import cors from "cors";
import helmet from "helmet";
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
process.on("unhandledRejection", (err) => {
console.error("[process] Unhandled rejection:", err);
});
process.on("uncaughtException", (err) => {
console.error("[process] Uncaught exception:", err);
});
import { corsConfig } from "./middleware/cors.js";
import { errorHandler, notFoundHandler } from "./middleware/errors.js";
import { correlationMiddleware } from "./middleware/correlation.js";
import { rateLimitAuth, rateLimitApi, rateLimitEnroll } from "./middleware/rateLimit.js";
import { authRouter } from "./routes/auth.js";
import { devicesRouter } from "./routes/devices.js";
import { enrollmentTokensRouter } from "./routes/enrollment-tokens.js";
import { groupsRouter } from "./routes/groups.js";
import { jobsRouter } from "./routes/jobs.js";
import { agentRouter } from "./routes/agent.js";
import { auditRouter } from "./routes/audit.js";
import { alertsRouter } from "./routes/alerts.js";
import { policiesRouter } from "./routes/policies.js";
import { dashboardRouter } from "./routes/dashboard.js";
import { usersRouter } from "./routes/users.js";
import { complianceRouter } from "./routes/compliance.js";
import { updateRequestsRouter } from "./routes/update-requests.js";
import { deploymentsRouter } from "./routes/deployments.js";
import { softwareRouter } from "./routes/software.js";
import { appCatalogRouter } from "./routes/appcatalog.js";
import { downloadsRouter } from "./routes/downloads.js";
import { installersRouter } from "./routes/installers.js";
import { remoteRouter } from "./routes/remote.js";
import { attachRelay } from "./ws/relay.js";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const app = express();
const port = parseInt(process.env.APP_PORT || "3000", 10);
app.use(helmet({
contentSecurityPolicy: false,
crossOriginEmbedderPolicy: false,
}));
app.use(cors(corsConfig));
app.use(express.json({ limit: "1mb" }));
app.use(correlationMiddleware);
app.get("/api/health", (_req, res) => {
res.json({ status: "ok", version: "0.1.0" });
});
const openapiPath = path.resolve(__dirname, "../../../docs/openapi.json");
if (fs.existsSync(openapiPath)) {
const spec = JSON.parse(fs.readFileSync(openapiPath, "utf-8"));
app.get("/api/docs", (_req, res) => {
res.send(`<!DOCTYPE html>
<html><head><title>Fleet Forge API Docs</title>
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css">
</head><body>
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
<script>
window.onload = function() {
SwaggerUIBundle({ spec: ${JSON.stringify(spec)}, dom_id: '#swagger-ui', presets: [SwaggerUIBundle.presets.apis], layout: "BaseLayout" });
};
</script></body></html>`);
});
app.get("/api/openapi.json", (_req, res) => res.json(spec));
}
app.use("/api/v1/auth/login", rateLimitAuth);
app.use("/api/v1/agent/enroll", rateLimitEnroll);
app.use("/api/v1", rateLimitApi);
app.use("/api/v1/auth", authRouter);
app.use("/api/v1/users", usersRouter);
app.use("/api/v1/devices", devicesRouter);
app.use("/api/v1/enrollment-tokens", enrollmentTokensRouter);
app.use("/api/v1/groups", groupsRouter);
app.use("/api/v1/jobs", jobsRouter);
app.use("/api/v1/policies", policiesRouter);
app.use("/api/v1/alerts", alertsRouter);
app.use("/api/v1/audit-events", auditRouter);
app.use("/api/v1/dashboard", dashboardRouter);
app.use("/api/v1/compliance", complianceRouter);
app.use("/api/v1/update-requests", updateRequestsRouter);
app.use("/api/v1/deployments", deploymentsRouter);
app.use("/api/v1/software", softwareRouter);
app.use("/api/v1/appcatalog", appCatalogRouter);
app.use("/api/v1/downloads", downloadsRouter);
app.use("/api/v1/installers", installersRouter);
app.use("/api/v1/agent", agentRouter);
app.use("/api/v1/remote", remoteRouter);
app.use(notFoundHandler);
app.use(errorHandler);
if (process.env.NODE_ENV !== "test") {
const server = app.listen(port, "0.0.0.0", () => {
console.log(`Fleet Forge API running on port ${port}`);
});
attachRelay(server);
}
export default app;
+38
View File
@@ -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,
});
}
+492
View File
@@ -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();
}
}
+68
View File
@@ -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 };
+17
View File
@@ -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");
}
+80
View File
@@ -0,0 +1,80 @@
import type { Request, Response, NextFunction } from "express";
import jwt from "jsonwebtoken";
import type { AuthenticatedUser, Role } from "@fleetforge/shared";
const JWT_SECRET = process.env.JWT_SECRET || "dev-secret";
const ROLE_HIERARCHY: Record<Role, number> = {
owner: 4,
admin: 3,
operator: 2,
read_only: 1,
};
export function generateToken(user: AuthenticatedUser): string {
return jwt.sign(
{
userId: user.userId,
organizationId: user.organizationId,
role: user.role,
email: user.email,
},
JWT_SECRET,
{ expiresIn: (process.env.JWT_EXPIRES_IN || "24h") as any }
);
}
export function verifyToken(token: string): AuthenticatedUser {
return jwt.verify(token, JWT_SECRET) as AuthenticatedUser;
}
export function authenticate(req: Request, res: Response, next: NextFunction) {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith("Bearer ")) {
res.status(401).json({
success: false,
error: { code: "UNAUTHORIZED", message: "Missing or invalid token" },
});
return;
}
try {
const token = authHeader.substring(7);
const user = verifyToken(token);
(req as any).user = user;
next();
} catch {
res.status(401).json({
success: false,
error: { code: "UNAUTHORIZED", message: "Invalid or expired token" },
});
}
}
export function authorize(...allowedRoles: Role[]) {
return (req: Request, res: Response, next: NextFunction) => {
const user = (req as any).user as AuthenticatedUser;
if (!user) {
res.status(401).json({
success: false,
error: { code: "UNAUTHORIZED", message: "Not authenticated" },
});
return;
}
if (allowedRoles.length > 0) {
const hasRole = allowedRoles.some(
(role) => ROLE_HIERARCHY[user.role] >= ROLE_HIERARCHY[role]
);
if (!hasRole) {
res.status(403).json({
success: false,
error: { code: "FORBIDDEN", message: "Insufficient permissions" },
});
return;
}
}
next();
};
}
+13
View File
@@ -0,0 +1,13 @@
import type { Request, Response, NextFunction } from "express";
import { v4 as uuidv4 } from "uuid";
export function correlationMiddleware(
req: Request,
_res: Response,
next: NextFunction
) {
const correlationId =
(req.headers["x-correlation-id"] as string) || uuidv4();
(req as any).correlationId = correlationId;
next();
}
+21
View File
@@ -0,0 +1,21 @@
export const corsConfig = {
origin: process.env.CORS_ORIGINS
? process.env.CORS_ORIGINS.split(",")
: [
"http://localhost:3000",
"http://localhost:3001",
"http://localhost:3002",
"http://localhost:3003",
"http://localhost:3004",
"http://localhost:3005",
"http://10.0.0.194:3000",
"http://10.0.0.194:3001",
"http://10.0.0.194:3002",
"http://10.0.0.194:3003",
"http://10.0.0.194:3004",
"http://10.0.0.194:3005",
],
credentials: true,
methods: ["GET", "POST", "PUT", "PATCH", "DELETE"],
allowedHeaders: ["Content-Type", "Authorization", "X-Correlation-ID"],
};
+21
View File
@@ -0,0 +1,21 @@
import type { Request, Response, NextFunction } from "express";
export function errorHandler(
err: Error,
_req: Request,
res: Response,
_next: NextFunction
) {
console.error("Unhandled error:", err);
res.status(500).json({
success: false,
error: { code: "INTERNAL_ERROR", message: "An internal error occurred" },
});
}
export function notFoundHandler(_req: Request, res: Response) {
res.status(404).json({
success: false,
error: { code: "NOT_FOUND", message: "Endpoint not found" },
});
}
+57
View File
@@ -0,0 +1,57 @@
import { RateLimiterMemory } from "rate-limiter-flexible";
const authLimiter = new RateLimiterMemory({
points: 10,
duration: 60,
});
const apiLimiter = new RateLimiterMemory({
points: 100,
duration: 60,
});
const enrollLimiter = new RateLimiterMemory({
points: 5,
duration: 300,
});
export async function rateLimitAuth(req: any, res: any, next: any) {
try {
const ip = req.ip || req.socket?.remoteAddress || "unknown";
await authLimiter.consume(`auth:${ip}`);
next();
} catch {
res.status(429).json({
success: false,
error: { code: "RATE_LIMITED", message: "Too many authentication attempts. Please try again later." },
});
}
}
export async function rateLimitApi(req: any, res: any, next: any) {
try {
const ip = req.ip || req.socket?.remoteAddress || "unknown";
const user = (req as any).user;
const key = user ? `api:${user.userId}` : `api:${ip}`;
await apiLimiter.consume(key);
next();
} catch {
res.status(429).json({
success: false,
error: { code: "RATE_LIMITED", message: "Rate limit exceeded. Please slow down." },
});
}
}
export async function rateLimitEnroll(req: any, res: any, next: any) {
try {
const ip = req.ip || req.socket?.remoteAddress || "unknown";
await enrollLimiter.consume(`enroll:${ip}`);
next();
} catch {
res.status(429).json({
success: false,
error: { code: "RATE_LIMITED", message: "Too many enrollment attempts." },
});
}
}
+612
View File
@@ -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" },
});
}
});
+65
View File
@@ -0,0 +1,65 @@
import { Router } from "express";
import { db } from "../db/index.js";
import { alerts } from "../db/schema.js";
import { authenticate, authorize } from "../middleware/auth.js";
import { eq, and, desc, count } from "drizzle-orm";
export const alertsRouter = Router();
alertsRouter.use(authenticate);
alertsRouter.get("/", authorize("read_only"), async (req, res) => {
const user = (req as any).user;
const resolved = req.query.resolved as string | undefined;
let whereClause = eq(alerts.organizationId, user.organizationId);
if (resolved !== undefined) {
whereClause = and(whereClause, eq(alerts.isResolved, resolved === "true"))!;
}
const [totalResult] = await db
.select({ total: count() })
.from(alerts)
.where(whereClause);
const allAlerts = await db
.select()
.from(alerts)
.where(whereClause)
.orderBy(desc(alerts.createdAt))
.limit(50);
res.json({
success: true,
data: allAlerts,
meta: {
page: 1,
limit: 50,
total: totalResult.total,
totalPages: 1,
},
});
});
alertsRouter.patch("/:alertId/resolve", authorize("admin"), async (req, res) => {
const user = (req as any).user;
const alertId = req.params.alertId as string;
const [resolved] = await db
.update(alerts)
.set({ isResolved: true, resolvedAt: new Date(), resolvedByUserId: user.userId })
.where(
and(eq(alerts.id, alertId), eq(alerts.organizationId, user.organizationId))
)
.returning({ id: alerts.id });
if (!resolved) {
res.status(404).json({
success: false,
error: { code: "NOT_FOUND", message: "Alert not found" },
});
return;
}
res.json({ success: true });
});
+201
View File
@@ -0,0 +1,201 @@
import { Router } from "express";
import { authenticate, authorize } from "../middleware/auth.js";
export const appCatalogRouter = Router();
appCatalogRouter.use(authenticate);
const WINGET_SEARCH_URL = "https://api.winget.run/v2/packages";
const CHOCOLATEY_SEARCH = "https://community.chocolatey.org/api/v2/Search()";
const CHOCOLATEY_FEED = "https://community.chocolatey.org/api/v2/Packages()";
const UPSTREAM_TIMEOUT_MS = 12_000;
const CACHE_TTL_MS = 60_000;
const cache = new Map<string, { expiresAt: number; value: unknown }>();
function cacheGet<T>(key: string): T | undefined {
const hit = cache.get(key);
if (hit && hit.expiresAt > Date.now()) return hit.value as T;
if (hit) cache.delete(key);
return undefined;
}
function cacheSet(key: string, value: unknown) {
cache.set(key, { expiresAt: Date.now() + CACHE_TTL_MS, value });
}
async function fetchUpstream(url: string): Promise<string> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), UPSTREAM_TIMEOUT_MS);
try {
const res = await fetch(url, { signal: controller.signal });
if (!res.ok) {
throw new Error(`upstream returned HTTP ${res.status}`);
}
return await res.text();
} finally {
clearTimeout(timer);
}
}
interface WingetPackage {
id: string;
name: string;
publisher: string;
latestVersion: string;
versions: string[];
description: string;
tags: string[];
iconUrl: string;
homepage: string;
license: string;
}
function mapWinget(raw: any): WingetPackage {
const latest = raw.Latest || {};
return {
id: raw.Id || "",
name: latest.Name || raw.Id || "",
publisher: latest.Publisher || "",
latestVersion: Array.isArray(raw.Versions) && raw.Versions.length > 0 ? raw.Versions[0] : "",
versions: Array.isArray(raw.Versions) ? raw.Versions : [],
description: latest.Description || "",
tags: Array.isArray(latest.Tags) ? latest.Tags : [],
iconUrl: raw.IconUrl || "",
homepage: latest.Homepage || "",
license: latest.License || "",
};
}
interface ChocolateyPackage {
id: string;
title: string;
version: string;
description: string;
downloadCount: number;
iconUrl: string;
}
const fieldRegex = (base: string) =>
new RegExp(`<d:${base}[^>]*>([^<]*)</d:${base}>`);
function mapChocolatey(xml: string): ChocolateyPackage[] {
const packages: ChocolateyPackage[] = [];
const entries = xml.match(/<entry>[\s\S]*?<\/entry>/g) || [];
for (const entry of entries) {
const idMatch = entry.match(/Packages\(Id='([^']+)'/);
const title = entry.match(fieldRegex("Title"));
const version = entry.match(fieldRegex("Version"));
const description = entry.match(fieldRegex("Description"));
const downloadCount = entry.match(fieldRegex("DownloadCount"));
const iconUrl = entry.match(fieldRegex("IconUrl"));
if (!idMatch) continue;
packages.push({
id: decodeXmlEntities(idMatch[1]),
title: decodeXmlEntities(title ? title[1] : ""),
version: decodeXmlEntities(version ? version[1] : ""),
description: decodeXmlEntities(description ? description[1] : ""),
downloadCount: downloadCount ? parseInt(downloadCount[1], 10) || 0 : 0,
iconUrl: decodeXmlEntities(iconUrl ? iconUrl[1] : ""),
});
}
return packages;
}
function decodeXmlEntities(value: string): string {
return value
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&apos;/g, "'");
}
appCatalogRouter.get("/winget", authorize("read_only"), async (req, res) => {
try {
const search = String(req.query.search || "").trim();
const page = Math.max(1, parseInt(req.query.page as string) || 1);
const limit = Math.min(Math.max(parseInt(req.query.limit as string) || 25, 1), 50);
const take = limit;
const skip = (page - 1) * take;
const cacheKey = `winget:${search}:${page}:${limit}`;
const cached = cacheGet<{ packages: WingetPackage[]; total: number }>(cacheKey);
if (cached) {
res.json({ success: true, data: { ...cached, page, limit } });
return;
}
const url = `${WINGET_SEARCH_URL}?query=${encodeURIComponent(search)}&take=${take}&page=${skip}&sortField=SearchScore`;
const body = await fetchUpstream(url);
const parsed = JSON.parse(body);
const rawPackages = Array.isArray(parsed.Packages) ? parsed.Packages : [];
const packages = rawPackages.map(mapWinget);
const total = typeof parsed.Total === "number" ? parsed.Total : 0;
cacheSet(cacheKey, { packages, total });
res.json({ success: true, data: { packages, total, page, limit } });
} catch (err) {
res.status(502).json({
success: false,
error: { code: "UPSTREAM_ERROR", message: (err as Error).message },
});
}
});
appCatalogRouter.get("/chocolatey", authorize("read_only"), async (req, res) => {
try {
const search = String(req.query.search || "").trim();
const page = Math.max(1, parseInt(req.query.page as string) || 1);
const limit = Math.min(Math.max(parseInt(req.query.limit as string) || 25, 1), 50);
const skip = (page - 1) * limit;
const cacheKey = `chocolatey:${search}:${page}:${limit}`;
const cached = cacheGet<{ packages: ChocolateyPackage[] }>(cacheKey);
if (cached) {
res.json({ success: true, data: { ...cached, page, limit } });
return;
}
let packages: ChocolateyPackage[];
if (search) {
// Search() matches title/description/tags but returns one entry per version.
// Fetch a wider window, deduplicate (newest version first) and slice.
const params = new URLSearchParams({
searchTerm: `'${search}'`,
targetFramework: "",
includePrerelease: "false",
$skip: "0",
$top: String(skip + limit * 4),
$select: "Id,Version,Title,Description,DownloadCount,IconUrl",
});
const xml = await fetchUpstream(`${CHOCOLATEY_SEARCH}?${params.toString()}`);
const raw = mapChocolatey(xml);
const seen = new Set<string>();
const unique = raw.filter((p) => {
if (seen.has(p.id)) return false;
seen.add(p.id);
return true;
});
packages = unique.length > skip ? unique.slice(skip, skip + limit) : [];
} else {
// Browse-all: one row per package (latest version only).
const params = new URLSearchParams({
$filter: "IsLatestVersion",
$skip: String(skip),
$top: String(limit),
$select: "Id,Version,Title,Description,DownloadCount,IconUrl",
});
const xml = await fetchUpstream(`${CHOCOLATEY_FEED}?${params.toString()}`);
packages = mapChocolatey(xml);
}
cacheSet(cacheKey, { packages });
res.json({ success: true, data: { packages, page, limit } });
} catch (err) {
res.status(502).json({
success: false,
error: { code: "UPSTREAM_ERROR", message: (err as Error).message },
});
}
});
+46
View File
@@ -0,0 +1,46 @@
import { Router } from "express";
import { db } from "../db/index.js";
import { auditEvents } from "../db/schema.js";
import { authenticate, authorize } from "../middleware/auth.js";
import { eq, and, desc, ilike, count } from "drizzle-orm";
export const auditRouter = Router();
auditRouter.use(authenticate);
auditRouter.get("/", authorize("admin"), async (req, res) => {
const user = (req as any).user;
const page = parseInt(req.query.page as string) || 1;
const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
const offset = (page - 1) * limit;
const action = req.query.action as string | undefined;
let whereClause = eq(auditEvents.organizationId, user.organizationId);
if (action) {
whereClause = and(whereClause, ilike(auditEvents.action, `%${action}%`))!;
}
const [totalResult] = await db
.select({ total: count() })
.from(auditEvents)
.where(whereClause);
const events = await db
.select()
.from(auditEvents)
.where(whereClause)
.orderBy(desc(auditEvents.createdAt))
.limit(limit)
.offset(offset);
res.json({
success: true,
data: events,
meta: {
page,
limit,
total: totalResult.total,
totalPages: Math.ceil(totalResult.total / limit),
},
});
});
+144
View File
@@ -0,0 +1,144 @@
import { Router } from "express";
import bcrypt from "bcrypt";
import { z } from "zod";
import { db } from "../db/index.js";
import { users } from "../db/schema.js";
import { generateToken } from "../middleware/auth.js";
import { auditLog } from "../lib/audit.js";
import { eq, and } from "drizzle-orm";
export const authRouter = Router();
const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(1),
organizationSlug: z.string().optional(),
});
authRouter.post("/login", async (req, res) => {
try {
const body = loginSchema.parse(req.body);
const [user] = await db
.select()
.from(users)
.where(and(eq(users.email, body.email), eq(users.isActive, true)))
.limit(1);
if (!user) {
res.status(401).json({
success: false,
error: { code: "INVALID_CREDENTIALS", message: "Invalid email or password" },
});
return;
}
const passwordValid = await bcrypt.compare(body.password, user.passwordHash);
if (!passwordValid) {
await auditLog({
req,
userId: user.id,
organizationId: user.organizationId,
action: "auth.login_failed",
targetType: "user",
targetId: user.id,
result: "failure",
metadata: { reason: "invalid_password" },
});
res.status(401).json({
success: false,
error: { code: "INVALID_CREDENTIALS", message: "Invalid email or password" },
});
return;
}
await db
.update(users)
.set({ lastLoginAt: new Date() })
.where(eq(users.id, user.id));
const token = generateToken({
userId: user.id,
organizationId: user.organizationId,
role: user.role as any,
email: user.email,
});
await auditLog({
req,
userId: user.id,
organizationId: user.organizationId,
action: "auth.login",
targetType: "user",
targetId: user.id,
result: "success",
});
res.json({
success: true,
data: {
token,
user: {
id: user.id,
email: user.email,
displayName: user.displayName,
role: user.role,
},
},
});
} catch (err) {
if (err instanceof z.ZodError) {
res.status(400).json({
success: false,
error: { code: "VALIDATION_ERROR", message: "Invalid request body", details: err.errors },
});
return;
}
throw err;
}
});
authRouter.get("/me", async (req, res) => {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith("Bearer ")) {
res.status(401).json({
success: false,
error: { code: "UNAUTHORIZED", message: "Not authenticated" },
});
return;
}
try {
const { verifyToken } = await import("../middleware/auth.js");
const payload = verifyToken(authHeader.substring(7));
const [user] = await db
.select()
.from(users)
.where(eq(users.id, payload.userId))
.limit(1);
if (!user) {
res.status(401).json({
success: false,
error: { code: "UNAUTHORIZED", message: "User not found" },
});
return;
}
res.json({
success: true,
data: {
id: user.id,
email: user.email,
displayName: user.displayName,
role: user.role,
},
});
} catch {
res.status(401).json({
success: false,
error: { code: "UNAUTHORIZED", message: "Invalid token" },
});
}
});
+126
View File
@@ -0,0 +1,126 @@
import { Router } from "express";
import { db } from "../db/index.js";
import { complianceResults, devices } from "../db/schema.js";
import { authenticate, authorize } from "../middleware/auth.js";
import { eq, and, desc } from "drizzle-orm";
import { evaluatePoliciesForDevice } from "../lib/compliance.js";
import { getPool } from "../db/index.js";
export const complianceRouter = Router();
complianceRouter.use(authenticate);
complianceRouter.get(
"/devices/:deviceId",
authorize("read_only"),
async (req, res) => {
const user = (req as any).user;
const deviceId = req.params.deviceId as string;
const [device] = await db
.select()
.from(devices)
.where(
and(
eq(devices.id, deviceId),
eq(devices.organizationId, user.organizationId)
)
);
if (!device) {
res.status(404).json({
success: false,
error: { code: "NOT_FOUND", message: "Device not found" },
});
return;
}
const results = await db
.select()
.from(complianceResults)
.where(eq(complianceResults.deviceId, deviceId))
.orderBy(desc(complianceResults.evaluatedAt));
res.json({ success: true, data: results });
}
);
complianceRouter.post(
"/devices/:deviceId/evaluate",
authorize("operator"),
async (req, res) => {
const user = (req as any).user;
const deviceId = req.params.deviceId as string;
const [device] = await db
.select()
.from(devices)
.where(
and(
eq(devices.id, deviceId),
eq(devices.organizationId, user.organizationId)
)
);
if (!device) {
res.status(404).json({
success: false,
error: { code: "NOT_FOUND", message: "Device not found" },
});
return;
}
const pool = getPool();
const result = await evaluatePoliciesForDevice(
pool,
deviceId,
user.organizationId
);
res.json({ success: true, data: result });
}
);
complianceRouter.post(
"/evaluate",
authorize("admin"),
async (req, res) => {
const user = (req as any).user;
const pool = getPool();
const { evaluatePolicies } = await import("../lib/compliance.js");
const result = await evaluatePolicies(pool, user.organizationId);
res.json({ success: true, data: result });
}
);
complianceRouter.get(
"/summary",
authorize("read_only"),
async (req, res) => {
const user = (req as any).user;
const results = await db
.select()
.from(complianceResults)
.innerJoin(devices, eq(complianceResults.deviceId, devices.id))
.where(eq(devices.organizationId, user.organizationId));
const total = results.length;
const compliant = results.filter(
(r) => r.compliance_results.status === "compliant"
).length;
const nonCompliant = results.filter(
(r) => r.compliance_results.status === "non_compliant"
).length;
const unknown = results.filter(
(r) => r.compliance_results.status === "unknown"
).length;
res.json({
success: true,
data: { total, compliant, nonCompliant, unknown },
});
}
);
+111
View File
@@ -0,0 +1,111 @@
import { Router } from "express";
import { db } from "../db/index.js";
import { devices, alerts, jobs } from "../db/schema.js";
import { authenticate, authorize } from "../middleware/auth.js";
import { eq, and, count, sql, desc } from "drizzle-orm";
export const dashboardRouter = Router();
dashboardRouter.use(authenticate);
dashboardRouter.get("/stats", authorize("read_only"), async (req, res) => {
const user = (req as any).user;
const [totalDevices] = await db
.select({ total: count() })
.from(devices)
.where(eq(devices.organizationId, user.organizationId));
const [onlineDevices] = await db
.select({ total: count() })
.from(devices)
.where(
and(eq(devices.organizationId, user.organizationId), eq(devices.status, "online"))
);
const [offlineDevices] = await db
.select({ total: count() })
.from(devices)
.where(
and(
eq(devices.organizationId, user.organizationId),
eq(devices.status, "offline")
)
);
const [warningDevices] = await db
.select({ total: count() })
.from(devices)
.where(
and(
eq(devices.organizationId, user.organizationId),
eq(devices.status, "warning")
)
);
const [openAlerts] = await db
.select({ total: count() })
.from(alerts)
.where(
and(eq(alerts.organizationId, user.organizationId), eq(alerts.isResolved, false))
);
const [pendingJobs] = await db
.select({ total: count() })
.from(jobs)
.where(
and(
eq(jobs.organizationId, user.organizationId),
sql`${jobs.status} IN ('queued', 'dispatched', 'running')`
)
);
const osDistribution = await db
.select({
os: devices.osName,
total: count(),
})
.from(devices)
.where(eq(devices.organizationId, user.organizationId))
.groupBy(devices.osName);
const recentDevices = await db
.select({
id: devices.id,
name: devices.name,
status: devices.status,
osName: devices.osName,
lastHeartbeatAt: devices.lastHeartbeatAt,
})
.from(devices)
.where(eq(devices.organizationId, user.organizationId))
.orderBy(desc(devices.enrolledAt))
.limit(5);
const offlineThreshold = new Date(Date.now() - 5 * 60 * 1000);
const [staleDevices] = await db
.select({ total: count() })
.from(devices)
.where(
and(
eq(devices.organizationId, user.organizationId),
eq(devices.status, "offline"),
sql`${devices.lastHeartbeatAt} < ${offlineThreshold}`
)
);
res.json({
success: true,
data: {
totalDevices: totalDevices.total,
onlineDevices: onlineDevices.total,
offlineDevices: offlineDevices.total,
warningDevices: warningDevices.total,
openAlerts: openAlerts.total,
pendingJobs: pendingJobs.total,
staleDevices: staleDevices.total,
osDistribution,
recentDevices,
},
});
});
+321
View File
@@ -0,0 +1,321 @@
import { Router } from "express";
import { z } from "zod";
import { db } from "../db/index.js";
import { deployments, deploymentTargets, devices, users } from "../db/schema.js";
import { authenticate, authorize } from "../middleware/auth.js";
import { auditLog } from "../lib/audit.js";
import { eq, and, desc, count } from "drizzle-orm";
export const deploymentsRouter = Router();
deploymentsRouter.use(authenticate);
deploymentsRouter.get("/", authorize("read_only"), async (req, res) => {
const user = (req as any).user;
const page = parseInt(req.query.page as string) || 1;
const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
const offset = (page - 1) * limit;
const status = req.query.status as string | undefined;
const whereConditions = [eq(deployments.organizationId, user.organizationId)];
if (status) {
whereConditions.push(eq(deployments.status, status));
}
const [totalResult] = await db
.select({ total: count() })
.from(deployments)
.where(and(...whereConditions));
const rows = await db
.select({
id: deployments.id,
organizationId: deployments.organizationId,
name: deployments.name,
description: deployments.description,
jobType: deployments.jobType,
parameters: deployments.parameters,
status: deployments.status,
createdByUserId: deployments.createdByUserId,
approvedByUserId: deployments.approvedByUserId,
approvedAt: deployments.approvedAt,
scheduledAt: deployments.scheduledAt,
startedAt: deployments.startedAt,
completedAt: deployments.completedAt,
totalTargets: deployments.totalTargets,
completedTargets: deployments.completedTargets,
failedTargets: deployments.failedTargets,
createdAt: deployments.createdAt,
updatedAt: deployments.updatedAt,
createdByName: users.displayName,
})
.from(deployments)
.leftJoin(users, eq(deployments.createdByUserId, users.id))
.where(and(...whereConditions))
.orderBy(desc(deployments.createdAt))
.limit(limit)
.offset(offset);
res.json({
success: true,
data: rows,
meta: {
page,
limit,
total: totalResult.total,
totalPages: Math.ceil(totalResult.total / limit),
},
});
});
const createDeploymentSchema = z.object({
name: z.string().min(1).max(255),
description: z.string().optional(),
jobType: z.enum([
"install_package", "update_package", "remove_package",
"start_service", "stop_service", "restart_service",
"check_service_status", "reboot_system", "shutdown_system",
"start_remote_session", "stop_remote_session",
"update_inventory", "update_system_info",
"install_from_file", "download_and_install", "install_registry_package",
]),
parameters: z.record(z.unknown()).default({}),
deviceIds: z.array(z.string().uuid()).min(1),
scheduledAt: z.string().datetime().optional(),
autoApprove: z.boolean().default(false),
});
deploymentsRouter.post("/", authorize("operator"), async (req, res) => {
try {
const body = createDeploymentSchema.parse(req.body);
const authUser = (req as any).user;
const status = body.autoApprove ? "approved" : "draft";
const [deployment] = await db
.insert(deployments)
.values({
organizationId: authUser.organizationId,
name: body.name,
description: body.description || null,
jobType: body.jobType,
parameters: body.parameters,
status,
createdByUserId: authUser.userId,
approvedByUserId: body.autoApprove ? authUser.userId : null,
approvedAt: body.autoApprove ? new Date() : null,
scheduledAt: body.scheduledAt ? new Date(body.scheduledAt) : null,
totalTargets: body.deviceIds.length,
})
.returning();
const targets = body.deviceIds.map((deviceId) => ({
deploymentId: deployment.id,
deviceId,
status: "pending" as const,
}));
await db.insert(deploymentTargets).values(targets);
await auditLog({
req,
organizationId: authUser.organizationId,
action: "deployment.create",
targetType: "deployment",
targetId: deployment.id,
deviceIds: body.deviceIds,
result: "success",
metadata: {
name: body.name,
jobType: body.jobType,
targetCount: body.deviceIds.length,
status,
},
});
res.status(201).json({ success: true, data: deployment });
} catch (err) {
if (err instanceof z.ZodError) {
res.status(400).json({
success: false,
error: { code: "VALIDATION_ERROR", message: "Invalid request body", details: err.errors },
});
return;
}
throw err;
}
});
deploymentsRouter.get("/:deploymentId", authorize("read_only"), async (req, res) => {
const user = (req as any).user;
const deploymentId = req.params.deploymentId as string;
const [deployment] = await db
.select({
id: deployments.id,
organizationId: deployments.organizationId,
name: deployments.name,
description: deployments.description,
jobType: deployments.jobType,
parameters: deployments.parameters,
status: deployments.status,
createdByUserId: deployments.createdByUserId,
approvedByUserId: deployments.approvedByUserId,
approvedAt: deployments.approvedAt,
scheduledAt: deployments.scheduledAt,
startedAt: deployments.startedAt,
completedAt: deployments.completedAt,
totalTargets: deployments.totalTargets,
completedTargets: deployments.completedTargets,
failedTargets: deployments.failedTargets,
createdAt: deployments.createdAt,
updatedAt: deployments.updatedAt,
createdByName: users.displayName,
})
.from(deployments)
.leftJoin(users, eq(deployments.createdByUserId, users.id))
.where(
and(
eq(deployments.id, deploymentId),
eq(deployments.organizationId, user.organizationId)
)
)
.limit(1);
if (!deployment) {
res.status(404).json({
success: false,
error: { code: "NOT_FOUND", message: "Deployment not found" },
});
return;
}
const targets = await db
.select({
id: deploymentTargets.id,
deploymentId: deploymentTargets.deploymentId,
deviceId: deploymentTargets.deviceId,
jobId: deploymentTargets.jobId,
status: deploymentTargets.status,
result: deploymentTargets.result,
startedAt: deploymentTargets.startedAt,
completedAt: deploymentTargets.completedAt,
createdAt: deploymentTargets.createdAt,
deviceName: devices.name,
})
.from(deploymentTargets)
.leftJoin(devices, eq(deploymentTargets.deviceId, devices.id))
.where(eq(deploymentTargets.deploymentId, deploymentId));
res.json({ success: true, data: { ...deployment, targets } });
});
deploymentsRouter.patch("/:deploymentId/approve", authorize("operator"), async (req, res) => {
const user = (req as any).user;
const deploymentId = req.params.deploymentId as string;
const [existing] = await db
.select()
.from(deployments)
.where(
and(
eq(deployments.id, deploymentId),
eq(deployments.organizationId, user.organizationId),
eq(deployments.status, "draft")
)
)
.limit(1);
if (!existing) {
res.status(404).json({
success: false,
error: { code: "NOT_FOUND", message: "Deployment not found or not in draft status" },
});
return;
}
const [updated] = await db
.update(deployments)
.set({
status: "approved",
approvedByUserId: user.userId,
approvedAt: new Date(),
})
.where(eq(deployments.id, deploymentId))
.returning();
await auditLog({
req,
organizationId: user.organizationId,
action: "deployment.approve",
targetType: "deployment",
targetId: deploymentId,
result: "success",
metadata: { name: existing.name },
});
res.json({ success: true, data: updated });
});
deploymentsRouter.patch("/:deploymentId/cancel", authorize("operator"), async (req, res) => {
const user = (req as any).user;
const deploymentId = req.params.deploymentId as string;
const [updated] = await db
.update(deployments)
.set({ status: "cancelled", completedAt: new Date() })
.where(
and(
eq(deployments.id, deploymentId),
eq(deployments.organizationId, user.organizationId),
eq(deployments.status, "draft")
)
)
.returning();
if (!updated) {
res.status(404).json({
success: false,
error: { code: "NOT_FOUND", message: "Deployment not found or not cancellable" },
});
return;
}
await db
.update(deploymentTargets)
.set({ status: "cancelled" })
.where(
and(
eq(deploymentTargets.deploymentId, deploymentId),
eq(deploymentTargets.status, "pending")
)
);
res.json({ success: true, data: updated });
});
deploymentsRouter.delete("/:deploymentId", authorize("operator"), async (req, res) => {
const user = (req as any).user;
const deploymentId = req.params.deploymentId as string;
const [deleted] = await db
.delete(deployments)
.where(
and(
eq(deployments.id, deploymentId),
eq(deployments.organizationId, user.organizationId),
eq(deployments.status, "draft")
)
)
.returning();
if (!deleted) {
res.status(404).json({
success: false,
error: { code: "NOT_FOUND", message: "Deployment not found or not in draft status" },
});
return;
}
res.json({ success: true });
});
+377
View File
@@ -0,0 +1,377 @@
import { Router } from "express";
import { z } from "zod";
import { db } from "../db/index.js";
import {
devices,
inventorySnapshots,
metricSnapshots,
jobs,
jobTargets,
alerts,
complianceResults,
policies,
deviceCredentials,
} from "../db/schema.js";
import { authenticate, authorize } from "../middleware/auth.js";
import { auditLog } from "../lib/audit.js";
import { eq, and, desc, asc, ilike, count, inArray } from "drizzle-orm";
export const devicesRouter = Router();
devicesRouter.use(authenticate);
devicesRouter.get("/", authorize("read_only"), async (req, res) => {
const user = (req as any).user;
const page = parseInt(req.query.page as string) || 1;
const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
const offset = (page - 1) * limit;
const search = req.query.search as string | undefined;
const status = req.query.status as string | undefined;
const deviceType = req.query.deviceType as string | undefined;
const sort = (req.query.sort as string) || "enrolled_at";
const order = req.query.order === "asc" ? asc : desc;
let whereClause = eq(devices.organizationId, user.organizationId);
if (search) {
whereClause = and(whereClause, ilike(devices.name, `%${search}%`))!;
}
if (status) {
whereClause = and(whereClause, eq(devices.status, status))!;
}
if (deviceType) {
whereClause = and(whereClause, eq(devices.deviceType, deviceType))!;
}
const sortColumn =
sort === "name"
? devices.name
: sort === "status"
? devices.status
: sort === "os_name"
? devices.osName
: devices.enrolledAt;
const [totalResult] = await db
.select({ total: count() })
.from(devices)
.where(whereClause);
const allDevices = await db
.select()
.from(devices)
.where(whereClause)
.orderBy(order(sortColumn))
.limit(limit)
.offset(offset);
res.json({
success: true,
data: allDevices,
meta: {
page,
limit,
total: totalResult.total,
totalPages: Math.ceil(totalResult.total / limit),
},
});
});
devicesRouter.get("/:deviceId", authorize("read_only"), async (req, res) => {
const user = (req as any).user;
const deviceId = req.params.deviceId as string;
const [device] = await db
.select()
.from(devices)
.where(
and(eq(devices.id, deviceId), eq(devices.organizationId, user.organizationId))
)
.limit(1);
if (!device) {
res.status(404).json({
success: false,
error: { code: "NOT_FOUND", message: "Device not found" },
});
return;
}
res.json({ success: true, data: device });
});
devicesRouter.get(
"/:deviceId/inventory",
authorize("read_only"),
async (req, res) => {
const user = (req as any).user;
const deviceId = req.params.deviceId as string;
const [device] = await db
.select({ id: devices.id })
.from(devices)
.where(
and(
eq(devices.id, deviceId),
eq(devices.organizationId, user.organizationId)
)
)
.limit(1);
if (!device) {
res.status(404).json({
success: false,
error: { code: "NOT_FOUND", message: "Device not found" },
});
return;
}
const snapshots = await db
.select()
.from(inventorySnapshots)
.where(eq(inventorySnapshots.deviceId, deviceId))
.orderBy(desc(inventorySnapshots.reportedAt))
.limit(10);
res.json({ success: true, data: snapshots });
}
);
devicesRouter.get(
"/:deviceId/metrics",
authorize("read_only"),
async (req, res) => {
const user = (req as any).user;
const deviceId = req.params.deviceId as string;
const [device] = await db
.select({ id: devices.id })
.from(devices)
.where(
and(
eq(devices.id, deviceId),
eq(devices.organizationId, user.organizationId)
)
)
.limit(1);
if (!device) {
res.status(404).json({
success: false,
error: { code: "NOT_FOUND", message: "Device not found" },
});
return;
}
const metrics = await db
.select()
.from(metricSnapshots)
.where(eq(metricSnapshots.deviceId, deviceId))
.orderBy(desc(metricSnapshots.reportedAt))
.limit(100);
res.json({ success: true, data: metrics });
}
);
devicesRouter.patch("/:deviceId", authorize("admin"), async (req, res) => {
const user = (req as any).user;
const deviceId = req.params.deviceId as string;
const updateSchema = z.object({
name: z.string().min(1).max(255).optional(),
deviceGroupId: z.string().uuid().nullable().optional(),
tags: z.array(z.string()).optional(),
isDisabled: z.boolean().optional(),
});
try {
const body = updateSchema.parse(req.body);
const [updated] = await db
.update(devices)
.set(body)
.where(
and(eq(devices.id, deviceId), eq(devices.organizationId, user.organizationId))
)
.returning();
if (!updated) {
res.status(404).json({
success: false,
error: { code: "NOT_FOUND", message: "Device not found" },
});
return;
}
res.json({ success: true, data: updated });
} catch (err) {
if (err instanceof z.ZodError) {
res.status(400).json({
success: false,
error: { code: "VALIDATION_ERROR", message: "Invalid request body", details: err.errors },
});
return;
}
throw err;
}
});
devicesRouter.delete("/:deviceId", authorize("admin"), async (req, res) => {
const user = (req as any).user;
const deviceId = req.params.deviceId as string;
const [device] = await db
.select({ id: devices.id, name: devices.name })
.from(devices)
.where(
and(eq(devices.id, deviceId), eq(devices.organizationId, user.organizationId))
)
.limit(1);
if (!device) {
res.status(404).json({
success: false,
error: { code: "NOT_FOUND", message: "Device not found" },
});
return;
}
const relatedJobIds = await db
.select({ jobId: jobTargets.jobId })
.from(jobTargets)
.where(eq(jobTargets.deviceId, deviceId));
const jobIdList = relatedJobIds.map((r) => r.jobId);
await db.delete(alerts).where(eq(alerts.deviceId, deviceId));
await db.delete(complianceResults).where(eq(complianceResults.deviceId, deviceId));
await db.delete(metricSnapshots).where(eq(metricSnapshots.deviceId, deviceId));
await db.delete(inventorySnapshots).where(eq(inventorySnapshots.deviceId, deviceId));
await db.delete(deviceCredentials).where(eq(deviceCredentials.deviceId, deviceId));
if (jobIdList.length > 0) {
await db.delete(jobTargets).where(eq(jobTargets.deviceId, deviceId));
const remainingTargets = await db
.select({ jobId: jobTargets.jobId })
.from(jobTargets)
.where(inArray(jobTargets.jobId, jobIdList));
const remainingJobIds = new Set(remainingTargets.map((r) => r.jobId));
const orphanedJobIds = jobIdList.filter((id) => !remainingJobIds.has(id));
if (orphanedJobIds.length > 0) {
await db.delete(jobs).where(inArray(jobs.id, orphanedJobIds));
}
}
await db.delete(devices).where(eq(devices.id, deviceId));
await auditLog({
req,
organizationId: user.organizationId,
action: "device.delete",
targetType: "device",
targetId: deviceId,
deviceIds: [deviceId],
result: "success",
metadata: { name: device.name },
});
res.status(204).send();
});
devicesRouter.get("/:deviceId/jobs", authorize("read_only"), async (req, res) => {
const user = (req as any).user;
const deviceId = req.params.deviceId as string;
const [device] = await db
.select({ id: devices.id })
.from(devices)
.where(and(eq(devices.id, deviceId), eq(devices.organizationId, user.organizationId)))
.limit(1);
if (!device) {
res.status(404).json({ success: false, error: { code: "NOT_FOUND", message: "Device not found" } });
return;
}
const deviceJobs = await db
.select({
id: jobs.id,
jobType: jobs.jobType,
status: jobs.status,
parameters: jobs.parameters,
targetStatus: jobTargets.status,
targetResult: jobTargets.result,
dispatchedAt: jobs.dispatchedAt,
startedAt: jobs.startedAt,
completedAt: jobs.completedAt,
createdAt: jobs.createdAt,
})
.from(jobTargets)
.innerJoin(jobs, eq(jobTargets.jobId, jobs.id))
.where(eq(jobTargets.deviceId, deviceId))
.orderBy(desc(jobs.createdAt))
.limit(50);
res.json({ success: true, data: deviceJobs });
});
devicesRouter.get("/:deviceId/alerts", authorize("read_only"), async (req, res) => {
const user = (req as any).user;
const deviceId = req.params.deviceId as string;
const [device] = await db
.select({ id: devices.id })
.from(devices)
.where(and(eq(devices.id, deviceId), eq(devices.organizationId, user.organizationId)))
.limit(1);
if (!device) {
res.status(404).json({ success: false, error: { code: "NOT_FOUND", message: "Device not found" } });
return;
}
const deviceAlerts = await db
.select()
.from(alerts)
.where(eq(alerts.deviceId, deviceId))
.orderBy(desc(alerts.createdAt))
.limit(50);
res.json({ success: true, data: deviceAlerts });
});
devicesRouter.get("/:deviceId/compliance", authorize("read_only"), async (req, res) => {
const user = (req as any).user;
const deviceId = req.params.deviceId as string;
const [device] = await db
.select({ id: devices.id })
.from(devices)
.where(and(eq(devices.id, deviceId), eq(devices.organizationId, user.organizationId)))
.limit(1);
if (!device) {
res.status(404).json({ success: false, error: { code: "NOT_FOUND", message: "Device not found" } });
return;
}
const results = await db
.select({
id: complianceResults.id,
status: complianceResults.status,
details: complianceResults.details,
evaluatedAt: complianceResults.evaluatedAt,
policyName: policies.name,
policyId: policies.id,
})
.from(complianceResults)
.innerJoin(policies, eq(complianceResults.policyId, policies.id))
.where(eq(complianceResults.deviceId, deviceId))
.orderBy(desc(complianceResults.evaluatedAt))
.limit(50);
res.json({ success: true, data: results });
});
+126
View File
@@ -0,0 +1,126 @@
import { Router } from "express";
import { authenticate, authorize } from "../middleware/auth.js";
import fs from "fs";
import path from "path";
export const downloadsRouter = Router();
const AGENT_VERSION = "v0.1.0";
function resolveReleasesDir(): string {
const candidates = [
process.env.AGENT_RELEASES_DIR,
path.resolve(process.cwd(), "../../infrastructure/agent-releases"),
"/home/administrator/Projekte/FleetForge/infrastructure/agent-releases",
];
for (const dir of candidates) {
if (dir && fs.existsSync(dir)) return dir;
}
return candidates[candidates.length - 1]!;
}
const RELEASES_DIR = resolveReleasesDir();
console.log("[downloads] RELEASES_DIR:", RELEASES_DIR, "exists:", fs.existsSync(RELEASES_DIR));
const PLATFORMS = [
{
os: "linux",
arch: "amd64",
label: "Linux x86_64",
filename: "forge-agent-linux-amd64",
installHint: "chmod +x forge-agent-linux-amd64 && sudo mv forge-agent-linux-amd64 /usr/local/bin/forge-agent",
},
{
os: "linux",
arch: "arm64",
label: "Linux ARM64",
filename: "forge-agent-linux-arm64",
installHint: "chmod +x forge-agent-linux-arm64 && sudo mv forge-agent-linux-arm64 /usr/local/bin/forge-agent",
},
{
os: "windows",
arch: "amd64",
label: "Windows x86_64",
filename: "forge-agent-windows-amd64.exe",
installHint: "Move forge-agent-windows-amd64.exe to C:\\Program Files\\FleetForge\\forge-agent.exe",
},
{
os: "darwin",
arch: "amd64",
label: "macOS Intel",
filename: "forge-agent-darwin-amd64",
installHint: "chmod +x forge-agent-darwin-amd64 && sudo mv forge-agent-darwin-amd64 /usr/local/bin/forge-agent",
},
{
os: "darwin",
arch: "arm64",
label: "macOS Apple Silicon",
filename: "forge-agent-darwin-arm64",
installHint: "chmod +x forge-agent-darwin-arm64 && sudo mv forge-agent-darwin-arm64 /usr/local/bin/forge-agent",
},
];
downloadsRouter.use(authenticate);
downloadsRouter.get("/agent", authorize("read_only"), (_req, res) => {
const versionDir = path.join(RELEASES_DIR, AGENT_VERSION);
console.log("[downloads] RELEASES_DIR:", RELEASES_DIR);
console.log("[downloads] versionDir:", versionDir);
console.log("[downloads] exists:", fs.existsSync(versionDir));
const platforms = PLATFORMS.map((p) => {
const filePath = path.join(versionDir, p.filename);
const exists = fs.existsSync(filePath);
const size = exists ? fs.statSync(filePath).size : 0;
return {
...p,
available: exists,
size,
sizeFormatted: exists ? formatBytes(size) : "N/A",
};
});
res.json({
success: true,
data: {
version: AGENT_VERSION,
platforms,
},
});
});
downloadsRouter.get("/agent/:filename", authorize("read_only"), (req, res) => {
const filename = req.params.filename as string;
const platform = PLATFORMS.find((p) => p.filename === filename);
if (!platform) {
res.status(404).json({
success: false,
error: { code: "NOT_FOUND", message: "File not found" },
});
return;
}
const filePath = path.join(RELEASES_DIR, AGENT_VERSION, filename);
if (!fs.existsSync(filePath)) {
res.status(404).json({
success: false,
error: { code: "NOT_FOUND", message: "Binary not available for this platform" },
});
return;
}
res.setHeader("Content-Disposition", `attachment; filename="${filename}"`);
res.setHeader("Content-Length", fs.statSync(filePath).size);
res.setHeader("Content-Type", "application/octet-stream");
fs.createReadStream(filePath).pipe(res);
});
function formatBytes(bytes: number): string {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
}
+133
View File
@@ -0,0 +1,133 @@
import { Router } from "express";
import { z } from "zod";
import { db } from "../db/index.js";
import { enrollmentTokens } from "../db/schema.js";
import { authenticate, authorize } from "../middleware/auth.js";
import { generateEnrollmentToken, hashToken } from "../lib/tokens.js";
import { auditLog } from "../lib/audit.js";
import { eq, and } from "drizzle-orm";
export const enrollmentTokensRouter = Router();
enrollmentTokensRouter.use(authenticate);
enrollmentTokensRouter.get("/", authorize("admin"), async (req, res) => {
const user = (req as any).user;
const tokens = await db
.select({
id: enrollmentTokens.id,
expiresAt: enrollmentTokens.expiresAt,
maxUses: enrollmentTokens.maxUses,
useCount: enrollmentTokens.useCount,
isRevoked: enrollmentTokens.isRevoked,
label: enrollmentTokens.label,
createdAt: enrollmentTokens.createdAt,
})
.from(enrollmentTokens)
.where(eq(enrollmentTokens.organizationId, user.organizationId));
res.json({ success: true, data: tokens });
});
const createTokenSchema = z.object({
label: z.string().max(255).optional(),
expiresInHours: z.number().min(1).max(720).default(24),
maxUses: z.number().min(1).max(100).nullable().optional(),
});
enrollmentTokensRouter.post("/", authorize("admin"), async (req, res) => {
try {
const body = createTokenSchema.parse(req.body);
const authUser = (req as any).user;
const rawToken = generateEnrollmentToken();
const tokenHash = hashToken(rawToken);
const expiresAt = new Date();
expiresAt.setHours(expiresAt.getHours() + body.expiresInHours);
const [token] = await db
.insert(enrollmentTokens)
.values({
organizationId: authUser.organizationId,
tokenHash,
expiresAt,
maxUses: body.maxUses ?? null,
label: body.label || null,
createdByUserId: authUser.userId,
})
.returning({
id: enrollmentTokens.id,
expiresAt: enrollmentTokens.expiresAt,
maxUses: enrollmentTokens.maxUses,
label: enrollmentTokens.label,
createdAt: enrollmentTokens.createdAt,
});
await auditLog({
req,
organizationId: authUser.organizationId,
action: "enrollment_token.create",
targetType: "enrollment_token",
targetId: token.id,
result: "success",
});
res.status(201).json({
success: true,
data: {
...token,
token: rawToken,
},
});
} catch (err) {
if (err instanceof z.ZodError) {
res.status(400).json({
success: false,
error: { code: "VALIDATION_ERROR", message: "Invalid request body", details: err.errors },
});
return;
}
throw err;
}
});
enrollmentTokensRouter.delete(
"/:tokenId",
authorize("admin"),
async (req, res) => {
const user = (req as any).user;
const tokenId = req.params.tokenId as string;
const [revoked] = await db
.update(enrollmentTokens)
.set({ isRevoked: true })
.where(
and(
eq(enrollmentTokens.id, tokenId),
eq(enrollmentTokens.organizationId, user.organizationId)
)
)
.returning({ id: enrollmentTokens.id });
if (!revoked) {
res.status(404).json({
success: false,
error: { code: "NOT_FOUND", message: "Token not found" },
});
return;
}
await auditLog({
req,
organizationId: user.organizationId,
action: "enrollment_token.revoke",
targetType: "enrollment_token",
targetId: tokenId,
result: "success",
});
res.json({ success: true });
}
);
+88
View File
@@ -0,0 +1,88 @@
import { Router } from "express";
import { z } from "zod";
import { db } from "../db/index.js";
import { deviceGroups, devices } from "../db/schema.js";
import { authenticate, authorize } from "../middleware/auth.js";
import { eq, and } from "drizzle-orm";
export const groupsRouter = Router();
groupsRouter.use(authenticate);
groupsRouter.get("/", authorize("read_only"), async (req, res) => {
const user = (req as any).user;
const groups = await db
.select({
id: deviceGroups.id,
name: deviceGroups.name,
description: deviceGroups.description,
createdAt: deviceGroups.createdAt,
})
.from(deviceGroups)
.where(eq(deviceGroups.organizationId, user.organizationId));
res.json({ success: true, data: groups });
});
const createGroupSchema = z.object({
name: z.string().min(1).max(255),
description: z.string().optional(),
});
groupsRouter.post("/", authorize("admin"), async (req, res) => {
try {
const body = createGroupSchema.parse(req.body);
const authUser = (req as any).user;
const [group] = await db
.insert(deviceGroups)
.values({
organizationId: authUser.organizationId,
name: body.name,
description: body.description || null,
})
.returning();
res.status(201).json({ success: true, data: group });
} catch (err) {
if (err instanceof z.ZodError) {
res.status(400).json({
success: false,
error: { code: "VALIDATION_ERROR", message: "Invalid request body", details: err.errors },
});
return;
}
throw err;
}
});
groupsRouter.delete("/:groupId", authorize("admin"), async (req, res) => {
const user = (req as any).user;
const groupId = req.params.groupId as string;
await db
.update(devices)
.set({ deviceGroupId: null })
.where(eq(devices.deviceGroupId, groupId));
const [deleted] = await db
.delete(deviceGroups)
.where(
and(
eq(deviceGroups.id, groupId),
eq(deviceGroups.organizationId, user.organizationId)
)
)
.returning({ id: deviceGroups.id });
if (!deleted) {
res.status(404).json({
success: false,
error: { code: "NOT_FOUND", message: "Group not found" },
});
return;
}
res.status(204).send();
});
+216
View File
@@ -0,0 +1,216 @@
import { Router } from "express";
import { z } from "zod";
import { db } from "../db/index.js";
import { enrollmentTokens } from "../db/schema.js";
import { authenticate, authorize } from "../middleware/auth.js";
import { generateEnrollmentToken, hashToken } from "../lib/tokens.js";
import { auditLog } from "../lib/audit.js";
import { eq } from "drizzle-orm";
import { execFile } from "child_process";
import { promisify } from "util";
import fs from "fs";
import path from "path";
import os from "os";
const execFileAsync = promisify(execFile);
export const installersRouter = Router();
installersRouter.use(authenticate);
const AGENT_VERSION = "0.1.0";
const INSTALLER_DIR = path.resolve(
process.env.INSTALLER_DIR || path.join(process.cwd(), "../../infrastructure/installer")
);
const INSTALLER_CANDIDATES = [
INSTALLER_DIR,
"/home/administrator/Projekte/FleetForge/infrastructure/installer",
];
const AGENT_RELEASES_DIR = path.resolve(
process.env.AGENT_RELEASES_DIR || path.join(process.cwd(), "../../infrastructure/agent-releases")
);
const MSI_CACHE_DIR = path.resolve(
process.env.MSI_CACHE_DIR || path.join(os.tmpdir(), "fleetforge-msi-cache")
);
function getAgentBinaryPath(): string {
const candidates = [
path.join(AGENT_RELEASES_DIR, AGENT_VERSION, "forge-agent-windows-amd64.exe"),
path.join(AGENT_RELEASES_DIR, "forge-agent-windows-amd64.exe"),
"/home/administrator/Projekte/FleetForge/infrastructure/agent-releases/v0.1.0/forge-agent-windows-amd64.exe",
];
for (const p of candidates) {
if (fs.existsSync(p)) return p;
}
throw new Error("Windows agent binary not found in " + AGENT_RELEASES_DIR);
}
const generateSchema = z.object({
serverUrl: z.string().url(),
});
installersRouter.get("/", authorize("admin"), async (req, res) => {
try {
const user = (req as any).user;
const tokens = await db
.select({
id: enrollmentTokens.id,
expiresAt: enrollmentTokens.expiresAt,
maxUses: enrollmentTokens.maxUses,
useCount: enrollmentTokens.useCount,
isRevoked: enrollmentTokens.isRevoked,
label: enrollmentTokens.label,
createdAt: enrollmentTokens.createdAt,
})
.from(enrollmentTokens)
.where(eq(enrollmentTokens.organizationId, user.organizationId));
const installerFiles = fs.existsSync(MSI_CACHE_DIR)
? fs.readdirSync(MSI_CACHE_DIR).filter((f) => f.endsWith(".msi"))
: [];
res.json({
success: true,
data: {
version: AGENT_VERSION,
tokens: tokens.map((t) => ({
...t,
label: t.label || "Installer Token",
})),
cachedInstallers: installerFiles.length,
},
});
} catch (err) {
console.error("[installers] List error:", err);
res.status(500).json({
success: false,
error: { code: "INTERNAL_ERROR", message: "Failed to list installers" },
});
}
});
installersRouter.post("/generate", authorize("admin"), async (req, res) => {
try {
const user = (req as any).user;
const body = generateSchema.parse(req.body);
const serverUrl = body.serverUrl;
const rawToken = generateEnrollmentToken();
const tokenHash = hashToken(rawToken);
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + 30);
const [token] = await db
.insert(enrollmentTokens)
.values({
organizationId: user.organizationId,
tokenHash,
expiresAt,
maxUses: 1,
label: `MSI Installer - ${serverUrl}`,
createdByUserId: user.userId,
})
.returning({
id: enrollmentTokens.id,
label: enrollmentTokens.label,
createdAt: enrollmentTokens.createdAt,
});
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ff-installer-"));
try {
const configData = {
server_url: serverUrl,
enrollment_token: rawToken,
device_id: "",
credential: "",
label: "",
heartbeat_interval_seconds: 30,
};
const configPath = path.join(tmpDir, "config.json");
fs.writeFileSync(configPath, JSON.stringify(configData, null, 2));
const agentBinary = getAgentBinaryPath();
const filename = `forge-agent-${user.organizationId.slice(0, 8)}-${Date.now()}.msi`;
const outputPath = path.join(tmpDir, filename);
fs.copyFileSync(agentBinary, path.join(tmpDir, "forge-agent.exe"));
console.log(`[installers] Generating MSI for org ${user.organizationId}...`);
let wxsPath = "";
for (const dir of INSTALLER_CANDIDATES) {
const p = path.join(dir, "agent.wxs");
if (fs.existsSync(p)) { wxsPath = p; break; }
}
if (!wxsPath) {
throw new Error("WiX template not found at " + wxsPath);
}
const processedWxsPath = path.join(tmpDir, "agent.wxs");
const wxsContent = fs.readFileSync(wxsPath, "utf-8")
.replace(/__VERSION__/g, AGENT_VERSION.replace("v", ""));
fs.writeFileSync(processedWxsPath, wxsContent);
const dockerCmd = `docker run --rm -v ${tmpDir}:/work fleetforge-wixl wixl -o /work/${filename} -D AgentBinaryPath=/work/forge-agent.exe -D ConfigFilePath=/work/config.json /work/agent.wxs`;
const { stdout, stderr } = await execFileAsync(
"sg",
["docker", "-c", dockerCmd],
{
timeout: 120000,
env: { ...process.env },
}
);
if (!fs.existsSync(outputPath)) {
throw new Error("MSI generation failed. Docker output: " + (stderr || stdout));
}
const msiSize = fs.statSync(outputPath).size;
if (msiSize < 1000) {
throw new Error("Generated MSI is too small (" + msiSize + " bytes), likely corrupt");
}
console.log(`[installers] MSI generated: ${filename} (${msiSize} bytes)`);
res.setHeader("Content-Disposition", `attachment; filename="forge-agent-${user.organizationId.slice(0, 8)}.msi"`);
res.setHeader("Content-Length", msiSize);
res.setHeader("Content-Type", "application/x-msi");
fs.createReadStream(outputPath).pipe(res);
res.on("finish", () => {
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
});
await auditLog({
req,
organizationId: user.organizationId,
action: "installer.generate",
targetType: "installer",
targetId: token.id,
result: "success",
metadata: {
serverUrl,
tokenId: token.id,
filename,
fileSize: msiSize,
},
});
} catch (err) {
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
throw err;
}
} catch (err) {
if (err instanceof z.ZodError) {
res.status(400).json({
success: false,
error: { code: "VALIDATION_ERROR", message: "Invalid request body", details: err.errors },
});
return;
}
console.error("[installers] Generate error:", err);
res.status(500).json({
success: false,
error: { code: "INTERNAL_ERROR", message: `Failed to generate installer: ${(err as Error).message}` },
});
}
});
+238
View File
@@ -0,0 +1,238 @@
import { Router } from "express";
import { z } from "zod";
import { db } from "../db/index.js";
import { jobs, jobTargets, devices } from "../db/schema.js";
import { authenticate, authorize } from "../middleware/auth.js";
import { auditLog } from "../lib/audit.js";
import { eq, and, desc, count, inArray } from "drizzle-orm";
export const jobsRouter = Router();
jobsRouter.use(authenticate);
jobsRouter.get("/", authorize("read_only"), async (req, res) => {
const user = (req as any).user;
const page = parseInt(req.query.page as string) || 1;
const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
const offset = (page - 1) * limit;
const [totalResult] = await db
.select({ total: count() })
.from(jobs)
.where(eq(jobs.organizationId, user.organizationId));
const allJobs = await db
.select()
.from(jobs)
.where(eq(jobs.organizationId, user.organizationId))
.orderBy(desc(jobs.createdAt))
.limit(limit)
.offset(offset);
res.json({
success: true,
data: allJobs,
meta: {
page,
limit,
total: totalResult.total,
totalPages: Math.ceil(totalResult.total / limit),
},
});
});
const createJobSchema = z.object({
jobType: z.enum([
"update_inventory",
"update_system_info",
"check_service_status",
"start_service",
"stop_service",
"restart_service",
"install_package",
"update_package",
"remove_package",
"reboot_system",
"shutdown_system",
"start_remote_session",
"stop_remote_session",
"install_from_file",
"download_and_install",
"install_registry_package",
]),
deviceIds: z.array(z.string().uuid()).min(1),
parameters: z.record(z.unknown()).default({}),
expiresInMinutes: z.number().min(1).max(1440).optional(),
});
jobsRouter.post("/", authorize("operator"), async (req, res) => {
try {
const body = createJobSchema.parse(req.body);
const authUser = (req as any).user;
const expiresAt = body.expiresInMinutes
? new Date(Date.now() + body.expiresInMinutes * 60 * 1000)
: null;
const [job] = await db
.insert(jobs)
.values({
organizationId: authUser.organizationId,
createdByUserId: authUser.userId,
jobType: body.jobType,
parameters: body.parameters,
expiresAt,
})
.returning();
const targets = body.deviceIds.map((deviceId) => ({
jobId: job.id,
deviceId,
status: "queued" as const,
}));
await db.insert(jobTargets).values(targets);
await auditLog({
req,
organizationId: authUser.organizationId,
action: "job.create",
targetType: "job",
targetId: job.id,
deviceIds: body.deviceIds,
result: "success",
metadata: { jobType: body.jobType },
});
res.status(201).json({ success: true, data: job });
} catch (err) {
if (err instanceof z.ZodError) {
res.status(400).json({
success: false,
error: { code: "VALIDATION_ERROR", message: "Invalid request body", details: err.errors },
});
return;
}
throw err;
}
});
jobsRouter.get("/:jobId", authorize("read_only"), async (req, res) => {
const user = (req as any).user;
const jobId = req.params.jobId as string;
const [job] = await db
.select()
.from(jobs)
.where(and(eq(jobs.id, jobId), eq(jobs.organizationId, user.organizationId)))
.limit(1);
if (!job) {
res.status(404).json({
success: false,
error: { code: "NOT_FOUND", message: "Job not found" },
});
return;
}
const targets = await db
.select({
id: jobTargets.id,
jobId: jobTargets.jobId,
deviceId: jobTargets.deviceId,
deviceName: devices.name,
status: jobTargets.status,
result: jobTargets.result,
startedAt: jobTargets.startedAt,
completedAt: jobTargets.completedAt,
createdAt: jobTargets.createdAt,
})
.from(jobTargets)
.leftJoin(devices, eq(jobTargets.deviceId, devices.id))
.where(eq(jobTargets.jobId, jobId));
res.json({ success: true, data: { ...job, targets } });
});
jobsRouter.patch("/:jobId/cancel", authorize("operator"), async (req, res) => {
const user = (req as any).user;
const jobId = req.params.jobId as string;
const [updated] = await db
.update(jobs)
.set({ status: "cancelled", completedAt: new Date() })
.where(
and(
eq(jobs.id, jobId),
eq(jobs.organizationId, user.organizationId),
inArray(jobs.status, ["queued", "dispatched", "running"])
)
)
.returning({ id: jobs.id });
if (!updated) {
res.status(404).json({
success: false,
error: { code: "NOT_FOUND", message: "Job not found or not cancellable" },
});
return;
}
await db
.update(jobTargets)
.set({ status: "cancelled", completedAt: new Date() })
.where(
and(
eq(jobTargets.jobId, jobId),
inArray(jobTargets.status, ["queued", "dispatched", "running"])
)
);
await auditLog({
req,
organizationId: user.organizationId,
action: "job.cancel",
targetType: "job",
targetId: jobId,
result: "success",
metadata: { message: "Job cancelled" },
});
res.json({ success: true });
});
jobsRouter.delete("/:jobId", authorize("operator"), async (req, res) => {
const user = (req as any).user;
const jobId = req.params.jobId as string;
const [existing] = await db
.select({ id: jobs.id })
.from(jobs)
.where(
and(eq(jobs.id, jobId), eq(jobs.organizationId, user.organizationId))
)
.limit(1);
if (!existing) {
res.status(404).json({
success: false,
error: { code: "NOT_FOUND", message: "Job not found" },
});
return;
}
await db.delete(jobTargets).where(eq(jobTargets.jobId, jobId));
await db.delete(jobs).where(eq(jobs.id, jobId));
await auditLog({
req,
organizationId: user.organizationId,
action: "job.delete",
targetType: "job",
targetId: jobId,
result: "success",
metadata: { message: "Job deleted" },
});
res.json({ success: true });
});
+201
View File
@@ -0,0 +1,201 @@
import { Router } from "express";
import { z } from "zod";
import { db } from "../db/index.js";
import { policies, complianceResults } from "../db/schema.js";
import { authenticate, authorize } from "../middleware/auth.js";
import { eq, and, desc } from "drizzle-orm";
export const policiesRouter = Router();
policiesRouter.use(authenticate);
policiesRouter.get("/", authorize("read_only"), async (req, res) => {
const user = (req as any).user;
const allPolicies = await db
.select()
.from(policies)
.where(eq(policies.organizationId, user.organizationId));
res.json({ success: true, data: allPolicies });
});
const policyRuleSchema = z.object({
type: z.string(),
condition: z.string(),
value: z.union([z.string(), z.number(), z.boolean()]),
});
const createPolicySchema = z.object({
name: z.string().min(1).max(255),
description: z.string().optional(),
rules: z.array(policyRuleSchema).default([]),
isEnabled: z.boolean().default(true),
});
const updatePolicySchema = z.object({
name: z.string().min(1).max(255).optional(),
description: z.string().nullable().optional(),
rules: z.array(policyRuleSchema).optional(),
isEnabled: z.boolean().optional(),
});
policiesRouter.get("/:policyId", authorize("read_only"), async (req, res) => {
const user = (req as any).user;
const policyId = req.params.policyId as string;
const [policy] = await db
.select()
.from(policies)
.where(
and(eq(policies.id, policyId), eq(policies.organizationId, user.organizationId))
);
if (!policy) {
res.status(404).json({
success: false,
error: { code: "NOT_FOUND", message: "Policy not found" },
});
return;
}
res.json({ success: true, data: policy });
});
policiesRouter.post("/", authorize("admin"), async (req, res) => {
try {
const body = createPolicySchema.parse(req.body);
const authUser = (req as any).user;
const [policy] = await db
.insert(policies)
.values({
organizationId: authUser.organizationId,
name: body.name,
description: body.description || null,
rules: body.rules,
isEnabled: body.isEnabled,
})
.returning();
res.status(201).json({ success: true, data: policy });
} catch (err) {
if (err instanceof z.ZodError) {
res.status(400).json({
success: false,
error: { code: "VALIDATION_ERROR", message: "Invalid request body", details: err.errors },
});
return;
}
throw err;
}
});
policiesRouter.put("/:policyId", authorize("admin"), async (req, res) => {
try {
const user = (req as any).user;
const policyId = req.params.policyId as string;
const body = updatePolicySchema.parse(req.body);
const [existing] = await db
.select()
.from(policies)
.where(
and(eq(policies.id, policyId), eq(policies.organizationId, user.organizationId))
);
if (!existing) {
res.status(404).json({
success: false,
error: { code: "NOT_FOUND", message: "Policy not found" },
});
return;
}
const updates: Record<string, unknown> = {};
if (body.name !== undefined) updates.name = body.name;
if (body.description !== undefined) updates.description = body.description;
if (body.rules !== undefined) updates.rules = body.rules;
if (body.isEnabled !== undefined) updates.isEnabled = body.isEnabled;
if (Object.keys(updates).length === 0) {
res.json({ success: true, data: existing });
return;
}
updates.updatedAt = new Date();
const [updated] = await db
.update(policies)
.set(updates)
.where(
and(eq(policies.id, policyId), eq(policies.organizationId, user.organizationId))
)
.returning();
res.json({ success: true, data: updated });
} catch (err) {
if (err instanceof z.ZodError) {
res.status(400).json({
success: false,
error: { code: "VALIDATION_ERROR", message: "Invalid request body", details: err.errors },
});
return;
}
throw err;
}
});
policiesRouter.delete("/:policyId", authorize("admin"), async (req, res) => {
const user = (req as any).user;
const policyId = req.params.policyId as string;
const [deleted] = await db
.delete(policies)
.where(
and(eq(policies.id, policyId), eq(policies.organizationId, user.organizationId))
)
.returning({ id: policies.id });
if (!deleted) {
res.status(404).json({
success: false,
error: { code: "NOT_FOUND", message: "Policy not found" },
});
return;
}
res.status(204).send();
});
policiesRouter.get(
"/:policyId/compliance",
authorize("read_only"),
async (req, res) => {
const user = (req as any).user;
const policyId = req.params.policyId as string;
const [policy] = await db
.select()
.from(policies)
.where(
and(eq(policies.id, policyId), eq(policies.organizationId, user.organizationId))
);
if (!policy) {
res.status(404).json({
success: false,
error: { code: "NOT_FOUND", message: "Policy not found" },
});
return;
}
const results = await db
.select()
.from(complianceResults)
.where(eq(complianceResults.policyId, policyId))
.orderBy(desc(complianceResults.evaluatedAt));
res.json({ success: true, data: results });
}
);
+274
View File
@@ -0,0 +1,274 @@
import { Router } from "express";
import { z } from "zod";
import { db } from "../db/index.js";
import {
remoteSessions,
jobs,
jobTargets,
devices,
} from "../db/schema.js";
import { authenticate, authorize } from "../middleware/auth.js";
import { auditLog } from "../lib/audit.js";
import { eq, and, desc } from "drizzle-orm";
import { randomBytes } from "crypto";
export const remoteRouter = Router();
remoteRouter.use(authenticate);
const createSessionSchema = z.object({
deviceId: z.string().uuid(),
vncPort: z.number().int().min(1).max(65535).default(5900),
});
const ERROR_MSG = (code: string, message: string) =>
({ success: false, error: { code, message } });
remoteRouter.post("/sessions", authorize("operator"), async (req, res) => {
try {
const body = createSessionSchema.parse(req.body);
const user = (req as any).user;
const [device] = await db
.select()
.from(devices)
.where(
and(eq(devices.id, body.deviceId), eq(devices.organizationId, user.organizationId))
)
.limit(1);
if (!device) {
res.status(404).json(ERROR_MSG("NOT_FOUND", "Device not found"));
return;
}
await db
.update(remoteSessions)
.set({ status: "closed", endedAt: new Date() })
.where(
and(
eq(remoteSessions.deviceId, body.deviceId),
eq(remoteSessions.status, "pending")
)
);
await db
.update(remoteSessions)
.set({ status: "closed", endedAt: new Date() })
.where(
and(
eq(remoteSessions.deviceId, body.deviceId),
eq(remoteSessions.status, "starting")
)
);
await db
.update(remoteSessions)
.set({ status: "closed", endedAt: new Date() })
.where(
and(
eq(remoteSessions.deviceId, body.deviceId),
eq(remoteSessions.status, "active")
)
);
await db
.update(remoteSessions)
.set({ status: "closed", endedAt: new Date() })
.where(
and(
eq(remoteSessions.deviceId, body.deviceId),
eq(remoteSessions.status, "closing")
)
);
const vncPassword = randomBytes(4).toString("hex");
const [session] = await db
.insert(remoteSessions)
.values({
organizationId: user.organizationId,
deviceId: body.deviceId,
createdByUserId: user.userId,
status: "pending",
vncPort: body.vncPort,
vncPassword,
})
.returning();
const [job] = await db
.insert(jobs)
.values({
organizationId: user.organizationId,
createdByUserId: user.userId,
jobType: "start_remote_session",
parameters: {
session_id: session.id,
vnc_port: body.vncPort,
vnc_password: vncPassword,
},
status: "dispatched",
dispatchedAt: new Date(),
})
.returning();
await db.insert(jobTargets).values({
jobId: job.id,
deviceId: body.deviceId,
status: "dispatched",
});
await db
.update(remoteSessions)
.set({ jobId: job.id })
.where(eq(remoteSessions.id, session.id));
await auditLog({
req,
organizationId: user.organizationId,
action: "remote.session.create",
targetType: "remote_session",
targetId: session.id,
deviceIds: [body.deviceId],
result: "success",
metadata: { vncPort: body.vncPort },
});
res.status(201).json({
success: true,
data: {
id: session.id,
deviceId: session.deviceId,
status: session.status,
vncPort: session.vncPort,
vncPassword: session.vncPassword,
},
});
} catch (err) {
if (err instanceof z.ZodError) {
res.status(400).json(ERROR_MSG("VALIDATION_ERROR", "Invalid request body"));
return;
}
throw err;
}
});
remoteRouter.get("/sessions", authorize("read_only"), async (req, res) => {
const user = (req as any).user;
const all = await db
.select({
id: remoteSessions.id,
deviceId: remoteSessions.deviceId,
status: remoteSessions.status,
vncPort: remoteSessions.vncPort,
startedAt: remoteSessions.startedAt,
endedAt: remoteSessions.endedAt,
createdAt: remoteSessions.createdAt,
deviceName: devices.name,
})
.from(remoteSessions)
.innerJoin(devices, eq(devices.id, remoteSessions.deviceId))
.where(eq(remoteSessions.organizationId, user.organizationId))
.orderBy(desc(remoteSessions.createdAt))
.limit(50);
res.json({ success: true, data: all });
});
remoteRouter.get("/sessions/:sessionId", authorize("read_only"), async (req, res) => {
const user = (req as any).user;
const sessionId = req.params.sessionId as string;
const [row] = await db
.select({
id: remoteSessions.id,
deviceId: remoteSessions.deviceId,
status: remoteSessions.status,
vncPort: remoteSessions.vncPort,
vncPassword: remoteSessions.vncPassword,
errorMessage: remoteSessions.errorMessage,
startedAt: remoteSessions.startedAt,
endedAt: remoteSessions.endedAt,
createdAt: remoteSessions.createdAt,
deviceName: devices.name,
})
.from(remoteSessions)
.innerJoin(devices, eq(devices.id, remoteSessions.deviceId))
.where(
and(
eq(remoteSessions.id, sessionId),
eq(remoteSessions.organizationId, user.organizationId)
)
)
.limit(1);
if (!row) {
res.status(404).json(ERROR_MSG("NOT_FOUND", "Session not found"));
return;
}
res.json({ success: true, data: row });
});
remoteRouter.delete("/sessions/:sessionId", authorize("operator"), async (req, res) => {
const user = (req as any).user;
const sessionId = req.params.sessionId as string;
const [session] = await db
.select()
.from(remoteSessions)
.where(
and(
eq(remoteSessions.id, sessionId),
eq(remoteSessions.organizationId, user.organizationId)
)
)
.limit(1);
if (!session) {
res.status(404).json(ERROR_MSG("NOT_FOUND", "Session not found"));
return;
}
if (session.status === "active" || session.status === "starting" || session.status === "pending") {
const [job] = await db
.insert(jobs)
.values({
organizationId: user.organizationId,
createdByUserId: user.userId,
jobType: "stop_remote_session",
parameters: { session_id: session.id },
status: "dispatched",
dispatchedAt: new Date(),
})
.returning();
await db.insert(jobTargets).values({
jobId: job.id,
deviceId: session.deviceId,
status: "dispatched",
});
await db
.update(remoteSessions)
.set({ status: "closing" })
.where(eq(remoteSessions.id, session.id));
} else {
await db
.update(remoteSessions)
.set({ status: "closed", endedAt: new Date() })
.where(eq(remoteSessions.id, session.id));
}
await auditLog({
req,
organizationId: user.organizationId,
action: "remote.session.delete",
targetType: "remote_session",
targetId: session.id,
deviceIds: [session.deviceId],
result: "success",
});
res.json({ success: true, data: { id: session.id, status: "closing" } });
});
export { remoteSessions };
+414
View File
@@ -0,0 +1,414 @@
import { Router } from "express";
import multer from "multer";
import path from "path";
import fs from "fs";
import crypto from "crypto";
import { fileURLToPath } from "url";
import { db } from "../db/index.js";
import { devices, softwarePackages } from "../db/schema.js";
import { authenticate, authorize } from "../middleware/auth.js";
import { eq, and, sql, desc, ilike, count } from "drizzle-orm";
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")
);
if (!fs.existsSync(SOFTWARE_DIR)) {
fs.mkdirSync(SOFTWARE_DIR, { recursive: true });
}
const ALLOWED_EXTENSIONS: Record<string, string[]> = {
windows: [".msi", ".exe", ".msix", ".appx"],
linux: [".deb", ".rpm", ".tar.gz", ".tar.xz"],
macos: [".dmg", ".pkg", ".tar.gz"],
};
const storage = multer.diskStorage({
destination: (_req, _file, cb) => {
const orgDir = path.join(SOFTWARE_DIR, (_req as any).user?.organizationId || "default");
fs.mkdirSync(orgDir, { recursive: true });
cb(null, orgDir);
},
filename: (_req, file, cb) => {
const unique = crypto.randomBytes(8).toString("hex");
const ext = path.extname(file.originalname);
cb(null, `${Date.now()}-${unique}${ext}`);
},
});
const upload = multer({
storage,
limits: { fileSize: 2 * 1024 * 1024 * 1024 },
fileFilter: (_req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase();
const allAllowed = Object.values(ALLOWED_EXTENSIONS).flat();
if (allAllowed.includes(ext)) {
cb(null, true);
} else {
cb(new Error(`File type ${ext} not allowed. Supported: ${allAllowed.join(", ")}`));
}
},
});
export const softwareRouter = Router();
softwareRouter.use(authenticate);
softwareRouter.get("/inventory", authorize("read_only"), async (req, res) => {
const user = (req as any).user;
const packageName = req.query.package as string | undefined;
const { rows: latestSnapshots } = await db.execute(sql`
SELECT DISTINCT ON (device_id) device_id, packages
FROM inventory_snapshots
WHERE device_id IN (
SELECT id FROM devices
WHERE organization_id = ${user.organizationId}
AND is_disabled = false AND is_archived = false
)
ORDER BY device_id, reported_at DESC
`);
const packageMap = new Map<string, {
name: string;
version: string;
manager: string;
devices: Array<{ deviceId: string; deviceName: string }>;
}>();
const { rows: deviceNames } = await db.execute(sql`
SELECT id, name FROM devices
WHERE organization_id = ${user.organizationId}
`);
const nameMap = new Map<string, string>();
for (const d of deviceNames as any[]) {
nameMap.set(d.id, d.name);
}
for (const snapshot of latestSnapshots as any[]) {
const pkgs = (snapshot.packages || []) as any[];
for (const pkg of pkgs) {
if (packageName && pkg.name !== packageName) continue;
const key = `${pkg.name}@${pkg.version}@${pkg.manager}`;
if (packageMap.has(key)) {
packageMap.get(key)!.devices.push({
deviceId: snapshot.device_id,
deviceName: nameMap.get(snapshot.device_id) || snapshot.device_id,
});
} else {
packageMap.set(key, {
name: pkg.name,
version: pkg.version,
manager: pkg.manager,
devices: [{
deviceId: snapshot.device_id,
deviceName: nameMap.get(snapshot.device_id) || snapshot.device_id,
}],
});
}
}
}
const packages = Array.from(packageMap.values()).sort((a, b) =>
a.name.localeCompare(b.name)
);
const uniqueNames = new Set(packages.map((p) => p.name));
const summary = Array.from(uniqueNames).map((name) => {
const versions = packages.filter((p) => p.name === name);
return {
name,
versions: versions.map((v) => ({
version: v.version,
manager: v.manager,
deviceCount: v.devices.length,
devices: v.devices,
})),
totalDevices: versions.reduce((sum, v) => sum + v.devices.length, 0),
};
});
res.json({ success: true, data: summary });
});
softwareRouter.get("/inventory/devices/:deviceId", authorize("read_only"), async (req, res) => {
const user = (req as any).user;
const deviceId = req.params.deviceId as string;
const [device] = await db
.select({ id: devices.id, name: devices.name })
.from(devices)
.where(
and(
eq(devices.id, deviceId),
eq(devices.organizationId, user.organizationId)
)
)
.limit(1);
if (!device) {
res.status(404).json({
success: false,
error: { code: "NOT_FOUND", message: "Device not found" },
});
return;
}
const { rows: snapshots } = await db.execute(sql`
SELECT packages, services, reported_at
FROM inventory_snapshots
WHERE device_id = ${deviceId}
ORDER BY reported_at DESC
LIMIT 1
`);
const latest = (snapshots as any[])[0] || null;
res.json({
success: true,
data: {
deviceId: device.id,
deviceName: device.name,
packages: latest?.packages || [],
services: latest?.services || [],
reportedAt: latest?.reported_at || null,
},
});
});
softwareRouter.get("/services", authorize("read_only"), async (req, res) => {
const user = (req as any).user;
const { rows: latestSnapshots } = await db.execute(sql`
SELECT DISTINCT ON (device_id) device_id, services
FROM inventory_snapshots
WHERE device_id IN (
SELECT id FROM devices
WHERE organization_id = ${user.organizationId}
AND is_disabled = false AND is_archived = false
)
ORDER BY device_id, reported_at DESC
`);
const serviceMap = new Map<string, {
name: string;
status: string;
enabled: boolean;
devices: Array<{ deviceId: string; deviceName: string }>;
}>();
const { rows: deviceNames } = await db.execute(sql`
SELECT id, name FROM devices
WHERE organization_id = ${user.organizationId}
`);
const nameMap = new Map<string, string>();
for (const d of deviceNames as any[]) {
nameMap.set(d.id, d.name);
}
for (const snapshot of latestSnapshots as any[]) {
const svcs = (snapshot.services || []) as any[];
for (const svc of svcs) {
const key = `${svc.name}@${svc.status}`;
if (serviceMap.has(key)) {
serviceMap.get(key)!.devices.push({
deviceId: snapshot.device_id,
deviceName: nameMap.get(snapshot.device_id) || snapshot.device_id,
});
} else {
serviceMap.set(key, {
name: svc.name,
status: svc.status,
enabled: svc.enabled,
devices: [{
deviceId: snapshot.device_id,
deviceName: nameMap.get(snapshot.device_id) || snapshot.device_id,
}],
});
}
}
}
const services = Array.from(serviceMap.values()).sort((a, b) =>
a.name.localeCompare(b.name)
);
res.json({ success: true, data: services });
});
softwareRouter.get("/repository", authorize("read_only"), async (req, res) => {
const user = (req as any).user;
const page = parseInt(req.query.page as string) || 1;
const limit = Math.min(parseInt(req.query.limit as string) || 50, 100);
const offset = (page - 1) * limit;
const search = req.query.search as string | undefined;
const platform = req.query.platform as string | undefined;
let whereClause = eq(softwarePackages.organizationId, user.organizationId);
if (search) {
whereClause = and(whereClause, ilike(softwarePackages.name, `%${search}%`))!;
}
if (platform) {
whereClause = and(whereClause, eq(softwarePackages.platform, platform))!;
}
const [totalResult] = await db
.select({ total: count() })
.from(softwarePackages)
.where(whereClause);
const pkgs = await db
.select()
.from(softwarePackages)
.where(whereClause)
.orderBy(desc(softwarePackages.createdAt))
.limit(limit)
.offset(offset);
res.json({
success: true,
data: pkgs,
meta: { page, limit, total: totalResult.total, totalPages: Math.ceil(totalResult.total / limit) },
});
});
softwareRouter.post("/repository", authorize("admin"), upload.single("file"), async (req, res) => {
try {
const user = (req as any).user;
const file = req.file;
if (!file) {
res.status(400).json({
success: false,
error: { code: "VALIDATION_ERROR", message: "No file uploaded" },
});
return;
}
const body = req.body;
const name = body.name || path.basename(file.originalname, path.extname(file.originalname));
const version = body.version || "1.0.0";
const packageType = body.package_type || path.extname(file.originalname).replace(".", "");
const platform = body.platform || detectPlatform(file.originalname);
const architecture = body.architecture || "amd64";
const checksum = crypto.createHash("sha256").update(fs.readFileSync(file.path)).digest("hex");
const [pkg] = await db
.insert(softwarePackages)
.values({
organizationId: user.organizationId,
name,
description: body.description || null,
version,
packageType,
platform,
architecture,
filename: file.filename,
fileSize: file.size,
checksum,
installCommand: body.install_command || null,
tags: body.tags ? JSON.parse(body.tags) : [],
uploadedByUserId: user.userId,
})
.returning();
res.status(201).json({ success: true, data: pkg });
} catch (err: any) {
console.error("[software/upload] Error:", err);
res.status(500).json({
success: false,
error: { code: "INTERNAL_ERROR", message: err.message || "Upload failed" },
});
}
});
softwareRouter.get("/repository/:packageId/download", authorize("read_only"), async (req, res) => {
const user = (req as any).user;
const packageId = req.params.packageId as string;
const [pkg] = await db
.select()
.from(softwarePackages)
.where(
and(
eq(softwarePackages.id, packageId),
eq(softwarePackages.organizationId, user.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, user.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.name}-${pkg.version}${path.extname(pkg.filename)}"`);
res.setHeader("Content-Type", "application/octet-stream");
res.setHeader("Content-Length", pkg.fileSize);
fs.createReadStream(filePath).pipe(res);
});
softwareRouter.delete("/repository/:packageId", authorize("admin"), async (req, res) => {
const user = (req as any).user;
const packageId = req.params.packageId as string;
const [pkg] = await db
.select()
.from(softwarePackages)
.where(
and(
eq(softwarePackages.id, packageId),
eq(softwarePackages.organizationId, user.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, user.organizationId);
const filePath = path.join(orgDir, pkg.filename);
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
await db
.delete(softwarePackages)
.where(eq(softwarePackages.id, packageId));
res.status(204).send();
});
function detectPlatform(filename: string): string {
const ext = path.extname(filename).toLowerCase();
if ([".msi", ".exe", ".msix", ".appx"].includes(ext)) return "windows";
if ([".deb", ".rpm"].includes(ext)) return "linux";
if ([".dmg", ".pkg"].includes(ext)) return "macos";
return "unknown";
}
+340
View File
@@ -0,0 +1,340 @@
import { Router } from "express";
import { z } from "zod";
import { db } from "../db/index.js";
import { updateRequests, devices, users, deployments, deploymentTargets } from "../db/schema.js";
import { authenticate, authorize } from "../middleware/auth.js";
import { auditLog } from "../lib/audit.js";
import { eq, and, desc, count } from "drizzle-orm";
export const updateRequestsRouter = Router();
updateRequestsRouter.use(authenticate);
updateRequestsRouter.get("/", authorize("read_only"), async (req, res) => {
const user = (req as any).user;
const page = parseInt(req.query.page as string) || 1;
const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
const offset = (page - 1) * limit;
const status = req.query.status as string | undefined;
const whereConditions = [eq(updateRequests.organizationId, user.organizationId)];
if (status) {
whereConditions.push(eq(updateRequests.status, status));
}
const [totalResult] = await db
.select({ total: count() })
.from(updateRequests)
.where(and(...whereConditions));
const requests = await db
.select({
id: updateRequests.id,
organizationId: updateRequests.organizationId,
deviceId: updateRequests.deviceId,
packageName: updateRequests.packageName,
packageManager: updateRequests.packageManager,
currentVersion: updateRequests.currentVersion,
targetVersion: updateRequests.targetVersion,
requestedByUserId: updateRequests.requestedByUserId,
status: updateRequests.status,
approvedByUserId: updateRequests.approvedByUserId,
approvedAt: updateRequests.approvedAt,
rejectionReason: updateRequests.rejectionReason,
deploymentId: updateRequests.deploymentId,
notes: updateRequests.notes,
createdAt: updateRequests.createdAt,
updatedAt: updateRequests.updatedAt,
deviceName: devices.name,
requestedByName: users.displayName,
})
.from(updateRequests)
.leftJoin(devices, eq(updateRequests.deviceId, devices.id))
.leftJoin(users, eq(updateRequests.requestedByUserId, users.id))
.where(and(...whereConditions))
.orderBy(desc(updateRequests.createdAt))
.limit(limit)
.offset(offset);
res.json({
success: true,
data: requests,
meta: {
page,
limit,
total: totalResult.total,
totalPages: Math.ceil(totalResult.total / limit),
},
});
});
const createRequestSchema = z.object({
deviceId: z.string().uuid(),
packageName: z.string().min(1).max(255),
packageManager: z.string().default("apt"),
currentVersion: z.string().max(100).optional(),
targetVersion: z.string().min(1).max(100),
notes: z.string().optional(),
});
updateRequestsRouter.post("/", authorize("operator"), async (req, res) => {
try {
const body = createRequestSchema.parse(req.body);
const authUser = (req as any).user;
const [device] = await db
.select({ id: devices.id, organizationId: devices.organizationId })
.from(devices)
.where(
and(
eq(devices.id, body.deviceId),
eq(devices.organizationId, authUser.organizationId)
)
)
.limit(1);
if (!device) {
res.status(404).json({
success: false,
error: { code: "NOT_FOUND", message: "Device not found" },
});
return;
}
const [request] = await db
.insert(updateRequests)
.values({
organizationId: authUser.organizationId,
deviceId: body.deviceId,
packageName: body.packageName,
packageManager: body.packageManager,
currentVersion: body.currentVersion || null,
targetVersion: body.targetVersion,
requestedByUserId: authUser.userId,
notes: body.notes || null,
})
.returning();
await auditLog({
req,
organizationId: authUser.organizationId,
action: "update_request.create",
targetType: "update_request",
targetId: request.id,
deviceIds: [body.deviceId],
result: "success",
metadata: {
packageName: body.packageName,
targetVersion: body.targetVersion,
},
});
res.status(201).json({ success: true, data: request });
} catch (err) {
if (err instanceof z.ZodError) {
res.status(400).json({
success: false,
error: { code: "VALIDATION_ERROR", message: "Invalid request body", details: err.errors },
});
return;
}
throw err;
}
});
const approveSchema = z.object({
deployNow: z.boolean().default(false),
});
updateRequestsRouter.patch("/:requestId/approve", authorize("operator"), async (req, res) => {
try {
const body = approveSchema.parse(req.body);
const user = (req as any).user;
const requestId = req.params.requestId as string;
const [existing] = await db
.select()
.from(updateRequests)
.where(
and(
eq(updateRequests.id, requestId),
eq(updateRequests.organizationId, user.organizationId),
eq(updateRequests.status, "pending")
)
)
.limit(1);
if (!existing) {
res.status(404).json({
success: false,
error: { code: "NOT_FOUND", message: "Request not found or not pending" },
});
return;
}
const [updated] = await db
.update(updateRequests)
.set({
status: "approved",
approvedByUserId: user.userId,
approvedAt: new Date(),
})
.where(eq(updateRequests.id, requestId))
.returning();
let deploymentId = null;
if (body.deployNow) {
const [deployment] = await db
.insert(deployments)
.values({
organizationId: user.organizationId,
name: `Update ${existing.packageName} to ${existing.targetVersion}`,
description: `Approved update for ${existing.packageName} on device ${existing.deviceId}`,
jobType: "update_package",
parameters: {
package_name: existing.packageName,
target_version: existing.targetVersion,
manager: existing.packageManager,
},
status: "approved",
createdByUserId: user.userId,
approvedByUserId: user.userId,
approvedAt: new Date(),
totalTargets: 1,
})
.returning();
await db.insert(deploymentTargets).values({
deploymentId: deployment.id,
deviceId: existing.deviceId,
status: "pending",
});
await db
.update(updateRequests)
.set({ deploymentId: deployment.id })
.where(eq(updateRequests.id, requestId));
deploymentId = deployment.id;
}
await auditLog({
req,
organizationId: user.organizationId,
action: "update_request.approve",
targetType: "update_request",
targetId: requestId,
deviceIds: [existing.deviceId],
result: "success",
metadata: {
packageName: existing.packageName,
deployNow: body.deployNow,
deploymentId,
},
});
res.json({ success: true, data: { ...updated, deploymentId } });
} catch (err) {
if (err instanceof z.ZodError) {
res.status(400).json({
success: false,
error: { code: "VALIDATION_ERROR", message: "Invalid request body", details: err.errors },
});
return;
}
throw err;
}
});
const rejectSchema = z.object({
reason: z.string().min(1).max(500),
});
updateRequestsRouter.patch("/:requestId/reject", authorize("operator"), async (req, res) => {
try {
const body = rejectSchema.parse(req.body);
const user = (req as any).user;
const requestId = req.params.requestId as string;
const [existing] = await db
.select()
.from(updateRequests)
.where(
and(
eq(updateRequests.id, requestId),
eq(updateRequests.organizationId, user.organizationId),
eq(updateRequests.status, "pending")
)
)
.limit(1);
if (!existing) {
res.status(404).json({
success: false,
error: { code: "NOT_FOUND", message: "Request not found or not pending" },
});
return;
}
const [updated] = await db
.update(updateRequests)
.set({
status: "rejected",
rejectionReason: body.reason,
})
.where(eq(updateRequests.id, requestId))
.returning();
await auditLog({
req,
organizationId: user.organizationId,
action: "update_request.reject",
targetType: "update_request",
targetId: requestId,
deviceIds: [existing.deviceId],
result: "success",
metadata: {
packageName: existing.packageName,
reason: body.reason,
},
});
res.json({ success: true, data: updated });
} catch (err) {
if (err instanceof z.ZodError) {
res.status(400).json({
success: false,
error: { code: "VALIDATION_ERROR", message: "Invalid request body", details: err.errors },
});
return;
}
throw err;
}
});
updateRequestsRouter.patch("/:requestId/cancel", authorize("operator"), async (req, res) => {
const user = (req as any).user;
const requestId = req.params.requestId as string;
const [updated] = await db
.update(updateRequests)
.set({ status: "cancelled" })
.where(
and(
eq(updateRequests.id, requestId),
eq(updateRequests.organizationId, user.organizationId),
eq(updateRequests.status, "pending")
)
)
.returning();
if (!updated) {
res.status(404).json({
success: false,
error: { code: "NOT_FOUND", message: "Request not found or not cancellable" },
});
return;
}
res.json({ success: true, data: updated });
});
+102
View File
@@ -0,0 +1,102 @@
import { Router } from "express";
import { z } from "zod";
import bcrypt from "bcrypt";
import { db } from "../db/index.js";
import { users } from "../db/schema.js";
import { authenticate, authorize } from "../middleware/auth.js";
import { auditLog } from "../lib/audit.js";
import { eq, and } from "drizzle-orm";
export const usersRouter = Router();
usersRouter.use(authenticate);
const createUserSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
displayName: z.string().min(1).max(255),
role: z.enum(["admin", "operator", "read_only"]),
});
usersRouter.get("/", authorize("admin"), async (req, res) => {
const user = (req as any).user;
const allUsers = await db
.select({
id: users.id,
email: users.email,
displayName: users.displayName,
role: users.role,
isActive: users.isActive,
lastLoginAt: users.lastLoginAt,
createdAt: users.createdAt,
})
.from(users)
.where(eq(users.organizationId, user.organizationId));
res.json({ success: true, data: allUsers });
});
usersRouter.post("/", authorize("admin"), async (req, res) => {
try {
const body = createUserSchema.parse(req.body);
const authUser = (req as any).user;
const passwordHash = await bcrypt.hash(body.password, 12);
const [newUser] = await db
.insert(users)
.values({
organizationId: authUser.organizationId,
email: body.email,
passwordHash,
displayName: body.displayName,
role: body.role,
})
.returning({
id: users.id,
email: users.email,
displayName: users.displayName,
role: users.role,
});
await auditLog({
req,
organizationId: authUser.organizationId,
action: "user.create",
targetType: "user",
targetId: newUser.id,
result: "success",
});
res.status(201).json({ success: true, data: newUser });
} catch (err) {
if (err instanceof z.ZodError) {
res.status(400).json({
success: false,
error: { code: "VALIDATION_ERROR", message: "Invalid request body", details: err.errors },
});
return;
}
throw err;
}
});
usersRouter.patch("/:userId/deactivate", authorize("admin"), async (req, res) => {
const authUser = (req as any).user;
const userId = req.params.userId as string;
await db
.update(users)
.set({ isActive: false })
.where(and(eq(users.id, userId), eq(users.organizationId, authUser.organizationId)));
await auditLog({
req,
organizationId: authUser.organizationId,
action: "user.deactivate",
targetType: "user",
targetId: userId,
result: "success",
});
res.json({ success: true });
});
+197
View File
@@ -0,0 +1,197 @@
import type { Server } from "http";
import { WebSocket, WebSocketServer } from "ws";
import { db } from "../db/index.js";
import { remoteSessions, deviceCredentials, devices } from "../db/schema.js";
import { eq, and } from "drizzle-orm";
import { hashCredential } from "../lib/tokens.js";
import { verifyToken } from "../middleware/auth.js";
const agents = new Map<string, WebSocket>();
const browsers = new Map<string, WebSocket>();
const pairs = new Set<string>();
async function closeSession(sessionId: string) {
await db
.update(remoteSessions)
.set({ status: "closed", endedAt: new Date() })
.where(eq(remoteSessions.id, sessionId));
}
function pair(sessionId: string) {
const agent = agents.get(sessionId);
const browser = browsers.get(sessionId);
if (!agent || !browser || pairs.has(sessionId)) return;
pairs.add(sessionId);
const teardown = async (origin: "agent" | "browser") => {
pairs.delete(sessionId);
agents.delete(sessionId);
browsers.delete(sessionId);
if (agent.readyState === WebSocket.OPEN) agent.close(1011, `${origin} disconnected`);
if (browser.readyState === WebSocket.OPEN) browser.close(1011, `${origin} disconnected`);
closeSession(sessionId).catch((err) => console.error("[remote] closeSession error:", err));
};
agent.on("close", () => teardown("agent"));
agent.on("error", () => teardown("agent"));
browser.on("close", () => teardown("browser"));
browser.on("error", () => teardown("browser"));
}
function forwardTo(peer: WebSocket | undefined, data: WebSocket.RawData) {
if (peer && peer.readyState === WebSocket.OPEN) {
peer.send(data, { binary: true });
}
}
// attachForwarder wires up message forwarding as soon as the socket exists so
// that bytes cannot be lost while the handler awaits the ownership/credential
// database checks. The forwarding is peer-lookup based and safe to use in any
// connection order.
function attachForwarder(ws: WebSocket, sessionId: string, kind: "agent" | "browser") {
ws.on("message", (data) => {
forwardTo(kind === "agent" ? browsers.get(sessionId) : agents.get(sessionId), data);
});
}
async function handleAgentTunnel(url: URL, ws: WebSocket) {
const sessionId = url.searchParams.get("session_id") || "";
const token = url.searchParams.get("token") || "";
if (!sessionId || !token) {
ws.close(4001, "Missing session_id or token");
return;
}
attachForwarder(ws, sessionId, "agent");
const credHash = hashCredential(token);
const [deviceCred] = await db
.select()
.from(deviceCredentials)
.where(
and(
eq(deviceCredentials.credentialHash, credHash),
eq(deviceCredentials.credentialType, "device_key")
)
)
.limit(1);
if (!deviceCred || (deviceCred.expiresAt && new Date(deviceCred.expiresAt) < new Date())) {
ws.close(4001, "Invalid device credential");
return;
}
const [device] = await db
.select()
.from(devices)
.where(eq(devices.id, deviceCred.deviceId))
.limit(1);
if (!device || device.isDisabled) {
ws.close(4001, "Device disabled");
return;
}
const [session] = await db
.select()
.from(remoteSessions)
.where(eq(remoteSessions.id, sessionId))
.limit(1);
if (!session || session.deviceId !== device.id) {
ws.close(4001, "Session not found for device");
return;
}
if (session.status === "closed" || session.status === "failed") {
ws.close(4001, "Session closed");
return;
}
await db
.update(remoteSessions)
.set({ status: "active", startedAt: new Date(), errorMessage: null })
.where(eq(remoteSessions.id, sessionId));
agents.set(sessionId, ws);
pair(sessionId);
ws.on("close", () => {
if (agents.get(sessionId) === ws) {
agents.delete(sessionId);
closeSession(sessionId).catch(() => {});
}
});
}
async function handleBrowser(url: URL, ws: WebSocket) {
const token = url.searchParams.get("token") || "";
let user: any;
try {
user = verifyToken(token);
} catch {
ws.close(4001, "Invalid session");
return;
}
const sessionId = url.pathname.split("/").pop() || "";
attachForwarder(ws, sessionId, "browser");
const [session] = await db
.select()
.from(remoteSessions)
.where(eq(remoteSessions.id, sessionId))
.limit(1);
if (!session || session.organizationId !== user.organizationId) {
ws.close(4001, "Session not found");
return;
}
if (browsers.has(sessionId)) {
browsers.get(sessionId)?.close(1013, "Replaced by another viewer");
browsers.delete(sessionId);
}
browsers.set(sessionId, ws);
pair(sessionId);
ws.on("close", () => {
if (browsers.get(sessionId) === ws) browsers.delete(sessionId);
});
}
export function attachRelay(server: Server) {
const wss = new WebSocketServer({ noServer: true });
server.on("upgrade", (req, socket, head) => {
const base = `${req.headers["x-forwarded-proto"] === "https" ? "https" : "http"}://${req.headers.host}`;
let url: URL;
try {
url = new URL(req.url || "/", base);
} catch {
socket.destroy();
return;
}
if (url.pathname === "/api/v1/remote/agent") {
wss.handleUpgrade(req, socket, head, (ws) => {
handleAgentTunnel(url, ws).catch(() => {
ws.close(4000, "Agent tunnel error");
});
});
return;
}
if (/^\/api\/v1\/remote\/browser\/[0-9a-fA-F-]{36}$/.test(url.pathname)) {
wss.handleUpgrade(req, socket, head, (ws) => {
handleBrowser(url, ws).catch(() => {
ws.close(4000, "Session error");
});
});
return;
}
socket.destroy();
});
server.on("close", () => wss.close());
}