feat(monitors): route checks through an outbound HTTP proxy

Deployments with restricted egress could not run checks at all: got does not
read HTTP_PROXY/HTTPS_PROXY (unlike curl or axios), and NODE_USE_ENV_PROXY
only affects Node's built-in fetch, which got does not use. Even a correctly
built proxy agent was discarded, because HttpProvider overwrote
options.agent on every request.

Proxy selection is resolved per check and applied where the agent is chosen.
NO_PROXY handling is delegated to proxy-from-env rather than reimplemented,
so wildcards, host lists and port-qualified entries behave exactly as
operators expect from curl.

Infrastructure monitors are excluded by default. Hardware and pagespeed
checks both delegate to HttpProvider, so a naive global proxy would also
capture Capture agents — which normally sit on the internal network, where
proxying them fails or hairpins. PROXY_MONITOR_TYPES is the escape hatch for
deployments whose agents genuinely are behind the proxy, and accepts "none"
to disable proxying without unsetting the environment variables. Values that
are not real monitor types, or that open raw sockets and cannot be proxied
(ping, port, docker, grpc, websocket, dns, game), are dropped and logged so
a typo cannot silently disable proxying.

Agents are cached by proxy URL and TLS mode. Keying on TLS matters: sharing
one agent between a strict monitor and an ignoreTlsErrors monitor would leak
the relaxed setting across monitors. Caching also preserves pooling, so 50
monitors share tunnels instead of dialling the proxy per check.

Both new dependencies were already present transitively (76K total).

Notification providers still call out directly and are unaffected; that is a
known follow-up for the same class of deployment.
This commit is contained in:
gorkem-bwl committed 2026-08-20 10:39:36 -04:00
1 parent a719edb0fc
commit 705c19f7c8
10 files changed
+471 -30

No files matched your search

+19
View File
@@ -32,3 +32,22 @@ ORIGIN=localhost
# Set to "false" to disable the selectable theme feature on status pages.
# When disabled, all status pages render with the default "refined" theme.
STATUS_PAGE_THEMES_ENABLED=true
# Outbound Proxy
# --------------
# Route monitor checks through an HTTP proxy, for deployments whose egress is
# restricted. Credentials may be embedded: http://user:pass@proxy:3128
# HTTP_PROXY=http://proxy.corp.example:3128
# HTTPS_PROXY=http://proxy.corp.example:3128
#
# Hosts that must be reached directly. Supports wildcards and host lists, with
# the same semantics curl uses.
# NO_PROXY=localhost,127.0.0.1,*.internal,10.0.0.5
#
# Monitor types to route through the proxy, comma-separated. Defaults to
# "http,pagespeed". Infrastructure ("hardware") monitors are excluded by
# default because Capture agents normally sit on the internal network — add it
# here if yours are reachable only through the proxy. Use "none" to disable
# proxying without unsetting the variables above. Types that open raw sockets
# (ping, port, docker, grpc, websocket, dns, game) cannot be proxied.
# PROXY_MONITOR_TYPES=http,pagespeed
+99 -24
View File
@@ -26,6 +26,7 @@
"got": "14.4.7",
"handlebars": "^4.7.8",
"helmet": "^8.0.0",
"https-proxy-agent": "^9.1.0",
"isomorphic-dompurify": "^2.26.0",
"jmespath": "^0.16.0",
"jsdom": "^26.1.0",
@@ -35,6 +36,7 @@
"multer": "^1.4.5-lts.2",
"nodemailer": "9.0.5",
"ping": "0.4.4",
"proxy-from-env": "^2.1.0",
"re2": "^1.24.1",
"sharp": "0.35.3",
"ssl-checker": "2.0.10",
@@ -172,7 +174,6 @@
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.7",
@@ -805,7 +806,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@@ -850,7 +850,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
}
@@ -866,6 +865,29 @@
"kuler": "^2.0.0"
}
},
"node_modules/@emnapi/core": {
"version": "1.11.3",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz",
"integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.3",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.11.3",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
"integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/wasi-threads": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz",
@@ -873,6 +895,7 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"tslib": "^2.4.0"
}
@@ -3050,7 +3073,6 @@
"integrity": "sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/body-parser": "*",
"@types/express-serve-static-core": "^5.0.0",
@@ -3211,7 +3233,6 @@
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.1.tgz",
"integrity": "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg==",
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~7.16.0"
}
@@ -3448,7 +3469,6 @@
"integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.56.1",
"@typescript-eslint/types": "8.56.1",
@@ -4149,7 +4169,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -4557,7 +4576,6 @@
"integrity": "sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"peerDependencies": {
"bare-abort-controller": "*"
},
@@ -4876,7 +4894,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.10.42",
"caniuse-lite": "^1.0.30001800",
@@ -5739,7 +5756,6 @@
"resolved": "https://registry.npmjs.org/cssnano/-/cssnano-7.1.9.tgz",
"integrity": "sha512-uPR75+5Dk/WJ/YSPR1/YDHdwMM9c5FsaARljfKWgeCKLKOtJ0we21xy/RcCjn53fZnD/f6yYEIZ8pu18+GnbNQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"cssnano-preset-default": "^7.0.17",
"lilconfig": "^3.1.3"
@@ -6517,7 +6533,6 @@
"integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -6895,7 +6910,6 @@
"resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
"integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
"license": "MIT",
"peer": true,
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
@@ -7916,16 +7930,26 @@
}
},
"node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-9.1.0.tgz",
"integrity": "sha512-ag87y7cJJ9/3+GxFr8Oy4O5faDsGRGnBGsJj/YjOSsSx/5eadKLYTMPlzuR6obgoCDDm0abAAZitXXQkMOPSpA==",
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "4"
"agent-base": "9.0.0",
"debug": "^4.3.4",
"proxy-agent-negotiate": "1.1.0"
},
"engines": {
"node": ">= 14"
"node": ">= 20"
}
},
"node_modules/https-proxy-agent/node_modules/agent-base": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-9.0.0.tgz",
"integrity": "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==",
"license": "MIT",
"engines": {
"node": ">= 20"
}
},
"node_modules/human-signals": {
@@ -8265,6 +8289,19 @@
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/isomorphic-dompurify/node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/isomorphic-dompurify/node_modules/jsdom": {
"version": "27.0.1",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.0.1.tgz",
@@ -8463,7 +8500,6 @@
"integrity": "sha512-Yi1jqNC/Oq0N4hBgNH/YvBpP1P57QqundgytzYqy3yqAa7NZPNjSoi4SGbRAXDMdBzNE6xBCi5U7RgfrvMEUVQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@jest/core": "30.4.2",
"@jest/types": "30.4.1",
@@ -9204,6 +9240,19 @@
}
}
},
"node_modules/jsdom/node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/jsesc": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
@@ -10773,6 +10822,20 @@
}
}
},
"node_modules/mongodb-memory-server-core/node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"dev": true,
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/mongodb-memory-server-core/node_modules/mongodb": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/mongodb/-/mongodb-7.3.0.tgz",
@@ -11688,7 +11751,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.17",
"picocolors": "^1.1.1",
@@ -12372,6 +12434,23 @@
"node": ">= 0.10"
}
},
"node_modules/proxy-agent-negotiate": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-agent-negotiate/-/proxy-agent-negotiate-1.1.0.tgz",
"integrity": "sha512-N8IBcM3UgCVzz2L2Lqv8DVntDnnC8/hiV4nEDUPkqq72TPUgYWjQc+bdZlBPZK9LzPAvOY//gAt0S0DApoOXWQ==",
"license": "MIT",
"engines": {
"node": ">= 20"
},
"peerDependencies": {
"kerberos": "^2.0.0"
},
"peerDependenciesMeta": {
"kerberos": {
"optional": true
}
}
},
"node_modules/proxy-from-env": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
@@ -13763,7 +13842,6 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -13951,7 +14029,6 @@
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@cspotcode/source-map-support": "^0.8.0",
"@tsconfig/node10": "^1.0.7",
@@ -14115,7 +14192,6 @@
"integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -14869,7 +14945,6 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
+2
View File
@@ -49,6 +49,7 @@
"got": "14.4.7",
"handlebars": "^4.7.8",
"helmet": "^8.0.0",
"https-proxy-agent": "^9.1.0",
"isomorphic-dompurify": "^2.26.0",
"jmespath": "^0.16.0",
"jsdom": "^26.1.0",
@@ -58,6 +59,7 @@
"multer": "^1.4.5-lts.2",
"nodemailer": "9.0.5",
"ping": "0.4.4",
"proxy-from-env": "^2.1.0",
"re2": "^1.24.1",
"sharp": "0.35.3",
"ssl-checker": "2.0.10",
+11
View File
@@ -34,6 +34,17 @@ const envSchema = z.object({
// Optional
ORIGIN: z.string().optional(),
// Outbound proxy for monitor checks. HTTP_PROXY/HTTPS_PROXY/NO_PROXY follow
// the conventional semantics (NO_PROXY supports wildcards and host lists) and
// are read by proxy-from-env directly from the environment.
HTTP_PROXY: z.string().optional(),
HTTPS_PROXY: z.string().optional(),
NO_PROXY: z.string().optional(),
// Comma-separated monitor types to route through the proxy. Defaults to
// "http,pagespeed"; "hardware" is excluded because Capture agents normally
// live on the internal network. Set explicitly to override.
PROXY_MONITOR_TYPES: z.string().optional(),
// Feature flags
STATUS_PAGE_THEMES_ENABLED: booleanCoercion.default(true),
});
+1 -1
View File
@@ -70,7 +70,7 @@ export const buildWorker = async (shared: SharedServices, envSettings: EnvConfig
// Network providers
// ***********************
const pingProvider = new PingProvider(ping);
const httpProvider = new HttpProvider(got, new AdvancedMatcher(jmespath));
const httpProvider = new HttpProvider(got, new AdvancedMatcher(jmespath), envSettings.proxyMonitorTypes, logger);
const pageSpeedProvider = new PageSpeedProvider(httpProvider, settingsService, logger);
const hardwareProvider = new HardwareProvider(httpProvider);
const dockerProvider = new DockerProvider(logger, Docker);
@@ -16,6 +16,8 @@ export type EnvConfig = {
queueMode: QueueMode;
queuePrimaryProcesses: boolean;
statusPageThemesEnabled: boolean;
/** Comma-separated monitor types routed through the outbound proxy, if set. */
proxyMonitorTypes?: string;
clientConfig: ClientRuntimeConfig;
};
@@ -43,6 +45,7 @@ export class SettingsService implements ISettingsService {
queueMode: env.QUEUE_MODE,
queuePrimaryProcesses: env.QUEUE_PRIMARY_PROCESSES,
statusPageThemesEnabled: env.STATUS_PAGE_THEMES_ENABLED,
proxyMonitorTypes: env.PROXY_MONITOR_TYPES,
clientConfig: {
...(env.CLIENT_CONFIG_API_BASE_URL && { apiBaseUrl: env.CLIENT_CONFIG_API_BASE_URL }),
...(env.CLIENT_CONFIG_CLIENT_HOST && { clientHost: env.CLIENT_CONFIG_CLIENT_HOST }),
+26 -5
View File
@@ -9,6 +9,7 @@ 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 { ProxyAgentCache, parseProxyMonitorTypes, resolveProxyForCheck } from "@/service/network/proxyPolicy.js";
export class HttpProvider implements IStatusProvider<HttpStatusPayload> {
readonly type = "http";
@@ -18,9 +19,15 @@ export class HttpProvider implements IStatusProvider<HttpStatusPayload> {
private readonly httpsAgent: HttpsAgent;
private readonly httpsAgentInsecure: HttpsAgent;
// Outbound proxy, resolved once at startup from the environment
private readonly proxyableTypes: Set<string>;
private readonly proxyAgents = new ProxyAgentCache();
constructor(
private got: Got,
private advancedMatcher: IAdvancedMatcher
private advancedMatcher: IAdvancedMatcher,
proxyMonitorTypes?: string,
logger?: { warn: (payload: { message: string; service: string; method: string }) => void }
) {
const cacheable = new CacheableLookup({ maxTtl: 300, errorTtl: 30 });
this.got = got.extend({
@@ -35,6 +42,12 @@ export class HttpProvider implements IStatusProvider<HttpStatusPayload> {
this.httpAgent = new HttpAgent(agentOptions);
this.httpsAgent = new HttpsAgent({ ...agentOptions, rejectUnauthorized: true });
this.httpsAgentInsecure = new HttpsAgent({ ...agentOptions, rejectUnauthorized: false });
const { types, warning } = parseProxyMonitorTypes(proxyMonitorTypes);
this.proxyableTypes = types;
if (warning) {
logger?.warn({ message: warning, service: "HttpProvider", method: "constructor" });
}
}
supports(type: MonitorType) {
@@ -167,10 +180,18 @@ export class HttpProvider implements IStatusProvider<HttpStatusPayload> {
headers: monitor.secret ? { Authorization: `Bearer ${secret}` } : undefined,
};
options.agent = {
http: this.httpAgent,
https: ignoreTlsErrors ? this.httpsAgentInsecure : this.httpsAgent,
};
// Route through an outbound proxy when the environment configures one and
// this monitor type is eligible; otherwise use the shared direct agents.
const { proxyUrl } = resolveProxyForCheck(monitor.type, url, this.proxyableTypes);
if (proxyUrl) {
const proxyAgent = this.proxyAgents.get(proxyUrl, !ignoreTlsErrors);
options.agent = { http: proxyAgent, https: proxyAgent };
} else {
options.agent = {
http: this.httpAgent,
https: ignoreTlsErrors ? this.httpsAgentInsecure : this.httpsAgent,
};
}
options.method = monitor.method;
+135
View File
@@ -0,0 +1,135 @@
import { HttpsProxyAgent } from "https-proxy-agent";
import { getProxyForUrl } from "proxy-from-env";
import type { Agent as HttpAgent } from "http";
import type { Agent as HttpsAgent } from "https";
import { MonitorTypes, type MonitorType } from "@/domain/monitors/monitor.type.js";
/**
* Monitor types whose checks may be routed through an outbound HTTP proxy.
*
* Only types served by HttpProvider can be proxied at all — ping, port, docker,
* grpc, websocket, dns and game open raw sockets, which an HTTP proxy cannot
* carry.
*
* "hardware" is deliberately excluded by default: infrastructure monitors talk
* to Capture agents, which almost always sit on the internal network. Sending
* those through a corporate proxy fails or hairpins, so operators must opt in
* explicitly via PROXY_MONITOR_TYPES.
*/
export const PROXYABLE_MONITOR_TYPES = ["http", "pagespeed"] as const;
export type ProxyableMonitorType = (typeof PROXYABLE_MONITOR_TYPES)[number];
/** Types that reach the network through HttpProvider, and so could be proxied. */
const HTTP_BACKED_MONITOR_TYPES = ["http", "pagespeed", "hardware"] as const;
export interface ProxyResolution {
/** Proxy URL to dial through, or null to connect directly. */
proxyUrl: string | null;
/** Set when the configured type list contained unusable entries. */
warning?: string;
}
/**
* Parses the PROXY_MONITOR_TYPES escape hatch.
*
* Unset falls back to PROXYABLE_MONITOR_TYPES, and the literal "none" disables
* proxying entirely. Entries that are not real monitor types, or that never
* reach the network through HttpProvider, are dropped and reported so a typo
* cannot silently disable proxying.
*/
export const parseProxyMonitorTypes = (raw: string | undefined): { types: Set<string>; warning?: string } => {
if (raw === undefined || raw.trim() === "") {
return { types: new Set<string>(PROXYABLE_MONITOR_TYPES) };
}
const requested = raw
.split(",")
.map((entry) => entry.trim().toLowerCase())
.filter((entry) => entry !== "");
// Explicit opt-out, so proxying can be disabled without unsetting the proxy
// environment variables the rest of the container may rely on.
if (requested.length === 1 && requested[0] === "none") {
return { types: new Set<string>() };
}
const types = new Set<string>();
const unknown: string[] = [];
const unproxyable: string[] = [];
for (const entry of requested) {
if (!(MonitorTypes as readonly string[]).includes(entry)) {
unknown.push(entry);
} else if (!(HTTP_BACKED_MONITOR_TYPES as readonly string[]).includes(entry)) {
unproxyable.push(entry);
} else {
types.add(entry);
}
}
const problems: string[] = [];
if (unknown.length > 0) {
problems.push(`unknown monitor type(s): ${unknown.join(", ")}`);
}
if (unproxyable.length > 0) {
problems.push(`monitor type(s) that do not use HTTP and cannot be proxied: ${unproxyable.join(", ")}`);
}
return {
types,
warning: problems.length > 0 ? `PROXY_MONITOR_TYPES ignored ${problems.join("; ")}. Proxying: ${[...types].join(", ") || "(none)"}` : undefined,
};
};
/**
* Decides whether a single check should be proxied.
*
* Honours NO_PROXY (including wildcards and port-qualified hosts) by delegating
* to proxy-from-env, so the same rules operators already use for curl apply
* here rather than a hand-rolled reimplementation.
*
* proxy-from-env resolves against process.env, which is read once at startup
* (see createProxySettings). Checks run concurrently, so the environment is
* never mutated per-check.
*/
export const resolveProxyForCheck = (monitorType: MonitorType, url: string, proxyableTypes: Set<string>): ProxyResolution => {
if (!proxyableTypes.has(monitorType)) {
return { proxyUrl: null };
}
const proxyUrl = getProxyForUrl(url);
return { proxyUrl: proxyUrl === "" ? null : proxyUrl };
};
/**
* Builds proxy agents on demand and reuses them.
*
* Keyed by proxy URL *and* TLS mode: a monitor with ignoreTlsErrors must not
* share an agent with a strict one, or the relaxed setting would leak across
* monitors. Caching also preserves connection pooling — without it every check
* would open a fresh tunnel to the proxy.
*/
export class ProxyAgentCache {
private readonly agents = new Map<string, HttpAgent | HttpsAgent>();
get(proxyUrl: string, rejectUnauthorized: boolean): HttpAgent | HttpsAgent {
const key = `${proxyUrl}::${rejectUnauthorized ? "strict" : "insecure"}`;
const existing = this.agents.get(key);
if (existing) return existing;
const agent = new HttpsProxyAgent(proxyUrl, {
keepAlive: true,
maxSockets: 256,
maxFreeSockets: 256,
rejectUnauthorized,
}) as unknown as HttpsAgent;
this.agents.set(key, agent);
return agent;
}
/** Number of distinct agents held; used in tests to prove pooling. */
get size(): number {
return this.agents.size;
}
}
+8
View File
@@ -0,0 +1,8 @@
declare module "proxy-from-env" {
/**
* Resolves the proxy that should handle a request to the given URL, reading
* HTTP_PROXY / HTTPS_PROXY / NO_PROXY (and their lowercase forms) from
* process.env. Returns an empty string when the request should go direct.
*/
export function getProxyForUrl(url: string): string;
}
@@ -0,0 +1,167 @@
import { describe, expect, it, afterEach } from "@jest/globals";
import {
PROXYABLE_MONITOR_TYPES,
ProxyAgentCache,
parseProxyMonitorTypes,
resolveProxyForCheck,
} from "../../../../src/service/network/proxyPolicy.ts";
// proxy-from-env reads process.env directly, so these tests set and restore it.
const PROXY_KEYS = ["HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "no_proxy"];
const withEnv = (vars: Record<string, string>) => {
for (const key of PROXY_KEYS) delete process.env[key];
for (const [key, value] of Object.entries(vars)) process.env[key] = value;
};
afterEach(() => {
for (const key of PROXY_KEYS) delete process.env[key];
});
const defaults = () => parseProxyMonitorTypes(undefined).types;
describe("parseProxyMonitorTypes", () => {
it("defaults to http and pagespeed, excluding infrastructure", () => {
const { types, warning } = parseProxyMonitorTypes(undefined);
expect([...types].sort()).toEqual([...PROXYABLE_MONITOR_TYPES].sort());
expect(types.has("hardware")).toBe(false);
expect(warning).toBeUndefined();
});
it("treats an empty value as unset", () => {
expect([...parseProxyMonitorTypes(" ").types].sort()).toEqual([...PROXYABLE_MONITOR_TYPES].sort());
});
it("lets operators opt infrastructure in", () => {
const { types, warning } = parseProxyMonitorTypes("http,hardware");
expect(types.has("hardware")).toBe(true);
expect(types.has("pagespeed")).toBe(false);
expect(warning).toBeUndefined();
});
it("tolerates whitespace and mixed case", () => {
const { types } = parseProxyMonitorTypes(" HTTP , Hardware ");
expect([...types].sort()).toEqual(["hardware", "http"]);
});
it("allows proxying to be disabled entirely with an explicit sentinel", () => {
const { types, warning } = parseProxyMonitorTypes("none");
expect(types.size).toBe(0);
// "none" is a supported value, not a typo, so it must not warn.
expect(warning).toBeUndefined();
});
it("drops socket-based types that a proxy cannot carry, and says so", () => {
const { types, warning } = parseProxyMonitorTypes("http,ping,port,dns");
expect([...types]).toEqual(["http"]);
expect(warning).toContain("cannot be proxied");
expect(warning).toContain("ping");
});
it("reports unknown types rather than silently ignoring them", () => {
const { types, warning } = parseProxyMonitorTypes("http,htpp");
expect([...types]).toEqual(["http"]);
expect(warning).toContain("unknown monitor type");
expect(warning).toContain("htpp");
});
});
describe("resolveProxyForCheck", () => {
it("proxies a public HTTP monitor", () => {
withEnv({ HTTPS_PROXY: "http://corp:3128" });
expect(resolveProxyForCheck("http", "https://example.com", defaults()).proxyUrl).toBe("http://corp:3128");
});
it("leaves infrastructure checks direct by default", () => {
withEnv({ HTTPS_PROXY: "http://corp:3128" });
// Capture agents sit on the internal network; proxying them would break them.
expect(resolveProxyForCheck("hardware", "https://10.0.0.5:59232", defaults()).proxyUrl).toBeNull();
});
it("proxies infrastructure once opted in", () => {
withEnv({ HTTPS_PROXY: "http://corp:3128" });
const types = parseProxyMonitorTypes("http,hardware").types;
expect(resolveProxyForCheck("hardware", "https://10.0.0.5:59232", types).proxyUrl).toBe("http://corp:3128");
});
it("returns null when no proxy is configured", () => {
withEnv({});
expect(resolveProxyForCheck("http", "https://example.com", defaults()).proxyUrl).toBeNull();
});
it("honours NO_PROXY host entries", () => {
withEnv({ HTTPS_PROXY: "http://corp:3128", NO_PROXY: "internal.example.com" });
expect(resolveProxyForCheck("http", "https://internal.example.com", defaults()).proxyUrl).toBeNull();
expect(resolveProxyForCheck("http", "https://public.example.com", defaults()).proxyUrl).toBe("http://corp:3128");
});
it("honours NO_PROXY wildcards", () => {
withEnv({ HTTPS_PROXY: "http://corp:3128", NO_PROXY: "*.internal" });
expect(resolveProxyForCheck("http", "https://box.internal", defaults()).proxyUrl).toBeNull();
});
it("keeps credentials in an authenticated proxy URL", () => {
withEnv({ HTTPS_PROXY: "http://enki:s3cr3t@corp:3128" });
expect(resolveProxyForCheck("http", "https://example.com", defaults()).proxyUrl).toBe("http://enki:s3cr3t@corp:3128");
});
it("selects the proxy matching the target scheme", () => {
withEnv({ HTTP_PROXY: "http://plain:3128", HTTPS_PROXY: "http://secure:3129" });
expect(resolveProxyForCheck("http", "http://example.com", defaults()).proxyUrl).toBe("http://plain:3128");
expect(resolveProxyForCheck("http", "https://example.com", defaults()).proxyUrl).toBe("http://secure:3129");
});
it("never proxies socket-based monitor types", () => {
withEnv({ HTTPS_PROXY: "http://corp:3128" });
for (const type of ["ping", "port", "docker", "grpc", "websocket", "dns", "game"] as const) {
expect(resolveProxyForCheck(type, "https://example.com", defaults()).proxyUrl).toBeNull();
}
});
});
describe("ProxyAgentCache", () => {
it("reuses one agent per proxy so connections stay pooled", () => {
const cache = new ProxyAgentCache();
const first = cache.get("http://corp:3128", true);
const second = cache.get("http://corp:3128", true);
expect(second).toBe(first);
expect(cache.size).toBe(1);
});
it("keeps strict and TLS-ignoring agents separate", () => {
const cache = new ProxyAgentCache();
const strict = cache.get("http://corp:3128", true);
const insecure = cache.get("http://corp:3128", false);
// Sharing one agent would leak ignoreTlsErrors across monitors.
expect(insecure).not.toBe(strict);
expect(cache.size).toBe(2);
});
it("keeps a separate agent per proxy URL", () => {
const cache = new ProxyAgentCache();
cache.get("http://a:3128", true);
cache.get("http://b:3128", true);
expect(cache.size).toBe(2);
});
});