| @@ -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. | |||
| @@ -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 | |||
| @@ -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. | |||
| ]; | |||
| @@ -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 | |||
| <Location "/"> | |||
| AuthType Basic | |||
| AuthName "Graphite privado" | |||
| AuthBasicProvider file | |||
| AuthUserFile /etc/apache2/graphite.htpasswd | |||
| Require user whmcs-graphite | |||
| </Location> | |||
| ``` | |||
| 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) | |||
| @@ -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); | |||
| @@ -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); | |||