From b3e4f3031296ffc69d914f542f95895a449ce2c7 Mon Sep 17 00:00:00 2001 From: Meier Lukas Date: Tue, 4 Mar 2025 21:46:11 +0100 Subject: [PATCH] fix(integration): store omv session in redis (#2467) * feat(pihole): add support for v6 * fix: add session-store to keep using same session for pi-hole requests * fix(integration): store omv session in redis * chore: address pull request feedback --- .../integrations/src/base/session-store.ts | 21 ++- .../openmediavault-integration.ts | 178 ++++++++++++------ .../src/pi-hole/v6/pi-hole-integration-v6.ts | 2 +- packages/integrations/test/pi-hole.spec.ts | 2 +- 4 files changed, 134 insertions(+), 69 deletions(-) diff --git a/packages/integrations/src/base/session-store.ts b/packages/integrations/src/base/session-store.ts index da80d55f0..58e4dad46 100644 --- a/packages/integrations/src/base/session-store.ts +++ b/packages/integrations/src/base/session-store.ts @@ -1,10 +1,12 @@ +import superjson from "superjson"; + import { decryptSecret, encryptSecret } from "@homarr/common/server"; import { logger } from "@homarr/log"; import { createGetSetChannel } from "@homarr/redis"; const localLogger = logger.child({ module: "SessionStore" }); -export const createSessionStore = (integration: { id: string }) => { +export const createSessionStore = (integration: { id: string }) => { const channelName = `session-store:${integration.id}`; const channel = createGetSetChannel<`${string}.${string}`>(channelName); @@ -13,11 +15,20 @@ export const createSessionStore = (integration: { id: string }) => { localLogger.debug("Getting session from store", { store: channelName }); const value = await channel.getAsync(); if (!value) return null; - return decryptSecret(value); + try { + return superjson.parse(decryptSecret(value)); + } catch (error) { + localLogger.warn("Failed to load session", { store: channelName, error }); + return null; + } }, - async setAsync(value: string) { + async setAsync(value: TValue) { localLogger.debug("Updating session in store", { store: channelName }); - await channel.setAsync(encryptSecret(value)); + try { + await channel.setAsync(encryptSecret(superjson.stringify(value))); + } catch (error) { + localLogger.error("Failed to save session", { store: channelName, error }); + } }, async clearAsync() { localLogger.debug("Cleared session in store", { store: channelName }); @@ -26,4 +37,4 @@ export const createSessionStore = (integration: { id: string }) => { }; }; -export type SessionStore = ReturnType; +export type SessionStore = ReturnType>; diff --git a/packages/integrations/src/openmediavault/openmediavault-integration.ts b/packages/integrations/src/openmediavault/openmediavault-integration.ts index c639d3377..09f718954 100644 --- a/packages/integrations/src/openmediavault/openmediavault-integration.ts +++ b/packages/integrations/src/openmediavault/openmediavault-integration.ts @@ -1,53 +1,40 @@ -import type { Response } from "undici"; +import type { Headers, HeadersInit, Response as UndiciResponse } from "undici"; import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server"; +import { logger } from "@homarr/log"; +import { ResponseError } from "../base/error"; +import type { IntegrationInput } from "../base/integration"; import { Integration } from "../base/integration"; +import type { SessionStore } from "../base/session-store"; +import { createSessionStore } from "../base/session-store"; import { IntegrationTestConnectionError } from "../base/test-connection-error"; import type { HealthMonitoring } from "../types"; import { cpuTempSchema, fileSystemSchema, smartSchema, systemInformationSchema } from "./openmediavault-types"; +const localLogger = logger.child({ module: "OpenMediaVaultIntegration" }); + +type SessionStoreValue = + | { type: "header"; sessionId: string } + | { type: "cookie"; loginToken: string; sessionId: string }; + export class OpenMediaVaultIntegration extends Integration { - static extractSessionIdFromCookies(headers: Headers): string { - const cookies = headers.get("set-cookie") ?? ""; - const sessionId = cookies - .split(";") - .find((cookie) => cookie.includes("X-OPENMEDIAVAULT-SESSIONID") || cookie.includes("OPENMEDIAVAULT-SESSIONID")); + private readonly sessionStore: SessionStore; - if (sessionId) { - return sessionId; - } else { - throw new Error("Session ID not found in cookies"); - } - } - - static extractLoginTokenFromCookies(headers: Headers): string { - const cookies = headers.get("set-cookie") ?? ""; - const loginToken = cookies - .split(";") - .find((cookie) => cookie.includes("X-OPENMEDIAVAULT-LOGIN") || cookie.includes("OPENMEDIAVAULT-LOGIN")); - - if (loginToken) { - return loginToken; - } else { - throw new Error("Login token not found in cookies"); - } + constructor(integration: IntegrationInput) { + super(integration); + this.sessionStore = createSessionStore(integration); } public async getSystemInfoAsync(): Promise { - if (!this.headers) { - await this.authenticateAndConstructSessionInHeaderAsync(); - } - - const systemResponses = await this.makeOpenMediaVaultRPCCallAsync("system", "getInformation", {}, this.headers); - const fileSystemResponse = await this.makeOpenMediaVaultRPCCallAsync( + const systemResponses = await this.makeAuthenticatedRpcCallAsync("system", "getInformation"); + const fileSystemResponse = await this.makeAuthenticatedRpcCallAsync( "filesystemmgmt", "enumerateMountedFilesystems", { includeroot: true }, - this.headers, ); - const smartResponse = await this.makeOpenMediaVaultRPCCallAsync("smart", "enumerateDevices", {}, this.headers); - const cpuTempResponse = await this.makeOpenMediaVaultRPCCallAsync("cputemp", "get", {}, this.headers); + const smartResponse = await this.makeAuthenticatedRpcCallAsync("smart", "enumerateDevices"); + const cpuTempResponse = await this.makeAuthenticatedRpcCallAsync("cputemp", "get"); const systemResult = systemInformationSchema.safeParse(await systemResponses.json()); const fileSystemResult = fileSystemSchema.safeParse(await fileSystemResponse.json()); @@ -98,30 +85,43 @@ export class OpenMediaVaultIntegration extends Integration { } public async testConnectionAsync(): Promise { - const response = await this.makeOpenMediaVaultRPCCallAsync("session", "login", { - username: this.getSecretValue("username"), - password: this.getSecretValue("password"), + await this.getSessionAsync().catch((error) => { + if (error instanceof ResponseError) { + throw new IntegrationTestConnectionError("invalidCredentials"); + } }); - - if (!response.ok) { - throw new IntegrationTestConnectionError("invalidCredentials"); - } - const result = await response.json(); - if (typeof result !== "object" || result === null || !("response" in result)) { - throw new IntegrationTestConnectionError("invalidJson"); - } } - private async makeOpenMediaVaultRPCCallAsync( + private async makeAuthenticatedRpcCallAsync( serviceName: string, method: string, - params: Record, - headers: Record = {}, - ): Promise { + params: Record = {}, + ): Promise { + return await this.withAuthAsync(async (session) => { + const headers: HeadersInit = + session.type === "cookie" + ? { + Cookie: `${session.loginToken};${session.sessionId}`, + } + : { + "X-OPENMEDIAVAULT-SESSIONID": session.sessionId, + }; + + return await this.makeRpcCallAsync(serviceName, method, params, headers); + }); + } + + private async makeRpcCallAsync( + serviceName: string, + method: string, + params: Record = {}, + headers: HeadersInit = {}, + ): Promise { return await fetchWithTrustedCertificatesAsync(this.url("/rpc.php"), { method: "POST", headers: { "Content-Type": "application/json", + "User-Agent": "Homarr", ...headers, }, body: JSON.stringify({ @@ -132,25 +132,79 @@ export class OpenMediaVaultIntegration extends Integration { }); } - private headers: Record | undefined = undefined; + /** + * Run the callback with the current session id + * @param callback + * @returns + */ + private async withAuthAsync(callback: (session: SessionStoreValue) => Promise) { + const storedSession = await this.sessionStore.getAsync(); - private async authenticateAndConstructSessionInHeaderAsync() { - const authResponse = await this.makeOpenMediaVaultRPCCallAsync("session", "login", { + if (storedSession) { + localLogger.debug("Using stored session for request", { integrationId: this.integration.id }); + const response = await callback(storedSession); + if (response.status !== 401) { + return response; + } + + localLogger.info("Session expired, getting new session", { integrationId: this.integration.id }); + } + + const session = await this.getSessionAsync(); + await this.sessionStore.setAsync(session); + return await callback(session); + } + + /** + * Get a session id from the openmediavault server + * @returns The session details + */ + private async getSessionAsync(): Promise { + const response = await this.makeRpcCallAsync("session", "login", { username: this.getSecretValue("username"), password: this.getSecretValue("password"), }); - const authResult = (await authResponse.json()) as Response; - const response = (authResult as { response?: { sessionid?: string } }).response; - let sessionId; - const headers: Record = {}; - if (response?.sessionid) { - sessionId = response.sessionid; - headers["X-OPENMEDIAVAULT-SESSIONID"] = sessionId; + + const data = (await response.json()) as { response?: { sessionid?: string } }; + if (data.response?.sessionid) { + return { + type: "header", + sessionId: data.response.sessionid, + }; } else { - sessionId = OpenMediaVaultIntegration.extractSessionIdFromCookies(authResponse.headers); - const loginToken = OpenMediaVaultIntegration.extractLoginTokenFromCookies(authResponse.headers); - headers.Cookie = `${loginToken};${sessionId}`; + const sessionId = OpenMediaVaultIntegration.extractSessionIdFromCookies(response.headers); + const loginToken = OpenMediaVaultIntegration.extractLoginTokenFromCookies(response.headers); + + if (!sessionId || !loginToken) { + throw new ResponseError( + response, + `${JSON.stringify(data)} - sessionId=${"*".repeat(sessionId?.length ?? 0)} loginToken=${"*".repeat(loginToken?.length ?? 0)}`, + ); + } + + return { + type: "cookie", + loginToken, + sessionId, + }; } - this.headers = headers; + } + + private static extractSessionIdFromCookies(headers: Headers): string | null { + const cookies = headers.getSetCookie(); + const sessionId = cookies.find( + (cookie) => cookie.includes("X-OPENMEDIAVAULT-SESSIONID") || cookie.includes("OPENMEDIAVAULT-SESSIONID"), + ); + + return sessionId ?? null; + } + + private static extractLoginTokenFromCookies(headers: Headers): string | null { + const cookies = headers.getSetCookie(); + const loginToken = cookies.find( + (cookie) => cookie.includes("X-OPENMEDIAVAULT-LOGIN") || cookie.includes("OPENMEDIAVAULT-LOGIN"), + ); + + return loginToken ?? null; } } diff --git a/packages/integrations/src/pi-hole/v6/pi-hole-integration-v6.ts b/packages/integrations/src/pi-hole/v6/pi-hole-integration-v6.ts index 02b1aa8e0..8b3913ed0 100644 --- a/packages/integrations/src/pi-hole/v6/pi-hole-integration-v6.ts +++ b/packages/integrations/src/pi-hole/v6/pi-hole-integration-v6.ts @@ -18,7 +18,7 @@ import { dnsBlockingGetSchema, sessionResponseSchema, statsSummaryGetSchema } fr const localLogger = logger.child({ module: "PiHoleIntegrationV6" }); export class PiHoleIntegrationV6 extends Integration implements DnsHoleSummaryIntegration { - private readonly sessionStore: SessionStore; + private readonly sessionStore: SessionStore; constructor(integration: IntegrationInput) { super(integration); diff --git a/packages/integrations/test/pi-hole.spec.ts b/packages/integrations/test/pi-hole.spec.ts index f03c4b548..aaa25d03c 100644 --- a/packages/integrations/test/pi-hole.spec.ts +++ b/packages/integrations/test/pi-hole.spec.ts @@ -70,7 +70,7 @@ vi.mock("../src/base/session-store", () => ({ async clearAsync() { return await Promise.resolve(); }, - }) satisfies SessionStore, + }) satisfies SessionStore, })); describe("Pi-hole v6 integration", () => {