Parcourir la source

Initial public release of WHMCS VPSManager module

main
psarria il y a 17 heures
révision
e08c929837
26 fichiers modifiés avec 1920 ajouts et 0 suppressions
  1. +15
    -0
      .gitignore
  2. +78
    -0
      AUDIT.md
  3. +182
    -0
      README.md
  4. +41
    -0
      WhmcsHelpers/CustomField.php
  5. +9
    -0
      assets/Chart.js.LICENSE.md
  6. +6
    -0
      assets/THIRD-PARTY.md
  7. +22
    -0
      assets/chart-4.4.8.scoped.js
  8. +14
    -0
      assets/chart-4.4.8.umd.js
  9. +8
    -0
      assets/metrics.css
  10. +92
    -0
      assets/metrics.js
  11. +2
    -0
      assets/vendor-sha256.txt
  12. +7
    -0
      config.example.php
  13. +66
    -0
      lib/ClientView.php
  14. +43
    -0
      lib/Config.php
  15. +155
    -0
      lib/Graphite.php
  16. +45
    -0
      lib/VPSManager.php
  17. +10
    -0
      templates/alert.tpl
  18. +29
    -0
      templates/firewall.tpl
  19. +13
    -0
      templates/links.php
  20. +29
    -0
      templates/metrics.php
  21. +34
    -0
      templates/snapshots.tpl
  22. +68
    -0
      tests/frontend.cjs
  23. +227
    -0
      tests/run.php
  24. +103
    -0
      tools/audit.py
  25. +8
    -0
      tools/scope-chart.cjs
  26. +614
    -0
      vpsmanager.php

+ 15
- 0
.gitignore Voir le fichier

@@ -0,0 +1,15 @@
config.php
.env
.env.*
*.local.php
*.log
*.sql
*.sql.gz
*.zip
*.tar
*.tar.gz
*.bak
*.swp
*.swo
*~
.DS_Store

+ 78
- 0
AUDIT.md Voir le fichier

@@ -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.

+ 182
- 0
README.md Voir le fichier

@@ -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)

+ 41
- 0
WhmcsHelpers/CustomField.php Voir le fichier

@@ -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

+ 9
- 0
assets/Chart.js.LICENSE.md Voir le fichier

@@ -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.

+ 6
- 0
assets/THIRD-PARTY.md Voir le fichier

@@ -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.

+ 22
- 0
assets/chart-4.4.8.scoped.js
Fichier diff supprimé car celui-ci est trop grand
Voir le fichier


+ 14
- 0
assets/chart-4.4.8.umd.js
Fichier diff supprimé car celui-ci est trop grand
Voir le fichier


+ 8
- 0
assets/metrics.css Voir le fichier

@@ -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%; }

+ 92
- 0
assets/metrics.js Voir le fichier

@@ -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();
}());

+ 2
- 0
assets/vendor-sha256.txt Voir le fichier

@@ -0,0 +1,2 @@
e4cf4d144b222634f2e64ff707cd57b953f8de4b65b231cd5ac6bde114648e4d chart-4.4.8.umd.js
b0872c3ee90b60c32f34616a8c1aa092772d66fe444cc3d1b768fd8ebdfc6a7a chart-4.4.8.scoped.js

+ 7
- 0
config.example.php Voir le fichier

@@ -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.
];

+ 66
- 0
lib/ClientView.php Voir le fichier

@@ -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();
}
}

+ 43
- 0
lib/Config.php Voir le fichier

@@ -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, '/');
}
}

+ 155
- 0
lib/Graphite.php Voir le fichier

@@ -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);
}
}
}

+ 45
- 0
lib/VPSManager.php Voir le fichier

@@ -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;
}
}

+ 10
- 0
templates/alert.tpl Voir le fichier

@@ -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>

+ 29
- 0
templates/firewall.tpl Voir le fichier

@@ -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>

+ 13
- 0
templates/links.php Voir le fichier

@@ -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>

+ 29
- 0
templates/metrics.php Voir le fichier

@@ -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&amp;id=<?= (int) $serviceId ?>&amp;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; ?>

+ 34
- 0
templates/snapshots.tpl Voir le fichier

@@ -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&ntilde;o</td>
<td class='snapshot'>Acci&oacute;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>&nbsp;
<p><b>Nota:</b> Los tiempos de restauraci&oacute;n varian en funci&oacute;n del tama&ntilde;o y tipo de snapshot. Los snapshots locales son r&aacute;pidos. Los snapshots remotos son mas sensibles a la congesti&oacute;n de la red por lo que esto es un factor que ser&aacute; determinante. Como referencia, un snapshot remoto de 100GB puede tardar en restaurarse 1h aproximadamente.

+ 68
- 0
tests/frontend.cjs Voir le fichier

@@ -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.`);

+ 227
- 0
tests/run.php Voir le fichier

@@ -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');
}

+ 103
- 0
tools/audit.py Voir le fichier

@@ -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.')

+ 8
- 0
tools/scope-chart.cjs Voir le fichier

@@ -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);

+ 614
- 0
vpsmanager.php Voir le fichier

@@ -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 [];
}

Chargement…
Annuler
Enregistrer