Compare commits

..
Author SHA1 Message Date
Alex Holliday cd520dacc5 add tests 2026-08-20 14:38:18 -07:00
Alex Holliday 637bb5cca9 add db migration for proxy mode inherit 2026-08-20 14:31:23 -07:00
Alex Holliday ede93add64 add clear username flag 2026-08-20 13:57:24 -07:00
Alex Holliday 4443a53a23 strip password 2026-08-20 13:52:14 -07:00
Alex Holliday 1b2d2887af add proxy routes/controller 2026-08-20 13:35:11 -07:00
Alex Holliday 54690fc73c add/update validations 2026-08-20 13:34:56 -07:00
Alex Holliday 6e3252aec8 wire repo and service 2026-08-20 11:31:51 -07:00
Alex Holliday edd9335e96 add gloabl proxy to app settings 2026-08-20 11:16:07 -07:00
Alex Holliday cf13bc329e add proxy service 2026-08-20 11:00:05 -07:00
Alex Holliday 80097afb71 update monitor model/repos 2026-08-20 10:59:40 -07:00
Alex Holliday 5435167e73 add proxy repository and interface 2026-08-20 10:49:12 -07:00
Alex Holliday 059dc2737b add proxy type and model 2026-08-20 10:28:50 -07:00
Alexander Holliday 158f2fffbe Merge pull request #3858 from bluewave-labs/feat/uptime-percentage-two-decimals
feat(client): show uptime percentages with two decimals
2026-08-20 09:39:56 -07:00
gorkem-bwl 68c1f2e281 feat(client): show uptime percentages with two decimals
Requested by a user who found xx.xx% more trustworthy than xx.x% on their
status page. The presentational argument holds, but the stronger reason is
that one decimal was actively misleading.

Intl rounds to the displayed precision, so every value above 99.95% rendered
as "100.0%" — a claim of zero downtime for a period that contained real
outages. On a public status page that is the opposite of reassuring: the
number reads as flawless during a month with a visible incident.

One decimal also collapsed the range operators care about most. 99.9%,
99.95% and 99.99% are three very different commitments (8.7 hours, 4.4 hours
and 52 minutes of annual downtime) and the first rendered as 99.9% while the
other two were indistinguishable at 100.0%.

Everything flows through the shared formatter in FormatUtils, so this covers
both surfaces at once: the monitor list and diagnostics gauges in the web
app, and the uptime figure, chart cells and hover tooltip on status pages.
Infrastructure gauges are untouched — they format separately at zero
decimals, where CPU at "43.00%" would only add noise.

Server-side precision was already sufficient; the aggregation divides
without rounding, so no API change is needed.

Adds tests pinning the regression: 7 of the 10 fail against the previous
one-decimal formatter.

Note: this restores the client Vitest setup. PR #3844 introduced it, but the
config, scripts and logger tests were dropped when that branch merged into
develop, leaving the client with no test runner again.
2026-08-20 09:38:07 -07:00
39 changed files with 936 additions and 776 deletions

No files matched your search

+17 -11
View File
@@ -1,25 +1,31 @@
/**
* Intl.NumberFormat instance for percentage formatting.
* Reused across all formatting calls for performance.
*
* Two decimals rather than one: at a single decimal every value above 99.95%
* renders as "100.0%", so a period containing real downtime is reported as
* flawless. It also collapses 99.9 / 99.95 / 99.99 — three materially
* different SLA commitments — into the same figure.
*/
const percentageFormatter = new Intl.NumberFormat("en-US", {
style: "percent",
minimumFractionDigits: 1,
maximumFractionDigits: 1,
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
/**
* Formats a decimal value as a percentage string.
* @param value - Decimal value (e.g., 0.75 for 75%)
* @returns Formatted percentage string (e.g., "75.0%")
* @returns Formatted percentage string (e.g., "75.00%")
* @example
* formatPercentage(0.75) // "75.0%"
* formatPercentage(1) // "100.0%"
* formatPercentage(0.5432) // "54.3%"
* formatPercentage(0.75) // "75.00%"
* formatPercentage(1) // "100.00%"
* formatPercentage(0.5432) // "54.32%"
* formatPercentage(0.9999) // "99.99%"
*/
export const formatPercentage = (value: number): string => {
if (typeof value !== "number" || Number.isNaN(value)) {
return "0.0%";
return "0.00%";
}
return percentageFormatter.format(value);
};
@@ -27,11 +33,11 @@ export const formatPercentage = (value: number): string => {
/**
* Formats a whole number percentage value as a percentage string.
* @param value - Whole number percentage (e.g., 75 for 75%)
* @returns Formatted percentage string (e.g., "75.0%")
* @returns Formatted percentage string (e.g., "75.00%")
* @example
* formatPercentageFromWhole(75) // "75.0%"
* formatPercentageFromWhole(100) // "100.0%"
* formatPercentageFromWhole(54.32) // "54.3%"
* formatPercentageFromWhole(75) // "75.00%"
* formatPercentageFromWhole(100) // "100.00%"
* formatPercentageFromWhole(54.32) // "54.32%"
*/
export const formatPercentageFromWhole = (value: number): string => {
return formatPercentage(value / 100);
-21
View File
@@ -32,24 +32,3 @@ 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
+30 -142
View File
@@ -26,8 +26,6 @@
"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",
@@ -37,7 +35,6 @@
"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",
@@ -175,6 +172,7 @@
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.7",
@@ -807,6 +805,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@@ -851,6 +850,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
}
@@ -866,29 +866,6 @@
"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",
@@ -896,7 +873,6 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"tslib": "^2.4.0"
}
@@ -3074,6 +3050,7 @@
"integrity": "sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/body-parser": "*",
"@types/express-serve-static-core": "^5.0.0",
@@ -3234,6 +3211,7 @@
"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"
}
@@ -3470,6 +3448,7 @@
"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",
@@ -4170,6 +4149,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -4577,6 +4557,7 @@
"integrity": "sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"peerDependencies": {
"bare-abort-controller": "*"
},
@@ -4895,6 +4876,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.10.42",
"caniuse-lite": "^1.0.30001800",
@@ -5757,6 +5739,7 @@
"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"
@@ -6534,6 +6517,7 @@
"integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -6911,6 +6895,7 @@
"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",
@@ -7905,26 +7890,16 @@
}
},
"node_modules/http-proxy-agent": {
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-9.1.0.tgz",
"integrity": "sha512-2NxoveTT58mjYT4n3RPTEfCZGLMbidoO8XEieXfpSYxu+PQJ1qpx4ypwH6N+uF9twBPIvRRgvkvW5HUTYWENig==",
"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": "9.0.0",
"debug": "^4.3.4",
"proxy-agent-negotiate": "1.1.0"
"agent-base": "^7.1.0",
"debug": "^4.3.4"
},
"engines": {
"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": ">= 14"
}
},
"node_modules/http2-wrapper": {
@@ -7941,26 +7916,16 @@
}
},
"node_modules/https-proxy-agent": {
"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==",
"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": "9.0.0",
"debug": "^4.3.4",
"proxy-agent-negotiate": "1.1.0"
"agent-base": "^7.1.2",
"debug": "4"
},
"engines": {
"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": ">= 14"
}
},
"node_modules/human-signals": {
@@ -8300,32 +8265,6 @@
"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",
@@ -8524,6 +8463,7 @@
"integrity": "sha512-Yi1jqNC/Oq0N4hBgNH/YvBpP1P57QqundgytzYqy3yqAa7NZPNjSoi4SGbRAXDMdBzNE6xBCi5U7RgfrvMEUVQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@jest/core": "30.4.2",
"@jest/types": "30.4.1",
@@ -9264,32 +9204,6 @@
}
}
},
"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",
@@ -10859,20 +10773,6 @@
}
}
},
"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",
@@ -11788,6 +11688,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.17",
"picocolors": "^1.1.1",
@@ -12471,23 +12372,6 @@
"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",
@@ -13879,6 +13763,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -14066,6 +13951,7 @@
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@cspotcode/source-map-support": "^0.8.0",
"@tsconfig/node10": "^1.0.7",
@@ -14229,6 +14115,7 @@
"integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -14982,6 +14869,7 @@
"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,8 +49,6 @@
"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",
@@ -60,7 +58,6 @@
"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",
@@ -0,0 +1,79 @@
import { Request, Response, RequestHandler } from "express";
import { catchAsync } from "@/utils/catchAsync.js";
import { IProxiesService } from "@/domain/proxies/proxy.service.js";
import { requireTeamId } from "./controllerUtils.js";
import {
createProxyBodyValidation,
editProxyBodyValidation,
getProxyByIdParamValidation,
editProxyParamValidation,
deleteProxyParamValidation,
} from "@/api/validation/index.js";
export interface IProxiesController {
createProxy: RequestHandler;
getProxyById: RequestHandler;
getProxiesByTeamId: RequestHandler;
editProxy: RequestHandler;
deleteProxy: RequestHandler;
}
class ProxyController implements IProxiesController {
constructor(private proxiesService: IProxiesService) {}
createProxy = catchAsync(async (req: Request, res: Response) => {
const validatedBody = createProxyBodyValidation.parse(req.body);
const teamId = requireTeamId(req.user?.teamId);
const proxy = await this.proxiesService.createProxy(validatedBody, teamId);
return res.status(200).json({
success: true,
msg: "Proxy created successfully",
data: proxy,
});
});
getProxyById = catchAsync(async (req: Request, res: Response) => {
const teamId = requireTeamId(req.user?.teamId);
const { id: proxyId } = getProxyByIdParamValidation.parse(req.params);
const proxy = await this.proxiesService.getProxy(proxyId, teamId);
return res.status(200).json({
success: true,
msg: "Proxy retrieved successfully",
data: proxy,
});
});
getProxiesByTeamId = catchAsync(async (req: Request, res: Response) => {
const teamId = requireTeamId(req.user?.teamId);
const proxies = await this.proxiesService.getProxiesByTeamId(teamId);
return res.status(200).json({
success: true,
msg: "Proxies retrieved successfully",
data: proxies,
});
});
editProxy = catchAsync(async (req: Request, res: Response) => {
const teamId = requireTeamId(req.user?.teamId);
const { id: proxyId } = editProxyParamValidation.parse(req.params);
const validatedBody = editProxyBodyValidation.parse(req.body);
const updatedProxy = await this.proxiesService.updateProxy(proxyId, teamId, validatedBody);
return res.status(200).json({
success: true,
msg: "Proxy updated successfully",
data: updatedProxy,
});
});
deleteProxy = catchAsync(async (req: Request, res: Response) => {
const teamId = requireTeamId(req.user?.teamId);
const { id: proxyId } = deleteProxyParamValidation.parse(req.params);
await this.proxiesService.deleteProxy(proxyId, teamId);
return res.status(200).json({
success: true,
msg: "Proxy deleted successfully",
});
});
}
export default ProxyController;
+12
View File
@@ -0,0 +1,12 @@
import { IProxiesController } from "@/api/controllers/proxyController.js";
import { Router } from "express";
export const createProxyRoutes = (proxyController: IProxiesController): Router => {
const router = Router();
router.post("/", proxyController.createProxy);
router.get("/team", proxyController.getProxiesByTeamId);
router.get("/:id", proxyController.getProxyById);
router.delete("/:id", proxyController.deleteProxy);
router.patch("/:id", proxyController.editProxy);
return router;
};
+1
View File
@@ -22,3 +22,4 @@ export * from "./notificationValidation.js";
export * from "./userValidation.js";
export * from "./incidentValidation.js";
export * from "./tagValidation.js";
export * from "./proxyValidation.js";
+25 -3
View File
@@ -10,6 +10,7 @@ import {
MonitorStatuses,
MonitorTypes,
PageSpeedStrategies,
ProxyModes,
} from "@/domain/monitors/monitor.type.js";
import { DateRanges, SortOrders } from "@/types/query.js";
@@ -100,6 +101,16 @@ const refineHeadMatching = (body: { method?: string; useAdvancedMatching?: boole
}
};
const refineProxySelection = (body: { proxyMode?: string; proxyId?: string }, ctx: z.RefinementCtx) => {
if (body.proxyMode === "custom" && !body.proxyId) {
ctx.addIssue({
code: "custom",
path: ["proxyId"],
message: "A proxy must be selected when proxy mode is custom",
});
}
};
export const createMonitorBodyValidation = z
.object({
_id: z.string().optional(),
@@ -110,6 +121,8 @@ export const createMonitorBodyValidation = z
statusWindowThreshold: z.number().min(1).max(100).default(60),
url: z.string().min(1, "URL is required"),
ignoreTlsErrors: z.boolean().default(false),
proxyMode: z.enum(ProxyModes).default("inherit"),
proxyId: z.string().optional(),
useAdvancedMatching: z.boolean().default(false),
port: z.number().optional(),
isActive: z.boolean().optional(),
@@ -140,7 +153,8 @@ export const createMonitorBodyValidation = z
.superRefine(refineDnsHostname)
.superRefine(refineStrategyType)
.superRefine(refineHeadMatching)
.superRefine(refineRegexPattern);
.superRefine(refineRegexPattern)
.superRefine(refineProxySelection);
export const editMonitorBodyValidation = z
.object({
@@ -156,6 +170,8 @@ export const editMonitorBodyValidation = z
customUpCodes: z.array(httpStatusCode).optional(),
secret: z.string().optional(),
ignoreTlsErrors: z.boolean().optional(),
proxyMode: z.enum(ProxyModes).optional(),
proxyId: z.string().optional(),
useAdvancedMatching: z.boolean().optional(),
jsonPath: z.union([z.string(), z.literal("")]).optional(),
expectedValue: z.union([z.string(), z.literal("")]).optional(),
@@ -180,7 +196,8 @@ export const editMonitorBodyValidation = z
.superRefine(refineDnsHostname)
.superRefine(refineStrategyType)
.superRefine(refineHeadMatching)
.superRefine(refineRegexPattern);
.superRefine(refineRegexPattern)
.superRefine(refineProxySelection);
export const pauseMonitorParamValidation = z.object({
monitorId: z.string().min(1, "Monitor ID is required"),
@@ -215,6 +232,8 @@ const importedMonitorSchema = z
statusWindowThreshold: z.number().min(1).max(100).default(60),
type: z.enum(MonitorTypes, "Invalid monitor type"),
ignoreTlsErrors: z.boolean().default(false),
proxyMode: z.enum(ProxyModes).default("inherit"),
proxyId: z.string().optional(),
useAdvancedMatching: z.boolean().default(false),
jsonPath: z.union([z.string(), z.literal("")]).optional(),
expectedValue: z.union([z.string(), z.literal("")]).optional(),
@@ -253,7 +272,8 @@ const importedMonitorSchema = z
.superRefine(refineDnsHostname)
.superRefine(refineStrategyType)
.superRefine(refineHeadMatching)
.superRefine(refineRegexPattern);
.superRefine(refineRegexPattern)
.superRefine(refineProxySelection);
export const importMonitorsBodyValidation = z.object({
monitors: z.array(importedMonitorSchema).min(1, "At least one monitor is required"),
@@ -285,6 +305,8 @@ export const monitorResponseSchema = z
statusWindowSize: z.number(),
statusWindowThreshold: z.number(),
ignoreTlsErrors: z.boolean(),
proxyMode: z.enum(ProxyModes),
proxyId: z.string().optional(),
useAdvancedMatching: z.boolean(),
jsonPath: z.string().optional(),
expectedValue: z.string().optional(),
@@ -0,0 +1,45 @@
import { ProxyProtocols } from "@/domain/proxies/proxy.type.js";
import { z } from "zod";
//****************************************
// Proxy Validations
//****************************************
export const createProxyBodyValidation = z.object({
name: z.string().min(1, "Proxy name is required"),
protocol: z.enum(ProxyProtocols, "Invalid proxy protocol"),
host: z.string().min(1, "Proxy host is required"),
port: z.number().int().min(1, "Proxy port is required").max(65535, "Proxy port must be between 1 and 65535"),
username: z.string().optional(),
password: z.string().optional(),
});
export const editProxyBodyValidation = createProxyBodyValidation.extend({
clearPassword: z.boolean().optional(),
clearUsername: z.boolean().optional(),
});
export const getProxyByIdParamValidation = z.object({
id: z.string().min(1, "Proxy ID is required"),
});
export const editProxyParamValidation = z.object({
id: z.string().min(1, "Proxy ID is required"),
});
export const deleteProxyParamValidation = z.object({
id: z.string().min(1, "Proxy ID is required"),
});
export const proxyResponseSchema = z
.object({
id: z.string(),
teamId: z.string(),
name: z.string(),
protocol: z.enum(ProxyProtocols),
host: z.string(),
port: z.number(),
username: z.string().optional(),
hasPassword: z.boolean(),
createdAt: z.string(),
updatedAt: z.string(),
})
.passthrough();
@@ -40,5 +40,16 @@ export const updateAppSettingsBodyValidation = z
temperature: z.number().min(1).max(150).optional(),
})
.optional(),
globalProxyEnabled: z.boolean().optional(),
globalProxyId: z.string().nullable().optional(),
})
.strip();
.strip()
.superRefine((body, ctx) => {
if (body.globalProxyEnabled === true && !body.globalProxyId) {
ctx.addIssue({
code: "custom",
path: ["globalProxyId"],
message: "A proxy must be selected to enable the global proxy",
});
}
});
+3
View File
@@ -12,6 +12,7 @@ import NotificationController from "../api/controllers/notificationController.js
import TagsController from "../api/controllers/tagController.js";
import DiagnosticController from "../api/controllers/diagnosticController.js";
import IncidentController from "../api/controllers/incidentController.js";
import ProxyController from "@/api/controllers/proxyController.js";
import { ApiServices } from "@/config/services.api.js";
export interface InitializedControllers {
@@ -29,6 +30,7 @@ export interface InitializedControllers {
tagController: TagsController;
diagnosticController: DiagnosticController;
incidentController: IncidentController;
proxyController: ProxyController;
}
export const initializeControllers = (apiServices: ApiServices): InitializedControllers => {
return {
@@ -46,5 +48,6 @@ export const initializeControllers = (apiServices: ApiServices): InitializedCont
tagController: new TagsController(apiServices.tagsService),
diagnosticController: new DiagnosticController(apiServices.diagnosticService),
incidentController: new IncidentController(apiServices.incidentService),
proxyController: new ProxyController(apiServices.proxiesService),
};
};
-11
View File
@@ -34,17 +34,6 @@ 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),
});
+3
View File
@@ -19,6 +19,7 @@ import { createDiagnosticRoutes } from "@/api/routes/diagnosticRoutes.js";
import { createNotificationRoutes } from "@/api/routes/notificationRoutes.js";
import { createTagRoutes } from "@/api/routes/tagRoutes.js";
import { createIncidentRoutes } from "@/api/routes/incidentRoutes.js";
import { createProxyRoutes } from "@/api/routes/proxyRoutes.js";
export const setupRoutes = (app: Application, controllers: InitializedControllers, apiServices: ApiServices) => {
const verifyJWT = createVerifyJWT(apiServices.settingsService);
@@ -37,6 +38,7 @@ export const setupRoutes = (app: Application, controllers: InitializedController
const tagRoutes = createTagRoutes(controllers.tagController);
const diagnosticRoutes = createDiagnosticRoutes(controllers.diagnosticController, verifyJWT);
const incidentRoutes = createIncidentRoutes(controllers.incidentController);
const proxyRoutes = createProxyRoutes(controllers.proxyController);
app.use("/api/v1/auth", authApiLimiter, authRoutes);
app.use("/api/v1/monitors", verifyJWT, monitorRoutes);
@@ -52,4 +54,5 @@ export const setupRoutes = (app: Application, controllers: InitializedController
app.use("/api/v1/tags", verifyJWT, tagRoutes);
app.use("/api/v1/diagnostic", verifyJWT, diagnosticRoutes);
app.use("/api/v1/incidents", verifyJWT, incidentRoutes);
app.use("/api/v1/proxies", verifyJWT, proxyRoutes);
};
+6 -3
View File
@@ -6,14 +6,14 @@ import { IStatusPageService, StatusPageService } from "@/domain/status-pages/sta
import { ITagsService, TagsService } from "@/domain/tags/tag.service.js";
import { IUserService, UserService } from "@/domain/users/user.service.js";
import { IJobScheduler } from "@/worker/worker.interface.js";
import { ProxiesService, IProxiesService } from "@/domain/proxies/proxy.service.js";
import { SharedServices } from "@/config/services.shared.js";
// Third-party
import jwt from "jsonwebtoken";
import crypto from "crypto";
import { games } from "gamedig";
import { SharedServices } from "@/config/services.shared.js";
export interface ApiServices extends SharedServices {
worker: IJobScheduler; // control-plane handle only (DBQueueWorker in all-in-one, bare JobScheduler in API-only)
userService: IUserService;
@@ -23,6 +23,7 @@ export interface ApiServices extends SharedServices {
statusPageService: IStatusPageService;
tagsService: ITagsService;
diagnosticService: IDiagnosticService;
proxiesService: IProxiesService;
}
export const buildApi = (shared: SharedServices, jobScheduler: IJobScheduler): ApiServices => {
@@ -45,6 +46,7 @@ export const buildApi = (shared: SharedServices, jobScheduler: IJobScheduler): A
teamsRepository,
maintenanceWindowsRepository,
jobsRepository,
proxiesRepository,
} = shared;
const userService = new UserService({
@@ -94,7 +96,7 @@ export const buildApi = (shared: SharedServices, jobScheduler: IJobScheduler): A
const statusPageService = new StatusPageService(statusPagesRepository, settingsService, monitorsRepository, checksRepository);
const tagsService = new TagsService(tagsRepository, monitorsRepository);
const diagnosticService = new DiagnosticService(db);
const proxiesService = new ProxiesService(proxiesRepository, monitorsRepository, settingsService);
return {
...shared,
worker: jobScheduler,
@@ -105,5 +107,6 @@ export const buildApi = (shared: SharedServices, jobScheduler: IJobScheduler): A
statusPageService,
tagsService,
diagnosticService,
proxiesService,
};
};
+5
View File
@@ -52,6 +52,7 @@ import { IStatusPagesRepository } from "@/domain/status-pages/status-page-reposi
import { ITagsRepository } from "@/domain/tags/tag.repository.interface.js";
import { ITeamsRepository } from "@/domain/teams/team.repository.interface.js";
import { IUsersRepository } from "@/domain/users/user.repository.interface.js";
import { IProxiesRepository } from "@/domain/proxies/proxy.repository.interface.js";
// Mongo repository implementations
import MongoSettingsRepository from "@/domain/app-settings/app-settings.repository.mongo.js";
@@ -70,6 +71,7 @@ import MongoStatusPagesRepository from "@/domain/status-pages/status-page-reposi
import MongoTagsRepository from "@/domain/tags/tag.repository.mongo.js";
import MongoTeamsRepository from "@/domain/teams/team.repository.model.js";
import MongoUsersRepository from "@/domain/users/user.repository.mongo.js";
import MongoProxiesRepository from "@/domain/proxies/proxy.repository.mongo.js";
// Shared infrastructure + business services that both the API and the worker process construct.
export interface SharedServices {
@@ -103,6 +105,7 @@ export interface SharedServices {
incidentsRepository: IIncidentsRepository;
teamsRepository: ITeamsRepository;
maintenanceWindowsRepository: IMaintenanceWindowsRepository;
proxiesRepository: IProxiesRepository;
}
export const buildShared = async ({
@@ -139,6 +142,7 @@ export const buildShared = async ({
const tagsRepository = new MongoTagsRepository();
const teamsRepository = new MongoTeamsRepository();
const maintenanceWindowsRepository = new MongoMaintenanceWindowsRepository();
const proxiesRepository = new MongoProxiesRepository();
// Inject settings repository into settings service (now that DB is connected)
(settingsService as SettingsService).setRepository(settingsRepository);
@@ -221,6 +225,7 @@ export const buildShared = async ({
incidentsRepository,
teamsRepository,
maintenanceWindowsRepository,
proxiesRepository,
};
return sharedServices;
};
+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), envSettings.proxyMonitorTypes, logger);
const httpProvider = new HttpProvider(got, new AdvancedMatcher(jmespath));
const pageSpeedProvider = new PageSpeedProvider(httpProvider, settingsService, logger);
const hardwareProvider = new HardwareProvider(httpProvider);
const dockerProvider = new DockerProvider(logger, Docker);
@@ -0,0 +1,27 @@
import { MonitorModel } from "../../domain/monitors/monitor.model.js";
import type { ILogger } from "@/utils/logger.js";
/**
* Backfill proxyMode on monitors created before the field existed.
*
* Mongoose defaults only apply to new documents, and monitorResponseSchema
* requires proxyMode on every monitor read.
*/
export async function backfillMonitorProxyMode(logger: ILogger): Promise<void> {
const SERVICE_NAME = "Migration:BackfillMonitorProxyMode";
try {
logger.info({ service: SERVICE_NAME, message: "Starting proxyMode backfill" });
const result = await MonitorModel.updateMany({ proxyMode: { $exists: false } }, { $set: { proxyMode: "inherit" } });
logger.info({
service: SERVICE_NAME,
message: `Backfilled proxyMode on ${result.modifiedCount} monitors`,
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error({ service: SERVICE_NAME, message: `Error backfilling proxyMode: ${errorMessage}` });
throw error;
}
}
+2
View File
@@ -9,6 +9,7 @@ import { migrateMaintenanceWindowMonitorIdToArray } from "./0007_migrateMaintena
import { backfillMonitorLastEvaluatedAt } from "./0008_backfillMonitorLastEvaluatedAt.js";
import { recomputeResponseTimeFromTimings } from "./0009_recomputeResponseTimeFromTimings.js";
import { slimRecentChecks } from "./0010_slimRecentChecks.js";
import { backfillMonitorProxyMode } from "./0011_backfillMonitorProxyMode.js";
import type { ILogger } from "@/utils/logger.js";
type MigrationEntry = {
@@ -27,6 +28,7 @@ const migrations: MigrationEntry[] = [
{ name: "0008_backfillMonitorLastEvaluatedAt", execute: backfillMonitorLastEvaluatedAt },
{ name: "0009_recomputeResponseTimeFromTimings", execute: recomputeResponseTimeFromTimings },
{ name: "0010_slimRecentChecks", execute: slimRecentChecks },
{ name: "0011_backfillMonitorProxyMode", execute: backfillMonitorProxyMode },
];
const runMigrations = async (logger: ILogger) => {
@@ -40,6 +40,8 @@ const AppSettingsSchema = new Schema<AppSettingsDocument>(
singleton: { type: Boolean, required: true, unique: true, default: true },
version: { type: Number, default: 1 },
globalThresholds: { type: thresholdsSchema },
globalProxyEnabled: { type: Boolean, default: false },
globalProxyId: { type: String, default: null },
},
{ timestamps: true }
);
@@ -28,6 +28,8 @@ class MongoSettingsRepository implements ISettingsRepository {
singleton: doc.singleton,
version: doc.version ?? 1,
globalThresholds: doc.globalThresholds ?? undefined,
globalProxyEnabled: doc.globalProxyEnabled ?? false,
globalProxyId: doc.globalProxyId ?? undefined,
createdAt: toDateString(doc.createdAt),
updatedAt: toDateString(doc.updatedAt),
};
@@ -16,8 +16,6 @@ export type EnvConfig = {
queueMode: QueueMode;
queuePrimaryProcesses: boolean;
statusPageThemesEnabled: boolean;
/** Comma-separated monitor types routed through the outbound proxy, if set. */
proxyMonitorTypes?: string;
clientConfig: ClientRuntimeConfig;
};
@@ -45,7 +43,6 @@ 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 }),
@@ -49,6 +49,8 @@ export interface Settings {
singleton: boolean;
version: number;
globalThresholds?: SettingsThresholds;
globalProxyEnabled: boolean;
globalProxyId?: string | null;
createdAt: string;
updatedAt: string;
}
+13 -2
View File
@@ -1,6 +1,6 @@
import { Schema, model, Types } from "mongoose";
import type { Monitor, MonitorMatchMethod, CheckSnapshot } from "@/domain/monitors/monitor.type.js";
import { DnsRecordTypes, MonitorTypes, MonitorStatuses, PageSpeedStrategies, HttpMethods } from "@/domain/monitors/monitor.type.js";
import { DnsRecordTypes, MonitorTypes, MonitorStatuses, PageSpeedStrategies, HttpMethods, ProxyModes } from "@/domain/monitors/monitor.type.js";
import type {
CheckAudits,
ILighthouseAudit,
@@ -14,7 +14,7 @@ type CheckSnapshotDocument = Omit<CheckSnapshot, "createdAt"> & { createdAt: Dat
type MonitorDocumentBase = Omit<
Monitor,
"id" | "userId" | "teamId" | "notifications" | "tags" | "selectedDisks" | "statusWindow" | "recentChecks" | "createdAt" | "updatedAt"
"id" | "userId" | "teamId" | "notifications" | "tags" | "selectedDisks" | "statusWindow" | "recentChecks" | "proxyId" | "createdAt" | "updatedAt"
> & {
statusWindow: boolean[];
recentChecks: CheckSnapshotDocument[];
@@ -22,6 +22,7 @@ type MonitorDocumentBase = Omit<
tags: Types.ObjectId[];
selectedDisks: string[];
matchMethod?: MonitorMatchMethod;
proxyId?: Types.ObjectId;
};
interface MonitorDocument extends MonitorDocumentBase {
@@ -170,6 +171,16 @@ const MonitorSchema = new Schema<MonitorDocument>(
type: Boolean,
default: false,
},
proxyMode: {
type: String,
enum: ProxyModes,
default: "inherit",
},
proxyId: {
type: Schema.Types.ObjectId,
ref: "Proxy",
},
useAdvancedMatching: {
type: Boolean,
default: false,
@@ -53,6 +53,7 @@ export interface IMonitorsRepository {
// counts
findMonitorCountByTeamIdAndType(teamId: string, config: TeamQueryConfig): Promise<number>;
findMonitorCountByProxyId(proxyId: string): Promise<number>;
// other
findMonitorsSummaryByTeamId(teamId: string, config?: SummaryConfig): Promise<MonitorsSummary>;
@@ -172,6 +172,11 @@ class MongoMonitorsRepository implements IMonitorsRepository {
return count;
};
findMonitorCountByProxyId = async (proxyId: string): Promise<number> => {
const count = await MonitorModel.countDocuments({ proxyId });
return count;
};
updateById = async (monitorId: string, teamId: string, patch: Partial<Monitor>) => {
const updatedMonitor = await MonitorModel.findOneAndUpdate(
{ _id: monitorId, teamId },
@@ -418,6 +423,8 @@ class MongoMonitorsRepository implements IMonitorsRepository {
statusWindowThreshold: doc.statusWindowThreshold,
type: doc.type,
ignoreTlsErrors: doc.ignoreTlsErrors,
proxyMode: doc.proxyMode ?? "inherit",
proxyId: doc.proxyId ? toStringId(doc.proxyId) : undefined,
useAdvancedMatching: doc.useAdvancedMatching ?? false,
jsonPath: doc.jsonPath ?? undefined,
expectedValue: doc.expectedValue ?? undefined,
@@ -41,6 +41,9 @@ export const DefaultPageSpeedStrategy: PageSpeedStrategy = "desktop";
export const GeoCheckSupportedTypes: readonly MonitorType[] = ["http", "ping"] as const;
export const supportsGeoCheck = (type: MonitorType): boolean => GeoCheckSupportedTypes.includes(type);
export const ProxyModes = ["inherit", "none", "custom"] as const;
export type ProxyMode = (typeof ProxyModes)[number];
export const UptimeDetailsSupportedTypes = [
"http",
"ping",
@@ -81,6 +84,8 @@ export interface Monitor {
statusWindowThreshold: number;
type: MonitorType;
ignoreTlsErrors: boolean;
proxyMode: ProxyMode;
proxyId?: string;
useAdvancedMatching: boolean;
jsonPath?: string;
expectedValue?: string;
+38
View File
@@ -0,0 +1,38 @@
import { Schema, model, type Types } from "mongoose";
import type { Proxy as ProxyEntity } from "@/domain/proxies/proxy.type.js";
import { ProxyProtocols } from "@/domain/proxies/proxy.type.js";
type ProxyDocumentBase = Omit<ProxyEntity, "id" | "teamId" | "createdAt" | "updatedAt">;
interface ProxyDocument extends ProxyDocumentBase {
_id: Types.ObjectId;
teamId: Types.ObjectId;
createdAt: Date;
updatedAt: Date;
}
const ProxySchema = new Schema<ProxyDocument>(
{
teamId: {
type: Schema.Types.ObjectId,
ref: "Team",
immutable: true,
required: true,
},
name: { type: String, required: true },
protocol: { type: String, enum: ProxyProtocols, required: true },
host: { type: String, required: true },
port: { type: Number, required: true },
username: { type: String, required: false },
password: { type: String, required: false },
},
{ timestamps: true }
);
ProxySchema.index({ teamId: 1, name: 1 }, { unique: true });
const ProxyModel = model<ProxyDocument>("Proxy", ProxySchema);
export type { ProxyDocument };
export { ProxyModel };
export default ProxyModel;
@@ -0,0 +1,13 @@
import { Proxy } from "@/domain/proxies/proxy.type.js";
export interface IProxiesRepository {
// create
create(proxyData: Partial<Proxy>): Promise<Proxy>;
// read
findById(proxyId: string, teamId: string): Promise<Proxy>;
findByTeamId(teamId: string): Promise<Proxy[]>;
// update
updateById(proxyId: string, teamId: string, patch: Partial<Proxy>, options?: { unsetPassword?: boolean; unsetUsername?: boolean }): Promise<Proxy>;
// delete
deleteById(proxyId: string, teamId: string): Promise<Proxy>;
}
@@ -0,0 +1,95 @@
import { IProxiesRepository } from "@/domain/proxies/proxy.repository.interface.js";
import { Proxy } from "@/domain/proxies/proxy.type.js";
import { ProxyDocument, ProxyModel } from "@/domain/proxies/proxy.model.js";
import { AppError } from "@/utils/AppError.js";
import { toStringId, toDateString } from "@/utils/mongoMappers.js";
import { UpdateQuery } from "mongoose";
const SERVICE_NAME = "ProxiesRepository";
class MongoProxiesRepository implements IProxiesRepository {
static SERVICE_NAME = SERVICE_NAME;
private toEntity = (doc: ProxyDocument): Proxy => {
return {
id: toStringId(doc._id),
teamId: toStringId(doc.teamId),
name: doc.name,
protocol: doc.protocol,
host: doc.host,
port: doc.port,
username: doc.username,
password: doc.password,
createdAt: toDateString(doc.createdAt),
updatedAt: toDateString(doc.updatedAt),
};
};
async create(proxyData: Partial<Proxy>): Promise<Proxy> {
try {
const proxy = await ProxyModel.create({ ...proxyData });
return this.toEntity(proxy);
} catch (error) {
if (error && typeof error === "object" && (error as { code?: number }).code === 11000) {
throw new AppError({ message: `A proxy named "${proxyData.name}" already exists`, service: SERVICE_NAME, status: 409 });
}
throw error;
}
}
async findById(proxyId: string, teamId: string): Promise<Proxy> {
const proxy = await ProxyModel.findOne({ _id: proxyId, teamId });
if (!proxy) {
throw new AppError({ message: "Proxy not found", service: SERVICE_NAME, status: 404 });
}
return this.toEntity(proxy);
}
async findByTeamId(teamId: string): Promise<Proxy[]> {
const proxies = await ProxyModel.find({ teamId });
return proxies.map(this.toEntity);
}
async updateById(
proxyId: string,
teamId: string,
patch: Partial<Proxy>,
options?: { unsetPassword?: boolean; unsetUsername?: boolean }
): Promise<Proxy> {
try {
const update: UpdateQuery<ProxyDocument> = {
$set: {
...patch,
},
};
if (options?.unsetPassword) {
delete update.$set?.password;
update.$unset = { ...update.$unset, password: 1 };
}
if (options?.unsetUsername) {
delete update.$set?.username;
update.$unset = { ...update.$unset, username: 1 };
}
const updatedProxy = await ProxyModel.findOneAndUpdate({ _id: proxyId, teamId }, update, { new: true, runValidators: true });
if (!updatedProxy) {
throw new AppError({ message: "Proxy not found or could not be updated", service: SERVICE_NAME, status: 404 });
}
return this.toEntity(updatedProxy);
} catch (error) {
if (error && typeof error === "object" && (error as { code?: number }).code === 11000) {
throw new AppError({ message: `A proxy named "${patch.name}" already exists`, service: SERVICE_NAME, status: 409 });
}
throw error;
}
}
async deleteById(proxyId: string, teamId: string): Promise<Proxy> {
const deletedProxy = await ProxyModel.findOneAndDelete({ _id: proxyId, teamId });
if (!deletedProxy) {
throw new AppError({ message: "Proxy not found or could not be deleted", service: SERVICE_NAME, status: 404 });
}
return this.toEntity(deletedProxy);
}
}
export default MongoProxiesRepository;
@@ -0,0 +1,92 @@
import { Proxy, ProxyResponse } from "@/domain/proxies/proxy.type.js";
import { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interface.js";
import { IProxiesRepository } from "@/domain/proxies/proxy.repository.interface.js";
import { AppError } from "@/utils/AppError.js";
import { ISettingsService } from "@/domain/app-settings/app-settings.service.js";
export interface IProxiesService {
createProxy(proxy: Partial<Proxy>, teamId: string): Promise<ProxyResponse>;
getProxy(proxyId: string, teamId: string): Promise<ProxyResponse>;
getProxiesByTeamId(teamId: string): Promise<ProxyResponse[]>;
updateProxy(proxyId: string, teamId: string, patch: Partial<Proxy> & { clearPassword?: boolean; clearUsername?: boolean }): Promise<ProxyResponse>;
deleteProxy(proxyId: string, teamId: string): Promise<ProxyResponse>;
}
const SERVICE_NAME = "ProxiesService";
export class ProxiesService implements IProxiesService {
constructor(
private proxiesRepository: IProxiesRepository,
private monitorsRepository: IMonitorsRepository,
private settingsService: ISettingsService
) {}
private toResponse = ({ password, ...rest }: Proxy): ProxyResponse => ({ ...rest, hasPassword: Boolean(password) });
createProxy = async (proxyData: Partial<Proxy>, teamId: string): Promise<ProxyResponse> => {
proxyData.teamId = teamId;
if (!proxyData.password) {
delete proxyData.password;
}
if (!proxyData.username) {
delete proxyData.username;
}
const raw = await this.proxiesRepository.create(proxyData);
return this.toResponse(raw);
};
getProxy = async (proxyId: string, teamId: string): Promise<ProxyResponse> => {
const raw = await this.proxiesRepository.findById(proxyId, teamId);
return this.toResponse(raw);
};
getProxiesByTeamId = async (teamId: string): Promise<ProxyResponse[]> => {
const raw = await this.proxiesRepository.findByTeamId(teamId);
const clean = raw.map((r) => {
return this.toResponse(r);
});
return clean;
};
updateProxy = async (
proxyId: string,
teamId: string,
patch: Partial<Proxy> & { clearPassword?: boolean; clearUsername?: boolean }
): Promise<ProxyResponse> => {
// Empty/absent password and username don't delete, clearPassword / clearUsername does
const { clearPassword, clearUsername, ...proxyPatch } = patch;
if (!proxyPatch.password) {
delete proxyPatch.password;
}
if (!proxyPatch.username) {
delete proxyPatch.username;
}
const raw = await this.proxiesRepository.updateById(proxyId, teamId, proxyPatch, {
unsetPassword: clearPassword === true,
unsetUsername: clearUsername === true,
});
return this.toResponse(raw);
};
deleteProxy = async (proxyId: string, teamId: string): Promise<ProxyResponse> => {
// Make sure proxy is not in use
const monitorsUsingProxy = await this.monitorsRepository.findMonitorCountByProxyId(proxyId);
if (monitorsUsingProxy > 0) {
throw new AppError({
message: `Proxy still in use by ${monitorsUsingProxy} monitor${monitorsUsingProxy > 1 ? "s" : ""}`,
service: SERVICE_NAME,
status: 409,
});
}
// Make sure this is not set as a global proxy
const settings = await this.settingsService.getDBSettings();
if (settings.globalProxyId === proxyId) {
throw new AppError({
message: "Proxy is set as the global proxy in settings",
service: SERVICE_NAME,
status: 409,
});
}
const raw = await this.proxiesRepository.deleteById(proxyId, teamId);
return this.toResponse(raw);
};
}
+17
View File
@@ -0,0 +1,17 @@
export const ProxyProtocols = ["http", "https"] as const;
export type ProxyProtocol = (typeof ProxyProtocols)[number];
export interface Proxy {
id: string;
teamId: string;
name: string;
protocol: ProxyProtocol;
host: string;
port: number;
username?: string;
password?: string;
createdAt: string;
updatedAt: string;
}
export type ProxyResponse = Omit<Proxy, "password"> & { hasPassword: boolean };
+5 -61
View File
@@ -9,7 +9,6 @@ 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";
@@ -19,15 +18,9 @@ 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,
proxyMonitorTypes?: string,
logger?: { warn: (payload: { message: string; service: string; method: string }) => void }
private advancedMatcher: IAdvancedMatcher
) {
const cacheable = new CacheableLookup({ maxTtl: 300, errorTtl: 30 });
this.got = got.extend({
@@ -42,12 +35,6 @@ 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) {
@@ -179,37 +166,11 @@ export class HttpProvider implements IStatusProvider<HttpStatusPayload> {
const options: Record<string, unknown> = {
headers: monitor.secret ? { Authorization: `Bearer ${secret}` } : undefined,
};
let cleanupProxyListener: (() => void) | undefined;
// 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.agent = {
http: this.httpAgent,
https: ignoreTlsErrors ? this.httpsAgentInsecure : this.httpsAgent,
};
options.method = monitor.method;
@@ -227,24 +188,7 @@ 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
@@ -1,162 +0,0 @@
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
@@ -1,8 +0,0 @@
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, proxyMonitorTypes?: string) => {
const createProvider = (matcher?: any) => {
const advancedMatcher = matcher ?? createMockMatcher();
const provider = new HttpProvider(mockGot as any, advancedMatcher, proxyMonitorTypes);
const provider = new HttpProvider(mockGot as any, advancedMatcher);
return { provider, advancedMatcher };
};
@@ -494,128 +494,4 @@ 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();
});
});
});
@@ -1,218 +0,0 @@
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");
});
});
@@ -0,0 +1,208 @@
import { describe, expect, it, jest } from "@jest/globals";
import { ProxiesService } from "../../../src/domain/proxies/proxy.service.ts";
import type { Proxy } from "../../../src/domain/proxies/proxy.type.ts";
// ── Helpers ──────────────────────────────────────────────────────────────────
const createProxiesRepo = () => ({
create: jest.fn(),
findById: jest.fn(),
findByTeamId: jest.fn(),
updateById: jest.fn(),
deleteById: jest.fn(),
});
const createMonitorsRepo = () => ({
findMonitorCountByProxyId: jest.fn(),
});
const createSettingsService = () => ({
getDBSettings: jest.fn(),
});
const createService = () => {
const proxiesRepository = createProxiesRepo();
const monitorsRepository = createMonitorsRepo();
const settingsService = createSettingsService();
(monitorsRepository.findMonitorCountByProxyId as jest.Mock).mockResolvedValue(0);
(settingsService.getDBSettings as jest.Mock).mockResolvedValue({ globalProxyId: null });
const service = new ProxiesService(proxiesRepository as any, monitorsRepository as any, settingsService as any);
return { service, proxiesRepository, monitorsRepository, settingsService };
};
const makeProxy = (overrides?: Partial<Proxy>): Proxy =>
({
id: "proxy-1",
teamId: "team-1",
name: "Egress proxy",
protocol: "http",
host: "proxy.internal",
port: 3128,
username: "user",
password: "secret",
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
...overrides,
}) as Proxy;
// ── Tests ────────────────────────────────────────────────────────────────────
describe("ProxiesService", () => {
describe("createProxy", () => {
it("sets teamId and delegates to repository", async () => {
const { service, proxiesRepository } = createService();
(proxiesRepository.create as jest.Mock).mockResolvedValue(makeProxy());
await service.createProxy({ name: "Egress proxy", protocol: "http", host: "proxy.internal", port: 3128 }, "team-1");
expect(proxiesRepository.create).toHaveBeenCalledWith(expect.objectContaining({ teamId: "team-1", name: "Egress proxy" }));
});
it("omits password from the response and reports hasPassword", async () => {
const { service, proxiesRepository } = createService();
(proxiesRepository.create as jest.Mock).mockResolvedValue(makeProxy());
const result = await service.createProxy({ name: "Egress proxy" }, "team-1");
expect(result).not.toHaveProperty("password");
expect(result.hasPassword).toBe(true);
});
it("strips blank credentials before creating", async () => {
const { service, proxiesRepository } = createService();
(proxiesRepository.create as jest.Mock).mockResolvedValue(makeProxy({ username: undefined, password: undefined }));
await service.createProxy({ name: "Egress proxy", username: "", password: "" }, "team-1");
const created = (proxiesRepository.create as jest.Mock).mock.calls[0][0] as Partial<Proxy>;
expect(created).not.toHaveProperty("username");
expect(created).not.toHaveProperty("password");
});
});
describe("getProxy", () => {
it("delegates to repository and masks the password", async () => {
const { service, proxiesRepository } = createService();
(proxiesRepository.findById as jest.Mock).mockResolvedValue(makeProxy());
const result = await service.getProxy("proxy-1", "team-1");
expect(proxiesRepository.findById).toHaveBeenCalledWith("proxy-1", "team-1");
expect(result).not.toHaveProperty("password");
expect(result.hasPassword).toBe(true);
});
it("reports hasPassword false when none is stored", async () => {
const { service, proxiesRepository } = createService();
(proxiesRepository.findById as jest.Mock).mockResolvedValue(makeProxy({ password: undefined }));
const result = await service.getProxy("proxy-1", "team-1");
expect(result.hasPassword).toBe(false);
});
});
describe("getProxiesByTeamId", () => {
it("masks every proxy in the list", async () => {
const { service, proxiesRepository } = createService();
(proxiesRepository.findByTeamId as jest.Mock).mockResolvedValue([makeProxy(), makeProxy({ id: "proxy-2", password: undefined })]);
const result = await service.getProxiesByTeamId("team-1");
expect(proxiesRepository.findByTeamId).toHaveBeenCalledWith("team-1");
expect(result).toHaveLength(2);
for (const proxy of result) {
expect(proxy).not.toHaveProperty("password");
}
expect(result[0].hasPassword).toBe(true);
expect(result[1].hasPassword).toBe(false);
});
});
describe("updateProxy", () => {
it("keeps the stored password when the patch has a blank one", async () => {
const { service, proxiesRepository } = createService();
(proxiesRepository.updateById as jest.Mock).mockResolvedValue(makeProxy());
await service.updateProxy("proxy-1", "team-1", { name: "Renamed", password: "", username: "" });
const patch = (proxiesRepository.updateById as jest.Mock).mock.calls[0][2] as Partial<Proxy>;
expect(patch).not.toHaveProperty("password");
expect(patch).not.toHaveProperty("username");
expect(patch.name).toBe("Renamed");
});
it("passes a new password through", async () => {
const { service, proxiesRepository } = createService();
(proxiesRepository.updateById as jest.Mock).mockResolvedValue(makeProxy({ password: "next" }));
await service.updateProxy("proxy-1", "team-1", { password: "next" });
const patch = (proxiesRepository.updateById as jest.Mock).mock.calls[0][2] as Partial<Proxy>;
expect(patch.password).toBe("next");
});
it("maps clear flags to unset options and keeps them out of the patch", async () => {
const { service, proxiesRepository } = createService();
(proxiesRepository.updateById as jest.Mock).mockResolvedValue(makeProxy({ username: undefined, password: undefined }));
await service.updateProxy("proxy-1", "team-1", { name: "Renamed", clearPassword: true, clearUsername: true });
const [, , patch, options] = (proxiesRepository.updateById as jest.Mock).mock.calls[0];
expect(patch).not.toHaveProperty("clearPassword");
expect(patch).not.toHaveProperty("clearUsername");
expect(options).toEqual({ unsetPassword: true, unsetUsername: true });
});
it("does not unset when clear flags are absent", async () => {
const { service, proxiesRepository } = createService();
(proxiesRepository.updateById as jest.Mock).mockResolvedValue(makeProxy());
await service.updateProxy("proxy-1", "team-1", { name: "Renamed" });
const options = (proxiesRepository.updateById as jest.Mock).mock.calls[0][3];
expect(options).toEqual({ unsetPassword: false, unsetUsername: false });
});
it("masks the updated proxy in the response", async () => {
const { service, proxiesRepository } = createService();
(proxiesRepository.updateById as jest.Mock).mockResolvedValue(makeProxy());
const result = await service.updateProxy("proxy-1", "team-1", { name: "Renamed" });
expect(result).not.toHaveProperty("password");
expect(result.hasPassword).toBe(true);
});
});
describe("deleteProxy", () => {
it("deletes when unreferenced and masks the response", async () => {
const { service, proxiesRepository } = createService();
(proxiesRepository.deleteById as jest.Mock).mockResolvedValue(makeProxy());
const result = await service.deleteProxy("proxy-1", "team-1");
expect(proxiesRepository.deleteById).toHaveBeenCalledWith("proxy-1", "team-1");
expect(result).not.toHaveProperty("password");
});
it("rejects with 409 when monitors reference the proxy", async () => {
const { service, proxiesRepository, monitorsRepository } = createService();
(monitorsRepository.findMonitorCountByProxyId as jest.Mock).mockResolvedValue(3);
await expect(service.deleteProxy("proxy-1", "team-1")).rejects.toMatchObject({ status: 409 });
expect(proxiesRepository.deleteById).not.toHaveBeenCalled();
});
it("rejects with 409 when the proxy is the global proxy", async () => {
const { service, proxiesRepository, settingsService } = createService();
(settingsService.getDBSettings as jest.Mock).mockResolvedValue({ globalProxyId: "proxy-1" });
await expect(service.deleteProxy("proxy-1", "team-1")).rejects.toMatchObject({ status: 409 });
expect(proxiesRepository.deleteById).not.toHaveBeenCalled();
});
});
});
@@ -470,3 +470,72 @@ describe("monitorValidation — customUpCodes", () => {
});
});
});
describe("monitorValidation — proxy fields", () => {
const baseHttpBody = {
name: "HTTP check",
type: "http" as const,
url: "https://example.com",
};
describe("createMonitorBodyValidation", () => {
it("defaults proxyMode to inherit", () => {
const parsed = createMonitorBodyValidation.parse(baseHttpBody);
expect(parsed.proxyMode).toBe("inherit");
expect(parsed.proxyId).toBeUndefined();
});
it("accepts custom mode with a proxyId", () => {
const parsed = createMonitorBodyValidation.parse({ ...baseHttpBody, proxyMode: "custom", proxyId: "proxy-1" });
expect(parsed.proxyMode).toBe("custom");
expect(parsed.proxyId).toBe("proxy-1");
});
it("rejects custom mode without a proxyId", () => {
expect(() => createMonitorBodyValidation.parse({ ...baseHttpBody, proxyMode: "custom" })).toThrow();
});
it("rejects an unknown proxyMode", () => {
expect(() => createMonitorBodyValidation.parse({ ...baseHttpBody, proxyMode: "global" })).toThrow();
});
it("allows a stray proxyId on non-custom modes", () => {
for (const proxyMode of ["inherit", "none"] as const) {
const parsed = createMonitorBodyValidation.parse({ ...baseHttpBody, proxyMode, proxyId: "proxy-1" });
expect(parsed.proxyMode).toBe(proxyMode);
}
});
});
describe("editMonitorBodyValidation", () => {
it("leaves proxyMode unset when omitted (no default injected on PATCH)", () => {
const parsed = editMonitorBodyValidation.parse({ name: "Renamed" });
expect(parsed.proxyMode).toBeUndefined();
});
it("rejects custom mode without a proxyId", () => {
expect(() => editMonitorBodyValidation.parse({ proxyMode: "custom" })).toThrow();
});
it("accepts custom mode with a proxyId", () => {
const parsed = editMonitorBodyValidation.parse({ proxyMode: "custom", proxyId: "proxy-1" });
expect(parsed.proxyId).toBe("proxy-1");
});
});
describe("importMonitorsBodyValidation", () => {
it("defaults proxyMode to inherit on import", () => {
const parsed = importMonitorsBodyValidation.parse({ monitors: [baseHttpBody] });
expect(parsed.monitors[0].proxyMode).toBe("inherit");
});
it("rejects an imported monitor with custom mode and no proxyId", () => {
expect(() => importMonitorsBodyValidation.parse({ monitors: [{ ...baseHttpBody, proxyMode: "custom" }] })).toThrow();
});
});
});
@@ -0,0 +1,97 @@
import { describe, expect, it } from "@jest/globals";
import { createProxyBodyValidation, editProxyBodyValidation, proxyResponseSchema } from "../../../src/api/validation/proxyValidation.ts";
const validBody = {
name: "Egress proxy",
protocol: "http" as const,
host: "proxy.internal",
port: 3128,
};
describe("proxyValidation", () => {
describe("createProxyBodyValidation", () => {
it("accepts a minimal valid body", () => {
const parsed = createProxyBodyValidation.parse(validBody);
expect(parsed).toEqual(validBody);
});
it("accepts optional credentials", () => {
const parsed = createProxyBodyValidation.parse({ ...validBody, username: "user", password: "secret" });
expect(parsed.username).toBe("user");
expect(parsed.password).toBe("secret");
});
it("rejects an empty name", () => {
expect(() => createProxyBodyValidation.parse({ ...validBody, name: "" })).toThrow();
});
it("rejects an empty host", () => {
expect(() => createProxyBodyValidation.parse({ ...validBody, host: "" })).toThrow();
});
it("rejects a protocol outside the tuple", () => {
expect(() => createProxyBodyValidation.parse({ ...validBody, protocol: "socks5" })).toThrow();
});
it("accepts the port boundaries", () => {
expect(createProxyBodyValidation.parse({ ...validBody, port: 1 }).port).toBe(1);
expect(createProxyBodyValidation.parse({ ...validBody, port: 65535 }).port).toBe(65535);
});
it("rejects out-of-range and non-integer ports", () => {
for (const port of [0, -1, 65536, 3128.5, "3128"]) {
expect(() => createProxyBodyValidation.parse({ ...validBody, port })).toThrow();
}
});
});
describe("editProxyBodyValidation", () => {
it("accepts the create shape without clear flags", () => {
const parsed = editProxyBodyValidation.parse(validBody);
expect(parsed.clearPassword).toBeUndefined();
expect(parsed.clearUsername).toBeUndefined();
});
it("accepts clearPassword and clearUsername", () => {
const parsed = editProxyBodyValidation.parse({ ...validBody, clearPassword: true, clearUsername: true });
expect(parsed.clearPassword).toBe(true);
expect(parsed.clearUsername).toBe(true);
});
it("rejects non-boolean clear flags", () => {
expect(() => editProxyBodyValidation.parse({ ...validBody, clearPassword: "yes" })).toThrow();
});
});
describe("proxyResponseSchema", () => {
const validResponse = {
id: "proxy-1",
teamId: "team-1",
name: "Egress proxy",
protocol: "http",
host: "proxy.internal",
port: 3128,
hasPassword: false,
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
};
it("accepts a masked proxy", () => {
expect(() => proxyResponseSchema.parse(validResponse)).not.toThrow();
});
it("requires hasPassword", () => {
const { hasPassword: _hasPassword, ...withoutFlag } = validResponse;
expect(() => proxyResponseSchema.parse(withoutFlag)).toThrow();
});
it("does not declare a password field", () => {
expect(Object.keys(proxyResponseSchema.shape)).not.toContain("password");
});
});
});