chore(release): automatic release v1.20.0

This commit is contained in:
homarr-releases[bot]
2025-05-16 19:15:02 +00:00
committed by GitHub
182 changed files with 8484 additions and 2487 deletions

View File

@@ -31,6 +31,7 @@ body:
label: Version
description: What version of Homarr are you running?
options:
- 1.19.1
- 1.19.0
- 1.18.0
- 1.17.0

View File

@@ -6,7 +6,7 @@ on:
jobs:
approve-automatic-prs:
runs-on: ubuntu-latest
if: github.actor_id == 158783068 || github.actor_id == 190541745 # Id of renovate bot and crowdin bot see https://api.github.com/users/homarr-renovate%5Bbot%5D and https://api.github.com/users/homarr-crowdin%5Bbot%5D
if: github.actor_id == 158783068 || github.actor_id == 190541745 || github.actor_id == 210161987 # Id of renovate bot and crowdin bot see https://api.github.com/users/homarr-renovate%5Bbot%5D and https://api.github.com/users/homarr-crowdin%5Bbot%5D and https://api.github.com/users/homarr-update-contributors%5Bbot%5D
steps:
- name: Checkout code
uses: actions/checkout@v4

View File

@@ -0,0 +1,70 @@
name: Update Contributors
on:
schedule:
- cron: "0 12 * * FRI" # At 12:00 on Friday.
workflow_dispatch:
env:
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
CROWDIN_TOKEN: "${{ secrets.CROWDIN_UPDATE_CONTRIBUTORS_TOKEN }}"
permissions:
contents: write
jobs:
update-contributors:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [22]
steps:
- name: Obtain token
id: obtainToken
uses: tibdex/github-app-token@v2
with:
private_key: ${{ secrets.HOMARR_UPDATE_CONTRIBUTORS_PRIVATE_KEY }}
app_id: ${{ vars.HOMARR_UPDATE_CONTRIBUTORS_APP_ID }}
- name: Checkout repository
uses: actions/checkout@v4
env:
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- name: Run update script
run: node ./scripts/update-contributors.mjs
- name: Commit changes
env:
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
run: |
git config --global user.email "210161987+homarr-update-contributors[bot]@users.noreply.github.com"
git config --global user.name "Homarr Update Contributors"
git add .
git commit -m "chore: update contributors"
- name: Create Pull Request
id: create-pull-request
uses: peter-evans/create-pull-request@v7
with:
token: ${{ steps.obtainToken.outputs.token }}
branch: update-contributors
base: dev
title: "chore: update contributors"
delete-branch: true
body: |
This PR updates the contributors list in the static-data directory.
- name: Install GitHub CLI
run: sudo apt-get install -y gh
- name: Enable auto-merge
env:
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
run: |
gh pr merge ${{steps.create-pull-request.outputs.pull-request-number}} --auto --squash

2
.nvmrc
View File

@@ -1 +1 @@
22.15.0
22.15.1

View File

@@ -1,4 +1,4 @@
FROM node:22.15.0-alpine AS base
FROM node:22.15.1-alpine AS base
FROM base AS builder
RUN apk add --no-cache libc6-compat

View File

@@ -48,17 +48,17 @@
"@homarr/ui": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@homarr/widgets": "workspace:^0.1.0",
"@mantine/colors-generator": "^8.0.0",
"@mantine/core": "^8.0.0",
"@mantine/dropzone": "^8.0.0",
"@mantine/hooks": "^8.0.0",
"@mantine/modals": "^8.0.0",
"@mantine/tiptap": "^8.0.0",
"@mantine/colors-generator": "^8.0.1",
"@mantine/core": "^8.0.1",
"@mantine/dropzone": "^8.0.1",
"@mantine/hooks": "^8.0.1",
"@mantine/modals": "^8.0.1",
"@mantine/tiptap": "^8.0.1",
"@million/lint": "1.0.14",
"@tabler/icons-react": "^3.31.0",
"@tanstack/react-query": "^5.75.7",
"@tanstack/react-query-devtools": "^5.75.7",
"@tanstack/react-query-next-experimental": "^5.75.7",
"@tanstack/react-query": "^5.76.1",
"@tanstack/react-query-devtools": "^5.76.1",
"@tanstack/react-query-next-experimental": "^5.76.1",
"@trpc/client": "^11.1.2",
"@trpc/next": "^11.1.2",
"@trpc/react-query": "^11.1.2",
@@ -81,7 +81,7 @@
"react-dom": "19.1.0",
"react-error-boundary": "^6.0.0",
"react-simple-code-editor": "^0.14.1",
"sass": "^1.87.0",
"sass": "^1.89.0",
"superjson": "2.2.2",
"swagger-ui-react": "^5.21.0",
"use-deep-compare-effect": "^1.8.1",
@@ -92,10 +92,10 @@
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/chroma-js": "3.1.1",
"@types/node": "^22.15.17",
"@types/node": "^22.15.18",
"@types/prismjs": "^1.26.5",
"@types/react": "19.1.3",
"@types/react-dom": "19.1.3",
"@types/react": "19.1.4",
"@types/react-dom": "19.1.5",
"@types/swagger-ui-react": "^5.18.0",
"concurrently": "^9.1.2",
"eslint": "^9.26.0",

View File

@@ -0,0 +1,96 @@
import { useMemo } from "react";
import { Accordion, Anchor, Card, Stack, Text } from "@mantine/core";
import { IconSubtask } from "@tabler/icons-react";
import { getMantineColor } from "@homarr/common";
import { useI18n } from "@homarr/translation/client";
import { CertificateErrorDetails } from "./test-connection-certificate";
import type { AnyMappedTestConnectionError, MappedError } from "./types";
interface IntegrationTestConnectionErrorProps {
error: AnyMappedTestConnectionError;
url: string;
}
export const IntegrationTestConnectionError = ({ error, url }: IntegrationTestConnectionErrorProps) => {
const t = useI18n();
const causeArray = useMemo(() => toCauseArray(error.cause), [error.cause]);
return (
<Card withBorder style={{ borderColor: getMantineColor("red", 8) }}>
<Stack>
<Stack gap="sm">
<Text size="lg" fw={500} c="red.8">
{t(`integration.testConnection.error.${error.type}.title`)}
</Text>
{error.type !== "request" && error.type !== "certificate" && error.type !== "statusCode" ? (
<Text size="md">{t(`integration.testConnection.error.${error.type}.description`)}</Text>
) : null}
{error.type === "request" ? (
<Text size="md">
{t(
`integration.testConnection.error.request.description.${error.data.type}.${error.data.reason}` as never,
)}
</Text>
) : null}
{error.type === "statusCode" ? (
error.data.reason === "other" ? (
<Text size="md">
{t.rich("integration.testConnection.error.statusCode.otherDescription", {
statusCode: error.data.statusCode.toString(),
url: () => <Anchor href={error.data.url}>{error.data.url}</Anchor>,
})}
</Text>
) : (
<Text size="md">
{t.rich("integration.testConnection.error.statusCode.description", {
reason: t(`integration.testConnection.error.statusCode.reason.${error.data.reason}`),
statusCode: error.data.statusCode.toString(),
url: () => <Anchor href={error.data.url}>{error.data.url}</Anchor>,
})}
</Text>
)
) : null}
{error.type === "certificate" ? <CertificateErrorDetails error={error} url={url} /> : null}
</Stack>
{error.cause ? (
<Accordion variant="contained">
<Accordion.Item value="cause">
<Accordion.Control icon={<IconSubtask size={16} stroke={1.5} />}>
{t("integration.testConnection.error.common.cause.title")}
</Accordion.Control>
<Accordion.Panel>
<pre style={{ whiteSpace: "pre-wrap" }}>
{error.name}: {error.message}
{"\n"}
{causeArray
.map(
(cause) =>
`caused by ${cause.name}${cause.message ? `: ${cause.message}` : ""} ${cause.metadata.map((item) => `${item.key}=${item.value}`).join(" ")}`,
)
.join("\n")}
</pre>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
) : null}
</Stack>
</Card>
);
};
const toCauseArray = (cause: MappedError | undefined) => {
const causeArray: MappedError[] = [];
let currentCause: MappedError | undefined = cause;
while (currentCause) {
causeArray.push(currentCause);
currentCause = currentCause.cause;
}
return causeArray.map(({ cause: _innerCause, ...cause }) => cause);
};

View File

@@ -0,0 +1,327 @@
import { useState } from "react";
import { ActionIcon, Alert, Anchor, Button, Card, CopyButton, Group, SimpleGrid, Stack, Text } from "@mantine/core";
import { IconAlertTriangle, IconCheck, IconCopy, IconExclamationCircle, IconRepeat } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { getMantineColor } from "@homarr/common";
import { createId } from "@homarr/db/client";
import { createDocumentationLink } from "@homarr/definitions";
import { createModal, useConfirmModal, useModalAction } from "@homarr/modals";
import { AddCertificateModal } from "@homarr/modals-collection";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useCurrentLocale, useI18n, useScopedI18n } from "@homarr/translation/client";
import type { MappedCertificate, MappedTestConnectionCertificateError } from "./types";
interface CertificateErrorDetailsProps {
error: MappedTestConnectionCertificateError;
url: string;
}
export const CertificateErrorDetails = ({ error, url }: CertificateErrorDetailsProps) => {
const tError = useScopedI18n("integration.testConnection.error");
const { data: session } = useSession();
const isAdmin = session?.user.permissions.includes("admin") ?? false;
const [showRetryButton, setShowRetryButton] = useState(false);
const { openModal: openUploadModal } = useModalAction(AddCertificateModal);
const { openConfirmModal } = useConfirmModal();
const { mutateAsync: trustHostnameAsync } = clientApi.certificates.trustHostnameMismatch.useMutation();
const { mutateAsync: addCertificateAsync } = clientApi.certificates.addCertificate.useMutation();
const handleTrustHostname = () => {
const { hostname } = new URL(url);
openConfirmModal({
title: tError("certificate.hostnameMismatch.confirm.title"),
children: tError("certificate.hostnameMismatch.confirm.message"),
// eslint-disable-next-line no-restricted-syntax
async onConfirm() {
await trustHostnameAsync(
{
hostname,
certificate: error.data.certificate.pem,
},
{
onSuccess() {
showSuccessNotification({
title: tError("certificate.hostnameMismatch.notification.success.title"),
message: tError("certificate.hostnameMismatch.notification.success.message"),
});
setShowRetryButton(true);
},
onError() {
showErrorNotification({
title: tError("certificate.hostnameMismatch.notification.error.title"),
message: tError("certificate.hostnameMismatch.notification.error.message"),
});
},
},
);
},
});
};
const handleTrustSelfSigned = () => {
const { hostname } = new URL(url);
openConfirmModal({
title: tError("certificate.selfSigned.confirm.title"),
children: tError("certificate.selfSigned.confirm.message"),
// eslint-disable-next-line no-restricted-syntax
async onConfirm() {
const formData = new FormData();
formData.append(
"file",
new File([error.data.certificate.pem], `${hostname}-${createId()}.crt`, {
type: "application/x-x509-ca-cert",
}),
);
await addCertificateAsync(formData, {
onSuccess() {
showSuccessNotification({
title: tError("certificate.selfSigned.notification.success.title"),
message: tError("certificate.selfSigned.notification.success.message"),
});
setShowRetryButton(true);
},
onError() {
showErrorNotification({
title: tError("certificate.selfSigned.notification.error.title"),
message: tError("certificate.selfSigned.notification.error.message"),
});
},
});
},
});
};
const description = <Text size="md">{tError(`certificate.description.${error.data.reason}`)}</Text>;
if (!isAdmin) {
return (
<>
{description}
<NotEnoughPermissionsAlert />
</>
);
}
return (
<>
{description}
<CertificateDetailsCard certificate={error.data.certificate} />
{error.data.reason === "hostnameMismatch" && <HostnameMismatchAlert />}
{!error.data.certificate.isSelfSigned && error.data.reason === "untrusted" && <CertificateExtractAlert />}
{showRetryButton && (
<Button
variant="default"
fullWidth
leftSection={<IconRepeat size={16} color={getMantineColor("blue", 6)} stroke={1.5} />}
type="submit"
>
{tError("certificate.action.retry.label")}
</Button>
)}
{(error.data.reason === "untrusted" && error.data.certificate.isSelfSigned) ||
error.data.reason === "hostnameMismatch" ? (
<Button
variant="default"
fullWidth
onClick={error.data.reason === "hostnameMismatch" ? handleTrustHostname : handleTrustSelfSigned}
>
{tError("certificate.action.trust.label")}
</Button>
) : null}
{error.data.reason === "untrusted" && !error.data.certificate.isSelfSigned ? (
<Button
variant="default"
fullWidth
onClick={() =>
openUploadModal({
onSuccess() {
setShowRetryButton(true);
},
})
}
>
{tError("certificate.action.upload.label")}
</Button>
) : null}
</>
);
};
const NotEnoughPermissionsAlert = () => {
const t = useI18n();
return (
<Alert
icon={<IconAlertTriangle size={16} />}
title={t("integration.testConnection.error.certificate.alert.permission.title")}
color="yellow"
>
{t("integration.testConnection.error.certificate.alert.permission.message")}
</Alert>
);
};
const HostnameMismatchAlert = () => {
const t = useI18n();
return (
<Alert
icon={<IconAlertTriangle size={16} />}
title={t("integration.testConnection.error.certificate.alert.hostnameMismatch.title")}
color="yellow"
>
{t("integration.testConnection.error.certificate.alert.hostnameMismatch.message")}
</Alert>
);
};
const CertificateExtractAlert = () => {
const t = useI18n();
return (
<Alert
icon={<IconExclamationCircle size={16} />}
title={t("integration.testConnection.error.certificate.alert.extract.title")}
color="red"
>
{t.rich("integration.testConnection.error.certificate.alert.extract.message", {
docsLink: () => (
<Anchor
href={createDocumentationLink("/docs/management/certificates", "#obtaining-certificates")}
target="_blank"
>
{t("common.here")}
</Anchor>
),
})}
</Alert>
);
};
interface CertificateDetailsProps {
certificate: MappedCertificate;
}
export const CertificateDetailsCard = ({ certificate }: CertificateDetailsProps) => {
const { openModal } = useModalAction(PemContentModal);
const locale = useCurrentLocale();
const tDetails = useScopedI18n("integration.testConnection.error.certificate.details");
const tCertificateField = useScopedI18n("certificate.field");
return (
<Card withBorder>
<Text fw={500}>{tDetails("title")}</Text>
<Group justify="space-between">
<Text size="sm" c="dimmed">
{tDetails("description")}
</Text>
<Anchor
size="sm"
ta="start"
component="button"
type="button"
onClick={() => openModal({ content: certificate.pem })}
>
{tDetails("content.action")}
</Anchor>
</Group>
<SimpleGrid cols={{ base: 1, md: 2 }} mt="md">
<Stack gap={0}>
<Text size="xs" c="dimmed">
{tCertificateField("subject.label")}
</Text>
<Text size="sm">{certificate.subject}</Text>
</Stack>
<Stack gap={0}>
<Text size="xs" c="dimmed">
{tCertificateField("issuer.label")}
</Text>
<Text size="sm">{certificate.issuer}</Text>
</Stack>
<Stack gap={0}>
<Text size="xs" c="dimmed">
{tCertificateField("validFrom.label")}
</Text>
<Text size="sm">
{new Intl.DateTimeFormat(locale, {
dateStyle: "full",
timeStyle: "long",
}).format(certificate.validFrom)}
</Text>
</Stack>
<Stack gap={0}>
<Text size="xs" c="dimmed">
{tCertificateField("validTo.label")}
</Text>
<Text size="sm">
{new Intl.DateTimeFormat(locale, {
dateStyle: "full",
timeStyle: "long",
}).format(certificate.validTo)}
</Text>
</Stack>
<Stack gap={0}>
<Text size="xs" c="dimmed">
{tCertificateField("serialNumber.label")}
</Text>
<Text size="sm">{certificate.serialNumber}</Text>
</Stack>
</SimpleGrid>
<SimpleGrid cols={1} mt="md">
<Stack gap={0}>
<Text size="xs" c="dimmed">
{tCertificateField("fingerprint.label")}
</Text>
<Text size="sm">{certificate.fingerprint}</Text>
</Stack>
</SimpleGrid>
</Card>
);
};
const PemContentModal = createModal<{ content: string }>(({ actions, innerProps }) => {
const t = useI18n();
return (
<Stack>
<Card w="100%" pos="relative" bg="dark.6" fz="xs" p="sm">
<pre
style={{
whiteSpace: "pre-wrap",
wordWrap: "break-word",
}}
>
{innerProps.content}
</pre>
<CopyButton value={innerProps.content}>
{({ copy, copied }) => (
<ActionIcon onClick={copy} pos="absolute" top={8} right={8} variant="default">
{copied ? (
<IconCheck size={16} stroke={1.5} color={getMantineColor("green", 6)} />
) : (
<IconCopy size={16} stroke={1.5} />
)}
</ActionIcon>
)}
</CopyButton>
</Card>
<Button variant="light" color="gray" onClick={actions.closeModal}>
{t("common.action.close")}
</Button>
</Stack>
);
}).withOptions({
defaultTitle(t) {
return t("integration.testConnection.error.certificate.details.content.title");
},
size: "lg",
});

View File

@@ -0,0 +1,6 @@
import type { RouterOutputs } from "@homarr/api";
export type AnyMappedTestConnectionError = Exclude<RouterOutputs["integration"]["create"], undefined>["error"];
export type MappedTestConnectionCertificateError = Extract<AnyMappedTestConnectionError, { type: "certificate" }>;
export type MappedCertificate = MappedTestConnectionCertificateError["data"]["certificate"];
export type MappedError = Exclude<AnyMappedTestConnectionError["cause"], undefined>;

View File

@@ -1,5 +1,6 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Button, Fieldset, Group, Stack, TextInput } from "@mantine/core";
@@ -10,7 +11,6 @@ import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { getAllSecretKindOptions, getDefaultSecretKinds } from "@homarr/definitions";
import { useZodForm } from "@homarr/form";
import { convertIntegrationTestConnectionError } from "@homarr/integrations/client";
import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
@@ -18,6 +18,8 @@ import { integrationUpdateSchema } from "@homarr/validation/integration";
import { SecretCard } from "../../_components/secrets/integration-secret-card";
import { IntegrationSecretInput } from "../../_components/secrets/integration-secret-inputs";
import { IntegrationTestConnectionError } from "../../_components/test-connection/integration-test-connection-error";
import type { AnyMappedTestConnectionError } from "../../_components/test-connection/types";
interface EditIntegrationForm {
integration: RouterOutputs["integration"]["byId"];
@@ -43,6 +45,7 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
},
});
const { mutateAsync, isPending } = clientApi.integration.update.useMutation();
const [error, setError] = useState<null | AnyMappedTestConnectionError>(null);
const secretsMap = new Map(integration.secrets.map((secret) => [secret.kind, secret]));
@@ -57,26 +60,24 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
})),
},
{
onSuccess: () => {
onSuccess: (data) => {
// We do it this way as we are unable to send a typesafe error through onError
if (data?.error) {
setError(data.error);
showErrorNotification({
title: t("integration.page.edit.notification.error.title"),
message: t("integration.page.edit.notification.error.message"),
});
return;
}
showSuccessNotification({
title: t("integration.page.edit.notification.success.title"),
message: t("integration.page.edit.notification.success.message"),
});
void revalidatePathActionAsync("/manage/integrations").then(() => router.push("/manage/integrations"));
},
onError: (error) => {
const testConnectionError = convertIntegrationTestConnectionError(error.data?.error);
if (testConnectionError) {
showErrorNotification({
title: t(`integration.testConnection.notification.${testConnectionError.key}.title`),
message:
testConnectionError.message ??
t(`integration.testConnection.notification.${testConnectionError.key}.message`),
});
return;
}
onError: () => {
showErrorNotification({
title: t("integration.page.edit.notification.error.title"),
message: t("integration.page.edit.notification.error.message"),
@@ -128,6 +129,8 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
</Stack>
</Fieldset>
{error !== null && <IntegrationTestConnectionError error={error} url={form.values.url} />}
<Group justify="end" align="center">
<Button variant="default" component={Link} href="/manage/integrations">
{t("common.action.backToOverview")}

View File

@@ -24,13 +24,14 @@ import type { IntegrationKind, IntegrationSecretKind } from "@homarr/definitions
import { getAllSecretKindOptions, getIconUrl, getIntegrationName, integrationDefs } from "@homarr/definitions";
import type { UseFormReturnType } from "@homarr/form";
import { useZodForm } from "@homarr/form";
import { convertIntegrationTestConnectionError } from "@homarr/integrations/client";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { appHrefSchema } from "@homarr/validation/app";
import { integrationCreateSchema } from "@homarr/validation/integration";
import { IntegrationSecretInput } from "../_components/secrets/integration-secret-inputs";
import { IntegrationTestConnectionError } from "../_components/test-connection/integration-test-connection-error";
import type { AnyMappedTestConnectionError } from "../_components/test-connection/types";
interface NewIntegrationFormProps {
searchParams: Partial<z.infer<typeof integrationCreateSchema>> & {
@@ -73,6 +74,7 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
clientApi.integration.create.useMutation();
const { mutateAsync: createAppAsync, isPending: isPendingApp } = clientApi.app.create.useMutation();
const isPending = isPendingIntegration || isPendingApp;
const [error, setError] = useState<null | AnyMappedTestConnectionError>(null);
const handleSubmitAsync = async (values: FormType) => {
await createIntegrationAsync(
@@ -81,7 +83,17 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
...values,
},
{
async onSuccess() {
async onSuccess(data) {
// We do it this way as we are unable to send a typesafe error through onError
if (data?.error) {
setError(data.error);
showErrorNotification({
title: t("integration.page.create.notification.error.title"),
message: t("integration.page.create.notification.error.message"),
});
return;
}
showSuccessNotification({
title: t("integration.page.create.notification.success.title"),
message: t("integration.page.create.notification.success.message"),
@@ -114,19 +126,7 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
},
);
},
onError: (error) => {
const testConnectionError = convertIntegrationTestConnectionError(error.data?.error);
if (testConnectionError) {
showErrorNotification({
title: t(`integration.testConnection.notification.${testConnectionError.key}.title`),
message:
testConnectionError.message ??
t(`integration.testConnection.notification.${testConnectionError.key}.message`),
});
return;
}
onError: () => {
showErrorNotification({
title: t("integration.page.create.notification.error.title"),
message: t("integration.page.create.notification.error.message"),
@@ -164,6 +164,8 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
</Stack>
</Fieldset>
{error !== null && <IntegrationTestConnectionError error={error} url={form.values.url} />}
{supportsSearchEngine && (
<Checkbox
label={t("integration.field.attemptSearchEngineCreation.label")}

View File

@@ -1,82 +1,23 @@
"use client";
import { Button, FileInput, Group, Stack } from "@mantine/core";
import { IconCertificate } from "@tabler/icons-react";
import { z } from "zod";
import { Button } from "@mantine/core";
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 { useModalAction } from "@homarr/modals";
import { AddCertificateModal } from "@homarr/modals-collection";
import { useI18n } from "@homarr/translation/client";
import { superRefineCertificateFile } from "@homarr/validation/certificates";
export const AddCertificateButton = () => {
const { openModal } = useModalAction(AddCertificateModal);
const t = useI18n();
const handleClick = () => {
openModal({});
openModal({
async onSuccess() {
await revalidatePathActionAsync("/manage/tools/certificates");
},
});
};
return <Button onClick={handleClick}>{t("certificate.action.create.label")}</Button>;
};
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 (
<form
onSubmit={form.onSubmit(async (values) => {
const formData = new FormData();
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
formData.set("file", values.file!);
await mutateAsync(formData, {
async onSuccess() {
showSuccessNotification({
title: t("certificate.action.create.notification.success.title"),
message: t("certificate.action.create.notification.success.message"),
});
await revalidatePathActionAsync("/manage/tools/certificates");
actions.closeModal();
},
onError() {
showErrorNotification({
title: t("certificate.action.create.notification.error.title"),
message: t("certificate.action.create.notification.error.message"),
});
},
});
})}
>
<Stack>
<FileInput leftSection={<IconCertificate size={16} />} {...form.getInputProps("file")} />
<Group justify="end">
<Button onClick={actions.closeModal} variant="subtle" color="gray">
{t("common.action.cancel")}
</Button>
<Button type="submit" loading={form.submitting}>
{t("common.action.add")}
</Button>
</Group>
</Stack>
</form>
);
}).withOptions({
defaultTitle(t) {
return t("certificate.action.create.label");
},
});

View File

@@ -0,0 +1,52 @@
"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 RemoveHostnameActionIconProps {
hostname: string;
thumbprint: string;
}
export const RemoveHostnameActionIcon = (input: RemoveHostnameActionIconProps) => {
const { mutateAsync } = clientApi.certificates.removeTrustedHostname.useMutation();
const { openConfirmModal } = useConfirmModal();
const t = useI18n();
const handleRemove = () => {
openConfirmModal({
title: t("certificate.action.removeHostname.label"),
children: t("certificate.action.removeHostname.confirm"),
// eslint-disable-next-line no-restricted-syntax
async onConfirm() {
await mutateAsync(input, {
async onSuccess() {
await revalidatePathActionAsync("/manage/tools/certificates/hostnames");
showSuccessNotification({
title: t("certificate.action.removeHostname.notification.success.title"),
message: t("certificate.action.removeHostname.notification.success.message"),
});
},
onError() {
showErrorNotification({
title: t("certificate.action.removeHostname.notification.error.title"),
message: t("certificate.action.removeHostname.notification.error.message"),
});
},
});
},
});
};
return (
<ActionIcon color="red" variant="subtle" onClick={handleRemove}>
<IconTrash color="red" size={16} stroke={1.5} />
</ActionIcon>
);
};

View File

@@ -0,0 +1,99 @@
import { X509Certificate } from "node:crypto";
import Link from "next/link";
import { notFound } from "next/navigation";
import {
Button,
Group,
Stack,
Table,
TableTbody,
TableTd,
TableTh,
TableThead,
TableTr,
Text,
Title,
} from "@mantine/core";
import { IconCertificateOff } from "@tabler/icons-react";
import { auth } from "@homarr/auth/next";
import { getTrustedCertificateHostnamesAsync } from "@homarr/certificates/server";
import { getI18n } from "@homarr/translation/server";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { NoResults } from "~/components/no-results";
import { RemoveHostnameActionIcon } from "./_components/remove-hostname";
export default async function TrustedHostnamesPage() {
const session = await auth();
if (!session?.user.permissions.includes("admin")) {
notFound();
}
const t = await getI18n();
const trustedHostnames = await getTrustedCertificateHostnamesAsync().then((hostnames) => {
return hostnames.map((hostname) => {
let subject: string | null;
try {
subject = new X509Certificate(hostname.certificate).subject;
} catch {
subject = null;
}
return {
...hostname,
subject,
};
});
});
return (
<>
<DynamicBreadcrumb />
<Stack>
<Group justify="space-between">
<Stack gap={4}>
<Title>{t("certificate.page.hostnames.title")}</Title>
<Text>{t("certificate.page.hostnames.description")}</Text>
</Stack>
<Button variant="default" component={Link} href="/manage/tools/certificates">
{t("certificate.page.hostnames.toCertificates")}
</Button>
</Group>
{trustedHostnames.length === 0 && (
<NoResults icon={IconCertificateOff} title={t("certificate.page.hostnames.noResults.title")} />
)}
{trustedHostnames.length >= 1 && (
<Table>
<TableThead>
<TableTr>
<TableTh>{t("certificate.field.hostname.label")}</TableTh>
<TableTh>{t("certificate.field.subject.label")}</TableTh>
<TableTh>{t("certificate.field.fingerprint.label")}</TableTh>
<TableTh></TableTh>
</TableTr>
</TableThead>
<TableTbody>
{trustedHostnames.map(({ hostname, subject, thumbprint }) => (
<TableTr key={`${hostname}-${thumbprint}`}>
<TableTd>{hostname}</TableTd>
<TableTd>{subject}</TableTd>
<TableTd>{thumbprint}</TableTd>
<TableTd>
<Group justify="end">
<RemoveHostnameActionIcon hostname={hostname} thumbprint={thumbprint} />
</Group>
</TableTd>
</TableTr>
))}
</TableTbody>
</Table>
)}
</Stack>
</>
);
}

View File

@@ -1,6 +1,7 @@
import { X509Certificate } from "node:crypto";
import Link from "next/link";
import { notFound } from "next/navigation";
import { Card, Group, SimpleGrid, Stack, Text, Title } from "@mantine/core";
import { Button, Card, Group, SimpleGrid, Stack, Text, Title } from "@mantine/core";
import { IconAlertTriangle, IconCertificate, IconCertificateOff } from "@tabler/icons-react";
import dayjs from "dayjs";
@@ -64,7 +65,12 @@ export default async function CertificatesPage({ params }: CertificatesPageProps
<Text>{t("certificate.page.list.description")}</Text>
</Stack>
<AddCertificateButton />
<Group>
<Button variant="default" component={Link} href="/manage/tools/certificates/hostnames">
{t("certificate.page.list.toHostnames")}
</Button>
<AddCertificateButton />
</Group>
</Group>
{x509Certificates.length === 0 && (

View File

@@ -11,11 +11,11 @@ interface LogoProps {
export const Logo = ({ size = 60, shouldUseNextImage = false, src, alt }: LogoProps) =>
shouldUseNextImage ? (
<Image src={src} alt={alt} width={size} height={size} />
<Image className="logo" src={src} alt={alt} width={size} height={size} />
) : (
// we only want to use next/image for logos that we are sure will be preloaded and are allowed
// eslint-disable-next-line @next/next/no-img-element
<img src={src} alt={alt} width={size} height={size} />
<img className="logo" src={src} alt={alt} width={size} height={size} />
);
const logoWithTitleSizes = {

View File

@@ -3,7 +3,8 @@
"compilerOptions": {
"baseUrl": ".",
"paths": {
"~/*": ["./src/*"]
"~/*": ["./src/*"],
"@homarr/node-unifi": ["../../node_modules/@types/node-unifi"]
},
"plugins": [
{

View File

@@ -38,13 +38,13 @@
"dayjs": "^1.11.13",
"dotenv": "^16.5.0",
"superjson": "2.2.2",
"undici": "7.8.0"
"undici": "7.9.0"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/node": "^22.15.17",
"@types/node": "^22.15.18",
"dotenv-cli": "^8.0.0",
"eslint": "^9.26.0",
"prettier": "^3.5.3",

View File

@@ -3,7 +3,8 @@
"compilerOptions": {
"baseUrl": ".",
"paths": {
"~/*": ["src/*"]
"~/*": ["src/*"],
"@homarr/node-unifi": ["../../node_modules/@types/node-unifi"]
},
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
},

View File

@@ -3,7 +3,8 @@
"compilerOptions": {
"baseUrl": ".",
"paths": {
"~/*": ["src/*"]
"~/*": ["src/*"],
"@homarr/node-unifi": ["../../node_modules/@types/node-unifi"]
},
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
},

View File

@@ -47,15 +47,15 @@
"jsdom": "^26.1.0",
"prettier": "^3.5.3",
"semantic-release": "^24.2.3",
"testcontainers": "^10.25.0",
"testcontainers": "^10.26.0",
"turbo": "^2.5.3",
"typescript": "^5.8.3",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^3.1.3"
},
"packageManager": "pnpm@10.10.0",
"packageManager": "pnpm@10.11.0",
"engines": {
"node": ">=22.15.0"
"node": ">=22.15.1"
},
"pnpm": {
"onlyBuiltDependencies": [
@@ -70,13 +70,16 @@
"tree-sitter-json"
],
"overrides": {
"proxmox-api>undici": "7.8.0"
"proxmox-api>undici": "7.9.0"
},
"allowUnusedPatches": true,
"ignoredBuiltDependencies": [
"@scarf/scarf",
"core-js-pure",
"protobufjs"
]
],
"patchedDependencies": {
"@types/node-unifi": "patches/@types__node-unifi.patch"
}
}
}

View File

@@ -41,7 +41,7 @@
"@homarr/server-settings": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@kubernetes/client-node": "^1.2.0",
"@tanstack/react-query": "^5.75.7",
"@tanstack/react-query": "^5.76.1",
"@trpc/client": "^11.1.2",
"@trpc/react-query": "^11.1.2",
"@trpc/server": "^11.1.2",

View File

@@ -4,6 +4,9 @@ import { z } from "zod";
import { zfd } from "zod-form-data";
import { addCustomRootCertificateAsync, removeCustomRootCertificateAsync } from "@homarr/certificates/server";
import { and, eq } from "@homarr/db";
import { trustedCertificateHostnames } from "@homarr/db/schema";
import { logger } from "@homarr/log";
import { certificateValidFileNameSchema, superRefineCertificateFile } from "@homarr/validation/certificates";
import { createTRPCRouter, permissionRequiredProcedure } from "../../trpc";
@@ -20,8 +23,13 @@ export const certificateRouter = createTRPCRouter({
const content = await input.file.text();
// Validate the certificate
let x509Certificate: X509Certificate;
try {
new X509Certificate(content);
x509Certificate = new X509Certificate(content);
logger.info("Adding trusted certificate", {
subject: x509Certificate.subject,
issuer: x509Certificate.issuer,
});
} catch {
throw new TRPCError({
code: "BAD_REQUEST",
@@ -30,11 +38,89 @@ export const certificateRouter = createTRPCRouter({
}
await addCustomRootCertificateAsync(input.file.name, content);
logger.info("Added trusted certificate", {
subject: x509Certificate.subject,
issuer: x509Certificate.issuer,
});
}),
trustHostnameMismatch: permissionRequiredProcedure
.requiresPermission("admin")
.input(z.object({ hostname: z.string(), certificate: z.string() }))
.mutation(async ({ ctx, input }) => {
// Validate the certificate
let x509Certificate: X509Certificate;
try {
x509Certificate = new X509Certificate(input.certificate);
logger.info("Adding trusted hostname", {
subject: x509Certificate.subject,
issuer: x509Certificate.issuer,
thumbprint: x509Certificate.fingerprint256,
hostname: input.hostname,
});
} catch {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Invalid certificate",
});
}
await ctx.db.insert(trustedCertificateHostnames).values({
hostname: input.hostname,
thumbprint: x509Certificate.fingerprint256,
certificate: input.certificate,
});
logger.info("Added trusted hostname", {
subject: x509Certificate.subject,
issuer: x509Certificate.issuer,
thumbprint: x509Certificate.fingerprint256,
hostname: input.hostname,
});
}),
removeTrustedHostname: permissionRequiredProcedure
.requiresPermission("admin")
.input(z.object({ hostname: z.string(), thumbprint: z.string() }))
.mutation(async ({ ctx, input }) => {
logger.info("Removing trusted hostname", {
hostname: input.hostname,
thumbprint: input.thumbprint,
});
const dbResult = await ctx.db
.delete(trustedCertificateHostnames)
.where(
and(
eq(trustedCertificateHostnames.hostname, input.hostname),
eq(trustedCertificateHostnames.thumbprint, input.thumbprint),
),
);
logger.info("Removed trusted hostname", {
hostname: input.hostname,
thumbprint: input.thumbprint,
count: dbResult.changes,
});
}),
removeCertificate: permissionRequiredProcedure
.requiresPermission("admin")
.input(z.object({ fileName: certificateValidFileNameSchema }))
.mutation(async ({ input }) => {
await removeCustomRootCertificateAsync(input.fileName);
.mutation(async ({ input, ctx }) => {
logger.info("Removing trusted certificate", {
fileName: input.fileName,
});
const certificate = await removeCustomRootCertificateAsync(input.fileName);
if (!certificate) return;
// Delete all trusted hostnames for this certificate
await ctx.db
.delete(trustedCertificateHostnames)
.where(eq(trustedCertificateHostnames.thumbprint, certificate.fingerprint256));
logger.info("Removed trusted certificate", {
fileName: input.fileName,
subject: certificate.subject,
issuer: certificate.issuer,
});
}),
});

View File

@@ -24,6 +24,7 @@ import {
integrationSecretKindObject,
} from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
import { logger } from "@homarr/log";
import { byIdSchema } from "@homarr/validation/common";
import {
integrationCreateSchema,
@@ -34,7 +35,8 @@ import {
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../../trpc";
import { throwIfActionForbiddenAsync } from "./integration-access";
import { testConnectionAsync } from "./integration-test-connection";
import { MissingSecretError, testConnectionAsync } from "./integration-test-connection";
import { mapTestConnectionError } from "./map-test-connection-error";
export const integrationRouter = createTRPCRouter({
all: publicProcedure.query(async ({ ctx }) => {
@@ -185,14 +187,34 @@ export const integrationRouter = createTRPCRouter({
.requiresPermission("integration-create")
.input(integrationCreateSchema)
.mutation(async ({ ctx, input }) => {
await testConnectionAsync({
logger.info("Creating integration", {
name: input.name,
kind: input.kind,
url: input.url,
});
const result = await testConnectionAsync({
id: "new",
name: input.name,
url: input.url,
kind: input.kind,
secrets: input.secrets,
}).catch((error) => {
if (!(error instanceof MissingSecretError)) throw error;
throw new TRPCError({
code: "BAD_REQUEST",
message: error.message,
});
});
if (!result.success) {
logger.error(result.error);
return {
error: mapTestConnectionError(result.error),
};
}
const integrationId = createId();
await ctx.db.insert(integrations).values({
id: integrationId,
@@ -211,6 +233,13 @@ export const integrationRouter = createTRPCRouter({
);
}
logger.info("Created integration", {
id: integrationId,
name: input.name,
kind: input.kind,
url: input.url,
});
if (
input.attemptSearchEngineCreation &&
integrationDefs[input.kind].category.flatMap((category) => category).includes("search")
@@ -229,6 +258,10 @@ export const integrationRouter = createTRPCRouter({
update: protectedProcedure.input(integrationUpdateSchema).mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full");
logger.info("Updating integration", {
id: input.id,
});
const integration = await ctx.db.query.integrations.findFirst({
where: eq(integrations.id, input.id),
with: {
@@ -243,7 +276,7 @@ export const integrationRouter = createTRPCRouter({
});
}
await testConnectionAsync(
const testResult = await testConnectionAsync(
{
id: input.id,
name: input.name,
@@ -252,7 +285,21 @@ export const integrationRouter = createTRPCRouter({
secrets: input.secrets,
},
integration.secrets,
);
).catch((error) => {
if (!(error instanceof MissingSecretError)) throw error;
throw new TRPCError({
code: "BAD_REQUEST",
message: error.message,
});
});
if (!testResult.success) {
logger.error(testResult.error);
return {
error: mapTestConnectionError(testResult.error),
};
}
await ctx.db
.update(integrations)
@@ -286,6 +333,13 @@ export const integrationRouter = createTRPCRouter({
}
}
}
logger.info("Updated integration", {
id: input.id,
name: input.name,
kind: integration.kind,
url: input.url,
});
}),
delete: protectedProcedure.input(byIdSchema).mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full");

View File

@@ -2,7 +2,7 @@ import { decryptSecret } from "@homarr/common/server";
import type { Integration } from "@homarr/db/schema";
import type { IntegrationKind, IntegrationSecretKind } from "@homarr/definitions";
import { getAllSecretKindOptions } from "@homarr/definitions";
import { createIntegrationAsync, IntegrationTestConnectionError } from "@homarr/integrations";
import { createIntegrationAsync } from "@homarr/integrations";
import { logger } from "@homarr/log";
type FormIntegration = Integration & {
@@ -19,6 +19,12 @@ export const testConnectionAsync = async (
value: `${string}.${string}`;
}[] = [],
) => {
logger.info("Testing connection", {
integrationName: integration.name,
integrationKind: integration.kind,
integrationUrl: integration.url,
});
const formSecrets = integration.secrets
.filter((secret) => secret.value !== null)
.map((secret) => ({
@@ -72,7 +78,15 @@ export const testConnectionAsync = async (
decryptedSecrets,
});
await integrationInstance.testConnectionAsync();
const result = await integrationInstance.testConnectionAsync();
if (result.success) {
logger.info("Tested connection successfully", {
integrationName: integration.name,
integrationKind: integration.kind,
integrationUrl: integration.url,
});
}
return result;
};
interface SourcedIntegrationSecret {
@@ -87,7 +101,7 @@ const getSecretKindOption = (kind: IntegrationKind, sourcedSecrets: SourcedInteg
);
if (matchingSecretKindOptions.length === 0) {
throw new IntegrationTestConnectionError("secretNotDefined");
throw new MissingSecretError();
}
if (matchingSecretKindOptions.length === 1) {
@@ -122,3 +136,9 @@ const getSecretKindOption = (kind: IntegrationKind, sourcedSecrets: SourcedInteg
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return matchingSecretKindOptions[0]!;
};
export class MissingSecretError extends Error {
constructor() {
super("No secret defined for this integration");
}
}

View File

@@ -0,0 +1,141 @@
import type { X509Certificate } from "node:crypto";
import type { RequestErrorCode } from "@homarr/common/server";
import type {
AnyTestConnectionError,
TestConnectionErrorDataOfType,
TestConnectionErrorType,
} from "@homarr/integrations/test-connection";
export interface MappedError {
name: string;
message: string;
metadata: { key: string; value: string | number | boolean }[];
cause?: MappedError;
}
const ignoredErrorProperties = ["name", "message", "cause", "stack"];
const mapError = (error: Error): MappedError => {
const metadata = Object.entries(error)
.filter(([key]) => !ignoredErrorProperties.includes(key))
.map(([key, value]) => {
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
return { key, value };
}
return null;
})
.filter((value) => value !== null);
return {
name: error.name,
message: error.message,
metadata,
cause: error.cause && error.cause instanceof Error ? mapError(error.cause) : undefined,
};
};
export interface MappedCertificate {
isSelfSigned: boolean;
issuer: string;
subject: string;
serialNumber: string;
validFrom: Date;
validTo: Date;
fingerprint: string;
pem: string;
}
const mapCertificate = (certificate: X509Certificate, code: RequestErrorCode): MappedCertificate => ({
isSelfSigned: certificate.ca || code === "DEPTH_ZERO_SELF_SIGNED_CERT",
issuer: certificate.issuer,
subject: certificate.subject,
serialNumber: certificate.serialNumber,
validFrom: certificate.validFromDate,
validTo: certificate.validToDate,
fingerprint: certificate.fingerprint256,
pem: certificate.toString(),
});
type MappedData<TType extends TestConnectionErrorType> = TType extends "unknown" | "parse"
? undefined
: TType extends "certificate"
? {
type: TestConnectionErrorDataOfType<TType>["requestError"]["type"];
reason: TestConnectionErrorDataOfType<TType>["requestError"]["reason"];
certificate: MappedCertificate;
}
: TType extends "request"
? {
type: TestConnectionErrorDataOfType<TType>["requestError"]["type"];
reason: TestConnectionErrorDataOfType<TType>["requestError"]["reason"];
}
: TType extends "authorization"
? {
statusCode: TestConnectionErrorDataOfType<TType>["statusCode"];
reason: TestConnectionErrorDataOfType<TType>["reason"];
}
: TType extends "statusCode"
? {
statusCode: TestConnectionErrorDataOfType<TType>["statusCode"];
reason: TestConnectionErrorDataOfType<TType>["reason"];
url: TestConnectionErrorDataOfType<TType>["url"];
}
: never;
type AnyMappedData = {
[TType in TestConnectionErrorType]: MappedData<TType>;
}[TestConnectionErrorType];
const mapData = (error: AnyTestConnectionError): AnyMappedData => {
if (error.type === "unknown") return undefined;
if (error.type === "parse") return undefined;
if (error.type === "certificate") {
return {
type: error.data.requestError.type,
reason: error.data.requestError.reason,
certificate: mapCertificate(error.data.certificate, error.data.requestError.code),
};
}
if (error.type === "request") {
return {
type: error.data.requestError.type,
reason: error.data.requestError.reason,
};
}
if (error.type === "authorization") {
return {
statusCode: error.data.statusCode,
reason: error.data.reason,
};
}
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (error.type === "statusCode") {
return {
statusCode: error.data.statusCode,
reason: error.data.reason,
url: error.data.url,
};
}
throw new Error(`Unsupported error type: ${(error as AnyTestConnectionError).type}`);
};
interface MappedTestConnectionError<TType extends TestConnectionErrorType> {
type: TType;
name: string;
message: string;
data: MappedData<TType>;
cause?: MappedError;
}
export type AnyMappedTestConnectionError = {
[TType in TestConnectionErrorType]: MappedTestConnectionError<TType>;
}[TestConnectionErrorType];
export const mapTestConnectionError = (error: AnyTestConnectionError) => {
return {
type: error.type,
name: error.name,
message: error.message,
data: mapData(error),
cause: error.cause ? mapError(error.cause) : undefined,
} as AnyMappedTestConnectionError;
};

View File

@@ -25,7 +25,7 @@ const defaultSessionWithPermissions = (permissions: GroupPermissionKey[] = []) =
// Mock the auth module to return an empty session
vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session }));
vi.mock("../../integration/integration-test-connection", () => ({
testConnectionAsync: async () => await Promise.resolve(undefined),
testConnectionAsync: async () => await Promise.resolve({ success: true }),
}));
describe("all should return all integrations", () => {

View File

@@ -22,7 +22,7 @@ describe("testConnectionAsync should run test connection of integration", () =>
const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
factorySpy.mockReturnValue(
Promise.resolve({
testConnectionAsync: async () => await Promise.resolve(),
testConnectionAsync: async () => await Promise.resolve({ success: true }),
} as homarrIntegrations.PiHoleIntegrationV6),
);
optionsSpy.mockReturnValue([["apiKey"]]);
@@ -64,7 +64,7 @@ describe("testConnectionAsync should run test connection of integration", () =>
const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
factorySpy.mockReturnValue(
Promise.resolve({
testConnectionAsync: async () => await Promise.resolve(),
testConnectionAsync: async () => await Promise.resolve({ success: true }),
} as homarrIntegrations.PiHoleIntegrationV6),
);
optionsSpy.mockReturnValue([["apiKey"]]);
@@ -113,7 +113,7 @@ describe("testConnectionAsync should run test connection of integration", () =>
const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
factorySpy.mockReturnValue(
Promise.resolve({
testConnectionAsync: async () => await Promise.resolve(),
testConnectionAsync: async () => await Promise.resolve({ success: true }),
} as homarrIntegrations.PiHoleIntegrationV6),
);
optionsSpy.mockReturnValue([["apiKey"]]);
@@ -162,7 +162,7 @@ describe("testConnectionAsync should run test connection of integration", () =>
const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
factorySpy.mockReturnValue(
Promise.resolve({
testConnectionAsync: async () => await Promise.resolve(),
testConnectionAsync: async () => await Promise.resolve({ success: true }),
} as homarrIntegrations.PiHoleIntegrationV6),
);
optionsSpy.mockReturnValue([["username", "password"], ["apiKey"]]);
@@ -215,7 +215,7 @@ describe("testConnectionAsync should run test connection of integration", () =>
const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
factorySpy.mockReturnValue(
Promise.resolve({
testConnectionAsync: async () => await Promise.resolve(),
testConnectionAsync: async () => await Promise.resolve({ success: true }),
} as homarrIntegrations.PiHoleIntegrationV6),
);
optionsSpy.mockReturnValue([["username", "password"], ["apiKey"]]);

View File

@@ -40,6 +40,7 @@ export const env = createEnv({
AUTH_OIDC_GROUPS_ATTRIBUTE: z.string().default("groups"), // Is used in the signIn event to assign the correct groups, key is from object of decoded id_token
AUTH_OIDC_NAME_ATTRIBUTE_OVERWRITE: z.string().optional(),
AUTH_OIDC_FORCE_USERINFO: createBooleanSchema(false),
AUTH_OIDC_ENABLE_DANGEROUS_ACCOUNT_LINKING: createBooleanSchema(false),
}
: {}),
...(authProviders.includes("ldap")

View File

@@ -23,8 +23,8 @@
},
"prettier": "@homarr/prettier-config",
"dependencies": {
"@auth/core": "^0.39.0",
"@auth/drizzle-adapter": "^1.9.0",
"@auth/core": "^0.39.1",
"@auth/drizzle-adapter": "^1.9.1",
"@homarr/certificates": "workspace:^0.1.0",
"@homarr/common": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0",
@@ -32,11 +32,11 @@
"@homarr/env": "workspace:^0.1.0",
"@homarr/log": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"bcrypt": "^5.1.1",
"bcrypt": "^6.0.0",
"cookies": "^0.9.1",
"ldapts": "8.0.0",
"next": "15.3.2",
"next-auth": "5.0.0-beta.27",
"next-auth": "5.0.0-beta.28",
"react": "19.1.0",
"react-dom": "19.1.0",
"zod": "^3.24.4"

View File

@@ -15,6 +15,7 @@ export const OidcProvider = (headers: ReadonlyHeaders | null): OIDCConfig<Profil
clientId: env.AUTH_OIDC_CLIENT_ID,
clientSecret: env.AUTH_OIDC_CLIENT_SECRET,
issuer: env.AUTH_OIDC_ISSUER,
allowDangerousEmailAccountLinking: env.AUTH_OIDC_ENABLE_DANGEROUS_ACCOUNT_LINKING,
authorization: {
params: {
scope: env.AUTH_OIDC_SCOPE_OVERWRITE,

View File

@@ -23,7 +23,8 @@
"prettier": "@homarr/prettier-config",
"dependencies": {
"@homarr/common": "workspace:^0.1.0",
"undici": "7.8.0"
"@homarr/db": "workspace:^0.1.0",
"undici": "7.9.0"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",

View File

@@ -1,13 +1,18 @@
import { X509Certificate } from "node:crypto";
import fsSync from "node:fs";
import fs from "node:fs/promises";
import type { AgentOptions } from "node:https";
import { Agent as HttpsAgent } from "node:https";
import path from "node:path";
import { rootCertificates } from "node:tls";
import { checkServerIdentity, rootCertificates } from "node:tls";
import axios from "axios";
import { fetch } from "undici";
import { env } from "@homarr/common/env";
import { LoggingAgent } from "@homarr/common/server";
import type { InferSelectModel } from "@homarr/db";
import { db } from "@homarr/db";
import type { trustedCertificateHostnames } from "@homarr/db/schema";
const getCertificateFolder = () => {
return env.NODE_ENV === "production"
@@ -40,10 +45,23 @@ export const loadCustomRootCertificatesAsync = async () => {
export const removeCustomRootCertificateAsync = async (fileName: string) => {
const folder = getCertificateFolder();
if (!folder) {
return;
return null;
}
await fs.rm(path.join(folder, fileName));
const existingFiles = await fs.readdir(folder, { withFileTypes: true });
if (!existingFiles.some((file) => file.isFile() && file.name === fileName)) {
throw new Error(`File ${fileName} does not exist`);
}
const fullPath = path.join(folder, fileName);
const content = await fs.readFile(fullPath, "utf8");
await fs.rm(fullPath);
try {
return new X509Certificate(content);
} catch {
return null;
}
};
export const addCustomRootCertificateAsync = async (fileName: string, content: string) => {
@@ -61,25 +79,56 @@ export const addCustomRootCertificateAsync = async (fileName: string, content: s
await fs.writeFile(path.join(folder, fileName), content);
};
export const createCertificateAgentAsync = async () => {
export const getTrustedCertificateHostnamesAsync = async () => {
return await db.query.trustedCertificateHostnames.findMany();
};
export const getAllTrustedCertificatesAsync = async () => {
const customCertificates = await loadCustomRootCertificatesAsync();
return rootCertificates.concat(customCertificates.map((cert) => cert.content));
};
export const createCustomCheckServerIdentity = (
trustedHostnames: InferSelectModel<typeof trustedCertificateHostnames>[],
): typeof checkServerIdentity => {
return (hostname, peerCertificate) => {
const matchingTrustedHostnames = trustedHostnames.filter(
(cert) => cert.thumbprint === peerCertificate.fingerprint256,
);
// We trust the certificate if we have a matching hostname
if (matchingTrustedHostnames.some((cert) => cert.hostname === hostname)) return undefined;
return checkServerIdentity(hostname, peerCertificate);
};
};
export const createCertificateAgentAsync = async (override?: {
ca: string | string[];
checkServerIdentity: typeof checkServerIdentity;
}) => {
return new LoggingAgent({
connect: {
ca: rootCertificates.concat(customCertificates.map((cert) => cert.content)),
connect: override ?? {
ca: await getAllTrustedCertificatesAsync(),
checkServerIdentity: createCustomCheckServerIdentity(await getTrustedCertificateHostnamesAsync()),
},
});
};
export const createHttpsAgentAsync = async () => {
const customCertificates = await loadCustomRootCertificatesAsync();
return new HttpsAgent({
ca: rootCertificates.concat(customCertificates.map((cert) => cert.content)),
});
export const createHttpsAgentAsync = async (override?: Pick<AgentOptions, "ca" | "checkServerIdentity">) => {
return new HttpsAgent(
override ?? {
ca: await getAllTrustedCertificatesAsync(),
checkServerIdentity: createCustomCheckServerIdentity(await getTrustedCertificateHostnamesAsync()),
},
);
};
export const createAxiosCertificateInstanceAsync = async () => {
export const createAxiosCertificateInstanceAsync = async (
override?: Pick<AgentOptions, "ca" | "checkServerIdentity">,
) => {
return axios.create({
httpsAgent: await createHttpsAgentAsync(),
httpsAgent: await createHttpsAgentAsync(override),
});
};

View File

@@ -33,8 +33,9 @@
"next": "15.3.2",
"react": "19.1.0",
"react-dom": "19.1.0",
"undici": "7.8.0",
"zod": "^3.24.4"
"undici": "7.9.0",
"zod": "^3.24.4",
"zod-validation-error": "^3.4.0"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",

View File

@@ -0,0 +1,43 @@
import { AxiosError } from "axios";
import { logger } from "@homarr/log";
import type { AnyRequestError } from "../request-error";
import { RequestError } from "../request-error";
import { ResponseError } from "../response-error";
import { matchErrorCode } from "./fetch-http-error-handler";
import { HttpErrorHandler } from "./http-error-handler";
export class AxiosHttpErrorHandler extends HttpErrorHandler {
handleRequestError(error: unknown): AnyRequestError | undefined {
if (!(error instanceof AxiosError)) return undefined;
if (error.code === undefined) return undefined;
logger.debug("Received Axios request error", {
code: error.code,
message: error.message,
});
const requestErrorInput = matchErrorCode(error.code);
if (!requestErrorInput) return undefined;
return new RequestError(requestErrorInput, {
cause: error,
});
}
handleResponseError(error: unknown): ResponseError | undefined {
if (!(error instanceof AxiosError)) return undefined;
if (error.response === undefined) return undefined;
logger.debug("Received Axios response error", {
status: error.response.status,
url: error.response.config.url,
message: error.message,
});
return new ResponseError({
status: error.response.status,
url: error.response.config.url ?? "?",
});
}
}

View File

@@ -0,0 +1,68 @@
import { logger } from "@homarr/log";
import { objectEntries } from "../../../object";
import type { Modify } from "../../../types";
import type { AnyRequestError, AnyRequestErrorInput, RequestErrorCode, RequestErrorReason } from "../request-error";
import { RequestError, requestErrorMap } from "../request-error";
import type { ResponseError } from "../response-error";
import { HttpErrorHandler } from "./http-error-handler";
export class FetchHttpErrorHandler extends HttpErrorHandler {
constructor(private type = "undici") {
super();
}
handleRequestError(error: unknown): AnyRequestError | undefined {
if (!isTypeErrorWithCode(error)) return undefined;
logger.debug(`Received ${this.type} request error`, {
code: error.cause.code,
});
const result = matchErrorCode(error.cause.code);
if (!result) return undefined;
return new RequestError(result, { cause: error });
}
/**
* Response errors do not exist for fetch as it does not throw errors for non successful responses.
*/
handleResponseError(_: unknown): ResponseError | undefined {
return undefined;
}
}
type TypeErrorWithCode = Modify<
TypeError,
{
cause: Error & { code: string };
}
>;
const isTypeErrorWithCode = (error: unknown): error is TypeErrorWithCode => {
return (
error instanceof TypeError &&
error.cause instanceof Error &&
"code" in error.cause &&
typeof error.cause.code === "string"
);
};
export const matchErrorCode = (code: string): AnyRequestErrorInput | undefined => {
for (const [key, value] of objectEntries(requestErrorMap)) {
const entries = Object.entries(value) as [string, string | string[]][];
const found = entries.find(([_, entryCode]) =>
typeof entryCode === "string" ? entryCode === code : entryCode.includes(code),
);
if (!found) continue;
return {
type: key,
reason: found[0] as RequestErrorReason<typeof key>,
code: code as RequestErrorCode,
};
}
return undefined;
};

View File

@@ -0,0 +1,7 @@
import type { AnyRequestError } from "../request-error";
import type { ResponseError } from "../response-error";
export abstract class HttpErrorHandler {
abstract handleRequestError(error: unknown): AnyRequestError | undefined;
abstract handleResponseError(error: unknown): ResponseError | undefined;
}

View File

@@ -0,0 +1,5 @@
export * from "./http-error-handler";
export * from "./fetch-http-error-handler";
export * from "./ofetch-http-error-handler";
export * from "./axios-http-error-handler";
export * from "./tsdav-http-error-handler";

View File

@@ -0,0 +1,38 @@
import { FetchError } from "ofetch";
import { logger } from "@homarr/log";
import type { AnyRequestError } from "../request-error";
import { ResponseError } from "../response-error";
import { FetchHttpErrorHandler } from "./fetch-http-error-handler";
import { HttpErrorHandler } from "./http-error-handler";
/**
* Ofetch is a wrapper around the native fetch API
* which will always throw the FetchError (also for non successful responses).
*
* It is for example used within the ctrl packages like qbittorrent, deluge, transmission, etc.
*/
export class OFetchHttpErrorHandler extends HttpErrorHandler {
handleRequestError(error: unknown): AnyRequestError | undefined {
if (!(error instanceof FetchError)) return undefined;
if (!(error.cause instanceof TypeError)) return undefined;
const result = new FetchHttpErrorHandler("ofetch").handleRequestError(error.cause);
if (!result) return undefined;
return result;
}
handleResponseError(error: unknown): ResponseError | undefined {
if (!(error instanceof FetchError)) return undefined;
if (error.response === undefined) return undefined;
logger.debug("Received ofetch response error", {
status: error.response.status,
url: error.response.url,
});
return new ResponseError(error.response);
}
}

View File

@@ -0,0 +1,25 @@
import { logger } from "@homarr/log";
import type { AnyRequestError } from "../request-error";
import { ResponseError } from "../response-error";
import { FetchHttpErrorHandler } from "./fetch-http-error-handler";
import { HttpErrorHandler } from "./http-error-handler";
export class TsdavHttpErrorHandler extends HttpErrorHandler {
handleRequestError(error: unknown): AnyRequestError | undefined {
return new FetchHttpErrorHandler("tsdav").handleRequestError(error);
}
handleResponseError(error: unknown): ResponseError | undefined {
if (!(error instanceof Error)) return undefined;
// Tsdav sadly does not throw a custom error and rather just uses "Error"
// https://github.com/natelindev/tsdav/blob/bf33f04b1884694d685ee6f2b43fe9354b12d167/src/account.ts#L86
if (error.message !== "Invalid credentials") return undefined;
logger.debug("Received Tsdav response error", {
status: 401,
});
return new ResponseError({ status: 401, url: "?" });
}
}

View File

@@ -0,0 +1,3 @@
export * from "./handlers";
export * from "./request-error";
export * from "./response-error";

View File

@@ -0,0 +1,73 @@
export type AnyRequestError = {
[key in keyof RequestErrorMap]: RequestError<key>;
}[keyof RequestErrorMap];
export type AnyRequestErrorInput = {
[key in RequestErrorType]: RequestErrorInput<key>;
}[RequestErrorType];
export interface RequestErrorInput<TType extends RequestErrorType> {
type: TType;
reason: RequestErrorReason<TType>;
code: RequestErrorCode;
}
export class RequestError<TType extends RequestErrorType> extends Error {
public readonly type: TType;
public readonly reason: RequestErrorReason<TType>;
public readonly code: RequestErrorCode;
constructor(input: AnyRequestErrorInput, options: { cause?: Error }) {
super("Request failed", options);
this.name = RequestError.name;
this.type = input.type as TType;
this.reason = input.reason as RequestErrorReason<TType>;
this.code = input.code;
}
get cause(): Error | undefined {
return super.cause as Error | undefined;
}
}
export const requestErrorMap = {
certificate: {
expired: ["CERT_HAS_EXPIRED"],
hostnameMismatch: ["ERR_TLS_CERT_ALTNAME_INVALID", "CERT_COMMON_NAME_INVALID"],
notYetValid: ["CERT_NOT_YET_VALID"],
untrusted: ["DEPTH_ZERO_SELF_SIGNED_CERT", "UNABLE_TO_VERIFY_LEAF_SIGNATURE", "UNABLE_TO_GET_ISSUER_CERT_LOCALLY"],
},
connection: {
hostUnreachable: "EHOSTUNREACH",
networkUnreachable: "ENETUNREACH",
refused: "ECONNREFUSED",
reset: "ECONNRESET",
},
dns: {
notFound: "ENOTFOUND",
timeout: "EAI_AGAIN",
noAnswer: "ENODATA",
},
timeout: {
aborted: "ECONNABORTED",
timeout: "ETIMEDOUT",
},
} as const satisfies Record<string, Record<string, string | string[]>>;
type RequestErrorMap = typeof requestErrorMap;
export type RequestErrorType = keyof RequestErrorMap;
export type RequestErrorReason<TType extends RequestErrorType> = keyof RequestErrorMap[TType];
export type AnyRequestErrorReason = {
[key in keyof RequestErrorMap]: RequestErrorReason<key>;
}[keyof RequestErrorMap];
type ExtractInnerValues<T> = {
[K in keyof T]: T[K][keyof T[K]];
}[keyof T];
type FlattenStringOrStringArray<T> = T extends (infer U)[] ? U : T;
export type RequestErrorCode = FlattenStringOrStringArray<ExtractInnerValues<typeof requestErrorMap>>;

View File

@@ -0,0 +1,12 @@
export class ResponseError extends Error {
public readonly statusCode: number;
public readonly url?: string;
constructor(response: { status: number; url?: string }, options?: ErrorOptions) {
super("Response did not indicate success", options);
this.name = ResponseError.name;
this.statusCode = response.status;
this.url = response.url;
}
}

View File

@@ -0,0 +1,2 @@
export * from "./parse";
export * from "./http";

View File

@@ -0,0 +1,3 @@
export * from "./parse-error-handler";
export * from "./zod-parse-error-handler";
export * from "./json-parse-error-handler";

View File

@@ -0,0 +1,16 @@
import { logger } from "@homarr/log";
import { ParseError } from "../parse-error";
import { ParseErrorHandler } from "./parse-error-handler";
export class JsonParseErrorHandler extends ParseErrorHandler {
handleParseError(error: unknown): ParseError | undefined {
if (!(error instanceof SyntaxError)) return undefined;
logger.debug("Received JSON parse error", {
message: error.message,
});
return new ParseError("Failed to parse json", { cause: error });
}
}

View File

@@ -0,0 +1,5 @@
import type { ParseError } from "../parse-error";
export abstract class ParseErrorHandler {
abstract handleParseError(error: unknown): ParseError | undefined;
}

View File

@@ -0,0 +1,24 @@
import { ZodError } from "zod";
import { fromError } from "zod-validation-error";
import { logger } from "@homarr/log";
import { ParseError } from "../parse-error";
import { ParseErrorHandler } from "./parse-error-handler";
export class ZodParseErrorHandler extends ParseErrorHandler {
handleParseError(error: unknown): ParseError | undefined {
if (!(error instanceof ZodError)) return undefined;
// TODO: migrate to zod v4 prettfyError once it's released
// https://v4.zod.dev/v4#error-pretty-printing
const message = fromError(error, {
issueSeparator: "\n",
prefix: null,
}).toString();
logger.debug("Received Zod parse error");
return new ParseError(message, { cause: error });
}
}

View File

@@ -0,0 +1,2 @@
export * from "./handlers";
export * from "./parse-error";

View File

@@ -0,0 +1,10 @@
export class ParseError extends Error {
constructor(message: string, options?: { cause: Error }) {
super(`Failed to parse data:\n${message}`, options);
this.name = ParseError.name;
}
get cause(): Error {
return super.cause as Error;
}
}

View File

@@ -0,0 +1,3 @@
export const isFunction = (value: unknown): value is (...args: unknown[]) => unknown => {
return typeof value === "function";
};

View File

@@ -10,3 +10,4 @@ export * from "./number";
export * from "./error";
export * from "./fetch-with-timeout";
export * from "./theme";
export * from "./function";

View File

@@ -2,3 +2,4 @@ export * from "./security";
export * from "./encryption";
export * from "./user-agent";
export * from "./fetch-agent";
export * from "./errors";

View File

@@ -0,0 +1,6 @@
CREATE TABLE `trusted_certificate_hostname` (
`hostname` varchar(256) NOT NULL,
`thumbprint` varchar(128) NOT NULL,
`certificate` text NOT NULL,
CONSTRAINT `trusted_certificate_hostname_hostname_thumbprint_pk` PRIMARY KEY(`hostname`,`thumbprint`)
);

File diff suppressed because it is too large Load Diff

View File

@@ -225,6 +225,13 @@
"when": 1740784837957,
"tag": "0031_add_dynamic_section_options",
"breakpoints": true
},
{
"idx": 32,
"version": "5",
"when": 1746821770071,
"tag": "0032_add_trusted_certificate_hostnames",
"breakpoints": true
}
]
}

View File

@@ -0,0 +1,6 @@
CREATE TABLE `trusted_certificate_hostname` (
`hostname` text NOT NULL,
`thumbprint` text NOT NULL,
`certificate` text NOT NULL,
PRIMARY KEY(`hostname`, `thumbprint`)
);

File diff suppressed because it is too large Load Diff

View File

@@ -225,6 +225,13 @@
"when": 1740784849045,
"tag": "0031_add_dynamic_section_options",
"breakpoints": true
},
{
"idx": 32,
"version": "6",
"when": 1746821779051,
"tag": "0032_add_trusted_certificate_hostnames",
"breakpoints": true
}
]
}

View File

@@ -38,15 +38,15 @@
},
"prettier": "@homarr/prettier-config",
"dependencies": {
"@auth/core": "^0.39.0",
"@auth/core": "^0.39.1",
"@homarr/common": "workspace:^0.1.0",
"@homarr/definitions": "workspace:^0.1.0",
"@homarr/env": "workspace:^0.1.0",
"@homarr/log": "workspace:^0.1.0",
"@homarr/server-settings": "workspace:^0.1.0",
"@mantine/core": "^8.0.0",
"@mantine/core": "^8.0.1",
"@paralleldrive/cuid2": "^2.2.2",
"@testcontainers/mysql": "^10.25.0",
"@testcontainers/mysql": "^10.26.0",
"better-sqlite3": "^11.10.0",
"dotenv": "^16.5.0",
"drizzle-kit": "^0.31.1",

View File

@@ -39,6 +39,7 @@ export const {
layouts,
itemLayouts,
sectionLayouts,
trustedCertificateHostnames,
} = schema;
export type User = InferSelectModel<typeof schema.users>;

View File

@@ -494,6 +494,20 @@ export const onboarding = mysqlTable("onboarding", {
previousStep: varchar({ length: 64 }).$type<OnboardingStep>(),
});
export const trustedCertificateHostnames = mysqlTable(
"trusted_certificate_hostname",
{
hostname: varchar({ length: 256 }).notNull(),
thumbprint: varchar({ length: 128 }).notNull(),
certificate: text().notNull(),
},
(table) => ({
compoundKey: primaryKey({
columns: [table.hostname, table.thumbprint],
}),
}),
);
export const accountRelations = relations(accounts, ({ one }) => ({
user: one(users, {
fields: [accounts.userId],

View File

@@ -479,6 +479,20 @@ export const onboarding = sqliteTable("onboarding", {
previousStep: text().$type<OnboardingStep>(),
});
export const trustedCertificateHostnames = sqliteTable(
"trusted_certificate_hostname",
{
hostname: text().notNull(),
thumbprint: text().notNull(),
certificate: text().notNull(),
},
(table) => ({
compoundKey: primaryKey({
columns: [table.hostname, table.thumbprint],
}),
}),
);
export const accountRelations = relations(accounts, ({ one }) => ({
user: one(users, {
fields: [accounts.userId],

View File

@@ -23,13 +23,16 @@
},
"prettier": "@homarr/prettier-config",
"dependencies": {
"@homarr/common": "workspace:^0.1.0"
"@homarr/common": "workspace:^0.1.0",
"fast-xml-parser": "^5.2.3",
"zod": "^3.24.4"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.26.0",
"tsx": "4.19.4",
"typescript": "^5.8.3"
}
}

View File

@@ -1,6 +1,6 @@
import fs from "fs/promises";
import path, { dirname } from "path";
import { fileURLToPath } from "url";
import fs from "node:fs/promises";
import path, { dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { XMLParser } from "fast-xml-parser";
import { z } from "zod";

View File

@@ -174,6 +174,7 @@ export type HomarrDocumentationPath =
| "/docs/getting-started/installation/easy-panel"
| "/docs/getting-started/installation/helm"
| "/docs/getting-started/installation/home-assistant"
| "/docs/getting-started/installation/pika-pods"
| "/docs/getting-started/installation/portainer"
| "/docs/getting-started/installation/proxmox"
| "/docs/getting-started/installation/qnap"

View File

@@ -26,7 +26,7 @@
"@homarr/common": "workspace:^0.1.0",
"@homarr/translation": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@mantine/form": "^8.0.0",
"@mantine/form": "^8.0.1",
"zod": "^3.24.4"
},
"devDependencies": {

View File

@@ -29,7 +29,7 @@
"@homarr/notifications": "workspace:^0.1.0",
"@homarr/translation": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@mantine/core": "^8.0.0",
"@mantine/core": "^8.0.1",
"react": "19.1.0",
"zod": "^3.24.4"
},

View File

@@ -2,5 +2,6 @@ export * from "./new-app/_app-new-form";
export * from "./new-app/_form";
export * from "./icon-picker/icon-picker";
export * from "./new-app/icon-matcher";
export * from "./upload-media/upload-media";

View File

@@ -6,6 +6,7 @@
"type": "module",
"exports": {
".": "./index.ts",
"./test-connection": "./src/base/test-connection/index.ts",
"./client": "./src/client.ts",
"./types": "./src/types.ts"
},
@@ -32,16 +33,16 @@
"@homarr/db": "workspace:^0.1.0",
"@homarr/definitions": "workspace:^0.1.0",
"@homarr/log": "workspace:^0.1.0",
"@homarr/node-unifi": "^2.6.0",
"@homarr/redis": "workspace:^0.1.0",
"@homarr/translation": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@jellyfin/sdk": "^0.11.0",
"maria2": "^0.4.0",
"node-ical": "^0.20.1",
"node-unifi": "^2.5.1",
"proxmox-api": "1.1.1",
"tsdav": "^2.1.4",
"undici": "7.8.0",
"undici": "7.9.0",
"xml2js": "^0.6.2",
"zod": "^3.24.4"
},

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More