From 44712608b7e0dfcbaf5d36c40792b04f2edec8d7 Mon Sep 17 00:00:00 2001 From: Yossi Hillali Date: Wed, 7 Aug 2024 09:06:59 +0300 Subject: [PATCH] feat: AdGuard Home integration (#929) * feat: AdGuard Home integration * fix: code improvments * fix: a better errorMessages method --- packages/api/src/router/widgets/dns-hole.ts | 35 ++-- .../adguard-home/adguard-home-integration.ts | 150 ++++++++++++++++++ .../src/adguard-home/adguard-home-types.ts | 43 +++++ packages/integrations/src/base/creator.ts | 3 + packages/integrations/src/index.ts | 5 +- 5 files changed, 209 insertions(+), 27 deletions(-) create mode 100644 packages/integrations/src/adguard-home/adguard-home-integration.ts create mode 100644 packages/integrations/src/adguard-home/adguard-home-types.ts diff --git a/packages/api/src/router/widgets/dns-hole.ts b/packages/api/src/router/widgets/dns-hole.ts index ea6ebc375..f12c36edb 100644 --- a/packages/api/src/router/widgets/dns-hole.ts +++ b/packages/api/src/router/widgets/dns-hole.ts @@ -1,6 +1,6 @@ import { TRPCError } from "@trpc/server"; -import { PiHoleIntegration } from "@homarr/integrations"; +import { AdGuardHomeIntegration, PiHoleIntegration } from "@homarr/integrations"; import type { DnsHoleSummary } from "@homarr/integrations/types"; import { logger } from "@homarr/log"; import { createCacheChannel } from "@homarr/redis"; @@ -22,14 +22,9 @@ export const dnsHoleRouter = createTRPCRouter({ case "piHole": client = new PiHoleIntegration(integration); break; - // case 'adGuardHome': - // client = new AdGuardHomeIntegration(integration); - // break; - default: - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: `Unsupported integration type: ${integration.kind}`, - }); + case "adGuardHome": + client = new AdGuardHomeIntegration(integration); + break; } return await client.getSummaryAsync().catch((err) => { @@ -59,14 +54,9 @@ export const dnsHoleRouter = createTRPCRouter({ case "piHole": client = new PiHoleIntegration(ctx.integration); break; - // case 'adGuardHome': - // client = new AdGuardHomeIntegration(ctx.integration); - // break; - default: - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: `Unsupported integration type: ${ctx.integration.kind}`, - }); + case "adGuardHome": + client = new AdGuardHomeIntegration(ctx.integration); + break; } await client.enableAsync(); }), @@ -80,14 +70,9 @@ export const dnsHoleRouter = createTRPCRouter({ case "piHole": client = new PiHoleIntegration(ctx.integration); break; - // case 'adGuardHome': - // client = new AdGuardHomeIntegration(ctx.integration); - // break; - default: - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: `Unsupported integration type: ${ctx.integration.kind}`, - }); + case "adGuardHome": + client = new AdGuardHomeIntegration(ctx.integration); + break; } await client.disableAsync(input.duration); }), diff --git a/packages/integrations/src/adguard-home/adguard-home-integration.ts b/packages/integrations/src/adguard-home/adguard-home-integration.ts new file mode 100644 index 000000000..b60b3f69d --- /dev/null +++ b/packages/integrations/src/adguard-home/adguard-home-integration.ts @@ -0,0 +1,150 @@ +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 { filteringStatusSchema, statsResponseSchema, statusResponseSchema } from "./adguard-home-types"; + +export class AdGuardHomeIntegration extends Integration implements DnsHoleSummaryIntegration { + public async getSummaryAsync(): Promise { + const statsResponse = await fetch(`${this.integration.url}/control/stats`, { + headers: { + Authorization: `Basic ${this.getAuthorizationHeaderValue()}`, + }, + }); + + if (!statsResponse.ok) { + throw new Error( + `Failed to fetch stats for ${this.integration.name} (${this.integration.id}): ${statsResponse.statusText}`, + ); + } + + const statusResponse = await fetch(`${this.integration.url}/control/status`, { + headers: { + Authorization: `Basic ${this.getAuthorizationHeaderValue()}`, + }, + }); + + if (!statusResponse.ok) { + throw new Error( + `Failed to fetch status for ${this.integration.name} (${this.integration.id}): ${statusResponse.statusText}`, + ); + } + + const filteringStatusResponse = await fetch(`${this.integration.url}/control/filtering/status`, { + headers: { + Authorization: `Basic ${this.getAuthorizationHeaderValue()}`, + }, + }); + + if (!filteringStatusResponse.ok) { + throw new Error( + `Failed to fetch filtering status for ${this.integration.name} (${this.integration.id}): ${filteringStatusResponse.statusText}`, + ); + } + + const stats = statsResponseSchema.safeParse(await statsResponse.json()); + const status = statusResponseSchema.safeParse(await statusResponse.json()); + const filteringStatus = filteringStatusSchema.safeParse(await filteringStatusResponse.json()); + + const errorMessages: string[] = []; + if (!stats.success) { + errorMessages.push(`Stats parsing error: ${stats.error.message}`); + } + if (!status.success) { + errorMessages.push(`Status parsing error: ${status.error.message}`); + } + if (!filteringStatus.success) { + errorMessages.push(`Filtering status parsing error: ${filteringStatus.error.message}`); + } + if (!stats.success || !status.success || !filteringStatus.success) { + throw new Error( + `Failed to parse summary for ${this.integration.name} (${this.integration.id}):\n${errorMessages.join("\n")}`, + ); + } + + const blockedQueriesToday = + stats.data.time_units === "days" + ? (stats.data.blocked_filtering[stats.data.blocked_filtering.length - 1] ?? 0) + : stats.data.blocked_filtering.reduce((prev, sum) => prev + sum, 0); + const queriesToday = + stats.data.time_units === "days" + ? (stats.data.dns_queries[stats.data.dns_queries.length - 1] ?? 0) + : stats.data.dns_queries.reduce((prev, sum) => prev + sum, 0); + const countFilteredDomains = filteringStatus.data.filters + .filter((filter) => filter.enabled) + .reduce((sum, filter) => filter.rules_count + sum, 0); + + return { + status: status.data.protection_enabled ? ("enabled" as const) : ("disabled" as const), + adsBlockedToday: blockedQueriesToday, + adsBlockedTodayPercentage: (queriesToday / blockedQueriesToday) * 100, + domainsBeingBlocked: countFilteredDomains, + dnsQueriesToday: queriesToday, + }; + } + + public async testConnectionAsync(): Promise { + await super.handleTestConnectionResponseAsync({ + queryFunctionAsync: async () => { + return await fetch(`${this.integration.url}/control/status`, { + headers: { + Authorization: `Basic ${this.getAuthorizationHeaderValue()}`, + }, + }); + }, + handleResponseAsync: async (response) => { + try { + const result = (await response.json()) as unknown; + if (typeof result === "object" && result !== null) return; + } catch (error) { + throw new IntegrationTestConnectionError("invalidJson"); + } + + throw new IntegrationTestConnectionError("invalidCredentials"); + }, + }); + } + + public async enableAsync(): Promise { + const response = await fetch(`${this.integration.url}/control/protection`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Basic ${this.getAuthorizationHeaderValue()}`, + }, + body: JSON.stringify({ + enabled: true, + }), + }); + if (!response.ok) { + throw new Error( + `Failed to enable AdGuard Home for ${this.integration.name} (${this.integration.id}): ${response.statusText}`, + ); + } + } + + public async disableAsync(duration?: number): Promise { + const response = await fetch(`${this.integration.url}/control/protection`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Basic ${this.getAuthorizationHeaderValue()}`, + }, + body: JSON.stringify({ + enabled: false, + duration: duration, + }), + }); + if (!response.ok) { + throw new Error( + `Failed to disable AdGuard Home for ${this.integration.name} (${this.integration.id}): ${response.statusText}`, + ); + } + } + + private getAuthorizationHeaderValue() { + const username = super.getSecretValue("username"); + const password = super.getSecretValue("password"); + return Buffer.from(`${username}:${password}`).toString("base64"); + } +} diff --git a/packages/integrations/src/adguard-home/adguard-home-types.ts b/packages/integrations/src/adguard-home/adguard-home-types.ts new file mode 100644 index 000000000..adc56b4d9 --- /dev/null +++ b/packages/integrations/src/adguard-home/adguard-home-types.ts @@ -0,0 +1,43 @@ +import { z } from "@homarr/validation"; + +export const statsResponseSchema = z.object({ + time_units: z.enum(["hours", "days"]), + top_queried_domains: z.array(z.record(z.string(), z.number())), + top_clients: z.array(z.record(z.string(), z.number())), + top_blocked_domains: z.array(z.record(z.string(), z.number())), + dns_queries: z.array(z.number()), + blocked_filtering: z.array(z.number()), + replaced_safebrowsing: z.array(z.number()), + replaced_parental: z.array(z.number()), + num_dns_queries: z.number().min(0), + num_blocked_filtering: z.number().min(0), + num_replaced_safebrowsing: z.number().min(0), + num_replaced_safesearch: z.number().min(0), + num_replaced_parental: z.number().min(0), + avg_processing_time: z.number().min(0), +}); + +export const statusResponseSchema = z.object({ + version: z.string(), + language: z.string(), + dns_addresses: z.array(z.string()), + dns_port: z.number().positive(), + http_port: z.number().positive(), + protection_disabled_duration: z.number(), + protection_enabled: z.boolean(), + dhcp_available: z.boolean(), + running: z.boolean(), +}); + +export const filteringStatusSchema = z.object({ + filters: z.array( + z.object({ + url: z.string(), + name: z.string(), + last_updated: z.string().optional(), + id: z.number().nonnegative(), + rules_count: z.number().nonnegative(), + enabled: z.boolean(), + }), + ), +}); diff --git a/packages/integrations/src/base/creator.ts b/packages/integrations/src/base/creator.ts index fb7106e89..53d91a046 100644 --- a/packages/integrations/src/base/creator.ts +++ b/packages/integrations/src/base/creator.ts @@ -1,5 +1,6 @@ import type { IntegrationKind } from "@homarr/definitions"; +import { AdGuardHomeIntegration } from "../adguard-home/adguard-home-integration"; import { HomeAssistantIntegration } from "../homeassistant/homeassistant-integration"; import { JellyfinIntegration } from "../jellyfin/jellyfin-integration"; import { SonarrIntegration } from "../media-organizer/sonarr/sonarr-integration"; @@ -10,6 +11,8 @@ export const integrationCreatorByKind = (kind: IntegrationKind, integration: Int switch (kind) { case "piHole": return new PiHoleIntegration(integration); + case "adGuardHome": + return new AdGuardHomeIntegration(integration); case "homeAssistant": return new HomeAssistantIntegration(integration); case "jellyfin": diff --git a/packages/integrations/src/index.ts b/packages/integrations/src/index.ts index 13267b54c..5a7dc7eb7 100644 --- a/packages/integrations/src/index.ts +++ b/packages/integrations/src/index.ts @@ -1,12 +1,13 @@ // General integrations -export { PiHoleIntegration } from "./pi-hole/pi-hole-integration"; +export { AdGuardHomeIntegration } from "./adguard-home/adguard-home-integration"; export { HomeAssistantIntegration } from "./homeassistant/homeassistant-integration"; export { JellyfinIntegration } from "./jellyfin/jellyfin-integration"; export { SonarrIntegration } from "./media-organizer/sonarr/sonarr-integration"; +export { PiHoleIntegration } from "./pi-hole/pi-hole-integration"; // Types export type { StreamSession } from "./interfaces/media-server/session"; // Helpers -export { IntegrationTestConnectionError } from "./base/test-connection-error"; export { integrationCreatorByKind } from "./base/creator"; +export { IntegrationTestConnectionError } from "./base/test-connection-error";