⚙️ Cómo Construí un Panel de Minecraft con Node.js — Arquitectura Real

📅 3 de Marzo, 2026 ⏱️ 22 min lectura 🏷️ Node.js · WebSocket · RCON · Avanzado ✍️ OliveerF Network

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.

📋 Contenido

  1. El problema: ¿por qué no usar Pterodactyl?
  2. Arquitectura general del sistema
  3. RCON: protocolo y implementación en Node.js
  4. Consola en tiempo real con WebSocket
  5. Autenticación con JWT
  6. Schema MySQL para usuarios y servidores
  7. Manejo de procesos Java con child_process
  8. Lecciones aprendidas y qué haría diferente

🤔 ¿Por qué no usar Pterodactyl?

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?

Advertencia honesta: Para un negocio de hosting serio con muchos usuarios, Pterodactyl o PufferPanel son la elección correcta. Un panel custom es una deuda técnica enorme. Lo construí con ojos abiertos sobre ese trade-off.

🗺️ Arquitectura General

Browser (React) ⟺ HTTP/WebSocket Express API (Node.js)
ServerManager ⟺ RCON TCP Java Process (MC Server)
AuthService MySQL
WebSocket Hub ← stdout pipe MC stdout stream

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 — El Protocolo

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();
  }
}

📡 Consola en Tiempo Real con WebSocket

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í:

  1. Al iniciar el proceso Java, hacemos pipe del stdout del proceso
  2. Cada línea de stdout la añadimos a un buffer circular de 500 líneas
  3. Cuando un cliente abre la consola, le enviamos el buffer completo (historial)
  4. Nuevas líneas se broadcastean a todos los clientes WebSocket suscritos a ese servidor
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
    }
  }
}

🔐 Autenticación con JWT

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();
}
⚠️ Seguridad: El JWT_SECRET debe estar en variables de entorno, nunca en el código. Usa node -e "console.log(require('crypto').randomBytes(64).toString('hex'))" para generar uno robusto.

🗄️ Schema MySQL

-- 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);

⚙️ Iniciar el Proceso Java con child_process

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;
}

📚 Lecciones Aprendidas

Cosas que haría diferente hoy:
Cosas que salieron bien:

🔧 ¿Quieres usar el panel sin construirlo?

OliveerF Network está disponible gratis. Servidor Paper 1.21, consola en vivo, MySQL incluida, panel completo.

Crear Servidor Gratis