feat(integration): improve integration test connection (#3005)
This commit is contained in:
@@ -4,6 +4,9 @@ import { z } from "zod";
|
||||
import { zfd } from "zod-form-data";
|
||||
|
||||
import { addCustomRootCertificateAsync, removeCustomRootCertificateAsync } from "@homarr/certificates/server";
|
||||
import { and, eq } from "@homarr/db";
|
||||
import { trustedCertificateHostnames } from "@homarr/db/schema";
|
||||
import { logger } from "@homarr/log";
|
||||
import { certificateValidFileNameSchema, superRefineCertificateFile } from "@homarr/validation/certificates";
|
||||
|
||||
import { createTRPCRouter, permissionRequiredProcedure } from "../../trpc";
|
||||
@@ -20,8 +23,13 @@ export const certificateRouter = createTRPCRouter({
|
||||
const content = await input.file.text();
|
||||
|
||||
// Validate the certificate
|
||||
let x509Certificate: X509Certificate;
|
||||
try {
|
||||
new X509Certificate(content);
|
||||
x509Certificate = new X509Certificate(content);
|
||||
logger.info("Adding trusted certificate", {
|
||||
subject: x509Certificate.subject,
|
||||
issuer: x509Certificate.issuer,
|
||||
});
|
||||
} catch {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
@@ -30,11 +38,89 @@ export const certificateRouter = createTRPCRouter({
|
||||
}
|
||||
|
||||
await addCustomRootCertificateAsync(input.file.name, content);
|
||||
|
||||
logger.info("Added trusted certificate", {
|
||||
subject: x509Certificate.subject,
|
||||
issuer: x509Certificate.issuer,
|
||||
});
|
||||
}),
|
||||
trustHostnameMismatch: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.input(z.object({ hostname: z.string(), certificate: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// Validate the certificate
|
||||
let x509Certificate: X509Certificate;
|
||||
try {
|
||||
x509Certificate = new X509Certificate(input.certificate);
|
||||
logger.info("Adding trusted hostname", {
|
||||
subject: x509Certificate.subject,
|
||||
issuer: x509Certificate.issuer,
|
||||
thumbprint: x509Certificate.fingerprint256,
|
||||
hostname: input.hostname,
|
||||
});
|
||||
} catch {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Invalid certificate",
|
||||
});
|
||||
}
|
||||
|
||||
await ctx.db.insert(trustedCertificateHostnames).values({
|
||||
hostname: input.hostname,
|
||||
thumbprint: x509Certificate.fingerprint256,
|
||||
certificate: input.certificate,
|
||||
});
|
||||
|
||||
logger.info("Added trusted hostname", {
|
||||
subject: x509Certificate.subject,
|
||||
issuer: x509Certificate.issuer,
|
||||
thumbprint: x509Certificate.fingerprint256,
|
||||
hostname: input.hostname,
|
||||
});
|
||||
}),
|
||||
removeTrustedHostname: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.input(z.object({ hostname: z.string(), thumbprint: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
logger.info("Removing trusted hostname", {
|
||||
hostname: input.hostname,
|
||||
thumbprint: input.thumbprint,
|
||||
});
|
||||
const dbResult = await ctx.db
|
||||
.delete(trustedCertificateHostnames)
|
||||
.where(
|
||||
and(
|
||||
eq(trustedCertificateHostnames.hostname, input.hostname),
|
||||
eq(trustedCertificateHostnames.thumbprint, input.thumbprint),
|
||||
),
|
||||
);
|
||||
|
||||
logger.info("Removed trusted hostname", {
|
||||
hostname: input.hostname,
|
||||
thumbprint: input.thumbprint,
|
||||
count: dbResult.changes,
|
||||
});
|
||||
}),
|
||||
removeCertificate: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.input(z.object({ fileName: certificateValidFileNameSchema }))
|
||||
.mutation(async ({ input }) => {
|
||||
await removeCustomRootCertificateAsync(input.fileName);
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
logger.info("Removing trusted certificate", {
|
||||
fileName: input.fileName,
|
||||
});
|
||||
|
||||
const certificate = await removeCustomRootCertificateAsync(input.fileName);
|
||||
if (!certificate) return;
|
||||
|
||||
// Delete all trusted hostnames for this certificate
|
||||
await ctx.db
|
||||
.delete(trustedCertificateHostnames)
|
||||
.where(eq(trustedCertificateHostnames.thumbprint, certificate.fingerprint256));
|
||||
|
||||
logger.info("Removed trusted certificate", {
|
||||
fileName: input.fileName,
|
||||
subject: certificate.subject,
|
||||
issuer: certificate.issuer,
|
||||
});
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
integrationSecretKindObject,
|
||||
} from "@homarr/definitions";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import { logger } from "@homarr/log";
|
||||
import { byIdSchema } from "@homarr/validation/common";
|
||||
import {
|
||||
integrationCreateSchema,
|
||||
@@ -34,7 +35,8 @@ import {
|
||||
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../../trpc";
|
||||
import { throwIfActionForbiddenAsync } from "./integration-access";
|
||||
import { testConnectionAsync } from "./integration-test-connection";
|
||||
import { MissingSecretError, testConnectionAsync } from "./integration-test-connection";
|
||||
import { mapTestConnectionError } from "./map-test-connection-error";
|
||||
|
||||
export const integrationRouter = createTRPCRouter({
|
||||
all: publicProcedure.query(async ({ ctx }) => {
|
||||
@@ -185,14 +187,34 @@ export const integrationRouter = createTRPCRouter({
|
||||
.requiresPermission("integration-create")
|
||||
.input(integrationCreateSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await testConnectionAsync({
|
||||
logger.info("Creating integration", {
|
||||
name: input.name,
|
||||
kind: input.kind,
|
||||
url: input.url,
|
||||
});
|
||||
|
||||
const result = await testConnectionAsync({
|
||||
id: "new",
|
||||
name: input.name,
|
||||
url: input.url,
|
||||
kind: input.kind,
|
||||
secrets: input.secrets,
|
||||
}).catch((error) => {
|
||||
if (!(error instanceof MissingSecretError)) throw error;
|
||||
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: error.message,
|
||||
});
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
logger.error(result.error);
|
||||
return {
|
||||
error: mapTestConnectionError(result.error),
|
||||
};
|
||||
}
|
||||
|
||||
const integrationId = createId();
|
||||
await ctx.db.insert(integrations).values({
|
||||
id: integrationId,
|
||||
@@ -211,6 +233,13 @@ export const integrationRouter = createTRPCRouter({
|
||||
);
|
||||
}
|
||||
|
||||
logger.info("Created integration", {
|
||||
id: integrationId,
|
||||
name: input.name,
|
||||
kind: input.kind,
|
||||
url: input.url,
|
||||
});
|
||||
|
||||
if (
|
||||
input.attemptSearchEngineCreation &&
|
||||
integrationDefs[input.kind].category.flatMap((category) => category).includes("search")
|
||||
@@ -229,6 +258,10 @@ export const integrationRouter = createTRPCRouter({
|
||||
update: protectedProcedure.input(integrationUpdateSchema).mutation(async ({ ctx, input }) => {
|
||||
await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full");
|
||||
|
||||
logger.info("Updating integration", {
|
||||
id: input.id,
|
||||
});
|
||||
|
||||
const integration = await ctx.db.query.integrations.findFirst({
|
||||
where: eq(integrations.id, input.id),
|
||||
with: {
|
||||
@@ -243,7 +276,7 @@ export const integrationRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
|
||||
await testConnectionAsync(
|
||||
const testResult = await testConnectionAsync(
|
||||
{
|
||||
id: input.id,
|
||||
name: input.name,
|
||||
@@ -252,7 +285,21 @@ export const integrationRouter = createTRPCRouter({
|
||||
secrets: input.secrets,
|
||||
},
|
||||
integration.secrets,
|
||||
);
|
||||
).catch((error) => {
|
||||
if (!(error instanceof MissingSecretError)) throw error;
|
||||
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: error.message,
|
||||
});
|
||||
});
|
||||
|
||||
if (!testResult.success) {
|
||||
logger.error(testResult.error);
|
||||
return {
|
||||
error: mapTestConnectionError(testResult.error),
|
||||
};
|
||||
}
|
||||
|
||||
await ctx.db
|
||||
.update(integrations)
|
||||
@@ -286,6 +333,13 @@ export const integrationRouter = createTRPCRouter({
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("Updated integration", {
|
||||
id: input.id,
|
||||
name: input.name,
|
||||
kind: integration.kind,
|
||||
url: input.url,
|
||||
});
|
||||
}),
|
||||
delete: protectedProcedure.input(byIdSchema).mutation(async ({ ctx, input }) => {
|
||||
await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full");
|
||||
|
||||
@@ -2,7 +2,7 @@ import { decryptSecret } from "@homarr/common/server";
|
||||
import type { Integration } from "@homarr/db/schema";
|
||||
import type { IntegrationKind, IntegrationSecretKind } from "@homarr/definitions";
|
||||
import { getAllSecretKindOptions } from "@homarr/definitions";
|
||||
import { createIntegrationAsync, IntegrationTestConnectionError } from "@homarr/integrations";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import { logger } from "@homarr/log";
|
||||
|
||||
type FormIntegration = Integration & {
|
||||
@@ -19,6 +19,12 @@ export const testConnectionAsync = async (
|
||||
value: `${string}.${string}`;
|
||||
}[] = [],
|
||||
) => {
|
||||
logger.info("Testing connection", {
|
||||
integrationName: integration.name,
|
||||
integrationKind: integration.kind,
|
||||
integrationUrl: integration.url,
|
||||
});
|
||||
|
||||
const formSecrets = integration.secrets
|
||||
.filter((secret) => secret.value !== null)
|
||||
.map((secret) => ({
|
||||
@@ -72,7 +78,15 @@ export const testConnectionAsync = async (
|
||||
decryptedSecrets,
|
||||
});
|
||||
|
||||
await integrationInstance.testConnectionAsync();
|
||||
const result = await integrationInstance.testConnectionAsync();
|
||||
if (result.success) {
|
||||
logger.info("Tested connection successfully", {
|
||||
integrationName: integration.name,
|
||||
integrationKind: integration.kind,
|
||||
integrationUrl: integration.url,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
interface SourcedIntegrationSecret {
|
||||
@@ -87,7 +101,7 @@ const getSecretKindOption = (kind: IntegrationKind, sourcedSecrets: SourcedInteg
|
||||
);
|
||||
|
||||
if (matchingSecretKindOptions.length === 0) {
|
||||
throw new IntegrationTestConnectionError("secretNotDefined");
|
||||
throw new MissingSecretError();
|
||||
}
|
||||
|
||||
if (matchingSecretKindOptions.length === 1) {
|
||||
@@ -122,3 +136,9 @@ const getSecretKindOption = (kind: IntegrationKind, sourcedSecrets: SourcedInteg
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
return matchingSecretKindOptions[0]!;
|
||||
};
|
||||
|
||||
export class MissingSecretError extends Error {
|
||||
constructor() {
|
||||
super("No secret defined for this integration");
|
||||
}
|
||||
}
|
||||
|
||||
141
packages/api/src/router/integration/map-test-connection-error.ts
Normal file
141
packages/api/src/router/integration/map-test-connection-error.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import type { X509Certificate } from "node:crypto";
|
||||
|
||||
import type { RequestErrorCode } from "@homarr/common/server";
|
||||
import type {
|
||||
AnyTestConnectionError,
|
||||
TestConnectionErrorDataOfType,
|
||||
TestConnectionErrorType,
|
||||
} from "@homarr/integrations/test-connection";
|
||||
|
||||
export interface MappedError {
|
||||
name: string;
|
||||
message: string;
|
||||
metadata: { key: string; value: string | number | boolean }[];
|
||||
cause?: MappedError;
|
||||
}
|
||||
|
||||
const ignoredErrorProperties = ["name", "message", "cause", "stack"];
|
||||
const mapError = (error: Error): MappedError => {
|
||||
const metadata = Object.entries(error)
|
||||
.filter(([key]) => !ignoredErrorProperties.includes(key))
|
||||
.map(([key, value]) => {
|
||||
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
||||
return { key, value };
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter((value) => value !== null);
|
||||
return {
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
metadata,
|
||||
cause: error.cause && error.cause instanceof Error ? mapError(error.cause) : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
export interface MappedCertificate {
|
||||
isSelfSigned: boolean;
|
||||
issuer: string;
|
||||
subject: string;
|
||||
serialNumber: string;
|
||||
validFrom: Date;
|
||||
validTo: Date;
|
||||
fingerprint: string;
|
||||
pem: string;
|
||||
}
|
||||
|
||||
const mapCertificate = (certificate: X509Certificate, code: RequestErrorCode): MappedCertificate => ({
|
||||
isSelfSigned: certificate.ca || code === "DEPTH_ZERO_SELF_SIGNED_CERT",
|
||||
issuer: certificate.issuer,
|
||||
subject: certificate.subject,
|
||||
serialNumber: certificate.serialNumber,
|
||||
validFrom: certificate.validFromDate,
|
||||
validTo: certificate.validToDate,
|
||||
fingerprint: certificate.fingerprint256,
|
||||
pem: certificate.toString(),
|
||||
});
|
||||
|
||||
type MappedData<TType extends TestConnectionErrorType> = TType extends "unknown" | "parse"
|
||||
? undefined
|
||||
: TType extends "certificate"
|
||||
? {
|
||||
type: TestConnectionErrorDataOfType<TType>["requestError"]["type"];
|
||||
reason: TestConnectionErrorDataOfType<TType>["requestError"]["reason"];
|
||||
certificate: MappedCertificate;
|
||||
}
|
||||
: TType extends "request"
|
||||
? {
|
||||
type: TestConnectionErrorDataOfType<TType>["requestError"]["type"];
|
||||
reason: TestConnectionErrorDataOfType<TType>["requestError"]["reason"];
|
||||
}
|
||||
: TType extends "authorization"
|
||||
? {
|
||||
statusCode: TestConnectionErrorDataOfType<TType>["statusCode"];
|
||||
reason: TestConnectionErrorDataOfType<TType>["reason"];
|
||||
}
|
||||
: TType extends "statusCode"
|
||||
? {
|
||||
statusCode: TestConnectionErrorDataOfType<TType>["statusCode"];
|
||||
reason: TestConnectionErrorDataOfType<TType>["reason"];
|
||||
url: TestConnectionErrorDataOfType<TType>["url"];
|
||||
}
|
||||
: never;
|
||||
|
||||
type AnyMappedData = {
|
||||
[TType in TestConnectionErrorType]: MappedData<TType>;
|
||||
}[TestConnectionErrorType];
|
||||
|
||||
const mapData = (error: AnyTestConnectionError): AnyMappedData => {
|
||||
if (error.type === "unknown") return undefined;
|
||||
if (error.type === "parse") return undefined;
|
||||
if (error.type === "certificate") {
|
||||
return {
|
||||
type: error.data.requestError.type,
|
||||
reason: error.data.requestError.reason,
|
||||
certificate: mapCertificate(error.data.certificate, error.data.requestError.code),
|
||||
};
|
||||
}
|
||||
if (error.type === "request") {
|
||||
return {
|
||||
type: error.data.requestError.type,
|
||||
reason: error.data.requestError.reason,
|
||||
};
|
||||
}
|
||||
if (error.type === "authorization") {
|
||||
return {
|
||||
statusCode: error.data.statusCode,
|
||||
reason: error.data.reason,
|
||||
};
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (error.type === "statusCode") {
|
||||
return {
|
||||
statusCode: error.data.statusCode,
|
||||
reason: error.data.reason,
|
||||
url: error.data.url,
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported error type: ${(error as AnyTestConnectionError).type}`);
|
||||
};
|
||||
|
||||
interface MappedTestConnectionError<TType extends TestConnectionErrorType> {
|
||||
type: TType;
|
||||
name: string;
|
||||
message: string;
|
||||
data: MappedData<TType>;
|
||||
cause?: MappedError;
|
||||
}
|
||||
export type AnyMappedTestConnectionError = {
|
||||
[TType in TestConnectionErrorType]: MappedTestConnectionError<TType>;
|
||||
}[TestConnectionErrorType];
|
||||
|
||||
export const mapTestConnectionError = (error: AnyTestConnectionError) => {
|
||||
return {
|
||||
type: error.type,
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
data: mapData(error),
|
||||
cause: error.cause ? mapError(error.cause) : undefined,
|
||||
} as AnyMappedTestConnectionError;
|
||||
};
|
||||
@@ -25,7 +25,7 @@ const defaultSessionWithPermissions = (permissions: GroupPermissionKey[] = []) =
|
||||
// Mock the auth module to return an empty session
|
||||
vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session }));
|
||||
vi.mock("../../integration/integration-test-connection", () => ({
|
||||
testConnectionAsync: async () => await Promise.resolve(undefined),
|
||||
testConnectionAsync: async () => await Promise.resolve({ success: true }),
|
||||
}));
|
||||
|
||||
describe("all should return all integrations", () => {
|
||||
|
||||
@@ -22,7 +22,7 @@ describe("testConnectionAsync should run test connection of integration", () =>
|
||||
const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
|
||||
factorySpy.mockReturnValue(
|
||||
Promise.resolve({
|
||||
testConnectionAsync: async () => await Promise.resolve(),
|
||||
testConnectionAsync: async () => await Promise.resolve({ success: true }),
|
||||
} as homarrIntegrations.PiHoleIntegrationV6),
|
||||
);
|
||||
optionsSpy.mockReturnValue([["apiKey"]]);
|
||||
@@ -64,7 +64,7 @@ describe("testConnectionAsync should run test connection of integration", () =>
|
||||
const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
|
||||
factorySpy.mockReturnValue(
|
||||
Promise.resolve({
|
||||
testConnectionAsync: async () => await Promise.resolve(),
|
||||
testConnectionAsync: async () => await Promise.resolve({ success: true }),
|
||||
} as homarrIntegrations.PiHoleIntegrationV6),
|
||||
);
|
||||
optionsSpy.mockReturnValue([["apiKey"]]);
|
||||
@@ -113,7 +113,7 @@ describe("testConnectionAsync should run test connection of integration", () =>
|
||||
const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
|
||||
factorySpy.mockReturnValue(
|
||||
Promise.resolve({
|
||||
testConnectionAsync: async () => await Promise.resolve(),
|
||||
testConnectionAsync: async () => await Promise.resolve({ success: true }),
|
||||
} as homarrIntegrations.PiHoleIntegrationV6),
|
||||
);
|
||||
optionsSpy.mockReturnValue([["apiKey"]]);
|
||||
@@ -162,7 +162,7 @@ describe("testConnectionAsync should run test connection of integration", () =>
|
||||
const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
|
||||
factorySpy.mockReturnValue(
|
||||
Promise.resolve({
|
||||
testConnectionAsync: async () => await Promise.resolve(),
|
||||
testConnectionAsync: async () => await Promise.resolve({ success: true }),
|
||||
} as homarrIntegrations.PiHoleIntegrationV6),
|
||||
);
|
||||
optionsSpy.mockReturnValue([["username", "password"], ["apiKey"]]);
|
||||
@@ -215,7 +215,7 @@ describe("testConnectionAsync should run test connection of integration", () =>
|
||||
const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
|
||||
factorySpy.mockReturnValue(
|
||||
Promise.resolve({
|
||||
testConnectionAsync: async () => await Promise.resolve(),
|
||||
testConnectionAsync: async () => await Promise.resolve({ success: true }),
|
||||
} as homarrIntegrations.PiHoleIntegrationV6),
|
||||
);
|
||||
optionsSpy.mockReturnValue([["username", "password"], ["apiKey"]]);
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"prettier": "@homarr/prettier-config",
|
||||
"dependencies": {
|
||||
"@homarr/common": "workspace:^0.1.0",
|
||||
"@homarr/db": "workspace:^0.1.0",
|
||||
"undici": "7.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import { X509Certificate } from "node:crypto";
|
||||
import fsSync from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import type { AgentOptions } from "node:https";
|
||||
import { Agent as HttpsAgent } from "node:https";
|
||||
import path from "node:path";
|
||||
import { rootCertificates } from "node:tls";
|
||||
import { checkServerIdentity, rootCertificates } from "node:tls";
|
||||
import axios from "axios";
|
||||
import { fetch } from "undici";
|
||||
|
||||
import { env } from "@homarr/common/env";
|
||||
import { LoggingAgent } from "@homarr/common/server";
|
||||
import type { InferSelectModel } from "@homarr/db";
|
||||
import { db } from "@homarr/db";
|
||||
import type { trustedCertificateHostnames } from "@homarr/db/schema";
|
||||
|
||||
const getCertificateFolder = () => {
|
||||
return env.NODE_ENV === "production"
|
||||
@@ -40,10 +45,23 @@ export const loadCustomRootCertificatesAsync = async () => {
|
||||
export const removeCustomRootCertificateAsync = async (fileName: string) => {
|
||||
const folder = getCertificateFolder();
|
||||
if (!folder) {
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
await fs.rm(path.join(folder, fileName));
|
||||
const existingFiles = await fs.readdir(folder, { withFileTypes: true });
|
||||
if (!existingFiles.some((file) => file.isFile() && file.name === fileName)) {
|
||||
throw new Error(`File ${fileName} does not exist`);
|
||||
}
|
||||
|
||||
const fullPath = path.join(folder, fileName);
|
||||
const content = await fs.readFile(fullPath, "utf8");
|
||||
|
||||
await fs.rm(fullPath);
|
||||
try {
|
||||
return new X509Certificate(content);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const addCustomRootCertificateAsync = async (fileName: string, content: string) => {
|
||||
@@ -61,25 +79,56 @@ export const addCustomRootCertificateAsync = async (fileName: string, content: s
|
||||
await fs.writeFile(path.join(folder, fileName), content);
|
||||
};
|
||||
|
||||
export const createCertificateAgentAsync = async () => {
|
||||
export const getTrustedCertificateHostnamesAsync = async () => {
|
||||
return await db.query.trustedCertificateHostnames.findMany();
|
||||
};
|
||||
|
||||
export const getAllTrustedCertificatesAsync = async () => {
|
||||
const customCertificates = await loadCustomRootCertificatesAsync();
|
||||
return rootCertificates.concat(customCertificates.map((cert) => cert.content));
|
||||
};
|
||||
|
||||
export const createCustomCheckServerIdentity = (
|
||||
trustedHostnames: InferSelectModel<typeof trustedCertificateHostnames>[],
|
||||
): typeof checkServerIdentity => {
|
||||
return (hostname, peerCertificate) => {
|
||||
const matchingTrustedHostnames = trustedHostnames.filter(
|
||||
(cert) => cert.thumbprint === peerCertificate.fingerprint256,
|
||||
);
|
||||
|
||||
// We trust the certificate if we have a matching hostname
|
||||
if (matchingTrustedHostnames.some((cert) => cert.hostname === hostname)) return undefined;
|
||||
|
||||
return checkServerIdentity(hostname, peerCertificate);
|
||||
};
|
||||
};
|
||||
|
||||
export const createCertificateAgentAsync = async (override?: {
|
||||
ca: string | string[];
|
||||
checkServerIdentity: typeof checkServerIdentity;
|
||||
}) => {
|
||||
return new LoggingAgent({
|
||||
connect: {
|
||||
ca: rootCertificates.concat(customCertificates.map((cert) => cert.content)),
|
||||
connect: override ?? {
|
||||
ca: await getAllTrustedCertificatesAsync(),
|
||||
checkServerIdentity: createCustomCheckServerIdentity(await getTrustedCertificateHostnamesAsync()),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const createHttpsAgentAsync = async () => {
|
||||
const customCertificates = await loadCustomRootCertificatesAsync();
|
||||
return new HttpsAgent({
|
||||
ca: rootCertificates.concat(customCertificates.map((cert) => cert.content)),
|
||||
});
|
||||
export const createHttpsAgentAsync = async (override?: Pick<AgentOptions, "ca" | "checkServerIdentity">) => {
|
||||
return new HttpsAgent(
|
||||
override ?? {
|
||||
ca: await getAllTrustedCertificatesAsync(),
|
||||
checkServerIdentity: createCustomCheckServerIdentity(await getTrustedCertificateHostnamesAsync()),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
export const createAxiosCertificateInstanceAsync = async () => {
|
||||
export const createAxiosCertificateInstanceAsync = async (
|
||||
override?: Pick<AgentOptions, "ca" | "checkServerIdentity">,
|
||||
) => {
|
||||
return axios.create({
|
||||
httpsAgent: await createHttpsAgentAsync(),
|
||||
httpsAgent: await createHttpsAgentAsync(override),
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -34,7 +34,8 @@
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"undici": "7.9.0",
|
||||
"zod": "^3.24.4"
|
||||
"zod": "^3.24.4",
|
||||
"zod-validation-error": "^3.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { AxiosError } from "axios";
|
||||
|
||||
import { logger } from "@homarr/log";
|
||||
|
||||
import type { AnyRequestError } from "../request-error";
|
||||
import { RequestError } from "../request-error";
|
||||
import { ResponseError } from "../response-error";
|
||||
import { matchErrorCode } from "./fetch-http-error-handler";
|
||||
import { HttpErrorHandler } from "./http-error-handler";
|
||||
|
||||
export class AxiosHttpErrorHandler extends HttpErrorHandler {
|
||||
handleRequestError(error: unknown): AnyRequestError | undefined {
|
||||
if (!(error instanceof AxiosError)) return undefined;
|
||||
if (error.code === undefined) return undefined;
|
||||
|
||||
logger.debug("Received Axios request error", {
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
});
|
||||
|
||||
const requestErrorInput = matchErrorCode(error.code);
|
||||
if (!requestErrorInput) return undefined;
|
||||
|
||||
return new RequestError(requestErrorInput, {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
handleResponseError(error: unknown): ResponseError | undefined {
|
||||
if (!(error instanceof AxiosError)) return undefined;
|
||||
if (error.response === undefined) return undefined;
|
||||
|
||||
logger.debug("Received Axios response error", {
|
||||
status: error.response.status,
|
||||
url: error.response.config.url,
|
||||
message: error.message,
|
||||
});
|
||||
|
||||
return new ResponseError({
|
||||
status: error.response.status,
|
||||
url: error.response.config.url ?? "?",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { logger } from "@homarr/log";
|
||||
|
||||
import { objectEntries } from "../../../object";
|
||||
import type { Modify } from "../../../types";
|
||||
import type { AnyRequestError, AnyRequestErrorInput, RequestErrorCode, RequestErrorReason } from "../request-error";
|
||||
import { RequestError, requestErrorMap } from "../request-error";
|
||||
import type { ResponseError } from "../response-error";
|
||||
import { HttpErrorHandler } from "./http-error-handler";
|
||||
|
||||
export class FetchHttpErrorHandler extends HttpErrorHandler {
|
||||
constructor(private type = "undici") {
|
||||
super();
|
||||
}
|
||||
|
||||
handleRequestError(error: unknown): AnyRequestError | undefined {
|
||||
if (!isTypeErrorWithCode(error)) return undefined;
|
||||
|
||||
logger.debug(`Received ${this.type} request error`, {
|
||||
code: error.cause.code,
|
||||
});
|
||||
|
||||
const result = matchErrorCode(error.cause.code);
|
||||
if (!result) return undefined;
|
||||
|
||||
return new RequestError(result, { cause: error });
|
||||
}
|
||||
|
||||
/**
|
||||
* Response errors do not exist for fetch as it does not throw errors for non successful responses.
|
||||
*/
|
||||
handleResponseError(_: unknown): ResponseError | undefined {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
type TypeErrorWithCode = Modify<
|
||||
TypeError,
|
||||
{
|
||||
cause: Error & { code: string };
|
||||
}
|
||||
>;
|
||||
|
||||
const isTypeErrorWithCode = (error: unknown): error is TypeErrorWithCode => {
|
||||
return (
|
||||
error instanceof TypeError &&
|
||||
error.cause instanceof Error &&
|
||||
"code" in error.cause &&
|
||||
typeof error.cause.code === "string"
|
||||
);
|
||||
};
|
||||
|
||||
export const matchErrorCode = (code: string): AnyRequestErrorInput | undefined => {
|
||||
for (const [key, value] of objectEntries(requestErrorMap)) {
|
||||
const entries = Object.entries(value) as [string, string | string[]][];
|
||||
const found = entries.find(([_, entryCode]) =>
|
||||
typeof entryCode === "string" ? entryCode === code : entryCode.includes(code),
|
||||
);
|
||||
if (!found) continue;
|
||||
|
||||
return {
|
||||
type: key,
|
||||
reason: found[0] as RequestErrorReason<typeof key>,
|
||||
code: code as RequestErrorCode,
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { AnyRequestError } from "../request-error";
|
||||
import type { ResponseError } from "../response-error";
|
||||
|
||||
export abstract class HttpErrorHandler {
|
||||
abstract handleRequestError(error: unknown): AnyRequestError | undefined;
|
||||
abstract handleResponseError(error: unknown): ResponseError | undefined;
|
||||
}
|
||||
5
packages/common/src/errors/http/handlers/index.ts
Normal file
5
packages/common/src/errors/http/handlers/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from "./http-error-handler";
|
||||
export * from "./fetch-http-error-handler";
|
||||
export * from "./ofetch-http-error-handler";
|
||||
export * from "./axios-http-error-handler";
|
||||
export * from "./tsdav-http-error-handler";
|
||||
@@ -0,0 +1,38 @@
|
||||
import { FetchError } from "ofetch";
|
||||
|
||||
import { logger } from "@homarr/log";
|
||||
|
||||
import type { AnyRequestError } from "../request-error";
|
||||
import { ResponseError } from "../response-error";
|
||||
import { FetchHttpErrorHandler } from "./fetch-http-error-handler";
|
||||
import { HttpErrorHandler } from "./http-error-handler";
|
||||
|
||||
/**
|
||||
* Ofetch is a wrapper around the native fetch API
|
||||
* which will always throw the FetchError (also for non successful responses).
|
||||
*
|
||||
* It is for example used within the ctrl packages like qbittorrent, deluge, transmission, etc.
|
||||
*/
|
||||
export class OFetchHttpErrorHandler extends HttpErrorHandler {
|
||||
handleRequestError(error: unknown): AnyRequestError | undefined {
|
||||
if (!(error instanceof FetchError)) return undefined;
|
||||
if (!(error.cause instanceof TypeError)) return undefined;
|
||||
|
||||
const result = new FetchHttpErrorHandler("ofetch").handleRequestError(error.cause);
|
||||
if (!result) return undefined;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
handleResponseError(error: unknown): ResponseError | undefined {
|
||||
if (!(error instanceof FetchError)) return undefined;
|
||||
if (error.response === undefined) return undefined;
|
||||
|
||||
logger.debug("Received ofetch response error", {
|
||||
status: error.response.status,
|
||||
url: error.response.url,
|
||||
});
|
||||
|
||||
return new ResponseError(error.response);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { logger } from "@homarr/log";
|
||||
|
||||
import type { AnyRequestError } from "../request-error";
|
||||
import { ResponseError } from "../response-error";
|
||||
import { FetchHttpErrorHandler } from "./fetch-http-error-handler";
|
||||
import { HttpErrorHandler } from "./http-error-handler";
|
||||
|
||||
export class TsdavHttpErrorHandler extends HttpErrorHandler {
|
||||
handleRequestError(error: unknown): AnyRequestError | undefined {
|
||||
return new FetchHttpErrorHandler("tsdav").handleRequestError(error);
|
||||
}
|
||||
|
||||
handleResponseError(error: unknown): ResponseError | undefined {
|
||||
if (!(error instanceof Error)) return undefined;
|
||||
// Tsdav sadly does not throw a custom error and rather just uses "Error"
|
||||
// https://github.com/natelindev/tsdav/blob/bf33f04b1884694d685ee6f2b43fe9354b12d167/src/account.ts#L86
|
||||
if (error.message !== "Invalid credentials") return undefined;
|
||||
|
||||
logger.debug("Received Tsdav response error", {
|
||||
status: 401,
|
||||
});
|
||||
|
||||
return new ResponseError({ status: 401, url: "?" });
|
||||
}
|
||||
}
|
||||
3
packages/common/src/errors/http/index.ts
Normal file
3
packages/common/src/errors/http/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./handlers";
|
||||
export * from "./request-error";
|
||||
export * from "./response-error";
|
||||
73
packages/common/src/errors/http/request-error.ts
Normal file
73
packages/common/src/errors/http/request-error.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
export type AnyRequestError = {
|
||||
[key in keyof RequestErrorMap]: RequestError<key>;
|
||||
}[keyof RequestErrorMap];
|
||||
|
||||
export type AnyRequestErrorInput = {
|
||||
[key in RequestErrorType]: RequestErrorInput<key>;
|
||||
}[RequestErrorType];
|
||||
|
||||
export interface RequestErrorInput<TType extends RequestErrorType> {
|
||||
type: TType;
|
||||
reason: RequestErrorReason<TType>;
|
||||
code: RequestErrorCode;
|
||||
}
|
||||
|
||||
export class RequestError<TType extends RequestErrorType> extends Error {
|
||||
public readonly type: TType;
|
||||
public readonly reason: RequestErrorReason<TType>;
|
||||
public readonly code: RequestErrorCode;
|
||||
|
||||
constructor(input: AnyRequestErrorInput, options: { cause?: Error }) {
|
||||
super("Request failed", options);
|
||||
this.name = RequestError.name;
|
||||
|
||||
this.type = input.type as TType;
|
||||
this.reason = input.reason as RequestErrorReason<TType>;
|
||||
this.code = input.code;
|
||||
}
|
||||
|
||||
get cause(): Error | undefined {
|
||||
return super.cause as Error | undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export const requestErrorMap = {
|
||||
certificate: {
|
||||
expired: ["CERT_HAS_EXPIRED"],
|
||||
hostnameMismatch: ["ERR_TLS_CERT_ALTNAME_INVALID", "CERT_COMMON_NAME_INVALID"],
|
||||
notYetValid: ["CERT_NOT_YET_VALID"],
|
||||
untrusted: ["DEPTH_ZERO_SELF_SIGNED_CERT", "UNABLE_TO_VERIFY_LEAF_SIGNATURE", "UNABLE_TO_GET_ISSUER_CERT_LOCALLY"],
|
||||
},
|
||||
connection: {
|
||||
hostUnreachable: "EHOSTUNREACH",
|
||||
networkUnreachable: "ENETUNREACH",
|
||||
refused: "ECONNREFUSED",
|
||||
reset: "ECONNRESET",
|
||||
},
|
||||
dns: {
|
||||
notFound: "ENOTFOUND",
|
||||
timeout: "EAI_AGAIN",
|
||||
noAnswer: "ENODATA",
|
||||
},
|
||||
timeout: {
|
||||
aborted: "ECONNABORTED",
|
||||
timeout: "ETIMEDOUT",
|
||||
},
|
||||
} as const satisfies Record<string, Record<string, string | string[]>>;
|
||||
|
||||
type RequestErrorMap = typeof requestErrorMap;
|
||||
|
||||
export type RequestErrorType = keyof RequestErrorMap;
|
||||
|
||||
export type RequestErrorReason<TType extends RequestErrorType> = keyof RequestErrorMap[TType];
|
||||
export type AnyRequestErrorReason = {
|
||||
[key in keyof RequestErrorMap]: RequestErrorReason<key>;
|
||||
}[keyof RequestErrorMap];
|
||||
|
||||
type ExtractInnerValues<T> = {
|
||||
[K in keyof T]: T[K][keyof T[K]];
|
||||
}[keyof T];
|
||||
|
||||
type FlattenStringOrStringArray<T> = T extends (infer U)[] ? U : T;
|
||||
|
||||
export type RequestErrorCode = FlattenStringOrStringArray<ExtractInnerValues<typeof requestErrorMap>>;
|
||||
12
packages/common/src/errors/http/response-error.ts
Normal file
12
packages/common/src/errors/http/response-error.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export class ResponseError extends Error {
|
||||
public readonly statusCode: number;
|
||||
public readonly url?: string;
|
||||
|
||||
constructor(response: { status: number; url?: string }, options?: ErrorOptions) {
|
||||
super("Response did not indicate success", options);
|
||||
this.name = ResponseError.name;
|
||||
|
||||
this.statusCode = response.status;
|
||||
this.url = response.url;
|
||||
}
|
||||
}
|
||||
2
packages/common/src/errors/index.ts
Normal file
2
packages/common/src/errors/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./parse";
|
||||
export * from "./http";
|
||||
3
packages/common/src/errors/parse/handlers/index.ts
Normal file
3
packages/common/src/errors/parse/handlers/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./parse-error-handler";
|
||||
export * from "./zod-parse-error-handler";
|
||||
export * from "./json-parse-error-handler";
|
||||
@@ -0,0 +1,16 @@
|
||||
import { logger } from "@homarr/log";
|
||||
|
||||
import { ParseError } from "../parse-error";
|
||||
import { ParseErrorHandler } from "./parse-error-handler";
|
||||
|
||||
export class JsonParseErrorHandler extends ParseErrorHandler {
|
||||
handleParseError(error: unknown): ParseError | undefined {
|
||||
if (!(error instanceof SyntaxError)) return undefined;
|
||||
|
||||
logger.debug("Received JSON parse error", {
|
||||
message: error.message,
|
||||
});
|
||||
|
||||
return new ParseError("Failed to parse json", { cause: error });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { ParseError } from "../parse-error";
|
||||
|
||||
export abstract class ParseErrorHandler {
|
||||
abstract handleParseError(error: unknown): ParseError | undefined;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { ZodError } from "zod";
|
||||
import { fromError } from "zod-validation-error";
|
||||
|
||||
import { logger } from "@homarr/log";
|
||||
|
||||
import { ParseError } from "../parse-error";
|
||||
import { ParseErrorHandler } from "./parse-error-handler";
|
||||
|
||||
export class ZodParseErrorHandler extends ParseErrorHandler {
|
||||
handleParseError(error: unknown): ParseError | undefined {
|
||||
if (!(error instanceof ZodError)) return undefined;
|
||||
|
||||
// TODO: migrate to zod v4 prettfyError once it's released
|
||||
// https://v4.zod.dev/v4#error-pretty-printing
|
||||
const message = fromError(error, {
|
||||
issueSeparator: "\n",
|
||||
prefix: null,
|
||||
}).toString();
|
||||
|
||||
logger.debug("Received Zod parse error");
|
||||
|
||||
return new ParseError(message, { cause: error });
|
||||
}
|
||||
}
|
||||
2
packages/common/src/errors/parse/index.ts
Normal file
2
packages/common/src/errors/parse/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./handlers";
|
||||
export * from "./parse-error";
|
||||
10
packages/common/src/errors/parse/parse-error.ts
Normal file
10
packages/common/src/errors/parse/parse-error.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export class ParseError extends Error {
|
||||
constructor(message: string, options?: { cause: Error }) {
|
||||
super(`Failed to parse data:\n${message}`, options);
|
||||
this.name = ParseError.name;
|
||||
}
|
||||
|
||||
get cause(): Error {
|
||||
return super.cause as Error;
|
||||
}
|
||||
}
|
||||
3
packages/common/src/function.ts
Normal file
3
packages/common/src/function.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const isFunction = (value: unknown): value is (...args: unknown[]) => unknown => {
|
||||
return typeof value === "function";
|
||||
};
|
||||
@@ -10,3 +10,4 @@ export * from "./number";
|
||||
export * from "./error";
|
||||
export * from "./fetch-with-timeout";
|
||||
export * from "./theme";
|
||||
export * from "./function";
|
||||
|
||||
@@ -2,3 +2,4 @@ export * from "./security";
|
||||
export * from "./encryption";
|
||||
export * from "./user-agent";
|
||||
export * from "./fetch-agent";
|
||||
export * from "./errors";
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
CREATE TABLE `trusted_certificate_hostname` (
|
||||
`hostname` varchar(256) NOT NULL,
|
||||
`thumbprint` varchar(128) NOT NULL,
|
||||
`certificate` text NOT NULL,
|
||||
CONSTRAINT `trusted_certificate_hostname_hostname_thumbprint_pk` PRIMARY KEY(`hostname`,`thumbprint`)
|
||||
);
|
||||
2056
packages/db/migrations/mysql/meta/0032_snapshot.json
Normal file
2056
packages/db/migrations/mysql/meta/0032_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -225,6 +225,13 @@
|
||||
"when": 1740784837957,
|
||||
"tag": "0031_add_dynamic_section_options",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 32,
|
||||
"version": "5",
|
||||
"when": 1746821770071,
|
||||
"tag": "0032_add_trusted_certificate_hostnames",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
CREATE TABLE `trusted_certificate_hostname` (
|
||||
`hostname` text NOT NULL,
|
||||
`thumbprint` text NOT NULL,
|
||||
`certificate` text NOT NULL,
|
||||
PRIMARY KEY(`hostname`, `thumbprint`)
|
||||
);
|
||||
1976
packages/db/migrations/sqlite/meta/0032_snapshot.json
Normal file
1976
packages/db/migrations/sqlite/meta/0032_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -225,6 +225,13 @@
|
||||
"when": 1740784849045,
|
||||
"tag": "0031_add_dynamic_section_options",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 32,
|
||||
"version": "6",
|
||||
"when": 1746821779051,
|
||||
"tag": "0032_add_trusted_certificate_hostnames",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ export const {
|
||||
layouts,
|
||||
itemLayouts,
|
||||
sectionLayouts,
|
||||
trustedCertificateHostnames,
|
||||
} = schema;
|
||||
|
||||
export type User = InferSelectModel<typeof schema.users>;
|
||||
|
||||
@@ -494,6 +494,20 @@ export const onboarding = mysqlTable("onboarding", {
|
||||
previousStep: varchar({ length: 64 }).$type<OnboardingStep>(),
|
||||
});
|
||||
|
||||
export const trustedCertificateHostnames = mysqlTable(
|
||||
"trusted_certificate_hostname",
|
||||
{
|
||||
hostname: varchar({ length: 256 }).notNull(),
|
||||
thumbprint: varchar({ length: 128 }).notNull(),
|
||||
certificate: text().notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
compoundKey: primaryKey({
|
||||
columns: [table.hostname, table.thumbprint],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
export const accountRelations = relations(accounts, ({ one }) => ({
|
||||
user: one(users, {
|
||||
fields: [accounts.userId],
|
||||
|
||||
@@ -479,6 +479,20 @@ export const onboarding = sqliteTable("onboarding", {
|
||||
previousStep: text().$type<OnboardingStep>(),
|
||||
});
|
||||
|
||||
export const trustedCertificateHostnames = sqliteTable(
|
||||
"trusted_certificate_hostname",
|
||||
{
|
||||
hostname: text().notNull(),
|
||||
thumbprint: text().notNull(),
|
||||
certificate: text().notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
compoundKey: primaryKey({
|
||||
columns: [table.hostname, table.thumbprint],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
export const accountRelations = relations(accounts, ({ one }) => ({
|
||||
user: one(users, {
|
||||
fields: [accounts.userId],
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./index.ts",
|
||||
"./test-connection": "./src/base/test-connection/index.ts",
|
||||
"./client": "./src/client.ts",
|
||||
"./types": "./src/types.ts"
|
||||
},
|
||||
@@ -32,13 +33,13 @@
|
||||
"@homarr/db": "workspace:^0.1.0",
|
||||
"@homarr/definitions": "workspace:^0.1.0",
|
||||
"@homarr/log": "workspace:^0.1.0",
|
||||
"@homarr/node-unifi": "^2.6.0",
|
||||
"@homarr/redis": "workspace:^0.1.0",
|
||||
"@homarr/translation": "workspace:^0.1.0",
|
||||
"@homarr/validation": "workspace:^0.1.0",
|
||||
"@jellyfin/sdk": "^0.11.0",
|
||||
"maria2": "^0.4.0",
|
||||
"node-ical": "^0.20.1",
|
||||
"node-unifi": "^2.5.1",
|
||||
"proxmox-api": "1.1.1",
|
||||
"tsdav": "^2.1.4",
|
||||
"undici": "7.9.0",
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { convertIntegrationTestConnectionError } from "./base/test-connection-error";
|
||||
@@ -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> {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
};
|
||||
},
|
||||
|
||||
@@ -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()),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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()),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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()),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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[]> {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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[]> {
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"] {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
30
packages/integrations/src/proxmox/proxmox-error-handler.ts
Normal file
30
packages/integrations/src/proxmox/proxmox-error-handler.ts
Normal 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 }),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
import type { StartedTestContainer } from "testcontainers";
|
||||
import { GenericContainer, getContainerRuntimeClient, ImageName, Wait } from "testcontainers";
|
||||
import { beforeAll, describe, expect, test } from "vitest";
|
||||
import { beforeAll, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { createDb } from "@homarr/db/test";
|
||||
|
||||
import { Aria2Integration } from "../src";
|
||||
|
||||
vi.mock("@homarr/db", async (importActual) => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
||||
const actual = await importActual<typeof import("@homarr/db")>();
|
||||
return {
|
||||
...actual,
|
||||
db: createDb(),
|
||||
};
|
||||
});
|
||||
|
||||
const API_KEY = "ARIA2_API_KEY";
|
||||
const IMAGE_NAME = "hurlenko/aria2-ariang:latest";
|
||||
|
||||
@@ -19,10 +30,10 @@ describe("Aria2 integration", () => {
|
||||
const aria2Integration = createAria2Intergration(startedContainer, API_KEY);
|
||||
|
||||
// Act
|
||||
const actAsync = async () => await aria2Integration.testConnectionAsync();
|
||||
const result = await aria2Integration.testConnectionAsync();
|
||||
|
||||
// Assert
|
||||
await expect(actAsync()).resolves.not.toThrow();
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
// Cleanup
|
||||
await startedContainer.stop();
|
||||
|
||||
@@ -1,250 +1,57 @@
|
||||
import { Response } from "undici";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { IntegrationTestConnectionError } from "../src";
|
||||
import { ResponseError } from "@homarr/common/server";
|
||||
import { createDb } from "@homarr/db/test";
|
||||
|
||||
import type { IntegrationTestingInput } from "../src/base/integration";
|
||||
import { Integration } from "../src/base/integration";
|
||||
import type { TestingResult } from "../src/base/test-connection/test-connection-service";
|
||||
|
||||
type HandleResponseProps = Parameters<Integration["handleTestConnectionResponseAsync"]>[0];
|
||||
|
||||
class BaseIntegrationMock extends Integration {
|
||||
public async fakeTestConnectionAsync(props: HandleResponseProps): Promise<void> {
|
||||
await super.handleTestConnectionResponseAsync(props);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
public async testConnectionAsync(): Promise<void> {}
|
||||
}
|
||||
vi.mock("@homarr/db", async (importActual) => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
||||
const actual = await importActual<typeof import("@homarr/db")>();
|
||||
return {
|
||||
...actual,
|
||||
db: createDb(),
|
||||
};
|
||||
});
|
||||
|
||||
describe("Base integration", () => {
|
||||
describe("handleTestConnectionResponseAsync", () => {
|
||||
test("With no cause error should throw IntegrationTestConnectionError with key commonError", async () => {
|
||||
// Arrange
|
||||
const integration = new BaseIntegrationMock({ id: "id", name: "name", url: "url", decryptedSecrets: [] });
|
||||
test("testConnectionAsync should handle errors", async () => {
|
||||
const responseError = new ResponseError({ status: 500, url: "https://example.com" });
|
||||
const integration = new FakeIntegration(undefined, responseError);
|
||||
|
||||
const errorMessage = "The error message";
|
||||
const props: HandleResponseProps = {
|
||||
async queryFunctionAsync() {
|
||||
return await Promise.reject(new Error(errorMessage));
|
||||
},
|
||||
};
|
||||
const result = await integration.testConnectionAsync();
|
||||
|
||||
// Act
|
||||
const actPromise = integration.fakeTestConnectionAsync(props);
|
||||
|
||||
// Assert
|
||||
await expect(actPromise).rejects.toHaveProperty("key", "commonError");
|
||||
await expect(actPromise).rejects.toHaveProperty("detailMessage", errorMessage);
|
||||
});
|
||||
|
||||
test("With cause ENOTFOUND should throw IntegrationTestConnectionError with key domainNotFound", async () => {
|
||||
// Arrange
|
||||
const integration = new BaseIntegrationMock({ id: "id", name: "name", url: "url", decryptedSecrets: [] });
|
||||
|
||||
const props: HandleResponseProps = {
|
||||
async queryFunctionAsync() {
|
||||
return await Promise.reject(new Error("Error", { cause: { code: "ENOTFOUND" } }));
|
||||
},
|
||||
};
|
||||
|
||||
// Act
|
||||
const actAsync = async () => await integration.fakeTestConnectionAsync(props);
|
||||
|
||||
// Assert
|
||||
await expect(actAsync()).rejects.toHaveProperty("key", "domainNotFound");
|
||||
});
|
||||
|
||||
test("With cause ENOTFOUND should throw IntegrationTestConnectionError with key connectionRefused", async () => {
|
||||
// Arrange
|
||||
const integration = new BaseIntegrationMock({ id: "id", name: "name", url: "url", decryptedSecrets: [] });
|
||||
|
||||
const props: HandleResponseProps = {
|
||||
async queryFunctionAsync() {
|
||||
return await Promise.reject(new Error("Error", { cause: { code: "ECONNREFUSED" } }));
|
||||
},
|
||||
};
|
||||
|
||||
// Act
|
||||
const actAsync = async () => await integration.fakeTestConnectionAsync(props);
|
||||
|
||||
// Assert
|
||||
await expect(actAsync()).rejects.toHaveProperty("key", "connectionRefused");
|
||||
});
|
||||
|
||||
test("With cause ENOTFOUND should throw IntegrationTestConnectionError with key connectionAborted", async () => {
|
||||
// Arrange
|
||||
const integration = new BaseIntegrationMock({ id: "id", name: "name", url: "url", decryptedSecrets: [] });
|
||||
|
||||
const props: HandleResponseProps = {
|
||||
async queryFunctionAsync() {
|
||||
return await Promise.reject(new Error("Error", { cause: { code: "ECONNABORTED" } }));
|
||||
},
|
||||
};
|
||||
|
||||
// Act
|
||||
const actAsync = async () => await integration.fakeTestConnectionAsync(props);
|
||||
|
||||
// Assert
|
||||
await expect(actAsync()).rejects.toHaveProperty("key", "connectionAborted");
|
||||
});
|
||||
|
||||
test("With not handled cause error should throw IntegrationTestConnectionError with key commonError", async () => {
|
||||
// Arrange
|
||||
const integration = new BaseIntegrationMock({ id: "id", name: "name", url: "url", decryptedSecrets: [] });
|
||||
|
||||
const errorMessage = "The error message";
|
||||
const props: HandleResponseProps = {
|
||||
async queryFunctionAsync() {
|
||||
return await Promise.reject(new Error(errorMessage));
|
||||
},
|
||||
};
|
||||
|
||||
// Act
|
||||
const actPromise = integration.fakeTestConnectionAsync(props);
|
||||
|
||||
// Assert
|
||||
await expect(actPromise).rejects.toHaveProperty("key", "commonError");
|
||||
await expect(actPromise).rejects.toHaveProperty("detailMessage", errorMessage);
|
||||
});
|
||||
|
||||
test("With response status code 400 should throw IntegrationTestConnectionError with key badRequest", async () => {
|
||||
// Arrange
|
||||
const integration = new BaseIntegrationMock({ id: "id", name: "name", url: "url", decryptedSecrets: [] });
|
||||
|
||||
const props: HandleResponseProps = {
|
||||
async queryFunctionAsync() {
|
||||
return await Promise.resolve(new Response(null, { status: 400 }));
|
||||
},
|
||||
};
|
||||
|
||||
// Act
|
||||
const actAsync = async () => await integration.fakeTestConnectionAsync(props);
|
||||
|
||||
// Assert
|
||||
await expect(actAsync()).rejects.toHaveProperty("key", "badRequest");
|
||||
});
|
||||
|
||||
test("With response status code 401 should throw IntegrationTestConnectionError with key unauthorized", async () => {
|
||||
// Arrange
|
||||
const integration = new BaseIntegrationMock({ id: "id", name: "name", url: "url", decryptedSecrets: [] });
|
||||
|
||||
const props: HandleResponseProps = {
|
||||
async queryFunctionAsync() {
|
||||
return await Promise.resolve(new Response(null, { status: 401 }));
|
||||
},
|
||||
};
|
||||
|
||||
// Act
|
||||
const actAsync = async () => await integration.fakeTestConnectionAsync(props);
|
||||
|
||||
// Assert
|
||||
await expect(actAsync()).rejects.toHaveProperty("key", "unauthorized");
|
||||
});
|
||||
|
||||
test("With response status code 403 should throw IntegrationTestConnectionError with key forbidden", async () => {
|
||||
// Arrange
|
||||
const integration = new BaseIntegrationMock({ id: "id", name: "name", url: "url", decryptedSecrets: [] });
|
||||
|
||||
const props: HandleResponseProps = {
|
||||
async queryFunctionAsync() {
|
||||
return await Promise.resolve(new Response(null, { status: 403 }));
|
||||
},
|
||||
};
|
||||
|
||||
// Act
|
||||
const actAsync = async () => await integration.fakeTestConnectionAsync(props);
|
||||
|
||||
// Assert
|
||||
await expect(actAsync()).rejects.toHaveProperty("key", "forbidden");
|
||||
});
|
||||
|
||||
test("With response status code 404 should throw IntegrationTestConnectionError with key notFound", async () => {
|
||||
// Arrange
|
||||
const integration = new BaseIntegrationMock({ id: "id", name: "name", url: "url", decryptedSecrets: [] });
|
||||
|
||||
const props: HandleResponseProps = {
|
||||
async queryFunctionAsync() {
|
||||
return await Promise.resolve(new Response(null, { status: 404 }));
|
||||
},
|
||||
};
|
||||
|
||||
// Act
|
||||
const actAsync = async () => await integration.fakeTestConnectionAsync(props);
|
||||
|
||||
// Assert
|
||||
await expect(actAsync()).rejects.toHaveProperty("key", "notFound");
|
||||
});
|
||||
|
||||
test("With response status code 500 should throw IntegrationTestConnectionError with key internalServerError", async () => {
|
||||
// Arrange
|
||||
const integration = new BaseIntegrationMock({ id: "id", name: "name", url: "url", decryptedSecrets: [] });
|
||||
|
||||
const props: HandleResponseProps = {
|
||||
async queryFunctionAsync() {
|
||||
return await Promise.resolve(new Response(null, { status: 500 }));
|
||||
},
|
||||
};
|
||||
|
||||
// Act
|
||||
const actAsync = async () => await integration.fakeTestConnectionAsync(props);
|
||||
|
||||
// Assert
|
||||
await expect(actAsync()).rejects.toHaveProperty("key", "internalServerError");
|
||||
});
|
||||
|
||||
test("With response status code 503 should throw IntegrationTestConnectionError with key serviceUnavailable", async () => {
|
||||
// Arrange
|
||||
const integration = new BaseIntegrationMock({ id: "id", name: "name", url: "url", decryptedSecrets: [] });
|
||||
|
||||
const props: HandleResponseProps = {
|
||||
async queryFunctionAsync() {
|
||||
return await Promise.resolve(new Response(null, { status: 503 }));
|
||||
},
|
||||
};
|
||||
|
||||
// Act
|
||||
const actAsync = async () => await integration.fakeTestConnectionAsync(props);
|
||||
|
||||
// Assert
|
||||
await expect(actAsync()).rejects.toHaveProperty("key", "serviceUnavailable");
|
||||
});
|
||||
|
||||
test("With response status code 418 (or any other unhandled code) should throw IntegrationTestConnectionError with key commonError", async () => {
|
||||
// Arrange
|
||||
const integration = new BaseIntegrationMock({ id: "id", name: "name", url: "url", decryptedSecrets: [] });
|
||||
|
||||
const props: HandleResponseProps = {
|
||||
async queryFunctionAsync() {
|
||||
return await Promise.resolve(new Response(null, { status: 418 }));
|
||||
},
|
||||
};
|
||||
|
||||
// Act
|
||||
const actAsync = async () => await integration.fakeTestConnectionAsync(props);
|
||||
|
||||
// Assert
|
||||
await expect(actAsync()).rejects.toHaveProperty("key", "commonError");
|
||||
});
|
||||
|
||||
test("Errors from handleResponseAsync should be thrown", async () => {
|
||||
// Arrange
|
||||
const integration = new BaseIntegrationMock({ id: "id", name: "name", url: "url", decryptedSecrets: [] });
|
||||
|
||||
const errorMessage = "The error message";
|
||||
const props: HandleResponseProps = {
|
||||
async queryFunctionAsync() {
|
||||
return await Promise.resolve(new Response(null, { status: 200 }));
|
||||
},
|
||||
async handleResponseAsync() {
|
||||
return await Promise.reject(new IntegrationTestConnectionError("commonError", errorMessage));
|
||||
},
|
||||
};
|
||||
|
||||
// Act
|
||||
const actPromise = integration.fakeTestConnectionAsync(props);
|
||||
|
||||
// Assert
|
||||
await expect(actPromise).rejects.toHaveProperty("key", "commonError");
|
||||
await expect(actPromise).rejects.toHaveProperty("detailMessage", errorMessage);
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
if (result.success) return;
|
||||
expect(result.error.type === "statusCode").toBe(true);
|
||||
if (result.error.type !== "statusCode") return;
|
||||
expect(result.error.data.statusCode).toBe(500);
|
||||
expect(result.error.data.url).toContain("https://example.com");
|
||||
expect(result.error.data.reason).toBe("internalServerError");
|
||||
});
|
||||
});
|
||||
|
||||
class FakeIntegration extends Integration {
|
||||
constructor(
|
||||
private testingResult?: TestingResult,
|
||||
private error?: Error,
|
||||
) {
|
||||
super({
|
||||
id: "test",
|
||||
name: "Test",
|
||||
url: "https://example.com",
|
||||
decryptedSecrets: [],
|
||||
});
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
protected testingAsync(_: IntegrationTestingInput): Promise<TestingResult> {
|
||||
if (this.error) {
|
||||
return Promise.reject(this.error);
|
||||
}
|
||||
|
||||
return Promise.resolve(this.testingResult ?? { success: true });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
import { join } from "path";
|
||||
import type { StartedTestContainer } from "testcontainers";
|
||||
import { GenericContainer, getContainerRuntimeClient, ImageName, Wait } from "testcontainers";
|
||||
import { beforeAll, describe, expect, test } from "vitest";
|
||||
import { beforeAll, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { HomeAssistantIntegration, IntegrationTestConnectionError } from "../src";
|
||||
import { createDb } from "@homarr/db/test";
|
||||
|
||||
import { HomeAssistantIntegration } from "../src";
|
||||
import { TestConnectionError } from "../src/base/test-connection/test-connection-error";
|
||||
|
||||
vi.mock("@homarr/db", async (importActual) => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
||||
const actual = await importActual<typeof import("@homarr/db")>();
|
||||
return {
|
||||
...actual,
|
||||
db: createDb(),
|
||||
};
|
||||
});
|
||||
|
||||
const DEFAULT_API_KEY =
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJkNjQwY2VjNDFjOGU0NGM5YmRlNWQ4ZmFjMjUzYWViZiIsImlhdCI6MTcxODQ3MTE1MSwiZXhwIjoyMDMzODMxMTUxfQ.uQCZ5FZTokipa6N27DtFhLHkwYEXU1LZr0fsVTryL2Q";
|
||||
@@ -21,10 +33,10 @@ describe("Home Assistant integration", () => {
|
||||
const homeAssistantIntegration = createHomeAssistantIntegration(startedContainer);
|
||||
|
||||
// Act
|
||||
const actAsync = async () => await homeAssistantIntegration.testConnectionAsync();
|
||||
const result = await homeAssistantIntegration.testConnectionAsync();
|
||||
|
||||
// Assert
|
||||
await expect(actAsync()).resolves.not.toThrow();
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
// Cleanup
|
||||
await startedContainer.stop();
|
||||
@@ -35,10 +47,14 @@ describe("Home Assistant integration", () => {
|
||||
const homeAssistantIntegration = createHomeAssistantIntegration(startedContainer, "wrong-api-key");
|
||||
|
||||
// Act
|
||||
const actAsync = async () => await homeAssistantIntegration.testConnectionAsync();
|
||||
const result = await homeAssistantIntegration.testConnectionAsync();
|
||||
|
||||
// Assert
|
||||
await expect(actAsync()).rejects.toThrow(IntegrationTestConnectionError);
|
||||
expect(result.success).toBe(false);
|
||||
if (result.success) return;
|
||||
|
||||
expect(result.error).toBeInstanceOf(TestConnectionError);
|
||||
expect(result.error.type).toBe("authorization");
|
||||
|
||||
// Cleanup
|
||||
await startedContainer.stop();
|
||||
|
||||
@@ -2,9 +2,21 @@ import { readFile } from "fs/promises";
|
||||
import { join } from "path";
|
||||
import type { StartedTestContainer } from "testcontainers";
|
||||
import { GenericContainer, getContainerRuntimeClient, ImageName, Wait } from "testcontainers";
|
||||
import { beforeAll, describe, expect, test } from "vitest";
|
||||
import { beforeAll, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { createDb } from "@homarr/db/test";
|
||||
|
||||
import { NzbGetIntegration } from "../src";
|
||||
import { TestConnectionError } from "../src/base/test-connection/test-connection-error";
|
||||
|
||||
vi.mock("@homarr/db", async (importActual) => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
||||
const actual = await importActual<typeof import("@homarr/db")>();
|
||||
return {
|
||||
...actual,
|
||||
db: createDb(),
|
||||
};
|
||||
});
|
||||
|
||||
const username = "nzbget";
|
||||
const password = "tegbzn6789";
|
||||
@@ -22,10 +34,10 @@ describe("Nzbget integration", () => {
|
||||
const nzbGetIntegration = createNzbGetIntegration(startedContainer, username, password);
|
||||
|
||||
// Act
|
||||
const actAsync = async () => await nzbGetIntegration.testConnectionAsync();
|
||||
const result = await nzbGetIntegration.testConnectionAsync();
|
||||
|
||||
// Assert
|
||||
await expect(actAsync()).resolves.not.toThrow();
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
// Cleanup
|
||||
await startedContainer.stop();
|
||||
@@ -37,10 +49,14 @@ describe("Nzbget integration", () => {
|
||||
const nzbGetIntegration = createNzbGetIntegration(startedContainer, "wrong-user", "wrong-password");
|
||||
|
||||
// Act
|
||||
const actAsync = async () => await nzbGetIntegration.testConnectionAsync();
|
||||
const result = await nzbGetIntegration.testConnectionAsync();
|
||||
|
||||
// Assert
|
||||
await expect(actAsync()).rejects.toThrow();
|
||||
expect(result.success).toBe(false);
|
||||
if (result.success) return;
|
||||
|
||||
expect(result.error).toBeInstanceOf(TestConnectionError);
|
||||
expect(result.error.type).toBe("authorization");
|
||||
|
||||
// Cleanup
|
||||
await startedContainer.stop();
|
||||
|
||||
@@ -2,8 +2,20 @@ import type { StartedTestContainer } from "testcontainers";
|
||||
import { GenericContainer, Wait } from "testcontainers";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { createDb } from "@homarr/db/test";
|
||||
|
||||
import { PiHoleIntegrationV5, PiHoleIntegrationV6 } from "../src";
|
||||
import type { SessionStore } from "../src/base/session-store";
|
||||
import { TestConnectionError } from "../src/base/test-connection/test-connection-error";
|
||||
|
||||
vi.mock("@homarr/db", async (importActual) => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
||||
const actual = await importActual<typeof import("@homarr/db")>();
|
||||
return {
|
||||
...actual,
|
||||
db: createDb(),
|
||||
};
|
||||
});
|
||||
|
||||
const DEFAULT_PASSWORD = "12341234";
|
||||
const DEFAULT_API_KEY = "3b1434980677dcf53fa8c4a611db3b1f0f88478790097515c0abb539102778b9"; // Some hash generated from password
|
||||
@@ -27,31 +39,34 @@ describe("Pi-hole v5 integration", () => {
|
||||
await piholeContainer.stop();
|
||||
}, 20_000); // Timeout of 20 seconds
|
||||
|
||||
test("testConnectionAsync should not throw", async () => {
|
||||
test("testConnectionAsync should be successful", async () => {
|
||||
// Arrange
|
||||
const piholeContainer = await createPiHoleV5Container(DEFAULT_PASSWORD).start();
|
||||
const piHoleIntegration = createPiHoleIntegrationV5(piholeContainer, DEFAULT_API_KEY);
|
||||
|
||||
// Act
|
||||
const actAsync = async () => await piHoleIntegration.testConnectionAsync();
|
||||
const result = await piHoleIntegration.testConnectionAsync();
|
||||
|
||||
// Assert
|
||||
await expect(actAsync()).resolves.not.toThrow();
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
// Cleanup
|
||||
await piholeContainer.stop();
|
||||
}, 20_000); // Timeout of 20 seconds
|
||||
|
||||
test("testConnectionAsync should throw with wrong credentials", async () => {
|
||||
test("testConnectionAsync should fail with unauthorized for wrong credentials", async () => {
|
||||
// Arrange
|
||||
const piholeContainer = await createPiHoleV5Container(DEFAULT_PASSWORD).start();
|
||||
const piHoleIntegration = createPiHoleIntegrationV5(piholeContainer, "wrong-api-key");
|
||||
|
||||
// Act
|
||||
const actAsync = async () => await piHoleIntegration.testConnectionAsync();
|
||||
const result = await piHoleIntegration.testConnectionAsync();
|
||||
|
||||
// Assert
|
||||
await expect(actAsync()).rejects.toThrow();
|
||||
expect(result.success).toBe(false);
|
||||
if (result.success) return;
|
||||
expect(result.error).toBeInstanceOf(TestConnectionError);
|
||||
expect(result.error.type).toBe("authorization");
|
||||
|
||||
// Cleanup
|
||||
await piholeContainer.stop();
|
||||
@@ -138,31 +153,34 @@ describe("Pi-hole v6 integration", () => {
|
||||
expect(status.timer).toBeGreaterThan(timer - 10);
|
||||
}, 20_000); // Timeout of 20 seconds
|
||||
|
||||
test("testConnectionAsync should not throw", async () => {
|
||||
test("testConnectionAsync should be successful", async () => {
|
||||
// Arrange
|
||||
const piholeContainer = await createPiHoleV6Container(DEFAULT_PASSWORD).start();
|
||||
const piHoleIntegration = createPiHoleIntegrationV6(piholeContainer, DEFAULT_PASSWORD);
|
||||
|
||||
// Act
|
||||
const actAsync = async () => await piHoleIntegration.testConnectionAsync();
|
||||
const result = await piHoleIntegration.testConnectionAsync();
|
||||
|
||||
// Assert
|
||||
await expect(actAsync()).resolves.not.toThrow();
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
// Cleanup
|
||||
await piholeContainer.stop();
|
||||
}, 20_000); // Timeout of 20 seconds
|
||||
|
||||
test("testConnectionAsync should throw with wrong credentials", async () => {
|
||||
test("testConnectionAsync should fail with unauthorized for wrong credentials", async () => {
|
||||
// Arrange
|
||||
const piholeContainer = await createPiHoleV6Container(DEFAULT_PASSWORD).start();
|
||||
const piHoleIntegration = createPiHoleIntegrationV6(piholeContainer, "wrong-api-key");
|
||||
|
||||
// Act
|
||||
const actAsync = async () => await piHoleIntegration.testConnectionAsync();
|
||||
const result = await piHoleIntegration.testConnectionAsync();
|
||||
|
||||
// Assert
|
||||
await expect(actAsync()).rejects.toThrow();
|
||||
expect(result.success).toBe(false);
|
||||
if (result.success) return;
|
||||
expect(result.error).toBeInstanceOf(TestConnectionError);
|
||||
expect(result.error.type).toBe("authorization");
|
||||
|
||||
// Cleanup
|
||||
await piholeContainer.stop();
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
import { join } from "path";
|
||||
import { GenericContainer, getContainerRuntimeClient, ImageName, Wait } from "testcontainers";
|
||||
import type { StartedTestContainer } from "testcontainers";
|
||||
import { beforeAll, describe, expect, test } from "vitest";
|
||||
import { beforeAll, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { createDb } from "@homarr/db/test";
|
||||
|
||||
import { SabnzbdIntegration } from "../src";
|
||||
import { TestConnectionError } from "../src/base/test-connection/test-connection-error";
|
||||
import type { DownloadClientItem } from "../src/interfaces/downloads/download-client-items";
|
||||
|
||||
vi.mock("@homarr/db", async (importActual) => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
||||
const actual = await importActual<typeof import("@homarr/db")>();
|
||||
return {
|
||||
...actual,
|
||||
db: createDb(),
|
||||
};
|
||||
});
|
||||
|
||||
const DEFAULT_API_KEY = "8r45mfes43s3iw7x3oecto6dl9ilxnf9";
|
||||
const IMAGE_NAME = "linuxserver/sabnzbd:latest";
|
||||
|
||||
@@ -21,10 +33,10 @@ describe("Sabnzbd integration", () => {
|
||||
const sabnzbdIntegration = createSabnzbdIntegration(startedContainer, DEFAULT_API_KEY);
|
||||
|
||||
// Act
|
||||
const actAsync = async () => await sabnzbdIntegration.testConnectionAsync();
|
||||
const result = await sabnzbdIntegration.testConnectionAsync();
|
||||
|
||||
// Assert
|
||||
await expect(actAsync()).resolves.not.toThrow();
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
// Cleanup
|
||||
await startedContainer.stop();
|
||||
@@ -36,10 +48,13 @@ describe("Sabnzbd integration", () => {
|
||||
const sabnzbdIntegration = createSabnzbdIntegration(startedContainer, "wrong-api-key");
|
||||
|
||||
// Act
|
||||
const actAsync = async () => await sabnzbdIntegration.testConnectionAsync();
|
||||
const result = await sabnzbdIntegration.testConnectionAsync();
|
||||
|
||||
// Assert
|
||||
await expect(actAsync()).rejects.toThrow();
|
||||
expect(result.success).toBe(false);
|
||||
if (result.success) return;
|
||||
expect(result.error).toBeInstanceOf(TestConnectionError);
|
||||
expect(result.error.type).toBe("authorization");
|
||||
|
||||
// Cleanup
|
||||
await startedContainer.stop();
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { Button, FileInput, Group, Stack } from "@mantine/core";
|
||||
import { IconCertificate } from "@tabler/icons-react";
|
||||
import { z } from "zod";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import type { MaybePromise } from "@homarr/common/types";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { createModal } from "@homarr/modals";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { superRefineCertificateFile } from "@homarr/validation/certificates";
|
||||
|
||||
interface InnerProps {
|
||||
onSuccess?: () => MaybePromise<void>;
|
||||
}
|
||||
|
||||
export const AddCertificateModal = createModal<InnerProps>(({ actions, innerProps }) => {
|
||||
const t = useI18n();
|
||||
const form = useZodForm(
|
||||
z.object({
|
||||
file: z.instanceof(File).nullable().superRefine(superRefineCertificateFile),
|
||||
}),
|
||||
{
|
||||
initialValues: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
file: null!,
|
||||
},
|
||||
},
|
||||
);
|
||||
const { mutateAsync } = clientApi.certificates.addCertificate.useMutation();
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={form.onSubmit(async (values) => {
|
||||
const formData = new FormData();
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
formData.set("file", values.file!);
|
||||
await mutateAsync(formData, {
|
||||
async onSuccess() {
|
||||
showSuccessNotification({
|
||||
title: t("certificate.action.create.notification.success.title"),
|
||||
message: t("certificate.action.create.notification.success.message"),
|
||||
});
|
||||
await innerProps.onSuccess?.();
|
||||
actions.closeModal();
|
||||
},
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
title: t("certificate.action.create.notification.error.title"),
|
||||
message: t("certificate.action.create.notification.error.message"),
|
||||
});
|
||||
},
|
||||
});
|
||||
})}
|
||||
>
|
||||
<Stack>
|
||||
<FileInput leftSection={<IconCertificate size={16} />} {...form.getInputProps("file")} />
|
||||
<Group justify="end">
|
||||
<Button onClick={actions.closeModal} variant="subtle" color="gray">
|
||||
{t("common.action.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" loading={form.submitting}>
|
||||
{t("common.action.add")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
}).withOptions({
|
||||
defaultTitle(t) {
|
||||
return t("certificate.action.create.label");
|
||||
},
|
||||
});
|
||||
1
packages/modals-collection/src/certificates/index.ts
Normal file
1
packages/modals-collection/src/certificates/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./add-certificate-modal";
|
||||
@@ -4,3 +4,4 @@ export * from "./groups";
|
||||
export * from "./search-engines";
|
||||
export * from "./docker";
|
||||
export * from "./apps";
|
||||
export * from "./certificates";
|
||||
|
||||
@@ -702,6 +702,132 @@
|
||||
"create": "Test connection and create",
|
||||
"edit": "Test connection and save"
|
||||
},
|
||||
"error": {
|
||||
"common": {
|
||||
"cause": {
|
||||
"title": "Cause with more details"
|
||||
}
|
||||
},
|
||||
"unknown": {
|
||||
"title": "Unknown error",
|
||||
"description": "An unknown error occurred, open the cause below to see more details"
|
||||
},
|
||||
"parse": {
|
||||
"title": "Parse error",
|
||||
"description": "The response could not be parsed. Please verify that the URL is pointing to the base URL of the service."
|
||||
},
|
||||
"authorization": {
|
||||
"title": "Authorization error",
|
||||
"description": "The request was not authorized. Please verify that the credentials are correct and you have them configured with enough permissions."
|
||||
},
|
||||
"statusCode": {
|
||||
"title": "Response error",
|
||||
"description": "Received unexpected {statusCode} ({reason}) response from <url></url>. Please verify that the URL is pointing to the base URL of the integration.",
|
||||
"otherDescription": "Received unexpected {statusCode} response from <url></url>. Please verify that the URL is pointing to the base URL of the integration.",
|
||||
"reason": {
|
||||
"badRequest": "Bad request",
|
||||
"notFound": "Not found",
|
||||
"tooManyRequests": "Too many requests",
|
||||
"internalServerError": "Internal server error",
|
||||
"serviceUnavailable": "Service unavailable",
|
||||
"gatewayTimeout": "Gateway timeout"
|
||||
}
|
||||
},
|
||||
"certificate": {
|
||||
"title": "Certificate error",
|
||||
"description": {
|
||||
"expired": "The certificate has expired.",
|
||||
"notYetValid": "The certificate is not yet valid.",
|
||||
"untrusted": "The certificate is not trusted.",
|
||||
"hostnameMismatch": "The certificate hostname does not match the URL."
|
||||
},
|
||||
"alert": {
|
||||
"permission": {
|
||||
"title": "Not enough permissions",
|
||||
"message": "You are not allowed to trust or upload certificates. Please contact your administrator to upload the necessary root certificate."
|
||||
},
|
||||
"hostnameMismatch": {
|
||||
"title": "Hostname mismatch",
|
||||
"message": "The hostname in the certificate does not match the hostname you are connecting to. This could indicate a security risk, but you can still choose to trust this certificate."
|
||||
},
|
||||
"extract": {
|
||||
"title": "CA certificate extraction failed",
|
||||
"message": "Only self signed certificates without a chain can be fetched automatically. If you are using a self signed certificate, please make sure to upload the CA certificate manually. You can find instructions on how to do this <docsLink></docsLink>."
|
||||
}
|
||||
},
|
||||
"action": {
|
||||
"retry": {
|
||||
"label": "Retry creation"
|
||||
},
|
||||
"trust": {
|
||||
"label": "Trust certificate"
|
||||
},
|
||||
"upload": {
|
||||
"label": "Upload certificate"
|
||||
}
|
||||
},
|
||||
"hostnameMismatch": {
|
||||
"confirm": {
|
||||
"title": "Trust hostname mismatch",
|
||||
"message": "Are you sure you want to trust the certificate with a hostname mismatch?"
|
||||
},
|
||||
"notification": {
|
||||
"success": {
|
||||
"title": "Trusted certificate",
|
||||
"message": "Added hostname to trusted certificate list"
|
||||
},
|
||||
"error": {
|
||||
"title": "Failed to trust certificate",
|
||||
"message": "The certificate with a hostname mismatch could not be trusted"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selfSigned": {
|
||||
"confirm": {
|
||||
"title": "Trust self signed certificate",
|
||||
"message": "Are you sure you want to trust this self signed certificate?"
|
||||
},
|
||||
"notification": {
|
||||
"success": {
|
||||
"title": "Trusted certificate",
|
||||
"message": "Added certificate to trusted certificate list"
|
||||
},
|
||||
"error": {
|
||||
"title": "Failed to trust certificate",
|
||||
"message": "Failed to add certificate to trusted certificate list"
|
||||
}
|
||||
}
|
||||
},
|
||||
"details": {
|
||||
"title": "Details",
|
||||
"description": "Review the certificate information before deciding to trust it.",
|
||||
"content": {
|
||||
"action": "Show content",
|
||||
"title": "PEM Certificate"
|
||||
}
|
||||
}
|
||||
},
|
||||
"request": {
|
||||
"title": "Request error",
|
||||
"description": {
|
||||
"connection": {
|
||||
"hostUnreachable": "The server could not be reached. This usually means the host is offline or unreachable from your network.",
|
||||
"networkUnreachable": "The network is unreachable. Please check your internet connection or network configuration.",
|
||||
"refused": "The server refused the connection. It may not be running or is rejecting requests on the specified port.",
|
||||
"reset": "The connection was unexpectedly closed by the server. This can happen if the server is unstable or restarted."
|
||||
},
|
||||
"dns": {
|
||||
"notFound": "The server address could not be found. Please check the URL for typos or invalid domain names.",
|
||||
"timeout": "DNS lookup timed out. This may be a temporary issue—please try again in a few moments.",
|
||||
"noAnswer": "The DNS server didn't return a valid response. The domain may exist but has no valid records."
|
||||
},
|
||||
"timeout": {
|
||||
"aborted": "The request was aborted before it could complete. This might be due to a user action or system timeout.",
|
||||
"timeout": "The request took too long to complete and was timed out. Check your network or try again later."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"alertNotice": "The Save button is enabled once a successful connection is established",
|
||||
"notification": {
|
||||
"success": {
|
||||
@@ -878,6 +1004,7 @@
|
||||
"cancel": "Cancel",
|
||||
"delete": "Delete",
|
||||
"discard": "Discard",
|
||||
"close": "Close",
|
||||
"confirm": "Confirm",
|
||||
"continue": "Continue",
|
||||
"previous": "Previous",
|
||||
@@ -3496,7 +3623,10 @@
|
||||
"label": "Logs"
|
||||
},
|
||||
"certificates": {
|
||||
"label": "Certificates"
|
||||
"label": "Certificates",
|
||||
"hostnames": {
|
||||
"label": "Hostnames"
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
@@ -3894,6 +4024,29 @@
|
||||
}
|
||||
},
|
||||
"certificate": {
|
||||
"field": {
|
||||
"hostname": {
|
||||
"label": "Hostname"
|
||||
},
|
||||
"subject": {
|
||||
"label": "Subject"
|
||||
},
|
||||
"issuer": {
|
||||
"label": "Issuer"
|
||||
},
|
||||
"validFrom": {
|
||||
"label": "Valid from"
|
||||
},
|
||||
"validTo": {
|
||||
"label": "Valid to"
|
||||
},
|
||||
"serialNumber": {
|
||||
"label": "Serial number"
|
||||
},
|
||||
"fingerprint": {
|
||||
"label": "Fingerprint"
|
||||
}
|
||||
},
|
||||
"page": {
|
||||
"list": {
|
||||
"title": "Trusted certificates",
|
||||
@@ -3905,7 +4058,16 @@
|
||||
"title": "Invalid certificate",
|
||||
"description": "Failed to parse certificate"
|
||||
},
|
||||
"expires": "Expires {when}"
|
||||
"expires": "Expires {when}",
|
||||
"toHostnames": "Trusted hostnames"
|
||||
},
|
||||
"hostnames": {
|
||||
"title": "Trusted certificate hostnames",
|
||||
"description": "Some certificates do not allow the specific domain Homarr uses to request them, because of this all trusted hostnames with their certificate thumbprints are used to bypass these restrictions.",
|
||||
"noResults": {
|
||||
"title": "There are no hostnames yet"
|
||||
},
|
||||
"toCertificates": "Certificates"
|
||||
}
|
||||
},
|
||||
"action": {
|
||||
@@ -3935,6 +4097,20 @@
|
||||
"message": "The certificate could not be removed"
|
||||
}
|
||||
}
|
||||
},
|
||||
"removeHostname": {
|
||||
"label": "Remove trusted hostname",
|
||||
"confirm": "Are you sure you want to remove this trusted hostname? This can cause some integrations to stop working.",
|
||||
"notification": {
|
||||
"success": {
|
||||
"title": "Hostname removed",
|
||||
"message": "The hostname was removed successfully"
|
||||
},
|
||||
"error": {
|
||||
"title": "Hostname not removed",
|
||||
"message": "The hostname could not be removed"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user