Initial push
This commit is contained in:
129
lib/minecraft/rcon.ts
Normal file
129
lib/minecraft/rcon.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
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()
|
||||
Reference in New Issue
Block a user