Compare commits

...
Author SHA1 Message Date
gorkem-bwl e760db1192 fix(monitors): correct proxy transport and error attribution
Second review round found two blocking defects, both verified against a stub
proxy rather than inferred.

HttpsProxyAgent was used for every target, and it always issues CONNECT:

    PROXY RECEIVED: "CONNECT example.com:80 HTTP/1.1"

Squid's stock ACL permits CONNECT only to 443, so every http:// monitor would
have broken the moment HTTP_PROXY was set — in exactly the hardened networks
this feature targets. Plain-http targets now use http-proxy-agent, which
sends an absolute-URI request. The agent cache keys on target scheme.

A refused CONNECT is replayed onto the socket, so the proxy's own status was
parsed as the target's: a proxy ACL denial reported "the site returned 403",
and a monitor whose customUpCodes included 403 or 407 was marked UP while the
target was never reached. Proxy-tunnel rejections are now detected via the
agent's proxyConnect event and reported as a network error naming the proxy.
The listener is per request and removed in a finally block, so a failure
cannot be attributed to a concurrent check.

Also from review:

- The fallback for an invalid PROXY_MONITOR_TYPES no longer fires when every
  entry was a deliberate but unproxyable type ("ping,port"). Falling back
  there would have proxied http and pagespeed, which the operator never
  nominated. Typos still fall back; unproxyable lists stay empty and warn.
- Dropped the now-redundant strict/insecure agent split: TLS is applied per
  request, and Node already separates socket pools by TLS options, so the
  split only doubled tunnels to the proxy.
- Renamed to ProxyPolicy.ts to match the PascalCase peers in the directory.
- The warning no longer echoes the raw config value.

Adds 9 tests covering scheme selection, tunnel rejection, and the narrowed
fallback. Verified by reverting: the tunnel-failure test fails without the fix.
2026-08-20 10:58:55 -04:00
gorkem-bwl 3b03850a3b fix(monitors): keep ignoreTlsErrors effective for proxied checks
Review found that the proxy path silently dropped ignoreTlsErrors.
rejectUnauthorized was passed to the HttpsProxyAgent constructor, where it
governs the socket to the proxy rather than the tunnelled handshake with the
target. Verified directly: the value lands in connectOpts, while the target
handshake reads got's per-request https options, which were unset and so
defaulted to strict.

The consequence was worse than a no-op. A monitor with ignoreTlsErrors
against a self-signed endpoint works today; configuring a proxy would have
made it fail verification, flip to down and fire a false incident with
notifications. Stating the option per request fixes it, and the agent cache
keeps its TLS split so a relaxed agent is never shared with a strict monitor.

Also from review:

- A fully-invalid PROXY_MONITOR_TYPES disabled proxying entirely, which in a
  restricted-egress deployment would take every HTTP monitor down at once —
  the exact failure the parser was meant to prevent. It now falls back to the
  defaults and warns loudly. "none" remains the way to disable deliberately.
- The "none" sentinel only matched when it was the sole entry, so "none,http"
  half-enabled proxying. It is now honoured wherever it appears.
- "unknown" is a real MonitorType but never a deliberate choice, so it is
  reported as a typo rather than as an unproxyable type.
- Dropped the unused warning field from ProxyResolution.
- Documented that lowercase proxy variables are honoured and take precedence,
  which matters on base images that already set them.

Adds 10 tests, including the request-level TLS assertions the previous suite
lacked. Verified by reverting the fix: both TLS tests fail without it.
2026-08-20 10:46:25 -04:00
gorkem-bwl 705c19f7c8 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.
2026-08-20 10:39:36 -04:00
11 changed files with 756 additions and 38 deletions

No files matched your search

+21
View File
@@ -32,3 +32,24 @@ 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
# The lowercase forms (http_proxy, https_proxy, no_proxy) are also honoured and
# take precedence over these, which matters on base images that already set them.
# 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
+142 -30
View File
@@ -26,6 +26,8 @@
"got": "14.4.7",
"handlebars": "^4.7.8",
"helmet": "^8.0.0",
"http-proxy-agent": "^9.1.0",
"https-proxy-agent": "^9.1.0",
"isomorphic-dompurify": "^2.26.0",
"jmespath": "^0.16.0",
"jsdom": "^26.1.0",
@@ -35,6 +37,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 +175,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 +807,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@@ -850,7 +851,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
}
@@ -866,6 +866,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 +896,7 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"tslib": "^2.4.0"
}
@@ -3050,7 +3074,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 +3234,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 +3470,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 +4170,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -4557,7 +4577,6 @@
"integrity": "sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"peerDependencies": {
"bare-abort-controller": "*"
},
@@ -4876,7 +4895,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.10.42",
"caniuse-lite": "^1.0.30001800",
@@ -5739,7 +5757,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 +6534,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 +6911,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",
@@ -7890,16 +7905,26 @@
}
},
"node_modules/http-proxy-agent": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
"integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-9.1.0.tgz",
"integrity": "sha512-2NxoveTT58mjYT4n3RPTEfCZGLMbidoO8XEieXfpSYxu+PQJ1qpx4ypwH6N+uF9twBPIvRRgvkvW5HUTYWENig==",
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.0",
"debug": "^4.3.4"
"agent-base": "9.0.0",
"debug": "^4.3.4",
"proxy-agent-negotiate": "1.1.0"
},
"engines": {
"node": ">= 14"
"node": ">= 20"
}
},
"node_modules/http-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/http2-wrapper": {
@@ -7916,16 +7941,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 +8300,32 @@
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/isomorphic-dompurify/node_modules/http-proxy-agent": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
"integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.0",
"debug": "^4.3.4"
},
"engines": {
"node": ">= 14"
}
},
"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 +8524,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 +9264,32 @@
}
}
},
"node_modules/jsdom/node_modules/http-proxy-agent": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
"integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.0",
"debug": "^4.3.4"
},
"engines": {
"node": ">= 14"
}
},
"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 +10859,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 +11788,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.17",
"picocolors": "^1.1.1",
@@ -12372,6 +12471,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 +13879,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 +14066,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 +14229,6 @@
"integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -14869,7 +14982,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"
}
+3
View File
@@ -49,6 +49,8 @@
"got": "14.4.7",
"handlebars": "^4.7.8",
"helmet": "^8.0.0",
"http-proxy-agent": "^9.1.0",
"https-proxy-agent": "^9.1.0",
"isomorphic-dompurify": "^2.26.0",
"jmespath": "^0.16.0",
"jsdom": "^26.1.0",
@@ -58,6 +60,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 }),
+61 -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) {
@@ -166,11 +179,37 @@ export class HttpProvider implements IStatusProvider<HttpStatusPayload> {
const options: Record<string, unknown> = {
headers: monitor.secret ? { Authorization: `Bearer ${secret}` } : undefined,
};
let cleanupProxyListener: (() => void) | 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);
// Set when the proxy itself refuses the tunnel, so a proxy ACL denial is not
// reported as the target's own status code.
let tunnelFailure: { statusCode: number } | null = null;
if (proxyUrl) {
const proxyAgent = this.proxyAgents.get(proxyUrl, url);
options.agent = { http: proxyAgent, https: proxyAgent };
// The agent's rejectUnauthorized governs the socket to the proxy, not the
// tunnelled handshake with the target, so ignoreTlsErrors has to be stated
// per request or it would be silently dropped for proxied monitors.
options.https = { rejectUnauthorized: !ignoreTlsErrors };
const emitter = proxyAgent as unknown as {
once?: (event: string, cb: (connect: { statusCode: number }) => void) => void;
off?: (event: string, cb: (connect: { statusCode: number }) => void) => void;
};
const onProxyConnect = (connect: { statusCode: number }) => {
if (connect.statusCode >= 400) tunnelFailure = { statusCode: connect.statusCode };
};
emitter.once?.("proxyConnect", onProxyConnect);
cleanupProxyListener = () => emitter.off?.("proxyConnect", onProxyConnect);
} else {
options.agent = {
http: this.httpAgent,
https: ignoreTlsErrors ? this.httpsAgentInsecure : this.httpsAgent,
};
}
options.method = monitor.method;
@@ -188,7 +227,24 @@ export class HttpProvider implements IStatusProvider<HttpStatusPayload> {
timings: response.timings,
});
} catch (error: unknown) {
if (tunnelFailure) {
// The proxy rejected the tunnel, so the target was never reached and
// its status is unknown. Reporting NETWORK_ERROR keeps customUpCodes
// from marking the monitor up on the proxy's 403/407.
return {
monitorId: monitor.id,
teamId: monitor.teamId,
type: monitor.type,
status: false,
code: NETWORK_ERROR,
message: `Proxy refused the connection (HTTP ${(tunnelFailure as { statusCode: number }).statusCode})`,
responseTime: 0,
payload: null as T,
};
}
return this.handleHttpError(error, monitor);
} finally {
cleanupProxyListener?.();
}
}
}
+162
View File
@@ -0,0 +1,162 @@
import { HttpProxyAgent } from "http-proxy-agent";
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;
}
/**
* 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. Honoured
// wherever it appears, so "none,http" disables rather than half-enabling.
if (requested.includes("none")) {
return { types: new Set<string>() };
}
const types = new Set<string>();
const unknown: string[] = [];
const unproxyable: string[] = [];
for (const entry of requested) {
// "unknown" is a real MonitorType but never a deliberate choice here.
if (entry === "unknown" || !(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(", ")}`);
}
// Every entry was a typo. Disabling all proxying here would take every HTTP
// monitor down in a restricted-egress deployment, so fall back to the defaults
// and make the misconfiguration loud instead.
//
// An all-unproxyable list (e.g. "ping,port") is left empty on purpose: those
// are deliberate choices, and silently proxying http instead would route
// traffic the operator never nominated.
if (types.size === 0 && unproxyable.length === 0) {
return {
types: new Set<string>(PROXYABLE_MONITOR_TYPES),
warning: `PROXY_MONITOR_TYPES matched no proxyable monitor type — ${problems.join("; ")}. Falling back to: ${PROXYABLE_MONITOR_TYPES.join(", ")}. Use "none" to disable proxying.`,
};
}
return {
types,
warning: problems.length > 0 ? `PROXY_MONITOR_TYPES ignored ${problems.join("; ")}. Proxying: ${[...types].join(", ")}` : 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.
*
* The agent must match the *target* scheme, not the proxy's. HttpsProxyAgent
* always issues CONNECT, which hardened proxies (Squid's stock ACL among them)
* permit only to 443 — so a plain-http target needs HttpProxyAgent, which sends
* an absolute-URI request instead.
*
* Caching preserves connection pooling: without it every check would open a
* fresh connection to the proxy. Node keys its own socket pools on the TLS
* options it receives per request, so agents are shared across monitors
* regardless of ignoreTlsErrors — that setting is applied per request in
* HttpProvider, not baked into the agent.
*/
export class ProxyAgentCache {
private readonly agents = new Map<string, HttpAgent | HttpsAgent>();
get(proxyUrl: string, targetUrl: string): HttpAgent | HttpsAgent {
const targetIsSecure = safeProtocol(targetUrl) !== "http:";
const key = `${proxyUrl}::${targetIsSecure ? "https" : "http"}`;
const existing = this.agents.get(key);
if (existing) return existing;
const options = { keepAlive: true, maxSockets: 256, maxFreeSockets: 256 };
const agent = (targetIsSecure ? new HttpsProxyAgent(proxyUrl, options) : new HttpProxyAgent(proxyUrl, options)) 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;
}
}
/** Target URLs are validated upstream; fall back to https on anything odd. */
const safeProtocol = (url: string): string => {
try {
return new URL(url).protocol;
} catch {
return "https:";
}
};
+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;
}
@@ -74,9 +74,9 @@ const makeGotResponse = (overrides?: Record<string, any>) => ({
...overrides,
});
const createProvider = (matcher?: any) => {
const createProvider = (matcher?: any, proxyMonitorTypes?: string) => {
const advancedMatcher = matcher ?? createMockMatcher();
const provider = new HttpProvider(mockGot as any, advancedMatcher);
const provider = new HttpProvider(mockGot as any, advancedMatcher, proxyMonitorTypes);
return { provider, advancedMatcher };
};
@@ -494,4 +494,128 @@ describe("HttpProvider", () => {
expect(advancedMatcher.validate).not.toHaveBeenCalled();
});
});
// ── Outbound proxy ───────────────────────────────────────────────────
describe("outbound proxy routing", () => {
const PROXY_KEYS = ["HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "no_proxy"];
const clearProxyEnv = () => {
for (const key of PROXY_KEYS) delete process.env[key];
};
const agentOf = () => (mockGot.mock.calls[0]?.[1] as any)?.agent;
const httpsOptionOf = () => (mockGot.mock.calls[0]?.[1] as any)?.https;
const isProxyAgent = (agent: any) => Boolean(agent?.https?.constructor?.name?.includes("Proxy"));
it("routes an http monitor through the configured proxy", async () => {
clearProxyEnv();
process.env.HTTPS_PROXY = "http://corp:3128";
mockGot.mockResolvedValue(makeGotResponse());
const { provider } = createProvider();
await provider.handle(makeMonitor({ url: "https://example.com" }));
expect(isProxyAgent(agentOf())).toBe(true);
clearProxyEnv();
});
it("leaves infrastructure monitors direct by default", async () => {
clearProxyEnv();
process.env.HTTPS_PROXY = "http://corp:3128";
mockGot.mockResolvedValue(makeGotResponse());
const { provider } = createProvider();
// Capture agents live on the internal network; proxying them breaks them.
await provider.handle(makeMonitor({ type: "hardware", url: "https://10.0.0.5:59232" }));
expect(isProxyAgent(agentOf())).toBe(false);
clearProxyEnv();
});
it("routes infrastructure monitors once opted in", async () => {
clearProxyEnv();
process.env.HTTPS_PROXY = "http://corp:3128";
mockGot.mockResolvedValue(makeGotResponse());
const { provider } = createProvider(undefined, "http,hardware");
await provider.handle(makeMonitor({ type: "hardware", url: "https://10.0.0.5:59232" }));
expect(isProxyAgent(agentOf())).toBe(true);
clearProxyEnv();
});
it("keeps ignoreTlsErrors effective for proxied monitors", async () => {
clearProxyEnv();
process.env.HTTPS_PROXY = "http://corp:3128";
mockGot.mockResolvedValue(makeGotResponse());
const { provider } = createProvider();
// The proxy agent's rejectUnauthorized applies to the socket to the proxy,
// not the tunnelled handshake, so this must be stated per request or a
// self-signed target would start failing the moment a proxy is configured.
await provider.handle(makeMonitor({ url: "https://self-signed.example.com", ignoreTlsErrors: true }));
expect(httpsOptionOf()).toEqual({ rejectUnauthorized: false });
clearProxyEnv();
});
it("keeps TLS strict for proxied monitors that did not opt out", async () => {
clearProxyEnv();
process.env.HTTPS_PROXY = "http://corp:3128";
mockGot.mockResolvedValue(makeGotResponse());
const { provider } = createProvider();
await provider.handle(makeMonitor({ url: "https://example.com", ignoreTlsErrors: false }));
expect(httpsOptionOf()).toEqual({ rejectUnauthorized: true });
clearProxyEnv();
});
it("does not set a proxy agent when no proxy is configured", async () => {
clearProxyEnv();
mockGot.mockResolvedValue(makeGotResponse());
const { provider } = createProvider();
await provider.handle(makeMonitor({ url: "https://example.com" }));
expect(isProxyAgent(agentOf())).toBe(false);
});
it("reports a refused proxy tunnel as a network error, not the target's status", async () => {
clearProxyEnv();
process.env.HTTPS_PROXY = "http://corp:3128";
const { provider } = createProvider();
// The agent replays a refused CONNECT onto the socket, so the proxy's own
// status would otherwise be parsed as the target's.
const agent: any = (provider as any).proxyAgents.get("http://corp:3128", "https://example.com");
const err = new HTTPError("Response code 403 (Forbidden)");
(err as any).response = { statusCode: 403, body: "", headers: {} };
(err as any).timings = { phases: { total: 5 } };
mockGot.mockImplementation(() => {
agent.emit("proxyConnect", { statusCode: 403 });
return Promise.reject(err);
});
const result = await provider.handle(makeMonitor({ url: "https://example.com", customUpCodes: [403] }));
// customUpCodes must not mark the monitor up on the proxy's status.
expect(result.status).toBe(false);
expect(result.code).toBe(NETWORK_ERROR);
expect(result.message).toContain("Proxy refused");
clearProxyEnv();
});
it("honours NO_PROXY", async () => {
clearProxyEnv();
process.env.HTTPS_PROXY = "http://corp:3128";
process.env.NO_PROXY = "example.com";
mockGot.mockResolvedValue(makeGotResponse());
const { provider } = createProvider();
await provider.handle(makeMonitor({ url: "https://example.com" }));
expect(isProxyAgent(agentOf())).toBe(false);
clearProxyEnv();
});
});
});
@@ -0,0 +1,218 @@
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("honours the none sentinel wherever it appears", () => {
expect(parseProxyMonitorTypes("none,http").types.size).toBe(0);
});
it("leaves an all-unproxyable list empty rather than proxying types nobody named", () => {
// "ping,port" is a misunderstanding, but a deliberate one — silently
// proxying http instead would route traffic the operator never nominated.
const { types, warning } = parseProxyMonitorTypes("ping,port");
expect(types.size).toBe(0);
expect(warning).toContain("cannot be proxied");
});
it("falls back to defaults when every entry is invalid, rather than disabling proxying", () => {
// Silently proxying nothing would take every HTTP monitor down in a
// restricted-egress deployment, which is worse than ignoring the typo.
const { types, warning } = parseProxyMonitorTypes("htpp");
expect([...types].sort()).toEqual([...PROXYABLE_MONITOR_TYPES].sort());
expect(warning).toContain("Falling back to");
});
it("treats the internal unknown type as a typo", () => {
const { types, warning } = parseProxyMonitorTypes("unknown");
expect([...types].sort()).toEqual([...PROXYABLE_MONITOR_TYPES].sort());
expect(warning).toContain("unknown monitor type");
});
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", "https://example.com");
const second = cache.get("http://corp:3128", "https://other.example.com");
expect(second).toBe(first);
expect(cache.size).toBe(1);
});
it("uses a CONNECT-based agent for https targets", () => {
const cache = new ProxyAgentCache();
const agent = cache.get("http://corp:3128", "https://example.com");
expect(agent.constructor.name).toBe("HttpsProxyAgent");
});
it("uses an absolute-URI agent for plain http targets", () => {
const cache = new ProxyAgentCache();
// HttpsProxyAgent would issue CONNECT, which hardened proxies allow only
// to 443, breaking every http:// monitor.
const agent = cache.get("http://corp:3128", "http://example.com");
expect(agent.constructor.name).toBe("HttpProxyAgent");
});
it("keeps http and https target agents separate", () => {
const cache = new ProxyAgentCache();
cache.get("http://corp:3128", "https://example.com");
cache.get("http://corp:3128", "http://example.com");
expect(cache.size).toBe(2);
});
it("keeps a separate agent per proxy URL", () => {
const cache = new ProxyAgentCache();
cache.get("http://a:3128", "https://example.com");
cache.get("http://b:3128", "https://example.com");
expect(cache.size).toBe(2);
});
it("falls back to a CONNECT agent for an unparseable target", () => {
const cache = new ProxyAgentCache();
expect(cache.get("http://corp:3128", "not-a-url").constructor.name).toBe("HttpsProxyAgent");
});
});