feat(pihole): add support for v6 (#2448)
* feat(pihole): add support for v6 * fix: add session-store to keep using same session for pi-hole requests * chore: address pull request feedback * fix: import issue * fix: other import errors
This commit is contained in:
@@ -21,13 +21,13 @@ import { SonarrIntegration } from "../media-organizer/sonarr/sonarr-integration"
|
||||
import { TdarrIntegration } from "../media-transcoding/tdarr-integration";
|
||||
import { OpenMediaVaultIntegration } from "../openmediavault/openmediavault-integration";
|
||||
import { OverseerrIntegration } from "../overseerr/overseerr-integration";
|
||||
import { PiHoleIntegration } from "../pi-hole/pi-hole-integration";
|
||||
import { createPiHoleIntegrationAsync } from "../pi-hole/pi-hole-integration-factory";
|
||||
import { PlexIntegration } from "../plex/plex-integration";
|
||||
import { ProwlarrIntegration } from "../prowlarr/prowlarr-integration";
|
||||
import { ProxmoxIntegration } from "../proxmox/proxmox-integration";
|
||||
import type { Integration, IntegrationInput } from "./integration";
|
||||
|
||||
export const integrationCreator = <TKind extends keyof typeof integrationCreators>(
|
||||
export const createIntegrationAsync = async <TKind extends keyof typeof integrationCreators>(
|
||||
integration: IntegrationInput & { kind: TKind },
|
||||
) => {
|
||||
if (!(integration.kind in integrationCreators)) {
|
||||
@@ -36,15 +36,22 @@ export const integrationCreator = <TKind extends keyof typeof integrationCreator
|
||||
);
|
||||
}
|
||||
|
||||
return new integrationCreators[integration.kind](integration) as InstanceType<(typeof integrationCreators)[TKind]>;
|
||||
const creator = integrationCreators[integration.kind];
|
||||
|
||||
// factories are an array, to differentiate in js between class constructors and functions
|
||||
if (Array.isArray(creator)) {
|
||||
return (await creator[0](integration)) as IntegrationInstanceOfKind<TKind>;
|
||||
}
|
||||
|
||||
return new creator(integration) as IntegrationInstanceOfKind<TKind>;
|
||||
};
|
||||
|
||||
export const integrationCreatorFromSecrets = <TKind extends keyof typeof integrationCreators>(
|
||||
export const createIntegrationAsyncFromSecrets = <TKind extends keyof typeof integrationCreators>(
|
||||
integration: Modify<DbIntegration, { kind: TKind }> & {
|
||||
secrets: { kind: IntegrationSecretKind; value: `${string}.${string}` }[];
|
||||
},
|
||||
) => {
|
||||
return integrationCreator({
|
||||
return createIntegrationAsync({
|
||||
...integration,
|
||||
decryptedSecrets: integration.secrets.map((secret) => ({
|
||||
...secret,
|
||||
@@ -53,8 +60,11 @@ export const integrationCreatorFromSecrets = <TKind extends keyof typeof integra
|
||||
});
|
||||
};
|
||||
|
||||
type IntegrationInstance = new (integration: IntegrationInput) => Integration;
|
||||
|
||||
// factories are an array, to differentiate in js between class constructors and functions
|
||||
export const integrationCreators = {
|
||||
piHole: PiHoleIntegration,
|
||||
piHole: [createPiHoleIntegrationAsync],
|
||||
adGuardHome: AdGuardHomeIntegration,
|
||||
homeAssistant: HomeAssistantIntegration,
|
||||
jellyfin: JellyfinIntegration,
|
||||
@@ -76,4 +86,12 @@ export const integrationCreators = {
|
||||
tdarr: TdarrIntegration,
|
||||
proxmox: ProxmoxIntegration,
|
||||
emby: EmbyIntegration,
|
||||
} satisfies Record<IntegrationKind, new (integration: IntegrationInput) => Integration>;
|
||||
} satisfies Record<IntegrationKind, IntegrationInstance | [(input: IntegrationInput) => Promise<Integration>]>;
|
||||
|
||||
type IntegrationInstanceOfKind<TKind extends keyof typeof integrationCreators> = {
|
||||
[kind in TKind]: (typeof integrationCreators)[kind] extends [(input: IntegrationInput) => Promise<Integration>]
|
||||
? Awaited<ReturnType<(typeof integrationCreators)[kind][0]>>
|
||||
: (typeof integrationCreators)[kind] extends IntegrationInstance
|
||||
? InstanceType<(typeof integrationCreators)[kind]>
|
||||
: never;
|
||||
}[TKind];
|
||||
|
||||
47
packages/integrations/src/base/error.ts
Normal file
47
packages/integrations/src/base/error.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { Response as UndiciResponse } from "undici";
|
||||
import type { z } from "zod";
|
||||
|
||||
import type { IntegrationInput } from "./integration";
|
||||
|
||||
export class ParseError extends Error {
|
||||
public readonly zodError: z.ZodError;
|
||||
public readonly input: unknown;
|
||||
|
||||
constructor(dataName: string, zodError: z.ZodError, input?: unknown) {
|
||||
super(`Failed to parse ${dataName}`);
|
||||
this.zodError = zodError;
|
||||
this.input = input;
|
||||
}
|
||||
}
|
||||
|
||||
export class ResponseError extends Error {
|
||||
public readonly statusCode: number;
|
||||
public readonly url: string;
|
||||
public readonly content?: string;
|
||||
|
||||
constructor(response: Response | UndiciResponse, content: unknown) {
|
||||
super("Response failed");
|
||||
|
||||
this.statusCode = response.status;
|
||||
this.url = response.url;
|
||||
|
||||
try {
|
||||
this.content = JSON.stringify(content);
|
||||
} catch {
|
||||
this.content = content as string;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class IntegrationResponseError extends ResponseError {
|
||||
public readonly integration: Pick<IntegrationInput, "id" | "name" | "url">;
|
||||
|
||||
constructor(integration: IntegrationInput, response: Response | UndiciResponse, content: unknown) {
|
||||
super(response, content);
|
||||
this.integration = {
|
||||
id: integration.id,
|
||||
name: integration.name,
|
||||
url: integration.url,
|
||||
};
|
||||
}
|
||||
}
|
||||
29
packages/integrations/src/base/session-store.ts
Normal file
29
packages/integrations/src/base/session-store.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { decryptSecret, encryptSecret } from "@homarr/common/server";
|
||||
import { logger } from "@homarr/log";
|
||||
import { createGetSetChannel } from "@homarr/redis";
|
||||
|
||||
const localLogger = logger.child({ module: "SessionStore" });
|
||||
|
||||
export const createSessionStore = (integration: { id: string }) => {
|
||||
const channelName = `session-store:${integration.id}`;
|
||||
const channel = createGetSetChannel<`${string}.${string}`>(channelName);
|
||||
|
||||
return {
|
||||
async getAsync() {
|
||||
localLogger.debug("Getting session from store", { store: channelName });
|
||||
const value = await channel.getAsync();
|
||||
if (!value) return null;
|
||||
return decryptSecret(value);
|
||||
},
|
||||
async setAsync(value: string) {
|
||||
localLogger.debug("Updating session in store", { store: channelName });
|
||||
await channel.setAsync(encryptSecret(value));
|
||||
},
|
||||
async clearAsync() {
|
||||
localLogger.debug("Cleared session in store", { store: channelName });
|
||||
await channel.removeAsync();
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export type SessionStore = ReturnType<typeof createSessionStore>;
|
||||
@@ -13,7 +13,8 @@ export { RadarrIntegration } from "./media-organizer/radarr/radarr-integration";
|
||||
export { SonarrIntegration } from "./media-organizer/sonarr/sonarr-integration";
|
||||
export { OpenMediaVaultIntegration } from "./openmediavault/openmediavault-integration";
|
||||
export { OverseerrIntegration } from "./overseerr/overseerr-integration";
|
||||
export { PiHoleIntegration } from "./pi-hole/pi-hole-integration";
|
||||
export { PiHoleIntegrationV5 } from "./pi-hole/v5/pi-hole-integration-v5";
|
||||
export { PiHoleIntegrationV6 } from "./pi-hole/v6/pi-hole-integration-v6";
|
||||
export { PlexIntegration } from "./plex/plex-integration";
|
||||
export { ProwlarrIntegration } from "./prowlarr/prowlarr-integration";
|
||||
export { LidarrIntegration } from "./media-organizer/lidarr/lidarr-integration";
|
||||
@@ -36,5 +37,5 @@ export type { TdarrWorker } from "./interfaces/media-transcoding/workers";
|
||||
export { downloadClientItemSchema } from "./interfaces/downloads/download-client-items";
|
||||
|
||||
// Helpers
|
||||
export { integrationCreator, integrationCreatorFromSecrets } from "./base/creator";
|
||||
export { createIntegrationAsync, createIntegrationAsyncFromSecrets } from "./base/creator";
|
||||
export { IntegrationTestConnectionError } from "./base/test-connection-error";
|
||||
|
||||
@@ -2,4 +2,6 @@ import type { DnsHoleSummary } from "./dns-hole-summary-types";
|
||||
|
||||
export interface DnsHoleSummaryIntegration {
|
||||
getSummaryAsync(): Promise<DnsHoleSummary>;
|
||||
enableAsync(): Promise<void>;
|
||||
disableAsync(duration?: number): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { removeTrailingSlash } from "@homarr/common";
|
||||
|
||||
import type { IntegrationInput } from "../base/integration";
|
||||
import { PiHoleIntegrationV5 } from "./v5/pi-hole-integration-v5";
|
||||
import { PiHoleIntegrationV6 } from "./v6/pi-hole-integration-v6";
|
||||
|
||||
export const createPiHoleIntegrationAsync = async (input: IntegrationInput) => {
|
||||
const baseUrl = removeTrailingSlash(input.url);
|
||||
const url = new URL(`${baseUrl}/api/info/version`);
|
||||
const response = await fetch(url);
|
||||
|
||||
/**
|
||||
* In pi-hole 5 the api was at /admin/api.php, in pi-hole 6 it was moved to /api
|
||||
* For the /api/info/version endpoint, the response is 404 in pi-hole 5
|
||||
* and 401 in pi-hole 6
|
||||
*/
|
||||
if (response.status === 404) {
|
||||
return new PiHoleIntegrationV5(input);
|
||||
}
|
||||
|
||||
return new PiHoleIntegrationV6(input);
|
||||
};
|
||||
@@ -1,12 +1,12 @@
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
|
||||
|
||||
import { Integration } from "../base/integration";
|
||||
import { IntegrationTestConnectionError } from "../base/test-connection-error";
|
||||
import type { DnsHoleSummaryIntegration } from "../interfaces/dns-hole-summary/dns-hole-summary-integration";
|
||||
import type { DnsHoleSummary } from "../interfaces/dns-hole-summary/dns-hole-summary-types";
|
||||
import { summaryResponseSchema } from "./pi-hole-types";
|
||||
import { Integration } from "../../base/integration";
|
||||
import { IntegrationTestConnectionError } from "../../base/test-connection-error";
|
||||
import type { DnsHoleSummaryIntegration } from "../../interfaces/dns-hole-summary/dns-hole-summary-integration";
|
||||
import type { DnsHoleSummary } from "../../interfaces/dns-hole-summary/dns-hole-summary-types";
|
||||
import { summaryResponseSchema } from "./pi-hole-schemas-v5";
|
||||
|
||||
export class PiHoleIntegration extends Integration implements DnsHoleSummaryIntegration {
|
||||
export class PiHoleIntegrationV5 extends Integration implements DnsHoleSummaryIntegration {
|
||||
public async getSummaryAsync(): Promise<DnsHoleSummary> {
|
||||
const apiKey = super.getSecretValue("apiKey");
|
||||
const response = await fetchWithTrustedCertificatesAsync(this.url("/admin/api.php?summaryRaw", { auth: apiKey }));
|
||||
@@ -7,7 +7,3 @@ export const summaryResponseSchema = z.object({
|
||||
dns_queries_today: z.number(),
|
||||
ads_percentage_today: z.number(),
|
||||
});
|
||||
|
||||
export const controlsInputSchema = z.object({
|
||||
duration: z.number().optional(),
|
||||
});
|
||||
204
packages/integrations/src/pi-hole/v6/pi-hole-integration-v6.ts
Normal file
204
packages/integrations/src/pi-hole/v6/pi-hole-integration-v6.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
import type { Response as UndiciResponse } from "undici";
|
||||
import type { z } from "zod";
|
||||
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
|
||||
import { extractErrorMessage } from "@homarr/common";
|
||||
import { logger } from "@homarr/log";
|
||||
|
||||
import { IntegrationResponseError, ParseError, ResponseError } from "../../base/error";
|
||||
import type { IntegrationInput } from "../../base/integration";
|
||||
import { Integration } from "../../base/integration";
|
||||
import type { SessionStore } from "../../base/session-store";
|
||||
import { createSessionStore } from "../../base/session-store";
|
||||
import { IntegrationTestConnectionError } from "../../base/test-connection-error";
|
||||
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;
|
||||
|
||||
constructor(integration: IntegrationInput) {
|
||||
super(integration);
|
||||
this.sessionStore = createSessionStore(integration);
|
||||
}
|
||||
|
||||
public async getDnsBlockingStatusAsync(): Promise<z.infer<typeof dnsBlockingGetSchema>> {
|
||||
const response = await this.withAuthAsync(async (sessionId) => {
|
||||
return await fetchWithTrustedCertificatesAsync(this.url("/api/dns/blocking"), {
|
||||
headers: {
|
||||
sid: sessionId,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new IntegrationResponseError(this.integration, response, await response.json());
|
||||
}
|
||||
|
||||
const result = dnsBlockingGetSchema.safeParse(await response.json());
|
||||
|
||||
if (!result.success) {
|
||||
throw new ParseError("DNS blocking status", result.error, await response.json());
|
||||
}
|
||||
|
||||
return result.data;
|
||||
}
|
||||
|
||||
private async getStatsSummaryAsync(): Promise<z.infer<typeof statsSummaryGetSchema>> {
|
||||
const response = await this.withAuthAsync(async (sessionId) => {
|
||||
return await fetchWithTrustedCertificatesAsync(this.url("/api/stats/summary"), {
|
||||
headers: {
|
||||
sid: sessionId,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new IntegrationResponseError(this.integration, response, await response.json());
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const result = statsSummaryGetSchema.safeParse(data);
|
||||
|
||||
if (!result.success) {
|
||||
throw new ParseError("stats summary", result.error, data);
|
||||
}
|
||||
|
||||
return result.data;
|
||||
}
|
||||
|
||||
public async getSummaryAsync(): Promise<DnsHoleSummary> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
public async testConnectionAsync(): Promise<void> {
|
||||
try {
|
||||
const sessionId = await this.getSessionAsync();
|
||||
await this.clearSessionAsync(sessionId);
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof ParseError) {
|
||||
throw new IntegrationTestConnectionError("invalidJson");
|
||||
}
|
||||
|
||||
if (error instanceof ResponseError && error.statusCode === 401) {
|
||||
throw new IntegrationTestConnectionError("invalidCredentials");
|
||||
}
|
||||
|
||||
throw new IntegrationTestConnectionError("commonError", extractErrorMessage(error));
|
||||
}
|
||||
}
|
||||
|
||||
public async enableAsync(): Promise<void> {
|
||||
const response = await this.withAuthAsync(async (sessionId) => {
|
||||
return await fetchWithTrustedCertificatesAsync(this.url("/api/dns/blocking"), {
|
||||
headers: {
|
||||
sid: sessionId,
|
||||
},
|
||||
body: JSON.stringify({ blocking: true }),
|
||||
method: "POST",
|
||||
});
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new IntegrationResponseError(this.integration, response, await response.json());
|
||||
}
|
||||
}
|
||||
|
||||
public async disableAsync(duration?: number): Promise<void> {
|
||||
const response = await this.withAuthAsync(async (sessionId) => {
|
||||
return await fetchWithTrustedCertificatesAsync(this.url("/api/dns/blocking"), {
|
||||
headers: {
|
||||
sid: sessionId,
|
||||
},
|
||||
body: JSON.stringify({ blocking: false, timer: duration }),
|
||||
method: "POST",
|
||||
});
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new IntegrationResponseError(this.integration, response, await response.json());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the callback with the current session id
|
||||
* @param callback
|
||||
* @returns
|
||||
*/
|
||||
private async withAuthAsync(callback: (sessionId: string) => Promise<UndiciResponse>) {
|
||||
const storedSession = await this.sessionStore.getAsync();
|
||||
|
||||
if (storedSession) {
|
||||
localLogger.debug("Using stored session for request", { integrationId: this.integration.id });
|
||||
const response = await callback(storedSession);
|
||||
if (response.status !== 401) {
|
||||
return response;
|
||||
}
|
||||
|
||||
localLogger.info("Session expired, getting new session", { integrationId: this.integration.id });
|
||||
}
|
||||
|
||||
const sessionId = await this.getSessionAsync();
|
||||
await this.sessionStore.setAsync(sessionId);
|
||||
const response = await callback(sessionId);
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a session id from the Pi-hole server
|
||||
* @returns The session id
|
||||
*/
|
||||
private async getSessionAsync(): Promise<string> {
|
||||
const apiKey = super.getSecretValue("apiKey");
|
||||
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/auth"), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ password: apiKey }),
|
||||
headers: {
|
||||
"User-Agent": "Homarr",
|
||||
},
|
||||
});
|
||||
const data = await response.json();
|
||||
const result = sessionResponseSchema.safeParse(data);
|
||||
if (!result.success) {
|
||||
throw new ParseError("session response", result.error, data);
|
||||
}
|
||||
if (!result.data.session.sid) {
|
||||
throw new IntegrationResponseError(this.integration, response, data);
|
||||
}
|
||||
|
||||
localLogger.info("Received session id successfully", { integrationId: this.integration.id });
|
||||
|
||||
return result.data.session.sid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the session from the Pi-hole server
|
||||
* @param sessionId The session id to remove
|
||||
*/
|
||||
private async clearSessionAsync(sessionId: string) {
|
||||
const response = await fetchWithTrustedCertificatesAsync(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");
|
||||
}
|
||||
}
|
||||
28
packages/integrations/src/pi-hole/v6/pi-hole-schemas-v6.ts
Normal file
28
packages/integrations/src/pi-hole/v6/pi-hole-schemas-v6.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const sessionResponseSchema = z.object({
|
||||
session: z.object({
|
||||
sid: z.string().nullable(),
|
||||
message: z.string().nullable(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const dnsBlockingGetSchema = z.object({
|
||||
blocking: z.enum(["enabled", "disabled", "failed", "unknown"]).transform((value) => {
|
||||
if (value === "failed") return undefined;
|
||||
if (value === "unknown") return undefined;
|
||||
return value;
|
||||
}),
|
||||
timer: z.number().nullable(),
|
||||
});
|
||||
|
||||
export const statsSummaryGetSchema = z.object({
|
||||
queries: z.object({
|
||||
total: z.number(),
|
||||
blocked: z.number(),
|
||||
percent_blocked: z.number(),
|
||||
}),
|
||||
gravity: z.object({
|
||||
domains_being_blocked: z.number(),
|
||||
}),
|
||||
});
|
||||
@@ -3,7 +3,6 @@ export * from "./interfaces/dns-hole-summary/dns-hole-summary-types";
|
||||
export * from "./interfaces/health-monitoring/healt-monitoring";
|
||||
export * from "./interfaces/indexer-manager/indexer";
|
||||
export * from "./interfaces/media-requests/media-request";
|
||||
export * from "./pi-hole/pi-hole-types";
|
||||
export * from "./base/searchable-integration";
|
||||
export * from "./homeassistant/homeassistant-types";
|
||||
export * from "./proxmox/proxmox-types";
|
||||
|
||||
Reference in New Issue
Block a user