config.php de la A a la Z, la CLI y los logs
config.php de la A a la Z, la CLI y los logs
Terminaste el capítulo 2 con una instancia respondiendo por HTTPS y un cron cada cinco minutos. El asistente web te escribió un fichero en la raíz, config.php, y probablemente no lo has vuelto a mirar. Ese fichero es todo lo que FOSSBilling sabe de sí mismo antes de tocar la base de datos: dónde está, cómo se llama, con qué clave descifra las credenciales de tus pasarelas de pago y a cuántas peticiones por hora deja entrar a un invitado.
El problema real que resuelve este capítulo es que la documentación oficial de config.php no coincide con el código en al menos seis puntos, y algunos de esos puntos cuestan horas: una clave documentada que no existe, una URL que se graba sin protocolo cuando la doc la muestra con protocolo, y un argumento de CLI que la ayuda anuncia pero el método ignora. A eso se suman los dos temas que solo aparecen cuando algo va mal: los seis comandos reales de console.php y los once canales de log, con la parte que rota sola y la parte que no rota nunca y te llena el disco.
Al terminar tendrás una referencia clave por clave verificada contra FOSSBilling 0.8.5, un procedimiento de cambio que no rompe la instalación, path_data fuera de la raíz web, logrotate cubriendo lo que Monolog no cubre y los comandos de mantenimiento ejecutados con el usuario correcto.
Dónde vive config.php, cómo se genera y cómo respaldarlo
config.php vive en la raíz de la instalación. El bootstrap lo expone como la constante PATH_CONFIG, que es literalmente PATH_ROOT/config.php, y no hay forma soportada de moverlo a otro sitio: en el VPS de referencia de este curso es /var/www/fossbilling/config.php y en la imagen Docker oficial /var/www/html/config.php. A su lado viven la plantilla config-sample.php, que sí viene en el ZIP, y el respaldo automático config.old.php.
No viene en el ZIP de release. El asistente web lo genera al final de la instalación, a partir de los valores que introdujiste, y acto seguido invalida OPcache para esa ruta. Si prefieres no usar el asistente, la cabecera de config-sample.php describe la alternativa manual: renombrar la plantilla a config.php, importar install/sql/structure.sql e install/sql/content.sql, abrir /admin para crear la cuenta de administrador y borrar install/.
Cuando la propia aplicación reescribe la configuración —el actualizador integrado activando modo mantenimiento, o el patcher migrando claves—, FOSSBilling\Config crea antes un respaldo config.old.php y después invalida la caché de opcodes con opcache_invalidate(PATH_CONFIG, true). Eso tiene dos consecuencias operativas: tienes una copia previa gratis tras cada actualización, y ese config.old.php contiene las mismas credenciales y el mismo salt que el original, así que hay que bloquearlo en el servidor web igual que al principal. La plantilla oficial de nginx bloquea /config.php pero no /config.old.php.
El procedimiento oficial de cambio, literal de admin-guide/config.mdoc, tiene cuatro pasos: editar el fichero, guardarlo, limpiar la caché desde System → Tools → Clear cache o borrando /data/cache/, y probar. El aviso que acompaña a esos pasos es explícito: “Always back up config.php before making changes. A syntax error will break your installation.”
En la práctica, el ciclo seguro es este:
FB=/var/www/fossbilling
sudo mkdir -p /var/backups/fossbilling
# 1. Copia fechada FUERA de la raiz web, con permisos cerrados
sudo cp "$FB/config.php" "/var/backups/fossbilling/config-$(date +%F-%H%M).php"
sudo chmod 600 /var/backups/fossbilling/config-*.php
# 2. Editar como el usuario del servidor web
sudo -u www-data nano "$FB/config.php"
# 3. Verificar la sintaxis ANTES de recargar. Esto es lo que evita el 500.
php -l "$FB/config.php"
# salida esperada: No syntax errors detected in /var/www/fossbilling/config.php
# 4. Limpiar cache con el comando real de la CLI
sudo -u www-data php "$FB/console.php" cache:clear
Gotcha: un config.php.bak dejado en la raíz es descargable si el servidor no bloquea esa extensión. La configuración endurecida de nginx del capítulo 2 bloquea un puñado de extensiones sensibles, pero .bak solo entra si la añadiste. Por eso el respaldo del paso 1 va a /var/backups, no al lado del original.
El arranque: qué lee load.php y en qué orden
Antes de recorrer clave por clave conviene ver dónde se consume cada una. src/load.php es el bootstrap común de los tres puntos de entrada —index.php, console.php y cron.php— y ejecuta siempre la misma secuencia:
preInit(); // rutas, autoloader de Composer, carga manual de libs criticas
init(); // config, constantes de runtime, DI, sesion, Sentry
checkInstaller();
checkSSL();
checkWebServer();
postInit(); // error handlers e ini_set de logging
flowchart TD
A["preInit: PATH_ROOT PATH_VENDOR PATH_LIBRARY PATH_CONFIG"] --> B{"Existe vendor?"}
B -- No --> B1["Exception codigo 1: The composer packages are missing"]
B -- Si --> C["init: leer config.php"]
C --> C1{"config.php valido?"}
C1 -- No --> C2["Exception codigo 3: configuracion vacia o invalida"]
C1 -- Si --> D["date_default_timezone_set con i18n.timezone"]
D --> E["Constantes: ADMIN_PREFIX DEBUG PATH_DATA PATH_CACHE PATH_LOG INSTANCE_ID SYSTEM_URL BIND_TO"]
E --> F["Cargar di.php y registrar Sentry"]
F --> G["checkInstaller: en produccion borra la carpeta install"]
G --> H["checkSSL: si force_https y la peticion no es segura redirige a HTTPS"]
H --> I["checkWebServer: Apache o Litespeed sin htaccess lanza codigo 5"]
I --> J["postInit: error handlers e ini_set error_log a PATH_LOG php_error.log"]
Las constantes que salen de ahí son el mapa completo de rutas de la aplicación. preInit() fija las que no dependen de la configuración: PATH_ROOT (el directorio de load.php), PATH_LIBRARY, PATH_VENDOR, PATH_THEMES, PATH_MODS, PATH_LANGS, PATH_UPLOADS (PATH_ROOT/data/uploads) y PATH_CONFIG (PATH_ROOT/config.php). Ya con config.php leído, init() añade las que sí dependen de él: PATH_DATA (valor de path_data), PATH_CACHE (PATH_DATA/cache), PATH_LOG (PATH_DATA/log), ADMIN_PREFIX (admin_area_prefix), INSTANCE_ID (info.instance_id), SYSTEM_URL (esquema derivado más url sin protocolo), DEBUG y BIND_TO (Tools::getDefaultInterface()).
Los códigos de excepción de arranque son la herramienta de diagnóstico más barata que tienes cuando la instalación no levanta: 1 falta vendor/, 2 la carpeta install/ sigue presente en producción, 3 config.php está vacío o es inválido, 5 falta .htaccess en Apache o LiteSpeed.
config-sample.php completo y comentado
Esta es la estructura literal del tag 0.8.5. Los comentarios inline del original se han omitido por longitud; los valores y el anidamiento son exactos.
<?php
declare(strict_types=1);
/**
* FOSSBilling configuration file example.
* ... instrucciones de instalacion manual ...
* For more information, see the documentation: https://docs.fossbilling.org/customizing-fossbilling/config/
*/
return [
'security' => [
'mode' => 'strict',
'force_https' => true,
'trusted_proxies' => [
'enabled' => false,
'proxies' => [],
'headers' => 'x_forwarded',
],
'session_lifespan' => 7200,
'session_regeneration_grace_period' => 300,
'perform_session_fingerprinting' => true,
'debug_fingerprint' => false,
],
'debug_and_monitoring' => [
'debug' => false,
'log_stacktrace' => true,
'stacktrace_length' => 25,
'report_errors' => false,
],
'info' => [
'salt' => bin2hex(random_bytes(16)),
'instance_id' => 'XXXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXXX',
],
'url' => 'localhost/',
'admin_area_prefix' => '/admin',
'update_branch' => 'release',
'maintenance_mode' => [
'enabled' => false,
'allowed_urls' => [],
'allowed_ips' => [],
],
'disable_auto_cron' => true,
'i18n' => [
'locale' => 'en_US',
'auto_detect_locale' => true,
'timezone' => 'UTC',
'date_format' => 'medium',
'time_format' => 'short',
'datetime_pattern' => '',
],
'path_data' => __DIR__ . '/data',
'db' => [
'driver' => 'pdo_mysql',
'host' => getenv('DB_HOST') ?: '127.0.0.1',
'name' => getenv('DB_NAME') ?: 'fossbilling',
'user' => getenv('DB_USER') ?: 'foo',
'password' => getenv('DB_PASS') ?: 'bar',
'port' => getenv('DB_PORT') ?: '3306',
],
'twig' => [
'debug' => false,
'auto_reload' => true,
'cache' => __DIR__ . '/data/cache',
'strict_variables' => true,
],
'api' => [
'require_referrer_header' => false,
'allowed_ips' => [],
'CSRFPrevention' => true,
],
'rate_limiter' => [
'enabled' => true,
'whitelist_ips' => [],
'policies' => [
// 'client_signup' => ['policy' => 'fixed_window', 'limit' => 5, 'interval' => '1 hour'],
],
],
];
Son catorce claves de primer nivel: security, debug_and_monitoring, info, url, admin_area_prefix, update_branch, maintenance_mode, disable_auto_cron, i18n, path_data, db, twig, api y rate_limiter. No hay más. Cualquier clave que veas en un tutorial y no esté en esta lista, o es de 0.7.x, o es de BoxBilling, o no existe.
Fíjate en la última línea del comentario de cabecera. Apunta a https://docs.fossbilling.org/customizing-fossbilling/config/, que es una URL muerta. La página viva es https://docs.fossbilling.org/admin-guide/config/. Es la primera de las seis discrepancias que recogeremos más abajo.
Bloque security: modo estricto, force_https, sesiones y fingerprinting
| Parámetro | Defecto | Efecto real |
|---|---|---|
security.mode | strict | Con strict las cookies se emiten con SameSite=Strict y HttpOnly. Con regular se usan propiedades por defecto, manteniendo HttpOnly. |
security.force_https | true | Redirige a HTTPS y marca las cookies como seguras. |
security.session_lifespan | 7200 | Vida de la sesión en segundos. El cron lo reutiliza para purgar sesiones viejas de la base de datos. |
security.session_regeneration_grace_period | 300 | Segundos de gracia tras regenerar el identificador de sesión, para no tirar peticiones concurrentes en vuelo. |
security.perform_session_fingerprinting | true | Verifica la huella del navegador en cada petición. |
security.debug_fingerprint | false | Registra las comprobaciones de huella. La doc pide activarlo solo temporalmente. |
force_https no es solo una redirección. Se implementa en load.php::checkSSL():
function checkSSL(): void
{
global $request;
if (Config::getProperty('security.force_https') && !Environment::isCLI()) {
if (!$request->isSecure()) {
emitResponse(new RedirectResponse('https://' . $request->getHost() . $request->getRequestUri()));
}
}
}
La parte que realmente protege la sesión no es el RedirectResponse, es que las cookies pasan a marcarse como seguras. Por eso la guía oficial insiste en tener el certificado listo antes de lanzar el asistente: el instalador deriva el valor con str_starts_with($systemUrl, 'https://'), y si instalaste por HTTP te queda force_https => false grabado.
security.mode en strict tiene un efecto colateral conocido con pasarelas de pago: la cookie SameSite=Strict no viaja cuando PayPal devuelve al usuario a tu sitio. El core lo resuelve con un restore_token firmado que restaura el session_id; lo verás en detalle en el capítulo 10. No bajes a regular para arreglar un retorno de pasarela.
La recomendación oficial de security/best-practices.mdoc para este bloque es corta y clara: “Keep mode set to strict”, “Don’t increase session_lifespan unnecessarily”.
trusted_proxies: la clave que evita el bucle de redirección infinito
Es la clave que más tiempo hace perder de todo el fichero.
'security' => [
'trusted_proxies' => [
'enabled' => false,
'proxies' => [],
'headers' => 'x_forwarded',
],
],
El mecanismo del fallo es este: $request->isSecure() solo devuelve true si Symfony confía en la cabecera X-Forwarded-Proto. Con trusted_proxies.enabled => false no confía en nadie, así que detrás de un proxy inverso FOSSBilling cree que toda petición llega por HTTP.
flowchart LR
subgraph ROTO["Sin trusted_proxies"]
A1["Cliente HTTPS"] --> B1["Proxy inverso"]
B1 --> C1["FOSSBilling ve HTTP"]
C1 --> D1["force_https emite redirect a HTTPS"]
D1 --> B1
end
subgraph BIEN["Con trusted_proxies y X-Forwarded-Proto"]
A2["Cliente HTTPS"] --> B2["Proxy inverso reenvia X-Forwarded-Proto https"]
B2 --> C2["Symfony confia en la IP del proxy"]
C2 --> D2["isSecure devuelve true"]
D2 --> E2["Se sirve la pagina sin redirigir"]
end
Configuración correcta detrás de un proxy en red privada:
'security' => [
'trusted_proxies' => [
'enabled' => true,
'proxies' => ['10.0.0.0/8', '172.16.0.0/12'],
'headers' => 'x_forwarded',
],
],
Valores de headers. La documentación de configuración solo menciona dos: x_forwarded (el estándar de facto) y forwarded (RFC 7239). El instalador acepta cuatro: x_forwarded, forwarded, aws_elb y traefik; cualquier otro valor aborta con The trusted proxy header format is invalid. Ese desfase es otra de las seis discrepancias.
Del lado del proxy hay que reenviar X-Forwarded-Proto y X-Forwarded-Host. La cita literal de la doc de seguridad: “Reverse proxies often make FOSSBilling think it is being accessed over HTTP even when the visitor is using HTTPS. To avoid that, make sure your proxy forwards X-Forwarded-Proto: https.” El diagnóstico del bucle es contar saltos con curl -sIL -o /dev/null -w "%{num_redirects}\n" https://billing.example.com/: si se dispara al máximo, es exactamente esto.
Advertencia: trusted_proxies no solo arregla las redirecciones. El limitador de tasa resuelve la IP del cliente con $this->di['request']->getClientIp(). Sin proxies de confianza configurados, todos tus visitantes comparten la IP del proxy y una sola persona agota la cuota de todos. Volveremos a ello en la sección de rate_limiter.
debug_and_monitoring: qué expone la Debug Bar y qué manda a Sentry
| Parámetro | Defecto | Efecto |
|---|---|---|
debug_and_monitoring.debug | false | Mensajes de depuración avanzados y Debug Bar en index.php, con colectores de PDO para RedBeanPHP y Doctrine. |
debug_and_monitoring.log_stacktrace | true | Incluye el stack trace al lanzarse una excepción. Requiere debug => true para tener efecto. |
debug_and_monitoring.stacktrace_length | 25 | Longitud máxima del stack trace. |
debug_and_monitoring.report_errors | false | Envío automático de errores, estabilidad y rendimiento a Sentry.io. |
El comentario del propio código sobre debug es inequívoco: “You should keep this disabled unless you’re making tests as it can reveal some information about your server.” Lo que la Debug Bar expone cuando está activa: tiempos de las fases internas del core con nombres literales session_start, translate, registerModule, init, checkperm, sharedMapping, mapping, executeShared, execute; las consultas SQL de RedBeanPHP y de Doctrine por separado; y un volcado de la configuración. info.salt y todas las claves de db se enmascaran con ******** antes de pasarlas al colector, pero el resto del fichero se ve entero.
Y hay un efecto lateral que sorprende: checkInstaller() cambia de comportamiento con debug activo. Con debug => true la carpeta install/ no se borra automáticamente.
Sobre report_errors, la tabla de maintenance/error-reporting.mdoc enumera lo que sale hacia Sentry: cabeceras y URL de la petición, versión de PHP, errores y excepciones de PHP, versión de FOSSBilling, el instance_id, tipo de servidor web, stack trace e información del sistema operativo. Retención declarada: 90 días. Es una decisión del operador, no una recomendación; si tu instancia procesa datos de clientes en la UE, revísalo antes de activarlo.
info.salt: la clave que no puedes cambiar ni perder
'info' => [
'salt' => bin2hex(random_bytes(16)),
'instance_id' => 'XXXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXXX',
],
salt son 32 caracteres hexadecimales generados una sola vez, en la instalación. La documentación lo dice en una línea: “Keep this secret and don’t change it after installation.” El porqué está en el contenedor de dependencias: el servicio crypt es un Box_Crypt que hace cifrado simétrico reversible usando info.salt como clave. Lo que se cifra con él son, entre otras cosas, las credenciales de las pasarelas de pago y las de los server managers. Traducción operativa:
Uno. Si cambias el salt, todo lo cifrado con el anterior queda ilegible. No hay migración automática.
Dos. Si pierdes config.php y restauras solo la base de datos, las credenciales cifradas son irrecuperables. Tendrás que volver a introducir a mano las claves de Stripe, de PayPal y de cada server manager.
Tres. Por eso el respaldo de config.php es tan crítico como el volcado SQL, y por eso conviene guardarlo aparte del tar.gz general, cifrado o con permisos 600.
instance_id es distinto: es un UUID v4 generado con Uuid::v4() y la doc afirma que es seguro compartirlo —“It’s safe to share — it doesn’t reveal anything about your installation.”—. Se ve en Settings → About y sirve para correlacionar errores en Sentry.
url, admin_area_prefix y las constantes que derivan de ellos
'url' => 'localhost/',
'admin_area_prefix' => '/admin',
Desde 0.8.0 el valor de url se graba SIN prefijo de protocolo. El instalador aplica str_replace(['https://','http://'], '', $systemUrl) antes de escribirlo. Es decir, lo que verás en un config.php real es:
'url' => 'billing.example.com/',
La documentación de configuración lo describe como “Your FOSSBilling URL with trailing slash (e.g., https://billing.example.com/)”, con protocolo. Contradice el comportamiento real y es la discrepancia que más confunde al editar a mano. La barra final sí es obligatoria: normalizeSystemUrl() la añade siempre.
El esquema no se pierde, se deriva. load.php construye la constante SYSTEM_URL como el esquema —https:// si security.force_https está activo o si la petición ya es segura— más el valor de url sin protocolo. Si escribes 'url' => 'https://billing.example.com/' a mano acabas con un SYSTEM_URL con el esquema duplicado.
admin_area_prefix se expone como constante ADMIN_PREFIX y es lo que index.php usa para decidir qué aplicación instancia:
if (strncasecmp($url, ADMIN_PREFIX, strlen(ADMIN_PREFIX)) === 0) {
define('ADMIN_AREA', true);
// ...
$app = new Box_AppAdmin([], $debugBar);
} else {
define('ADMIN_AREA', false);
$app = new Box_AppClient([], $debugBar);
}
Cambiarlo mueve el panel de verdad; basta con poner 'admin_area_prefix' => '/gestion-interna-7fa2' y limpiar la caché con console.php cache:clear. Es ofuscación, no seguridad. Reduce el ruido de bots, no detiene a nadie dirigido. Combínalo con restricción por IP y con el limitador de tasa; el capítulo 16 lo trata a fondo.
La clave hermana es update_branch, con valores release (estables, lo recomendado en producción) o preview (builds de desarrollo, “may have bugs”). El instalador la fija con Version::isPreviewVersion() ? 'preview' : 'release'.
Y disable_auto_cron, que desactiva la ejecución del cron cuando un administrador inicia sesión. El instalador la calcula como !Version::isPreviewVersion() && !Environment::isDevelopment(), así que en una instalación estable de producción queda en true: ese respaldo está apagado y no debes contar con él.
Bloque db y las variables de entorno DB_HOST, DB_NAME, DB_USER, DB_PASS, DB_PORT
| Parámetro | Defecto en el sample | Variable de entorno |
|---|---|---|
db.driver | pdo_mysql | — |
db.host | 127.0.0.1 | DB_HOST |
db.name | fossbilling | DB_NAME |
db.user | foo | DB_USER |
db.password | bar | DB_PASS |
db.port | 3306 | DB_PORT |
Dos notas de compatibilidad. En 0.7.x la clave era db.type con valor 'mysql'; el patcher renombra clave y valor a db.driver / 'pdo_mysql' durante la actualización. Y db.port pasa por Tools::normalizePort(), que rechaza valores inválidos con Database port is invalid.
El detalle que rompe los despliegues containerizados. En config-sample.php esas cinco entradas son llamadas a getenv('DB_X') ?: '<defecto>'. Pero el config.php que genera el instalador graba los valores literales ya resueltos, no las llamadas a getenv(). Si montas la imagen oficial, completas el asistente web y luego intentas cambiar la contraseña de la base de datos vía variable de entorno del contenedor, no pasa nada: el fichero tiene la cadena escrita a fuego.
La solución es reponer los getenv() a mano después de instalar:
'db' => [
'driver' => 'pdo_mysql',
'host' => getenv('DB_HOST') ?: '127.0.0.1',
'name' => getenv('DB_NAME') ?: 'fossbilling',
'user' => getenv('DB_USER') ?: 'fossbilling',
'password' => getenv('DB_PASS') ?: '',
'port' => getenv('DB_PORT') ?: '3306',
],
Las variables documentadas en admin-guide/config.mdoc son las cinco de base de datos más APP_ENV=dev (“Enable development mode”) y APP_DEBUG=true (“Enable debug mode”).
APP_ENV la interpreta FOSSBilling\Environment y tiene efectos verificados: con dev se activan el profiler de Twig y el colector de DebugBar, los proxies de Doctrine se autogeneran y install/ no se borra; con prod explícito checkInstaller() sí borra install/; con test, PATH_DATA y PATH_UPLOADS se redirigen a sys_get_temp_dir()/fossbilling_test_data.
Comprobación rápida de que el bloque db es correcto, sin abrir el navegador:
sudo -u www-data php -r '$d = (require "/var/www/fossbilling/config.php")["db"];
try { new PDO("mysql:host={$d["host"]};port={$d["port"]};dbname={$d["name"]}", $d["user"], $d["password"]); echo "BD OK\n"; }
catch (Throwable $e) { echo "BD FALLA: ", $e->getMessage(), "\n"; }'
Bloque twig: auto_reload, cache y strict_variables en producción
| Parámetro | Defecto | Qué hace | En producción |
|---|---|---|---|
twig.debug | false | Modo debug del motor de plantillas | false |
twig.auto_reload | true | Recompila la plantilla si detecta que cambió | false |
twig.cache | __DIR__ . '/data/cache' | Directorio de plantillas compiladas | Escribible por el usuario web |
twig.strict_variables | true | Lanza error ante variables inexistentes | true |
auto_reload cuesta un stat() por plantilla y por petición. En una página del panel que compone veinte plantillas eso son veinte llamadas al sistema de ficheros que no aportan nada si el tema no cambia. Ponerlo en false es la optimización más barata del fichero, con una contrapartida disciplinaria: tras tocar cualquier plantilla hay que limpiar la caché a mano.
Es decir, 'auto_reload' => false en el bloque, seguido siempre de sudo -u www-data php console.php cache:clear cada vez que toques una plantilla.
No bajes strict_variables a false para silenciar un tema roto. Muchos de los bugfixes de 0.8.3 fueron precisamente errores de strict_variables en plantillas: la opción está haciendo su trabajo, que es señalar que tu tema referencia una variable que ya no existe. Arreglar el tema es la salida; el capítulo 15 entra en ello.
Fíjate en que twig.cache apunta por defecto dentro de data/. Es un detalle que importa al mover path_data, y volvemos a él más abajo.
Bloque api: referrer, allowed_ips y CSRFPrevention
'api' => [
'require_referrer_header' => false,
'allowed_ips' => [],
'CSRFPrevention' => true,
],
| Parámetro | Defecto | Efecto |
|---|---|---|
api.require_referrer_header | false | Exige que toda petición a la API lleve una cabecera Referer que empiece por SYSTEM_URL. Si no coincide, la API responde con el código de aplicación 1004. |
api.allowed_ips | [] | Lista blanca de IPs. Si no coincide, código 1002. |
api.CSRFPrevention | true | Protección CSRF de las llamadas de navegador autenticadas por sesión. |
api.allowed_ips = [] significa permitir TODAS las IPs, no ninguna. Es la lectura contraintuitiva más frecuente de este fichero: un array vacío es “sin filtro”, no “lista cerrada”. Sobre CSRF, el comentario del propio código no deja margen: “Disabling this is highly discouraged and opens your instance to a known vulnerability.” El token se compara con hash_equals() contra el valor de sesión y, si existe cookie, también contra la cookie, en un patrón de doble envío. Un fallo devuelve CSRF token invalid con código 403.
Y el matiz que hace falta para integrar: las llamadas externas autenticadas con API key no necesitan token CSRF. La protección solo aplica al flujo de navegador con sesión. Si estás escribiendo un cliente de la API y te topas con errores de CSRF, el problema es que estás autenticando con sesión en vez de con clave; el capítulo 14 desarrolla el modelo completo.
rate_limiter: las treinta políticas por defecto y cómo sobrescribirlas
Este bloque sustituye desde 0.8.0 a las claves antiguas api.rate_span, api.rate_limit, api.throttle_delay, api.rate_span_login, api.rate_limit_login y api.rate_limit_whitelist. Si vienes de 0.7.x, el patcher te propone aceptar los nuevos valores por defecto. Por debajo usa el componente RateLimiter de Symfony.
| Parámetro | Defecto | Significado |
|---|---|---|
rate_limiter.enabled | true | Activa o desactiva el limitador entero |
rate_limiter.whitelist_ips | [] | IPs y CIDRs exentos |
rate_limiter.policies | [] | Sobrescribe políticas concretas; lo que no toques hereda el defecto |
Un policies vacío no significa “sin límites”: significa que se aplican íntegras las 30 políticas de FOSSBilling\Security\RateLimiter::getDefaultConfig(). Esta tabla no está en la documentación; sale del código del tag 0.8.5.
| Política | Tipo | Límite | Intervalo |
|---|---|---|---|
api_guest | token_bucket | 100 | 60 seconds |
api_authenticated_ip | token_bucket | 1000 | 1 hour |
api_authenticated_account | token_bucket | 1000 | 1 hour |
api_login | fixed_window | 10 | 1 hour |
client_password_reset_ip | fixed_window | 10 | 1 hour |
client_password_reset_email | fixed_window | 3 | 1 hour |
client_password_reset_confirm_ip | fixed_window | 20 | 60 seconds |
client_password_reset_confirm_post_ip | fixed_window | 20 | 60 seconds |
client_email_confirm_ip | fixed_window | 20 | 60 seconds |
staff_password_reset_ip | fixed_window | 5 | 1 hour |
staff_password_reset_email | fixed_window | 3 | 1 hour |
staff_password_reset_confirm_ip | fixed_window | 20 | 60 seconds |
staff_password_reset_confirm_post_ip | fixed_window | 20 | 60 seconds |
client_signup | fixed_window | 5 | 1 hour |
guest_ticket_create | fixed_window | 3 | 1 hour |
order_generation_ip | fixed_window | 15 | 1 hour |
domain_lookup_ip | fixed_window | 60 | 1 hour |
invoice_payment_ip | fixed_window | 10 | 1 hour |
invoice_payment_hash | fixed_window | 10 | 1 hour |
invoice_pdf_ip | fixed_window | 10 | 1 hour |
invoice_pdf_hash | fixed_window | 10 | 1 hour |
invoice_get_ip | fixed_window | 10 | 1 hour |
invoice_get_hash | fixed_window | 30 | 1 hour |
cart_promo_apply_ip | fixed_window | 10 | 1 hour |
client_email_verification_resend_ip | fixed_window | 30 | 1 hour |
client_email_verification_resend_account | fixed_window | 3 | 1 hour |
client_email_resend_ip | fixed_window | 30 | 1 hour |
client_email_resend_account | fixed_window | 5 | 1 hour |
profile_password_change_ip | fixed_window | 30 | 1 hour |
profile_password_change_account | fixed_window | 5 | 1 hour |
Dos tipos de política: token_bucket rellena crédito de forma continua y tolera ráfagas, mientras que fixed_window cuenta eventos dentro de una ventana rígida. Por eso el tráfico de API usa el primero y las acciones sensibles —login, alta de cliente, reseteo de contraseña— usan el segundo. Las que más se notan en operación: api_login con 10 intentos por hora es la barrera contra fuerza bruta; client_signup con 5 por hora limita altas masivas; guest_ticket_create con 3 por hora corta el spam de tickets; invoice_pdf_ip con 10 por hora puede sorprenderte si un cliente descarga muchas facturas seguidas.
Sobrescritura, con la sintaxis literal de la doc. Solo escribes las políticas que cambias:
'rate_limiter' => [
'enabled' => true,
'whitelist_ips' => ['203.0.113.10'],
'policies' => [
'invoice_pdf_ip' => ['policy' => 'fixed_window', 'limit' => 40, 'interval' => '1 hour'],
],
],
flowchart TD
A["Un cliente legitimo recibe 429"] --> B{"Hay proxy inverso o CDN delante?"}
B -- Si --> B1["Configurar security.trusted_proxies y reenviar X-Forwarded-Proto"]
B1 --> Z["Reintentar y volver a medir"]
B -- No --> C{"Es una integracion con IP fija?"}
C -- Si --> C1["Anadir la IP a rate_limiter.whitelist_ips"]
C1 --> Z
C -- No --> D{"El limite por defecto es realmente bajo para tu caso?"}
D -- Si --> D1["Sobrescribir solo esa politica en rate_limiter.policies"]
D1 --> Z
D -- No --> E["Dejar el limite: probablemente es abuso real"]
El orden de ese diagrama no es arbitrario. Antes de subir ningún límite hay que descartar el problema del proxy, porque con trusted_proxies mal puesto el limitador ve una sola IP para todo el tráfico y subir la cuota solo retrasa el mismo fallo. Desde 0.8.4 hay además una interfaz de gestión de límites en el panel, útil para inspeccionar el estado sin editar el fichero.
maintenance_mode: URLs e IPs permitidas y qué no bloquea
'maintenance_mode' => [
'enabled' => true,
'allowed_urls' => ['/api/guest/*'],
'allowed_ips' => ['192.168.1.0/24'],
],
La comprobación vive en Box_App::processRequest() y corta la petición salvo en tres casos: que la ruta empiece por el prefijo de administración, que esté en allowed_urls —soporta comodín *— o entre las siempre permitidas (/api/guest/staff/login, /api/admin), o que la IP esté en allowed_ips, que acepta CIDR vía Symfony\Component\HttpFoundation\IpUtils.
Cuando corta, la respuesta depende del área: para api devuelve JSON con 503, para el resto renderiza la plantilla mod_system_maintenance también con estado 503.
El punto que hay que interiorizar: el modo mantenimiento NO bloquea el área de administración. Nunca lo uses como sustituto de una restricción de acceso al panel. Su propósito es cerrar el escaparate mientras trabajas por dentro, y el actualizador integrado lo activa y lo restaura solo durante una actualización.
Prueba de que quedó bien puesto: curl -s -o /dev/null -w "%{http_code}\n" contra / debe devolver 503 y contra /admin debe devolver 200.
Bloque i18n: locale, timezone y formatos ICU
| Parámetro | Defecto | Opciones | Efecto |
|---|---|---|---|
i18n.locale | en_US | código de idioma | Idioma por defecto |
i18n.auto_detect_locale | true | bool | Detecta el idioma preferido del navegador |
i18n.timezone | UTC | zona horaria | Zona por defecto, aplicada con date_default_timezone_set() en el arranque |
i18n.date_format | medium | none, short, medium, long | Formato de fecha de IntlDateFormatter |
i18n.time_format | short | none, short, medium, long | Formato de hora |
i18n.datetime_pattern | '' | patrón ICU | Si lo rellenas, anula date_format y time_format |
auto_detect_locale en false significa, literal del comentario del código, “always use the configured locale unless the user manually selects another language”.
Todo este bloque depende de la extensión intl, que es obligatoria. Si falta no es un problema cosmético de formato: la instalación entera se bloquea.
Un 'datetime_pattern' => 'dd-MM-yyyy HH:mm' deja date_format y time_format sin efecto: pasas a controlar la representación completa con la sintaxis de patrones de ICU.
Recomendación operativa: deja i18n.timezone en UTC. Desde 0.8.4 existe zona horaria por usuario en el perfil del cliente, así que la presentación se resuelve donde corresponde. Facturación y cron son mucho más fáciles de razonar en UTC, sobre todo con cambios de horario de verano. Los síntomas de tener esto mal son caros de diagnosticar: facturas con fecha desplazada un día, suspensiones que ocurren antes de tiempo y un “Last Scheduled Execution” que parece del futuro. La numeración de facturas y su relación con las fechas la verás en el capítulo 4.
Hay tres capas que alinear, y las tres deberían decir UTC:
timedatectl set-timezone UTC # sistema operativo
grep -n '^date.timezone' /etc/php/8.3/fpm/php.ini # PHP
grep -n "'timezone'" /var/www/fossbilling/config.php # FOSSBilling
Mover path_data fuera de la raíz web
path_data es la única ruta configurable del fichero, y de ella cuelgan dos constantes derivadas: PATH_CACHE = PATH_DATA/cache y PATH_LOG = PATH_DATA/log.
graph TD
D["path_data"] --> C["cache"]
D --> L["log"]
D --> U["uploads"]
D --> F["update-finalization.json"]
C --> C1["plantillas Twig compiladas"]
C --> C2["metadatos de Doctrine"]
C --> C3["namespace rate_limit del limitador"]
L --> L1["11 carpetas de canal con rotacion Monolog"]
L --> L2["php_error.log sin rotacion"]
L --> L3["exception_handler.log sin rotacion"]
Por defecto todo eso vive dentro de la raíz de documentos, protegido únicamente por una regla del servidor web (location ^~ /data/ { return 403; } en nginx). La protección estructural es sacarlo del árbol servido:
sudo mkdir -p /var/lib/fossbilling/data/{cache,log,uploads}
sudo rsync -a /var/www/fossbilling/data/ /var/lib/fossbilling/data/
sudo chown -R www-data:www-data /var/lib/fossbilling
sudo chmod -R 750 /var/lib/fossbilling
# y en config.php: 'path_data' => '/var/lib/fossbilling/data',
Gotcha: twig.cache no sigue a path_data. Es una clave independiente que apunta literalmente a __DIR__ . '/data/cache', es decir, dentro de la raíz. Si mueves path_data y no tocas twig.cache, la caché de plantillas se sigue escribiendo en el árbol web. Hay que moverla explícitamente en el mismo cambio, poniendo 'cache' => '/var/lib/fossbilling/data/cache' dentro del bloque twig.
Verificación después del cambio: console.php cache:clear, comprobar que aparecen ficheros nuevos en /var/lib/fossbilling/data/log/ y que curl -s -o /dev/null -w "%{http_code}\n" https://billing.example.com/data/log/ devuelve 403 o 404.
Nota honesta: el proyecto no documenta este escenario. El parámetro existe, es configurable y funciona, pero no hay guía oficial que describa sus efectos secundarios; el de twig.cache lo hemos deducido leyendo el sample. Si actualizas y algo se comporta raro con las rutas, revisa primero estas dos claves.
Las seis discrepancias verificadas entre documentación y código
Recopiladas para que no las descubras a las tres de la mañana. Todas comprobadas contra el tag 0.8.5.
| # | Lo que dice la documentación | Lo que hace el código | Cómo lo verificas |
|---|---|---|---|
| 1 | url se documenta como https://billing.example.com/, con protocolo | El instalador aplica str_replace(['https://','http://'], '', $systemUrl) y graba billing.example.com/ | grep "'url'" config.php |
| 2 | La sección Data & Logging documenta path_logs y log_to_db | Ninguna de las dos existe. No están en config-sample.php ni se referencian en di.php. La ruta de logs se deriva como PATH_LOG = PATH_DATA/log | grep -n "path_logs|log_to_db" config-sample.php di.php |
| 3 | El comentario de cabecera de config-sample.php remite a docs.fossbilling.org/customizing-fossbilling/config/ | Esa URL está muerta; la página viva es /admin-guide/config/ | curl -sIL sobre ambas |
| 4 | trusted_proxies.headers admite dos valores: x_forwarded y forwarded | El instalador acepta cuatro: x_forwarded, forwarded, aws_elb y traefik | Mensaje The trusted proxy header format is invalid. en install/install.php |
| 5 | Las variables DB_HOST, DB_NAME, DB_USER, DB_PASS, DB_PORT se presentan como forma soportada de configurar la base de datos | Solo funcionan si el config.php conserva los getenv(). El que genera el instalador graba valores literales | grep getenv config.php tras instalar |
| 6 | cron:run anuncia un argumento interval en minutos | Se ignora por completo: la firma real es runCrons(): bool, sin parámetros | php console.php cron:run 999 se comporta igual que sin argumento |
Ninguna de las seis es un bug de seguridad. Todas son horas perdidas si no las conoces.
console.php: los seis comandos reales y cómo ejecutarlos bien
console.php es una aplicación de Symfony Console que carga dinámicamente los comandos de los módulos activos buscando ficheros en modules/<Modulo>/Commands/*.php. El listado siempre autoritativo es el de tu propia instalación, con sudo -u www-data php /var/www/fossbilling/console.php list. En el tag 0.8.5 hay exactamente seis, con estos nombres leídos de sus atributos #[AsCommand]:
| Comando | Descripción literal del código | Fichero |
|---|---|---|
cron:run [interval] | ”Executes the cron jobs” | modules/Cron/Commands/Run.php |
cache:clear | ”Clears the cache” | modules/System/Commands/CacheClear.php |
system:run-patcher | ”Runs update patches and config migrations” | modules/System/Commands/RunPatcher.php |
system:version | ”Returns the current version of FOSSBilling” | modules/System/Commands/Version.php |
system:releasenotes | ”Returns the release notes of all newer versions of FOSSBilling” | modules/System/Commands/ReleaseNotes.php |
theme:list | ”Returns the list of the installed themes” | modules/Theme/Commands/Listing.php |
Dos nombres que casi todo el mundo escribe mal. Es cache:clear, no system:cache-clear. Y es system:releasenotes todo junto, sin guion, no system:release-notes.
El argumento interval de cron:run no hace nada. Está declarado con $this->addArgument('interval', InputArgument::OPTIONAL, 'Interval in minutes') y tanto cron.php como el comando invocan $service->runCrons($interval), pero la firma real del método en 0.8.5 es public function runCrons(): bool, sin parámetros. PHP tolera argumentos extra en métodos de userland, así que no falla; simplemente se descarta.
Ejecuta siempre como el usuario del servidor web. Es la regla que más disgustos evita:
sudo -u www-data php /var/www/fossbilling/console.php cache:clear
Si lo lanzas como root, los ficheros que se regeneren en data/cache quedan con propietario root y la aplicación empieza a devolver errores de permisos que no parecen tener causa. La reparación:
sudo chown -R www-data:www-data /var/www/fossbilling/data
Salidas útiles. cron:run imprime cabecera, última ejecución y resultado —Successfully ran the cron jobs.— y devuelve Command::FAILURE si alguna tarea aislada falló.
system:run-patcher solo funciona desde CLI —“This command can only be run from the CLI.”— y es idempotente: cachea el estado bajo la clave updatePatcher con version y latest_patch_level, así que si no hay finalización pendiente y el estado coincide imprime “The update patcher has already been run for this version.” y sale con éxito sin trabajar.
Comprobación de versión instalada contra la última publicada, y los mismos comandos dentro del contenedor oficial, cuya raíz es /var/www/html:
LOCAL=$(sudo -u www-data php /var/www/fossbilling/console.php system:version | tr -dc '0-9.')
LATEST=$(curl -fsSL https://api.github.com/repos/FOSSBilling/FOSSBilling/releases/latest | grep -oP '"tag_name":\s*"\K[^"]+')
echo "instalada=$LOCAL ultima=$LATEST"
docker compose exec -u www-data fossbilling php /var/www/html/console.php system:version
docker compose exec -u www-data fossbilling php /var/www/html/console.php cache:clear
Los once canales de Monolog, la rotación de 90 días y qué NO rota
La configuración del logging vive en src/library/FOSSBilling/Monolog.php y no es configurable desde config.php. Estos son los once canales:
public array $channels = [
'activity', 'application', 'cron', 'database', 'license',
'mail', 'event', 'routing', 'billing', 'security', 'email',
];
// ...
$path = Path::join(PATH_LOG, $channel, "{$channel}.log");
$this->logger[$channel] = new Logger($channel);
$rotatingHandler = new RotatingFileHandler($path, 90, Level::Debug);
Datos concretos del handler: es un Monolog\Handler\RotatingFileHandler con maxFiles = 90, es decir retención de 90 días con rotación diaria, y nivel mínimo Level::Debug, o sea que se registra absolutamente todo. El formato de línea es [%datetime%] %channel%.%level_name%: %message% %context% %extra% con dateFormat 'd-M-Y H:i:s e'.
Los ficheros quedan en <path_data>/log/<canal>/<canal>-YYYY-MM-DD.log. Qué mirar en cada canal:
| Canal | Cuándo lo abres |
|---|---|
activity | Acciones de usuarios y staff. Es lo que también alimenta la tabla de actividad vía Box_LogDb. |
application | Errores y avisos generales del núcleo. |
cron | Ejecuciones del cron y tareas aisladas que fallaron. Primer sitio al que ir si el panel avisa de cron atrasado. |
database | Incidencias de la capa de datos. |
license | Comprobaciones de licencia de extensiones. |
mail | Envío de correo. |
email | Cola y plantillas de correo. Es un canal distinto de mail. |
event | Eventos disparados, en nivel debug, con su payload. Enorme valor para depurar hooks. |
routing | Resolución de rutas y 404. |
billing | Facturación, pagos y renovaciones. |
security | Autenticación, CSRF, límites de tasa. |
Escribir en un canal desde código propio es una sola línea —$di['logger']->setChannel('miextension')->info('Nuevo pedido: #' . $id);— y es la forma correcta de instrumentar tus módulos, como verás en el capítulo 13. Ese mismo $di['logger'] es un Box_Log que además escribe en la tabla de actividad vía Box_LogDb.
Lo que NO pasa por Monolog y por tanto NO rota:
data/log/php_error.log, fijado en el bootstrap conini_set('error_log', Path::join(PATH_LOG, 'php_error.log')).data/log/exception_handler.log, que escribe el manejador de excepciones de último recurso.- El fichero al que rediriges la salida del cron del sistema, por ejemplo
/var/log/fossbilling-cron.log. /var/log/cron.logdentro del contenedor Docker oficial.
Comandos del día a día:
FB=/var/www/fossbilling
tail -f "$FB/data/log/cron/cron-$(date +%Y-%m-%d).log"
tail -f "$FB/data/log/security/security-$(date +%Y-%m-%d).log"
tail -50 "$FB/data/log/php_error.log"
du -sh "$FB"/data/log/* | sort -h # 90 dias por 11 canales crece mas de lo que parece
logrotate para php_error.log, exception_handler.log y el log del cron
Los dos ficheros que Monolog no toca crecen sin límite. En una instancia con un error recurrente en cada petición, php_error.log llega a gigabytes y llena la partición, y cuando la partición se llena FOSSBilling deja de poder escribir en data/cache: pasas de un error cosmético a un sitio caído.
sudo tee /etc/logrotate.d/fossbilling > /dev/null <<'EOF'
/var/www/fossbilling/data/log/php_error.log
/var/www/fossbilling/data/log/exception_handler.log
{
weekly
rotate 12
compress
delaycompress
missingok
notifempty
copytruncate
su www-data www-data
create 0640 www-data www-data
}
EOF
sudo logrotate -d /etc/logrotate.d/fossbilling
copytruncate no es opcional aquí. PHP-FPM mantiene abierto el descriptor del fichero de error; si logrotate lo renombra sin truncar, el proceso sigue escribiendo en el inodo antiguo y el fichero nuevo se queda vacío para siempre. copytruncate copia el contenido y trunca el original en su sitio, conservando el descriptor.
El log al que rediriges el cron del sistema va aparte, porque su propietario y su ubicación son distintos. Créalo con sudo touch /var/log/fossbilling-cron.log y sudo chown www-data:www-data /var/log/fossbilling-cron.log, y dale su propia política:
sudo tee /etc/logrotate.d/fossbilling-cron > /dev/null <<'EOF'
/var/log/fossbilling-cron.log {
weekly
rotate 8
compress
delaycompress
missingok
notifempty
create 0640 www-data www-data
}
EOF
sudo logrotate -d /etc/logrotate.d/fossbilling-cron
El flag -d es una simulación: imprime lo que haría sin tocar nada. Ejecútalo siempre antes de dar por buena la configuración; para forzar una rotación real de prueba, sudo logrotate -f /etc/logrotate.d/fossbilling.
Si quieres además acotar la retención de los canales de Monolog por debajo de los 90 días no hay parámetro: hay que borrar por antigüedad desde fuera, por ejemplo con sudo find /var/www/fossbilling/data/log -name '*-20*.log' -mtime +30 -print -delete, revisando la salida antes de quitar el -print.
Errores comunes y diagnóstico
| Síntoma | Causa probable | Diagnóstico | Solución |
|---|---|---|---|
| HTTP 500 justo tras editar la configuración | Error de sintaxis en config.php | php -l config.php | Restaurar el .bak o config.old.php y volver a editar con php -l antes de recargar |
| Bucle de redirección infinito detrás de un proxy | force_https activo sin trusted_proxies | curl -sIL -w "%{num_redirects}" | security.trusted_proxies.enabled => true con la IP del proxy, y reenviar X-Forwarded-Proto |
| Todos los clientes reciben 429 a la vez | El limitador ve la IP del proxy como única IP | tail data/log/security/security-$(date +%F).log | Configurar trusted_proxies; no subir los límites primero |
Cambio en config.php sin efecto | Caché no limpiada u OPcache con el fichero antiguo | Comparar el valor en el fichero con el comportamiento | console.php cache:clear y, si persiste, recargar PHP-FPM |
path_logs no funciona por más que lo escribas | La clave no existe en 0.8.5 | grep -n path_logs config-sample.php sin resultados | Mover path_data entero |
Variables DB_* del contenedor ignoradas | El instalador grabó valores literales | grep getenv config.php sin resultados | Reponer los getenv() a mano en el bloque db |
| Errores de permisos tras un mantenimiento | Ejecutaste console.php o el cron como root | ls -l data/cache muestra propietario root | chown -R www-data:www-data data y usar siempre sudo -u www-data |
| Comando de CLI “no existe” | Nombre mal escrito | console.php list | Es cache:clear y system:releasenotes, sin guion |
El argumento de minutos de cron:run no cambia nada | Se ignora por diseño en 0.8.5 | Comparar dos ejecuciones | Programar la frecuencia en el crontab, no en el argumento |
| Partición llena | php_error.log sin rotar | du -sh data/log/* | Instalar el logrotate de la sección anterior |
| El panel funciona pero el sitio público da 503 | maintenance_mode.enabled => true olvidado | curl a / y a /admin | Ponerlo en false y limpiar caché |
| Plantilla modificada que no se refleja | twig.auto_reload => false | Revisar el bloque twig | console.php cache:clear tras cada cambio de tema |
| Credenciales de pasarela ilegibles tras restaurar | Se restauró la base de datos con otro config.php | Comparar info.salt con el del respaldo | Recuperar el config.php original; si no existe, reintroducir las credenciales |
| Fechas de facturas desplazadas un día | Desajuste entre las tres capas de zona horaria | timedatectl, date.timezone, i18n.timezone | Alinear las tres en UTC |
Lo que queda operativo
Tienes ahora el fichero de configuración entero mapeado clave por clave contra el código de 0.8.5, no contra la documentación: sabes qué se graba sin protocolo, qué claves documentadas no existen, por qué un proxy mal declarado rompe a la vez las redirecciones y el limitador de tasa, y dónde está el salt que no puedes perder. Tienes path_data fuera de la raíz web con su twig.cache acompañándolo, logrotate cubriendo los dos ficheros que Monolog ignora, y los seis comandos de console.php con el usuario correcto.
Con la plataforma bajo control, el siguiente paso deja de ser infraestructura y pasa a ser negocio. En el capítulo 4 configuras los datos de la empresa, das de alta las monedas con sus tasas de conversión, defines los impuestos y decides el esquema de numeración de facturas, que es una de esas decisiones que conviene tomar bien antes de emitir la primera.