feat: reuse authentication for all HTTP integrations
This commit is contained in:
1 parent
879a4ae23c
commit
901fd3d940
36 files changed
+830
-295
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
+1
-1
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 && (
|
||||
<Select
|
||||
label={t("integrationType")}
|
||||
data={customWidgetIntegrationKinds.map((value) => ({ value, label: integrationDefs[value].name }))}
|
||||
data={httpIntegrationKinds.map((value) => ({ value, label: integrationDefs[value].name }))}
|
||||
value={kind}
|
||||
allowDeselect={false}
|
||||
onChange={(value) => {
|
||||
const selected = customWidgetIntegrationKinds.find((candidate) => candidate === value);
|
||||
const selected = httpIntegrationKinds.find((candidate) => candidate === value);
|
||||
if (selected) onKindChange(selected);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Select
|
||||
label={t("selectIntegration")}
|
||||
description={integrationDefs[kind].name}
|
||||
description={isHttpIntegrationKind(kind) ? integrationDefs[kind].name : kind}
|
||||
data={integrations.map(({ id, name }) => ({ value: id, label: name }))}
|
||||
value={integrationId ?? null}
|
||||
searchable
|
||||
@@ -55,3 +59,22 @@ export function IntegrationSourceSelect({ kind, integrationId, onChange, onKindC
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export function SourceSetupPanel(props: CustomWidgetSourceSetupPanelProps) {
|
||||
return (
|
||||
<CustomWidgetSourceSetupPanel
|
||||
{...props}
|
||||
renderIntegrationSource={(setup, value) => {
|
||||
const kind = httpIntegrationKinds.find((candidate) => candidate === setup.integrationKind);
|
||||
if (!kind) return null;
|
||||
return (
|
||||
<IntegrationSourceSelect
|
||||
kind={kind}
|
||||
integrationId={value.integrationId}
|
||||
onChange={(integrationId) => props.onChange(setup.sourceId, { ...value, integrationId })}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<CustomWidgetSourceSetupPanel
|
||||
{...props}
|
||||
renderIntegrationSource={(setup, value) => {
|
||||
const kind = customWidgetIntegrationKinds.find((candidate) => candidate === setup.integrationKind);
|
||||
if (!kind) return null;
|
||||
return (
|
||||
<IntegrationSourceSelect
|
||||
kind={kind}
|
||||
integrationId={value.integrationId}
|
||||
onChange={(integrationId) => props.onChange(setup.sourceId, { ...value, integrationId })}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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<string, CustomWidgetSource>,
|
||||
@@ -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<CustomJsxRequest, "kind" | "method" | "auth">,
|
||||
getSecrets: () => Array<{ kind: string; value: string }>,
|
||||
): Promise<
|
||||
Pick<CustomWidgetHttpRequest, "baseUrl" | "networkScope" | "auth" | "tls" | "pathPrefix" | "redactSecrets"> & {
|
||||
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(
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import type { CustomWidgetHttpRequest, CustomWidgetHttpResponse } from "./request-types";
|
||||
import { assertCustomWidgetPathScope, resolveSameOriginTarget } from "./network-policy";
|
||||
|
||||
type HeaderSetter = Pick<Headers, "set">;
|
||||
|
||||
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<CustomWidgetHttpResponse>,
|
||||
): Promise<CustomWidgetHttpResponse> {
|
||||
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<T>(callback: () => Promise<T>, signal: AbortSignal): Promise<T> {
|
||||
signal.throwIfAborted();
|
||||
let abort: (() => void) | undefined;
|
||||
const aborted = new Promise<never>((_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);
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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<CustomWid
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => 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;
|
||||
}
|
||||
|
||||
@@ -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<string, string>;
|
||||
query?: Record<string, string>;
|
||||
transformUrl?(url: URL): void;
|
||||
transformBody?(body: string | undefined): string;
|
||||
isExpired?(response: CustomWidgetHttpResponse): boolean;
|
||||
refreshAsync?(): Promise<CustomWidgetAuthConfig>;
|
||||
}
|
||||
|
||||
export interface CustomWidgetHttpRequest {
|
||||
resolveConnectionAsync?(): Promise<
|
||||
Pick<CustomWidgetHttpRequest, "baseUrl" | "auth" | "tls" | "pathPrefix" | "redactSecrets">
|
||||
>;
|
||||
tls?: Pick<ConnectionOptions, "ca" | "checkServerIdentity">;
|
||||
pathPrefix?: string;
|
||||
redactSecrets?: CustomWidgetAuthConfig["secrets"];
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<unknown> {
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -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<IntegrationKind, "truenas" | "mock"> {
|
||||
return Object.hasOwn(integrationDefs, kind) && kind !== "truenas" && kind !== "mock";
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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<IntegrationKind, (input: IntegrationInput) => Promise<Integration>>;
|
||||
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<Integration>) &
|
||||
(Kind extends "truenas" | "mock"
|
||||
? unknown
|
||||
: {
|
||||
authenticate(input: IntegrationInput): IntegrationHttpAuthentication | Promise<IntegrationHttpAuthentication>;
|
||||
});
|
||||
};
|
||||
|
||||
type IntegrationCreators = typeof integrationCreators;
|
||||
type IntegrationInstanceOfKind<TKind extends keyof IntegrationCreators> = Awaited<
|
||||
@@ -162,3 +340,11 @@ export async function createIntegrationAsync(
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getIntegrationHttpAuthenticationAsync(
|
||||
integration: IntegrationInput & { kind: IntegrationKind },
|
||||
): Promise<IntegrationHttpAuthentication> {
|
||||
const creator = integrationCreators[integration.kind];
|
||||
if (!("authenticate" in creator)) throw new Error("This integration does not use HTTP");
|
||||
return creator.authenticate(integration);
|
||||
}
|
||||
@@ -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<BeszelSession>;
|
||||
|
||||
constructor(integration: IntegrationInput) {
|
||||
|
||||
@@ -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<TestingResult> {
|
||||
const client = await this.getClientAsync(input.dispatcher);
|
||||
const isSuccess = await client.login();
|
||||
|
||||
@@ -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<IntegrationHttpAuthentication> {
|
||||
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<TestingResult> {
|
||||
const client = await this.getClientAsync(input.dispatcher);
|
||||
const isSuccess = await client.login();
|
||||
|
||||
@@ -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<TestingResult> {
|
||||
const client = await this.getClientAsync(input.dispatcher);
|
||||
await client.getSession();
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
export { createIntegrationAsync } from "./base/creator";
|
||||
export { createIntegrationAsync, getIntegrationHttpAuthenticationAsync } from "./base/creator";
|
||||
|
||||
export type { IntegrationHttpAuthentication } from "./http-auth";
|
||||
@@ -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<string, string>;
|
||||
redactValues?: string[];
|
||||
query?: Record<string, string>;
|
||||
baseUrl?: string;
|
||||
transformUrl?(url: URL): void;
|
||||
transformBody?(body: string | undefined): string;
|
||||
isExpired?(response: { status: number; data: unknown }): boolean;
|
||||
refreshAsync?(): Promise<IntegrationHttpAuthentication>;
|
||||
}
|
||||
|
||||
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<string, string> = {},
|
||||
) =>
|
||||
(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<T>(
|
||||
create: (input: IntegrationInput) => Promise<T>,
|
||||
authenticate: (
|
||||
input: IntegrationInput,
|
||||
create: () => Promise<T>,
|
||||
) => IntegrationHttpAuthentication | Promise<IntegrationHttpAuthentication>,
|
||||
) {
|
||||
return Object.assign(create, {
|
||||
authenticate: (input: IntegrationInput) => withAuthTimeout(() => authenticate(input, () => create(input))),
|
||||
});
|
||||
}
|
||||
export const clientAuthentication = async <
|
||||
T extends { getHttpAuthenticationAsync(refresh?: boolean): Promise<IntegrationHttpAuthentication> },
|
||||
>(
|
||||
_input: IntegrationInput,
|
||||
create: () => Promise<T>,
|
||||
) => {
|
||||
const client = await create();
|
||||
const resolve = async (refresh = false): Promise<IntegrationHttpAuthentication> => {
|
||||
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<T>(resolve: () => T | Promise<T>): Promise<T> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
Promise.resolve().then(resolve),
|
||||
new Promise<never>((_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<Record<IntegrationKind, string>>;
|
||||
|
||||
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);
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
@@ -25,6 +25,10 @@ const asArray = <TValue>(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<TestingResult> {
|
||||
const url = this.url("/rest/ping.view", this.getAuthParams());
|
||||
const response = await input.fetchAsync(url);
|
||||
|
||||
@@ -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<IntegrationHttpAuthentication> {
|
||||
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<SessionStoreValue>;
|
||||
|
||||
constructor(integration: IntegrationInput) {
|
||||
|
||||
@@ -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<string, string> = {};
|
||||
if (this.hasSecretValue("apiKey")) query.auth = this.getSecretValue("apiKey");
|
||||
return { query };
|
||||
}
|
||||
|
||||
public async getSummaryAsync(): Promise<DnsHoleSummary> {
|
||||
const apiKey = super.getSecretValue("apiKey");
|
||||
const response = await fetchWithTrustedCertificatesAsync(this.url("/admin/api.php?summaryRaw", { auth: apiKey }));
|
||||
|
||||
@@ -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<string, string> };
|
||||
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<string, string> = {};
|
||||
if (session.sid) headers.sid = session.sid;
|
||||
return { headers };
|
||||
}
|
||||
|
||||
private readonly sessionStore: SessionStore<{ sid: string | null }>;
|
||||
|
||||
constructor(integration: IntegrationInput) {
|
||||
|
||||
@@ -51,6 +51,25 @@ type SynologyClientOptions = {
|
||||
const apiDefinitionCache = new Map<string, ApiDefinition>();
|
||||
|
||||
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;
|
||||
|
||||
@@ -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<StoredSession>(this.integration);
|
||||
private client?: SynologyClient;
|
||||
|
||||
|
||||
@@ -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<string, string> = {};
|
||||
const query: Record<string, string> = {};
|
||||
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<StoredSession>;
|
||||
|
||||
@@ -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<TestingResult> {
|
||||
const response = await input.fetchAsync(this.url("/api/version"), { headers: this.getAuthHeaders() });
|
||||
|
||||
|
||||
@@ -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<TestingResult> {
|
||||
const authHeaders = await this.getAuthHeadersAsync();
|
||||
const url = this.url("/websites");
|
||||
|
||||
@@ -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<string, string>; 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<NetworkControllerSummary> {
|
||||
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<string, string> = { 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;
|
||||
};
|
||||
|
||||
|
||||
Reference in new issue
Block a user