diff --git a/.env.example b/.env.example
index 854c2b19a..75f6c3486 100644
--- a/.env.example
+++ b/.env.example
@@ -28,6 +28,10 @@ DB_URL='FULL_PATH_TO_YOUR_SQLITE_DB_FILE'
# DB_PASSWORD='password'
# DB_NAME='name-of-database'
+# The below path can be used to store trusted certificates during development, it is not required and can be left empty.
+# If it is used, please use the full path to the directory where the certificates are stored.
+# LOCAL_CERTIFICATE_PATH='FULL_PATH_TO_CERTIFICATES'
+
TURBO_TELEMETRY_DISABLED=1
# Configure logging to use winston logger
diff --git a/.gitignore b/.gitignore
index 3d0fc5393..76c8f08fc 100644
--- a/.gitignore
+++ b/.gitignore
@@ -64,4 +64,4 @@ packages/cli/cli.cjs
e2e/shared/tmp
#personal backgrounds
-apps/nextjs/public/images/background.png
\ No newline at end of file
+apps/nextjs/public/images/background.png
diff --git a/apps/nextjs/package.json b/apps/nextjs/package.json
index 0006f8c36..bbbc36c6d 100644
--- a/apps/nextjs/package.json
+++ b/apps/nextjs/package.json
@@ -18,6 +18,7 @@
"@homarr/analytics": "workspace:^0.1.0",
"@homarr/api": "workspace:^0.1.0",
"@homarr/auth": "workspace:^0.1.0",
+ "@homarr/certificates": "workspace:^0.1.0",
"@homarr/common": "workspace:^0.1.0",
"@homarr/cron-job-status": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0",
diff --git a/apps/nextjs/src/app/[locale]/manage/layout.tsx b/apps/nextjs/src/app/[locale]/manage/layout.tsx
index 0613e46d6..f6b25c2c5 100644
--- a/apps/nextjs/src/app/[locale]/manage/layout.tsx
+++ b/apps/nextjs/src/app/[locale]/manage/layout.tsx
@@ -6,6 +6,7 @@ import {
IconBrandDiscord,
IconBrandDocker,
IconBrandGithub,
+ IconCertificate,
IconGitFork,
IconHome,
IconInfoSmall,
@@ -119,6 +120,12 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
href: "/manage/tools/logs",
hidden: !session?.user.permissions.includes("other-view-logs"),
},
+ {
+ label: t("items.tools.items.certificates"),
+ icon: IconCertificate,
+ href: "/manage/tools/certificates",
+ hidden: !session?.user.permissions.includes("admin"),
+ },
{
label: t("items.tools.items.tasks"),
icon: IconReport,
diff --git a/apps/nextjs/src/app/[locale]/manage/tools/certificates/_components/add-certificate.tsx b/apps/nextjs/src/app/[locale]/manage/tools/certificates/_components/add-certificate.tsx
new file mode 100644
index 000000000..312abdffb
--- /dev/null
+++ b/apps/nextjs/src/app/[locale]/manage/tools/certificates/_components/add-certificate.tsx
@@ -0,0 +1,81 @@
+"use client";
+
+import { Button, FileInput, Group, Stack } from "@mantine/core";
+import { IconCertificate } from "@tabler/icons-react";
+
+import { clientApi } from "@homarr/api/client";
+import { revalidatePathActionAsync } from "@homarr/common/client";
+import { useZodForm } from "@homarr/form";
+import { createModal, useModalAction } from "@homarr/modals";
+import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
+import { useI18n } from "@homarr/translation/client";
+import { superRefineCertificateFile, z } from "@homarr/validation";
+
+export const AddCertificateButton = () => {
+ const { openModal } = useModalAction(AddCertificateModal);
+ const t = useI18n();
+
+ const handleClick = () => {
+ openModal({});
+ };
+
+ return ;
+};
+
+const AddCertificateModal = createModal(({ actions }) => {
+ 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 (
+
+ );
+}).withOptions({
+ defaultTitle(t) {
+ return t("certificate.action.create.label");
+ },
+});
diff --git a/apps/nextjs/src/app/[locale]/manage/tools/certificates/_components/remove-certificate.tsx b/apps/nextjs/src/app/[locale]/manage/tools/certificates/_components/remove-certificate.tsx
new file mode 100644
index 000000000..04e1c7613
--- /dev/null
+++ b/apps/nextjs/src/app/[locale]/manage/tools/certificates/_components/remove-certificate.tsx
@@ -0,0 +1,54 @@
+"use client";
+
+import { ActionIcon } from "@mantine/core";
+import { IconTrash } from "@tabler/icons-react";
+
+import { clientApi } from "@homarr/api/client";
+import { revalidatePathActionAsync } from "@homarr/common/client";
+import { useConfirmModal } from "@homarr/modals";
+import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
+import { useI18n } from "@homarr/translation/client";
+
+interface RemoveCertificateProps {
+ fileName: string;
+}
+
+export const RemoveCertificate = ({ fileName }: RemoveCertificateProps) => {
+ const { openConfirmModal } = useConfirmModal();
+ const { mutateAsync } = clientApi.certificates.removeCertificate.useMutation();
+ const t = useI18n();
+
+ const handleClick = () => {
+ openConfirmModal({
+ title: t("certificate.action.remove.label"),
+ children: t("certificate.action.remove.confirm"),
+ // eslint-disable-next-line no-restricted-syntax
+ async onConfirm() {
+ await mutateAsync(
+ { fileName },
+ {
+ async onSuccess() {
+ showSuccessNotification({
+ title: t("certificate.action.remove.notification.success.title"),
+ message: t("certificate.action.remove.notification.success.message"),
+ });
+ await revalidatePathActionAsync("/manage/tools/certificates");
+ },
+ onError() {
+ showErrorNotification({
+ title: t("certificate.action.remove.notification.error.title"),
+ message: t("certificate.action.remove.notification.error.message"),
+ });
+ },
+ },
+ );
+ },
+ });
+ };
+
+ return (
+
+
+
+ );
+};
diff --git a/apps/nextjs/src/app/[locale]/manage/tools/certificates/page.tsx b/apps/nextjs/src/app/[locale]/manage/tools/certificates/page.tsx
new file mode 100644
index 000000000..0be4bcac3
--- /dev/null
+++ b/apps/nextjs/src/app/[locale]/manage/tools/certificates/page.tsx
@@ -0,0 +1,97 @@
+import { X509Certificate } from "node:crypto";
+import { notFound } from "next/navigation";
+import { Card, Group, SimpleGrid, Stack, Text, Title } from "@mantine/core";
+import { IconCertificate, IconCertificateOff } from "@tabler/icons-react";
+import dayjs from "dayjs";
+
+import { auth } from "@homarr/auth/next";
+import { loadCustomRootCertificatesAsync } from "@homarr/certificates/server";
+import { getMantineColor } from "@homarr/common";
+import type { SupportedLanguage } from "@homarr/translation";
+import { getI18n } from "@homarr/translation/server";
+
+import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
+import { NoResults } from "~/components/no-results";
+import { AddCertificateButton } from "./_components/add-certificate";
+import { RemoveCertificate } from "./_components/remove-certificate";
+
+interface CertificatesPageProps {
+ params: Promise<{
+ locale: SupportedLanguage;
+ }>;
+}
+
+export default async function CertificatesPage({ params }: CertificatesPageProps) {
+ const session = await auth();
+ if (!session?.user.permissions.includes("admin")) {
+ notFound();
+ }
+
+ const { locale } = await params;
+ const t = await getI18n();
+ const certificates = await loadCustomRootCertificatesAsync();
+ const x509Certificates = certificates
+ .map((cert) => ({
+ ...cert,
+ x509: new X509Certificate(cert.content),
+ }))
+ .sort((certA, certB) => certA.x509.validToDate.getTime() - certB.x509.validToDate.getTime());
+
+ return (
+ <>
+
+
+
+
+
+ {t("certificate.page.list.title")}
+ {t("certificate.page.list.description")}
+
+
+
+
+
+ {x509Certificates.length === 0 && (
+
+ )}
+
+
+ {x509Certificates.map((cert) => (
+
+
+
+
+
+ {cert.x509.subject}
+
+ {cert.fileName}
+
+
+
+
+ {t("certificate.page.list.expires", {
+ when: new Intl.RelativeTimeFormat(locale).format(
+ dayjs(cert.x509.validToDate).diff(dayjs(), "days"),
+ "days",
+ ),
+ })}
+
+
+
+
+
+
+ ))}
+
+
+ >
+ );
+}
+
+const iconColor = (validTo: Date) => {
+ const daysUntilInvalid = dayjs(validTo).diff(new Date(), "days");
+ if (daysUntilInvalid < 1) return "red";
+ if (daysUntilInvalid < 7) return "orange";
+ if (daysUntilInvalid < 30) return "yellow";
+ return "green";
+};
diff --git a/apps/tasks/src/undici-log-agent-override.ts b/apps/tasks/src/undici-log-agent-override.ts
index 8f0e49f00..f05432c02 100644
--- a/apps/tasks/src/undici-log-agent-override.ts
+++ b/apps/tasks/src/undici-log-agent-override.ts
@@ -1,35 +1,6 @@
-import type { Dispatcher } from "undici";
-import { Agent, setGlobalDispatcher } from "undici";
+import { setGlobalDispatcher } from "undici";
-import { logger } from "@homarr/log";
-
-export class LoggingAgent extends Agent {
- constructor(...props: ConstructorParameters) {
- super(...props);
- }
-
- dispatch(options: Dispatcher.DispatchOptions, handler: Dispatcher.DispatchHandler): boolean {
- const url = new URL(`${options.origin as string}${options.path}`);
-
- // The below code should prevent sensitive data from being logged as
- // some integrations use query parameters for auth
- url.searchParams.forEach((value, key) => {
- if (value === "") return; // Skip empty values
- if (/^-?\d{1,12}$/.test(value)) return; // Skip small numbers
- if (value === "true" || value === "false") return; // Skip boolean values
- if (/^[a-zA-Z]{1,12}$/.test(value)) return; // Skip short strings
- if (/^\d{4}-\d{2}-\d{2}$/.test(value)) return; // Skip dates
- if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(value)) return; // Skip date times
-
- url.searchParams.set(key, "REDACTED");
- });
-
- logger.info(
- `Dispatching request ${url.toString().replaceAll("=&", "&")} (${Object.keys(options.headers ?? {}).length} headers)`,
- );
- return super.dispatch(options, handler);
- }
-}
+import { LoggingAgent } from "@homarr/common/server";
const agent = new LoggingAgent();
setGlobalDispatcher(agent);
diff --git a/packages/api/package.json b/packages/api/package.json
index ca541bd0c..9338eba7f 100644
--- a/packages/api/package.json
+++ b/packages/api/package.json
@@ -22,6 +22,7 @@
"prettier": "@homarr/prettier-config",
"dependencies": {
"@homarr/auth": "workspace:^0.1.0",
+ "@homarr/certificates": "workspace:^0.1.0",
"@homarr/common": "workspace:^0.1.0",
"@homarr/cron-job-runner": "workspace:^0.1.0",
"@homarr/cron-job-status": "workspace:^0.1.0",
diff --git a/packages/api/src/root.ts b/packages/api/src/root.ts
index fa2b22209..a0c04f460 100644
--- a/packages/api/src/root.ts
+++ b/packages/api/src/root.ts
@@ -1,6 +1,7 @@
import { apiKeysRouter } from "./router/apiKeys";
import { appRouter as innerAppRouter } from "./router/app";
import { boardRouter } from "./router/board";
+import { certificateRouter } from "./router/certificates/certificate-router";
import { cronJobsRouter } from "./router/cron-jobs";
import { dockerRouter } from "./router/docker/docker-router";
import { groupRouter } from "./router/group";
@@ -41,6 +42,7 @@ export const appRouter = createTRPCRouter({
apiKeys: apiKeysRouter,
media: mediaRouter,
updateChecker: updateCheckerRouter,
+ certificates: certificateRouter,
});
// export type definition of API
diff --git a/packages/api/src/router/certificates/certificate-router.ts b/packages/api/src/router/certificates/certificate-router.ts
new file mode 100644
index 000000000..d50150c80
--- /dev/null
+++ b/packages/api/src/router/certificates/certificate-router.ts
@@ -0,0 +1,27 @@
+import { z } from "zod";
+import { zfd } from "zod-form-data";
+
+import { addCustomRootCertificateAsync, removeCustomRootCertificateAsync } from "@homarr/certificates/server";
+import { superRefineCertificateFile, validation } from "@homarr/validation";
+
+import { createTRPCRouter, permissionRequiredProcedure } from "../../trpc";
+
+export const certificateRouter = createTRPCRouter({
+ addCertificate: permissionRequiredProcedure
+ .requiresPermission("admin")
+ .input(
+ zfd.formData({
+ file: zfd.file().superRefine(superRefineCertificateFile),
+ }),
+ )
+ .mutation(async ({ input }) => {
+ const content = await input.file.text();
+ await addCustomRootCertificateAsync(input.file.name, content);
+ }),
+ removeCertificate: permissionRequiredProcedure
+ .requiresPermission("admin")
+ .input(z.object({ fileName: validation.certificates.validFileNameSchema }))
+ .mutation(async ({ input }) => {
+ await removeCustomRootCertificateAsync(input.fileName);
+ }),
+});
diff --git a/packages/certificates/eslint.config.js b/packages/certificates/eslint.config.js
new file mode 100644
index 000000000..5b19b6f8a
--- /dev/null
+++ b/packages/certificates/eslint.config.js
@@ -0,0 +1,9 @@
+import baseConfig from "@homarr/eslint-config/base";
+
+/** @type {import('typescript-eslint').Config} */
+export default [
+ {
+ ignores: [],
+ },
+ ...baseConfig,
+];
diff --git a/packages/certificates/package.json b/packages/certificates/package.json
new file mode 100644
index 000000000..26dca44ca
--- /dev/null
+++ b/packages/certificates/package.json
@@ -0,0 +1,35 @@
+{
+ "name": "@homarr/certificates",
+ "version": "0.1.0",
+ "private": true,
+ "license": "MIT",
+ "type": "module",
+ "exports": {
+ "./server": "./src/server.ts"
+ },
+ "typesVersions": {
+ "*": {
+ "*": [
+ "src/*"
+ ]
+ }
+ },
+ "scripts": {
+ "clean": "rm -rf .turbo node_modules",
+ "format": "prettier --check . --ignore-path ../../.gitignore",
+ "lint": "eslint",
+ "typecheck": "tsc --noEmit"
+ },
+ "prettier": "@homarr/prettier-config",
+ "dependencies": {
+ "@homarr/common": "workspace:^0.1.0",
+ "undici": "7.2.3"
+ },
+ "devDependencies": {
+ "@homarr/eslint-config": "workspace:^0.2.0",
+ "@homarr/prettier-config": "workspace:^0.1.0",
+ "@homarr/tsconfig": "workspace:^0.1.0",
+ "eslint": "^9.18.0",
+ "typescript": "^5.7.3"
+ }
+}
diff --git a/packages/certificates/src/server.ts b/packages/certificates/src/server.ts
new file mode 100644
index 000000000..334c1f47f
--- /dev/null
+++ b/packages/certificates/src/server.ts
@@ -0,0 +1,87 @@
+import fsSync from "node:fs";
+import fs from "node:fs/promises";
+import { Agent } from "node:https";
+import path from "node:path";
+import { rootCertificates } from "node:tls";
+import axios from "axios";
+import { fetch } from "undici";
+
+import { LoggingAgent } from "@homarr/common/server";
+
+const getCertificateFolder = () => {
+ return process.env.NODE_ENV === "production"
+ ? path.join("/appdata", "trusted-certificates")
+ : process.env.LOCAL_CERTIFICATE_PATH;
+};
+
+export const loadCustomRootCertificatesAsync = async () => {
+ const folder = getCertificateFolder();
+
+ if (!folder) {
+ return [];
+ }
+
+ if (!fsSync.existsSync(folder)) {
+ await fs.mkdir(folder, { recursive: true });
+ }
+
+ const dirContent = await fs.readdir(folder);
+ return await Promise.all(
+ dirContent
+ .filter((file) => file.endsWith(".crt"))
+ .map(async (file) => ({
+ content: await fs.readFile(path.join(folder, file), "utf8"),
+ fileName: file,
+ })),
+ );
+};
+
+export const removeCustomRootCertificateAsync = async (fileName: string) => {
+ const folder = getCertificateFolder();
+ if (!folder) {
+ return;
+ }
+
+ await fs.rm(path.join(folder, fileName));
+};
+
+export const addCustomRootCertificateAsync = async (fileName: string, content: string) => {
+ const folder = getCertificateFolder();
+ if (!folder) {
+ throw new Error(
+ "When you want to use custom certificates locally you need to set LOCAL_CERTIFICATE_PATH to an absolute path",
+ );
+ }
+
+ if (fileName.includes("/")) {
+ throw new Error("Invalid file name");
+ }
+
+ await fs.writeFile(path.join(folder, fileName), content);
+};
+
+export const createCertificateAgentAsync = async () => {
+ const customCertificates = await loadCustomRootCertificatesAsync();
+ return new LoggingAgent({
+ connect: {
+ ca: rootCertificates.concat(customCertificates.map((cert) => cert.content)),
+ },
+ });
+};
+
+export const createAxiosCertificateInstanceAsync = async () => {
+ const customCertificates = await loadCustomRootCertificatesAsync();
+ return axios.create({
+ httpsAgent: new Agent({
+ ca: rootCertificates.concat(customCertificates.map((cert) => cert.content)),
+ }),
+ });
+};
+
+export const fetchWithTrustedCertificatesAsync: typeof fetch = async (url, options) => {
+ const agent = await createCertificateAgentAsync();
+ return fetch(url, {
+ ...options,
+ dispatcher: agent,
+ });
+};
diff --git a/packages/certificates/tsconfig.json b/packages/certificates/tsconfig.json
new file mode 100644
index 000000000..cbe8483d9
--- /dev/null
+++ b/packages/certificates/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "extends": "@homarr/tsconfig/base.json",
+ "compilerOptions": {
+ "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
+ },
+ "include": ["*.ts", "src"],
+ "exclude": ["node_modules"]
+}
diff --git a/packages/common/package.json b/packages/common/package.json
index f1705a4ed..6e226ab72 100644
--- a/packages/common/package.json
+++ b/packages/common/package.json
@@ -32,6 +32,7 @@
"next": "15.1.4",
"react": "19.0.0",
"react-dom": "19.0.0",
+ "undici": "7.2.3",
"zod": "^3.24.1"
},
"devDependencies": {
diff --git a/packages/common/src/fetch-agent.ts b/packages/common/src/fetch-agent.ts
new file mode 100644
index 000000000..f19a866af
--- /dev/null
+++ b/packages/common/src/fetch-agent.ts
@@ -0,0 +1,32 @@
+import type { Dispatcher } from "undici";
+import { Agent } from "undici";
+
+import { logger } from "@homarr/log";
+
+export class LoggingAgent extends Agent {
+ constructor(...props: ConstructorParameters) {
+ super(...props);
+ }
+
+ dispatch(options: Dispatcher.DispatchOptions, handler: Dispatcher.DispatchHandler): boolean {
+ const url = new URL(`${options.origin as string}${options.path}`);
+
+ // The below code should prevent sensitive data from being logged as
+ // some integrations use query parameters for auth
+ url.searchParams.forEach((value, key) => {
+ if (value === "") return; // Skip empty values
+ if (/^-?\d{1,12}$/.test(value)) return; // Skip small numbers
+ if (value === "true" || value === "false") return; // Skip boolean values
+ if (/^[a-zA-Z]{1,12}$/.test(value)) return; // Skip short strings
+ if (/^\d{4}-\d{2}-\d{2}$/.test(value)) return; // Skip dates
+ if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(value)) return; // Skip date times
+
+ url.searchParams.set(key, "REDACTED");
+ });
+
+ logger.info(
+ `Dispatching request ${url.toString().replaceAll("=&", "&")} (${Object.keys(options.headers ?? {}).length} headers)`,
+ );
+ return super.dispatch(options, handler);
+ }
+}
diff --git a/packages/common/src/server.ts b/packages/common/src/server.ts
index 3b0958cbc..8203e2757 100644
--- a/packages/common/src/server.ts
+++ b/packages/common/src/server.ts
@@ -1,3 +1,4 @@
export * from "./security";
export * from "./encryption";
export * from "./user-agent";
+export * from "./fetch-agent";
diff --git a/apps/tasks/src/test/undici-log-agent-override.spec.ts b/packages/common/src/test/fetch-agent.spec.ts
similarity index 98%
rename from apps/tasks/src/test/undici-log-agent-override.spec.ts
rename to packages/common/src/test/fetch-agent.spec.ts
index f237c22e7..064335018 100644
--- a/apps/tasks/src/test/undici-log-agent-override.spec.ts
+++ b/packages/common/src/test/fetch-agent.spec.ts
@@ -3,7 +3,7 @@ import { describe, expect, test, vi } from "vitest";
import { logger } from "@homarr/log";
-import { LoggingAgent } from "~/undici-log-agent-override";
+import { LoggingAgent } from "../fetch-agent";
vi.mock("undici", () => {
return {
diff --git a/packages/integrations/package.json b/packages/integrations/package.json
index 9644991fb..de505b975 100644
--- a/packages/integrations/package.json
+++ b/packages/integrations/package.json
@@ -27,6 +27,7 @@
"@ctrl/deluge": "^7.1.0",
"@ctrl/qbittorrent": "^9.2.0",
"@ctrl/transmission": "^7.2.0",
+ "@homarr/certificates": "workspace:^0.1.0",
"@homarr/common": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0",
"@homarr/definitions": "workspace:^0.1.0",
@@ -35,6 +36,7 @@
"@homarr/translation": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@jellyfin/sdk": "^0.11.0",
+ "undici": "7.2.3",
"xml2js": "^0.6.2"
},
"devDependencies": {
diff --git a/packages/integrations/src/adguard-home/adguard-home-integration.ts b/packages/integrations/src/adguard-home/adguard-home-integration.ts
index d7aca6add..2ba8880ef 100644
--- a/packages/integrations/src/adguard-home/adguard-home-integration.ts
+++ b/packages/integrations/src/adguard-home/adguard-home-integration.ts
@@ -1,3 +1,5 @@
+import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
+
import { Integration } from "../base/integration";
import { IntegrationTestConnectionError } from "../base/test-connection-error";
import type { DnsHoleSummaryIntegration } from "../interfaces/dns-hole-summary/dns-hole-summary-integration";
@@ -6,7 +8,7 @@ import { filteringStatusSchema, statsResponseSchema, statusResponseSchema } from
export class AdGuardHomeIntegration extends Integration implements DnsHoleSummaryIntegration {
public async getSummaryAsync(): Promise {
- const statsResponse = await fetch(this.url("/control/stats"), {
+ const statsResponse = await fetchWithTrustedCertificatesAsync(this.url("/control/stats"), {
headers: {
Authorization: `Basic ${this.getAuthorizationHeaderValue()}`,
},
@@ -18,7 +20,7 @@ export class AdGuardHomeIntegration extends Integration implements DnsHoleSummar
);
}
- const statusResponse = await fetch(this.url("/control/status"), {
+ const statusResponse = await fetchWithTrustedCertificatesAsync(this.url("/control/status"), {
headers: {
Authorization: `Basic ${this.getAuthorizationHeaderValue()}`,
},
@@ -30,7 +32,7 @@ export class AdGuardHomeIntegration extends Integration implements DnsHoleSummar
);
}
- const filteringStatusResponse = await fetch(this.url("/control/filtering/status"), {
+ const filteringStatusResponse = await fetchWithTrustedCertificatesAsync(this.url("/control/filtering/status"), {
headers: {
Authorization: `Basic ${this.getAuthorizationHeaderValue()}`,
},
@@ -86,7 +88,7 @@ export class AdGuardHomeIntegration extends Integration implements DnsHoleSummar
public async testConnectionAsync(): Promise {
await super.handleTestConnectionResponseAsync({
queryFunctionAsync: async () => {
- return await fetch(this.url("/control/status"), {
+ return await fetchWithTrustedCertificatesAsync(this.url("/control/status"), {
headers: {
Authorization: `Basic ${this.getAuthorizationHeaderValue()}`,
},
@@ -94,7 +96,7 @@ export class AdGuardHomeIntegration extends Integration implements DnsHoleSummar
},
handleResponseAsync: async (response) => {
try {
- const result = (await response.json()) as unknown;
+ const result = await response.json();
if (typeof result === "object" && result !== null) return;
} catch {
throw new IntegrationTestConnectionError("invalidJson");
@@ -106,7 +108,7 @@ export class AdGuardHomeIntegration extends Integration implements DnsHoleSummar
}
public async enableAsync(): Promise {
- const response = await fetch(this.url("/control/protection"), {
+ const response = await fetchWithTrustedCertificatesAsync(this.url("/control/protection"), {
method: "POST",
headers: {
"Content-Type": "application/json",
@@ -124,7 +126,7 @@ export class AdGuardHomeIntegration extends Integration implements DnsHoleSummar
}
public async disableAsync(duration = 0): Promise {
- const response = await fetch(this.url("/control/protection"), {
+ const response = await fetchWithTrustedCertificatesAsync(this.url("/control/protection"), {
method: "POST",
headers: {
"Content-Type": "application/json",
diff --git a/packages/integrations/src/base/integration.ts b/packages/integrations/src/base/integration.ts
index 413ec6096..dbfee6ccc 100644
--- a/packages/integrations/src/base/integration.ts
+++ b/packages/integrations/src/base/integration.ts
@@ -1,3 +1,5 @@
+import type { Response } from "undici";
+
import { extractErrorMessage, removeTrailingSlash } from "@homarr/common";
import type { IntegrationSecretKind } from "@homarr/definitions";
import { logger } from "@homarr/log";
diff --git a/packages/integrations/src/dashdot/dashdot-integration.ts b/packages/integrations/src/dashdot/dashdot-integration.ts
index 271e17409..315c46177 100644
--- a/packages/integrations/src/dashdot/dashdot-integration.ts
+++ b/packages/integrations/src/dashdot/dashdot-integration.ts
@@ -4,6 +4,7 @@ import "@homarr/redis";
import dayjs from "dayjs";
+import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { z } from "@homarr/validation";
import { createChannelEventHistory } from "../../../redis/src/lib/channel";
@@ -12,7 +13,7 @@ import type { HealthMonitoring } from "../types";
export class DashDotIntegration extends Integration {
public async testConnectionAsync(): Promise {
- const response = await fetch(this.url("/info"));
+ const response = await fetchWithTrustedCertificatesAsync(this.url("/info"));
await response.json();
}
@@ -52,7 +53,7 @@ export class DashDotIntegration extends Integration {
}
private async getInfoAsync() {
- const infoResponse = await fetch(this.url("/info"));
+ const infoResponse = await fetchWithTrustedCertificatesAsync(this.url("/info"));
const serverInfo = await internalServerInfoApi.parseAsync(await infoResponse.json());
return {
maxAvailableMemoryBytes: serverInfo.ram.size,
@@ -66,7 +67,7 @@ export class DashDotIntegration extends Integration {
private async getCurrentCpuLoadAsync() {
const channel = this.getChannel();
- const cpu = await fetch(this.url("/load/cpu"));
+ const cpu = await fetchWithTrustedCertificatesAsync(this.url("/load/cpu"));
const data = await cpuLoadPerCoreApiList.parseAsync(await cpu.json());
await channel.pushAsync(data);
return {
@@ -88,12 +89,12 @@ export class DashDotIntegration extends Integration {
}
private async getCurrentStorageLoadAsync() {
- const storageLoad = await fetch(this.url("/load/storage"));
+ const storageLoad = await fetchWithTrustedCertificatesAsync(this.url("/load/storage"));
return (await storageLoad.json()) as number[];
}
private async getCurrentMemoryLoadAsync() {
- const memoryLoad = await fetch(this.url("/load/ram"));
+ const memoryLoad = await fetchWithTrustedCertificatesAsync(this.url("/load/ram"));
const data = await memoryLoadApi.parseAsync(await memoryLoad.json());
return {
loadInBytes: data.load,
diff --git a/packages/integrations/src/download-client/deluge/deluge-integration.ts b/packages/integrations/src/download-client/deluge/deluge-integration.ts
index 29067f2c0..80c04b180 100644
--- a/packages/integrations/src/download-client/deluge/deluge-integration.ts
+++ b/packages/integrations/src/download-client/deluge/deluge-integration.ts
@@ -1,6 +1,8 @@
import { Deluge } from "@ctrl/deluge";
import dayjs from "dayjs";
+import { createCertificateAgentAsync } from "@homarr/certificates/server";
+
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";
@@ -8,13 +10,13 @@ import type { DownloadClientStatus } from "../../interfaces/downloads/download-c
export class DelugeIntegration extends DownloadClientIntegration {
public async testConnectionAsync(): Promise {
- const client = this.getClient();
+ const client = await this.getClientAsync();
await client.login();
}
public async getClientJobsAndStatusAsync(): Promise {
const type = "torrent";
- const client = this.getClient();
+ const client = await this.getClientAsync();
const {
stats: { download_rate, upload_rate },
torrents: rawTorrents,
@@ -57,7 +59,7 @@ export class DelugeIntegration extends DownloadClientIntegration {
}
public async pauseQueueAsync() {
- const client = this.getClient();
+ const client = await this.getClientAsync();
const store = (await client.listTorrents()).result.torrents;
await Promise.all(
Object.entries(store).map(async ([id]) => {
@@ -67,11 +69,12 @@ export class DelugeIntegration extends DownloadClientIntegration {
}
public async pauseItemAsync({ id }: DownloadClientItem): Promise {
- await this.getClient().pauseTorrent(id);
+ const client = await this.getClientAsync();
+ await client.pauseTorrent(id);
}
public async resumeQueueAsync() {
- const client = this.getClient();
+ const client = await this.getClientAsync();
const store = (await client.listTorrents()).result.torrents;
await Promise.all(
Object.entries(store).map(async ([id]) => {
@@ -81,17 +84,20 @@ export class DelugeIntegration extends DownloadClientIntegration {
}
public async resumeItemAsync({ id }: DownloadClientItem): Promise {
- await this.getClient().resumeTorrent(id);
+ const client = await this.getClientAsync();
+ await client.resumeTorrent(id);
}
public async deleteItemAsync({ id }: DownloadClientItem, fromDisk: boolean): Promise {
- await this.getClient().removeTorrent(id, fromDisk);
+ const client = await this.getClientAsync();
+ await client.removeTorrent(id, fromDisk);
}
- private getClient() {
+ private async getClientAsync() {
return new Deluge({
baseUrl: this.url("/").toString(),
password: this.getSecretValue("password"),
+ dispatcher: await createCertificateAgentAsync(),
});
}
diff --git a/packages/integrations/src/download-client/nzbget/nzbget-integration.ts b/packages/integrations/src/download-client/nzbget/nzbget-integration.ts
index b17af0a87..ca2215624 100644
--- a/packages/integrations/src/download-client/nzbget/nzbget-integration.ts
+++ b/packages/integrations/src/download-client/nzbget/nzbget-integration.ts
@@ -1,5 +1,7 @@
import dayjs from "dayjs";
+import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
+
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";
@@ -96,7 +98,7 @@ export class NzbGetIntegration extends DownloadClientIntegration {
const password = this.getSecretValue("password");
const url = this.url(`/${username}:${password}/jsonrpc`);
const body = JSON.stringify({ method, params });
- return await fetch(url, { method: "POST", body })
+ return await fetchWithTrustedCertificatesAsync(url, { method: "POST", body })
.then(async (response) => {
if (!response.ok) {
throw new Error(response.statusText);
diff --git a/packages/integrations/src/download-client/qbittorrent/qbittorrent-integration.ts b/packages/integrations/src/download-client/qbittorrent/qbittorrent-integration.ts
index 2932e47c4..e15acab5e 100644
--- a/packages/integrations/src/download-client/qbittorrent/qbittorrent-integration.ts
+++ b/packages/integrations/src/download-client/qbittorrent/qbittorrent-integration.ts
@@ -1,6 +1,8 @@
import { QBittorrent } from "@ctrl/qbittorrent";
import dayjs from "dayjs";
+import { createCertificateAgentAsync } from "@homarr/certificates/server";
+
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";
@@ -8,13 +10,13 @@ import type { DownloadClientStatus } from "../../interfaces/downloads/download-c
export class QBitTorrentIntegration extends DownloadClientIntegration {
public async testConnectionAsync(): Promise {
- const client = this.getClient();
+ const client = await this.getClientAsync();
await client.login();
}
public async getClientJobsAndStatusAsync(): Promise {
const type = "torrent";
- const client = this.getClient();
+ const client = await this.getClientAsync();
const torrents = await client.listTorrents();
const rates = torrents.reduce(
({ down, up }, { dlspeed, upspeed }) => ({ down: down + dlspeed, up: up + upspeed }),
@@ -50,30 +52,36 @@ export class QBitTorrentIntegration extends DownloadClientIntegration {
}
public async pauseQueueAsync() {
- await this.getClient().pauseTorrent("all");
+ const client = await this.getClientAsync();
+ await client.pauseTorrent("all");
}
public async pauseItemAsync({ id }: DownloadClientItem): Promise {
- await this.getClient().pauseTorrent(id);
+ const client = await this.getClientAsync();
+ await client.pauseTorrent(id);
}
public async resumeQueueAsync() {
- await this.getClient().resumeTorrent("all");
+ const client = await this.getClientAsync();
+ await client.resumeTorrent("all");
}
public async resumeItemAsync({ id }: DownloadClientItem): Promise {
- await this.getClient().resumeTorrent(id);
+ const client = await this.getClientAsync();
+ await client.resumeTorrent(id);
}
public async deleteItemAsync({ id }: DownloadClientItem, fromDisk: boolean): Promise {
- await this.getClient().removeTorrent(id, fromDisk);
+ const client = await this.getClientAsync();
+ await client.removeTorrent(id, fromDisk);
}
- private getClient() {
+ private async getClientAsync() {
return new QBittorrent({
baseUrl: this.url("/").toString(),
username: this.getSecretValue("username"),
password: this.getSecretValue("password"),
+ dispatcher: await createCertificateAgentAsync(),
});
}
diff --git a/packages/integrations/src/download-client/sabnzbd/sabnzbd-integration.ts b/packages/integrations/src/download-client/sabnzbd/sabnzbd-integration.ts
index 644420644..6da299198 100644
--- a/packages/integrations/src/download-client/sabnzbd/sabnzbd-integration.ts
+++ b/packages/integrations/src/download-client/sabnzbd/sabnzbd-integration.ts
@@ -1,6 +1,8 @@
import dayjs from "dayjs";
import duration from "dayjs/plugin/duration";
+import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
+
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";
@@ -106,12 +108,12 @@ export class SabnzbdIntegration extends DownloadClientIntegration {
apikey: this.getSecretValue("apiKey"),
});
- return await fetch(url)
+ return await fetchWithTrustedCertificatesAsync(url)
.then((response) => {
if (!response.ok) {
throw new Error(response.statusText);
}
- return response.json() as Promise;
+ return response.json();
})
.catch((error) => {
if (error instanceof Error) {
diff --git a/packages/integrations/src/download-client/transmission/transmission-integration.ts b/packages/integrations/src/download-client/transmission/transmission-integration.ts
index 919da6815..eefc01cde 100644
--- a/packages/integrations/src/download-client/transmission/transmission-integration.ts
+++ b/packages/integrations/src/download-client/transmission/transmission-integration.ts
@@ -1,6 +1,8 @@
import { Transmission } from "@ctrl/transmission";
import dayjs from "dayjs";
+import { createCertificateAgentAsync } from "@homarr/certificates/server";
+
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";
@@ -8,12 +10,13 @@ import type { DownloadClientStatus } from "../../interfaces/downloads/download-c
export class TransmissionIntegration extends DownloadClientIntegration {
public async testConnectionAsync(): Promise {
- await this.getClient().getSession();
+ const client = await this.getClientAsync();
+ await client.getSession();
}
public async getClientJobsAndStatusAsync(): Promise {
const type = "torrent";
- const client = this.getClient();
+ const client = await this.getClientAsync();
const { torrents } = (await client.listTorrents()).arguments;
const rates = torrents.reduce(
({ down, up }, { rateDownload, rateUpload }) => ({ down: down + rateDownload, up: up + rateUpload }),
@@ -47,34 +50,38 @@ export class TransmissionIntegration extends DownloadClientIntegration {
}
public async pauseQueueAsync() {
- const client = this.getClient();
+ const client = await this.getClientAsync();
const ids = (await client.listTorrents()).arguments.torrents.map(({ hashString }) => hashString);
- await this.getClient().pauseTorrent(ids);
+ await client.pauseTorrent(ids);
}
public async pauseItemAsync({ id }: DownloadClientItem): Promise {
- await this.getClient().pauseTorrent(id);
+ const client = await this.getClientAsync();
+ await client.pauseTorrent(id);
}
public async resumeQueueAsync() {
- const client = this.getClient();
+ const client = await this.getClientAsync();
const ids = (await client.listTorrents()).arguments.torrents.map(({ hashString }) => hashString);
- await this.getClient().resumeTorrent(ids);
+ await client.resumeTorrent(ids);
}
public async resumeItemAsync({ id }: DownloadClientItem): Promise {
- await this.getClient().resumeTorrent(id);
+ const client = await this.getClientAsync();
+ await client.resumeTorrent(id);
}
public async deleteItemAsync({ id }: DownloadClientItem, fromDisk: boolean): Promise {
- await this.getClient().removeTorrent(id, fromDisk);
+ const client = await this.getClientAsync();
+ await client.removeTorrent(id, fromDisk);
}
- private getClient() {
+ private async getClientAsync() {
return new Transmission({
baseUrl: this.url("/").toString(),
username: this.getSecretValue("username"),
password: this.getSecretValue("password"),
+ dispatcher: await createCertificateAgentAsync(),
});
}
diff --git a/packages/integrations/src/homeassistant/homeassistant-integration.ts b/packages/integrations/src/homeassistant/homeassistant-integration.ts
index 7d69d40b1..2f280002b 100644
--- a/packages/integrations/src/homeassistant/homeassistant-integration.ts
+++ b/packages/integrations/src/homeassistant/homeassistant-integration.ts
@@ -1,3 +1,4 @@
+import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { logger } from "@homarr/log";
import { Integration } from "../base/integration";
@@ -7,7 +8,7 @@ export class HomeAssistantIntegration extends Integration {
public async getEntityStateAsync(entityId: string) {
try {
const response = await this.getAsync(`/api/states/${entityId}`);
- const body = (await response.json()) as unknown;
+ const body = await response.json();
if (!response.ok) {
logger.warn(`Response did not indicate success`);
return {
@@ -71,7 +72,7 @@ export class HomeAssistantIntegration extends Integration {
* @returns the response from the API
*/
private async getAsync(path: `/api/${string}`) {
- return await fetch(this.url(path), {
+ return await fetchWithTrustedCertificatesAsync(this.url(path), {
headers: this.getAuthHeaders(),
});
}
@@ -84,7 +85,7 @@ export class HomeAssistantIntegration extends Integration {
* @returns the response from the API
*/
private async postAsync(path: `/api/${string}`, body: Record) {
- return await fetch(this.url(path), {
+ return await fetchWithTrustedCertificatesAsync(this.url(path), {
headers: this.getAuthHeaders(),
body: JSON.stringify(body),
method: "POST",
diff --git a/packages/integrations/src/jellyfin/jellyfin-integration.ts b/packages/integrations/src/jellyfin/jellyfin-integration.ts
index 2383473cd..2e40391cb 100644
--- a/packages/integrations/src/jellyfin/jellyfin-integration.ts
+++ b/packages/integrations/src/jellyfin/jellyfin-integration.ts
@@ -2,6 +2,8 @@ import { Jellyfin } from "@jellyfin/sdk";
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
import { getSystemApi } from "@jellyfin/sdk/lib/utils/api/system-api";
+import { createAxiosCertificateInstanceAsync } from "@homarr/certificates/server";
+
import { Integration } from "../base/integration";
import type { StreamSession } from "../interfaces/media-server/session";
@@ -65,12 +67,13 @@ export class JellyfinIntegration extends Integration {
* @returns An instance of Api that has been authenticated
*/
private async getApiAsync() {
+ const httpsAgent = await createAxiosCertificateInstanceAsync();
if (this.hasSecretValue("apiKey")) {
const apiKey = this.getSecretValue("apiKey");
- return this.jellyfin.createApi(this.url("/").toString(), apiKey);
+ return this.jellyfin.createApi(this.url("/").toString(), apiKey, httpsAgent);
}
- const apiClient = this.jellyfin.createApi(this.url("/").toString());
+ const apiClient = this.jellyfin.createApi(this.url("/").toString(), undefined, httpsAgent);
// 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
diff --git a/packages/integrations/src/media-organizer/lidarr/lidarr-integration.ts b/packages/integrations/src/media-organizer/lidarr/lidarr-integration.ts
index b89d552ea..f48eda43b 100644
--- a/packages/integrations/src/media-organizer/lidarr/lidarr-integration.ts
+++ b/packages/integrations/src/media-organizer/lidarr/lidarr-integration.ts
@@ -1,3 +1,4 @@
+import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { logger } from "@homarr/log";
import { z } from "@homarr/validation";
@@ -8,7 +9,7 @@ export class LidarrIntegration extends MediaOrganizerIntegration {
public async testConnectionAsync(): Promise {
await super.handleTestConnectionResponseAsync({
queryFunctionAsync: async () => {
- return await fetch(this.url("/api"), {
+ return await fetchWithTrustedCertificatesAsync(this.url("/api"), {
headers: { "X-Api-Key": super.getSecretValue("apiKey") },
});
},
@@ -28,7 +29,7 @@ export class LidarrIntegration extends MediaOrganizerIntegration {
unmonitored: includeUnmonitored,
});
- const response = await fetch(url, {
+ const response = await fetchWithTrustedCertificatesAsync(url, {
headers: {
"X-Api-Key": super.getSecretValue("apiKey"),
},
diff --git a/packages/integrations/src/media-organizer/radarr/radarr-integration.ts b/packages/integrations/src/media-organizer/radarr/radarr-integration.ts
index e11400b1e..0817fe9f6 100644
--- a/packages/integrations/src/media-organizer/radarr/radarr-integration.ts
+++ b/packages/integrations/src/media-organizer/radarr/radarr-integration.ts
@@ -1,3 +1,4 @@
+import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import type { AtLeastOneOf } from "@homarr/common/types";
import { logger } from "@homarr/log";
import { z } from "@homarr/validation";
@@ -20,7 +21,7 @@ export class RadarrIntegration extends MediaOrganizerIntegration {
unmonitored: includeUnmonitored,
});
- const response = await fetch(url, {
+ const response = await fetchWithTrustedCertificatesAsync(url, {
headers: {
"X-Api-Key": super.getSecretValue("apiKey"),
},
@@ -94,7 +95,7 @@ export class RadarrIntegration extends MediaOrganizerIntegration {
public async testConnectionAsync(): Promise {
await super.handleTestConnectionResponseAsync({
queryFunctionAsync: async () => {
- return await fetch(this.url("/api"), {
+ return await fetchWithTrustedCertificatesAsync(this.url("/api"), {
headers: { "X-Api-Key": super.getSecretValue("apiKey") },
});
},
diff --git a/packages/integrations/src/media-organizer/readarr/readarr-integration.ts b/packages/integrations/src/media-organizer/readarr/readarr-integration.ts
index 13d9eaccc..085677c74 100644
--- a/packages/integrations/src/media-organizer/readarr/readarr-integration.ts
+++ b/packages/integrations/src/media-organizer/readarr/readarr-integration.ts
@@ -1,3 +1,4 @@
+import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { logger } from "@homarr/log";
import { z } from "@homarr/validation";
@@ -8,7 +9,7 @@ export class ReadarrIntegration extends MediaOrganizerIntegration {
public async testConnectionAsync(): Promise {
await super.handleTestConnectionResponseAsync({
queryFunctionAsync: async () => {
- return await fetch(this.url("/api"), {
+ return await fetchWithTrustedCertificatesAsync(this.url("/api"), {
headers: { "X-Api-Key": super.getSecretValue("apiKey") },
});
},
@@ -34,7 +35,7 @@ export class ReadarrIntegration extends MediaOrganizerIntegration {
includeAuthor,
});
- const response = await fetch(url, {
+ const response = await fetchWithTrustedCertificatesAsync(url, {
headers: {
"X-Api-Key": super.getSecretValue("apiKey"),
},
diff --git a/packages/integrations/src/media-organizer/sonarr/sonarr-integration.ts b/packages/integrations/src/media-organizer/sonarr/sonarr-integration.ts
index c13c65620..c9cb9393f 100644
--- a/packages/integrations/src/media-organizer/sonarr/sonarr-integration.ts
+++ b/packages/integrations/src/media-organizer/sonarr/sonarr-integration.ts
@@ -1,3 +1,4 @@
+import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { logger } from "@homarr/log";
import { z } from "@homarr/validation";
@@ -21,7 +22,7 @@ export class SonarrIntegration extends MediaOrganizerIntegration {
includeEpisodeImages: true,
});
- const response = await fetch(url, {
+ const response = await fetchWithTrustedCertificatesAsync(url, {
headers: {
"X-Api-Key": super.getSecretValue("apiKey"),
},
@@ -93,7 +94,7 @@ export class SonarrIntegration extends MediaOrganizerIntegration {
public async testConnectionAsync(): Promise {
await super.handleTestConnectionResponseAsync({
queryFunctionAsync: async () => {
- return await fetch(this.url("/api"), {
+ return await fetchWithTrustedCertificatesAsync(this.url("/api"), {
headers: { "X-Api-Key": super.getSecretValue("apiKey") },
});
},
diff --git a/packages/integrations/src/media-transcoding/tdarr-integration.ts b/packages/integrations/src/media-transcoding/tdarr-integration.ts
index c602b8da0..fad62b28d 100644
--- a/packages/integrations/src/media-transcoding/tdarr-integration.ts
+++ b/packages/integrations/src/media-transcoding/tdarr-integration.ts
@@ -1,3 +1,4 @@
+import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { z } from "@homarr/validation";
import { Integration } from "../base/integration";
@@ -9,7 +10,7 @@ import { getNodesResponseSchema, getStatisticsSchema, getStatusTableSchema } fro
export class TdarrIntegration extends Integration {
public async testConnectionAsync(): Promise {
const url = this.url("/api/v2/status");
- const response = await fetch(url);
+ const response = await fetchWithTrustedCertificatesAsync(url);
if (response.status !== 200) {
throw new Error(`Unexpected status code: ${response.status}`);
}
@@ -19,7 +20,7 @@ export class TdarrIntegration extends Integration {
public async getStatisticsAsync(): Promise {
const url = this.url("/api/v2/cruddb");
- const response = await fetch(url, {
+ const response = await fetchWithTrustedCertificatesAsync(url, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
@@ -61,7 +62,7 @@ export class TdarrIntegration extends Integration {
public async getWorkersAsync(): Promise {
const url = this.url("/api/v2/get-nodes");
- const response = await fetch(url, {
+ const response = await fetchWithTrustedCertificatesAsync(url, {
method: "GET",
headers: { "content-type": "application/json" },
});
@@ -101,7 +102,7 @@ export class TdarrIntegration extends Integration {
private async getTranscodingQueueAsync(firstItemIndex: number, pageSize: number) {
const url = this.url("/api/v2/client/status-tables");
- const response = await fetch(url, {
+ const response = await fetchWithTrustedCertificatesAsync(url, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
@@ -136,7 +137,7 @@ export class TdarrIntegration extends Integration {
private async getHealthCheckDataAsync(firstItemIndex: number, pageSize: number, totalQueueCount: number) {
const url = this.url("/api/v2/client/status-tables");
- const response = await fetch(url, {
+ const response = await fetchWithTrustedCertificatesAsync(url, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
diff --git a/packages/integrations/src/openmediavault/openmediavault-integration.ts b/packages/integrations/src/openmediavault/openmediavault-integration.ts
index 2296ac124..8295d557e 100644
--- a/packages/integrations/src/openmediavault/openmediavault-integration.ts
+++ b/packages/integrations/src/openmediavault/openmediavault-integration.ts
@@ -1,3 +1,7 @@
+import type { Response } from "undici";
+
+import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
+
import { Integration } from "../base/integration";
import { IntegrationTestConnectionError } from "../base/test-connection-error";
import type { HealthMonitoring } from "../types";
@@ -105,7 +109,7 @@ export class OpenMediaVaultIntegration extends Integration {
if (!response.ok) {
throw new IntegrationTestConnectionError("invalidCredentials");
}
- const result = (await response.json()) as unknown;
+ const result = await response.json();
if (typeof result !== "object" || result === null || !("response" in result)) {
throw new IntegrationTestConnectionError("invalidJson");
}
@@ -117,7 +121,7 @@ export class OpenMediaVaultIntegration extends Integration {
params: Record,
headers: Record = {},
): Promise {
- return await fetch(this.url("/rpc.php"), {
+ return await fetchWithTrustedCertificatesAsync(this.url("/rpc.php"), {
method: "POST",
headers: {
"Content-Type": "application/json",
diff --git a/packages/integrations/src/overseerr/overseerr-integration.ts b/packages/integrations/src/overseerr/overseerr-integration.ts
index c4f31420f..a11b050ea 100644
--- a/packages/integrations/src/overseerr/overseerr-integration.ts
+++ b/packages/integrations/src/overseerr/overseerr-integration.ts
@@ -1,3 +1,4 @@
+import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { logger } from "@homarr/log";
import { z } from "@homarr/validation";
@@ -20,7 +21,7 @@ interface OverseerrSearchResult {
*/
export class OverseerrIntegration extends Integration implements ISearchableIntegration {
public async searchAsync(query: string) {
- const response = await fetch(this.url("/api/v1/search", { query }), {
+ const response = await fetchWithTrustedCertificatesAsync(this.url("/api/v1/search", { query }), {
headers: {
"X-Api-Key": this.getSecretValue("apiKey"),
},
@@ -44,7 +45,7 @@ export class OverseerrIntegration extends Integration implements ISearchableInte
public async getSeriesInformationAsync(mediaType: "movie" | "tv", id: number) {
const url = mediaType === "tv" ? this.url(`/api/v1/tv/${id}`) : this.url(`/api/v1/movie/${id}`);
- const response = await fetch(url, {
+ const response = await fetchWithTrustedCertificatesAsync(url, {
headers: {
"X-Api-Key": this.getSecretValue("apiKey"),
},
@@ -60,7 +61,7 @@ export class OverseerrIntegration extends Integration implements ISearchableInte
*/
public async requestMediaAsync(mediaType: "movie" | "tv", id: number, seasons?: number[]): Promise {
const url = this.url("/api/v1/request");
- const response = await fetch(url, {
+ const response = await fetchWithTrustedCertificatesAsync(url, {
method: "POST",
body: JSON.stringify({
mediaType,
@@ -80,13 +81,12 @@ export class OverseerrIntegration extends Integration implements ISearchableInte
}
public async testConnectionAsync(): Promise {
- const response = await fetch(this.url("/api/v1/auth/me"), {
+ const response = await fetchWithTrustedCertificatesAsync(this.url("/api/v1/auth/me"), {
headers: {
"X-Api-Key": this.getSecretValue("apiKey"),
},
});
- // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
- const json: object = await response.json();
+ const json = (await response.json()) as object;
if (Object.keys(json).includes("id")) {
return;
}
@@ -96,14 +96,17 @@ export class OverseerrIntegration extends Integration implements ISearchableInte
public async getRequestsAsync(): Promise {
//Ensure to get all pending request first
- const pendingRequests = await fetch(this.url("/api/v1/request", { take: -1, filter: "pending" }), {
- headers: {
- "X-Api-Key": this.getSecretValue("apiKey"),
+ const pendingRequests = await fetchWithTrustedCertificatesAsync(
+ this.url("/api/v1/request", { take: -1, filter: "pending" }),
+ {
+ headers: {
+ "X-Api-Key": this.getSecretValue("apiKey"),
+ },
},
- });
+ );
//Change 20 to integration setting (set to -1 for all)
- const allRequests = await fetch(this.url("/api/v1/request", { take: 20 }), {
+ const allRequests = await fetchWithTrustedCertificatesAsync(this.url("/api/v1/request", { take: 20 }), {
headers: {
"X-Api-Key": this.getSecretValue("apiKey"),
},
@@ -151,7 +154,7 @@ export class OverseerrIntegration extends Integration implements ISearchableInte
}
public async getStatsAsync(): Promise {
- const response = await fetch(this.url("/api/v1/request/count"), {
+ const response = await fetchWithTrustedCertificatesAsync(this.url("/api/v1/request/count"), {
headers: {
"X-Api-Key": this.getSecretValue("apiKey"),
},
@@ -160,7 +163,7 @@ export class OverseerrIntegration extends Integration implements ISearchableInte
}
public async getUsersAsync(): Promise {
- const response = await fetch(this.url("/api/v1/user", { take: -1 }), {
+ const response = await fetchWithTrustedCertificatesAsync(this.url("/api/v1/user", { take: -1 }), {
headers: {
"X-Api-Key": this.getSecretValue("apiKey"),
},
@@ -177,7 +180,7 @@ export class OverseerrIntegration extends Integration implements ISearchableInte
public async approveRequestAsync(requestId: number): Promise {
logger.info(`Approving media request id='${requestId}' integration='${this.integration.name}'`);
- await fetch(this.url(`/api/v1/request/${requestId}/approve`), {
+ await fetchWithTrustedCertificatesAsync(this.url(`/api/v1/request/${requestId}/approve`), {
method: "POST",
headers: {
"X-Api-Key": this.getSecretValue("apiKey"),
@@ -195,7 +198,7 @@ export class OverseerrIntegration extends Integration implements ISearchableInte
public async declineRequestAsync(requestId: number): Promise {
logger.info(`Declining media request id='${requestId}' integration='${this.integration.name}'`);
- await fetch(this.url(`/api/v1/request/${requestId}/decline`), {
+ await fetchWithTrustedCertificatesAsync(this.url(`/api/v1/request/${requestId}/decline`), {
method: "POST",
headers: {
"X-Api-Key": this.getSecretValue("apiKey"),
@@ -212,7 +215,7 @@ export class OverseerrIntegration extends Integration implements ISearchableInte
}
private async getItemInformationAsync(id: number, type: MediaRequest["type"]): Promise {
- const response = await fetch(this.url(`/api/v1/${type}/${id}`), {
+ const response = await fetchWithTrustedCertificatesAsync(this.url(`/api/v1/${type}/${id}`), {
headers: {
"X-Api-Key": this.getSecretValue("apiKey"),
},
diff --git a/packages/integrations/src/pi-hole/pi-hole-integration.ts b/packages/integrations/src/pi-hole/pi-hole-integration.ts
index 5549ab91a..45090a977 100644
--- a/packages/integrations/src/pi-hole/pi-hole-integration.ts
+++ b/packages/integrations/src/pi-hole/pi-hole-integration.ts
@@ -1,3 +1,5 @@
+import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
+
import { Integration } from "../base/integration";
import { IntegrationTestConnectionError } from "../base/test-connection-error";
import type { DnsHoleSummaryIntegration } from "../interfaces/dns-hole-summary/dns-hole-summary-integration";
@@ -7,7 +9,7 @@ import { summaryResponseSchema } from "./pi-hole-types";
export class PiHoleIntegration extends Integration implements DnsHoleSummaryIntegration {
public async getSummaryAsync(): Promise {
const apiKey = super.getSecretValue("apiKey");
- const response = await fetch(this.url("/admin/api.php?summaryRaw", { auth: 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}`,
@@ -36,11 +38,11 @@ export class PiHoleIntegration extends Integration implements DnsHoleSummaryInte
await super.handleTestConnectionResponseAsync({
queryFunctionAsync: async () => {
- return await fetch(this.url("/admin/api.php?status", { auth: apiKey }));
+ return await fetchWithTrustedCertificatesAsync(this.url("/admin/api.php?status", { auth: apiKey }));
},
handleResponseAsync: async (response) => {
try {
- const result = (await response.json()) as unknown;
+ const result = await response.json();
if (typeof result === "object" && result !== null && "status" in result) return;
} catch {
throw new IntegrationTestConnectionError("invalidJson");
@@ -53,7 +55,7 @@ export class PiHoleIntegration extends Integration implements DnsHoleSummaryInte
public async enableAsync(): Promise {
const apiKey = super.getSecretValue("apiKey");
- const response = await fetch(this.url("/admin/api.php?enable", { auth: apiKey }));
+ const response = await fetchWithTrustedCertificatesAsync(this.url("/admin/api.php?enable", { auth: apiKey }));
if (!response.ok) {
throw new Error(
`Failed to enable PiHole for ${this.integration.name} (${this.integration.id}): ${response.statusText}`,
@@ -64,7 +66,7 @@ export class PiHoleIntegration extends Integration implements DnsHoleSummaryInte
public async disableAsync(duration?: number): Promise {
const apiKey = super.getSecretValue("apiKey");
const url = this.url(`/admin/api.php?disable${duration ? `=${duration}` : ""}`, { auth: apiKey });
- const response = await fetch(url);
+ const response = await fetchWithTrustedCertificatesAsync(url);
if (!response.ok) {
throw new Error(
`Failed to disable PiHole for ${this.integration.name} (${this.integration.id}): ${response.statusText}`,
diff --git a/packages/integrations/src/plex/plex-integration.ts b/packages/integrations/src/plex/plex-integration.ts
index d33e9457b..52d5ce189 100644
--- a/packages/integrations/src/plex/plex-integration.ts
+++ b/packages/integrations/src/plex/plex-integration.ts
@@ -1,5 +1,6 @@
import { parseStringPromise } from "xml2js";
+import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { logger } from "@homarr/log";
import { Integration } from "../base/integration";
@@ -11,7 +12,7 @@ export class PlexIntegration extends Integration {
public async getCurrentSessionsAsync(): Promise {
const token = super.getSecretValue("apiKey");
- const response = await fetch(this.url("/status/sessions"), {
+ const response = await fetchWithTrustedCertificatesAsync(this.url("/status/sessions"), {
headers: {
"X-Plex-Token": token,
},
@@ -66,7 +67,7 @@ export class PlexIntegration extends Integration {
await super.handleTestConnectionResponseAsync({
queryFunctionAsync: async () => {
- return await fetch(this.url("/"), {
+ return await fetchWithTrustedCertificatesAsync(this.url("/"), {
headers: {
"X-Plex-Token": token,
},
diff --git a/packages/integrations/src/prowlarr/prowlarr-integration.ts b/packages/integrations/src/prowlarr/prowlarr-integration.ts
index 97839f0ff..7f11cd07a 100644
--- a/packages/integrations/src/prowlarr/prowlarr-integration.ts
+++ b/packages/integrations/src/prowlarr/prowlarr-integration.ts
@@ -1,3 +1,5 @@
+import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
+
import { Integration } from "../base/integration";
import { IntegrationTestConnectionError } from "../base/test-connection-error";
import type { Indexer } from "../interfaces/indexer-manager/indexer";
@@ -7,7 +9,7 @@ export class ProwlarrIntegration extends Integration {
public async getIndexersAsync(): Promise {
const apiKey = super.getSecretValue("apiKey");
- const indexerResponse = await fetch(this.url("/api/v1/indexer"), {
+ const indexerResponse = await fetchWithTrustedCertificatesAsync(this.url("/api/v1/indexer"), {
headers: {
"X-Api-Key": apiKey,
},
@@ -18,7 +20,7 @@ export class ProwlarrIntegration extends Integration {
);
}
- const statusResponse = await fetch(this.url("/api/v1/indexerstatus"), {
+ const statusResponse = await fetchWithTrustedCertificatesAsync(this.url("/api/v1/indexerstatus"), {
headers: {
"X-Api-Key": apiKey,
},
@@ -60,7 +62,7 @@ export class ProwlarrIntegration extends Integration {
public async testAllAsync(): Promise {
const apiKey = super.getSecretValue("apiKey");
- const response = await fetch(this.url("/api/v1/indexer/testall"), {
+ const response = await fetchWithTrustedCertificatesAsync(this.url("/api/v1/indexer/testall"), {
headers: {
"X-Api-Key": apiKey,
},
@@ -78,7 +80,7 @@ export class ProwlarrIntegration extends Integration {
await super.handleTestConnectionResponseAsync({
queryFunctionAsync: async () => {
- return await fetch(this.url("/api"), {
+ return await fetchWithTrustedCertificatesAsync(this.url("/api"), {
headers: {
"X-Api-Key": apiKey,
},
@@ -86,7 +88,7 @@ export class ProwlarrIntegration extends Integration {
},
handleResponseAsync: async (response) => {
try {
- const result = (await response.json()) as unknown;
+ const result = await response.json();
if (typeof result === "object" && result !== null) return;
} catch {
throw new IntegrationTestConnectionError("invalidJson");
diff --git a/packages/integrations/test/base.spec.ts b/packages/integrations/test/base.spec.ts
index 9ef1f3861..e58ea5208 100644
--- a/packages/integrations/test/base.spec.ts
+++ b/packages/integrations/test/base.spec.ts
@@ -1,3 +1,4 @@
+import { Response } from "undici";
import { describe, expect, test } from "vitest";
import { IntegrationTestConnectionError } from "../src";
diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json
index 22e69f867..cb8deef4b 100644
--- a/packages/translation/src/lang/en.json
+++ b/packages/translation/src/lang/en.json
@@ -897,6 +897,7 @@
"passwordRequirements": "Password does not meet the requirements",
"boardAlreadyExists": "A board with this name already exists",
"invalidFileType": "Invalid file type, expected {expected}",
+ "invalidFileName": "Invalid file name",
"fileTooLarge": "File is too large, maximum size is {maxSize}",
"invalidConfiguration": "Invalid configuration",
"groupNameTaken": "Group name already taken"
@@ -2101,6 +2102,7 @@
"docker": "Docker",
"logs": "Logs",
"api": "API",
+ "certificates": "Certificates",
"tasks": "Tasks"
}
},
@@ -2706,6 +2708,9 @@
},
"logs": {
"label": "Logs"
+ },
+ "certificates": {
+ "label": "Certificates"
}
},
"settings": {
@@ -3101,5 +3106,46 @@
}
}
}
+ },
+ "certificate": {
+ "page": {
+ "list": {
+ "title": "Trusted certificates",
+ "description": "Used by Homarr to request data from integrations.",
+ "noResults": {
+ "title": "There are no certificates yet"
+ },
+ "expires": "Expires {when}"
+ }
+ },
+ "action": {
+ "create": {
+ "label": "Add certificate",
+ "notification": {
+ "success": {
+ "title": "Certificate added",
+ "message": "The certificate was added successfully"
+ },
+ "error": {
+ "title": "Failed to add certificate",
+ "message": "The certificate could not be added"
+ }
+ }
+ },
+ "remove": {
+ "label": "Remove certificate",
+ "confirm": "Are you sure you want to remove the certificate?",
+ "notification": {
+ "success": {
+ "title": "Certificate removed",
+ "message": "The certificate was removed successfully"
+ },
+ "error": {
+ "title": "Certificate not removed",
+ "message": "The certificate could not be removed"
+ }
+ }
+ }
+ }
}
}
diff --git a/packages/validation/src/certificates.ts b/packages/validation/src/certificates.ts
new file mode 100644
index 000000000..7ad6d3aeb
--- /dev/null
+++ b/packages/validation/src/certificates.ts
@@ -0,0 +1,52 @@
+import { z } from "zod";
+
+import { createCustomErrorParams } from "./form/i18n";
+
+const validFileNameSchema = z.string().regex(/^[\w\-. ]+$/);
+
+export const superRefineCertificateFile = (value: File | null, context: z.RefinementCtx) => {
+ if (!value) {
+ return context.addIssue({
+ code: "invalid_type",
+ expected: "object",
+ received: "null",
+ });
+ }
+
+ const result = validFileNameSchema.safeParse(value.name);
+ if (!result.success) {
+ return context.addIssue({
+ code: "custom",
+ params: createCustomErrorParams({
+ key: "invalidFileName",
+ params: {},
+ }),
+ });
+ }
+
+ if (value.type !== "application/x-x509-ca-cert" && value.type !== "application/pkix-cert") {
+ return context.addIssue({
+ code: "custom",
+ params: createCustomErrorParams({
+ key: "invalidFileType",
+ params: { expected: ".crt" },
+ }),
+ });
+ }
+
+ if (value.size > 1024 * 1024) {
+ return context.addIssue({
+ code: "custom",
+ params: createCustomErrorParams({
+ key: "fileTooLarge",
+ params: { maxSize: "1 MB" },
+ }),
+ });
+ }
+
+ return null;
+};
+
+export const certificateSchemas = {
+ validFileNameSchema,
+};
diff --git a/packages/validation/src/index.ts b/packages/validation/src/index.ts
index 6791c8a54..955031786 100644
--- a/packages/validation/src/index.ts
+++ b/packages/validation/src/index.ts
@@ -1,5 +1,6 @@
import { appSchemas } from "./app";
import { boardSchemas } from "./board";
+import { certificateSchemas } from "./certificates";
import { commonSchemas } from "./common";
import { groupSchemas } from "./group";
import { iconsSchemas } from "./icons";
@@ -24,6 +25,7 @@ export const validation = {
media: mediaSchemas,
settings: settingsSchemas,
common: commonSchemas,
+ certificates: certificateSchemas,
};
export {
@@ -33,6 +35,7 @@ export {
type BoardItemAdvancedOptions,
type BoardItemIntegration,
} from "./shared";
+export { superRefineCertificateFile } from "./certificates";
export { passwordRequirements } from "./user";
export { supportedMediaUploadFormats } from "./media";
export { zodEnumFromArray, zodUnionFromArray } from "./enums";
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index a6f8fa9cd..d1ffd91a8 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -88,6 +88,9 @@ importers:
'@homarr/auth':
specifier: workspace:^0.1.0
version: link:../../packages/auth
+ '@homarr/certificates':
+ specifier: workspace:^0.1.0
+ version: link:../../packages/certificates
'@homarr/common':
specifier: workspace:^0.1.0
version: link:../../packages/common
@@ -491,6 +494,9 @@ importers:
'@homarr/auth':
specifier: workspace:^0.1.0
version: link:../auth
+ '@homarr/certificates':
+ specifier: workspace:^0.1.0
+ version: link:../certificates
'@homarr/common':
specifier: workspace:^0.1.0
version: link:../common
@@ -665,6 +671,31 @@ importers:
specifier: ^5.7.3
version: 5.7.3
+ packages/certificates:
+ dependencies:
+ '@homarr/common':
+ specifier: workspace:^0.1.0
+ version: link:../common
+ undici:
+ specifier: 7.2.3
+ version: 7.2.3
+ devDependencies:
+ '@homarr/eslint-config':
+ specifier: workspace:^0.2.0
+ version: link:../../tooling/eslint
+ '@homarr/prettier-config':
+ specifier: workspace:^0.1.0
+ version: link:../../tooling/prettier
+ '@homarr/tsconfig':
+ specifier: workspace:^0.1.0
+ version: link:../../tooling/typescript
+ eslint:
+ specifier: ^9.18.0
+ version: 9.18.0
+ typescript:
+ specifier: ^5.7.3
+ version: 5.7.3
+
packages/cli:
dependencies:
'@drizzle-team/brocli':
@@ -716,6 +747,9 @@ importers:
react-dom:
specifier: 19.0.0
version: 19.0.0(react@19.0.0)
+ undici:
+ specifier: 7.2.3
+ version: 7.2.3
zod:
specifier: ^3.24.1
version: 3.24.1
@@ -1052,6 +1086,9 @@ importers:
'@ctrl/transmission':
specifier: ^7.2.0
version: 7.2.0
+ '@homarr/certificates':
+ specifier: workspace:^0.1.0
+ version: link:../certificates
'@homarr/common':
specifier: workspace:^0.1.0
version: link:../common
@@ -1076,6 +1113,9 @@ importers:
'@jellyfin/sdk':
specifier: ^0.11.0
version: 0.11.0(axios@1.7.7)
+ undici:
+ specifier: 7.2.3
+ version: 7.2.3
xml2js:
specifier: ^0.6.2
version: 0.6.2
diff --git a/scripts/run.sh b/scripts/run.sh
index f3f92820c..83833d5cd 100644
--- a/scripts/run.sh
+++ b/scripts/run.sh
@@ -1,6 +1,7 @@
# Create sub directories in volume
mkdir -p /appdata/db
mkdir -p /appdata/redis
+mkdir -p /appdata/trusted-certificates
# Run migrations
if [ $DB_MIGRATIONS_DISABLED = "true" ]; then
diff --git a/turbo.json b/turbo.json
index e259bcb22..20ccadef0 100644
--- a/turbo.json
+++ b/turbo.json
@@ -38,6 +38,7 @@
"DOCKER_PORTS",
"NODE_ENV",
"PORT",
+ "LOCAL_CERTIFICATE_PATH",
"SECRET_ENCRYPTION_KEY",
"SKIP_ENV_VALIDATION"
],