| @@ -0,0 +1,15 @@ | |||
| config.php | |||
| .env | |||
| .env.* | |||
| *.local.php | |||
| *.log | |||
| *.sql | |||
| *.sql.gz | |||
| *.zip | |||
| *.tar | |||
| *.tar.gz | |||
| *.bak | |||
| *.swp | |||
| *.swo | |||
| *~ | |||
| .DS_Store | |||
| @@ -0,0 +1,78 @@ | |||
| # Auditoría de la primera entrega | |||
| Fecha: 2026-09-19. Base: ZIP del módulo facilitado por el titular. Se extrajo en un | |||
| área de trabajo separada y se construyó este árbol sin copiar ningún historial Git. | |||
| ## Revisión de secretos | |||
| No se encontraron credenciales reales literales en el código aportado: las contraseñas | |||
| se leían de los custom fields en ejecución. Sí se encontraron URLs de infraestructura | |||
| hardcodeadas y un flujo que introducía credenciales de panel en HTML/JavaScript. | |||
| - Se eliminaron las URLs de producción del módulo y de `lib/VPSManager.php`. | |||
| - Se extrajeron API, Graphite y vista completa de gráficas a configuración externa. | |||
| - Los dos bloques de autologin (cliente/administración) desaparecieron. | |||
| - Se eliminaron siete bloques `try/catch` vacíos cuyo `logModuleCall` habría registrado | |||
| `$params`, mensajes y trazas completos. No se conserva un logger de credenciales. | |||
| - Se habilitó `CURLOPT_RETURNTRANSFER` en las llamadas heredadas que volcaban respuestas | |||
| al navegador. No se expone el cuerpo de la API a HTML. | |||
| - El ejemplo de configuración está vacío; no existe configuración real en el árbol. | |||
| - `.gitignore` excluye configuración, entornos, backups, logs, dumps y archivos comprimidos. | |||
| `gitleaks` no estaba instalado. Se utilizó `tools/audit.py`, revisión manual de coincidencias | |||
| y comparación del módulo saneado con el original, además de inventario de archivos. | |||
| El análisis cubre: | |||
| 1. `password`, `passwd`, `secret`, `token`, `api_key`, `apikey`, `Authorization:`, cookies y | |||
| marcadores `BEGIN PRIVATE KEY` / `BEGIN RSA PRIVATE KEY`. | |||
| 2. Valores de credencial literales, formatos conocidos de tokens, claves privadas y | |||
| credenciales embebidas en URLs. | |||
| 3. IPs privadas y literales de alta entropía, revisados con contexto. | |||
| 4. `.env`, configuración privada, claves SSH, certificados privados, SQL, logs, backups, | |||
| swap, ZIP/tar y binarios no revisados. | |||
| 5. Dependencias locales contrastadas por SHA-256; la descarga original Chart.js 4.4.8 se | |||
| verificó contra el SHA-512 de integridad publicado en el registro NPM y se conserva | |||
| su licencia MIT. El wrapper de aislamiento se genera mediante un script legible. | |||
| 6. Mismo análisis antes de `git init`, sobre el índice y sobre los blobs de `HEAD`. | |||
| Las coincidencias legítimas son nombres de opciones/variables, documentación de seguridad, | |||
| el token CSRF de WHMCS, patrones del propio escáner y sentinelas ficticios `FAKE_` del test. | |||
| Las URLs `user:pass@…example.invalid` son fixtures negativos que deben rechazarse. | |||
| No se detectaron secretos reales en el árbol auditado. Esto describe los resultados de | |||
| las comprobaciones realizadas, no una garantía matemática del escáner. | |||
| ## Pruebas realizadas | |||
| - PHP 8.1.2 (paquete Ubuntu con actualizaciones): `php -n -l` en todos los PHP, sin errores. | |||
| - `php -n tests/run.php`: 158 aserciones PASS, HTTP simulado, sin acceso a producción. | |||
| - `node tests/frontend.cjs`: 24 aserciones PASS. | |||
| - `node --check assets/metrics.js`: PASS. | |||
| - Revisión visual en navegador local: las tres gráficas, unidades y cortes en los huecos. | |||
| - Configuración: fichero válido, inexistente, sintaxis errónea, tipo erróneo, clave ausente | |||
| y URL inválida; rechazos de esquemas inseguros y credenciales en URL. | |||
| - Graphite: HTTP 500, timeout, JSON inválido, respuesta demasiado grande, series ausentes, | |||
| respuesta parcial, puntos inválidos, timestamps duplicados/desalineados y `null`. | |||
| - Validación: UUID válido y malicioso, tipos no LX y whitelist de cinco rangos. | |||
| - Memoria del fixture: 8589934592 bytes = 8 GiB, usada 4365361152 bytes, | |||
| porcentaje 50.81949234008789 %. No se presupone una capacidad fija. | |||
| - Red: tasas mantenidas en bytes/s en PHP y multiplicadas por 8 solo al presentar. | |||
| - Autorización en el límite del módulo: GET/POST hostiles no cambian el UUID/tipo del | |||
| servicio resuelto por WHMCS; los enlaces usan su `serviceid`, no el `id` solicitado. | |||
| - Regresión con dobles: reinicio/parada/arranque, firewall, lista/restauración de snapshots, | |||
| suspensión/reactivación, cancelación y selectores conservan rutas/payloads esperados. | |||
| ## Límites y decisiones de despliegue | |||
| No se ha accedido a una instalación WHMCS ni a un servicio VPS de producción. La | |||
| regresión anterior prueba contratos mediante mocks, no efectos reales de provisioning. | |||
| Quedan por verificar en staging la versión/plantilla instalada, autorización efectiva | |||
| entre dos clientes, campos solo de administrador, CSRF del dispatcher y política CSP. | |||
| La vista completa Graphs conserva el botón, pero requiere configuración y confirmación | |||
| de que su servicio externo autentica/autoriza; no se ha validado su seguridad real. | |||
| El autologin ISPConfig se sustituye por login manual. SSO seguro y licencia global siguen | |||
| pendientes. Los avisos de copyright y licencia de componentes ajenos se conservan. | |||
| 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. | |||
| @@ -0,0 +1,182 @@ | |||
| # WHMCS VPSManager | |||
| Módulo VPSManager para WHMCS con controles de reinicio, parada, arranque, firewall, | |||
| snapshots, suspensión/reactivación, enlace al panel y métricas Graphite de servicios LX. | |||
| Esta versión parte del módulo facilitado y conserva su comportamiento de provisioning. | |||
| `CreateAccount` ya devolvía `success` sin provisionar; `TerminateAccount`, `ChangePackage` | |||
| y `ChangePassword` estaban vacíos y siguen así. No se debe asumir que estas funciones | |||
| estén implementadas. El tratamiento de errores de los comandos de provisioning sigue | |||
| siendo el heredado: un `success` no acredita por sí solo que el backend ejecutó la acción. | |||
| No se ha reescrito suspensión/reactivación ni su contrato HTTP. | |||
| ## Requisitos e instalación | |||
| - PHP 8.1 o posterior con cURL, JSON y certificados CA actualizados. Probado con PHP 8.1. | |||
| - WHMCS con módulos de provisioning y los custom fields actuales del producto. | |||
| - Acceso HTTPS desde PHP a Graphite. Las consultas no las realiza el navegador. | |||
| - La versión y la plantilla reales de WHMCS no se han facilitado. Validar el módulo en | |||
| staging antes de sustituirlo en producción; las pruebas locales no equivalen a esa validación. | |||
| Instalar `vpsmanager.php`, `lib/`, `WhmcsHelpers/`, `templates/` y `assets/` dentro de | |||
| `<WHMCS>/modules/servers/vpsmanager/`. Mantener el nombre del directorio `vpsmanager`. | |||
| No instalar `.git/`, `tests/` ni `tools/` en el servidor web. Los ficheros vacíos `logo.png` | |||
| y `hooks.php` del original no se distribuyen. | |||
| No se cambia el orden de las opciones existentes del módulo ni los nombres de sus custom | |||
| fields. Es obligatorio que `uuid`, `vtype`, `gzid`, `url`, `username` y `password` sean | |||
| campos administrados por el servidor/administrador, no editables ni mostrados al cliente. | |||
| Revisar la configuración de custom fields y la plantilla WHMCS: el módulo ya no imprime | |||
| contraseñas, pero no puede corregir una plantilla externa que imprima esos campos. | |||
| ## Configuración externa | |||
| Copiar `config.example.php` fuera del árbol web y del repositorio, normalmente a: | |||
| ```text | |||
| /etc/open6hosting/whmcs-vpsmanager.php | |||
| ``` | |||
| La variable de entorno `O6H_VPSMANAGER_CONFIG` permite usar otra ruta absoluta. | |||
| Si se utiliza PHP-FPM, comprobar que el pool realmente transmite esa variable. | |||
| - `vpsmanager_api_url`: base de la API VPSManager, sin barra final. Se admite HTTP para | |||
| conservar la compatibilidad con la API original; preferir HTTPS o transporte privado | |||
| protegido. No deshabilitar la verificación TLS. | |||
| - `graphite_url`: base HTTPS de Graphite, sin `/render/`, credenciales embebidas, | |||
| query string ni fragmento. La implementación añade `/render/`. | |||
| - `graphs_url`: base HTTPS de la vista completa antigua, opcional. | |||
| - `graphs_authorization_confirmed`: poner a `true` únicamente tras comprobar que la | |||
| vista completa autentica al cliente y autoriza el VPS solicitado. Con URL ausente o sin | |||
| esa comprobación se conserva el botón **Graphs**, desactivado. El UUID en la URL no es | |||
| un control de acceso. Las nuevas tres gráficas no dependen de esa vista externa. | |||
| Configurar solamente los valores privados reales en el fichero externo, nunca en el | |||
| archivo de ejemplo. No se han incluido valores de producción en este repositorio. | |||
| Permisos recomendados: directorio `0750`, fichero `0640`, propietario administrador y | |||
| un grupo que permita lectura al usuario real de PHP-FPM/servidor web. Adaptar usuario y | |||
| grupo a la instalación. PHP no debe poder escribir esa configuración. Nunca `chmod 777`. | |||
| La configuración PHP es código de confianza controlado por el administrador. | |||
| La falta de configuración no debe generar un fatal del módulo: las métricas muestran un | |||
| aviso discreto, los comandos que requieren la API devuelven un error genérico y los | |||
| selectores de nodos/paquetes quedan vacíos. No guardar una configuración de producto | |||
| hasta restablecer estos selectores. | |||
| ## Métricas LX | |||
| Solo `vtype = lx`. El UUID debe tener formato hexadecimal `8-4-4-4-12`, por ejemplo el | |||
| UUID ficticio `00000000-0000-4000-8000-000000000001`. Los targets internos son: | |||
| | Gráfica / serie | Target bajo `lx.<uuid>.` | Transformación | | |||
| | --- | --- | --- | | |||
| | Carga / 1 min | `load.load.shortterm` | Ninguna | | |||
| | Carga / 5 min | `load.load.midterm` | Ninguna | | |||
| | Carga / 15 min | `load.load.longterm` | Ninguna | | |||
| | Memoria usada | `memory.memory-used` | Bytes, presentación IEC | | |||
| | Memoria libre | `memory.memory-free` | Bytes, usada para calcular el total | | |||
| | Red RX | `interface-eth0.if_octets.rx` | Bytes/s → bits/s, una vez al presentar | | |||
| | Red TX | `interface-eth0.if_octets.tx` | Bytes/s → bits/s, una vez al presentar | | |||
| La carga **no es porcentaje de CPU**. Memoria total = usada + libre en el mismo timestamp; | |||
| porcentaje = usada / total × 100 si ambos datos existen y el total es positivo. No hay una | |||
| capacidad fija. Una serie de memoria ausente no se interpreta como cero. La red ya contiene | |||
| tasas: no se aplica `derivative` ni `nonNegativeDerivative`, ni se calcula volumen mensual. | |||
| Se agrupan los siete targets en una petición, con `format=json`, `until=now` y | |||
| `maxDataPoints=1000`. Se conserva la resolución devuelta por Graphite, los timestamps y | |||
| los huecos `null`; no se unen las líneas a través de esos huecos. El resumen corresponde | |||
| al timestamp más reciente de la gráfica y muestra su fecha/hora local del navegador; | |||
| no sustituye un dato ausente por una muestra antigua. No existe actualización automática. | |||
| Rangos: `1h`, `6h`, **`24h` por defecto**, `7d`, `30d`. Cualquier otra entrada cae a `24h`. | |||
| Los enlaces recargan `clientarea.php?action=productdetails&id=<servicio>&metrics_range=<rango>`. | |||
| No hay endpoint AJAX ni endpoint de métricas accesible directamente. WHMCS autoriza el | |||
| servicio antes de invocar `vpsmanager_ClientArea`; el módulo solo toma UUID/tipo de `$params` | |||
| y nunca de GET/POST. Cambiar `id` vuelve a pasar por la autorización de WHMCS. | |||
| PHP mantiene verificación TLS y no sigue redirecciones. Connect timeout 3 s, total 5 s, | |||
| respuesta limitada a 2 MiB. Un fallo HTTP, timeout, JSON inválido o dato mal formado muestra | |||
| **Las métricas no están disponibles temporalmente.** sin bloquear los demás controles. | |||
| 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. | |||
| ## Interfaz y acceso al panel | |||
| Las vistas y la lógica Graphite están separadas de las operaciones existentes. | |||
| Las tres cards usan clases compatibles con Bootstrap y estilos limitados al módulo. | |||
| Se reutiliza `window.Chart` si es Chart.js 4.x; en otro caso se carga Chart.js **4.4.8** | |||
| localmente y se restaura cualquier `window.Chart` previo. No se consultan CDNs en visitas. | |||
| No se ha podido inspeccionar la plantilla instalada; comprobar compatibilidad en staging. | |||
| El eje temporal numérico permite timestamps irregulares sin adaptador de fechas externo. | |||
| **Antes:** el módulo insertaba usuario/contraseña ISPConfig en JavaScript y los enviaba | |||
| por AJAX desde el navegador, tanto en cliente como en administración. | |||
| **Después:** **Acceder al Panel** abre únicamente el login HTTPS en otra pestaña, con | |||
| `noopener noreferrer`. No se incorpora usuario ni contraseña. No hay autologin: el código | |||
| recibido no incluía un mecanismo SSO seguro. No se ha inventado ninguno. | |||
| Las URLs inseguras, con credenciales, query string o fragmentos no generan enlaces activos. | |||
| Las plantillas de acciones conservan las operaciones y añaden escape HTML y el token | |||
| CSRF de WHMCS. El módulo depende del dispatcher de WHMCS para autenticación, autorización | |||
| y validación del token. No exponer las acciones mediante un endpoint personalizado. | |||
| ## Desarrollo y pruebas | |||
| Desde la raíz del repositorio: | |||
| ```bash | |||
| find . -type f -name '*.php' -not -path './.git/*' -print0 | xargs -0 -n1 php -n -l | |||
| php -n tests/run.php | |||
| node tests/frontend.cjs | |||
| node --check assets/metrics.js | |||
| python3 tools/audit.py | |||
| ``` | |||
| `php -n` es deliberado: el test sustituye cURL por dobles y no realiza ninguna conexión. | |||
| Cubre configuración, UUID/tipo/rangos, fallos HTTP/JSON/timeout, respuesta grande, series | |||
| parciales, huecos, alineación temporal, cálculos, ausencia de secretos en HTML, manipulación | |||
| GET/POST y rutas/payloads de las operaciones existentes. Los fixtures usan dominios `.invalid`, | |||
| IPs de documentación y contraseñas ficticias identificadas con `FAKE_`. | |||
| Los tests JS verifican unidades, huecos, carga local y conservación de otra versión de Chart.js. | |||
| `tools/scope-chart.cjs` genera el bundle aislado a partir del fichero upstream, cuya licencia | |||
| MIT y procedencia constan en `assets/Chart.js.LICENSE.md` y `assets/THIRD-PARTY.md`. | |||
| No confundir esa licencia con una licencia para nuestro proyecto. | |||
| La prueba de aislamiento de UUID invoca el hook con los parámetros de un servicio y peticiones | |||
| GET/POST que intentan elegir otro. La prueba end-to-end del dispatcher requiere WHMCS real: | |||
| con dos clientes y servicios distintos, verificar que el cliente A no puede acceder al | |||
| `id` del B, con cualquiera de los cinco rangos, ni alterar los campos UUID/tipo. Verificar | |||
| además token CSRF de las acciones, biblioteca/tema instalado, CSP y los controles en staging. | |||
| No se han ejecutado reinicios, restauraciones o suspensiones sobre infraestructura real. | |||
| ## Historial, publicación y licencia | |||
| El repositorio público debe empezar con un único commit limpio, sin importar historial | |||
| anterior. No copiar el ZIP original, backups, configuración real ni el directorio de trabajo. | |||
| `tools/audit.py` revisa árbol, índice o archivos versionados; revisar también sus límites en | |||
| `AUDIT.md`. Ningún escáner prueba la ausencia absoluta de todos los posibles secretos. | |||
| Destino previsto: `https://gitea.open6hosting.com/Open6Hosting/whmcs-vpsmanager`. | |||
| No hacer force-push sobre un repositorio existente. | |||
| **Licencia del proyecto pendiente de decisión del titular.** No se concede una licencia | |||
| open source por defecto. Se conserva el aviso de copyright original de `WhmcsHelpers/CustomField.php`; | |||
| confirmar también los derechos de redistribución de ese helper antes de declarar una licencia | |||
| global. La licencia de Chart.js solo cubre ese componente de terceros. | |||
| ## Referencias de integración | |||
| - [WHMCS: Client Area Output](https://developers.whmcs.com/provisioning-modules/client-area-output) | |||
| - [WHMCS: Module Parameters](https://developers.whmcs.com/provisioning-modules/module-parameters) | |||
| - [WHMCS: Module Logging](https://developers.whmcs.com/provisioning-modules/module-logging) | |||
| - [Graphite Render API](https://graphite.readthedocs.io/en/latest/render_api.html) | |||
| - [Chart.js: ejes numéricos](https://www.chartjs.org/docs/latest/axes/cartesian/linear.html) | |||
| @@ -0,0 +1,41 @@ | |||
| <?php | |||
| namespace VPSManager\WhmcsHelpers; | |||
| use WHMCS\Database\Capsule; | |||
| /** | |||
| * Helper for domain data. | |||
| * WhmcsHelper | |||
| * | |||
| * @copyright Copyright (c) WeDevelop.coffee 2018 | |||
| */ | |||
| class CustomField | |||
| { | |||
| public static function getByName($name) | |||
| { | |||
| $customfield = Capsule::table('tblcustomfields') | |||
| ->where('fieldname', 'like', $name .'|%') | |||
| ->first(); | |||
| return $customfield; | |||
| } | |||
| /** | |||
| * Get the customfield value from the custom fields array. | |||
| * | |||
| * @param $name | |||
| * @param $fields | |||
| * @return string | |||
| */ | |||
| public static function getValueFromCustomFields($name, $fields) | |||
| { | |||
| $customfield_id = self::getByName($name)->id; | |||
| foreach($fields as $field) | |||
| { | |||
| if($field['id'] == $customfield_id) | |||
| return $field['value']; | |||
| } | |||
| } | |||
| } // END class Domain | |||
| @@ -0,0 +1,9 @@ | |||
| The MIT License (MIT) | |||
| Copyright (c) 2014-2024 Chart.js Contributors | |||
| Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: | |||
| The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. | |||
| THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. | |||
| @@ -0,0 +1,6 @@ | |||
| Chart.js 4.4.8, MIT. Upstream: https://github.com/chartjs/Chart.js | |||
| NPM: https://registry.npmjs.org/chart.js/4.4.8 | |||
| Tarball verified against published SHA-512 integrity. | |||
| Integrity: sha512-IkGZlVpXP+83QpMm4uxEiGqSI7jFizwVtF3+n5Pc3k7sMO+tkd0qxh2OzLhenM0K80xtmAONWGBn082EiBQSDA== | |||
| No license is selected for this module. | |||
| @@ -0,0 +1,8 @@ | |||
| .vpsmanager-links { margin: 0 0 1rem; } | |||
| .vpsmanager-metrics { margin-top: 1rem; } | |||
| .vpsmanager-ranges { display: flex; gap: .4rem; flex-wrap: wrap; } | |||
| .vpsmanager-card { margin-bottom: 1rem; border: 1px solid #d7dce2; border-radius: 5px; } | |||
| .vpsmanager-card .card-header { padding: 12px 16px; background: #f6f8fa; } | |||
| .vpsmanager-card h3 { margin: 0; font-size: 16px; } | |||
| .vpsmanager-card .card-body { padding: 16px; } | |||
| .vpsmanager-canvas { position: relative; height: 260px; width: 100%; } | |||
| @@ -0,0 +1,92 @@ | |||
| (function () { | |||
| 'use strict'; | |||
| const asset = 'modules/servers/vpsmanager/assets/chart-4.4.8.scoped.js'; | |||
| const unavailable = 'Las métricas no están disponibles temporalmente.'; | |||
| const colors = ['#176aa3', '#bd5623', '#338345']; | |||
| function scaled(value, base, units) { | |||
| if (value === null || !Number.isFinite(value)) return 'Sin datos'; | |||
| let i = 0; | |||
| while (Math.abs(value) >= base && i < units.length - 1) { value /= base; i++; } | |||
| return value.toLocaleString('es-ES', { maximumFractionDigits: 2 }) + ' ' + units[i]; | |||
| } | |||
| function format(kind, value) { | |||
| if (kind === 'memory') return scaled(value, 1024, ['B', 'KiB', 'MiB', 'GiB', 'TiB']); | |||
| if (kind === 'network') return scaled(value, 1000, ['bit/s', 'Kbit/s', 'Mbit/s', 'Gbit/s']); | |||
| return value === null ? 'Sin datos' : value.toLocaleString('es-ES', { maximumFractionDigits: 2 }); | |||
| } | |||
| function datasets(kind, series) { | |||
| return Object.entries(series).map(([label, points], i) => ({ | |||
| label, | |||
| // The server keeps rates in bytes/s. Conversion happens exactly here. | |||
| data: points.map(p => ({ x: p.x, y: p.y === null ? null : (kind === 'network' ? p.y * 8 : p.y) })), | |||
| borderColor: colors[i % colors.length], backgroundColor: colors[i % colors.length], | |||
| borderWidth: 2, pointRadius: 0, pointHitRadius: 8, spanGaps: false, tension: 0 | |||
| })); | |||
| } | |||
| function summary(kind, data, metrics) { | |||
| const times = data.flatMap(d => d.data.map(p => p.x)); | |||
| if (!times.length) return unavailable; | |||
| const lastTime = times.reduce((a, b) => Math.max(a, b), 0); | |||
| const values = data.map(d => { | |||
| const point = d.data.find(p => p.x === lastTime); | |||
| return d.label + ': ' + format(kind, point ? point.y : null); | |||
| }); | |||
| if (kind === 'memory') { | |||
| const percentage = metrics.memoryPercent.find(p => p.x === lastTime); | |||
| values.push('Uso: ' + (percentage && percentage.y !== null ? percentage.y.toFixed(1) + ' %' : 'Sin datos')); | |||
| } | |||
| return values.join(' · ') + ' · ' + new Date(lastTime).toLocaleString('es-ES'); | |||
| } | |||
| function start(ChartClass) { | |||
| document.querySelectorAll('.vpsmanager-metrics[data-metrics]').forEach(root => { | |||
| if (root.dataset.initialized || !root.dataset.metrics) return; | |||
| root.dataset.initialized = 'true'; | |||
| let metrics; | |||
| try { metrics = JSON.parse(root.dataset.metrics); } catch (_) { return; } | |||
| root.querySelectorAll('[data-chart]').forEach(card => { | |||
| const kind = card.dataset.chart; | |||
| const data = datasets(kind, metrics[kind]); | |||
| const text = card.querySelector('[data-summary]'); | |||
| const canvas = card.querySelector('canvas'); | |||
| if (!ChartClass || !data.some(d => d.data.some(p => p.y !== null))) { | |||
| text.textContent = unavailable; | |||
| canvas.parentElement.hidden = true; | |||
| return; | |||
| } | |||
| text.textContent = summary(kind, data, metrics); | |||
| try { | |||
| new ChartClass(canvas, { | |||
| type: 'line', data: { datasets: data }, | |||
| options: { | |||
| responsive: true, maintainAspectRatio: false, animation: false, parsing: false, | |||
| interaction: { mode: 'nearest', intersect: false }, | |||
| scales: { | |||
| x: { type: 'linear', ticks: { maxTicksLimit: 6, callback: v => new Date(v).toLocaleString('es-ES', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) } }, | |||
| y: { beginAtZero: true, ticks: { callback: v => format(kind, v) } } | |||
| }, | |||
| plugins: { tooltip: { callbacks: { | |||
| title: items => items.length ? new Date(items[0].parsed.x).toLocaleString('es-ES') : '', | |||
| label: ctx => ctx.dataset.label + ': ' + format(kind, ctx.parsed.y) | |||
| } } } | |||
| } | |||
| }); | |||
| } catch (_) { | |||
| text.textContent = unavailable; | |||
| canvas.parentElement.hidden = true; | |||
| } | |||
| }); | |||
| }); | |||
| } | |||
| function boot() { | |||
| // Reuse an installed compatible Chart.js; otherwise use the local isolated build. | |||
| if (window.Chart && /^4\./.test(window.Chart.version || '')) { start(window.Chart); return; } | |||
| if (window.O6HVpsChart) { start(window.O6HVpsChart); return; } | |||
| const script = document.createElement('script'); | |||
| script.src = asset; | |||
| script.onload = () => start(window.O6HVpsChart); | |||
| script.onerror = () => start(null); | |||
| document.head.appendChild(script); | |||
| } | |||
| if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot, { once: true }); | |||
| else boot(); | |||
| }()); | |||
| @@ -0,0 +1,2 @@ | |||
| e4cf4d144b222634f2e64ff707cd57b953f8de4b65b231cd5ac6bde114648e4d chart-4.4.8.umd.js | |||
| b0872c3ee90b60c32f34616a8c1aa092772d66fe444cc3d1b768fd8ebdfc6a7a chart-4.4.8.scoped.js | |||
| @@ -0,0 +1,7 @@ | |||
| <?php | |||
| return [ | |||
| 'vpsmanager_api_url' => '', | |||
| 'graphite_url' => '', | |||
| 'graphs_authorization_confirmed' => false, | |||
| 'graphs_url' => '', // Optional existing full graphs UI base URL; review its authorization. | |||
| ]; | |||
| @@ -0,0 +1,66 @@ | |||
| <?php | |||
| namespace VPSManager; | |||
| require_once __DIR__ . '/Graphite.php'; | |||
| final class ClientView | |||
| { | |||
| public static function escape($value): string | |||
| { | |||
| return htmlspecialchars((string) $value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); | |||
| } | |||
| public static function links(array $params, ?array $config = null): string | |||
| { | |||
| if ($config === null) { | |||
| try { $config = Config::load(); } catch (\Throwable $e) { $config = []; } | |||
| } | |||
| $fields = $params['customfields'] ?? []; | |||
| $panel = ''; | |||
| $graphs = ''; | |||
| $raw = $fields['url'] ?? ''; | |||
| if (is_string($raw) && $raw !== '') { | |||
| // Preserve legacy host-only form, without accepting arbitrary schemes or credentials. | |||
| if (preg_match('/\A[a-zA-Z0-9.-]+\z/', $raw)) { | |||
| $raw = 'https://' . $raw . ':8080/login/index.php'; | |||
| } | |||
| try { $panel = Config::url(['panel' => $raw], 'panel'); } catch (\Throwable $e) { /* Hide unsafe URL. */ } | |||
| } | |||
| if (($config['graphs_authorization_confirmed'] ?? false) === true | |||
| && Graphite::validUuid($fields['uuid'] ?? null) | |||
| && is_string($fields['vtype'] ?? null) | |||
| && preg_match('/\A[a-z0-9_-]{1,24}\z/', $fields['vtype'])) { | |||
| try { | |||
| $graphs = Config::url($config, 'graphs_url') . '/collect/servidor/' | |||
| . rawurlencode($fields['vtype']) . '/' . rawurlencode($fields['uuid']) . '/-1h/now'; | |||
| } catch (\Throwable $e) { /* Configuration is optional. */ } | |||
| } | |||
| ob_start(); | |||
| require __DIR__ . '/../templates/links.php'; | |||
| return ob_get_clean(); | |||
| } | |||
| public static function render(array $params, $requestedRange = '24h', ?Graphite $graphite = null): string | |||
| { | |||
| $range = Graphite::range($requestedRange); | |||
| $config = []; | |||
| try { $config = Config::load(); } catch (\Throwable $e) { /* Metrics fail independently. */ } | |||
| $links = self::links($params, $config); | |||
| if (($params['customfields']['vtype'] ?? null) !== 'lx') { | |||
| return $links; | |||
| } | |||
| $metrics = null; | |||
| try { | |||
| $graphite = $graphite ?? new Graphite($config); | |||
| $metrics = $graphite->getForService($params, $range); | |||
| } catch (\Throwable $e) { | |||
| // Do not log exceptions, configuration, raw responses or WHMCS parameters. | |||
| } | |||
| $serviceId = filter_var($params['serviceid'] ?? null, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]); | |||
| $ranges = Graphite::RANGES; | |||
| $cards = ['load' => 'Carga del sistema', 'memory' => 'Uso de memoria', 'network' => 'Tráfico de red']; | |||
| $json = $metrics === null ? '' : json_encode($metrics, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_THROW_ON_ERROR); | |||
| ob_start(); | |||
| require __DIR__ . '/../templates/metrics.php'; | |||
| return ob_get_clean(); | |||
| } | |||
| } | |||
| @@ -0,0 +1,43 @@ | |||
| <?php | |||
| namespace VPSManager; | |||
| final class Config | |||
| { | |||
| public static function load(): array | |||
| { | |||
| $path = getenv('O6H_VPSMANAGER_CONFIG') ?: '/etc/open6hosting/whmcs-vpsmanager.php'; | |||
| if (!is_file($path) || !is_readable($path)) { | |||
| throw new \RuntimeException('Configuración del módulo no disponible.'); | |||
| } | |||
| // Private PHP configuration is trusted administrator-controlled code. | |||
| ob_start(); | |||
| try { | |||
| $config = (static function ($file) { return include $file; })($path); | |||
| } catch (\Throwable $e) { | |||
| throw new \RuntimeException('Configuración del módulo no válida.'); | |||
| } finally { | |||
| ob_end_clean(); | |||
| } | |||
| if (!is_array($config)) { | |||
| throw new \RuntimeException('Configuración del módulo no válida.'); | |||
| } | |||
| return $config; | |||
| } | |||
| public static function url(array $config, string $key, bool $httpsOnly = true): string | |||
| { | |||
| $url = $config[$key] ?? null; | |||
| if (!is_string($url) || !filter_var($url, FILTER_VALIDATE_URL)) { | |||
| throw new \RuntimeException('URL de configuración no válida.'); | |||
| } | |||
| $parts = parse_url($url); | |||
| $allowed = $httpsOnly ? ['https'] : ['https', 'http']; | |||
| if (!$parts || !in_array($parts['scheme'] ?? '', $allowed, true) | |||
| || isset($parts['user']) || isset($parts['pass']) | |||
| || isset($parts['query']) || isset($parts['fragment']) | |||
| || preg_match('/[\x00-\x20\x7f]/', $url)) { | |||
| throw new \RuntimeException('URL de configuración no válida.'); | |||
| } | |||
| return rtrim($url, '/'); | |||
| } | |||
| } | |||
| @@ -0,0 +1,155 @@ | |||
| <?php | |||
| namespace VPSManager; | |||
| require_once __DIR__ . '/Config.php'; | |||
| final class Graphite | |||
| { | |||
| public const RANGES = ['1h' => '-1h', '6h' => '-6h', '24h' => '-24h', '7d' => '-7d', '30d' => '-30d']; | |||
| private const SUFFIXES = [ | |||
| 'load1' => 'load.load.shortterm', | |||
| 'load5' => 'load.load.midterm', | |||
| 'load15' => 'load.load.longterm', | |||
| 'used' => 'memory.memory-used', | |||
| 'free' => 'memory.memory-free', | |||
| 'rx' => 'interface-eth0.if_octets.rx', | |||
| 'tx' => 'interface-eth0.if_octets.tx', | |||
| ]; | |||
| private $baseUrl; | |||
| private $transport; | |||
| public function __construct(array $config, ?callable $transport = null) | |||
| { | |||
| $this->baseUrl = Config::url($config, 'graphite_url'); | |||
| $this->transport = $transport ?? [self::class, 'request']; | |||
| } | |||
| public static function range($value): string | |||
| { | |||
| return is_string($value) && isset(self::RANGES[$value]) ? $value : '24h'; | |||
| } | |||
| public static function validUuid($uuid): bool | |||
| { | |||
| return is_string($uuid) && preg_match('/\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i', $uuid) === 1; | |||
| } | |||
| // Only WHMCS-authorized module parameters may be passed here; never request UUID/type. | |||
| public function getForService(array $params, $range = '24h'): array | |||
| { | |||
| $fields = $params['customfields'] ?? []; | |||
| if (($fields['vtype'] ?? null) !== 'lx' || !self::validUuid($fields['uuid'] ?? null)) { | |||
| throw new \InvalidArgumentException('Servicio no compatible con las métricas LX.'); | |||
| } | |||
| $range = self::range($range); | |||
| $targets = []; | |||
| foreach (self::SUFFIXES as $key => $suffix) { | |||
| $targets[$key] = 'lx.' . $fields['uuid'] . '.' . $suffix; | |||
| } | |||
| $query = 'format=json&from=' . rawurlencode(self::RANGES[$range]) . '&until=now&maxDataPoints=1000'; | |||
| foreach ($targets as $target) { | |||
| $query .= '&target=' . rawurlencode($target); | |||
| } | |||
| $response = call_user_func($this->transport, $this->baseUrl . '/render/?' . $query); | |||
| if (!is_array($response) || ($response['status'] ?? 0) !== 200 || !is_string($response['body'] ?? null)) { | |||
| throw new \RuntimeException('Graphite HTTP error.'); | |||
| } | |||
| $raw = json_decode($response['body'], true, 32); | |||
| if (substr(ltrim($response['body']), 0, 1) !== '[' || json_last_error() !== JSON_ERROR_NONE || !is_array($raw) || !array_is_list($raw)) { | |||
| throw new \RuntimeException('Graphite JSON error.'); | |||
| } | |||
| $series = array_fill_keys(array_keys($targets), []); | |||
| $seen = []; | |||
| foreach ($raw as $row) { | |||
| if (!is_array($row) || !is_string($row['target'] ?? null)) { | |||
| throw new \RuntimeException('Graphite series error.'); | |||
| } | |||
| $key = array_search($row['target'], $targets, true); | |||
| if ($key === false) { | |||
| continue; // Never expose unrequested targets or metadata. | |||
| } | |||
| if (isset($seen[$key]) || !is_array($row['datapoints'] ?? null) || count($row['datapoints']) > 10000) { | |||
| throw new \RuntimeException('Graphite series error.'); | |||
| } | |||
| $seen[$key] = true; | |||
| $points = []; | |||
| foreach ($row['datapoints'] as $point) { | |||
| if (!is_array($point) || count($point) !== 2 || !array_is_list($point) | |||
| || !is_int($point[1]) || $point[1] < 0 || $point[1] > 253402300799) { | |||
| throw new \RuntimeException('Graphite datapoint error.'); | |||
| } | |||
| $value = $point[0]; | |||
| if ($value !== null && ((!is_int($value) && !is_float($value)) || !is_finite((float) $value) || $value < 0)) { | |||
| throw new \RuntimeException('Graphite value error.'); | |||
| } | |||
| if (array_key_exists($point[1], $points)) { | |||
| throw new \RuntimeException('Graphite duplicate timestamp.'); | |||
| } | |||
| $points[$point[1]] = $value; | |||
| } | |||
| ksort($points, SORT_NUMERIC); | |||
| foreach ($points as $timestamp => $value) { | |||
| $series[$key][] = ['x' => $timestamp * 1000, 'y' => $value]; | |||
| } | |||
| } | |||
| // Align memory by timestamps, never by index; absent data stays null. | |||
| $used = array_column($series['used'], 'y', 'x'); | |||
| $free = array_column($series['free'], 'y', 'x'); | |||
| $times = array_unique(array_merge(array_keys($used), array_keys($free))); | |||
| sort($times, SORT_NUMERIC); | |||
| $total = []; | |||
| $percent = []; | |||
| foreach ($times as $time) { | |||
| $u = $used[$time] ?? null; | |||
| $f = $free[$time] ?? null; | |||
| $sum = $u !== null && $f !== null ? $u + $f : null; | |||
| if ($sum !== null && !is_finite((float) $sum)) { | |||
| throw new \RuntimeException('Graphite memory overflow.'); | |||
| } | |||
| $total[] = ['x' => $time, 'y' => $sum]; | |||
| $percent[] = ['x' => $time, 'y' => $sum !== null && $sum > 0 ? ($u / $sum) * 100 : null]; | |||
| } | |||
| return [ | |||
| 'range' => $range, | |||
| 'load' => ['1 min' => $series['load1'], '5 min' => $series['load5'], '15 min' => $series['load15']], | |||
| 'memory' => ['Usada' => $series['used'], 'Total' => $total], | |||
| 'memoryPercent' => $percent, | |||
| 'network' => ['RX' => $series['rx'], 'TX' => $series['tx']], // bytes/s; presentation converts once. | |||
| ]; | |||
| } | |||
| private static function request(string $url): array | |||
| { | |||
| if (!function_exists('curl_init')) { | |||
| throw new \RuntimeException('Graphite transport unavailable.'); | |||
| } | |||
| $ch = curl_init($url); | |||
| $body = ''; | |||
| curl_setopt_array($ch, [ | |||
| CURLOPT_CONNECTTIMEOUT => 3, | |||
| CURLOPT_TIMEOUT => 5, | |||
| CURLOPT_FOLLOWLOCATION => false, | |||
| CURLOPT_PROTOCOLS => CURLPROTO_HTTPS, | |||
| CURLOPT_SSL_VERIFYPEER => true, | |||
| CURLOPT_SSL_VERIFYHOST => 2, | |||
| CURLOPT_HTTPHEADER => ['Accept: application/json'], | |||
| CURLOPT_WRITEFUNCTION => static function ($handle, $chunk) use (&$body) { | |||
| if (strlen($body) + strlen($chunk) > 2097152) { | |||
| return 0; | |||
| } | |||
| $body .= $chunk; | |||
| return strlen($chunk); | |||
| }, | |||
| ]); | |||
| try { | |||
| $ok = curl_exec($ch); | |||
| $status = curl_getinfo($ch, CURLINFO_HTTP_CODE); | |||
| if ($ok === false) { | |||
| throw new \RuntimeException('Graphite transport error.'); | |||
| } | |||
| return ['status' => $status, 'body' => $body]; | |||
| } finally { | |||
| curl_close($ch); | |||
| } | |||
| } | |||
| } | |||
| @@ -0,0 +1,45 @@ | |||
| <?php | |||
| namespace VPSManager; | |||
| require_once __DIR__ . '/Config.php'; | |||
| class VPSManager | |||
| { | |||
| public function getParam($name) | |||
| { | |||
| return isset($this->params[$name]) ? $this->params[$name] : ''; | |||
| } | |||
| public function apiCall($route) | |||
| { | |||
| if (!in_array($route, ['nodes', 'packs'], true)) { | |||
| throw new \InvalidArgumentException('Ruta de API no permitida.'); | |||
| } | |||
| $base = Config::url(Config::load(), 'vpsmanager_api_url', false); | |||
| if (!function_exists('curl_init')) { | |||
| throw new \RuntimeException('Transporte del módulo no disponible.'); | |||
| } | |||
| $ch = curl_init($base . '/' . $route); | |||
| curl_setopt_array($ch, [ | |||
| CURLOPT_RETURNTRANSFER => true, | |||
| CURLOPT_CONNECTTIMEOUT => 3, | |||
| CURLOPT_TIMEOUT => 10, | |||
| CURLOPT_SSL_VERIFYPEER => true, | |||
| CURLOPT_SSL_VERIFYHOST => 2, | |||
| CURLOPT_FOLLOWLOCATION => false, | |||
| ]); | |||
| try { | |||
| $body = curl_exec($ch); | |||
| $status = curl_getinfo($ch, CURLINFO_HTTP_CODE); | |||
| } finally { | |||
| curl_close($ch); | |||
| } | |||
| if ($body === false || $status < 200 || $status >= 300) { | |||
| throw new \RuntimeException('API del módulo no disponible.'); | |||
| } | |||
| $data = json_decode($body, true); | |||
| if (!is_array($data)) { | |||
| throw new \RuntimeException('Respuesta de API no válida.'); | |||
| } | |||
| return $data; | |||
| } | |||
| } | |||
| @@ -0,0 +1,10 @@ | |||
| <p><b>Atencion!</b></p> | |||
| <p>Por favor confirme, desea {$description|escape:'html'}?</b> | |||
| <form method="post" action="clientarea.php?action=productdetails"> | |||
| <input type="hidden" name="token" value="{$token|escape:'html'}" /> | |||
| <input type="hidden" name="id" value="{$serviceid|escape:'html'}" /> | |||
| <input type="hidden" name="modop" value="custom" /> | |||
| <input type="hidden" name="a" value="{$action|escape:'html'}" /> | |||
| <input type="submit" name="yes" value="Sí" /> | |||
| <input type="submit" name="abort" value="No" /> | |||
| </form> | |||
| @@ -0,0 +1,29 @@ | |||
| <h1>Desbloquear IP</h1> | |||
| <p>Indica que IP deseas desbloquear en el cortafuegos:</b> | |||
| <form method="post" action="clientarea.php?action=productdetails"> | |||
| <input type="hidden" name="token" value="{$token|escape:'html'}" /> | |||
| <input type="hidden" name="id" value="{$serviceid|escape:'html'}" /> | |||
| <input type="hidden" name="modop" value="custom" /> | |||
| <input type="hidden" name="a" value="{$action|escape:'html'}" /> | |||
| <input type="text" name="ip" value="{$selfip|escape:'html'}" /> | |||
| <p /> | |||
| <p name="buttons"> | |||
| <input type="submit" name="proceed" value="Desbloquear" /> | |||
| <input type="submit" name="abort" value="Cancelar" /> | |||
| </p> | |||
| </form><b>Nota:</b> <i>El desbloqueo tambien añade la IP a lista blanca</i> | |||
| <h1>Bloquear IP</h1> | |||
| <p>Bloqueo de una IP en el cortafuegos:</b> | |||
| <form method="post" action="clientarea.php?action=productdetails"> | |||
| <input type="hidden" name="token" value="{$token|escape:'html'}" /> | |||
| <input type="hidden" name="id" value="{$serviceid|escape:'html'}" /> | |||
| <input type="hidden" name="modop" value="custom" /> | |||
| <input type="hidden" name="a" value="{$action|escape:'html'}" /> | |||
| <input type="text" name="ip" value="{$selfip|escape:'html'}" /> | |||
| <p /> | |||
| <p name="buttons"> | |||
| <input type="submit" name="proceed" value="Bloquear" /> | |||
| <input type="submit" name="abort" value="Cancelar" /> | |||
| </p> | |||
| </form><b>Nota:</b> <i>El bloqueo tiene prioridad sobre la lista blanca</i> | |||
| @@ -0,0 +1,13 @@ | |||
| <?php if (!defined('WHMCS')) { http_response_code(403); exit; } ?> | |||
| <div class="vpsmanager-links"> | |||
| <?php if ($graphs !== ''): ?> | |||
| <a class="btn btn-default" href="<?= \VPSManager\ClientView::escape($graphs) ?>" target="_blank" rel="noopener noreferrer">Graphs</a> | |||
| <?php else: ?> | |||
| <button class="btn btn-default" type="button" disabled title="Vista completa pendiente de configuración segura">Graphs</button> | |||
| <?php endif; ?> | |||
| <?php if ($panel !== ''): ?> | |||
| <a class="btn btn-default" href="<?= \VPSManager\ClientView::escape($panel) ?>" target="_blank" rel="noopener noreferrer">Acceder al Panel</a> | |||
| <?php else: ?> | |||
| <button class="btn btn-default" type="button" disabled title="URL del panel no disponible">Acceder al Panel</button> | |||
| <?php endif; ?> | |||
| </div> | |||
| @@ -0,0 +1,29 @@ | |||
| <?php if (!defined('WHMCS')) { http_response_code(403); exit; } ?> | |||
| <?= $links ?> | |||
| <section class="vpsmanager-metrics" aria-label="Métricas del VPS" data-metrics="<?= \VPSManager\ClientView::escape($json) ?>"> | |||
| <p class="vpsmanager-ranges" aria-label="Intervalo temporal"> | |||
| <?php foreach ($ranges as $label => $expression): ?> | |||
| <?php if ($serviceId !== false): ?> | |||
| <a class="btn <?= $label === $range ? 'btn-primary' : 'btn-default' ?>" href="clientarea.php?action=productdetails&id=<?= (int) $serviceId ?>&metrics_range=<?= $label ?>" <?= $label === $range ? 'aria-current="true"' : '' ?>><?= $label ?></a> | |||
| <?php endif; ?> | |||
| <?php endforeach; ?> | |||
| </p> | |||
| <?php if ($metrics === null): ?> | |||
| <p class="text-muted" role="status">Las métricas no están disponibles temporalmente.</p> | |||
| <?php else: ?> | |||
| <?php foreach ($cards as $key => $title): ?> | |||
| <div class="panel panel-default card vpsmanager-card" data-chart="<?= $key ?>"> | |||
| <div class="panel-heading card-header"><h3 class="panel-title"><?= $title ?></h3></div> | |||
| <div class="panel-body card-body"> | |||
| <p class="text-muted" data-summary></p> | |||
| <div class="vpsmanager-canvas"><canvas role="img" aria-label="<?= $title ?>"></canvas></div> | |||
| </div> | |||
| </div> | |||
| <?php endforeach; ?> | |||
| <noscript>Activa JavaScript para visualizar las gráficas.</noscript> | |||
| <?php endif; ?> | |||
| </section> | |||
| <link rel="stylesheet" href="modules/servers/vpsmanager/assets/metrics.css"> | |||
| <?php if ($metrics !== null): ?> | |||
| <script src="modules/servers/vpsmanager/assets/metrics.js" defer></script> | |||
| <?php endif; ?> | |||
| @@ -0,0 +1,34 @@ | |||
| <h1>Listado de snapshots</h1> | |||
| <form method="post" action="clientarea.php?action=productdetails"> | |||
| <input type="hidden" name="token" value="{$token|escape:'html'}" /> | |||
| <table class='snapshot'> | |||
| <caption class='snapshot'>Snapshots por sistema</caption> | |||
| <tr class='snapshot'> | |||
| <td class='snapshot'>Servidor</td> | |||
| <td class='snapshot'>Digest sha1</td> | |||
| <td class='snapshot'>Tipo</td> | |||
| <td class='snapshot'>Fecha</td> | |||
| <td class='snapshot'>Tamaño</td> | |||
| <td class='snapshot'>Acción</td> | |||
| </tr> | |||
| <tr> | |||
| <td class='snapshot'>{$serverhostname|escape:'html'}</td> | |||
| <td class='snapshot'>{$uuid|escape:'html'}</td> | |||
| <td class='snapshot'>Remoto</td> | |||
| <td class='snapshot'>{$date|escape:'html'}</td> | |||
| <td class='snapshot'>{$size|escape:'html'}</td> | |||
| <td class='snapshot'><input type='submit' value='Restaurar'></input></td> | |||
| </tr> | |||
| <p> | |||
| </table> | |||
| <input type="hidden" name="id" value="{$serviceid|escape:'html'}" /> | |||
| <input type="hidden" name="modop" value="custom" /> | |||
| <input type="hidden" name="a" value="{$action|escape:'html'}" /> | |||
| </form> | |||
| <p> | |||
| <p><b>Nota:</b> Los tiempos de restauración varian en función del tamaño y tipo de snapshot. Los snapshots locales son rápidos. Los snapshots remotos son mas sensibles a la congestión de la red por lo que esto es un factor que será determinante. Como referencia, un snapshot remoto de 100GB puede tardar en restaurarse 1h aproximadamente. | |||
| @@ -0,0 +1,68 @@ | |||
| 'use strict'; | |||
| const fs = require('fs'); | |||
| const vm = require('vm'); | |||
| const assert = require('assert/strict'); | |||
| const source = fs.readFileSync(__dirname + '/../assets/metrics.js', 'utf8'); | |||
| const points = [{x: 1700000000000, y: 125000}, {x: 1700000060000, y: null}, {x: 1700000420000, y: 250000}]; | |||
| const metrics = { | |||
| load: {'1 min': points, '5 min': points, '15 min': points}, | |||
| memory: {Usada: [{x: 1700000420000, y: 4365361152}], Total: [{x: 1700000420000, y: 8589934592}]}, | |||
| memoryPercent: [{x: 1700000420000, y: 50.81949234008789}], | |||
| network: {RX: points, TX: points} | |||
| }; | |||
| let assertions = 0; | |||
| function check(condition) { assert.ok(condition); assertions++; } | |||
| function run({version = '4.4.8', noChart = false, failLoad = false, absent = false, throwChart = false} = {}) { | |||
| const configs = []; | |||
| const cards = ['load','memory','network'].map(kind => ({ | |||
| dataset: {chart: kind}, summary: {textContent: ''}, canvas: {parentElement: {hidden: false}}, | |||
| querySelector(selector) { return selector === 'canvas' ? this.canvas : this.summary; } | |||
| })); | |||
| const data = JSON.parse(JSON.stringify(metrics)); | |||
| if (absent) data.network = {RX: [], TX: []}; | |||
| const root = {dataset: {metrics: JSON.stringify(data)}, querySelectorAll: () => cards}; | |||
| function FakeChart(canvas, config) { if (throwChart) throw Error('fixture'); configs.push(config); } | |||
| FakeChart.version = version; | |||
| let loaded = 0; | |||
| const window = noChart ? {} : {Chart: FakeChart}; | |||
| const document = { | |||
| readyState: 'complete', querySelectorAll: () => [root], createElement: () => ({}), | |||
| head: {appendChild(script) { | |||
| loaded++; | |||
| check(script.src === 'modules/servers/vpsmanager/assets/chart-4.4.8.scoped.js'); | |||
| if (failLoad) script.onerror(); | |||
| else { window.O6HVpsChart = FakeChart; script.onload(); } | |||
| }} | |||
| }; | |||
| vm.runInNewContext(source, {window, document}); | |||
| return {configs, cards, loaded}; | |||
| } | |||
| const r = run(); | |||
| check(r.configs.length === 3 && r.loaded === 0); | |||
| const network = r.configs[2]; | |||
| check(network.data.datasets[0].data[0].y === 1000000); // Exactly one bytes/s -> bits/s conversion. | |||
| check(network.data.datasets[0].data[1].y === null); | |||
| check(network.data.datasets[0].data[2].x === 1700000420000); | |||
| check(network.data.datasets[0].spanGaps === false); | |||
| check(network.options.scales.x.type === 'linear'); | |||
| check(network.options.scales.y.ticks.callback(1000000) === '1 Mbit/s'); | |||
| check(network.options.scales.y.ticks.callback(1000) === '1 Kbit/s'); | |||
| check(network.options.scales.y.ticks.callback(1) === '1 bit/s'); | |||
| check(network.options.scales.y.ticks.callback(1000000000) === '1 Gbit/s'); | |||
| check(r.configs[0].options.scales.y.max === undefined); // Load is not CPU percent. | |||
| check(r.configs[1].options.scales.y.ticks.callback(8589934592) === '8 GiB'); | |||
| check(r.cards[1].summary.textContent.includes('50.8 %')); | |||
| check(r.cards[1].summary.textContent.includes('8 GiB')); | |||
| check(run({version: '2.9.4'}).loaded === 1); | |||
| check(run({noChart: true}).configs.length === 3); | |||
| check(run({noChart: true, failLoad: true}).cards.every(c => c.canvas.parentElement.hidden)); | |||
| const partial = run({absent: true}); | |||
| check(partial.configs.length === 2 && partial.cards[2].summary.textContent === 'Las métricas no están disponibles temporalmente.'); | |||
| check(run({throwChart: true}).cards.every(c => c.canvas.parentElement.hidden)); | |||
| // Local bundle must not overwrite a WHMCS template's global Chart. | |||
| const prior = {version: '2.9.4'}; | |||
| const context = {window: {Chart: prior}, console}; | |||
| vm.runInNewContext(fs.readFileSync(__dirname + '/../assets/chart-4.4.8.scoped.js', 'utf8'), context); | |||
| check(context.window.Chart === prior); | |||
| check(context.window.O6HVpsChart.version === '4.4.8'); | |||
| console.log(`PASS: ${assertions} frontend assertions.`); | |||
| @@ -0,0 +1,227 @@ | |||
| <?php | |||
| // Intentionally run with php -n: cURL is mocked and no request leaves the process. | |||
| if (extension_loaded('curl')) { | |||
| fwrite(STDERR, "Run with php -n (without the cURL extension).\n"); | |||
| exit(1); | |||
| } | |||
| 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) { | |||
| define($name, $i + 1); | |||
| } | |||
| $GLOBALS['calls'] = []; | |||
| $GLOBALS['response'] = ['status' => 200, 'body' => '{}', 'timeout' => false]; | |||
| function curl_init($url) { return (object) ['url' => $url, 'options' => []]; } | |||
| function curl_setopt($ch, $option, $value) { $ch->options[$option] = $value; return true; } | |||
| function curl_setopt_array($ch, $options) { foreach ($options as $k => $v) curl_setopt($ch, $k, $v); return true; } | |||
| function curl_exec($ch) { | |||
| $GLOBALS['calls'][] = $ch; | |||
| $response = $GLOBALS['response']; | |||
| if ($response['timeout'] ?? false) return false; | |||
| if (isset($ch->options[CURLOPT_WRITEFUNCTION])) { | |||
| $body = $response['body']; | |||
| return $ch->options[CURLOPT_WRITEFUNCTION]($ch, $body) === strlen($body); | |||
| } | |||
| if (!($ch->options[CURLOPT_RETURNTRANSFER] ?? false)) echo $response['body']; | |||
| return $response['body']; | |||
| } | |||
| function curl_getinfo($ch, $option) { return $GLOBALS['response']['status']; } | |||
| function curl_close($ch) {} | |||
| function logModuleCall(...$args) { throw new RuntimeException('Unexpected potentially sensitive log call'); } | |||
| set_error_handler(static function ($severity, $message, $file, $line) { throw new ErrorException($message, 0, $severity, $file, $line); }); | |||
| define('WHMCS', true); | |||
| require_once __DIR__ . '/../vpsmanager.php'; | |||
| use VPSManager\Config; | |||
| use VPSManager\Graphite; | |||
| use VPSManager\ClientView; | |||
| $count = 0; | |||
| function check($condition, $message) { | |||
| global $count; | |||
| if (!$condition) throw new RuntimeException('FAIL: ' . $message); | |||
| $count++; | |||
| } | |||
| function rejects(callable $fn, $message) { | |||
| try { $fn(); } catch (Throwable $e) { check(true, $message); return; } | |||
| check(false, $message); | |||
| } | |||
| function fixture($uuid) { | |||
| $values = [ | |||
| 'load.load.shortterm' => 18.14, 'load.load.midterm' => 20.03, 'load.load.longterm' => 20.51, | |||
| 'memory.memory-used' => 4365361152, 'memory.memory-free' => 4224573440, | |||
| 'interface-eth0.if_octets.rx' => 125000, 'interface-eth0.if_octets.tx' => 250000, | |||
| ]; | |||
| $data = []; | |||
| foreach ($values as $suffix => $value) { | |||
| $data[] = ['target' => "lx.$uuid.$suffix", 'datapoints' => [[$value, 1700000000], [null, 1700000060], [$value, 1700000420]]]; | |||
| } | |||
| return $data; | |||
| } | |||
| $uuid = '00000000-0000-4000-8000-000000000001'; | |||
| $other = '00000000-0000-4000-8000-000000000002'; | |||
| $params = ['serviceid' => 42, 'domain' => 'vm.example.invalid', 'customfields' => [ | |||
| 'vtype' => 'lx', 'uuid' => $uuid, 'gzid' => 'fixture-node', 'url' => 'panel.example.invalid', | |||
| 'username' => 'FAKE_PANEL_USER', 'password' => 'FAKE_PANEL_PASSWORD_SENTINEL', | |||
| ], 'serverpassword' => 'FAKE_SERVER_PASSWORD_SENTINEL']; | |||
| $config = ['graphite_url' => 'https://metrics.example.invalid', 'vpsmanager_api_url' => 'https://api.example.invalid', | |||
| 'graphs_url' => 'https://graphs.example.invalid', 'graphs_authorization_confirmed' => true]; | |||
| $temp = tempnam(sys_get_temp_dir(), 'vpsmanager-test-'); | |||
| putenv('O6H_VPSMANAGER_CONFIG=' . $temp); | |||
| function writeConfig($path, $data) { file_put_contents($path, "<?php\nreturn " . var_export($data, true) . ";\n"); } | |||
| try { | |||
| writeConfig($temp, $config); | |||
| check(Config::load() === $config, 'valid external configuration'); | |||
| check(Config::url($config, 'graphite_url') === $config['graphite_url'], 'valid URL'); | |||
| foreach (['', 'not-a-url', 'http://metrics.example.invalid', 'https://user:pass@example.invalid', 'https://example.invalid/?token=test', 'https://example.invalid/#frag', 'file:///tmp/example'] as $url) { | |||
| rejects(fn() => Config::url(['url' => $url], 'url'), 'unsafe URL'); | |||
| } | |||
| rejects(fn() => Config::url([], 'graphite_url'), 'missing mandatory value'); | |||
| putenv('O6H_VPSMANAGER_CONFIG=' . $temp . '.missing'); | |||
| rejects(fn() => Config::load(), 'missing file'); | |||
| check(vpsmanager_m_suspend($params) === 'Configuración del módulo no disponible.', 'missing config handled in suspension'); | |||
| putenv('O6H_VPSMANAGER_CONFIG=' . $temp); | |||
| file_put_contents($temp, '<?php return [;'); | |||
| rejects(fn() => Config::load(), 'syntax error in private file is controlled'); | |||
| writeConfig($temp, false); | |||
| rejects(fn() => Config::load(), 'wrong configuration type'); | |||
| writeConfig($temp, $config); | |||
| check(Graphite::validUuid($uuid), 'UUID accepted'); | |||
| foreach (['*', 'foo.*', '../../', '{a,b}', 'x)&target=*', $uuid . "\n", [], null] as $bad) { | |||
| check(!Graphite::validUuid($bad), 'invalid UUID rejected'); | |||
| } | |||
| foreach (array_keys(Graphite::RANGES) as $range) check(Graphite::range($range) === $range, 'allowed range'); | |||
| foreach (['-1y', 'now', '1h&target=*', '', [], null, 24] as $bad) check(Graphite::range($bad) === '24h', 'fallback range'); | |||
| $raw = fixture($uuid); | |||
| $GLOBALS['response'] = ['status' => 200, 'body' => json_encode($raw)]; | |||
| $graphite = new Graphite($config); | |||
| $before = count($GLOBALS['calls']); | |||
| $metrics = $graphite->getForService($params, '6h'); | |||
| check(count($GLOBALS['calls']) === $before + 1, 'one request for all seven series'); | |||
| $request = end($GLOBALS['calls']); | |||
| check(substr_count($request->url, '&target=') === 7, 'seven repeated target parameters'); | |||
| check(strpos($request->url, 'from=-6h') !== false, 'range mapped internally'); | |||
| check(strpos($request->url, 'derivative') === false, 'no derivatives'); | |||
| check($request->options[CURLOPT_SSL_VERIFYPEER] === true && $request->options[CURLOPT_SSL_VERIFYHOST] === 2, 'TLS verified'); | |||
| check($request->options[CURLOPT_FOLLOWLOCATION] === false && $request->options[CURLOPT_PROTOCOLS] === CURLPROTO_HTTPS, 'HTTPS without redirects'); | |||
| check($request->options[CURLOPT_CONNECTTIMEOUT] === 3 && $request->options[CURLOPT_TIMEOUT] === 5, 'bounded timeout'); | |||
| check(array_keys($metrics['load']) === ['1 min', '5 min', '15 min'], 'load labels'); | |||
| check($metrics['load']['1 min'][0]['y'] === 18.14, 'load not normalized'); | |||
| check($metrics['load']['1 min'][1]['y'] === null, 'null preserved'); | |||
| check($metrics['load']['1 min'][2]['x'] - $metrics['load']['1 min'][1]['x'] === 360000, 'arbitrary timestamps'); | |||
| check($metrics['memory']['Total'][0]['y'] === 8589934592, 'memory total'); | |||
| check($metrics['memory']['Total'][0]['y'] / (1024 ** 3) === 8, 'memory GiB'); | |||
| check(abs($metrics['memoryPercent'][0]['y'] - 50.81949234008789) < 0.00001, 'memory percentage'); | |||
| check($metrics['memory']['Total'][1]['y'] === null, 'memory null not zero'); | |||
| check($metrics['network']['RX'][0]['y'] === 125000, 'network remains bytes/s in PHP'); | |||
| foreach (['kvm', 'bhyve', 'joyent', 'openvz', 'dedicado', '*', []] as $type) { | |||
| $bad = $params; $bad['customfields']['vtype'] = $type; | |||
| rejects(fn() => $graphite->getForService($bad), 'unsupported vtype'); | |||
| } | |||
| $bad = $params; $bad['customfields']['uuid'] = '*'; | |||
| rejects(fn() => $graphite->getForService($bad), 'arbitrary target rejected'); | |||
| foreach ([['status' => 500, 'body' => 'PRIVATE_RESPONSE'], ['status' => 200, 'body' => '{'], ['status' => 200, 'body' => '{}'], | |||
| ['status' => 200, 'body' => '', 'timeout' => true], ['status' => 200, 'body' => str_repeat('x', 2097153)]] as $error) { | |||
| $GLOBALS['response'] = $error; | |||
| rejects(fn() => $graphite->getForService($params), 'Graphite failure'); | |||
| $html = vpsmanager_ClientArea($params); | |||
| check(strpos($html, 'Las métricas no están disponibles temporalmente.') !== false, 'graceful failure'); | |||
| check(strpos($html, 'Acceder al Panel') !== false, 'panel survives metrics failure'); | |||
| check(strpos($html, 'PRIVATE_RESPONSE') === false, 'response not exposed'); | |||
| } | |||
| $GLOBALS['response'] = ['status' => 200, 'body' => '[]']; | |||
| check($graphite->getForService($params)['load']['1 min'] === [], 'missing series'); | |||
| $partial = [$raw[0], $raw[3]]; | |||
| $GLOBALS['response']['body'] = json_encode($partial); | |||
| $m = $graphite->getForService($params); | |||
| check($m['load']['1 min'][0]['y'] === 18.14 && $m['load']['5 min'] === [], 'partially available series'); | |||
| check($m['memory']['Total'][0]['y'] === null, 'missing free not treated as zero'); | |||
| $shifted = $raw; | |||
| $shifted[4]['datapoints'][0][1] += 1; | |||
| $GLOBALS['response']['body'] = json_encode($shifted); | |||
| check($graphite->getForService($params)['memory']['Total'][0]['y'] === null, 'memory aligned by timestamp'); | |||
| $zero = $raw; | |||
| $zero[3]['datapoints'][0][0] = 0; $zero[4]['datapoints'][0][0] = 0; | |||
| $GLOBALS['response']['body'] = json_encode($zero); | |||
| check($graphite->getForService($params)['memoryPercent'][0]['y'] === null, 'zero total safe'); | |||
| foreach ([[['bad', 1700000000]], [[1, '1700000000']], [[-1, 1700000000]], [[1, 1700000000], [2, 1700000000]]] as $points) { | |||
| $broken = $raw; $broken[0]['datapoints'] = $points; | |||
| $GLOBALS['response']['body'] = json_encode($broken); | |||
| rejects(fn() => $graphite->getForService($params), 'invalid datapoints rejected'); | |||
| } | |||
| $unexpected = $raw; | |||
| $unexpected[] = ['target' => 'lx.' . $other . '.load.load.shortterm', 'datapoints' => [[999, 1700000000]]]; | |||
| $GLOBALS['response']['body'] = json_encode($unexpected); | |||
| check(strpos(json_encode($graphite->getForService($params)), $other) === false, 'unrequested target dropped'); | |||
| // Authorization boundary regression: only WHMCS service params select the resource. | |||
| $GLOBALS['response'] = ['status' => 200, 'body' => json_encode($raw)]; | |||
| $_GET = ['uuid' => $other, 'vtype' => 'kvm', 'id' => 99, 'target' => '*', 'from' => '-1y', 'metrics_range' => '1h&target=*']; | |||
| $_POST = $_GET; | |||
| $html = vpsmanager_ClientArea($params); | |||
| $request = end($GLOBALS['calls']); | |||
| check(strpos($request->url, $uuid) !== false && strpos($request->url, $other) === false, 'GET and POST cannot select another UUID'); | |||
| check(strpos($request->url, 'from=-24h') !== false, 'malicious range falls back'); | |||
| check(strpos($html, 'id=42') !== false && strpos($html, 'id=99') === false, 'range links use authorized service id'); | |||
| foreach (['FAKE_PANEL_USER', 'FAKE_PANEL_PASSWORD_SENTINEL', 'FAKE_SERVER_PASSWORD_SENTINEL', 'metrics.example.invalid', 'load.load.shortterm'] as $secret) { | |||
| check(strpos($html, $secret) === false, 'frontend receives only presentation data'); | |||
| check(strpos(implode('', vpsmanager_AdminServicesTabFields($params)), $secret) === false, 'admin panel no credentials'); | |||
| } | |||
| check(strpos($html, 'https://panel.example.invalid:8080/login/index.php') !== false, 'manual panel login'); | |||
| foreach (['javascript:alert(1)', 'https://user:pass@panel.example.invalid', 'https://panel.example.invalid/?password=test', '"><script>alert(1)</script>'] as $url) { | |||
| $bad = $params; $bad['customfields']['url'] = $url; | |||
| $links = ClientView::links($bad, []); | |||
| check(strpos($links, 'href=') === false, 'unsafe panel link disabled'); | |||
| } | |||
| $bad = $params; $bad['customfields']['vtype'] = 'kvm'; | |||
| $before = count($GLOBALS['calls']); | |||
| check(strpos(vpsmanager_ClientArea($bad), 'data-metrics') === false && count($GLOBALS['calls']) === $before, 'other types do not query Graphite'); | |||
| check(strpos(ClientView::links($params, []), 'Graphs</button>') !== false, 'Graphs retained when unconfigured'); | |||
| check(strpos(ClientView::links($params, $config), '/collect/servidor/lx/' . $uuid) !== false, 'configured full Graphs link retained'); | |||
| // Existing operations: routes, payloads, cancellation, buttons and wrapper delegation. | |||
| $_GET = []; $_POST = []; | |||
| $GLOBALS['response'] = ['status' => 200, 'body' => '{"size":"10 GiB","date":"2026-01-01"}']; | |||
| foreach (['vpsmanager_m_reboot' => '/command/reboot', 'vpsmanager_m_shutdown' => '/command/shutdown', | |||
| 'vpsmanager_m_boot' => '/command/boot', 'vpsmanager_m_snapshots' => '/restoresnap', | |||
| 'vpsmanager_SuspendAccount' => '/suspend', 'vpsmanager_UnsuspendAccount' => '/unsuspend'] as $fn => $route) { | |||
| ob_start(); $result = $fn($params); $output = ob_get_clean(); | |||
| $call = end($GLOBALS['calls']); | |||
| check($result === 'success', $fn . ' return value'); | |||
| check($call->url === $config['vpsmanager_api_url'] . $route, $fn . ' original route'); | |||
| check(json_decode($call->options[CURLOPT_POSTFIELDS], true) === ['gzid' => 'fixture-node', 'uuid' => $uuid], $fn . ' original payload'); | |||
| check($output === '', $fn . ' raw response not sent to client'); | |||
| } | |||
| foreach (['Desbloquear' => '/unblock', 'Bloquear' => '/block'] as $action => $route) { | |||
| $_POST = ['proceed' => $action, 'ip' => '192.0.2.1']; | |||
| check(vpsmanager_m_firewall($params) === 'success', 'firewall result'); | |||
| $call = end($GLOBALS['calls']); | |||
| check($call->url === $config['vpsmanager_api_url'] . $route, 'firewall route'); | |||
| check(json_decode($call->options[CURLOPT_POSTFIELDS], true)['ip'] === '192.0.2.1', 'firewall IP payload'); | |||
| } | |||
| $_POST = ['proceed' => 'invalid', 'ip' => '192.0.2.1']; | |||
| check(vpsmanager_m_firewall($params) === 'Acción de firewall no válida.', 'firewall invalid action'); | |||
| $_POST = ['proceed' => 'Bloquear', 'ip' => '*']; | |||
| check(vpsmanager_m_firewall($params) === 'Dirección IP no válida.', 'firewall invalid IP'); | |||
| $_POST = []; | |||
| $snapshots = vpsmanager_Snapshots($params); | |||
| check($snapshots['vars']['size'] === '10 GiB' && $snapshots['vars']['date'] === '2026-01-01', 'snapshot data retained'); | |||
| check(end($GLOBALS['calls'])->url === $config['vpsmanager_api_url'] . '/getsnap', 'snapshot list route'); | |||
| foreach (['Reiniciar', 'Detener', 'Iniciar', 'Firewall', 'Snapshots'] as $action) { | |||
| $fn = 'vpsmanager_' . $action; | |||
| $_POST = []; | |||
| check(is_file(__DIR__ . '/../' . $fn($params)['templatefile'] . '.tpl'), 'action template exists'); | |||
| $_POST = ['a' => $action, 'abort' => 'No']; | |||
| $before = count($GLOBALS['calls']); | |||
| check(is_string($fn($params)) && count($GLOBALS['calls']) === $before, 'cancellation does not call API'); | |||
| } | |||
| check(vpsmanager_ClientAreaCustomButtonArray() === ['Reiniciar' => 'Reiniciar', 'Detener' => 'Detener', 'Iniciar' => 'Iniciar', 'Firewall' => 'Firewall', 'Snapshots' => 'Snapshots'], 'buttons unchanged'); | |||
| check(vpsmanager_CreateAccount($params) === 'success', 'legacy CreateAccount preserved'); | |||
| $GLOBALS['response'] = ['status' => 200, 'body' => '["fixture-choice"]']; | |||
| 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'); | |||
| echo "PASS: $count assertions; mocked HTTP only.\n"; | |||
| } finally { | |||
| unlink($temp); | |||
| putenv('O6H_VPSMANAGER_CONFIG'); | |||
| } | |||
| @@ -0,0 +1,103 @@ | |||
| #!/usr/bin/env python3 | |||
| """Local secret review. Never print matched values. Use alongside manual review.""" | |||
| import argparse | |||
| import hashlib | |||
| import math | |||
| from pathlib import Path | |||
| import re | |||
| import subprocess | |||
| import sys | |||
| from collections import Counter | |||
| root = Path(__file__).resolve().parents[1] | |||
| parser = argparse.ArgumentParser() | |||
| parser.add_argument('--staged', action='store_true') | |||
| parser.add_argument('--tracked', action='store_true') | |||
| args = parser.parse_args() | |||
| if args.staged and args.tracked: | |||
| parser.error('Choose one Git mode') | |||
| mode = 'staged' if args.staged else 'tracked' if args.tracked else 'tree' | |||
| def git(*parts): | |||
| return subprocess.check_output(['git', '-C', str(root), *parts]) | |||
| if mode != 'tree': | |||
| files = [p.decode() for p in git('ls-files', '-z').split(b'\0') if p] | |||
| else: | |||
| files = [str(p.relative_to(root)) for p in root.rglob('*') if p.is_file() and '.git' not in p.relative_to(root).parts] | |||
| # Only these two exact artifacts may bypass heuristic string/entropy inspection. | |||
| # Their content is checked against audited digests; any change requires review. | |||
| trusted = {} | |||
| manifest = root / 'assets' / 'vendor-sha256.txt' | |||
| for line in manifest.read_text().splitlines(): | |||
| digest, name = line.split(' ', 1) | |||
| trusted['assets/' + name] = digest | |||
| keywords = re.compile(r'password|passwd|secret|token|api_key|apikey|Authorization:|BEGIN (?:RSA )?PRIVATE KEY|cookie', re.I) | |||
| private_key = re.compile(r'-{5}BEGIN (?:[A-Z0-9]+ )*PRIVATE KEY-{5}') | |||
| url_auth = re.compile(r'https?://[^\s/\'"]+:[^\s/\'"]+@', re.I) | |||
| credential = re.compile(r'''["']?(?:password|passwd|secret|token|api_key|apikey)["']?\s*(?:=>|=|:)\s*(["'])([^"'\n]+)\1''', re.I) | |||
| known_token = re.compile(r'\b(?:AKIA[0-9A-Z]{16}|gh[pousr]_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{30,}|xox[baprs]-[A-Za-z0-9-]{15,})\b') | |||
| private_ip = re.compile(r'\b(?:10(?:\.\d{1,3}){3}|192\.168(?:\.\d{1,3}){2}|172\.(?:1[6-9]|2\d|3[01])(?:\.\d{1,3}){2})\b') | |||
| quote = re.compile(r'''["']([A-Za-z0-9_+/=.-]{28,})["']''') | |||
| findings = [] | |||
| review = [] | |||
| def flag(name, line, reason): | |||
| findings.append(f'{name}:{line}: {reason}') | |||
| for name in sorted(files): | |||
| path = root / name | |||
| if mode == 'tree' and path.is_symlink(): | |||
| flag(name, 0, 'unexpected symlink'); continue | |||
| if mode == 'staged': | |||
| content = git('show', ':' + name) | |||
| elif mode == 'tracked': | |||
| content = git('show', 'HEAD:' + name) | |||
| else: | |||
| content = path.read_bytes() | |||
| leaf = Path(name).name.lower() | |||
| if (leaf in ['config.php', '.env', 'id_rsa', 'id_ed25519'] and name != 'lib/Config.php') or leaf.startswith('.env.') or leaf.endswith(('.local.php', '.sql', '.sql.gz', '.bak', '.swp', '.swo', '~', '.zip', '.tar', '.tar.gz', '.key', '.p12', '.pfx')): | |||
| flag(name, 0, 'private/configuration/backup/archive filename') | |||
| if name in trusted: | |||
| if hashlib.sha256(content).hexdigest() != trusted[name]: | |||
| flag(name, 0, 'vendor digest mismatch') | |||
| continue | |||
| try: | |||
| text = content.decode('utf-8') | |||
| except UnicodeDecodeError: | |||
| flag(name, 0, 'unreviewed binary file'); continue | |||
| for number, line in enumerate(text.splitlines(), 1): | |||
| if keywords.search(line): | |||
| review.append(f'{name}:{number}') | |||
| if private_key.search(line) or known_token.search(line): | |||
| flag(name, number, 'private key or known credential pattern') | |||
| if url_auth.search(line) and not (name == 'tests/run.php' and 'example.invalid' in line): | |||
| flag(name, number, 'credential embedded in URL') | |||
| if private_ip.search(line): | |||
| flag(name, number, 'private IP literal') | |||
| for match in credential.finditer(line): | |||
| value = match.group(2) | |||
| if name == 'tests/run.php' and value.startswith('FAKE_'): | |||
| continue | |||
| flag(name, number, 'nonempty credential-like literal') | |||
| for match in quote.finditer(line): | |||
| value = match.group(1) | |||
| if name == 'tests/run.php' and value.startswith('FAKE_'): | |||
| continue | |||
| if 'example.invalid' in value or re.fullmatch(r'[0-9a-f-]{36}', value): | |||
| continue | |||
| # Code identifiers, paths and human phrases need manual keyword review; | |||
| # look for random-looking mixed-case/digit string literals. | |||
| counts = Counter(value) | |||
| entropy = -sum((n/len(value))*math.log2(n/len(value)) for n in counts.values()) | |||
| if entropy > 4.5 and re.search('[a-z]', value) and re.search('[A-Z]', value) and re.search('[0-9]', value): | |||
| flag(name, number, 'high-entropy literal requires review') | |||
| print(f'Audit mode={mode}; files={len(files)}; keyword lines={len(review)}; flagged={len(findings)}') | |||
| for item in findings: | |||
| print(item) | |||
| if findings: | |||
| sys.exit(1) | |||
| print('PASS heuristic scan. Keyword matches require the documented manual review; values are not printed.') | |||
| @@ -0,0 +1,8 @@ | |||
| 'use strict'; | |||
| const fs = require('fs'); | |||
| const path = require('path'); | |||
| const assets = path.join(__dirname, '..', 'assets'); | |||
| const original = fs.readFileSync(path.join(assets, 'chart-4.4.8.umd.js'), 'utf8'); | |||
| const prefix = '(function () {\nvar hadChart = Object.prototype.hasOwnProperty.call(window, "Chart");\nvar previousChart = window.Chart;\nvar module = {exports: {}}; var exports = module.exports;\n'; | |||
| const suffix = '\nwindow.O6HVpsChart = module.exports;\nif (hadChart) window.Chart = previousChart; else delete window.Chart;\n}());\n'; | |||
| fs.writeFileSync(path.join(assets, 'chart-4.4.8.scoped.js'), prefix + original.replace(/^\/\/# sourceMappingURL=.*$/m, '') + suffix); | |||
| @@ -0,0 +1,614 @@ | |||
| <?php | |||
| /** | |||
| * WHMCS VPSManager Module | |||
| * | |||
| */ | |||
| if (!defined('WHMCS')) { | |||
| http_response_code(403); | |||
| exit; | |||
| } | |||
| require_once __DIR__ . '/lib/VPSManager.php'; | |||
| require_once __DIR__ . '/lib/ClientView.php'; | |||
| require_once __DIR__ . '/WhmcsHelpers/CustomField.php'; | |||
| use VPSManager\VPSManager; | |||
| use WHMCS\Database\Capsule; | |||
| use VPSManager\WhmcsHelpers\CustomField; | |||
| function vpsmanager_Metadata() { | |||
| return array( | |||
| 'DisplayName' => 'o6h VPSManager Module', | |||
| 'APIVersion' => '1.1', | |||
| 'RequiresServer' => true, | |||
| 'DefaultNonSSLPort' => '8080', | |||
| 'DefaultSSLPort' => '8080', | |||
| ); | |||
| } | |||
| function vpsmanager_ConfigOptions( $params ) { | |||
| $vpsmanager = new VPSManager(); | |||
| try { | |||
| $nodes = $vpsmanager->apiCall('nodes'); | |||
| $packs = $vpsmanager->apiCall('packs'); | |||
| } catch (\Throwable $e) { | |||
| $nodes = []; | |||
| $packs = []; | |||
| } | |||
| return [ | |||
| 'VPSManager Remote Username' => [ | |||
| 'Type' => 'text', | |||
| 'Size' => '16', | |||
| 'Description' => 'Remote Username configured in VPSManager.' | |||
| ], | |||
| 'VPSManager Remote Password' => [ | |||
| 'Type' => 'password', | |||
| 'Size' => '16', | |||
| 'Description' => 'Remote Password configured in VPSManager.' | |||
| ], | |||
| 'VPSManager URL' => [ | |||
| 'Type' => 'text', | |||
| 'Size' => '32', | |||
| 'Description' => 'E.g. vpsmanager.example.tld:8080' | |||
| ], | |||
| 'VPSManager SSL' => [ | |||
| 'Type' => 'yesno', | |||
| 'Description' => 'Tick if you enabled SSL on your VPSManager' | |||
| . ' Controlpanel Web Interface.' | |||
| ], | |||
| 'Nodo' => [ | |||
| 'Type' => 'dropdown', | |||
| 'Options' => $nodes, | |||
| 'SimpleMode' => false, | |||
| ], | |||
| 'Pack' => [ | |||
| 'Type' => "dropdown", # Dropdown Choice of Options | |||
| 'Options' => $packs | |||
| ], | |||
| ]; | |||
| } | |||
| function vpsmanager_CreateAccount( $params ) { | |||
| return 'success'; | |||
| } | |||
| function vpsmanager_TerminateAccount( $params ) { | |||
| } | |||
| function vpsmanager_ChangePackage( $params ) { | |||
| } | |||
| function vpsmanager_SuspendAccount( $params ) | |||
| { | |||
| return vpsmanager_m_suspend($params); | |||
| } | |||
| function vpsmanager_UnsuspendAccount( $params ) | |||
| { | |||
| return vpsmanager_m_unsuspend($params); | |||
| } | |||
| function vpsmanager_ChangePassword( $params ) { | |||
| } | |||
| function vpsmanager_ClientAreaCustomButtonArray() | |||
| { | |||
| return array( | |||
| "Reiniciar" => "Reiniciar", | |||
| "Detener" => "Detener", | |||
| "Iniciar" => "Iniciar", | |||
| "Firewall" => "Firewall", | |||
| "Snapshots" => "Snapshots", | |||
| ); | |||
| } | |||
| function vpsmanager_Firewall(array $params) | |||
| { | |||
| if (isset($_POST["a"])) { | |||
| return vpsmanager_m_firewall($params); | |||
| } | |||
| return [ | |||
| 'templatefile' => 'templates/firewall', | |||
| 'vars' => [ | |||
| 'action' => 'Firewall', | |||
| 'selfip' => ($_SERVER['REMOTE_ADDR'] ?? '') | |||
| ] | |||
| ]; | |||
| } | |||
| function vpsmanager_Reiniciar(array $params) | |||
| { | |||
| if (isset($_POST["a"])) { | |||
| return vpsmanager_m_reboot($params); | |||
| } | |||
| return [ | |||
| 'templatefile' => 'templates/alert', | |||
| 'vars' => [ | |||
| 'action' => 'Reiniciar', | |||
| 'description' => 'reiniciar el servidor' | |||
| ] | |||
| ]; | |||
| } | |||
| function vpsmanager_Detener(array $params) | |||
| { | |||
| if (isset($_POST["a"])) { | |||
| return vpsmanager_m_shutdown($params); | |||
| } | |||
| return [ | |||
| 'templatefile' => 'templates/alert', | |||
| 'vars' => [ | |||
| 'action' => 'Detener', | |||
| 'description' => 'detener el servidor' | |||
| ] | |||
| ]; | |||
| } | |||
| function vpsmanager_Iniciar(array $params) | |||
| { | |||
| if (isset($_POST["a"])) { | |||
| return vpsmanager_m_boot($params); | |||
| } | |||
| return [ | |||
| 'templatefile' => 'templates/alert', | |||
| 'vars' => [ | |||
| 'action' => 'Iniciar', | |||
| 'description' => 'iniciar el servidor' | |||
| ] | |||
| ]; | |||
| } | |||
| function vpsmanager_Snapshots(array $params) | |||
| { | |||
| // si recibimos un post lo procesamos en la otra funcion | |||
| if (isset($_POST["a"])) { | |||
| return vpsmanager_m_snapshots($params); | |||
| } | |||
| $datos = $params['customfields']; | |||
| $gzid = $datos['gzid']; | |||
| $uuid = $datos['uuid']; | |||
| $serverhostname = $params['domain']; | |||
| // hacemos la llamada para popular la tabla de snapshots | |||
| try { | |||
| $globalurl = \VPSManager\Config::url(\VPSManager\Config::load(), 'vpsmanager_api_url', false); | |||
| if (!function_exists('curl_init')) { | |||
| return 'Transporte del módulo no disponible.'; | |||
| } | |||
| } catch (\Throwable $e) { | |||
| return 'Configuración del módulo no disponible.'; | |||
| } | |||
| $url = $globalurl . '/getsnap'; | |||
| $ch = curl_init($url); | |||
| curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); | |||
| curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true); | |||
| curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2); | |||
| //setup request to send json via POST | |||
| $data = array( | |||
| 'gzid' => $gzid, | |||
| 'uuid' => $uuid | |||
| ); | |||
| $payload = json_encode($data); | |||
| curl_setopt($ch, CURLOPT_POSTFIELDS, $payload); | |||
| curl_setopt($ch, CURLOPT_HTTPHEADER, array('Content-Type: application/json')); | |||
| curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); | |||
| $result = curl_exec($ch); | |||
| //Decode Json | |||
| $datos = json_decode($result,true); | |||
| curl_close($ch); | |||
| return [ | |||
| 'templatefile' => 'templates/snapshots', | |||
| 'vars' => [ | |||
| 'action' => 'Snapshots', | |||
| 'selfip' => ($_SERVER['REMOTE_ADDR'] ?? ''), | |||
| 'uuid' => $uuid, | |||
| 'serverhostname' => $serverhostname, | |||
| 'size' => $datos['size'] ?? '', | |||
| 'date' => $datos['date'] ?? '' | |||
| ] | |||
| ]; | |||
| } | |||
| function vpsmanager_m_snapshots(array $params) | |||
| { | |||
| if (isset($_POST["abort"])) { | |||
| return "operacion cancelada por el usuario"; | |||
| } | |||
| $datos = $params['customfields']; | |||
| $gzid = $datos['gzid']; | |||
| $uuid = $datos['uuid']; | |||
| try { | |||
| $globalurl = \VPSManager\Config::url(\VPSManager\Config::load(), 'vpsmanager_api_url', false); | |||
| if (!function_exists('curl_init')) { | |||
| return 'Transporte del módulo no disponible.'; | |||
| } | |||
| } catch (\Throwable $e) { | |||
| return 'Configuración del módulo no disponible.'; | |||
| } | |||
| $url = $globalurl . '/restoresnap'; | |||
| //create a new cURL resource | |||
| $ch = curl_init($url); | |||
| curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); | |||
| curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true); | |||
| curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2); | |||
| //setup request to send json via POST | |||
| $data = array( | |||
| 'gzid' => $gzid, | |||
| 'uuid' => $uuid | |||
| ); | |||
| $payload = json_encode($data); | |||
| //attach encoded JSON string to the POST fields | |||
| curl_setopt($ch, CURLOPT_POSTFIELDS, $payload); | |||
| //set the content type to application/json | |||
| curl_setopt($ch, CURLOPT_HTTPHEADER, array('Content-Type:application/json')); | |||
| //execute the POST request | |||
| $result = curl_exec($ch); | |||
| //close cURL resource | |||
| curl_close($ch); | |||
| return 'success'; | |||
| } | |||
| function vpsmanager_m_reboot(array $params) | |||
| { | |||
| if (isset($_POST["abort"])) { | |||
| return "Operacion cancelada por el usuario"; | |||
| } | |||
| $datos = $params['customfields']; | |||
| $gzid = $datos['gzid']; | |||
| $uuid = $datos['uuid']; | |||
| try { | |||
| $globalurl = \VPSManager\Config::url(\VPSManager\Config::load(), 'vpsmanager_api_url', false); | |||
| if (!function_exists('curl_init')) { | |||
| return 'Transporte del módulo no disponible.'; | |||
| } | |||
| } catch (\Throwable $e) { | |||
| return 'Configuración del módulo no disponible.'; | |||
| } | |||
| $url = $globalurl . '/command/reboot'; | |||
| //create a new cURL resource | |||
| $ch = curl_init($url); | |||
| curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); | |||
| curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true); | |||
| curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2); | |||
| //setup request to send json via POST | |||
| $data = array( | |||
| 'gzid' => $gzid, | |||
| 'uuid' => $uuid | |||
| ); | |||
| $payload = json_encode($data); | |||
| //attach encoded JSON string to the POST fields | |||
| curl_setopt($ch, CURLOPT_POSTFIELDS, $payload); | |||
| //set the content type to application/json | |||
| curl_setopt($ch, CURLOPT_HTTPHEADER, array('Content-Type: application/json')); | |||
| curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); | |||
| //execute the POST request | |||
| $result = curl_exec($ch); | |||
| //close cURL resource | |||
| curl_close($ch); | |||
| return 'success'; | |||
| } | |||
| function vpsmanager_m_shutdown(array $params) | |||
| { | |||
| if (isset($_POST["abort"])) { | |||
| return "operacion cancelada por el usuario"; | |||
| } | |||
| $datos = $params['customfields']; | |||
| $gzid = $datos['gzid']; | |||
| $uuid = $datos['uuid']; | |||
| try { | |||
| $globalurl = \VPSManager\Config::url(\VPSManager\Config::load(), 'vpsmanager_api_url', false); | |||
| if (!function_exists('curl_init')) { | |||
| return 'Transporte del módulo no disponible.'; | |||
| } | |||
| } catch (\Throwable $e) { | |||
| return 'Configuración del módulo no disponible.'; | |||
| } | |||
| $url = $globalurl . '/command/shutdown'; | |||
| //create a new cURL resource | |||
| $ch = curl_init($url); | |||
| curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); | |||
| curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true); | |||
| curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2); | |||
| //setup request to send json via POST | |||
| $data = array( | |||
| 'gzid' => $gzid, | |||
| 'uuid' => $uuid | |||
| ); | |||
| $payload = json_encode($data); | |||
| //attach encoded JSON string to the POST fields | |||
| curl_setopt($ch, CURLOPT_POSTFIELDS, $payload); | |||
| //set the content type to application/json | |||
| curl_setopt($ch, CURLOPT_HTTPHEADER, array('Content-Type:application/json')); | |||
| //execute the POST request | |||
| $result = curl_exec($ch); | |||
| //close cURL resource | |||
| curl_close($ch); | |||
| return 'success'; | |||
| } | |||
| function vpsmanager_m_firewall(array $params) | |||
| { | |||
| if (isset($_POST["abort"])) { | |||
| return "operacion cancelada por el usuario"; | |||
| } | |||
| try { | |||
| $globalurl = \VPSManager\Config::url(\VPSManager\Config::load(), 'vpsmanager_api_url', false); | |||
| if (!function_exists('curl_init')) { | |||
| return 'Transporte del módulo no disponible.'; | |||
| } | |||
| } catch (\Throwable $e) { | |||
| return 'Configuración del módulo no disponible.'; | |||
| } | |||
| $datos = $params['customfields']; | |||
| $gzid = $datos['gzid']; | |||
| $uuid = $datos['uuid']; | |||
| $ip = $_POST["ip"] ?? ''; | |||
| if (!is_string($ip) || !filter_var($ip, FILTER_VALIDATE_IP)) { | |||
| return 'Dirección IP no válida.'; | |||
| } | |||
| if ( ($_POST["proceed"] ?? null) == "Desbloquear" ) { | |||
| $url = $globalurl . '/unblock'; | |||
| } elseif ( ($_POST["proceed"] ?? null) == "Bloquear" ) { | |||
| $url = $globalurl . '/block'; | |||
| } else { | |||
| return 'Acción de firewall no válida.'; | |||
| } | |||
| //create a new cURL resource | |||
| $ch = curl_init($url); | |||
| curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); | |||
| curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true); | |||
| curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2); | |||
| //setup request to send json via POST | |||
| $data = array( | |||
| 'gzid' => $gzid, | |||
| 'uuid' => $uuid, | |||
| 'ip' => $ip | |||
| ); | |||
| $payload = json_encode($data); | |||
| //attach encoded JSON string to the POST fields | |||
| curl_setopt($ch, CURLOPT_POSTFIELDS, $payload); | |||
| //set the content type to application/json | |||
| curl_setopt($ch, CURLOPT_HTTPHEADER, array('Content-Type:application/json')); | |||
| //execute the POST request | |||
| $result = curl_exec($ch); | |||
| //close cURL resource | |||
| curl_close($ch); | |||
| return 'success'; | |||
| } | |||
| function vpsmanager_m_boot(array $params) | |||
| { | |||
| if (isset($_POST["abort"])) { | |||
| return "operacion cancelada por el usuario"; | |||
| } | |||
| $datos = $params['customfields']; | |||
| $gzid = $datos['gzid']; | |||
| $uuid = $datos['uuid']; | |||
| try { | |||
| $globalurl = \VPSManager\Config::url(\VPSManager\Config::load(), 'vpsmanager_api_url', false); | |||
| if (!function_exists('curl_init')) { | |||
| return 'Transporte del módulo no disponible.'; | |||
| } | |||
| } catch (\Throwable $e) { | |||
| return 'Configuración del módulo no disponible.'; | |||
| } | |||
| $url = $globalurl . '/command/boot'; | |||
| //create a new cURL resource | |||
| $ch = curl_init($url); | |||
| curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); | |||
| curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true); | |||
| curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2); | |||
| //setup request to send json via POST | |||
| $data = array( | |||
| 'gzid' => $gzid, | |||
| 'uuid' => $uuid | |||
| ); | |||
| $payload = json_encode($data); | |||
| //attach encoded JSON string to the POST fields | |||
| curl_setopt($ch, CURLOPT_POSTFIELDS, $payload); | |||
| //set the content type to application/json | |||
| curl_setopt($ch, CURLOPT_HTTPHEADER, array('Content-Type:application/json')); | |||
| //execute the POST request | |||
| $result = curl_exec($ch); | |||
| //close cURL resource | |||
| curl_close($ch); | |||
| return 'success'; | |||
| } | |||
| function vpsmanager_m_suspend(array $params) | |||
| { | |||
| $datos = $params['customfields']; | |||
| $gzid = $datos['gzid']; | |||
| $uuid = $datos['uuid']; | |||
| try { | |||
| $globalurl = \VPSManager\Config::url(\VPSManager\Config::load(), 'vpsmanager_api_url', false); | |||
| if (!function_exists('curl_init')) { | |||
| return 'Transporte del módulo no disponible.'; | |||
| } | |||
| } catch (\Throwable $e) { | |||
| return 'Configuración del módulo no disponible.'; | |||
| } | |||
| $url = $globalurl . '/suspend'; | |||
| //create a new cURL resource | |||
| $ch = curl_init($url); | |||
| curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); | |||
| curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true); | |||
| curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2); | |||
| //setup request to send json via POST | |||
| $data = array( | |||
| 'gzid' => $gzid, | |||
| 'uuid' => $uuid | |||
| ); | |||
| $payload = json_encode($data); | |||
| //attach encoded JSON string to the POST fields | |||
| curl_setopt($ch, CURLOPT_POSTFIELDS, $payload); | |||
| //set the content type to application/json | |||
| curl_setopt($ch, CURLOPT_HTTPHEADER, array('Content-Type:application/json')); | |||
| //execute the POST request | |||
| $result = curl_exec($ch); | |||
| //close cURL resource | |||
| curl_close($ch); | |||
| return 'success'; | |||
| } | |||
| function vpsmanager_m_unsuspend(array $params) | |||
| { | |||
| $datos = $params['customfields']; | |||
| $gzid = $datos['gzid']; | |||
| $uuid = $datos['uuid']; | |||
| try { | |||
| $globalurl = \VPSManager\Config::url(\VPSManager\Config::load(), 'vpsmanager_api_url', false); | |||
| if (!function_exists('curl_init')) { | |||
| return 'Transporte del módulo no disponible.'; | |||
| } | |||
| } catch (\Throwable $e) { | |||
| return 'Configuración del módulo no disponible.'; | |||
| } | |||
| $url = $globalurl . '/unsuspend'; | |||
| //create a new cURL resource | |||
| $ch = curl_init($url); | |||
| curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); | |||
| curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true); | |||
| curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2); | |||
| //setup request to send json via POST | |||
| $data = array( | |||
| 'gzid' => $gzid, | |||
| 'uuid' => $uuid | |||
| ); | |||
| $payload = json_encode($data); | |||
| //attach encoded JSON string to the POST fields | |||
| curl_setopt($ch, CURLOPT_POSTFIELDS, $payload); | |||
| //set the content type to application/json | |||
| curl_setopt($ch, CURLOPT_HTTPHEADER, array('Content-Type:application/json')); | |||
| //execute the POST request | |||
| $result = curl_exec($ch); | |||
| //close cURL resource | |||
| curl_close($ch); | |||
| return 'success'; | |||
| } | |||
| function vpsmanager_ClientArea($params) | |||
| { | |||
| // WHMCS resolves and authorizes this service before calling the module. | |||
| // Only a whitelisted range is read from the request, never uuid/vtype/id. | |||
| return \VPSManager\ClientView::render($params, $_GET['metrics_range'] ?? '24h'); | |||
| } | |||
| function vpsmanager_AdminServicesTabFields($params) | |||
| { | |||
| return ['' => \VPSManager\ClientView::links($params)]; | |||
| } | |||
| function vpsmanager_LoginLink($params) | |||
| { | |||
| return []; | |||
| } | |||