diff --git a/AUDIT.md b/AUDIT.md index 3deabb7..5866ca0 100644 --- a/AUDIT.md +++ b/AUDIT.md @@ -76,3 +76,16 @@ pendientes. Los avisos de copyright y licencia de componentes ajenos se conserva No se ha modificado la semántica heredada de éxito/error de suspensión/reactivación. La API heredada puede anunciar éxito sin comprobar HTTP; la mejora de ese contrato queda fuera de esta intervención para evitar una reescritura del provisioning. + +## Actualización: autenticación HTTP Basic de Graphite + +Se añade `graphite_auth` (`none`/`basic`), `graphite_username` y `graphite_password`, +únicamente en configuración privada. cURL envía las credenciales por HTTPS; no se añaden a +URLs ni a la estructura devuelta al frontend. Configuraciones incompletas o inválidas se +rechazan antes de realizar la petición. No se siguen redirecciones ni hay fallback anónimo +al usar Basic. Las respuestas 401/403 conservan el error discreto del área de cliente. + +Validación tras esta actualización: 192 aserciones PHP y 24 frontend, lint de todos los PHP, +diff check y auditoría del árbol. Las nuevas credenciales de prueba son sentinelas `FAKE_`. +Se incluye una guía para Apache 2.4. No se han configurado ni verificado el VirtualHost ni +la autenticación del servidor Graphite real; el cambio del cliente no bloquea acceso público. diff --git a/README.md b/README.md index 5e0d7c3..5649a44 100644 --- a/README.md +++ b/README.md @@ -102,10 +102,26 @@ respuesta limitada a 2 MiB. Un fallo HTTP, timeout, JSON inválido o dato mal fo Las series ausentes pueden convivir con las disponibles. No se registran cuerpos HTTP, excepciones detalladas, configuración, credenciales ni parámetros completos de WHMCS. -No se incluyen credenciales de Graphite en la implementación. Esta versión requiere que -el servidor WHMCS pueda acceder al Render API con la configuración de acceso de la red; -si la instalación exige un mecanismo adicional de autenticación, debe integrarse del -lado servidor antes del despliegue. Nunca añadir tokens a URLs públicas. +Graphite admite autenticación HTTP Basic sobre HTTPS mediante configuración privada: + +```php +'graphite_auth' => 'basic', +'graphite_username' => '', // Completar solo en el fichero privado. +'graphite_password' => '', // Completar solo en el fichero privado. +``` + +Usuario y contraseña son de una cuenta técnica exclusiva para WHMCS, no de los clientes. +Se envían mediante las opciones de autenticación cURL, nunca en la URL, HTML o JavaScript. +Una configuración Basic incompleta o inválida impide enviar la petición; no hay fallback +anónimo. HTTP 401/403 y redirecciones muestran el aviso genérico de métricas no disponibles. +TLS sigue siendo obligatorio. `none` conserva compatibilidad con instalaciones anteriores, +pero NO protege Graphite: para el despliegue protegido seleccionar `basic`. + +La protección de entrada se configura en Apache; actualizar este módulo no cierra por sí +solo el acceso público. Seguir [la guía Apache](docs/GRAPHITE-APACHE.md), incluyendo la +protección de rutas Render/API y puertos de backend. No compartir la cuenta técnica con +clientes. `graphs_authorization_confirmed` debe permanecer en `false` para la vista Graphs +antigua, que carece de autorización por VPS. ## Interfaz y acceso al panel diff --git a/config.example.php b/config.example.php index 3a936ea..e59d6ff 100644 --- a/config.example.php +++ b/config.example.php @@ -2,6 +2,9 @@ return [ 'vpsmanager_api_url' => '', 'graphite_url' => '', + 'graphite_auth' => 'none', // Set to basic when the Graphite HTTPS proxy requires it. + 'graphite_username' => '', + 'graphite_password' => '', 'graphs_authorization_confirmed' => false, 'graphs_url' => '', // Optional existing full graphs UI base URL; review its authorization. ]; diff --git a/docs/GRAPHITE-APACHE.md b/docs/GRAPHITE-APACHE.md new file mode 100644 index 0000000..2ac5d8b --- /dev/null +++ b/docs/GRAPHITE-APACHE.md @@ -0,0 +1,110 @@ +# Graphite privado con Apache y WHMCS + +Esta guía es una propuesta para Apache 2.4. No se ha aplicado ni validado contra el +VirtualHost de producción: hay que inspeccionar su configuración actual antes de integrarla. +El módulo PHP ya admite las credenciales; el cierre del acceso público se hace en Apache. + +## 1. Cuenta técnica + +Crear una cuenta HTTP Basic exclusiva para el servidor WHMCS, por ejemplo `whmcs-graphite`. +No es una cuenta de cliente ni un usuario del panel ISPConfig. No compartirla con clientes: +puede acceder a todas las métricas disponibles en Graphite. WHMCS sigue autorizando qué +servicio puede visualizar cada cliente antes de solicitar sus datos. + +En una instalación tipo Debian/Ubuntu, usar un fichero de contraseñas fuera del DocumentRoot, +por ejemplo `/etc/apache2/graphite.htpasswd`. Estos comandos solicitan la clave de forma +interactiva y evitan sobrescribir un fichero ya existente: + +```bash +if [ -e /etc/apache2/graphite.htpasswd ]; then + sudo htpasswd -B /etc/apache2/graphite.htpasswd whmcs-graphite +else + sudo htpasswd -cB /etc/apache2/graphite.htpasswd whmcs-graphite +fi +``` + +No usar `htpasswd -b` ni poner la contraseña en argumentos. `-B` genera un hash bcrypt; +`-c` se usa solamente al crear el fichero. Darle permisos `0640`, propietario administrador +y grupo legible por el usuario real de Apache. Adaptar ruta, usuario y grupo al sistema; +no usar `chmod 777` ni guardar este fichero en Git. + +## 2. Proteger el VirtualHost HTTPS completo + +Integrar este bloque en el VirtualHost HTTPS **existente de Graphite**, conservando su +certificado y sus directivas WSGI/proxy/estáticos actuales: + +```apache + + AuthType Basic + AuthName "Graphite privado" + AuthBasicProvider file + AuthUserFile /etc/apache2/graphite.htpasswd + Require user whmcs-graphite + +``` + +Se requieren los módulos `auth_basic`, `authn_file` y `authz_user`. Revisar que ninguna +sección más específica (Location, Directory, alias u otro VirtualHost) elimine o sustituya +esta protección. Deben quedar protegidas tanto la interfaz como `/render/`, las APIs de +búsqueda de métricas y todas las demás rutas. Proteger únicamente la página de login no basta. + +El VirtualHost HTTP debe redirigir a HTTPS sin servir Graphite. El backend WSGI/proxy debe +escuchar solo en loopback/red privada protegida; cerrar cualquier puerto/origen/hostname +alternativo que permita eludir Apache. Revisar todos los virtual hosts que sirvan el mismo +backend. No activar logs que incluyan Authorization ni modo verbose de cURL. + +Antes de recargar, comprobar la configuración: + +```bash +sudo apachectl configtest +``` + +Solo después de obtener `Syntax OK`, recargar Apache con el mecanismo propio del sistema. +No se incluye un VirtualHost completo para evitar sustituir o romper el despliegue existente. + +## 3. Configuración privada de WHMCS + +En `/etc/open6hosting/whmcs-vpsmanager.php`, conservar las otras claves y añadir: + +```php +'graphite_auth' => 'basic', +'graphite_username' => 'whmcs-graphite', +'graphite_password' => '', // Introducir aquí la clave real, solo en este fichero privado. +``` + +Mantener `graphite_url` como la base HTTPS que ya se utiliza, sin `/render/` y sin +credenciales o query string. Las claves vacías del ejemplo NO son una configuración válida +para Basic: hasta completarlas el módulo muestra el aviso de métricas no disponibles. + +La contraseña real debe ser la misma creada en Apache. No copiar el hash bcrypt del fichero +htpasswd: cURL necesita el secreto original y Apache verifica su hash. El fichero privado de +WHMCS debe ser legible por PHP-FPM, no estar dentro del repositorio/DocumentRoot y no ser +escribible por PHP. Restringir igualmente copias de seguridad y acceso administrativo. + +## 4. Orden del cambio y verificación + +1. Preparar la cuenta y el fichero privado de WHMCS. +2. Instalar esta versión del módulo, manteniendo una copia de seguridad fuera del webroot. +3. Activar la protección de Apache, validar sintaxis y recargar. +4. Desde fuera, sin credenciales, comprobar respuesta **401** en interfaz y Render API. +5. Con contraseña incorrecta, comprobar **401** y que no se devuelve ningún dato. +6. Desde WHMCS, con la cuenta correcta, comprobar **200 JSON** de Render y las tres gráficas. +7. Con dos clientes WHMCS, comprobar que ninguno puede visualizar el servicio del otro. +8. Verificar que el backend y los nombres/puertos alternativos no exponen Graphite. + +Para una comprobación manual autenticada, `curl --user whmcs-graphite` solicita la +contraseña sin incluirla en los argumentos. Usar la URL HTTPS propia y no activar `-v`. +No probar con credenciales incluidas en la URL ni copiar respuestas de producción a Git. + +La cuenta Basic autentica al servidor WHMCS frente a Graphite. La autorización individual +por cliente sigue siendo responsabilidad de WHMCS; Apache no filtra targets por UUID. +Esta autenticación tampoco protege automáticamente la aplicación independiente Graphs: +si esa aplicación consulta Graphite desde su servidor, puede seguir publicando los datos. +Mantener su botón desactivado y restringir o corregir esa aplicación por separado. + +## Referencias + +- [Apache 2.4: autenticación y autorización](https://httpd.apache.org/docs/2.4/howto/auth.html) +- [Apache htpasswd](https://httpd.apache.org/docs/2.4/programs/htpasswd.html) +- [cURL HTTPAUTH](https://curl.se/libcurl/c/CURLOPT_HTTPAUTH.html) +- [cURL USERNAME](https://curl.se/libcurl/c/CURLOPT_USERNAME.html) diff --git a/lib/Graphite.php b/lib/Graphite.php index 80238b6..63dbc20 100644 --- a/lib/Graphite.php +++ b/lib/Graphite.php @@ -17,11 +17,26 @@ final class Graphite ]; private $baseUrl; private $transport; + private $username; + private $password; public function __construct(array $config, ?callable $transport = null) { $this->baseUrl = Config::url($config, 'graphite_url'); - $this->transport = $transport ?? [self::class, 'request']; + $mode = $config['graphite_auth'] ?? 'none'; + $username = $config['graphite_username'] ?? ''; + $password = $config['graphite_password'] ?? ''; + if (!in_array($mode, ['none', 'basic'], true) + || !is_string($username) || !is_string($password) + || ($mode === 'none' && ($username !== '' || $password !== '')) + || ($mode === 'basic' && ($username === '' || $password === '' + || strpos($username, ':') !== false + || preg_match('/[\\x00-\\x1f\\x7f]/', $username . $password)))) { + throw new \RuntimeException('Configuración de autenticación Graphite no válida.'); + } + $this->username = $mode === 'basic' ? $username : null; + $this->password = $mode === 'basic' ? $password : null; + $this->transport = $transport ?? [$this, 'request']; } public static function range($value): string @@ -118,7 +133,7 @@ final class Graphite ]; } - private static function request(string $url): array + private function request(string $url): array { if (!function_exists('curl_init')) { throw new \RuntimeException('Graphite transport unavailable.'); @@ -141,6 +156,13 @@ final class Graphite return strlen($chunk); }, ]); + if ($this->username !== null) { + curl_setopt_array($ch, [ + CURLOPT_HTTPAUTH => CURLAUTH_BASIC, + CURLOPT_USERNAME => $this->username, + CURLOPT_PASSWORD => $this->password, + ]); + } try { $ok = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_HTTP_CODE); diff --git a/tests/run.php b/tests/run.php index 174fac2..45c9650 100644 --- a/tests/run.php +++ b/tests/run.php @@ -6,7 +6,7 @@ if (extension_loaded('curl')) { } foreach (['CURLOPT_RETURNTRANSFER', 'CURLOPT_CONNECTTIMEOUT', 'CURLOPT_TIMEOUT', 'CURLOPT_SSL_VERIFYPEER', 'CURLOPT_SSL_VERIFYHOST', 'CURLOPT_POSTFIELDS', 'CURLOPT_HTTPHEADER', 'CURLOPT_FOLLOWLOCATION', - 'CURLOPT_PROTOCOLS', 'CURLPROTO_HTTPS', 'CURLOPT_WRITEFUNCTION', 'CURLINFO_HTTP_CODE'] as $i => $name) { + 'CURLOPT_PROTOCOLS', 'CURLPROTO_HTTPS', 'CURLOPT_WRITEFUNCTION', 'CURLINFO_HTTP_CODE', 'CURLOPT_HTTPAUTH', 'CURLAUTH_BASIC', 'CURLOPT_USERNAME', 'CURLOPT_PASSWORD'] as $i => $name) { define($name, $i + 1); } $GLOBALS['calls'] = []; @@ -220,6 +220,40 @@ try { check(vpsmanager_ConfigOptions($params)['Nodo']['Options'] === ['fixture-choice'], 'nodes configuration'); $GLOBALS['response'] = ['status' => 500, 'body' => 'PRIVATE_RESPONSE']; check(vpsmanager_ConfigOptions($params)['Pack']['Options'] === [], 'configuration failure handled'); + // Graphite server-to-server Basic authentication. All credentials here are fictitious. + $auth = $config + ['graphite_auth' => 'basic', 'graphite_username' => 'FAKE_GRAPHITE_USER', + 'graphite_password' => 'FAKE_GRAPHITE_PASSWORD']; + $GLOBALS['response'] = ['status' => 200, 'body' => json_encode($raw)]; + $secured = new Graphite($auth); + $secured->getForService($params); + $call = end($GLOBALS['calls']); + check($call->options[CURLOPT_HTTPAUTH] === CURLAUTH_BASIC, 'explicit Basic authentication'); + check($call->options[CURLOPT_USERNAME] === $auth['graphite_username'], 'server-side username'); + check($call->options[CURLOPT_PASSWORD] === $auth['graphite_password'], 'server-side password'); + check(strpos($call->url, 'FAKE_GRAPHITE') === false, 'credentials absent from URL'); + check($call->options[CURLOPT_FOLLOWLOCATION] === false, 'credentials cannot follow redirects'); + check($call->options[CURLOPT_SSL_VERIFYPEER] === true, 'authenticated TLS verified'); + writeConfig($temp, $auth); + foreach ([200, 401, 403, 302] as $status) { + $GLOBALS['response']['status'] = $status; + $html = vpsmanager_ClientArea($params); + check(strpos($html, 'FAKE_GRAPHITE') === false, 'authentication absent from frontend'); + check(strpos($html, base64_encode($auth['graphite_username'] . ':' . $auth['graphite_password'])) === false, 'no Basic header in HTML'); + if ($status !== 200) { + check(strpos($html, 'Las métricas no están disponibles temporalmente.') !== false, 'auth/redirect failures are discreet'); + } + } + foreach ([['graphite_username' => ''], ['graphite_password' => ''], ['graphite_auth' => 'unknown'], + ['graphite_auth' => 'none'], ['graphite_username' => 'user:name'], ['graphite_password' => "FAKE_BAD\r\nvalue"], + ['graphite_password' => []], ['graphite_url' => 'http://metrics.example.invalid']] as $override) { + $before = count($GLOBALS['calls']); + rejects(fn() => new Graphite(array_replace($auth, $override)), 'invalid auth configuration rejected'); + check(count($GLOBALS['calls']) === $before, 'invalid auth sends no request'); + } + writeConfig($temp, $config); + $GLOBALS['response'] = ['status' => 200, 'body' => json_encode($raw)]; + (new Graphite($config))->getForService($params); + check(!isset(end($GLOBALS['calls'])->options[CURLOPT_HTTPAUTH]), 'legacy configuration remains supported'); echo "PASS: $count assertions; mocked HTTP only.\n"; } finally { unlink($temp);