Add pihole integration (#860)

*  Add pihole integration

* Update src/widgets/adhole/AdHoleControls.tsx

Co-authored-by: Larvey <39219859+LarveyOfficial@users.noreply.github.com>

* Update src/tools/client/math.ts

Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>

* Update src/widgets/dnshole/DnsHoleSummary.tsx

Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>

---------

Co-authored-by: Larvey <39219859+LarveyOfficial@users.noreply.github.com>
Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
This commit is contained in:
Manuel
2023-05-06 19:51:53 +02:00
committed by GitHub
parent 6ad799efe8
commit 92e8d79c5a
22 changed files with 1289 additions and 10 deletions

View File

@@ -0,0 +1,53 @@
/* eslint-disable no-await-in-loop */
import { z } from 'zod';
import { getCookie } from 'cookies-next';
import { NextApiRequest, NextApiResponse } from 'next';
import { getConfig } from '../../../../tools/config/getConfig';
import { findAppProperty } from '../../../../tools/client/app-properties';
import { PiHoleClient } from '../../../../tools/server/sdk/pihole/piHole';
const getQuerySchema = z.object({
status: z.enum(['enabled', 'disabled']),
});
export const Post = async (request: NextApiRequest, response: NextApiResponse) => {
const configName = getCookie('config-name', { req: request });
const config = getConfig(configName?.toString() ?? 'default');
const parseResult = getQuerySchema.safeParse(request.query);
if (!parseResult.success) {
response.status(400).json({ message: 'invalid query parameters, please specify the status' });
return;
}
const applicableApps = config.apps.filter((x) => x.integration?.type === 'pihole');
for (let i = 0; i < applicableApps.length; i += 1) {
const app = applicableApps[i];
const pihole = new PiHoleClient(
app.url,
findAppProperty(app, 'password')
);
switch (parseResult.data.status) {
case 'enabled':
await pihole.enable();
break;
case 'disabled':
await pihole.disable();
break;
}
}
response.status(200).json({});
};
export default async (request: NextApiRequest, response: NextApiResponse) => {
if (request.method === 'POST') {
return Post(request, response);
}
return response.status(405).json({});
};

View File

@@ -0,0 +1,273 @@
import Consola from 'consola';
import { describe, it, vi, expect } from 'vitest';
import { createMocks } from 'node-mocks-http';
import GetSummary from './summary';
import { ConfigType } from '../../../../types/config';
const mockedGetConfig = vi.fn();
describe('DNS hole', () => {
it('combine and return aggregated data', async () => {
// arrange
const { req, res } = createMocks({
method: 'GET',
});
vi.mock('./../../../../tools/config/getConfig.ts', () => ({
get getConfig() {
return mockedGetConfig;
},
}));
mockedGetConfig.mockReturnValue({
apps: [
{
url: 'http://pi.hole',
integration: {
type: 'pihole',
properties: [
{
field: 'password',
type: 'private',
value: 'hf3829fj238g8',
},
],
},
},
],
} as ConfigType);
const errorLogSpy = vi.spyOn(Consola, 'error');
const warningLogSpy = vi.spyOn(Consola, 'warn');
fetchMock.mockResponse((request) => {
if (request.url === 'http://pi.hole/admin/api.php?summaryRaw&auth=hf3829fj238g8') {
return JSON.stringify({
domains_being_blocked: 780348,
dns_queries_today: 36910,
ads_blocked_today: 9700,
ads_percentage_today: 26.280142,
unique_domains: 6217,
queries_forwarded: 12943,
queries_cached: 13573,
clients_ever_seen: 20,
unique_clients: 17,
dns_queries_all_types: 36910,
reply_UNKNOWN: 947,
reply_NODATA: 3313,
reply_NXDOMAIN: 1244,
reply_CNAME: 5265,
reply_IP: 25635,
reply_DOMAIN: 97,
reply_RRNAME: 4,
reply_SERVFAIL: 28,
reply_REFUSED: 0,
reply_NOTIMP: 0,
reply_OTHER: 0,
reply_DNSSEC: 0,
reply_NONE: 0,
reply_BLOB: 377,
dns_queries_all_replies: 36910,
privacy_level: 0,
status: 'enabled',
gravity_last_updated: {
file_exists: true,
absolute: 1682216493,
relative: {
days: 5,
hours: 17,
minutes: 52,
},
},
});
}
return Promise.reject(new Error(`Bad url: ${request.url}`));
});
// Act
await GetSummary(req, res);
// Assert
expect(res._getStatusCode()).toBe(200);
expect(res.finished).toBe(true);
expect(JSON.parse(res._getData())).toEqual({
adsBlockedToday: 9700,
adsBlockedTodayPercentage: 0.26280140883229475,
dnsQueriesToday: 36910,
domainsBeingBlocked: 780348,
status: [
{
status: 'enabled',
},
],
});
expect(errorLogSpy).not.toHaveBeenCalled();
expect(warningLogSpy).not.toHaveBeenCalled();
errorLogSpy.mockRestore();
});
it('combine and return aggregated data when multiple instances', async () => {
// arrange
const { req, res } = createMocks({
method: 'GET',
});
vi.mock('./../../../../tools/config/getConfig.ts', () => ({
get getConfig() {
return mockedGetConfig;
},
}));
mockedGetConfig.mockReturnValue({
apps: [
{
id: 'app1',
url: 'http://pi.hole',
integration: {
type: 'pihole',
properties: [
{
field: 'password',
type: 'private',
value: 'hf3829fj238g8',
},
],
},
},
{
id: 'app2',
url: 'http://pi2.hole',
integration: {
type: 'pihole',
properties: [
{
field: 'password',
type: 'private',
value: 'ayaka',
},
],
},
},
],
} as ConfigType);
const errorLogSpy = vi.spyOn(Consola, 'error');
const warningLogSpy = vi.spyOn(Consola, 'warn');
fetchMock.mockResponse((request) => {
if (request.url === 'http://pi.hole/admin/api.php?summaryRaw&auth=hf3829fj238g8') {
return JSON.stringify({
domains_being_blocked: 3,
dns_queries_today: 8,
ads_blocked_today: 5,
ads_percentage_today: 26,
unique_domains: 4,
queries_forwarded: 2,
queries_cached: 2,
clients_ever_seen: 2,
unique_clients: 3,
dns_queries_all_types: 3,
reply_UNKNOWN: 2,
reply_NODATA: 3,
reply_NXDOMAIN: 5,
reply_CNAME: 6,
reply_IP: 5,
reply_DOMAIN: 3,
reply_RRNAME: 2,
reply_SERVFAIL: 2,
reply_REFUSED: 0,
reply_NOTIMP: 0,
reply_OTHER: 0,
reply_DNSSEC: 0,
reply_NONE: 0,
reply_BLOB: 1,
dns_queries_all_replies: 36910,
privacy_level: 0,
status: 'enabled',
gravity_last_updated: {
file_exists: true,
absolute: 1682216493,
relative: {
days: 5,
hours: 17,
minutes: 52,
},
},
});
}
if (request.url === 'http://pi2.hole/admin/api.php?summaryRaw&auth=ayaka') {
return JSON.stringify({
domains_being_blocked: 1,
dns_queries_today: 3,
ads_blocked_today: 2,
ads_percentage_today: 47,
unique_domains: 4,
queries_forwarded: 4,
queries_cached: 2,
clients_ever_seen: 2,
unique_clients: 2,
dns_queries_all_types: 1,
reply_UNKNOWN: 3,
reply_NODATA: 2,
reply_NXDOMAIN: 1,
reply_CNAME: 3,
reply_IP: 2,
reply_DOMAIN: 97,
reply_RRNAME: 4,
reply_SERVFAIL: 28,
reply_REFUSED: 0,
reply_NOTIMP: 0,
reply_OTHER: 0,
reply_DNSSEC: 0,
reply_NONE: 0,
reply_BLOB: 2,
dns_queries_all_replies: 4,
privacy_level: 0,
status: 'disabled',
gravity_last_updated: {
file_exists: true,
absolute: 1682216493,
relative: {
days: 5,
hours: 17,
minutes: 52,
},
},
});
}
return Promise.reject(new Error(`Bad url: ${request.url}`));
});
// Act
await GetSummary(req, res);
// Assert
expect(res._getStatusCode()).toBe(200);
expect(res.finished).toBe(true);
expect(JSON.parse(res._getData())).toStrictEqual({
adsBlockedToday: 7,
adsBlockedTodayPercentage: 0.6363636363636364,
dnsQueriesToday: 11,
domainsBeingBlocked: 4,
status: [
{
appId: 'app1',
status: 'enabled',
},
{
appId: 'app2',
status: 'disabled',
},
],
});
expect(errorLogSpy).not.toHaveBeenCalled();
expect(warningLogSpy).not.toHaveBeenCalled();
errorLogSpy.mockRestore();
});
});

View File

@@ -0,0 +1,60 @@
import Consola from 'consola';
import { getCookie } from 'cookies-next';
import { NextApiRequest, NextApiResponse } from 'next';
import { findAppProperty } from '../../../../tools/client/app-properties';
import { getConfig } from '../../../../tools/config/getConfig';
import { PiHoleClient } from '../../../../tools/server/sdk/pihole/piHole';
import { AdStatistics } from '../../../../widgets/dnshole/type';
export const Get = async (request: NextApiRequest, response: NextApiResponse) => {
const configName = getCookie('config-name', { req: request });
const config = getConfig(configName?.toString() ?? 'default');
const applicableApps = config.apps.filter((x) => x.integration?.type === 'pihole');
const data: AdStatistics = {
domainsBeingBlocked: 0,
adsBlockedToday: 0,
adsBlockedTodayPercentage: 0,
dnsQueriesToday: 0,
status: [],
};
const adsBlockedTodayPercentageArr: number[] = [];
for (let i = 0; i < applicableApps.length; i += 1) {
const app = applicableApps[i];
try {
const piHole = new PiHoleClient(app.url, findAppProperty(app, 'password'));
// eslint-disable-next-line no-await-in-loop
const summary = await piHole.getSummary();
data.domainsBeingBlocked += summary.domains_being_blocked;
data.adsBlockedToday += summary.ads_blocked_today;
data.dnsQueriesToday += summary.dns_queries_today;
data.status.push({
status: summary.status,
appId: app.id,
});
adsBlockedTodayPercentageArr.push(summary.ads_percentage_today);
} catch (err) {
Consola.error(`Failed to communicate with PiHole at ${app.url}: ${err}`);
}
}
data.adsBlockedTodayPercentage = data.adsBlockedToday / data.dnsQueriesToday;
if (Number.isNaN(data.adsBlockedTodayPercentage)) {
data.adsBlockedTodayPercentage = 0;
}
return response.status(200).json(data);
};
export default async (request: NextApiRequest, response: NextApiResponse) => {
if (request.method === 'GET') {
return Get(request, response);
}
return response.status(405);
};