130 lines
4.0 KiB
TypeScript
130 lines
4.0 KiB
TypeScript
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<typeof setTimeout> | 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<void> {
|
|
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<void> {
|
|
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<string> {
|
|
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<void> {
|
|
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()
|