feat(integration): improve integration test connection (#3005)
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
88
packages/integrations/src/base/errors/decorator.ts
Normal file
88
packages/integrations/src/base/errors/decorator.ts
Normal 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;
|
||||
};
|
||||
};
|
||||
5
packages/integrations/src/base/errors/handler.ts
Normal file
5
packages/integrations/src/base/errors/handler.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import type { IntegrationError, IntegrationErrorData } from "./integration-error";
|
||||
|
||||
export interface IIntegrationErrorHandler {
|
||||
handleError(error: unknown, integration: IntegrationErrorData): IntegrationError | undefined;
|
||||
}
|
||||
13
packages/integrations/src/base/errors/http/index.ts
Normal file
13
packages/integrations/src/base/errors/http/index.ts
Normal 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());
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
18
packages/integrations/src/base/errors/integration-error.ts
Normal file
18
packages/integrations/src/base/errors/integration-error.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
6
packages/integrations/src/base/errors/parse/index.ts
Normal file
6
packages/integrations/src/base/errors/parse/index.ts
Normal 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());
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
6
packages/integrations/src/base/test-connection/index.ts
Normal file
6
packages/integrations/src/base/test-connection/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export type {
|
||||
TestConnectionError,
|
||||
AnyTestConnectionError,
|
||||
TestConnectionErrorDataOfType,
|
||||
TestConnectionErrorType,
|
||||
} from "./test-connection-error";
|
||||
@@ -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">>;
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user