feat: Fleet Forge UEM - device management, agent, remote desktop (VNC), TLS reverse proxy
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
# ADR-001: Monorepo with pnpm Workspaces
|
||||
|
||||
## Status
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
Fleet Forge requires multiple packages: API server, web dashboard, shared types, and the Go agent. We need a structure that supports shared code, independent builds, and a single repository.
|
||||
|
||||
## Decision
|
||||
Use pnpm workspaces for the TypeScript/Node.js packages. The Go agent lives in the same repo but is built independently.
|
||||
|
||||
## Rationale
|
||||
- pnpm workspaces provide efficient dependency management with a single lockfile
|
||||
- Workspace references (`workspace:*`) enable local development without publishing
|
||||
- Compatible with Next.js, Express, and Drizzle ORM
|
||||
- The Go agent uses standard Go modules independently
|
||||
|
||||
## Consequences
|
||||
- All TS packages share a common `tsconfig.base.json`
|
||||
- Build order is managed via workspace dependency graph
|
||||
- Go agent has its own `go.mod` and is not part of the pnpm workspace
|
||||
@@ -0,0 +1,22 @@
|
||||
# ADR-002: TypeScript + Express for API
|
||||
|
||||
## Status
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
The API server needs to be fast to develop, type-safe, and maintainable.
|
||||
|
||||
## Decision
|
||||
Use TypeScript with Express.js, Drizzle ORM, and PostgreSQL.
|
||||
|
||||
## Rationale
|
||||
- TypeScript provides end-to-end type safety when shared with the frontend
|
||||
- Express is mature, well-documented, and sufficient for a REST API
|
||||
- Drizzle ORM is lightweight, type-safe, and generates SQL directly
|
||||
- Zod provides runtime request validation that aligns with TypeScript types
|
||||
- bcrypt for password hashing, JWT for auth tokens
|
||||
|
||||
## Consequences
|
||||
- API and web share the `@fleetforge/shared` package
|
||||
- Drizzle migrations are SQL-based, version-controlled in `infrastructure/migrations/`
|
||||
- No ORM overhead from heavier alternatives (Prisma, TypeORM)
|
||||
@@ -0,0 +1,22 @@
|
||||
# ADR-003: Go for Forge Agent
|
||||
|
||||
## Status
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
The agent needs to run on Linux, Windows, and macOS with minimal dependencies and resource usage.
|
||||
|
||||
## Decision
|
||||
Write the agent in Go, producing statically-linked binaries for each platform.
|
||||
|
||||
## Rationale
|
||||
- Go compiles to a single binary with no runtime dependencies
|
||||
- Excellent cross-compilation support (GOOS/GOARCH)
|
||||
- Small memory footprint suitable for resource-constrained devices
|
||||
- gopsutil library provides cross-platform system metrics
|
||||
- Strong standard library for HTTP, JSON, and OS interaction
|
||||
|
||||
## Consequences
|
||||
- Agent is built separately from the TypeScript monorepo
|
||||
- Agent version is tracked in the source code and reported in heartbeats
|
||||
- Future: could use Go plugins for platform-specific adapters
|
||||
@@ -0,0 +1,24 @@
|
||||
# ADR-004: Authentication Model
|
||||
|
||||
## Status
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
The platform needs secure authentication for both admin users and device agents.
|
||||
|
||||
## Decision
|
||||
- **Admin users**: JWT-based authentication with bcrypt password hashes
|
||||
- **Device agents**: Unique credential per device, stored as SHA-256 hashes in the database
|
||||
- **Enrollment**: Time-limited, optionally single-use tokens, also stored as hashes
|
||||
|
||||
## Rationale
|
||||
- JWTs are stateless, simple to implement, and work well with REST APIs
|
||||
- Device credentials are long-lived but can be rotated
|
||||
- Token hashing prevents database compromise from revealing usable credentials
|
||||
- Role-based authorization with hierarchy: owner > admin > operator > read_only
|
||||
|
||||
## Consequences
|
||||
- JWT secret must be configured via environment variable
|
||||
- Tokens should have reasonable expiry (24h default for users)
|
||||
- Device credentials are generated during enrollment and stored securely
|
||||
- All auth decisions are logged to the audit trail
|
||||
@@ -0,0 +1,25 @@
|
||||
# ADR-005: Device Status Calculation
|
||||
|
||||
## Status
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
Devices report heartbeats at regular intervals. We need to determine online/offline status.
|
||||
|
||||
## Decision
|
||||
- Agent sends heartbeats every 30 seconds (configurable)
|
||||
- Server marks device as `online` on each received heartbeat
|
||||
- A background worker checks for stale devices every 60 seconds
|
||||
- If last heartbeat is older than 2 minutes, device is marked `offline`
|
||||
- If metrics show concerning values (disk > 90%), device is marked `warning`
|
||||
|
||||
## Rationale
|
||||
- Simple threshold-based status is reliable and easy to understand
|
||||
- Status transitions are logged for audit purposes
|
||||
- The threshold is configurable per-device or globally
|
||||
- Future: more sophisticated health scoring
|
||||
|
||||
## Consequences
|
||||
- Status is computed server-side, not trusted from the agent
|
||||
- The heartbeat interval affects status responsiveness
|
||||
- Status changes generate audit events and can trigger alerts
|
||||
@@ -0,0 +1,444 @@
|
||||
{
|
||||
"openapi": "3.0.3",
|
||||
"info": {
|
||||
"title": "Fleet Forge API",
|
||||
"description": "Unified Endpoint & Infrastructure Management Platform API",
|
||||
"version": "0.1.0",
|
||||
"contact": {
|
||||
"name": "Fleet Forge",
|
||||
"url": "https://github.com/fleetforge/fleet-forge"
|
||||
}
|
||||
},
|
||||
"servers": [
|
||||
{ "url": "http://localhost:3000", "description": "Local development" }
|
||||
],
|
||||
"components": {
|
||||
"securitySchemes": {
|
||||
"bearerAuth": {
|
||||
"type": "http",
|
||||
"scheme": "bearer",
|
||||
"bearerFormat": "JWT"
|
||||
},
|
||||
"deviceAuth": {
|
||||
"type": "http",
|
||||
"scheme": "bearer",
|
||||
"description": "Device credential for agent authentication"
|
||||
}
|
||||
},
|
||||
"schemas": {
|
||||
"ApiResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"success": { "type": "boolean" },
|
||||
"data": {},
|
||||
"error": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code": { "type": "string" },
|
||||
"message": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"meta": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"page": { "type": "integer" },
|
||||
"limit": { "type": "integer" },
|
||||
"total": { "type": "integer" },
|
||||
"totalPages": { "type": "integer" }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"LoginRequest": {
|
||||
"type": "object",
|
||||
"required": ["email", "password"],
|
||||
"properties": {
|
||||
"email": { "type": "string", "format": "email" },
|
||||
"password": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"EnrollRequest": {
|
||||
"type": "object",
|
||||
"required": ["token", "hostname", "platform", "os_name", "os_version", "architecture", "agent_version"],
|
||||
"properties": {
|
||||
"token": { "type": "string" },
|
||||
"hostname": { "type": "string" },
|
||||
"platform": { "type": "string", "enum": ["linux", "darwin", "windows"] },
|
||||
"os_name": { "type": "string" },
|
||||
"os_version": { "type": "string" },
|
||||
"architecture": { "type": "string" },
|
||||
"kernel_version": { "type": "string" },
|
||||
"cpu_model": { "type": "string" },
|
||||
"cpu_cores": { "type": "integer" },
|
||||
"total_memory_bytes": { "type": "integer" },
|
||||
"total_disk_bytes": { "type": "integer" },
|
||||
"ip_address": { "type": "string" },
|
||||
"agent_version": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"HeartbeatRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"hostname": { "type": "string" },
|
||||
"os_version": { "type": "string" },
|
||||
"kernel_version": { "type": "string" },
|
||||
"agent_version": { "type": "string" },
|
||||
"ip_address": { "type": "string" },
|
||||
"metrics": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"cpu_usage_percent": { "type": "number" },
|
||||
"memory_usage_percent": { "type": "number" },
|
||||
"disk_usage_percent": { "type": "number" },
|
||||
"uptime_seconds": { "type": "integer" }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"CreateJobRequest": {
|
||||
"type": "object",
|
||||
"required": ["jobType", "deviceIds"],
|
||||
"properties": {
|
||||
"jobType": {
|
||||
"type": "string",
|
||||
"enum": ["update_inventory", "update_system_info", "check_service_status", "start_service", "stop_service", "restart_service", "install_package", "update_package", "reboot_system"]
|
||||
},
|
||||
"deviceIds": {
|
||||
"type": "array",
|
||||
"items": { "type": "string", "format": "uuid" }
|
||||
},
|
||||
"parameters": { "type": "object" },
|
||||
"expiresInMinutes": { "type": "integer", "minimum": 1, "maximum": 1440 }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [{ "bearerAuth": [] }],
|
||||
"paths": {
|
||||
"/api/health": {
|
||||
"get": {
|
||||
"tags": ["System"],
|
||||
"summary": "Health check",
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": { "description": "API is healthy" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/auth/login": {
|
||||
"post": {
|
||||
"tags": ["Auth"],
|
||||
"summary": "Login",
|
||||
"security": [],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/LoginRequest" } } }
|
||||
},
|
||||
"responses": {
|
||||
"200": { "description": "Login successful, returns JWT token" },
|
||||
"401": { "description": "Invalid credentials" },
|
||||
"429": { "description": "Rate limited" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/auth/me": {
|
||||
"post": {
|
||||
"tags": ["Auth"],
|
||||
"summary": "Get current user",
|
||||
"responses": {
|
||||
"200": { "description": "Current user info" },
|
||||
"401": { "description": "Not authenticated" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/devices": {
|
||||
"get": {
|
||||
"tags": ["Devices"],
|
||||
"summary": "List devices",
|
||||
"parameters": [
|
||||
{ "name": "page", "in": "query", "schema": { "type": "integer", "default": 1 } },
|
||||
{ "name": "limit", "in": "query", "schema": { "type": "integer", "default": 20 } },
|
||||
{ "name": "search", "in": "query", "schema": { "type": "string" } },
|
||||
{ "name": "status", "in": "query", "schema": { "type": "string", "enum": ["online", "offline", "warning"] } },
|
||||
{ "name": "deviceType", "in": "query", "schema": { "type": "string" } },
|
||||
{ "name": "sort", "in": "query", "schema": { "type": "string" } },
|
||||
{ "name": "order", "in": "query", "schema": { "type": "string", "enum": ["asc", "desc"] } }
|
||||
],
|
||||
"responses": {
|
||||
"200": { "description": "Paginated list of devices" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/devices/{deviceId}": {
|
||||
"get": {
|
||||
"tags": ["Devices"],
|
||||
"summary": "Get device details",
|
||||
"parameters": [
|
||||
{ "name": "deviceId", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }
|
||||
],
|
||||
"responses": {
|
||||
"200": { "description": "Device details" },
|
||||
"404": { "description": "Device not found" }
|
||||
}
|
||||
},
|
||||
"patch": {
|
||||
"tags": ["Devices"],
|
||||
"summary": "Update device",
|
||||
"parameters": [
|
||||
{ "name": "deviceId", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }
|
||||
],
|
||||
"responses": {
|
||||
"200": { "description": "Device updated" }
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"tags": ["Devices"],
|
||||
"summary": "Delete device",
|
||||
"parameters": [
|
||||
{ "name": "deviceId", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }
|
||||
],
|
||||
"responses": {
|
||||
"204": { "description": "Device deleted" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/devices/{deviceId}/metrics": {
|
||||
"get": {
|
||||
"tags": ["Devices"],
|
||||
"summary": "Get device metrics",
|
||||
"parameters": [
|
||||
{ "name": "deviceId", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }
|
||||
],
|
||||
"responses": {
|
||||
"200": { "description": "Device metrics history" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/devices/{deviceId}/inventory": {
|
||||
"get": {
|
||||
"tags": ["Devices"],
|
||||
"summary": "Get device inventory",
|
||||
"parameters": [
|
||||
{ "name": "deviceId", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }
|
||||
],
|
||||
"responses": {
|
||||
"200": { "description": "Device inventory snapshots" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/enrollment-tokens": {
|
||||
"get": {
|
||||
"tags": ["Enrollment"],
|
||||
"summary": "List enrollment tokens",
|
||||
"responses": { "200": { "description": "List of tokens" } }
|
||||
},
|
||||
"post": {
|
||||
"tags": ["Enrollment"],
|
||||
"summary": "Create enrollment token",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"label": { "type": "string" },
|
||||
"expiresInHours": { "type": "integer", "default": 24 },
|
||||
"maxUses": { "type": "integer", "nullable": true }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"201": { "description": "Token created. The raw token is only shown once." }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/enrollment-tokens/{tokenId}": {
|
||||
"delete": {
|
||||
"tags": ["Enrollment"],
|
||||
"summary": "Revoke enrollment token",
|
||||
"parameters": [
|
||||
{ "name": "tokenId", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }
|
||||
],
|
||||
"responses": {
|
||||
"200": { "description": "Token revoked" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/groups": {
|
||||
"get": {
|
||||
"tags": ["Groups"],
|
||||
"summary": "List device groups",
|
||||
"responses": { "200": { "description": "List of groups" } }
|
||||
},
|
||||
"post": {
|
||||
"tags": ["Groups"],
|
||||
"summary": "Create device group",
|
||||
"responses": { "201": { "description": "Group created" } }
|
||||
}
|
||||
},
|
||||
"/api/v1/jobs": {
|
||||
"get": {
|
||||
"tags": ["Jobs"],
|
||||
"summary": "List jobs",
|
||||
"responses": { "200": { "description": "Paginated list of jobs" } }
|
||||
},
|
||||
"post": {
|
||||
"tags": ["Jobs"],
|
||||
"summary": "Create job",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/CreateJobRequest" } } }
|
||||
},
|
||||
"responses": {
|
||||
"201": { "description": "Job created and queued" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/jobs/{jobId}/cancel": {
|
||||
"patch": {
|
||||
"tags": ["Jobs"],
|
||||
"summary": "Cancel queued job",
|
||||
"parameters": [
|
||||
{ "name": "jobId", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }
|
||||
],
|
||||
"responses": {
|
||||
"200": { "description": "Job cancelled" },
|
||||
"404": { "description": "Job not found or not cancellable" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/policies": {
|
||||
"get": {
|
||||
"tags": ["Policies"],
|
||||
"summary": "List policies",
|
||||
"responses": { "200": { "description": "List of policies" } }
|
||||
},
|
||||
"post": {
|
||||
"tags": ["Policies"],
|
||||
"summary": "Create policy",
|
||||
"responses": { "201": { "description": "Policy created" } }
|
||||
}
|
||||
},
|
||||
"/api/v1/alerts": {
|
||||
"get": {
|
||||
"tags": ["Alerts"],
|
||||
"summary": "List alerts",
|
||||
"parameters": [
|
||||
{ "name": "resolved", "in": "query", "schema": { "type": "string", "enum": ["true", "false"] } }
|
||||
],
|
||||
"responses": { "200": { "description": "List of alerts" } }
|
||||
}
|
||||
},
|
||||
"/api/v1/alerts/{alertId}/resolve": {
|
||||
"patch": {
|
||||
"tags": ["Alerts"],
|
||||
"summary": "Resolve alert",
|
||||
"parameters": [
|
||||
{ "name": "alertId", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }
|
||||
],
|
||||
"responses": { "200": { "description": "Alert resolved" } }
|
||||
}
|
||||
},
|
||||
"/api/v1/audit-events": {
|
||||
"get": {
|
||||
"tags": ["Audit"],
|
||||
"summary": "List audit events",
|
||||
"parameters": [
|
||||
{ "name": "page", "in": "query", "schema": { "type": "integer" } },
|
||||
{ "name": "limit", "in": "query", "schema": { "type": "integer" } },
|
||||
{ "name": "action", "in": "query", "schema": { "type": "string" } }
|
||||
],
|
||||
"responses": { "200": { "description": "Paginated audit events" } }
|
||||
}
|
||||
},
|
||||
"/api/v1/dashboard/stats": {
|
||||
"get": {
|
||||
"tags": ["Dashboard"],
|
||||
"summary": "Get dashboard statistics",
|
||||
"responses": { "200": { "description": "Dashboard stats" } }
|
||||
}
|
||||
},
|
||||
"/api/v1/users": {
|
||||
"get": {
|
||||
"tags": ["Users"],
|
||||
"summary": "List users",
|
||||
"responses": { "200": { "description": "List of users" } }
|
||||
},
|
||||
"post": {
|
||||
"tags": ["Users"],
|
||||
"summary": "Create user",
|
||||
"responses": { "201": { "description": "User created" } }
|
||||
}
|
||||
},
|
||||
"/api/v1/agent/enroll": {
|
||||
"post": {
|
||||
"tags": ["Agent"],
|
||||
"summary": "Enroll device",
|
||||
"security": [],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/EnrollRequest" } } }
|
||||
},
|
||||
"responses": {
|
||||
"201": { "description": "Device enrolled, returns device_id and credential" },
|
||||
"401": { "description": "Invalid enrollment token" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/agent/heartbeat": {
|
||||
"post": {
|
||||
"tags": ["Agent"],
|
||||
"summary": "Send heartbeat",
|
||||
"security": [{ "deviceAuth": [] }],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/HeartbeatRequest" } } }
|
||||
},
|
||||
"responses": {
|
||||
"200": { "description": "Heartbeat acknowledged" },
|
||||
"401": { "description": "Invalid device credential" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/agent/jobs": {
|
||||
"get": {
|
||||
"tags": ["Agent"],
|
||||
"summary": "Fetch pending jobs",
|
||||
"security": [{ "deviceAuth": [] }],
|
||||
"responses": {
|
||||
"200": { "description": "List of pending jobs for this device" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/agent/jobs/{jobId}/result": {
|
||||
"post": {
|
||||
"tags": ["Agent"],
|
||||
"summary": "Submit job result",
|
||||
"security": [{ "deviceAuth": [] }],
|
||||
"parameters": [
|
||||
{ "name": "jobId", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }
|
||||
],
|
||||
"responses": {
|
||||
"200": { "description": "Result accepted" }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
{ "name": "System", "description": "Health and system endpoints" },
|
||||
{ "name": "Auth", "description": "Authentication" },
|
||||
{ "name": "Devices", "description": "Device management" },
|
||||
{ "name": "Enrollment", "description": "Enrollment token management" },
|
||||
{ "name": "Groups", "description": "Device groups" },
|
||||
{ "name": "Jobs", "description": "Job management" },
|
||||
{ "name": "Policies", "description": "Compliance policies" },
|
||||
{ "name": "Alerts", "description": "System alerts" },
|
||||
{ "name": "Audit", "description": "Audit trail" },
|
||||
{ "name": "Dashboard", "description": "Dashboard statistics" },
|
||||
{ "name": "Users", "description": "User management" },
|
||||
{ "name": "Agent", "description": "Agent endpoints (enroll, heartbeat, jobs)" }
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user