diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100644
index 0000000..129d4b9
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -0,0 +1,29 @@
+name: API Tests
+
+on:
+ push:
+ branches:
+ - main
+ pull_request:
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: '3.10'
+
+ - name: Install dependencies
+ run: |
+ python -m pip install --upgrade pip
+ pip install -r requirements.txt
+ pip install pytest
+
+ - name: Run tests
+ run: |
+ PYTHONPATH=. pytest tests/
diff --git a/README.de.md b/README.de.md
index 7498ae3..027a3cd 100644
--- a/README.de.md
+++ b/README.de.md
@@ -232,9 +232,14 @@ Backups werden in `/home/pi/pihole-rescue-backups/` gespeichert und enthalten:
---
-## 🐍 Optional: Suite REST API
+## 🐍 Optional: Suite REST API & Dashboard
`start_suite.py` ist eine **optionale** FastAPI-Anwendung für Monitoring.
+Es enthält auch ein modernes, responsives **Dashboard** zur Überwachung Ihres DNS-Stacks.
+
+- **Dashboard-Zugriff**: Standardmäßig erreichbar unter `http://127.0.0.1:8090/`.
+- **Fernzugriff**: Ein sicherer Fernzugriff wird über SSH-Portweiterleitung (`ssh -L 8090:127.0.0.1:8090 user@pihole-server`) oder einen Reverse-Proxy empfohlen.
+- **Authentifizierung**: Für das Dashboard-Login und die API-Endpunkte ist der `SUITE_API_KEY` erforderlich.
```bash
# Konfiguration
diff --git a/README.md b/README.md
index 8f55f65..1e5c901 100644
--- a/README.md
+++ b/README.md
@@ -234,9 +234,14 @@ Backups are stored in `/home/pi/pihole-rescue-backups/` and include:
---
-## 🐍 Optional: Suite REST API
+## 🐍 Optional: Suite REST API & Dashboard
`start_suite.py` is an **optional** FastAPI server providing a JSON API for monitoring.
+It also hosts a modern, responsive, read-only web **Dashboard** for monitoring your DNS stack.
+
+- **Dashboard Access**: By default accessible at `http://127.0.0.1:8090/`.
+- **Remote Access**: Secure remote access is recommended via SSH port forwarding (`ssh -L 8090:127.0.0.1:8090 user@pihole-server`) or a reverse proxy.
+- **Authentication**: Dashboard login and API endpoints require the `SUITE_API_KEY`.
```bash
# Setup
diff --git a/dashboard/app.js b/dashboard/app.js
new file mode 100644
index 0000000..f9d05e7
--- /dev/null
+++ b/dashboard/app.js
@@ -0,0 +1,239 @@
+document.addEventListener('DOMContentLoaded', () => {
+ // DOM Elements
+ const authOverlay = document.getElementById('auth-overlay');
+ const authForm = document.getElementById('auth-form');
+ const apiKeyInput = document.getElementById('api-key');
+ const authError = document.getElementById('auth-error');
+ const dashboard = document.getElementById('dashboard');
+ const logoutBtn = document.getElementById('logout-btn');
+ const lastUpdateEl = document.getElementById('last-update');
+
+ // API Key management
+ let apiKey = sessionStorage.getItem('suite_api_key');
+
+ // Initialization
+ if (apiKey) {
+ showDashboard();
+ fetchData();
+ } else {
+ showAuth();
+ }
+
+ // Event Listeners
+ authForm.addEventListener('submit', async (e) => {
+ e.preventDefault();
+ const key = apiKeyInput.value.trim();
+ if (!key) return;
+
+ // Verify key by hitting /health
+ try {
+ const res = await fetch('/health', {
+ headers: { 'X-API-Key': key }
+ });
+
+ if (res.ok) {
+ apiKey = key;
+ sessionStorage.setItem('suite_api_key', key);
+ authError.classList.add('hidden');
+ showDashboard();
+ fetchData();
+ } else {
+ authError.textContent = 'Ungültiger API-Schlüssel.';
+ authError.classList.remove('hidden');
+ }
+ } catch (err) {
+ authError.textContent = 'Verbindungsfehler zur API.';
+ authError.classList.remove('hidden');
+ }
+ });
+
+ logoutBtn.addEventListener('click', () => {
+ sessionStorage.removeItem('suite_api_key');
+ apiKey = null;
+ apiKeyInput.value = '';
+ showAuth();
+ });
+
+ // UI State Management
+ function showAuth() {
+ authOverlay.classList.remove('hidden');
+ dashboard.classList.add('hidden');
+ apiKeyInput.focus();
+ }
+
+ function showDashboard() {
+ authOverlay.classList.add('hidden');
+ dashboard.classList.remove('hidden');
+ }
+
+ function updateStatusIndicator(elementId, status) {
+ const el = document.getElementById(elementId);
+ if (!el) return;
+ el.className = 'status-indicator ' + status;
+ }
+
+ function updateElementText(elementId, text) {
+ const el = document.getElementById(elementId);
+ if (el) el.textContent = text;
+ }
+
+ // Data Fetching
+ async function apiFetch(endpoint) {
+ try {
+ const res = await fetch(endpoint, {
+ headers: { 'X-API-Key': apiKey }
+ });
+ if (res.status === 401) {
+ logoutBtn.click();
+ throw new Error('Unauthorized');
+ }
+ if (!res.ok) throw new Error('API Error');
+ return await res.json();
+ } catch (error) {
+ console.error(`Error fetching ${endpoint}:`, error);
+ return null;
+ }
+ }
+
+ async function fetchData() {
+ const now = new Date();
+ updateElementText('last-update', `Letzte Aktualisierung: ${now.toLocaleTimeString()}`);
+
+ updateStatusIndicator('overall-status', 'loading');
+ updateElementText('overall-status .status-text', 'Wird geladen...');
+
+ const [health, urls, pihole, unbound, netalertx, stats] = await Promise.all([
+ apiFetch('/health'),
+ apiFetch('/urls'),
+ apiFetch('/pihole'),
+ apiFetch('/unbound'),
+ apiFetch('/netalertx'),
+ apiFetch('/stats')
+ ]);
+
+ let allHealthy = true;
+
+ // Process System Health
+ if (health) {
+ updateStatusIndicator('system-status-indicator', 'healthy');
+ updateElementText('system-uptime', formatUptime(health.uptime_seconds));
+ updateElementText('system-version', health.version);
+ } else {
+ updateStatusIndicator('system-status-indicator', 'error');
+ allHealthy = false;
+ }
+
+ if (urls) {
+ updateElementText('system-ip', urls.host_ip);
+ const linkPihole = document.getElementById('link-pihole-admin');
+ if (linkPihole) linkPihole.href = urls.pihole_admin;
+
+ const linkNetalertx = document.getElementById('link-netalertx');
+ if (linkNetalertx) linkNetalertx.href = urls.netalertx;
+ }
+
+ // Process Pi-hole
+ if (pihole) {
+ if (pihole.ftl_active) {
+ updateStatusIndicator('pihole-status-indicator', 'healthy');
+ updateElementText('pihole-status', 'Aktiv (FTL läuft)');
+ } else {
+ updateStatusIndicator('pihole-status-indicator', 'error');
+ updateElementText('pihole-status', 'Fehler (FTL inaktiv)');
+ allHealthy = false;
+ }
+ updateElementText('pihole-version', pihole.pihole_version);
+
+ let upstreamsStr = 'Keine / Unbekannt';
+ if (pihole.upstreams && pihole.upstreams.length > 0) {
+ upstreamsStr = pihole.upstreams.join(', ');
+
+ // Verify Unbound upstream if unbound is available
+ if (unbound && unbound.port) {
+ const expectedUpstream = `127.0.0.1#${unbound.port}`;
+ if (!pihole.upstreams.includes(expectedUpstream)) {
+ upstreamsStr += ' (Warnung: Unbound nicht konfiguriert!)';
+ updateStatusIndicator('pihole-status-indicator', 'warning');
+ allHealthy = false;
+ }
+ }
+ }
+ updateElementText('pihole-upstreams', upstreamsStr);
+
+ } else {
+ updateStatusIndicator('pihole-status-indicator', 'unavailable');
+ updateElementText('pihole-status', 'Nicht erreichbar');
+ allHealthy = false;
+ }
+
+ if (stats) {
+ updateElementText('pihole-recent-queries', stats.recent_queries);
+ }
+
+ // Process Unbound
+ if (unbound) {
+ updateElementText('unbound-port', unbound.port);
+ if (unbound.service_active) {
+ updateElementText('unbound-status', 'Aktiv');
+ if (unbound.dig_ok) {
+ updateStatusIndicator('unbound-status-indicator', 'healthy');
+ updateElementText('unbound-dig', 'Erfolgreich');
+ } else {
+ updateStatusIndicator('unbound-status-indicator', 'warning');
+ updateElementText('unbound-dig', 'Fehlgeschlagen');
+ allHealthy = false;
+ }
+ } else {
+ updateStatusIndicator('unbound-status-indicator', 'error');
+ updateElementText('unbound-status', 'Inaktiv');
+ updateElementText('unbound-dig', '-');
+ allHealthy = false;
+ }
+ updateElementText('unbound-dig-result', unbound.dig_result || '-');
+ } else {
+ updateStatusIndicator('unbound-status-indicator', 'unavailable');
+ updateElementText('unbound-status', 'Nicht erreichbar');
+ allHealthy = false;
+ }
+
+ // Process NetAlertX (Optional)
+ if (netalertx) {
+ updateElementText('netalertx-port', netalertx.port);
+ if (netalertx.http_reachable) {
+ updateStatusIndicator('netalertx-status-indicator', 'healthy');
+ updateElementText('netalertx-status', 'Erreichbar');
+ } else {
+ updateStatusIndicator('netalertx-status-indicator', 'unavailable');
+ updateElementText('netalertx-status', 'Nicht erreichbar');
+ }
+ } else {
+ updateStatusIndicator('netalertx-status-indicator', 'unavailable');
+ updateElementText('netalertx-status', 'Status unbekannt');
+ }
+
+ // Overall status
+ if (allHealthy) {
+ updateStatusIndicator('overall-status .status-indicator', 'healthy');
+ updateElementText('overall-status .status-text', 'Alle Kernsysteme gesund');
+ } else {
+ updateStatusIndicator('overall-status .status-indicator', 'warning');
+ updateElementText('overall-status .status-text', 'Probleme erkannt');
+ }
+
+ // Auto-refresh every 30 seconds
+ setTimeout(fetchData, 30000);
+ }
+
+ function formatUptime(seconds) {
+ if (seconds === undefined || seconds === null) return '-';
+ const d = Math.floor(seconds / (3600*24));
+ const h = Math.floor(seconds % (3600*24) / 3600);
+ const m = Math.floor(seconds % 3600 / 60);
+
+ let res = [];
+ if (d > 0) res.push(`${d}d`);
+ if (h > 0) res.push(`${h}h`);
+ res.push(`${m}m`);
+ return res.join(' ');
+ }
+});
\ No newline at end of file
diff --git a/dashboard/index.html b/dashboard/index.html
new file mode 100644
index 0000000..34454da
--- /dev/null
+++ b/dashboard/index.html
@@ -0,0 +1,146 @@
+
+
+
+
+
+ Pi-hole Suite - Infrastruktur Übersicht
+
+
+
+
+
+
+
Authentifizierung erforderlich
+
Bitte geben Sie den API-Schlüssel ein, um auf das Dashboard zuzugreifen.
+
+
Ungültiger API-Schlüssel oder Verbindungsfehler.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Status:
+ Wird geladen...
+
+
+ Version:
+ -
+
+
+ Upstream DNS:
+ -
+
+
+ Letzte 100 Anfragen:
+ -
+
+
+
+
+
+
+
+
+
+
+ Status:
+ Wird geladen...
+
+
+ Port:
+ -
+
+
+ DNS Auflösung (dig):
+ -
+
+
+ Auflösungsergebnis:
+ -
+
+
+
+
+
+
+
+
+
+ Status:
+ Wird geladen...
+
+
+ Port:
+ -
+
+
+ Bemerkung:
+ Dieses Modul ist optional.
+
+
+
+
+
+
+
+
+
+
+ Uptime:
+ -
+
+
+ API Version:
+ -
+
+
+ Host IP:
+ -
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/dashboard/style.css b/dashboard/style.css
new file mode 100644
index 0000000..73c955c
--- /dev/null
+++ b/dashboard/style.css
@@ -0,0 +1,333 @@
+:root {
+ --bg-color: #121212;
+ --text-primary: #e0e0e0;
+ --text-secondary: #a0a0a0;
+ --glass-bg: rgba(30, 30, 30, 0.6);
+ --glass-border: rgba(255, 255, 255, 0.08);
+ --glass-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37);
+
+ --color-healthy: #4caf50;
+ --color-warning: #ff9800;
+ --color-error: #f44336;
+ --color-unavailable: #9e9e9e;
+ --color-loading: #2196f3;
+
+ --accent-color: #3f51b5;
+ --accent-hover: #5c6bc0;
+}
+
+* {
+ box-sizing: border-box;
+ margin: 0;
+ padding: 0;
+}
+
+body {
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
+ background-color: var(--bg-color);
+ color: var(--text-primary);
+ line-height: 1.6;
+ min-height: 100vh;
+ display: flex;
+ flex-direction: column;
+}
+
+/* Glassmorphism Classes */
+.glass-panel {
+ background: var(--glass-bg);
+ backdrop-filter: blur(12px);
+ -webkit-backdrop-filter: blur(12px);
+ border: 1px solid var(--glass-border);
+ border-radius: 12px;
+ box-shadow: var(--glass-shadow);
+}
+
+/* Authentication Overlay */
+.overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 1000;
+ background-color: var(--bg-color);
+}
+
+.auth-panel {
+ padding: 2.5rem;
+ width: 100%;
+ max-width: 400px;
+ text-align: center;
+}
+
+.auth-panel h2 {
+ margin-bottom: 1rem;
+ font-weight: 500;
+}
+
+.auth-panel p {
+ color: var(--text-secondary);
+ margin-bottom: 1.5rem;
+ font-size: 0.9rem;
+}
+
+.auth-panel form {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+}
+
+input[type="password"], input[type="text"] {
+ width: 100%;
+ padding: 0.75rem 1rem;
+ border-radius: 6px;
+ border: 1px solid rgba(255, 255, 255, 0.2);
+ background: rgba(0, 0, 0, 0.2);
+ color: var(--text-primary);
+ font-size: 1rem;
+ outline: none;
+ transition: border-color 0.2s;
+}
+
+input[type="password"]:focus, input[type="text"]:focus {
+ border-color: var(--accent-color);
+}
+
+.btn {
+ background-color: var(--accent-color);
+ color: white;
+ border: none;
+ padding: 0.75rem 1rem;
+ border-radius: 6px;
+ font-size: 1rem;
+ cursor: pointer;
+ transition: background-color 0.2s;
+ text-decoration: none;
+ display: inline-block;
+ text-align: center;
+}
+
+.btn:hover {
+ background-color: var(--accent-hover);
+}
+
+.btn-small {
+ padding: 0.4rem 0.8rem;
+ font-size: 0.85rem;
+}
+
+.btn-outline {
+ background-color: transparent;
+ border: 1px solid var(--accent-color);
+ color: var(--text-primary);
+}
+
+.btn-outline:hover {
+ background-color: rgba(63, 81, 181, 0.2);
+}
+
+.error-msg {
+ color: var(--color-error);
+ margin-top: 1rem;
+ font-size: 0.85rem;
+}
+
+.hidden {
+ display: none !important;
+}
+
+/* Dashboard Layout */
+.dashboard-container {
+ padding: 2rem;
+ max-width: 1200px;
+ margin: 0 auto;
+ width: 100%;
+}
+
+.dashboard-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ margin-bottom: 2rem;
+ flex-wrap: wrap;
+ gap: 1rem;
+}
+
+.header-title h1 {
+ font-size: 1.8rem;
+ font-weight: 400;
+ margin-bottom: 0.5rem;
+}
+
+.system-status {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ font-size: 0.9rem;
+ color: var(--text-secondary);
+}
+
+.header-actions {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+ gap: 0.5rem;
+}
+
+.last-update {
+ font-size: 0.8rem;
+ color: var(--text-secondary);
+}
+
+.dashboard-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
+ gap: 1.5rem;
+}
+
+/* Cards */
+.card {
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+.card-header {
+ padding: 1.25rem 1.5rem;
+ border-bottom: 1px solid var(--glass-border);
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.card-header h2 {
+ font-size: 1.2rem;
+ font-weight: 500;
+}
+
+.card-body {
+ padding: 1.5rem;
+ flex-grow: 1;
+}
+
+.card-footer {
+ padding: 1rem 1.5rem;
+ border-top: 1px solid var(--glass-border);
+ background: rgba(0, 0, 0, 0.1);
+}
+
+/* Info Rows */
+.info-row {
+ display: flex;
+ justify-content: space-between;
+ align-items: baseline;
+ padding: 0.5rem 0;
+ border-bottom: 1px dashed rgba(255, 255, 255, 0.05);
+}
+
+.info-row:last-child {
+ border-bottom: none;
+}
+
+.info-row .label {
+ color: var(--text-secondary);
+ font-size: 0.9rem;
+}
+
+.info-row .value {
+ font-weight: 500;
+ text-align: right;
+ word-break: break-all;
+ max-width: 60%;
+}
+
+.info-row .value-code {
+ font-family: monospace;
+ font-size: 0.85rem;
+ background: rgba(0, 0, 0, 0.3);
+ padding: 0.2rem 0.4rem;
+ border-radius: 4px;
+}
+
+.info-row .note {
+ font-size: 0.85rem;
+ color: var(--text-secondary);
+ font-style: italic;
+}
+
+/* Status Indicators */
+.status-indicator {
+ display: inline-block;
+ width: 12px;
+ height: 12px;
+ border-radius: 50%;
+}
+
+.status-indicator.healthy {
+ background-color: var(--color-healthy);
+ box-shadow: 0 0 8px var(--color-healthy);
+}
+
+.status-indicator.warning {
+ background-color: var(--color-warning);
+ box-shadow: 0 0 8px var(--color-warning);
+}
+
+.status-indicator.error {
+ background-color: var(--color-error);
+ box-shadow: 0 0 8px var(--color-error);
+}
+
+.status-indicator.unavailable {
+ background-color: var(--color-unavailable);
+}
+
+.status-indicator.loading {
+ background-color: var(--color-loading);
+ animation: pulse 1.5s infinite;
+}
+
+@keyframes pulse {
+ 0% {
+ opacity: 0.5;
+ transform: scale(0.8);
+ }
+ 50% {
+ opacity: 1;
+ transform: scale(1.2);
+ }
+ 100% {
+ opacity: 0.5;
+ transform: scale(0.8);
+ }
+}
+
+/* Responsive */
+@media (max-width: 768px) {
+ .dashboard-container {
+ padding: 1rem;
+ }
+
+ .dashboard-header {
+ flex-direction: column;
+ align-items: flex-start;
+ }
+
+ .header-actions {
+ align-items: flex-start;
+ margin-top: 1rem;
+ }
+
+ .info-row {
+ flex-direction: column;
+ gap: 0.25rem;
+ }
+
+ .info-row .value {
+ text-align: left;
+ max-width: 100%;
+ }
+}
diff --git a/install.sh b/install.sh
index ac50344..f52ac5e 100755
--- a/install.sh
+++ b/install.sh
@@ -1119,6 +1119,14 @@ setup_python_suite() {
sudo mkdir -p "$suite_app_dir" 2>/dev/null || true
if [[ -f "$SCRIPT_DIR/start_suite.py" ]]; then
sudo install -m 0644 "$SCRIPT_DIR/start_suite.py" "$suite_entrypoint"
+
+ # Install dashboard assets
+ if [[ -d "$SCRIPT_DIR/dashboard" ]]; then
+ sudo mkdir -p "$suite_app_dir/dashboard"
+ sudo cp -r "$SCRIPT_DIR/dashboard/"* "$suite_app_dir/dashboard/"
+ sudo chmod -R 0644 "$suite_app_dir/dashboard/"*
+ sudo find "$suite_app_dir/dashboard" -type d -exec chmod 0755 {} \;
+ fi
else
log_warning "Python Suite entrypoint missing at $SCRIPT_DIR/start_suite.py (service may fail to start)"
fi
diff --git a/start_suite.py b/start_suite.py
index 563f0a8..4f94531 100755
--- a/start_suite.py
+++ b/start_suite.py
@@ -30,8 +30,10 @@
from typing import Any
from urllib.request import Request, urlopen
-from fastapi import Depends, FastAPI, Header, HTTPException
import uvicorn
+from fastapi import Depends, FastAPI, Header, HTTPException
+from fastapi.responses import RedirectResponse
+from fastapi.staticfiles import StaticFiles
APP_VERSION = "1.0.0"
START_TIME = time.time()
@@ -56,6 +58,14 @@ def require_api_key(x_api_key: str | None = Header(default=None, alias="X-API-Ke
app = FastAPI(title="Pi-hole Suite API", version=APP_VERSION)
+# Mount dashboard if it exists
+if os.path.isdir(os.path.join(os.path.dirname(__file__), "dashboard")):
+ app.mount("/dashboard", StaticFiles(directory=os.path.join(os.path.dirname(__file__), "dashboard"), html=True), name="dashboard")
+
+@app.get("/", include_in_schema=False)
+def root_redirect():
+ return RedirectResponse(url="/dashboard")
+
def _read_lines(path: str, limit: int) -> list[str]:
try:
@@ -191,7 +201,7 @@ def _read_pihole_v6_upstreams() -> list[str]:
return []
# Very small parser: look for upstreams = ["127.0.0.1#5335", ...]
- m = re.search(r"^upstreams\s*=\s*\[(.*?)\]", t, flags=re.M)
+ m = re.search(r"^upstreams\s*=\s*\[(.*?)\]", t, flags=re.MULTILINE)
if not m:
return []
@@ -254,7 +264,7 @@ def unbound() -> dict[str, Any]:
[
"dig",
"+short",
- f"@127.0.0.1",
+ "@127.0.0.1",
"-p",
str(unbound_port),
"+time=1",
diff --git a/tests/test_suite.py b/tests/test_suite.py
new file mode 100644
index 0000000..63d0d06
--- /dev/null
+++ b/tests/test_suite.py
@@ -0,0 +1,140 @@
+import os
+from unittest.mock import patch
+
+from fastapi.testclient import TestClient
+
+# Set required env vars before importing start_suite
+os.environ["SUITE_API_KEY"] = "test-key"
+os.environ["SUITE_PORT"] = "8090"
+os.environ["SUITE_HOST"] = "127.0.0.1"
+
+from start_suite import app
+
+client = TestClient(app)
+HEADERS = {"X-API-Key": "test-key"}
+BAD_HEADERS = {"X-API-Key": "wrong-key"}
+
+def test_root_redirect():
+ # Test dashboard redirect
+ response = client.get("/", follow_redirects=False)
+ assert response.status_code == 307
+ assert response.headers["location"] == "/dashboard"
+
+def test_dashboard_static_files():
+ # Only test if dashboard folder is present
+ if os.path.isdir(os.path.join(os.path.dirname(__file__), "..", "dashboard")):
+ response = client.get("/dashboard/")
+ assert response.status_code == 200
+ assert "text/html" in response.headers["content-type"]
+
+def test_auth_success():
+ response = client.get("/health", headers=HEADERS)
+ assert response.status_code == 200
+ assert response.json()["ok"] is True
+
+def test_auth_missing():
+ response = client.get("/health")
+ assert response.status_code in [401, 422] # missing header error depends on dependency resolution
+
+def test_auth_invalid():
+ response = client.get("/health", headers=BAD_HEADERS)
+ assert response.status_code == 401
+ assert response.json()["detail"] == "Unauthorized"
+
+@patch("start_suite._run")
+@patch("start_suite._read_pihole_v6_upstreams")
+def test_pihole_healthy(mock_upstreams, mock_run):
+ def run_side_effect(cmd, **kwargs):
+ if cmd == ["pihole", "-v"]:
+ return {"ok": True, "stdout": "Pi-hole v5.0"}
+ if cmd == ["systemctl", "is-active", "pihole-FTL"]:
+ return {"ok": True, "returncode": 0}
+ return {"ok": False}
+
+ mock_run.side_effect = run_side_effect
+ mock_upstreams.return_value = ["1.1.1.1"]
+
+ response = client.get("/pihole", headers=HEADERS)
+ assert response.status_code == 200
+ data = response.json()
+ assert data["ftl_active"] is True
+ assert data["pihole_version"] == "Pi-hole v5.0"
+ assert "1.1.1.1" in data["upstreams"]
+
+@patch("start_suite._run")
+@patch("start_suite._read_pihole_v6_upstreams")
+def test_pihole_unavailable(mock_upstreams, mock_run):
+ def run_side_effect(cmd, **kwargs):
+ if cmd == ["pihole", "-v"]:
+ return {"ok": False}
+ if cmd == ["systemctl", "is-active", "pihole-FTL"]:
+ return {"ok": True, "returncode": 3}
+ return {"ok": False}
+
+ mock_run.side_effect = run_side_effect
+ mock_upstreams.return_value = []
+
+ response = client.get("/pihole", headers=HEADERS)
+ assert response.status_code == 200
+ data = response.json()
+ assert data["ftl_active"] is False
+ assert data["pihole_version"] == "unknown"
+
+@patch("start_suite._run")
+def test_unbound_healthy(mock_run):
+ def run_side_effect(cmd, **kwargs):
+ if cmd[0:3] == ["systemctl", "is-active", "unbound"]:
+ return {"ok": True, "returncode": 0}
+ if cmd[0] == "dig":
+ return {"ok": True, "stdout": "1.1.1.1"}
+ return {"ok": False}
+
+ mock_run.side_effect = run_side_effect
+ response = client.get("/unbound", headers=HEADERS)
+ assert response.status_code == 200
+ data = response.json()
+ assert data["service_active"] is True
+ assert data["dig_ok"] is True
+ assert data["dig_result"] == "1.1.1.1"
+
+@patch("start_suite._run")
+def test_unbound_unavailable(mock_run):
+ def run_side_effect(cmd, **kwargs):
+ if cmd[0:3] == ["systemctl", "is-active", "unbound"]:
+ return {"ok": True, "returncode": 3}
+ if cmd[0] == "dig":
+ return {"ok": False, "error": "timeout"}
+ return {"ok": False}
+
+ mock_run.side_effect = run_side_effect
+ response = client.get("/unbound", headers=HEADERS)
+ assert response.status_code == 200
+ data = response.json()
+ assert data["service_active"] is False
+ assert data["dig_ok"] is False
+
+@patch("start_suite._http_ok")
+def test_netalertx_healthy(mock_http_ok):
+ mock_http_ok.return_value = True
+ response = client.get("/netalertx", headers=HEADERS)
+ assert response.status_code == 200
+ assert response.json()["http_reachable"] is True
+
+@patch("start_suite._http_ok")
+def test_netalertx_unavailable(mock_http_ok):
+ mock_http_ok.return_value = False
+ response = client.get("/netalertx", headers=HEADERS)
+ assert response.status_code == 200
+ assert response.json()["http_reachable"] is False
+
+@patch("start_suite._run")
+def test_command_timeout(mock_run):
+ def run_side_effect(cmd, **kwargs):
+ return {"ok": False, "error": "Command timed out"}
+
+ mock_run.side_effect = run_side_effect
+ response = client.get("/pihole", headers=HEADERS)
+ assert response.status_code == 200
+ data = response.json()
+ assert data["ftl_active"] is False
+ assert data["pihole_version"] == "unknown"