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:
@@ -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>>;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user