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,346 @@
import Consola from 'consola';
import { vi, describe, it, expect } from 'vitest';
import { PiHoleClient } from './piHole';
describe('PiHole API client', () => {
it('summary - throw exception when response status code is not 200', async () => {
// arrange
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=nice') {
return {
status: 404,
};
}
return Promise.reject(new Error(`Bad url: ${request.url}`));
});
const client = new PiHoleClient('http://pi.hole', 'nice');
// Act && Assert
await expect(() => client.getSummary()).rejects.toThrowErrorMatchingInlineSnapshot(
'"Status code does not indicate success: 404"'
);
expect(errorLogSpy).not.toHaveBeenCalled();
expect(warningLogSpy).not.toHaveBeenCalledOnce();
errorLogSpy.mockRestore();
});
it('summary -throw exception when response is empty', async () => {
// arrange
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=nice') {
return JSON.stringify([]);
}
return Promise.reject(new Error(`Bad url: ${request.url}`));
});
const client = new PiHoleClient('http://pi.hole', 'nice');
// Act && Assert
await expect(() => client.getSummary()).rejects.toThrowErrorMatchingInlineSnapshot(
'"Response does not indicate success. Authentication is most likely invalid: "'
);
expect(errorLogSpy).not.toHaveBeenCalled();
expect(warningLogSpy).not.toHaveBeenCalledOnce();
errorLogSpy.mockRestore();
});
it('summary -fetch and return object when success', async () => {
// arrange
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=nice') {
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}`));
});
const client = new PiHoleClient('http://pi.hole', 'nice');
// Act
const summary = await client.getSummary();
// Assert
expect(summary).toStrictEqual({
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 },
},
});
expect(errorLogSpy).not.toHaveBeenCalled();
expect(warningLogSpy).not.toHaveBeenCalled();
errorLogSpy.mockRestore();
});
it('enable - return true when state change is as expected', async () => {
// arrange
const errorLogSpy = vi.spyOn(Consola, 'error');
const warningLogSpy = vi.spyOn(Consola, 'warn');
let calledCount = 0;
fetchMock.mockResponse((request) => {
if (request.url === 'http://pi.hole/admin/api.php?enable&auth=nice') {
calledCount += 1;
return JSON.stringify({
status: 'enabled',
});
}
return Promise.reject(new Error(`Bad url: ${request.url}`));
});
const client = new PiHoleClient('http://pi.hole', 'nice');
// Act
const summary = await client.enable();
// Assert
expect(summary).toBe(true);
expect(calledCount).toBe(1);
expect(errorLogSpy).not.toHaveBeenCalled();
expect(warningLogSpy).not.toHaveBeenCalled();
errorLogSpy.mockRestore();
});
it('enable - return false when state change is not as expected', async () => {
// arrange
const errorLogSpy = vi.spyOn(Consola, 'error');
const warningLogSpy = vi.spyOn(Consola, 'warn');
let calledCount = 0;
fetchMock.mockResponse((request) => {
if (request.url === 'http://pi.hole/admin/api.php?enable&auth=nice') {
calledCount += 1;
return JSON.stringify({
status: 'disabled',
});
}
return Promise.reject(new Error(`Bad url: ${request.url}`));
});
const client = new PiHoleClient('http://pi.hole', 'nice');
// Act
const summary = await client.enable();
// Assert
expect(summary).toBe(false);
expect(calledCount).toBe(1);
expect(errorLogSpy).not.toHaveBeenCalled();
expect(warningLogSpy).not.toHaveBeenCalled();
errorLogSpy.mockRestore();
});
it('disable - return true when state change is as expected', async () => {
// arrange
const errorLogSpy = vi.spyOn(Consola, 'error');
const warningLogSpy = vi.spyOn(Consola, 'warn');
let calledCount = 0;
fetchMock.mockResponse((request) => {
if (request.url === 'http://pi.hole/admin/api.php?disable&auth=nice') {
calledCount += 1;
return JSON.stringify({
status: 'disabled',
});
}
return Promise.reject(new Error(`Bad url: ${request.url}`));
});
const client = new PiHoleClient('http://pi.hole', 'nice');
// Act
const summary = await client.disable();
// Assert
expect(summary).toBe(true);
expect(calledCount).toBe(1);
expect(errorLogSpy).not.toHaveBeenCalled();
expect(warningLogSpy).not.toHaveBeenCalled();
errorLogSpy.mockRestore();
});
it('disable - return false when state change is not as expected', async () => {
// arrange
const errorLogSpy = vi.spyOn(Consola, 'error');
const warningLogSpy = vi.spyOn(Consola, 'warn');
let calledCount = 0;
fetchMock.mockResponse((request) => {
if (request.url === 'http://pi.hole/admin/api.php?disable&auth=nice') {
calledCount += 1;
return JSON.stringify({
status: 'enabled',
});
}
return Promise.reject(new Error(`Bad url: ${request.url}`));
});
const client = new PiHoleClient('http://pi.hole', 'nice');
// Act
const summary = await client.disable();
// Assert
expect(summary).toBe(false);
expect(calledCount).toBe(1);
expect(errorLogSpy).not.toHaveBeenCalled();
expect(warningLogSpy).not.toHaveBeenCalled();
errorLogSpy.mockRestore();
});
it('disable - throw error when status code does not indicate success', async () => {
// arrange
const errorLogSpy = vi.spyOn(Consola, 'error');
const warningLogSpy = vi.spyOn(Consola, 'warn');
let calledCount = 0;
fetchMock.mockResponse((request) => {
if (request.url === 'http://pi.hole/admin/api.php?disable&auth=nice') {
calledCount += 1;
return {
status: 404,
};
}
return Promise.reject(new Error(`Bad url: ${request.url}`));
});
const client = new PiHoleClient('http://pi.hole', 'nice');
// Act & Assert
await expect(() => client.disable()).rejects.toThrowErrorMatchingInlineSnapshot('"Status code does not indicate success: 404"');
expect(calledCount).toBe(1);
expect(errorLogSpy).not.toHaveBeenCalled();
expect(warningLogSpy).not.toHaveBeenCalled();
errorLogSpy.mockRestore();
});
it('disable - throw error when response is empty', async () => {
// arrange
const errorLogSpy = vi.spyOn(Consola, 'error');
const warningLogSpy = vi.spyOn(Consola, 'warn');
let calledCount = 0;
fetchMock.mockResponse((request) => {
if (request.url === 'http://pi.hole/admin/api.php?disable&auth=nice') {
calledCount += 1;
return JSON.stringify([]);
}
return Promise.reject(new Error(`Bad url: ${request.url}`));
});
const client = new PiHoleClient('http://pi.hole', 'nice');
// Act & Assert
await expect(() => client.disable()).rejects.toThrowErrorMatchingInlineSnapshot('"Response does not indicate success. Authentication is most likely invalid: "');
expect(calledCount).toBe(1);
expect(errorLogSpy).not.toHaveBeenCalled();
expect(warningLogSpy).not.toHaveBeenCalled();
errorLogSpy.mockRestore();
});
});

View File

@@ -0,0 +1,74 @@
import { PiHoleApiStatusChangeResponse, PiHoleApiSummaryResponse } from './piHole.type';
export class PiHoleClient {
private readonly baseHostName: string;
constructor(hostname: string, private readonly apiToken: string) {
this.baseHostName = this.trimStringEnding(hostname, ['/admin/index.php', '/admin', '/']);
}
async getSummary() {
const response = await fetch(
new URL(`${this.baseHostName}/admin/api.php?summaryRaw&auth=${this.apiToken}`)
);
if (response.status !== 200) {
throw new Error(`Status code does not indicate success: ${response.status}`);
}
const json = await response.json();
if (Array.isArray(json)) {
throw new Error(
`Response does not indicate success. Authentication is most likely invalid: ${json}`
);
}
return json as PiHoleApiSummaryResponse;
}
async enable() {
const response = await this.sendStatusChangeRequest('enable');
return response.status === 'enabled';
}
async disable() {
const response = await this.sendStatusChangeRequest('disable');
return response.status === 'disabled';
}
private async sendStatusChangeRequest(
action: 'enable' | 'disable'
): Promise<PiHoleApiStatusChangeResponse> {
const response = await fetch(
`${this.baseHostName}/admin/api.php?${action}&auth=${this.apiToken}`
);
if (response.status !== 200) {
return Promise.reject(new Error(`Status code does not indicate success: ${response.status}`));
}
const json = await response.json();
if (Array.isArray(json)) {
return Promise.reject(
new Error(
`Response does not indicate success. Authentication is most likely invalid: ${json}`
)
);
}
return json as PiHoleApiStatusChangeResponse;
}
private trimStringEnding(original: string, toTrimIfExists: string[]) {
for (let i = 0; i < toTrimIfExists.length; i += 1) {
if (!original.endsWith(toTrimIfExists[i])) {
continue;
}
return original.substring(0, original.indexOf(toTrimIfExists[i]));
}
return original;
}
}

View File

@@ -0,0 +1,38 @@
export type PiHoleApiSummaryResponse = {
domains_being_blocked: number;
dns_queries_today: number;
ads_blocked_today: number;
ads_percentage_today: number;
unique_domains: number;
queries_forwarded: number;
queries_cached: number;
clients_ever_seen: number;
unique_clients: number;
dns_queries_all_types: number;
reply_UNKNOWN: number;
reply_NODATA: number;
reply_NXDOMAIN: number;
reply_CNAME: number;
reply_IP: number;
reply_DOMAIN: number;
reply_RRNAME: number;
reply_SERVFAIL: number;
reply_REFUSED: number;
reply_NOTIMP: number;
reply_OTHER: number;
reply_DNSSEC: number;
reply_NONE: number;
reply_BLOB: number;
dns_queries_all_replies: number;
privacy_level: number;
status: 'enabled' | 'disabled';
gravity_last_updated: {
file_exists: boolean;
absolute: number;
relative: { days: number; hours: number; minutes: number };
};
};
export type PiHoleApiStatusChangeResponse = {
status: 'enabled' | 'disabled';
};

View File

@@ -38,6 +38,8 @@ export const dashboardNamespaces = [
'modules/video-stream',
'modules/media-requests-list',
'modules/media-requests-stats',
'modules/dns-hole-summary',
'modules/dns-hole-controls',
'widgets/error-boundary',
];