Initial push

This commit is contained in:
2026-03-08 15:49:34 +01:00
parent 8da12bb7d1
commit 47127f276d
101 changed files with 13844 additions and 8 deletions

129
lib/minecraft/rcon.ts Normal file
View 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()