import { Rcon } from "rcon-client" import { db } from "@/lib/db/index" import { serverSettings } from "@/lib/db/schema" // Shell metacharacters that must never appear in RCON commands const SHELL_METACHAR_RE = /[;&|`$<>\\(){}\[\]!#~]/ export class RconManager { private client: Rcon | null = null private connecting = false private retryCount = 0 private readonly maxRetries = 5 private retryTimer: ReturnType | null = null /** True when the underlying Rcon socket is open */ isConnected(): boolean { return this.client !== null && (this.client as unknown as { authenticated: boolean }).authenticated === true } /** * Connect to the Minecraft RCON server using credentials stored in the DB. * Resolves when the handshake succeeds, rejects after maxRetries failures. */ async connect(): Promise { if (this.isConnected()) return if (this.connecting) return this.connecting = true try { const settings = await db.select().from(serverSettings).limit(1) const cfg = settings[0] if (!cfg) throw new Error("No server settings found in database") const rconHost = process.env.MC_RCON_HOST ?? "127.0.0.1" const rconPort = cfg.rconPort ?? 25575 const rconPassword = cfg.rconPassword ?? process.env.MC_RCON_PASSWORD ?? "" this.client = new Rcon({ host: rconHost, port: rconPort, password: rconPassword, timeout: 5000, }) await this.client.connect() this.retryCount = 0 console.log(`[RCON] Connected to ${rconHost}:${rconPort}`) } finally { this.connecting = false } } /** Cleanly close the RCON socket */ async disconnect(): Promise { if (this.retryTimer) { clearTimeout(this.retryTimer) this.retryTimer = null } if (this.client) { try { await this.client.end() } catch { // ignore errors during disconnect } this.client = null } console.log("[RCON] Disconnected") } /** * Send a command to the Minecraft server via RCON. * Rejects if the command contains shell metacharacters to prevent injection. * Auto-reconnects if disconnected (up to maxRetries attempts with backoff). */ async sendCommand(command: string): Promise { this.validateCommand(command) if (!this.isConnected()) { await this.connectWithBackoff() } if (!this.client) { throw new Error("RCON client is not connected") } try { const response = await this.client.send(command) return response } catch (err) { // Mark as disconnected and surface error this.client = null throw new Error(`RCON command failed: ${err instanceof Error ? err.message : String(err)}`) } } // ------------------------------------------------------------------------- // Private helpers // ------------------------------------------------------------------------- private validateCommand(command: string): void { if (!command || typeof command !== "string") { throw new Error("Command must be a non-empty string") } if (command.length > 1024) { throw new Error("Command exceeds maximum length of 1024 characters") } if (SHELL_METACHAR_RE.test(command)) { throw new Error("Command contains disallowed characters") } } private async connectWithBackoff(): Promise { while (this.retryCount < this.maxRetries) { const delay = Math.min(1000 * 2 ** this.retryCount, 30_000) this.retryCount++ console.warn(`[RCON] Reconnecting (attempt ${this.retryCount}/${this.maxRetries}) in ${delay}ms…`) await new Promise((resolve) => setTimeout(resolve, delay)) try { await this.connect() return } catch (err) { console.error(`[RCON] Reconnect attempt ${this.retryCount} failed:`, err) } } throw new Error(`RCON failed to reconnect after ${this.maxRetries} attempts`) } } export const rconClient = new RconManager()