diff --git a/modules/pihole/api/index.php b/modules/pihole/api/index.php new file mode 100644 index 00000000..c9617e19 --- /dev/null +++ b/modules/pihole/api/index.php @@ -0,0 +1,1166 @@ +settings('pihole'); + +$body = file_get_contents('php://input'); +$payload = []; +if ($body !== false && $body !== '') { + $decoded = json_decode($body, true); + if (is_array($decoded)) { + $payload = $decoded; + } +} + +$respond = function (array $data, int $status = 200): void { + http_response_code($status); + echo json_encode($data, JSON_UNESCAPED_UNICODE); + exit; +}; + +$debugPush = static function (string $label, array $payload = []): void { + module_debug_push('pihole', array_merge(['label' => $label], $payload)); +}; + +$sessionFingerprint = static function (array $instance): string { + return sha1(json_encode([ + 'id' => (string)($instance['id'] ?? ''), + 'url' => (string)($instance['url'] ?? ''), + 'password' => (string)($instance['password'] ?? ''), + ], JSON_UNESCAPED_UNICODE)); +}; + +$getSessionBucket = static function (): array { + $bucket = $_SESSION['pihole_api_sessions'] ?? []; + return is_array($bucket) ? $bucket : []; +}; + +$readSessionCache = static function (array $instance) use ($getSessionBucket, $sessionFingerprint): ?array { + $bucket = $getSessionBucket(); + $key = (string)($instance['id'] ?? ''); + if ($key === '' || !isset($bucket[$key]) || !is_array($bucket[$key])) { + return null; + } + + $row = $bucket[$key]; + if (($row['fingerprint'] ?? '') !== $sessionFingerprint($instance)) { + return null; + } + + return $row; +}; + +$writeSessionCache = static function (array $instance, string $sid, int $validity = 300, ?string $csrf = null) use ($sessionFingerprint): void { + $key = (string)($instance['id'] ?? ''); + if ($key === '' || $sid === '') { + return; + } + + if (!isset($_SESSION['pihole_api_sessions']) || !is_array($_SESSION['pihole_api_sessions'])) { + $_SESSION['pihole_api_sessions'] = []; + } + + $_SESSION['pihole_api_sessions'][$key] = [ + 'fingerprint' => $sessionFingerprint($instance), + 'sid' => $sid, + 'csrf' => $csrf ?? '', + 'validity' => $validity > 0 ? $validity : 300, + 'expires_at' => time() + max(30, $validity - 15), + 'updated_at' => time(), + ]; +}; + +$touchSessionCache = static function (array $instance, ?int $validity = null) use ($readSessionCache, $writeSessionCache): void { + $cached = $readSessionCache($instance); + if (!$cached || empty($cached['sid'])) { + return; + } + + $writeSessionCache( + $instance, + (string)$cached['sid'], + $validity ?? (int)($cached['validity'] ?? 300), + (string)($cached['csrf'] ?? '') + ); +}; + +$clearSessionCache = static function (array $instance): void { + $key = (string)($instance['id'] ?? ''); + if ($key === '' || !isset($_SESSION['pihole_api_sessions']) || !is_array($_SESSION['pihole_api_sessions'])) { + return; + } + + unset($_SESSION['pihole_api_sessions'][$key]); +}; + +$normalizeApiPath = function (string $baseUrl, string $apiPath): string { + $base = rtrim($baseUrl, '/'); + $path = $apiPath; + if ($path === '') { + $path = '/admin/api.php'; + } + if ($path[0] !== '/') { + $path = '/' . $path; + } + return $base . $path; +}; + +$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]; + } + + if ($httpCode >= 400) { + $apiError = is_array($data['error'] ?? null) ? $data['error'] : []; + return [ + 'ok' => false, + 'error' => (string)($apiError['message'] ?? $apiError['key'] ?? ('HTTP ' . $httpCode)), + 'error_key' => (string)($apiError['key'] ?? ''), + 'hint' => $apiError['hint'] ?? null, + 'http_code' => $httpCode, + 'data' => $data, + 'url' => $url, + ]; + } + + if (isset($data['error'])) { + $apiError = is_array($data['error']) ? $data['error'] : []; + return [ + 'ok' => false, + 'error' => (string)($apiError['message'] ?? $apiError['key'] ?? 'api_error'), + 'error_key' => (string)($apiError['key'] ?? ''), + 'hint' => $apiError['hint'] ?? null, + 'http_code' => $httpCode, + 'data' => $data, + 'url' => $url, + ]; + } + + return ['ok' => true, 'data' => $data, 'http_code' => $httpCode, 'url' => $url]; +}; + +$v5Request = function (array $instance, array $params, ?int $timeoutOverride = null) use ($normalizeApiPath, $httpRequest): array { + if (!empty($instance['password']) && !isset($params['auth'])) { + $params['auth'] = $instance['password']; + } + + $url = $normalizeApiPath((string)$instance['url'], (string)$instance['api_path']); + $qs = http_build_query($params, '', '&', PHP_QUERY_RFC3986); + $full = $qs !== '' ? $url . '?' . $qs : $url; + + $timeout = $timeoutOverride ?? (int)($instance['timeout'] ?? 8); + if ($timeout <= 0) { + $timeout = 8; + } + $verify = !empty($instance['verify_tls']); + + return $httpRequest('GET', $full, ['Accept: application/json'], null, $verify, $timeout); +}; + +$v6Logout = function (array $instance, string $sid) use ($httpRequest): void { + if ($sid === '') { + return; + } + + $base = rtrim((string)$instance['url'], '/'); + $url = $base . '/api/auth?sid=' . rawurlencode($sid); + $timeout = (int)($instance['timeout'] ?? 8); + if ($timeout <= 0) { + $timeout = 8; + } + $verify = !empty($instance['verify_tls']); + $httpRequest('DELETE', $url, ['Accept: application/json', 'X-FTL-SID: ' . $sid], null, $verify, $timeout); +}; + +$v6Auth = function (array $instance) use ($httpRequest, $readSessionCache, $writeSessionCache, $clearSessionCache, $v6Logout): array { + $cached = $readSessionCache($instance); + if ($cached && !empty($cached['sid']) && (int)($cached['expires_at'] ?? 0) > time()) { + return [ + 'ok' => true, + 'http_code' => 200, + 'url' => rtrim((string)$instance['url'], '/') . '/api/auth', + 'data' => ['session' => ['sid' => (string)$cached['sid'], 'validity' => (int)($cached['validity'] ?? 300)]], + 'sid' => (string)$cached['sid'], + 'cached' => true, + ]; + } + + $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['password'] ?? '')]; + $body = json_encode($payload, JSON_UNESCAPED_UNICODE); + $res = $httpRequest('POST', $url, ['Accept: application/json', 'Content-Type: application/json'], $body, $verify, $timeout); + if (!$res['ok']) { + $clearSessionCache($instance); + return $res; + } + $data = (array)($res['data'] ?? []); + $session = (array)($data['session'] ?? []); + $sid = (string)($session['sid'] ?? ''); + $validity = (int)($session['validity'] ?? 300); + $csrf = (string)($session['csrf'] ?? ''); + if ($cached && !empty($cached['sid']) && $cached['sid'] !== $sid) { + $v6Logout($instance, (string)$cached['sid']); + } + if ($sid !== '') { + $writeSessionCache($instance, $sid, $validity, $csrf); + } + $res['sid'] = $sid; + $res['cached'] = false; + return $res; +}; + +$v6Request = function (array $instance, string $path, string $method, array $payload, string $sid, ?int $timeoutOverride = null) use ($httpRequest, $clearSessionCache, $touchSessionCache): array { + $base = rtrim((string)$instance['url'], '/'); + $path = ltrim($path, '/'); + $url = $base . '/api/' . $path; + $timeout = $timeoutOverride ?? (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; + } + $body = json_encode($payload, JSON_UNESCAPED_UNICODE); + $headers[] = 'Content-Type: application/json'; + } + + $result = $httpRequest($method, $url, $headers, $body, $verify, $timeout); + $httpCode = (int)($result['http_code'] ?? 0); + if ($sid !== '') { + if (($result['ok'] ?? false) === true) { + $touchSessionCache($instance); + } elseif (in_array($httpCode, [401, 403], true)) { + $clearSessionCache($instance); + } + } + + return $result; +}; + +$v6RequestAny = function (array $instance, array $paths, string $method, array $payload, string $sid, ?int $timeoutOverride = null) use ($v6Request): array { + $last = ['ok' => false, 'error' => 'no_path', 'http_code' => 0, 'url' => '']; + foreach ($paths as $path) { + $result = $v6Request($instance, (string)$path, $method, $payload, $sid, $timeoutOverride); + if (($result['ok'] ?? false) === true) { + return $result; + } + $last = $result; + $httpCode = (int)($result['http_code'] ?? 0); + if (!in_array($httpCode, [0, 400, 404], true)) { + return $result; + } + } + return $last; +}; + +$detectApi = function (array $instance) use ($v6Auth, $v6RequestAny, $v5Request, $clearSessionCache): array { + $sid = ''; + $authRes = null; + if (!empty($instance['password'])) { + $authRes = $v6Auth($instance); + if (($authRes['ok'] ?? false) && !empty($authRes['sid'])) { + $sid = (string)$authRes['sid']; + + $probe = $v6RequestAny($instance, ['stats/summary', 'summary'], 'GET', [], $sid); + if (!($probe['ok'] ?? false)) { + $versionProbe = $v6RequestAny($instance, ['dns/blocking'], 'GET', [], $sid); + if (($versionProbe['ok'] ?? false) || in_array((int)($versionProbe['http_code'] ?? 0), [401, 403], true)) { + $probe = $versionProbe; + } + } + if (!(empty($authRes['cached'])) && in_array((int)($probe['http_code'] ?? 0), [401, 403], true)) { + $clearSessionCache($instance); + $authRes = $v6Auth($instance); + if (($authRes['ok'] ?? false) && !empty($authRes['sid'])) { + $sid = (string)$authRes['sid']; + $probe = $v6RequestAny($instance, ['stats/summary', 'summary'], 'GET', [], $sid); + if (!($probe['ok'] ?? false)) { + $versionProbe = $v6RequestAny($instance, ['dns/blocking'], 'GET', [], $sid); + if (($versionProbe['ok'] ?? false) || in_array((int)($versionProbe['http_code'] ?? 0), [401, 403], true)) { + $probe = $versionProbe; + } + } + } + } + return ['version' => 6, 'sid' => $sid, 'probe' => $probe, 'auth' => $authRes]; + } + + $httpCode = (int)($authRes['http_code'] ?? 0); + if (in_array($httpCode, [401, 403], true)) { + return ['version' => 6, 'sid' => '', 'probe' => $authRes, 'auth' => $authRes]; + } + } + + $probe = $v6RequestAny($instance, ['stats/summary', 'summary', 'dns/blocking'], 'GET', [], $sid); + if ($probe['ok'] || in_array((int)($probe['http_code'] ?? 0), [401, 403], true)) { + return ['version' => 6, 'sid' => $sid, 'probe' => $probe, 'auth' => $authRes]; + } + + $legacy = $v5Request($instance, ['summaryRaw' => 1]); + if ($legacy['ok']) { + return ['version' => 5, 'sid' => '', 'probe' => $legacy]; + } + + return ['version' => 0, 'sid' => '', 'probe' => $probe, 'legacy' => $legacy, 'auth' => $authRes]; +}; + +$debugResult = static function (string $label, string $instanceId, array $instance, array $result) use ($debugPush): void { + $payload = [ + 'instance_id' => $instanceId, + 'instance_name' => (string)($instance['name'] ?? $instanceId), + 'url' => (string)($instance['url'] ?? ''), + 'ok' => (bool)($result['ok'] ?? false), + 'http_code' => (int)($result['http_code'] ?? 0), + 'error' => (string)($result['error'] ?? ''), + 'error_key' => (string)($result['error_key'] ?? ''), + 'hint' => $result['hint'] ?? null, + 'request_url' => (string)($result['url'] ?? ''), + ]; + + if (isset($result['data']) && is_array($result['data'])) { + $payload['response'] = $result['data']; + } elseif (isset($result['raw'])) { + $payload['raw'] = (string)$result['raw']; + } + + $debugPush($label, $payload); +}; + +$resolvePrimaryId = function () use ($instances): ?string { + foreach ($instances as $id => $row) { + if (!empty($row['is_primary'])) { + return $id; + } + } + $first = array_key_first($instances); + return $first !== null ? (string)$first : null; +}; + +$resolveActionTimeout = static function () use ($moduleSettings): int { + $timeout = (int)($moduleSettings['action_timeout_sec'] ?? 120); + return $timeout > 0 ? $timeout : 120; +}; + +$pickInstances = function (string $target) use ($instances, $resolvePrimaryId): array { + if ($target === 'all') { + return $instances; + } + if ($target === 'primary') { + $primaryId = $resolvePrimaryId(); + if ($primaryId !== null && isset($instances[$primaryId])) { + return [$primaryId => $instances[$primaryId]]; + } + } + if (isset($instances[$target])) { + return [$target => $instances[$target]]; + } + return []; +}; + +$aggregateTopList = function (array $items, array &$bucket): void { + foreach ($items as $label => $count) { + if (!is_string($label)) { + continue; + } + $bucket[$label] = ($bucket[$label] ?? 0) + (int)$count; + } +}; + +$aggregateMap = function (array $map, array &$bucket): void { + foreach ($map as $label => $count) { + if (!is_string($label)) { + continue; + } + $bucket[$label] = ($bucket[$label] ?? 0) + (int)$count; + } +}; + +$extractQueryTypes = function (array $data): array { + if (isset($data['querytypes']) && is_array($data['querytypes'])) { + return $data['querytypes']; + } + return $data; +}; + +$extractForwardDestinations = function (array $data): array { + if (isset($data['forward_destinations']) && is_array($data['forward_destinations'])) { + return $data['forward_destinations']; + } + return $data; +}; + +$extractSources = function (array $data): array { + if (isset($data['query_sources']) && is_array($data['query_sources'])) { + return $data['query_sources']; + } + return $data; +}; + +$parseUpdates = function (?array $versions): array { + if (!$versions) { + return ['available' => false, 'details' => []]; + } + $details = []; + $available = false; + foreach (['core', 'web', 'FTL'] as $key) { + $current = $versions[$key . '_current'] ?? null; + $latest = $versions[$key . '_latest'] ?? null; + $updateFlag = $versions[$key . '_update'] ?? null; + $needs = false; + if (is_string($updateFlag)) { + $needs = $updateFlag === 'true' || $updateFlag === '1'; + } elseif (is_bool($updateFlag)) { + $needs = $updateFlag; + } + if ($current && $latest && $current !== $latest) { + $needs = true; + } + $details[$key] = [ + 'current' => $current, + 'latest' => $latest, + 'update' => $needs, + ]; + if ($needs) { + $available = true; + } + } + return ['available' => $available, 'details' => $details]; +}; + +if ($action === 'dashboard') { + if (empty($instances)) { + $respond(['ok' => false, 'error' => 'no_instances'], 400); + } + + $debugPush('dashboard.request', [ + 'action' => $action, + 'instance_count' => count($instances), + ]); + + $aggregate = [ + 'summary' => [ + 'dns_queries_today' => 0, + 'ads_blocked_today' => 0, + 'unique_clients' => 0, + 'unique_domains' => 0, + 'blocked_domains' => 0, + 'queries_forwarded' => 0, + 'queries_cached' => 0, + 'status' => 'unknown', + ], + 'top_ads' => [], + 'top_queries' => [], + 'query_types' => [], + 'query_sources' => [], + 'forward_destinations' => [], + 'recent_blocked' => [], + 'updates' => ['available' => false, 'details' => []], + ]; + + $instancePayloads = []; + $statuses = []; + + $makeError = function (string $scope, array $result): array { + return [ + 'scope' => $scope, + 'error' => $result['error'] ?? 'error', + 'error_key' => $result['error_key'] ?? '', + 'hint' => $result['hint'] ?? null, + 'http_code' => $result['http_code'] ?? 0, + 'url' => $result['url'] ?? '', + ]; + }; + + $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; + + $apiInfo = $detectApi($instance); + $debugPush('api.detect.result', [ + 'instance_id' => $id, + 'instance_name' => (string)($instance['name'] ?? $id), + 'version' => (int)($apiInfo['version'] ?? 0), + 'sid_present' => !empty($apiInfo['sid']), + ]); + if ($apiInfo['version'] === 6) { + $sid = (string)($apiInfo['sid'] ?? ''); + $summary = $apiInfo['probe']; + $debugResult('dashboard.summary', $id, $instance, $summary); + if (!$summary['ok']) { + $errors[] = $makeError('summary', $summary); + } + + $blocking = $v6RequestAny($instance, ['dns/blocking'], 'GET', [], $sid); + $debugResult('dashboard.blocking', $id, $instance, $blocking); + if (!$blocking['ok']) { + $errors[] = $makeError('blocking', $blocking); + } + + $queries = $v6RequestAny($instance, [ + 'queries', + 'queries/all', + 'queries?from=' . $from . '&until=' . $now, + 'queries/all?from=' . $from . '&until=' . $now, + ], 'GET', [], $sid); + $debugResult('dashboard.queries', $id, $instance, $queries); + if (!$queries['ok']) { + $errors[] = $makeError('queries', $queries); + } + + $upstreams = $v6RequestAny($instance, ['stats/upstreams', 'upstreams'], 'GET', [], $sid); + $debugResult('dashboard.upstreams', $id, $instance, $upstreams); + 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), + 'blocked_domains' => (int)( + $sum['domains_being_blocked'] + ?? $sum['gravity']['domains_being_blocked'] + ?? $sum['gravity']['domains'] + ?? $sum['gravity']['blocked'] + ?? 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 = []; + if (isset($queries['data']['queries']) && is_array($queries['data']['queries'])) { + $queryList = $queries['data']['queries']; + } elseif (isset($queries['data']['data']) && is_array($queries['data']['data'])) { + $queryList = $queries['data']['data']; + } elseif (array_is_list($queries['data'])) { + $queryList = $queries['data']; + } + $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 = strtoupper((string)($entry['status'] ?? '')); + $isBlocked = str_contains($statusVal, 'GRAVITY') + || str_contains($statusVal, 'BLOCK') + || str_contains($statusVal, 'DENY') + || str_contains($statusVal, 'CNAME'); + 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 = []; + if (isset($upstreams['data']['upstreams']) && is_array($upstreams['data']['upstreams'])) { + $upList = $upstreams['data']['upstreams']; + } elseif (isset($upstreams['data']['data']) && is_array($upstreams['data']['data'])) { + $upList = $upstreams['data']['data']; + } elseif (array_is_list($upstreams['data'])) { + $upList = $upstreams['data']; + } + $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]); + $debugResult('dashboard.summary', $id, $instance, $summary); + $debugResult('dashboard.topItems', $id, $instance, $topItems); + $debugResult('dashboard.queryTypes', $id, $instance, $queryTypes); + $debugResult('dashboard.querySources', $id, $instance, $querySources); + $debugResult('dashboard.forwardDestinations', $id, $instance, $forwardDest); + $debugResult('dashboard.recentBlocked', $id, $instance, $recentBlocked); + $debugResult('dashboard.versions', $id, $instance, $versions); + + 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']; + if (is_array($probe)) { + $debugResult('dashboard.probe', $id, $instance, $probe); + } + $errors[] = $makeError('probe', is_array($probe) ? $probe : ['error' => 'unknown']); + } + + 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); + $aggregate['summary']['unique_clients'] += (int)($summaryData['unique_clients'] ?? 0); + $aggregate['summary']['unique_domains'] += (int)($summaryData['unique_domains'] ?? 0); + $aggregate['summary']['blocked_domains'] += (int)($summaryData['blocked_domains'] ?? 0); + $aggregate['summary']['queries_forwarded'] += (int)($summaryData['queries_forwarded'] ?? 0); + $aggregate['summary']['queries_cached'] += (int)($summaryData['queries_cached'] ?? 0); + $status = (string)($summaryData['status'] ?? 'unknown'); + $statuses[] = $status; + } + + if (is_array($topData)) { + if (!empty($topData['top_ads']) && is_array($topData['top_ads'])) { + $aggregateTopList($topData['top_ads'], $aggregate['top_ads']); + } + if (!empty($topData['top_queries']) && is_array($topData['top_queries'])) { + $aggregateTopList($topData['top_queries'], $aggregate['top_queries']); + } + } + + if (is_array($queryTypesData)) { + $aggregateMap($queryTypesData, $aggregate['query_types']); + } + + if (is_array($querySourcesData)) { + $aggregateMap($querySourcesData, $aggregate['query_sources']); + } + + if (is_array($forwardDestData)) { + $aggregateMap($forwardDestData, $aggregate['forward_destinations']); + } + + if (is_array($recentData)) { + foreach ($recentData as $entry) { + if (is_string($entry)) { + $aggregate['recent_blocked'][] = ['domain' => $entry, 'instance' => $instance['name']]; + } elseif (is_array($entry) && isset($entry['domain'])) { + $aggregate['recent_blocked'][] = ['domain' => (string)$entry['domain'], 'instance' => $instance['name']]; + } + } + } + + if (!empty($updates['available'])) { + $aggregate['updates']['available'] = true; + } + $aggregate['updates']['details'][$id] = $updates; + + $instancePayloads[$id] = [ + 'meta' => [ + 'id' => $id, + 'name' => $instance['name'], + 'url' => $instance['url'], + 'is_primary' => !empty($instance['is_primary']), + ], + 'summary' => $summaryData, + 'top_items' => $topData, + 'query_types' => $queryTypesData, + 'query_sources' => $querySourcesData, + 'forward_destinations' => $forwardDestData, + 'recent_blocked' => $recentData, + 'versions' => $versionsData, + 'updates' => $updates, + 'errors' => $errors, + ]; + } + + if ($aggregate['summary']['dns_queries_today'] > 0) { + $aggregate['summary']['ads_percentage_today'] = round( + $aggregate['summary']['ads_blocked_today'] / $aggregate['summary']['dns_queries_today'] * 100, + 2 + ); + } else { + $aggregate['summary']['ads_percentage_today'] = 0; + } + + $status = 'unknown'; + if ($statuses) { + $allEnabled = count(array_filter($statuses, fn($s) => $s === 'enabled')) === count($statuses); + $allDisabled = count(array_filter($statuses, fn($s) => $s === 'disabled')) === count($statuses); + if ($allEnabled) { + $status = 'enabled'; + } elseif ($allDisabled) { + $status = 'disabled'; + } else { + $status = 'partial'; + } + } + $aggregate['summary']['status'] = $status; + + $debugPush('dashboard.response', [ + 'aggregate_summary' => $aggregate['summary'], + 'instance_ids' => array_keys($instancePayloads), + ]); + + $respond([ + 'ok' => true, + 'ts' => time(), + 'instances' => $instancePayloads, + 'aggregate' => $aggregate, + ]); +} + +if ($action === 'test') { + require_admin(); + $target = (string)($payload['instance'] ?? ''); + if ($target === '') { + $respond(['ok' => false, 'error' => 'missing_instance'], 400); + } + if (!isset($instances[$target])) { + $respond(['ok' => false, 'error' => 'invalid_instance'], 400); + } + + $instance = $instances[$target]; + $debugPush('test.request', [ + 'instance_id' => $target, + 'instance_name' => (string)($instance['name'] ?? $target), + 'url' => (string)($instance['url'] ?? ''), + ]); + $apiInfo = $detectApi($instance); + $debugPush('test.detect.result', [ + 'instance_id' => $target, + 'version' => (int)($apiInfo['version'] ?? 0), + 'sid_present' => !empty($apiInfo['sid']), + ]); + if (!empty($apiInfo['probe']) && is_array($apiInfo['probe'])) { + $debugResult('test.probe', $target, $instance, $apiInfo['probe']); + } + if ($apiInfo['version'] === 6) { + $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 = 'Passwort oder App-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 v5 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 = 'Passwort oder Legacy-API-Token falsch oder nicht berechtigt.'; + } elseif ($error === 'invalid_json') { + $status = 'invalid'; + $message = 'API antwortet nicht mit JSON. URL oder API-Pfad 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'] ?? ''), + ]); +} + +if ($action === 'disable') { + require_admin(); + $debugPush('disable.request', ['payload' => $payload]); + $minutes = (int)($payload['minutes'] ?? 0); + $target = (string)($payload['instance'] ?? 'all'); + if ($minutes <= 0) { + $respond(['ok' => false, 'error' => 'invalid_minutes'], 400); + } + $targets = $pickInstances($target); + if (!$targets) { + $respond(['ok' => false, 'error' => 'invalid_instance'], 400); + } + + $results = []; + foreach ($targets as $id => $instance) { + $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; + $debugResult('disable.result', $id, $instance, $result); + } + $respond(['ok' => true, 'results' => $results]); +} + +if ($action === 'enable') { + require_admin(); + $debugPush('enable.request', ['payload' => $payload]); + $target = (string)($payload['instance'] ?? 'all'); + $targets = $pickInstances($target); + if (!$targets) { + $respond(['ok' => false, 'error' => 'invalid_instance'], 400); + } + + $results = []; + foreach ($targets as $id => $instance) { + $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; + $debugResult('enable.result', $id, $instance, $result); + } + $respond(['ok' => true, 'results' => $results]); +} + +if ($action === 'gravity') { + require_admin(); + $debugPush('gravity.request', ['payload' => $payload]); + $actionTimeout = $resolveActionTimeout(); + $target = $listsPrimaryOnly ? 'primary' : (string)($payload['instance'] ?? 'primary'); + $targets = $pickInstances($target); + if (!$targets) { + $respond(['ok' => false, 'error' => 'invalid_instance'], 400); + } + $results = []; + foreach ($targets as $id => $instance) { + $apiInfo = $detectApi($instance); + if ($apiInfo['version'] === 6) { + $sid = (string)($apiInfo['sid'] ?? ''); + $result = $v6Request($instance, 'action/gravity', 'POST', [], $sid, $actionTimeout); + } elseif ($apiInfo['version'] === 5) { + $result = $v5Request($instance, ['updateGravity' => 1], $actionTimeout); + } else { + $result = ['ok' => false, 'error' => 'unknown_api', 'http_code' => 0, 'url' => '']; + } + $results[$id] = $result; + $debugResult('gravity.result', $id, $instance, $result); + } + $respond(['ok' => true, 'results' => $results]); +} + +if ($action === 'domain_add') { + require_admin(); + $debugPush('domain_add.request', ['payload' => $payload]); + $domain = trim((string)($payload['domain'] ?? '')); + $type = (string)($payload['type'] ?? 'block'); + if ($domain === '') { + $respond(['ok' => false, 'error' => 'missing_domain'], 400); + } + $target = $listsPrimaryOnly ? 'primary' : (string)($payload['instance'] ?? 'primary'); + $targets = $pickInstances($target); + if (!$targets) { + $respond(['ok' => false, 'error' => 'invalid_instance'], 400); + } + + $params = $type === 'allow' + ? ['list' => 'white', 'add' => $domain] + : ['list' => 'black', 'add' => $domain]; + + $results = []; + foreach ($targets as $id => $instance) { + $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; + $debugResult('domain_add.result', $id, $instance, $result); + } + $respond(['ok' => true, 'results' => $results]); +} + +if ($action === 'adlist_add') { + require_admin(); + $debugPush('adlist_add.request', ['payload' => $payload]); + $url = trim((string)($payload['url'] ?? '')); + if ($url === '') { + $respond(['ok' => false, 'error' => 'missing_url'], 400); + } + $target = $listsPrimaryOnly ? 'primary' : (string)($payload['instance'] ?? 'primary'); + $targets = $pickInstances($target); + if (!$targets) { + $respond(['ok' => false, 'error' => 'invalid_instance'], 400); + } + + $results = []; + foreach ($targets as $id => $instance) { + $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; + $debugResult('adlist_add.result', $id, $instance, $result); + } + $respond(['ok' => true, 'results' => $results]); +} + +if ($action === 'update') { + require_admin(); + $debugPush('update.request', ['payload' => $payload]); + $actionTimeout = $resolveActionTimeout(); + $target = (string)($payload['instance'] ?? 'primary'); + $targets = $pickInstances($target); + if (!$targets) { + $respond(['ok' => false, 'error' => 'invalid_instance'], 400); + } + + $results = []; + foreach ($targets as $id => $instance) { + $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], $actionTimeout); + } else { + $result = ['ok' => false, 'error' => 'unknown_api', 'http_code' => 0, 'url' => '']; + } + $results[$id] = $result; + $debugResult('update.result', $id, $instance, $result); + } + $respond(['ok' => true, 'results' => $results]); +} + +$respond(['ok' => false, 'error' => 'unknown_action'], 400); diff --git a/modules/pihole/assets/pihole-native.js b/modules/pihole/assets/pihole-native.js new file mode 100644 index 00000000..8df52ffa --- /dev/null +++ b/modules/pihole/assets/pihole-native.js @@ -0,0 +1,121 @@ +(function () { + 'use strict'; + + function toPartialUrl(input, fallbackView) { + const url = new URL(input, window.location.origin); + if (!url.searchParams.has('view') && fallbackView) { + url.searchParams.set('view', fallbackView); + } + url.searchParams.set('partial', '1'); + return url; + } + + async function fetchHtml(url, options) { + const response = await fetch(url.toString(), { + credentials: 'same-origin', + headers: { + Accept: 'text/html', + 'X-Requested-With': 'XMLHttpRequest' + }, + ...options + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + return response.text(); + } + + function enhanceHost(host, state) { + host.querySelectorAll('a[href]').forEach((link) => { + const href = link.getAttribute('href') || ''; + if (!href.startsWith('/apps/pihole')) { + return; + } + + link.addEventListener('click', (event) => { + event.preventDefault(); + state.load(href); + }); + }); + + host.querySelectorAll('form').forEach((form) => { + form.addEventListener('submit', async (event) => { + event.preventDefault(); + + const method = String(form.getAttribute('method') || 'get').toUpperCase(); + const action = form.getAttribute('action') || state.currentUrl.toString(); + const formData = new FormData(form); + const url = toPartialUrl(action, state.defaultView); + + try { + if (method === 'GET') { + const next = new URL(url.toString()); + next.search = ''; + next.searchParams.set('partial', '1'); + for (const [key, value] of formData.entries()) { + next.searchParams.append(key, String(value)); + } + await state.load(next.toString()); + return; + } + + const html = await fetchHtml(url, { + method, + body: formData + }); + state.render(html, url); + } catch (error) { + state.renderError(error); + } + }); + }); + } + + function initContent(host) { + if (typeof window.initPiholeModule === 'function') { + window.initPiholeModule(host); + } + if (typeof window.initPiholeInstancesModule === 'function') { + window.initPiholeInstancesModule(host); + } + } + + window.initPiholeApp = function initPiholeApp(host, options) { + if (!(host instanceof HTMLElement)) { + return; + } + + const entryRoute = String(options?.entryRoute || '/apps/pihole/index.php'); + const defaultView = String(options?.defaultView || 'dashboard'); + + const state = { + defaultView, + currentUrl: toPartialUrl(entryRoute, defaultView), + async load(targetUrl) { + const url = toPartialUrl(targetUrl, defaultView); + this.currentUrl = url; + host.innerHTML = '

Pi-hole wird geladen...

'; + try { + const html = await fetchHtml(url); + this.render(html, url); + } catch (error) { + this.renderError(error); + } + }, + render(html, url) { + this.currentUrl = url; + host.innerHTML = html; + enhanceHost(host, this); + initContent(host); + }, + renderError(error) { + const message = error instanceof Error ? error.message : 'Pi-hole konnte nicht geladen werden.'; + host.innerHTML = `

${message}

`; + } + }; + + state.load(entryRoute); + }; +})(); diff --git a/modules/pihole/assets/pihole.css b/modules/pihole/assets/pihole.css new file mode 100644 index 00000000..4320e456 --- /dev/null +++ b/modules/pihole/assets/pihole.css @@ -0,0 +1,391 @@ +.pihole-frame { + height: 100%; + min-height: 0; +} + +.pihole-panel-shell { + max-width: 1280px; +} + +.pihole-content { + display: grid; + gap: 18px; +} + +.pihole-card, +.module-box, +.module-box-soft { + border: 1px solid rgba(148, 163, 184, 0.24); + border-radius: 22px; + background: rgba(255, 255, 255, 0.92); + box-shadow: 0 12px 28px rgba(15, 23, 42, 0.06); + padding: 20px; +} + +.pihole-message { + padding: 14px 16px; + border-radius: 16px; +} + +.pihole-message--error { + color: #991b1b; + background: rgba(254, 226, 226, 0.9); + border: 1px solid rgba(248, 113, 113, 0.3); +} + +.pihole-message--success { + color: #166534; + background: rgba(220, 252, 231, 0.9); + border: 1px solid rgba(74, 222, 128, 0.3); +} + +.pihole-grid, +.module-box-grid--stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 14px; +} + +.pihole-stat { + padding: 16px; +} + +.pihole-stat-value { + font-size: 1.6rem; + font-weight: 700; + margin-top: 6px; +} + +.pihole-stat-sub { + margin-top: 4px; + color: #475569; +} + +.pihole-section-header, +.module-box-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.pihole-section-title, +.module-box-title { + margin: 0; + font-size: 1.2rem; + line-height: 1.2; +} + +.pihole-actions, +.pihole-card-actions, +.pihole-modal-actions { + margin-top: 12px; + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; +} + +.pihole-primary-action, +.cta-button { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 44px; + padding: 10px 16px; + border: 0; + border-radius: 14px; + background: linear-gradient(135deg, #14b8a6, #0f766e); + color: #fff; + font: inherit; + font-weight: 700; + cursor: pointer; + text-decoration: none; +} + +.pihole-link-button, +.nav-link { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 40px; + padding: 8px 14px; + border: 1px solid rgba(148, 163, 184, 0.24); + border-radius: 14px; + background: rgba(255, 255, 255, 0.92); + color: inherit; + font: inherit; + font-weight: 600; + cursor: pointer; + text-decoration: none; +} + +.pihole-inline { + display: inline-flex; + gap: 8px; + align-items: center; +} + +.pihole-inline input { + width: 110px; +} + +.pihole-instance-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 14px; + margin-top: 12px; +} + +.pihole-instance, +.pihole-instance-card { + display: grid; + gap: 12px; +} + +.pihole-instance-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 12px; +} + +.pihole-instance-stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + gap: 10px; + margin-top: 12px; +} + +.pihole-instance-value { + font-weight: 700; + margin-top: 4px; +} + +.pihole-status { + padding: 6px 10px; + border-radius: 999px; + font-size: 0.85rem; + font-weight: 600; + background: rgba(20, 184, 166, 0.15); + color: #0f766e; +} + +.pihole-status.is-disabled { + background: rgba(239, 68, 68, 0.15); + color: #b91c1c; +} + +.pihole-status.is-partial { + background: rgba(245, 158, 11, 0.18); + color: #92400e; +} + +.pihole-split, +.module-box-grid--panels { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 14px; +} + +.pihole-list, +.pihole-blocked { + display: grid; + gap: 8px; + margin-top: 12px; +} + +.pihole-list-row, +.pihole-blocked-row { + display: flex; + justify-content: space-between; + gap: 12px; + padding: 8px 10px; + border-radius: 12px; + background: rgba(248, 250, 252, 0.95); +} + +.pihole-blocked-row { + background: rgba(254, 242, 242, 0.9); +} + +.pihole-update { + margin-top: 10px; + font-size: 0.95rem; + color: #475569; +} + +.pihole-error { + margin-top: 8px; + font-size: 0.9rem; + color: #b91c1c; +} + +.pihole-form, +.pihole-setup-form { + display: grid; + gap: 12px; + margin-top: 12px; +} + +.pihole-form { + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + align-items: end; +} + +.pihole-form label, +.pihole-field { + display: grid; + gap: 6px; +} + +.pihole-field input, +.pihole-field select, +.pihole-form input, +.pihole-form select, +.pihole-form textarea, +.pihole-instance-form input { + width: 100%; + min-height: 44px; + padding: 10px 12px; + border: 1px solid rgba(148, 163, 184, 0.24); + border-radius: 14px; + background: #fff; + color: #0f172a; + font: inherit; +} + +.pihole-setup-grid, +.pihole-instance-form-grid, +.form-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 12px; +} + +.pihole-form-field-wide { + grid-column: 1 / -1; +} + +.pihole-checkbox-field { + display: inline-flex; + align-items: center; + gap: 10px; +} + +.pihole-checkbox-field input { + width: 18px; + min-height: 18px; +} + +.pihole-page.is-busy { + position: relative; +} + +.modal { + position: fixed; + inset: 0; + display: none; + align-items: center; + justify-content: center; + padding: 24px; + background: rgba(15, 23, 42, 0.24); + z-index: 90; +} + +.modal.is-open, +.modal[aria-hidden="false"] { + display: flex; +} + +.modal-card { + width: min(720px, 96vw); + border-radius: 18px; + border: 1px solid rgba(148, 163, 184, 0.24); + background: rgba(255, 255, 255, 0.98); + box-shadow: 0 28px 64px rgba(15, 23, 42, 0.18); + padding: 20px; +} + +.modal-header { + display: flex; + justify-content: space-between; + gap: 16px; +} + +.icon-button { + border: 1px solid rgba(148, 163, 184, 0.24); + border-radius: 12px; + background: #fff; + color: #0f172a; + width: 40px; + height: 40px; + cursor: pointer; +} + +.pihole-console-body { + margin-top: 16px; + display: grid; + gap: 10px; + max-height: 48vh; + overflow: auto; + padding: 4px; +} + +.pihole-console-line { + display: grid; + gap: 4px; + padding: 12px 14px; + border-radius: 14px; + border: 1px solid rgba(148, 163, 184, 0.24); + background: rgba(255, 255, 255, 0.75); +} + +.pihole-console-line span { + font-size: 0.8rem; + color: #475569; +} + +.pihole-console-line strong { + font-weight: 600; +} + +.pihole-console-line.is-success { + border-color: rgba(20, 184, 166, 0.22); + background: rgba(20, 184, 166, 0.08); +} + +.pihole-console-line.is-error { + border-color: rgba(239, 68, 68, 0.24); + background: rgba(239, 68, 68, 0.08); +} + +.pihole-test-result { + margin-top: 6px; + font-size: 0.9rem; + color: #475569; +} + +.pihole-test-result.is-ok { color: #0f766e; } +.pihole-test-result.is-auth, +.pihole-test-result.is-unreachable, +.pihole-test-result.is-error { color: #b91c1c; } +.pihole-test-result.is-invalid { color: #92400e; } + +@media (max-width: 980px) { + .pihole-frame { + grid-template-columns: 1fr; + } + + .pihole-actions, + .pihole-card-actions, + .pihole-modal-actions, + .pihole-inline { + flex-direction: column; + align-items: stretch; + } + + .pihole-inline input { + width: 100%; + } +} diff --git a/modules/pihole/assets/pihole.js b/modules/pihole/assets/pihole.js new file mode 100644 index 00000000..c92c0522 --- /dev/null +++ b/modules/pihole/assets/pihole.js @@ -0,0 +1,448 @@ +(function () { + 'use strict'; + + window.initPiholeModule = function initPiholeModule(rootNode) { + const scope = rootNode instanceof Element ? rootNode : document; + const page = scope.matches?.('[data-pihole-page]') ? scope : scope.querySelector('[data-pihole-page]'); + if (!page || page.dataset.piholeBound === '1') return; + page.dataset.piholeBound = '1'; + + const pageType = page.dataset.piholePage || 'dashboard'; + const configuredRefreshSeconds = Number(page.dataset.refreshSeconds || 0); + const defaultRefreshSeconds = pageType === 'dashboard' ? 1 : 5; + const refreshSeconds = Number.isFinite(configuredRefreshSeconds) && configuredRefreshSeconds >= 0 + ? configuredRefreshSeconds + : defaultRefreshSeconds; + + const fmt = new Intl.NumberFormat('de-DE'); + const fmtDate = (ts) => { + if (!ts) return '–'; + const d = new Date(ts * 1000); + return d.toLocaleString('de-DE'); + }; + + let refreshTimer = null; + let loadInFlight = false; + let actionInFlight = false; + let actionConsoleApi = null; + let actionConsoleBody = null; + let actionConsoleClose = null; + + const apiCall = async (action, payload = {}) => { + const res = await fetch(`/api/pihole/index.php?action=${encodeURIComponent(action)}`, { + method: 'POST', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify(payload), + }); + const data = await res.json().catch(() => ({})); + 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; + }; + + const query = (selector) => page.querySelector(selector); + const setText = (el, value) => { + if (el) el.textContent = value; + }; + + const ensureActionConsole = () => { + if (actionConsoleApi && actionConsoleBody && actionConsoleClose) return; + + const root = document.createElement('div'); + root.className = 'modal'; + root.dataset.piholeConsoleModal = '1'; + root.setAttribute('aria-hidden', 'true'); + root.innerHTML = ` + + `; + document.body.appendChild(root); + + actionConsoleBody = root.querySelector('[data-pihole-console-body]'); + actionConsoleClose = root.querySelector('[data-pihole-console-close]'); + const clearBtn = root.querySelector('[data-pihole-console-clear]'); + + actionConsoleApi = { + open() { + root.classList.add('is-open'); + root.setAttribute('aria-hidden', 'false'); + }, + close() { + root.classList.remove('is-open'); + root.setAttribute('aria-hidden', 'true'); + } + }; + + actionConsoleClose?.addEventListener('click', () => { + if (!actionInFlight) actionConsoleApi.close(); + }); + + clearBtn?.addEventListener('click', () => { + if (actionConsoleBody) actionConsoleBody.innerHTML = ''; + }); + }; + + const appendActionLog = (message, tone = 'info') => { + ensureActionConsole(); + if (!actionConsoleBody) return; + const row = document.createElement('div'); + row.className = `pihole-console-line is-${tone}`; + row.innerHTML = `${new Date().toLocaleTimeString('de-DE')}${message}`; + actionConsoleBody.appendChild(row); + actionConsoleBody.scrollTop = actionConsoleBody.scrollHeight; + }; + + const setActionLock = (locked) => { + actionInFlight = locked; + page.classList.toggle('is-busy', locked); + ensureActionConsole(); + if (actionConsoleClose) { + actionConsoleClose.disabled = locked; + } + + page.querySelectorAll('button, input, select, textarea').forEach((formControl) => { + if (locked) { + formControl.dataset.piholeWasDisabled = formControl.disabled ? 'true' : 'false'; + formControl.disabled = true; + return; + } + formControl.disabled = formControl.dataset.piholeWasDisabled === 'true'; + delete formControl.dataset.piholeWasDisabled; + }); + }; + + const statusLabel = (status) => { + if (status === 'enabled') return 'Aktiv'; + if (status === 'disabled') return 'Deaktiviert'; + if (status === 'partial') return 'Teilweise'; + return 'Unbekannt'; + }; + + const setStatusBadge = (el, status) => { + if (!el) return; + el.textContent = statusLabel(status); + el.classList.remove('is-disabled', 'is-partial'); + if (status === 'disabled') el.classList.add('is-disabled'); + if (status === 'partial') el.classList.add('is-partial'); + }; + + const mapToList = (map, limit = 10) => { + if (!map || typeof map !== 'object') return []; + return Object.entries(map) + .filter(([, value]) => typeof value === 'number' || typeof value === 'string') + .map(([key, value]) => [key, Number(value)]) + .sort((a, b) => b[1] - a[1]) + .slice(0, limit); + }; + + const renderList = (container, map, emptyText) => { + if (!container) return; + const rows = mapToList(map, 10); + container.innerHTML = ''; + if (!rows.length) { + const div = document.createElement('div'); + div.className = 'muted'; + div.textContent = emptyText || 'Keine Daten'; + container.appendChild(div); + return; + } + rows.forEach(([label, value]) => { + const row = document.createElement('div'); + row.className = 'pihole-list-row'; + row.innerHTML = `${label}${fmt.format(value)}`; + container.appendChild(row); + }); + }; + + const renderBlocked = (container, list) => { + if (!container) return; + container.innerHTML = ''; + if (!Array.isArray(list) || !list.length) { + const div = document.createElement('div'); + div.className = 'muted'; + div.textContent = 'Keine Daten'; + container.appendChild(div); + return; + } + list.slice(0, 20).forEach((entry) => { + const domain = entry.domain || entry; + const instance = entry.instance || ''; + const row = document.createElement('div'); + row.className = 'pihole-blocked-row'; + row.innerHTML = `${domain}${instance}`; + container.appendChild(row); + }); + }; + + const renderInstances = (instances) => { + const holder = query('[data-instance-cards]'); + const tpl = scope.querySelector('#pihole-instance-template'); + if (!holder || !tpl) return; + holder.innerHTML = ''; + + Object.values(instances).forEach((entry) => { + const node = tpl.content.cloneNode(true); + const root = node.querySelector('[data-instance]'); + if (!root) return; + root.dataset.instance = entry.meta.id; + + const summary = entry.summary || {}; + setText(root.querySelector('[data-instance-name]'), entry.meta.name || entry.meta.id); + setText(root.querySelector('[data-instance-url]'), entry.meta.url || ''); + setText(root.querySelector('[data-instance-dns]'), fmt.format(Number(summary.dns_queries_today || 0))); + setText(root.querySelector('[data-instance-ads]'), fmt.format(Number(summary.ads_blocked_today || 0))); + setText(root.querySelector('[data-instance-percent]'), `${Number(summary.ads_percentage_today || summary.ads_percentage || 0).toFixed(2)}%`); + setStatusBadge(root.querySelector('[data-instance-status]'), summary.status || 'unknown'); + + root.querySelectorAll('[data-instance]').forEach((btn) => { + if (btn.dataset.instance === '') btn.dataset.instance = entry.meta.id; + }); + const customInput = root.querySelector('[data-custom-minutes]'); + if (customInput) customInput.dataset.customMinutes = entry.meta.id; + + const updateEl = root.querySelector('[data-instance-update]'); + if (updateEl) { + updateEl.textContent = entry.updates && entry.updates.available ? 'Updates verfuegbar' : 'Keine Updates erkannt'; + } + + const errorEl = root.querySelector('[data-instance-errors]'); + if (errorEl) { + if (Array.isArray(entry.errors) && entry.errors.length) { + const lines = entry.errors.map((err) => { + const code = err.http_code ? `HTTP ${err.http_code}` : 'HTTP ?'; + const scopeName = err.scope || 'request'; + const msg = err.error || 'error'; + const hint = err.hint ? `, Hint: ${typeof err.hint === 'string' ? err.hint : JSON.stringify(err.hint)}` : ''; + return `${scopeName}: ${msg} (${code}${hint})`; + }); + errorEl.textContent = `API Fehler: ${lines.join(' | ')}`; + } else { + errorEl.textContent = ''; + } + } + + holder.appendChild(node); + }); + }; + + const renderDashboardData = (data) => { + const summary = data.aggregate?.summary || {}; + setText(query('[data-summary-dns]'), fmt.format(Number(summary.dns_queries_today || 0))); + setText(query('[data-summary-blocked]'), fmt.format(Number(summary.ads_blocked_today || 0))); + setText(query('[data-summary-percent]'), `${Number(summary.ads_percentage_today || 0).toFixed(2)}%`); + setText(query('[data-summary-clients]'), fmt.format(Number(summary.unique_clients || 0))); + setStatusBadge(query('[data-summary-status]'), summary.status || 'unknown'); + setText(query('[data-summary-last-refresh]'), `Letztes Update: ${fmtDate(data.ts)}`); + + renderInstances(data.instances || {}); + renderList(query('[data-query-types]'), data.aggregate?.query_types, 'Keine Daten'); + renderList(query('[data-forward-destinations]'), data.aggregate?.forward_destinations, 'Keine Daten'); + renderList(query('[data-top-ads]'), data.aggregate?.top_ads, 'Keine Daten'); + renderList(query('[data-top-queries]'), data.aggregate?.top_queries, 'Keine Daten'); + renderList(query('[data-top-clients]'), data.aggregate?.query_sources, 'Keine Daten'); + renderBlocked(query('[data-recent-blocked]'), data.aggregate?.recent_blocked); + }; + + const dashboardFingerprint = (data) => JSON.stringify({ + blocked_domains: Number(data?.aggregate?.summary?.blocked_domains || 0), + ads_blocked_today: Number(data?.aggregate?.summary?.ads_blocked_today || 0), + unique_domains: Number(data?.aggregate?.summary?.unique_domains || 0), + instances: Object.values(data?.instances || {}).map((entry) => ({ + id: entry?.meta?.id || '', + blocked_domains: Number(entry?.summary?.blocked_domains || 0), + ads_blocked_today: Number(entry?.summary?.ads_blocked_today || 0), + unique_domains: Number(entry?.summary?.unique_domains || 0), + })), + }); + + const delay = (ms) => new Promise((resolve) => window.setTimeout(resolve, ms)); + + const monitorGravityProgress = async (baselineData, instanceLabel) => { + const baselineFingerprint = dashboardFingerprint(baselineData || {}); + const maxPolls = 24; + const pollDelayMs = 5000; + + appendActionLog(`Pi-hole meldet keinen Live-Fortschritt. Pruefe jetzt zyklisch auf erkennbare Aenderungen fuer ${instanceLabel}.`, 'info'); + + for (let attempt = 1; attempt <= maxPolls; attempt += 1) { + await delay(pollDelayMs); + appendActionLog(`Pruefung ${attempt}/${maxPolls}: aktuelle Statusdaten werden geladen ...`, 'info'); + try { + const data = await apiCall('dashboard'); + if (!data.ok) throw new Error(data.error || 'API error'); + renderDashboardData(data); + if (dashboardFingerprint(data) !== baselineFingerprint) { + appendActionLog(`Erkennbare Aenderung gefunden. Listen-Update fuer ${instanceLabel} scheint abgeschlossen zu sein.`, 'success'); + return true; + } + } catch (err) { + appendActionLog(`Statuspruefung fehlgeschlagen: ${err.message}`, 'error'); + } + } + + appendActionLog(`Innerhalb des Beobachtungsfensters wurde keine erkennbare Aenderung gefunden.`, 'error'); + return false; + }; + + const loadDashboard = async () => { + if (loadInFlight || actionInFlight) return; + loadInFlight = true; + try { + const data = await apiCall('dashboard'); + if (!data.ok) throw new Error(data.error || 'API error'); + renderDashboardData(data); + query('[data-pihole-load-error]')?.remove(); + } catch (err) { + let message = query('[data-pihole-load-error]'); + if (!message) { + message = document.createElement('div'); + message.className = 'window-app-card pihole-card'; + message.style.marginTop = '1rem'; + message.dataset.piholeLoadError = '1'; + page.appendChild(message); + } + message.textContent = `Fehler beim Laden der Pi-hole Daten: ${err.message}`; + } finally { + loadInFlight = false; + } + }; + + page.addEventListener('click', async (e) => { + const btn = e.target.closest('[data-action]'); + if (!btn) return; + + const action = btn.dataset.action; + const instance = btn.dataset.instance || 'all'; + const minutes = btn.dataset.minutes; + const buttonScope = btn.closest('.pihole-instance') || page; + const customInput = buttonScope.querySelector(`[data-custom-minutes="${instance}"]`); + const payload = { instance }; + let baselineData = null; + + if (action === 'disable') payload.minutes = Number(minutes || 0); + if (action === 'disable-custom') payload.minutes = Number(customInput?.value || 0); + + try { + ensureActionConsole(); + if (actionConsoleBody) actionConsoleBody.innerHTML = ''; + actionConsoleApi.open(); + setActionLock(true); + + baselineData = action === 'gravity' ? await apiCall('dashboard').catch(() => null) : null; + + if (action === 'enable') { + appendActionLog(`Aktiviere ${instance === 'all' ? 'alle Instanzen' : `Instanz ${instance}`}.`, 'info'); + await apiCall('enable', payload); + } else if (action === 'disable' || action === 'disable-custom') { + if (!payload.minutes || payload.minutes <= 0) { + appendActionLog('Fehler: Bitte Minuten angeben.', 'error'); + return; + } + appendActionLog(`Deaktiviere ${instance === 'all' ? 'alle Instanzen' : `Instanz ${instance}`} fuer ${payload.minutes} Minuten.`, 'info'); + await apiCall('disable', payload); + } else if (action === 'gravity') { + appendActionLog(`Starte Listen-Update fuer ${instance === 'all' ? 'alle Instanzen' : `Instanz ${instance}`}.`, 'info'); + await apiCall('gravity', payload); + const status = query('[data-list-update-status]'); + if (status) status.textContent = 'Listen-Update gestartet.'; + } else if (action === 'update') { + appendActionLog(`Starte Pi-hole-Update fuer ${instance === 'all' ? 'alle Instanzen' : `Instanz ${instance}`}.`, 'info'); + await apiCall('update', payload); + } + + appendActionLog('Aktion abgeschlossen. Dashboard wird aktualisiert.', 'success'); + if (action === 'gravity') { + await monitorGravityProgress(baselineData, instance === 'all' ? 'alle Instanzen' : `Instanz ${instance}`); + } else { + await loadDashboard(); + } + } catch (err) { + appendActionLog(`Aktion fehlgeschlagen: ${err.message}`, 'error'); + } finally { + setActionLock(false); + } + }); + + const domainForm = query('[data-domain-form]'); + domainForm?.addEventListener('submit', async (e) => { + e.preventDefault(); + const formData = new FormData(domainForm); + const type = String(formData.get('type') || 'block'); + const domain = String(formData.get('domain') || '').trim(); + const status = query('[data-domain-status]'); + if (!domain) return; + try { + await apiCall('domain_add', { type, domain, instance: 'primary' }); + if (status) status.textContent = `Domain hinzugefuegt: ${domain}`; + domainForm.reset(); + } catch (err) { + if (status) status.textContent = `Fehler: ${err.message}`; + } + }); + + const adlistForm = query('[data-adlist-form]'); + adlistForm?.addEventListener('submit', async (e) => { + e.preventDefault(); + const formData = new FormData(adlistForm); + const url = String(formData.get('url') || '').trim(); + const status = query('[data-adlist-status]'); + if (!url) return; + try { + await apiCall('adlist_add', { url, instance: 'primary' }); + if (status) status.textContent = `Adlist hinzugefuegt: ${url}`; + adlistForm.reset(); + } catch (err) { + if (status) status.textContent = `Fehler: ${err.message}`; + } + }); + + const stopAutoRefresh = () => { + if (refreshTimer !== null) { + window.clearInterval(refreshTimer); + refreshTimer = null; + } + }; + + const startAutoRefresh = () => { + stopAutoRefresh(); + if (refreshSeconds <= 0 || document.visibilityState !== 'visible') return; + refreshTimer = window.setInterval(loadDashboard, refreshSeconds * 1000); + }; + + loadDashboard(); + startAutoRefresh(); + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible') { + loadDashboard(); + startAutoRefresh(); + } else { + stopAutoRefresh(); + } + }); + window.addEventListener('beforeunload', stopAutoRefresh); + }; + + if (document.querySelector('[data-pihole-page]')) { + window.initPiholeModule(document); + } +})(); diff --git a/modules/pihole/assets/pihole_instances.js b/modules/pihole/assets/pihole_instances.js new file mode 100644 index 00000000..81b54862 --- /dev/null +++ b/modules/pihole/assets/pihole_instances.js @@ -0,0 +1,121 @@ +(function () { + 'use strict'; + + window.initPiholeInstancesModule = function initPiholeInstancesModule(rootNode) { + const scope = rootNode instanceof Element ? rootNode : document; + const modal = scope.querySelector('[data-instance-modal]'); + const form = scope.querySelector('[data-instance-form]'); + + scope.querySelectorAll('[data-instance-test]').forEach((button) => { + if (button.dataset.piholeBound === '1') return; + button.dataset.piholeBound = '1'; + button.addEventListener('click', async (event) => { + event.preventDefault(); + const card = button.closest('[data-instance-id]'); + if (!card) return; + + const instanceId = card.dataset.instanceId || ''; + const resultEl = card.querySelector('[data-instance-result]'); + if (resultEl) { + resultEl.classList.remove('is-ok', 'is-auth', 'is-unreachable', 'is-invalid', 'is-error'); + resultEl.textContent = 'Teste Verbindung...'; + } + + try { + const res = await fetch(`/api/pihole/index.php?action=test`, { + method: 'POST', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify({ instance: instanceId }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`); + if (resultEl) { + resultEl.classList.add(data.status ? `is-${data.status}` : 'is-ok'); + resultEl.textContent = data.message || 'Verbindung OK.'; + } + } catch (err) { + if (resultEl) { + resultEl.classList.add('is-error'); + resultEl.textContent = `Test fehlgeschlagen: ${err.message || 'Fehler'}`; + } + } + }); + }); + + if (!modal || !form || modal.dataset.piholeBound === '1') return; + modal.dataset.piholeBound = '1'; + + const title = scope.querySelector('[data-instance-modal-title]'); + const closeBtn = scope.querySelector('[data-instance-close]'); + const newBtn = scope.querySelector('[data-instance-new]'); + const cancelBtn = scope.querySelector('[data-instance-cancel]'); + const currentIdInput = form.querySelector('input[name="current_id"]'); + const idInput = form.querySelector('input[name="instance_id"]'); + const nameInput = form.querySelector('input[name="name"]'); + const urlInput = form.querySelector('input[name="url"]'); + const passwordInput = form.querySelector('input[name="password"]'); + const primaryInput = form.querySelector('input[name="is_primary"]'); + + const resetForm = () => { + if (currentIdInput) currentIdInput.value = ''; + if (idInput) idInput.value = ''; + if (nameInput) nameInput.value = ''; + if (urlInput) urlInput.value = ''; + if (passwordInput) passwordInput.value = ''; + if (primaryInput) primaryInput.checked = false; + }; + + const openModal = () => { + modal.classList.add('is-open'); + modal.setAttribute('aria-hidden', 'false'); + if (idInput) window.setTimeout(() => idInput.focus(), 20); + }; + + const closeModal = () => { + modal.classList.remove('is-open'); + modal.setAttribute('aria-hidden', 'true'); + }; + + scope.querySelectorAll('[data-instance-edit]').forEach((btn) => { + btn.addEventListener('click', () => { + const card = btn.closest('[data-instance-id]'); + if (!card) return; + if (currentIdInput) currentIdInput.value = card.dataset.instanceId || ''; + if (idInput) idInput.value = card.dataset.instanceId || ''; + if (nameInput) nameInput.value = card.dataset.name || ''; + if (urlInput) urlInput.value = card.dataset.url || ''; + if (passwordInput) passwordInput.value = ''; + if (primaryInput) primaryInput.checked = card.dataset.primary === '1'; + if (title) title.textContent = 'Instanz bearbeiten'; + openModal(); + }); + }); + + newBtn?.addEventListener('click', () => { + resetForm(); + if (title) title.textContent = 'Neue Instanz'; + openModal(); + }); + + closeBtn?.addEventListener('click', closeModal); + cancelBtn?.addEventListener('click', () => { + resetForm(); + closeModal(); + }); + + modal.addEventListener('click', (event) => { + if (event.target === modal) closeModal(); + }); + + document.addEventListener('keydown', (event) => { + if (event.key === 'Escape' && modal.classList.contains('is-open')) { + closeModal(); + } + }); + }; + + if (document.querySelector('[data-instance-modal]')) { + window.initPiholeInstancesModule(document); + } +})(); diff --git a/modules/pihole/bootstrap.php b/modules/pihole/bootstrap.php new file mode 100644 index 00000000..a30feee9 --- /dev/null +++ b/modules/pihole/bootstrap.php @@ -0,0 +1,116 @@ +registerFunction($moduleName, 'settings', function () use ($moduleName): array { + return modules()->settings($moduleName); +}); + +$mm->registerFunction($moduleName, 'instances', function () use ($moduleName): array { + $settings = modules()->settings($moduleName); + $apiPath = trim((string)($settings['api_path'] ?? '/admin/api.php')); + if ($apiPath === '') { + $apiPath = '/admin/api.php'; + } + if ($apiPath[0] !== '/') { + $apiPath = '/' . $apiPath; + } + + $timeout = (int)($settings['api_timeout_sec'] ?? 8); + if ($timeout <= 0) { + $timeout = 8; + } + + $verifyTls = !isset($settings['verify_tls']) || $settings['verify_tls'] === '1' || $settings['verify_tls'] === 1 || $settings['verify_tls'] === true; + + $normalizeSecret = static function (array $row): string { + $password = trim((string)($row['password'] ?? '')); + if ($password !== '') { + return $password; + } + + return trim((string)($row['token'] ?? '')); + }; + + $instances = []; + + $rawJson = trim((string)($settings['instances_json'] ?? '')); + if ($rawJson !== '') { + $decoded = json_decode($rawJson, true); + if (is_array($decoded)) { + foreach ($decoded as $row) { + if (!is_array($row)) { + continue; + } + $id = trim((string)($row['id'] ?? '')); + $url = trim((string)($row['url'] ?? '')); + if ($id === '' || $url === '') { + continue; + } + $instances[$id] = [ + 'id' => $id, + 'name' => trim((string)($row['name'] ?? '')) ?: $id, + 'url' => rtrim($url, '/'), + 'password' => $normalizeSecret($row), + 'api_path' => $apiPath, + 'timeout' => $timeout, + 'verify_tls' => $verifyTls, + 'is_primary' => !empty($row['is_primary']), + ]; + } + } + } + + if (empty($instances)) { + foreach (['primary', 'secondary'] as $key) { + $urlKey = $key . '_url'; + $tokenKey = $key . '_token'; + $nameKey = $key . '_name'; + $url = trim((string)($settings[$urlKey] ?? '')); + if ($url === '') { + continue; + } + $instances[$key] = [ + 'id' => $key, + 'name' => trim((string)($settings[$nameKey] ?? '')) ?: ($key === 'primary' ? 'Primaer' : 'Sekundaer'), + 'url' => rtrim($url, '/'), + 'password' => trim((string)($settings[$tokenKey] ?? '')), + 'api_path' => $apiPath, + 'timeout' => $timeout, + 'verify_tls' => $verifyTls, + 'is_primary' => $key === 'primary', + ]; + } + } + + $primaryId = trim((string)($settings['primary_id'] ?? '')); + if ($primaryId !== '' && isset($instances[$primaryId])) { + foreach ($instances as $id => &$row) { + $row['is_primary'] = ($id === $primaryId); + } + unset($row); + } else { + $hasPrimary = false; + foreach ($instances as $row) { + if (!empty($row['is_primary'])) { + $hasPrimary = true; + break; + } + } + if (!$hasPrimary && $instances) { + $firstKey = array_key_first($instances); + if ($firstKey !== null) { + $instances[$firstKey]['is_primary'] = true; + } + } + } + + return $instances; +}); + +$mm->registerFunction($moduleName, 'lists_primary_only', function () use ($moduleName): bool { + $settings = modules()->settings($moduleName); + return !empty($settings['lists_primary_only']); +}); diff --git a/modules/pihole/config/module.php b/modules/pihole/config/module.php new file mode 100644 index 00000000..51e40916 --- /dev/null +++ b/modules/pihole/config/module.php @@ -0,0 +1,15 @@ + '/admin/api.php', + 'api_timeout_sec' => 8, + 'action_timeout_sec' => 120, + 'dashboard_refresh_sec' => 1, + 'lists_refresh_sec' => 5, + 'queries_refresh_sec' => 5, + 'verify_tls' => true, + 'lists_primary_only' => false, + 'instances_json' => '', + 'primary_id' => '', +]; diff --git a/modules/pihole/design.json b/modules/pihole/design.json new file mode 100644 index 00000000..0b09d222 --- /dev/null +++ b/modules/pihole/design.json @@ -0,0 +1,16 @@ +{ + "eyebrow": "Modul", + "title": "Pi-hole", + "description": "Pi-hole Monitoring, Listen und Steuerung fuer mehrere Instanzen.", + "actions": [ + { "label": "Setup", "href": "/apps/pihole/index.php?view=setup", "variant": "ghost" }, + { "label": "Instanzen", "href": "/apps/pihole/index.php?view=instances", "variant": "secondary" } + ], + "tabs": [ + { "label": "Dashboard", "href": "/apps/pihole/index.php?view=dashboard", "view": "dashboard" }, + { "label": "Listen", "href": "/apps/pihole/index.php?view=lists", "view": "lists" }, + { "label": "Queries", "href": "/apps/pihole/index.php?view=queries", "view": "queries" }, + { "label": "Instanzen", "href": "/apps/pihole/index.php?view=instances", "view": "instances" }, + { "label": "Setup", "href": "/apps/pihole/index.php?view=setup", "view": "setup" } + ] +} diff --git a/modules/pihole/desktop.php b/modules/pihole/desktop.php new file mode 100644 index 00000000..653cb05c --- /dev/null +++ b/modules/pihole/desktop.php @@ -0,0 +1,42 @@ + 'pihole', + 'title' => (string) ($manifest['title'] ?? 'Pi-hole'), + 'icon' => 'PH', + 'entry_route' => '/apps/pihole/index.php', + 'window_mode' => 'window', + 'content_mode' => 'native-module', + 'default_width' => 1220, + 'default_height' => 860, + 'supports_widget' => false, + 'supports_tray' => false, + 'module_name' => 'network', + 'summary' => (string) ($manifest['description'] ?? 'Pi-hole Monitoring, Listen und Steuerung fuer mehrere Instanzen.'), + 'native_module' => [ + 'initializer' => 'initPiholeApp', + 'options' => [ + 'entryRoute' => '/apps/pihole/index.php', + 'defaultView' => 'dashboard', + ], + 'assets' => [ + 'styles' => [ + ['href' => '/module-assets/index.php?module=pihole&path=assets/pihole.css&v=' . rawurlencode((string) (is_file(__DIR__ . '/assets/pihole.css') ? filemtime(__DIR__ . '/assets/pihole.css') : '0'))], + ], + 'scripts' => [ + ['src' => '/module-assets/index.php?module=pihole&path=assets/pihole.js&v=' . rawurlencode((string) (is_file(__DIR__ . '/assets/pihole.js') ? filemtime(__DIR__ . '/assets/pihole.js') : '0'))], + ['src' => '/module-assets/index.php?module=pihole&path=assets/pihole_instances.js&v=' . rawurlencode((string) (is_file(__DIR__ . '/assets/pihole_instances.js') ? filemtime(__DIR__ . '/assets/pihole_instances.js') : '0'))], + ['src' => '/module-assets/index.php?module=pihole&path=assets/pihole-native.js&v=' . rawurlencode((string) (is_file(__DIR__ . '/assets/pihole-native.js') ? filemtime(__DIR__ . '/assets/pihole-native.js') : '0'))], + ], + ], + ], +]; diff --git a/modules/pihole/docs/README.md b/modules/pihole/docs/README.md new file mode 100644 index 00000000..4fc030bd --- /dev/null +++ b/modules/pihole/docs/README.md @@ -0,0 +1,15 @@ +# Pi-hole + +Importiertes Nexus-Modul fuer Pi-hole Monitoring, Listenpflege und Instanzsteuerung. + +## Desktop-Anbindung + +- Desktop-App unter `/apps/pihole/index.php` +- API-Endpunkt unter `/api/pihole/index.php` +- native Modul-Einbettung mit globaler Desktop-Sidebar + +## Settings + +- bestehende Nexus-Settings werden ueber `nexus_module_settings` gelesen +- neue Overrides werden in `data/module-settings/pihole.json` gespeichert +- Instanzen werden weiterhin im Modul-Setting `instances_json` verwaltet diff --git a/modules/pihole/module.json b/modules/pihole/module.json new file mode 100644 index 00000000..db203175 --- /dev/null +++ b/modules/pihole/module.json @@ -0,0 +1,66 @@ +{ + "title": "Pi-hole", + "version": "0.1.0", + "description": "Pi-hole Monitoring, Listen und Steuerung fuer mehrere Instanzen.", + "enabled_by_default": true, + "setup": { + "fields": [ + { + "name": "api_path", + "label": "API Pfad", + "type": "text", + "help": "Standard ist /admin/api.php fuer v5-Instanzen. v6 wird automatisch erkannt.", + "required": false + }, + { + "name": "verify_tls", + "label": "TLS Zertifikate pruefen", + "type": "checkbox", + "help": "Bei internen Testsystemen nur deaktivieren, wenn self-signed Zertifikate genutzt werden.", + "required": false + }, + { + "name": "api_timeout_sec", + "label": "API Timeout Standard (Sekunden)", + "type": "number", + "help": "Timeout fuer normale Pi-hole API-Abfragen wie Dashboard und Queries. Standard: 8 Sekunden.", + "required": false + }, + { + "name": "action_timeout_sec", + "label": "API Timeout Aktionen (Sekunden)", + "type": "number", + "help": "Timeout fuer laengere Aktionen wie Listen- oder Pi-hole-Updates. Standard: 120 Sekunden.", + "required": false + }, + { + "name": "dashboard_refresh_sec", + "label": "Refresh Dashboard (Sekunden)", + "type": "number", + "help": "Automatische Aktualisierung fuer das Dashboard. Standard: 1 Sekunde. 0 deaktiviert den Auto-Refresh.", + "required": false + }, + { + "name": "lists_refresh_sec", + "label": "Refresh Listen (Sekunden)", + "type": "number", + "help": "Automatische Aktualisierung fuer die Listen-Seite. Standard: 5 Sekunden. 0 deaktiviert den Auto-Refresh.", + "required": false + }, + { + "name": "queries_refresh_sec", + "label": "Refresh Queries (Sekunden)", + "type": "number", + "help": "Automatische Aktualisierung fuer die Queries-Seite. Standard: 5 Sekunden. 0 deaktiviert den Auto-Refresh.", + "required": false + }, + { + "name": "lists_primary_only", + "label": "Listen nur auf Primaer-Instanz bearbeiten", + "type": "checkbox", + "help": "Erzwingt Listen- und Domain-Aenderungen nur auf der Primaer-Instanz.", + "required": false + } + ] + } +} diff --git a/modules/pihole/pages/index.php b/modules/pihole/pages/index.php new file mode 100644 index 00000000..a73507ff --- /dev/null +++ b/modules/pihole/pages/index.php @@ -0,0 +1,260 @@ + [ + 'label' => 'Dashboard', + 'title' => 'Pi-hole Dashboard', + 'description' => 'Status, Blockings, Usage und direkte Steuerung fuer alle angebundenen Instanzen.', + 'nav_description' => 'Live-Status, Sperren und aggregierte DNS-Zahlen.', + ], + 'lists' => [ + 'label' => 'Listen', + 'title' => 'Listen & Domains', + 'description' => 'Gravity-Updates, Top-Domains und neue Listen-/Domain-Eintraege.', + 'nav_description' => 'Blocklisten, Whitelist und Adlist-Pflege.', + ], + 'queries' => [ + 'label' => 'Queries', + 'title' => 'Zugriffe & Blockings', + 'description' => 'Aktuelle Blockings und Top-Clients ueber alle Instanzen.', + 'nav_description' => 'Abfragen, blockierte Domains und Client-Nutzung.', + ], + 'instances' => [ + 'label' => 'Instanzen', + 'title' => 'Pi-hole Instanzen', + 'description' => 'Hosts, Zugangsdaten und Primaer-Instanz administrieren.', + 'nav_description' => 'Instanzen anlegen, testen, bearbeiten und loeschen.', + ], + 'setup' => [ + 'label' => 'Setup', + 'title' => 'Setup', + 'description' => 'Timeouts, API-Pfad und globale Pi-hole-Moduloptionen verwalten.', + 'nav_description' => 'Standard-Timeouts, TLS und Refresh-Regeln.', + ], +]; + +$assetVersion = static function (string $relativePath): string { + $fullPath = dirname(__DIR__) . '/' . ltrim($relativePath, '/'); + $mtime = is_file($fullPath) ? filemtime($fullPath) : false; + + return $mtime === false ? '0' : (string) $mtime; +}; + +$renderSetup = static function (): void { + $moduleName = 'pihole'; + $manifestPath = dirname(__DIR__) . '/module.json'; + $manifestRaw = is_file($manifestPath) ? file_get_contents($manifestPath) : false; + $manifest = is_string($manifestRaw) && trim($manifestRaw) !== '' ? json_decode($manifestRaw, true) : []; + $fields = is_array($manifest['setup']['fields'] ?? null) ? $manifest['setup']['fields'] : []; + $settings = modules()->settings($moduleName); + $notice = null; + $error = null; + + $getValue = static function (array $source, string $path): mixed { + $value = $source; + foreach (explode('.', $path) as $segment) { + if (!is_array($value) || !array_key_exists($segment, $value)) { + return null; + } + $value = $value[$segment]; + } + + return $value; + }; + + $setValue = static function (array &$target, string $path, mixed $value): void { + $segments = explode('.', $path); + $cursor = &$target; + foreach ($segments as $index => $segment) { + if ($index === count($segments) - 1) { + $cursor[$segment] = $value; + return; + } + if (!isset($cursor[$segment]) || !is_array($cursor[$segment])) { + $cursor[$segment] = []; + } + $cursor = &$cursor[$segment]; + } + }; + + if ($_SERVER['REQUEST_METHOD'] === 'POST' && (string) ($_POST['action'] ?? '') === 'save_setup') { + try { + $nextSettings = $settings; + foreach ($fields as $field) { + if (!is_array($field)) { + continue; + } + $name = trim((string) ($field['name'] ?? '')); + $type = trim((string) ($field['type'] ?? 'text')); + if ($name === '') { + continue; + } + $raw = $_POST['settings'][$name] ?? null; + $value = match ($type) { + 'checkbox' => $raw !== null, + 'number' => ($raw === null || $raw === '') ? null : (is_numeric((string) $raw) ? 0 + $raw : null), + default => is_string($raw) ? trim($raw) : '', + }; + $setValue($nextSettings, $name, $value); + } + modules()->saveSettings($moduleName, $nextSettings); + $settings = modules()->settings($moduleName); + $notice = 'Setup gespeichert.'; + } catch (\Throwable $e) { + $error = $e->getMessage(); + } + } + ?> +
+ +
+ +
+ +
+
+
+

Modul-Setup

+

Bestehende Nexus-Einstellungen werden uebernommen. Aenderungen werden lokal als Override gespeichert.

+
+
+
+ +
+ + + + +
+
+ +
+
+
+
+ '/apps/pihole/index.php?view=dashboard', + 'lists' => '/apps/pihole/index.php?view=lists', + 'queries' => '/apps/pihole/index.php?view=queries', + 'instances' => '/apps/pihole/index.php?view=instances', + 'setup' => '/apps/pihole/index.php?view=setup', + ]; + + ob_start(); + if ($view === 'setup') { + $renderSetup(); + } else { + require dirname(__DIR__) . '/partials/' . $view . '.php'; + } + $innerContent = (string) ob_get_clean(); + ?> +
+
+ +
+
+
+
+

Pi-hole

+

+

+
+
+ Desktop-App + Netzwerk-Tools + View: +
+
+ +
+
+
+
+ + + + + + Pi-hole + + + + + + + + + + diff --git a/modules/pihole/partials/dashboard.php b/modules/pihole/partials/dashboard.php new file mode 100644 index 00000000..ae9c2713 --- /dev/null +++ b/modules/pihole/partials/dashboard.php @@ -0,0 +1,134 @@ +settings('pihole'); +$instances = module_fn('pihole', 'instances'); +$hasConfig = !empty($instances); +$refreshSeconds = (int)($settings['dashboard_refresh_sec'] ?? 1); +if ($refreshSeconds < 0) { + $refreshSeconds = 1; +} +?> +
+ +
+
+ Hosts + Instanzen verwalten +
+ +
Keine Pi-hole Instanzen vorhanden. Bitte zuerst hinzufuegen.
+
+ Neue Instanz
+ +
+ +
+
+ +
+
+ + Primaer + +
+ +
+ +
+ + + + +
+
+
DNS Queries (heute)
+
+
+
+
Ads geblockt
+
+
+
+
+
Unique Clients
+
+
+
+
Status
+
+
+
+ +
+
+ Blocker steuern (alle Instanzen) + Letztes Update: – +
+
+ + + + + + +
+ + +
+
+
+ +
+
+ Instanzen + Einzeln steuerbar & getrennte Updates +
+
+
+ +
+
+ Usage (Aggregiert) + Query-Typen und Weiterleitungen +
+
+
+
Query-Typen
+
+
+
+
Forward Destinations
+
+
+
+
+ +
+ + diff --git a/modules/pihole/partials/instances.php b/modules/pihole/partials/instances.php new file mode 100644 index 00000000..12e9853f --- /dev/null +++ b/modules/pihole/partials/instances.php @@ -0,0 +1,408 @@ +settings($moduleName); +$notice = null; +$error = null; +$testResults = []; + +$httpRequest = static function (string $method, string $url, array $headers, ?string $body, bool $verify, int $timeout): array { + $raw = ''; + $httpCode = 0; + $requestError = ''; + + 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)) { + $requestError = 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); + if ($raw === '') { + $requestError = 'HTTP request failed'; + } else { + $httpCode = 200; + } + } + + if ($requestError !== '') { + return ['ok' => false, 'http_code' => $httpCode, 'error' => $requestError, 'raw' => $raw, 'url' => $url]; + } + + $decoded = json_decode($raw, true); + if (!is_array($decoded)) { + return ['ok' => false, 'http_code' => $httpCode, 'error' => 'invalid_json', 'raw' => $raw, 'url' => $url]; + } + + return ['ok' => true, 'http_code' => $httpCode, 'data' => $decoded, 'url' => $url]; +}; + +$runConnectionTest = static function (array $instance, array $settings) use ($httpRequest): array { + $verifyTls = !isset($settings['verify_tls']) || $settings['verify_tls'] === '1' || $settings['verify_tls'] === 1 || $settings['verify_tls'] === true; + $timeout = (int)($settings['api_timeout_sec'] ?? 8); + if ($timeout <= 0) { + $timeout = 8; + } + + $url = rtrim((string)($instance['url'] ?? ''), '/'); + $password = trim((string)($instance['password'] ?? '')); + $v6AuthUrl = $url . '/api/auth'; + $v6Payload = json_encode(['password' => $password], JSON_UNESCAPED_UNICODE); + $v6Auth = $httpRequest('POST', $v6AuthUrl, ['Accept: application/json', 'Content-Type: application/json'], $v6Payload, $verifyTls, $timeout); + + if ($v6Auth['ok']) { + $session = (array)(($v6Auth['data']['session'] ?? []) ?: []); + $sid = trim((string)($session['sid'] ?? '')); + if ($sid !== '') { + $probe = $httpRequest('GET', $url . '/api/stats/summary', ['Accept: application/json', 'X-FTL-SID: ' . $sid], null, $verifyTls, $timeout); + if ($probe['ok']) { + return [ + 'ok' => true, + 'status' => 'ok', + 'message' => 'Verbindung OK. API v6 antwortet.', + 'version' => 6, + 'details' => ['auth' => $v6Auth, 'probe' => $probe], + ]; + } + + return [ + 'ok' => false, + 'status' => 'error', + 'message' => 'v6 Auth OK, aber Stats-Endpoint antwortet nicht sauber.', + 'version' => 6, + 'details' => ['auth' => $v6Auth, 'probe' => $probe], + ]; + } + } + + $legacyUrl = $url . '/admin/api.php?summaryRaw'; + if ($password !== '') { + $legacyUrl .= '&auth=' . rawurlencode($password); + } + $v5Probe = $httpRequest('GET', $legacyUrl, ['Accept: application/json'], null, $verifyTls, $timeout); + if ($v5Probe['ok']) { + return [ + 'ok' => true, + 'status' => 'ok', + 'message' => 'Verbindung OK. API v5 antwortet.', + 'version' => 5, + 'details' => ['auth' => $v6Auth, 'probe' => $v5Probe], + ]; + } + + $status = 'error'; + $message = 'Unbekannter Fehler.'; + $httpCode = (int)($v6Auth['http_code'] ?? $v5Probe['http_code'] ?? 0); + $requestError = (string)($v6Auth['error'] ?? $v5Probe['error'] ?? 'error'); + if ($httpCode === 0) { + $status = 'unreachable'; + $message = 'Host nicht erreichbar oder kein HTTP-Response.'; + } elseif ($httpCode === 401 || $httpCode === 403) { + $status = 'auth'; + $message = 'Passwort oder App-Passwort falsch oder nicht berechtigt.'; + } elseif ($requestError === 'invalid_json') { + $status = 'invalid'; + $message = 'API antwortet nicht mit JSON. URL pruefen.'; + } else { + $message = 'API Fehler: ' . $requestError . ' (HTTP ' . $httpCode . ')'; + } + + return [ + 'ok' => false, + 'status' => $status, + 'message' => $message, + 'details' => ['auth' => $v6Auth, 'probe' => $v5Probe], + ]; +}; + +$loadInstances = function (array $settings): array { + $normalizeSecret = static function (array $row): string { + $password = trim((string)($row['password'] ?? '')); + if ($password !== '') { + return $password; + } + + return trim((string)($row['token'] ?? '')); + }; + + $instances = []; + $rawJson = trim((string)($settings['instances_json'] ?? '')); + if ($rawJson !== '') { + $decoded = json_decode($rawJson, true); + if (is_array($decoded)) { + foreach ($decoded as $row) { + if (!is_array($row)) { + continue; + } + $id = trim((string)($row['id'] ?? '')); + $url = trim((string)($row['url'] ?? '')); + if ($id === '' || $url === '') { + continue; + } + $instances[$id] = [ + 'id' => $id, + 'name' => trim((string)($row['name'] ?? '')) ?: $id, + 'url' => $url, + 'password' => $normalizeSecret($row), + 'is_primary' => !empty($row['is_primary']), + ]; + } + } + } + + if (!$instances) { + foreach (['primary', 'secondary'] as $key) { + $url = trim((string)($settings[$key . '_url'] ?? '')); + if ($url === '') { + continue; + } + $instances[$key] = [ + 'id' => $key, + 'name' => trim((string)($settings[$key . '_name'] ?? '')) ?: ($key === 'primary' ? 'Primaer' : 'Sekundaer'), + 'url' => $url, + 'password' => trim((string)($settings[$key . '_token'] ?? '')), + 'is_primary' => $key === 'primary', + ]; + } + } + + return $instances; +}; + +$instances = $loadInstances($settings); + +$sanitizeId = function (string $id): string { + $id = preg_replace('/[^a-zA-Z0-9_-]/', '', $id); + return trim((string)$id); +}; + +$saveInstances = function (array $settings, array $instances): void { + $payload = $settings; + $payload['instances_json'] = json_encode(array_values($instances), JSON_UNESCAPED_UNICODE); + modules()->saveSettings('pihole', $payload); +}; + +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $deleteId = trim((string)($_POST['delete_id'] ?? '')); + $testId = trim((string)($_POST['test_id'] ?? '')); + $currentId = trim((string)($_POST['current_id'] ?? '')); + $instanceId = trim((string)($_POST['instance_id'] ?? '')); + $name = trim((string)($_POST['name'] ?? '')); + $url = trim((string)($_POST['url'] ?? '')); + $password = trim((string)($_POST['password'] ?? '')); + $isPrimary = isset($_POST['is_primary']); + + if ($testId !== '') { + if (isset($instances[$testId])) { + module_debug_push('pihole', [ + 'label' => 'connection.test.start', + 'instance_id' => $testId, + 'instance_name' => (string)($instances[$testId]['name'] ?? $testId), + 'url' => (string)($instances[$testId]['url'] ?? ''), + ]); + + $result = $runConnectionTest($instances[$testId], $settings); + $testResults[$testId] = $result; + + module_debug_push('pihole', [ + 'label' => 'connection.test.result', + 'instance_id' => $testId, + 'instance_name' => (string)($instances[$testId]['name'] ?? $testId), + 'result' => $result, + ]); + + $notice = $result['message'] ?? null; + } else { + $error = 'Test-Instanz nicht gefunden.'; + } + } elseif ($deleteId !== '') { + if (isset($instances[$deleteId])) { + unset($instances[$deleteId]); + $notice = 'Instanz geloescht.'; + } + } else { + $instanceId = $sanitizeId($instanceId); + if ($instanceId === '' || $url === '') { + $error = 'Bitte ID und URL angeben.'; + } elseif ($currentId === '' && isset($instances[$instanceId])) { + $error = 'Die Instanz-ID ist bereits vergeben. Bitte eine eindeutige ID verwenden.'; + } elseif ($currentId !== '' && $currentId !== $instanceId && isset($instances[$instanceId])) { + $error = 'Die neue Instanz-ID ist bereits vergeben. Bitte eine eindeutige ID verwenden.'; + } else { + $existingPassword = ''; + if ($currentId !== '' && isset($instances[$currentId])) { + $existingPassword = (string)($instances[$currentId]['password'] ?? ''); + } + $passwordToStore = $password !== '' ? $password : $existingPassword; + if ($currentId !== '' && $currentId !== $instanceId) { + unset($instances[$currentId]); + } + $instances[$instanceId] = [ + 'id' => $instanceId, + 'name' => $name !== '' ? $name : $instanceId, + 'url' => $url, + 'password' => $passwordToStore, + 'is_primary' => $isPrimary, + ]; + + if ($isPrimary) { + foreach ($instances as $id => &$row) { + $row['is_primary'] = ($id === $instanceId); + } + unset($row); + $settings['primary_id'] = $instanceId; + } + + $notice = $currentId !== '' ? 'Instanz aktualisiert.' : 'Instanz gespeichert.'; + } + } + + if (!$error) { + $saveInstances($settings, $instances); + $settings = modules()->settings($moduleName); + $instances = $loadInstances($settings); + } +} + +$primaryId = trim((string)($settings['primary_id'] ?? '')); +if ($primaryId === '') { + foreach ($instances as $id => $row) { + if (!empty($row['is_primary'])) { + $primaryId = $id; + break; + } + } +} +?> +
+
+
+
+

Instanzen

+

Pi-hole Instanzen hinzufuegen, bearbeiten und loeschen.

+
+
+ +
+
+ + +
+ +
+ +
+ +
+ + +
+ +
Keine Instanzen vorhanden.
+ + +
+
+
+ +
ID:
+
URL:
+
+ + Primaer + +
+
+ +
+ + +
+
+ + +
+
+
+
+ + +
+
+
+ + diff --git a/modules/pihole/partials/lists.php b/modules/pihole/partials/lists.php new file mode 100644 index 00000000..5e7eed87 --- /dev/null +++ b/modules/pihole/partials/lists.php @@ -0,0 +1,88 @@ +settings('pihole'); +$instances = module_fn('pihole', 'instances'); +$hasConfig = !empty($instances); +$refreshSeconds = (int)($settings['lists_refresh_sec'] ?? 5); +if ($refreshSeconds < 0) { + $refreshSeconds = 5; +} +?> +
+ +
+ Keine Instanzen konfiguriert +
Bitte zuerst eine Pi-hole Instanz hinzufuegen.
+
Instanzen verwalten
+
+ +
+
+ Listen-Updates + Gravity / Blocklisten fuer alle oder einzelne Instanzen neu laden +
+
+ + + + +
+
+
+ +
+
+
+ Top geblockte Domains (Aggregiert) + Letzte Statistiken +
+
+
+
+
+ Top erlaubte Domains (Aggregiert) + Letzte Statistiken +
+
+
+
+ +
+
+ Domainlisten erweitern + Eintraege werden auf der Primaer-Instanz gesetzt +
+
+ + + +
+
+
+ +
+
+ Adlist-URL hinzufuegen + Optional: unterstuetzt nur wenn die API den Endpunkt anbietet. +
+
+ + +
+
+
+ +
diff --git a/modules/pihole/partials/queries.php b/modules/pihole/partials/queries.php new file mode 100644 index 00000000..3009d56b --- /dev/null +++ b/modules/pihole/partials/queries.php @@ -0,0 +1,35 @@ +settings('pihole'); +$instances = module_fn('pihole', 'instances'); +$hasConfig = !empty($instances); +$refreshSeconds = (int)($settings['queries_refresh_sec'] ?? 5); +if ($refreshSeconds < 0) { + $refreshSeconds = 5; +} +?> +
+ +
+ Keine Instanzen konfiguriert +
Bitte zuerst eine Pi-hole Instanz hinzufuegen.
+
Instanzen verwalten
+
+ +
+
+
+ Aktuelle Blockings + Letzte geblockte Domains +
+
+
+
+
+ Top Clients (Aggregiert) + Anfragen nach Client +
+
+
+
+ +
diff --git a/public/api/pihole/index.php b/public/api/pihole/index.php new file mode 100644 index 00000000..f62eecbb --- /dev/null +++ b/public/api/pihole/index.php @@ -0,0 +1,5 @@ +