Este artículo documenta las decisiones técnicas reales detrás del panel de OliveerF Network. No es un tutorial inventado — es el post-mortem de construir algo que funciona en producción. Hay partes bonitas y partes que refactorizaría hoy. Explico ambas.
Pterodactyl es el panel open-source estándar del industria para hosting de juegos. Es potente, maduro y gratuito. La pregunta obvia es: ¿por qué construir algo desde cero?
El backend es un único proceso Node.js con Express. Cada servidor de Minecraft corre como un child process gestionado por Node. La consola en tiempo real funciona capturando el stdout del proceso Java y reenviando esas líneas a todos los WebSocket clients suscritos a ese servidor.
RCON (Remote Console) es un protocolo TCP binario que Minecraft usa para recibir comandos de forma remota. El servidor MC debe tener enable-rcon=true en server.properties con un puerto y contraseña configurados.
El protocolo RCON es sorprendentemente simple. Cada paquete tiene esta estructura:
┌─────────────────────────────────────────┐
│ Length (4 bytes, little-endian Int32) │
│ Request ID (4 bytes, Int32) │
│ Type (4 bytes: LOGIN=3, CMD=2, RESP=0) │
│ Payload (variable, UTF-8 string) │
│ Padding (2 null bytes) │
└─────────────────────────────────────────┘
Implementé un cliente RCON propio en lugar de usar una librería, porque quería entender exactamente qué estaba pasando:
const net = require('net'); class RCONClient { constructor(host, port, password) { this.host = host; this.port = port; this.password = password; this.socket = null; this.requestId = 1; this.pending = new Map(); // requestId → {resolve, reject} this.buffer = Buffer.alloc(0); } connect() { return new Promise((resolve, reject) => { this.socket = net.createConnection(this.port, this.host); this.socket.on('connect', async () => { try { await this.login(); resolve(); } catch (e) { reject(e); } }); this.socket.on('data', (chunk) => this._onData(chunk)); this.socket.on('error', (e) => reject(e)); }); } _buildPacket(id, type, payload) { const payloadBuf = Buffer.from(payload + '\0', 'utf8'); const len = payloadBuf.length + 10; // 4 (id) + 4 (type) + 2 (padding) const buf = Buffer.alloc(4 + len); buf.writeInt32LE(len, 0); buf.writeInt32LE(id, 4); buf.writeInt32LE(type, 8); payloadBuf.copy(buf, 12); buf.writeUInt8(0, buf.length - 2); buf.writeUInt8(0, buf.length - 1); return buf; } login() { const id = this.requestId++; return new Promise((resolve, reject) => { this.pending.set(id, { resolve, reject }); this.socket.write(this._buildPacket(id, 3, this.password)); }); } send(command) { const id = this.requestId++; return new Promise((resolve, reject) => { this.pending.set(id, { resolve, reject }); this.socket.write(this._buildPacket(id, 2, command)); // timeout de seguridad setTimeout(() => { if (this.pending.has(id)) { this.pending.get(id).reject(new Error('RCON timeout')); this.pending.delete(id); } }, 5000); }); } _onData(chunk) { this.buffer = Buffer.concat([this.buffer, chunk]); while (this.buffer.length >= 14) { const len = this.buffer.readInt32LE(0); if (this.buffer.length < 4 + len) break; const id = this.buffer.readInt32LE(4); const payload = this.buffer.toString('utf8', 12, 4 + len - 2); this.buffer = this.buffer.slice(4 + len); const cb = this.pending.get(id); if (cb) { cb.resolve(payload); this.pending.delete(id); } } } disconnect() { if (this.socket) this.socket.destroy(); } }
La consola es la feature más vistosa del panel. Cada línea que imprime el servidor Minecraft aparece instantáneamente en el browser. Funciona así:
const { WebSocketServer } = require('ws'); class ConsoleHub { constructor() { // Map: serverId → Set of ws clients this.subscribers = new Map(); // Map: serverId → Array (circular buffer) this.buffers = new Map(); } attachProcess(serverId, childProcess) { if (!this.buffers.has(serverId)) { this.buffers.set(serverId, []); } const buf = this.buffers.get(serverId); childProcess.stdout.on('data', (data) => { const lines = data.toString().split('\n').filter(l => l.trim()); for (const line of lines) { buf.push({ t: Date.now(), l: line }); if (buf.length > 500) buf.shift(); // mantener max 500 líneas this._broadcast(serverId, line); } }); childProcess.stderr.on('data', (data) => { const line = '[STDERR] ' + data.toString().trim(); buf.push({ t: Date.now(), l: line }); this._broadcast(serverId, line); }); } subscribe(serverId, wsClient) { if (!this.subscribers.has(serverId)) { this.subscribers.set(serverId, new Set()); } this.subscribers.get(serverId).add(wsClient); // Mandar historial al nuevo cliente const history = this.buffers.get(serverId) || []; wsClient.send(JSON.stringify({ type: 'history', lines: history })); wsClient.on('close', () => { this.subscribers.get(serverId)?.delete(wsClient); }); } _broadcast(serverId, line) { const clients = this.subscribers.get(serverId); if (!clients) return; const msg = JSON.stringify({ type: 'line', l: line, t: Date.now() }); for (const ws of clients) { if (ws.readyState === 1) ws.send(msg); // OPEN = 1 } } }
Cada petición al API debe incluir un JWT en el header Authorization: Bearer <token>. El JWT contiene el userId y el serverId al que tiene acceso. Si el serverId del token no coincide con el servidor solicitado, la petición es rechazada aunque el token sea válido.
const jwt = require('jsonwebtoken'); const JWT_SECRET = process.env.JWT_SECRET; // NUNCA hardcodear function generateToken(userId, serverIds) { return jwt.sign( { userId, serverIds }, JWT_SECRET, { expiresIn: '7d' } ); } function authMiddleware(req, res, next) { const header = req.headers.authorization; if (!header?.startsWith('Bearer ')) { return res.status(401).json({ error: 'No autorizado' }); } try { const decoded = jwt.verify(header.slice(7), JWT_SECRET); req.user = decoded; next(); } catch (e) { res.status(401).json({ error: 'Token inválido o expirado' }); } } // Middleware adicional para verificar acceso al servidor específico function serverOwnerMiddleware(req, res, next) { const { serverId } = req.params; if (!req.user.serverIds.includes(serverId)) { return res.status(403).json({ error: 'Sin acceso a este servidor' }); } next(); }
node -e "console.log(require('crypto').randomBytes(64).toString('hex'))" para generar uno robusto.
-- Tabla de usuarios CREATE TABLE users ( id VARCHAR(36) NOT NULL DEFAULT (UUID()) PRIMARY KEY, username VARCHAR(32) NOT NULL UNIQUE, email VARCHAR(128) NOT NULL UNIQUE, password VARCHAR(96) NOT NULL, -- bcrypt hash created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, plan ENUM('free', 'pro', 'premium') DEFAULT 'free' ); -- Tabla de servidores CREATE TABLE servers ( id VARCHAR(36) NOT NULL DEFAULT (UUID()) PRIMARY KEY, owner_id VARCHAR(36) NOT NULL, name VARCHAR(64) NOT NULL, port SMALLINT UNSIGNED NOT NULL UNIQUE, rcon_port SMALLINT UNSIGNED NOT NULL UNIQUE, rcon_pass VARCHAR(64) NOT NULL, status ENUM('stopped', 'starting', 'running', 'stopping') DEFAULT 'stopped', ram_mb INT UNSIGNED DEFAULT 2048, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE CASCADE ); -- Tabla de comandos programados (cron) CREATE TABLE scheduled_commands ( id INT AUTO_INCREMENT PRIMARY KEY, server_id VARCHAR(36) NOT NULL, cron_expr VARCHAR(64) NOT NULL, -- e.g. '0 3 * * *' command VARCHAR(256) NOT NULL, enabled BOOLEAN DEFAULT TRUE, FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE ); -- Índices útiles CREATE INDEX idx_servers_owner ON servers(owner_id); CREATE INDEX idx_servers_status ON servers(status);
const { spawn } = require('child_process'); const path = require('path'); function startMinecraftServer(server) { const serverDir = path.join(__dirname, '../servers/', server.id); const args = [ // Aikar flags `-Xms${server.ram_mb}M`, `-Xmx${server.ram_mb}M`, '-XX:+UseG1GC', '-XX:+ParallelRefProcEnabled', '-XX:MaxGCPauseMillis=200', '-XX:+UnlockExperimentalVMOptions', '-XX:+DisableExplicitGC', '-XX:G1NewSizePercent=30', '-XX:G1MaxNewSizePercent=40', '-XX:G1HeapRegionSize=8M', '-XX:G1ReservePercent=20', '-XX:G1HeapWastePercent=5', '-XX:G1MixedGCCountTarget=4', '-XX:InitiatingHeapOccupancyPercent=15', '-XX:G1MixedGCLiveThresholdPercent=90', '-XX:G1RSetUpdatingPauseTimePercent=5', '-XX:SurvivorRatio=32', '-XX:+PerfDisableSharedMem', '-XX:MaxTenuringThreshold=1', '-Dusing.aikars.flags=https://mcflags.emc.gs', '-Daikars.new.flags=true', // Paper jar '-jar', 'paper.jar', '--nogui' ]; const proc = spawn('java', args, { cwd: serverDir, // NO heredar env del proceso padre por seguridad env: { PATH: process.env.PATH, HOME: serverDir }, stdio: ['pipe', 'pipe', 'pipe'] // stdin, stdout, stderr }); proc.on('exit', (code) => { console.log(`[${server.id}] Servidor terminó con código ${code}`); // Actualizar estado en DB db.query('UPDATE servers SET status = ? WHERE id = ?', ['stopped', server.id]); }); return proc; }
/proc/[pid]/status en Linux para monitorear memoria, pero en Windows esto no existe. Debería usar una librería cross-platform.OliveerF Network está disponible gratis. Servidor Paper 1.21, consola en vivo, MySQL incluida, panel completo.
Crear Servidor Gratis