From 901fd3d940f2e5fe19afd686f860045b99505e9e Mon Sep 17 00:00:00 2001 From: ajnart Date: Sat, 12 Sep 2026 23:33:23 +0200 Subject: [PATCH] feat: reuse authentication for all HTTP integrations --- .../custom-widgets/requests-and-security.mdx | 4 +- .../src/components/workshop/DetailPage.tsx | 8 +- .../workshop/[id]/_workshop-detail.tsx | 2 +- .../custom-widget-import-dialog.tsx | 2 +- .../custom-widget-source-setup-dialog.tsx | 2 +- .../integration-source-select.tsx | 33 +- .../custom-widgets/source-setup-panel.tsx | 26 -- .../router/custom-widget/source-resolver.ts | 20 +- .../router/integration/integration-http.ts | 84 +++- .../custom-widgets/src/core/request-schema.ts | 20 +- packages/custom-widgets/src/server/auth.ts | 54 +++ packages/custom-widgets/src/server/index.ts | 2 +- .../src/server/request-executor.ts | 37 +- .../src/server/request-types.ts | 10 + .../src/server/response-redaction.ts | 37 -- .../custom-widgets/src/server/response.ts | 37 ++ packages/definitions/src/integration.ts | 5 + packages/integrations/package.json | 1 - packages/integrations/src/base/creator.ts | 390 +++++++++++++----- .../src/beszel/beszel-integration.ts | 6 + .../deluge/deluge-integration.ts | 9 + .../qbittorrent/qbittorrent-integration.ts | 12 + .../transmission/transmission-integration.ts | 14 + packages/integrations/src/factory.ts | 4 +- packages/integrations/src/http-auth.ts | 192 ++++++--- .../src/jellyfin/jellyfin-integration.ts | 5 + .../src/navidrome/navidrome-integration.ts | 4 + .../openmediavault-integration.ts | 13 + .../src/pi-hole/v5/pi-hole-integration-v5.ts | 6 + .../src/pi-hole/v6/pi-hole-integration-v6.ts | 13 + .../src/synology/synology-client.ts | 19 + .../src/synology/synology-integration.ts | 4 + .../src/technitium/technitium-integration.ts | 20 + .../src/traefik/traefik-integration.ts | 4 + .../src/umami/umami-integration.ts | 4 + .../unifi-controller-integration.ts | 22 +- 36 files changed, 830 insertions(+), 295 deletions(-) delete mode 100644 apps/nextjs/src/components/custom-widgets/source-setup-panel.tsx delete mode 100644 packages/custom-widgets/src/server/response-redaction.ts diff --git a/apps/docs/docs/management/custom-widgets/requests-and-security.mdx b/apps/docs/docs/management/custom-widgets/requests-and-security.mdx index aed3c2c7e..c74b9b107 100644 --- a/apps/docs/docs/management/custom-widgets/requests-and-security.mdx +++ b/apps/docs/docs/management/custom-widgets/requests-and-security.mdx @@ -9,7 +9,7 @@ request to a different origin. ## Existing integrations -Sources can reuse configured Sonarr, Radarr, Lidarr, Readarr, Prowlarr, Overseerr, Jellyseerr, Seerr, Immich, slskd, Home Assistant, Coolify, Audiobookshelf, Speedtest Tracker, AdGuard Home, and Nextcloud integrations: +Sources can reuse any configured HTTP integration, including its saved credentials and login session. WebSocket-only integrations and the internal mock integration are excluded: ```json { @@ -23,7 +23,7 @@ paths append to the saved URL, including reverse-proxy subpaths. Changing the in Deleting it leaves the widget unconfigured until another instance is selected. The binding is shared by every placement of that widget definition. Exports and Workshop submissions retain the required type and omit the local integration ID. -Arbitrary integration requests require integration `full` access, including GET requests: service APIs can expose credentials or change state through reads. Board permissions still apply separately. Saved credentials are redacted from upstream responses. Non-GET integration requests must be actions. Requests and redirects stay within the configured URL path. +Arbitrary integration requests require integration `full` access, including GET requests: service APIs can expose credentials or change state through reads. Board permissions still apply separately. Saved credentials are redacted from upstream responses. Non-GET integration requests must be actions. Requests and redirects stay within the configured URL path. Expired sessions are refreshed; queries can retry once, while actions require another explicit invocation. ## Named requests diff --git a/apps/docs/src/components/workshop/DetailPage.tsx b/apps/docs/src/components/workshop/DetailPage.tsx index 6eba7aa54..07d6f09cc 100644 --- a/apps/docs/src/components/workshop/DetailPage.tsx +++ b/apps/docs/src/components/workshop/DetailPage.tsx @@ -32,7 +32,7 @@ import type { WorkshopSubmission, WorkshopVote } from "@site/src/lib/pocketbase" import { getWorkshopBackend } from "@site/src/lib/pocketbase"; import { getRuntimeWorkshopApiUrl } from "@site/src/lib/runtime-config"; import type { SubmissionType } from "@site/src/lib/workshop-schema"; -import { integrationDefs } from "@homarr/definitions"; +import { integrationDefs, isHttpIntegrationKind } from "@homarr/definitions"; import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core"; import { githubAvatarUrl, @@ -100,7 +100,11 @@ const avatarFallback = (name: string) => name.trim().slice(0, 1).toUpperCase() | type WidgetSource = HomarrCustomWidgetV2["sources"][string]; const sourceHost = (source: WidgetSource) => { - if (source.type === "integration") return `${integrationDefs[source.integrationKind].name} integration`; + if (source.type === "integration") { + if (isHttpIntegrationKind(source.integrationKind)) + return `${integrationDefs[source.integrationKind].name} integration`; + return `${source.integrationKind} integration`; + } const { baseUrl } = source; try { return new URL(baseUrl).host; diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/workshop/[id]/_workshop-detail.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/workshop/[id]/_workshop-detail.tsx index 10fb4c7d3..c4448a78b 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/workshop/[id]/_workshop-detail.tsx +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/workshop/[id]/_workshop-detail.tsx @@ -1,6 +1,6 @@ "use client"; -import { SourceSetupPanel } from "~/components/custom-widgets/source-setup-panel"; +import { SourceSetupPanel } from "~/components/custom-widgets/integration-source-select"; import { useRouter } from "next/navigation"; import { useMemo } from "react"; import { diff --git a/apps/nextjs/src/components/custom-widgets/custom-widget-import-dialog.tsx b/apps/nextjs/src/components/custom-widgets/custom-widget-import-dialog.tsx index 5ece18b91..8230a38e9 100644 --- a/apps/nextjs/src/components/custom-widgets/custom-widget-import-dialog.tsx +++ b/apps/nextjs/src/components/custom-widgets/custom-widget-import-dialog.tsx @@ -1,6 +1,6 @@ "use client"; -import { SourceSetupPanel } from "~/components/custom-widgets/source-setup-panel"; +import { SourceSetupPanel } from "~/components/custom-widgets/integration-source-select"; import type { ModalProps } from "@mantine/core"; import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core"; diff --git a/apps/nextjs/src/components/custom-widgets/custom-widget-source-setup-dialog.tsx b/apps/nextjs/src/components/custom-widgets/custom-widget-source-setup-dialog.tsx index 916134a45..fbd803b47 100644 --- a/apps/nextjs/src/components/custom-widgets/custom-widget-source-setup-dialog.tsx +++ b/apps/nextjs/src/components/custom-widgets/custom-widget-source-setup-dialog.tsx @@ -1,6 +1,6 @@ "use client"; -import { SourceSetupPanel } from "~/components/custom-widgets/source-setup-panel"; +import { SourceSetupPanel } from "~/components/custom-widgets/integration-source-select"; import { useRouter } from "next/navigation"; import { useEffect, useMemo, useState } from "react"; import { Button, Center, Group, Loader, Modal, Stack, Text } from "@mantine/core"; diff --git a/apps/nextjs/src/components/custom-widgets/integration-source-select.tsx b/apps/nextjs/src/components/custom-widgets/integration-source-select.tsx index baa10de33..e4e1bb4bd 100644 --- a/apps/nextjs/src/components/custom-widgets/integration-source-select.tsx +++ b/apps/nextjs/src/components/custom-widgets/integration-source-select.tsx @@ -2,12 +2,16 @@ import { Alert, Select, Stack, Text } from "@mantine/core"; +import { CustomWidgetSourceSetupPanel } from "@homarr/custom-widgets/workbench"; +import type { CustomWidgetSourceSetupPanelProps } from "@homarr/custom-widgets/workbench"; + import { clientApi } from "@homarr/api/client"; import type { CustomWidgetIntegrationSource } from "@homarr/custom-widgets/core"; -import { customWidgetIntegrationKinds } from "@homarr/custom-widgets/core"; -import { integrationDefs } from "@homarr/definitions"; +import { integrationDefs, integrationKinds, isHttpIntegrationKind } from "@homarr/definitions"; import { useI18n } from "@homarr/translation/client"; +const httpIntegrationKinds = integrationKinds.filter(isHttpIntegrationKind); + interface IntegrationSourceSelectProps { kind: CustomWidgetIntegrationSource["integrationKind"]; integrationId?: string; @@ -27,18 +31,18 @@ export function IntegrationSourceSelect({ kind, integrationId, onChange, onKindC {onKindChange && ( ({ value: id, label: name }))} value={integrationId ?? null} searchable @@ -55,3 +59,22 @@ export function IntegrationSourceSelect({ kind, integrationId, onChange, onKindC ); } + +export function SourceSetupPanel(props: CustomWidgetSourceSetupPanelProps) { + return ( + { + const kind = httpIntegrationKinds.find((candidate) => candidate === setup.integrationKind); + if (!kind) return null; + return ( + props.onChange(setup.sourceId, { ...value, integrationId })} + /> + ); + }} + /> + ); +} diff --git a/apps/nextjs/src/components/custom-widgets/source-setup-panel.tsx b/apps/nextjs/src/components/custom-widgets/source-setup-panel.tsx deleted file mode 100644 index 17eab0cde..000000000 --- a/apps/nextjs/src/components/custom-widgets/source-setup-panel.tsx +++ /dev/null @@ -1,26 +0,0 @@ -"use client"; - -import { customWidgetIntegrationKinds } from "@homarr/custom-widgets/core"; -import { CustomWidgetSourceSetupPanel } from "@homarr/custom-widgets/workbench"; -import type { CustomWidgetSourceSetupPanelProps } from "@homarr/custom-widgets/workbench"; - -import { IntegrationSourceSelect } from "./integration-source-select"; - -export function SourceSetupPanel(props: CustomWidgetSourceSetupPanelProps) { - return ( - { - const kind = customWidgetIntegrationKinds.find((candidate) => candidate === setup.integrationKind); - if (!kind) return null; - return ( - props.onChange(setup.sourceId, { ...value, integrationId })} - /> - ); - }} - /> - ); -} diff --git a/packages/api/src/router/custom-widget/source-resolver.ts b/packages/api/src/router/custom-widget/source-resolver.ts index ff79edc7a..ab5e0f490 100644 --- a/packages/api/src/router/custom-widget/source-resolver.ts +++ b/packages/api/src/router/custom-widget/source-resolver.ts @@ -1,9 +1,9 @@ import { TRPCError } from "@trpc/server"; import type { CustomJsxRequest, CustomWidgetSource } from "@homarr/custom-widgets/core"; -import { customWidgetIntegrationKinds, getCustomWidgetSourceAuthType } from "@homarr/custom-widgets/core"; +import { getCustomWidgetSourceAuthType } from "@homarr/custom-widgets/core"; import type { CustomWidgetHttpRequest } from "@homarr/custom-widgets/server"; -import type { integrationHttpAuth } from "@homarr/integrations/http-auth"; +import { isHttpIntegrationKind } from "@homarr/definitions"; import type { IntegrationHttpContext } from "../integration/integration-http"; import { @@ -12,9 +12,6 @@ import { getIntegrationHttpConnection, } from "../integration/integration-http"; -// Keep the portable schema's supported types within the server authentication adapters. -customWidgetIntegrationKinds satisfies readonly (keyof typeof integrationHttpAuth)[]; - export async function assertCustomWidgetIntegrationBindings( ctx: IntegrationHttpContext, sources: Record, @@ -36,7 +33,7 @@ async function resolveIntegration( }); } const integration = await getIntegrationForHttpRequest(ctx, source.integrationId); - if (!integration || integration.kind !== source.integrationKind) { + if (!isHttpIntegrationKind(integration.kind) || integration.kind !== source.integrationKind) { throw new TRPCError({ code: "PRECONDITION_FAILED", message: "The selected integration is unavailable or has a different type", @@ -51,7 +48,10 @@ export async function resolveCustomWidgetSource( request: Pick, getSecrets: () => Array<{ kind: string; value: string }>, ): Promise< - Pick & { + Pick< + CustomWidgetHttpRequest, + "baseUrl" | "networkScope" | "auth" | "tls" | "pathPrefix" | "redactSecrets" | "resolveConnectionAsync" + > & { cacheVersion: string; } > { @@ -65,11 +65,7 @@ export async function resolveCustomWidgetSource( return { baseUrl: source.baseUrl, networkScope: source.networkScope, auth, cacheVersion: "" }; } const integration = await resolveIntegration(ctx, source); - const connection = await getIntegrationHttpConnection(integration); - return { - ...connection, - auth: request.auth === "none" ? undefined : connection.auth, - }; + return getIntegrationHttpConnection(integration, request.auth !== "none"); } export async function getCustomWidgetIntegrationCacheVersions( diff --git a/packages/api/src/router/integration/integration-http.ts b/packages/api/src/router/integration/integration-http.ts index 02517da78..003688b33 100644 --- a/packages/api/src/router/integration/integration-http.ts +++ b/packages/api/src/router/integration/integration-http.ts @@ -9,11 +9,13 @@ import { getTrustedCertificateHostnamesAsync, } from "@homarr/core/infrastructure/certificates"; import { createCustomCheckServerIdentity } from "@homarr/core/infrastructure/http"; -import type { CustomWidgetHttpRequest } from "@homarr/custom-widgets/server"; +import { CustomWidgetDomainError } from "@homarr/custom-widgets/server"; +import type { CustomWidgetAuthConfig, CustomWidgetHttpRequest } from "@homarr/custom-widgets/server"; import type { Database } from "@homarr/db"; import { eq, inArray } from "@homarr/db"; import { groupMembers, integrations, integrationGroupPermissions, integrationUserPermissions } from "@homarr/db/schema"; -import { IntegrationHttpAuthError, resolveIntegrationHttpAuth } from "@homarr/integrations/http-auth"; +import { getIntegrationHttpAuthenticationAsync } from "@homarr/integrations/factory"; +import type { IntegrationHttpAuthentication } from "@homarr/integrations/factory"; export interface IntegrationHttpContext { db: Database; @@ -53,32 +55,72 @@ export function getIntegrationHttpCacheVersion(integration: HttpIntegration) { .slice(0, 16); } -export async function getIntegrationHttpConnection(integration: HttpIntegration) { +export async function getIntegrationHttpConnection(integration: HttpIntegration, authenticate = true) { try { const secrets = integration.secrets.map(({ kind, value }) => ({ kind, value: decryptSecret(value) })); - const auth = resolveIntegrationHttpAuth(integration.kind, secrets); - const url = new URL(integration.url); - let tls: CustomWidgetHttpRequest["tls"]; - if (url.protocol === "https:") { - const [ca, hostnames] = await Promise.all([ - getAllTrustedCertificatesAsync(), - getTrustedCertificateHostnamesAsync(), - ]); - tls = { ca, checkServerIdentity: createCustomCheckServerIdentity(hostnames) }; - } + let baseUrl = integration.url; + if (integration.kind === "ical") baseUrl = secrets.find(({ kind }) => kind === "url")?.value ?? ""; return { - baseUrl: integration.url, - auth, - tls, + baseUrl, networkScope: "loopback" as const, - pathPrefix: url.pathname, + pathPrefix: new URL(baseUrl).pathname, redactSecrets: secrets, cacheVersion: getIntegrationHttpCacheVersion(integration), + resolveConnectionAsync: async () => { + try { + let authentication: IntegrationHttpAuthentication = {}; + if (authenticate) { + authentication = await getIntegrationHttpAuthenticationAsync({ + ...integration, + externalUrl: null, + decryptedSecrets: secrets, + }); + } + const auth = toWidgetAuth(authentication, secrets); + const url = new URL(authentication.baseUrl ?? baseUrl); + let tls: CustomWidgetHttpRequest["tls"]; + if (url.protocol === "https:") { + const [ca, hostnames] = await Promise.all([ + getAllTrustedCertificatesAsync(), + getTrustedCertificateHostnamesAsync(), + ]); + tls = { ca, checkServerIdentity: createCustomCheckServerIdentity(hostnames) }; + } + return { baseUrl: url.href, auth, tls, pathPrefix: url.pathname, redactSecrets: auth.secrets }; + } catch { + throw new CustomWidgetDomainError({ + code: "BAD_GATEWAY", + message: "Integration connection could not be resolved", + }); + } + }, }; - } catch (error) { - if (error instanceof IntegrationHttpAuthError) { - throw new TRPCError({ code: error.code, message: error.message }); - } + } catch { throw new TRPCError({ code: "PRECONDITION_FAILED", message: "Integration connection could not be resolved" }); } } + +function toWidgetAuth( + authentication: IntegrationHttpAuthentication, + secrets: CustomWidgetAuthConfig["secrets"], +): CustomWidgetAuthConfig { + const authSecrets = [...secrets, ...(authentication.redactValues ?? []).map((value) => ({ kind: "session", value }))]; + const refresh = authentication.refreshAsync; + return { + ...authentication, + type: "integration", + secrets: authSecrets, + refreshAsync: + refresh && + (async () => { + try { + return toWidgetAuth(await refresh(), secrets); + } catch { + throw new CustomWidgetDomainError({ + code: "BAD_GATEWAY", + message: "Integration session could not be refreshed", + }); + } + }), + }; +} diff --git a/packages/custom-widgets/src/core/request-schema.ts b/packages/custom-widgets/src/core/request-schema.ts index 69efe65b7..f330c37a8 100644 --- a/packages/custom-widgets/src/core/request-schema.ts +++ b/packages/custom-widgets/src/core/request-schema.ts @@ -57,28 +57,10 @@ export const customWidgetHttpSourceSchema = z.strictObject({ networkScope: z.enum(customJsxNetworkScopes), auth: authSchema.default("none"), }); -export const customWidgetIntegrationKinds = [ - "sonarr", - "radarr", - "lidarr", - "readarr", - "prowlarr", - "overseerr", - "jellyseerr", - "seerr", - "immich", - "slskd", - "homeAssistant", - "coolify", - "audiobookshelf", - "speedtestTracker", - "adGuardHome", - "nextcloud", -] as const; export const customWidgetIntegrationSourceSchema = z.strictObject({ type: z.literal("integration"), name: z.string().trim().min(1).max(128).optional(), - integrationKind: z.enum(customWidgetIntegrationKinds), + integrationKind: customWidgetIdentifierSchema, integrationId: z.string().min(1).max(100).optional(), baseUrl: z.never().optional(), networkScope: z.never().optional(), diff --git a/packages/custom-widgets/src/server/auth.ts b/packages/custom-widgets/src/server/auth.ts index d9e9ecfc5..a4a72ee07 100644 --- a/packages/custom-widgets/src/server/auth.ts +++ b/packages/custom-widgets/src/server/auth.ts @@ -1,3 +1,6 @@ +import type { CustomWidgetHttpRequest, CustomWidgetHttpResponse } from "./request-types"; +import { assertCustomWidgetPathScope, resolveSameOriginTarget } from "./network-policy"; + type HeaderSetter = Pick; export const AUTH_HANDLERS: Record< @@ -43,3 +46,54 @@ export function applyAuth( handler(headers, url, secretMap.apiKey, headerName ?? undefined); } } + +export async function performAuthenticatedRequest( + input: CustomWidgetHttpRequest, + signal: AbortSignal, + perform: (input: CustomWidgetHttpRequest, signal: AbortSignal) => Promise, +): Promise { + const target = resolveSameOriginTarget(input.baseUrl, input.targetUrl); + if (input.pathPrefix !== undefined) assertCustomWidgetPathScope(target, input.pathPrefix); + if (input.resolveConnectionAsync) { + const connection = await withinDeadline(input.resolveConnectionAsync, signal); + const base = new URL(connection.baseUrl); + target.protocol = base.protocol; + target.host = base.host; + input = { ...input, ...connection, targetUrl: target }; + } + let response = await perform(input, signal); + if (!isExpired(input, response)) return response; + response.ok = false; + response.status = 401; + response.statusText = "Integration session expired"; + if (!input.auth?.refreshAsync) return response; + const auth = await withinDeadline(input.auth.refreshAsync, signal); + // An action may have reached the service. Refresh for its next explicit invocation. + if (input.kind === "action") return response; + input = { ...input, auth }; + response = await perform(input, signal); + if (isExpired(input, response)) { + response.ok = false; + response.status = 401; + response.statusText = "Integration session expired"; + } + return response; +} + +function isExpired(input: CustomWidgetHttpRequest, response: CustomWidgetHttpResponse) { + return input.auth && (response.status === 401 || input.auth.isExpired?.(response)); +} + +async function withinDeadline(callback: () => Promise, signal: AbortSignal): Promise { + signal.throwIfAborted(); + let abort: (() => void) | undefined; + const aborted = new Promise((_resolve, reject) => { + abort = () => reject(signal.reason); + signal.addEventListener("abort", abort, { once: true }); + }); + try { + return await Promise.race([callback(), aborted]); + } finally { + if (abort) signal.removeEventListener("abort", abort); + } +} diff --git a/packages/custom-widgets/src/server/index.ts b/packages/custom-widgets/src/server/index.ts index 3301b0cbf..d95ebee95 100644 --- a/packages/custom-widgets/src/server/index.ts +++ b/packages/custom-widgets/src/server/index.ts @@ -40,6 +40,6 @@ export { } from "./request-manifest"; export type { CustomJsxRuntimeParams } from "./request-manifest"; -export { redactResponseSecrets } from "./response-redaction"; +export { redactResponseSecrets } from "./response"; export { assertCustomWidgetPathScope } from "./network-policy"; diff --git a/packages/custom-widgets/src/server/request-executor.ts b/packages/custom-widgets/src/server/request-executor.ts index 28b7dbac3..dfbd7e2da 100644 --- a/packages/custom-widgets/src/server/request-executor.ts +++ b/packages/custom-widgets/src/server/request-executor.ts @@ -4,7 +4,7 @@ import { Headers, Response } from "undici"; import type { CustomWidgetHttpRequest, CustomWidgetHttpResponse } from "./request-types"; export type { CustomWidgetAuthConfig, CustomWidgetHttpRequest, CustomWidgetHttpResponse } from "./request-types"; -import { applyAuth } from "./auth"; +import { applyAuth, performAuthenticatedRequest } from "./auth"; import { CustomWidgetDomainError } from "./errors"; import { assertSafeStaticHeaders, @@ -16,7 +16,7 @@ import { } from "./network-policy"; import { closeDispatcher } from "./request-dispatcher-lifecycle"; import { parseResponseBody } from "./response"; -import { redactResponseSecrets } from "./response-redaction"; +import { redactResponseSecrets } from "./response"; export { assertSafeStaticHeaders, @@ -52,7 +52,8 @@ async function performRequest(input: CustomWidgetHttpRequest): Promise controller.abort(), MAX_REQUEST_DURATION_MS); try { - return await performRequestWithinDeadline(input, controller.signal); + assertRequest(input); + return await performAuthenticatedRequest(input, controller.signal, performRequestWithinDeadline); } catch (error) { if (controller.signal.aborted) { throw new CustomWidgetDomainError({ @@ -84,9 +85,12 @@ async function performRequestWithinDeadline( const baseUrl = validateCustomWidgetUrl(input.baseUrl); let currentUrl = resolveSameOriginTarget(input.baseUrl, input.targetUrl); let currentMethod = input.method; - let currentBody = input.body; + let currentBody = input.auth?.transformBody ? input.auth.transformBody(input.body) : input.body; + assertRequest({ ...input, body: currentBody }); const maxRedirects = input.kind === "query" ? MAX_QUERY_REDIRECTS : 0; for (let redirects = 0; ; redirects += 1) { + input.auth?.transformUrl?.(currentUrl); + resolveSameOriginTarget(input.baseUrl, currentUrl); if (input.pathPrefix !== undefined) assertCustomWidgetPathScope(currentUrl, input.pathPrefix); const dispatcher = createPinnedAgent( await resolveAndValidateHost(currentUrl.hostname, input.networkScope, { signal: deadlineSignal }), @@ -114,15 +118,22 @@ async function performRequestWithinDeadline( statusText: STATUS_CODES[responseData.statusCode] ?? "", headers: normalizeResponseHeaders(responseData.headers), }); - result = { - kind: "response", - response: { - ok: response.ok, - status: response.status, - statusText: response.statusText, - data: redactResponseSecrets(await parseResponseBody(response), input.redactSecrets ?? []), - }, + const parsed = { + ok: response.ok, + status: response.status, + statusText: response.statusText, + data: await parseResponseBody(response), }; + if (input.auth?.isExpired?.(parsed)) { + parsed.ok = false; + parsed.status = 401; + parsed.statusText = "Integration session expired"; + } + parsed.data = redactResponseSecrets(parsed.data, [ + ...(input.redactSecrets ?? []), + ...(input.auth?.secrets ?? []), + ]); + result = { kind: "response", response: parsed }; } else { await responseData.body.dump(); result = { @@ -227,6 +238,8 @@ function buildHeaders(input: CustomWidgetHttpRequest, url: URL, body: string | u if (input.auth) { if (input.auth.type === "apiKeyHeader") assertSafeStaticHeaders({ [input.auth.headerName ?? "X-API-Key"]: "" }); applyAuth(headers, url, input.auth.type, input.auth.secrets, input.auth.headerName); + for (const [name, value] of Object.entries(input.auth.headers ?? {})) headers.set(name, value); + for (const [name, value] of Object.entries(input.auth.query ?? {})) url.searchParams.set(name, value); } return headers; } diff --git a/packages/custom-widgets/src/server/request-types.ts b/packages/custom-widgets/src/server/request-types.ts index 9332813de..6280cf7b9 100644 --- a/packages/custom-widgets/src/server/request-types.ts +++ b/packages/custom-widgets/src/server/request-types.ts @@ -3,12 +3,22 @@ import type { ConnectionOptions } from "node:tls"; import type { CustomJsxNetworkScope, CustomWidgetMethod } from "../core"; export interface CustomWidgetAuthConfig { + baseUrl?: string; type: string; secrets: Array<{ kind: string; value: string }>; headerName?: string | null; + headers?: Record; + query?: Record; + transformUrl?(url: URL): void; + transformBody?(body: string | undefined): string; + isExpired?(response: CustomWidgetHttpResponse): boolean; + refreshAsync?(): Promise; } export interface CustomWidgetHttpRequest { + resolveConnectionAsync?(): Promise< + Pick + >; tls?: Pick; pathPrefix?: string; redactSecrets?: CustomWidgetAuthConfig["secrets"]; diff --git a/packages/custom-widgets/src/server/response-redaction.ts b/packages/custom-widgets/src/server/response-redaction.ts deleted file mode 100644 index 90c4ece89..000000000 --- a/packages/custom-widgets/src/server/response-redaction.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { Buffer } from "node:buffer"; - -export function redactResponseSecrets(data: unknown, secrets: Array<{ kind: string; value: string }>): unknown { - if (secrets.length === 0) return data; - const values = secrets.map(({ value }) => value).filter(Boolean); - const username = secrets.find(({ kind }) => kind === "username")?.value; - const password = secrets.find(({ kind }) => kind === "password")?.value; - if (username && password) values.push(`${username}:${password}`); - const sensitive = [ - ...new Set( - values.flatMap((value) => [ - value, - JSON.stringify(value).slice(1, -1), - encodeURIComponent(value), - Buffer.from(value).toString("base64"), - ]), - ), - ].toSorted((a, b) => b.length - a.length); - const redact = (value: string) => { - for (const secret of sensitive) value = value.replaceAll(secret, "[REDACTED]"); - return value; - }; - const visit = (value: unknown): unknown => { - if (typeof value === "string") return redact(value); - if ( - (typeof value === "number" || typeof value === "boolean" || value === null) && - redact(String(value)) !== String(value) - ) - return "[REDACTED]"; - if (Array.isArray(value)) return value.map(visit); - if (value !== null && typeof value === "object") { - return Object.fromEntries(Object.entries(value).map(([key, child]) => [redact(key), visit(child)])); - } - return value; - }; - return visit(data); -} diff --git a/packages/custom-widgets/src/server/response.ts b/packages/custom-widgets/src/server/response.ts index dec549d17..22dc432c4 100644 --- a/packages/custom-widgets/src/server/response.ts +++ b/packages/custom-widgets/src/server/response.ts @@ -1,3 +1,4 @@ +import { Buffer } from "node:buffer"; import type { Response } from "undici"; import { CustomWidgetDomainError } from "./errors"; @@ -63,3 +64,39 @@ export async function parseResponseBody(response: Response): Promise { const tooLarge = () => new CustomWidgetDomainError({ code: "PAYLOAD_TOO_LARGE", message: "Response exceeds the 1 MiB limit" }); + +export function redactResponseSecrets(data: unknown, secrets: Array<{ kind: string; value: string }>): unknown { + if (secrets.length === 0) return data; + const values = secrets.map(({ value }) => value).filter(Boolean); + const username = secrets.find(({ kind }) => kind === "username")?.value; + const password = secrets.find(({ kind }) => kind === "password")?.value; + if (username && password) values.push(`${username}:${password}`); + const sensitive = [ + ...new Set( + values.flatMap((value) => [ + value, + JSON.stringify(value).slice(1, -1), + encodeURIComponent(value), + Buffer.from(value).toString("base64"), + ]), + ), + ].toSorted((a, b) => b.length - a.length); + const redact = (value: string) => { + for (const secret of sensitive) value = value.replaceAll(secret, "[REDACTED]"); + return value; + }; + const visit = (value: unknown): unknown => { + if (typeof value === "string") return redact(value); + if ( + (typeof value === "number" || typeof value === "boolean" || value === null) && + redact(String(value)) !== String(value) + ) + return "[REDACTED]"; + if (Array.isArray(value)) return value.map(visit); + if (value !== null && typeof value === "object") { + return Object.fromEntries(Object.entries(value).map(([key, child]) => [redact(key), visit(child)])); + } + return value; + }; + return visit(data); +} diff --git a/packages/definitions/src/integration.ts b/packages/definitions/src/integration.ts index c277c52b1..e51ba7b9e 100644 --- a/packages/definitions/src/integration.ts +++ b/packages/definitions/src/integration.ts @@ -725,3 +725,8 @@ export const integrationCategories = [ ] as const; export type IntegrationCategory = (typeof integrationCategories)[number]; + +/** Integrations whose configured connection can serve HTTP requests. */ +export function isHttpIntegrationKind(kind: string): kind is Exclude { + return Object.hasOwn(integrationDefs, kind) && kind !== "truenas" && kind !== "mock"; +} diff --git a/packages/integrations/package.json b/packages/integrations/package.json index 05f997907..37144edc8 100644 --- a/packages/integrations/package.json +++ b/packages/integrations/package.json @@ -12,7 +12,6 @@ } }, "exports": { - "./http-auth": "./src/http-auth.ts", ".": "./index.ts", "./anchor": "./src/anchor/anchor-types.ts", "./downloads": "./src/interfaces/downloads/download-client-items.ts", diff --git a/packages/integrations/src/base/creator.ts b/packages/integrations/src/base/creator.ts index e3ed72a7f..95b6310d9 100644 --- a/packages/integrations/src/base/creator.ts +++ b/packages/integrations/src/base/creator.ts @@ -2,117 +2,295 @@ import type { IntegrationKind } from "@homarr/definitions"; import { createLogger } from "@homarr/core/infrastructure/logs"; import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error"; +import { + withHttpAuthentication, + apiKeyAuth, + bearerAuth, + basicAuth, + headerAuth, + queryAuth, + noAuth, + optionalAuth, + alternativeAuth, + rpcAuth, + feedAuth, + secret, + clientAuthentication, +} from "../http-auth"; +import type { IntegrationHttpAuthentication } from "../http-auth"; import type { Integration, IntegrationInput } from "./integration"; const logger = createLogger({ module: "integrationFactory" }); // Keep each import path explicit so Node and Turbopack can discover every lazy integration chunk. const integrationCreators = { - anchor: async (input: IntegrationInput) => - new (await import("../anchor/anchor-integration")).AnchorIntegration(input), - piHole: async (input: IntegrationInput) => - (await import("../pi-hole/pi-hole-integration-factory")).createPiHoleIntegrationAsync(input), - adGuardHome: async (input: IntegrationInput) => - new (await import("../adguard-home/adguard-home-integration")).AdGuardHomeIntegration(input), - technitiumDns: async (input: IntegrationInput) => - (await import("../technitium/technitium-integration-factory")).createTechnitiumDnsIntegrationAsync(input), - homeAssistant: async (input: IntegrationInput) => - new (await import("../homeassistant/homeassistant-integration")).HomeAssistantIntegration(input), - jellyfin: async (input: IntegrationInput) => - new (await import("../jellyfin/jellyfin-integration")).JellyfinIntegration(input), - plex: async (input: IntegrationInput) => new (await import("../plex/plex-integration")).PlexIntegration(input), - sonarr: async (input: IntegrationInput) => - new (await import("../media-organizer/sonarr/sonarr-integration")).SonarrIntegration(input), - radarr: async (input: IntegrationInput) => - new (await import("../media-organizer/radarr/radarr-integration")).RadarrIntegration(input), - sabNzbd: async (input: IntegrationInput) => - new (await import("../download-client/sabnzbd/sabnzbd-integration")).SabnzbdIntegration(input), - nzbGet: async (input: IntegrationInput) => - new (await import("../download-client/nzbget/nzbget-integration")).NzbGetIntegration(input), - qBittorrent: async (input: IntegrationInput) => - new (await import("../download-client/qbittorrent/qbittorrent-integration")).QBitTorrentIntegration(input), - deluge: async (input: IntegrationInput) => - new (await import("../download-client/deluge/deluge-integration")).DelugeIntegration(input), - transmission: async (input: IntegrationInput) => - new (await import("../download-client/transmission/transmission-integration")).TransmissionIntegration(input), - slskd: async (input: IntegrationInput) => - new (await import("../download-client/slskd/slskd-integration")).SlskdIntegration(input), - aria2: async (input: IntegrationInput) => - new (await import("../download-client/aria2/aria2-integration")).Aria2Integration(input), - jellyseerr: async (input: IntegrationInput) => - new (await import("../jellyseerr/jellyseerr-integration")).JellyseerrIntegration(input), - seerr: async (input: IntegrationInput) => new (await import("../seerr/seerr-integration")).SeerrIntegration(input), - overseerr: async (input: IntegrationInput) => - new (await import("../overseerr/overseerr-integration")).OverseerrIntegration(input), - prowlarr: async (input: IntegrationInput) => - new (await import("../prowlarr/prowlarr-integration")).ProwlarrIntegration(input), - openmediavault: async (input: IntegrationInput) => - new (await import("../openmediavault/openmediavault-integration")).OpenMediaVaultIntegration(input), - lidarr: async (input: IntegrationInput) => - new (await import("../media-organizer/lidarr/lidarr-integration")).LidarrIntegration(input), - readarr: async (input: IntegrationInput) => - new (await import("../media-organizer/readarr/readarr-integration")).ReadarrIntegration(input), - dashDot: async (input: IntegrationInput) => - new (await import("../dashdot/dashdot-integration")).DashDotIntegration(input), - tdarr: async (input: IntegrationInput) => - new (await import("../media-transcoding/tdarr-integration")).TdarrIntegration(input), - proxmox: async (input: IntegrationInput) => - new (await import("../proxmox/proxmox-integration")).ProxmoxIntegration(input), - emby: async (input: IntegrationInput) => new (await import("../emby/emby-integration")).EmbyIntegration(input), - nextcloud: async (input: IntegrationInput) => - new (await import("../nextcloud/nextcloud.integration")).NextcloudIntegration(input), - unifiController: async (input: IntegrationInput) => - new (await import("../unifi-controller/unifi-controller-integration")).UnifiControllerIntegration(input), - opnsense: async (input: IntegrationInput) => - new (await import("../opnsense/opnsense-integration")).OPNsenseIntegration(input), - ical: async (input: IntegrationInput) => new (await import("../ical/ical-integration")).ICalIntegration(input), - ntfy: async (input: IntegrationInput) => new (await import("../ntfy/ntfy-integration")).NTFYIntegration(input), - gotify: async (input: IntegrationInput) => - new (await import("../gotify/gotify-integration")).GotifyIntegration(input), + anchor: withHttpAuthentication( + async (input: IntegrationInput) => new (await import("../anchor/anchor-integration")).AnchorIntegration(input), + bearerAuth, + ), + piHole: withHttpAuthentication( + async (input: IntegrationInput) => + (await import("../pi-hole/pi-hole-integration-factory")).createPiHoleIntegrationAsync(input), + clientAuthentication, + ), + adGuardHome: withHttpAuthentication( + async (input: IntegrationInput) => + new (await import("../adguard-home/adguard-home-integration")).AdGuardHomeIntegration(input), + basicAuth(), + ), + technitiumDns: withHttpAuthentication( + async (input: IntegrationInput) => + (await import("../technitium/technitium-integration-factory")).createTechnitiumDnsIntegrationAsync(input), + clientAuthentication, + ), + homeAssistant: withHttpAuthentication( + async (input: IntegrationInput) => + new (await import("../homeassistant/homeassistant-integration")).HomeAssistantIntegration(input), + bearerAuth, + ), + jellyfin: withHttpAuthentication( + async (input: IntegrationInput) => + new (await import("../jellyfin/jellyfin-integration")).JellyfinIntegration(input), + clientAuthentication, + ), + plex: withHttpAuthentication( + async (input: IntegrationInput) => new (await import("../plex/plex-integration")).PlexIntegration(input), + headerAuth("X-Plex-Token"), + ), + sonarr: withHttpAuthentication( + async (input: IntegrationInput) => + new (await import("../media-organizer/sonarr/sonarr-integration")).SonarrIntegration(input), + apiKeyAuth, + ), + radarr: withHttpAuthentication( + async (input: IntegrationInput) => + new (await import("../media-organizer/radarr/radarr-integration")).RadarrIntegration(input), + apiKeyAuth, + ), + sabNzbd: withHttpAuthentication( + async (input: IntegrationInput) => + new (await import("../download-client/sabnzbd/sabnzbd-integration")).SabnzbdIntegration(input), + queryAuth("apikey"), + ), + nzbGet: withHttpAuthentication( + async (input: IntegrationInput) => + new (await import("../download-client/nzbget/nzbget-integration")).NzbGetIntegration(input), + basicAuth(), + ), + qBittorrent: withHttpAuthentication( + async (input: IntegrationInput) => + new (await import("../download-client/qbittorrent/qbittorrent-integration")).QBitTorrentIntegration(input), + clientAuthentication, + ), + deluge: withHttpAuthentication( + async (input: IntegrationInput) => + new (await import("../download-client/deluge/deluge-integration")).DelugeIntegration(input), + clientAuthentication, + ), + transmission: withHttpAuthentication( + async (input: IntegrationInput) => + new (await import("../download-client/transmission/transmission-integration")).TransmissionIntegration(input), + clientAuthentication, + ), + slskd: withHttpAuthentication( + async (input: IntegrationInput) => + new (await import("../download-client/slskd/slskd-integration")).SlskdIntegration(input), + apiKeyAuth, + ), + aria2: withHttpAuthentication( + async (input: IntegrationInput) => + new (await import("../download-client/aria2/aria2-integration")).Aria2Integration(input), + optionalAuth(rpcAuth), + ), + jellyseerr: withHttpAuthentication( + async (input: IntegrationInput) => + new (await import("../jellyseerr/jellyseerr-integration")).JellyseerrIntegration(input), + apiKeyAuth, + ), + seerr: withHttpAuthentication( + async (input: IntegrationInput) => new (await import("../seerr/seerr-integration")).SeerrIntegration(input), + apiKeyAuth, + ), + overseerr: withHttpAuthentication( + async (input: IntegrationInput) => + new (await import("../overseerr/overseerr-integration")).OverseerrIntegration(input), + apiKeyAuth, + ), + prowlarr: withHttpAuthentication( + async (input: IntegrationInput) => + new (await import("../prowlarr/prowlarr-integration")).ProwlarrIntegration(input), + apiKeyAuth, + ), + openmediavault: withHttpAuthentication( + async (input: IntegrationInput) => + new (await import("../openmediavault/openmediavault-integration")).OpenMediaVaultIntegration(input), + clientAuthentication, + ), + lidarr: withHttpAuthentication( + async (input: IntegrationInput) => + new (await import("../media-organizer/lidarr/lidarr-integration")).LidarrIntegration(input), + apiKeyAuth, + ), + readarr: withHttpAuthentication( + async (input: IntegrationInput) => + new (await import("../media-organizer/readarr/readarr-integration")).ReadarrIntegration(input), + apiKeyAuth, + ), + dashDot: withHttpAuthentication( + async (input: IntegrationInput) => new (await import("../dashdot/dashdot-integration")).DashDotIntegration(input), + noAuth, + ), + tdarr: withHttpAuthentication( + async (input: IntegrationInput) => + new (await import("../media-transcoding/tdarr-integration")).TdarrIntegration(input), + optionalAuth(apiKeyAuth), + ), + proxmox: withHttpAuthentication( + async (input: IntegrationInput) => new (await import("../proxmox/proxmox-integration")).ProxmoxIntegration(input), + (input) => ({ + headers: { + Authorization: `PVEAPIToken=${secret(input, "username")}@${secret(input, "realm")}!${secret(input, "tokenId")}=${secret(input, "apiKey")}`, + }, + }), + ), + emby: withHttpAuthentication( + async (input: IntegrationInput) => new (await import("../emby/emby-integration")).EmbyIntegration(input), + headerAuth("X-Emby-Token"), + ), + nextcloud: withHttpAuthentication( + async (input: IntegrationInput) => + new (await import("../nextcloud/nextcloud.integration")).NextcloudIntegration(input), + basicAuth("username", "password", { "OCS-APIRequest": "true" }), + ), + unifiController: withHttpAuthentication( + async (input: IntegrationInput) => + new (await import("../unifi-controller/unifi-controller-integration")).UnifiControllerIntegration(input), + clientAuthentication, + ), + opnsense: withHttpAuthentication( + async (input: IntegrationInput) => + new (await import("../opnsense/opnsense-integration")).OPNsenseIntegration(input), + basicAuth("opnsenseApiKey", "opnsenseApiSecret"), + ), + ical: withHttpAuthentication( + async (input: IntegrationInput) => new (await import("../ical/ical-integration")).ICalIntegration(input), + feedAuth, + ), + ntfy: withHttpAuthentication( + async (input: IntegrationInput) => new (await import("../ntfy/ntfy-integration")).NTFYIntegration(input), + optionalAuth(bearerAuth), + ), + gotify: withHttpAuthentication( + async (input: IntegrationInput) => new (await import("../gotify/gotify-integration")).GotifyIntegration(input), + basicAuth(), + ), mock: async (input: IntegrationInput) => new (await import("../mock/mock-integration")).MockIntegration(input), truenas: async (input: IntegrationInput) => new (await import("../truenas/truenas-integration")).TrueNasIntegration(input), - synology: async (input: IntegrationInput) => - new (await import("../synology/synology-integration")).SynologyIntegration(input), - unraid: async (input: IntegrationInput) => - new (await import("../unraid/unraid-integration")).UnraidIntegration(input), - coolify: async (input: IntegrationInput) => - new (await import("../coolify/coolify-integration")).CoolifyIntegration(input), - tracearr: async (input: IntegrationInput) => - new (await import("../tracearr/tracearr-integration")).TracearrIntegration(input), - glances: async (input: IntegrationInput) => - new (await import("../glances/glances-integration")).GlancesIntegration(input), - immich: async (input: IntegrationInput) => - new (await import("../immich/immich-integration")).ImmichIntegration(input), - paperlessNgx: async (input: IntegrationInput) => - new (await import("../paperless-ngx/paperless-ngx-integration")).PaperlessNgxIntegration(input), - patchmon: async (input: IntegrationInput) => - new (await import("../patchmon/patchmon-integration")).PatchMonIntegration(input), - speedtestTracker: async (input: IntegrationInput) => - new (await import("../speedtest-tracker/speedtest-tracker-integration")).SpeedtestTrackerIntegration(input), - audiobookshelf: async (input: IntegrationInput) => - new (await import("../audiobookshelf/audiobookshelf-integration")).AudiobookshelfIntegration(input), - navidrome: async (input: IntegrationInput) => - new (await import("../navidrome/navidrome-integration")).NavidromeIntegration(input), - umami: async (input: IntegrationInput) => new (await import("../umami/umami-integration")).UmamiIntegration(input), - gluetun: async (input: IntegrationInput) => - new (await import("../gluetun/gluetun-integration")).GluetunIntegration(input), - archiveTeamWarrior: async (input: IntegrationInput) => - new (await import("../archive-team-warrior/archive-team-warrior-integration")).ArchiveTeamWarriorIntegration(input), - uptimeKuma: async (input: IntegrationInput) => - new (await import("../uptime-kuma/uptime-kuma-integration")).UptimeKumaIntegration(input), - peaNut: async (input: IntegrationInput) => - new (await import("../peanut/peanut-integration")).PeaNutIntegration(input), - beszel: async (input: IntegrationInput) => - new (await import("../beszel/beszel-integration")).BeszelIntegration(input), - bazarr: async (input: IntegrationInput) => - new (await import("../bazarr/bazarr-integration")).BazarrIntegration(input), - traefik: async (input: IntegrationInput) => - new (await import("../traefik/traefik-integration")).TraefikIntegration(input), - wud: async (input: IntegrationInput) => new (await import("../wud/wud-integration")).WudIntegration(input), - llamacpp: async (input: IntegrationInput) => - new (await import("../llama-cpp/llamacpp-integration")).LlamacppIntegration(input), -} satisfies Record Promise>; + synology: withHttpAuthentication( + async (input: IntegrationInput) => + new (await import("../synology/synology-integration")).SynologyIntegration(input), + clientAuthentication, + ), + unraid: withHttpAuthentication( + async (input: IntegrationInput) => new (await import("../unraid/unraid-integration")).UnraidIntegration(input), + apiKeyAuth, + ), + coolify: withHttpAuthentication( + async (input: IntegrationInput) => new (await import("../coolify/coolify-integration")).CoolifyIntegration(input), + bearerAuth, + ), + tracearr: withHttpAuthentication( + async (input: IntegrationInput) => + new (await import("../tracearr/tracearr-integration")).TracearrIntegration(input), + bearerAuth, + ), + glances: withHttpAuthentication( + async (input: IntegrationInput) => new (await import("../glances/glances-integration")).GlancesIntegration(input), + noAuth, + ), + immich: withHttpAuthentication( + async (input: IntegrationInput) => new (await import("../immich/immich-integration")).ImmichIntegration(input), + apiKeyAuth, + ), + paperlessNgx: withHttpAuthentication( + async (input: IntegrationInput) => + new (await import("../paperless-ngx/paperless-ngx-integration")).PaperlessNgxIntegration(input), + headerAuth("Authorization", "Token "), + ), + patchmon: withHttpAuthentication( + async (input: IntegrationInput) => + new (await import("../patchmon/patchmon-integration")).PatchMonIntegration(input), + basicAuth("patchmonApiKey", "patchmonApiSecret"), + ), + speedtestTracker: withHttpAuthentication( + async (input: IntegrationInput) => + new (await import("../speedtest-tracker/speedtest-tracker-integration")).SpeedtestTrackerIntegration(input), + bearerAuth, + ), + audiobookshelf: withHttpAuthentication( + async (input: IntegrationInput) => + new (await import("../audiobookshelf/audiobookshelf-integration")).AudiobookshelfIntegration(input), + bearerAuth, + ), + navidrome: withHttpAuthentication( + async (input: IntegrationInput) => + new (await import("../navidrome/navidrome-integration")).NavidromeIntegration(input), + clientAuthentication, + ), + umami: withHttpAuthentication( + async (input: IntegrationInput) => new (await import("../umami/umami-integration")).UmamiIntegration(input), + clientAuthentication, + ), + gluetun: withHttpAuthentication( + async (input: IntegrationInput) => new (await import("../gluetun/gluetun-integration")).GluetunIntegration(input), + alternativeAuth(apiKeyAuth), + ), + archiveTeamWarrior: withHttpAuthentication( + async (input: IntegrationInput) => + new (await import("../archive-team-warrior/archive-team-warrior-integration")).ArchiveTeamWarriorIntegration( + input, + ), + optionalAuth(basicAuth(), ["username", "password"]), + ), + uptimeKuma: withHttpAuthentication( + async (input: IntegrationInput) => + new (await import("../uptime-kuma/uptime-kuma-integration")).UptimeKumaIntegration(input), + noAuth, + ), + peaNut: withHttpAuthentication( + async (input: IntegrationInput) => new (await import("../peanut/peanut-integration")).PeaNutIntegration(input), + optionalAuth(basicAuth(), ["username", "password"]), + ), + beszel: withHttpAuthentication( + async (input: IntegrationInput) => new (await import("../beszel/beszel-integration")).BeszelIntegration(input), + clientAuthentication, + ), + bazarr: withHttpAuthentication( + async (input: IntegrationInput) => new (await import("../bazarr/bazarr-integration")).BazarrIntegration(input), + apiKeyAuth, + ), + traefik: withHttpAuthentication( + async (input: IntegrationInput) => new (await import("../traefik/traefik-integration")).TraefikIntegration(input), + clientAuthentication, + ), + wud: withHttpAuthentication( + async (input: IntegrationInput) => new (await import("../wud/wud-integration")).WudIntegration(input), + optionalAuth(basicAuth(), ["username", "password"]), + ), + llamacpp: withHttpAuthentication( + async (input: IntegrationInput) => + new (await import("../llama-cpp/llamacpp-integration")).LlamacppIntegration(input), + noAuth, + ), +} satisfies { + [Kind in IntegrationKind]: ((input: IntegrationInput) => Promise) & + (Kind extends "truenas" | "mock" + ? unknown + : { + authenticate(input: IntegrationInput): IntegrationHttpAuthentication | Promise; + }); +}; type IntegrationCreators = typeof integrationCreators; type IntegrationInstanceOfKind = Awaited< @@ -162,3 +340,11 @@ export async function createIntegrationAsync( throw error; } } + +export async function getIntegrationHttpAuthenticationAsync( + integration: IntegrationInput & { kind: IntegrationKind }, +): Promise { + const creator = integrationCreators[integration.kind]; + if (!("authenticate" in creator)) throw new Error("This integration does not use HTTP"); + return creator.authenticate(integration); +} diff --git a/packages/integrations/src/beszel/beszel-integration.ts b/packages/integrations/src/beszel/beszel-integration.ts index 4917be739..b806de6bf 100644 --- a/packages/integrations/src/beszel/beszel-integration.ts +++ b/packages/integrations/src/beszel/beszel-integration.ts @@ -125,6 +125,12 @@ export const isSessionExpired = (session: BeszelSession, now = Date.now()) => session.expiresAt !== undefined && session.expiresAt - sessionExpiryLeewayMs <= now; export class BeszelIntegration extends Integration { + public async getHttpAuthenticationAsync(refresh = false) { + if (refresh) await this.sessionStore.clearAsync(); + const session = await this.authenticateAsync(); + return { headers: { Authorization: session.token } }; + } + private readonly sessionStore: SessionStore; constructor(integration: IntegrationInput) { diff --git a/packages/integrations/src/download-client/deluge/deluge-integration.ts b/packages/integrations/src/download-client/deluge/deluge-integration.ts index 0ed32127b..7da8bb81f 100644 --- a/packages/integrations/src/download-client/deluge/deluge-integration.ts +++ b/packages/integrations/src/download-client/deluge/deluge-integration.ts @@ -17,6 +17,15 @@ import type { DownloadClientStatus } from "../../interfaces/downloads/download-c @HandleIntegrationErrors([integrationOFetchHttpErrorHandler]) export class DelugeIntegration extends Integration implements IDownloadClientIntegration { + public async getHttpAuthenticationAsync() { + const client = await this.getClientAsync(); + await client.login(); + if (!(await client.connected())) await client.connect(); + const cookie = client.exportState().auth.cookieHeader; + if (!cookie) throw new Error("Integration authentication did not return a session"); + return { headers: { Cookie: cookie } }; + } + protected async testingAsync(input: IntegrationTestingInput): Promise { const client = await this.getClientAsync(input.dispatcher); const isSuccess = await client.login(); diff --git a/packages/integrations/src/download-client/qbittorrent/qbittorrent-integration.ts b/packages/integrations/src/download-client/qbittorrent/qbittorrent-integration.ts index c08dd5848..c05440f3f 100644 --- a/packages/integrations/src/download-client/qbittorrent/qbittorrent-integration.ts +++ b/packages/integrations/src/download-client/qbittorrent/qbittorrent-integration.ts @@ -1,3 +1,4 @@ +import type { IntegrationHttpAuthentication } from "../../http-auth"; import { QBittorrent } from "@ctrl/qbittorrent"; import dayjs from "dayjs"; import type { Dispatcher } from "undici"; @@ -17,6 +18,17 @@ import type { DownloadClientStatus } from "../../interfaces/downloads/download-c @HandleIntegrationErrors([integrationOFetchHttpErrorHandler]) export class QBitTorrentIntegration extends Integration implements IDownloadClientIntegration { + public async getHttpAuthenticationAsync(): Promise { + if (this.hasSecretValue("apiKey")) return { headers: { Authorization: `Bearer ${this.getSecretValue("apiKey")}` } }; + const client = await this.getClientAsync(); + const auth = client.exportState().auth; + if (!auth) return {}; + return { + headers: { Cookie: `${auth.cookieName ?? "SID"}=${auth.sid}` }, + isExpired: ({ status }) => status === 403, + }; + } + protected async testingAsync(input: IntegrationTestingInput): Promise { const client = await this.getClientAsync(input.dispatcher); const isSuccess = await client.login(); diff --git a/packages/integrations/src/download-client/transmission/transmission-integration.ts b/packages/integrations/src/download-client/transmission/transmission-integration.ts index 7b579b1bc..4b6c376d8 100644 --- a/packages/integrations/src/download-client/transmission/transmission-integration.ts +++ b/packages/integrations/src/download-client/transmission/transmission-integration.ts @@ -16,6 +16,20 @@ import type { DownloadClientStatus } from "../../interfaces/downloads/download-c @HandleIntegrationErrors([integrationOFetchHttpErrorHandler]) export class TransmissionIntegration extends Integration implements IDownloadClientIntegration { + public async getHttpAuthenticationAsync() { + const client = await this.getClientAsync(); + await client.getSession(); + const session = client.exportState().auth?.sessionId; + if (!session) throw new Error("Integration authentication did not return a session"); + const credentials = Buffer.from(`${this.getSecretValue("username")}:${this.getSecretValue("password")}`).toString( + "base64", + ); + return { + headers: { Authorization: `Basic ${credentials}`, "X-Transmission-Session-Id": session }, + isExpired: (response: { status: number }) => response.status === 409, + }; + } + protected async testingAsync(input: IntegrationTestingInput): Promise { const client = await this.getClientAsync(input.dispatcher); await client.getSession(); diff --git a/packages/integrations/src/factory.ts b/packages/integrations/src/factory.ts index f0c6d6d04..f1e64b9bb 100644 --- a/packages/integrations/src/factory.ts +++ b/packages/integrations/src/factory.ts @@ -1 +1,3 @@ -export { createIntegrationAsync } from "./base/creator"; +export { createIntegrationAsync, getIntegrationHttpAuthenticationAsync } from "./base/creator"; + +export type { IntegrationHttpAuthentication } from "./http-auth"; diff --git a/packages/integrations/src/http-auth.ts b/packages/integrations/src/http-auth.ts index 94dae0695..d7d8a6ae4 100644 --- a/packages/integrations/src/http-auth.ts +++ b/packages/integrations/src/http-auth.ts @@ -1,56 +1,148 @@ -import type { IntegrationKind } from "@homarr/definitions"; +import { Buffer } from "node:buffer"; -export interface IntegrationHttpAuth { - type: "apiKeyHeader" | "bearer" | "basic"; - secrets: Array<{ kind: string; value: string }>; +import type { IntegrationSecretKind } from "@homarr/definitions"; + +import type { IntegrationInput } from "./base/integration"; + +export interface IntegrationHttpAuthentication { + headers?: Record; + redactValues?: string[]; + query?: Record; + baseUrl?: string; + transformUrl?(url: URL): void; + transformBody?(body: string | undefined): string; + isExpired?(response: { status: number; data: unknown }): boolean; + refreshAsync?(): Promise; } -export class IntegrationHttpAuthError extends Error { - readonly code: "BAD_REQUEST" | "PRECONDITION_FAILED"; - constructor(input: { code: "BAD_REQUEST" | "PRECONDITION_FAILED"; message: string }) { - super(input.message); - this.code = input.code; +export function secret(input: IntegrationInput, kind: IntegrationSecretKind) { + const value = input.decryptedSecrets.find((entry) => entry.kind === kind)?.value; + if (!value) throw new Error(`Integration credential ${kind} is missing`); + return value; +} + +export const headerAuth = + (name: string, prefix = "", kind: IntegrationSecretKind = "apiKey") => + (input: IntegrationInput): IntegrationHttpAuthentication => ({ + headers: { [name]: `${prefix}${secret(input, kind)}` }, + }); +export const bearerAuth = headerAuth("Authorization", "Bearer "); +export const apiKeyAuth = headerAuth("X-API-Key"); +export const basicAuth = + ( + username: IntegrationSecretKind = "username", + password: IntegrationSecretKind = "password", + headers: Record = {}, + ) => + (input: IntegrationInput): IntegrationHttpAuthentication => { + const credentials = Buffer.from(`${secret(input, username)}:${secret(input, password)}`).toString("base64"); + return { headers: { ...headers, Authorization: `Basic ${credentials}` }, redactValues: [credentials] }; + }; +export const queryAuth = + (name: string, kind: IntegrationSecretKind = "apiKey") => + (input: IntegrationInput): IntegrationHttpAuthentication => ({ query: { [name]: secret(input, kind) } }); +export const noAuth = (): IntegrationHttpAuthentication => ({}); +export const optionalAuth = + ( + resolve: (input: IntegrationInput) => IntegrationHttpAuthentication, + required: IntegrationSecretKind[] = ["apiKey"], + ) => + (input: IntegrationInput) => { + if ( + !required.every((requiredKind) => + input.decryptedSecrets.some(({ kind, value }) => kind === requiredKind && value), + ) + ) + return noAuth(); + return resolve(input); + }; +export const alternativeAuth = + ( + apiKey: (input: IntegrationInput) => IntegrationHttpAuthentication, + fallback = optionalAuth(basicAuth(), ["username", "password"]), + ) => + (input: IntegrationInput) => { + if (input.decryptedSecrets.some(({ kind, value }) => kind === "apiKey" && value)) return apiKey(input); + return fallback(input); + }; +export const rpcAuth = (input: IntegrationInput): IntegrationHttpAuthentication => ({ + transformBody(body) { + const authenticate = (value: unknown): unknown => { + if (Array.isArray(value)) return value.map(authenticate); + if (!value || typeof value !== "object") throw new Error("Expected a JSON-RPC request body"); + const request = value as { method?: string; methodName?: string; params?: unknown[] }; + const method = request.method ?? request.methodName; + const params = request.params ?? []; + if (!Array.isArray(params)) throw new Error("JSON-RPC params must be an array"); + if (method === "system.multicall") return { ...request, params: [authenticate(params[0])] }; + if (method === "system.listMethods" || method === "system.listNotifications") return request; + if (typeof params[0] === "string" && params[0].startsWith("token:")) params.shift(); + return { ...request, params: [`token:${secret(input, "apiKey")}`, ...params] }; + }; + return JSON.stringify(authenticate(JSON.parse(body ?? "null"))); + }, +}); + +export function withHttpAuthentication( + create: (input: IntegrationInput) => Promise, + authenticate: ( + input: IntegrationInput, + create: () => Promise, + ) => IntegrationHttpAuthentication | Promise, +) { + return Object.assign(create, { + authenticate: (input: IntegrationInput) => withAuthTimeout(() => authenticate(input, () => create(input))), + }); +} +export const clientAuthentication = async < + T extends { getHttpAuthenticationAsync(refresh?: boolean): Promise }, +>( + _input: IntegrationInput, + create: () => Promise, +) => { + const client = await create(); + const resolve = async (refresh = false): Promise => { + const auth = await client.getHttpAuthenticationAsync(refresh); + const values = [...Object.values(auth.headers ?? {}), ...Object.values(auth.query ?? {})]; + for (const [name, value] of Object.entries(auth.headers ?? {})) { + if (name.toLowerCase() === "cookie") { + for (const cookie of value.split(";")) values.push(cookie.slice(cookie.indexOf("=") + 1).trim()); + } + if (name.toLowerCase() === "authorization") values.push(value.slice(value.indexOf(" ") + 1)); + } + return { + ...auth, + redactValues: auth.redactValues ?? values, + refreshAsync: () => withAuthTimeout(() => resolve(true)), + }; + }; + return resolve(); +}; + +async function withAuthTimeout(resolve: () => T | Promise): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + Promise.resolve().then(resolve), + new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new Error("Integration authentication timed out")), 10_000); + }), + ]); + } finally { + clearTimeout(timer); } } -// Match the existing adapters; never guess authentication from secret names alone. -export const integrationHttpAuth = { - sonarr: "apiKeyHeader", - radarr: "apiKeyHeader", - lidarr: "apiKeyHeader", - readarr: "apiKeyHeader", - prowlarr: "apiKeyHeader", - overseerr: "apiKeyHeader", - jellyseerr: "apiKeyHeader", - seerr: "apiKeyHeader", - immich: "apiKeyHeader", - slskd: "apiKeyHeader", - homeAssistant: "bearer", - coolify: "bearer", - audiobookshelf: "bearer", - speedtestTracker: "bearer", - adGuardHome: "basic", - nextcloud: "basic", -} as const satisfies Partial>; - -export function resolveIntegrationHttpAuth( - kind: IntegrationKind, - secrets: IntegrationHttpAuth["secrets"], -): IntegrationHttpAuth { - if (!(kind in integrationHttpAuth)) { - throw new IntegrationHttpAuthError({ - code: "BAD_REQUEST", - message: "This integration's authentication is not supported by HTTP requests", - }); - } - const type = integrationHttpAuth[kind as keyof typeof integrationHttpAuth]; - let required = ["apiKey"]; - if (type === "basic") required = ["username", "password"]; - if (required.some((key) => !secrets.some((secret) => secret.kind === key && secret.value))) { - throw new IntegrationHttpAuthError({ - code: "PRECONDITION_FAILED", - message: "Integration credentials are incomplete", - }); - } - return { type, secrets }; -} +export const feedAuth = (input: IntegrationInput): IntegrationHttpAuthentication => { + const baseUrl = secret(input, "url"); + const base = new URL(baseUrl); + return { + baseUrl, + redactValues: [...base.searchParams.values()], + transformUrl(url) { + // The stored feed URL is a complete endpoint, so the root request uses it exactly. + if (url.pathname === `${base.pathname}/`) url.pathname = base.pathname; + for (const [name, value] of base.searchParams) url.searchParams.set(name, value); + }, + }; +}; diff --git a/packages/integrations/src/jellyfin/jellyfin-integration.ts b/packages/integrations/src/jellyfin/jellyfin-integration.ts index 8167ee9be..c39e41766 100644 --- a/packages/integrations/src/jellyfin/jellyfin-integration.ts +++ b/packages/integrations/src/jellyfin/jellyfin-integration.ts @@ -60,6 +60,11 @@ export function parseLocation(remoteEndPoint: string | null | undefined): "lan" @HandleIntegrationErrors([integrationAxiosHttpErrorHandler]) export class JellyfinIntegration extends Integration implements IMediaServerIntegration, IMediaReleasesIntegration { + public async getHttpAuthenticationAsync() { + const api = await this.getApiAsync(); + return { headers: { "X-Emby-Token": api.accessToken } }; + } + private readonly jellyfin: Jellyfin = new Jellyfin({ clientInfo: { name: "Homarr", diff --git a/packages/integrations/src/navidrome/navidrome-integration.ts b/packages/integrations/src/navidrome/navidrome-integration.ts index ebbff6611..25413b016 100644 --- a/packages/integrations/src/navidrome/navidrome-integration.ts +++ b/packages/integrations/src/navidrome/navidrome-integration.ts @@ -25,6 +25,10 @@ const asArray = (value: TValue | TValue[] | undefined): TValue[] => [value].flat().filter((item): item is TValue => item !== undefined); export class NavidromeIntegration extends Integration implements IMediaServerIntegration { + public async getHttpAuthenticationAsync() { + return { query: this.getAuthParams(), redactValues: [] }; + } + protected async testingAsync(input: IntegrationTestingInput): Promise { const url = this.url("/rest/ping.view", this.getAuthParams()); const response = await input.fetchAsync(url); diff --git a/packages/integrations/src/openmediavault/openmediavault-integration.ts b/packages/integrations/src/openmediavault/openmediavault-integration.ts index 382f211e6..937f715d7 100644 --- a/packages/integrations/src/openmediavault/openmediavault-integration.ts +++ b/packages/integrations/src/openmediavault/openmediavault-integration.ts @@ -1,3 +1,4 @@ +import type { IntegrationHttpAuthentication } from "../http-auth"; import type { Headers, HeadersInit, fetch as undiciFetch, Response as UndiciResponse } from "undici"; import { ResponseError } from "@homarr/common/server"; @@ -20,6 +21,18 @@ type SessionStoreValue = | { type: "cookie"; loginToken: string; sessionId: string }; export class OpenMediaVaultIntegration extends Integration implements ISystemHealthMonitoringIntegration { + public async getHttpAuthenticationAsync(refresh = false): Promise { + if (refresh) await this.sessionStore.clearAsync(); + let session = await this.sessionStore.getAsync(); + if (!session) { + session = await this.getSessionAsync(); + await this.sessionStore.setAsync(session); + } + if (session.type === "cookie") + return { headers: { Cookie: `${session.loginToken.split(";")[0]}; ${session.sessionId.split(";")[0]}` } }; + return { headers: { "X-OPENMEDIAVAULT-SESSIONID": session.sessionId } }; + } + private readonly sessionStore: SessionStore; constructor(integration: IntegrationInput) { diff --git a/packages/integrations/src/pi-hole/v5/pi-hole-integration-v5.ts b/packages/integrations/src/pi-hole/v5/pi-hole-integration-v5.ts index 2c6d7a2c4..57a55d6c2 100644 --- a/packages/integrations/src/pi-hole/v5/pi-hole-integration-v5.ts +++ b/packages/integrations/src/pi-hole/v5/pi-hole-integration-v5.ts @@ -11,6 +11,12 @@ import type { DnsHoleSummary } from "../../interfaces/dns-hole-summary/dns-hole- import { summaryResponseSchema } from "./pi-hole-schemas-v5"; export class PiHoleIntegrationV5 extends Integration implements DnsHoleSummaryIntegration { + public async getHttpAuthenticationAsync() { + const query: Record = {}; + if (this.hasSecretValue("apiKey")) query.auth = this.getSecretValue("apiKey"); + return { query }; + } + public async getSummaryAsync(): Promise { const apiKey = super.getSecretValue("apiKey"); const response = await fetchWithTrustedCertificatesAsync(this.url("/admin/api.php?summaryRaw", { auth: apiKey })); 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 fd54c0915..7fc224758 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 @@ -17,6 +17,19 @@ import { dnsBlockingGetSchema, sessionResponseSchema, statsSummaryGetSchema } fr const logger = createLogger({ module: "piHoleIntegration", version: "v6" }); export class PiHoleIntegrationV6 extends Integration implements DnsHoleSummaryIntegration { + public async getHttpAuthenticationAsync(refresh = false) { + if (!this.hasSecretValue("apiKey")) return { headers: {} as Record }; + if (refresh) await this.sessionStore.clearAsync(); + let session = await this.sessionStore.getAsync(); + if (!session) { + session = { sid: await this.getSessionAsync() }; + await this.sessionStore.setAsync(session); + } + const headers: Record = {}; + if (session.sid) headers.sid = session.sid; + return { headers }; + } + private readonly sessionStore: SessionStore<{ sid: string | null }>; constructor(integration: IntegrationInput) { diff --git a/packages/integrations/src/synology/synology-client.ts b/packages/integrations/src/synology/synology-client.ts index 6a03c01ba..06920b906 100644 --- a/packages/integrations/src/synology/synology-client.ts +++ b/packages/integrations/src/synology/synology-client.ts @@ -51,6 +51,25 @@ type SynologyClientOptions = { const apiDefinitionCache = new Map(); export class SynologyClient { + public async getHttpAuthenticationAsync(refresh = false) { + if (refresh) await this.sessionStore.clearAsync(); + let session = await this.sessionStore.getAsync(); + if (!session) { + session = await this.loginAsync(); + await this.sessionStore.setAsync(session); + } + return { + headers: { Cookie: session.cookieHeader }, + isExpired(response: { status: number; data: unknown }) { + const payload = synologyEnvelopeSchema.safeParse(response.data); + return ( + response.status === 401 || + (payload.success && payload.data.success === false && SESSION_ERROR_CODES.has(payload.data.error?.code ?? 0)) + ); + }, + }; + } + private readonly integrationId: string; private readonly baseUrl: string; private readonly username: string; diff --git a/packages/integrations/src/synology/synology-integration.ts b/packages/integrations/src/synology/synology-integration.ts index 2628b5c1c..02bc6610b 100644 --- a/packages/integrations/src/synology/synology-integration.ts +++ b/packages/integrations/src/synology/synology-integration.ts @@ -22,6 +22,10 @@ type StoredSession = { @HandleIntegrationErrors([]) export class SynologyIntegration extends Integration implements ISystemHealthMonitoringIntegration { + public async getHttpAuthenticationAsync(refresh = false) { + return this.getClient().getHttpAuthenticationAsync(refresh); + } + private readonly sessionStore = createSessionStore(this.integration); private client?: SynologyClient; diff --git a/packages/integrations/src/technitium/technitium-integration.ts b/packages/integrations/src/technitium/technitium-integration.ts index 9b805f38d..05adc9e60 100644 --- a/packages/integrations/src/technitium/technitium-integration.ts +++ b/packages/integrations/src/technitium/technitium-integration.ts @@ -26,6 +26,26 @@ import type { StoredSession, TechnitiumVersion } from "./technitium-types"; const logger = createLogger({ module: "technitiumDnsIntegration" }); export class TechnitiumDnsIntegration extends Integration implements DnsHoleSummaryIntegration { + public async getHttpAuthenticationAsync(refresh = false) { + if (refresh) await this.sessionStore.clearAsync(); + const token = await this.acquireTokenAsync(); + const headers: Record = {}; + const query: Record = {}; + if (token) { + if (this.version === "v15") headers.Authorization = `Bearer ${token}`; + else query.token = token; + } + return { + headers, + query, + isExpired: (response: { data: unknown }) => + response.data !== null && + typeof response.data === "object" && + "status" in response.data && + response.data.status === "invalid-token", + }; + } + // Mutable: updated when a login response or session cache reveals the server's auth version. private version: TechnitiumVersion; private readonly sessionStore: SessionStore; diff --git a/packages/integrations/src/traefik/traefik-integration.ts b/packages/integrations/src/traefik/traefik-integration.ts index 2e0e72b4c..fd22953fd 100644 --- a/packages/integrations/src/traefik/traefik-integration.ts +++ b/packages/integrations/src/traefik/traefik-integration.ts @@ -29,6 +29,10 @@ type ResourcePath = const MAX_RESOURCE_DETAILS = 200; export class TraefikIntegration extends Integration { + public async getHttpAuthenticationAsync() { + return { headers: this.getAuthHeaders() }; + } + protected async testingAsync(input: IntegrationTestingInput): Promise { const response = await input.fetchAsync(this.url("/api/version"), { headers: this.getAuthHeaders() }); diff --git a/packages/integrations/src/umami/umami-integration.ts b/packages/integrations/src/umami/umami-integration.ts index 52a2ab89a..a8a14deb0 100644 --- a/packages/integrations/src/umami/umami-integration.ts +++ b/packages/integrations/src/umami/umami-integration.ts @@ -47,6 +47,10 @@ const extractDataArray = (json: unknown): unknown[] => { }; export class UmamiIntegration extends Integration { + public async getHttpAuthenticationAsync() { + return { headers: await this.getAuthHeadersAsync() }; + } + protected async testingAsync(input: IntegrationTestingInput): Promise { const authHeaders = await this.getAuthHeadersAsync(); const url = this.url("/websites"); diff --git a/packages/integrations/src/unifi-controller/unifi-controller-integration.ts b/packages/integrations/src/unifi-controller/unifi-controller-integration.ts index 86bdd245e..2d84099a7 100644 --- a/packages/integrations/src/unifi-controller/unifi-controller-integration.ts +++ b/packages/integrations/src/unifi-controller/unifi-controller-integration.ts @@ -21,6 +21,14 @@ import type { HealthSubsystem } from "./unifi-controller-types"; @HandleIntegrationErrors([integrationAxiosHttpErrorHandler]) export class UnifiControllerIntegration extends Integration implements NetworkControllerSummaryIntegration { + private httpAuthentication?: () => Promise<{ headers: Record; baseUrl: string }>; + + public async getHttpAuthenticationAsync() { + await this.createControllerClientAsync(); + if (!this.httpAuthentication) throw new Error("Integration authentication did not return a session"); + return this.httpAuthentication(); + } + public async getNetworkSummaryAsync(): Promise { const client = await this.createControllerClientAsync(); const stats = await client.getSitesStats(); @@ -71,13 +79,17 @@ export class UnifiControllerIntegration extends Integration implements NetworkCo // use 8443. If no port was provided, try both without hiding authentication errors. const ports = url.port ? [Number(url.port)] : [443, 8443]; const createClientForPortAsync = async (port: number) => { + let readAuthentication: typeof this.httpAuthentication; + const baseUrl = new URL(this.integration.url); + baseUrl.protocol = "https:"; + baseUrl.port = String(port); const client = new Unifi.Controller({ host: url.hostname, port, username: this.getSecretValue("username"), password: this.getSecretValue("password"), createAxiosInstance({ cookies }) { - return axios.create({ + const instance = axios.create({ adapter: "http", httpAgent: new HttpCookieAgent({ cookies }), httpsAgent: new HttpsCookieAgent({ @@ -86,10 +98,18 @@ export class UnifiControllerIntegration extends Integration implements NetworkCo ...certificateOptions, }), }); + readAuthentication = async () => { + const headers: Record = { Cookie: await cookies.jar.getCookieString(baseUrl.href) }; + const csrf = instance.defaults.headers.common["x-csrf-token"]; + if (typeof csrf === "string") headers["x-csrf-token"] = csrf; + return { headers, baseUrl: baseUrl.href }; + }; + return instance; }, }); await client.login(this.getSecretValue("username"), this.getSecretValue("password"), null); + this.httpAuthentication = readAuthentication; return client; };