From 5a57115ca073aea1c75a000fda6a23648c5488e4 Mon Sep 17 00:00:00 2001 From: Meier Lukas Date: Thu, 25 Dec 2025 11:32:14 +0100 Subject: [PATCH] feat(http): add proxy support (#4721) --- packages/api/src/router/location.ts | 10 ++++++++-- packages/core/src/infrastructure/http/http-agent.ts | 7 +++---- packages/core/src/infrastructure/http/request.ts | 13 +++++++------ .../src/test/infrastructure/http/http-agent.spec.ts | 2 +- .../src/repositories/github.icon-repository.ts | 8 +++++--- .../src/repositories/jsdelivr.icon-repository.ts | 7 +++++-- .../request-handler/src/minecraft-server-status.ts | 7 +++++-- packages/request-handler/src/stock-price.ts | 12 ++++++++---- packages/request-handler/src/update-checker.ts | 4 ++-- packages/request-handler/src/weather.ts | 12 ++++++++---- 10 files changed, 52 insertions(+), 30 deletions(-) diff --git a/packages/api/src/router/location.ts b/packages/api/src/router/location.ts index 72779d2e7..99bb9c3af 100644 --- a/packages/api/src/router/location.ts +++ b/packages/api/src/router/location.ts @@ -1,6 +1,7 @@ import { z } from "zod/v4"; -import { fetchWithTimeoutAsync } from "@homarr/core/infrastructure/http/timeout"; +import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; +import { withTimeoutAsync } from "@homarr/core/infrastructure/http/timeout"; import { createTRPCRouter, publicProcedure } from "../trpc"; @@ -36,7 +37,12 @@ export const locationRouter = createTRPCRouter({ .input(locationSearchCityInput) .output(locationSearchCityOutput) .query(async ({ input }) => { - const res = await fetchWithTimeoutAsync(`https://geocoding-api.open-meteo.com/v1/search?name=${input.query}`); + const res = await withTimeoutAsync(async (signal) => { + return await fetchWithTrustedCertificatesAsync( + `https://geocoding-api.open-meteo.com/v1/search?name=${input.query}`, + { signal }, + ); + }); return (await res.json()) as z.infer; }), }); diff --git a/packages/core/src/infrastructure/http/http-agent.ts b/packages/core/src/infrastructure/http/http-agent.ts index 5b2368215..b15ada890 100644 --- a/packages/core/src/infrastructure/http/http-agent.ts +++ b/packages/core/src/infrastructure/http/http-agent.ts @@ -1,5 +1,5 @@ import type { Dispatcher } from "undici"; -import { Agent } from "undici"; +import { EnvHttpProxyAgent } from "undici"; import type { ILogger } from "@homarr/core/infrastructure/logs"; import { createLogger } from "@homarr/core/infrastructure/logs"; @@ -7,16 +7,15 @@ 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 { +interface HttpAgentOptions extends EnvHttpProxyAgent.Options { logger?: ILogger; } -export class UndiciHttpAgent extends Agent { +export class UndiciHttpAgent extends EnvHttpProxyAgent { private logger: ILogger; constructor(props?: HttpAgentOptions) { super(props); - this.logger = props?.logger ?? createLogger({ module: "httpAgent" }); } diff --git a/packages/core/src/infrastructure/http/request.ts b/packages/core/src/infrastructure/http/request.ts index b8dcf962e..9bfc376fa 100644 --- a/packages/core/src/infrastructure/http/request.ts +++ b/packages/core/src/infrastructure/http/request.ts @@ -42,12 +42,13 @@ export const createCertificateAgentAsync = async (override?: { }; export const createHttpsAgentAsync = async (override?: Pick) => { - return new HttpsAgent( - override ?? { - ca: await getAllTrustedCertificatesAsync(), - checkServerIdentity: createCustomCheckServerIdentity(await getTrustedCertificateHostnamesAsync()), - }, - ); + return new HttpsAgent({ + ca: await getAllTrustedCertificatesAsync(), + checkServerIdentity: createCustomCheckServerIdentity(await getTrustedCertificateHostnamesAsync()), + // Override the ca and checkServerIdentity if provided + ...override, + proxyEnv: process.env, + }); }; export const createAxiosCertificateInstanceAsync = async ( diff --git a/packages/core/src/test/infrastructure/http/http-agent.spec.ts b/packages/core/src/test/infrastructure/http/http-agent.spec.ts index 52d6b09e8..3fb7943d7 100644 --- a/packages/core/src/test/infrastructure/http/http-agent.spec.ts +++ b/packages/core/src/test/infrastructure/http/http-agent.spec.ts @@ -7,7 +7,7 @@ import { TestLogger } from "../logs"; vi.mock("undici", () => { return { - Agent: class Agent { + EnvHttpProxyAgent: class EnvHttpProxyAgent { dispatch(_options: Dispatcher.DispatchOptions, _handler: Dispatcher.DispatchHandler): boolean { return true; } diff --git a/packages/icons/src/repositories/github.icon-repository.ts b/packages/icons/src/repositories/github.icon-repository.ts index 8f6e55dc8..afa9d51a7 100644 --- a/packages/icons/src/repositories/github.icon-repository.ts +++ b/packages/icons/src/repositories/github.icon-repository.ts @@ -1,6 +1,7 @@ import { parse } from "path"; -import { fetchWithTimeoutAsync } from "@homarr/core/infrastructure/http/timeout"; +import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; +import { withTimeoutAsync } from "@homarr/core/infrastructure/http/timeout"; import type { IconRepositoryLicense } from "../types/icon-repository-license"; import type { RepositoryIconGroup } from "../types/repository-icon-group"; @@ -19,11 +20,12 @@ export class GitHubIconRepository extends IconRepository { } protected async getAllIconsInternalAsync(): Promise { - if (!this.repositoryIndexingUrl || !this.repositoryBlobUrlTemplate) { + const url = this.repositoryIndexingUrl; + if (!url || !this.repositoryBlobUrlTemplate) { throw new Error("Repository URLs are required for this repository"); } - const response = await fetchWithTimeoutAsync(this.repositoryIndexingUrl); + const response = await withTimeoutAsync(async (signal) => fetchWithTrustedCertificatesAsync(url, { signal })); const listOfFiles = (await response.json()) as GitHubApiResponse; return { diff --git a/packages/icons/src/repositories/jsdelivr.icon-repository.ts b/packages/icons/src/repositories/jsdelivr.icon-repository.ts index acff85923..eb9d7200f 100644 --- a/packages/icons/src/repositories/jsdelivr.icon-repository.ts +++ b/packages/icons/src/repositories/jsdelivr.icon-repository.ts @@ -1,6 +1,7 @@ import { parse } from "path"; -import { fetchWithTimeoutAsync } from "@homarr/core/infrastructure/http/timeout"; +import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; +import { withTimeoutAsync } from "@homarr/core/infrastructure/http/timeout"; import type { IconRepositoryLicense } from "../types/icon-repository-license"; import type { RepositoryIconGroup } from "../types/repository-icon-group"; @@ -19,7 +20,9 @@ export class JsdelivrIconRepository extends IconRepository { } protected async getAllIconsInternalAsync(): Promise { - const response = await fetchWithTimeoutAsync(this.repositoryIndexingUrl); + const response = await withTimeoutAsync(async (signal) => + fetchWithTrustedCertificatesAsync(this.repositoryIndexingUrl, { signal }), + ); const listOfFiles = (await response.json()) as JsdelivrApiResponse; return { diff --git a/packages/request-handler/src/minecraft-server-status.ts b/packages/request-handler/src/minecraft-server-status.ts index f6c813193..98d850171 100644 --- a/packages/request-handler/src/minecraft-server-status.ts +++ b/packages/request-handler/src/minecraft-server-status.ts @@ -1,7 +1,8 @@ import dayjs from "dayjs"; import { z } from "zod/v4"; -import { fetchWithTimeoutAsync } from "@homarr/core/infrastructure/http/timeout"; +import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; +import { withTimeoutAsync } from "@homarr/core/infrastructure/http/timeout"; import { createCachedWidgetRequestHandler } from "./lib/cached-widget-request-handler"; @@ -11,7 +12,9 @@ export const minecraftServerStatusRequestHandler = createCachedWidgetRequestHand async requestAsync(input: { domain: string; isBedrockServer: boolean }) { const path = `${input.isBedrockServer ? "/bedrock" : ""}/3/${input.domain}`; - const response = await fetchWithTimeoutAsync(`https://api.mcsrvstat.us${path}`); + const response = await withTimeoutAsync(async (signal) => + fetchWithTrustedCertificatesAsync(`https://api.mcsrvstat.us${path}`, { signal }), + ); return responseSchema.parse(await response.json()); }, cacheDuration: dayjs.duration(5, "minutes"), diff --git a/packages/request-handler/src/stock-price.ts b/packages/request-handler/src/stock-price.ts index 7b724c43a..b0ca15e47 100644 --- a/packages/request-handler/src/stock-price.ts +++ b/packages/request-handler/src/stock-price.ts @@ -1,7 +1,8 @@ import dayjs from "dayjs"; import { z } from "zod/v4"; -import { fetchWithTimeoutAsync } from "@homarr/core/infrastructure/http/timeout"; +import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; +import { withTimeoutAsync } from "@homarr/core/infrastructure/http/timeout"; import { createCachedWidgetRequestHandler } from "./lib/cached-widget-request-handler"; @@ -9,9 +10,12 @@ export const fetchStockPriceHandler = createCachedWidgetRequestHandler({ queryKey: "fetchStockPriceResult", widgetKind: "stockPrice", async requestAsync(input: { stock: string; timeRange: string; timeInterval: string }) { - const response = await fetchWithTimeoutAsync( - `https://query1.finance.yahoo.com/v8/finance/chart/${input.stock}?range=${input.timeRange}&interval=${input.timeInterval}`, - ); + const response = await withTimeoutAsync(async (signal) => { + return await fetchWithTrustedCertificatesAsync( + `https://query1.finance.yahoo.com/v8/finance/chart/${input.stock}?range=${input.timeRange}&interval=${input.timeInterval}`, + { signal }, + ); + }); const data = dataSchema.parse(await response.json()); if ("error" in data) { diff --git a/packages/request-handler/src/update-checker.ts b/packages/request-handler/src/update-checker.ts index b2dca3b1e..755f14d3b 100644 --- a/packages/request-handler/src/update-checker.ts +++ b/packages/request-handler/src/update-checker.ts @@ -3,7 +3,7 @@ import { Octokit } from "octokit"; import { compareSemVer, isValidSemVer } from "semver-parser"; import { env } from "@homarr/common/env"; -import { fetchWithTimeoutAsync } from "@homarr/core/infrastructure/http/timeout"; +import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; import { createLogger } from "@homarr/core/infrastructure/logs"; import { createChannelWithLatestAndEvents } from "@homarr/redis"; import { createCachedRequestHandler } from "@homarr/request-handler/lib/cached-request-handler"; @@ -23,7 +23,7 @@ export const updateCheckerRequestHandler = createCachedRequestHandler({ const octokit = new Octokit({ request: { - fetch: fetchWithTimeoutAsync, + fetch: fetchWithTrustedCertificatesAsync, }, }); const releases = await octokit.rest.repos.listReleases({ diff --git a/packages/request-handler/src/weather.ts b/packages/request-handler/src/weather.ts index fdf1af8f2..eb4828048 100644 --- a/packages/request-handler/src/weather.ts +++ b/packages/request-handler/src/weather.ts @@ -1,7 +1,8 @@ import dayjs from "dayjs"; import { z } from "zod"; -import { fetchWithTimeoutAsync } from "@homarr/core/infrastructure/http/timeout"; +import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; +import { withTimeoutAsync } from "@homarr/core/infrastructure/http/timeout"; import { createCachedWidgetRequestHandler } from "./lib/cached-widget-request-handler"; @@ -9,9 +10,12 @@ export const weatherRequestHandler = createCachedWidgetRequestHandler({ queryKey: "weatherAtLocation", widgetKind: "weather", async requestAsync(input: { latitude: number; longitude: number }) { - const res = await fetchWithTimeoutAsync( - `https://api.open-meteo.com/v1/forecast?latitude=${input.latitude}&longitude=${input.longitude}&daily=weathercode,temperature_2m_max,temperature_2m_min,sunrise,sunset,wind_speed_10m_max,wind_gusts_10m_max¤t_weather=true&timezone=auto`, - ); + const res = await withTimeoutAsync(async (signal) => { + return await fetchWithTrustedCertificatesAsync( + `https://api.open-meteo.com/v1/forecast?latitude=${input.latitude}&longitude=${input.longitude}&daily=weathercode,temperature_2m_max,temperature_2m_min,sunrise,sunset,wind_speed_10m_max,wind_gusts_10m_max¤t_weather=true&timezone=auto`, + { signal }, + ); + }); const json: unknown = await res.json(); const weather = await atLocationOutput.parseAsync(json); return {