diff --git a/modules/pihole/assets/pihole.css b/modules/pihole/assets/pihole.css new file mode 100644 index 0000000..2de22a5 --- /dev/null +++ b/modules/pihole/assets/pihole.css @@ -0,0 +1,173 @@ +.pihole-page .card { background: var(--panel); } + +.pihole-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 14px; +} + +.pihole-stat { + padding: 16px; + background: var(--panel-2); +} + +.pihole-stat-value { + font-size: 1.6rem; + font-weight: 700; + margin-top: 6px; +} + +.pihole-stat-sub { + margin-top: 4px; + color: var(--muted); +} + +.pihole-section-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.pihole-actions { + margin-top: 12px; + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; +} + +.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 { + padding: 16px; + background: var(--panel-2); +} + +.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(0, 179, 164, 0.15); + color: #0a6b63; +} + +.pihole-status.is-disabled { + background: rgba(255, 90, 61, 0.15); + color: #a83a28; +} + +.pihole-status.is-partial { + background: rgba(255, 166, 0, 0.18); + color: #8a5a00; +} + +.pihole-split { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 14px; +} + +.pihole-list { + display: grid; + gap: 8px; + margin-top: 12px; +} + +.pihole-list-row { + display: flex; + justify-content: space-between; + gap: 12px; + padding: 8px 10px; + border-radius: 12px; + background: rgba(255, 255, 255, 0.7); +} + +.pihole-list-row strong { + font-weight: 600; +} + +.pihole-update { + margin-top: 10px; + font-size: 0.95rem; + color: var(--muted); +} + +.pihole-blocked { + display: grid; + gap: 8px; + margin-top: 12px; +} + +.pihole-blocked-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 8px 10px; + border-radius: 12px; + background: rgba(255, 90, 61, 0.08); +} + +.pihole-form { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 12px; + align-items: end; + margin-top: 12px; +} + +.pihole-form label { + display: grid; + gap: 6px; +} + +@media (max-width: 680px) { + .pihole-actions { + flex-direction: column; + align-items: stretch; + } + + .pihole-inline { + width: 100%; + } + + .pihole-inline input { + width: 100%; + } +} diff --git a/modules/pihole/assets/pihole.js b/modules/pihole/assets/pihole.js new file mode 100644 index 0000000..11d8603 --- /dev/null +++ b/modules/pihole/assets/pihole.js @@ -0,0 +1,251 @@ +(() => { + const page = document.querySelector('[data-pihole-page]'); + if (!page) return; + + const fmt = new Intl.NumberFormat('de-DE'); + const fmtDate = (ts) => { + if (!ts) return '–'; + const d = new Date(ts * 1000); + return d.toLocaleString('de-DE'); + }; + + const apiCall = async (action, payload = {}) => { + const res = await fetch(`/module/pihole/api?action=${encodeURIComponent(action)}`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + } + ); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + throw new Error(data.error || `HTTP ${res.status}`); + } + return data; + }; + + const setText = (el, value) => { + if (!el) return; + el.textContent = value; + }; + + 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 []; + const entries = Object.entries(map) + .filter(([, value]) => typeof value === 'number' || typeof value === 'string') + .map(([key, value]) => [key, Number(value)]) + .sort((a, b) => b[1] - a[1]); + return entries.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 = document.querySelector('[data-instance-cards]'); + const tpl = document.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))); + const percent = summary.ads_percentage_today || summary.ads_percentage || 0; + setText(root.querySelector('[data-instance-percent]'), `${Number(percent).toFixed(2)}%`); + setStatusBadge(root.querySelector('[data-instance-status]'), summary.status || 'unknown'); + + const actions = root.querySelectorAll('[data-instance]'); + actions.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 (entry.updates && entry.updates.available) { + updateEl.textContent = 'Updates verfuegbar'; + } else { + updateEl.textContent = 'Keine Updates erkannt'; + } + + holder.appendChild(node); + }); + }; + + const bindActionButtons = () => { + document.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 scope = btn.closest('.pihole-instance') || document; + const customInput = scope.querySelector(`[data-custom-minutes="${instance}"]`); + let payload = { instance }; + + if (action === 'disable') { + payload.minutes = Number(minutes || 0); + } + if (action === 'disable-custom') { + payload.minutes = Number(customInput?.value || 0); + } + + try { + if (action === 'enable') { + await apiCall('enable', payload); + } else if (action === 'disable' || action === 'disable-custom') { + if (!payload.minutes || payload.minutes <= 0) { + alert('Bitte Minuten angeben.'); + return; + } + await apiCall('disable', payload); + } else if (action === 'gravity') { + await apiCall('gravity', payload); + const status = document.querySelector('[data-list-update-status]'); + if (status) status.textContent = 'Listen-Update gestartet.'; + } else if (action === 'update') { + await apiCall('update', payload); + } + await loadDashboard(); + } catch (err) { + alert(`Aktion fehlgeschlagen: ${err.message}`); + } + }); + }; + + const bindForms = () => { + const domainForm = document.querySelector('[data-domain-form]'); + if (domainForm) { + 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 = document.querySelector('[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 = document.querySelector('[data-adlist-form]'); + if (adlistForm) { + adlistForm.addEventListener('submit', async (e) => { + e.preventDefault(); + const formData = new FormData(adlistForm); + const url = String(formData.get('url') || '').trim(); + const status = document.querySelector('[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 loadDashboard = async () => { + try { + const data = await apiCall('dashboard'); + if (!data.ok) throw new Error(data.error || 'API error'); + + const summary = data.aggregate?.summary || {}; + setText(document.querySelector('[data-summary-dns]'), fmt.format(Number(summary.dns_queries_today || 0))); + setText(document.querySelector('[data-summary-blocked]'), fmt.format(Number(summary.ads_blocked_today || 0))); + setText(document.querySelector('[data-summary-percent]'), `${Number(summary.ads_percentage_today || 0).toFixed(2)}%`); + setText(document.querySelector('[data-summary-clients]'), fmt.format(Number(summary.unique_clients || 0))); + setStatusBadge(document.querySelector('[data-summary-status]'), summary.status || 'unknown'); + setText(document.querySelector('[data-summary-last-refresh]'), `Letztes Update: ${fmtDate(data.ts)}`); + + renderInstances(data.instances || {}); + renderList(document.querySelector('[data-query-types]'), data.aggregate?.query_types, 'Keine Daten'); + renderList(document.querySelector('[data-forward-destinations]'), data.aggregate?.forward_destinations, 'Keine Daten'); + renderList(document.querySelector('[data-top-ads]'), data.aggregate?.top_ads, 'Keine Daten'); + renderList(document.querySelector('[data-top-queries]'), data.aggregate?.top_queries, 'Keine Daten'); + renderList(document.querySelector('[data-top-clients]'), data.aggregate?.query_sources, 'Keine Daten'); + renderBlocked(document.querySelector('[data-recent-blocked]'), data.aggregate?.recent_blocked); + } catch (err) { + const message = document.createElement('div'); + message.className = 'card'; + message.style.marginTop = '1rem'; + message.textContent = `Fehler beim Laden der Pi-hole Daten: ${err.message}`; + page.appendChild(message); + } + }; + + bindActionButtons(); + bindForms(); + loadDashboard(); +})(); diff --git a/modules/pihole/bootstrap.php b/modules/pihole/bootstrap.php new file mode 100644 index 0000000..e5792a9 --- /dev/null +++ b/modules/pihole/bootstrap.php @@ -0,0 +1,55 @@ +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; + + $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, '/'), + 'token' => trim((string)($settings[$tokenKey] ?? '')), + 'api_path' => $apiPath, + 'timeout' => $timeout, + 'verify_tls' => $verifyTls, + 'is_primary' => $key === 'primary', + ]; + } + + 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/module.json b/modules/pihole/module.json new file mode 100644 index 0000000..31ef237 --- /dev/null +++ b/modules/pihole/module.json @@ -0,0 +1,36 @@ +{ + "title": "Pi-hole", + "version": "0.1.0", + "description": "Pi-hole Monitoring, Listen und Steuerung fuer zwei Instanzen.", + "menu": [ + { "label": "Dashboard", "href": "/module/pihole" }, + { "label": "Listen", "href": "/module/pihole/lists" }, + { "label": "Zugriffe", "href": "/module/pihole/queries" }, + { "label": "Setup", "href": "/modules/setup/pihole" } + ], + "sidebar": { + "enabled": true, + "collapsible": true, + "default": "collapsed", + "items": [ + { "label": "Dashboard", "href": "/module/pihole" }, + { "label": "Listen", "href": "/module/pihole/lists" }, + { "label": "Zugriffe", "href": "/module/pihole/queries" }, + { "label": "Setup", "href": "/modules/setup/pihole" } + ] + }, + "setup": { + "fields": [ + { "name": "primary_name", "label": "Primaer-Name", "type": "text", "required": false, "help": "Anzeige-Name (z.B. Pi-hole Main)" }, + { "name": "primary_url", "label": "Primaer-URL", "type": "text", "required": true, "help": "Basis-URL der Instanz, z.B. http://pi-hole.local" }, + { "name": "primary_token", "label": "Primaer-API Token", "type": "password", "required": false, "help": "Token aus dem Pi-hole Admin (API Token)." }, + { "name": "secondary_name", "label": "Sekundaer-Name", "type": "text", "required": false, "help": "Anzeige-Name (z.B. Pi-hole Backup)" }, + { "name": "secondary_url", "label": "Sekundaer-URL", "type": "text", "required": false, "help": "Basis-URL der zweiten Instanz" }, + { "name": "secondary_token", "label": "Sekundaer-API Token", "type": "password", "required": false }, + { "name": "api_path", "label": "API Pfad", "type": "text", "required": false, "help": "Standard: /admin/api.php" }, + { "name": "api_timeout_sec", "label": "API Timeout (Sek.)", "type": "number", "required": false, "help": "Standard 8" }, + { "name": "verify_tls", "label": "TLS Verifikation aktiv", "type": "checkbox", "required": false, "help": "Deaktivieren nur wenn das Zertifikat unsicher ist." }, + { "name": "lists_primary_only", "label": "Listen nur auf Primaer", "type": "checkbox", "required": false, "help": "Listen-Updates und neue Listen nur auf der Primaer-Instanz ausfuehren." } + ] + } +} diff --git a/modules/pihole/pages/api.php b/modules/pihole/pages/api.php new file mode 100644 index 0000000..0191056 --- /dev/null +++ b/modules/pihole/pages/api.php @@ -0,0 +1,463 @@ + true, + CURLOPT_TIMEOUT => $timeout, + CURLOPT_CONNECTTIMEOUT => $timeout, + CURLOPT_SSL_VERIFYPEER => $verify, + CURLOPT_SSL_VERIFYHOST => $verify ? 2 : 0, + ]); + $raw = (string)curl_exec($ch); + if ($raw === '' && curl_errno($ch)) { + $error = curl_error($ch); + } + $httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + } else { + $ctx = stream_context_create([ + 'http' => [ + 'method' => 'GET', + 'timeout' => $timeout, + ], + 'ssl' => [ + 'verify_peer' => $verify, + 'verify_peer_name' => $verify, + ], + ]); + $raw = (string)@file_get_contents($full, false, $ctx); + $httpCode = 200; + if ($raw === '') { + $error = 'HTTP request failed'; + } + } + + if ($error !== '') { + return ['ok' => false, 'error' => $error, 'http_code' => $httpCode, 'url' => $full]; + } + + $data = json_decode($raw, true); + if (!is_array($data)) { + return ['ok' => false, 'error' => 'invalid_json', 'http_code' => $httpCode, 'raw' => $raw, 'url' => $full]; + } + + return ['ok' => true, 'data' => $data, 'http_code' => $httpCode, 'url' => $full]; +}; + +$pickInstances = function (string $target) use ($instances): array { + if ($target === 'all') { + return $instances; + } + 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); + } + + $aggregate = [ + 'summary' => [ + 'dns_queries_today' => 0, + 'ads_blocked_today' => 0, + 'unique_clients' => 0, + 'unique_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 = []; + + foreach ($instances as $id => $instance) { + $errors = []; + + $summary = $apiRequest($instance, ['summaryRaw' => 1]); + if (!$summary['ok']) { + $errors[] = ['scope' => 'summary', 'error' => $summary['error'] ?? 'error']; + } + + $topItems = $apiRequest($instance, ['topItems' => 50]); + if (!$topItems['ok']) { + $errors[] = ['scope' => 'topItems', 'error' => $topItems['error'] ?? 'error']; + } + + $queryTypes = $apiRequest($instance, ['getQueryTypes' => 1]); + if (!$queryTypes['ok']) { + $errors[] = ['scope' => 'queryTypes', 'error' => $queryTypes['error'] ?? 'error']; + } + + $querySources = $apiRequest($instance, ['getQuerySources' => 1]); + if (!$querySources['ok']) { + $errors[] = ['scope' => 'querySources', 'error' => $querySources['error'] ?? 'error']; + } + + $forwardDest = $apiRequest($instance, ['getForwardDestinations' => 1]); + if (!$forwardDest['ok']) { + $errors[] = ['scope' => 'forwardDestinations', 'error' => $forwardDest['error'] ?? 'error']; + } + + $recentBlocked = $apiRequest($instance, ['recentBlocked' => 30]); + if (!$recentBlocked['ok']) { + $errors[] = ['scope' => 'recentBlocked', 'error' => $recentBlocked['error'] ?? 'error']; + } + + $versions = $apiRequest($instance, ['versions' => 1]); + if (!$versions['ok']) { + $errors[] = ['scope' => 'versions', 'error' => $versions['error'] ?? 'error']; + } + + $summaryData = $summary['ok'] ? $summary['data'] : null; + $topData = $topItems['ok'] ? $topItems['data'] : null; + $queryTypesData = $queryTypes['ok'] ? $extractQueryTypes($queryTypes['data']) : null; + $querySourcesData = $querySources['ok'] ? $extractSources($querySources['data']) : null; + $forwardDestData = $forwardDest['ok'] ? $extractForwardDestinations($forwardDest['data']) : null; + $recentData = $recentBlocked['ok'] ? $recentBlocked['data'] : null; + $versionsData = $versions['ok'] ? $versions['data'] : null; + $updates = $parseUpdates(is_array($versionsData) ? $versionsData : null); + + if (is_array($summaryData)) { + $aggregate['summary']['dns_queries_today'] += (int)($summaryData['dns_queries_today'] ?? 0); + $aggregate['summary']['ads_blocked_today'] += (int)($summaryData['ads_blocked_today'] ?? 0); + $aggregate['summary']['unique_clients'] += (int)($summaryData['unique_clients'] ?? 0); + $aggregate['summary']['unique_domains'] += (int)($summaryData['unique_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; + + $respond([ + 'ok' => true, + 'ts' => time(), + 'instances' => $instancePayloads, + 'aggregate' => $aggregate, + ]); +} + +if ($action === 'disable') { + require_admin(); + $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) { + $result = $apiRequest($instance, ['disable' => $minutes * 60]); + $results[$id] = $result; + } + $respond(['ok' => true, 'results' => $results]); +} + +if ($action === 'enable') { + require_admin(); + $target = (string)($payload['instance'] ?? 'all'); + $targets = $pickInstances($target); + if (!$targets) { + $respond(['ok' => false, 'error' => 'invalid_instance'], 400); + } + + $results = []; + foreach ($targets as $id => $instance) { + $result = $apiRequest($instance, ['enable' => 1]); + $results[$id] = $result; + } + $respond(['ok' => true, 'results' => $results]); +} + +if ($action === 'gravity') { + require_admin(); + $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) { + $result = $apiRequest($instance, ['updateGravity' => 1]); + $results[$id] = $result; + } + $respond(['ok' => true, 'results' => $results]); +} + +if ($action === 'domain_add') { + require_admin(); + $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) { + $result = $apiRequest($instance, $params); + $results[$id] = $result; + } + $respond(['ok' => true, 'results' => $results]); +} + +if ($action === 'adlist_add') { + require_admin(); + $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) { + $result = $apiRequest($instance, ['list' => 'adlist', 'add' => $url]); + $results[$id] = $result; + } + $respond(['ok' => true, 'results' => $results]); +} + +if ($action === 'update') { + require_admin(); + $target = (string)($payload['instance'] ?? 'primary'); + $targets = $pickInstances($target); + if (!$targets) { + $respond(['ok' => false, 'error' => 'invalid_instance'], 400); + } + + $results = []; + foreach ($targets as $id => $instance) { + $result = $apiRequest($instance, ['update' => 1]); + $results[$id] = $result; + } + $respond(['ok' => true, 'results' => $results]); +} + +$respond(['ok' => false, 'error' => 'unknown_action'], 400); diff --git a/modules/pihole/pages/asset.php b/modules/pihole/pages/asset.php new file mode 100644 index 0000000..cff4b24 --- /dev/null +++ b/modules/pihole/pages/asset.php @@ -0,0 +1,30 @@ + $base . '/pihole.css', + 'pihole.js' => $base . '/pihole.js', +]; + +if (!isset($map[$file])) { + http_response_code(404); + exit('Not found'); +} + +$path = $map[$file]; +if (!$base || !is_file($path) || !str_starts_with($path, $base)) { + http_response_code(404); + exit('Not found'); +} + +$ext = pathinfo($path, PATHINFO_EXTENSION); +if ($ext === 'css') { + header('Content-Type: text/css; charset=utf-8'); +} elseif ($ext === 'js') { + header('Content-Type: application/javascript; charset=utf-8'); +} else { + header('Content-Type: application/octet-stream'); +} + +readfile($path); +exit; diff --git a/modules/pihole/pages/index.php b/modules/pihole/pages/index.php new file mode 100644 index 0000000..4dedd37 --- /dev/null +++ b/modules/pihole/pages/index.php @@ -0,0 +1,114 @@ +assets(); +$assets->addStyle('/module/pihole/asset?file=pihole.css'); +$assets->addScript('/module/pihole/asset?file=pihole.js', 'footer', true); + +$instances = module_fn('pihole', 'instances'); +$hasConfig = !empty($instances); +?> +
Status, Blockings, Usage und Steuerung fuer beide Instanzen.
+ + +Top-Domains, Listen-Updates und neue Eintraege (Primaer-Instanz).
+ + +Aktuelle Blockings, Top Clients und Status pro Instanz.
+ + +