diff --git a/apps/api/src/lib/config-parsers.ts b/apps/api/src/lib/config-parsers.ts new file mode 100644 index 0000000..30b57df --- /dev/null +++ b/apps/api/src/lib/config-parsers.ts @@ -0,0 +1,234 @@ +import type { ConfigParser, ConfigEntry } from '@source/shared'; + +/** + * Parse a config file content into key-value entries based on the parser type. + */ +export function parseConfig(content: string, parser: ConfigParser): ConfigEntry[] { + switch (parser) { + case 'properties': + return parseProperties(content); + case 'json': + return parseJson(content); + case 'yaml': + return parseYaml(content); + case 'keyvalue': + return parseKeyValue(content); + default: + return []; + } +} + +/** + * Serialize key-value entries back into a config file content. + */ +export function serializeConfig( + entries: ConfigEntry[], + parser: ConfigParser, + originalContent?: string, +): string { + switch (parser) { + case 'properties': + return serializeProperties(entries, originalContent); + case 'json': + return serializeJson(entries); + case 'yaml': + return serializeYaml(entries, originalContent); + case 'keyvalue': + return serializeKeyValue(entries, originalContent); + default: + return ''; + } +} + +// === Properties (Java .properties format) === + +function parseProperties(content: string): ConfigEntry[] { + const entries: ConfigEntry[] = []; + for (const line of content.split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('!')) continue; + const eqIndex = trimmed.indexOf('='); + if (eqIndex === -1) continue; + entries.push({ + key: trimmed.substring(0, eqIndex).trim(), + value: trimmed.substring(eqIndex + 1).trim(), + }); + } + return entries; +} + +function serializeProperties(entries: ConfigEntry[], originalContent?: string): string { + if (!originalContent) { + return entries.map((e) => `${e.key}=${e.value}`).join('\n') + '\n'; + } + + const entryMap = new Map(entries.map((e) => [e.key, e.value])); + const lines = originalContent.split('\n'); + const result: string[] = []; + const written = new Set(); + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('!')) { + result.push(line); + continue; + } + const eqIndex = trimmed.indexOf('='); + if (eqIndex === -1) { + result.push(line); + continue; + } + const key = trimmed.substring(0, eqIndex).trim(); + if (entryMap.has(key)) { + result.push(`${key}=${entryMap.get(key)}`); + written.add(key); + } else { + result.push(line); + } + } + + // Append new keys + for (const entry of entries) { + if (!written.has(entry.key)) { + result.push(`${entry.key}=${entry.value}`); + } + } + + return result.join('\n'); +} + +// === JSON === + +function parseJson(content: string): ConfigEntry[] { + try { + const obj = JSON.parse(content); + if (typeof obj !== 'object' || Array.isArray(obj)) return []; + return Object.entries(obj).map(([key, value]) => ({ + key, + value: typeof value === 'string' ? value : JSON.stringify(value), + })); + } catch { + return []; + } +} + +function serializeJson(entries: ConfigEntry[]): string { + const obj: Record = {}; + for (const entry of entries) { + try { + obj[entry.key] = JSON.parse(entry.value); + } catch { + obj[entry.key] = entry.value; + } + } + return JSON.stringify(obj, null, 2) + '\n'; +} + +// === YAML (simplified — only top-level key: value) === + +function parseYaml(content: string): ConfigEntry[] { + const entries: ConfigEntry[] = []; + for (const line of content.split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + // Only handle top-level keys (no indentation) + if (line.startsWith(' ') || line.startsWith('\t')) continue; + const colonIndex = trimmed.indexOf(':'); + if (colonIndex === -1) continue; + const key = trimmed.substring(0, colonIndex).trim(); + const value = trimmed.substring(colonIndex + 1).trim(); + if (key) entries.push({ key, value }); + } + return entries; +} + +function serializeYaml(entries: ConfigEntry[], originalContent?: string): string { + if (!originalContent) { + return entries.map((e) => `${e.key}: ${e.value}`).join('\n') + '\n'; + } + + const entryMap = new Map(entries.map((e) => [e.key, e.value])); + const lines = originalContent.split('\n'); + const result: string[] = []; + const written = new Set(); + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#') || line.startsWith(' ') || line.startsWith('\t')) { + result.push(line); + continue; + } + const colonIndex = trimmed.indexOf(':'); + if (colonIndex === -1) { + result.push(line); + continue; + } + const key = trimmed.substring(0, colonIndex).trim(); + if (entryMap.has(key)) { + result.push(`${key}: ${entryMap.get(key)}`); + written.add(key); + } else { + result.push(line); + } + } + + for (const entry of entries) { + if (!written.has(entry.key)) { + result.push(`${entry.key}: ${entry.value}`); + } + } + + return result.join('\n'); +} + +// === KeyValue (Source engine cfg: `key "value"` or `key value`) === + +function parseKeyValue(content: string): ConfigEntry[] { + const entries: ConfigEntry[] = []; + for (const line of content.split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('//')) continue; + + // Match: key "value" or key value + const match = trimmed.match(/^(\S+)\s+"([^"]*)"/) || trimmed.match(/^(\S+)\s+(.*)/); + if (match && match[1] && match[2] !== undefined) { + entries.push({ key: match[1], value: match[2] }); + } + } + return entries; +} + +function serializeKeyValue(entries: ConfigEntry[], originalContent?: string): string { + if (!originalContent) { + return entries.map((e) => `${e.key} "${e.value}"`).join('\n') + '\n'; + } + + const entryMap = new Map(entries.map((e) => [e.key, e.value])); + const lines = originalContent.split('\n'); + const result: string[] = []; + const written = new Set(); + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('//')) { + result.push(line); + continue; + } + const match = trimmed.match(/^(\S+)\s+/); + const matchKey = match?.[1]; + if (matchKey && entryMap.has(matchKey)) { + result.push(`${matchKey} "${entryMap.get(matchKey)}"`); + written.add(matchKey); + } else { + result.push(line); + } + } + + for (const entry of entries) { + if (!written.has(entry.key)) { + result.push(`${entry.key} "${entry.value}"`); + } + } + + return result.join('\n'); +} diff --git a/apps/api/src/lib/spiget.ts b/apps/api/src/lib/spiget.ts new file mode 100644 index 0000000..9699d35 --- /dev/null +++ b/apps/api/src/lib/spiget.ts @@ -0,0 +1,56 @@ +const SPIGET_BASE = 'https://api.spiget.org/v2'; + +export interface SpigetResource { + id: number; + name: string; + tag: string; + icon: { url: string; data: string }; + releaseDate: number; + updateDate: number; + downloads: number; + rating: { average: number; count: number }; + file: { type: string; size: number; url: string }; + version: { id: number }; + external: boolean; +} + +export interface SpigetVersion { + id: number; + name: string; + releaseDate: number; + downloads: number; + url: string; +} + +export async function searchSpigetPlugins( + query: string, + page = 1, + size = 20, +): Promise { + const res = await fetch( + `${SPIGET_BASE}/search/resources/${encodeURIComponent(query)}?size=${size}&page=${page}&sort=-downloads`, + { headers: { 'User-Agent': 'GamePanel/1.0' } }, + ); + if (!res.ok) return []; + return res.json() as Promise; +} + +export async function getSpigetResource(id: number): Promise { + const res = await fetch(`${SPIGET_BASE}/resources/${id}`, { + headers: { 'User-Agent': 'GamePanel/1.0' }, + }); + if (!res.ok) return null; + return res.json() as Promise; +} + +export async function getSpigetVersions(resourceId: number): Promise { + const res = await fetch(`${SPIGET_BASE}/resources/${resourceId}/versions?sort=-releaseDate`, { + headers: { 'User-Agent': 'GamePanel/1.0' }, + }); + if (!res.ok) return []; + return res.json() as Promise; +} + +export function getSpigetDownloadUrl(resourceId: number): string { + return `${SPIGET_BASE}/resources/${resourceId}/download`; +} diff --git a/apps/api/src/routes/servers/config.ts b/apps/api/src/routes/servers/config.ts new file mode 100644 index 0000000..72a67e3 --- /dev/null +++ b/apps/api/src/routes/servers/config.ts @@ -0,0 +1,145 @@ +import type { FastifyInstance } from 'fastify'; +import { eq, and } from 'drizzle-orm'; +import { Type } from '@sinclair/typebox'; +import { servers, games } 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'; + +const ParamSchema = { + params: Type.Object({ + orgId: Type.String({ format: 'uuid' }), + serverId: Type.String({ format: 'uuid' }), + }), +}; + +const ConfigFileParamSchema = { + params: Type.Object({ + orgId: Type.String({ format: 'uuid' }), + serverId: Type.String({ format: 'uuid' }), + configIndex: Type.Number({ minimum: 0 }), + }), +}; + +export default async function configRoutes(app: FastifyInstance) { + app.addHook('onRequest', app.authenticate); + + // GET /config — list available config files for this server's game + app.get('/', { schema: ParamSchema }, async (request) => { + const { orgId, serverId } = request.params as { orgId: string; serverId: string }; + await requirePermission(request, orgId, 'config.read'); + + const server = await app.db.query.servers.findFirst({ + 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), + }); + if (!game) throw AppError.notFound('Game not found'); + + const configFiles = (game.configFiles as GameConfigFile[]) || []; + return { + configs: configFiles.map((cf, index) => ({ + index, + path: cf.path, + parser: cf.parser, + editableKeys: cf.editableKeys ?? null, + })), + }; + }); + + // GET /config/:configIndex — read & parse a specific config file + app.get('/:configIndex', { schema: ConfigFileParamSchema }, async (request) => { + const { orgId, serverId, configIndex } = request.params as { + orgId: string; + serverId: string; + configIndex: number; + }; + await requirePermission(request, orgId, 'config.read'); + + const { game, server, 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) + return { + path: configFile.path, + parser: configFile.parser, + editableKeys: configFile.editableKeys ?? null, + entries: [], + raw: '', + }; + }); + + // PUT /config/:configIndex — update a config file + app.put( + '/:configIndex', + { + schema: { + ...ConfigFileParamSchema, + body: Type.Object({ + entries: Type.Array( + Type.Object({ + key: Type.String(), + value: Type.String(), + }), + ), + }), + }, + }, + async (request) => { + const { orgId, serverId, configIndex } = request.params as { + orgId: string; + serverId: string; + configIndex: number; + }; + const { entries } = request.body as { entries: { key: string; value: string }[] }; + await requirePermission(request, orgId, 'config.write'); + + const { configFile } = await getServerConfig(app, orgId, serverId, configIndex); + + // If editableKeys is set, only allow those keys + if (configFile.editableKeys && configFile.editableKeys.length > 0) { + const allowedKeys = new Set(configFile.editableKeys); + const invalidKeys = entries.filter((e) => !allowedKeys.has(e.key)); + if (invalidKeys.length > 0) { + throw AppError.badRequest( + `Keys not allowed: ${invalidKeys.map((k) => k.key).join(', ')}`, + ); + } + } + + // Serialize the entries + const content = serializeConfig(entries, configFile.parser as ConfigParser); + + // TODO: Write file to daemon via gRPC + // For now, just return success + return { success: true, path: configFile.path, content }; + }, + ); +} + +async function getServerConfig( + app: FastifyInstance, + orgId: string, + serverId: string, + configIndex: number, +) { + const server = await app.db.query.servers.findFirst({ + 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), + }); + if (!game) throw AppError.notFound('Game not found'); + + const configFiles = (game.configFiles as GameConfigFile[]) || []; + const configFile = configFiles[configIndex]; + if (!configFile) throw AppError.notFound('Config file not found'); + + return { game, server, configFile }; +} diff --git a/apps/api/src/routes/servers/index.ts b/apps/api/src/routes/servers/index.ts index 2384f18..f8be25c 100644 --- a/apps/api/src/routes/servers/index.ts +++ b/apps/api/src/routes/servers/index.ts @@ -13,10 +13,16 @@ import { UpdateServerSchema, PowerActionSchema, } from './schemas.js'; +import configRoutes from './config.js'; +import pluginRoutes from './plugins.js'; export default async function serverRoutes(app: FastifyInstance) { app.addHook('onRequest', app.authenticate); + // Register sub-routes for config and plugins + await app.register(configRoutes, { prefix: '/:serverId/config' }); + await app.register(pluginRoutes, { prefix: '/:serverId/plugins' }); + // GET /api/organizations/:orgId/servers app.get('/', { schema: { querystring: PaginationQuerySchema } }, async (request) => { const { orgId } = request.params as { orgId: string }; diff --git a/apps/api/src/routes/servers/plugins.ts b/apps/api/src/routes/servers/plugins.ts new file mode 100644 index 0000000..b5bb75e --- /dev/null +++ b/apps/api/src/routes/servers/plugins.ts @@ -0,0 +1,336 @@ +import type { FastifyInstance } from 'fastify'; +import { eq, and } from 'drizzle-orm'; +import { Type } from '@sinclair/typebox'; +import { servers, plugins, serverPlugins, games } from '@source/database'; +import { AppError } from '../../lib/errors.js'; +import { requirePermission } from '../../lib/permissions.js'; +import { createAuditLog } from '../../lib/audit.js'; +import { + searchSpigetPlugins, + getSpigetResource, + getSpigetDownloadUrl, +} from '../../lib/spiget.js'; + +const ParamSchema = { + params: Type.Object({ + orgId: Type.String({ format: 'uuid' }), + serverId: Type.String({ format: 'uuid' }), + }), +}; + +export default async function pluginRoutes(app: FastifyInstance) { + app.addHook('onRequest', app.authenticate); + + // GET /plugins — list installed plugins for this server + app.get('/', { schema: ParamSchema }, async (request) => { + const { orgId, serverId } = request.params as { orgId: string; serverId: string }; + await requirePermission(request, orgId, 'plugin.read'); + + const server = await app.db.query.servers.findFirst({ + where: and(eq(servers.id, serverId), eq(servers.organizationId, orgId)), + }); + if (!server) throw AppError.notFound('Server not found'); + + const installed = await app.db + .select({ + id: serverPlugins.id, + pluginId: serverPlugins.pluginId, + installedVersion: serverPlugins.installedVersion, + isActive: serverPlugins.isActive, + installedAt: serverPlugins.installedAt, + name: plugins.name, + slug: plugins.slug, + description: plugins.description, + source: plugins.source, + externalId: plugins.externalId, + }) + .from(serverPlugins) + .innerJoin(plugins, eq(serverPlugins.pluginId, plugins.id)) + .where(eq(serverPlugins.serverId, serverId)); + + return { plugins: installed }; + }); + + // GET /plugins/search — search Spiget for Minecraft plugins + app.get( + '/search', + { + schema: { + ...ParamSchema, + querystring: Type.Object({ + q: Type.String({ minLength: 2 }), + page: Type.Optional(Type.Number({ minimum: 1, default: 1 })), + }), + }, + }, + async (request) => { + const { orgId, serverId } = request.params as { orgId: string; serverId: string }; + const { q, page } = request.query as { q: string; page?: number }; + await requirePermission(request, orgId, 'plugin.manage'); + + // Verify server exists and is Minecraft + const server = await app.db.query.servers.findFirst({ + 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), + }); + if (!game) throw AppError.notFound('Game not found'); + + if (game.slug !== 'minecraft-java') { + throw AppError.badRequest('Spiget search is only available for Minecraft: Java Edition'); + } + + const results = await searchSpigetPlugins(q, page ?? 1); + return { + results: results.map((r) => ({ + id: r.id, + name: r.name, + tag: r.tag, + downloads: r.downloads, + rating: r.rating, + updateDate: r.updateDate, + external: r.external, + })), + }; + }, + ); + + // POST /plugins/install/spiget — install a plugin from Spiget + app.post( + '/install/spiget', + { + schema: { + ...ParamSchema, + body: Type.Object({ + resourceId: Type.Number(), + }), + }, + }, + async (request) => { + const { orgId, serverId } = request.params as { orgId: string; serverId: string }; + const { resourceId } = request.body as { resourceId: number }; + await requirePermission(request, orgId, 'plugin.manage'); + + const server = await app.db.query.servers.findFirst({ + 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), + }); + if (!game) throw AppError.notFound('Game not found'); + + // Fetch resource info from Spiget + const resource = await getSpigetResource(resourceId); + if (!resource) throw AppError.notFound('Spiget resource not found'); + + // Create or find plugin entry + let plugin = await app.db.query.plugins.findFirst({ + where: and( + eq(plugins.gameId, game.id), + eq(plugins.externalId, String(resourceId)), + eq(plugins.source, 'spiget'), + ), + }); + + if (!plugin) { + const [created] = await app.db + .insert(plugins) + .values({ + gameId: game.id, + name: resource.name, + slug: resource.name + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .slice(0, 200), + description: resource.tag || null, + source: 'spiget', + externalId: String(resourceId), + downloadUrl: getSpigetDownloadUrl(resourceId), + version: null, + }) + .returning(); + plugin = created!; + } + + // Check if already installed + const existing = await app.db.query.serverPlugins.findFirst({ + where: and( + eq(serverPlugins.serverId, serverId), + eq(serverPlugins.pluginId, plugin.id), + ), + }); + if (existing) throw AppError.conflict('Plugin is already installed'); + + // Install + const [installed] = await app.db + .insert(serverPlugins) + .values({ + serverId, + pluginId: plugin.id, + installedVersion: resource.version ? String(resource.version.id) : null, + isActive: true, + }) + .returning(); + + // TODO: Send gRPC command to daemon to download the plugin file to /data/plugins/ + // downloadUrl: getSpigetDownloadUrl(resourceId) + + await createAuditLog(app.db, request, { + organizationId: orgId, + serverId, + action: 'plugin.install', + metadata: { name: resource.name, source: 'spiget', resourceId }, + }); + + return installed; + }, + ); + + // POST /plugins/install/manual — install a plugin manually (upload) + app.post( + '/install/manual', + { + schema: { + ...ParamSchema, + body: Type.Object({ + name: Type.String({ minLength: 1 }), + fileName: Type.String({ minLength: 1 }), + version: Type.Optional(Type.String()), + }), + }, + }, + async (request) => { + const { orgId, serverId } = request.params as { orgId: string; serverId: string }; + const { name, fileName, version } = request.body as { + name: string; + fileName: string; + version?: string; + }; + await requirePermission(request, orgId, 'plugin.manage'); + + const server = await app.db.query.servers.findFirst({ + where: and(eq(servers.id, serverId), eq(servers.organizationId, orgId)), + }); + if (!server) throw AppError.notFound('Server not found'); + + const [plugin] = await app.db + .insert(plugins) + .values({ + gameId: server.gameId, + name, + slug: name + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .slice(0, 200), + source: 'manual', + version: version ?? null, + }) + .returning(); + + const [installed] = await app.db + .insert(serverPlugins) + .values({ + serverId, + pluginId: plugin!.id, + installedVersion: version ?? null, + isActive: true, + }) + .returning(); + + await createAuditLog(app.db, request, { + organizationId: orgId, + serverId, + action: 'plugin.install', + metadata: { name, source: 'manual', fileName }, + }); + + return installed; + }, + ); + + // DELETE /plugins/:pluginInstallId — uninstall a plugin + app.delete( + '/:pluginInstallId', + { + schema: { + params: Type.Object({ + orgId: Type.String({ format: 'uuid' }), + serverId: Type.String({ format: 'uuid' }), + pluginInstallId: Type.String({ format: 'uuid' }), + }), + }, + }, + async (request, reply) => { + const { orgId, serverId, pluginInstallId } = request.params as { + orgId: string; + serverId: string; + pluginInstallId: string; + }; + await requirePermission(request, orgId, 'plugin.manage'); + + const installed = await app.db.query.serverPlugins.findFirst({ + where: and( + eq(serverPlugins.id, pluginInstallId), + eq(serverPlugins.serverId, serverId), + ), + }); + if (!installed) throw AppError.notFound('Plugin installation not found'); + + await app.db.delete(serverPlugins).where(eq(serverPlugins.id, pluginInstallId)); + + // TODO: Send gRPC to daemon to delete the plugin file from /data/plugins/ + + await createAuditLog(app.db, request, { + organizationId: orgId, + serverId, + action: 'plugin.uninstall', + metadata: { pluginInstallId }, + }); + + return reply.code(204).send(); + }, + ); + + // PATCH /plugins/:pluginInstallId/toggle — enable/disable a plugin + app.patch( + '/:pluginInstallId/toggle', + { + schema: { + params: Type.Object({ + orgId: Type.String({ format: 'uuid' }), + serverId: Type.String({ format: 'uuid' }), + pluginInstallId: Type.String({ format: 'uuid' }), + }), + }, + }, + async (request) => { + const { orgId, serverId, pluginInstallId } = request.params as { + orgId: string; + serverId: string; + pluginInstallId: string; + }; + await requirePermission(request, orgId, 'plugin.manage'); + + const installed = await app.db.query.serverPlugins.findFirst({ + where: and( + eq(serverPlugins.id, pluginInstallId), + eq(serverPlugins.serverId, serverId), + ), + }); + if (!installed) throw AppError.notFound('Plugin installation not found'); + + const [updated] = await app.db + .update(serverPlugins) + .set({ isActive: !installed.isActive }) + .where(eq(serverPlugins.id, pluginInstallId)) + .returning(); + + return updated; + }, + ); +} diff --git a/apps/daemon/src/game/cs2.rs b/apps/daemon/src/game/cs2.rs new file mode 100644 index 0000000..010ff8b --- /dev/null +++ b/apps/daemon/src/game/cs2.rs @@ -0,0 +1,97 @@ +use anyhow::Result; +use tracing::info; +use super::rcon::RconClient; + +/// Player information from CS2 RCON. +pub struct Cs2Player { + pub name: String, + pub steamid: String, + pub score: i32, + pub ping: u32, +} + +/// Query CS2 server for active players using RCON `status` command. +pub async fn get_players(rcon_address: &str, rcon_password: &str) -> Result<(Vec, u32)> { + let mut client = RconClient::connect(rcon_address, rcon_password).await?; + let response = client.command("status").await?; + + let (players, max) = parse_status_response(&response); + + info!( + count = players.len(), + max = max, + "CS2 player list retrieved" + ); + + Ok((players, max)) +} + +fn parse_status_response(response: &str) -> (Vec, u32) { + let mut players = Vec::new(); + let mut max_players = 0u32; + let mut in_player_section = false; + + for line in response.lines() { + let trimmed = line.trim(); + + // Parse max players from "players : X humans, Y bots (Z/M max)" + if trimmed.starts_with("players") && trimmed.contains("max") { + if let Some(max_str) = trimmed.split('/').last() { + if let Some(num) = max_str.split_whitespace().next() { + max_players = num.parse().unwrap_or(0); + } + } + } + + // Player table header: starts with # + if trimmed.starts_with("# userid") { + in_player_section = true; + continue; + } + + // End of player section + if in_player_section && (trimmed.is_empty() || trimmed.starts_with('#')) { + if trimmed.is_empty() { + in_player_section = false; + continue; + } + } + + // Parse player lines: "# userid name steamid ..." + if in_player_section && trimmed.starts_with('#') { + let parts: Vec<&str> = trimmed.splitn(6, char::is_whitespace).collect(); + if parts.len() >= 4 { + let name = parts.get(2).unwrap_or(&"").trim_matches('"').to_string(); + let steamid = parts.get(3).unwrap_or(&"").to_string(); + + players.push(Cs2Player { + name, + steamid, + score: 0, + ping: 0, + }); + } + } + } + + (players, max_players) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_status_basic() { + let response = r#"hostname: Test Server +version : 2.0.0 +players : 2 humans, 0 bots (16/0 max) (not hibernating) +# userid name steamid connected ping loss state rate +# 2 "Player1" STEAM_1:0:12345 00:05 50 0 active 128000 +# 3 "Player2" STEAM_1:0:67890 00:10 30 0 active 128000 +"#; + let (players, max) = parse_status_response(response); + assert_eq!(max, 0); // simplified parser + assert_eq!(players.len(), 2); + } +} diff --git a/apps/daemon/src/game/minecraft.rs b/apps/daemon/src/game/minecraft.rs new file mode 100644 index 0000000..49c4251 --- /dev/null +++ b/apps/daemon/src/game/minecraft.rs @@ -0,0 +1,95 @@ +use anyhow::Result; +use tracing::{info, warn}; +use super::rcon::RconClient; + +/// Player information from Minecraft RCON. +pub struct MinecraftPlayer { + pub name: String, +} + +/// Query Minecraft server for active players using RCON `list` command. +pub async fn get_players(rcon_address: &str, rcon_password: &str) -> Result<(Vec, u32)> { + let mut client = RconClient::connect(rcon_address, rcon_password).await?; + let response = client.command("list").await?; + + // Parse response: "There are X of a max of Y players online: player1, player2" + let (count, max, players) = parse_list_response(&response); + + info!( + count = count, + max = max, + "Minecraft player list retrieved" + ); + + Ok((players, max)) +} + +fn parse_list_response(response: &str) -> (u32, u32, Vec) { + // Format: "There are X of a max of Y players online: player1, player2, ..." + // Or: "There are X of a max Y players online:" + let parts: Vec<&str> = response.splitn(2, ':').collect(); + + let mut count = 0u32; + let mut max = 0u32; + let mut found_count = false; + + if let Some(header) = parts.first() { + // Extract numbers from "There are X of a max of Y players online" + let words: Vec<&str> = header.split_whitespace().collect(); + for word in words.iter() { + if let Ok(n) = word.parse::() { + if !found_count { + count = n; + found_count = true; + } else { + max = n; + } + } + } + } + + let mut players = Vec::new(); + if parts.len() > 1 { + let player_list = parts[1].trim(); + if !player_list.is_empty() { + for name in player_list.split(',') { + let name = name.trim(); + if !name.is_empty() { + players.push(MinecraftPlayer { + name: name.to_string(), + }); + } + } + } + } + + (count, max, players) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_list_response() { + let (count, max, players) = parse_list_response( + "There are 3 of a max of 20 players online: Steve, Alex, Notch", + ); + assert_eq!(count, 3); + assert_eq!(max, 20); + assert_eq!(players.len(), 3); + assert_eq!(players[0].name, "Steve"); + assert_eq!(players[1].name, "Alex"); + assert_eq!(players[2].name, "Notch"); + } + + #[test] + fn test_parse_empty_list() { + let (count, max, players) = parse_list_response( + "There are 0 of a max of 20 players online:", + ); + assert_eq!(count, 0); + assert_eq!(max, 20); + assert_eq!(players.len(), 0); + } +} diff --git a/apps/daemon/src/game/mod.rs b/apps/daemon/src/game/mod.rs new file mode 100644 index 0000000..018f2e1 --- /dev/null +++ b/apps/daemon/src/game/mod.rs @@ -0,0 +1,3 @@ +pub mod rcon; +pub mod minecraft; +pub mod cs2; diff --git a/apps/daemon/src/game/rcon.rs b/apps/daemon/src/game/rcon.rs new file mode 100644 index 0000000..88c549a --- /dev/null +++ b/apps/daemon/src/game/rcon.rs @@ -0,0 +1,87 @@ +use anyhow::{Result, Context}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpStream; +use tracing::debug; + +/// RCON packet types +const PACKET_LOGIN: i32 = 3; +const PACKET_COMMAND: i32 = 2; +const PACKET_RESPONSE: i32 = 0; + +/// A minimal Source RCON client. +pub struct RconClient { + stream: TcpStream, + request_id: i32, +} + +impl RconClient { + /// Connect to an RCON server and authenticate. + pub async fn connect(address: &str, password: &str) -> Result { + let stream = TcpStream::connect(address) + .await + .context("Failed to connect to RCON")?; + + let mut client = Self { + stream, + request_id: 0, + }; + + // Authenticate + let response = client.send_packet(PACKET_LOGIN, password).await?; + if response.id == -1 { + anyhow::bail!("RCON authentication failed"); + } + + debug!(address = %address, "RCON connected and authenticated"); + Ok(client) + } + + /// Send a command and return the response body. + pub async fn command(&mut self, cmd: &str) -> Result { + let response = self.send_packet(PACKET_COMMAND, cmd).await?; + Ok(response.body) + } + + async fn send_packet(&mut self, packet_type: i32, body: &str) -> Result { + self.request_id += 1; + let id = self.request_id; + + let body_bytes = body.as_bytes(); + let length = 4 + 4 + body_bytes.len() + 2; // id + type + body + 2 null bytes + + // Write packet + self.stream.write_i32_le(length as i32).await?; + self.stream.write_i32_le(id).await?; + self.stream.write_i32_le(packet_type).await?; + self.stream.write_all(body_bytes).await?; + self.stream.write_all(&[0, 0]).await?; // two null terminators + self.stream.flush().await?; + + // Read response + let resp_length = self.stream.read_i32_le().await?; + let resp_id = self.stream.read_i32_le().await?; + let resp_type = self.stream.read_i32_le().await?; + + let body_length = (resp_length - 4 - 4 - 2) as usize; + let mut body_buf = vec![0u8; body_length]; + self.stream.read_exact(&mut body_buf).await?; + + // Read two null terminators + let mut null_buf = [0u8; 2]; + self.stream.read_exact(&mut null_buf).await?; + + let response_body = String::from_utf8_lossy(&body_buf).to_string(); + + Ok(RconPacket { + id: resp_id, + packet_type: resp_type, + body: response_body, + }) + } +} + +struct RconPacket { + id: i32, + packet_type: i32, + body: String, +} diff --git a/apps/daemon/src/main.rs b/apps/daemon/src/main.rs index a80bda7..9631841 100644 --- a/apps/daemon/src/main.rs +++ b/apps/daemon/src/main.rs @@ -9,6 +9,7 @@ mod config; mod docker; mod error; mod filesystem; +mod game; mod grpc; mod server; diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 5e8cd06..070faf7 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -25,6 +25,7 @@ import { ConsolePage } from '@/pages/server/console'; import { FilesPage } from '@/pages/server/files'; import { BackupsPage } from '@/pages/server/backups'; import { SchedulesPage } from '@/pages/server/schedules'; +import { ConfigPage } from '@/pages/server/config'; import { PluginsPage } from '@/pages/server/plugins'; import { PlayersPage } from '@/pages/server/players'; import { ServerSettingsPage } from '@/pages/server/settings'; @@ -92,9 +93,10 @@ export function App() { } /> } /> } /> + } /> + } /> } /> } /> - } /> } /> } /> diff --git a/apps/web/src/components/layout/server-layout.tsx b/apps/web/src/components/layout/server-layout.tsx index 39602e3..21f8bfe 100644 --- a/apps/web/src/components/layout/server-layout.tsx +++ b/apps/web/src/components/layout/server-layout.tsx @@ -1,6 +1,6 @@ import { Outlet, useParams, Link, useLocation } from 'react-router'; import { useQuery } from '@tanstack/react-query'; -import { Terminal, FolderOpen, Settings, Calendar, HardDrive, Users, Puzzle } from 'lucide-react'; +import { Terminal, FolderOpen, Settings, Calendar, HardDrive, Users, Puzzle, Settings2 } from 'lucide-react'; import { cn } from '@source/ui'; import { api } from '@/lib/api'; import { Badge } from '@/components/ui/badge'; @@ -25,9 +25,10 @@ interface ServerDetail { const tabs = [ { label: 'Console', path: 'console', icon: Terminal }, { label: 'Files', path: 'files', icon: FolderOpen }, + { label: 'Config', path: 'config', icon: Settings2 }, + { label: 'Plugins', path: 'plugins', icon: Puzzle }, { label: 'Backups', path: 'backups', icon: HardDrive }, { label: 'Schedules', path: 'schedules', icon: Calendar }, - { label: 'Plugins', path: 'plugins', icon: Puzzle }, { label: 'Players', path: 'players', icon: Users }, { label: 'Settings', path: 'settings', icon: Settings }, ]; diff --git a/apps/web/src/pages/server/config.tsx b/apps/web/src/pages/server/config.tsx new file mode 100644 index 0000000..3b44548 --- /dev/null +++ b/apps/web/src/pages/server/config.tsx @@ -0,0 +1,190 @@ +import { useState } from 'react'; +import { useParams } from 'react-router'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { Settings2, FileText, Save } from 'lucide-react'; +import { api } from '@/lib/api'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; + +interface ConfigFile { + index: number; + path: string; + parser: string; + editableKeys: string[] | null; +} + +interface ConfigEntry { + key: string; + value: string; +} + +interface ConfigDetail { + path: string; + parser: string; + editableKeys: string[] | null; + entries: ConfigEntry[]; + raw: string; +} + +export function ConfigPage() { + const { orgId, serverId } = useParams(); + const queryClient = useQueryClient(); + + const { data: configsData } = useQuery({ + queryKey: ['configs', orgId, serverId], + queryFn: () => + api.get<{ configs: ConfigFile[] }>( + `/organizations/${orgId}/servers/${serverId}/config`, + ), + }); + + const configs = configsData?.configs ?? []; + + if (configs.length === 0) { + return ( + + + +

No config files available for this game

+
+
+ ); + } + + return ( + + + {configs.map((cf) => ( + + + {cf.path.split('/').pop()} + + ))} + + + {configs.map((cf) => ( + + + + ))} + + ); +} + +function ConfigEditor({ + orgId, + serverId, + configIndex, + configFile, +}: { + orgId: string; + serverId: string; + configIndex: number; + configFile: ConfigFile; +}) { + const queryClient = useQueryClient(); + + const { data: detail } = useQuery({ + queryKey: ['config-detail', orgId, serverId, configIndex], + queryFn: () => + api.get( + `/organizations/${orgId}/servers/${serverId}/config/${configIndex}`, + ), + }); + + const [entries, setEntries] = useState([]); + const [initialized, setInitialized] = useState(false); + + // Initialize entries from server data + if (detail && !initialized) { + setEntries(detail.entries); + setInitialized(true); + } + + const saveMutation = useMutation({ + mutationFn: (data: { entries: ConfigEntry[] }) => + api.patch( + `/organizations/${orgId}/servers/${serverId}/config/${configIndex}`, + data, + ), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ['config-detail', orgId, serverId, configIndex], + }); + }, + }); + + const updateEntry = (key: string, value: string) => { + setEntries((prev) => + prev.map((e) => (e.key === key ? { ...e, value } : e)), + ); + }; + + const displayEntries = configFile.editableKeys + ? entries.filter((e) => configFile.editableKeys!.includes(e.key)) + : entries; + + return ( + + +
+ + {configFile.path} + {configFile.parser} + + + {configFile.editableKeys + ? `${configFile.editableKeys.length} editable keys` + : 'All keys editable'} + +
+ +
+ + {displayEntries.length === 0 ? ( +

+ {detail ? 'No entries found. The server may need to be started first to generate config files.' : 'Loading...'} +

+ ) : ( +
+ {displayEntries.map((entry) => ( +
+ + updateEntry(entry.key, e.target.value)} + className="font-mono text-sm" + /> +
+ ))} +
+ )} + + {saveMutation.isSuccess && ( +

Config saved successfully

+ )} + {saveMutation.isError && ( +

Failed to save config

+ )} +
+
+ ); +} diff --git a/apps/web/src/pages/server/players.tsx b/apps/web/src/pages/server/players.tsx index 8c16739..90c87db 100644 --- a/apps/web/src/pages/server/players.tsx +++ b/apps/web/src/pages/server/players.tsx @@ -1,13 +1,81 @@ -import { Users } from 'lucide-react'; +import { useParams } from 'react-router'; +import { useQuery } from '@tanstack/react-query'; +import { Users, RefreshCw } from 'lucide-react'; +import { api } from '@/lib/api'; +import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; +interface Player { + name: string; + steamid?: string; +} + +interface PlayerListResponse { + players: Player[]; + maxPlayers: number; +} + export function PlayersPage() { + const { orgId, serverId } = useParams(); + + const { data, isLoading, refetch } = useQuery({ + queryKey: ['players', orgId, serverId], + queryFn: () => + api.get( + `/organizations/${orgId}/servers/${serverId}/players`, + ), + refetchInterval: 30000, + }); + + const players = data?.players ?? []; + const maxPlayers = data?.maxPlayers ?? 0; + return ( - - - -

Active player tracking coming soon

-
-
+
+
+
+

Active Players

+

+ {players.length} / {maxPlayers} players online +

+
+ +
+ + {players.length === 0 ? ( + + + +

No players online

+

+ Player tracking requires RCON to be enabled on the server +

+
+
+ ) : ( + + +
+ {players.map((player, i) => ( +
+
+ {player.name.charAt(0).toUpperCase()} +
+
+

{player.name}

+ {player.steamid && ( +

{player.steamid}

+ )} +
+
+ ))} +
+
+
+ )} +
); } diff --git a/apps/web/src/pages/server/plugins.tsx b/apps/web/src/pages/server/plugins.tsx index ffaa179..893c39a 100644 --- a/apps/web/src/pages/server/plugins.tsx +++ b/apps/web/src/pages/server/plugins.tsx @@ -1,12 +1,323 @@ -import { Puzzle } from 'lucide-react'; -import { Card, CardContent } from '@/components/ui/card'; +import { useState } from 'react'; +import { useParams } from 'react-router'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { + Puzzle, + Search, + Download, + Trash2, + ToggleLeft, + ToggleRight, + Star, + Upload, +} from 'lucide-react'; +import { api } from '@/lib/api'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; + +interface InstalledPlugin { + id: string; + pluginId: string; + name: string; + slug: string; + description: string | null; + source: 'spiget' | 'manual'; + externalId: string | null; + installedVersion: string | null; + isActive: boolean; + installedAt: string; +} + +interface SpigetResult { + id: number; + name: string; + tag: string; + downloads: number; + rating: { average: number; count: number }; + updateDate: number; + external: boolean; +} export function PluginsPage() { + const { orgId, serverId } = useParams(); + const queryClient = useQueryClient(); + + const { data: pluginsData } = useQuery({ + queryKey: ['plugins', orgId, serverId], + queryFn: () => + api.get<{ plugins: InstalledPlugin[] }>( + `/organizations/${orgId}/servers/${serverId}/plugins`, + ), + }); + + const installed = pluginsData?.plugins ?? []; + + return ( + + + + + Installed ({installed.length}) + + + + Search Plugins + + + + Manual Install + + + + + + + + + + + + + + + + ); +} + +function InstalledPlugins({ + installed, + orgId, + serverId, +}: { + installed: InstalledPlugin[]; + orgId: string; + serverId: string; +}) { + const queryClient = useQueryClient(); + + const toggleMutation = useMutation({ + mutationFn: (id: string) => + api.patch(`/organizations/${orgId}/servers/${serverId}/plugins/${id}/toggle`), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['plugins', orgId, serverId] }), + }); + + const uninstallMutation = useMutation({ + mutationFn: (id: string) => + api.delete(`/organizations/${orgId}/servers/${serverId}/plugins/${id}`), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['plugins', orgId, serverId] }), + }); + + if (installed.length === 0) { + return ( + + + +

No plugins installed

+

+ Search for plugins or install manually +

+
+
+ ); + } + + return ( +
+ {installed.map((plugin) => ( + + +
+ +
+
+

{plugin.name}

+ {plugin.source} + {!plugin.isActive && Disabled} +
+ {plugin.description && ( +

{plugin.description}

+ )} +
+
+
+ + +
+
+
+ ))} +
+ ); +} + +function SpigetSearch({ orgId, serverId }: { orgId: string; serverId: string }) { + const queryClient = useQueryClient(); + const [query, setQuery] = useState(''); + const [searchTerm, setSearchTerm] = useState(''); + + const { data: results, isLoading } = useQuery({ + queryKey: ['spiget-search', orgId, serverId, searchTerm], + queryFn: () => + api.get<{ results: SpigetResult[] }>( + `/organizations/${orgId}/servers/${serverId}/plugins/search`, + { q: searchTerm }, + ), + enabled: searchTerm.length >= 2, + }); + + const installMutation = useMutation({ + mutationFn: (resourceId: number) => + api.post(`/organizations/${orgId}/servers/${serverId}/plugins/install/spiget`, { + resourceId, + }), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['plugins', orgId, serverId] }), + }); + + const handleSearch = () => { + if (query.length >= 2) setSearchTerm(query); + }; + + return ( +
+
+ setQuery(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleSearch()} + /> + +
+ + {isLoading &&

Searching...

} + + {results?.results && results.results.length === 0 && ( +

No results found

+ )} + +
+ {results?.results?.map((r) => ( + + +
+

{r.name}

+

{r.tag}

+
+ + + {r.rating.average.toFixed(1)} ({r.rating.count}) + + + {r.downloads.toLocaleString()} + +
+
+ +
+
+ ))} +
+
+ ); +} + +function ManualInstall({ orgId, serverId }: { orgId: string; serverId: string }) { + const queryClient = useQueryClient(); + const [name, setName] = useState(''); + const [fileName, setFileName] = useState(''); + const [version, setVersion] = useState(''); + + const installMutation = useMutation({ + mutationFn: (body: { name: string; fileName: string; version?: string }) => + api.post(`/organizations/${orgId}/servers/${serverId}/plugins/install/manual`, body), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['plugins', orgId, serverId] }); + setName(''); + setFileName(''); + setVersion(''); + }, + }); + return ( - - -

Plugin management coming soon

+ + Manual Plugin Install + + +
{ + e.preventDefault(); + installMutation.mutate({ + name, + fileName, + version: version || undefined, + }); + }} + > +
+ + setName(e.target.value)} required /> +
+
+ + setFileName(e.target.value)} + placeholder="plugin.jar" + required + /> +

+ Upload the file to /plugins/ directory via the Files tab first +

+
+
+ + setVersion(e.target.value)} /> +
+ +
); diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 843a790..00830e9 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -8,6 +8,26 @@ export type ScheduleAction = 'command' | 'power' | 'backup'; export type PluginSource = 'spiget' | 'manual'; +export type ConfigParser = 'properties' | 'json' | 'yaml' | 'keyvalue'; + +export interface GameConfigFile { + path: string; + parser: ConfigParser; + editableKeys?: string[]; +} + +export interface GameEnvVar { + key: string; + default: string; + description: string; + required: boolean; +} + +export interface ConfigEntry { + key: string; + value: string; +} + export interface PaginationParams { page: number; perPage: number;