From a56bff23056d016046cfe2f10d66e32f9759355d Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 21 Aug 2026 11:06:42 -0700 Subject: [PATCH] add proxy resolver --- .../proxies/proxy.repository.interface.ts | 1 + .../domain/proxies/proxy.repository.mongo.ts | 14 ++- server/src/service/network/ProxyResolver.ts | 108 ++++++++++++++++++ server/src/types/network.ts | 4 + 4 files changed, 126 insertions(+), 1 deletion(-) create mode 100644 server/src/service/network/ProxyResolver.ts diff --git a/server/src/domain/proxies/proxy.repository.interface.ts b/server/src/domain/proxies/proxy.repository.interface.ts index a235d0df4..5d39ddb80 100644 --- a/server/src/domain/proxies/proxy.repository.interface.ts +++ b/server/src/domain/proxies/proxy.repository.interface.ts @@ -5,6 +5,7 @@ export interface IProxiesRepository { create(proxyData: Partial): Promise; // read findById(proxyId: string, teamId: string): Promise; + findByIdOrNull(proxyId: string, teamId?: string): Promise; findByTeamId(teamId: string): Promise; // update updateById(proxyId: string, teamId: string, patch: Partial, options?: { unsetPassword?: boolean; unsetUsername?: boolean }): Promise; diff --git a/server/src/domain/proxies/proxy.repository.mongo.ts b/server/src/domain/proxies/proxy.repository.mongo.ts index 3ff810587..454ef68ab 100644 --- a/server/src/domain/proxies/proxy.repository.mongo.ts +++ b/server/src/domain/proxies/proxy.repository.mongo.ts @@ -3,7 +3,7 @@ import { Proxy } from "@/domain/proxies/proxy.type.js"; import { ProxyDocument, ProxyModel } from "@/domain/proxies/proxy.model.js"; import { AppError } from "@/utils/AppError.js"; import { toStringId, toDateString } from "@/utils/mongoMappers.js"; -import { UpdateQuery } from "mongoose"; +import { Error as MongooseError, UpdateQuery } from "mongoose"; const SERVICE_NAME = "ProxiesRepository"; @@ -45,6 +45,18 @@ class MongoProxiesRepository implements IProxiesRepository { return this.toEntity(proxy); } + async findByIdOrNull(proxyId: string, teamId?: string): Promise { + try { + const proxy = await ProxyModel.findOne(teamId ? { _id: proxyId, teamId } : { _id: proxyId }); + return proxy ? this.toEntity(proxy) : null; + } catch (error) { + if (error instanceof MongooseError.CastError) { + return null; + } + throw error; + } + } + async findByTeamId(teamId: string): Promise { const proxies = await ProxyModel.find({ teamId }); return proxies.map(this.toEntity); diff --git a/server/src/service/network/ProxyResolver.ts b/server/src/service/network/ProxyResolver.ts new file mode 100644 index 000000000..8f9551acc --- /dev/null +++ b/server/src/service/network/ProxyResolver.ts @@ -0,0 +1,108 @@ +import { ISettingsService } from "@/domain/app-settings/app-settings.service.js"; +import { Monitor } from "@/domain/monitors/monitor.type.js"; +import { IProxiesRepository } from "@/domain/proxies/proxy.repository.interface.js"; +import { ILogger } from "@/utils/logger.js"; +import { Proxy } from "@/domain/proxies/proxy.type.js"; + +export interface IProxyResolver { + resolve(monitor: Monitor): Promise; +} + +const SERVICE_NAME = "ProxyResolver"; + +export const buildProxyUrl = (proxy: Proxy): string => { + const { protocol, host, port, username, password } = proxy; + let credentials = ""; + if (username) { + credentials = password ? `${encodeURIComponent(username)}:${encodeURIComponent(password)}@` : `${encodeURIComponent(username)}@`; + } + return `${protocol}://${credentials}${host}:${port}`; +}; + +export class ProxyResolver implements IProxyResolver { + static SERVICE_NAME = SERVICE_NAME; + constructor( + private proxiesRepository: IProxiesRepository, + private settingsService: ISettingsService, + private logger: ILogger, + private ttlMs = 60_000 + ) {} + + private cache = new Map(); + + private async getCached(key: string, fetch: () => Promise): Promise { + const hit = this.cache.get(key); + if (hit && hit.expiresAt > Date.now()) { + return hit.value as T; + } + const value = await fetch(); + this.cache.set(key, { value, expiresAt: Date.now() + this.ttlMs }); + return value; + } + + private warn(message: string, monitor: Monitor, error?: unknown) { + this.logger.warn({ + message, + service: SERVICE_NAME, + method: "resolve", + details: { + monitorId: monitor.id, + proxyMode: monitor.proxyMode, + proxyId: monitor.proxyId, + ...(error instanceof Error ? { error: error.message } : {}), + }, + }); + } + + private resolveCustom = async (monitor: Monitor): Promise => { + const { proxyId, teamId } = monitor; + if (!proxyId) { + this.warn("Proxy mode is custom, but proxyId is missing, connecting direct", monitor); + return undefined; + } + + const proxy = await this.getCached(`proxy:${proxyId}:${teamId}`, () => this.proxiesRepository.findByIdOrNull(proxyId, teamId)); + if (!proxy) { + this.warn("Proxy not found, connecting direct", monitor); + return undefined; + } + return buildProxyUrl(proxy); + }; + + private resolveInherit = async (monitor: Monitor): Promise => { + const settings = await this.getCached("settings", () => this.settingsService.getDBSettings()); + if (!settings.globalProxyEnabled) { + return undefined; + } + const globalProxyId = settings.globalProxyId; + if (!globalProxyId) { + return undefined; + } + const proxy = await this.getCached(`proxy:${globalProxyId}`, () => this.proxiesRepository.findByIdOrNull(globalProxyId)); + if (!proxy) { + this.warn("Global proxy references a proxy that no longer exists, connecting direct", monitor); + return undefined; + } + return buildProxyUrl(proxy); + }; + + async resolve(monitor: Monitor): Promise { + if (monitor.type !== "http") { + return undefined; + } + try { + switch (monitor.proxyMode) { + case "none": + return undefined; + case "custom": + return await this.resolveCustom(monitor); + default: + // "inherit" is the default mode + return await this.resolveInherit(monitor); + } + } catch (error: unknown) { + this.warn("Proxy resolution failed, connecting direct", monitor, error); + return undefined; + } + } +} diff --git a/server/src/types/network.ts b/server/src/types/network.ts index 2839ed95f..0c8dd7cae 100644 --- a/server/src/types/network.ts +++ b/server/src/types/network.ts @@ -152,3 +152,7 @@ export type StatusChangeResult = { }; export type MonitorStatusResponseOverrides = Partial, "monitorId" | "teamId" | "type">>; + +export type CheckContext = { + proxyUrl?: string; +};