feat: OPNsense integration and widget (#3424)

Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
Co-authored-by: deepsource-io[bot] <42547082+deepsource-io[bot]@users.noreply.github.com>
This commit is contained in:
Benoit SERRA
2025-08-01 18:34:06 +02:00
committed by GitHub
parent 511551aee7
commit 1dc1854cbf
24 changed files with 1151 additions and 2 deletions

View File

@@ -31,6 +31,7 @@ import { NextcloudIntegration } from "../nextcloud/nextcloud.integration";
import { NPMIntegration } from "../npm/npm-integration";
import { NTFYIntegration } from "../ntfy/ntfy-integration";
import { OpenMediaVaultIntegration } from "../openmediavault/openmediavault-integration";
import { OPNsenseIntegration } from "../opnsense/opnsense-integration";
import { OverseerrIntegration } from "../overseerr/overseerr-integration";
import { createPiHoleIntegrationAsync } from "../pi-hole/pi-hole-integration-factory";
import { PlexIntegration } from "../plex/plex-integration";
@@ -102,6 +103,7 @@ export const integrationCreators = {
emby: EmbyIntegration,
nextcloud: NextcloudIntegration,
unifiController: UnifiControllerIntegration,
opnsense: OPNsenseIntegration,
github: GithubIntegration,
dockerHub: DockerHubIntegration,
gitlab: GitlabIntegration,

View File

@@ -21,13 +21,20 @@ 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 { OPNsenseIntegration } from "./opnsense/opnsense-integration";
// Types
export type { IntegrationInput } from "./base/integration";
export type { DownloadClientJobsAndStatus } from "./interfaces/downloads/download-client-data";
export type { ExtendedDownloadClientItem } from "./interfaces/downloads/download-client-items";
export type { ExtendedClientStatus } from "./interfaces/downloads/download-client-status";
export type {
FirewallInterface,
FirewallCpuSummary,
FirewallInterfacesSummary,
FirewallVersionSummary,
FirewallMemorySummary,
} from "./interfaces/firewall-summary/firewall-summary-types";
export type { SystemHealthMonitoring } from "./interfaces/health-monitoring/health-monitoring-types";
export { MediaRequestStatus } from "./interfaces/media-requests/media-request-types";
export type { MediaRequestList, MediaRequestStats } from "./interfaces/media-requests/media-request-types";

View File

@@ -0,0 +1,13 @@
import type {
FirewallCpuSummary,
FirewallInterfacesSummary,
FirewallMemorySummary,
FirewallVersionSummary,
} from "./firewall-summary-types";
export interface FirewallSummaryIntegration {
getFirewallCpuAsync(): Promise<FirewallCpuSummary>;
getFirewallMemoryAsync(): Promise<FirewallMemorySummary>;
getFirewallInterfacesAsync(): Promise<FirewallInterfacesSummary[]>;
getFirewallVersionAsync(): Promise<FirewallVersionSummary>;
}

View File

@@ -0,0 +1,24 @@
export interface FirewallInterfacesSummary {
data: FirewallInterface[];
timestamp: Date;
}
export interface FirewallInterface {
name: string;
receive: number;
transmit: number;
}
export interface FirewallVersionSummary {
version: string;
}
export interface FirewallCpuSummary {
total: number;
}
export interface FirewallMemorySummary {
used: number;
total: number;
percent: number;
}

View File

@@ -0,0 +1,189 @@
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { ParseError, ResponseError } from "@homarr/common/server";
import { createChannelEventHistory } from "@homarr/redis";
import { HandleIntegrationErrors } from "../base/errors/decorator";
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import { TestConnectionError } from "../base/test-connection/test-connection-error";
import type { TestingResult } from "../base/test-connection/test-connection-service";
import type { FirewallSummaryIntegration } from "../interfaces/firewall-summary/firewall-summary-integration";
import type {
FirewallCpuSummary,
FirewallInterface,
FirewallInterfacesSummary,
FirewallMemorySummary,
FirewallVersionSummary,
} from "../interfaces/firewall-summary/firewall-summary-types";
import {
opnsenseCPUSchema,
opnsenseInterfacesSchema,
opnsenseMemorySchema,
opnsenseSystemSummarySchema,
} from "./opnsense-types";
@HandleIntegrationErrors([])
export class OPNsenseIntegration extends Integration implements FirewallSummaryIntegration {
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const response = await input.fetchAsync(this.url("/api/diagnostics/system/system_information"), {
headers: {
Authorization: this.getAuthHeaders(),
},
});
if (!response.ok) return TestConnectionError.StatusResult(response);
const result = await response.json();
if (typeof result === "object" && result !== null) return { success: true };
return TestConnectionError.ParseResult(new ParseError("Expected object data"));
}
private getAuthHeaders() {
const username = super.getSecretValue("username");
const password = super.getSecretValue("password");
return `Basic ${btoa(`${username}:${password}`)}`;
}
public async getFirewallVersionAsync(): Promise<FirewallVersionSummary> {
const responseVersion = await fetchWithTrustedCertificatesAsync(
this.url("/api/diagnostics/system/system_information"),
{
headers: {
Authorization: this.getAuthHeaders(),
},
},
);
if (!responseVersion.ok) {
throw new ResponseError(responseVersion);
}
const summary = opnsenseSystemSummarySchema.parse(await responseVersion.json());
return {
version: summary.versions.at(0) ?? "Unknown",
};
}
private getInterfacesChannel() {
return createChannelEventHistory<FirewallInterface[]>(`integration:${this.integration.id}:interfaces`, 15);
}
public async getFirewallInterfacesAsync(): Promise<FirewallInterfacesSummary[]> {
const channel = this.getInterfacesChannel();
const responseInterfaces = await fetchWithTrustedCertificatesAsync(this.url("/api/diagnostics/traffic/interface"), {
headers: {
Authorization: this.getAuthHeaders(),
},
});
if (!responseInterfaces.ok) {
throw new ResponseError(responseInterfaces);
}
const interfaces = opnsenseInterfacesSchema.parse(await responseInterfaces.json());
const returnValue: FirewallInterface[] = [];
const interfaceKeys = Object.keys(interfaces.interfaces);
for (const key of interfaceKeys) {
const inter = interfaces.interfaces[key];
if (!inter) continue;
const bytesTransmitted = inter["bytes transmitted"];
const bytesReceived = inter["bytes received"];
const receiveValue = parseInt(bytesReceived, 10);
const transmitValue = parseInt(bytesTransmitted, 10);
returnValue.push({
name: inter.name,
receive: receiveValue,
transmit: transmitValue,
});
}
await channel.pushAsync(returnValue);
return await channel.getSliceAsync(0, 1);
}
public async getFirewallMemoryAsync(): Promise<FirewallMemorySummary> {
const responseMemory = await fetchWithTrustedCertificatesAsync(
this.url("/api/diagnostics/system/systemResources"),
{
headers: {
Authorization: this.getAuthHeaders(),
},
},
);
if (!responseMemory.ok) {
throw new ResponseError(responseMemory);
}
const memory = opnsenseMemorySchema.parse(await responseMemory.json());
// Using parseInt for memoryTotal is normal, the api sends the total memory as a string
const memoryTotal = parseInt(memory.memory.total);
const memoryUsed = memory.memory.used;
const memoryPercent = (100 * memoryUsed) / memoryTotal;
return {
total: memoryTotal,
used: memoryUsed,
percent: memoryPercent,
};
}
public async getFirewallCpuAsync(): Promise<FirewallCpuSummary> {
const responseCpu = await fetchWithTrustedCertificatesAsync(this.url("/api/diagnostics/cpu_usage/stream"), {
headers: {
Authorization: this.getAuthHeaders(),
},
});
if (!responseCpu.ok) {
throw new ResponseError(responseCpu);
}
if (!responseCpu.body) {
throw new Error("ReadableStream not supported in this environment.");
}
const reader = responseCpu.body.getReader();
const decoder = new TextDecoder();
let loopCounter = 0;
try {
while (loopCounter < 10) {
loopCounter++;
const result = await reader.read();
if (result.done) {
break;
}
if (!(result.value instanceof Uint8Array)) {
throw new Error("Received value is not an Uint8Array.");
}
const value: AllowSharedBufferSource = result.value;
const chunk = decoder.decode(value, { stream: true });
const lines = chunk.split("\n");
for (const line of lines) {
if (!line.startsWith("data:")) {
continue;
}
if (loopCounter < 2) {
continue;
}
const data = line.substring(5).trim();
const cpuValues = opnsenseCPUSchema.parse(JSON.parse(data));
return {
...cpuValues,
};
}
}
throw new Error("No valid CPU data found.");
} finally {
await reader.cancel();
}
}
}

View File

@@ -0,0 +1,30 @@
import { z } from "zod";
// API documentation : https://docs.opnsense.org/development/api.html#core-api
export const opnsenseSystemSummarySchema = z.object({
name: z.string(),
versions: z.array(z.string()),
});
export const opnsenseMemorySchema = z.object({
memory: z.object({
total: z.string(),
used: z.number(),
}),
});
const interfaceSchema = z.object({
"bytes received": z.string(),
"bytes transmitted": z.string(),
name: z.string(),
});
export const opnsenseInterfacesSchema = z.object({
interfaces: z.record(interfaceSchema),
time: z.number(),
});
export const opnsenseCPUSchema = z.object({
total: z.number(),
});

View File

@@ -1,6 +1,7 @@
export * from "./interfaces/calendar/calendar-types";
export * from "./interfaces/dns-hole-summary/dns-hole-summary-types";
export * from "./interfaces/network-controller-summary/network-controller-summary-types";
export * from "./interfaces/firewall-summary/firewall-summary-types";
export * from "./interfaces/health-monitoring/health-monitoring-types";
export * from "./interfaces/indexer-manager/indexer-manager-types";
export * from "./interfaces/media-requests/media-request-types";
@@ -8,4 +9,5 @@ export * from "./base/searchable-integration";
export * from "./homeassistant/homeassistant-types";
export * from "./proxmox/proxmox-types";
export * from "./unifi-controller/unifi-controller-types";
export * from "./opnsense/opnsense-types";
export * from "./interfaces/media-releases";