From 80e81a36a980624f3eb7329c219a9d4ac7bf022c Mon Sep 17 00:00:00 2001 From: Alexander Linder <34923333+SeineEloquenz@users.noreply.github.com> Date: Sun, 28 Jun 2026 17:34:46 +0200 Subject: [PATCH] Feature/add proxy route to FetchService (#7153) * Add proxy route to FetchService * Update changelog --- CHANGELOG.md | 6 ++ apps/api/src/services/fetch/fetch.service.ts | 69 ++++++++++++++++++- .../fetch/interfaces/proxy-route.interface.ts | 19 +++++ libs/common/src/lib/config.ts | 1 + 4 files changed, 93 insertions(+), 2 deletions(-) create mode 100644 apps/api/src/services/fetch/interfaces/proxy-route.interface.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c068a34a..0722a3d5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +### Added + +- Added support for routing outgoing requests through a per-domain proxy via the `PROXY_ROUTES` setting in the `FetchService` + ## 3.18.0 - 2026-06-28 ### Added diff --git a/apps/api/src/services/fetch/fetch.service.ts b/apps/api/src/services/fetch/fetch.service.ts index 2425e476e..4596350ca 100644 --- a/apps/api/src/services/fetch/fetch.service.ts +++ b/apps/api/src/services/fetch/fetch.service.ts @@ -4,6 +4,7 @@ import { PROPERTY_API_KEY_OPENROUTER, PROPERTY_OPENROUTER_MODEL, PROPERTY_OPENROUTER_MODEL_WEB_FETCH, + PROPERTY_PROXY_ROUTES, PROPERTY_WEB_FETCH_ROUTES } from '@ghostfolio/common/config'; @@ -12,6 +13,7 @@ import { createOpenRouter } from '@openrouter/ai-sdk-provider'; import { generateText, jsonSchema, tool } from 'ai'; import ms from 'ms'; +import { ProxyRoute } from './interfaces/proxy-route.interface'; import { WebFetchRoute } from './interfaces/web-fetch-route.interface'; @Injectable() @@ -21,11 +23,17 @@ export class FetchService implements OnModuleInit { private static readonly REDACTED_QUERY_PARAM_NAMES = ['apikey', 'api_token']; private static readonly WEB_FETCH_TIMEOUT = ms('30 seconds'); + private proxyRoutes: ProxyRoute[] = []; private webFetchRoutes: WebFetchRoute[] = []; public constructor(private readonly propertyService: PropertyService) {} public async onModuleInit() { + this.proxyRoutes = + (await this.propertyService.getByKey( + PROPERTY_PROXY_ROUTES + )) ?? []; + this.webFetchRoutes = (await this.propertyService.getByKey( PROPERTY_WEB_FETCH_ROUTES @@ -59,8 +67,10 @@ export class FetchService implements OnModuleInit { } } + const proxiedInput = this.applyProxyRoute(input); + try { - return await globalThis.fetch(input, init); + return await globalThis.fetch(proxiedInput, init); } catch (error) { if (error instanceof Error) { this.logger.error( @@ -171,18 +181,73 @@ export class FetchService implements OnModuleInit { } } + /** + * Rewrites the origin (protocol, host and port) of a request when its domain + * matches a configured {@link ProxyRoute}, preserving path and query. Returns + * the input unchanged when no route matches or parsing fails. + */ + private applyProxyRoute(input: RequestInfo | URL): RequestInfo | URL { + let requestUrl: URL; + + try { + requestUrl = new URL( + input instanceof Request ? input.url : input.toString() + ); + } catch { + return input; + } + + const route = this.proxyRoutes.find(({ domain }) => { + return this.hostnameMatchesDomain({ + domain, + hostname: requestUrl.hostname + }); + }); + + if (!route) { + return input; + } + + try { + const proxyUrl = new URL(route.url); + + requestUrl.host = proxyUrl.host; + requestUrl.protocol = proxyUrl.protocol; + } catch { + this.logger.warn( + `Skipping proxy route for "${route.domain}": invalid url "${route.url}"` + ); + + return input; + } + + return input instanceof Request + ? new Request(requestUrl.toString(), input) + : requestUrl.toString(); + } + private getMatchingWebFetchRoute(url: string) { try { const { hostname } = new URL(url); return this.webFetchRoutes.find(({ domain }) => { - return hostname === domain || hostname.endsWith(`.${domain}`); + return this.hostnameMatchesDomain({ domain, hostname }); }); } catch { return undefined; } } + private hostnameMatchesDomain({ + domain, + hostname + }: { + domain: string; + hostname: string; + }): boolean { + return hostname === domain || hostname.endsWith(`.${domain}`); + } + private redactUrl(rawUrl: string): string { try { const url = new URL(rawUrl); diff --git a/apps/api/src/services/fetch/interfaces/proxy-route.interface.ts b/apps/api/src/services/fetch/interfaces/proxy-route.interface.ts new file mode 100644 index 000000000..83edba6c0 --- /dev/null +++ b/apps/api/src/services/fetch/interfaces/proxy-route.interface.ts @@ -0,0 +1,19 @@ +/** + * Overrides the origin (protocol, host and port) of outgoing requests for a + * given domain. + * + * Configured via the `PROXY_ROUTES` property as a JSON array, e.g. + * + * [ + * { + * "domain": "example.com", + * "url": "http://example-proxy:8191" + * } + * ] + * + * Matches the domain itself and its subdomains (e.g. `api.example.com`). + */ +export interface ProxyRoute { + domain: string; + url: string; +} diff --git a/libs/common/src/lib/config.ts b/libs/common/src/lib/config.ts index 9c5ef8a5e..4f3e9dd9d 100644 --- a/libs/common/src/lib/config.ts +++ b/libs/common/src/lib/config.ts @@ -259,6 +259,7 @@ export const PROPERTY_IS_READ_ONLY_MODE = 'IS_READ_ONLY_MODE'; export const PROPERTY_IS_USER_SIGNUP_ENABLED = 'IS_USER_SIGNUP_ENABLED'; export const PROPERTY_OPENROUTER_MODEL = 'OPENROUTER_MODEL'; export const PROPERTY_OPENROUTER_MODEL_WEB_FETCH = 'OPENROUTER_MODEL_WEB_FETCH'; +export const PROPERTY_PROXY_ROUTES = 'PROXY_ROUTES'; export const PROPERTY_REFERRAL_PARTNERS = 'REFERRAL_PARTNERS'; export const PROPERTY_SLACK_COMMUNITY_USERS = 'SLACK_COMMUNITY_USERS'; export const PROPERTY_STRIPE_CONFIG = 'STRIPE_CONFIG';