feat: AdGuard Home integration (#929)
* feat: AdGuard Home integration * fix: code improvments * fix: a better errorMessages method
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
|
|
||||||
import { PiHoleIntegration } from "@homarr/integrations";
|
import { AdGuardHomeIntegration, PiHoleIntegration } from "@homarr/integrations";
|
||||||
import type { DnsHoleSummary } from "@homarr/integrations/types";
|
import type { DnsHoleSummary } from "@homarr/integrations/types";
|
||||||
import { logger } from "@homarr/log";
|
import { logger } from "@homarr/log";
|
||||||
import { createCacheChannel } from "@homarr/redis";
|
import { createCacheChannel } from "@homarr/redis";
|
||||||
@@ -22,14 +22,9 @@ export const dnsHoleRouter = createTRPCRouter({
|
|||||||
case "piHole":
|
case "piHole":
|
||||||
client = new PiHoleIntegration(integration);
|
client = new PiHoleIntegration(integration);
|
||||||
break;
|
break;
|
||||||
// case 'adGuardHome':
|
case "adGuardHome":
|
||||||
// client = new AdGuardHomeIntegration(integration);
|
client = new AdGuardHomeIntegration(integration);
|
||||||
// break;
|
break;
|
||||||
default:
|
|
||||||
throw new TRPCError({
|
|
||||||
code: "INTERNAL_SERVER_ERROR",
|
|
||||||
message: `Unsupported integration type: ${integration.kind}`,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return await client.getSummaryAsync().catch((err) => {
|
return await client.getSummaryAsync().catch((err) => {
|
||||||
@@ -59,14 +54,9 @@ export const dnsHoleRouter = createTRPCRouter({
|
|||||||
case "piHole":
|
case "piHole":
|
||||||
client = new PiHoleIntegration(ctx.integration);
|
client = new PiHoleIntegration(ctx.integration);
|
||||||
break;
|
break;
|
||||||
// case 'adGuardHome':
|
case "adGuardHome":
|
||||||
// client = new AdGuardHomeIntegration(ctx.integration);
|
client = new AdGuardHomeIntegration(ctx.integration);
|
||||||
// break;
|
break;
|
||||||
default:
|
|
||||||
throw new TRPCError({
|
|
||||||
code: "INTERNAL_SERVER_ERROR",
|
|
||||||
message: `Unsupported integration type: ${ctx.integration.kind}`,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
await client.enableAsync();
|
await client.enableAsync();
|
||||||
}),
|
}),
|
||||||
@@ -80,14 +70,9 @@ export const dnsHoleRouter = createTRPCRouter({
|
|||||||
case "piHole":
|
case "piHole":
|
||||||
client = new PiHoleIntegration(ctx.integration);
|
client = new PiHoleIntegration(ctx.integration);
|
||||||
break;
|
break;
|
||||||
// case 'adGuardHome':
|
case "adGuardHome":
|
||||||
// client = new AdGuardHomeIntegration(ctx.integration);
|
client = new AdGuardHomeIntegration(ctx.integration);
|
||||||
// break;
|
break;
|
||||||
default:
|
|
||||||
throw new TRPCError({
|
|
||||||
code: "INTERNAL_SERVER_ERROR",
|
|
||||||
message: `Unsupported integration type: ${ctx.integration.kind}`,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
await client.disableAsync(input.duration);
|
await client.disableAsync(input.duration);
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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<DnsHoleSummary> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
43
packages/integrations/src/adguard-home/adguard-home-types.ts
Normal file
43
packages/integrations/src/adguard-home/adguard-home-types.ts
Normal file
@@ -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(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
});
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { IntegrationKind } from "@homarr/definitions";
|
import type { IntegrationKind } from "@homarr/definitions";
|
||||||
|
|
||||||
|
import { AdGuardHomeIntegration } from "../adguard-home/adguard-home-integration";
|
||||||
import { HomeAssistantIntegration } from "../homeassistant/homeassistant-integration";
|
import { HomeAssistantIntegration } from "../homeassistant/homeassistant-integration";
|
||||||
import { JellyfinIntegration } from "../jellyfin/jellyfin-integration";
|
import { JellyfinIntegration } from "../jellyfin/jellyfin-integration";
|
||||||
import { SonarrIntegration } from "../media-organizer/sonarr/sonarr-integration";
|
import { SonarrIntegration } from "../media-organizer/sonarr/sonarr-integration";
|
||||||
@@ -10,6 +11,8 @@ export const integrationCreatorByKind = (kind: IntegrationKind, integration: Int
|
|||||||
switch (kind) {
|
switch (kind) {
|
||||||
case "piHole":
|
case "piHole":
|
||||||
return new PiHoleIntegration(integration);
|
return new PiHoleIntegration(integration);
|
||||||
|
case "adGuardHome":
|
||||||
|
return new AdGuardHomeIntegration(integration);
|
||||||
case "homeAssistant":
|
case "homeAssistant":
|
||||||
return new HomeAssistantIntegration(integration);
|
return new HomeAssistantIntegration(integration);
|
||||||
case "jellyfin":
|
case "jellyfin":
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
// General integrations
|
// 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 { HomeAssistantIntegration } from "./homeassistant/homeassistant-integration";
|
||||||
export { JellyfinIntegration } from "./jellyfin/jellyfin-integration";
|
export { JellyfinIntegration } from "./jellyfin/jellyfin-integration";
|
||||||
export { SonarrIntegration } from "./media-organizer/sonarr/sonarr-integration";
|
export { SonarrIntegration } from "./media-organizer/sonarr/sonarr-integration";
|
||||||
|
export { PiHoleIntegration } from "./pi-hole/pi-hole-integration";
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
export type { StreamSession } from "./interfaces/media-server/session";
|
export type { StreamSession } from "./interfaces/media-server/session";
|
||||||
|
|
||||||
// Helpers
|
// Helpers
|
||||||
export { IntegrationTestConnectionError } from "./base/test-connection-error";
|
|
||||||
export { integrationCreatorByKind } from "./base/creator";
|
export { integrationCreatorByKind } from "./base/creator";
|
||||||
|
export { IntegrationTestConnectionError } from "./base/test-connection-error";
|
||||||
|
|||||||
Reference in New Issue
Block a user