El mayor portal de MU Online de Brasil — desde 2003
Tutorial Avanzado Web

Cómo crear una API entre el sitio y el GameServer en MU Online

Aprende a construir una API REST en PHP entre el sitio y el GameServer de MU Online para intercambiar datos con seguridad, disparar comandos y sincronizar información sin exponer la base SQL directamente en la web.

BR Bruno · Actualizado el 10 jul 2026 · ⏱ 24 min de lectura
Respuesta rápida

Conectar el sitio PHP directamente a la base SQL Server del GameServer funciona, pero es frágil: esparces las credenciales de la base por varios scripts, expones el puerto 1433 cuando el sitio queda en otra máquina y mezclas la regla de negocio con el acceso a datos. Una API REST resuelve esto crean

Conectar el sitio PHP directamente a la base SQL Server del GameServer funciona, pero es frágil: esparces las credenciales de la base por varios scripts, expones el puerto 1433 cuando el sitio queda en otra máquina y mezclas la regla de negocio con el acceso a datos. Una API REST resuelve esto creando una frontera clara: el sitio habla HTTP con la API, y solo la API habla SQL con la base. Este tutorial muestra, paso a paso, cómo construir esa capa en PHP puro, con autenticación por token, validación de entrada, control de tasa y un canal para disparar comandos al GameServer. Los nombres de tablas, puertos y formatos de contraseña aparecen como ejemplo y varían según la versión y la distribución de tu MuServer, así que trata cada valor como algo a confirmar en tu entorno. Si aún no montaste la base del servidor, comienza por la guía de cómo crear un servidor de MU Online y vuelve aquí para la capa web.

Requisitos previos

Antes de escribir una línea de código, ten el entorno listo. La API es la pieza central de la integración, así que cada dependencia necesita estar sólida.

  • GameServer y SQL Server funcionando — la base (MuOnline, por ejemplo) ya creada, con las tablas MEMB_INFO, Character y afines pobladas.
  • PHP 8.1 o superior con los drivers sqlsrv y pdo_sqlsrv instalados y habilitados (php -m | grep sqlsrv).
  • Servidor web (Apache o Nginx) con soporte para reescritura de URL, para enrutar todas las peticiones a un único punto de entrada.
  • Certificado TLS válido para el dominio de la API (Let's Encrypt lo resuelve gratis).
  • Red interna definida — de preferencia, la API, el GameServer y el SQL Server en la misma VLAN, con la base cerrada a internet.
  • Un usuario SQL dedicado para la API, con permisos mínimos (nunca uses sa).
ComponentePapel en la arquitecturaEjemplo (varía según la versión)
Sitio / Launcher / BotCliente que consume la APIPHP, JS, Python
API RESTCapa de validación y accesoPHP 8.x + sqlsrv
SQL ServerPersistencia de los datos del juegoMuOnline, puerto 1433
GameServerConsume comandos y sirve el juegoMuServer S6 / S12+

Arquitectura de la solución

La idea central es que ningún cliente hable con la base directamente. Todo el tráfico pasa por HTTP hasta la API, que autentica, valida y traduce a SQL.

CLIENTES                         API (PHP)                 BACKEND
┌───────────┐   HTTPS + token   ┌─────────────┐  sqlsrv  ┌────────────┐
│ Sitio web │ ────────────────▶ │  Enrutador  │ ───────▶ │ SQL Server │
│ Launcher  │                   │  Auth       │          │ (MuOnline) │
│ Bot Disc. │ ◀──────────────── │  Validación │ ◀─────── └────────────┘
└───────────┘   JSON            │  Rate limit │                │
                                └──────┬──────┘          cola / socket
                                       │                        ▼
                                       └───── comando ────▶ GameServer

El GameServer entra en dos puntos: cuando la API lee datos que él grabó (rankings, estado online) y cuando la API necesita mandar una acción de vuelta: dar un ítem, kickear a un jugador, enviar VIP. La forma de ese "mandar de vuelta" varía según la versión: algunas builds ofrecen un socket administrativo, otras dependen de una tabla de cola que el GameServer recorre.

Estructura de carpetas y enrutamiento

Organiza la API para que exista un único punto de entrada (index.php), lo que facilita aplicar autenticación y CORS de forma central.

/api
├── public/
│   └── index.php        ← front controller (único punto de entrada)
├── src/
│   ├── Database.php      ← conexión sqlsrv (singleton)
│   ├── Auth.php          ← validación de token
│   ├── RateLimiter.php   ← control de tasa
│   └── Controllers/
│       ├── AccountController.php
│       ├── RankingController.php
│       └── CommandController.php
├── config.php           ← credenciales (¡fuera del public!)
└── .htaccess

El .htaccess (Apache) redirige todo al front controller:

RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^ index.php [QSA,L]

# Nunca servir archivos sensibles
<FilesMatch "\.(env|ini|log)$">
    Require all denied
</FilesMatch>

Configuración de conexión segura a la base

Aísla la conexión en un único archivo, fuera de la carpeta pública, usando un usuario SQL con permisos mínimos.

<?php
// src/Database.php
final class Database
{
    private static ?PDO $pdo = null;

    public static function conn(): PDO
    {
        if (self::$pdo === null) {
            $cfg = require __DIR__ . '/../config.php';
            $dsn = "sqlsrv:Server={$cfg['host']};Database={$cfg['db']}";

            self::$pdo = new PDO($dsn, $cfg['user'], $cfg['pass'], [
                PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
                PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
                PDO::ATTR_EMULATE_PREPARES   => false, // prepares reales
            ]);
        }
        return self::$pdo;
    }
}

En el SQL Server, crea el usuario de la API con lo mínimo necesario. Concede SELECT, INSERT y UPDATE solo en las tablas que la API realmente toca; nunca db_owner.

-- Ejemplo (varía según la versión): usuario dedicado de la API
CREATE LOGIN api_mu WITH PASSWORD = 'S3nh4_Forte_Da_API!2026';
CREATE USER api_mu FOR LOGIN api_mu;

GRANT SELECT, INSERT, UPDATE ON dbo.MEMB_INFO TO api_mu;
GRANT SELECT ON dbo.Character TO api_mu;
GRANT SELECT, INSERT, UPDATE ON dbo.MEMB_VIP TO api_mu;
-- Cola de comandos creada por ti (ver más adelante)
GRANT SELECT, INSERT, UPDATE ON dbo.WEB_COMMAND_QUEUE TO api_mu;

Autenticación por token

La API necesita saber quién está llamando. Para comunicación servidor-a-servidor (sitio → API), el enfoque más simple y sólido es un token de aplicación enviado en el encabezado Authorization. Para acciones en nombre de un jugador, genera un token de sesión tras el login.

<?php
// src/Auth.php
final class Auth
{
    public static function exigirToken(): array
    {
        $header = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
        if (!preg_match('/Bearer\s+(.+)/', $header, $m)) {
            self::negar('Token ausente');
        }
        $token = trim($m[1]);

        $stmt = Database::conn()->prepare(
            "SELECT app_id, scope, expira_em
             FROM WEB_API_TOKEN
             WHERE token_hash = ? AND ativo = 1"
        );
        // Guarda siempre el HASH del token, nunca el token puro
        $stmt->execute([hash('sha256', $token)]);
        $row = $stmt->fetch();

        if (!$row) {
            self::negar('Token inválido');
        }
        if ($row['expira_em'] !== null && strtotime($row['expira_em']) < time()) {
            self::negar('Token expirado');
        }
        return $row; // contiene alcance/permisos
    }

    public static function negar(string $msg): never
    {
        http_response_code(401);
        echo json_encode(['erro' => $msg]);
        exit;
    }
}

Guardar solo el sha256 del token en la base es esencial: si la base se filtra, el atacante no consigue reconstruir los tokens en uso. El token puro solo existe en el momento de la generación y en el cliente que lo recibió.

Control de tasa (rate limiting)

Sin límite de peticiones, un único cliente puede inundar la API, ya sea por bug o por ataque. Un limitador simple por IP y por token ya filtra el abuso grosero.

<?php
// src/RateLimiter.php
final class RateLimiter
{
    // Máximo de peticiones por ventana de tiempo
    public static function verificar(string $chave, int $limite = 60, int $janela = 60): void
    {
        $dir  = sys_get_temp_dir() . '/api_rl';
        @mkdir($dir, 0700, true);
        $arq  = $dir . '/' . hash('sha256', $chave);

        $agora   = time();
        $dados   = @json_decode(@file_get_contents($arq), true) ?: ['inicio' => $agora, 'hits' => 0];

        if ($agora - $dados['inicio'] >= $janela) {
            $dados = ['inicio' => $agora, 'hits' => 0]; // nueva ventana
        }
        $dados['hits']++;

        if ($dados['hits'] > $limite) {
            http_response_code(429);
            header('Retry-After: ' . ($janela - ($agora - $dados['inicio'])));
            echo json_encode(['erro' => 'Límite de peticiones excedido']);
            exit;
        }
        file_put_contents($arq, json_encode($dados), LOCK_EX);
    }
}

En producción con múltiples servidores, cambia el archivo por Redis, para que el contador sea compartido entre las instancias.

Endpoint de lectura: ranking

Los endpoints de lectura son los más seguros para empezar, pues no alteran nada. El controller lee la tabla Character y devuelve JSON limpio.

<?php
// src/Controllers/RankingController.php
final class RankingController
{
    public function top(int $limite = 50): void
    {
        $limite = max(1, min($limite, 200)); // fija el intervalo

        $stmt = Database::conn()->prepare(
            "SELECT TOP (?) Name, Class, cLevel, Resets, ConnectStat
             FROM Character
             WHERE CtlCode = 0
             ORDER BY Resets DESC, cLevel DESC"
        );
        $stmt->execute([$limite]);

        $dados = array_map(function ($r) {
            return [
                'nome'   => $r['Name'],
                'classe' => (int) $r['Class'],
                'level'  => (int) $r['cLevel'],
                'resets' => (int) $r['Resets'],
                'online' => (bool) $r['ConnectStat'],
            ];
        }, $stmt->fetchAll());

        Response::json(['ranking' => $dados]);
    }
}

Pasar el límite como parámetro preparado y fijarlo entre 1 y 200 evita que alguien pida TOP 9999999 y tire la base.

Endpoint de escritura: enviar comando al GameServer

Aquí está el punto más delicado. Cuando el sitio necesita dar un ítem, activar VIP o kickear a un jugador, la API no debe ejecutar eso "a mano" en la base de forma arriesgada. El patrón más confiable y portátil es la cola de comandos: la API inserta una fila en una tabla y el GameServer (o un servicio auxiliar) la consume. Eso desacopla los dos sistemas y deja un rastro auditable.

Crea la tabla de cola:

-- Ejemplo (varía según la versión): cola de comandos web -> juego
CREATE TABLE WEB_COMMAND_QUEUE (
    id          INT IDENTITY(1,1) PRIMARY KEY,
    conta       VARCHAR(10)  NOT NULL,
    comando     VARCHAR(50)  NOT NULL,  -- ej: 'ADD_VIP', 'GIVE_ITEM'
    parametros  NVARCHAR(MAX) NULL,     -- JSON con detalles
    status      TINYINT      NOT NULL DEFAULT 0, -- 0=pendiente 1=ok 2=error
    criado_em   DATETIME     NOT NULL DEFAULT GETDATE(),
    processado_em DATETIME   NULL
);

El controller solo valida y encola:

<?php
// src/Controllers/CommandController.php
final class CommandController
{
    private const COMANDOS_PERMITIDOS = ['ADD_VIP', 'GIVE_ITEM', 'KICK'];

    public function enfileirar(array $auth): void
    {
        $body = json_decode(file_get_contents('php://input'), true) ?: [];

        $conta   = trim($body['conta']   ?? '');
        $comando = strtoupper(trim($body['comando'] ?? ''));
        $params  = $body['parametros'] ?? [];

        // Validaciones estrictas
        if (!preg_match('/^[a-zA-Z0-9]{4,10}$/', $conta)) {
            Response::erro(422, 'Cuenta inválida');
        }
        if (!in_array($comando, self::COMANDOS_PERMITIDOS, true)) {
            Response::erro(422, 'Comando no permitido');
        }
        // El alcance del token necesita autorizar comandos
        if (($auth['scope'] ?? '') !== 'admin') {
            Response::erro(403, 'Sin permiso para comandos');
        }

        $stmt = Database::conn()->prepare(
            "INSERT INTO WEB_COMMAND_QUEUE (conta, comando, parametros)
             VALUES (?, ?, ?)"
        );
        $stmt->execute([$conta, $comando, json_encode($params)]);

        Response::json(['status' => 'encolado', 'id' => Database::conn()->lastInsertId()]);
    }
}

Un servicio ligero (un script agendado o un daemon) lee los registros pendientes y los aplica, ya sea grabando en la tabla final del juego o enviando vía socket administrativo cuando tu versión de GameServer ofrece ese canal. Mantener la lista de comandos permitidos como una constante (allowlist) impide que cualquier string se convierta en una acción ejecutable.

Front controller y respuesta estandarizada

El index.php amarra todo: define encabezados, aplica rate limit, autentica y enruta.

<?php
// public/index.php
require __DIR__ . '/../src/Database.php';
require __DIR__ . '/../src/Auth.php';
require __DIR__ . '/../src/RateLimiter.php';
// ... require de los controllers

header('Content-Type: application/json; charset=utf-8');
header('X-Content-Type-Options: nosniff');

RateLimiter::verificar($_SERVER['REMOTE_ADDR'] ?? 'anon');

$rota   = $_GET['rota'] ?? '';
$metodo = $_SERVER['REQUEST_METHOD'];

try {
    switch ("$metodo $rota") {
        case 'GET ranking':
            (new RankingController())->top((int)($_GET['limite'] ?? 50));
            break;
        case 'POST comando':
            $auth = Auth::exigirToken();
            (new CommandController())->enfileirar($auth);
            break;
        default:
            Response::erro(404, 'Ruta no encontrada');
    }
} catch (Throwable $e) {
    error_log('[API] ' . $e->getMessage());
    Response::erro(500, 'Error interno');
}

Fíjate en que el mensaje de error devuelto al cliente es genérico (Error interno), mientras que el detalle real va solo al log. Filtrar mensajes de excepción del SQL Server le entrega al atacante nombres de tabla y la estructura de la base.

Probando la API

Antes de enchufar el sitio, prueba cada endpoint aisladamente con curl. Así separas los problemas de la API de los problemas del front-end.

  1. Levanta la API y confirma que el front controller responde: curl https://api.seuserver.com/?rota=ranking.
  2. Verifica que el ranking retorna JSON válido y no un error de conexión.
  3. Genera un token de alcance admin y guarda solo el hash en la base.
  4. Prueba el comando: curl -X POST -H "Authorization: Bearer SEU_TOKEN" -d '{"conta":"teste","comando":"ADD_VIP","parametros":{"dias":30}}' https://api.seuserver.com/?rota=comando.
  5. Confirma en la tabla WEB_COMMAND_QUEUE que la fila fue creada con status = 0.
  6. Ejecuta el consumidor y verifica que el status cambia a 1.

Errores comunes y soluciones

ErrorCausa probableSolución
Could not find driverpdo_sqlsrv no habilitado en PHPConfirma con php -m, actívalo en el php.ini y reinicia el web server
401 Token inválido siempreComparando el token puro con el hash de la baseAplica hash('sha256', $token) antes de consultar
Login failed for user 'api_mu'Usuario SQL sin permiso o contraseña erradaRecrea el login y concede solo los GRANT mínimos
Comando encolado pero nunca aplicadoConsumidor de la cola detenidoVerifica el servicio/agendador que procesa WEB_COMMAND_QUEUE
429 en uso normalLímite de tasa muy bajoAjusta limite/janela en el RateLimiter según el tráfico real
Datos sensibles en el error HTTPExcepción del SQL filtrándose al clienteCaptura Throwable, loguea el detalle y responde un mensaje genérico
CORS bloqueando el sitioEncabezados ausentesDefine Access-Control-Allow-Origin para el dominio exacto del sitio

Buenas prácticas de seguridad

La API se convierte en el portón de entrada de tu servidor, así que trátala como infraestructura crítica. Nunca dejes el SQL Server abierto a internet: la API debe ser la única que lo alcanza, por la red interna. Usa siempre prepared statements, sin excepción, incluso en consultas que parecen inofensivas. Mantén los tokens con alcance: un token que solo lee ranking jamás debe conseguir enviar comandos. Rota los tokens periódicamente y ten un camino para revocar (ativo = 0) cualquier token comprometido en segundos. Por último, loguea toda acción de escritura con fecha, IP y token de origen; cuando algo salga mal, ese rastro es la diferencia entre resolver en minutos y quedar a oscuras.

Lista de verificación de lanzamiento

  • PHP con sqlsrv/pdo_sqlsrv activos y probados
  • Usuario SQL dedicado con permisos mínimos (sin sa, sin db_owner)
  • SQL Server cerrado a internet, accesible solo por la red interna
  • HTTPS/TLS válido en el dominio de la API
  • Front controller único con rate limiting aplicado
  • Tokens guardados solo como hash sha256 en la base
  • Alcances de token definidos (lectura vs. admin)
  • Allowlist de comandos implementada en el CommandController
  • Cola WEB_COMMAND_QUEUE creada y consumidor corriendo
  • Mensajes de error genéricos al cliente, detalle solo en el log
  • Endpoints probados individualmente con curl
  • Log de auditoría para toda operación de escritura

Preguntas frecuentes

¿Por qué usar una API en vez de conectar el sitio directo al SQL?

Una API crea una capa intermedia que valida, autentica y limita cada operación. Eso evita exponer el puerto 1433 del SQL Server en internet y reduce la superficie de ataque a un único endpoint controlado.

¿La API necesita correr en el mismo servidor que el GameServer?

No es obligatorio, pero se recomienda que quede en la misma red interna del GameServer y del SQL Server. Así la base nunca necesita aceptar conexiones externas y la API se vuelve el único punto de entrada.

¿Cómo recibe el GameServer comandos venidos del sitio?

Depende de la versión. Muchos GameServers exponen un socket administrativo (ConnectServer/GS Live) o leen una tabla de cola en la base. La API graba el comando y el GameServer lo consume, o lo envía vía socket cuando la versión lo soporta.

¿Necesito HTTPS en la API?

Sí, siempre. Tokens y datos de cuenta viajan en las peticiones. Sin TLS cualquier intermediario en la red lee los tokens en texto plano y asume la identidad del sitio.

¿Puedo reaprovechar esta API para una app móvil o un bot de Discord?

Sí. Ese es el mayor beneficio de una API: cualquier cliente autorizado (sitio, launcher, app, bot) consume los mismos endpoints con el mismo modelo de autenticación, sin duplicar la lógica de acceso a la base.

BR
Editor de eventos, mapas e ítems

Bruno es especialista en eventos, mapas, bosses y economía de ítems de MU Online. Documenta cada detalle basándose en el juego real.

Sigue leyendo

Artículos relacionados