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