Server managers: aprovisionar hosting y escribir el tuyo
Server managers: aprovisionar hosting y escribir el tuyo
En el capítulo 10 dejaste el dinero entrando: una factura se marca como pagada y el pedido queda listo para activarse. Falta el otro extremo del contrato: que alguien cree de verdad la cuenta en cPanel, en Plesk o en HestiaCP sin que tú entres al panel a mano. Esa es la función de los server managers.
El problema es doble. Primero, la superficie de integración de FOSSBilling 0.8.5 son exactamente seis paneles de hosting compartido y ninguno de VPS: si tu plan era vender VPS con Proxmox facturado desde aquí, hoy no existe y tendrás que escribirlo tú. Segundo, la guía oficial para escribir un server manager documenta seis métodos que no existen en el código: si la copias, tu clase ni siquiera se puede instanciar.
Este capítulo recorre ambas cosas con el código delante. Al terminar tendrás un servidor dado de alta con el test de conexión en verde, un plan cuyo nombre coincide con el paquete del panel, un producto que aprovisiona solo al cobrarse, y el esqueleto de un Server_Manager propio con los trece métodos correctos, cliente HTTP de Symfony y respeto por tls_verify. La versión de referencia es FOSSBilling 0.8.5, publicada el 2026-07-20; cuando algo dependa de la versión se dice, y se indica cómo comprobarlo en el código.
Los seis server managers del núcleo: puertos, autenticación y estado
Todo vive bajo src/library/Server/. Un find sobre ese directorio en 0.8.5 devuelve once ficheros, y sus tamaños ya dicen dónde está la complejidad:
src/library/Server/
Manager.php 315 lineas (abstracta) Exception.php 24
Account.php 338 Client.php 422 Package.php 320 (los tres DTOs)
Manager/Custom.php 261 Manager/CWP.php 425 Manager/Directadmin.php 851
Manager/Hestia.php 383 Manager/Plesk.php 593 Manager/Whm.php 762
| Manager | Clase | Puerto | Autenticación | Estado |
|---|---|---|---|---|
| WHM / cPanel | Server_Manager_Whm | 2087 | Header Authorization: WHM <user>:<accesshash> con API token, o Basic <user>:<password> | Soportado. Endpoint /json-api/<action> |
| Plesk | Server_Manager_Plesk | 8443 | username + password vía cliente XML-API de Plesk | Soportado. La doc admite “Plesk has minor syncing issues” |
| DirectAdmin | Server_Manager_Directadmin | 2222 | HTTP Basic, admite admin|usuario para login-as | Soportado. Endpoints /CMD_<comando> |
| HestiaCP | Server_Manager_Hestia | 8083 | POST a /api/ con hash = <accessKeyId>:<secretKey>, o user + password | Soportado |
| CWP | Server_Manager_CWP | 2304 | accesshash más IP Origin declarada en el panel | Soportado. Arreglos en 0.8.0 con el PR #3240 |
| Custom | Server_Manager_Custom | — | ninguna | Plantilla no-op: todo devuelve true y solo escribe logs |
Server_Manager_Custom es más útil de lo que parece: no aprovisiona nada, cada método registra una línea y devuelve true. Sirve para probar el circuito completo de pedido, factura, activación y correo sin tocar un panel real, y como base mínima para escribir el tuyo: si un pedido cae en failed_setup, apuntar temporalmente el producto a un servidor con manager Custom te dice en un intento si el fallo está en FOSSBilling o en el panel remoto. Fuera del núcleo hay poco y con fecha de caducidad: en el directorio oficial de extensiones solo existe ISPmanager 6 (grant436/fossbilling-ispmanager 1.0.0). En FOSSBilling/extensions-extra/managers sobreviven CentovaCast, ISPConfig-3, SolusVM y Virtualmin, todos marcados Untested y con baja prevista el 31-Aug-2026. Y en la comunidad está el Vesta / myVesta de jaapmarcus, migrado a Symfony HttpClient por su autor, cuya compatibilidad con 0.8.5 no está verificada. Virtualmin e ISPConfig estuvieron en el núcleo y se eliminaron en el PR #1527, cerrado el 2023-08-10, con este motivo textual: “These don’t work correctly and we don’t currently have anyone to maintain them”. Es el mismo patrón de la purga de pasarelas: mantener integraciones que nadie prueba sale más caro que borrarlas.
Lo que no existe: Proxmox, Virtualizor, VirtFusion, SolusVM, Pterodactyl y CyberPanel
Esta sección existe porque es la pregunta que más tiempo hace perder.
| Panel | Situación verificada al 2026-08-09 |
|---|---|
| Proxmox | El repo oficial FOSSBilling/Proxmox está archivado, último cambio el 2026-05-07. El issue #27 “[Feature Request] Proxmox module” se cerró el 2023-04-03. No hay integración oficial viva |
| SolusVM | En extensions-extra/managers/SolusVM, Untested, con eliminación prevista el 31-Aug-2026 |
| Virtualizor | No existe módulo oficial ni comunitario en el directorio. Solo aparece citado en el issue paraguas #1362 |
| VirtFusion | Solicitado en #1362, sin implementación |
| Pterodactyl | Postura oficial: “the core project does not plan to build or maintain an integration” |
| CyberPanel | Ni integración nativa ni extensión publicada. Solo aparece como voto en #1362 |
Sobre Proxmox circulan tres módulos comunitarios: fyrodotnet/fossbilling-vps (1 estrella, actualizado el 2026-08-01), fossware-dev/fossbilling-pve (0 estrellas, 2026-04-16) y st-yuda/FOSSBilling-NAT-Proxmox (0 estrellas, 2026-07-02). Ninguno está en el directorio oficial ni tiene su funcionamiento verificado contra 0.8.5. Tratarlos como dependencia de producción sin auditar su código es una decisión de riesgo, no un atajo. La postura del proyecto sobre VPS la dejó por escrito el mantenedor BelleNottelling:
“for the time-being VPS control panels will probably be considered out-of-scope for core FOSSBilling development due to the complexity of implementing them and the already large backlog”
Y sobre la lista completa de paneles pedidos en el issue #1362 —CyberPanel, Enhance, KeyHelp, Froxlor, Sentora, VirtFusion, FastPanel, myVesta, ISPmanager, BrainyCP, 20i— la respuesta de j-a-pope fue igual de directa: “Will the FOSSBilling team create and maintain integrations for all of them? No, of course not, and probably not any of them in the very near term future because we are much more focused on all of the things on the roadmap to get to a stable 1.0 release.”
Conclusión operativa: FOSSBilling 0.8.5 es sólido para hosting compartido y reseller sobre cPanel, Plesk, DirectAdmin, HestiaCP y CWP. Cualquier oferta VPS o de servidores de juegos exige desarrollo propio, y ese desarrollo es tuyo de mantener.
flowchart TD
A["Que vas a vender"] --> B{"Hosting compartido o reseller"}
B -->|Si| C["Manager del nucleo"]
C --> C1["WHM o cPanel"]
C --> C2["Plesk o DirectAdmin"]
C --> C3["HestiaCP o CWP"]
B -->|No| D{"VPS o servidor de juegos"}
D -->|Si| E["No hay integracion en el nucleo"]
E --> F["Escribir un Server_Manager propio"]
E --> G["O replantear la oferta"]
F --> H["Coste: mantener la API remota de por vida"]
D -->|No| I["Producto sin aprovisionamiento automatico"]
Modelo de datos: service_hosting_server, service_hosting_hp y service_hosting
Tres tablas y un producto que las apunta; entenderlo evita el 80 % de los errores de configuración.
erDiagram
service_hosting_server ||--o{ service_hosting : "aloja"
service_hosting_hp ||--o{ service_hosting : "define limites"
client_order ||--|| service_hosting : "service_id"
product }o--|| service_hosting_server : "config.server_id"
product }o--|| service_hosting_hp : "config.hosting_plan_id"
service_hosting_server {
string name_ip_hostname
string ns1_ns2_ns3_ns4
string manager
string username_password_accesshash
int port
bool secure
json config
int passwordLength
int max_accounts
json assigned_ips
string status_url
bool active
}
service_hosting_hp {
string name
int bandwidth_quota
int max_addon_ftp_sql_pop
int max_sub_park
json config
}
Léelo así: el producto no guarda el servidor ni el plan como columnas propias, los guarda dentro de su config como server_id y hosting_plan_id. El pedido apunta al servicio por client_order.service_id, y service_hosting es la fila que existe una vez por cuenta aprovisionada, con su sld, su tld, su username y su contraseña ya sustituida por un marcador.
En service_hosting_server la columna config es un JSON con exactamente dos claves en 0.8.5: userprefix y tls_verify. En service_hosting_hp la columna config es el saco de custom values del plan, donde caben las claves que cada panel necesita. La ruta de interfaz es System → Hosting plans and servers, con las plantillas mod_servicehosting_server.html.twig y mod_servicehosting_hp.html.twig, y las rutas /admin/servicehosting, /admin/servicehosting/server/:id y /admin/servicehosting/plan/:id.
Alta de un servidor: campos obligatorios, el JSON config y tls_verify
El endpoint es POST /api/admin/servicehosting/server_create, en src/modules/Servicehosting/Api/Admin.php. Solo tres parámetros son obligatorios: name, ip y manager. Los opcionales, según el docblock de server_update, son hostname, ns1 a ns4, username, password, accesshash, userprefix, port, passwordLength, secure, tls_verify, active, assigned_ips, status_url y max_accounts.
El JSON de config no se pasa entero, se compone así en el servidor:
$data['config'] = [
'userprefix' => $data['userprefix'] ?? null,
'tls_verify' => Tools::normalizeBoolean($data['tls_verify'] ?? true, true),
];
tls_verify es true por defecto. Es lo correcto, y es también la causa número uno de que un servidor recién dado de alta falle el test de conexión: los paneles de hosting suelen escuchar en su puerto de administración con certificado autofirmado. Tienes dos salidas legítimas y una mala. Uno. Instalar un certificado válido en ese puerto, que es lo que debes hacer. Dos. Apuntar hostname al nombre que figura en el certificado en vez de a la IP, porque muchos fallos son de nombre y no de confianza. Tres, la mala: poner tls_verify a false, con lo que la contraseña de root de tu WHM viaja por un canal sin verificar contra suplantación. Si lo haces como parche, anótalo y ponle fecha.
curl -s -u admin:TU_API_KEY -H 'Content-Type: application/json' \
-d '{"name":"whm-01","ip":"203.0.113.20","hostname":"srv1.ejemplo.com",
"manager":"Whm","username":"root","accesshash":"TOKEN_DE_WHM",
"port":2087,"secure":true,"tls_verify":true,"userprefix":"sl",
"passwordLength":16,"ns1":"ns1.ejemplo.com","ns2":"ns2.ejemplo.com","active":true}' \
https://billing.ejemplo.com/api/admin/servicehosting/server_create
El valor de manager no es libre: se valida contra los ficheros en disco, y el descubrimiento es un Finder de Symfony.
if (!in_array($manager, $this->_getServerManagers(), true)) {
throw new Exception('Server manager :manager is not a valid server manager', [':manager' => $manager]);
}
// _getServerManagers()
$finder = new Finder();
$finder->files()->in(Path::join(PATH_LIBRARY, 'Server', 'Manager'))->name('*.php');
$finder->sortByName();
De ahí salen tres consecuencias. Uno. El valor de manager es literalmente el nombre del fichero sin extensión, y la clase se resuelve como 'Server_Manager_' . $manager. Los válidos hoy son CWP, Custom, Directadmin, Hestia, Plesk y Whm; ojo con la capitalización, es Whm y no WHM, Directadmin y no DirectAdmin. Dos. El Finder no soporta subcarpetas, a diferencia de las pasarelas de pago que sí admiten un nivel de directorio: un manager en Manager/MiPanel/MiPanel.php no existe para FOSSBilling. Tres. Ese in_array(..., true) estricto es la barrera anti path-traversal.
Tras un server_update, la API llama a validateServerConfig(), que instancia el manager y convierte cualquier error de construcción en una InformationException con código 719. Guardar un servidor mal configurado te da 719 en el momento, no el día que vendas.
Enmascarado de credenciales desde 0.8.4 y el centinela que preserva el valor
Hasta 0.8.3 la contraseña de root de tu WHM se devolvía en claro por la API de administración; el PR #3840, entregado en 0.8.4, lo cambió. Tiene dos mitades. Al leer, los campos sensibles salen enmascarados. Al escribir, updateServer() pasa cada campo por normalizeCredential() con la constante CREDENTIAL_KEEP_SENTINEL: si el valor recibido está vacío, contiene solo espacios o es el centinela, se conserva el valor existente en base de datos. Eso permite que el formulario se pinte con asteriscos y se guarde sin borrar nada. Cuando sí hay rotación de password o accesshash, el cambio se audita sin registrar el valor.
La lista de campos a enmascarar la calcula getServerManagerSecretFields() uniendo tres fuentes: la whitelist base del módulo, que es ['password','accesshash']; lo que devuelva el Manager::getSecretFields() estático, que en Plesk es ['username','password']; y todo campo del getForm() del manager marcado con 'secret' => true.
Gotcha: si escribes un manager cuya credencial se llama, por ejemplo, api_token, y no la declaras ni en getSecretFields() ni con 'secret' => true, ese token se devolverá en claro por la API admin. El enmascarado no adivina nombres, se declara. Y al automatizar: si vas a cambiar el port de un servidor por API, no envíes password ni accesshash vacíos esperando borrarlos, porque el centinela los conserva; rotar exige enviar el valor nuevo.
Test de conexión: el paso que no puedes saltarte antes de vender
El endpoint es POST /api/admin/servicehosting/server_test_connection con {"id": N} y exige el permiso servicehosting:manage_servers. La cadena interna conviene conocerla para leer los errores: Service::testConnection($model) → getServerManager($model) → $manager->testConnection().
curl -s -u admin:TU_API_KEY -H 'Content-Type: application/json' -d '{"id": 1}' \
https://billing.ejemplo.com/api/admin/servicehosting/server_test_connection
Cada manager implementa testConnection() con la llamada más barata de su API. HestiaCP lanza v-list-users y falla con cualquier código de retorno distinto de cero:
$postVars = ['cmd' => 'v-list-users', 'arg1' => $this->_config['username'], 'arg2' => $this->_config['password']];
$result = $this->request($postVars);
if (intval($result) != 0) {
throw new Server_Exception('Failed to connect to the :type: server. Please verify your credentials and configuration', [':type:' => 'HestiaCP']);
}
Advertencia: un test en verde prueba que las credenciales sirven para listar, no para crear. En CWP y en HestiaCP los permisos de la API key son granulares, y es perfectamente posible pasar el test y fallar en createAccount() por falta del permiso de alta. La comprobación honesta es un pedido de prueba completo con un cliente ficticio antes de abrir la tienda.
Planes de hosting: el nombre ES el paquete del panel y los valores por defecto
El endpoint es POST /api/admin/servicehosting/hp_create y el único parámetro obligatorio es name; los demás tienen defaults codificados en createHp():
| Campo | Default | Significado |
|---|---|---|
bandwidth y quota | 1048576 cada uno | transferencia y disco, 1024 * 1024 |
max_addon y max_park | 1 | dominios adicionales y aparcados |
max_sub y max_pop | 1 | subdominios y cuentas de correo |
max_sql y max_ftp | 1 | bases de datos y cuentas FTP |
Esos 1 son un pie de foto peligroso: crear el plan solo con name es vender un hosting con una base de datos, una cuenta de correo y una cuenta FTP. Rellena los límites siempre. Y ahora la regla que gobierna todo el módulo:
El valor de
service_hosting_hp.namees el identificador del paquete en el panel de control.
Salvo WHM, ningún manager crea paquetes. Hestia, CWP, Plesk y DirectAdmin esperan que el paquete ya exista con ese nombre exacto. Si en HestiaCP se llama basico-2026 y en FOSSBilling escribes Basico 2026, la creación de la cuenta falla en el momento de la venta, no antes. hp_update acepta además el array config y el par new_config_name / new_config_value para añadir valores personalizados, con un comportamiento que sorprende:
foreach ($inConfig as $key => $val) {
if (empty($val)) unset($config[$key]);
else $config[$key] = $val;
}
Un valor vacío no guarda una cadena vacía: elimina la clave. Es la forma de borrar un custom value, y también la de perder uno sin querer si tu script envía campos vacíos. El borrado de un plan en uso está bloqueado con Hosting plan is used by :count: service hostings, código 704.
Custom values del plan y su mapeo a Server_Package
Al aprovisionar, el plan se vuelca en un Server_Package mediante getServerPackage():
$config = json_decode($model->config ?? '', true) ?: [];
$p = new \Server_Package();
$p->setCustomValues($config)
->setMaxFtp($model->max_ftp)
->setMaxSql($model->max_sql)
->setMaxPop($model->max_pop)
->setMaxSubdomains($model->max_sub)
->setMaxParkedDomains($model->max_park)
->setMaxDomains($model->max_addon)
->setBandwidth($model->bandwidth)
->setQuota($model->quota)
->setName($model->name);
Los ocho límites numéricos son universales, pero cada panel tiene parámetros que no encajan en ninguno. Para eso están los custom values: van en el config del plan y se leen con $package->getCustomValue('clave'). Los que usa WHM son cgi para habilitar CGI, cpmod para el tema de cPanel, maxlst para el número de listas de correo y hasshell para el acceso shell. Server_Package implementa __call(), y por eso el manager de WHM puede invocar getAcllist(), getHasCgi(), getTheme(), getLanguage(), getMaxEmailLists(), getMaxAddons() o getHasShell() sin que esos getters estén declarados. Es cómodo y es también una fuente de errores silenciosos: un getEmial() mal escrito devuelve vacío en vez de fallar. getCustomValue() es la vía explícita y la que se lee mejor.
Vincular servidor y plan al producto: los errores 701, 702, 703 y 704
El producto de tipo hosting —constante Box\Mod\Product\Service::HOSTING = 'hosting', ver el capítulo 5— guarda server_id y hosting_plan_id dentro de su configuración. La validación vive en Servicehosting\Service::validateOrderData():
if (!isset($data['server_id'])) throw new InformationException('Hosting product is not configured completely. Configure server for hosting product.', null, 701);
if (!isset($data['hosting_plan_id'])) throw new InformationException('Hosting product is not configured completely. Configure hosting plan for hosting product.', null, 702);
if (!isset($data['sld']) || empty($data['sld'])) throw new InformationException('Domain name is invalid.', null, 703);
if (!isset($data['tld']) || empty($data['tld'])) throw new InformationException('Domain extension is invalid.', null, 704);
if (($data['domain']['action'] ?? null) === 'subdomain') $this->assertSubdomainAvailable($data['sld'], $data['tld']);
| Código | Mensaje | Qué falta de verdad |
|---|---|---|
| 701 | Configure server for hosting product | El producto no tiene server_id en su config |
| 702 | Configure hosting plan for hosting product | El producto no tiene hosting_plan_id |
| 703 | Domain name is invalid | El carrito no envió sld, o llegó vacío |
| 704 | Domain extension is invalid | El carrito no envió tld, o llegó vacío |
Los dos primeros saltan en cuanto un cliente añade el producto al carrito; los dos últimos son casi siempre un formulario de producto mal montado. Para vincular servidor y plan por API:
curl -s -u admin:TU_API_KEY -H 'Content-Type: application/json' \
-d '{"id": 3, "config": {"server_id": 1, "hosting_plan_id": 2}}' \
https://billing.ejemplo.com/api/admin/product/update
El aprovisionamiento depende además de setup = after_payment en el producto; con setup = manual la cuenta se crea cuando alguien del staff activa el pedido a mano. Ese detalle está en el capítulo 7.
Subdominios gratuitos y la comprobación anti-duplicados de 0.8.1
La versión 0.8.1 introdujo con el PR #3667 el subdominio gratuito: el cliente no compra dominio y se le asigna sucuenta.tudominio.com. Eso abrió el agujero obvio —dos clientes pidiendo el mismo subdominio— que se cerró con assertSubdomainAvailable():
SELECT COUNT(*) FROM service_hosting sh
INNER JOIN client_order co ON co.service_id = sh.id AND co.service_type = :service_type
WHERE LOWER(sh.sld) = LOWER(:sld) AND LOWER(sh.tld) = LOWER(:tld)
AND co.status != :canceled_status
Tres detalles que cambian el comportamiento observable. Uno. La comparación es LOWER() en ambos lados, así que MiSitio y misitio colisionan. Dos. Se excluyen los pedidos cancelados, no los suspendidos: el subdominio de un cliente suspendido por impago sigue reservado, y es deliberado. Tres. La comprobación solo se dispara cuando domain.action vale subdomain; con dominio propio la unicidad la garantiza el registrador, tema del capítulo 12.
Aprovisionamiento al activar el pedido: generateUsername(), contraseñas y el placeholder
Cuando el cron activa un pedido pagado, Order\Service llama al servicio del tipo de producto y, en hosting, eso aterriza en Servicehosting\Service::action_activate():
$serverManager = $this->_getServerManagerForOrder($model);
$pass = $this->di['tools']->generatePassword($serverManager->getPasswordLength(), true);
if (!empty($config['password'])) $pass = $config['password'];
$username = !empty($config['username'])
? $config['username']
: $serverManager->generateUsername($model->sld . $model->tld);
$model->username = $username;
$model->pass = $pass;
if (!isset($config['import']) || !$config['import']) {
[$adapter, $account] = $this->_getAM($model);
$adapter->createAccount($account);
}
$model->pass = self::PASSWORD_PLACEHOLDER; // la contrasena NO se persiste en claro
$this->di['db']->store($model);
return ['username' => $username];
Uno. La contraseña no se guarda. Se genera, se usa para crear la cuenta, se envía en el correo de activación y acto seguido la columna se sobrescribe con self::PASSWORD_PLACEHOLDER. Consecuencia para soporte: si el cliente la pierde, no puedes recuperarla, solo cambiarla con la acción change_password. Que la plantilla mod_servicehosting_activated sea el único sitio donde vive esa credencial es un problema de diseño del correo, no de FOSSBilling.
Dos. La longitud sale del servidor. Server_Manager::getPasswordLength() devuelve $this->_config['passwordLength'] ?? 10: si no rellenas ese campo, todas las cuentas nacen con diez caracteres. Tres. El usuario se deriva del dominio, con esta implementación base:
$username = preg_replace('/[^A-Za-z0-9]/', '', $domain);
$username = substr((string) $username, 0, 7);
$randomNumber = random_int(0, 9);
$prefix = $this->_config['config']['userprefix'] ?? '';
return $prefix . $username . $randomNumber;
Se quitan los caracteres no alfanuméricos, se corta a siete, se añade un dígito aleatorio y se antepone el userprefix del servidor: con userprefix = "sl" y dominio ejemplo.com sale algo como slejemplo4. Ese único dígito significa que el espacio de colisión es de diez valores para dominios cuyos siete primeros caracteres coincidan: ejemplotienda.com y ejemplotaller.com comparten prefijo. En un panel con miles de cuentas, eso ocurre. Hestia sobrescribe el método con #[Override] porque HestiaCP no admite usuarios que empiecen por número (ver hestiacp/hestiacp#4195): fuerza minúsculas y sustituye por 'a' un primer carácter numérico.
Cuatro. config['import'] = true salta la creación. Es la vía para adoptar una cuenta que ya existe: se registra el servicio pero no se llama a createAccount(). No es un importador; no hay descubrimiento masivo, hay que crear el pedido a mano y marcar la bandera.
sequenceDiagram
autonumber
participant GW as Pasarela
participant Inv as Invoice Service
participant Cron as cron.php
participant Ord as Order Service
participant SH as Servicehosting Service
participant SM as Server_Manager
participant P as Panel remoto
GW->>Inv: ipn.php marca la factura como pagada
Cron->>Inv: invoice_batch_activate_paid
Inv->>Ord: activateOrder con task activate
Ord->>SH: action_create y despues action_activate
SH->>SM: getPasswordLength y generateUsername con sld mas tld
SH->>SH: _getAM construye Server_Account y Server_Package
SH->>SM: createAccount con el DTO de cuenta
SM->>P: llamada HTTP a la API del panel
P-->>SM: respuesta de exito
SH->>SH: guarda PASSWORD_PLACEHOLDER en service_hosting
Ord->>Ord: status active y expires_at segun el periodo
Ord-->>GW: email mod_servicehosting_activated al cliente
Si el manager lanza excepción en cualquier punto, el pedido queda en failed_setup, la excepción se guarda como nota en el historial de estado y el correo de activación no sale. Reintentar es activar el pedido de nuevo: activateOrder() acepta pending_setup y failed_setup. Sobre el servicio ya aprovisionado, el módulo expone además change_plan, change_username, change_ip, change_domain, change_password, sync y update; cada una llama al método correspondiente del manager y cada una puede fallar por separado si el panel no soporta esa operación.
WHM/cPanel a fondo: tokens, createacct, addpkg y el nombre compuesto del paquete
WHM es el manager más completo de los seis y el único que crea paquetes; sus 762 líneas se explican por eso. Conexión. Puerto 2087, URL <scheme>://<host>:<port>/json-api/<action>. Cabecera Authorization: WHM <username>:<accesshash> cuando hay accesshash, y Authorization: Basic <username>:<password> cuando no. El timeout del cliente HTTP está fijado en 90 segundos, con comentario explícito en el código: “Account creation can timeout if set too low - see #1086”. El token se genera en WHM → Development → Manage API Tokens y se muestra una sola vez; el usuario habitual es root o un reseller con permisos. Desde 0.8.3 (#3793) la etiqueta del campo menciona explícitamente “Access Hash or API Token”, porque la confusión era constante.
Creación de cuenta. Acción createacct con ['username', 'domain', 'password', 'contactemail', 'plan', 'useregns' => 0]. Si la cuenta es de reseller se añade 'reseller' => 1 y se llaman después setupreseller con makeowner=0 y setacls con $package->getAcllist().
Creación de paquetes. checkPackageExists($package, true) consulta listpkgs y, si no existe, llama a addpkg:
['name' => $name, 'quota', 'bwlimit', 'maxsub', 'maxpark', 'maxaddon',
'maxftp', 'maxsql', 'maxpop', 'cgi', 'cpmod', 'maxlst', 'hasshell']
Gotcha del nombre compuesto: getPackageName() no devuelve el nombre del plan, devuelve <whm_username>_<plan_name>. Con usuario root y plan Basico, el paquete en cPanel es root_Basico. Es la convención de cPanel para paquetes por propietario, y es la razón de que busques tu plan en WHM y no aparezca con el nombre que escribiste.
Cambio de plan. Acción modifyacct, con las claves en MAYÚSCULAS: HASCGI, CPTHEME, LANG, MAXPOP, MAXFTP, MAXLST, MAXSUB, MAXPARK, MAXADDON, MAXSQL, más shell en minúsculas —la inconsistencia es de la API de WHM, no del código de FOSSBilling. Los errores se detectan inspeccionando $json->cpanelresult->error, y también $json->data->result == '0' con el motivo en $json->data->reason. La referencia citada en el propio código es https://api.docs.cpanel.net/whm/introduction.
Consecuencia poco intuitiva de que WHM sí cree paquetes: los cambios posteriores en el plan de FOSSBilling no se sincronizan de vuelta al paquete de WHM. El paquete se crea una vez con los valores del momento.
Plesk, DirectAdmin, HestiaCP y CWP: una por una, con sus trampas
Plesk
Puerto 8443, autenticación con username y password, y un cliente XML-API propio que el manager instancia con new Client($host, $port) seguido de setCredentials($user, $pass). La particularidad es que getForm() devuelve solo ['label' => 'Plesk'] y no define ningún campo: las credenciales salen de los campos genéricos del formulario de servidor. getSecretFields() sí está sobrescrito y devuelve ['username', 'password'], así que ambos quedan enmascarados. La documentación oficial admite que “Plesk has minor syncing issues”. Traducido a operación: verifica manualmente el estado de las cuentas Plesk tras suspensiones y cambios de plan. Arreglos previos: #3398 de calidad y #1809 sobre cambio de contraseña. Requisito de entorno: la integración necesita las extensiones PHP simplexml y xml, listadas como sugeridas en FOSSBilling\Requirements con la justificación “the Plesk integration”.
DirectAdmin
Puerto 2222, HTTP Basic mediante auth_basic de Symfony HttpClient, timeout 60 segundos, endpoints con la forma <scheme>://<host>:<port>/CMD_<comando>?<querystring>. Dos rasgos propios. Uno. Soporta login-as: si se pasa $asUser, el usuario se envía como admin|usuario. Dos. El campo de contraseña se etiqueta “Password / Login Key”, porque DirectAdmin admite login keys de ámbito reducido; usarlas en lugar de la contraseña de admin es la configuración recomendable. La detección de fallo de autenticación es peculiar: si la respuesta contiene <!doctype html> o DirectAdmin Login, el manager lanza Server_Exception. Es decir, DirectAdmin responde con la página de login en HTML en vez de con un error de API. Es también el manager con más historial de bugs corregidos, lo que importa al fijar versión mínima:
| Issue | Problema | Corregido en |
|---|---|---|
| #3692 | Crash al probar la conexión | 0.8.2 |
| #3708 | Credenciales no enviadas tras 0.8.0 / 0.8.1 | — |
| #3709 | TypeError de strlen() en PHP 8.3 tras conexión exitosa | — |
| #3894 | Borrado de cuenta con falsos negativos: ahora verifica que la cuenta desapareció antes de reportar fallo | 0.8.4 |
| #3959 | Apóstrofes decodificados como entidades HTML | — |
Si operas DirectAdmin, 0.8.4 es tu suelo razonable.
HestiaCP
Puerto 8083, endpoint https://<host>:<port>/api/, siempre HTTPS y siempre POST form-encoded. Timeout 120 segundos, el más largo de los seis. La autenticación tiene tres niveles de prioridad:
if (accesshash && username) $params['hash'] = "$username:$accesshash";
elseif (accesshash) $params['hash'] = $accesshash;
else { $params['user'] = $username; $params['password'] = $password; }
A cada petición se le añade $params['returncode'] = 'yes', que es lo que hace que Hestia devuelva un código numérico en vez de texto. Si la respuesta contiene la cadena Error, excepción; si intval($result) !== 0, se escribe en error_log el código de retorno junto con el comando. Preparación obligatoria —y causa más común de un test rojo en Hestia—: uno, añadir la IP del servidor de FOSSBilling a la lista de IP permitidas de Hestia; dos, generar una access key con permisos de billing en lugar de usar la contraseña de admin. Dos limitaciones firmes: changeAccountIp() lanza Server_Exception porque no está soportado, y Hestia no crea paquetes.
CWP
Puerto 2304. El formulario tiene un solo campo, accesshash, marcado como secreto. Su init() exige ip, host y accesshash, y lanza Server_Exception con código 2001 nombrando el elemento que falta. En el API Manager hay que rellenar IP Origin con la IP de FOSSBilling y conceder estos permisos:
| Grupo de permisos | Acciones necesarias |
|---|---|
| Account | Add, Update, Delete, List, Suspend, Unsuspend |
| Account Details | List |
| Account Package Change | Update |
| Change Password | Update |
Gotcha documentado y caro: “Hosting plan names with spaces (like ‘Plan 1’) won’t work correctly. Use names without spaces, like ‘Plan1’.” Si tu plan se llama Plan 1, las cuentas se crearán mal o no se crearán. CWP tampoco crea paquetes. La referencia de API citada en el código es https://docs.control-webpanel.com/docs/developer-tools/api-manager/configuration.
Las cinco limitaciones transversales del módulo de hosting
Documentadas en docs.fossbilling.org/admin-guide/product-types/hosting/, aplican a todos los managers:
| Limitación | Impacto operativo |
|---|---|
| No se pueden importar cuentas existentes | Migrar desde WHMCS o desde gestión manual significa crear los pedidos uno a uno, con config.import = true si la cuenta ya existe |
| Los motivos de suspensión no se propagan al panel | El cliente ve su cuenta suspendida sin explicación en cPanel; el motivo solo vive en FOSSBilling |
| No hay Single Sign-On para clientes | El área de cliente no abre sesión en el panel; hay que entregar usuario y contraseña |
| Salvo WHM, ningún manager crea paquetes | Se crean a mano en el panel y el nombre debe coincidir carácter a carácter |
| Los cambios no se sincronizan de vuelta | Editar un plan en FOSSBilling no reescribe el paquete del panel, ni siquiera en WHM |
Ninguna es un bug: son decisiones de alcance, y la primera y la tercera son las que más pesan al decidir si FOSSBilling encaja en un negocio de hosting ya en marcha.
Los trece métodos abstractos reales de Server_Manager (y los seis que documenta la web y no existen)
src/library/Server/Manager.php declara trece métodos abstractos:
abstract public function getLoginUrl(?Server_Account $account);
abstract public function getResellerLoginUrl(?Server_Account $account);
abstract public function testConnection();
abstract public function createAccount(Server_Account $account);
abstract public function synchronizeAccount(Server_Account $account); // devuelve Server_Account
abstract public function suspendAccount(Server_Account $account);
abstract public function unsuspendAccount(Server_Account $account);
abstract public function cancelAccount(Server_Account $account);
abstract public function changeAccountPassword(Server_Account $account, string $newPassword);
abstract public function changeAccountUsername(Server_Account $account, string $newUsername);
abstract public function changeAccountDomain(Server_Account $account, string $newDomain);
abstract public function changeAccountIp(Server_Account $account, string $newIp);
abstract public function changeAccountPackage(Server_Account $account, Server_Package $package);
La guía oficial de “creating a server manager” lista en cambio create(), suspend(), unsuspend(), cancel(), changePassword() y changePackage(). Ninguno de esos seis nombres existe en el código. Si sigues la guía literalmente, tu clase implementa métodos que nadie llama y deja sin implementar los trece que sí se declaran, y PHP se niega a instanciarla. El código manda:
| Lo que dice la doc oficial | Lo que hay en Manager.php |
|---|---|
create() | createAccount(Server_Account $account) |
suspend() | suspendAccount(Server_Account $account) |
unsuspend() | unsuspendAccount(Server_Account $account) |
cancel() | cancelAccount(Server_Account $account) |
changePassword() | changeAccountPassword(Server_Account $account, string $newPassword) |
changePackage() | changeAccountPackage(Server_Account $account, Server_Package $package) |
| no documentados | synchronizeAccount(), testConnection(), getLoginUrl(), getResellerLoginUrl(), changeAccountUsername(), changeAccountDomain(), changeAccountIp() |
Además heredas métodos ya implementados que no debes reescribir salvo motivo: __construct(array $options), init(), getSecretFields() estático, generateUsername(string $domain), getPasswordLength(), getLog(), setLog() y getHttpClient(). Los DTOs con los que trabajarás son tres. Server_Account expone Username, Password, Domain, Ip, Client, Package, Note, Reseller, Suspended y Ns1 a Ns4. Server_Client expone Id, FirstName, LastName, FullName, Company, Email, Address1, Address2, Street, City, State, Country, Zip y Telephone. Server_Package expone Name, Quota, Bandwidth, MaxDomains, MaxSubdomains, MaxParkedDomains, MaxFtp, MaxSql, MaxPop y MaxQuota, más setCustomValues(), setCustomValue() y getCustomValue(). Los dos últimos implementan __call() para tolerar getters no declarados.
flowchart LR
CFG["_config del servidor"] --> CTOR["__construct con array options"]
CTOR --> INIT["init valida y lanza 2001 si falta algo"]
INIT --> TEST["testConnection"]
TEST --> OPS["Operaciones sobre la cuenta"]
OPS --> A1["createAccount y cancelAccount"]
OPS --> A2["suspendAccount y unsuspendAccount"]
OPS --> A3["changeAccount password username domain ip package"]
OPS --> A4["synchronizeAccount"]
A1 --> HTTP["getHttpClient con verify_peer segun tls_verify"]
A2 --> HTTP
A3 --> HTTP
A4 --> HTTP
HTTP --> PANEL["API del panel remoto"]
getForm(), getSecretFields() e init(): configuración y validación del manager
Tres puntos de entrada definen cómo se configura tu manager. getForm() es estático y devuelve la definición del formulario que se pinta al elegir tu manager; lleva como mínimo una label, y cada campo con 'secret' => true entra automáticamente en el enmascarado. getSecretFields() es estático y devuelve una lista plana de nombres a enmascarar; es el refuerzo para credenciales que no vienen de tu getForm(), como hace Plesk. init() se ejecuta al final del constructor: ahí normalizas el puerto y validas la configuración mínima, lanzando Server_Exception con código 2001 nombrando el parámetro que falta, igual que CWP.
El $_config del constructor tiene esta forma:
protected array $_config = [
'ip' => null,
'host' => null,
'secure' => false,
'username' => null,
'password' => null,
'accesshash' => null, // API key / token
'config' => null, // array: ['userprefix' => ..., 'tls_verify' => bool]
'port' => null,
'passwordLength' => null,
];
Si $options['ssl'] viene informado, también se copia. Y tls_verify no es clave de primer nivel: vive dentro de config, y se lee como $this->_config['config']['tls_verify'].
Esqueleto de un Server_Manager propio con Symfony HttpClient
Punto de partida real con los trece métodos y el cliente HTTP correcto. Guárdalo como src/library/Server/Manager/MiPanel.php —fichero plano, sin subcarpeta— con la clase Server_Manager_MiPanel.
<?php
declare(strict_types=1);
class Server_Manager_MiPanel extends Server_Manager
{
/** Formulario del admin. 'secret' => true enmascara el valor (0.8.4+). */
public static function getForm(): array
{
return ['label' => 'Mi Panel', 'form' => ['credentials' => ['fields' => [
['name' => 'username', 'type' => 'text', 'label' => 'Usuario de API', 'required' => true, 'secret' => true],
['name' => 'accesshash', 'type' => 'text', 'label' => 'API Key', 'required' => true, 'secret' => true],
]]]];
}
/** Refuerzo del enmascarado. Whitelist base del modulo: password y accesshash. */
#[Override]
public static function getSecretFields(): array
{
return ['username', 'accesshash'];
}
/** Se ejecuta al final del constructor. Validar aqui la configuracion minima. */
public function init(): void
{
$this->_config['port'] = FOSSBilling\Tools::normalizePort($this->_config['port'] ?? null, 8443);
foreach (['host' => 'Hostname', 'accesshash' => 'API Key'] as $key => $label) {
if (empty($this->_config[$key])) {
throw new Server_Exception(
'The ":server_manager" server manager is not fully configured. Please configure the :missing',
[':server_manager' => 'MiPanel', ':missing' => $label],
2001
);
}
}
}
public function getPort(): int
{
return FOSSBilling\Tools::normalizePort($this->_config['port'] ?? null, 8443);
}
public function getLoginUrl(?Server_Account $account = null): string
{
return 'https://' . $this->_config['host'] . ':' . $this->getPort() . '/';
}
public function getResellerLoginUrl(?Server_Account $account = null): string
{
return $this->getLoginUrl($account);
}
public function testConnection(): bool
{
$result = $this->request('GET', '/api/v1/ping');
if (($result['status'] ?? '') !== 'ok') {
throw new Server_Exception(
'Failed to connect to the :type: server. Please verify your credentials and configuration',
[':type:' => 'MiPanel']
);
}
return true;
}
public function synchronizeAccount(Server_Account $account): Server_Account
{
$this->getLog()->info('Synchronizing account with server ' . $account->getUsername());
$remote = $this->request('GET', $this->url($account));
$new = clone $account;
$new->setSuspended((bool) ($remote['suspended'] ?? false));
if (!empty($remote['ip'])) {
$new->setIp($remote['ip']);
}
return $new;
}
public function createAccount(Server_Account $account): bool
{
$client = $account->getClient(); // Server_Client
$package = $account->getPackage(); // Server_Package
$this->request('POST', '/api/v1/accounts', [
'username' => $account->getUsername(), 'password' => $account->getPassword(),
'domain' => $account->getDomain(), 'email' => $client->getEmail(),
'full_name' => $client->getFullName(), 'reseller' => $account->getReseller() ? 1 : 0,
'plan' => $package->getName(), 'quota' => $package->getQuota(),
'bandwidth' => $package->getBandwidth(), 'max_sub' => $package->getMaxSubdomains(),
'max_park' => $package->getMaxParkedDomains(), 'max_addon' => $package->getMaxDomains(),
'max_ftp' => $package->getMaxFtp(), 'max_sql' => $package->getMaxSql(),
'max_pop' => $package->getMaxPop(), 'ns1' => $account->getNs1(), 'ns2' => $account->getNs2(),
'shell' => $package->getCustomValue('hasshell'), // custom value del plan
]);
return true;
}
// Siete operaciones con el mismo patron, en una linea por espacio: en tu codigo real, bloques PSR-12.
public function suspendAccount(Server_Account $a): bool { $this->request('POST', $this->url($a) . '/suspend'); return true; }
public function unsuspendAccount(Server_Account $a): bool { $this->request('POST', $this->url($a) . '/unsuspend'); return true; }
public function cancelAccount(Server_Account $a): bool { $this->request('DELETE', $this->url($a)); return true; }
public function changeAccountPassword(Server_Account $a, string $new): bool { $this->request('PATCH', $this->url($a), ['password' => $new]); return true; }
public function changeAccountUsername(Server_Account $a, string $new): bool { $this->request('PATCH', $this->url($a), ['username' => $new]); return true; }
public function changeAccountDomain(Server_Account $a, string $new): bool { $this->request('PATCH', $this->url($a), ['domain' => $new]); return true; }
public function changeAccountPackage(Server_Account $a, Server_Package $p): bool { $this->request('PATCH', $this->url($a), ['plan' => $p->getName()]); return true; }
/** Si el panel no lo soporta, excepcion. NUNCA devolver true en falso. */
public function changeAccountIp(Server_Account $account, string $newIp): never
{
throw new Server_Exception(':type: does not support :action:',
[':type:' => 'MiPanel', ':action:' => __trans('changing the account IP')]);
}
private function url(Server_Account $account): string
{
return '/api/v1/accounts/' . $this->uid($account);
}
private function uid(Server_Account $account): string
{
return rawurlencode((string) $account->getUsername());
}
/** Cliente HTTP: SIEMPRE via getHttpClient() y respetando config.tls_verify. */
private function request(string $method, string $path, array $payload = []): array
{
$verifyTls = FOSSBilling\Tools::normalizeBoolean($this->_config['config']['tls_verify'] ?? true, true);
$scheme = $this->_config['secure'] ? 'https' : 'http';
$url = $scheme . '://' . $this->_config['host'] . ':' . $this->getPort() . $path;
$client = $this->getHttpClient()->withOptions([
'verify_peer' => $verifyTls,
'verify_host' => $verifyTls,
'timeout' => 90, // crear cuentas puede tardar; ver el issue #1086 de WHM
'headers' => ['Authorization' => 'Bearer ' . $this->_config['accesshash']],
]);
$this->getLog()->debug(sprintf('MiPanel %s %s', $method, $url));
try {
$response = $payload === []
? $client->request($method, $url)
: $client->request($method, $url, ['json' => $payload]);
$body = $response->getContent();
} catch (\Symfony\Contracts\HttpClient\Exception\HttpExceptionInterface
|\Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface $e) {
$ex = new Server_Exception('HttpClientException: :error', [':error' => $e->getMessage()]);
$this->getLog()->error($ex->getMessage());
throw $ex;
}
$json = json_decode($body, true);
if (!is_array($json)) {
$this->getLog()->critical(sprintf('MiPanel respuesta invalida para %s: %s', $path, $body));
throw new Server_Exception('Failed to :action: on the :type: server, check the error logs for further details',
[':action:' => $path, ':type:' => 'MiPanel']);
}
return $json;
}
}
Cinco decisiones de ese esqueleto que no son estéticas. Uno. getHttpClient(), nunca cURL a pelo: el cliente heredado viene del contenedor con el User-Agent FOSSBilling/<version> y respeta BIND_TO, la interfaz de salida que define load.php; un cURL propio se salta ambas cosas y rompe los servidores con varias IP. Dos. tls_verify se lee siempre y por defecto es true; no lo cablees. Tres. changeAccountIp() declarado como never: cuando el panel no soporta una operación, lanza excepción en vez de devolver true, porque un true mentiroso se convierte en una incidencia de facturación semanas después; es el patrón de Hestia. Cuatro. Los mensajes usan marcadores :token: y __trans(), así entran en el sistema de traducción en vez de quedarse en inglés duro. Cinco. Todo pasa por getLog(): debug() para la traza de peticiones, error() para fallos de transporte y critical() para respuestas ininterpretables. Esas líneas son lo único que tendrás cuando el panel falle de madrugada.
Instalación manual de un manager de terceros, limpieza de caché y depuración
Los server managers no son auto-instalables. El instalador solo resuelve rutas para cuatro tipos, y el manager no está entre ellos:
return match ($type) {
TYPE_MOD => PATH_MODS,
TYPE_THEME => PATH_THEMES,
TYPE_TRANSLATION => PATH_LANGS,
TYPE_PG => Path::join(PATH_LIBRARY, 'Payment', 'Adapter'),
default => throw new \FOSSBilling\InformationException('Extension type (:type) is not supported for automatic path determination.', [':type' => $type]),
};
El procedimiento manual son tres pasos:
FB=/var/www/fossbilling
# 1. Copiar el fichero PLANO, sin subcarpeta, y darle el usuario del servidor web
sudo cp MiPanel.php "$FB/library/Server/Manager/MiPanel.php"
sudo chown www-data:www-data "$FB/library/Server/Manager/MiPanel.php"
# 2. Limpiar la cache y comprobar la sintaxis antes de recargar
sudo rm -rf "$FB/data/cache/"*
php -l "$FB/library/Server/Manager/MiPanel.php"
Salida esperada del tercer paso: No syntax errors detected in /var/www/fossbilling/library/Server/Manager/MiPanel.php. Después aparece en System → Hosting plans and servers → nuevo servidor, en el desplegable de manager, con el nombre del fichero sin extensión. Aviso relacionado: en 0.8.4 (#3832) se corrigió que “third-party registrar adapters were not being loaded after installation due to Composer’s optimized autoloader not discovering the files”; el mismo mecanismo de carga explícita aplica a los managers, así que si el tuyo no aparece en versiones anteriores a 0.8.4, la causa probable es esa. Para depurar, los canales de log son los del capítulo 3:
FB=/var/www/fossbilling
tail -f "$FB/data/log/application/application-$(date +%Y-%m-%d).log"
tail -f "$FB/data/log/cron/cron-$(date +%Y-%m-%d).log" "$FB/data/log/php_error.log"
mysql -e "SELECT id,name,ip,hostname,manager,port,secure,active FROM service_hosting_server" fossbilling
mysql -e "SELECT id,client_id,sld,tld,username FROM service_hosting ORDER BY id DESC LIMIT 20" fossbilling
Sobre timeouts. Los tres valores del núcleo son 90 segundos en WHM, 120 en HestiaCP y 60 en DirectAdmin. Si tu panel tarda más en crear una cuenta, la llamada se corta y el pedido cae a failed_setup aunque la cuenta se haya creado: el resultado es una cuenta huérfana en el panel, y reintentar fallará con “usuario ya existe”. La salida es borrar la cuenta en el panel antes de reintentar, o registrar el servicio con config.import = true.
Sobre el issue #4121. Hay una propuesta abierta para mover pasarelas de pago, registradores y server managers a src/extensions. Si se implementa, todas las rutas absolutas de este capítulo cambian y src/library/Server/Manager/ deja de ser el sitio donde vive tu fichero. Antes de fijar rutas en documentación interna o en un script de despliegue, mira el estado del issue y comprueba en tu instalación con ls -1 /var/www/fossbilling/library/Server/Manager/.
Errores comunes y diagnóstico
| Síntoma | Causa probable | Diagnóstico y solución |
|---|---|---|
Server manager :manager is not a valid server manager | El valor no coincide con un fichero de library/Server/Manager/ | Comprobar capitalización exacta: Whm, CWP, Directadmin, Hestia, Plesk, Custom |
| El manager nuevo no aparece en el desplegable | Fichero en subcarpeta, permisos incorrectos o caché sin limpiar | Debe ser fichero plano; chown www-data; rm -rf data/cache/*. En versiones previas a 0.8.4, ver #3832 |
| Código 2001 al guardar o al probar | init() encontró un parámetro obligatorio vacío | El mensaje nombra el campo. En CWP faltan ip, host o accesshash |
Código 719 tras server_update | validateServerConfig() no pudo instanciar el manager | Es el 2001 envuelto: revisar los campos del formulario del manager |
| Test de conexión con error TLS | tls_verify = true contra certificado autofirmado, o hostname que no casa con el certificado | Instalar certificado válido o apuntar hostname al nombre del certificado |
Test en verde pero createAccount() falla | La API key lee pero no crea | En CWP conceder Account Add/Update/Delete/List/Suspend/Unsuspend; en Hestia usar access key con permisos de billing |
| Conexión rechazada desde Hestia o CWP | La IP de FOSSBilling no está autorizada | Lista de IP permitidas en Hestia; campo IP Origin en el API Manager de CWP |
| Código 701 al añadir al carrito | El producto no tiene server_id | Producto → Config → seleccionar servidor |
| Código 702 al añadir al carrito | El producto no tiene hosting_plan_id | Producto → Config → seleccionar plan |
| Códigos 703 / 704 | sld o tld no llegaron del formulario | Revisar el formulario de dominio del producto y el payload de cart/add_item |
Pedido en failed_setup | Excepción del manager durante action_activate() | La nota del historial de estado trae el mensaje; contrastar con el canal application |
Cuenta creada en el panel pero pedido en failed_setup | Timeout del cliente HTTP con la cuenta ya creada | Borrar la cuenta huérfana y reactivar, o registrar el servicio con config.import = true |
Failed to connect to the :type: server en Hestia | Código de retorno distinto de cero en v-list-users | Verificar hash usuario:accesskey, puerto 8083 y respuesta por HTTPS |
| DirectAdmin rechaza credenciales correctas | El panel devuelve la página de login HTML, detectada por <!doctype html> o DirectAdmin Login | Comprobar la login key y el puerto 2222. Subir al menos a 0.8.4 por #3708, #3709 y #3894 |
| El paquete no aparece en WHM con el nombre esperado | getPackageName() devuelve <whm_username>_<plan_name> | Buscar root_Basico en vez de Basico |
| Cuentas con 1 base de datos y 1 correo | El plan se creó solo con name y tomó los defaults de createHp() | Rellenar max_sql, max_pop, max_ftp, max_sub, max_park, max_addon |
| CWP crea cuentas mal o no las crea | Nombre de plan con espacios | Renombrar sin espacios: Plan1, no Plan 1 |
| Un custom value desaparece al guardar | hp_update elimina la clave si el valor llega vacío | Enviar solo las claves a conservar, con valor |
| Usuarios de Hestia rechazados por empezar con número | Límite de HestiaCP, ver hestiacp/hestiacp#4195 | El manager lo corrige con a; si escribes uno propio, replica la regla |
| La contraseña del cliente no se recupera | Se sustituye por PASSWORD_PLACEHOLDER tras la creación | No es recuperable: usar la acción change_password |
| Un token propio aparece en claro en la API admin | No está declarado como secreto | Añadir 'secret' => true en getForm() o incluirlo en getSecretFields() |
Rotar password por API no surte efecto | Se envió vacío y el centinela conservó el valor previo | Enviar el valor nuevo; vacío significa “no cambiar” |
Lo que queda operativo y qué sigue
Tienes el circuito de hosting cerrado de punta a punta: un servidor dado de alta con credenciales enmascaradas y tls_verify activo, el test de conexión en verde, un plan cuyo nombre coincide con el paquete del panel, un producto que enlaza servidor y plan, y una cuenta que se crea sola cuando la factura se cobra. Conoces también los límites reales —sin importación de cuentas, sin SSO, sin VPS— y tienes el esqueleto de un Server_Manager propio con los trece métodos correctos, no con los seis que documenta la web.
Queda la otra mitad de lo que vende un hosting: el dominio. En el capítulo 12 verás los registradores del núcleo, la tabla tld con sus precios de registro, renovación y transferencia, por qué el precio de renovación de un dominio no se toma del pedido sino que se recalcula en cada ciclo, y cómo escribir tu propio Registrar_Adapter con los métodos abstractos reales que, igual que aquí, no son los que lista la documentación oficial.