From 8c36c3e36b6f8283420ceee56922a002f6155a91 Mon Sep 17 00:00:00 2001 From: Meier Lukas Date: Fri, 17 Jan 2025 00:08:40 +0100 Subject: [PATCH] feat(certificates): handle self signed certificates (#1951) * wip: add page and loading of certificates in folder * wip: add certificate addition and removal * feat: add removal ui for certificates * feat: migrate integrations to fetch or agent with trusted certificates * fix: lock file issues * fix: typecheck issue * fix: inconsistent package versions * chore: address pull request feedback * fix: add missing navigation item and restrict access to page * chore: address pull request feedback * fix: inconsistent undici dependency version * fix: inconsistent undici dependency version --- .env.example | 4 + .gitignore | 2 +- apps/nextjs/package.json | 1 + .../nextjs/src/app/[locale]/manage/layout.tsx | 7 ++ .../_components/add-certificate.tsx | 81 ++++++++++++++++ .../_components/remove-certificate.tsx | 54 +++++++++++ .../manage/tools/certificates/page.tsx | 97 +++++++++++++++++++ apps/tasks/src/undici-log-agent-override.ts | 33 +------ packages/api/package.json | 1 + packages/api/src/root.ts | 2 + .../router/certificates/certificate-router.ts | 27 ++++++ packages/certificates/eslint.config.js | 9 ++ packages/certificates/package.json | 35 +++++++ packages/certificates/src/server.ts | 87 +++++++++++++++++ packages/certificates/tsconfig.json | 8 ++ packages/common/package.json | 1 + packages/common/src/fetch-agent.ts | 32 ++++++ packages/common/src/server.ts | 1 + .../common/src/test/fetch-agent.spec.ts | 2 +- packages/integrations/package.json | 2 + .../adguard-home/adguard-home-integration.ts | 16 +-- packages/integrations/src/base/integration.ts | 2 + .../src/dashdot/dashdot-integration.ts | 11 ++- .../deluge/deluge-integration.ts | 22 +++-- .../nzbget/nzbget-integration.ts | 4 +- .../qbittorrent/qbittorrent-integration.ts | 24 +++-- .../sabnzbd/sabnzbd-integration.ts | 6 +- .../transmission/transmission-integration.ts | 27 ++++-- .../homeassistant-integration.ts | 7 +- .../src/jellyfin/jellyfin-integration.ts | 7 +- .../lidarr/lidarr-integration.ts | 5 +- .../radarr/radarr-integration.ts | 5 +- .../readarr/readarr-integration.ts | 5 +- .../sonarr/sonarr-integration.ts | 5 +- .../media-transcoding/tdarr-integration.ts | 11 ++- .../openmediavault-integration.ts | 8 +- .../src/overseerr/overseerr-integration.ts | 35 ++++--- .../src/pi-hole/pi-hole-integration.ts | 12 ++- .../integrations/src/plex/plex-integration.ts | 5 +- .../src/prowlarr/prowlarr-integration.ts | 12 ++- packages/integrations/test/base.spec.ts | 1 + packages/translation/src/lang/en.json | 46 +++++++++ packages/validation/src/certificates.ts | 52 ++++++++++ packages/validation/src/index.ts | 3 + pnpm-lock.yaml | 40 ++++++++ scripts/run.sh | 1 + turbo.json | 1 + 47 files changed, 737 insertions(+), 122 deletions(-) create mode 100644 apps/nextjs/src/app/[locale]/manage/tools/certificates/_components/add-certificate.tsx create mode 100644 apps/nextjs/src/app/[locale]/manage/tools/certificates/_components/remove-certificate.tsx create mode 100644 apps/nextjs/src/app/[locale]/manage/tools/certificates/page.tsx create mode 100644 packages/api/src/router/certificates/certificate-router.ts create mode 100644 packages/certificates/eslint.config.js create mode 100644 packages/certificates/package.json create mode 100644 packages/certificates/src/server.ts create mode 100644 packages/certificates/tsconfig.json create mode 100644 packages/common/src/fetch-agent.ts rename apps/tasks/src/test/undici-log-agent-override.spec.ts => packages/common/src/test/fetch-agent.spec.ts (98%) create mode 100644 packages/validation/src/certificates.ts 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 ( +
{ + 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 revalidatePathActionAsync("/manage/tools/certificates"); + actions.closeModal(); + }, + onError() { + showErrorNotification({ + title: t("certificate.action.create.notification.error.title"), + message: t("certificate.action.create.notification.error.message"), + }); + }, + }); + })} + > + + } {...form.getInputProps("file")} /> + + + + + +
+ ); +}).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" ],