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
This commit is contained in:
Meier Lukas
2025-03-04 21:46:11 +01:00
committed by GitHub
parent a3999bcf52
commit b3e4f30312
4 changed files with 134 additions and 69 deletions

View File

@@ -1,10 +1,12 @@
import superjson from "superjson";
import { decryptSecret, encryptSecret } from "@homarr/common/server"; import { decryptSecret, encryptSecret } from "@homarr/common/server";
import { logger } from "@homarr/log"; import { logger } from "@homarr/log";
import { createGetSetChannel } from "@homarr/redis"; import { createGetSetChannel } from "@homarr/redis";
const localLogger = logger.child({ module: "SessionStore" }); const localLogger = logger.child({ module: "SessionStore" });
export const createSessionStore = (integration: { id: string }) => { export const createSessionStore = <TValue>(integration: { id: string }) => {
const channelName = `session-store:${integration.id}`; const channelName = `session-store:${integration.id}`;
const channel = createGetSetChannel<`${string}.${string}`>(channelName); const channel = createGetSetChannel<`${string}.${string}`>(channelName);
@@ -13,11 +15,20 @@ export const createSessionStore = (integration: { id: string }) => {
localLogger.debug("Getting session from store", { store: channelName }); localLogger.debug("Getting session from store", { store: channelName });
const value = await channel.getAsync(); const value = await channel.getAsync();
if (!value) return null; if (!value) return null;
return decryptSecret(value); try {
return superjson.parse<TValue>(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 }); 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() { async clearAsync() {
localLogger.debug("Cleared session in store", { store: channelName }); localLogger.debug("Cleared session in store", { store: channelName });
@@ -26,4 +37,4 @@ export const createSessionStore = (integration: { id: string }) => {
}; };
}; };
export type SessionStore = ReturnType<typeof createSessionStore>; export type SessionStore<TValue> = ReturnType<typeof createSessionStore<TValue>>;

View File

@@ -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 { 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 { 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 { IntegrationTestConnectionError } from "../base/test-connection-error";
import type { HealthMonitoring } from "../types"; import type { HealthMonitoring } from "../types";
import { cpuTempSchema, fileSystemSchema, smartSchema, systemInformationSchema } from "./openmediavault-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 { export class OpenMediaVaultIntegration extends Integration {
static extractSessionIdFromCookies(headers: Headers): string { private readonly sessionStore: SessionStore<SessionStoreValue>;
const cookies = headers.get("set-cookie") ?? "";
const sessionId = cookies
.split(";")
.find((cookie) => cookie.includes("X-OPENMEDIAVAULT-SESSIONID") || cookie.includes("OPENMEDIAVAULT-SESSIONID"));
if (sessionId) { constructor(integration: IntegrationInput) {
return sessionId; super(integration);
} else { this.sessionStore = createSessionStore(integration);
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");
}
} }
public async getSystemInfoAsync(): Promise<HealthMonitoring> { public async getSystemInfoAsync(): Promise<HealthMonitoring> {
if (!this.headers) { const systemResponses = await this.makeAuthenticatedRpcCallAsync("system", "getInformation");
await this.authenticateAndConstructSessionInHeaderAsync(); const fileSystemResponse = await this.makeAuthenticatedRpcCallAsync(
}
const systemResponses = await this.makeOpenMediaVaultRPCCallAsync("system", "getInformation", {}, this.headers);
const fileSystemResponse = await this.makeOpenMediaVaultRPCCallAsync(
"filesystemmgmt", "filesystemmgmt",
"enumerateMountedFilesystems", "enumerateMountedFilesystems",
{ includeroot: true }, { includeroot: true },
this.headers,
); );
const smartResponse = await this.makeOpenMediaVaultRPCCallAsync("smart", "enumerateDevices", {}, this.headers); const smartResponse = await this.makeAuthenticatedRpcCallAsync("smart", "enumerateDevices");
const cpuTempResponse = await this.makeOpenMediaVaultRPCCallAsync("cputemp", "get", {}, this.headers); const cpuTempResponse = await this.makeAuthenticatedRpcCallAsync("cputemp", "get");
const systemResult = systemInformationSchema.safeParse(await systemResponses.json()); const systemResult = systemInformationSchema.safeParse(await systemResponses.json());
const fileSystemResult = fileSystemSchema.safeParse(await fileSystemResponse.json()); const fileSystemResult = fileSystemSchema.safeParse(await fileSystemResponse.json());
@@ -98,30 +85,43 @@ export class OpenMediaVaultIntegration extends Integration {
} }
public async testConnectionAsync(): Promise<void> { public async testConnectionAsync(): Promise<void> {
const response = await this.makeOpenMediaVaultRPCCallAsync("session", "login", { await this.getSessionAsync().catch((error) => {
username: this.getSecretValue("username"), if (error instanceof ResponseError) {
password: this.getSecretValue("password"), 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, serviceName: string,
method: string, method: string,
params: Record<string, unknown>, params: Record<string, unknown> = {},
headers: Record<string, string> = {}, ): Promise<UndiciResponse> {
): Promise<Response> { 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<string, unknown> = {},
headers: HeadersInit = {},
): Promise<UndiciResponse> {
return await fetchWithTrustedCertificatesAsync(this.url("/rpc.php"), { return await fetchWithTrustedCertificatesAsync(this.url("/rpc.php"), {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
"User-Agent": "Homarr",
...headers, ...headers,
}, },
body: JSON.stringify({ body: JSON.stringify({
@@ -132,25 +132,79 @@ export class OpenMediaVaultIntegration extends Integration {
}); });
} }
private headers: Record<string, string> | undefined = undefined; /**
* Run the callback with the current session id
* @param callback
* @returns
*/
private async withAuthAsync(callback: (session: SessionStoreValue) => Promise<UndiciResponse>) {
const storedSession = await this.sessionStore.getAsync();
private async authenticateAndConstructSessionInHeaderAsync() { if (storedSession) {
const authResponse = await this.makeOpenMediaVaultRPCCallAsync("session", "login", { 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<SessionStoreValue> {
const response = await this.makeRpcCallAsync("session", "login", {
username: this.getSecretValue("username"), username: this.getSecretValue("username"),
password: this.getSecretValue("password"), password: this.getSecretValue("password"),
}); });
const authResult = (await authResponse.json()) as Response;
const response = (authResult as { response?: { sessionid?: string } }).response; const data = (await response.json()) as { response?: { sessionid?: string } };
let sessionId; if (data.response?.sessionid) {
const headers: Record<string, string> = {}; return {
if (response?.sessionid) { type: "header",
sessionId = response.sessionid; sessionId: data.response.sessionid,
headers["X-OPENMEDIAVAULT-SESSIONID"] = sessionId; };
} else { } else {
sessionId = OpenMediaVaultIntegration.extractSessionIdFromCookies(authResponse.headers); const sessionId = OpenMediaVaultIntegration.extractSessionIdFromCookies(response.headers);
const loginToken = OpenMediaVaultIntegration.extractLoginTokenFromCookies(authResponse.headers); const loginToken = OpenMediaVaultIntegration.extractLoginTokenFromCookies(response.headers);
headers.Cookie = `${loginToken};${sessionId}`;
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;
} }
} }

View File

@@ -18,7 +18,7 @@ import { dnsBlockingGetSchema, sessionResponseSchema, statsSummaryGetSchema } fr
const localLogger = logger.child({ module: "PiHoleIntegrationV6" }); const localLogger = logger.child({ module: "PiHoleIntegrationV6" });
export class PiHoleIntegrationV6 extends Integration implements DnsHoleSummaryIntegration { export class PiHoleIntegrationV6 extends Integration implements DnsHoleSummaryIntegration {
private readonly sessionStore: SessionStore; private readonly sessionStore: SessionStore<string>;
constructor(integration: IntegrationInput) { constructor(integration: IntegrationInput) {
super(integration); super(integration);

View File

@@ -70,7 +70,7 @@ vi.mock("../src/base/session-store", () => ({
async clearAsync() { async clearAsync() {
return await Promise.resolve(); return await Promise.resolve();
}, },
}) satisfies SessionStore, }) satisfies SessionStore<string>,
})); }));
describe("Pi-hole v6 integration", () => { describe("Pi-hole v6 integration", () => {