"use server"; import { randomUUID } from "node:crypto"; import { events, rconServers } from "@minecraft-account-manager/database"; import { getClientIp } from "@minecraft-account-manager/network"; import { and, eq, sql } from "drizzle-orm"; import { headers } from "next/headers"; import { redirect } from "next/navigation"; import { requireAdminSession } from "@/lib/auth/require-admin"; import { db } from "@/lib/database"; import { isUniqueConstraintViolation } from "@/lib/database-errors"; import { decryptRconPassword, encryptRconPassword, rconCommandDigest } from "@/lib/rcon-credentials"; import { executeRcon, testRconConnection } from "@/lib/rcon-gateway"; import { recordAdminSubjectEvent } from "@/lib/audit"; import { validateRconCommand, validateRconConnection } from "@/lib/rcon-validation"; const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; type Admin = Awaited>; export type RconCommandState = { status: "idle" | "success" | "error"; message: string; serverId: string; }; function formConnection(formData: FormData, passwordRequired: boolean) { return validateRconConnection({ name: formData.get("name"), host: formData.get("host"), port: formData.get("port"), password: formData.get("password"), }, { passwordRequired }); } async function auditContext() { const requestHeaders = await headers(); return getClientIp(requestHeaders, process.env.TRUST_PROXY === "true"); } function auditData(admin: Admin, data: Record) { return { ...data, adminEmail: admin.email, adminName: admin.name }; } function rconPath(query: string) { return `/admin/rcon?${query}`; } export async function createRconServer(formData: FormData) { const admin = await requireAdminSession(); const details = formConnection(formData, true); if (!details?.password) redirect(rconPath("error=invalid-connection")); const id = randomUUID(); let encryptedPassword: string; try { encryptedPassword = encryptRconPassword(details.password, id); } catch { redirect(rconPath("error=configuration")); } const ipAddress = await auditContext(); try { await db.transaction(async (tx) => { await tx.insert(rconServers).values({ id, name: details.name, host: details.host, port: details.port, encryptedPassword, enabled: formData.get("enabled") === "yes", }); await tx.insert(events).values({ id: randomUUID(), source: "/web/admin", type: "games.minecraft.account-manager.rcon.connection.created", subject: `rcon-server/${id}`, time: new Date(), data: auditData(admin, { name: details.name, host: details.host, port: details.port }), ipAddress: ipAddress ?? null, }); }); } catch (error) { if (isUniqueConstraintViolation(error, "rcon_servers_name_uidx")) redirect(rconPath("error=duplicate-name")); redirect(rconPath("error=save-failed")); } redirect(rconPath("saved=created")); } export async function updateRconServer(formData: FormData) { const admin = await requireAdminSession(); const serverId = String(formData.get("serverId") ?? ""); const details = formConnection(formData, false); if (!UUID_PATTERN.test(serverId) || !details) redirect(rconPath("error=invalid-connection")); const ipAddress = await auditContext(); let result: string | null; try { result = await db.transaction(async (tx) => { await tx.execute(sql`select ${rconServers.id} from ${rconServers} where ${rconServers.id} = ${serverId} for update`); const [current] = await tx.select().from(rconServers).where(eq(rconServers.id, serverId)).limit(1); if (!current) return null; let encryptedPassword = current.encryptedPassword; if (details.password) encryptedPassword = encryptRconPassword(details.password, current.id); const enabled = formData.get("enabled") === "yes"; await tx.update(rconServers).set({ name: details.name, host: details.host, port: details.port, encryptedPassword, enabled, updatedAt: new Date(), }).where(eq(rconServers.id, current.id)); await tx.insert(events).values({ id: randomUUID(), source: "/web/admin", type: "games.minecraft.account-manager.rcon.connection.updated", subject: `rcon-server/${current.id}`, time: new Date(), data: auditData(admin, { name: details.name, host: details.host, port: details.port, enabled, passwordReplaced: Boolean(details.password), }), ipAddress: ipAddress ?? null, }); return current.id; }); } catch (error) { if (isUniqueConstraintViolation(error, "rcon_servers_name_uidx")) redirect(rconPath("error=duplicate-name")); redirect(rconPath("error=save-failed")); } if (!result) redirect(rconPath("error=unknown-connection")); redirect(rconPath("saved=updated")); } export async function setRconServerEnabled(formData: FormData) { const admin = await requireAdminSession(); const serverId = String(formData.get("serverId") ?? ""); if (!UUID_PATTERN.test(serverId)) redirect(rconPath("error=unknown-connection")); const enabled = formData.get("enabled") === "yes"; const ipAddress = await auditContext(); const result = await db.transaction(async (tx) => { await tx.execute(sql`select ${rconServers.id} from ${rconServers} where ${rconServers.id} = ${serverId} for update`); const [current] = await tx.select().from(rconServers).where(eq(rconServers.id, serverId)).limit(1); if (!current) return "missing" as const; if (enabled && !validateRconConnection({ ...current, password: "placeholder" }, { passwordRequired: true })) return "invalid" as const; await tx.update(rconServers).set({ enabled, updatedAt: new Date() }).where(eq(rconServers.id, current.id)); await tx.insert(events).values({ id: randomUUID(), source: "/web/admin", type: "games.minecraft.account-manager.rcon.connection.enabled-updated", subject: `rcon-server/${current.id}`, time: new Date(), data: auditData(admin, { name: current.name, enabled }), ipAddress: ipAddress ?? null, }); return "updated" as const; }); if (result === "missing") redirect(rconPath("error=unknown-connection")); if (result === "invalid") redirect(rconPath("error=invalid-connection")); redirect(rconPath(`saved=${enabled ? "enabled" : "disabled"}`)); } export async function deleteRconServer(formData: FormData) { const admin = await requireAdminSession(); const serverId = String(formData.get("serverId") ?? ""); if (!UUID_PATTERN.test(serverId) || formData.get("confirmation") !== serverId) redirect(rconPath("error=confirmation-required")); const ipAddress = await auditContext(); const deleted = await db.transaction(async (tx) => { const [server] = await tx.delete(rconServers).where(eq(rconServers.id, serverId)).returning({ id: rconServers.id, name: rconServers.name }); if (!server) return null; await tx.insert(events).values({ id: randomUUID(), source: "/web/admin", type: "games.minecraft.account-manager.rcon.connection.deleted", subject: `rcon-server/${server.id}`, time: new Date(), data: auditData(admin, { name: server.name }), ipAddress: ipAddress ?? null, }); return server; }); if (!deleted) redirect(rconPath("error=unknown-connection")); redirect(rconPath("saved=deleted")); } async function savedConnection(serverId: string, requireEnabled: boolean) { if (!UUID_PATTERN.test(serverId)) return null; const [server] = await db.select().from(rconServers).where(requireEnabled ? and(eq(rconServers.id, serverId), eq(rconServers.enabled, true)) : eq(rconServers.id, serverId)).limit(1); if (!server) return null; const validated = validateRconConnection({ ...server, password: "placeholder" }, { passwordRequired: true }); if (!validated) return null; try { return { ...server, password: decryptRconPassword(server.encryptedPassword, server.id) }; } catch { return null; } } export async function testSavedRconServer(formData: FormData) { const admin = await requireAdminSession(); const serverId = String(formData.get("serverId") ?? ""); const server = await savedConnection(serverId, false); if (!server) redirect(rconPath("error=connection-unavailable")); const started = Date.now(); const result = await testRconConnection(server); await recordAdminSubjectEvent(admin, `rcon-server/${server.id}`, "games.minecraft.account-manager.rcon.connection.tested", { serverId: server.id, name: server.name, success: result.ok, reason: result.ok ? null : result.reason, durationMs: Date.now() - started, }); redirect(rconPath(result.ok ? "saved=tested" : `error=test-${result.reason}`)); } export async function executeRconCommand( _previous: RconCommandState, formData: FormData, ): Promise { const admin = await requireAdminSession(); const serverId = String(formData.get("serverId") ?? ""); const command = validateRconCommand(formData.get("command")); if (!command) return { status: "error", message: "Enter one command of at most 1,024 bytes without control characters.", serverId }; const server = await savedConnection(serverId, true); if (!server) return { status: "error", message: "That RCON connection is disabled or unavailable.", serverId }; const verb = command.split(/\s+/u, 1)[0]!.toLowerCase().slice(0, 64); let commandDigest: string; try { commandDigest = rconCommandDigest(command); } catch { return { status: "error", message: "RCON command auditing is not configured.", serverId }; } const started = Date.now(); await recordAdminSubjectEvent(admin, `rcon-server/${server.id}`, "games.minecraft.account-manager.rcon.command.requested", { serverId: server.id, name: server.name, verb, commandDigest, }); const result = await executeRcon(server, command); await recordAdminSubjectEvent(admin, `rcon-server/${server.id}`, "games.minecraft.account-manager.rcon.command.completed", { serverId: server.id, name: server.name, verb, commandDigest, success: result.ok, reason: result.ok ? null : result.reason, durationMs: Date.now() - started, }); if (!result.ok) { const message = result.reason === "busy" ? "Another command is already running for this server." : result.reason === "timeout" ? "The RCON request timed out." : "The RCON server was unavailable or rejected authentication."; return { status: "error", message, serverId }; } return { status: "success", message: result.response || "Command completed with no response.", serverId }; }