diff --git a/server/.env.example b/server/.env.example index 5a44935b8..87b84fab5 100644 --- a/server/.env.example +++ b/server/.env.example @@ -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 diff --git a/server/package-lock.json b/server/package-lock.json index dac79faa8..0b4a308f9 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", + "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" } diff --git a/server/package.json b/server/package.json index f0362bb13..a8a2f210a 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", + "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", diff --git a/server/src/config/envValidation.ts b/server/src/config/envValidation.ts index a769a28c7..8e597d24e 100644 --- a/server/src/config/envValidation.ts +++ b/server/src/config/envValidation.ts @@ -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), }); diff --git a/server/src/config/services.worker.ts b/server/src/config/services.worker.ts index b0bdff2d6..9a15ab09a 100644 --- a/server/src/config/services.worker.ts +++ b/server/src/config/services.worker.ts @@ -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); diff --git a/server/src/domain/app-settings/app-settings.service.ts b/server/src/domain/app-settings/app-settings.service.ts index 2df2b7512..9ba14414c 100755 --- a/server/src/domain/app-settings/app-settings.service.ts +++ b/server/src/domain/app-settings/app-settings.service.ts @@ -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 }), diff --git a/server/src/service/network/HttpProvider.ts b/server/src/service/network/HttpProvider.ts index fb5f9fea5..f29eb5614 100644 --- a/server/src/service/network/HttpProvider.ts +++ b/server/src/service/network/HttpProvider.ts @@ -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 { readonly type = "http"; @@ -18,9 +19,15 @@ export class HttpProvider implements IStatusProvider { private readonly httpsAgent: HttpsAgent; private readonly httpsAgentInsecure: HttpsAgent; + // Outbound proxy, resolved once at startup from the environment + private readonly proxyableTypes: Set; + 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 { 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 { 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; diff --git a/server/src/service/network/proxyPolicy.ts b/server/src/service/network/proxyPolicy.ts new file mode 100644 index 000000000..9100a65c6 --- /dev/null +++ b/server/src/service/network/proxyPolicy.ts @@ -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; warning?: string } => { + if (raw === undefined || raw.trim() === "") { + return { types: new Set(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() }; + } + + const types = new Set(); + 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): 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(); + + 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; + } +} diff --git a/server/src/types/proxy-from-env.d.ts b/server/src/types/proxy-from-env.d.ts new file mode 100644 index 000000000..5fe22b5f0 --- /dev/null +++ b/server/src/types/proxy-from-env.d.ts @@ -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; +} diff --git a/server/test/unit/providers/network/proxyPolicy.test.ts b/server/test/unit/providers/network/proxyPolicy.test.ts new file mode 100644 index 000000000..b6c10e392 --- /dev/null +++ b/server/test/unit/providers/network/proxyPolicy.test.ts @@ -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) => { + 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); + }); +});