From 9307f5a926e9fcee1237a2da4253e0db9f0ba50d Mon Sep 17 00:00:00 2001 From: Molham Date: Sat, 19 Apr 2025 00:13:05 +0200 Subject: [PATCH] feat(pihole): Support Pi-hole v6 API with session management (#875) Add support for Pi-hole v6 API while maintaining v5 compatibility. The component now handles both API versions with proper session-based authentication for v6 and legacy authentication for v5. Includes automated retries, session caching, and proper cleanup. - Add new apiVersion config option (default: 5, accepts: 5,6) - Implement session-based auth with caching for v6 API - Add auto-retry mechanism with configurable attempts - Add periodic status polling for v6 API - Separate v5/v6 API handling logic for better maintenance - Improve error handling and status display - Update documentation with v6 API support details Breaking changes: None. V5 remains default for backward compatibility. V6 mode must be explicitly enabled by setting apiVersion: 6 in config. --- docs/customservices.md | 4 +- dummy-data/pihole/api.php | 2 +- src/components/services/PiHole.vue | 191 ++++++++++++++++++++++++++--- 3 files changed, 179 insertions(+), 18 deletions(-) diff --git a/docs/customservices.md b/docs/customservices.md index 672c4d7..fc6d318 100644 --- a/docs/customservices.md +++ b/docs/customservices.md @@ -471,10 +471,12 @@ The following configuration is available for the PiHole service. url: "http://192.168.0.151/admin" apikey: "<---insert-api-key-here--->" # optional, needed if web interface is password protected type: "PiHole" + apiVersion: 5 # optional, defaults to 5. Use 6 if your PiHole instance uses API v6 ``` **Remarks:** -If PiHole web interface is password protected, obtain the `apikey` from Settings > API/Web interface > Show API token. +- If PiHole web interface is password protected, obtain the `apikey` from Settings > API/Web interface > Show API token. +- For PiHole instances using API v6, set `apiVersion: 6` in your configuration. This enables session management and proper authentication handling. ## Ping diff --git a/dummy-data/pihole/api.php b/dummy-data/pihole/api.php index 62c453d..120f830 100644 --- a/dummy-data/pihole/api.php +++ b/dummy-data/pihole/api.php @@ -2,7 +2,7 @@ "domains_being_blocked": 152588, "dns_queries_today": 0, "ads_blocked_today": 0, - "ads_percentage_today": 42, + "percent_blocked": 42, "unique_domains": 0, "queries_forwarded": 0, "queries_cached": 0, diff --git a/src/components/services/PiHole.vue b/src/components/services/PiHole.vue index a192ea0..004f382 100644 --- a/src/components/services/PiHole.vue +++ b/src/components/services/PiHole.vue @@ -26,36 +26,195 @@ export default { name: "PiHole", mixins: [service], props: { - item: Object, + item: { + type: Object, + required: true + }, }, data: () => ({ status: "", - ads_percentage_today: 0, + percent_blocked: 0, + sessionId: null, + sessionExpiry: null, + retryCount: 0, + maxRetries: 3, + retryDelay: 5000, }), computed: { percentage: function () { - if (this.ads_percentage_today) { - return this.ads_percentage_today.toFixed(1); + if (this.percent_blocked) { + return this.percent_blocked.toFixed(1); } return ""; }, + isAuthenticated() { + return this.sessionId && this.sessionExpiry && Date.now() < this.sessionExpiry; + } }, created() { - this.fetchStatus(); + if (parseInt(this.item.apiVersion, 10) === 6) { + this.loadCachedSession(); + this.startStatusPolling(); + } else { + this.fetchStatus_v5(); + } + }, + beforeDestroy() { + if (parseInt(this.item.apiVersion, 10) === 6) { + this.stopStatusPolling(); + this.deleteSession(); + } }, methods: { - fetchStatus: async function () { - const authQueryParams = this.item.apikey - ? `?summaryRaw&auth=${this.item.apikey}` - : ""; - const result = await this.fetch(`/api.php${authQueryParams}`).catch((e) => - console.log(e), - ); - - this.status = result.status; - this.ads_percentage_today = result.ads_percentage_today; + startStatusPolling: function () { + this.fetchStatus(); + // Poll every 5 minutes + this.pollInterval = setInterval(this.fetchStatus, 300000); }, - }, + stopStatusPolling: function () { + if (this.pollInterval) { + clearInterval(this.pollInterval); + } + }, + loadCachedSession: function () { + try { + const cachedSession = localStorage.getItem(`pihole_session_${this.item.url}`); + if (cachedSession) { + const session = JSON.parse(cachedSession); + if (session.expiry > Date.now()) { + this.sessionId = session.sid; + this.sessionExpiry = session.expiry; + } else { + this.removeCacheSession(); + } + } + } catch (e) { + console.error("Failed to load cached session:", e); + this.removeCacheSession(); + } + }, + removeCacheSession: function () { + localStorage.removeItem(`pihole_session_${this.item.url}`); + this.sessionId = null; + this.sessionExpiry = null; + }, + deleteSession: async function () { + if (!this.sessionId) return; + + try { + await this.fetch(`/api/auth/session/${encodeURIComponent(this.sessionId)}`, { + method: 'DELETE' + }); + } catch (e) { + console.error("Failed to delete session:", e); + } finally { + this.removeCacheSession(); + } + }, + authenticate: async function () { + if (!this.item.apikey) { + console.error("API key is required for PiHole authentication"); + this.status = "disabled"; + return false; + } + + try { + const authResponse = await this.fetch("/api/auth", { + method: "POST", + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ password: this.item.apikey }), + }); + + if (authResponse?.session?.sid) { + this.sessionId = authResponse.session.sid; + this.sessionExpiry = Date.now() + (authResponse.session.validity * 1000); + + localStorage.setItem(`pihole_session_${this.item.url}`, JSON.stringify({ + sid: this.sessionId, + expiry: this.sessionExpiry + })); + + this.retryCount = 0; + return true; + } + throw new Error("Invalid authentication response"); + } catch (e) { + console.error("Authentication failed:", e); + this.status = "disabled"; + return false; + } + }, + retryWithDelay: async function () { + if (this.retryCount < this.maxRetries) { + this.retryCount++; + await new Promise(resolve => setTimeout(resolve, this.retryDelay)); + return this.fetchStatus(); + } + return false; + }, + fetchStatus: async function () { + try { + if (!this.isAuthenticated) { + const authenticated = await this.authenticate(); + if (!authenticated) return; + } + + const url = `${this.endpoint}/${`api/stats/summary?sid=${encodeURIComponent(this.sessionId)}`.replace(/^\/+/, '')}`; + const response = await fetch(url); + this.status = "enabled"; + + if (response.ok) { + const result = await response.json(); + if (result?.queries?.percent_blocked !== undefined) { + this.percent_blocked = result.queries.percent_blocked; + this.retryCount = 0; + } else { + throw new Error("Invalid response format"); + } + } else if (response.status === 401) { + this.removeCacheSession(); + return this.retryWithDelay(); + } else { + throw new Error(`HTTP error: ${response.status}`); + } + } catch (e) { + console.error("Failed to fetch status:", e); + if (e.message.includes("HTTP error: 401") || e.message.includes("HTTP error: 403")) { + this.removeCacheSession(); + return this.retryWithDelay(); + } + this.status = "disabled"; + this.removeCacheSession(); + } + }, + async fetchStatus_v5() { + try { + const params = {}; + if (this.item.apikey) { + params.auth = this.item.apikey; + } + const url = new URL(`${this.endpoint}/api.php`); + Object.keys(params).forEach(key => url.searchParams.append(key, params[key])); + + const response = await fetch(url); + this.status = response.status.toString(); + + if (response.ok) { + const result = await response.json(); + if (result?.ads_percentage_today !== undefined) { + this.percent_blocked = result.ads_percentage_today; + } + } else { + throw new Error(`HTTP error: ${response.status}`); + } + } catch (e) { + console.error("Failed to fetch v5 status:", e); + this.status = "error"; + } + }, + } };