diff --git a/packages/api/src/router/widgets/firewall.ts b/packages/api/src/router/widgets/firewall.ts new file mode 100644 index 000000000..2d582cb1f --- /dev/null +++ b/packages/api/src/router/widgets/firewall.ts @@ -0,0 +1,215 @@ +import { observable } from "@trpc/server/observable"; + +import type { Modify } from "@homarr/common/types"; +import type { Integration } from "@homarr/db/schema"; +import type { IntegrationKindByCategory } from "@homarr/definitions"; +import { getIntegrationKindsByCategory } from "@homarr/definitions"; +import type { + FirewallCpuSummary, + FirewallInterfacesSummary, + FirewallMemorySummary, + FirewallVersionSummary, +} from "@homarr/integrations"; +import { + firewallCpuRequestHandler, + firewallInterfacesRequestHandler, + firewallMemoryRequestHandler, + firewallVersionRequestHandler, +} from "@homarr/request-handler/firewall"; + +import { createManyIntegrationMiddleware } from "../../middlewares/integration"; +import { createTRPCRouter, publicProcedure } from "../../trpc"; + +export const firewallRouter = createTRPCRouter({ + getFirewallCpuStatus: publicProcedure + .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("firewall"))) + .query(async ({ ctx }) => { + const results = await Promise.all( + ctx.integrations.map(async (integration) => { + const innerHandler = firewallCpuRequestHandler.handler(integration, {}); + const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); + + return { + integration: { + id: integration.id, + name: integration.name, + kind: integration.kind, + updatedAt: timestamp, + }, + summary: data, + }; + }), + ); + return results; + }), + subscribeFirewallCpuStatus: publicProcedure + .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("firewall"))) + .subscription(({ ctx }) => { + return observable<{ + integration: Modify }>; + summary: FirewallCpuSummary; + }>((emit) => { + const unsubscribes: (() => void)[] = []; + for (const integrationWithSecrets of ctx.integrations) { + const { decryptedSecrets: _, ...integration } = integrationWithSecrets; + const innerHandler = firewallCpuRequestHandler.handler(integrationWithSecrets, {}); + const unsubscribe = innerHandler.subscribe((summary) => { + emit.next({ + integration, + summary, + }); + }); + unsubscribes.push(unsubscribe); + } + return () => { + unsubscribes.forEach((unsubscribe) => { + unsubscribe(); + }); + }; + }); + }), + + getFirewallInterfacesStatus: publicProcedure + .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("firewall"))) + .query(async ({ ctx }) => { + const results = await Promise.all( + ctx.integrations.map(async (integration) => { + const innerHandler = firewallInterfacesRequestHandler.handler(integration, {}); + const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); + + return { + integration: { + id: integration.id, + name: integration.name, + kind: integration.kind, + updatedAt: timestamp, + }, + summary: data, + }; + }), + ); + return results; + }), + subscribeFirewallInterfacesStatus: publicProcedure + .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("firewall"))) + .subscription(({ ctx }) => { + return observable<{ + integration: Modify }>; + summary: FirewallInterfacesSummary[]; + }>((emit) => { + const unsubscribes: (() => void)[] = []; + for (const integrationWithSecrets of ctx.integrations) { + const { decryptedSecrets: _, ...integration } = integrationWithSecrets; + const innerHandler = firewallInterfacesRequestHandler.handler(integrationWithSecrets, {}); + const unsubscribe = innerHandler.subscribe((summary) => { + emit.next({ + integration, + summary, + }); + }); + unsubscribes.push(unsubscribe); + } + return () => { + unsubscribes.forEach((unsubscribe) => { + unsubscribe(); + }); + }; + }); + }), + + getFirewallVersionStatus: publicProcedure + .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("firewall"))) + .query(async ({ ctx }) => { + const results = await Promise.all( + ctx.integrations.map(async (integration) => { + const innerHandler = firewallVersionRequestHandler.handler(integration, {}); + const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); + + return { + integration: { + id: integration.id, + name: integration.name, + kind: integration.kind, + updatedAt: timestamp, + }, + summary: data, + }; + }), + ); + return results; + }), + subscribeFirewallVersionStatus: publicProcedure + .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("firewall"))) + .subscription(({ ctx }) => { + return observable<{ + integration: Modify }>; + summary: FirewallVersionSummary; + }>((emit) => { + const unsubscribes: (() => void)[] = []; + for (const integrationWithSecrets of ctx.integrations) { + const { decryptedSecrets: _, ...integration } = integrationWithSecrets; + const innerHandler = firewallVersionRequestHandler.handler(integrationWithSecrets, {}); + const unsubscribe = innerHandler.subscribe((summary) => { + emit.next({ + integration, + summary, + }); + }); + unsubscribes.push(unsubscribe); + } + return () => { + unsubscribes.forEach((unsubscribe) => { + unsubscribe(); + }); + }; + }); + }), + + getFirewallMemoryStatus: publicProcedure + .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("firewall"))) + .query(async ({ ctx }) => { + const results = await Promise.all( + ctx.integrations.map(async (integration) => { + const innerHandler = firewallMemoryRequestHandler.handler(integration, {}); + const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); + + return { + integration: { + id: integration.id, + name: integration.name, + kind: integration.kind, + updatedAt: timestamp, + }, + summary: data, + }; + }), + ); + return results; + }), + subscribeFirewallMemoryStatus: publicProcedure + .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("firewall"))) + .subscription(({ ctx }) => { + return observable<{ + integration: Modify }>; + summary: FirewallMemorySummary; + }>((emit) => { + const unsubscribes: (() => void)[] = []; + for (const integrationWithSecrets of ctx.integrations) { + const { decryptedSecrets: _, ...integration } = integrationWithSecrets; + const innerHandler = firewallMemoryRequestHandler.handler(integrationWithSecrets, {}); + const unsubscribe = innerHandler.subscribe((summary) => { + emit.next({ + integration, + summary, + }); + }); + unsubscribes.push(unsubscribe); + } + return () => { + unsubscribes.forEach((unsubscribe) => { + unsubscribe(); + }); + }; + }); + }), +}); diff --git a/packages/api/src/router/widgets/index.ts b/packages/api/src/router/widgets/index.ts index 2ad920b5d..8175aaab3 100644 --- a/packages/api/src/router/widgets/index.ts +++ b/packages/api/src/router/widgets/index.ts @@ -3,6 +3,7 @@ import { appRouter } from "./app"; import { calendarRouter } from "./calendar"; import { dnsHoleRouter } from "./dns-hole"; import { downloadsRouter } from "./downloads"; +import { firewallRouter } from "./firewall"; import { healthMonitoringRouter } from "./health-monitoring"; import { indexerManagerRouter } from "./indexer-manager"; import { mediaReleaseRouter } from "./media-release"; @@ -40,5 +41,6 @@ export const widgetRouter = createTRPCRouter({ options: optionsRouter, releases: releasesRouter, networkController: networkControllerRouter, + firewall: firewallRouter, notifications: notificationsRouter, }); diff --git a/packages/cron-jobs-core/src/expressions.ts b/packages/cron-jobs-core/src/expressions.ts index e8015b486..59c500b67 100644 --- a/packages/cron-jobs-core/src/expressions.ts +++ b/packages/cron-jobs-core/src/expressions.ts @@ -1,6 +1,7 @@ import { checkCron } from "./validation"; export const EVERY_5_SECONDS = checkCron("*/5 * * * * *") satisfies string; +export const EVERY_30_SECONDS = checkCron("*/30 * * * * *") satisfies string; export const EVERY_MINUTE = checkCron("* * * * *") satisfies string; export const EVERY_5_MINUTES = checkCron("*/5 * * * *") satisfies string; export const EVERY_10_MINUTES = checkCron("*/10 * * * *") satisfies string; diff --git a/packages/cron-jobs/src/index.ts b/packages/cron-jobs/src/index.ts index b29e8a46e..edc542db8 100644 --- a/packages/cron-jobs/src/index.ts +++ b/packages/cron-jobs/src/index.ts @@ -3,6 +3,12 @@ import { dockerContainersJob } from "./jobs/docker"; import { iconsUpdaterJob } from "./jobs/icons-updater"; import { dnsHoleJob } from "./jobs/integrations/dns-hole"; import { downloadsJob } from "./jobs/integrations/downloads"; +import { + firewallCpuJob, + firewallInterfacesJob, + firewallMemoryJob, + firewallVersionJob, +} from "./jobs/integrations/firewall"; import { healthMonitoringJob } from "./jobs/integrations/health-monitoring"; import { smartHomeEntityStateJob } from "./jobs/integrations/home-assistant"; import { indexerManagerJob } from "./jobs/integrations/indexer-manager"; @@ -39,6 +45,10 @@ export const jobGroup = createCronJobGroup({ minecraftServerStatus: minecraftServerStatusJob, dockerContainers: dockerContainersJob, networkController: networkControllerJob, + firewallCpu: firewallCpuJob, + firewallMemory: firewallMemoryJob, + firewallVersion: firewallVersionJob, + firewallInterfaces: firewallInterfacesJob, refreshNotifications: refreshNotificationsJob, }); diff --git a/packages/cron-jobs/src/jobs/integrations/firewall.ts b/packages/cron-jobs/src/jobs/integrations/firewall.ts new file mode 100644 index 000000000..50637a642 --- /dev/null +++ b/packages/cron-jobs/src/jobs/integrations/firewall.ts @@ -0,0 +1,46 @@ +import { EVERY_5_SECONDS, EVERY_30_SECONDS, EVERY_HOUR, EVERY_MINUTE } from "@homarr/cron-jobs-core/expressions"; +import { + firewallCpuRequestHandler, + firewallInterfacesRequestHandler, + firewallMemoryRequestHandler, + firewallVersionRequestHandler, +} from "@homarr/request-handler/firewall"; +import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler"; + +import { createCronJob } from "../../lib"; + +export const firewallCpuJob = createCronJob("firewallCpu", EVERY_5_SECONDS).withCallback( + createRequestIntegrationJobHandler(firewallCpuRequestHandler.handler, { + widgetKinds: ["firewall"], + getInput: { + firewall: () => ({}), + }, + }), +); + +export const firewallMemoryJob = createCronJob("firewallMemory", EVERY_MINUTE).withCallback( + createRequestIntegrationJobHandler(firewallMemoryRequestHandler.handler, { + widgetKinds: ["firewall"], + getInput: { + firewall: () => ({}), + }, + }), +); + +export const firewallInterfacesJob = createCronJob("firewallInterfaces", EVERY_30_SECONDS).withCallback( + createRequestIntegrationJobHandler(firewallInterfacesRequestHandler.handler, { + widgetKinds: ["firewall"], + getInput: { + firewall: () => ({}), + }, + }), +); + +export const firewallVersionJob = createCronJob("firewallVersion", EVERY_HOUR).withCallback( + createRequestIntegrationJobHandler(firewallVersionRequestHandler.handler, { + widgetKinds: ["firewall"], + getInput: { + firewall: () => ({}), + }, + }), +); diff --git a/packages/definitions/src/integration.ts b/packages/definitions/src/integration.ts index 2cfd1c26f..4bf7adada 100644 --- a/packages/definitions/src/integration.ts +++ b/packages/definitions/src/integration.ts @@ -172,6 +172,12 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/unifi.png", category: ["networkController"], }, + opnsense: { + name: "OPNsense", + secretKinds: [["username", "password"]], + iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/opnsense.svg", + category: ["firewall"], + }, github: { name: "Github", secretKinds: [[], ["personalAccessToken"]], @@ -318,6 +324,7 @@ export const integrationCategories = [ "networkController", "releasesProvider", "notifications", + "firewall", ] as const; export type IntegrationCategory = (typeof integrationCategories)[number]; diff --git a/packages/definitions/src/widget.ts b/packages/definitions/src/widget.ts index 24da8f214..8bafbab57 100644 --- a/packages/definitions/src/widget.ts +++ b/packages/definitions/src/widget.ts @@ -26,6 +26,7 @@ export const widgetKinds = [ "releases", "mediaReleases", "dockerContainers", + "firewall", "notifications", ] as const; export type WidgetKind = (typeof widgetKinds)[number]; diff --git a/packages/integrations/src/base/creator.ts b/packages/integrations/src/base/creator.ts index fa6480180..a5a0f5d82 100644 --- a/packages/integrations/src/base/creator.ts +++ b/packages/integrations/src/base/creator.ts @@ -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, diff --git a/packages/integrations/src/index.ts b/packages/integrations/src/index.ts index d11232186..713777607 100644 --- a/packages/integrations/src/index.ts +++ b/packages/integrations/src/index.ts @@ -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"; diff --git a/packages/integrations/src/interfaces/firewall-summary/firewall-summary-integration.ts b/packages/integrations/src/interfaces/firewall-summary/firewall-summary-integration.ts new file mode 100644 index 000000000..463299ef5 --- /dev/null +++ b/packages/integrations/src/interfaces/firewall-summary/firewall-summary-integration.ts @@ -0,0 +1,13 @@ +import type { + FirewallCpuSummary, + FirewallInterfacesSummary, + FirewallMemorySummary, + FirewallVersionSummary, +} from "./firewall-summary-types"; + +export interface FirewallSummaryIntegration { + getFirewallCpuAsync(): Promise; + getFirewallMemoryAsync(): Promise; + getFirewallInterfacesAsync(): Promise; + getFirewallVersionAsync(): Promise; +} diff --git a/packages/integrations/src/interfaces/firewall-summary/firewall-summary-types.ts b/packages/integrations/src/interfaces/firewall-summary/firewall-summary-types.ts new file mode 100644 index 000000000..3106b2943 --- /dev/null +++ b/packages/integrations/src/interfaces/firewall-summary/firewall-summary-types.ts @@ -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; +} diff --git a/packages/integrations/src/opnsense/opnsense-integration.ts b/packages/integrations/src/opnsense/opnsense-integration.ts new file mode 100644 index 000000000..d1085141e --- /dev/null +++ b/packages/integrations/src/opnsense/opnsense-integration.ts @@ -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 { + 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 { + 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(`integration:${this.integration.id}:interfaces`, 15); + } + + public async getFirewallInterfacesAsync(): Promise { + 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 { + 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 { + 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(); + } + } +} diff --git a/packages/integrations/src/opnsense/opnsense-types.ts b/packages/integrations/src/opnsense/opnsense-types.ts new file mode 100644 index 000000000..24d112302 --- /dev/null +++ b/packages/integrations/src/opnsense/opnsense-types.ts @@ -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(), +}); diff --git a/packages/integrations/src/types.ts b/packages/integrations/src/types.ts index 827c100b3..e38b3e710 100644 --- a/packages/integrations/src/types.ts +++ b/packages/integrations/src/types.ts @@ -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"; diff --git a/packages/redis/src/index.ts b/packages/redis/src/index.ts index 0c00b1464..05d4389f9 100644 --- a/packages/redis/src/index.ts +++ b/packages/redis/src/index.ts @@ -9,6 +9,7 @@ export { createIntegrationOptionsChannel, createWidgetOptionsChannel, createChannelWithLatestAndEvents, + createChannelEventHistory, handshakeAsync, createSubPubChannel, createGetSetChannel, diff --git a/packages/redis/src/lib/channel.ts b/packages/redis/src/lib/channel.ts index d649563ed..5a184e55a 100644 --- a/packages/redis/src/lib/channel.ts +++ b/packages/redis/src/lib/channel.ts @@ -232,7 +232,7 @@ export const createChannelEventHistory = (channelName: string, maxElement if (length <= maxElements) { return; } - await getSetClient.ltrim(channelName, length - maxElements, length); + await getSetClient.ltrim(channelName, 0, maxElements - 1); }; return { diff --git a/packages/request-handler/src/firewall.ts b/packages/request-handler/src/firewall.ts new file mode 100644 index 000000000..3754f1099 --- /dev/null +++ b/packages/request-handler/src/firewall.ts @@ -0,0 +1,64 @@ +import dayjs from "dayjs"; + +import type { IntegrationKindByCategory } from "@homarr/definitions"; +import { createIntegrationAsync } from "@homarr/integrations"; +import type { + FirewallCpuSummary, + FirewallInterfacesSummary, + FirewallMemorySummary, + FirewallVersionSummary, +} from "@homarr/integrations/types"; + +import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler"; + +export const firewallCpuRequestHandler = createCachedIntegrationRequestHandler< + FirewallCpuSummary, + IntegrationKindByCategory<"firewall">, + Record +>({ + async requestAsync(integration, _input) { + const integrationInstance = await createIntegrationAsync(integration); + return integrationInstance.getFirewallCpuAsync(); + }, + cacheDuration: dayjs.duration(5, "seconds"), + queryKey: "firewallCpuSummary", +}); + +export const firewallMemoryRequestHandler = createCachedIntegrationRequestHandler< + FirewallMemorySummary, + IntegrationKindByCategory<"firewall">, + Record +>({ + async requestAsync(integration, _input) { + const integrationInstance = await createIntegrationAsync(integration); + return await integrationInstance.getFirewallMemoryAsync(); + }, + cacheDuration: dayjs.duration(15, "seconds"), + queryKey: "firewallMemorySummary", +}); + +export const firewallInterfacesRequestHandler = createCachedIntegrationRequestHandler< + FirewallInterfacesSummary[], + IntegrationKindByCategory<"firewall">, + Record +>({ + async requestAsync(integration, _input) { + const integrationInstance = await createIntegrationAsync(integration); + return await integrationInstance.getFirewallInterfacesAsync(); + }, + cacheDuration: dayjs.duration(30, "seconds"), + queryKey: "firewallInterfacesSummary", +}); + +export const firewallVersionRequestHandler = createCachedIntegrationRequestHandler< + FirewallVersionSummary, + IntegrationKindByCategory<"firewall">, + Record +>({ + async requestAsync(integration, _input) { + const integrationInstance = await createIntegrationAsync(integration); + return await integrationInstance.getFirewallVersionAsync(); + }, + cacheDuration: dayjs.duration(1, "hour"), + queryKey: "firewallVersionSummary", +}); diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 1b69d9fd4..af6198cd0 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -2413,6 +2413,35 @@ "internalServerError": "Failed to fetch Network Controller Summary" } }, + "firewall": { + "name": "Firewall Monitoring", + "description": "Displays a summary of firewalls", + "tab": { + "system": "System", + "interfaces": "Interfaces" + }, + "error": { + "internalServerError": "Unable to get data from firewall" + }, + "option": { + "interfaces": "Network interfaces to display" + }, + "widget": { + "fwname": "Name", + "version": "Version", + "versiontitle": "Versions", + "cputitle": "CPU usage", + "memorytitle": "Memory usage", + "cpu": "CPU", + "memory": "Memory", + "interfaces": { + "name": "name", + "trans": "Transmited", + "recv": "Received", + "title": "Network Interfaces" + } + } + }, "notifications": { "name": "Notifications", "description": "Display notification history from an integration", @@ -3192,6 +3221,18 @@ }, "dockerContainers": { "label": "Docker containers" + }, + "firewallCpu": { + "label": "Firewall CPU" + }, + "firewallMemory": { + "label": "Firewall Memory" + }, + "firewallVersion": { + "label": "Firewall Version" + }, + "firewallInterfaces": { + "label": "Firewall Interfaces" } }, "interval": { diff --git a/packages/widgets/src/firewall/component.tsx b/packages/widgets/src/firewall/component.tsx new file mode 100644 index 000000000..111ab85fa --- /dev/null +++ b/packages/widgets/src/firewall/component.tsx @@ -0,0 +1,397 @@ +"use client"; + +import { useCallback, useState } from "react"; +import { Accordion, Box, Center, Flex, Group, RingProgress, ScrollArea, Text } from "@mantine/core"; +import { useLocalStorage } from "@mantine/hooks"; +import { IconArrowBarDown, IconArrowBarUp, IconBrain, IconCpu, IconTopologyBus } from "@tabler/icons-react"; + +import { clientApi } from "@homarr/api/client"; +import type { FirewallInterface, FirewallInterfacesSummary } from "@homarr/integrations"; +import { useI18n } from "@homarr/translation/client"; + +import type { WidgetComponentProps } from "../definition"; +import { FirewallMenu } from "./firewall-menu"; +import { FirewallVersion } from "./firewall-version"; + +export interface Firewall { + label: string; + value: string; +} + +export default function FirewallWidget({ integrationIds, width, itemId }: WidgetComponentProps<"firewall">) { + const [selectedFirewall, setSelectedFirewall] = useState(""); + + const handleSelect = useCallback((value: string | null) => { + if (value !== null) { + setSelectedFirewall(value); + } else { + setSelectedFirewall("default_value"); + } + }, []); + + const firewallsCpuData = useUpdatingCpuStatus(integrationIds); + const firewallsMemoryData = useUpdatingMemoryStatus(integrationIds); + const firewallsVersionData = useUpdatingVersionStatus(integrationIds); + const firewallsInterfacesData = useUpdatingInterfacesStatus(integrationIds); + + const initialSelectedFirewall = firewallsVersionData[0] ? firewallsVersionData[0].integration.id : "undefined"; + const isTiny = width < 256; + + const [accordionValue, setAccordionValue] = useLocalStorage({ + key: `homarr-${itemId}-firewall`, + defaultValue: "interfaces", + }); + + const dropdownItems = firewallsVersionData.map((firewall) => ({ + label: firewall.integration.name, + value: firewall.integration.id, + })); + + const t = useI18n(); + + return ( + + + + + + + {/* Render CPU and Memory data */} + {firewallsCpuData + .filter(({ integration }) => integration.id === (selectedFirewall || initialSelectedFirewall)) + .map(({ summary, integration }) => ( + + {`${summary.total.toFixed(2)}%`} + + + } + sections={[ + { + value: Number(summary.total.toFixed(1)), + color: summary.total > 50 ? (summary.total < 75 ? "yellow" : "red") : "green", + }, + ]} + /> + ))} + {firewallsMemoryData + .filter(({ integration }) => integration.id === (selectedFirewall || initialSelectedFirewall)) + .map(({ summary, integration }) => ( + + {`${summary.percent.toFixed(1)}%`} + + + } + sections={[ + { + value: Number(summary.percent.toFixed(1)), + color: summary.percent > 50 ? (summary.percent < 75 ? "yellow" : "red") : "green", + }, + ]} + /> + ))} + + {firewallsInterfacesData + .filter(({ integration }) => integration.id === (selectedFirewall || initialSelectedFirewall)) + .map(({ summary }) => ( + + + }> + {t("widget.firewall.widget.interfaces.title")} + + + + {Array.isArray(summary) && summary.every((item) => Array.isArray(item.data)) ? ( + calculateBandwidth(summary).data.map(({ name, receive, transmit }) => ( + + + + {name} + + + + + + {formatBitsPerSec(transmit, 2)} + + + + + + {formatBitsPerSec(receive, 2)} + + + + )) + ) : ( + No data available + )} + + + + + ))} + + ); +} + +export const useUpdatingCpuStatus = (integrationIds: string[]) => { + const utils = clientApi.useUtils(); + const [firewallsCpuData] = clientApi.widget.firewall.getFirewallCpuStatus.useSuspenseQuery( + { + integrationIds, + }, + { + refetchOnMount: false, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + retry: false, + }, + ); + + clientApi.widget.firewall.subscribeFirewallCpuStatus.useSubscription( + { + integrationIds, + }, + { + onData: (data) => { + utils.widget.firewall.getFirewallCpuStatus.setData( + { + integrationIds, + }, + (prevData) => { + if (!prevData) { + return undefined; + } + + return prevData.map((item) => + item.integration.id === data.integration.id ? { ...item, summary: data.summary } : item, + ); + }, + ); + }, + }, + ); + + return firewallsCpuData; +}; + +export const useUpdatingMemoryStatus = (integrationIds: string[]) => { + const utils = clientApi.useUtils(); + const [firewallsMemoryData] = clientApi.widget.firewall.getFirewallMemoryStatus.useSuspenseQuery( + { + integrationIds, + }, + { + refetchOnMount: false, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + retry: false, + }, + ); + + clientApi.widget.firewall.subscribeFirewallMemoryStatus.useSubscription( + { + integrationIds, + }, + { + onData: (data) => { + utils.widget.firewall.getFirewallMemoryStatus.setData( + { + integrationIds, + }, + (prevData) => { + if (!prevData) { + return undefined; + } + + return prevData.map((item) => + item.integration.id === data.integration.id ? { ...item, summary: data.summary } : item, + ); + }, + ); + }, + }, + ); + + return firewallsMemoryData; +}; + +export const useUpdatingVersionStatus = (integrationIds: string[]) => { + const utils = clientApi.useUtils(); + const [firewallsVersionData] = clientApi.widget.firewall.getFirewallVersionStatus.useSuspenseQuery( + { + integrationIds, + }, + { + refetchOnMount: false, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + retry: false, + }, + ); + + clientApi.widget.firewall.subscribeFirewallVersionStatus.useSubscription( + { + integrationIds, + }, + { + onData: (data) => { + utils.widget.firewall.getFirewallVersionStatus.setData( + { + integrationIds, + }, + (prevData) => { + if (!prevData) { + return undefined; + } + + return prevData.map((item) => + item.integration.id === data.integration.id ? { ...item, summary: data.summary } : item, + ); + }, + ); + }, + }, + ); + return firewallsVersionData; +}; + +export const useUpdatingInterfacesStatus = (integrationIds: string[]) => { + const utils = clientApi.useUtils(); + const [firewallsInterfacesData] = clientApi.widget.firewall.getFirewallInterfacesStatus.useSuspenseQuery( + { + integrationIds, + }, + { + refetchOnMount: false, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + retry: false, + }, + ); + + clientApi.widget.firewall.subscribeFirewallInterfacesStatus.useSubscription( + { + integrationIds, + }, + { + onData: (data) => { + utils.widget.firewall.getFirewallInterfacesStatus.setData( + { + integrationIds, + }, + (prevData) => { + if (!prevData) { + return undefined; + } + return prevData.map((item) => + item.integration.id === data.integration.id ? { ...item, summary: data.summary } : item, + ); + }, + ); + }, + }, + ); + + return firewallsInterfacesData; +}; + +export function formatBitsPerSec(bytes: number, decimals: number): string { + if (bytes === 0) return "0 b/s"; + + const kilobyte = 1024; + const sizes = ["b/s", "kb/s", "Mb/s", "Gb/s", "Tb/s", "Pb/s", "Eb/s", "Zb/s", "Yb/s"]; + + const i = Math.floor(Math.log(bytes) / Math.log(kilobyte)); + + return `${parseFloat((bytes / Math.pow(kilobyte, i)).toFixed(decimals))} ${sizes[i]}`; +} + +export function calculateBandwidth(data: FirewallInterfacesSummary[]): { data: FirewallInterface[] } { + const result = { + data: [] as FirewallInterface[], + timestamp: new Date().toISOString(), + }; + + if (data.length > 1) { + const firstData = data[0]; + const secondData = data[1]; + + if (firstData && secondData) { + const time1 = new Date(firstData.timestamp); + const time2 = new Date(secondData.timestamp); + const timeDiffInSeconds = (time1.getTime() - time2.getTime()) / 1000; + + firstData.data.forEach((iface) => { + const ifaceName = iface.name; + const recv1 = iface.receive; + const trans1 = iface.transmit; + + const iface2 = secondData.data.find((i) => i.name === ifaceName); + + if (iface2) { + const recv2 = iface2.receive; + const trans2 = iface2.transmit; + const recvDiff = recv1 - recv2; + const transDiff = trans1 - trans2; + + result.data.push({ + name: ifaceName, + receive: (8 * recvDiff) / timeDiffInSeconds, + transmit: (8 * transDiff) / timeDiffInSeconds, + }); + } + }); + } + } + + return result; +} diff --git a/packages/widgets/src/firewall/firewall-menu.tsx b/packages/widgets/src/firewall/firewall-menu.tsx new file mode 100644 index 000000000..e96662dfb --- /dev/null +++ b/packages/widgets/src/firewall/firewall-menu.tsx @@ -0,0 +1,27 @@ +import { Box, Select } from "@mantine/core"; + +import type { Firewall } from "./component"; + +interface FirewallMenuProps { + onChange: (value: string | null) => void; + dropdownItems: Firewall[]; + selectedFirewall: string; + isTiny: boolean; +} + +export const FirewallMenu = ({ onChange, isTiny, dropdownItems, selectedFirewall }: FirewallMenuProps) => ( + +