Compare commits

...
103 changed files with 636 additions and 478 deletions

No files matched your search

@@ -20,7 +20,7 @@ runs:
using: "composite"
steps:
- name: Prebuilt debian dependencies
uses: ./.github/actions/prebuilt-debian@dev
uses: homarr-labs/homarr/.github/actions/prebuilt-debian@dev
id: prebuilt-debian
with:
architecture: ${{ inputs.architecture }}
+41
View File
@@ -1,3 +1,44 @@
## [1.47.0](https://github.com/homarr-labs/homarr/compare/v1.46.0...v1.47.0) (2025-12-19)
### Features
* [#4452](https://github.com/homarr-labs/homarr/issues/4452) improve calendar design ([#4461](https://github.com/homarr-labs/homarr/issues/4461)) ([08e9f7d](https://github.com/homarr-labs/homarr/commit/08e9f7d8d85475788534b46e6cceb40a7231c09d))
### Bug Fixes
* **boards:** selection menu not shown to users without change access ([#4689](https://github.com/homarr-labs/homarr/issues/4689)) ([a0a11e3](https://github.com/homarr-labs/homarr/commit/a0a11e35707513fee45551e09c26ff633f636c32))
* **deps:** update dependency dns-caching to ^0.2.9 ([#4648](https://github.com/homarr-labs/homarr/issues/4648)) ([6deab2e](https://github.com/homarr-labs/homarr/commit/6deab2ecec2dc4f5192b53213cb755a9853eb7c0))
* **deps:** update dependency drizzle-orm to ^0.45.1 ([#4663](https://github.com/homarr-labs/homarr/issues/4663)) ([f635009](https://github.com/homarr-labs/homarr/commit/f63500961f11589a972a41dde9e4117b1f25141c))
* **deps:** update dependency fast-xml-parser to ^5.3.3 ([#4683](https://github.com/homarr-labs/homarr/issues/4683)) ([611b65e](https://github.com/homarr-labs/homarr/commit/611b65e8bd420aaad580bf35071b651093623c95))
* **deps:** update dependency isomorphic-dompurify to ^2.34.0 ([#4650](https://github.com/homarr-labs/homarr/issues/4650)) ([5d5c18b](https://github.com/homarr-labs/homarr/commit/5d5c18b3649d5fa1b2b933d443ccd5abcbb84e3a))
* **deps:** update dependency jotai to ^2.16.0 ([#4655](https://github.com/homarr-labs/homarr/issues/4655)) ([4aef85b](https://github.com/homarr-labs/homarr/commit/4aef85ba60f4978da5a0b13c08e1799949fe4497))
* **deps:** update dependency ldapts to v8.0.16 ([#4640](https://github.com/homarr-labs/homarr/issues/4640)) ([ada5597](https://github.com/homarr-labs/homarr/commit/ada5597cf3ab0bafb04e9176b431d257b36d1dc8))
* **deps:** update dependency ldapts to v8.0.17 ([#4642](https://github.com/homarr-labs/homarr/issues/4642)) ([c09da85](https://github.com/homarr-labs/homarr/commit/c09da85f3a8450a70991ff0656228a9aa1364f89))
* **deps:** update dependency ldapts to v8.0.18 ([#4653](https://github.com/homarr-labs/homarr/issues/4653)) ([4bd142e](https://github.com/homarr-labs/homarr/commit/4bd142e09c666cc84c67c93e2202e571565b766c))
* **deps:** update dependency ldapts to v8.0.19 ([#4669](https://github.com/homarr-labs/homarr/issues/4669)) ([8ed771d](https://github.com/homarr-labs/homarr/commit/8ed771d30073809b08a6ace2c29b3de2675e37d1))
* **deps:** update dependency ldapts to v8.0.20 ([#4684](https://github.com/homarr-labs/homarr/issues/4684)) ([703c8cc](https://github.com/homarr-labs/homarr/commit/703c8ccc060bb1775a72f405da1e169926b1ce5d))
* **deps:** update dependency ldapts to v8.0.21 ([#4695](https://github.com/homarr-labs/homarr/issues/4695)) ([7f6eaa0](https://github.com/homarr-labs/homarr/commit/7f6eaa0b1f397a37b6acd15e0ccfea75ee2e0161))
* **deps:** update dependency ldapts to v8.0.22 ([#4700](https://github.com/homarr-labs/homarr/issues/4700)) ([391d725](https://github.com/homarr-labs/homarr/commit/391d725191563ba5048593c14baf077a924cea44))
* **deps:** update dependency ldapts to v8.0.23 ([#4701](https://github.com/homarr-labs/homarr/issues/4701)) ([db337b2](https://github.com/homarr-labs/homarr/commit/db337b2525762c96acd815bd4fdacfaabff5f0cf))
* **deps:** update dependency next-intl to v4.6.0 ([#4691](https://github.com/homarr-labs/homarr/issues/4691)) ([1ff6fac](https://github.com/homarr-labs/homarr/commit/1ff6fac71bb9187d0eb703a7b6543daf8861efe8))
* **deps:** update dependency sass to ^1.94.3 ([#4646](https://github.com/homarr-labs/homarr/issues/4646)) ([7e91d12](https://github.com/homarr-labs/homarr/commit/7e91d12439826eee76f118448c583294c3c46b42))
* **deps:** update dependency sass to ^1.95.0 ([#4647](https://github.com/homarr-labs/homarr/issues/4647)) ([eb760da](https://github.com/homarr-labs/homarr/commit/eb760da8b30583575fb638995fc2f2acbd5b2961))
* **deps:** update dependency sass to ^1.95.1 ([#4661](https://github.com/homarr-labs/homarr/issues/4661)) ([e327c66](https://github.com/homarr-labs/homarr/commit/e327c660b239840c4929f536eff3096342cc3433))
* **deps:** update dependency sass to ^1.96.0 ([#4668](https://github.com/homarr-labs/homarr/issues/4668)) ([bdfc150](https://github.com/homarr-labs/homarr/commit/bdfc1501152450a1546962331037a6fc1c782e4e))
* **deps:** update dependency swagger-ui-react to ^5.31.0 ([#4672](https://github.com/homarr-labs/homarr/issues/4672)) ([4d593f5](https://github.com/homarr-labs/homarr/commit/4d593f5b9d1f47ae2368f42131e8ef8d32f73fc9))
* **deps:** update dependency typescript-eslint to ^8.49.0 ([#4645](https://github.com/homarr-labs/homarr/issues/4645)) ([220cb66](https://github.com/homarr-labs/homarr/commit/220cb669a13fead953ff42040b39e06d1dcc1fe2))
* **deps:** update mantine monorepo to ^8.3.10 ([#4651](https://github.com/homarr-labs/homarr/issues/4651)) ([d0652f7](https://github.com/homarr-labs/homarr/commit/d0652f7cf5ac0e63a61adcfe43e53c4ed130e8fc))
* **deps:** update react monorepo to v19.2.2 ([#4676](https://github.com/homarr-labs/homarr/issues/4676)) ([82e79f2](https://github.com/homarr-labs/homarr/commit/82e79f26274a289e1ce4f9331b6ba16f00f5a86c))
* **deps:** update react monorepo to v19.2.3 ([#4680](https://github.com/homarr-labs/homarr/issues/4680)) ([4c71856](https://github.com/homarr-labs/homarr/commit/4c7185646d9d061d1aebf9194f9ef2f810fc3384))
* **deps:** update testcontainers-node monorepo to ^11.10.0 ([#4659](https://github.com/homarr-labs/homarr/issues/4659)) ([c880ce9](https://github.com/homarr-labs/homarr/commit/c880ce9bd99a7056f20d450275fcbc37f9372f63))
* **deps:** update trpc monorepo to ^11.8.0 ([#4697](https://github.com/homarr-labs/homarr/issues/4697)) ([1f44845](https://github.com/homarr-labs/homarr/commit/1f44845df489bb33bcfe943c6a05c0f157037ed6))
* **downloads:** improve progress to only show 100 percent if fully completed ([#4621](https://github.com/homarr-labs/homarr/issues/4621)) ([1062745](https://github.com/homarr-labs/homarr/commit/10627453d5b5a17991bbe9b8d4bc23981b366c39))
* **iframe-widget:** permission selection not applying to any origin ([#4616](https://github.com/homarr-labs/homarr/issues/4616)) ([479f083](https://github.com/homarr-labs/homarr/commit/479f083ab8ec405d1b8296b32a20262f6192a1f9))
* **invites:** creation with custom expiration not working ([#4677](https://github.com/homarr-labs/homarr/issues/4677)) ([423da9e](https://github.com/homarr-labs/homarr/commit/423da9eeb88510a123bdd2447dc97b54c5f1dec4))
* reference to prebuilt-debian action does not work ([47d7458](https://github.com/homarr-labs/homarr/commit/47d7458143d8801947cae1985745fa827b247dd1))
* **stock-price:** use previous-close for change calculation ([#4674](https://github.com/homarr-labs/homarr/issues/4674)) ([17fc7a0](https://github.com/homarr-labs/homarr/commit/17fc7a043b4adbed9f14408e23c95ec5fba0c8b8))
* **tdarr:** array parse error ([#4678](https://github.com/homarr-labs/homarr/issues/4678)) ([d86af07](https://github.com/homarr-labs/homarr/commit/d86af072bf136d76b02f3efa2c15520a4f63974e))
## [1.46.0](https://github.com/homarr-labs/homarr/compare/v1.45.3...v1.46.0) (2025-12-12)
### Features
+8
View File
@@ -1 +1,9 @@
# ── CODEOWNERS ───────────────────────────────────────
* @homarr-labs/maintainers
# Exempt Renovate‑managed files (no owners)
package.json @homarr-labs/none
package-lock.json @homarr-labs/none
pnpm-lock.yaml @homarr-labs/none
Dockerfile @homarr-labs/none
docker-compose.yml @homarr-labs/none
-1
View File
@@ -23,7 +23,6 @@
"@homarr/api": "workspace:^0.1.0",
"@homarr/auth": "workspace:^0.1.0",
"@homarr/boards": "workspace:^0.1.0",
"@homarr/certificates": "workspace:^0.1.0",
"@homarr/common": "workspace:^0.1.0",
"@homarr/core": "workspace:^0.1.0",
"@homarr/cron-job-status": "workspace:^0.1.0",
@@ -44,7 +44,7 @@ export const BoardContentHeaderActions = () => {
const { hasChangeAccess } = useBoardPermissions(board);
if (!hasChangeAccess) {
return null; // Hide actions for user without access
return <SelectBoardsMenu />;
}
return (
@@ -16,7 +16,7 @@ import {
import { IconCertificateOff } from "@tabler/icons-react";
import { auth } from "@homarr/auth/next";
import { getTrustedCertificateHostnamesAsync } from "@homarr/certificates/server";
import { getTrustedCertificateHostnamesAsync } from "@homarr/core/infrastructure/certificates";
import { getI18n } from "@homarr/translation/server";
import { Link } from "@homarr/ui";
@@ -5,8 +5,8 @@ import { IconAlertTriangle, IconCertificate, IconCertificateOff } from "@tabler/
import dayjs from "dayjs";
import { auth } from "@homarr/auth/next";
import { loadCustomRootCertificatesAsync } from "@homarr/certificates/server";
import { getMantineColor } from "@homarr/common";
import { loadCustomRootCertificatesAsync } from "@homarr/core/infrastructure/certificates";
import type { SupportedLanguage } from "@homarr/translation";
import { getI18n } from "@homarr/translation/server";
import { Link } from "@homarr/ui";
+2 -3
View File
@@ -1,6 +1,5 @@
import { setGlobalDispatcher } from "undici";
import { LoggingAgent } from "@homarr/common/server";
import { UndiciHttpAgent } from "@homarr/core/infrastructure/http";
const agent = new LoggingAgent();
setGlobalDispatcher(agent);
setGlobalDispatcher(new UndiciHttpAgent());
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "homarr",
"version": "1.46.0",
"version": "1.47.0",
"private": true,
"scripts": {
"build": "cross-env CI=true turbo build",
-1
View File
@@ -22,7 +22,6 @@
"prettier": "@homarr/prettier-config",
"dependencies": {
"@homarr/auth": "workspace:^0.1.0",
"@homarr/certificates": "workspace:^0.1.0",
"@homarr/common": "workspace:^0.1.0",
"@homarr/core": "workspace:^0.1.0",
"@homarr/cron-job-api": "workspace:^0.1.0",
@@ -3,7 +3,10 @@ import { TRPCError } from "@trpc/server";
import { zfd } from "zod-form-data";
import { z } from "zod/v4";
import { addCustomRootCertificateAsync, removeCustomRootCertificateAsync } from "@homarr/certificates/server";
import {
addCustomRootCertificateAsync,
removeCustomRootCertificateAsync,
} from "@homarr/core/infrastructure/certificates";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { and, eq } from "@homarr/db";
import { trustedCertificateHostnames } from "@homarr/db/schema";
+2 -2
View File
@@ -1,6 +1,6 @@
import { z } from "zod/v4";
import { fetchWithTimeout } from "@homarr/common";
import { fetchWithTimeoutAsync } from "@homarr/core/infrastructure/http/timeout";
import { createTRPCRouter, publicProcedure } from "../trpc";
@@ -36,7 +36,7 @@ export const locationRouter = createTRPCRouter({
.input(locationSearchCityInput)
.output(locationSearchCityOutput)
.query(async ({ input }) => {
const res = await fetchWithTimeout(`https://geocoding-api.open-meteo.com/v1/search?name=${input.query}`);
const res = await fetchWithTimeoutAsync(`https://geocoding-api.open-meteo.com/v1/search?name=${input.query}`);
return (await res.json()) as z.infer<typeof locationSearchCityOutput>;
}),
});
-1
View File
@@ -25,7 +25,6 @@
"dependencies": {
"@auth/core": "^0.41.1",
"@auth/drizzle-adapter": "^1.11.1",
"@homarr/certificates": "workspace:^0.1.0",
"@homarr/common": "workspace:^0.1.0",
"@homarr/core": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0",
@@ -3,7 +3,7 @@ import type { OIDCConfig } from "@auth/core/providers";
import type { Profile } from "@auth/core/types";
import { customFetch } from "next-auth";
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { env } from "../../env";
import { createRedirectUri } from "../../redirect";
-9
View File
@@ -1,9 +0,0 @@
import baseConfig from "@homarr/eslint-config/base";
/** @type {import('typescript-eslint').Config} */
export default [
{
ignores: [],
},
...baseConfig,
];
-36
View File
@@ -1,36 +0,0 @@
{
"name": "@homarr/certificates",
"version": "0.1.0",
"private": true,
"license": "Apache-2.0",
"type": "module",
"exports": {
"./server": "./src/server.ts"
},
"typesVersions": {
"*": {
"*": [
"src/*"
]
}
},
"scripts": {
"clean": "rm -rf .turbo node_modules",
"format": "prettier --check . --ignore-path ../../.gitignore",
"lint": "eslint",
"typecheck": "tsc --noEmit"
},
"prettier": "@homarr/prettier-config",
"dependencies": {
"@homarr/common": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0",
"undici": "7.16.0"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.39.2",
"typescript": "^5.9.3"
}
}
-141
View File
@@ -1,141 +0,0 @@
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 { checkServerIdentity, rootCertificates } from "node:tls";
import axios from "axios";
import type { RequestInfo, RequestInit, Response } from "undici";
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 = () => {
if (env.NODE_ENV !== "production") return process.env.LOCAL_CERTIFICATE_PATH;
return process.env.LOCAL_CERTIFICATE_PATH ?? path.join("/appdata", "trusted-certificates");
};
export const loadCustomRootCertificatesAsync = async () => {
const folder = getCertificateFolder();
if (!folder) {
return [];
}
if (!fsSync.existsSync(folder)) {
await fs.mkdir(folder, { recursive: true });
}
const dirContent = await fs.readdir(folder);
return await Promise.all(
dirContent
.filter((file) => file.endsWith(".crt") || file.endsWith(".pem"))
.map(async (file) => ({
content: await fs.readFile(path.join(folder, file), "utf8"),
fileName: file,
})),
);
};
export const removeCustomRootCertificateAsync = async (fileName: string) => {
const folder = getCertificateFolder();
if (!folder) {
return null;
}
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) => {
const folder = getCertificateFolder();
if (!folder) {
throw new Error(
"When you want to use custom certificates locally you need to set LOCAL_CERTIFICATE_PATH to an absolute path",
);
}
if (fileName.includes("/")) {
throw new Error("Invalid file name");
}
await fs.writeFile(path.join(folder, fileName), content);
};
export const 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: override ?? {
ca: await getAllTrustedCertificatesAsync(),
checkServerIdentity: createCustomCheckServerIdentity(await getTrustedCertificateHostnamesAsync()),
},
});
};
export const createHttpsAgentAsync = async (override?: Pick<AgentOptions, "ca" | "checkServerIdentity">) => {
return new HttpsAgent(
override ?? {
ca: await getAllTrustedCertificatesAsync(),
checkServerIdentity: createCustomCheckServerIdentity(await getTrustedCertificateHostnamesAsync()),
},
);
};
export const createAxiosCertificateInstanceAsync = async (
override?: Pick<AgentOptions, "ca" | "checkServerIdentity">,
) => {
return axios.create({
httpsAgent: await createHttpsAgentAsync(override),
});
};
export const fetchWithTrustedCertificatesAsync = async (url: RequestInfo, options?: RequestInit): Promise<Response> => {
const agent = await createCertificateAgentAsync(undefined);
return fetch(url, {
...options,
dispatcher: agent,
});
};
-8
View File
@@ -1,8 +0,0 @@
{
"extends": "@homarr/tsconfig/base.json",
"compilerOptions": {
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
},
"include": ["*.ts", "src"],
"exclude": ["node_modules"]
}
-2
View File
@@ -24,12 +24,10 @@ export const env = createEnv({
message: `SECRET_ENCRYPTION_KEY must only contain hex characters${errorSuffix}`,
}),
NO_EXTERNAL_CONNECTION: createBooleanSchema(false),
ENABLE_DNS_CACHING: createBooleanSchema(false),
},
runtimeEnv: {
SECRET_ENCRYPTION_KEY: process.env.SECRET_ENCRYPTION_KEY,
NODE_ENV: process.env.NODE_ENV,
NO_EXTERNAL_CONNECTION: process.env.NO_EXTERNAL_CONNECTION,
ENABLE_DNS_CACHING: process.env.ENABLE_DNS_CACHING,
},
});
-2
View File
@@ -8,7 +8,6 @@
".": "./index.ts",
"./types": "./src/types.ts",
"./server": "./src/server.ts",
"./init-dns": "./src/dns.ts",
"./client": "./src/client.ts",
"./env": "./env.ts"
},
@@ -30,7 +29,6 @@
"@homarr/core": "workspace:^0.1.0",
"@paralleldrive/cuid2": "^3.1.0",
"dayjs": "^1.11.19",
"dns-caching": "^0.2.9",
"next": "16.0.10",
"octokit": "^5.0.5",
"react": "19.2.3",
@@ -1,11 +1,11 @@
import type { Logger } from "@homarr/core/infrastructure/logs";
import type { ILogger } from "@homarr/core/infrastructure/logs";
import { createLogger } from "@homarr/core/infrastructure/logs";
import type { AnyRequestError } from "../request-error";
import type { ResponseError } from "../response-error";
export abstract class HttpErrorHandler {
protected logger: Logger;
protected logger: ILogger;
constructor(type: string) {
this.logger = createLogger({ module: "httpErrorHandler", type });
@@ -1,10 +1,10 @@
import type { Logger } from "@homarr/core/infrastructure/logs";
import type { ILogger } from "@homarr/core/infrastructure/logs";
import { createLogger } from "@homarr/core/infrastructure/logs";
import type { ParseError } from "../parse-error";
export abstract class ParseErrorHandler {
protected logger: Logger;
protected logger: ILogger;
constructor(type: string) {
this.logger = createLogger({ module: "parseErrorHandler", type });
}
-41
View File
@@ -1,41 +0,0 @@
import type { Dispatcher } from "undici";
import { Agent } from "undici";
import { createLogger } from "@homarr/core/infrastructure/logs";
// The below import statement initializes dns-caching
import "./dns";
const logger = createLogger({ module: "fetchAgent" });
export class LoggingAgent extends Agent {
constructor(...props: ConstructorParameters<typeof Agent>) {
super(...props);
}
dispatch(options: Dispatcher.DispatchOptions, handler: Dispatcher.DispatchHandler): boolean {
const path = options.path
.split("/")
.map((segment) => (segment.length >= 32 && !segment.startsWith("?") ? "REDACTED" : segment))
.join("/");
const url = new URL(`${options.origin as string}${path}`);
// The below code should prevent sensitive data from being logged as
// some integrations use query parameters for auth
url.searchParams.forEach((value, key) => {
if (value === "") return; // Skip empty values
if (/^-?\d{1,12}$/.test(value)) return; // Skip small numbers
if (value === "true" || value === "false") return; // Skip boolean values
if (/^[a-zA-Z]{1,12}$/.test(value)) return; // Skip short strings
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) return; // Skip dates
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(value)) return; // Skip date times
url.searchParams.set(key, "REDACTED");
});
logger.debug(
`Dispatching request ${url.toString().replaceAll("=&", "&")} (${Object.keys(options.headers ?? {}).length} headers)`,
);
return super.dispatch(options, handler);
}
}
-16
View File
@@ -1,16 +0,0 @@
/**
* Same as fetch, but with a timeout of 10 seconds.
* https://stackoverflow.com/questions/46946380/fetch-api-request-timeout
* @param param0 fetch arguments
* @returns fetch response
*/
export const fetchWithTimeout = (...[url, requestInit]: Parameters<typeof fetch>) => {
const controller = new AbortController();
// 10 seconds timeout:
const timeoutId = setTimeout(() => controller.abort(), 10000);
return fetch(url, { signal: controller.signal, ...requestInit }).finally(() => {
clearTimeout(timeoutId);
});
};
-1
View File
@@ -9,7 +9,6 @@ export * from "./id";
export * from "./url";
export * from "./number";
export * from "./error";
export * from "./fetch-with-timeout";
export * from "./theme";
export * from "./function";
export * from "./id";
-1
View File
@@ -1,5 +1,4 @@
export * from "./security";
export * from "./encryption";
export * from "./user-agent";
export * from "./fetch-agent";
export * from "./errors";
+9 -1
View File
@@ -13,7 +13,14 @@
"./infrastructure/logs/error": "./src/infrastructure/logs/error.ts",
"./infrastructure/db": "./src/infrastructure/db/index.ts",
"./infrastructure/db/env": "./src/infrastructure/db/env.ts",
"./infrastructure/db/constants": "./src/infrastructure/db/constants.ts"
"./infrastructure/db/constants": "./src/infrastructure/db/constants.ts",
"./infrastructure/certificates": "./src/infrastructure/certificates/index.ts",
"./infrastructure/certificates/hostnames/db/sqlite": "./src/infrastructure/certificates/hostnames/db/sqlite.ts",
"./infrastructure/certificates/hostnames/db/mysql": "./src/infrastructure/certificates/hostnames/db/mysql.ts",
"./infrastructure/certificates/hostnames/db/postgresql": "./src/infrastructure/certificates/hostnames/db/postgresql.ts",
"./infrastructure/dns/init": "./src/infrastructure/dns/init.ts",
"./infrastructure/http": "./src/infrastructure/http/index.ts",
"./infrastructure/http/timeout": "./src/infrastructure/http/timeout.ts"
},
"typesVersions": {
"*": {
@@ -32,6 +39,7 @@
"dependencies": {
"@t3-oss/env-nextjs": "^0.13.8",
"better-sqlite3": "^12.5.0",
"dns-caching": "^0.2.9",
"drizzle-orm": "^0.45.1",
"ioredis": "5.8.2",
"mysql2": "3.15.3",
@@ -0,0 +1,74 @@
import { X509Certificate } from "node:crypto";
import fsSync from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import { rootCertificates } from "node:tls";
const getCertificateFolder = () => {
if (process.env.NODE_ENV !== "production") return process.env.LOCAL_CERTIFICATE_PATH;
return process.env.LOCAL_CERTIFICATE_PATH ?? path.join("/appdata", "trusted-certificates");
};
export const loadCustomRootCertificatesAsync = async () => {
const folder = getCertificateFolder();
if (!folder) {
return [];
}
if (!fsSync.existsSync(folder)) {
await fs.mkdir(folder, { recursive: true });
}
const dirContent = await fs.readdir(folder);
return await Promise.all(
dirContent
.filter((file) => file.endsWith(".crt") || file.endsWith(".pem"))
.map(async (file) => ({
content: await fs.readFile(path.join(folder, file), "utf8"),
fileName: file,
})),
);
};
export const getAllTrustedCertificatesAsync = async () => {
const customCertificates = await loadCustomRootCertificatesAsync();
return rootCertificates.concat(customCertificates.map((cert) => cert.content));
};
export const removeCustomRootCertificateAsync = async (fileName: string) => {
const folder = getCertificateFolder();
if (!folder) {
return null;
}
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) => {
const folder = getCertificateFolder();
if (!folder) {
throw new Error(
"When you want to use custom certificates locally you need to set LOCAL_CERTIFICATE_PATH to an absolute path",
);
}
if (fileName.includes("/")) {
throw new Error("Invalid file name");
}
await fs.writeFile(path.join(folder, fileName), content);
};
@@ -0,0 +1,15 @@
import { mysqlTable, primaryKey, text, varchar } from "drizzle-orm/mysql-core";
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],
}),
}),
);
@@ -0,0 +1,15 @@
import { pgTable, primaryKey, text, varchar } from "drizzle-orm/pg-core";
export const trustedCertificateHostnames = pgTable(
"trusted_certificate_hostname",
{
hostname: varchar({ length: 256 }).notNull(),
thumbprint: varchar({ length: 128 }).notNull(),
certificate: text().notNull(),
},
(table) => ({
compoundKey: primaryKey({
columns: [table.hostname, table.thumbprint],
}),
}),
);
@@ -0,0 +1,10 @@
import { createSchema } from "../../../db";
import * as mysql from "./mysql";
import * as postgresql from "./postgresql";
import * as sqlite from "./sqlite";
export const schema = createSchema({
"better-sqlite3": () => sqlite,
mysql2: () => mysql,
"node-postgres": () => postgresql,
});
@@ -0,0 +1,15 @@
import { primaryKey, sqliteTable, text } from "drizzle-orm/sqlite-core";
export const trustedCertificateHostnames = sqliteTable(
"trusted_certificate_hostname",
{
hostname: text().notNull(),
thumbprint: text().notNull(),
certificate: text().notNull(),
},
(table) => ({
compoundKey: primaryKey({
columns: [table.hostname, table.thumbprint],
}),
}),
);
@@ -0,0 +1,12 @@
import type { InferSelectModel } from "drizzle-orm";
import { createDb } from "../../db";
import { schema } from "./db/schema";
const db = createDb(schema);
export const getTrustedCertificateHostnamesAsync = async () => {
return await db.query.trustedCertificateHostnames.findMany();
};
export type TrustedCertificateHostname = InferSelectModel<typeof schema.trustedCertificateHostnames>;
@@ -0,0 +1,7 @@
export { getTrustedCertificateHostnamesAsync } from "./hostnames";
export {
addCustomRootCertificateAsync,
removeCustomRootCertificateAsync,
getAllTrustedCertificatesAsync,
loadCustomRootCertificatesAsync,
} from "./files";
@@ -0,0 +1,8 @@
import { createBooleanSchema, createEnv } from "../env";
export const dnsEnv = createEnv({
server: {
ENABLE_DNS_CACHING: createBooleanSchema(false),
},
experimental__runtimeEnv: process.env,
});
@@ -2,7 +2,7 @@ import { DnsCacheManager } from "dns-caching";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { env } from "../env";
import { dnsEnv } from "./env";
// Add global type augmentation for homarr
declare global {
@@ -23,6 +23,6 @@ global.homarr.dnsCacheManager ??= new DnsCacheManager({
logger,
});
if (env.ENABLE_DNS_CACHING) {
if (dnsEnv.ENABLE_DNS_CACHING) {
global.homarr.dnsCacheManager.initialize();
}
@@ -0,0 +1,72 @@
import type { Dispatcher } from "undici";
import { Agent } from "undici";
import type { ILogger } from "@homarr/core/infrastructure/logs";
import { createLogger } from "@homarr/core/infrastructure/logs";
// The below import statement initializes dns-caching
import "@homarr/core/infrastructure/dns/init";
interface HttpAgentOptions extends Agent.Options {
logger?: ILogger;
}
export class UndiciHttpAgent extends Agent {
private logger: ILogger;
constructor(props?: HttpAgentOptions) {
super(props);
this.logger = props?.logger ?? createLogger({ module: "httpAgent" });
}
dispatch(options: Dispatcher.DispatchOptions, handler: Dispatcher.DispatchHandler): boolean {
this.logRequestDispatch(options);
return super.dispatch(options, handler);
}
private logRequestDispatch(options: Dispatcher.DispatchOptions) {
const path = this.redactPathParams(options.path);
let url = new URL(`${options.origin as string}${path}`);
url = this.redactSearchParams(url);
this.logger.debug(
`Dispatching request ${url.toString().replaceAll("=&", "&")} (${Object.keys(options.headers ?? {}).length} headers)`,
);
}
/**
* Redact path parameters that are longer than 32 characters
* This is to prevent sensitive data from being logged
* @param path path of the request
* @returns redacted path
*/
private redactPathParams(path: string): string {
return path
.split("/")
.map((segment) => (segment.length >= 32 && !segment.startsWith("?") ? "REDACTED" : segment))
.join("/");
}
/**
* Redact sensitive search parameters from the URL.
* It allows certain patterns to remain unredacted.
* Like small numbers, booleans, short strings, dates, and date-times.
* Some integrations use query parameters for auth.
* @param url URL object of the request
* @returns redacted URL object
*/
private redactSearchParams(url: URL): URL {
url.searchParams.forEach((value, key) => {
if (value === "") return; // Skip empty values
if (/^-?\d{1,12}$/.test(value)) return; // Skip small numbers
if (value === "true" || value === "false") return; // Skip boolean values
if (/^[a-zA-Z]{1,12}$/.test(value)) return; // Skip short strings
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) return; // Skip dates
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(value)) return; // Skip date times
url.searchParams.set(key, "REDACTED");
});
return url;
}
}
@@ -0,0 +1,8 @@
export { UndiciHttpAgent } from "./http-agent";
export {
createAxiosCertificateInstanceAsync,
createCertificateAgentAsync,
createCustomCheckServerIdentity,
createHttpsAgentAsync,
fetchWithTrustedCertificatesAsync,
} from "./request";
@@ -0,0 +1,82 @@
import type { AgentOptions } from "node:https";
import { Agent as HttpsAgent } from "node:https";
import { checkServerIdentity } from "node:tls";
import axios from "axios";
import type { RequestInfo, RequestInit, Response } from "undici";
import { fetch } from "undici";
import {
getAllTrustedCertificatesAsync,
getTrustedCertificateHostnamesAsync,
} from "@homarr/core/infrastructure/certificates";
import { UndiciHttpAgent } from "@homarr/core/infrastructure/http";
import type { TrustedCertificateHostname } from "../certificates/hostnames";
import { withTimeoutAsync } from "./timeout";
export const createCustomCheckServerIdentity = (
trustedHostnames: TrustedCertificateHostname[],
): 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 UndiciHttpAgent({
connect: override ?? {
ca: await getAllTrustedCertificatesAsync(),
checkServerIdentity: createCustomCheckServerIdentity(await getTrustedCertificateHostnamesAsync()),
},
});
};
export const createHttpsAgentAsync = async (override?: Pick<AgentOptions, "ca" | "checkServerIdentity">) => {
return new HttpsAgent(
override ?? {
ca: await getAllTrustedCertificatesAsync(),
checkServerIdentity: createCustomCheckServerIdentity(await getTrustedCertificateHostnamesAsync()),
},
);
};
export const createAxiosCertificateInstanceAsync = async (
override?: Pick<AgentOptions, "ca" | "checkServerIdentity">,
) => {
return axios.create({
httpsAgent: await createHttpsAgentAsync(override),
});
};
export const fetchWithTrustedCertificatesAsync = async (
url: RequestInfo,
options?: RequestInit & { timeout?: number },
): Promise<Response> => {
const agent = await createCertificateAgentAsync(undefined);
if (options?.timeout) {
return await withTimeoutAsync(
async (signal) =>
fetch(url, {
...options,
signal,
dispatcher: agent,
}),
options.timeout,
);
}
return fetch(url, {
...options,
dispatcher: agent,
});
};
@@ -0,0 +1,19 @@
import type { Response as UndiciResponse } from "undici";
// https://stackoverflow.com/questions/46946380/fetch-api-request-timeout
export const withTimeoutAsync = async <TResponse extends Response | UndiciResponse>(
callback: (signal: AbortSignal) => Promise<TResponse>,
timeout = 10000,
) => {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
return await callback(controller.signal).finally(() => {
clearTimeout(timeoutId);
});
};
export const fetchWithTimeoutAsync = async (...[url, requestInit]: Parameters<typeof fetch>) => {
return await withTimeoutAsync((signal) => fetch(url, { ...requestInit, signal }));
};
@@ -15,4 +15,12 @@ interface DefaultMetadata {
}
export const createLogger = (metadata: DefaultMetadata & Record<string, unknown>) => logger.child(metadata);
export type Logger = winston.Logger;
type LogMethod = ((message: string, metadata?: Record<string, unknown>) => void) | ((error: unknown) => void);
export interface ILogger {
debug: LogMethod;
info: LogMethod;
warn: LogMethod;
error: LogMethod;
}
@@ -1,9 +1,9 @@
import type { Dispatcher } from "undici";
import { describe, expect, test, vi } from "vitest";
import * as logs from "@homarr/core/infrastructure/logs";
import { UndiciHttpAgent } from "@homarr/core/infrastructure/http";
import { LoggingAgent } from "../fetch-agent";
import { TestLogger } from "../logs";
vi.mock("undici", () => {
return {
@@ -16,37 +16,28 @@ vi.mock("undici", () => {
};
});
vi.mock("@homarr/core/infrastructure/logs", async () => {
const actual: typeof logs = await vi.importActual("@homarr/core/infrastructure/logs");
return {
...actual,
createLogger: vi.fn().mockReturnValue({
debug: vi.fn(),
}),
};
});
const REDACTED = "REDACTED";
const loggerMock = logs.createLogger({ module: "test" });
describe("LoggingAgent should log all requests", () => {
describe("UndiciHttpAgent should log all requests", () => {
test("should log all requests", () => {
// Arrange
const debugSpy = vi.spyOn(loggerMock, "debug");
const agent = new LoggingAgent();
const logger = new TestLogger();
const agent = new UndiciHttpAgent({ logger });
// Act
agent.dispatch({ origin: "https://homarr.dev", path: "/", method: "GET" }, {});
// Assert
expect(debugSpy).toHaveBeenCalledWith("Dispatching request https://homarr.dev/ (0 headers)");
expect(logger.messages).toContainEqual({
level: "debug",
message: "Dispatching request https://homarr.dev/ (0 headers)",
});
});
test("should show amount of headers", () => {
// Arrange
const debugSpy = vi.spyOn(loggerMock, "debug");
const agent = new LoggingAgent();
const logger = new TestLogger();
const agent = new UndiciHttpAgent({ logger });
// Act
agent.dispatch(
@@ -63,7 +54,7 @@ describe("LoggingAgent should log all requests", () => {
);
// Assert
expect(debugSpy).toHaveBeenCalledWith(expect.stringContaining("(2 headers)"));
expect(logger.messages.at(-1)?.message).toContain("(2 headers)");
});
test.each([
@@ -81,14 +72,14 @@ describe("LoggingAgent should log all requests", () => {
[`/${"a".repeat(32)}/?param=123`, `/${REDACTED}/?param=123`],
])("should redact sensitive data in url https://homarr.dev%s", (path, expected) => {
// Arrange
const debugSpy = vi.spyOn(loggerMock, "debug");
const agent = new LoggingAgent();
const logger = new TestLogger();
const agent = new UndiciHttpAgent({ logger });
// Act
agent.dispatch({ origin: "https://homarr.dev", path, method: "GET" }, {});
// Assert
expect(debugSpy).toHaveBeenCalledWith(expect.stringContaining(` https://homarr.dev${expected} `));
expect(logger.messages.at(-1)?.message).toContain(` https://homarr.dev${expected} `);
});
test.each([
["empty", "/?empty"],
@@ -100,13 +91,13 @@ describe("LoggingAgent should log all requests", () => {
["date times", "/?datetime=2022-01-01T00:00:00.000Z"],
])("should not redact values that are %s", (_reason, path) => {
// Arrange
const debugSpy = vi.spyOn(loggerMock, "debug");
const agent = new LoggingAgent();
const logger = new TestLogger();
const agent = new UndiciHttpAgent({ logger });
// Act
agent.dispatch({ origin: "https://homarr.dev", path, method: "GET" }, {});
// Assert
expect(debugSpy).toHaveBeenCalledWith(expect.stringContaining(` https://homarr.dev${path} `));
expect(logger.messages.at(-1)?.message).toContain(` https://homarr.dev${path} `);
});
});
@@ -0,0 +1,49 @@
import type { ILogger } from "@homarr/core/infrastructure/logs";
import type { LogLevel } from "@homarr/core/infrastructure/logs/constants";
interface LogMessage {
level: LogLevel;
message: string;
meta?: Record<string, unknown>;
}
interface LogError {
level: LogLevel;
error: unknown;
}
type LogEntry = LogMessage | LogError;
export class TestLogger implements ILogger {
public entries: LogEntry[] = [];
public get messages(): LogMessage[] {
return this.entries.filter((entry) => "message" in entry);
}
public get errors(): LogError[] {
return this.entries.filter((entry) => "error" in entry);
}
private log(level: LogLevel, param1: unknown, param2?: Record<string, unknown>): void {
if (typeof param1 === "string") {
this.entries.push({ level, message: param1, meta: param2 });
} else {
this.entries.push({ level, error: param1 });
}
}
debug(param1: unknown, param2?: Record<string, unknown>): void {
this.log("debug", param1, param2);
}
info(param1: unknown, param2?: Record<string, unknown>): void {
this.log("info", param1, param2);
}
warn(param1: unknown, param2?: Record<string, unknown>): void {
this.log("warn", param1, param2);
}
error(param1: unknown, param2?: Record<string, unknown>): void {
this.log("error", param1, param2);
}
}
+2 -14
View File
@@ -46,6 +46,8 @@ const customBlob = customType<{ data: Buffer }>({
},
});
export * from "@homarr/core/infrastructure/certificates/hostnames/db/mysql";
export const apiKeys = mysqlTable("apiKey", {
id: varchar({ length: 64 }).notNull().primaryKey(),
apiKey: text().notNull(),
@@ -495,20 +497,6 @@ 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 cronJobConfigurations = mysqlTable("cron_job_configuration", {
name: varchar({ length: 256 }).notNull().primaryKey(),
cronExpression: varchar({ length: 32 }).notNull(),
+2 -14
View File
@@ -45,6 +45,8 @@ const customBlob = customType<{ data: Buffer }>({
},
});
export * from "@homarr/core/infrastructure/certificates/hostnames/db/postgresql";
export const apiKeys = pgTable("apiKey", {
id: varchar({ length: 64 }).notNull().primaryKey(),
apiKey: text().notNull(),
@@ -494,20 +496,6 @@ export const onboarding = pgTable("onboarding", {
previousStep: varchar({ length: 64 }).$type<OnboardingStep>(),
});
export const trustedCertificateHostnames = pgTable(
"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 cronJobConfigurations = pgTable("cron_job_configuration", {
name: varchar({ length: 256 }).notNull().primaryKey(),
cronExpression: varchar({ length: 32 }).notNull(),
+2 -14
View File
@@ -28,6 +28,8 @@ import type {
WidgetKind,
} from "@homarr/definitions";
export * from "@homarr/core/infrastructure/certificates/hostnames/db/sqlite";
export const apiKeys = sqliteTable("apiKey", {
id: text().notNull().primaryKey(),
apiKey: text().notNull(),
@@ -480,20 +482,6 @@ 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 cronJobConfigurations = sqliteTable("cron_job_configuration", {
name: text().notNull().primaryKey(),
cronExpression: text().notNull(),
@@ -1,6 +1,6 @@
import { parse } from "path";
import { fetchWithTimeout } from "@homarr/common";
import { fetchWithTimeoutAsync } from "@homarr/core/infrastructure/http/timeout";
import type { IconRepositoryLicense } from "../types/icon-repository-license";
import type { RepositoryIconGroup } from "../types/repository-icon-group";
@@ -23,7 +23,7 @@ export class GitHubIconRepository extends IconRepository {
throw new Error("Repository URLs are required for this repository");
}
const response = await fetchWithTimeout(this.repositoryIndexingUrl);
const response = await fetchWithTimeoutAsync(this.repositoryIndexingUrl);
const listOfFiles = (await response.json()) as GitHubApiResponse;
return {
@@ -1,6 +1,6 @@
import { parse } from "path";
import { fetchWithTimeout } from "@homarr/common";
import { fetchWithTimeoutAsync } from "@homarr/core/infrastructure/http/timeout";
import type { IconRepositoryLicense } from "../types/icon-repository-license";
import type { RepositoryIconGroup } from "../types/repository-icon-group";
@@ -19,7 +19,7 @@ export class JsdelivrIconRepository extends IconRepository {
}
protected async getAllIconsInternalAsync(): Promise<RepositoryIconGroup> {
const response = await fetchWithTimeout(this.repositoryIndexingUrl);
const response = await fetchWithTimeoutAsync(this.repositoryIndexingUrl);
const listOfFiles = (await response.json()) as JsdelivrApiResponse;
return {
-1
View File
@@ -22,7 +22,6 @@
},
"prettier": "@homarr/prettier-config",
"dependencies": {
"@homarr/certificates": "workspace:^0.1.0",
"@homarr/common": "workspace:^0.1.0",
"@homarr/core": "workspace:^0.1.0",
"@homarr/redis": "workspace:^0.1.0",
+1 -1
View File
@@ -1,8 +1,8 @@
import bcrypt from "bcrypt";
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { createId } from "@homarr/common";
import { decryptSecret, encryptSecret } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import { createGetSetChannel } from "@homarr/redis";
-1
View File
@@ -29,7 +29,6 @@
"@ctrl/qbittorrent": "^9.11.0",
"@ctrl/transmission": "^7.4.0",
"@gitbeaker/rest": "^43.8.0",
"@homarr/certificates": "workspace:^0.1.0",
"@homarr/common": "workspace:^0.1.0",
"@homarr/core": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0",
@@ -1,5 +1,5 @@
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { ParseError } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
@@ -3,8 +3,8 @@ import type { AxiosInstance } from "axios";
import type { Dispatcher } from "undici";
import { fetch as undiciFetch } from "undici";
import { createAxiosCertificateInstanceAsync, createCertificateAgentAsync } from "@homarr/certificates/server";
import { removeTrailingSlash } from "@homarr/common";
import { createAxiosCertificateInstanceAsync, createCertificateAgentAsync } from "@homarr/core/infrastructure/http";
import type { IntegrationSecretKind } from "@homarr/definitions";
import { HandleIntegrationErrors } from "./errors/decorator";
@@ -1,12 +1,12 @@
import type { X509Certificate } from "node:crypto";
import tls from "node:tls";
import { getPortFromUrl } from "@homarr/common";
import {
createCustomCheckServerIdentity,
getAllTrustedCertificatesAsync,
getTrustedCertificateHostnamesAsync,
} from "@homarr/certificates/server";
import { getPortFromUrl } from "@homarr/common";
} from "@homarr/core/infrastructure/certificates";
import { createCustomCheckServerIdentity } from "@homarr/core/infrastructure/http";
import { createLogger } from "@homarr/core/infrastructure/logs";
import type { IntegrationRequestErrorOfType } from "../errors/http/integration-request-error";
@@ -1,6 +1,6 @@
import type { RequestInit, Response } from "undici";
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { createLogger } from "@homarr/core/infrastructure/logs";
import type { IntegrationTestingInput } from "../base/integration";
@@ -5,7 +5,7 @@ import "@homarr/redis";
import dayjs from "dayjs";
import { z } from "zod/v4";
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { createChannelEventHistoryOld } from "../../../redis/src/lib/channel";
import type { IntegrationTestingInput } from "../base/integration";
@@ -1,7 +1,7 @@
import type { fetch, RequestInit, Response } from "undici";
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { ResponseError } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { createLogger } from "@homarr/core/infrastructure/logs";
import type { IntegrationInput, IntegrationTestingInput } from "../base/integration";
@@ -1,8 +1,8 @@
import path from "path";
import type { fetch as undiciFetch } from "undici";
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { ResponseError } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { Integration } from "../../base/integration";
import type { IntegrationTestingInput } from "../../base/integration";
@@ -2,7 +2,7 @@ import { Deluge } from "@ctrl/deluge";
import dayjs from "dayjs";
import type { Dispatcher } from "undici";
import { createCertificateAgentAsync } from "@homarr/certificates/server";
import { createCertificateAgentAsync } from "@homarr/core/infrastructure/http";
import { HandleIntegrationErrors } from "../../base/errors/decorator";
import { integrationOFetchHttpErrorHandler } from "../../base/errors/http";
@@ -1,8 +1,8 @@
import dayjs from "dayjs";
import type { fetch as undiciFetch } from "undici";
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { ResponseError } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { Integration } from "../../base/integration";
import type { IntegrationTestingInput } from "../../base/integration";
@@ -2,7 +2,7 @@ import { QBittorrent } from "@ctrl/qbittorrent";
import dayjs from "dayjs";
import type { Dispatcher } from "undici";
import { createCertificateAgentAsync } from "@homarr/certificates/server";
import { createCertificateAgentAsync } from "@homarr/core/infrastructure/http";
import { HandleIntegrationErrors } from "../../base/errors/decorator";
import { integrationOFetchHttpErrorHandler } from "../../base/errors/http";
@@ -2,8 +2,8 @@ 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 { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { Integration } from "../../base/integration";
import type { IntegrationTestingInput } from "../../base/integration";
@@ -2,7 +2,7 @@ import { Transmission } from "@ctrl/transmission";
import dayjs from "dayjs";
import type { Dispatcher } from "undici";
import { createCertificateAgentAsync } from "@homarr/certificates/server";
import { createCertificateAgentAsync } from "@homarr/core/infrastructure/http";
import { HandleIntegrationErrors } from "../../base/errors/decorator";
import { integrationOFetchHttpErrorHandler } from "../../base/errors/http";
@@ -1,8 +1,8 @@
import { BaseItemKind } from "@jellyfin/sdk/lib/generated-client/models";
import { z } from "zod/v4";
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { ResponseError } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
@@ -2,7 +2,7 @@ import { createAppAuth } from "@octokit/auth-app";
import { Octokit, RequestError } from "octokit";
import type { fetch } from "undici";
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { HandleIntegrationErrors } from "../base/errors/decorator";
@@ -2,7 +2,7 @@ import { createAppAuth } from "@octokit/auth-app";
import { Octokit, RequestError as OctokitRequestError } from "octokit";
import type { fetch } from "undici";
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { HandleIntegrationErrors } from "../base/errors/decorator";
@@ -3,7 +3,7 @@ import { createRequesterFn, defaultOptionsHandler } from "@gitbeaker/requester-u
import type { FormattedResponse, RequestOptions, ResourceOptions } from "@gitbeaker/requester-utils";
import { Gitlab } from "@gitbeaker/rest";
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { createLogger } from "@homarr/core/infrastructure/logs";
import type { IntegrationTestingInput } from "../base/integration";
@@ -1,7 +1,7 @@
import z from "zod";
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { ResponseError } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
@@ -1,6 +1,6 @@
import ICAL from "ical.js";
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
@@ -6,7 +6,7 @@ import { getUserApi } from "@jellyfin/sdk/lib/utils/api/user-api";
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api/user-library-api";
import type { AxiosInstance } from "axios";
import { createAxiosCertificateInstanceAsync } from "@homarr/certificates/server";
import { createAxiosCertificateInstanceAsync } from "@homarr/core/infrastructure/http";
import { HandleIntegrationErrors } from "../base/errors/decorator";
import { integrationAxiosHttpErrorHandler } from "../base/errors/http";
@@ -1,4 +1,4 @@
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { createLogger } from "@homarr/core/infrastructure/logs";
import type { IntegrationTestingInput } from "../base/integration";
@@ -1,6 +1,6 @@
import { z } from "zod/v4";
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { Integration } from "../../base/integration";
@@ -1,6 +1,6 @@
import { z } from "zod/v4";
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { createLogger } from "@homarr/core/infrastructure/logs";
import type { IntegrationTestingInput } from "../../base/integration";
@@ -1,6 +1,6 @@
import { z } from "zod/v4";
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { Integration } from "../../base/integration";
@@ -1,6 +1,6 @@
import { z } from "zod/v4";
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { Integration } from "../../base/integration";
@@ -1,4 +1,4 @@
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
@@ -6,7 +6,7 @@ import type { RequestInit as NodeFetchRequestInit } from "node-fetch";
import * as ical from "node-ical";
import { DAVClient } from "tsdav";
import { createHttpsAgentAsync } from "@homarr/certificates/server";
import { createHttpsAgentAsync } from "@homarr/core/infrastructure/http";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { HandleIntegrationErrors } from "../base/errors/decorator";
@@ -1,4 +1,4 @@
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
@@ -1,5 +1,5 @@
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { ResponseError } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { Integration } from "../base/integration";
import type { IntegrationTestingInput } from "../base/integration";
@@ -1,7 +1,7 @@
import type { Headers, HeadersInit, fetch as undiciFetch, Response as UndiciResponse } from "undici";
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { ResponseError } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { createLogger } from "@homarr/core/infrastructure/logs";
import type { IntegrationInput, IntegrationTestingInput } from "../base/integration";
@@ -1,5 +1,5 @@
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { ParseError, ResponseError } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { createChannelEventHistoryOld } from "../../../redis/src/lib/channel";
import type { IntegrationTestingInput } from "../base/integration";
@@ -1,6 +1,6 @@
import { z } from "zod/v4";
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
@@ -1,5 +1,5 @@
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { removeTrailingSlash } from "@homarr/common";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import type { IntegrationInput } from "../base/integration";
import { PiHoleIntegrationV5 } from "./v5/pi-hole-integration-v5";
@@ -1,5 +1,5 @@
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { ResponseError } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import type { IntegrationTestingInput } from "../../base/integration";
import { Integration } from "../../base/integration";
@@ -1,8 +1,8 @@
import type { fetch as undiciFetch, Response as UndiciResponse } from "undici";
import type { z } from "zod/v4";
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { ResponseError } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { createLogger } from "@homarr/core/infrastructure/logs";
import type { IntegrationInput, IntegrationTestingInput } from "../../base/integration";
@@ -1,8 +1,8 @@
import { parseStringPromise } from "xml2js";
import { z } from "zod/v4";
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { ParseError } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ImageProxy } from "@homarr/image-proxy";
@@ -1,6 +1,6 @@
import { z } from "zod/v4";
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
@@ -1,7 +1,7 @@
import type { Proxmox } from "proxmox-api";
import proxmoxApi from "proxmox-api";
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { HandleIntegrationErrors } from "../base/errors/decorator";
@@ -1,6 +1,6 @@
import type { RequestInit, Response } from "undici";
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { createLogger } from "@homarr/core/infrastructure/logs";
import type { IntegrationTestingInput } from "../base/integration";
@@ -2,12 +2,12 @@ import type tls from "node:tls";
import axios from "axios";
import { HttpCookieAgent, HttpsCookieAgent } from "http-cookie-agent/http";
import { getPortFromUrl } from "@homarr/common";
import {
createCustomCheckServerIdentity,
getAllTrustedCertificatesAsync,
getTrustedCertificateHostnamesAsync,
} from "@homarr/certificates/server";
import { getPortFromUrl } from "@homarr/common";
} from "@homarr/core/infrastructure/certificates";
import { createCustomCheckServerIdentity } from "@homarr/core/infrastructure/http";
import type { SiteStats } from "@homarr/node-unifi";
import Unifi from "@homarr/node-unifi";
+10
View File
@@ -14,6 +14,16 @@ vi.mock("@homarr/db", async (importActual) => {
db: createDb(),
};
});
vi.mock("@homarr/core/infrastructure/certificates", async (importActual) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
const actual = await importActual<typeof import("@homarr/core/infrastructure/certificates")>();
return {
...actual,
getTrustedCertificateHostnamesAsync: vi.fn().mockImplementation(() => {
return Promise.resolve([]);
}),
};
});
const API_KEY = "ARIA2_API_KEY";
const IMAGE_NAME = "hurlenko/aria2-ariang:latest";
+11
View File
@@ -16,6 +16,17 @@ vi.mock("@homarr/db", async (importActual) => {
};
});
vi.mock("@homarr/core/infrastructure/certificates", async (importActual) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
const actual = await importActual<typeof import("@homarr/core/infrastructure/certificates")>();
return {
...actual,
getTrustedCertificateHostnamesAsync: vi.fn().mockImplementation(() => {
return Promise.resolve([]);
}),
};
});
describe("Base integration", () => {
test("testConnectionAsync should handle errors", async () => {
const responseError = new ResponseError({ status: 500, url: "https://example.com" });
@@ -17,6 +17,17 @@ vi.mock("@homarr/db", async (importActual) => {
};
});
vi.mock("@homarr/core/infrastructure/certificates", async (importActual) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
const actual = await importActual<typeof import("@homarr/core/infrastructure/certificates")>();
return {
...actual,
getTrustedCertificateHostnamesAsync: vi.fn().mockImplementation(() => {
return Promise.resolve([]);
}),
};
});
const DEFAULT_API_KEY =
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJkNjQwY2VjNDFjOGU0NGM5YmRlNWQ4ZmFjMjUzYWViZiIsImlhdCI6MTcxODQ3MTE1MSwiZXhwIjoyMDMzODMxMTUxfQ.uQCZ5FZTokipa6N27DtFhLHkwYEXU1LZr0fsVTryL2Q";
const IMAGE_NAME = "ghcr.io/home-assistant/home-assistant:stable";
+11
View File
@@ -18,6 +18,17 @@ vi.mock("@homarr/db", async (importActual) => {
};
});
vi.mock("@homarr/core/infrastructure/certificates", async (importActual) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
const actual = await importActual<typeof import("@homarr/core/infrastructure/certificates")>();
return {
...actual,
getTrustedCertificateHostnamesAsync: vi.fn().mockImplementation(() => {
return Promise.resolve([]);
}),
};
});
const username = "nzbget";
const password = "tegbzn6789";
const IMAGE_NAME = "linuxserver/nzbget:latest";
@@ -17,6 +17,17 @@ vi.mock("@homarr/db", async (importActual) => {
};
});
vi.mock("@homarr/core/infrastructure/certificates", async (importActual) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
const actual = await importActual<typeof import("@homarr/core/infrastructure/certificates")>();
return {
...actual,
getTrustedCertificateHostnamesAsync: vi.fn().mockImplementation(() => {
return Promise.resolve([]);
}),
};
});
const DEFAULT_PASSWORD = "12341234";
const DEFAULT_API_KEY = "3b1434980677dcf53fa8c4a611db3b1f0f88478790097515c0abb539102778b9"; // Some hash generated from password
@@ -18,6 +18,17 @@ vi.mock("@homarr/db", async (importActual) => {
};
});
vi.mock("@homarr/core/infrastructure/certificates", async (importActual) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
const actual = await importActual<typeof import("@homarr/core/infrastructure/certificates")>();
return {
...actual,
getTrustedCertificateHostnamesAsync: vi.fn().mockImplementation(() => {
return Promise.resolve([]);
}),
};
});
const DEFAULT_API_KEY = "8r45mfes43s3iw7x3oecto6dl9ilxnf9";
const IMAGE_NAME = "linuxserver/sabnzbd:latest";
-1
View File
@@ -22,7 +22,6 @@
},
"prettier": "@homarr/prettier-config",
"dependencies": {
"@homarr/certificates": "workspace:^0.1.0",
"@homarr/common": "workspace:^0.1.0",
"@homarr/core": "workspace:^0.1.0"
},
+15 -21
View File
@@ -1,7 +1,8 @@
import { fetch } from "undici";
import { extractErrorMessage } from "@homarr/common";
import { LoggingAgent } from "@homarr/common/server";
import { UndiciHttpAgent } from "@homarr/core/infrastructure/http";
import { withTimeoutAsync } from "@homarr/core/infrastructure/http/timeout";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
@@ -9,28 +10,21 @@ const logger = createLogger({ module: "ping" });
export const sendPingRequestAsync = async (url: string) => {
try {
const controller = new AbortController();
// 10 seconds timeout:
const timeoutId = setTimeout(() => controller.abort(), 10000);
const start = performance.now();
return await fetch(url, {
dispatcher: new LoggingAgent({
connect: {
rejectUnauthorized: false, // Ping should always work, even with untrusted certificates
},
}),
signal: controller.signal,
})
.finally(() => {
clearTimeout(timeoutId);
})
.then((response) => {
const end = performance.now();
const durationMs = end - start;
return { statusCode: response.status, durationMs };
return await withTimeoutAsync(async (signal) => {
return await fetch(url, {
dispatcher: new UndiciHttpAgent({
connect: {
rejectUnauthorized: false, // Ping should always work, even with untrusted certificates
},
}),
signal,
});
}).then((response) => {
const end = performance.now();
const durationMs = end - start;
return { statusCode: response.status, durationMs };
});
} catch (error) {
logger.error(new ErrorWithMetadata("Failed to send ping request", { url }, { cause: error }));
return {
@@ -1,7 +1,7 @@
import dayjs from "dayjs";
import { z } from "zod/v4";
import { fetchWithTimeout } from "@homarr/common";
import { fetchWithTimeoutAsync } from "@homarr/core/infrastructure/http/timeout";
import { createCachedWidgetRequestHandler } from "./lib/cached-widget-request-handler";
@@ -11,7 +11,7 @@ export const minecraftServerStatusRequestHandler = createCachedWidgetRequestHand
async requestAsync(input: { domain: string; isBedrockServer: boolean }) {
const path = `${input.isBedrockServer ? "/bedrock" : ""}/3/${input.domain}`;
const response = await fetchWithTimeout(`https://api.mcsrvstat.us${path}`);
const response = await fetchWithTimeoutAsync(`https://api.mcsrvstat.us${path}`);
return responseSchema.parse(await response.json());
},
cacheDuration: dayjs.duration(5, "minutes"),
+2 -2
View File
@@ -1,7 +1,7 @@
import dayjs from "dayjs";
import { z } from "zod/v4";
import { fetchWithTimeout } from "@homarr/common";
import { fetchWithTimeoutAsync } from "@homarr/core/infrastructure/http/timeout";
import { createCachedWidgetRequestHandler } from "./lib/cached-widget-request-handler";
@@ -9,7 +9,7 @@ export const fetchStockPriceHandler = createCachedWidgetRequestHandler({
queryKey: "fetchStockPriceResult",
widgetKind: "stockPrice",
async requestAsync(input: { stock: string; timeRange: string; timeInterval: string }) {
const response = await fetchWithTimeout(
const response = await fetchWithTimeoutAsync(
`https://query1.finance.yahoo.com/v8/finance/chart/${input.stock}?range=${input.timeRange}&interval=${input.timeInterval}`,
);
const data = dataSchema.parse(await response.json());
Loaded 100 of 103 files, more files were not shown because too many files have changed in this diff. Show more