Server managers: aprovisionar hosting y escribir el tuyo

Por: Artiko
fossbillinghostingserver-managercpanelwhmpleskdirectadminhestiacpcwpaprovisionamientoproxmox

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
ManagerClasePuertoAutenticaciónEstado
WHM / cPanelServer_Manager_Whm2087Header Authorization: WHM <user>:<accesshash> con API token, o Basic <user>:<password>Soportado. Endpoint /json-api/<action>
PleskServer_Manager_Plesk8443username + password vía cliente XML-API de PleskSoportado. La doc admite “Plesk has minor syncing issues”
DirectAdminServer_Manager_Directadmin2222HTTP Basic, admite admin|usuario para login-asSoportado. Endpoints /CMD_<comando>
HestiaCPServer_Manager_Hestia8083POST a /api/ con hash = <accessKeyId>:<secretKey>, o user + passwordSoportado
CWPServer_Manager_CWP2304accesshash más IP Origin declarada en el panelSoportado. Arreglos en 0.8.0 con el PR #3240
CustomServer_Manager_CustomningunaPlantilla 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.

PanelSituación verificada al 2026-08-09
ProxmoxEl 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
SolusVMEn extensions-extra/managers/SolusVM, Untested, con eliminación prevista el 31-Aug-2026
VirtualizorNo existe módulo oficial ni comunitario en el directorio. Solo aparece citado en el issue paraguas #1362
VirtFusionSolicitado en #1362, sin implementación
PterodactylPostura oficial: “the core project does not plan to build or maintain an integration”
CyberPanelNi 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():

CampoDefaultSignificado
bandwidth y quota1048576 cada unotransferencia y disco, 1024 * 1024
max_addon y max_park1dominios adicionales y aparcados
max_sub y max_pop1subdominios y cuentas de correo
max_sql y max_ftp1bases 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.name es 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ódigoMensajeQué falta de verdad
701Configure server for hosting productEl producto no tiene server_id en su config
702Configure hosting plan for hosting productEl producto no tiene hosting_plan_id
703Domain name is invalidEl carrito no envió sld, o llegó vacío
704Domain extension is invalidEl 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:

IssueProblemaCorregido en
#3692Crash al probar la conexión0.8.2
#3708Credenciales no enviadas tras 0.8.0 / 0.8.1
#3709TypeError de strlen() en PHP 8.3 tras conexión exitosa
#3894Borrado de cuenta con falsos negativos: ahora verifica que la cuenta desapareció antes de reportar fallo0.8.4
#3959Apó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 permisosAcciones necesarias
AccountAdd, Update, Delete, List, Suspend, Unsuspend
Account DetailsList
Account Package ChangeUpdate
Change PasswordUpdate

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ónImpacto operativo
No se pueden importar cuentas existentesMigrar 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 panelEl cliente ve su cuenta suspendida sin explicación en cPanel; el motivo solo vive en FOSSBilling
No hay Single Sign-On para clientesEl área de cliente no abre sesión en el panel; hay que entregar usuario y contraseña
Salvo WHM, ningún manager crea paquetesSe crean a mano en el panel y el nombre debe coincidir carácter a carácter
Los cambios no se sincronizan de vueltaEditar 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 oficialLo 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 documentadossynchronizeAccount(), 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íntomaCausa probableDiagnóstico y solución
Server manager :manager is not a valid server managerEl 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 desplegableFichero en subcarpeta, permisos incorrectos o caché sin limpiarDebe 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 probarinit() encontró un parámetro obligatorio vacíoEl mensaje nombra el campo. En CWP faltan ip, host o accesshash
Código 719 tras server_updatevalidateServerConfig() no pudo instanciar el managerEs el 2001 envuelto: revisar los campos del formulario del manager
Test de conexión con error TLStls_verify = true contra certificado autofirmado, o hostname que no casa con el certificadoInstalar certificado válido o apuntar hostname al nombre del certificado
Test en verde pero createAccount() fallaLa API key lee pero no creaEn CWP conceder Account Add/Update/Delete/List/Suspend/Unsuspend; en Hestia usar access key con permisos de billing
Conexión rechazada desde Hestia o CWPLa IP de FOSSBilling no está autorizadaLista de IP permitidas en Hestia; campo IP Origin en el API Manager de CWP
Código 701 al añadir al carritoEl producto no tiene server_idProducto → Config → seleccionar servidor
Código 702 al añadir al carritoEl producto no tiene hosting_plan_idProducto → Config → seleccionar plan
Códigos 703 / 704sld o tld no llegaron del formularioRevisar el formulario de dominio del producto y el payload de cart/add_item
Pedido en failed_setupExcepció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_setupTimeout del cliente HTTP con la cuenta ya creadaBorrar la cuenta huérfana y reactivar, o registrar el servicio con config.import = true
Failed to connect to the :type: server en HestiaCódigo de retorno distinto de cero en v-list-usersVerificar hash usuario:accesskey, puerto 8083 y respuesta por HTTPS
DirectAdmin rechaza credenciales correctasEl panel devuelve la página de login HTML, detectada por <!doctype html> o DirectAdmin LoginComprobar 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 esperadogetPackageName() devuelve <whm_username>_<plan_name>Buscar root_Basico en vez de Basico
Cuentas con 1 base de datos y 1 correoEl 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 creaNombre de plan con espaciosRenombrar sin espacios: Plan1, no Plan 1
Un custom value desaparece al guardarhp_update elimina la clave si el valor llega vacíoEnviar solo las claves a conservar, con valor
Usuarios de Hestia rechazados por empezar con númeroLímite de HestiaCP, ver hestiacp/hestiacp#4195El manager lo corrige con a; si escribes uno propio, replica la regla
La contraseña del cliente no se recuperaSe sustituye por PASSWORD_PLACEHOLDER tras la creaciónNo es recuperable: usar la acción change_password
Un token propio aparece en claro en la API adminNo está declarado como secretoAñadir 'secret' => true en getForm() o incluirlo en getSecretFields()
Rotar password por API no surte efectoSe envió vacío y el centinela conservó el valor previoEnviar 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.