Módulo VPSManager para WHMCS con configuración externa, acceso seguro al panel y métricas Graphite para LX.
Вы не можете выбрать более 25 тем Темы должны начинаться с буквы или цифры, могут содержать дефисы(-) и должны содержать не более 35 символов.
 
 
 
 
 
 

262 строки
17 KiB

  1. <?php
  2. // Intentionally run with php -n: cURL is mocked and no request leaves the process.
  3. if (extension_loaded('curl')) {
  4. fwrite(STDERR, "Run with php -n (without the cURL extension).\n");
  5. exit(1);
  6. }
  7. foreach (['CURLOPT_RETURNTRANSFER', 'CURLOPT_CONNECTTIMEOUT', 'CURLOPT_TIMEOUT', 'CURLOPT_SSL_VERIFYPEER',
  8. 'CURLOPT_SSL_VERIFYHOST', 'CURLOPT_POSTFIELDS', 'CURLOPT_HTTPHEADER', 'CURLOPT_FOLLOWLOCATION',
  9. 'CURLOPT_PROTOCOLS', 'CURLPROTO_HTTPS', 'CURLOPT_WRITEFUNCTION', 'CURLINFO_HTTP_CODE', 'CURLOPT_HTTPAUTH', 'CURLAUTH_BASIC', 'CURLOPT_USERNAME', 'CURLOPT_PASSWORD'] as $i => $name) {
  10. define($name, $i + 1);
  11. }
  12. $GLOBALS['calls'] = [];
  13. $GLOBALS['response'] = ['status' => 200, 'body' => '{}', 'timeout' => false];
  14. function curl_init($url) { return (object) ['url' => $url, 'options' => []]; }
  15. function curl_setopt($ch, $option, $value) { $ch->options[$option] = $value; return true; }
  16. function curl_setopt_array($ch, $options) { foreach ($options as $k => $v) curl_setopt($ch, $k, $v); return true; }
  17. function curl_exec($ch) {
  18. $GLOBALS['calls'][] = $ch;
  19. $response = $GLOBALS['response'];
  20. if ($response['timeout'] ?? false) return false;
  21. if (isset($ch->options[CURLOPT_WRITEFUNCTION])) {
  22. $body = $response['body'];
  23. return $ch->options[CURLOPT_WRITEFUNCTION]($ch, $body) === strlen($body);
  24. }
  25. if (!($ch->options[CURLOPT_RETURNTRANSFER] ?? false)) echo $response['body'];
  26. return $response['body'];
  27. }
  28. function curl_getinfo($ch, $option) { return $GLOBALS['response']['status']; }
  29. function curl_close($ch) {}
  30. function logModuleCall(...$args) { throw new RuntimeException('Unexpected potentially sensitive log call'); }
  31. set_error_handler(static function ($severity, $message, $file, $line) { throw new ErrorException($message, 0, $severity, $file, $line); });
  32. define('WHMCS', true);
  33. require_once __DIR__ . '/../vpsmanager.php';
  34. use VPSManager\Config;
  35. use VPSManager\Graphite;
  36. use VPSManager\ClientView;
  37. $count = 0;
  38. function check($condition, $message) {
  39. global $count;
  40. if (!$condition) throw new RuntimeException('FAIL: ' . $message);
  41. $count++;
  42. }
  43. function rejects(callable $fn, $message) {
  44. try { $fn(); } catch (Throwable $e) { check(true, $message); return; }
  45. check(false, $message);
  46. }
  47. function fixture($uuid) {
  48. $values = [
  49. 'load.load.shortterm' => 18.14, 'load.load.midterm' => 20.03, 'load.load.longterm' => 20.51,
  50. 'memory.memory-used' => 4365361152, 'memory.memory-free' => 4224573440,
  51. 'interface-eth0.if_octets.rx' => 125000, 'interface-eth0.if_octets.tx' => 250000,
  52. ];
  53. $data = [];
  54. foreach ($values as $suffix => $value) {
  55. $data[] = ['target' => "lx.$uuid.$suffix", 'datapoints' => [[$value, 1700000000], [null, 1700000060], [$value, 1700000420]]];
  56. }
  57. return $data;
  58. }
  59. $uuid = '00000000-0000-4000-8000-000000000001';
  60. $other = '00000000-0000-4000-8000-000000000002';
  61. $params = ['serviceid' => 42, 'domain' => 'vm.example.invalid', 'customfields' => [
  62. 'vtype' => 'lx', 'uuid' => $uuid, 'gzid' => 'fixture-node', 'url' => 'panel.example.invalid',
  63. 'username' => 'FAKE_PANEL_USER', 'password' => 'FAKE_PANEL_PASSWORD_SENTINEL',
  64. ], 'serverpassword' => 'FAKE_SERVER_PASSWORD_SENTINEL'];
  65. $config = ['graphite_url' => 'https://metrics.example.invalid', 'vpsmanager_api_url' => 'https://api.example.invalid',
  66. 'graphs_url' => 'https://graphs.example.invalid', 'graphs_authorization_confirmed' => true];
  67. $temp = tempnam(sys_get_temp_dir(), 'vpsmanager-test-');
  68. putenv('O6H_VPSMANAGER_CONFIG=' . $temp);
  69. function writeConfig($path, $data) { file_put_contents($path, "<?php\nreturn " . var_export($data, true) . ";\n"); }
  70. try {
  71. writeConfig($temp, $config);
  72. check(Config::load() === $config, 'valid external configuration');
  73. check(Config::url($config, 'graphite_url') === $config['graphite_url'], 'valid URL');
  74. 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) {
  75. rejects(fn() => Config::url(['url' => $url], 'url'), 'unsafe URL');
  76. }
  77. rejects(fn() => Config::url([], 'graphite_url'), 'missing mandatory value');
  78. putenv('O6H_VPSMANAGER_CONFIG=' . $temp . '.missing');
  79. rejects(fn() => Config::load(), 'missing file');
  80. check(vpsmanager_m_suspend($params) === 'Configuración del módulo no disponible.', 'missing config handled in suspension');
  81. putenv('O6H_VPSMANAGER_CONFIG=' . $temp);
  82. file_put_contents($temp, '<?php return [;');
  83. rejects(fn() => Config::load(), 'syntax error in private file is controlled');
  84. writeConfig($temp, false);
  85. rejects(fn() => Config::load(), 'wrong configuration type');
  86. writeConfig($temp, $config);
  87. check(Graphite::validUuid($uuid), 'UUID accepted');
  88. foreach (['*', 'foo.*', '../../', '{a,b}', 'x)&target=*', $uuid . "\n", [], null] as $bad) {
  89. check(!Graphite::validUuid($bad), 'invalid UUID rejected');
  90. }
  91. foreach (array_keys(Graphite::RANGES) as $range) check(Graphite::range($range) === $range, 'allowed range');
  92. foreach (['-1y', 'now', '1h&target=*', '', [], null, 24] as $bad) check(Graphite::range($bad) === '24h', 'fallback range');
  93. $raw = fixture($uuid);
  94. $GLOBALS['response'] = ['status' => 200, 'body' => json_encode($raw)];
  95. $graphite = new Graphite($config);
  96. $before = count($GLOBALS['calls']);
  97. $metrics = $graphite->getForService($params, '6h');
  98. check(count($GLOBALS['calls']) === $before + 1, 'one request for all seven series');
  99. $request = end($GLOBALS['calls']);
  100. check(substr_count($request->url, '&target=') === 7, 'seven repeated target parameters');
  101. check(strpos($request->url, 'from=-6h') !== false, 'range mapped internally');
  102. check(strpos($request->url, 'derivative') === false, 'no derivatives');
  103. check($request->options[CURLOPT_SSL_VERIFYPEER] === true && $request->options[CURLOPT_SSL_VERIFYHOST] === 2, 'TLS verified');
  104. check($request->options[CURLOPT_FOLLOWLOCATION] === false && $request->options[CURLOPT_PROTOCOLS] === CURLPROTO_HTTPS, 'HTTPS without redirects');
  105. check($request->options[CURLOPT_CONNECTTIMEOUT] === 3 && $request->options[CURLOPT_TIMEOUT] === 5, 'bounded timeout');
  106. check(array_keys($metrics['load']) === ['1 min', '5 min', '15 min'], 'load labels');
  107. check($metrics['load']['1 min'][0]['y'] === 18.14, 'load not normalized');
  108. check($metrics['load']['1 min'][1]['y'] === null, 'null preserved');
  109. check($metrics['load']['1 min'][2]['x'] - $metrics['load']['1 min'][1]['x'] === 360000, 'arbitrary timestamps');
  110. check($metrics['memory']['Total'][0]['y'] === 8589934592, 'memory total');
  111. check($metrics['memory']['Total'][0]['y'] / (1024 ** 3) === 8, 'memory GiB');
  112. check(abs($metrics['memoryPercent'][0]['y'] - 50.81949234008789) < 0.00001, 'memory percentage');
  113. check($metrics['memory']['Total'][1]['y'] === null, 'memory null not zero');
  114. check($metrics['network']['RX'][0]['y'] === 125000, 'network remains bytes/s in PHP');
  115. foreach (['kvm', 'bhyve', 'joyent', 'openvz', 'dedicado', '*', []] as $type) {
  116. $bad = $params; $bad['customfields']['vtype'] = $type;
  117. rejects(fn() => $graphite->getForService($bad), 'unsupported vtype');
  118. }
  119. $bad = $params; $bad['customfields']['uuid'] = '*';
  120. rejects(fn() => $graphite->getForService($bad), 'arbitrary target rejected');
  121. foreach ([['status' => 500, 'body' => 'PRIVATE_RESPONSE'], ['status' => 200, 'body' => '{'], ['status' => 200, 'body' => '{}'],
  122. ['status' => 200, 'body' => '', 'timeout' => true], ['status' => 200, 'body' => str_repeat('x', 2097153)]] as $error) {
  123. $GLOBALS['response'] = $error;
  124. rejects(fn() => $graphite->getForService($params), 'Graphite failure');
  125. $html = vpsmanager_ClientArea($params);
  126. check(strpos($html, 'Las métricas no están disponibles temporalmente.') !== false, 'graceful failure');
  127. check(strpos($html, 'Acceder al Panel') !== false, 'panel survives metrics failure');
  128. check(strpos($html, 'PRIVATE_RESPONSE') === false, 'response not exposed');
  129. }
  130. $GLOBALS['response'] = ['status' => 200, 'body' => '[]'];
  131. check($graphite->getForService($params)['load']['1 min'] === [], 'missing series');
  132. $partial = [$raw[0], $raw[3]];
  133. $GLOBALS['response']['body'] = json_encode($partial);
  134. $m = $graphite->getForService($params);
  135. check($m['load']['1 min'][0]['y'] === 18.14 && $m['load']['5 min'] === [], 'partially available series');
  136. check($m['memory']['Total'][0]['y'] === null, 'missing free not treated as zero');
  137. $shifted = $raw;
  138. $shifted[4]['datapoints'][0][1] += 1;
  139. $GLOBALS['response']['body'] = json_encode($shifted);
  140. check($graphite->getForService($params)['memory']['Total'][0]['y'] === null, 'memory aligned by timestamp');
  141. $zero = $raw;
  142. $zero[3]['datapoints'][0][0] = 0; $zero[4]['datapoints'][0][0] = 0;
  143. $GLOBALS['response']['body'] = json_encode($zero);
  144. check($graphite->getForService($params)['memoryPercent'][0]['y'] === null, 'zero total safe');
  145. foreach ([[['bad', 1700000000]], [[1, '1700000000']], [[-1, 1700000000]], [[1, 1700000000], [2, 1700000000]]] as $points) {
  146. $broken = $raw; $broken[0]['datapoints'] = $points;
  147. $GLOBALS['response']['body'] = json_encode($broken);
  148. rejects(fn() => $graphite->getForService($params), 'invalid datapoints rejected');
  149. }
  150. $unexpected = $raw;
  151. $unexpected[] = ['target' => 'lx.' . $other . '.load.load.shortterm', 'datapoints' => [[999, 1700000000]]];
  152. $GLOBALS['response']['body'] = json_encode($unexpected);
  153. check(strpos(json_encode($graphite->getForService($params)), $other) === false, 'unrequested target dropped');
  154. // Authorization boundary regression: only WHMCS service params select the resource.
  155. $GLOBALS['response'] = ['status' => 200, 'body' => json_encode($raw)];
  156. $_GET = ['uuid' => $other, 'vtype' => 'kvm', 'id' => 99, 'target' => '*', 'from' => '-1y', 'metrics_range' => '1h&target=*'];
  157. $_POST = $_GET;
  158. $html = vpsmanager_ClientArea($params);
  159. $request = end($GLOBALS['calls']);
  160. check(strpos($request->url, $uuid) !== false && strpos($request->url, $other) === false, 'GET and POST cannot select another UUID');
  161. check(strpos($request->url, 'from=-24h') !== false, 'malicious range falls back');
  162. check(strpos($html, 'id=42') !== false && strpos($html, 'id=99') === false, 'range links use authorized service id');
  163. foreach (['FAKE_PANEL_USER', 'FAKE_PANEL_PASSWORD_SENTINEL', 'FAKE_SERVER_PASSWORD_SENTINEL', 'metrics.example.invalid', 'load.load.shortterm'] as $secret) {
  164. check(strpos($html, $secret) === false, 'frontend receives only presentation data');
  165. check(strpos(implode('', vpsmanager_AdminServicesTabFields($params)), $secret) === false, 'admin panel no credentials');
  166. }
  167. check(strpos($html, 'https://panel.example.invalid:8080/login/index.php') !== false, 'manual panel login');
  168. foreach (['javascript:alert(1)', 'https://user:pass@panel.example.invalid', 'https://panel.example.invalid/?password=test', '"><script>alert(1)</script>'] as $url) {
  169. $bad = $params; $bad['customfields']['url'] = $url;
  170. $links = ClientView::links($bad, []);
  171. check(strpos($links, 'href=') === false, 'unsafe panel link disabled');
  172. }
  173. $bad = $params; $bad['customfields']['vtype'] = 'kvm';
  174. $before = count($GLOBALS['calls']);
  175. check(strpos(vpsmanager_ClientArea($bad), 'data-metrics') === false && count($GLOBALS['calls']) === $before, 'other types do not query Graphite');
  176. check(strpos(ClientView::links($params, []), 'Graphs</button>') !== false, 'Graphs retained when unconfigured');
  177. check(strpos(ClientView::links($params, $config), '/collect/servidor/lx/' . $uuid) !== false, 'configured full Graphs link retained');
  178. // Existing operations: routes, payloads, cancellation, buttons and wrapper delegation.
  179. $_GET = []; $_POST = [];
  180. $GLOBALS['response'] = ['status' => 200, 'body' => '{"size":"10 GiB","date":"2026-01-01"}'];
  181. foreach (['vpsmanager_m_reboot' => '/command/reboot', 'vpsmanager_m_shutdown' => '/command/shutdown',
  182. 'vpsmanager_m_boot' => '/command/boot', 'vpsmanager_m_snapshots' => '/restoresnap',
  183. 'vpsmanager_SuspendAccount' => '/suspend', 'vpsmanager_UnsuspendAccount' => '/unsuspend'] as $fn => $route) {
  184. ob_start(); $result = $fn($params); $output = ob_get_clean();
  185. $call = end($GLOBALS['calls']);
  186. check($result === 'success', $fn . ' return value');
  187. check($call->url === $config['vpsmanager_api_url'] . $route, $fn . ' original route');
  188. check(json_decode($call->options[CURLOPT_POSTFIELDS], true) === ['gzid' => 'fixture-node', 'uuid' => $uuid], $fn . ' original payload');
  189. check($output === '', $fn . ' raw response not sent to client');
  190. }
  191. foreach (['Desbloquear' => '/unblock', 'Bloquear' => '/block'] as $action => $route) {
  192. $_POST = ['proceed' => $action, 'ip' => '192.0.2.1'];
  193. check(vpsmanager_m_firewall($params) === 'success', 'firewall result');
  194. $call = end($GLOBALS['calls']);
  195. check($call->url === $config['vpsmanager_api_url'] . $route, 'firewall route');
  196. check(json_decode($call->options[CURLOPT_POSTFIELDS], true)['ip'] === '192.0.2.1', 'firewall IP payload');
  197. }
  198. $_POST = ['proceed' => 'invalid', 'ip' => '192.0.2.1'];
  199. check(vpsmanager_m_firewall($params) === 'Acción de firewall no válida.', 'firewall invalid action');
  200. $_POST = ['proceed' => 'Bloquear', 'ip' => '*'];
  201. check(vpsmanager_m_firewall($params) === 'Dirección IP no válida.', 'firewall invalid IP');
  202. $_POST = [];
  203. $snapshots = vpsmanager_Snapshots($params);
  204. check($snapshots['vars']['size'] === '10 GiB' && $snapshots['vars']['date'] === '2026-01-01', 'snapshot data retained');
  205. check(end($GLOBALS['calls'])->url === $config['vpsmanager_api_url'] . '/getsnap', 'snapshot list route');
  206. foreach (['Reiniciar', 'Detener', 'Iniciar', 'Firewall', 'Snapshots'] as $action) {
  207. $fn = 'vpsmanager_' . $action;
  208. $_POST = [];
  209. check(is_file(__DIR__ . '/../' . $fn($params)['templatefile'] . '.tpl'), 'action template exists');
  210. $_POST = ['a' => $action, 'abort' => 'No'];
  211. $before = count($GLOBALS['calls']);
  212. check(is_string($fn($params)) && count($GLOBALS['calls']) === $before, 'cancellation does not call API');
  213. }
  214. check(vpsmanager_ClientAreaCustomButtonArray() === ['Reiniciar' => 'Reiniciar', 'Detener' => 'Detener', 'Iniciar' => 'Iniciar', 'Firewall' => 'Firewall', 'Snapshots' => 'Snapshots'], 'buttons unchanged');
  215. check(vpsmanager_CreateAccount($params) === 'success', 'legacy CreateAccount preserved');
  216. $GLOBALS['response'] = ['status' => 200, 'body' => '["fixture-choice"]'];
  217. check(vpsmanager_ConfigOptions($params)['Nodo']['Options'] === ['fixture-choice'], 'nodes configuration');
  218. $GLOBALS['response'] = ['status' => 500, 'body' => 'PRIVATE_RESPONSE'];
  219. check(vpsmanager_ConfigOptions($params)['Pack']['Options'] === [], 'configuration failure handled');
  220. // Graphite server-to-server Basic authentication. All credentials here are fictitious.
  221. $auth = $config + ['graphite_auth' => 'basic', 'graphite_username' => 'FAKE_GRAPHITE_USER',
  222. 'graphite_password' => 'FAKE_GRAPHITE_PASSWORD'];
  223. $GLOBALS['response'] = ['status' => 200, 'body' => json_encode($raw)];
  224. $secured = new Graphite($auth);
  225. $secured->getForService($params);
  226. $call = end($GLOBALS['calls']);
  227. check($call->options[CURLOPT_HTTPAUTH] === CURLAUTH_BASIC, 'explicit Basic authentication');
  228. check($call->options[CURLOPT_USERNAME] === $auth['graphite_username'], 'server-side username');
  229. check($call->options[CURLOPT_PASSWORD] === $auth['graphite_password'], 'server-side password');
  230. check(strpos($call->url, 'FAKE_GRAPHITE') === false, 'credentials absent from URL');
  231. check($call->options[CURLOPT_FOLLOWLOCATION] === false, 'credentials cannot follow redirects');
  232. check($call->options[CURLOPT_SSL_VERIFYPEER] === true, 'authenticated TLS verified');
  233. writeConfig($temp, $auth);
  234. foreach ([200, 401, 403, 302] as $status) {
  235. $GLOBALS['response']['status'] = $status;
  236. $html = vpsmanager_ClientArea($params);
  237. check(strpos($html, 'FAKE_GRAPHITE') === false, 'authentication absent from frontend');
  238. check(strpos($html, base64_encode($auth['graphite_username'] . ':' . $auth['graphite_password'])) === false, 'no Basic header in HTML');
  239. if ($status !== 200) {
  240. check(strpos($html, 'Las métricas no están disponibles temporalmente.') !== false, 'auth/redirect failures are discreet');
  241. }
  242. }
  243. foreach ([['graphite_username' => ''], ['graphite_password' => ''], ['graphite_auth' => 'unknown'],
  244. ['graphite_auth' => 'none'], ['graphite_username' => 'user:name'], ['graphite_password' => "FAKE_BAD\r\nvalue"],
  245. ['graphite_password' => []], ['graphite_url' => 'http://metrics.example.invalid']] as $override) {
  246. $before = count($GLOBALS['calls']);
  247. rejects(fn() => new Graphite(array_replace($auth, $override)), 'invalid auth configuration rejected');
  248. check(count($GLOBALS['calls']) === $before, 'invalid auth sends no request');
  249. }
  250. writeConfig($temp, $config);
  251. $GLOBALS['response'] = ['status' => 200, 'body' => json_encode($raw)];
  252. (new Graphite($config))->getForService($params);
  253. check(!isset(end($GLOBALS['calls'])->options[CURLOPT_HTTPAUTH]), 'legacy configuration remains supported');
  254. echo "PASS: $count assertions; mocked HTTP only.\n";
  255. } finally {
  256. unlink($temp);
  257. putenv('O6H_VPSMANAGER_CONFIG');
  258. }