diff --git a/server/package-lock.json b/server/package-lock.json index dac79faa8..3cb17bea6 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -26,6 +26,7 @@ "got": "14.4.7", "handlebars": "^4.7.8", "helmet": "^8.0.0", + "hpagent": "1.2.0", "isomorphic-dompurify": "^2.26.0", "jmespath": "^0.16.0", "jsdom": "^26.1.0", @@ -7778,6 +7779,15 @@ "node": ">=18.0.0" } }, + "node_modules/hpagent": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/hpagent/-/hpagent-1.2.0.tgz", + "integrity": "sha512-A91dYTeIB6NoXG+PxTQpCCDDnfHsW9kc06Lvpu1TEe9gnd6ZFeiBoRO9JvzEv6xK7EX97/dUE8g/vBMTqTS3CA==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/html-encoding-sniffer": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", diff --git a/server/package.json b/server/package.json index f0362bb13..5e67192db 100755 --- a/server/package.json +++ b/server/package.json @@ -49,6 +49,7 @@ "got": "14.4.7", "handlebars": "^4.7.8", "helmet": "^8.0.0", + "hpagent": "1.2.0", "isomorphic-dompurify": "^2.26.0", "jmespath": "^0.16.0", "jsdom": "^26.1.0", diff --git a/server/src/config/services.worker.ts b/server/src/config/services.worker.ts index b0bdff2d6..9b4ea8609 100644 --- a/server/src/config/services.worker.ts +++ b/server/src/config/services.worker.ts @@ -26,7 +26,7 @@ import { NotificationReactor } from "@/worker/reactors/reactor.notification.js"; import { IncidentReactor } from "@/worker/reactors/reactor.incident.js"; import { ReactorDispatcher } from "@/worker/reactors/reactor.dispatcher.js"; import { DBQueueWorker } from "@/worker/worker.db-queue.js"; - +import { ProxyResolver } from "@/service/network/ProxyResolver.js"; // Network providers import { PingProvider } from "@/service/network/PingProvider.js"; import { HttpProvider } from "@/service/network/HttpProvider.js"; @@ -64,6 +64,7 @@ export const buildWorker = async (shared: SharedServices, envSettings: EnvConfig incidentsRepository, teamsRepository, maintenanceWindowsRepository, + proxiesRepository, } = shared; // *********************** @@ -93,6 +94,7 @@ export const buildWorker = async (shared: SharedServices, envSettings: EnvConfig dnsProvider, ]); + const proxyResolver = new ProxyResolver(proxiesRepository, settingsService, logger); const bufferService = new BufferService(logger, checkService, geoChecksService, settingsService, jobsRepository); const statusService = new StatusService(logger, monitorsRepository, monitorStatsRepository); const monitorStatusPolicy = new MonitorStatusPolicy(); @@ -110,7 +112,15 @@ export const buildWorker = async (shared: SharedServices, envSettings: EnvConfig // Check producer/evaluator // Handles creating and evaluatiog checks // *********************** - const checkProducer = new CheckProducer(monitorsRepository, maintenanceWindowsRepository, checkService, networkService, bufferService, logger); + const checkProducer = new CheckProducer( + monitorsRepository, + maintenanceWindowsRepository, + checkService, + networkService, + proxyResolver, + bufferService, + logger + ); const checkEvaluator = new CheckEvaluator(statusService, monitorStatusPolicy); const geoCheckPipeline = new GeoChecksPipeline(maintenanceWindowsRepository, geoChecksService, bufferService, logger); 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/HttpProvider.ts b/server/src/service/network/HttpProvider.ts index fb5f9fea5..1ef35f474 100644 --- a/server/src/service/network/HttpProvider.ts +++ b/server/src/service/network/HttpProvider.ts @@ -9,11 +9,17 @@ import { Monitor, MonitorType } from "@/domain/monitors/monitor.type.js"; import { isStatusUp } from "@/service/network/utils.js"; import { NETWORK_ERROR } from "@/types/network.js"; import CacheableLookup from "cacheable-lookup"; +import { HttpProxyAgent, HttpsProxyAgent } from "hpagent"; +import { CheckContext } from "@/types/network.js"; + +export const PROXY_CONNECT_ERROR_CODES = new Set(["ECONNREFUSED", "ECONNRESET", "EHOSTUNREACH", "ENETUNREACH", "ENOTFOUND", "EAI_AGAIN", "EPIPE"]); export class HttpProvider implements IStatusProvider { readonly type = "http"; // Shared, pooled agents reused across every check + private static readonly PROXY_AGENT_CACHE_MAX = 50; + private readonly proxyAgents = new Map(); private readonly httpAgent: HttpAgent; private readonly httpsAgent: HttpsAgent; private readonly httpsAgentInsecure: HttpsAgent; @@ -41,6 +47,44 @@ export class HttpProvider implements IStatusProvider { return type === "http"; } + private proxyFailureMessage = (error: RequestError, proxyUrl: string): string | undefined => { + if (error.response || !error.code || !PROXY_CONNECT_ERROR_CODES.has(error.code)) { + return undefined; + } + const { hostname, port } = new URL(proxyUrl); + return `Proxy connection failed (${hostname}:${port}): ${error.message}`; + }; + + private getProxyAgents(proxyUrl: string, ignoreTlsErrors: boolean) { + const key = `${proxyUrl}|${ignoreTlsErrors}`; + const hit = this.proxyAgents.get(key); + // Agent already exists, refresh + if (hit) { + this.proxyAgents.delete(key); // Refresh + this.proxyAgents.set(key, hit); + return hit; + } + + // Create new agents + const agentOptions = { keepAlive: true, maxSockets: 256, maxFreeSockets: 256, proxy: proxyUrl }; + const pair = { + http: new HttpProxyAgent(agentOptions), + https: new HttpsProxyAgent({ ...agentOptions, rejectUnauthorized: !ignoreTlsErrors }), + }; + + // Cache agents + this.proxyAgents.set(key, pair); + if (this.proxyAgents.size > HttpProvider.PROXY_AGENT_CACHE_MAX) { + const [oldestKey] = this.proxyAgents.keys(); // Destructuring gets the first inserted, ie oldest, item + if (oldestKey === undefined) return pair; + const oldest = this.proxyAgents.get(oldestKey); + oldest?.http.destroy(); + oldest?.https.destroy(); + this.proxyAgents.delete(oldestKey); + } + return pair; + } + private buildResponse( monitor: Monitor, opts: { @@ -113,20 +157,22 @@ export class HttpProvider implements IStatusProvider { }; } - private handleHttpError(error: unknown, monitor: Monitor): MonitorStatusResponse { + private handleHttpError(error: unknown, monitor: Monitor, ctx?: CheckContext): MonitorStatusResponse { if (error instanceof HTTPError || error instanceof RequestError) { const statusCode = error.response?.statusCode; const statusUp = isStatusUp(statusCode, monitor.customUpCodes); const responseTime = error.timings?.phases?.total ?? 0; if (!statusUp) { + const message = (ctx?.proxyUrl && error instanceof RequestError && this.proxyFailureMessage(error, ctx.proxyUrl)) || error.message; + return { monitorId: monitor.id, teamId: monitor.teamId, type: monitor.type, status: false, code: statusCode ?? NETWORK_ERROR, - message: error.message, + message: message, responseTime, timings: error.timings, payload: null as T, @@ -156,7 +202,7 @@ export class HttpProvider implements IStatusProvider { }; } - async handle(monitor: Monitor): Promise> { + async handle(monitor: Monitor, ctx?: CheckContext): Promise> { const { url, secret, ignoreTlsErrors } = monitor; if (!url) { @@ -167,10 +213,12 @@ export class HttpProvider implements IStatusProvider { headers: monitor.secret ? { Authorization: `Bearer ${secret}` } : undefined, }; - options.agent = { - http: this.httpAgent, - https: ignoreTlsErrors ? this.httpsAgentInsecure : this.httpsAgent, - }; + options.agent = ctx?.proxyUrl + ? this.getProxyAgents(ctx.proxyUrl, Boolean(ignoreTlsErrors)) + : { + http: this.httpAgent, + https: ignoreTlsErrors ? this.httpsAgentInsecure : this.httpsAgent, + }; options.method = monitor.method; @@ -188,7 +236,7 @@ export class HttpProvider implements IStatusProvider { timings: response.timings, }); } catch (error: unknown) { - return this.handleHttpError(error, monitor); + return this.handleHttpError(error, monitor, ctx); } } } diff --git a/server/src/service/network/IStatusProvider.ts b/server/src/service/network/IStatusProvider.ts index 6da91b30f..e8f63f671 100644 --- a/server/src/service/network/IStatusProvider.ts +++ b/server/src/service/network/IStatusProvider.ts @@ -1,8 +1,8 @@ import { Monitor, MonitorType } from "@/domain/monitors/monitor.type.js"; import { MonitorStatusResponse } from "@/types/network.js"; - +import { CheckContext } from "@/types/network.js"; export interface IStatusProvider { type: string; supports: (type: MonitorType) => boolean; - handle(monitor: Monitor): Promise>; + handle(monitor: Monitor, ctx?: CheckContext): Promise>; } diff --git a/server/src/service/network/ProxyResolver.ts b/server/src/service/network/ProxyResolver.ts new file mode 100644 index 000000000..573905f4b --- /dev/null +++ b/server/src/service/network/ProxyResolver.ts @@ -0,0 +1,111 @@ +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; expiresAt: number }>(); + + private getCached(key: string, fetch: () => Promise): Promise { + const hit = this.cache.get(key); + if (hit && hit.expiresAt > Date.now()) { + return hit.value as Promise; + } + // Cache the in flight promise so concurrent misses share one fetch + const value = fetch(); + this.cache.set(key, { value, expiresAt: Date.now() + this.ttlMs }); + // Don't negative cache rejections, evict so the next resolve retries + value.catch(() => this.cache.delete(key)); + 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/service/networkService.ts b/server/src/service/networkService.ts index 75ff66bda..69e97b092 100644 --- a/server/src/service/networkService.ts +++ b/server/src/service/networkService.ts @@ -1,5 +1,5 @@ import type { Monitor, MonitorType } from "@/domain/monitors/monitor.type.js"; -import type { MonitorPayloadMap, MonitorStatusResponse } from "@/types/network.js"; +import type { CheckContext, MonitorPayloadMap, MonitorStatusResponse } from "@/types/network.js"; import type { AxiosStatic } from "axios"; import { AppError } from "@/utils/AppError.js"; import { NETWORK_ERROR } from "@/types/network.js"; @@ -8,7 +8,7 @@ import { IStatusProvider } from "./network/IStatusProvider.js"; const SERVICE_NAME = "NetworkService"; export interface INetworkService { - requestStatus(monitor: Monitor & { type: T }): Promise>; + requestStatus(monitor: Monitor & { type: T }, ctx?: CheckContext): Promise>; requestWebhook( type: string, url: string, @@ -39,12 +39,15 @@ export class NetworkService implements INetworkService { } // Main entry point - async requestStatus(monitor: Monitor & { type: T }): Promise> { + async requestStatus( + monitor: Monitor & { type: T }, + ctx?: CheckContext + ): Promise> { const provider = this.providers.find((p) => p.supports(monitor.type)); if (!provider) { return this.handleUnsupportedType(monitor.type) as Promise>; } - return provider.handle(monitor) as Promise>; + return provider.handle(monitor, ctx) as Promise>; } private async handleUnsupportedType(type: string): Promise { 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; +}; diff --git a/server/src/worker/worker.check-producer.ts b/server/src/worker/worker.check-producer.ts index b73e2c6de..f297d01be 100644 --- a/server/src/worker/worker.check-producer.ts +++ b/server/src/worker/worker.check-producer.ts @@ -9,6 +9,7 @@ import { ICheckService } from "@/domain/checks/check.service.js"; import { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interface.js"; import { IMaintenanceWindowsRepository } from "@/domain/maintenance-windows/maintenance-window.repository.interface.js"; import { isWindowActive } from "@/utils/maintenanceWindow.js"; +import { IProxyResolver } from "@/service/network/ProxyResolver.js"; export interface ICheckProducer { produce(monitor: Monitor): Promise<{ status: MonitorStatusResponse; check: Check } | null>; @@ -22,6 +23,7 @@ export class CheckProducer implements ICheckProducer { private maintenanceWindowsRepository: IMaintenanceWindowsRepository, private checkService: ICheckService, private networkService: INetworkService, + private proxyResolver: IProxyResolver, private bufferService: IBufferService, private logger: ILogger ) {} @@ -56,7 +58,8 @@ export class CheckProducer implements ICheckProducer { } // Step 1b: Acquire status - const status = await this.networkService.requestStatus(monitor); + const proxyUrl = await this.proxyResolver.resolve(monitor); + const status = await this.networkService.requestStatus(monitor, { proxyUrl }); if (!status) { throw new Error("No network response"); } diff --git a/server/test/helpers/heartbeatTestHarness.ts b/server/test/helpers/heartbeatTestHarness.ts index 898c3c91e..9c2161790 100644 --- a/server/test/helpers/heartbeatTestHarness.ts +++ b/server/test/helpers/heartbeatTestHarness.ts @@ -127,12 +127,14 @@ export function createHeartbeatTestHarness(): HeartbeatTestHarness { }; const maintenanceWindowsRepo = { findByMonitorId: jest.fn().mockResolvedValue([]) }; + const proxyResolver = { resolve: jest.fn().mockResolvedValue(undefined) }; const checkProducer = new CheckProducer( monitorsRepo as any, maintenanceWindowsRepo as any, checkService as any, networkService as any, + proxyResolver as any, bufferStub as any, logger ); diff --git a/server/test/unit/providers/network/httpProvider.test.ts b/server/test/unit/providers/network/httpProvider.test.ts index 9ab69f25d..4e559ab06 100644 --- a/server/test/unit/providers/network/httpProvider.test.ts +++ b/server/test/unit/providers/network/httpProvider.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, jest } from "@jest/globals"; +import { HttpProxyAgent, HttpsProxyAgent } from "hpagent"; import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts"; import { NETWORK_ERROR } from "../../../../src/types/network.ts"; import type { Monitor } from "../../../../src/domain/monitors/monitor.type.ts"; @@ -494,4 +495,152 @@ describe("HttpProvider", () => { expect(advancedMatcher.validate).not.toHaveBeenCalled(); }); }); + + // ── Proxy support ──────────────────────────────────────────────────── + + describe("proxy support", () => { + const PROXY_URL = "http://proxy.example.com:8080"; + + const gotOptions = (callIndex = 0) => mockGot.mock.calls[callIndex]?.[1] as any; + + const makeConnectionError = (code: string, message = `connect ${code} 10.0.0.1:8080`) => { + const err = new RequestError(message); + (err as any).code = code; + return err; + }; + + it("uses hpagent proxy agents when ctx.proxyUrl is set", async () => { + mockGot.mockResolvedValue(makeGotResponse()); + const { provider } = createProvider(); + + await provider.handle(makeMonitor(), { proxyUrl: PROXY_URL }); + + expect(gotOptions().agent.http).toBeInstanceOf(HttpProxyAgent); + expect(gotOptions().agent.https).toBeInstanceOf(HttpsProxyAgent); + }); + + it("uses the shared agents when ctx has no proxyUrl", async () => { + mockGot.mockResolvedValue(makeGotResponse()); + const { provider } = createProvider(); + + await provider.handle(makeMonitor()); + await provider.handle(makeMonitor(), {}); + + expect(gotOptions(0).agent.http).not.toBeInstanceOf(HttpProxyAgent); + expect(gotOptions(1).agent.http).toBe(gotOptions(0).agent.http); + expect(gotOptions(1).agent.https).toBe(gotOptions(0).agent.https); + }); + + it("reuses the cached agent pair across checks with the same proxyUrl", async () => { + mockGot.mockResolvedValue(makeGotResponse()); + const { provider } = createProvider(); + + await provider.handle(makeMonitor(), { proxyUrl: PROXY_URL }); + await provider.handle(makeMonitor(), { proxyUrl: PROXY_URL }); + + expect(gotOptions(1).agent.http).toBe(gotOptions(0).agent.http); + expect(gotOptions(1).agent.https).toBe(gotOptions(0).agent.https); + }); + + it("builds distinct agent pairs for distinct proxyUrls", async () => { + mockGot.mockResolvedValue(makeGotResponse()); + const { provider } = createProvider(); + + await provider.handle(makeMonitor(), { proxyUrl: PROXY_URL }); + await provider.handle(makeMonitor(), { proxyUrl: "http://other.example.com:3128" }); + + expect(gotOptions(1).agent.http).not.toBe(gotOptions(0).agent.http); + }); + + it("builds distinct pairs per ignoreTlsErrors and sets rejectUnauthorized accordingly", async () => { + mockGot.mockResolvedValue(makeGotResponse()); + const { provider } = createProvider(); + + await provider.handle(makeMonitor({ ignoreTlsErrors: false }), { proxyUrl: PROXY_URL }); + await provider.handle(makeMonitor({ ignoreTlsErrors: true }), { proxyUrl: PROXY_URL }); + + expect(gotOptions(1).agent.https).not.toBe(gotOptions(0).agent.https); + expect(gotOptions(0).agent.https.options.rejectUnauthorized).toBe(true); + expect(gotOptions(1).agent.https.options.rejectUnauthorized).toBe(false); + }); + + it("evicts and destroys the least-recently-used pair past the cache cap", async () => { + mockGot.mockResolvedValue(makeGotResponse()); + const { provider } = createProvider(); + + await provider.handle(makeMonitor(), { proxyUrl: "http://proxy0.example.com:8080" }); + const first = gotOptions(0).agent; + const httpDestroy = jest.spyOn(first.http, "destroy"); + const httpsDestroy = jest.spyOn(first.https, "destroy"); + + for (let i = 1; i <= 49; i++) { + await provider.handle(makeMonitor(), { proxyUrl: `http://proxy${i}.example.com:8080` }); + } + expect(httpDestroy).not.toHaveBeenCalled(); + + await provider.handle(makeMonitor(), { proxyUrl: "http://proxy50.example.com:8080" }); + + expect(httpDestroy).toHaveBeenCalled(); + expect(httpsDestroy).toHaveBeenCalled(); + // The evicted pair is gone — the same proxyUrl now gets a fresh pair + await provider.handle(makeMonitor(), { proxyUrl: "http://proxy0.example.com:8080" }); + expect(gotOptions(51).agent.http).not.toBe(first.http); + }); + + it("names the proxy in the message when the proxy connection fails", async () => { + mockGot.mockRejectedValue(makeConnectionError("ECONNREFUSED")); + const { provider } = createProvider(); + + const result = await provider.handle(makeMonitor(), { proxyUrl: PROXY_URL }); + + expect(result.status).toBe(false); + expect(result.code).toBe(NETWORK_ERROR); + expect(result.message).toBe("Proxy connection failed (proxy.example.com:8080): connect ECONNREFUSED 10.0.0.1:8080"); + }); + + it("does not attribute timeouts to the proxy", async () => { + mockGot.mockRejectedValue(makeConnectionError("ETIMEDOUT", "Timeout awaiting 'request' for 30000ms")); + const { provider } = createProvider(); + + const result = await provider.handle(makeMonitor(), { proxyUrl: PROXY_URL }); + + expect(result.status).toBe(false); + expect(result.message).toBe("Timeout awaiting 'request' for 30000ms"); + }); + + it("leaves connection-error messages unprefixed for direct checks", async () => { + mockGot.mockRejectedValue(makeConnectionError("ECONNREFUSED")); + const { provider } = createProvider(); + + const result = await provider.handle(makeMonitor()); + + expect(result.message).toBe("connect ECONNREFUSED 10.0.0.1:8080"); + }); + + it("never leaks proxy credentials into the message", async () => { + mockGot.mockRejectedValue(makeConnectionError("ECONNREFUSED")); + const { provider } = createProvider(); + + const result = await provider.handle(makeMonitor(), { proxyUrl: "http://bob:sekret@proxy.example.com:8080" }); + + expect(result.message).toContain("proxy.example.com:8080"); + expect(result.message).not.toContain("bob"); + expect(result.message).not.toContain("sekret"); + }); + + it("does not prefix errors that carry a response (target failure through a healthy proxy)", async () => { + const err = new RequestError("Response code 500 (Internal Server Error)"); + (err as any).code = "ECONNRESET"; + (err as any).response = { statusCode: 500, body: "", headers: {} }; + (err as any).timings = { phases: { total: 25 } }; + mockGot.mockRejectedValue(err); + const { provider } = createProvider(); + + const result = await provider.handle(makeMonitor(), { proxyUrl: PROXY_URL }); + + expect(result.status).toBe(false); + expect(result.code).toBe(500); + expect(result.message).toBe("Response code 500 (Internal Server Error)"); + }); + }); }); diff --git a/server/test/unit/services/checkProducer.test.ts b/server/test/unit/services/checkProducer.test.ts index dacff3d9b..2a1aa29f1 100644 --- a/server/test/unit/services/checkProducer.test.ts +++ b/server/test/unit/services/checkProducer.test.ts @@ -26,6 +26,7 @@ const createProducer = (overrides?: Record) => { maintenanceWindowsRepository: { findByMonitorId: jest.fn().mockResolvedValue([]) }, checkService: { toCheck: jest.fn().mockReturnValue({ id: "check-1" }) }, networkService: { requestStatus: jest.fn().mockResolvedValue({ monitorId: "m1", status: true, code: 200, message: "OK" }) }, + proxyResolver: { resolve: jest.fn().mockResolvedValue(undefined) }, buffer: { addToBuffer: jest.fn() }, ...overrides, }; @@ -34,6 +35,7 @@ const createProducer = (overrides?: Record) => { defaults.maintenanceWindowsRepository as any, defaults.checkService as any, defaults.networkService as any, + defaults.proxyResolver as any, defaults.buffer as any, defaults.logger as any ); @@ -82,6 +84,29 @@ describe("CheckProducer", () => { expect(defaults.networkService.requestStatus).toHaveBeenCalled(); }); + // ── proxy resolution ────────────────────────────────────────────────────── + + it("passes the resolved proxy url into requestStatus", async () => { + const { producer, defaults } = createProducer({ + proxyResolver: { resolve: jest.fn().mockResolvedValue("http://proxy.example.com:8080") }, + }); + const monitor = makeMonitor(); + + await producer.produce(monitor); + + expect(defaults.networkService.requestStatus).toHaveBeenCalledWith(monitor, { proxyUrl: "http://proxy.example.com:8080" }); + }); + + it("still produces a check when the resolver returns undefined", async () => { + const { producer, defaults } = createProducer(); + const monitor = makeMonitor(); + + const result = await producer.produce(monitor); + + expect(defaults.networkService.requestStatus).toHaveBeenCalledWith(monitor, { proxyUrl: undefined }); + expect(result).toEqual({ status: expect.objectContaining({ monitorId: "m1" }), check: { id: "check-1" } }); + }); + // ── acquire / record ────────────────────────────────────────────────────── it("throws when the network response is null", async () => { diff --git a/server/test/unit/services/networkService.test.ts b/server/test/unit/services/networkService.test.ts index 2e6627452..b33983bf9 100644 --- a/server/test/unit/services/networkService.test.ts +++ b/server/test/unit/services/networkService.test.ts @@ -54,7 +54,7 @@ describe("NetworkService", () => { const result = await service.requestStatus(monitor); expect(provider.supports).toHaveBeenCalledWith("http"); - expect(provider.handle).toHaveBeenCalledWith(monitor); + expect(provider.handle).toHaveBeenCalledWith(monitor, undefined); expect(result).toEqual( expect.objectContaining({ monitorId: "mon-1", @@ -73,7 +73,18 @@ describe("NetworkService", () => { await service.requestStatus(monitor); expect(httpProvider.handle).not.toHaveBeenCalled(); - expect(pingProvider.handle).toHaveBeenCalledWith(monitor); + expect(pingProvider.handle).toHaveBeenCalledWith(monitor, undefined); + }); + + it("forwards the check context to the provider", async () => { + const provider = createMockProvider("http"); + const { service } = createService([provider]); + const monitor = { type: "http", _id: "mon-1" } as unknown as Monitor & { type: "http" }; + const ctx = { proxyUrl: "http://proxy.example.com:8080" }; + + await service.requestStatus(monitor, ctx); + + expect(provider.handle).toHaveBeenCalledWith(monitor, ctx); }); it("returns unsupported-type response when no provider matches", async () => { diff --git a/server/test/unit/services/proxyResolver.test.ts b/server/test/unit/services/proxyResolver.test.ts new file mode 100644 index 000000000..d2c5eefa2 --- /dev/null +++ b/server/test/unit/services/proxyResolver.test.ts @@ -0,0 +1,267 @@ +import { describe, expect, it, jest } from "@jest/globals"; +import { ProxyResolver, buildProxyUrl } from "../../../src/service/network/ProxyResolver.ts"; +import type { Monitor } from "../../../src/domain/monitors/monitor.type.ts"; +import type { Proxy } from "../../../src/domain/proxies/proxy.type.ts"; +import { createMockLogger } from "../../helpers/createMockLogger.ts"; + +const makeMonitor = (overrides?: Partial): Monitor => + ({ + id: "m1", + teamId: "team", + type: "http", + proxyMode: "inherit", + ...overrides, + }) as Monitor; + +const makeProxy = (overrides?: Partial): Proxy => + ({ + id: "p1", + teamId: "team", + name: "corp", + protocol: "http", + host: "proxy.example.com", + port: 8080, + ...overrides, + }) as Proxy; + +const createResolver = (overrides?: Record) => { + const defaults = { + proxiesRepository: { findByIdOrNull: jest.fn<() => Promise>().mockResolvedValue(makeProxy()) }, + settingsService: { getDBSettings: jest.fn<() => Promise>().mockResolvedValue({ globalProxyEnabled: true, globalProxyId: "p1" }) }, + logger: createMockLogger(), + ttlMs: 60_000, + ...overrides, + }; + const resolver = new ProxyResolver(defaults.proxiesRepository as any, defaults.settingsService as any, defaults.logger as any, defaults.ttlMs); + return { resolver, defaults }; +}; + +describe("ProxyResolver", () => { + // ── gates ───────────────────────────────────────────────────────────────── + + it("returns undefined for non-http monitors without touching repo or settings", async () => { + const { resolver, defaults } = createResolver(); + + const result = await resolver.resolve(makeMonitor({ type: "port", proxyMode: "custom", proxyId: "p1" } as Partial)); + + expect(result).toBeUndefined(); + expect(defaults.proxiesRepository.findByIdOrNull).not.toHaveBeenCalled(); + expect(defaults.settingsService.getDBSettings).not.toHaveBeenCalled(); + }); + + it("returns undefined for proxyMode 'none' without calling settings", async () => { + const { resolver, defaults } = createResolver(); + + const result = await resolver.resolve(makeMonitor({ proxyMode: "none" })); + + expect(result).toBeUndefined(); + expect(defaults.settingsService.getDBSettings).not.toHaveBeenCalled(); + }); + + // ── custom mode ─────────────────────────────────────────────────────────── + + it("resolves a custom proxy with a team-scoped lookup", async () => { + const { resolver, defaults } = createResolver(); + + const result = await resolver.resolve(makeMonitor({ proxyMode: "custom", proxyId: "p1" })); + + expect(result).toBe("http://proxy.example.com:8080"); + expect(defaults.proxiesRepository.findByIdOrNull).toHaveBeenCalledWith("p1", "team"); + }); + + it("returns undefined and warns when the custom proxy no longer exists", async () => { + const { resolver, defaults } = createResolver({ + proxiesRepository: { findByIdOrNull: jest.fn<() => Promise>().mockResolvedValue(null) }, + }); + + const result = await resolver.resolve(makeMonitor({ proxyMode: "custom", proxyId: "gone" })); + + expect(result).toBeUndefined(); + expect(defaults.logger.warn).toHaveBeenCalled(); + }); + + it("returns undefined and warns when proxyMode is custom but proxyId is missing, without a repo call", async () => { + const { resolver, defaults } = createResolver(); + + const result = await resolver.resolve(makeMonitor({ proxyMode: "custom", proxyId: undefined })); + + expect(result).toBeUndefined(); + expect(defaults.logger.warn).toHaveBeenCalled(); + expect(defaults.proxiesRepository.findByIdOrNull).not.toHaveBeenCalled(); + }); + + // ── inherit mode ────────────────────────────────────────────────────────── + + it("returns undefined when the global proxy is disabled, without a repo call", async () => { + const { resolver, defaults } = createResolver({ + settingsService: { getDBSettings: jest.fn<() => Promise>().mockResolvedValue({ globalProxyEnabled: false, globalProxyId: "p1" }) }, + }); + + const result = await resolver.resolve(makeMonitor({ proxyMode: "inherit" })); + + expect(result).toBeUndefined(); + expect(defaults.proxiesRepository.findByIdOrNull).not.toHaveBeenCalled(); + }); + + it("resolves the global proxy with an unscoped lookup when enabled", async () => { + const { resolver, defaults } = createResolver(); + + const result = await resolver.resolve(makeMonitor({ proxyMode: "inherit" })); + + expect(result).toBe("http://proxy.example.com:8080"); + expect(defaults.proxiesRepository.findByIdOrNull).toHaveBeenCalledWith("p1"); + }); + + it("returns undefined when the global proxy is enabled but globalProxyId is null, without a repo call", async () => { + const { resolver, defaults } = createResolver({ + settingsService: { getDBSettings: jest.fn<() => Promise>().mockResolvedValue({ globalProxyEnabled: true, globalProxyId: null }) }, + }); + + const result = await resolver.resolve(makeMonitor({ proxyMode: "inherit" })); + + expect(result).toBeUndefined(); + expect(defaults.proxiesRepository.findByIdOrNull).not.toHaveBeenCalled(); + }); + + it("returns undefined and warns when the global proxy references a proxy that no longer exists", async () => { + const { resolver, defaults } = createResolver({ + proxiesRepository: { findByIdOrNull: jest.fn<() => Promise>().mockResolvedValue(null) }, + }); + + const result = await resolver.resolve(makeMonitor({ proxyMode: "inherit" })); + + expect(result).toBeUndefined(); + expect(defaults.logger.warn).toHaveBeenCalled(); + }); + + it("treats an unknown proxyMode as inherit", async () => { + const { resolver, defaults } = createResolver(); + + const result = await resolver.resolve(makeMonitor({ proxyMode: "garbage" as Monitor["proxyMode"] })); + + expect(result).toBe("http://proxy.example.com:8080"); + expect(defaults.settingsService.getDBSettings).toHaveBeenCalled(); + }); + + // ── never throws ────────────────────────────────────────────────────────── + + it("returns undefined and warns when the repository throws", async () => { + const { resolver, defaults } = createResolver({ + proxiesRepository: { findByIdOrNull: jest.fn<() => Promise>().mockRejectedValue(new Error("db down")) }, + }); + + const result = await resolver.resolve(makeMonitor({ proxyMode: "custom", proxyId: "p1" })); + + expect(result).toBeUndefined(); + expect(defaults.logger.warn).toHaveBeenCalled(); + }); + + it("returns undefined and warns when the settings service throws", async () => { + const { resolver, defaults } = createResolver({ + settingsService: { getDBSettings: jest.fn<() => Promise>().mockRejectedValue(new Error("db down")) }, + }); + + const result = await resolver.resolve(makeMonitor({ proxyMode: "inherit" })); + + expect(result).toBeUndefined(); + expect(defaults.logger.warn).toHaveBeenCalled(); + }); + + // ── caching ─────────────────────────────────────────────────────────────── + + it("serves a second resolve within the TTL from cache", async () => { + const { resolver, defaults } = createResolver(); + + await resolver.resolve(makeMonitor({ proxyMode: "inherit" })); + await resolver.resolve(makeMonitor({ proxyMode: "inherit" })); + + expect(defaults.settingsService.getDBSettings).toHaveBeenCalledTimes(1); + expect(defaults.proxiesRepository.findByIdOrNull).toHaveBeenCalledTimes(1); + }); + + it("refetches after the TTL expires", async () => { + const { resolver, defaults } = createResolver({ ttlMs: 0 }); + + await resolver.resolve(makeMonitor({ proxyMode: "inherit" })); + await resolver.resolve(makeMonitor({ proxyMode: "inherit" })); + + expect(defaults.settingsService.getDBSettings).toHaveBeenCalledTimes(2); + expect(defaults.proxiesRepository.findByIdOrNull).toHaveBeenCalledTimes(2); + }); + + it("negative-caches a dangling ref, costing one repo call per TTL window", async () => { + const { resolver, defaults } = createResolver({ + proxiesRepository: { findByIdOrNull: jest.fn<() => Promise>().mockResolvedValue(null) }, + }); + + await resolver.resolve(makeMonitor({ proxyMode: "custom", proxyId: "gone" })); + await resolver.resolve(makeMonitor({ proxyMode: "custom", proxyId: "gone" })); + + expect(defaults.proxiesRepository.findByIdOrNull).toHaveBeenCalledTimes(1); + }); + + it("shares one in-flight fetch between concurrent resolves", async () => { + const { resolver, defaults } = createResolver(); + + await Promise.all([ + resolver.resolve(makeMonitor({ proxyMode: "inherit" })), + resolver.resolve(makeMonitor({ proxyMode: "inherit" })), + resolver.resolve(makeMonitor({ proxyMode: "inherit" })), + ]); + + expect(defaults.settingsService.getDBSettings).toHaveBeenCalledTimes(1); + expect(defaults.proxiesRepository.findByIdOrNull).toHaveBeenCalledTimes(1); + }); + + it("does not cache a rejected fetch — the next resolve retries", async () => { + const findByIdOrNull = jest.fn<() => Promise>().mockRejectedValueOnce(new Error("db down")).mockResolvedValueOnce(makeProxy()); + const { resolver } = createResolver({ proxiesRepository: { findByIdOrNull } }); + + const first = await resolver.resolve(makeMonitor({ proxyMode: "custom", proxyId: "p1" })); + const second = await resolver.resolve(makeMonitor({ proxyMode: "custom", proxyId: "p1" })); + + expect(first).toBeUndefined(); + expect(second).toBe("http://proxy.example.com:8080"); + expect(findByIdOrNull).toHaveBeenCalledTimes(2); + }); + + it("does not let a scoped miss poison the unscoped global lookup for the same proxy id", async () => { + const findByIdOrNull = jest.fn<(proxyId: string, teamId?: string) => Promise>(async (_proxyId, teamId) => + teamId ? null : makeProxy() + ); + const { resolver } = createResolver({ proxiesRepository: { findByIdOrNull } }); + + const customResult = await resolver.resolve(makeMonitor({ proxyMode: "custom", proxyId: "p1" })); + const inheritResult = await resolver.resolve(makeMonitor({ proxyMode: "inherit" })); + + expect(customResult).toBeUndefined(); + expect(inheritResult).toBe("http://proxy.example.com:8080"); + expect(findByIdOrNull).toHaveBeenCalledTimes(2); + }); +}); + +describe("buildProxyUrl", () => { + it("builds a URL without credentials when there is no username", () => { + expect(buildProxyUrl(makeProxy())).toBe("http://proxy.example.com:8080"); + }); + + it("builds a URL with username and password", () => { + expect(buildProxyUrl(makeProxy({ username: "user", password: "pass" }))).toBe("http://user:pass@proxy.example.com:8080"); + }); + + it("percent-encodes credentials", () => { + expect(buildProxyUrl(makeProxy({ username: "user@x", password: "p@ss" }))).toBe("http://user%40x:p%40ss@proxy.example.com:8080"); + }); + + it("builds a URL with a username only, without a colon", () => { + expect(buildProxyUrl(makeProxy({ username: "user" }))).toBe("http://user@proxy.example.com:8080"); + }); + + it("emits no credentials for a password without a username", () => { + expect(buildProxyUrl(makeProxy({ password: "pass" }))).toBe("http://proxy.example.com:8080"); + }); + + it("uses the https scheme without a trailing slash", () => { + expect(buildProxyUrl(makeProxy({ protocol: "https" }))).toBe("https://proxy.example.com:8080"); + }); +});