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