From 252067142ee7da74dd6e912c5b313b9961b020af Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 21 Aug 2026 12:19:23 -0700 Subject: [PATCH] apply proxy agents to http monitors --- server/package-lock.json | 10 ++ server/package.json | 1 + server/src/service/network/HttpProvider.ts | 64 +++++++- .../providers/network/httpProvider.test.ts | 149 ++++++++++++++++++ 4 files changed, 216 insertions(+), 8 deletions(-) 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/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/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)"); + }); + }); });