feat(integration): improve integration test connection (#3005)

This commit is contained in:
Meier Lukas
2025-05-16 20:59:12 +02:00
committed by GitHub
parent 3daf1c8341
commit ef9a5e9895
111 changed files with 7168 additions and 976 deletions

View File

@@ -1,7 +1,10 @@
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { ParseError } from "@homarr/common/server";
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import { IntegrationTestConnectionError } from "../base/test-connection-error";
import { TestConnectionError } from "../base/test-connection/test-connection-error";
import type { TestingResult } from "../base/test-connection/test-connection-service";
import type { DnsHoleSummaryIntegration } from "../interfaces/dns-hole-summary/dns-hole-summary-integration";
import type { DnsHoleSummary } from "../interfaces/dns-hole-summary/dns-hole-summary-types";
import { filteringStatusSchema, statsResponseSchema, statusResponseSchema } from "./adguard-home-types";
@@ -85,26 +88,19 @@ export class AdGuardHomeIntegration extends Integration implements DnsHoleSummar
};
}
public async testConnectionAsync(): Promise<void> {
await super.handleTestConnectionResponseAsync({
queryFunctionAsync: async () => {
return await fetchWithTrustedCertificatesAsync(this.url("/control/status"), {
headers: {
Authorization: `Basic ${this.getAuthorizationHeaderValue()}`,
},
});
},
handleResponseAsync: async (response) => {
try {
const result = await response.json();
if (typeof result === "object" && result !== null) return;
} catch {
throw new IntegrationTestConnectionError("invalidJson");
}
throw new IntegrationTestConnectionError("invalidCredentials");
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const response = await input.fetchAsync(this.url("/control/status"), {
headers: {
Authorization: `Basic ${this.getAuthorizationHeaderValue()}`,
},
});
if (!response.ok) return TestConnectionError.StatusResult(response);
const result = await response.json();
if (typeof result === "object" && result !== null) return { success: true };
return TestConnectionError.ParseResult(new ParseError("Expected object data"));
}
public async enableAsync(): Promise<void> {

View File

@@ -1,47 +0,0 @@
import type { Response as UndiciResponse } from "undici";
import type { z } from "zod";
import type { IntegrationInput } from "./integration";
export class ParseError extends Error {
public readonly zodError: z.ZodError;
public readonly input: unknown;
constructor(dataName: string, zodError: z.ZodError, input?: unknown) {
super(`Failed to parse ${dataName}`);
this.zodError = zodError;
this.input = input;
}
}
export class ResponseError extends Error {
public readonly statusCode: number;
public readonly url: string;
public readonly content?: string;
constructor(response: Response | UndiciResponse, content: unknown) {
super("Response failed");
this.statusCode = response.status;
this.url = response.url;
try {
this.content = JSON.stringify(content);
} catch {
this.content = content as string;
}
}
}
export class IntegrationResponseError extends ResponseError {
public readonly integration: Pick<IntegrationInput, "id" | "name" | "url">;
constructor(integration: IntegrationInput, response: Response | UndiciResponse, content: unknown) {
super(response, content);
this.integration = {
id: integration.id,
name: integration.name,
url: integration.url,
};
}
}

View File

@@ -0,0 +1,88 @@
import { isFunction } from "@homarr/common";
import { logger } from "@homarr/log";
import type { Integration } from "../integration";
import type { IIntegrationErrorHandler } from "./handler";
import { IntegrationError } from "./integration-error";
import { IntegrationUnknownError } from "./integration-unknown-error";
const localLogger = logger.child({
module: "HandleIntegrationErrors",
});
// eslint-disable-next-line @typescript-eslint/no-empty-object-type, @typescript-eslint/no-explicit-any
type AbstractConstructor<T = {}> = abstract new (...args: any[]) => T;
export const HandleIntegrationErrors = (errorHandlers: IIntegrationErrorHandler[]) => {
return <T extends AbstractConstructor<Integration>>(IntegrationBaseClass: T): T => {
abstract class ErrorHandledIntegration extends IntegrationBaseClass {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
constructor(...args: any[]) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
super(...args);
const processedProperties = new Set<string>();
let currentProto: unknown = Object.getPrototypeOf(this);
while (currentProto && currentProto !== Object.prototype) {
for (const propertyKey of Object.getOwnPropertyNames(currentProto)) {
if (propertyKey === "constructor" || processedProperties.has(propertyKey)) continue;
const descriptor = Object.getOwnPropertyDescriptor(currentProto, propertyKey);
if (!descriptor) continue;
const original: unknown = descriptor.value;
if (!isFunction(original)) continue;
processedProperties.add(propertyKey);
const wrapped = (...methodArgs: unknown[]) => {
const handleError = (error: unknown) => {
if (error instanceof IntegrationError) {
throw error;
}
for (const handler of errorHandlers) {
const handledError = handler.handleError(error, this.publicIntegration);
if (!handledError) continue;
throw handledError;
}
// If the error was handled and should be thrown again, throw it
localLogger.debug("Unhandled error in integration", {
error: error instanceof Error ? `${error.name}: ${error.message}` : undefined,
integrationName: this.publicIntegration.name,
});
throw new IntegrationUnknownError(this.publicIntegration, { cause: error });
};
try {
const result = original.apply(this, methodArgs);
if (result instanceof Promise) {
return result.catch((error: unknown) => {
handleError(error);
});
}
return result;
} catch (error: unknown) {
handleError(error);
}
};
Object.defineProperty(this, propertyKey, {
...descriptor,
value: wrapped,
});
}
currentProto = Object.getPrototypeOf(currentProto);
}
}
}
return ErrorHandledIntegration;
};
};

View File

@@ -0,0 +1,5 @@
import type { IntegrationError, IntegrationErrorData } from "./integration-error";
export interface IIntegrationErrorHandler {
handleError(error: unknown, integration: IntegrationErrorData): IntegrationError | undefined;
}

View File

@@ -0,0 +1,13 @@
import {
AxiosHttpErrorHandler,
FetchHttpErrorHandler,
OFetchHttpErrorHandler,
TsdavHttpErrorHandler,
} from "@homarr/common/server";
import { IntegrationHttpErrorHandler } from "./integration-http-error-handler";
export const integrationFetchHttpErrorHandler = new IntegrationHttpErrorHandler(new FetchHttpErrorHandler());
export const integrationOFetchHttpErrorHandler = new IntegrationHttpErrorHandler(new OFetchHttpErrorHandler());
export const integrationAxiosHttpErrorHandler = new IntegrationHttpErrorHandler(new AxiosHttpErrorHandler());
export const integrationTsdavHttpErrorHandler = new IntegrationHttpErrorHandler(new TsdavHttpErrorHandler());

View File

@@ -0,0 +1,26 @@
import { RequestError, ResponseError } from "@homarr/common/server";
import type { HttpErrorHandler } from "@homarr/common/server";
import type { IIntegrationErrorHandler } from "../handler";
import type { IntegrationError, IntegrationErrorData } from "../integration-error";
import { IntegrationRequestError } from "./integration-request-error";
import { IntegrationResponseError } from "./integration-response-error";
export class IntegrationHttpErrorHandler implements IIntegrationErrorHandler {
private readonly httpErrorHandler: HttpErrorHandler;
constructor(httpErrorHandler: HttpErrorHandler) {
this.httpErrorHandler = httpErrorHandler;
}
handleError(error: unknown, integration: IntegrationErrorData): IntegrationError | undefined {
if (error instanceof RequestError) return new IntegrationRequestError(integration, { cause: error });
if (error instanceof ResponseError) return new IntegrationResponseError(integration, { cause: error });
const requestError = this.httpErrorHandler.handleRequestError(error);
if (requestError) return new IntegrationRequestError(integration, { cause: requestError });
const responseError = this.httpErrorHandler.handleResponseError(error);
if (responseError) return new IntegrationResponseError(integration, { cause: responseError });
return undefined;
}
}

View File

@@ -0,0 +1,19 @@
import type { AnyRequestError, RequestError, RequestErrorType } from "@homarr/common/server";
import type { IntegrationErrorData } from "../integration-error";
import { IntegrationError } from "../integration-error";
export type IntegrationRequestErrorOfType<TType extends RequestErrorType> = IntegrationRequestError & {
cause: RequestError<TType>;
};
export class IntegrationRequestError extends IntegrationError {
constructor(integration: IntegrationErrorData, { cause }: { cause: AnyRequestError }) {
super(integration, "Request to integration failed", { cause });
this.name = IntegrationRequestError.name;
}
get cause(): AnyRequestError {
return super.cause as AnyRequestError;
}
}

View File

@@ -0,0 +1,15 @@
import type { ResponseError } from "@homarr/common/server";
import type { IntegrationErrorData } from "../integration-error";
import { IntegrationError } from "../integration-error";
export class IntegrationResponseError extends IntegrationError {
constructor(integration: IntegrationErrorData, { cause }: { cause: ResponseError }) {
super(integration, "Response from integration did not indicate success", { cause });
this.name = IntegrationResponseError.name;
}
get cause(): ResponseError {
return super.cause as ResponseError;
}
}

View File

@@ -0,0 +1,18 @@
export interface IntegrationErrorData {
id: string;
name: string;
url: string;
}
export abstract class IntegrationError extends Error {
public readonly integrationId: string;
public readonly integrationName: string;
public readonly integrationUrl: string;
constructor(integration: IntegrationErrorData, message: string, { cause }: ErrorOptions) {
super(message, { cause });
this.integrationId = integration.id;
this.integrationName = integration.name;
this.integrationUrl = integration.url;
}
}

View File

@@ -0,0 +1,8 @@
import type { IntegrationErrorData } from "./integration-error";
import { IntegrationError } from "./integration-error";
export class IntegrationUnknownError extends IntegrationError {
constructor(integration: IntegrationErrorData, { cause }: ErrorOptions) {
super(integration, "An unknown error occured while executing Integration method", { cause });
}
}

View File

@@ -0,0 +1,6 @@
import { JsonParseErrorHandler, ZodParseErrorHandler } from "@homarr/common/server";
import { IntegrationParseErrorHandler } from "./integration-parse-error-handler";
export const integrationZodParseErrorHandler = new IntegrationParseErrorHandler(new ZodParseErrorHandler());
export const integrationJsonParseErrorHandler = new IntegrationParseErrorHandler(new JsonParseErrorHandler());

View File

@@ -0,0 +1,22 @@
import { ParseError } from "@homarr/common/server";
import type { ParseErrorHandler } from "@homarr/common/server";
import type { IIntegrationErrorHandler } from "../handler";
import type { IntegrationError, IntegrationErrorData } from "../integration-error";
import { IntegrationParseError } from "./integration-parse-error";
export class IntegrationParseErrorHandler implements IIntegrationErrorHandler {
private readonly parseErrorHandler: ParseErrorHandler;
constructor(parseErrorHandler: ParseErrorHandler) {
this.parseErrorHandler = parseErrorHandler;
}
handleError(error: unknown, integration: IntegrationErrorData): IntegrationError | undefined {
if (error instanceof ParseError) return new IntegrationParseError(integration, { cause: error });
const parseError = this.parseErrorHandler.handleParseError(error);
if (parseError) return new IntegrationParseError(integration, { cause: parseError });
return undefined;
}
}

View File

@@ -0,0 +1,15 @@
import type { ParseError } from "@homarr/common/server";
import type { IntegrationErrorData } from "../integration-error";
import { IntegrationError } from "../integration-error";
export class IntegrationParseError extends IntegrationError {
constructor(integration: IntegrationErrorData, { cause }: { cause: ParseError }) {
super(integration, "Failed to parse integration data", { cause });
this.name = IntegrationParseError.name;
}
get cause(): ParseError {
return super.cause as ParseError;
}
}

View File

@@ -1,18 +1,20 @@
import type { Response } from "undici";
import { z } from "zod";
import type tls from "node:tls";
import type { AxiosInstance } from "axios";
import type { Dispatcher } from "undici";
import { fetch as undiciFetch } from "undici";
import { extractErrorMessage, removeTrailingSlash } from "@homarr/common";
import { createAxiosCertificateInstanceAsync, createCertificateAgentAsync } from "@homarr/certificates/server";
import { removeTrailingSlash } from "@homarr/common";
import type { IntegrationSecretKind } from "@homarr/definitions";
import { logger } from "@homarr/log";
import type { TranslationObject } from "@homarr/translation";
import { IntegrationTestConnectionError } from "./test-connection-error";
import { HandleIntegrationErrors } from "./errors/decorator";
import { integrationFetchHttpErrorHandler } from "./errors/http";
import { integrationJsonParseErrorHandler, integrationZodParseErrorHandler } from "./errors/parse";
import { TestConnectionError } from "./test-connection/test-connection-error";
import type { TestingResult } from "./test-connection/test-connection-service";
import { TestConnectionService } from "./test-connection/test-connection-service";
import type { IntegrationSecret } from "./types";
const causeSchema = z.object({
code: z.string(),
});
export interface IntegrationInput {
id: string;
name: string;
@@ -20,9 +22,32 @@ export interface IntegrationInput {
decryptedSecrets: IntegrationSecret[];
}
export interface IntegrationTestingInput {
fetchAsync: typeof undiciFetch;
dispatcher: Dispatcher;
axiosInstance: AxiosInstance;
options: {
ca: string[] | string;
checkServerIdentity: typeof tls.checkServerIdentity;
};
}
@HandleIntegrationErrors([
integrationZodParseErrorHandler,
integrationJsonParseErrorHandler,
integrationFetchHttpErrorHandler,
])
export abstract class Integration {
constructor(protected integration: IntegrationInput) {}
public get publicIntegration() {
return {
id: this.integration.id,
name: this.integration.name,
url: this.integration.url,
};
}
protected getSecretValue(kind: IntegrationSecretKind) {
const secret = this.integration.decryptedSecrets.find((secret) => secret.kind === kind);
if (!secret) {
@@ -48,89 +73,43 @@ export abstract class Integration {
return url;
}
/**
* Test the connection to the integration
* @throws {IntegrationTestConnectionError} if the connection fails
*/
public abstract testConnectionAsync(): Promise<void>;
public async testConnectionAsync(): Promise<TestingResult> {
try {
const url = new URL(this.integration.url);
return await new TestConnectionService(url).handleAsync(async ({ ca, checkServerIdentity }) => {
const fetchDispatcher = await createCertificateAgentAsync({
ca,
checkServerIdentity,
});
protected async handleTestConnectionResponseAsync({
queryFunctionAsync,
handleResponseAsync,
}: {
queryFunctionAsync: () => Promise<Response>;
handleResponseAsync?: (response: Response) => Promise<void>;
}): Promise<void> {
const response = await queryFunctionAsync().catch((error) => {
if (error instanceof Error) {
const cause = causeSchema.safeParse(error.cause);
if (!cause.success) {
logger.error("Failed to test connection", error);
throw new IntegrationTestConnectionError("commonError", extractErrorMessage(error));
}
const axiosInstance = await createAxiosCertificateInstanceAsync({
ca,
checkServerIdentity,
});
if (cause.data.code === "ENOTFOUND") {
logger.error("Failed to test connection: Domain not found");
throw new IntegrationTestConnectionError("domainNotFound");
}
if (cause.data.code === "ECONNREFUSED") {
logger.error("Failed to test connection: Connection refused");
throw new IntegrationTestConnectionError("connectionRefused");
}
if (cause.data.code === "ECONNABORTED") {
logger.error("Failed to test connection: Connection aborted");
throw new IntegrationTestConnectionError("connectionAborted");
}
const testingAsync: typeof this.testingAsync = this.testingAsync.bind(this);
return await testingAsync({
dispatcher: fetchDispatcher,
fetchAsync: async (url, options) => await undiciFetch(url, { ...options, dispatcher: fetchDispatcher }),
axiosInstance,
options: {
ca,
checkServerIdentity,
},
});
});
} catch (error) {
if (!(error instanceof TestConnectionError)) {
return TestConnectionError.UnknownResult(error);
}
logger.error("Failed to test connection", error);
throw new IntegrationTestConnectionError("commonError", extractErrorMessage(error));
});
if (response.status >= 400) {
const body = await response.text();
logger.error(`Failed to test connection with status code ${response.status}. Body: '${body}'`);
throwErrorByStatusCode(response.status);
return error.toResult();
}
await handleResponseAsync?.(response);
}
}
export interface TestConnectionError {
key: Exclude<keyof TranslationObject["integration"]["testConnection"]["notification"], "success">;
message?: string;
/**
* Test the connection to the integration
* @returns {Promise<TestingResult>}
*/
protected abstract testingAsync(input: IntegrationTestingInput): Promise<TestingResult>;
}
export type TestConnectionResult =
| {
success: false;
error: TestConnectionError;
}
| {
success: true;
};
export const throwErrorByStatusCode = (statusCode: number) => {
switch (statusCode) {
case 400:
throw new IntegrationTestConnectionError("badRequest");
case 401:
throw new IntegrationTestConnectionError("unauthorized");
case 403:
throw new IntegrationTestConnectionError("forbidden");
case 404:
throw new IntegrationTestConnectionError("notFound");
case 429:
throw new IntegrationTestConnectionError("tooManyRequests");
case 500:
throw new IntegrationTestConnectionError("internalServerError");
case 503:
throw new IntegrationTestConnectionError("serviceUnavailable");
default:
throw new IntegrationTestConnectionError("commonError");
}
};

View File

@@ -1,27 +0,0 @@
import { z } from "zod";
import { FlattenError } from "@homarr/common";
import type { TestConnectionError } from "./integration";
export class IntegrationTestConnectionError extends FlattenError {
constructor(
public key: TestConnectionError["key"],
public detailMessage?: string,
) {
super("Checking integration connection failed", { key, message: detailMessage });
}
}
const schema = z.object({
key: z.custom<TestConnectionError["key"]>((value) => z.string().parse(value)),
message: z.string().optional(),
});
export const convertIntegrationTestConnectionError = (error: unknown) => {
const result = schema.safeParse(error);
if (!result.success) {
return;
}
return result.data;
};

View File

@@ -0,0 +1,6 @@
export type {
TestConnectionError,
AnyTestConnectionError,
TestConnectionErrorDataOfType,
TestConnectionErrorType,
} from "./test-connection-error";

View File

@@ -0,0 +1,176 @@
import type { X509Certificate } from "node:crypto";
import type { AnyRequestError, ParseError, RequestError } from "@homarr/common/server";
import { IntegrationRequestError } from "../errors/http/integration-request-error";
import { IntegrationResponseError } from "../errors/http/integration-response-error";
import type { IntegrationError } from "../errors/integration-error";
import { IntegrationUnknownError } from "../errors/integration-unknown-error";
import { IntegrationParseError } from "../errors/parse/integration-parse-error";
export type TestConnectionErrorType = keyof TestConnectionErrorMap;
export type AnyTestConnectionError = {
[TType in TestConnectionErrorType]: TestConnectionError<TType>;
}[TestConnectionErrorType];
export type TestConnectionErrorDataOfType<TType extends TestConnectionErrorType> = TestConnectionErrorMap[TType];
export class TestConnectionError<TType extends TestConnectionErrorType> extends Error {
public readonly type: TType;
public readonly data: TestConnectionErrorMap[TType];
private constructor(type: TType, data: TestConnectionErrorMap[TType], options?: { cause: Error }) {
super("Unable to connect to the integration", options);
this.name = TestConnectionError.name;
this.type = type;
this.data = data;
}
get cause(): Error | undefined {
return super.cause as Error | undefined;
}
public toResult() {
return {
success: false,
error: this,
} as const;
}
private static Unknown(cause: unknown) {
return new TestConnectionError(
"unknown",
undefined,
cause instanceof Error
? {
cause,
}
: undefined,
);
}
public static UnknownResult(cause: unknown) {
return this.Unknown(cause).toResult();
}
private static Certificate(requestError: RequestError<"certificate">, certificate: X509Certificate) {
return new TestConnectionError(
"certificate",
{
requestError,
certificate,
},
{
cause: requestError,
},
);
}
public static CertificateResult(requestError: RequestError<"certificate">, certificate: X509Certificate) {
return this.Certificate(requestError, certificate).toResult();
}
private static Authorization(statusCode: number) {
return new TestConnectionError("authorization", {
statusCode,
reason: statusCode === 403 ? "forbidden" : "unauthorized",
});
}
public static UnauthorizedResult(statusCode: number) {
return this.Authorization(statusCode).toResult();
}
private static Status(input: { status: number; url: string }) {
if (input.status === 401 || input.status === 403) return this.Authorization(input.status);
// We don't want to leak the query parameters in the error message
const urlWithoutQuery = new URL(input.url);
urlWithoutQuery.search = "";
return new TestConnectionError("statusCode", {
statusCode: input.status,
reason: input.status in statusCodeMap ? statusCodeMap[input.status as keyof typeof statusCodeMap] : "other",
url: urlWithoutQuery.toString(),
});
}
public static StatusResult(input: { status: number; url: string }) {
return this.Status(input).toResult();
}
private static Request(requestError: Exclude<AnyRequestError, RequestError<"certificate">>) {
return new TestConnectionError(
"request",
{ requestError },
{
cause: requestError,
},
);
}
public static RequestResult(requestError: Exclude<AnyRequestError, RequestError<"certificate">>) {
return this.Request(requestError).toResult();
}
private static Parse(cause: ParseError) {
return new TestConnectionError("parse", undefined, { cause });
}
public static ParseResult(cause: ParseError) {
return this.Parse(cause).toResult();
}
static FromIntegrationError(error: IntegrationError): AnyTestConnectionError {
if (error instanceof IntegrationUnknownError) {
return this.Unknown(error.cause);
}
if (error instanceof IntegrationRequestError) {
if (error.cause.type === "certificate") {
return this.Unknown(new Error("FromIntegrationError can not be used for certificate errors", { cause: error }));
}
return this.Request(error.cause);
}
if (error instanceof IntegrationResponseError) {
return this.Status({
status: error.cause.statusCode,
url: error.cause.url ?? "?",
});
}
if (error instanceof IntegrationParseError) {
return this.Parse(error.cause);
}
return this.Unknown(new Error("FromIntegrationError received unknown IntegrationError", { cause: error }));
}
}
const statusCodeMap = {
400: "badRequest",
404: "notFound",
429: "tooManyRequests",
500: "internalServerError",
503: "serviceUnavailable",
504: "gatewayTimeout",
} as const;
interface TestConnectionErrorMap {
unknown: undefined;
parse: undefined;
authorization: {
statusCode: number;
reason: "unauthorized" | "forbidden";
};
statusCode: {
statusCode: number;
reason: (typeof statusCodeMap)[keyof typeof statusCodeMap] | "other";
url: string;
};
certificate: {
requestError: RequestError<"certificate">;
certificate: X509Certificate;
};
request: {
requestError: Exclude<AnyRequestError, RequestError<"certificate">>;
};
}

View File

@@ -0,0 +1,134 @@
import type { X509Certificate } from "node:crypto";
import tls from "node:tls";
import {
createCustomCheckServerIdentity,
getAllTrustedCertificatesAsync,
getTrustedCertificateHostnamesAsync,
} from "@homarr/certificates/server";
import { getPortFromUrl } from "@homarr/common";
import { logger } from "@homarr/log";
import type { IntegrationRequestErrorOfType } from "../errors/http/integration-request-error";
import { IntegrationRequestError } from "../errors/http/integration-request-error";
import { IntegrationError } from "../errors/integration-error";
import type { AnyTestConnectionError } from "./test-connection-error";
import { TestConnectionError } from "./test-connection-error";
const localLogger = logger.child({
module: "TestConnectionService",
});
export type TestingResult =
| {
success: true;
}
| {
success: false;
error: AnyTestConnectionError;
};
type AsyncTestingCallback = (input: {
ca: string[] | string;
checkServerIdentity: typeof tls.checkServerIdentity;
}) => Promise<TestingResult>;
export class TestConnectionService {
constructor(private url: URL) {}
public async handleAsync(testingCallbackAsync: AsyncTestingCallback) {
localLogger.debug("Testing connection", {
url: this.url.toString(),
});
const testingResult = await testingCallbackAsync({
ca: await getAllTrustedCertificatesAsync(),
checkServerIdentity: createCustomCheckServerIdentity(await getTrustedCertificateHostnamesAsync()),
})
.then((result) => {
if (result.success) return result;
const error = result.error;
if (error instanceof TestConnectionError) return error.toResult();
return TestConnectionError.UnknownResult(error);
})
.catch((error: unknown) => {
if (!(error instanceof IntegrationError)) {
return TestConnectionError.UnknownResult(error);
}
if (!(error instanceof IntegrationRequestError)) {
return TestConnectionError.FromIntegrationError(error).toResult();
}
if (error.cause.type !== "certificate") {
return TestConnectionError.FromIntegrationError(error).toResult();
}
return {
success: false,
error: error as IntegrationRequestErrorOfType<"certificate">,
} as const;
});
if (testingResult.success) {
localLogger.debug("Testing connection succeeded", {
url: this.url.toString(),
});
return testingResult;
}
localLogger.debug("Testing connection failed", {
url: this.url.toString(),
error: `${testingResult.error.name}: ${testingResult.error.message}`,
});
if (!(testingResult.error instanceof IntegrationRequestError)) {
return testingResult.error.toResult();
}
const certificate = await this.fetchCertificateAsync();
if (!certificate) {
return TestConnectionError.UnknownResult(new Error("Unable to fetch certificate"));
}
return TestConnectionError.CertificateResult(testingResult.error.cause, certificate);
}
private async fetchCertificateAsync(): Promise<X509Certificate | undefined> {
logger.debug("Fetching certificate", {
url: this.url.toString(),
});
const url = this.url;
const port = getPortFromUrl(url);
const socket = await new Promise<tls.TLSSocket>((resolve, reject) => {
try {
const innerSocket = tls.connect(
{
host: url.hostname,
servername: url.hostname,
port,
rejectUnauthorized: false,
},
() => {
resolve(innerSocket);
},
);
} catch (error) {
reject(new Error("Unable to fetch certificate", { cause: error }));
}
});
const x509 = socket.getPeerX509Certificate();
socket.destroy();
localLogger.debug("Fetched certificate", {
url: this.url.toString(),
subject: x509?.subject,
issuer: x509?.issuer,
});
return x509;
}
}

View File

@@ -1 +0,0 @@
export { convertIntegrationTestConnectionError } from "./base/test-connection-error";

View File

@@ -8,13 +8,22 @@ import { z } from "zod";
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { createChannelEventHistory } from "../../../redis/src/lib/channel";
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import { TestConnectionError } from "../base/test-connection/test-connection-error";
import type { TestingResult } from "../base/test-connection/test-connection-service";
import type { HealthMonitoring } from "../types";
export class DashDotIntegration extends Integration {
public async testConnectionAsync(): Promise<void> {
const response = await fetchWithTrustedCertificatesAsync(this.url("/info"));
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const response = await input.fetchAsync(this.url("/info"));
if (!response.ok) return TestConnectionError.StatusResult(response);
await response.json();
return {
success: true,
};
}
public async getSystemInfoAsync(): Promise<HealthMonitoring> {

View File

@@ -1,7 +1,11 @@
import path from "path";
import type { fetch as undiciFetch } from "undici";
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { ResponseError } from "@homarr/common/server";
import type { IntegrationTestingInput } from "../../base/integration";
import type { TestingResult } from "../../base/test-connection/test-connection-service";
import type { DownloadClientJobsAndStatus } from "../../interfaces/downloads/download-client-data";
import { DownloadClientIntegration } from "../../interfaces/downloads/download-client-integration";
import type { DownloadClientItem } from "../../interfaces/downloads/download-client-items";
@@ -91,12 +95,15 @@ export class Aria2Integration extends DownloadClientIntegration {
}
}
public async testConnectionAsync(): Promise<void> {
const client = this.getClient();
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const client = this.getClient(input.fetchAsync);
await client.getVersion();
return {
success: true,
};
}
private getClient() {
private getClient(fetchAsync: typeof undiciFetch = fetchWithTrustedCertificatesAsync) {
const url = this.url("/jsonrpc");
return new Proxy(
@@ -114,21 +121,24 @@ export class Aria2Integration extends DownloadClientIntegration {
method: `aria2.${method}`,
params,
});
return await fetchWithTrustedCertificatesAsync(url, { method: "POST", body })
return await fetchAsync(url, { method: "POST", body })
.then(async (response) => {
const responseBody = (await response.json()) as { result: ReturnType<Aria2GetClient[typeof method]> };
if (!response.ok) {
throw new Error(response.statusText);
throw new ResponseError(response);
}
return responseBody.result;
})
.catch((error) => {
if (error instanceof Error) {
throw new Error(error.message);
} else {
throw new Error("Error communicating with Aria2");
throw error;
}
throw new Error("Error communicating with Aria2", {
cause: error,
});
});
};
},

View File

@@ -1,17 +1,32 @@
import { Deluge } from "@ctrl/deluge";
import dayjs from "dayjs";
import type { Dispatcher } from "undici";
import { createCertificateAgentAsync } from "@homarr/certificates/server";
import { HandleIntegrationErrors } from "../../base/errors/decorator";
import { integrationOFetchHttpErrorHandler } from "../../base/errors/http";
import type { IntegrationTestingInput } from "../../base/integration";
import { TestConnectionError } from "../../base/test-connection/test-connection-error";
import type { TestingResult } from "../../base/test-connection/test-connection-service";
import type { DownloadClientJobsAndStatus } from "../../interfaces/downloads/download-client-data";
import { DownloadClientIntegration } from "../../interfaces/downloads/download-client-integration";
import type { DownloadClientItem } from "../../interfaces/downloads/download-client-items";
import type { DownloadClientStatus } from "../../interfaces/downloads/download-client-status";
@HandleIntegrationErrors([integrationOFetchHttpErrorHandler])
export class DelugeIntegration extends DownloadClientIntegration {
public async testConnectionAsync(): Promise<void> {
const client = await this.getClientAsync();
await client.login();
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const client = await this.getClientAsync(input.dispatcher);
const isSuccess = await client.login();
if (!isSuccess) {
return TestConnectionError.UnauthorizedResult(401);
}
return {
success: true,
};
}
public async getClientJobsAndStatusAsync(): Promise<DownloadClientJobsAndStatus> {
@@ -93,11 +108,11 @@ export class DelugeIntegration extends DownloadClientIntegration {
await client.removeTorrent(id, fromDisk);
}
private async getClientAsync() {
private async getClientAsync(dispatcher?: Dispatcher) {
return new Deluge({
baseUrl: this.url("/").toString(),
password: this.getSecretValue("password"),
dispatcher: await createCertificateAgentAsync(),
dispatcher: dispatcher ?? (await createCertificateAgentAsync()),
});
}

View File

@@ -1,7 +1,11 @@
import dayjs from "dayjs";
import type { fetch as undiciFetch } from "undici";
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { ResponseError } from "@homarr/common/server";
import type { IntegrationTestingInput } from "../../base/integration";
import type { TestingResult } from "../../base/test-connection/test-connection-service";
import type { DownloadClientJobsAndStatus } from "../../interfaces/downloads/download-client-data";
import { DownloadClientIntegration } from "../../interfaces/downloads/download-client-integration";
import type { DownloadClientItem } from "../../interfaces/downloads/download-client-items";
@@ -9,8 +13,11 @@ import type { DownloadClientStatus } from "../../interfaces/downloads/download-c
import type { NzbGetClient } from "./nzbget-types";
export class NzbGetIntegration extends DownloadClientIntegration {
public async testConnectionAsync(): Promise<void> {
await this.nzbGetApiCallAsync("version");
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
await this.nzbGetApiCallWithCustomFetchAsync(input.fetchAsync, "version");
return {
success: true,
};
}
public async getClientJobsAndStatusAsync(): Promise<DownloadClientJobsAndStatus> {
@@ -93,24 +100,34 @@ export class NzbGetIntegration extends DownloadClientIntegration {
private async nzbGetApiCallAsync<CallType extends keyof NzbGetClient>(
method: CallType,
...params: Parameters<NzbGetClient[CallType]>
): Promise<ReturnType<NzbGetClient[CallType]>> {
return await this.nzbGetApiCallWithCustomFetchAsync(fetchWithTrustedCertificatesAsync, method, ...params);
}
private async nzbGetApiCallWithCustomFetchAsync<CallType extends keyof NzbGetClient>(
fetchAsync: typeof undiciFetch,
method: CallType,
...params: Parameters<NzbGetClient[CallType]>
): Promise<ReturnType<NzbGetClient[CallType]>> {
const username = this.getSecretValue("username");
const password = this.getSecretValue("password");
const url = this.url(`/${encodeURIComponent(username)}:${encodeURIComponent(password)}/jsonrpc`);
const body = JSON.stringify({ method, params });
return await fetchWithTrustedCertificatesAsync(url, { method: "POST", body })
return await fetchAsync(url, { method: "POST", body })
.then(async (response) => {
if (!response.ok) {
throw new Error(response.statusText);
throw new ResponseError(response);
}
return ((await response.json()) as { result: ReturnType<NzbGetClient[CallType]> }).result;
})
.catch((error) => {
if (error instanceof Error) {
throw new Error(error.message);
} else {
throw new Error("Error communicating with NzbGet");
throw error;
}
throw new Error("Error communicating with NzbGet", {
cause: error,
});
});
}

View File

@@ -1,17 +1,29 @@
import { QBittorrent } from "@ctrl/qbittorrent";
import dayjs from "dayjs";
import type { Dispatcher } from "undici";
import { createCertificateAgentAsync } from "@homarr/certificates/server";
import { HandleIntegrationErrors } from "../../base/errors/decorator";
import { integrationOFetchHttpErrorHandler } from "../../base/errors/http";
import type { IntegrationTestingInput } from "../../base/integration";
import { TestConnectionError } from "../../base/test-connection/test-connection-error";
import type { TestingResult } from "../../base/test-connection/test-connection-service";
import type { DownloadClientJobsAndStatus } from "../../interfaces/downloads/download-client-data";
import { DownloadClientIntegration } from "../../interfaces/downloads/download-client-integration";
import type { DownloadClientItem } from "../../interfaces/downloads/download-client-items";
import type { DownloadClientStatus } from "../../interfaces/downloads/download-client-status";
@HandleIntegrationErrors([integrationOFetchHttpErrorHandler])
export class QBitTorrentIntegration extends DownloadClientIntegration {
public async testConnectionAsync(): Promise<void> {
const client = await this.getClientAsync();
await client.login();
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const client = await this.getClientAsync(input.dispatcher);
const isSuccess = await client.login();
if (!isSuccess) return TestConnectionError.UnauthorizedResult(401);
return {
success: true,
};
}
public async getClientJobsAndStatusAsync(): Promise<DownloadClientJobsAndStatus> {
@@ -76,12 +88,12 @@ export class QBitTorrentIntegration extends DownloadClientIntegration {
await client.removeTorrent(id, fromDisk);
}
private async getClientAsync() {
private async getClientAsync(dispatcher?: Dispatcher) {
return new QBittorrent({
baseUrl: this.url("/").toString(),
username: this.getSecretValue("username"),
password: this.getSecretValue("password"),
dispatcher: await createCertificateAgentAsync(),
dispatcher: dispatcher ?? (await createCertificateAgentAsync()),
});
}

View File

@@ -1,8 +1,12 @@
import dayjs from "dayjs";
import duration from "dayjs/plugin/duration";
import type { fetch as undiciFetch } from "undici";
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { ResponseError } from "@homarr/common/server";
import type { IntegrationTestingInput } from "../../base/integration";
import type { TestingResult } from "../../base/test-connection/test-connection-service";
import type { DownloadClientJobsAndStatus } from "../../interfaces/downloads/download-client-data";
import { DownloadClientIntegration } from "../../interfaces/downloads/download-client-integration";
import type { DownloadClientItem } from "../../interfaces/downloads/download-client-items";
@@ -12,9 +16,10 @@ import { historySchema, queueSchema } from "./sabnzbd-schema";
dayjs.extend(duration);
export class SabnzbdIntegration extends DownloadClientIntegration {
public async testConnectionAsync(): Promise<void> {
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
//This is the one call that uses the least amount of data while requiring the api key
await this.sabNzbApiCallAsync("translate", { value: "ping" });
await this.sabNzbApiCallWithCustomFetchAsync(input.fetchAsync, "translate", { value: "ping" });
return { success: true };
}
public async getClientJobsAndStatusAsync(): Promise<DownloadClientJobsAndStatus> {
@@ -101,6 +106,13 @@ export class SabnzbdIntegration extends DownloadClientIntegration {
}
private async sabNzbApiCallAsync(mode: string, searchParams?: Record<string, string>): Promise<unknown> {
return await this.sabNzbApiCallWithCustomFetchAsync(fetchWithTrustedCertificatesAsync, mode, searchParams);
}
private async sabNzbApiCallWithCustomFetchAsync(
fetchAsync: typeof undiciFetch,
mode: string,
searchParams?: Record<string, string>,
): Promise<unknown> {
const url = this.url("/api", {
...searchParams,
output: "json",
@@ -108,19 +120,21 @@ export class SabnzbdIntegration extends DownloadClientIntegration {
apikey: this.getSecretValue("apiKey"),
});
return await fetchWithTrustedCertificatesAsync(url)
return await fetchAsync(url)
.then((response) => {
if (!response.ok) {
throw new Error(response.statusText);
throw new ResponseError(response);
}
return response.json();
})
.catch((error) => {
if (error instanceof Error) {
throw new Error(error.message);
} else {
throw new Error("Error communicating with SABnzbd");
throw error;
}
throw new Error("Error communicating with SABnzbd", {
cause: error,
});
});
}

View File

@@ -1,17 +1,26 @@
import { Transmission } from "@ctrl/transmission";
import dayjs from "dayjs";
import type { Dispatcher } from "undici";
import { createCertificateAgentAsync } from "@homarr/certificates/server";
import { HandleIntegrationErrors } from "../../base/errors/decorator";
import { integrationOFetchHttpErrorHandler } from "../../base/errors/http";
import type { IntegrationTestingInput } from "../../base/integration";
import type { TestingResult } from "../../base/test-connection/test-connection-service";
import type { DownloadClientJobsAndStatus } from "../../interfaces/downloads/download-client-data";
import { DownloadClientIntegration } from "../../interfaces/downloads/download-client-integration";
import type { DownloadClientItem } from "../../interfaces/downloads/download-client-items";
import type { DownloadClientStatus } from "../../interfaces/downloads/download-client-status";
@HandleIntegrationErrors([integrationOFetchHttpErrorHandler])
export class TransmissionIntegration extends DownloadClientIntegration {
public async testConnectionAsync(): Promise<void> {
const client = await this.getClientAsync();
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const client = await this.getClientAsync(input.dispatcher);
await client.getSession();
return {
success: true,
};
}
public async getClientJobsAndStatusAsync(): Promise<DownloadClientJobsAndStatus> {
@@ -76,12 +85,12 @@ export class TransmissionIntegration extends DownloadClientIntegration {
await client.removeTorrent(id, fromDisk);
}
private async getClientAsync() {
private async getClientAsync(dispatcher?: Dispatcher) {
return new Transmission({
baseUrl: this.url("/").toString(),
username: this.getSecretValue("username"),
password: this.getSecretValue("password"),
dispatcher: await createCertificateAgentAsync(),
dispatcher: dispatcher ?? (await createCertificateAgentAsync()),
});
}

View File

@@ -3,7 +3,10 @@ import { z } from "zod";
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import { TestConnectionError } from "../base/test-connection/test-connection-error";
import type { TestingResult } from "../base/test-connection/test-connection-service";
import type { CurrentSessionsInput, StreamSession } from "../interfaces/media-server/session";
import { convertJellyfinType } from "../jellyfin/jellyfin-integration";
@@ -32,19 +35,22 @@ export class EmbyIntegration extends Integration {
private static readonly deviceId = "homarr-emby-integration";
private static readonly authorizationHeaderValue = `Emby Client="Dashboard", Device="Homarr", DeviceId="${EmbyIntegration.deviceId}", Version="0.0.1"`;
public async testConnectionAsync(): Promise<void> {
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const apiKey = super.getSecretValue("apiKey");
await super.handleTestConnectionResponseAsync({
queryFunctionAsync: async () => {
return await fetchWithTrustedCertificatesAsync(super.url("/emby/System/Ping"), {
headers: {
[EmbyIntegration.apiKeyHeader]: apiKey,
Authorization: EmbyIntegration.authorizationHeaderValue,
},
});
const response = await input.fetchAsync(super.url("/emby/System/Ping"), {
headers: {
[EmbyIntegration.apiKeyHeader]: apiKey,
Authorization: EmbyIntegration.authorizationHeaderValue,
},
});
if (!response.ok) {
return TestConnectionError.StatusResult(response);
}
return {
success: true,
};
}
public async getCurrentSessionsAsync(options: CurrentSessionsInput): Promise<StreamSession[]> {

View File

@@ -1,7 +1,10 @@
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { logger } from "@homarr/log";
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import { TestConnectionError } from "../base/test-connection/test-connection-error";
import type { TestingResult } from "../base/test-connection/test-connection-service";
import { entityStateSchema } from "./homeassistant-types";
export class HomeAssistantIntegration extends Integration {
@@ -57,12 +60,18 @@ export class HomeAssistantIntegration extends Integration {
}
}
public async testConnectionAsync(): Promise<void> {
await super.handleTestConnectionResponseAsync({
queryFunctionAsync: async () => {
return await this.getAsync("/api/config");
},
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const response = await input.fetchAsync(this.url("/api/config"), {
headers: this.getAuthHeaders(),
});
if (!response.ok) {
return TestConnectionError.StatusResult(response);
}
return {
success: true,
};
}
/**

View File

@@ -40,4 +40,3 @@ export { downloadClientItemSchema } from "./interfaces/downloads/download-client
// Helpers
export { createIntegrationAsync, createIntegrationAsyncFromSecrets } from "./base/creator";
export { IntegrationTestConnectionError } from "./base/test-connection-error";

View File

@@ -2,12 +2,18 @@ import { Jellyfin } from "@jellyfin/sdk";
import { BaseItemKind } from "@jellyfin/sdk/lib/generated-client/models";
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
import { getSystemApi } from "@jellyfin/sdk/lib/utils/api/system-api";
import type { AxiosInstance } from "axios";
import { createAxiosCertificateInstanceAsync } from "@homarr/certificates/server";
import { HandleIntegrationErrors } from "../base/errors/decorator";
import { integrationAxiosHttpErrorHandler } from "../base/errors/http";
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import type { TestingResult } from "../base/test-connection/test-connection-service";
import type { CurrentSessionsInput, StreamSession } from "../interfaces/media-server/session";
@HandleIntegrationErrors([integrationAxiosHttpErrorHandler])
export class JellyfinIntegration extends Integration {
private readonly jellyfin: Jellyfin = new Jellyfin({
clientInfo: {
@@ -20,10 +26,11 @@ export class JellyfinIntegration extends Integration {
},
});
public async testConnectionAsync(): Promise<void> {
const api = await this.getApiAsync();
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const api = await this.getApiAsync(input.axiosInstance);
const systemApi = getSystemApi(api);
await systemApi.getPingSystem();
return { success: true };
}
public async getCurrentSessionsAsync(options: CurrentSessionsInput): Promise<StreamSession[]> {
@@ -31,10 +38,6 @@ export class JellyfinIntegration extends Integration {
const sessionApi = getSessionApi(api);
const sessions = await sessionApi.getSessions();
if (sessions.status !== 200) {
throw new Error(`Jellyfin server ${this.url("/")} returned a non successful status code: ${sessions.status}`);
}
return sessions.data
.filter((sessionInfo) => sessionInfo.UserId !== undefined)
.filter((sessionInfo) => sessionInfo.DeviceId !== "homarr")
@@ -71,14 +74,14 @@ export class JellyfinIntegration extends Integration {
* with a username and password.
* @returns An instance of Api that has been authenticated
*/
private async getApiAsync() {
const httpsAgent = await createAxiosCertificateInstanceAsync();
private async getApiAsync(fallbackInstance?: AxiosInstance) {
const axiosInstance = fallbackInstance ?? (await createAxiosCertificateInstanceAsync());
if (this.hasSecretValue("apiKey")) {
const apiKey = this.getSecretValue("apiKey");
return this.jellyfin.createApi(this.url("/").toString(), apiKey, httpsAgent);
return this.jellyfin.createApi(this.url("/").toString(), apiKey, axiosInstance);
}
const apiClient = this.jellyfin.createApi(this.url("/").toString(), undefined, httpsAgent);
const apiClient = this.jellyfin.createApi(this.url("/").toString(), undefined, axiosInstance);
// Authentication state is stored internally in the Api class, so now
// requests that require authentication can be made normally.
// see https://typescript-sdk.jellyfin.org/#usage

View File

@@ -3,18 +3,22 @@ import { z } from "zod";
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { logger } from "@homarr/log";
import type { IntegrationTestingInput } from "../../base/integration";
import { TestConnectionError } from "../../base/test-connection/test-connection-error";
import type { TestingResult } from "../../base/test-connection/test-connection-service";
import type { CalendarEvent } from "../../calendar-types";
import { MediaOrganizerIntegration } from "../media-organizer-integration";
export class LidarrIntegration extends MediaOrganizerIntegration {
public async testConnectionAsync(): Promise<void> {
await super.handleTestConnectionResponseAsync({
queryFunctionAsync: async () => {
return await fetchWithTrustedCertificatesAsync(this.url("/api"), {
headers: { "X-Api-Key": super.getSecretValue("apiKey") },
});
},
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const response = await input.fetchAsync(this.url("/api"), {
headers: { "X-Api-Key": super.getSecretValue("apiKey") },
});
if (!response.ok) return TestConnectionError.StatusResult(response);
await response.json();
return { success: true };
}
/**

View File

@@ -4,6 +4,9 @@ import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import type { AtLeastOneOf } from "@homarr/common/types";
import { logger } from "@homarr/log";
import type { IntegrationTestingInput } from "../../base/integration";
import { TestConnectionError } from "../../base/test-connection/test-connection-error";
import type { TestingResult } from "../../base/test-connection/test-connection-service";
import type { CalendarEvent } from "../../calendar-types";
import { radarrReleaseTypes } from "../../calendar-types";
import { MediaOrganizerIntegration } from "../media-organizer-integration";
@@ -93,14 +96,15 @@ export class RadarrIntegration extends MediaOrganizerIntegration {
return bestImage.remoteUrl;
};
public async testConnectionAsync(): Promise<void> {
await super.handleTestConnectionResponseAsync({
queryFunctionAsync: async () => {
return await fetchWithTrustedCertificatesAsync(this.url("/api"), {
headers: { "X-Api-Key": super.getSecretValue("apiKey") },
});
},
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const response = await input.fetchAsync(this.url("/api"), {
headers: { "X-Api-Key": super.getSecretValue("apiKey") },
});
if (!response.ok) return TestConnectionError.StatusResult(response);
await response.json();
return { success: true };
}
}

View File

@@ -3,18 +3,22 @@ import { z } from "zod";
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { logger } from "@homarr/log";
import type { IntegrationTestingInput } from "../../base/integration";
import { TestConnectionError } from "../../base/test-connection/test-connection-error";
import type { TestingResult } from "../../base/test-connection/test-connection-service";
import type { CalendarEvent } from "../../calendar-types";
import { MediaOrganizerIntegration } from "../media-organizer-integration";
export class ReadarrIntegration extends MediaOrganizerIntegration {
public async testConnectionAsync(): Promise<void> {
await super.handleTestConnectionResponseAsync({
queryFunctionAsync: async () => {
return await fetchWithTrustedCertificatesAsync(this.url("/api"), {
headers: { "X-Api-Key": super.getSecretValue("apiKey") },
});
},
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const response = await input.fetchAsync(this.url("/api"), {
headers: { "X-Api-Key": super.getSecretValue("apiKey") },
});
if (!response.ok) return TestConnectionError.StatusResult(response);
await response.json();
return { success: true };
}
/**

View File

@@ -3,6 +3,9 @@ import { z } from "zod";
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { logger } from "@homarr/log";
import type { IntegrationTestingInput } from "../../base/integration";
import { TestConnectionError } from "../../base/test-connection/test-connection-error";
import type { TestingResult } from "../../base/test-connection/test-connection-service";
import type { CalendarEvent } from "../../calendar-types";
import { MediaOrganizerIntegration } from "../media-organizer-integration";
@@ -92,14 +95,15 @@ export class SonarrIntegration extends MediaOrganizerIntegration {
return bestImage.remoteUrl;
};
public async testConnectionAsync(): Promise<void> {
await super.handleTestConnectionResponseAsync({
queryFunctionAsync: async () => {
return await fetchWithTrustedCertificatesAsync(this.url("/api"), {
headers: { "X-Api-Key": super.getSecretValue("apiKey") },
});
},
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const response = await input.fetchAsync(this.url("/api"), {
headers: { "X-Api-Key": super.getSecretValue("apiKey") },
});
if (!response.ok) return TestConnectionError.StatusResult(response);
await response.json();
return { success: true };
}
}

View File

@@ -1,24 +1,28 @@
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import { TestConnectionError } from "../base/test-connection/test-connection-error";
import type { TestingResult } from "../base/test-connection/test-connection-service";
import type { TdarrQueue } from "../interfaces/media-transcoding/queue";
import type { TdarrStatistics } from "../interfaces/media-transcoding/statistics";
import type { TdarrWorker } from "../interfaces/media-transcoding/workers";
import { getNodesResponseSchema, getStatisticsSchema, getStatusTableSchema } from "./tdarr-validation-schemas";
export class TdarrIntegration extends Integration {
public async testConnectionAsync(): Promise<void> {
await super.handleTestConnectionResponseAsync({
queryFunctionAsync: async () => {
return await fetchWithTrustedCertificatesAsync(this.url("/api/v2/is-server-alive"), {
method: "POST",
headers: {
accept: "application/json",
"X-Api-Key": super.hasSecretValue("apiKey") ? super.getSecretValue("apiKey") : "",
},
});
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const response = await input.fetchAsync(this.url("/api/v2/is-server-alive"), {
method: "POST",
headers: {
accept: "application/json",
"X-Api-Key": super.hasSecretValue("apiKey") ? super.getSecretValue("apiKey") : "",
},
});
if (!response.ok) return TestConnectionError.StatusResult(response);
await response.json();
return { success: true };
}
public async getStatisticsAsync(): Promise<TdarrStatistics> {

View File

@@ -3,21 +3,28 @@ import objectSupport from "dayjs/plugin/objectSupport";
import utc from "dayjs/plugin/utc";
import * as ical from "node-ical";
import { DAVClient } from "tsdav";
import type { RequestInit as UndiciFetchRequestInit } from "undici";
import type { Dispatcher, RequestInit as UndiciFetchRequestInit } from "undici";
import { createCertificateAgentAsync } from "@homarr/certificates/server";
import { logger } from "@homarr/log";
import { HandleIntegrationErrors } from "../base/errors/decorator";
import { integrationTsdavHttpErrorHandler } from "../base/errors/http";
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import type { TestingResult } from "../base/test-connection/test-connection-service";
import type { CalendarEvent } from "../calendar-types";
dayjs.extend(utc);
dayjs.extend(objectSupport);
@HandleIntegrationErrors([integrationTsdavHttpErrorHandler])
export class NextcloudIntegration extends Integration {
public async testConnectionAsync(): Promise<void> {
const client = await this.createCalendarClientAsync();
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const client = await this.createCalendarClientAsync(input.dispatcher);
await client.login();
return { success: true };
}
public async getCalendarEventsAsync(start: Date, end: Date): Promise<CalendarEvent[]> {
@@ -85,7 +92,7 @@ export class NextcloudIntegration extends Integration {
});
}
private async createCalendarClientAsync() {
private async createCalendarClientAsync(dispatcher?: Dispatcher) {
return new DAVClient({
serverUrl: this.integration.url,
credentials: {
@@ -96,7 +103,7 @@ export class NextcloudIntegration extends Integration {
defaultAccountType: "caldav",
fetchOptions: {
// We can use the undici options as the global fetch is used instead of the polyfilled.
dispatcher: await createCertificateAgentAsync(),
dispatcher: dispatcher ?? (await createCertificateAgentAsync()),
} satisfies UndiciFetchRequestInit as RequestInit,
});
}

View File

@@ -1,14 +1,14 @@
import type { Headers, HeadersInit, Response as UndiciResponse } from "undici";
import type { Headers, HeadersInit, fetch as undiciFetch, Response as UndiciResponse } from "undici";
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { ResponseError } from "@homarr/common/server";
import { logger } from "@homarr/log";
import { ResponseError } from "../base/error";
import type { IntegrationInput } from "../base/integration";
import type { IntegrationInput, IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import type { SessionStore } from "../base/session-store";
import { createSessionStore } from "../base/session-store";
import { IntegrationTestConnectionError } from "../base/test-connection-error";
import type { TestingResult } from "../base/test-connection/test-connection-service";
import type { HealthMonitoring } from "../types";
import { cpuTempSchema, fileSystemSchema, smartSchema, systemInformationSchema } from "./openmediavault-types";
@@ -84,12 +84,9 @@ export class OpenMediaVaultIntegration extends Integration {
};
}
public async testConnectionAsync(): Promise<void> {
await this.getSessionAsync().catch((error) => {
if (error instanceof ResponseError) {
throw new IntegrationTestConnectionError("invalidCredentials");
}
});
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
await this.getSessionAsync(input.fetchAsync);
return { success: true };
}
private async makeAuthenticatedRpcCallAsync(
@@ -111,13 +108,14 @@ export class OpenMediaVaultIntegration extends Integration {
});
}
private async makeRpcCallAsync(
private async makeRpcCallWithCustomFetchAsync(
serviceName: string,
method: string,
params: Record<string, unknown> = {},
headers: HeadersInit = {},
fetchAsync: typeof undiciFetch = fetchWithTrustedCertificatesAsync,
): Promise<UndiciResponse> {
return await fetchWithTrustedCertificatesAsync(this.url("/rpc.php"), {
return await fetchAsync(this.url("/rpc.php"), {
method: "POST",
headers: {
"Content-Type": "application/json",
@@ -132,6 +130,15 @@ export class OpenMediaVaultIntegration extends Integration {
});
}
private async makeRpcCallAsync(
serviceName: string,
method: string,
params: Record<string, unknown> = {},
headers: HeadersInit = {},
): Promise<UndiciResponse> {
return await this.makeRpcCallWithCustomFetchAsync(serviceName, method, params, headers);
}
/**
* Run the callback with the current session id
* @param callback
@@ -159,11 +166,21 @@ export class OpenMediaVaultIntegration extends Integration {
* Get a session id from the openmediavault server
* @returns The session details
*/
private async getSessionAsync(): Promise<SessionStoreValue> {
const response = await this.makeRpcCallAsync("session", "login", {
username: this.getSecretValue("username"),
password: this.getSecretValue("password"),
});
private async getSessionAsync(fetchAsync?: typeof undiciFetch): Promise<SessionStoreValue> {
const response = await this.makeRpcCallWithCustomFetchAsync(
"session",
"login",
{
username: this.getSecretValue("username"),
password: this.getSecretValue("password"),
},
undefined,
fetchAsync,
);
if (!response.ok) {
throw new ResponseError(response);
}
const data = (await response.json()) as { response?: { sessionid?: string } };
if (data.response?.sessionid) {
@@ -176,10 +193,10 @@ export class OpenMediaVaultIntegration extends Integration {
const loginToken = OpenMediaVaultIntegration.extractLoginTokenFromCookies(response.headers);
if (!sessionId || !loginToken) {
throw new ResponseError(
response,
`${JSON.stringify(data)} - sessionId=${"*".repeat(sessionId?.length ?? 0)} loginToken=${"*".repeat(loginToken?.length ?? 0)}`,
);
throw new ResponseError({
status: 401,
url: response.url,
});
}
return {

View File

@@ -3,8 +3,11 @@ import { z } from "zod";
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { logger } from "@homarr/log";
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import type { ISearchableIntegration } from "../base/searchable-integration";
import { TestConnectionError } from "../base/test-connection/test-connection-error";
import type { TestingResult } from "../base/test-connection/test-connection-service";
import type { MediaRequest, RequestStats, RequestUser } from "../interfaces/media-requests/media-request";
import { MediaAvailability, MediaRequestStatus } from "../interfaces/media-requests/media-request";
@@ -81,18 +84,18 @@ export class OverseerrIntegration extends Integration implements ISearchableInte
}
}
public async testConnectionAsync(): Promise<void> {
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/v1/auth/me"), {
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const response = await input.fetchAsync(this.url("/api/v1/auth/me"), {
headers: {
"X-Api-Key": this.getSecretValue("apiKey"),
},
});
const json = (await response.json()) as object;
if (Object.keys(json).includes("id")) {
return;
}
throw new Error(`Received response but unable to parse it: ${JSON.stringify(json)}`);
if (!response.ok) return TestConnectionError.StatusResult(response);
const responseSchema = z.object({ id: z.number() });
await responseSchema.parseAsync(await response.json());
return { success: true };
}
public async getRequestsAsync(): Promise<MediaRequest[]> {

View File

@@ -6,18 +6,25 @@ import { PiHoleIntegrationV5 } from "./v5/pi-hole-integration-v5";
import { PiHoleIntegrationV6 } from "./v6/pi-hole-integration-v6";
export const createPiHoleIntegrationAsync = async (input: IntegrationInput) => {
const baseUrl = removeTrailingSlash(input.url);
const url = new URL(`${baseUrl}/api/info/version`);
const response = await fetchWithTrustedCertificatesAsync(url);
try {
const baseUrl = removeTrailingSlash(input.url);
const url = new URL(`${baseUrl}/api/info/version`);
const response = await fetchWithTrustedCertificatesAsync(url);
/**
* In pi-hole 5 the api was at /admin/api.php, in pi-hole 6 it was moved to /api
* For the /api/info/version endpoint, the response is 404 in pi-hole 5
* and 401 in pi-hole 6
*/
if (response.status === 404) {
return new PiHoleIntegrationV5(input);
/**
* In pi-hole 5 the api was at /admin/api.php, in pi-hole 6 it was moved to /api
* For the /api/info/version endpoint, the response is 404 in pi-hole 5
* and 401 in pi-hole 6
*/
if (response.status === 404) {
return new PiHoleIntegrationV5(input);
}
return new PiHoleIntegrationV6(input);
} catch {
// We fall back to v6 if we can't reach the endpoint
// This is the case if the integration is not reachable
// the error will then be handled in the integration
return new PiHoleIntegrationV6(input);
}
return new PiHoleIntegrationV6(input);
};

View File

@@ -1,7 +1,10 @@
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { ResponseError } from "@homarr/common/server";
import type { IntegrationTestingInput } from "../../base/integration";
import { Integration } from "../../base/integration";
import { IntegrationTestConnectionError } from "../../base/test-connection-error";
import { TestConnectionError } from "../../base/test-connection/test-connection-error";
import type { TestingResult } from "../../base/test-connection/test-connection-service";
import type { DnsHoleSummaryIntegration } from "../../interfaces/dns-hole-summary/dns-hole-summary-integration";
import type { DnsHoleSummary } from "../../interfaces/dns-hole-summary/dns-hole-summary-types";
import { summaryResponseSchema } from "./pi-hole-schemas-v5";
@@ -11,46 +14,35 @@ export class PiHoleIntegrationV5 extends Integration implements DnsHoleSummaryIn
const apiKey = super.getSecretValue("apiKey");
const response = await fetchWithTrustedCertificatesAsync(this.url("/admin/api.php?summaryRaw", { auth: apiKey }));
if (!response.ok) {
throw new Error(
`Failed to fetch summary for ${this.integration.name} (${this.integration.id}): ${response.statusText}`,
);
throw new ResponseError(response);
}
const result = summaryResponseSchema.safeParse(await response.json());
if (!result.success) {
throw new Error(
`Failed to parse summary for ${this.integration.name} (${this.integration.id}), most likely your api key is wrong: ${result.error.message}`,
);
}
const data = await summaryResponseSchema.parseAsync(await response.json());
return {
status: result.data.status,
adsBlockedToday: result.data.ads_blocked_today,
adsBlockedTodayPercentage: result.data.ads_percentage_today,
domainsBeingBlocked: result.data.domains_being_blocked,
dnsQueriesToday: result.data.dns_queries_today,
status: data.status,
adsBlockedToday: data.ads_blocked_today,
adsBlockedTodayPercentage: data.ads_percentage_today,
domainsBeingBlocked: data.domains_being_blocked,
dnsQueriesToday: data.dns_queries_today,
};
}
public async testConnectionAsync(): Promise<void> {
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const apiKey = super.getSecretValue("apiKey");
await super.handleTestConnectionResponseAsync({
queryFunctionAsync: async () => {
return await fetchWithTrustedCertificatesAsync(this.url("/admin/api.php?status", { auth: apiKey }));
},
handleResponseAsync: async (response) => {
try {
const result = await response.json();
if (typeof result === "object" && result !== null && "status" in result) return;
} catch {
throw new IntegrationTestConnectionError("invalidJson");
}
const response = await input.fetchAsync(this.url("/admin/api.php?status", { auth: apiKey }));
throw new IntegrationTestConnectionError("invalidCredentials");
},
});
if (!response.ok) return TestConnectionError.StatusResult(response);
const data = await response.json();
// Pi-hole v5 returned an empty array if the API key is wrong
if (typeof data !== "object" || Array.isArray(data)) {
return TestConnectionError.UnauthorizedResult(401);
}
return { success: true };
}
public async enableAsync(): Promise<void> {

View File

@@ -1,16 +1,15 @@
import type { Response as UndiciResponse } from "undici";
import type { fetch as undiciFetch, Response as UndiciResponse } from "undici";
import type { z } from "zod";
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { extractErrorMessage } from "@homarr/common";
import { ResponseError } from "@homarr/common/server";
import { logger } from "@homarr/log";
import { IntegrationResponseError, ParseError, ResponseError } from "../../base/error";
import type { IntegrationInput } from "../../base/integration";
import type { IntegrationInput, IntegrationTestingInput } from "../../base/integration";
import { Integration } from "../../base/integration";
import type { SessionStore } from "../../base/session-store";
import { createSessionStore } from "../../base/session-store";
import { IntegrationTestConnectionError } from "../../base/test-connection-error";
import type { TestingResult } from "../../base/test-connection/test-connection-service";
import type { DnsHoleSummaryIntegration } from "../../interfaces/dns-hole-summary/dns-hole-summary-integration";
import type { DnsHoleSummary } from "../../types";
import { dnsBlockingGetSchema, sessionResponseSchema, statsSummaryGetSchema } from "./pi-hole-schemas-v6";
@@ -35,21 +34,17 @@ export class PiHoleIntegrationV6 extends Integration implements DnsHoleSummaryIn
});
if (!response.ok) {
throw new IntegrationResponseError(this.integration, response, await response.json());
throw new ResponseError(response);
}
const result = dnsBlockingGetSchema.safeParse(await response.json());
const result = await dnsBlockingGetSchema.parseAsync(await response.json());
if (!result.success) {
throw new ParseError("DNS blocking status", result.error, await response.json());
}
return result.data;
return result;
}
private async getStatsSummaryAsync(): Promise<z.infer<typeof statsSummaryGetSchema>> {
const response = await this.withAuthAsync(async (sessionId) => {
return await fetchWithTrustedCertificatesAsync(this.url("/api/stats/summary"), {
return fetchWithTrustedCertificatesAsync(this.url("/api/stats/summary"), {
headers: {
sid: sessionId,
},
@@ -57,17 +52,13 @@ export class PiHoleIntegrationV6 extends Integration implements DnsHoleSummaryIn
});
if (!response.ok) {
throw new IntegrationResponseError(this.integration, response, await response.json());
throw new ResponseError(response);
}
const data = await response.json();
const result = statsSummaryGetSchema.safeParse(data);
const result = await statsSummaryGetSchema.parseAsync(data);
if (!result.success) {
throw new ParseError("stats summary", result.error, data);
}
return result.data;
return result;
}
public async getSummaryAsync(): Promise<DnsHoleSummary> {
@@ -83,21 +74,10 @@ export class PiHoleIntegrationV6 extends Integration implements DnsHoleSummaryIn
};
}
public async testConnectionAsync(): Promise<void> {
try {
const sessionId = await this.getSessionAsync();
await this.clearSessionAsync(sessionId);
} catch (error: unknown) {
if (error instanceof ParseError) {
throw new IntegrationTestConnectionError("invalidJson");
}
if (error instanceof ResponseError && error.statusCode === 401) {
throw new IntegrationTestConnectionError("invalidCredentials");
}
throw new IntegrationTestConnectionError("commonError", extractErrorMessage(error));
}
protected async testingAsync({ fetchAsync }: IntegrationTestingInput): Promise<TestingResult> {
const sessionId = await this.getSessionAsync(fetchAsync);
await this.clearSessionAsync(sessionId, fetchAsync);
return { success: true };
}
public async enableAsync(): Promise<void> {
@@ -112,7 +92,7 @@ export class PiHoleIntegrationV6 extends Integration implements DnsHoleSummaryIn
});
if (!response.ok) {
throw new IntegrationResponseError(this.integration, response, await response.json());
throw new ResponseError(response);
}
}
@@ -128,7 +108,7 @@ export class PiHoleIntegrationV6 extends Integration implements DnsHoleSummaryIn
});
if (!response.ok) {
throw new IntegrationResponseError(this.integration, response, await response.json());
throw new ResponseError(response);
}
}
@@ -160,35 +140,39 @@ export class PiHoleIntegrationV6 extends Integration implements DnsHoleSummaryIn
* Get a session id from the Pi-hole server
* @returns The session id
*/
private async getSessionAsync(): Promise<string> {
private async getSessionAsync(fetchAsync: typeof undiciFetch = fetchWithTrustedCertificatesAsync): Promise<string> {
const apiKey = super.getSecretValue("apiKey");
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/auth"), {
const response = await fetchAsync(this.url("/api/auth"), {
method: "POST",
body: JSON.stringify({ password: apiKey }),
headers: {
"User-Agent": "Homarr",
},
});
if (!response.ok) throw new ResponseError(response);
const data = await response.json();
const result = sessionResponseSchema.safeParse(data);
if (!result.success) {
throw new ParseError("session response", result.error, data);
}
if (!result.data.session.sid) {
throw new IntegrationResponseError(this.integration, response, data);
const result = await sessionResponseSchema.parseAsync(data);
if (!result.session.sid) {
throw new ResponseError({ status: 401, url: response.url });
}
localLogger.info("Received session id successfully", { integrationId: this.integration.id });
return result.data.session.sid;
return result.session.sid;
}
/**
* Remove the session from the Pi-hole server
* @param sessionId The session id to remove
*/
private async clearSessionAsync(sessionId: string) {
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/auth"), {
private async clearSessionAsync(
sessionId: string,
fetchAsync: typeof undiciFetch = fetchWithTrustedCertificatesAsync,
) {
const response = await fetchAsync(this.url("/api/auth"), {
method: "DELETE",
headers: {
sid: sessionId,

View File

@@ -1,10 +1,13 @@
import { parseStringPromise } from "xml2js";
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { ParseError } from "@homarr/common/server";
import { logger } from "@homarr/log";
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import { IntegrationTestConnectionError } from "../base/test-connection-error";
import { TestConnectionError } from "../base/test-connection/test-connection-error";
import type { TestingResult } from "../base/test-connection/test-connection-service";
import type { CurrentSessionsInput, StreamSession } from "../interfaces/media-server/session";
import type { PlexResponse } from "./interface";
@@ -19,7 +22,7 @@ export class PlexIntegration extends Integration {
});
const body = await response.text();
// convert xml response to objects, as there is no JSON api
const data = await PlexIntegration.parseXml<PlexResponse>(body);
const data = await PlexIntegration.parseXmlAsync<PlexResponse>(body);
const mediaContainer = data.MediaContainer;
const mediaElements = [mediaContainer.Video ?? [], mediaContainer.Track ?? []].flat();
@@ -62,31 +65,36 @@ export class PlexIntegration extends Integration {
return medias;
}
public async testConnectionAsync(): Promise<void> {
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const token = super.getSecretValue("apiKey");
await super.handleTestConnectionResponseAsync({
queryFunctionAsync: async () => {
return await fetchWithTrustedCertificatesAsync(this.url("/"), {
headers: {
"X-Plex-Token": token,
},
});
},
handleResponseAsync: async (response) => {
try {
const result = await response.text();
await PlexIntegration.parseXml<PlexResponse>(result);
return;
} catch {
throw new IntegrationTestConnectionError("invalidCredentials");
}
const response = await input.fetchAsync(this.url("/"), {
headers: {
"X-Plex-Token": token,
},
});
if (!response.ok) return TestConnectionError.StatusResult(response);
const result = await response.text();
await PlexIntegration.parseXmlAsync<PlexResponse>(result);
return { success: true };
}
static parseXml<T>(xml: string): Promise<T> {
return parseStringPromise(xml) as Promise<T>;
static async parseXmlAsync<T>(xml: string): Promise<T> {
try {
return (await parseStringPromise(xml)) as Promise<T>;
} catch (error) {
throw new ParseError(
"Invalid xml format",
error instanceof Error
? {
cause: error,
}
: undefined,
);
}
}
static getCurrentlyPlayingType(type: string): NonNullable<StreamSession["currentlyPlaying"]>["type"] {

View File

@@ -1,7 +1,11 @@
import { z } from "zod";
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import { IntegrationTestConnectionError } from "../base/test-connection-error";
import { TestConnectionError } from "../base/test-connection/test-connection-error";
import type { TestingResult } from "../base/test-connection/test-connection-service";
import type { Indexer } from "../interfaces/indexer-manager/indexer";
import { indexerResponseSchema, statusResponseSchema } from "./prowlarr-types";
@@ -75,27 +79,19 @@ export class ProwlarrIntegration extends Integration {
}
}
public async testConnectionAsync(): Promise<void> {
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const apiKey = super.getSecretValue("apiKey");
await super.handleTestConnectionResponseAsync({
queryFunctionAsync: async () => {
return await fetchWithTrustedCertificatesAsync(this.url("/api"), {
headers: {
"X-Api-Key": apiKey,
},
});
},
handleResponseAsync: async (response) => {
try {
const result = await response.json();
if (typeof result === "object" && result !== null) return;
} catch {
throw new IntegrationTestConnectionError("invalidJson");
}
throw new IntegrationTestConnectionError("invalidCredentials");
const response = await input.fetchAsync(this.url("/api"), {
headers: {
"X-Api-Key": apiKey,
},
});
if (!response.ok) return TestConnectionError.StatusResult(response);
const responseSchema = z.object({});
await responseSchema.parseAsync(await response.json());
return { success: true };
}
}

View File

@@ -0,0 +1,30 @@
import { ResponseError } from "@homarr/common/server";
import type { IIntegrationErrorHandler } from "../base/errors/handler";
import { integrationFetchHttpErrorHandler } from "../base/errors/http";
import { IntegrationResponseError } from "../base/errors/http/integration-response-error";
import type { IntegrationError, IntegrationErrorData } from "../base/errors/integration-error";
export class ProxmoxApiErrorHandler implements IIntegrationErrorHandler {
handleError(error: unknown, integration: IntegrationErrorData): IntegrationError | undefined {
if (!(error instanceof Error)) return undefined;
if (error.cause && error.cause instanceof TypeError) {
return integrationFetchHttpErrorHandler.handleError(error.cause, integration);
}
if (error.message.includes(" return Error 400 "))
return new IntegrationResponseError(integration, { cause: new ResponseError({ status: 400 }, { cause: error }) });
if (error.message.includes(" return Error 500 "))
return new IntegrationResponseError(integration, { cause: new ResponseError({ status: 500 }, { cause: error }) });
if (error.message.includes(" return Error 401 "))
return new IntegrationResponseError(integration, { cause: new ResponseError({ status: 401 }, { cause: error }) });
const otherStatusCode = /connection failed with (\d{3})/.exec(error.message)?.at(1);
if (!otherStatusCode) return undefined;
const statusCode = parseInt(otherStatusCode, 10);
return new IntegrationResponseError(integration, {
cause: new ResponseError({ status: statusCode }, { cause: error }),
});
}
}

View File

@@ -2,11 +2,13 @@ import type { Proxmox } from "proxmox-api";
import proxmoxApi from "proxmox-api";
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { extractErrorMessage } from "@homarr/common";
import { logger } from "@homarr/log";
import { HandleIntegrationErrors } from "../base/errors/decorator";
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import { IntegrationTestConnectionError } from "../base/test-connection-error";
import type { TestingResult } from "../base/test-connection/test-connection-service";
import { ProxmoxApiErrorHandler } from "./proxmox-error-handler";
import type {
ComputeResourceBase,
LxcResource,
@@ -16,12 +18,12 @@ import type {
StorageResource,
} from "./proxmox-types";
@HandleIntegrationErrors([new ProxmoxApiErrorHandler()])
export class ProxmoxIntegration extends Integration {
public async testConnectionAsync(): Promise<void> {
const proxmox = this.getPromoxApi();
await proxmox.nodes.$get().catch((error) => {
throw new IntegrationTestConnectionError("internalServerError", extractErrorMessage(error));
});
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const proxmox = this.getPromoxApi(input.fetchAsync);
await proxmox.nodes.$get();
return { success: true };
}
public async getClusterInfoAsync() {
@@ -41,12 +43,12 @@ export class ProxmoxIntegration extends Integration {
};
}
private getPromoxApi() {
private getPromoxApi(fetchAsync = fetchWithTrustedCertificatesAsync) {
return proxmoxApi({
host: this.url("/").host,
tokenID: `${this.getSecretValue("username")}@${this.getSecretValue("realm")}!${this.getSecretValue("tokenId")}`,
tokenSecret: this.getSecretValue("apiKey"),
fetch: fetchWithTrustedCertificatesAsync,
fetch: fetchAsync,
});
}
}

View File

@@ -0,0 +1,89 @@
import proxmoxApi from "proxmox-api";
import type { fetch as undiciFetch } from "undici";
import { Response } from "undici";
import { describe, expect, test } from "vitest";
import { IntegrationRequestError } from "../../base/errors/http/integration-request-error";
import { IntegrationResponseError } from "../../base/errors/http/integration-response-error";
import { ProxmoxApiErrorHandler } from "../proxmox-error-handler";
describe("ProxmoxApiErrorHandler handleError should handle the provided error accordingly", () => {
test.each([400, 401, 500])("should handle %s error", async (statusCode) => {
// Arrange
// eslint-disable-next-line no-restricted-syntax
const mockedFetch: typeof undiciFetch = async () => {
return Promise.resolve(createFakeResponse(statusCode));
};
// Act
const result = await runWithAsync(mockedFetch);
// Assert
expect(result).toBeInstanceOf(IntegrationResponseError);
const error = result as unknown as IntegrationResponseError;
expect(error.cause.statusCode).toBe(statusCode);
});
test("should handle other non successful status codes", async () => {
// Arrange
// eslint-disable-next-line no-restricted-syntax
const mockedFetch: typeof undiciFetch = async () => {
return Promise.resolve(createFakeResponse(404));
};
// Act
const result = await runWithAsync(mockedFetch);
// Assert
expect(result).toBeInstanceOf(IntegrationResponseError);
const error = result as unknown as IntegrationResponseError;
expect(error.cause.statusCode).toBe(404);
});
test("should handle request error", async () => {
// Arrange
const mockedFetch: typeof undiciFetch = () => {
const errorWithCode = new Error("Inner error") as Error & { code: string };
errorWithCode.code = "ECONNREFUSED";
throw new TypeError("Outer error", { cause: errorWithCode });
};
// Act
const result = await runWithAsync(mockedFetch);
// Assert
// In the end in should have the structure IntegrationRequestError -> RequestError -> TypeError -> Error (with code)
expect(result).toBeInstanceOf(IntegrationRequestError);
const error = result as unknown as IntegrationRequestError;
expect(error.cause.cause).toBeInstanceOf(TypeError);
expect(error.cause.cause?.message).toBe("Outer error");
expect(error.cause.cause?.cause).toBeInstanceOf(Error);
const cause = error.cause.cause?.cause as Error & { code: string };
expect(cause.message).toBe("Inner error");
expect(cause.code).toBe("ECONNREFUSED");
});
});
const createFakeResponse = (statusCode: number) => {
return new Response(JSON.stringify({ data: {} }), {
status: statusCode,
// It expects a content-type and valid json response
// https://github.com/UrielCh/proxmox-api/blob/master/api/src/ProxmoxEngine.ts#L258
headers: { "content-type": "application/json;charset=UTF-8" },
});
};
const runWithAsync = async (mockedFetch: typeof undiciFetch) => {
const integration = { id: "test", name: "test", url: "http://proxmox.example.com" };
const client = createProxmoxClient(mockedFetch);
const handler = new ProxmoxApiErrorHandler();
return await client.nodes.$get().catch((error) => handler.handleError(error, integration));
};
const createProxmoxClient = (fetch: typeof undiciFetch) => {
return proxmoxApi({
host: "proxmox.example.com",
tokenID: "username@realm!tokenId",
tokenSecret: crypto.randomUUID(),
fetch,
});
};

View File

@@ -1,13 +1,26 @@
import type { SiteStats } from "node-unifi";
import { Controller } from "node-unifi";
import type tls from "node:tls";
import axios from "axios";
import { HttpCookieAgent, HttpsCookieAgent } from "http-cookie-agent/http";
import {
createCustomCheckServerIdentity,
getAllTrustedCertificatesAsync,
getTrustedCertificateHostnamesAsync,
} from "@homarr/certificates/server";
import { getPortFromUrl } from "@homarr/common";
import type { SiteStats } from "@homarr/node-unifi";
import Unifi from "@homarr/node-unifi";
import { HandleIntegrationErrors } from "../base/errors/decorator";
import { integrationAxiosHttpErrorHandler } from "../base/errors/http";
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import type { TestingResult } from "../base/test-connection/test-connection-service";
import type { NetworkControllerSummaryIntegration } from "../interfaces/network-controller-summary/network-controller-summary-integration";
import type { NetworkControllerSummary } from "../interfaces/network-controller-summary/network-controller-summary-types";
import type { HealthSubsystem } from "./unifi-controller-types";
@HandleIntegrationErrors([integrationAxiosHttpErrorHandler])
export class UnifiControllerIntegration extends Integration implements NetworkControllerSummaryIntegration {
public async getNetworkSummaryAsync(): Promise<NetworkControllerSummary> {
const client = await this.createControllerClientAsync();
@@ -38,20 +51,38 @@ export class UnifiControllerIntegration extends Integration implements NetworkCo
} satisfies NetworkControllerSummary;
}
public async testConnectionAsync(): Promise<void> {
const client = await this.createControllerClientAsync();
protected async testingAsync({ options }: IntegrationTestingInput): Promise<TestingResult> {
const client = await this.createControllerClientAsync(options);
await client.getSitesStats();
return { success: true };
}
private async createControllerClientAsync() {
private async createControllerClientAsync(options?: {
ca: string | string[];
checkServerIdentity: typeof tls.checkServerIdentity;
}) {
const url = new URL(this.integration.url);
const certificateOptions = options ?? {
ca: await getAllTrustedCertificatesAsync(),
checkServerIdentity: createCustomCheckServerIdentity(await getTrustedCertificateHostnamesAsync()),
};
const client = new Controller({
const client = new Unifi.Controller({
host: url.hostname,
port: getPortFromUrl(url),
sslverify: false, // TODO: implement a "ignore certificate toggle", see https://github.com/homarr-labs/homarr/issues/2553
username: this.getSecretValue("username"),
password: this.getSecretValue("password"),
createAxiosInstance({ cookies }) {
return axios.create({
adapter: "http",
httpAgent: new HttpCookieAgent({ cookies }),
httpsAgent: new HttpsCookieAgent({
cookies,
requestCert: true,
...certificateOptions,
}),
});
},
});
await client.login(this.getSecretValue("username"), this.getSecretValue("password"), null);