import type { FastifyInstance } from 'fastify'; import { eq, and, count } from 'drizzle-orm'; import { randomUUID } from 'crypto'; import { setTimeout as sleep } from 'timers/promises'; import { servers, allocations, nodes, games } from '@source/database'; import type { PowerAction } from '@source/shared'; import { AppError } from '../../lib/errors.js'; import { requirePermission } from '../../lib/permissions.js'; import { paginate, paginatedResponse, PaginationQuerySchema } from '../../lib/pagination.js'; import { createAuditLog } from '../../lib/audit.js'; import { daemonCreateServer, daemonDeleteServer, daemonGetServerStatus, daemonSetPowerState, type DaemonNodeConnection, } from '../../lib/daemon.js'; import { ServerParamSchema, CreateServerSchema, UpdateServerSchema, PowerActionSchema, } from './schemas.js'; import configRoutes from './config.js'; import fileRoutes from './files.js'; import pluginRoutes from './plugins.js'; import playerRoutes from './players.js'; import scheduleRoutes from './schedules.js'; import backupRoutes from './backups.js'; type MutableServerStatus = 'installing' | 'running' | 'stopped' | 'error'; function mapDaemonStatus(rawStatus: string): MutableServerStatus | null { switch (rawStatus.toLowerCase()) { case 'installing': return 'installing'; case 'running': return 'running'; case 'stopped': return 'stopped'; case 'starting': return 'running'; case 'stopping': return 'stopped'; case 'error': return 'error'; default: return null; } } function buildDaemonEnvironment( gameEnvVarsRaw: unknown, overrides: Record | undefined, memoryLimitBytes: number, ): Record { const environment: Record = {}; if (Array.isArray(gameEnvVarsRaw)) { for (const item of gameEnvVarsRaw) { if (!item || typeof item !== 'object') continue; const record = item as Record; const key = typeof record.key === 'string' ? record.key.trim() : ''; if (!key) continue; const defaultValue = record.default; if (defaultValue === undefined || defaultValue === null) continue; environment[key] = String(defaultValue); } } for (const [key, value] of Object.entries(overrides ?? {})) { environment[key] = String(value); } if ('MEMORY' in environment && !(overrides && 'MEMORY' in overrides)) { const memoryMiB = Math.max(256, Math.floor(memoryLimitBytes / (1024 * 1024))); environment.MEMORY = `${memoryMiB}M`; } return environment; } async function syncServerInstallStatus( app: FastifyInstance, node: DaemonNodeConnection, serverId: string, serverUuid: string, ): Promise { const maxAttempts = 120; const intervalMs = 5_000; for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { if (attempt > 1) { await sleep(intervalMs); } try { const daemonStatus = await daemonGetServerStatus(node, serverUuid); const mapped = mapDaemonStatus(daemonStatus.state); if (!mapped) continue; if (mapped === 'installing') continue; const now = new Date(); await app.db .update(servers) .set({ status: mapped, installedAt: mapped === 'running' || mapped === 'stopped' ? now : null, updatedAt: now, }) .where(eq(servers.id, serverId)); app.log.info( { serverId, serverUuid, status: mapped, attempt }, 'Synchronized install status from daemon', ); return; } catch (error) { app.log.warn( { error, serverId, serverUuid, attempt }, 'Failed to poll daemon server status', ); } } app.log.warn( { serverId, serverUuid }, 'Timed out while waiting for daemon install completion', ); } export default async function serverRoutes(app: FastifyInstance) { app.addHook('onRequest', app.authenticate); // Register sub-routes await app.register(configRoutes, { prefix: '/:serverId/config' }); await app.register(fileRoutes, { prefix: '/:serverId/files' }); await app.register(pluginRoutes, { prefix: '/:serverId/plugins' }); await app.register(playerRoutes, { prefix: '/:serverId/players' }); await app.register(scheduleRoutes, { prefix: '/:serverId/schedules' }); await app.register(backupRoutes, { prefix: '/:serverId/backups' }); // GET /api/organizations/:orgId/servers app.get('/', { schema: { querystring: PaginationQuerySchema } }, async (request) => { const { orgId } = request.params as { orgId: string }; await requirePermission(request, orgId, 'server.read'); const { page, perPage, offset, limit } = paginate(request.query as any); const [totalResult] = await app.db .select({ count: count() }) .from(servers) .where(eq(servers.organizationId, orgId)); const serverList = await app.db .select({ id: servers.id, uuid: servers.uuid, name: servers.name, description: servers.description, status: servers.status, memoryLimit: servers.memoryLimit, diskLimit: servers.diskLimit, cpuLimit: servers.cpuLimit, port: servers.port, createdAt: servers.createdAt, nodeName: nodes.name, nodeId: nodes.id, gameName: games.name, gameSlug: games.slug, gameId: games.id, }) .from(servers) .innerJoin(nodes, eq(servers.nodeId, nodes.id)) .innerJoin(games, eq(servers.gameId, games.id)) .where(eq(servers.organizationId, orgId)) .limit(limit) .offset(offset) .orderBy(servers.createdAt); return paginatedResponse(serverList, totalResult!.count, page, perPage); }); // POST /api/organizations/:orgId/servers app.post('/', { schema: CreateServerSchema }, async (request, reply) => { const { orgId } = request.params as { orgId: string }; await requirePermission(request, orgId, 'server.create'); const body = request.body as { name: string; description?: string; nodeId: string; gameId: string; memoryLimit: number; diskLimit: number; cpuLimit?: number; allocationId: string; environment?: Record; startupOverride?: string; }; // Verify node belongs to org const node = await app.db.query.nodes.findFirst({ where: and(eq(nodes.id, body.nodeId), eq(nodes.organizationId, orgId)), }); if (!node) throw AppError.notFound('Node not found in this organization'); // Verify game exists const game = await app.db.query.games.findFirst({ where: eq(games.id, body.gameId), }); if (!game) throw AppError.notFound('Game not found'); // Verify and claim allocation const allocation = await app.db.query.allocations.findFirst({ where: and( eq(allocations.id, body.allocationId), eq(allocations.nodeId, body.nodeId), ), }); if (!allocation) throw AppError.notFound('Allocation not found on this node'); if (allocation.serverId) throw AppError.conflict('Allocation is already in use'); const serverUuid = randomUUID().slice(0, 8); const [server] = await app.db .insert(servers) .values({ uuid: serverUuid, organizationId: orgId, nodeId: body.nodeId, gameId: body.gameId, name: body.name, description: body.description, memoryLimit: body.memoryLimit, diskLimit: body.diskLimit, cpuLimit: body.cpuLimit ?? 100, port: allocation.port, environment: body.environment ?? {}, startupOverride: body.startupOverride, status: 'installing', }) .returning(); if (!server) { throw new AppError(500, 'Failed to create server record', 'SERVER_CREATE_FAILED'); } // Assign allocation to server await app.db .update(allocations) .set({ serverId: server.id, isDefault: true }) .where(eq(allocations.id, body.allocationId)); const nodeConnection: DaemonNodeConnection = { fqdn: node.fqdn, grpcPort: node.grpcPort, daemonToken: node.daemonToken, }; const daemonRequest = { uuid: server.uuid, docker_image: game.dockerImage, memory_limit: server.memoryLimit, disk_limit: server.diskLimit, cpu_limit: server.cpuLimit, startup_command: body.startupOverride ?? game.startupCommand, environment: buildDaemonEnvironment(game.environmentVars, body.environment, server.memoryLimit), ports: [ { host_port: allocation.port, container_port: game.defaultPort, protocol: 'tcp' as const, }, ], install_plugin_urls: [], }; try { const daemonResponse = await daemonCreateServer(nodeConnection, daemonRequest); const daemonStatus = mapDaemonStatus(daemonResponse.status) ?? 'installing'; const now = new Date(); const [updatedServer] = await app.db .update(servers) .set({ status: daemonStatus, installedAt: daemonStatus === 'running' || daemonStatus === 'stopped' ? now : null, updatedAt: now, }) .where(eq(servers.id, server.id)) .returning(); if (daemonStatus === 'installing') { void syncServerInstallStatus(app, nodeConnection, server.id, server.uuid); } await createAuditLog(app.db, request, { organizationId: orgId, serverId: server.id, action: 'server.create', metadata: { name: body.name, gameSlug: game.slug, nodeId: body.nodeId }, }); return reply.code(201).send(updatedServer ?? server); } catch (error) { app.log.error( { error, serverId: server.id, serverUuid: server.uuid, nodeId: node.id }, 'Failed to provision server on daemon', ); await app.db .update(servers) .set({ status: 'error', updatedAt: new Date() }) .where(eq(servers.id, server.id)); throw new AppError(502, 'Failed to provision server on daemon', 'DAEMON_CREATE_FAILED'); } }); // GET /api/organizations/:orgId/servers/:serverId app.get('/:serverId', { schema: ServerParamSchema }, async (request) => { const { orgId, serverId } = request.params as { orgId: string; serverId: string }; await requirePermission(request, orgId, 'server.read'); const [server] = await app.db .select({ id: servers.id, uuid: servers.uuid, name: servers.name, description: servers.description, status: servers.status, memoryLimit: servers.memoryLimit, diskLimit: servers.diskLimit, cpuLimit: servers.cpuLimit, port: servers.port, additionalPorts: servers.additionalPorts, environment: servers.environment, startupOverride: servers.startupOverride, installedAt: servers.installedAt, createdAt: servers.createdAt, updatedAt: servers.updatedAt, nodeId: nodes.id, nodeName: nodes.name, nodeFqdn: nodes.fqdn, gameId: games.id, gameName: games.name, gameSlug: games.slug, }) .from(servers) .innerJoin(nodes, eq(servers.nodeId, nodes.id)) .innerJoin(games, eq(servers.gameId, games.id)) .where(and(eq(servers.id, serverId), eq(servers.organizationId, orgId))); if (!server) throw AppError.notFound('Server not found'); return server; }); // PATCH /api/organizations/:orgId/servers/:serverId app.patch('/:serverId', { schema: { ...ServerParamSchema, ...UpdateServerSchema } }, async (request) => { const { orgId, serverId } = request.params as { orgId: string; serverId: string }; await requirePermission(request, orgId, 'server.update'); const body = request.body as Record; const [updated] = await app.db .update(servers) .set({ ...body, updatedAt: new Date() }) .where(and(eq(servers.id, serverId), eq(servers.organizationId, orgId))) .returning(); if (!updated) throw AppError.notFound('Server not found'); await createAuditLog(app.db, request, { organizationId: orgId, serverId, action: 'server.update', metadata: body, }); return updated; }); // DELETE /api/organizations/:orgId/servers/:serverId app.delete('/:serverId', { schema: ServerParamSchema }, async (request, reply) => { const { orgId, serverId } = request.params as { orgId: string; serverId: string }; await requirePermission(request, orgId, 'server.delete'); const [server] = await app.db .select({ id: servers.id, uuid: servers.uuid, name: servers.name, nodeFqdn: nodes.fqdn, nodeGrpcPort: nodes.grpcPort, nodeDaemonToken: nodes.daemonToken, }) .from(servers) .innerJoin(nodes, eq(servers.nodeId, nodes.id)) .where(and(eq(servers.id, serverId), eq(servers.organizationId, orgId))); if (!server) throw AppError.notFound('Server not found'); try { await daemonDeleteServer( { fqdn: server.nodeFqdn, grpcPort: server.nodeGrpcPort, daemonToken: server.nodeDaemonToken, }, server.uuid, ); } catch (error) { app.log.error( { error, serverId: server.id, serverUuid: server.uuid }, 'Failed to delete server on daemon', ); throw new AppError(502, 'Failed to delete server on daemon', 'DAEMON_DELETE_FAILED'); } // Release allocations await app.db .update(allocations) .set({ serverId: null, isDefault: false }) .where(eq(allocations.serverId, serverId)); await createAuditLog(app.db, request, { organizationId: orgId, serverId, action: 'server.delete', metadata: { name: server.name, uuid: server.uuid }, }); await app.db.delete(servers).where(eq(servers.id, serverId)); return reply.code(204).send(); }); // POST /api/organizations/:orgId/servers/:serverId/power app.post('/:serverId/power', { schema: { ...ServerParamSchema, ...PowerActionSchema } }, async (request) => { const { orgId, serverId } = request.params as { orgId: string; serverId: string }; const { action } = request.body as { action: PowerAction }; // Check specific power permission const permMap = { start: 'power.start', stop: 'power.stop', restart: 'power.restart', kill: 'power.kill', } as const; await requirePermission(request, orgId, permMap[action]); const [server] = await app.db .select({ id: servers.id, uuid: servers.uuid, status: servers.status, nodeFqdn: nodes.fqdn, nodeGrpcPort: nodes.grpcPort, nodeDaemonToken: nodes.daemonToken, }) .from(servers) .innerJoin(nodes, eq(servers.nodeId, nodes.id)) .where(and(eq(servers.id, serverId), eq(servers.organizationId, orgId))); if (!server) throw AppError.notFound('Server not found'); if (server.status === 'suspended') { throw AppError.badRequest('Cannot send power action to a suspended server'); } try { await daemonSetPowerState( { fqdn: server.nodeFqdn, grpcPort: server.nodeGrpcPort, daemonToken: server.nodeDaemonToken, }, server.uuid, action, ); } catch (error) { app.log.error( { error, serverId: server.id, serverUuid: server.uuid, action }, 'Failed to send power action to daemon', ); throw new AppError(502, 'Failed to send power action to daemon', 'DAEMON_POWER_FAILED'); } const statusMap: Record = { start: 'running', stop: 'stopped', restart: 'running', kill: 'stopped', }; await app.db .update(servers) .set({ status: statusMap[action], installedAt: statusMap[action] === 'running' ? new Date() : undefined, updatedAt: new Date(), }) .where(eq(servers.id, serverId)); await createAuditLog(app.db, request, { organizationId: orgId, serverId, action: `server.power.${action}`, }); return { success: true, action }; }); }