import type { fetch as undiciFetch, Response as UndiciResponse } from "undici"; import type { z } from "zod/v4"; import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server"; import { ResponseError } from "@homarr/common/server"; import { logger } from "@homarr/log"; import type { IntegrationInput, IntegrationTestingInput } from "../../base/integration"; import { Integration } from "../../base/integration"; import type { SessionStore } from "../../base/session-store"; import { createSessionStore } from "../../base/session-store"; import type { TestingResult } from "../../base/test-connection/test-connection-service"; import type { DnsHoleSummaryIntegration } from "../../interfaces/dns-hole-summary/dns-hole-summary-integration"; import type { DnsHoleSummary } from "../../types"; import { dnsBlockingGetSchema, sessionResponseSchema, statsSummaryGetSchema } from "./pi-hole-schemas-v6"; const localLogger = logger.child({ module: "PiHoleIntegrationV6" }); export class PiHoleIntegrationV6 extends Integration implements DnsHoleSummaryIntegration { private readonly sessionStore: SessionStore<{ sid: string | null }>; constructor(integration: IntegrationInput) { super(integration); this.sessionStore = createSessionStore(integration); } public async getDnsBlockingStatusAsync(): Promise> { const response = await this.withAuthAsync(async (sessionId) => { return await fetchWithTrustedCertificatesAsync(this.url("/api/dns/blocking"), { headers: { sid: sessionId ?? undefined, }, }); }); if (!response.ok) { throw new ResponseError(response); } const result = await dnsBlockingGetSchema.parseAsync(await response.json()); return result; } private async getStatsSummaryAsync(): Promise> { const response = await this.withAuthAsync(async (sessionId) => { return fetchWithTrustedCertificatesAsync(this.url("/api/stats/summary"), { headers: { sid: sessionId ?? undefined, }, }); }); if (!response.ok) { throw new ResponseError(response); } const data = await response.json(); const result = await statsSummaryGetSchema.parseAsync(data); return result; } public async getSummaryAsync(): Promise { const dnsStatsSummary = await this.getStatsSummaryAsync(); const dnsBlockingStatus = await this.getDnsBlockingStatusAsync(); return { status: dnsBlockingStatus.blocking, adsBlockedToday: dnsStatsSummary.queries.blocked, adsBlockedTodayPercentage: dnsStatsSummary.queries.percent_blocked, domainsBeingBlocked: dnsStatsSummary.gravity.domains_being_blocked, dnsQueriesToday: dnsStatsSummary.queries.total, }; } protected async testingAsync({ fetchAsync }: IntegrationTestingInput): Promise { const sessionId = await this.getSessionAsync(fetchAsync); await this.clearSessionAsync(sessionId, fetchAsync); await this.sessionStore.clearAsync(); return { success: true }; } public async enableAsync(): Promise { const response = await this.withAuthAsync(async (sessionId) => { return await fetchWithTrustedCertificatesAsync(this.url("/api/dns/blocking"), { headers: { sid: sessionId ?? undefined, }, body: JSON.stringify({ blocking: true }), method: "POST", }); }); if (!response.ok) { throw new ResponseError(response); } } public async disableAsync(duration?: number): Promise { const response = await this.withAuthAsync(async (sessionId) => { return await fetchWithTrustedCertificatesAsync(this.url("/api/dns/blocking"), { headers: { sid: sessionId ?? undefined, }, body: JSON.stringify({ blocking: false, timer: duration }), method: "POST", }); }); if (!response.ok) { throw new ResponseError(response); } } /** * Run the callback with the current session id * @param callback * @returns */ private async withAuthAsync(callback: (sessionId: string | null) => Promise) { if (!super.hasSecretValue("apiKey")) { return await callback(null); } const storedSession = await this.sessionStore.getAsync(); if (storedSession) { localLogger.debug("Using stored session for request", { integrationId: this.integration.id }); const response = await callback(storedSession.sid); if (response.status !== 401) { return response; } localLogger.debug("Session expired, getting new session", { integrationId: this.integration.id }); } const sessionId = await this.getSessionAsync(); await this.sessionStore.setAsync({ sid: sessionId }); const response = await callback(sessionId); return response; } /** * Get a session id from the Pi-hole server * @returns The session id */ private async getSessionAsync( fetchAsync: typeof undiciFetch = fetchWithTrustedCertificatesAsync, ): Promise { const apiKey = super.hasSecretValue("apiKey") ? super.getSecretValue("apiKey") : null; const response = await fetchAsync(this.url("/api/auth"), { method: "POST", body: JSON.stringify({ password: apiKey ?? "" }), headers: { "User-Agent": "Homarr", }, }); if (!response.ok) throw new ResponseError(response); const data = await response.json(); const result = await sessionResponseSchema.parseAsync(data); if (!result.session.valid) { throw new ResponseError( { status: 401, url: response.url }, { cause: result.session.message ? new Error(result.session.message) : undefined, }, ); } localLogger.info("Received session id successfully", { integrationId: this.integration.id }); return result.session.sid; } /** * Remove the session from the Pi-hole server * @param sessionId The session id to remove */ private async clearSessionAsync( sessionId: string | null, fetchAsync: typeof undiciFetch = fetchWithTrustedCertificatesAsync, ) { if (!sessionId) { localLogger.debug("No session id to clear"); return; } const response = await fetchAsync(this.url("/api/auth"), { method: "DELETE", headers: { sid: sessionId, }, }); if (!response.ok) { localLogger.warn("Failed to clear session", { statusCode: response.status, content: await response.text() }); } logger.debug("Cleared session successfully"); } }