apply proxy agents to http monitors
This commit is contained in:
1 parent
1152a70afb
commit
252067142e
4 files changed
+216
-8
No files matched your search
Generated
+10
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<HttpStatusPayload> {
|
||||
readonly type = "http";
|
||||
|
||||
// Shared, pooled agents reused across every check
|
||||
private static readonly PROXY_AGENT_CACHE_MAX = 50;
|
||||
private readonly proxyAgents = new Map<string, { http: HttpProxyAgent; https: HttpsProxyAgent }>();
|
||||
private readonly httpAgent: HttpAgent;
|
||||
private readonly httpsAgent: HttpsAgent;
|
||||
private readonly httpsAgentInsecure: HttpsAgent;
|
||||
@@ -41,6 +47,44 @@ export class HttpProvider implements IStatusProvider<HttpStatusPayload> {
|
||||
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<T>(
|
||||
monitor: Monitor,
|
||||
opts: {
|
||||
@@ -113,20 +157,22 @@ export class HttpProvider implements IStatusProvider<HttpStatusPayload> {
|
||||
};
|
||||
}
|
||||
|
||||
private handleHttpError<T>(error: unknown, monitor: Monitor): MonitorStatusResponse<T> {
|
||||
private handleHttpError<T>(error: unknown, monitor: Monitor, ctx?: CheckContext): MonitorStatusResponse<T> {
|
||||
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<HttpStatusPayload> {
|
||||
};
|
||||
}
|
||||
|
||||
async handle<T>(monitor: Monitor): Promise<MonitorStatusResponse<T>> {
|
||||
async handle<T>(monitor: Monitor, ctx?: CheckContext): Promise<MonitorStatusResponse<T>> {
|
||||
const { url, secret, ignoreTlsErrors } = monitor;
|
||||
|
||||
if (!url) {
|
||||
@@ -167,10 +213,12 @@ export class HttpProvider implements IStatusProvider<HttpStatusPayload> {
|
||||
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<HttpStatusPayload> {
|
||||
timings: response.timings,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
return this.handleHttpError(error, monitor);
|
||||
return this.handleHttpError(error, monitor, ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)");
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user