diff --git a/apps/api/package.json b/apps/api/package.json index fb1f199..80c6796 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -10,6 +10,8 @@ "lint": "eslint src/" }, "dependencies": { + "@grpc/grpc-js": "^1.14.0", + "@grpc/proto-loader": "^0.8.0", "@fastify/cookie": "^11.0.0", "@fastify/cors": "^10.0.0", "@fastify/helmet": "^13.0.2", @@ -18,6 +20,7 @@ "@fastify/websocket": "^11.0.0", "@sinclair/typebox": "^0.34.0", "@source/database": "workspace:*", + "@source/proto": "workspace:*", "@source/shared": "workspace:*", "argon2": "^0.41.0", "drizzle-orm": "^0.38.0", diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 7cdb173..6091f71 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -5,6 +5,7 @@ import helmet from '@fastify/helmet'; import rateLimit from '@fastify/rate-limit'; import dbPlugin from './plugins/db.js'; import authPlugin from './plugins/auth.js'; +import socketPlugin from './plugins/socket.js'; import authRoutes from './routes/auth/index.js'; import organizationRoutes from './routes/organizations/index.js'; import internalRoutes from './routes/internal/index.js'; @@ -41,6 +42,7 @@ await app.register(rateLimit, { await app.register(cookie); await app.register(dbPlugin); await app.register(authPlugin); +await app.register(socketPlugin); // Error handler app.setErrorHandler((error: Error & { validation?: unknown; statusCode?: number; code?: string }, _request, reply) => { diff --git a/apps/api/src/lib/daemon.ts b/apps/api/src/lib/daemon.ts new file mode 100644 index 0000000..d73ebca --- /dev/null +++ b/apps/api/src/lib/daemon.ts @@ -0,0 +1,494 @@ +import grpc from '@grpc/grpc-js'; +import protoLoader from '@grpc/proto-loader'; +import type { PowerAction } from '@source/shared'; +import { PROTO_PATH } from '@source/proto'; + +export interface DaemonNodeConnection { + fqdn: string; + grpcPort: number; + daemonToken: string; +} + +export interface DaemonPortMapping { + host_port: number; + container_port: number; + protocol: 'tcp' | 'udp'; +} + +export interface DaemonCreateServerRequest { + uuid: string; + docker_image: string; + memory_limit: number; + disk_limit: number; + cpu_limit: number; + startup_command: string; + environment: Record; + ports: DaemonPortMapping[]; + install_plugin_urls: string[]; +} + +interface DaemonServerResponse { + uuid: string; + status: string; +} + +interface DaemonStatusResponse { + uuid: string; + state: string; +} + +interface EmptyResponse { + [key: string]: never; +} + +interface DaemonFileListResponseRaw { + files: { + name: string; + path: string; + is_directory: boolean; + size: number; + modified_at: number; + mime_type: string; + }[]; +} + +interface DaemonFileContentRaw { + data: Uint8Array | Buffer; + mime_type: string; +} + +interface DaemonPlayerListRaw { + players: { + name: string; + uuid: string; + connected_at: number; + }[]; + max_players: number; +} + +export interface DaemonConsoleOutput { + uuid: string; + line: string; + timestamp: number; +} + +export interface DaemonConsoleStreamHandle { + stream: grpc.ClientReadableStream; + close: () => void; +} + +export interface DaemonFileEntry { + name: string; + path: string; + isDirectory: boolean; + size: number; + modifiedAt: number; + mimeType: string; +} + +export interface DaemonPlayersResponse { + players: Array<{ + name: string; + id: string; + connectedAt: number; + }>; + maxPlayers: number; +} + +type UnaryCallback = (error: grpc.ServiceError | null, response: TResponse) => void; + +interface DaemonServiceClient extends grpc.Client { + createServer( + request: DaemonCreateServerRequest, + metadata: grpc.Metadata, + callback: UnaryCallback, + ): void; + deleteServer( + request: { uuid: string }, + metadata: grpc.Metadata, + callback: UnaryCallback, + ): void; + setPowerState( + request: { uuid: string; action: number }, + metadata: grpc.Metadata, + callback: UnaryCallback, + ): void; + getServerStatus( + request: { uuid: string }, + metadata: grpc.Metadata, + callback: UnaryCallback, + ): void; + streamConsole( + request: { uuid: string }, + metadata: grpc.Metadata, + ): grpc.ClientReadableStream; + sendCommand( + request: { uuid: string; command: string }, + metadata: grpc.Metadata, + callback: UnaryCallback, + ): void; + listFiles( + request: { uuid: string; path: string }, + metadata: grpc.Metadata, + callback: UnaryCallback, + ): void; + readFile( + request: { uuid: string; path: string }, + metadata: grpc.Metadata, + callback: UnaryCallback, + ): void; + writeFile( + request: { uuid: string; path: string; data: Uint8Array | Buffer }, + metadata: grpc.Metadata, + callback: UnaryCallback, + ): void; + deleteFiles( + request: { uuid: string; paths: string[] }, + metadata: grpc.Metadata, + callback: UnaryCallback, + ): void; + getActivePlayers( + request: { uuid: string }, + metadata: grpc.Metadata, + callback: UnaryCallback, + ): void; +} + +const packageDefinition = protoLoader.loadSync(PROTO_PATH, { + keepCase: true, + longs: Number, + enums: Number, + defaults: true, + oneofs: true, +}); + +const loaded = grpc.loadPackageDefinition(packageDefinition) as { + gamepanel?: { + daemon?: { + DaemonService?: grpc.ServiceClientConstructor; + }; + }; +}; + +const DaemonServiceCtor = loaded.gamepanel?.daemon?.DaemonService; +if (!DaemonServiceCtor) { + throw new Error('Failed to load DaemonService gRPC definition'); +} +const DaemonService = DaemonServiceCtor; + +const POWER_ACTIONS: Record = { + start: 0, + stop: 1, + restart: 2, + kill: 3, +}; + +function buildGrpcTarget(fqdn: string, grpcPort: number): string { + const trimmed = fqdn.trim(); + if (!trimmed) throw new Error('Node FQDN is empty'); + + if (trimmed.includes('://')) { + try { + const parsed = new URL(trimmed); + const host = parsed.hostname || parsed.host; + if (!host) throw new Error('Node FQDN has no hostname'); + if (parsed.port) return `${host}:${parsed.port}`; + return `${host}:${grpcPort}`; + } catch { + // Fall through to raw handling below. + } + } + + const withoutPath = trimmed.replace(/\/.*$/, ''); + if (/^\[.+\](?::\d+)?$/.test(withoutPath)) { + return /\]:\d+$/.test(withoutPath) ? withoutPath : `${withoutPath}:${grpcPort}`; + } + if (/^[^:]+:\d+$/.test(withoutPath)) return withoutPath; + if (withoutPath.includes(':')) return `[${withoutPath}]:${grpcPort}`; + return `${withoutPath}:${grpcPort}`; +} + +function getMetadata(daemonToken: string): grpc.Metadata { + const metadata = new grpc.Metadata(); + metadata.set('authorization', `Bearer ${daemonToken}`); + return metadata; +} + +function createClient(node: DaemonNodeConnection): DaemonServiceClient { + const target = buildGrpcTarget(node.fqdn, node.grpcPort); + return new DaemonService( + target, + grpc.credentials.createInsecure(), + ) as unknown as DaemonServiceClient; +} + +function waitForReady(client: grpc.Client, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + client.waitForReady(Date.now() + timeoutMs, (err) => { + if (err) { + reject(err); + return; + } + resolve(); + }); + }); +} + +function callUnary( + invoke: (callback: UnaryCallback) => void, + timeoutMs: number, +): Promise { + return new Promise((resolve, reject) => { + let completed = false; + + const timeout = setTimeout(() => { + if (completed) return; + completed = true; + reject(new Error(`gRPC request timed out after ${timeoutMs}ms`)); + }, timeoutMs); + + invoke((error, response) => { + if (completed) return; + completed = true; + clearTimeout(timeout); + + if (error) { + reject(error); + return; + } + + resolve(response); + }); + }); +} + +function toBuffer(data: Uint8Array | Buffer): Buffer { + if (Buffer.isBuffer(data)) return data; + return Buffer.from(data); +} + +const DEFAULT_CONNECT_TIMEOUT_MS = 8_000; +const DEFAULT_RPC_TIMEOUT_MS = 20_000; + +export async function daemonCreateServer( + node: DaemonNodeConnection, + request: DaemonCreateServerRequest, +): Promise { + const client = createClient(node); + try { + await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS); + return await callUnary( + (callback) => client.createServer(request, getMetadata(node.daemonToken), callback), + DEFAULT_RPC_TIMEOUT_MS, + ); + } finally { + client.close(); + } +} + +export async function daemonDeleteServer( + node: DaemonNodeConnection, + serverUuid: string, +): Promise { + const client = createClient(node); + try { + await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS); + await callUnary( + (callback) => client.deleteServer({ uuid: serverUuid }, getMetadata(node.daemonToken), callback), + DEFAULT_RPC_TIMEOUT_MS, + ); + } finally { + client.close(); + } +} + +export async function daemonSetPowerState( + node: DaemonNodeConnection, + serverUuid: string, + action: PowerAction, +): Promise { + const client = createClient(node); + try { + await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS); + await callUnary( + (callback) => client.setPowerState({ uuid: serverUuid, action: POWER_ACTIONS[action] }, getMetadata(node.daemonToken), callback), + DEFAULT_RPC_TIMEOUT_MS, + ); + } finally { + client.close(); + } +} + +export async function daemonGetServerStatus( + node: DaemonNodeConnection, + serverUuid: string, +): Promise { + const client = createClient(node); + try { + await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS); + return await callUnary( + (callback) => client.getServerStatus({ uuid: serverUuid }, getMetadata(node.daemonToken), callback), + DEFAULT_RPC_TIMEOUT_MS, + ); + } finally { + client.close(); + } +} + +export async function daemonOpenConsoleStream( + node: DaemonNodeConnection, + serverUuid: string, +): Promise { + const client = createClient(node); + try { + await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS); + const stream = client.streamConsole({ uuid: serverUuid }, getMetadata(node.daemonToken)); + + const close = () => { + try { + stream.cancel(); + } catch { + // no-op + } + client.close(); + }; + + stream.on('end', () => client.close()); + stream.on('error', () => client.close()); + + return { stream, close }; + } catch (error) { + client.close(); + throw error; + } +} + +export async function daemonSendCommand( + node: DaemonNodeConnection, + serverUuid: string, + command: string, +): Promise { + const client = createClient(node); + try { + await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS); + await callUnary( + (callback) => client.sendCommand({ uuid: serverUuid, command }, getMetadata(node.daemonToken), callback), + DEFAULT_RPC_TIMEOUT_MS, + ); + } finally { + client.close(); + } +} + +export async function daemonListFiles( + node: DaemonNodeConnection, + serverUuid: string, + path: string, +): Promise { + const client = createClient(node); + try { + await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS); + const response = await callUnary( + (callback) => client.listFiles({ uuid: serverUuid, path }, getMetadata(node.daemonToken), callback), + DEFAULT_RPC_TIMEOUT_MS, + ); + + return response.files.map((file) => ({ + name: file.name, + path: file.path, + isDirectory: file.is_directory, + size: Number(file.size), + modifiedAt: Number(file.modified_at), + mimeType: file.mime_type, + })); + } finally { + client.close(); + } +} + +export async function daemonReadFile( + node: DaemonNodeConnection, + serverUuid: string, + path: string, +): Promise<{ data: Buffer; mimeType: string }> { + const client = createClient(node); + try { + await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS); + const response = await callUnary( + (callback) => client.readFile({ uuid: serverUuid, path }, getMetadata(node.daemonToken), callback), + DEFAULT_RPC_TIMEOUT_MS, + ); + + return { + data: toBuffer(response.data), + mimeType: response.mime_type, + }; + } finally { + client.close(); + } +} + +export async function daemonWriteFile( + node: DaemonNodeConnection, + serverUuid: string, + path: string, + data: string | Buffer, +): Promise { + const client = createClient(node); + try { + await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS); + await callUnary( + (callback) => + client.writeFile( + { uuid: serverUuid, path, data: typeof data === 'string' ? Buffer.from(data, 'utf8') : data }, + getMetadata(node.daemonToken), + callback, + ), + DEFAULT_RPC_TIMEOUT_MS, + ); + } finally { + client.close(); + } +} + +export async function daemonDeleteFiles( + node: DaemonNodeConnection, + serverUuid: string, + paths: string[], +): Promise { + const client = createClient(node); + try { + await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS); + await callUnary( + (callback) => client.deleteFiles({ uuid: serverUuid, paths }, getMetadata(node.daemonToken), callback), + DEFAULT_RPC_TIMEOUT_MS, + ); + } finally { + client.close(); + } +} + +export async function daemonGetActivePlayers( + node: DaemonNodeConnection, + serverUuid: string, +): Promise { + const client = createClient(node); + try { + await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS); + const response = await callUnary( + (callback) => client.getActivePlayers({ uuid: serverUuid }, getMetadata(node.daemonToken), callback), + DEFAULT_RPC_TIMEOUT_MS, + ); + + return { + players: response.players.map((player) => ({ + name: player.name, + id: player.uuid, + connectedAt: Number(player.connected_at), + })), + maxPlayers: Number(response.max_players), + }; + } finally { + client.close(); + } +} diff --git a/apps/api/src/plugins/socket.ts b/apps/api/src/plugins/socket.ts new file mode 100644 index 0000000..cd14cb9 --- /dev/null +++ b/apps/api/src/plugins/socket.ts @@ -0,0 +1,259 @@ +import fp from 'fastify-plugin'; +import type { FastifyInstance } from 'fastify'; +import { and, eq } from 'drizzle-orm'; +import { Server as SocketIOServer } from 'socket.io'; +import { nodes, organizationMembers, servers } from '@source/database'; +import { ROLES } from '@source/shared'; +import type { Role } from '@source/shared'; +import type { AccessTokenPayload } from '../lib/jwt.js'; +import { + daemonOpenConsoleStream, + daemonSendCommand, + type DaemonConsoleStreamHandle, + type DaemonNodeConnection, +} from '../lib/daemon.js'; + +declare module 'fastify' { + interface FastifyInstance { + io: SocketIOServer; + } +} + +type ConsolePermission = 'console.read' | 'console.write'; + +export default fp(async (app: FastifyInstance) => { + const io = new SocketIOServer(app.server, { + path: '/socket.io', + cors: { + origin: process.env.CORS_ORIGIN || 'http://localhost:5173', + credentials: true, + }, + }); + + app.decorate('io', io); + + const activeStreams = new Map(); + + io.use((socket, next) => { + const token = typeof socket.handshake.auth?.token === 'string' + ? socket.handshake.auth.token + : null; + + if (!token) { + next(new Error('Unauthorized')); + return; + } + + const verifier = (app as any).jwt?.verify; + if (typeof verifier !== 'function') { + next(new Error('Authentication is not configured')); + return; + } + + try { + const payload = verifier(token) as AccessTokenPayload; + (socket.data as { user?: AccessTokenPayload }).user = payload; + next(); + } catch { + next(new Error('Unauthorized')); + } + }); + + io.on('connection', (socket) => { + const cleanupSocketStream = () => { + const current = activeStreams.get(socket.id); + if (!current) return; + current.close(); + activeStreams.delete(socket.id); + }; + + socket.on('server:console:join', async (payload: unknown) => { + const serverId = typeof (payload as { serverId?: unknown })?.serverId === 'string' + ? ((payload as { serverId: string }).serverId) + : ''; + if (!serverId) { + socket.emit('server:console:output', { line: '[error] Invalid server id' }); + return; + } + + const user = (socket.data as { user?: AccessTokenPayload }).user; + if (!user) { + socket.emit('server:console:output', { line: '[error] Unauthorized' }); + return; + } + + const server = await getServerContext(app, serverId); + if (!server) { + socket.emit('server:console:output', { line: '[error] Server not found' }); + return; + } + + const allowed = await hasConsolePermission(app, user, server.organizationId, 'console.read'); + if (!allowed) { + socket.emit('server:console:output', { line: '[error] Missing permission: console.read' }); + return; + } + + cleanupSocketStream(); + + try { + const streamHandle = await daemonOpenConsoleStream(server.node, server.serverUuid); + streamHandle.stream.on('data', (output) => { + socket.emit('server:console:output', { line: output.line }); + }); + streamHandle.stream.on('end', () => { + activeStreams.delete(socket.id); + socket.emit('server:console:output', { line: '[console] Stream ended' }); + }); + streamHandle.stream.on('error', (error) => { + activeStreams.delete(socket.id); + app.log.warn( + { error, serverId, serverUuid: server.serverUuid, socketId: socket.id }, + 'Console stream failed', + ); + socket.emit('server:console:output', { line: '[error] Console stream failed' }); + }); + + activeStreams.set(socket.id, streamHandle); + } catch (error) { + app.log.warn( + { error, serverId, serverUuid: server.serverUuid, socketId: socket.id }, + 'Failed to open console stream', + ); + socket.emit('server:console:output', { line: '[error] Failed to open console stream' }); + } + }); + + socket.on('server:console:leave', () => { + cleanupSocketStream(); + }); + + socket.on('server:console:command', async (payload: unknown) => { + const body = payload as { + serverId?: unknown; + orgId?: unknown; + command?: unknown; + }; + + const serverId = typeof body.serverId === 'string' ? body.serverId : ''; + const orgId = typeof body.orgId === 'string' ? body.orgId : ''; + const command = typeof body.command === 'string' ? body.command.trim() : ''; + + if (!serverId || !orgId || !command) { + socket.emit('server:console:output', { line: '[error] Invalid command payload' }); + return; + } + + const user = (socket.data as { user?: AccessTokenPayload }).user; + if (!user) { + socket.emit('server:console:output', { line: '[error] Unauthorized' }); + return; + } + + const server = await getServerContext(app, serverId, orgId); + if (!server) { + socket.emit('server:console:output', { line: '[error] Server not found' }); + return; + } + + const allowed = await hasConsolePermission(app, user, orgId, 'console.write'); + if (!allowed) { + socket.emit('server:console:output', { line: '[error] Missing permission: console.write' }); + return; + } + + try { + await daemonSendCommand(server.node, server.serverUuid, command); + } catch (error) { + app.log.warn( + { error, serverId, serverUuid: server.serverUuid, socketId: socket.id }, + 'Failed to send console command', + ); + socket.emit('server:console:output', { line: '[error] Failed to send command' }); + } + }); + + socket.on('disconnect', () => { + cleanupSocketStream(); + }); + }); + + app.addHook('onClose', async () => { + for (const handle of activeStreams.values()) { + handle.close(); + } + activeStreams.clear(); + + await new Promise((resolve) => { + io.close(() => resolve()); + }); + }); +}); + +async function hasConsolePermission( + app: FastifyInstance, + user: AccessTokenPayload, + orgId: string, + permission: ConsolePermission, +): Promise { + if (user.isSuperAdmin) return true; + + const member = await app.db.query.organizationMembers.findFirst({ + where: and( + eq(organizationMembers.organizationId, orgId), + eq(organizationMembers.userId, user.sub), + ), + columns: { + role: true, + customPermissions: true, + }, + }); + + if (!member) return false; + + const custom = (member.customPermissions ?? {}) as Record; + if (permission in custom) { + return Boolean(custom[permission]); + } + + const rolePerms = ROLES[member.role as Role]?.permissions ?? []; + return (rolePerms as readonly string[]).includes(permission); +} + +async function getServerContext( + app: FastifyInstance, + serverId: string, + orgId?: string, +): Promise<{ + organizationId: string; + serverUuid: string; + node: DaemonNodeConnection; +} | null> { + const whereClause = orgId + ? and(eq(servers.id, serverId), eq(servers.organizationId, orgId)) + : eq(servers.id, serverId); + + const [row] = await app.db + .select({ + organizationId: servers.organizationId, + serverUuid: servers.uuid, + nodeFqdn: nodes.fqdn, + nodeGrpcPort: nodes.grpcPort, + nodeDaemonToken: nodes.daemonToken, + }) + .from(servers) + .innerJoin(nodes, eq(servers.nodeId, nodes.id)) + .where(whereClause); + + if (!row) return null; + + return { + organizationId: row.organizationId, + serverUuid: row.serverUuid, + node: { + fqdn: row.nodeFqdn, + grpcPort: row.nodeGrpcPort, + daemonToken: row.nodeDaemonToken, + }, + }; +} diff --git a/apps/api/src/routes/servers/config.ts b/apps/api/src/routes/servers/config.ts index 72a67e3..f75c151 100644 --- a/apps/api/src/routes/servers/config.ts +++ b/apps/api/src/routes/servers/config.ts @@ -1,11 +1,12 @@ import type { FastifyInstance } from 'fastify'; import { eq, and } from 'drizzle-orm'; import { Type } from '@sinclair/typebox'; -import { servers, games } from '@source/database'; +import { servers, games, nodes } from '@source/database'; import type { GameConfigFile, ConfigParser } from '@source/shared'; import { AppError } from '../../lib/errors.js'; import { requirePermission } from '../../lib/permissions.js'; import { parseConfig, serializeConfig } from '../../lib/config-parsers.js'; +import { daemonReadFile, daemonWriteFile, type DaemonNodeConnection } from '../../lib/daemon.js'; const ParamSchema = { params: Type.Object({ @@ -60,16 +61,26 @@ export default async function configRoutes(app: FastifyInstance) { }; await requirePermission(request, orgId, 'config.read'); - const { game, server, configFile } = await getServerConfig(app, orgId, serverId, configIndex); + const { server, node, configFile } = await getServerConfig(app, orgId, serverId, configIndex); - // TODO: Read file from daemon via gRPC - // For now, return empty parsed result (will be connected in Phase 4 integration) + let raw = ''; + try { + const file = await daemonReadFile(node, server.uuid, configFile.path); + raw = file.data.toString('utf8'); + } catch (error) { + if (!isMissingConfigFileError(error)) { + app.log.error({ error, serverId, path: configFile.path }, 'Failed to read config file from daemon'); + throw new AppError(502, 'Failed to read config file from daemon', 'DAEMON_CONFIG_READ_FAILED'); + } + } + + const entries = raw ? parseConfig(raw, configFile.parser as ConfigParser) : []; return { path: configFile.path, parser: configFile.parser, editableKeys: configFile.editableKeys ?? null, - entries: [], - raw: '', + entries, + raw, }; }); @@ -98,7 +109,7 @@ export default async function configRoutes(app: FastifyInstance) { const { entries } = request.body as { entries: { key: string; value: string }[] }; await requirePermission(request, orgId, 'config.write'); - const { configFile } = await getServerConfig(app, orgId, serverId, configIndex); + const { server, node, configFile } = await getServerConfig(app, orgId, serverId, configIndex); // If editableKeys is set, only allow those keys if (configFile.editableKeys && configFile.editableKeys.length > 0) { @@ -111,11 +122,24 @@ export default async function configRoutes(app: FastifyInstance) { } } - // Serialize the entries - const content = serializeConfig(entries, configFile.parser as ConfigParser); + let originalContent: string | undefined; + try { + const current = await daemonReadFile(node, server.uuid, configFile.path); + originalContent = current.data.toString('utf8'); + } catch (error) { + if (!isMissingConfigFileError(error)) { + app.log.error({ error, serverId, path: configFile.path }, 'Failed to read existing config before write'); + throw new AppError(502, 'Failed to read existing config file', 'DAEMON_CONFIG_READ_FAILED'); + } + } - // TODO: Write file to daemon via gRPC - // For now, just return success + const content = serializeConfig( + entries, + configFile.parser as ConfigParser, + originalContent, + ); + + await daemonWriteFile(node, server.uuid, configFile.path, content); return { success: true, path: configFile.path, content }; }, ); @@ -127,13 +151,22 @@ async function getServerConfig( serverId: string, configIndex: number, ) { - const server = await app.db.query.servers.findFirst({ - where: and(eq(servers.id, serverId), eq(servers.organizationId, orgId)), - }); + const [server] = await app.db + .select({ + id: servers.id, + uuid: servers.uuid, + gameId: servers.gameId, + 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'); const game = await app.db.query.games.findFirst({ - where: eq(games.id, server.gameId), + where: eq(games.id, server.gameId as string), }); if (!game) throw AppError.notFound('Game not found'); @@ -141,5 +174,20 @@ async function getServerConfig( const configFile = configFiles[configIndex]; if (!configFile) throw AppError.notFound('Config file not found'); - return { game, server, configFile }; + const node: DaemonNodeConnection = { + fqdn: server.nodeFqdn, + grpcPort: server.nodeGrpcPort, + daemonToken: server.nodeDaemonToken, + }; + + return { game, server, node, configFile }; +} + +function isMissingConfigFileError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return ( + message.includes('No such file or directory') || + message.includes('Server responded with NOT_FOUND') || + message.includes('status code 404') + ); } diff --git a/apps/api/src/routes/servers/files.ts b/apps/api/src/routes/servers/files.ts new file mode 100644 index 0000000..1ebbb10 --- /dev/null +++ b/apps/api/src/routes/servers/files.ts @@ -0,0 +1,147 @@ +import type { FastifyInstance } from 'fastify'; +import { Type } from '@sinclair/typebox'; +import { and, eq } from 'drizzle-orm'; +import { nodes, servers } from '@source/database'; +import { AppError } from '../../lib/errors.js'; +import { requirePermission } from '../../lib/permissions.js'; +import { + daemonDeleteFiles, + daemonListFiles, + daemonReadFile, + daemonWriteFile, + type DaemonNodeConnection, +} from '../../lib/daemon.js'; + +const FileParamSchema = { + params: Type.Object({ + orgId: Type.String({ format: 'uuid' }), + serverId: Type.String({ format: 'uuid' }), + }), +}; + +export default async function fileRoutes(app: FastifyInstance) { + app.addHook('onRequest', app.authenticate); + + app.get( + '/', + { + schema: { + ...FileParamSchema, + querystring: Type.Object({ + path: Type.Optional(Type.String()), + }), + }, + }, + async (request) => { + const { orgId, serverId } = request.params as { orgId: string; serverId: string }; + const { path } = request.query as { path?: string }; + + await requirePermission(request, orgId, 'files.read'); + const serverContext = await getServerContext(app, orgId, serverId); + + const files = await daemonListFiles( + serverContext.node, + serverContext.serverUuid, + path?.trim() || '/', + ); + + return { files }; + }, + ); + + app.get( + '/read', + { + schema: { + ...FileParamSchema, + querystring: Type.Object({ + path: Type.String({ minLength: 1 }), + }), + }, + }, + async (request) => { + const { orgId, serverId } = request.params as { orgId: string; serverId: string }; + const { path } = request.query as { path: string }; + + await requirePermission(request, orgId, 'files.read'); + const serverContext = await getServerContext(app, orgId, serverId); + + const content = await daemonReadFile(serverContext.node, serverContext.serverUuid, path); + return { data: content.data.toString('utf8') }; + }, + ); + + app.post( + '/write', + { + schema: { + ...FileParamSchema, + body: Type.Object({ + path: Type.String({ minLength: 1 }), + data: Type.String(), + }), + }, + }, + async (request) => { + const { orgId, serverId } = request.params as { orgId: string; serverId: string }; + const { path, data } = request.body as { path: string; data: string }; + + await requirePermission(request, orgId, 'files.write'); + const serverContext = await getServerContext(app, orgId, serverId); + + await daemonWriteFile(serverContext.node, serverContext.serverUuid, path, data); + return { success: true, path }; + }, + ); + + app.post( + '/delete', + { + schema: { + ...FileParamSchema, + body: Type.Object({ + paths: Type.Array(Type.String({ minLength: 1 }), { minItems: 1 }), + }), + }, + }, + async (request) => { + const { orgId, serverId } = request.params as { orgId: string; serverId: string }; + const { paths } = request.body as { paths: string[] }; + + await requirePermission(request, orgId, 'files.delete'); + const serverContext = await getServerContext(app, orgId, serverId); + + await daemonDeleteFiles(serverContext.node, serverContext.serverUuid, paths); + return { success: true, paths }; + }, + ); +} + +async function getServerContext(app: FastifyInstance, orgId: string, serverId: string): Promise<{ + serverUuid: string; + node: DaemonNodeConnection; +}> { + const [server] = await app.db + .select({ + uuid: servers.uuid, + 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'); + } + + return { + serverUuid: server.uuid, + node: { + fqdn: server.nodeFqdn, + grpcPort: server.nodeGrpcPort, + daemonToken: server.nodeDaemonToken, + }, + }; +} diff --git a/apps/api/src/routes/servers/index.ts b/apps/api/src/routes/servers/index.ts index 17a0ee8..aedcbb6 100644 --- a/apps/api/src/routes/servers/index.ts +++ b/apps/api/src/routes/servers/index.ts @@ -1,12 +1,20 @@ 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, @@ -14,16 +22,123 @@ import { 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' }); @@ -128,24 +243,80 @@ export default async function serverRoutes(app: FastifyInstance) { 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 }) + .set({ serverId: server.id, isDefault: true }) .where(eq(allocations.id, body.allocationId)); - // TODO: Send gRPC CreateServer to daemon - // This will be implemented in Phase 4 + const nodeConnection: DaemonNodeConnection = { + fqdn: node.fqdn, + grpcPort: node.grpcPort, + daemonToken: node.daemonToken, + }; - await createAuditLog(app.db, request, { - organizationId: orgId, - serverId: server!.id, - action: 'server.create', - metadata: { name: body.name, gameSlug: game.slug, nodeId: body.nodeId }, - }); + 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: [], + }; - return reply.code(201).send(server); + 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 @@ -217,21 +388,43 @@ export default async function serverRoutes(app: FastifyInstance) { const { orgId, serverId } = request.params as { orgId: string; serverId: string }; await requirePermission(request, orgId, 'server.delete'); - const server = await app.db.query.servers.findFirst({ - where: and(eq(servers.id, serverId), eq(servers.organizationId, orgId)), - }); + 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 }) + .set({ serverId: null, isDefault: false }) .where(eq(allocations.serverId, serverId)); - // TODO: Send gRPC DeleteServer to daemon - - await app.db.delete(servers).where(eq(servers.id, serverId)); - await createAuditLog(app.db, request, { organizationId: orgId, serverId, @@ -239,6 +432,8 @@ export default async function serverRoutes(app: FastifyInstance) { metadata: { name: server.name, uuid: server.uuid }, }); + await app.db.delete(servers).where(eq(servers.id, serverId)); + return reply.code(204).send(); }); @@ -256,18 +451,43 @@ export default async function serverRoutes(app: FastifyInstance) { } as const; await requirePermission(request, orgId, permMap[action]); - const server = await app.db.query.servers.findFirst({ - where: and(eq(servers.id, serverId), eq(servers.organizationId, orgId)), - }); + 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'); } - // TODO: Send gRPC SetPowerState to daemon - // For now, just update status optimistically - const statusMap: Record = { + 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', @@ -276,7 +496,11 @@ export default async function serverRoutes(app: FastifyInstance) { await app.db .update(servers) - .set({ status: statusMap[action] as any, updatedAt: new Date() }) + .set({ + status: statusMap[action], + installedAt: statusMap[action] === 'running' ? new Date() : undefined, + updatedAt: new Date(), + }) .where(eq(servers.id, serverId)); await createAuditLog(app.db, request, { diff --git a/apps/api/src/routes/servers/players.ts b/apps/api/src/routes/servers/players.ts new file mode 100644 index 0000000..45770b2 --- /dev/null +++ b/apps/api/src/routes/servers/players.ts @@ -0,0 +1,63 @@ +import type { FastifyInstance } from 'fastify'; +import { Type } from '@sinclair/typebox'; +import { and, eq } from 'drizzle-orm'; +import { nodes, servers } from '@source/database'; +import { AppError } from '../../lib/errors.js'; +import { requirePermission } from '../../lib/permissions.js'; +import { daemonGetActivePlayers, type DaemonNodeConnection } from '../../lib/daemon.js'; + +const ParamSchema = { + params: Type.Object({ + orgId: Type.String({ format: 'uuid' }), + serverId: Type.String({ format: 'uuid' }), + }), +}; + +export default async function playerRoutes(app: FastifyInstance) { + app.addHook('onRequest', app.authenticate); + + app.get('/', { schema: ParamSchema }, async (request) => { + const { orgId, serverId } = request.params as { orgId: string; serverId: string }; + await requirePermission(request, orgId, 'server.read'); + + const serverContext = await getServerContext(app, orgId, serverId); + const players = await daemonGetActivePlayers(serverContext.node, serverContext.serverUuid); + + return { + players: players.players.map((player) => ({ + name: player.name, + steamid: player.id || undefined, + })), + maxPlayers: players.maxPlayers, + }; + }); +} + +async function getServerContext(app: FastifyInstance, orgId: string, serverId: string): Promise<{ + serverUuid: string; + node: DaemonNodeConnection; +}> { + const [server] = await app.db + .select({ + uuid: servers.uuid, + 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'); + } + + return { + serverUuid: server.uuid, + node: { + fqdn: server.nodeFqdn, + grpcPort: server.nodeGrpcPort, + daemonToken: server.nodeDaemonToken, + }, + }; +} diff --git a/apps/daemon/src/docker/container.rs b/apps/daemon/src/docker/container.rs index 88da499..c841e39 100644 --- a/apps/daemon/src/docker/container.rs +++ b/apps/daemon/src/docker/container.rs @@ -8,6 +8,7 @@ use bollard::container::{ use bollard::image::CreateImageOptions; use bollard::models::{HostConfig, PortBinding}; use futures::StreamExt; +use tokio::time::{sleep, Duration}; use tracing::info; use crate::docker::DockerManager; @@ -21,6 +22,56 @@ pub fn container_name(server_uuid: &str) -> String { } impl DockerManager { + async fn run_exec(&self, container_name: &str, cmd: Vec) -> Result { + let exec = self + .client() + .create_exec( + container_name, + bollard::exec::CreateExecOptions:: { + cmd: Some(cmd), + attach_stdout: Some(true), + attach_stderr: Some(true), + ..Default::default() + }, + ) + .await?; + + let mut captured = String::new(); + match self.client() + .start_exec(&exec.id, None::) + .await? + { + bollard::exec::StartExecResults::Attached { mut output, .. } => { + while let Some(chunk) = output.next().await { + let chunk = chunk?; + captured.push_str(&chunk.to_string()); + } + } + bollard::exec::StartExecResults::Detached => {} + } + + // Wait briefly for completion and collect exit code. + for _ in 0..30 { + let status = self.client().inspect_exec(&exec.id).await?; + if !status.running.unwrap_or(false) { + let code = status.exit_code.unwrap_or(0); + if code == 0 { + return Ok(captured); + } + return Err(anyhow::anyhow!("exec command failed with exit code {}", code)); + } + sleep(Duration::from_millis(100)).await; + } + + Err(anyhow::anyhow!("exec command timeout")) + } + + pub async fn rcon_command(&self, server_uuid: &str, command: &str) -> Result { + let name = container_name(server_uuid); + self.run_exec(&name, vec!["rcon-cli".to_string(), command.to_string()]) + .await + } + /// Pull a Docker image if not already present. pub async fn pull_image(&self, image: &str) -> Result<()> { info!(image = %image, "Pulling Docker image"); @@ -241,23 +292,20 @@ impl DockerManager { pub async fn send_command(&self, server_uuid: &str, command: &str) -> Result<()> { let name = container_name(server_uuid); - let exec = self - .client() - .create_exec( - &name, - bollard::exec::CreateExecOptions { - cmd: Some(vec!["sh", "-c", &format!("echo '{}' > /proc/1/fd/0", command)]), - attach_stdout: Some(true), - attach_stderr: Some(true), - ..Default::default() - }, - ) - .await?; + // Preferred path for Minecraft-like images where rcon-cli is available. + if self + .run_exec(&name, vec!["rcon-cli".to_string(), command.to_string()]) + .await + .is_ok() + { + return Ok(()); + } - self.client() - .start_exec(&exec.id, None::) - .await?; - - Ok(()) + // Generic fallback: write directly to PID 1 stdin. + let escaped = command.replace('\'', "'\"'\"'"); + let shell_cmd = format!("printf '%s\\n' '{}' > /proc/1/fd/0", escaped); + self.run_exec(&name, vec!["sh".to_string(), "-c".to_string(), shell_cmd]) + .await + .map(|_| ()) } } diff --git a/apps/daemon/src/grpc/service.rs b/apps/daemon/src/grpc/service.rs index 56d05cf..008e1e8 100644 --- a/apps/daemon/src/grpc/service.rs +++ b/apps/daemon/src/grpc/service.rs @@ -1,11 +1,12 @@ use std::pin::Pin; use std::sync::Arc; use std::time::Instant; +use std::collections::HashMap; use futures::StreamExt; use tokio_stream::wrappers::ReceiverStream; use tonic::{Request, Response, Status}; -use tracing::{info, error}; +use tracing::{info, error, warn}; use crate::server::{ServerManager, PortMap}; use crate::filesystem::FileSystem; @@ -241,9 +242,6 @@ impl DaemonService for DaemonServiceImpl { self.check_auth(&request)?; let uuid = request.into_inner().uuid; - // Verify server exists - let _ = self.server_manager.get_server(&uuid).await.map_err(Status::from)?; - let (tx, rx) = tokio::sync::mpsc::channel(256); let docker = self.server_manager.docker().clone(); @@ -482,10 +480,151 @@ impl DaemonService for DaemonServiceImpl { request: Request, ) -> Result, Status> { self.check_auth(&request)?; - // TODO: implement game-specific player queries (RCON) + let uuid = request.into_inner().uuid; + + let fs = self.get_fs(&uuid); + let properties = match fs.read_file("server.properties").await { + Ok(data) => parse_properties_map(&String::from_utf8_lossy(&data)), + Err(_) => HashMap::new(), + }; + let max_from_properties = properties + .get("max-players") + .and_then(|v| v.parse::().ok()) + .unwrap_or(0); + let rcon_enabled_from_properties = properties + .get("enable-rcon") + .map(|v| v.eq_ignore_ascii_case("true")) + .unwrap_or(false); + let rcon_password_from_properties = properties + .get("rcon.password") + .filter(|v| !v.trim().is_empty()) + .cloned(); + let rcon_port_from_properties = properties + .get("rcon.port") + .and_then(|v| v.parse::().ok()) + .unwrap_or(25575); + + // Try RCON-based player discovery for known games when runtime spec exists. + if let Ok(spec) = self.server_manager.get_server(&uuid).await { + let image = spec.docker_image.to_lowercase(); + + if image.contains("minecraft") { + let password_from_env = spec + .environment + .get("RCON_PASSWORD") + .or_else(|| spec.environment.get("MCRCON_PASSWORD")) + .filter(|v| !v.trim().is_empty()); + + let password = password_from_env + .cloned() + .or_else(|| { + if rcon_enabled_from_properties { + rcon_password_from_properties.clone() + } else { + None + } + }); + + if let Some(password) = password { + let host = spec + .environment + .get("RCON_HOST") + .filter(|v| !v.trim().is_empty()) + .cloned() + .unwrap_or_else(|| "127.0.0.1".to_string()); + let port = spec + .environment + .get("RCON_PORT") + .and_then(|v| v.parse::().ok()) + .unwrap_or(rcon_port_from_properties); + let address = format!("{}:{}", host, port); + + match crate::game::minecraft::get_players(&address, &password).await { + Ok((players, max)) => { + let mapped = players + .into_iter() + .map(|p| Player { + name: p.name, + uuid: String::new(), + connected_at: 0, + }) + .collect(); + return Ok(Response::new(PlayerList { + players: mapped, + max_players: max as i32, + })); + } + Err(e) => { + warn!(uuid = %uuid, error = %e, "Minecraft RCON player query failed"); + } + } + } + } else if image.contains("csgo") || image.contains("cs2") { + if let Some(password) = spec + .environment + .get("SRCDS_RCONPW") + .or_else(|| spec.environment.get("RCON_PASSWORD")) + .filter(|v| !v.trim().is_empty()) + { + let host = spec + .environment + .get("RCON_HOST") + .filter(|v| !v.trim().is_empty()) + .cloned() + .unwrap_or_else(|| "127.0.0.1".to_string()); + let port = spec + .environment + .get("RCON_PORT") + .and_then(|v| v.parse::().ok()) + .unwrap_or(27015); + let address = format!("{}:{}", host, port); + + match crate::game::cs2::get_players(&address, password).await { + Ok((players, max)) => { + let mapped = players + .into_iter() + .map(|p| Player { + name: p.name, + uuid: p.steamid, + connected_at: 0, + }) + .collect(); + return Ok(Response::new(PlayerList { + players: mapped, + max_players: max as i32, + })); + } + Err(e) => { + warn!(uuid = %uuid, error = %e, "CS2 RCON player query failed"); + } + } + } + } + } + + // Fallback for restarted daemon / missing runtime spec: + // try querying `rcon-cli list` inside the container and parse output. + if let Ok(output) = self.server_manager.docker().rcon_command(&uuid, "list").await { + let (names, max) = parse_minecraft_list_output(&output); + if !names.is_empty() || max > 0 { + let mapped = names + .into_iter() + .map(|name| Player { + name, + uuid: String::new(), + connected_at: 0, + }) + .collect(); + return Ok(Response::new(PlayerList { + players: mapped, + max_players: if max > 0 { max } else { max_from_properties }, + })); + } + } + Ok(Response::new(PlayerList { players: vec![], - max_players: 0, + max_players: max_from_properties, })) } } @@ -509,3 +648,55 @@ fn calculate_cpu_percent(stats: &bollard::container::Stats) -> f64 { 0.0 } } + +fn parse_properties_map(content: &str) -> HashMap { + let mut props = HashMap::new(); + for line in content.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with('!') { + continue; + } + let mut parts = trimmed.splitn(2, '='); + let Some(key) = parts.next() else { continue }; + let Some(value) = parts.next() else { continue }; + props.insert(key.trim().to_string(), value.trim().to_string()); + } + props +} + +fn parse_minecraft_list_output(output: &str) -> (Vec, i32) { + let mut max_players = 0i32; + let mut names = Vec::new(); + + // Typical response: + // "There are 1 of a max of 20 players online: player1, player2" + let parts: Vec<&str> = output.splitn(2, ':').collect(); + + if let Some(header) = parts.first() { + let mut first_number_seen = false; + for token in header.split_whitespace() { + if let Ok(value) = token.parse::() { + if !first_number_seen { + first_number_seen = true; + } else { + max_players = value; + break; + } + } + } + } + + if parts.len() > 1 { + let players = parts[1].trim(); + if !players.is_empty() { + for name in players.split(',') { + let clean = name.trim(); + if !clean.is_empty() { + names.push(clean.to_string()); + } + } + } + } + + (names, max_players) +} diff --git a/apps/daemon/src/server/manager.rs b/apps/daemon/src/server/manager.rs index 866765e..ba0331e 100644 --- a/apps/daemon/src/server/manager.rs +++ b/apps/daemon/src/server/manager.rs @@ -130,28 +130,32 @@ impl ServerManager { /// Start a server. pub async fn start_server(&self, uuid: &str) -> Result<(), DaemonError> { - let mut servers = self.servers.write().await; - let spec = servers - .get_mut(uuid) - .ok_or_else(|| DaemonError::ServerNotFound(uuid.to_string()))?; - - if !spec.can_transition_to(&ServerState::Starting) { - return Err(DaemonError::InvalidStateTransition { - current: spec.state.to_string(), - requested: "starting".to_string(), - }); + let mut managed = false; + { + let mut servers = self.servers.write().await; + if let Some(spec) = servers.get_mut(uuid) { + if !spec.can_transition_to(&ServerState::Starting) { + return Err(DaemonError::InvalidStateTransition { + current: spec.state.to_string(), + requested: "starting".to_string(), + }); + } + spec.state = ServerState::Starting; + managed = true; + } } - spec.state = ServerState::Starting; - drop(servers); - self.docker.start_container(uuid).await.map_err(|e| { DaemonError::Internal(format!("Failed to start container: {}", e)) })?; - let mut servers = self.servers.write().await; - if let Some(spec) = servers.get_mut(uuid) { - spec.state = ServerState::Running; + if managed { + let mut servers = self.servers.write().await; + if let Some(spec) = servers.get_mut(uuid) { + spec.state = ServerState::Running; + } + } else { + info!(uuid = %uuid, "Started container without managed runtime state"); } Ok(()) @@ -159,28 +163,32 @@ impl ServerManager { /// Stop a server. pub async fn stop_server(&self, uuid: &str) -> Result<(), DaemonError> { - let mut servers = self.servers.write().await; - let spec = servers - .get_mut(uuid) - .ok_or_else(|| DaemonError::ServerNotFound(uuid.to_string()))?; - - if !spec.can_transition_to(&ServerState::Stopping) { - return Err(DaemonError::InvalidStateTransition { - current: spec.state.to_string(), - requested: "stopping".to_string(), - }); + let mut managed = false; + { + let mut servers = self.servers.write().await; + if let Some(spec) = servers.get_mut(uuid) { + if !spec.can_transition_to(&ServerState::Stopping) { + return Err(DaemonError::InvalidStateTransition { + current: spec.state.to_string(), + requested: "stopping".to_string(), + }); + } + spec.state = ServerState::Stopping; + managed = true; + } } - spec.state = ServerState::Stopping; - drop(servers); - self.docker.stop_container(uuid, 30).await.map_err(|e| { DaemonError::Internal(format!("Failed to stop container: {}", e)) })?; - let mut servers = self.servers.write().await; - if let Some(spec) = servers.get_mut(uuid) { - spec.state = ServerState::Stopped; + if managed { + let mut servers = self.servers.write().await; + if let Some(spec) = servers.get_mut(uuid) { + spec.state = ServerState::Stopped; + } + } else { + info!(uuid = %uuid, "Stopped container without managed runtime state"); } Ok(()) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fecc764..4d6ad08 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -50,12 +50,21 @@ importers: '@fastify/websocket': specifier: ^11.0.0 version: 11.2.0 + '@grpc/grpc-js': + specifier: ^1.14.0 + version: 1.14.3 + '@grpc/proto-loader': + specifier: ^0.8.0 + version: 0.8.0 '@sinclair/typebox': specifier: ^0.34.0 version: 0.34.48 '@source/database': specifier: workspace:* version: link:../../packages/database + '@source/proto': + specifier: workspace:* + version: link:../../packages/proto '@source/shared': specifier: workspace:* version: link:../../packages/shared @@ -1006,6 +1015,15 @@ packages: '@floating-ui/utils@0.2.10': resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} + '@grpc/grpc-js@1.14.3': + resolution: {integrity: sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==} + engines: {node: '>=12.10.0'} + + '@grpc/proto-loader@0.8.0': + resolution: {integrity: sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ==} + engines: {node: '>=6'} + hasBin: true + '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -1038,6 +1056,9 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@js-sdsl/ordered-map@4.4.2': + resolution: {integrity: sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==} + '@lukeed/ms@2.0.2': resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==} engines: {node: '>=8'} @@ -1064,6 +1085,36 @@ packages: '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + '@protobufjs/aspromise@1.1.2': + resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} + + '@protobufjs/base64@1.1.2': + resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==} + + '@protobufjs/codegen@2.0.4': + resolution: {integrity: sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==} + + '@protobufjs/eventemitter@1.1.0': + resolution: {integrity: sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==} + + '@protobufjs/fetch@1.1.0': + resolution: {integrity: sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==} + + '@protobufjs/float@1.0.2': + resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} + + '@protobufjs/inquire@1.1.0': + resolution: {integrity: sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==} + + '@protobufjs/path@1.1.2': + resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} + + '@protobufjs/pool@1.1.0': + resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==} + + '@protobufjs/utf8@1.1.0': + resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==} + '@radix-ui/number@1.1.1': resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} @@ -1820,6 +1871,10 @@ packages: ajv@8.18.0: resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + ansi-styles@4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} @@ -1921,6 +1976,10 @@ packages: class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + clsx@2.1.1: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} @@ -2114,6 +2173,9 @@ packages: electron-to-chromium@1.5.302: resolution: {integrity: sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==} + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + end-of-stream@1.4.5: resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} @@ -2323,6 +2385,10 @@ packages: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + get-nonce@1.0.1: resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} engines: {node: '>=6'} @@ -2392,6 +2458,10 @@ packages: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + is-glob@4.0.3: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} @@ -2468,9 +2538,15 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + lodash.camelcase@4.3.0: + resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} + lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -2691,6 +2767,10 @@ packages: process-warning@5.0.0: resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} + protobufjs@7.5.4: + resolution: {integrity: sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==} + engines: {node: '>=12.0.0'} + pump@3.0.3: resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==} @@ -2772,6 +2852,10 @@ packages: resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} engines: {node: '>= 12.13.0'} + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + require-from-string@2.0.2: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} @@ -2895,9 +2979,17 @@ packages: stream-shift@1.0.3: resolution: {integrity: sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==} + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + strip-json-comments@3.1.1: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} @@ -3118,6 +3210,10 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} @@ -3153,9 +3249,21 @@ packages: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -3721,6 +3829,18 @@ snapshots: '@floating-ui/utils@0.2.10': {} + '@grpc/grpc-js@1.14.3': + dependencies: + '@grpc/proto-loader': 0.8.0 + '@js-sdsl/ordered-map': 4.4.2 + + '@grpc/proto-loader@0.8.0': + dependencies: + lodash.camelcase: 4.3.0 + long: 5.3.2 + protobufjs: 7.5.4 + yargs: 17.7.2 + '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.7': @@ -3751,6 +3871,8 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@js-sdsl/ordered-map@4.4.2': {} + '@lukeed/ms@2.0.2': {} '@nodelib/fs.scandir@2.1.5': @@ -3771,6 +3893,29 @@ snapshots: '@pinojs/redact@0.4.0': {} + '@protobufjs/aspromise@1.1.2': {} + + '@protobufjs/base64@1.1.2': {} + + '@protobufjs/codegen@2.0.4': {} + + '@protobufjs/eventemitter@1.1.0': {} + + '@protobufjs/fetch@1.1.0': + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/inquire': 1.1.0 + + '@protobufjs/float@1.0.2': {} + + '@protobufjs/inquire@1.1.0': {} + + '@protobufjs/path@1.1.2': {} + + '@protobufjs/pool@1.1.0': {} + + '@protobufjs/utf8@1.1.0': {} + '@radix-ui/number@1.1.1': {} '@radix-ui/primitive@1.1.3': {} @@ -4511,6 +4656,8 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 + ansi-regex@5.0.1: {} + ansi-styles@4.3.0: dependencies: color-convert: 2.0.1 @@ -4619,6 +4766,12 @@ snapshots: dependencies: clsx: 2.1.1 + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + clsx@2.1.1: {} color-convert@2.0.1: @@ -4710,6 +4863,8 @@ snapshots: electron-to-chromium@1.5.302: {} + emoji-regex@8.0.0: {} + end-of-stream@1.4.5: dependencies: once: 1.4.0 @@ -5070,6 +5225,8 @@ snapshots: gensync@1.0.0-beta.2: {} + get-caller-file@2.0.5: {} + get-nonce@1.0.1: {} get-tsconfig@4.13.6: @@ -5121,6 +5278,8 @@ snapshots: is-extglob@2.1.1: {} + is-fullwidth-code-point@3.0.0: {} + is-glob@4.0.3: dependencies: is-extglob: 2.1.1 @@ -5180,8 +5339,12 @@ snapshots: dependencies: p-locate: 5.0.0 + lodash.camelcase@4.3.0: {} + lodash.merge@4.6.2: {} + long@5.3.2: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -5378,6 +5541,21 @@ snapshots: process-warning@5.0.0: {} + protobufjs@7.5.4: + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/base64': 1.1.2 + '@protobufjs/codegen': 2.0.4 + '@protobufjs/eventemitter': 1.1.0 + '@protobufjs/fetch': 1.1.0 + '@protobufjs/float': 1.0.2 + '@protobufjs/inquire': 1.1.0 + '@protobufjs/path': 1.1.2 + '@protobufjs/pool': 1.1.0 + '@protobufjs/utf8': 1.1.0 + '@types/node': 22.19.11 + long: 5.3.2 + pump@3.0.3: dependencies: end-of-stream: 1.4.5 @@ -5449,6 +5627,8 @@ snapshots: real-require@0.2.0: {} + require-directory@2.1.1: {} + require-from-string@2.0.2: {} resolve-from@4.0.0: {} @@ -5601,10 +5781,20 @@ snapshots: stream-shift@1.0.3: {} + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + string_decoder@1.3.0: dependencies: safe-buffer: 5.2.1 + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + strip-json-comments@3.1.1: {} strip-json-comments@5.0.3: {} @@ -5799,6 +5989,12 @@ snapshots: word-wrap@1.2.5: {} + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrappy@1.0.2: {} ws@8.18.3: {} @@ -5809,8 +6005,22 @@ snapshots: xtend@4.0.2: {} + y18n@5.0.8: {} + yallist@3.1.1: {} + yargs-parser@21.1.1: {} + + yargs@17.7.2: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + yocto-queue@0.1.0: {} zustand@5.0.11(@types/react@19.2.14)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)):