$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, " 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, ' 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', '">'] 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') !== 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'); // Graphite server-to-server Basic authentication. All credentials here are fictitious. $auth = $config + ['graphite_auth' => 'basic', 'graphite_username' => 'FAKE_GRAPHITE_USER', 'graphite_password' => 'FAKE_GRAPHITE_PASSWORD']; $GLOBALS['response'] = ['status' => 200, 'body' => json_encode($raw)]; $secured = new Graphite($auth); $secured->getForService($params); $call = end($GLOBALS['calls']); check($call->options[CURLOPT_HTTPAUTH] === CURLAUTH_BASIC, 'explicit Basic authentication'); check($call->options[CURLOPT_USERNAME] === $auth['graphite_username'], 'server-side username'); check($call->options[CURLOPT_PASSWORD] === $auth['graphite_password'], 'server-side password'); check(strpos($call->url, 'FAKE_GRAPHITE') === false, 'credentials absent from URL'); check($call->options[CURLOPT_FOLLOWLOCATION] === false, 'credentials cannot follow redirects'); check($call->options[CURLOPT_SSL_VERIFYPEER] === true, 'authenticated TLS verified'); writeConfig($temp, $auth); foreach ([200, 401, 403, 302] as $status) { $GLOBALS['response']['status'] = $status; $html = vpsmanager_ClientArea($params); check(strpos($html, 'FAKE_GRAPHITE') === false, 'authentication absent from frontend'); check(strpos($html, base64_encode($auth['graphite_username'] . ':' . $auth['graphite_password'])) === false, 'no Basic header in HTML'); if ($status !== 200) { check(strpos($html, 'Las métricas no están disponibles temporalmente.') !== false, 'auth/redirect failures are discreet'); } } foreach ([['graphite_username' => ''], ['graphite_password' => ''], ['graphite_auth' => 'unknown'], ['graphite_auth' => 'none'], ['graphite_username' => 'user:name'], ['graphite_password' => "FAKE_BAD\r\nvalue"], ['graphite_password' => []], ['graphite_url' => 'http://metrics.example.invalid']] as $override) { $before = count($GLOBALS['calls']); rejects(fn() => new Graphite(array_replace($auth, $override)), 'invalid auth configuration rejected'); check(count($GLOBALS['calls']) === $before, 'invalid auth sends no request'); } writeConfig($temp, $config); $GLOBALS['response'] = ['status' => 200, 'body' => json_encode($raw)]; (new Graphite($config))->getForService($params); check(!isset(end($GLOBALS['calls'])->options[CURLOPT_HTTPAUTH]), 'legacy configuration remains supported'); echo "PASS: $count assertions; mocked HTTP only.\n"; } finally { unlink($temp); putenv('O6H_VPSMANAGER_CONFIG'); }