From 291ce9f0c7baad657f8552af0caa3ee9e623311c Mon Sep 17 00:00:00 2001 From: Lars Gebhardt-Kusche Date: Thu, 19 Mar 2026 00:13:30 +0100 Subject: [PATCH] nexus... --- modules/pihole/assets/pihole.js | 7 + modules/pihole/pages/api.php | 484 +++++++++++++++++++++++------ modules/pihole/pages/instances.php | 2 +- 3 files changed, 403 insertions(+), 90 deletions(-) diff --git a/modules/pihole/assets/pihole.js b/modules/pihole/assets/pihole.js index 804483e..5f8eb56 100644 --- a/modules/pihole/assets/pihole.js +++ b/modules/pihole/assets/pihole.js @@ -21,6 +21,13 @@ if (!res.ok) { throw new Error(data.error || `HTTP ${res.status}`); } + if (data && data.results && typeof data.results === 'object') { + const failures = Object.values(data.results).filter((row) => row && row.ok === false); + if (failures.length) { + const first = failures[0]; + throw new Error(first.error || 'action_failed'); + } + } return data; }; diff --git a/modules/pihole/pages/api.php b/modules/pihole/pages/api.php index d54f8e9..900a74c 100644 --- a/modules/pihole/pages/api.php +++ b/modules/pihole/pages/api.php @@ -33,7 +33,64 @@ $normalizeApiPath = function (string $baseUrl, string $apiPath): string { return $base . $path; }; -$apiRequest = function (array $instance, array $params) use ($normalizeApiPath): array { +$httpRequest = function (string $method, string $url, array $headers, ?string $body, bool $verify, int $timeout): array { + $raw = ''; + $httpCode = 0; + $error = ''; + + if (function_exists('curl_init')) { + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => $timeout, + CURLOPT_CONNECTTIMEOUT => $timeout, + CURLOPT_SSL_VERIFYPEER => $verify, + CURLOPT_SSL_VERIFYHOST => $verify ? 2 : 0, + CURLOPT_CUSTOMREQUEST => $method, + CURLOPT_HTTPHEADER => $headers, + ]); + if ($body !== null) { + curl_setopt($ch, CURLOPT_POSTFIELDS, $body); + } + $raw = (string)curl_exec($ch); + if ($raw === '' && curl_errno($ch)) { + $error = curl_error($ch); + } + $httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + } else { + $ctx = stream_context_create([ + 'http' => [ + 'method' => $method, + 'timeout' => $timeout, + 'header' => implode("\r\n", $headers), + 'content' => $body ?? '', + ], + 'ssl' => [ + 'verify_peer' => $verify, + 'verify_peer_name' => $verify, + ], + ]); + $raw = (string)@file_get_contents($url, false, $ctx); + $httpCode = 200; + if ($raw === '') { + $error = 'HTTP request failed'; + } + } + + if ($error !== '') { + return ['ok' => false, 'error' => $error, 'http_code' => $httpCode, 'url' => $url]; + } + + $data = json_decode($raw, true); + if (!is_array($data)) { + return ['ok' => false, 'error' => 'invalid_json', 'http_code' => $httpCode, 'raw' => $raw, 'url' => $url]; + } + + return ['ok' => true, 'data' => $data, 'http_code' => $httpCode, 'url' => $url]; +}; + +$v5Request = function (array $instance, array $params) use ($normalizeApiPath, $httpRequest): array { if (!empty($instance['token']) && !isset($params['auth'])) { $params['auth'] = $instance['token']; } @@ -48,53 +105,78 @@ $apiRequest = function (array $instance, array $params) use ($normalizeApiPath): } $verify = !empty($instance['verify_tls']); - $raw = ''; - $httpCode = 0; - $error = ''; + return $httpRequest('GET', $full, ['Accept: application/json'], null, $verify, $timeout); +}; - if (function_exists('curl_init')) { - $ch = curl_init($full); - curl_setopt_array($ch, [ - CURLOPT_RETURNTRANSFER => true, - CURLOPT_TIMEOUT => $timeout, - CURLOPT_CONNECTTIMEOUT => $timeout, - CURLOPT_SSL_VERIFYPEER => $verify, - CURLOPT_SSL_VERIFYHOST => $verify ? 2 : 0, - ]); - $raw = (string)curl_exec($ch); - if ($raw === '' && curl_errno($ch)) { - $error = curl_error($ch); +$v6Auth = function (array $instance) use ($httpRequest): array { + $base = rtrim((string)$instance['url'], '/'); + $url = $base . '/api/auth'; + $timeout = (int)($instance['timeout'] ?? 8); + if ($timeout <= 0) { + $timeout = 8; + } + $verify = !empty($instance['verify_tls']); + $payload = ['password' => (string)($instance['token'] ?? '')]; + $body = json_encode($payload, JSON_UNESCAPED_UNICODE); + $res = $httpRequest('POST', $url, ['Accept: application/json', 'Content-Type: application/json'], $body, $verify, $timeout); + if (!$res['ok']) { + return $res; + } + $data = (array)($res['data'] ?? []); + $session = (array)($data['session'] ?? []); + $sid = (string)($session['sid'] ?? ''); + $res['sid'] = $sid; + return $res; +}; + +$v6Request = function (array $instance, string $path, string $method, array $payload, string $sid) use ($httpRequest): array { + $base = rtrim((string)$instance['url'], '/'); + $path = ltrim($path, '/'); + $url = $base . '/api/' . $path; + $timeout = (int)($instance['timeout'] ?? 8); + if ($timeout <= 0) { + $timeout = 8; + } + $verify = !empty($instance['verify_tls']); + $headers = ['Accept: application/json']; + if ($sid !== '') { + $headers[] = 'sid: ' . $sid; + $headers[] = 'X-FTL-SID: ' . $sid; + } + + $body = null; + if ($method !== 'GET') { + if ($sid !== '' && !isset($payload['sid'])) { + $payload['sid'] = $sid; } - $httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); - curl_close($ch); - } else { - $ctx = stream_context_create([ - 'http' => [ - 'method' => 'GET', - 'timeout' => $timeout, - ], - 'ssl' => [ - 'verify_peer' => $verify, - 'verify_peer_name' => $verify, - ], - ]); - $raw = (string)@file_get_contents($full, false, $ctx); - $httpCode = 200; - if ($raw === '') { - $error = 'HTTP request failed'; + $body = json_encode($payload, JSON_UNESCAPED_UNICODE); + $headers[] = 'Content-Type: application/json'; + } + + return $httpRequest($method, $url, $headers, $body, $verify, $timeout); +}; + +$detectApi = function (array $instance) use ($v6Auth, $v6Request, $v5Request): array { + $sid = ''; + $authRes = null; + if (!empty($instance['token'])) { + $authRes = $v6Auth($instance); + if (($authRes['ok'] ?? false) && !empty($authRes['sid'])) { + $sid = (string)$authRes['sid']; } } - if ($error !== '') { - return ['ok' => false, 'error' => $error, 'http_code' => $httpCode, 'url' => $full]; + $probe = $v6Request($instance, 'stats/summary', 'GET', [], $sid); + if ($probe['ok'] || in_array((int)($probe['http_code'] ?? 0), [401, 403], true)) { + return ['version' => 6, 'sid' => $sid, 'probe' => $probe, 'auth' => $authRes]; } - $data = json_decode($raw, true); - if (!is_array($data)) { - return ['ok' => false, 'error' => 'invalid_json', 'http_code' => $httpCode, 'raw' => $raw, 'url' => $full]; + $legacy = $v5Request($instance, ['summaryRaw' => 1]); + if ($legacy['ok']) { + return ['version' => 5, 'sid' => '', 'probe' => $legacy]; } - return ['ok' => true, 'data' => $data, 'http_code' => $httpCode, 'url' => $full]; + return ['version' => 0, 'sid' => '', 'probe' => $probe, 'legacy' => $legacy]; }; $resolvePrimaryId = function () use ($instances): ?string { @@ -229,53 +311,187 @@ if ($action === 'dashboard') { ]; }; + $now = time(); + $from = $now - 3600; + foreach ($instances as $id => $instance) { $errors = []; + $summaryData = null; + $topData = null; + $queryTypesData = null; + $querySourcesData = null; + $forwardDestData = null; + $recentData = null; + $updates = ['available' => false, 'details' => []]; + $versionsData = null; - $summary = $apiRequest($instance, ['summaryRaw' => 1]); - if (!$summary['ok']) { - $errors[] = $makeError('summary', $summary); + $apiInfo = $detectApi($instance); + if ($apiInfo['version'] === 6) { + $sid = (string)($apiInfo['sid'] ?? ''); + $summary = $apiInfo['probe']; + if (!$summary['ok']) { + $errors[] = $makeError('summary', $summary); + } + + $blocking = $v6Request($instance, 'dns/blocking', 'GET', [], $sid); + if (!$blocking['ok']) { + $errors[] = $makeError('blocking', $blocking); + } + + $queries = $v6Request($instance, 'queries?from=' . $from . '&until=' . $now . '&length=2000', 'GET', [], $sid); + if (!$queries['ok']) { + $errors[] = $makeError('queries', $queries); + } + + $upstreams = $v6Request($instance, 'stats/upstreams', 'GET', [], $sid); + if (!$upstreams['ok']) { + $errors[] = $makeError('upstreams', $upstreams); + } + + if ($summary['ok'] && is_array($summary['data'])) { + $sum = $summary['data']; + $queriesBlock = (array)($sum['queries'] ?? []); + $clientsBlock = (array)($sum['clients'] ?? []); + $status = 'unknown'; + if ($blocking['ok'] && is_array($blocking['data'])) { + $blockingState = $blocking['data']['blocking'] ?? null; + if ($blockingState === true || $blockingState === 'enabled') { + $status = 'enabled'; + } elseif ($blockingState === false || $blockingState === 'disabled') { + $status = 'disabled'; + } + } + + $summaryData = [ + 'dns_queries_today' => (int)($queriesBlock['total'] ?? 0), + 'ads_blocked_today' => (int)($queriesBlock['blocked'] ?? 0), + 'unique_clients' => (int)($clientsBlock['active'] ?? $clientsBlock['total'] ?? 0), + 'unique_domains' => (int)($queriesBlock['unique_domains'] ?? 0), + 'queries_forwarded' => (int)($queriesBlock['forwarded'] ?? 0), + 'queries_cached' => (int)($queriesBlock['cached'] ?? 0), + 'status' => $status, + ]; + if ($summaryData['dns_queries_today'] > 0) { + $summaryData['ads_percentage_today'] = round( + $summaryData['ads_blocked_today'] / $summaryData['dns_queries_today'] * 100, + 2 + ); + } else { + $summaryData['ads_percentage_today'] = 0; + } + $queryTypesData = (array)($queriesBlock['types'] ?? []); + } + + if ($queries['ok'] && is_array($queries['data'])) { + $queryList = (array)($queries['data']['queries'] ?? []); + $topAds = []; + $topQueries = []; + $sources = []; + $recent = []; + + foreach ($queryList as $entry) { + if (!is_array($entry)) { + continue; + } + $domainRaw = $entry['domain'] ?? ''; + $domain = ''; + if (is_array($domainRaw)) { + $domain = (string)($domainRaw['name'] ?? $domainRaw['domain'] ?? ''); + } else { + $domain = (string)$domainRaw; + } + + $clientRaw = $entry['client'] ?? ''; + $client = ''; + if (is_array($clientRaw)) { + $name = (string)($clientRaw['name'] ?? ''); + $ip = (string)($clientRaw['ip'] ?? ''); + $client = $name !== '' ? ($name . ' (' . $ip . ')') : ($ip !== '' ? $ip : 'unknown'); + } else { + $client = (string)$clientRaw; + } + + if ($client !== '') { + $sources[$client] = ($sources[$client] ?? 0) + 1; + } + if ($domain !== '') { + $topQueries[$domain] = ($topQueries[$domain] ?? 0) + 1; + } + + $statusVal = (string)($entry['status'] ?? ''); + $isBlocked = str_contains($statusVal, 'GRAVITY') || str_contains($statusVal, 'BLOCK'); + if ($isBlocked && $domain !== '') { + $topAds[$domain] = ($topAds[$domain] ?? 0) + 1; + $recent[] = ['domain' => $domain, 'instance' => $instance['name']]; + } + } + + $topData = ['top_ads' => $topAds, 'top_queries' => $topQueries]; + $querySourcesData = $sources; + $recentData = $recent; + } + + if ($upstreams['ok'] && is_array($upstreams['data'])) { + $upList = (array)($upstreams['data']['upstreams'] ?? []); + $forwardDestData = []; + foreach ($upList as $item) { + if (!is_array($item)) { + continue; + } + $label = (string)($item['name'] ?? $item['ip'] ?? ''); + if ($label === '' && isset($item['port'])) { + $label = 'upstream:' . (string)$item['port']; + } + if ($label === '') { + continue; + } + $forwardDestData[$label] = (int)($item['count'] ?? 0); + } + } + } elseif ($apiInfo['version'] === 5) { + $summary = $apiInfo['probe']; + $topItems = $v5Request($instance, ['topItems' => 50]); + $queryTypes = $v5Request($instance, ['getQueryTypes' => 1]); + $querySources = $v5Request($instance, ['getQuerySources' => 1]); + $forwardDest = $v5Request($instance, ['getForwardDestinations' => 1]); + $recentBlocked = $v5Request($instance, ['recentBlocked' => 30]); + $versions = $v5Request($instance, ['versions' => 1]); + + if (!$summary['ok']) { + $errors[] = $makeError('summary', $summary); + } + if (!$topItems['ok']) { + $errors[] = $makeError('topItems', $topItems); + } + if (!$queryTypes['ok']) { + $errors[] = $makeError('queryTypes', $queryTypes); + } + if (!$querySources['ok']) { + $errors[] = $makeError('querySources', $querySources); + } + if (!$forwardDest['ok']) { + $errors[] = $makeError('forwardDestinations', $forwardDest); + } + if (!$recentBlocked['ok']) { + $errors[] = $makeError('recentBlocked', $recentBlocked); + } + if (!$versions['ok']) { + $errors[] = $makeError('versions', $versions); + } + + $summaryData = $summary['ok'] ? $summary['data'] : null; + $topData = $topItems['ok'] ? $topItems['data'] : null; + $queryTypesData = $queryTypes['ok'] ? $extractQueryTypes($queryTypes['data']) : null; + $querySourcesData = $querySources['ok'] ? $extractSources($querySources['data']) : null; + $forwardDestData = $forwardDest['ok'] ? $extractForwardDestinations($forwardDest['data']) : null; + $recentData = $recentBlocked['ok'] ? $recentBlocked['data'] : null; + $versionsData = $versions['ok'] ? $versions['data'] : null; + $updates = $parseUpdates(is_array($versionsData) ? $versionsData : null); + } else { + $probe = $apiInfo['probe'] ?? ['error' => 'unknown']; + $errors[] = $makeError('probe', is_array($probe) ? $probe : ['error' => 'unknown']); } - $topItems = $apiRequest($instance, ['topItems' => 50]); - if (!$topItems['ok']) { - $errors[] = $makeError('topItems', $topItems); - } - - $queryTypes = $apiRequest($instance, ['getQueryTypes' => 1]); - if (!$queryTypes['ok']) { - $errors[] = $makeError('queryTypes', $queryTypes); - } - - $querySources = $apiRequest($instance, ['getQuerySources' => 1]); - if (!$querySources['ok']) { - $errors[] = $makeError('querySources', $querySources); - } - - $forwardDest = $apiRequest($instance, ['getForwardDestinations' => 1]); - if (!$forwardDest['ok']) { - $errors[] = $makeError('forwardDestinations', $forwardDest); - } - - $recentBlocked = $apiRequest($instance, ['recentBlocked' => 30]); - if (!$recentBlocked['ok']) { - $errors[] = $makeError('recentBlocked', $recentBlocked); - } - - $versions = $apiRequest($instance, ['versions' => 1]); - if (!$versions['ok']) { - $errors[] = $makeError('versions', $versions); - } - - $summaryData = $summary['ok'] ? $summary['data'] : null; - $topData = $topItems['ok'] ? $topItems['data'] : null; - $queryTypesData = $queryTypes['ok'] ? $extractQueryTypes($queryTypes['data']) : null; - $querySourcesData = $querySources['ok'] ? $extractSources($querySources['data']) : null; - $forwardDestData = $forwardDest['ok'] ? $extractForwardDestinations($forwardDest['data']) : null; - $recentData = $recentBlocked['ok'] ? $recentBlocked['data'] : null; - $versionsData = $versions['ok'] ? $versions['data'] : null; - $updates = $parseUpdates(is_array($versionsData) ? $versionsData : null); - if (is_array($summaryData)) { $aggregate['summary']['dns_queries_today'] += (int)($summaryData['dns_queries_today'] ?? 0); $aggregate['summary']['ads_blocked_today'] += (int)($summaryData['ads_blocked_today'] ?? 0); @@ -384,12 +600,52 @@ if ($action === 'test') { } $instance = $instances[$target]; - $result = $apiRequest($instance, ['summaryRaw' => 1]); + $apiInfo = $detectApi($instance); + if ($apiInfo['version'] === 6) { + $sid = (string)($apiInfo['sid'] ?? ''); + $result = $apiInfo['probe']; + if ($result['ok']) { + $respond([ + 'ok' => true, + 'status' => 'ok', + 'message' => 'Verbindung OK. API v6 antwortet.', + ]); + } + $httpCode = (int)($result['http_code'] ?? 0); + $error = (string)($result['error'] ?? 'error'); + $status = 'error'; + $message = 'Unbekannter Fehler.'; + + if ($httpCode === 0) { + $status = 'unreachable'; + $message = 'Host nicht erreichbar oder kein HTTP-Response.'; + } elseif ($httpCode === 401 || $httpCode === 403) { + $status = 'auth'; + $message = 'API Passwort falsch oder nicht berechtigt.'; + } elseif ($error === 'invalid_json') { + $status = 'invalid'; + $message = 'API antwortet nicht mit JSON. URL pruefen.'; + } else { + $status = 'error'; + $message = 'API Fehler: ' . $error . ' (HTTP ' . $httpCode . ')'; + } + + $respond([ + 'ok' => false, + 'status' => $status, + 'message' => $message, + 'http_code' => $httpCode, + 'error' => $error, + 'url' => (string)($result['url'] ?? ''), + ]); + } + + $result = $v5Request($instance, ['summaryRaw' => 1]); if ($result['ok']) { $respond([ 'ok' => true, 'status' => 'ok', - 'message' => 'Verbindung OK. API antwortet.', + 'message' => 'Verbindung OK. API v5 antwortet.', ]); } @@ -436,7 +692,18 @@ if ($action === 'disable') { $results = []; foreach ($targets as $id => $instance) { - $result = $apiRequest($instance, ['disable' => $minutes * 60]); + $apiInfo = $detectApi($instance); + if ($apiInfo['version'] === 6) { + $sid = (string)($apiInfo['sid'] ?? ''); + $result = $v6Request($instance, 'dns/blocking', 'POST', [ + 'blocking' => false, + 'timer' => $minutes * 60, + ], $sid); + } elseif ($apiInfo['version'] === 5) { + $result = $v5Request($instance, ['disable' => $minutes * 60]); + } else { + $result = ['ok' => false, 'error' => 'unknown_api', 'http_code' => 0, 'url' => '']; + } $results[$id] = $result; } $respond(['ok' => true, 'results' => $results]); @@ -452,7 +719,17 @@ if ($action === 'enable') { $results = []; foreach ($targets as $id => $instance) { - $result = $apiRequest($instance, ['enable' => 1]); + $apiInfo = $detectApi($instance); + if ($apiInfo['version'] === 6) { + $sid = (string)($apiInfo['sid'] ?? ''); + $result = $v6Request($instance, 'dns/blocking', 'POST', [ + 'blocking' => true, + ], $sid); + } elseif ($apiInfo['version'] === 5) { + $result = $v5Request($instance, ['enable' => 1]); + } else { + $result = ['ok' => false, 'error' => 'unknown_api', 'http_code' => 0, 'url' => '']; + } $results[$id] = $result; } $respond(['ok' => true, 'results' => $results]); @@ -467,7 +744,15 @@ if ($action === 'gravity') { } $results = []; foreach ($targets as $id => $instance) { - $result = $apiRequest($instance, ['updateGravity' => 1]); + $apiInfo = $detectApi($instance); + if ($apiInfo['version'] === 6) { + $sid = (string)($apiInfo['sid'] ?? ''); + $result = $v6Request($instance, 'action/gravity', 'POST', [], $sid); + } elseif ($apiInfo['version'] === 5) { + $result = $v5Request($instance, ['updateGravity' => 1]); + } else { + $result = ['ok' => false, 'error' => 'unknown_api', 'http_code' => 0, 'url' => '']; + } $results[$id] = $result; } $respond(['ok' => true, 'results' => $results]); @@ -492,7 +777,14 @@ if ($action === 'domain_add') { $results = []; foreach ($targets as $id => $instance) { - $result = $apiRequest($instance, $params); + $apiInfo = $detectApi($instance); + if ($apiInfo['version'] === 6) { + $result = ['ok' => false, 'error' => 'not_supported_v6', 'http_code' => 400, 'url' => '']; + } elseif ($apiInfo['version'] === 5) { + $result = $v5Request($instance, $params); + } else { + $result = ['ok' => false, 'error' => 'unknown_api', 'http_code' => 0, 'url' => '']; + } $results[$id] = $result; } $respond(['ok' => true, 'results' => $results]); @@ -512,7 +804,14 @@ if ($action === 'adlist_add') { $results = []; foreach ($targets as $id => $instance) { - $result = $apiRequest($instance, ['list' => 'adlist', 'add' => $url]); + $apiInfo = $detectApi($instance); + if ($apiInfo['version'] === 6) { + $result = ['ok' => false, 'error' => 'not_supported_v6', 'http_code' => 400, 'url' => '']; + } elseif ($apiInfo['version'] === 5) { + $result = $v5Request($instance, ['list' => 'adlist', 'add' => $url]); + } else { + $result = ['ok' => false, 'error' => 'unknown_api', 'http_code' => 0, 'url' => '']; + } $results[$id] = $result; } $respond(['ok' => true, 'results' => $results]); @@ -528,7 +827,14 @@ if ($action === 'update') { $results = []; foreach ($targets as $id => $instance) { - $result = $apiRequest($instance, ['update' => 1]); + $apiInfo = $detectApi($instance); + if ($apiInfo['version'] === 6) { + $result = ['ok' => false, 'error' => 'not_supported_v6', 'http_code' => 400, 'url' => '']; + } elseif ($apiInfo['version'] === 5) { + $result = $v5Request($instance, ['update' => 1]); + } else { + $result = ['ok' => false, 'error' => 'unknown_api', 'http_code' => 0, 'url' => '']; + } $results[$id] = $result; } $respond(['ok' => true, 'results' => $results]); diff --git a/modules/pihole/pages/instances.php b/modules/pihole/pages/instances.php index f9df940..2ea1807 100644 --- a/modules/pihole/pages/instances.php +++ b/modules/pihole/pages/instances.php @@ -208,7 +208,7 @@ if ($primaryId === '') {