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