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.
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 tablasMEMB_INFO,Charactery afines pobladas. - PHP 8.1 o superior con los drivers
sqlsrvypdo_sqlsrvinstalados 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).
| Componente | Papel en la arquitectura | Ejemplo (varía según la versión) |
|---|---|---|
| Sitio / Launcher / Bot | Cliente que consume la API | PHP, JS, Python |
| API REST | Capa de validación y acceso | PHP 8.x + sqlsrv |
| SQL Server | Persistencia de los datos del juego | MuOnline, puerto 1433 |
| GameServer | Consume comandos y sirve el juego | MuServer 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.
- Levanta la API y confirma que el front controller responde:
curl https://api.seuserver.com/?rota=ranking. - Verifica que el ranking retorna JSON válido y no un error de conexión.
- Genera un token de alcance
adminy guarda solo el hash en la base. - 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. - Confirma en la tabla
WEB_COMMAND_QUEUEque la fila fue creada constatus = 0. - Ejecuta el consumidor y verifica que el
statuscambia a1.
Errores comunes y soluciones
| Error | Causa probable | Solución |
|---|---|---|
Could not find driver | pdo_sqlsrv no habilitado en PHP | Confirma con php -m, actívalo en el php.ini y reinicia el web server |
401 Token inválido siempre | Comparando el token puro con el hash de la base | Aplica hash('sha256', $token) antes de consultar |
Login failed for user 'api_mu' | Usuario SQL sin permiso o contraseña errada | Recrea el login y concede solo los GRANT mínimos |
| Comando encolado pero nunca aplicado | Consumidor de la cola detenido | Verifica el servicio/agendador que procesa WEB_COMMAND_QUEUE |
429 en uso normal | Límite de tasa muy bajo | Ajusta limite/janela en el RateLimiter según el tráfico real |
| Datos sensibles en el error HTTP | Excepción del SQL filtrándose al cliente | Captura Throwable, loguea el detalle y responde un mensaje genérico |
| CORS bloqueando el sitio | Encabezados ausentes | Define 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_sqlsrvactivos y probados - Usuario SQL dedicado con permisos mínimos (sin
sa, sindb_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
sha256en la base - Alcances de token definidos (lectura vs. admin)
- Allowlist de comandos implementada en el
CommandController - Cola
WEB_COMMAND_QUEUEcreada 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.