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.
pull/923/head
Molham 2025-04-19 00:13:05 +02:00 committed by Bastien Wirtz
parent 1f6e6e7cce
commit 9307f5a926
3 changed files with 179 additions and 18 deletions

View File

@ -471,10 +471,12 @@ The following configuration is available for the PiHole service.
url: "http://192.168.0.151/admin" url: "http://192.168.0.151/admin"
apikey: "<---insert-api-key-here--->" # optional, needed if web interface is password protected apikey: "<---insert-api-key-here--->" # optional, needed if web interface is password protected
type: "PiHole" type: "PiHole"
apiVersion: 5 # optional, defaults to 5. Use 6 if your PiHole instance uses API v6
``` ```
**Remarks:** **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 ## Ping

View File

@ -2,7 +2,7 @@
"domains_being_blocked": 152588, "domains_being_blocked": 152588,
"dns_queries_today": 0, "dns_queries_today": 0,
"ads_blocked_today": 0, "ads_blocked_today": 0,
"ads_percentage_today": 42, "percent_blocked": 42,
"unique_domains": 0, "unique_domains": 0,
"queries_forwarded": 0, "queries_forwarded": 0,
"queries_cached": 0, "queries_cached": 0,

View File

@ -26,36 +26,195 @@ export default {
name: "PiHole", name: "PiHole",
mixins: [service], mixins: [service],
props: { props: {
item: Object, item: {
type: Object,
required: true
},
}, },
data: () => ({ data: () => ({
status: "", status: "",
ads_percentage_today: 0, percent_blocked: 0,
sessionId: null,
sessionExpiry: null,
retryCount: 0,
maxRetries: 3,
retryDelay: 5000,
}), }),
computed: { computed: {
percentage: function () { percentage: function () {
if (this.ads_percentage_today) { if (this.percent_blocked) {
return this.ads_percentage_today.toFixed(1); return this.percent_blocked.toFixed(1);
} }
return ""; return "";
}, },
isAuthenticated() {
return this.sessionId && this.sessionExpiry && Date.now() < this.sessionExpiry;
}
}, },
created() { 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: { methods: {
fetchStatus: async function () { startStatusPolling: function () {
const authQueryParams = this.item.apikey this.fetchStatus();
? `?summaryRaw&auth=${this.item.apikey}` // Poll every 5 minutes
: ""; this.pollInterval = setInterval(this.fetchStatus, 300000);
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;
}, },
}, 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";
}
},
}
}; };
</script> </script>