@checkstack/healthcheck-http-backend 0.4.10 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +137 -0
- package/package.json +5 -5
- package/src/connect-probe.test.ts +43 -0
- package/src/connect-probe.ts +93 -0
- package/src/request-collector.test.ts +52 -2
- package/src/request-collector.ts +26 -5
- package/src/strategy.test.ts +241 -80
- package/src/strategy.ts +134 -12
- package/src/transport-client.ts +7 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,142 @@
|
|
|
1
1
|
# @checkstack/healthcheck-http-backend
|
|
2
2
|
|
|
3
|
+
## 0.5.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 8cad340: Fix collectors hard-failing on successful-but-non-OK application results.
|
|
8
|
+
|
|
9
|
+
A health-check collector must fail only when the TRANSPORT fails (the probe
|
|
10
|
+
could not complete: DNS/connect/TLS failure, timeout, aborted, unspawnable
|
|
11
|
+
process). A successfully-received result that is simply "not what you hoped" is
|
|
12
|
+
an assertable metric, not a collector failure - the user's assertions (or the
|
|
13
|
+
no-assertion default) decide health.
|
|
14
|
+
|
|
15
|
+
BREAKING CHANGE: checks that previously relied on a collector auto-failing on a
|
|
16
|
+
non-OK result will now report healthy unless an explicit assertion is added.
|
|
17
|
+
Affected collectors:
|
|
18
|
+
|
|
19
|
+
- HTTP request collector: a received response (including 4xx/5xx) is now a
|
|
20
|
+
successful collection. `statusCode` / `statusText` / `success` are exposed as
|
|
21
|
+
metrics; the collector no longer sets `error` on a non-2xx. Add a
|
|
22
|
+
`statusCode equals 200` assertion to fail on non-200 (or `statusCode equals
|
|
23
|
+
404` for a check that wants a 404). Only a real transport failure fails the
|
|
24
|
+
collector.
|
|
25
|
+
- gRPC health collector: a completed health RPC returning `NOT_SERVING` /
|
|
26
|
+
`SERVICE_UNKNOWN` / `UNKNOWN` is now a successful collection. `serving` /
|
|
27
|
+
`status` are assertable metrics; only a real RPC transport error fails the
|
|
28
|
+
collector.
|
|
29
|
+
- Jenkins node-health collector: offline nodes are now an assertable metric
|
|
30
|
+
(`offlineNodes`); a successful all-nodes API call no longer fails the
|
|
31
|
+
collector when some nodes are offline.
|
|
32
|
+
- Script (shell) execute collector: a non-zero exit code is now an assertable
|
|
33
|
+
metric (`exitCode` / `success`); the collector no longer hard-fails on a
|
|
34
|
+
non-zero exit. A timeout or a script that could not be spawned still fails the
|
|
35
|
+
collector (those are transport failures). Add a `success is true` (or
|
|
36
|
+
`exitCode equals 0`) assertion to fail on a non-zero exit.
|
|
37
|
+
|
|
38
|
+
Other strategies (DNS, TCP, TLS, ping, ssh, mysql, postgres, redis, rcon,
|
|
39
|
+
hardware, and the inline-script collector) were audited and already failed only
|
|
40
|
+
on genuine transport failures.
|
|
41
|
+
|
|
42
|
+
- 8cad340: feat(healthcheck-http): SSRF egress guard for the in-process HTTP collector
|
|
43
|
+
|
|
44
|
+
The HTTP healthcheck strategy runs in-process on the trusted core (whenever a
|
|
45
|
+
check is local or not satellite-only), so it now applies a secure-by-default
|
|
46
|
+
egress guard before connecting:
|
|
47
|
+
|
|
48
|
+
- Denies the cloud-metadata + link-local ranges by default (the same
|
|
49
|
+
`ALWAYS_BLOCKED_CIDRS` the script sandbox enforces), so a check can no longer
|
|
50
|
+
be pointed at `http://169.254.169.254/...` to read instance credentials.
|
|
51
|
+
- Keeps RFC1918 / internal probing ALLOWED by default (a monitoring tool's job).
|
|
52
|
+
- Resolves the target host to IP(s) and checks the CONNECTED IP, pinning the
|
|
53
|
+
request to the validated IP to resist DNS-rebind.
|
|
54
|
+
- Operator-extensible: the new optional `egressDenyCidrs` field on the HTTP
|
|
55
|
+
strategy config adds further CIDRs on top of the always-on block.
|
|
56
|
+
|
|
57
|
+
`@checkstack/backend-api` exports a reusable `resolveAndValidateHost` /
|
|
58
|
+
`pinUrlToIp` SSRF guard plus `DEFAULT_EGRESS_DENY_CIDRS`.
|
|
59
|
+
|
|
60
|
+
- 8cad340: Add a finer per-run transport timing breakdown to health checks.
|
|
61
|
+
|
|
62
|
+
Each run now records an optional structured `metadata.timings` (DNS, connect,
|
|
63
|
+
TLS, wait/time-to-first-byte, transfer, and a `processing` catch-all for
|
|
64
|
+
non-HTTP operation time). The run-detail view renders the phases it has, in
|
|
65
|
+
transport order, and falls back to the previous Connection + Processing split
|
|
66
|
+
for older runs that lack the finer data.
|
|
67
|
+
|
|
68
|
+
For HTTP the request is issued verbatim through `fetch` (original URL, headers,
|
|
69
|
+
and body), so request behavior is identical to a plain `fetch`. The timing is
|
|
70
|
+
measured around it: `fetch` resolves at the response headers, so wait
|
|
71
|
+
(time-to-first-byte) and transfer (body) are measured exactly on the request,
|
|
72
|
+
DNS is timed at the resolve step, and connect/TLS come from a short-lived,
|
|
73
|
+
best-effort raw `net`/`tls` probe to the same already-validated IP (the request
|
|
74
|
+
socket exposes no connect/handshake events on the Bun runtime). The probe is
|
|
75
|
+
timing-only and never fails the check. The probe validates the TLS certificate
|
|
76
|
+
(against the original hostname via SNI) like the real request does - it does not
|
|
77
|
+
disable certificate validation; an unverifiable cert simply yields no TLS-phase
|
|
78
|
+
timing rather than aborting. Other transports surface the connect and operation
|
|
79
|
+
times they already measure.
|
|
80
|
+
|
|
81
|
+
The SSRF guard now validates the resolved host (rejecting cloud-metadata /
|
|
82
|
+
link-local and operator-denied ranges) as a pre-flight check and no longer pins
|
|
83
|
+
the request to the resolved IP. Pinning rewrote the URL to the IP literal and
|
|
84
|
+
moved the host to the `Host` header, which breaks HTTP/2 origins (their
|
|
85
|
+
authority comes from the URL's `:authority`, not `Host`) - that is why real
|
|
86
|
+
hosts such as `google.com` started answering 404/429 instead of 200. The
|
|
87
|
+
pre-flight validation keeps blocking static metadata/link-local targets and
|
|
88
|
+
direct denied IP literals; the only thing dropped is DNS-rebind TOCTOU
|
|
89
|
+
protection (a narrow window that pinning closed at the cost of breaking
|
|
90
|
+
legitimate HTTP/2 requests).
|
|
91
|
+
|
|
92
|
+
The run-detail "slowest" badge no longer collides with the timing bar, and a
|
|
93
|
+
genuinely sub-millisecond phase reads as "<1 ms" instead of a bare "0 ms".
|
|
94
|
+
|
|
95
|
+
### Patch Changes
|
|
96
|
+
|
|
97
|
+
- 8cad340: Retune anomaly-detection defaults across every health-check strategy and the
|
|
98
|
+
hardware collector for a low-noise, problem-focused out-of-the-box experience.
|
|
99
|
+
|
|
100
|
+
The detection engine already learns a per-metric baseline, debounces with a
|
|
101
|
+
confirmation window, and applies practical-significance floors. This pass tunes
|
|
102
|
+
the per-metric **defaults** so a fresh install alerts only on genuine,
|
|
103
|
+
statistically-significant, problem-mapping deviations instead of flooding on
|
|
104
|
+
every metric that wiggles. 264 metrics were reviewed:
|
|
105
|
+
|
|
106
|
+
- **Default-disabled** the high-noise and un-baselineable classes that were
|
|
107
|
+
alerting for no good reason: raw identifiers and counts (status codes, error
|
|
108
|
+
and row counts, build counts, player and executor counts), config echoes and
|
|
109
|
+
near-constants (probe packet counts, CPU core count, total/swap memory),
|
|
110
|
+
payload-size and other run-to-run-volatile values, and deterministic values
|
|
111
|
+
like certificate days-remaining (governed by the check's own static-threshold
|
|
112
|
+
health logic, not statistics). These stay chartable and can be re-enabled per
|
|
113
|
+
field.
|
|
114
|
+
- **Hardened** the signals that should alert - latency/response/execution time
|
|
115
|
+
and availability/success/saturation percentages - with confirmation windows
|
|
116
|
+
and absolute + relative floors so brief spikes and sub-threshold jitter no
|
|
117
|
+
longer flap, and prefer percentage metrics over their absolute twins.
|
|
118
|
+
|
|
119
|
+
No detection-engine or schema changes; only per-metric `x-anomaly-*` defaults.
|
|
120
|
+
Users who had opted into any now-disabled metric keep their explicit override.
|
|
121
|
+
|
|
122
|
+
- Updated dependencies [8cad340]
|
|
123
|
+
- Updated dependencies [8cad340]
|
|
124
|
+
- Updated dependencies [8cad340]
|
|
125
|
+
- Updated dependencies [8cad340]
|
|
126
|
+
- Updated dependencies [8cad340]
|
|
127
|
+
- Updated dependencies [8cad340]
|
|
128
|
+
- Updated dependencies [8cad340]
|
|
129
|
+
- @checkstack/backend-api@0.25.0
|
|
130
|
+
- @checkstack/healthcheck-common@1.8.0
|
|
131
|
+
- @checkstack/common@0.17.0
|
|
132
|
+
|
|
133
|
+
## 0.4.11
|
|
134
|
+
|
|
135
|
+
### Patch Changes
|
|
136
|
+
|
|
137
|
+
- Updated dependencies [2ec8f64]
|
|
138
|
+
- @checkstack/backend-api@0.24.1
|
|
139
|
+
|
|
3
140
|
## 0.4.10
|
|
4
141
|
|
|
5
142
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@checkstack/healthcheck-http-backend",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"checkstack": {
|
|
@@ -14,16 +14,16 @@
|
|
|
14
14
|
"pack": "bunx @checkstack/scripts plugin-pack"
|
|
15
15
|
},
|
|
16
16
|
"dependencies": {
|
|
17
|
-
"@checkstack/backend-api": "0.
|
|
18
|
-
"@checkstack/healthcheck-common": "1.
|
|
17
|
+
"@checkstack/backend-api": "0.25.0",
|
|
18
|
+
"@checkstack/healthcheck-common": "1.8.0",
|
|
19
19
|
"jsonpath-plus": "^10.3.0",
|
|
20
|
-
"@checkstack/common": "0.
|
|
20
|
+
"@checkstack/common": "0.17.0"
|
|
21
21
|
},
|
|
22
22
|
"devDependencies": {
|
|
23
23
|
"@types/bun": "^1.0.0",
|
|
24
24
|
"typescript": "^5.0.0",
|
|
25
25
|
"@checkstack/tsconfig": "0.0.7",
|
|
26
|
-
"@checkstack/scripts": "0.6.
|
|
26
|
+
"@checkstack/scripts": "0.6.3"
|
|
27
27
|
},
|
|
28
28
|
"description": "Checkstack healthcheck-http-backend plugin",
|
|
29
29
|
"author": {
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
|
|
2
|
+
import net from "node:net";
|
|
3
|
+
import type { AddressInfo } from "node:net";
|
|
4
|
+
import { probeConnectTiming } from "./connect-probe";
|
|
5
|
+
|
|
6
|
+
describe("probeConnectTiming", () => {
|
|
7
|
+
let server: net.Server;
|
|
8
|
+
let port = 0;
|
|
9
|
+
|
|
10
|
+
beforeAll(async () => {
|
|
11
|
+
server = net.createServer((socket) => socket.end());
|
|
12
|
+
await new Promise<void>((resolve) =>
|
|
13
|
+
server.listen(0, "127.0.0.1", resolve),
|
|
14
|
+
);
|
|
15
|
+
port = (server.address() as AddressInfo).port;
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
afterAll(() => server.close());
|
|
19
|
+
|
|
20
|
+
it("measures TCP connect time to a listening port", async () => {
|
|
21
|
+
const result = await probeConnectTiming({
|
|
22
|
+
ip: "127.0.0.1",
|
|
23
|
+
port,
|
|
24
|
+
tls: false,
|
|
25
|
+
timeoutMs: 2000,
|
|
26
|
+
});
|
|
27
|
+
expect(result.connectMs).toBeGreaterThanOrEqual(0);
|
|
28
|
+
expect(result.tlsMs).toBeUndefined();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("never throws on a refused connection (timing-only, best-effort)", async () => {
|
|
32
|
+
// Port the listening server is NOT on; connection is refused.
|
|
33
|
+
const result = await probeConnectTiming({
|
|
34
|
+
ip: "127.0.0.1",
|
|
35
|
+
port: port + 1,
|
|
36
|
+
tls: false,
|
|
37
|
+
timeoutMs: 1000,
|
|
38
|
+
});
|
|
39
|
+
// Resolves to an empty result rather than rejecting - it must never fail
|
|
40
|
+
// the health check.
|
|
41
|
+
expect(result.connectMs).toBeUndefined();
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import net from "node:net";
|
|
2
|
+
import tls from "node:tls";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Best-effort TCP/TLS connect timing for the HTTP timing breakdown.
|
|
6
|
+
*
|
|
7
|
+
* Why a separate probe: the actual request goes through `fetch`, whose internal
|
|
8
|
+
* socket does NOT expose connect/handshake events on the Bun runtime (Bun never
|
|
9
|
+
* emits `connect`/`secureConnect` on `node:http` sockets). Raw `node:net` /
|
|
10
|
+
* `node:tls` sockets DO emit them on Bun, so we open one short-lived connection
|
|
11
|
+
* to the SAME already-validated IP purely to measure how long TCP connect and
|
|
12
|
+
* the TLS handshake take. It is representative (same host/IP/network, run
|
|
13
|
+
* alongside the request), not the request's own socket.
|
|
14
|
+
*
|
|
15
|
+
* This is timing-only and MUST NEVER fail the check: any error / timeout
|
|
16
|
+
* resolves to whatever was measured so far (possibly nothing). The socket is
|
|
17
|
+
* destroyed as soon as the handshake completes - no application bytes are sent.
|
|
18
|
+
*/
|
|
19
|
+
export interface ConnectProbeResult {
|
|
20
|
+
/** TCP connect duration in ms (from probe start to the `connect` event). */
|
|
21
|
+
connectMs?: number;
|
|
22
|
+
/** TLS handshake duration in ms (`connect` -> `secureConnect`); https only. */
|
|
23
|
+
tlsMs?: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ConnectProbeOptions {
|
|
27
|
+
/** The pre-validated IP literal to connect to (no DNS, no SSRF re-resolve). */
|
|
28
|
+
ip: string;
|
|
29
|
+
port: number;
|
|
30
|
+
/** Whether to perform a TLS handshake (https targets). */
|
|
31
|
+
tls: boolean;
|
|
32
|
+
/** SNI server name for the TLS handshake (the original hostname). */
|
|
33
|
+
servername?: string;
|
|
34
|
+
/** Abort the probe after this many ms. */
|
|
35
|
+
timeoutMs: number;
|
|
36
|
+
/** Monotonic clock; injectable for tests. Defaults to `performance.now`. */
|
|
37
|
+
now?: () => number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function probeConnectTiming({
|
|
41
|
+
ip,
|
|
42
|
+
port,
|
|
43
|
+
tls: useTls,
|
|
44
|
+
servername,
|
|
45
|
+
timeoutMs,
|
|
46
|
+
now = () => performance.now(),
|
|
47
|
+
}: ConnectProbeOptions): Promise<ConnectProbeResult> {
|
|
48
|
+
return new Promise<ConnectProbeResult>((resolve) => {
|
|
49
|
+
const result: ConnectProbeResult = {};
|
|
50
|
+
const start = now();
|
|
51
|
+
let settled = false;
|
|
52
|
+
|
|
53
|
+
const finish = (socket: net.Socket): void => {
|
|
54
|
+
if (settled) return;
|
|
55
|
+
settled = true;
|
|
56
|
+
socket.destroy();
|
|
57
|
+
resolve(result);
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
if (useTls) {
|
|
61
|
+
// Validate the certificate (the default): it is checked against
|
|
62
|
+
// `servername` (the original hostname), not the IP we dialed, so a
|
|
63
|
+
// legitimately valid cert still verifies. We deliberately do NOT disable
|
|
64
|
+
// validation - the real `fetch` validates strictly too, so a bad cert
|
|
65
|
+
// already fails the check; matching that here is consistent and avoids the
|
|
66
|
+
// insecure `rejectUnauthorized: false`. If the handshake can't complete
|
|
67
|
+
// (invalid/self-signed cert), the `error` handler below resolves with just
|
|
68
|
+
// the TCP `connectMs` and no `tlsMs` - timing is best-effort, never fatal.
|
|
69
|
+
const socket = tls.connect({
|
|
70
|
+
host: ip,
|
|
71
|
+
port,
|
|
72
|
+
...(servername === undefined ? {} : { servername }),
|
|
73
|
+
});
|
|
74
|
+
socket.once("connect", () => {
|
|
75
|
+
result.connectMs = Math.max(0, now() - start);
|
|
76
|
+
});
|
|
77
|
+
socket.once("secureConnect", () => {
|
|
78
|
+
const handshakeDone = now() - start;
|
|
79
|
+
result.tlsMs = Math.max(0, handshakeDone - (result.connectMs ?? 0));
|
|
80
|
+
finish(socket);
|
|
81
|
+
});
|
|
82
|
+
socket.once("error", () => finish(socket));
|
|
83
|
+
socket.setTimeout(timeoutMs, () => finish(socket));
|
|
84
|
+
} else {
|
|
85
|
+
const socket = net.connect({ host: ip, port }, () => {
|
|
86
|
+
result.connectMs = Math.max(0, now() - start);
|
|
87
|
+
finish(socket);
|
|
88
|
+
});
|
|
89
|
+
socket.once("error", () => finish(socket));
|
|
90
|
+
socket.setTimeout(timeoutMs, () => finish(socket));
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it, mock } from "bun:test";
|
|
2
|
+
import { evaluateAssertions } from "@checkstack/backend-api";
|
|
2
3
|
import { RequestCollector, type RequestConfig } from "./request-collector";
|
|
3
4
|
import type { HttpTransportClient } from "./transport-client";
|
|
4
5
|
|
|
@@ -43,7 +44,13 @@ describe("RequestCollector", () => {
|
|
|
43
44
|
expect(result.error).toBeUndefined();
|
|
44
45
|
});
|
|
45
46
|
|
|
46
|
-
|
|
47
|
+
// Regression: a received-but-non-2xx response (e.g. 500) is a SUCCESSFUL
|
|
48
|
+
// collection - the server was reached and answered. The collector must NOT
|
|
49
|
+
// set the `error` field (which the executor treats as a transport failure
|
|
50
|
+
// and uses to hard-fail the run). It records the status code as a metric
|
|
51
|
+
// and lets assertions decide health. Previously this asserted the wrong
|
|
52
|
+
// behavior (`error` contains "500"); updated to the corrected semantics.
|
|
53
|
+
it("does not hard-fail the collector on a 5xx response", async () => {
|
|
47
54
|
const collector = new RequestCollector();
|
|
48
55
|
const client = createMockClient({
|
|
49
56
|
statusCode: 500,
|
|
@@ -61,8 +68,51 @@ describe("RequestCollector", () => {
|
|
|
61
68
|
});
|
|
62
69
|
|
|
63
70
|
expect(result.result.statusCode).toBe(500);
|
|
71
|
+
// `success` stays a metric (2xx/3xx => true); 500 => false.
|
|
64
72
|
expect(result.result.success).toBe(false);
|
|
65
|
-
|
|
73
|
+
// The collector reports transport success: no error field is set.
|
|
74
|
+
expect(result.error).toBeUndefined();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// Regression: a 404 is a fully successful HTTP transaction. The collector
|
|
78
|
+
// must report transport success (no `error`) and expose the status code as
|
|
79
|
+
// a metric so a "statusCode equals 404" assertion can pass (GREEN), and so
|
|
80
|
+
// a check with no assertions is not auto-failed by the collector.
|
|
81
|
+
it("treats a 404 as a successful collection with the status as a metric", async () => {
|
|
82
|
+
const collector = new RequestCollector();
|
|
83
|
+
const client = createMockClient({
|
|
84
|
+
statusCode: 404,
|
|
85
|
+
statusText: "Not Found",
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const result = await collector.execute({
|
|
89
|
+
config: {
|
|
90
|
+
url: "https://example.com/missing",
|
|
91
|
+
method: "GET",
|
|
92
|
+
timeout: 5000,
|
|
93
|
+
},
|
|
94
|
+
client,
|
|
95
|
+
pluginId: "test",
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
expect(result.result.statusCode).toBe(404);
|
|
99
|
+
expect(result.error).toBeUndefined();
|
|
100
|
+
|
|
101
|
+
// A "status code equals 404" assertion passes against the result, so a
|
|
102
|
+
// check that WANTS a 404 can be green.
|
|
103
|
+
const failed = evaluateAssertions(
|
|
104
|
+
[{ field: "statusCode", operator: "equals", value: 404 }],
|
|
105
|
+
result.result as Record<string, unknown>,
|
|
106
|
+
);
|
|
107
|
+
expect(failed).toBeNull();
|
|
108
|
+
|
|
109
|
+
// A "status code equals 200" assertion fails - the user, not the
|
|
110
|
+
// collector, decides this is unhealthy.
|
|
111
|
+
const failed200 = evaluateAssertions(
|
|
112
|
+
[{ field: "statusCode", operator: "equals", value: 200 }],
|
|
113
|
+
result.result as Record<string, unknown>,
|
|
114
|
+
);
|
|
115
|
+
expect(failed200).not.toBeNull();
|
|
66
116
|
});
|
|
67
117
|
|
|
68
118
|
it("should convert headers array to record", async () => {
|
package/src/request-collector.ts
CHANGED
|
@@ -81,8 +81,12 @@ const requestResultSchema = healthResultSchema({
|
|
|
81
81
|
statusCode: healthResultNumber({
|
|
82
82
|
"x-chart-type": "counter",
|
|
83
83
|
"x-chart-label": "Status Code",
|
|
84
|
-
|
|
85
|
-
|
|
84
|
+
// Off by default: the raw status code is an identifier, not a quantity
|
|
85
|
+
// with a meaningful baseline. Legitimate shifts (200 -> 301/302 redirects,
|
|
86
|
+
// content negotiation) are not problems, while real availability loss is
|
|
87
|
+
// already captured by the `success` boolean. Charting stays available for
|
|
88
|
+
// opt-in.
|
|
89
|
+
"x-anomaly-enabled": false,
|
|
86
90
|
}),
|
|
87
91
|
statusText: healthResultString({
|
|
88
92
|
"x-chart-type": "text",
|
|
@@ -125,6 +129,13 @@ const requestAggregatedFields = {
|
|
|
125
129
|
"x-chart-unit": "ms",
|
|
126
130
|
"x-anomaly-enabled": true,
|
|
127
131
|
"x-anomaly-direction": "lower-is-better",
|
|
132
|
+
// Latency: bias toward fewer false positives. Require several consecutive
|
|
133
|
+
// anomalous buckets, ignore small absolute jitter (tens of ms) so fast
|
|
134
|
+
// endpoints stay quiet, and require a meaningful relative jump.
|
|
135
|
+
"x-anomaly-sensitivity": 2,
|
|
136
|
+
"x-anomaly-confirmation-window": 3,
|
|
137
|
+
"x-anomaly-min-absolute-delta": 50,
|
|
138
|
+
"x-anomaly-min-relative-delta": 0.5,
|
|
128
139
|
}),
|
|
129
140
|
successRate: aggregatedRate({
|
|
130
141
|
"x-chart-type": "gauge",
|
|
@@ -132,6 +143,11 @@ const requestAggregatedFields = {
|
|
|
132
143
|
"x-chart-unit": "%",
|
|
133
144
|
"x-anomaly-enabled": true,
|
|
134
145
|
"x-anomaly-direction": "higher-is-better",
|
|
146
|
+
// Availability percent: the canonical saturation/failure signal. Debounce
|
|
147
|
+
// transient single-bucket dips and ignore sub-percent noise so only a real,
|
|
148
|
+
// sustained drop alerts.
|
|
149
|
+
"x-anomaly-confirmation-window": 3,
|
|
150
|
+
"x-anomaly-min-absolute-delta": 2,
|
|
135
151
|
}),
|
|
136
152
|
};
|
|
137
153
|
|
|
@@ -214,6 +230,14 @@ export class RequestCollector implements CollectorStrategy<
|
|
|
214
230
|
});
|
|
215
231
|
|
|
216
232
|
const responseTimeMs = Date.now() - startTime;
|
|
233
|
+
// `success` is an ASSERTABLE METRIC (2xx/3xx), not a collector-failure
|
|
234
|
+
// signal. Receiving ANY response - including a 4xx/5xx - is a successful
|
|
235
|
+
// collection: the server was reached and answered. A real transport failure
|
|
236
|
+
// (DNS, connect, TLS, timeout, aborted) throws out of `client.exec` above
|
|
237
|
+
// and the executor records it as a collector failure. We must NOT set the
|
|
238
|
+
// `error` field here for a received-but-non-2xx response, otherwise the
|
|
239
|
+
// executor hard-fails the run and assertions (e.g. "statusCode equals 404")
|
|
240
|
+
// never get a chance to decide health.
|
|
217
241
|
const success = response.statusCode >= 200 && response.statusCode < 400;
|
|
218
242
|
|
|
219
243
|
return {
|
|
@@ -225,9 +249,6 @@ export class RequestCollector implements CollectorStrategy<
|
|
|
225
249
|
bodyLength: response.body?.length ?? 0,
|
|
226
250
|
success,
|
|
227
251
|
},
|
|
228
|
-
error: success
|
|
229
|
-
? undefined
|
|
230
|
-
: `HTTP ${response.statusCode}: ${response.statusText}`,
|
|
231
252
|
};
|
|
232
253
|
}
|
|
233
254
|
|
package/src/strategy.test.ts
CHANGED
|
@@ -1,11 +1,83 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
afterAll,
|
|
3
|
+
beforeAll,
|
|
4
|
+
describe,
|
|
5
|
+
expect,
|
|
6
|
+
it,
|
|
7
|
+
} from "bun:test";
|
|
8
|
+
import * as http from "node:http";
|
|
9
|
+
import { AddressInfo } from "node:net";
|
|
2
10
|
import { HttpHealthCheckStrategy } from "./strategy";
|
|
3
11
|
|
|
4
12
|
describe("HttpHealthCheckStrategy", () => {
|
|
5
|
-
|
|
13
|
+
// Inject a deterministic DNS resolver so the in-process SSRF guard does not
|
|
14
|
+
// depend on real network DNS in unit tests. By default every host resolves
|
|
15
|
+
// to a public IP (allowed); specific tests override with their own resolver.
|
|
16
|
+
const publicLookup = async () => [
|
|
17
|
+
{ address: "93.184.216.34", family: 4 },
|
|
18
|
+
];
|
|
19
|
+
const strategy = new HttpHealthCheckStrategy(publicLookup);
|
|
20
|
+
|
|
21
|
+
// A real local server backs the `client.exec` behaviour tests. The request is
|
|
22
|
+
// issued verbatim (no IP pinning), so the URL targets loopback directly; the
|
|
23
|
+
// SSRF guard validates the `127.0.0.1` literal (an allowed range) before the
|
|
24
|
+
// request goes out.
|
|
25
|
+
let server: http.Server;
|
|
26
|
+
let serverPort = 0;
|
|
27
|
+
const loopbackLookup = async () => [{ address: "127.0.0.1", family: 4 }];
|
|
28
|
+
const localStrategy = new HttpHealthCheckStrategy(loopbackLookup);
|
|
29
|
+
const localUrl = (path: string) => `http://127.0.0.1:${serverPort}${path}`;
|
|
30
|
+
|
|
31
|
+
beforeAll(async () => {
|
|
32
|
+
server = http.createServer((req, res) => {
|
|
33
|
+
const url = req.url ?? "/";
|
|
34
|
+
if (url === "/notfound") {
|
|
35
|
+
res.writeHead(404, { "content-type": "text/plain" });
|
|
36
|
+
res.end("missing");
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (url === "/echo") {
|
|
40
|
+
const chunks: Buffer[] = [];
|
|
41
|
+
req.on("data", (c: Buffer) => chunks.push(c));
|
|
42
|
+
req.on("end", () => {
|
|
43
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
44
|
+
res.end(
|
|
45
|
+
JSON.stringify({
|
|
46
|
+
method: req.method,
|
|
47
|
+
body: Buffer.concat(chunks).toString("utf8"),
|
|
48
|
+
auth: req.headers["authorization"] ?? null,
|
|
49
|
+
custom: req.headers["x-custom-header"] ?? null,
|
|
50
|
+
host: req.headers["host"] ?? null,
|
|
51
|
+
}),
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (url === "/text") {
|
|
57
|
+
res.writeHead(200, { "content-type": "text/plain" });
|
|
58
|
+
res.end("Hello World");
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
if (url === "/slow") {
|
|
62
|
+
// Delay the response so the server's processing time must land in the
|
|
63
|
+
// `waitMs` (time-to-first-byte) phase, not vanish.
|
|
64
|
+
setTimeout(() => {
|
|
65
|
+
res.writeHead(200, { "content-type": "text/plain" });
|
|
66
|
+
res.end("slow");
|
|
67
|
+
}, 300);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
71
|
+
res.end(JSON.stringify({ status: "ok" }));
|
|
72
|
+
});
|
|
73
|
+
await new Promise<void>((resolve) =>
|
|
74
|
+
server.listen(0, "127.0.0.1", resolve),
|
|
75
|
+
);
|
|
76
|
+
serverPort = (server.address() as AddressInfo).port;
|
|
77
|
+
});
|
|
6
78
|
|
|
7
|
-
|
|
8
|
-
|
|
79
|
+
afterAll(() => {
|
|
80
|
+
server.close();
|
|
9
81
|
});
|
|
10
82
|
|
|
11
83
|
describe("config migration (assume-v1-on-read)", () => {
|
|
@@ -57,17 +129,11 @@ describe("HttpHealthCheckStrategy", () => {
|
|
|
57
129
|
|
|
58
130
|
describe("client.exec", () => {
|
|
59
131
|
it("should return successful response for valid request", async () => {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
statusText: "OK",
|
|
64
|
-
headers: { "Content-Type": "application/json" },
|
|
65
|
-
}),
|
|
66
|
-
);
|
|
67
|
-
|
|
68
|
-
const connectedClient = await strategy.createClient({ timeout: 5000 });
|
|
132
|
+
const connectedClient = await localStrategy.createClient({
|
|
133
|
+
timeout: 5000,
|
|
134
|
+
});
|
|
69
135
|
const result = await connectedClient.client.exec({
|
|
70
|
-
url: "
|
|
136
|
+
url: localUrl("/api"),
|
|
71
137
|
method: "GET",
|
|
72
138
|
timeout: 5000,
|
|
73
139
|
});
|
|
@@ -75,40 +141,67 @@ describe("HttpHealthCheckStrategy", () => {
|
|
|
75
141
|
expect(result.statusCode).toBe(200);
|
|
76
142
|
expect(result.statusText).toBe("OK");
|
|
77
143
|
expect(result.contentType).toContain("application/json");
|
|
144
|
+
// The connected client surfaces the request's transport phase timings on
|
|
145
|
+
// its holder. wait + transfer come from the fetch (always present); dns
|
|
146
|
+
// is measured at the resolve step; connect/tls are best-effort.
|
|
147
|
+
const timings = connectedClient.timings;
|
|
148
|
+
if (!timings) throw new Error("expected the HTTP client to surface timings");
|
|
149
|
+
expect(timings.waitMs).toBeGreaterThanOrEqual(0);
|
|
150
|
+
expect(timings.transferMs).toBeGreaterThanOrEqual(0);
|
|
151
|
+
expect(timings.dnsMs).toBeGreaterThanOrEqual(0);
|
|
78
152
|
|
|
79
153
|
connectedClient.close();
|
|
80
154
|
});
|
|
81
155
|
|
|
82
|
-
it("
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
);
|
|
156
|
+
it("attributes a slow server response to the wait phase (not lost)", async () => {
|
|
157
|
+
const connectedClient = await localStrategy.createClient({
|
|
158
|
+
timeout: 5000,
|
|
159
|
+
});
|
|
160
|
+
await connectedClient.client.exec({
|
|
161
|
+
url: localUrl("/slow"),
|
|
162
|
+
method: "GET",
|
|
163
|
+
timeout: 5000,
|
|
164
|
+
});
|
|
86
165
|
|
|
87
|
-
const
|
|
166
|
+
const timings = connectedClient.timings;
|
|
167
|
+
if (!timings) throw new Error("expected the HTTP client to surface timings");
|
|
168
|
+
// The 300ms server delay must surface as wait time (the bug was the
|
|
169
|
+
// dominant phase vanishing, leaving only a sub-ms transfer).
|
|
170
|
+
expect(timings.waitMs).toBeGreaterThanOrEqual(250);
|
|
171
|
+
// The breakdown must roughly account for the whole request, not <1ms.
|
|
172
|
+
const total =
|
|
173
|
+
(timings.dnsMs ?? 0) +
|
|
174
|
+
(timings.connectMs ?? 0) +
|
|
175
|
+
(timings.tlsMs ?? 0) +
|
|
176
|
+
(timings.waitMs ?? 0) +
|
|
177
|
+
(timings.transferMs ?? 0);
|
|
178
|
+
expect(total).toBeGreaterThanOrEqual(250);
|
|
179
|
+
|
|
180
|
+
connectedClient.close();
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("should return 404 status for not found (received = success)", async () => {
|
|
184
|
+
const connectedClient = await localStrategy.createClient({
|
|
185
|
+
timeout: 5000,
|
|
186
|
+
});
|
|
88
187
|
const result = await connectedClient.client.exec({
|
|
89
|
-
url: "
|
|
188
|
+
url: localUrl("/notfound"),
|
|
90
189
|
method: "GET",
|
|
91
190
|
timeout: 5000,
|
|
92
191
|
});
|
|
93
192
|
|
|
94
193
|
expect(result.statusCode).toBe(404);
|
|
194
|
+
expect(result.body).toBe("missing");
|
|
95
195
|
|
|
96
196
|
connectedClient.close();
|
|
97
197
|
});
|
|
98
198
|
|
|
99
199
|
it("should send custom headers with request", async () => {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
capturedHeaders = options?.headers as Record<string, string>;
|
|
106
|
-
return new Response(null, { status: 200 });
|
|
107
|
-
}) as unknown as typeof fetch);
|
|
108
|
-
|
|
109
|
-
const connectedClient = await strategy.createClient({ timeout: 5000 });
|
|
110
|
-
await connectedClient.client.exec({
|
|
111
|
-
url: "https://example.com/api",
|
|
200
|
+
const connectedClient = await localStrategy.createClient({
|
|
201
|
+
timeout: 5000,
|
|
202
|
+
});
|
|
203
|
+
const result = await connectedClient.client.exec({
|
|
204
|
+
url: localUrl("/echo"),
|
|
112
205
|
method: "GET",
|
|
113
206
|
headers: {
|
|
114
207
|
Authorization: "Bearer my-token",
|
|
@@ -117,45 +210,37 @@ describe("HttpHealthCheckStrategy", () => {
|
|
|
117
210
|
timeout: 5000,
|
|
118
211
|
});
|
|
119
212
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
213
|
+
const parsed = JSON.parse(result.body) as {
|
|
214
|
+
auth: string | null;
|
|
215
|
+
custom: string | null;
|
|
216
|
+
};
|
|
217
|
+
expect(parsed.auth).toBe("Bearer my-token");
|
|
218
|
+
expect(parsed.custom).toBe("custom-value");
|
|
123
219
|
|
|
124
220
|
connectedClient.close();
|
|
125
221
|
});
|
|
126
222
|
|
|
127
223
|
it("should return JSON body as string", async () => {
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
status: 200,
|
|
132
|
-
headers: { "Content-Type": "application/json" },
|
|
133
|
-
}),
|
|
134
|
-
);
|
|
135
|
-
|
|
136
|
-
const connectedClient = await strategy.createClient({ timeout: 5000 });
|
|
224
|
+
const connectedClient = await localStrategy.createClient({
|
|
225
|
+
timeout: 5000,
|
|
226
|
+
});
|
|
137
227
|
const result = await connectedClient.client.exec({
|
|
138
|
-
url: "
|
|
228
|
+
url: localUrl("/api"),
|
|
139
229
|
method: "GET",
|
|
140
230
|
timeout: 5000,
|
|
141
231
|
});
|
|
142
232
|
|
|
143
|
-
expect(result.body).toBe(JSON.stringify(
|
|
233
|
+
expect(result.body).toBe(JSON.stringify({ status: "ok" }));
|
|
144
234
|
|
|
145
235
|
connectedClient.close();
|
|
146
236
|
});
|
|
147
237
|
|
|
148
238
|
it("should handle text body", async () => {
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
headers: { "Content-Type": "text/plain" },
|
|
153
|
-
}),
|
|
154
|
-
);
|
|
155
|
-
|
|
156
|
-
const connectedClient = await strategy.createClient({ timeout: 5000 });
|
|
239
|
+
const connectedClient = await localStrategy.createClient({
|
|
240
|
+
timeout: 5000,
|
|
241
|
+
});
|
|
157
242
|
const result = await connectedClient.client.exec({
|
|
158
|
-
url: "
|
|
243
|
+
url: localUrl("/text"),
|
|
159
244
|
method: "GET",
|
|
160
245
|
timeout: 5000,
|
|
161
246
|
});
|
|
@@ -166,47 +251,123 @@ describe("HttpHealthCheckStrategy", () => {
|
|
|
166
251
|
});
|
|
167
252
|
|
|
168
253
|
it("should send POST body", async () => {
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
capturedBody = options?.body as string;
|
|
175
|
-
return new Response(null, { status: 201 });
|
|
176
|
-
}) as unknown as typeof fetch);
|
|
177
|
-
|
|
178
|
-
const connectedClient = await strategy.createClient({ timeout: 5000 });
|
|
179
|
-
await connectedClient.client.exec({
|
|
180
|
-
url: "https://example.com/api",
|
|
254
|
+
const connectedClient = await localStrategy.createClient({
|
|
255
|
+
timeout: 5000,
|
|
256
|
+
});
|
|
257
|
+
const result = await connectedClient.client.exec({
|
|
258
|
+
url: localUrl("/echo"),
|
|
181
259
|
method: "POST",
|
|
182
260
|
body: JSON.stringify({ name: "test" }),
|
|
183
261
|
timeout: 5000,
|
|
184
262
|
});
|
|
185
263
|
|
|
186
|
-
|
|
264
|
+
const parsed = JSON.parse(result.body) as { body: string };
|
|
265
|
+
expect(parsed.body).toBe('{"name":"test"}');
|
|
187
266
|
|
|
188
267
|
connectedClient.close();
|
|
189
268
|
});
|
|
190
269
|
|
|
191
270
|
it("should use correct HTTP method", async () => {
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
})
|
|
271
|
+
const connectedClient = await localStrategy.createClient({
|
|
272
|
+
timeout: 5000,
|
|
273
|
+
});
|
|
274
|
+
const result = await connectedClient.client.exec({
|
|
275
|
+
url: localUrl("/echo"),
|
|
276
|
+
method: "DELETE",
|
|
277
|
+
timeout: 5000,
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
const parsed = JSON.parse(result.body) as { method: string };
|
|
281
|
+
expect(parsed.method).toBe("DELETE");
|
|
282
|
+
|
|
283
|
+
connectedClient.close();
|
|
284
|
+
});
|
|
200
285
|
|
|
286
|
+
it("sends the request's Host header to the server", async () => {
|
|
287
|
+
const connectedClient = await localStrategy.createClient({
|
|
288
|
+
timeout: 5000,
|
|
289
|
+
});
|
|
290
|
+
const result = await connectedClient.client.exec({
|
|
291
|
+
url: localUrl("/echo"),
|
|
292
|
+
method: "GET",
|
|
293
|
+
timeout: 5000,
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
const parsed = JSON.parse(result.body) as { host: string | null };
|
|
297
|
+
// The request is issued verbatim, so the server sees the URL's authority.
|
|
298
|
+
expect(parsed.host).toBe(`127.0.0.1:${serverPort}`);
|
|
299
|
+
|
|
300
|
+
connectedClient.close();
|
|
301
|
+
});
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
describe("SSRF guard (in-process egress)", () => {
|
|
305
|
+
it("refuses a request whose host resolves to the cloud-metadata IP", async () => {
|
|
306
|
+
const metadataStrategy = new HttpHealthCheckStrategy(async () => [
|
|
307
|
+
{ address: "169.254.169.254", family: 4 },
|
|
308
|
+
]);
|
|
309
|
+
|
|
310
|
+
const connectedClient = await metadataStrategy.createClient({
|
|
311
|
+
timeout: 5000,
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
// The guard rejects in resolveTarget, BEFORE any socket is opened.
|
|
315
|
+
await expect(
|
|
316
|
+
connectedClient.client.exec({
|
|
317
|
+
url: "http://metadata.internal/latest/meta-data/",
|
|
318
|
+
method: "GET",
|
|
319
|
+
timeout: 5000,
|
|
320
|
+
}),
|
|
321
|
+
).rejects.toThrow(/denied egress range/);
|
|
322
|
+
|
|
323
|
+
connectedClient.close();
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it("refuses a direct cloud-metadata IP literal", async () => {
|
|
201
327
|
const connectedClient = await strategy.createClient({ timeout: 5000 });
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
328
|
+
|
|
329
|
+
await expect(
|
|
330
|
+
connectedClient.client.exec({
|
|
331
|
+
url: "http://169.254.169.254/latest/meta-data/",
|
|
332
|
+
method: "GET",
|
|
333
|
+
timeout: 5000,
|
|
334
|
+
}),
|
|
335
|
+
).rejects.toThrow(/denied egress range/);
|
|
336
|
+
connectedClient.close();
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it("allows an internal RFC1918 host by default (monitoring stays allowed)", async () => {
|
|
340
|
+
// Resolve to loopback so the (allowed) request reaches the local server.
|
|
341
|
+
const internalStrategy = new HttpHealthCheckStrategy(loopbackLookup);
|
|
342
|
+
const connectedClient = await internalStrategy.createClient({
|
|
205
343
|
timeout: 5000,
|
|
206
344
|
});
|
|
207
345
|
|
|
208
|
-
|
|
346
|
+
const result = await connectedClient.client.exec({
|
|
347
|
+
url: localUrl("/healthz"),
|
|
348
|
+
method: "GET",
|
|
349
|
+
timeout: 5000,
|
|
350
|
+
});
|
|
351
|
+
expect(result.statusCode).toBe(200);
|
|
352
|
+
connectedClient.close();
|
|
353
|
+
});
|
|
209
354
|
|
|
355
|
+
it("honors an operator-extended denylist for an internal range", async () => {
|
|
356
|
+
const internalStrategy = new HttpHealthCheckStrategy(async () => [
|
|
357
|
+
{ address: "10.5.5.5", family: 4 },
|
|
358
|
+
]);
|
|
359
|
+
const connectedClient = await internalStrategy.createClient({
|
|
360
|
+
timeout: 5000,
|
|
361
|
+
egressDenyCidrs: ["10.0.0.0/8"],
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
await expect(
|
|
365
|
+
connectedClient.client.exec({
|
|
366
|
+
url: "http://internal.service.local/healthz",
|
|
367
|
+
method: "GET",
|
|
368
|
+
timeout: 5000,
|
|
369
|
+
}),
|
|
370
|
+
).rejects.toThrow(/denied egress range/);
|
|
210
371
|
connectedClient.close();
|
|
211
372
|
});
|
|
212
373
|
});
|
package/src/strategy.ts
CHANGED
|
@@ -8,13 +8,17 @@ import {
|
|
|
8
8
|
z,
|
|
9
9
|
type InferAggregatedResult,
|
|
10
10
|
type ConnectedClient,
|
|
11
|
+
type TransportTimings,
|
|
11
12
|
baseStrategyConfigSchema,
|
|
13
|
+
DEFAULT_EGRESS_DENY_CIDRS,
|
|
14
|
+
resolveAndValidateHost,
|
|
12
15
|
} from "@checkstack/backend-api";
|
|
13
16
|
import {
|
|
14
17
|
healthResultString,
|
|
15
18
|
healthResultSchema,
|
|
16
19
|
StrategyCategory,
|
|
17
20
|
} from "@checkstack/healthcheck-common";
|
|
21
|
+
import { probeConnectTiming } from "./connect-probe";
|
|
18
22
|
import type {
|
|
19
23
|
HttpTransportClient,
|
|
20
24
|
HttpRequest,
|
|
@@ -28,8 +32,25 @@ import type {
|
|
|
28
32
|
/**
|
|
29
33
|
* HTTP health check configuration schema.
|
|
30
34
|
* Global defaults only - action params moved to RequestCollector.
|
|
35
|
+
*
|
|
36
|
+
* `egressDenyCidrs` extends the SSRF guard's default denylist. The collector
|
|
37
|
+
* runs in-process on the trusted core, so it always refuses to connect to
|
|
38
|
+
* cloud-metadata + link-local ranges (the secure default); operators can add
|
|
39
|
+
* further CIDRs here (e.g. to block a sensitive internal range) WITHOUT losing
|
|
40
|
+
* the metadata/link-local block. Leaving it unset keeps the secure default.
|
|
41
|
+
* RFC1918 / internal probing stays allowed by default (a monitoring tool's job).
|
|
42
|
+
*
|
|
43
|
+
* Optional + additive: existing stored configs (which lack this field) remain
|
|
44
|
+
* valid, so no schema-version bump / migration is required.
|
|
31
45
|
*/
|
|
32
|
-
export const httpHealthCheckConfigSchema = baseStrategyConfigSchema.extend({
|
|
46
|
+
export const httpHealthCheckConfigSchema = baseStrategyConfigSchema.extend({
|
|
47
|
+
egressDenyCidrs: z
|
|
48
|
+
.array(z.string())
|
|
49
|
+
.optional()
|
|
50
|
+
.describe(
|
|
51
|
+
"Extra CIDR ranges to deny for outbound requests (added on top of the always-on cloud-metadata/link-local block).",
|
|
52
|
+
),
|
|
53
|
+
});
|
|
33
54
|
|
|
34
55
|
export type HttpHealthCheckConfig = z.infer<typeof httpHealthCheckConfigSchema>;
|
|
35
56
|
|
|
@@ -64,8 +85,12 @@ const httpAggregatedFields = {
|
|
|
64
85
|
errorCount: aggregatedCounter({
|
|
65
86
|
"x-chart-type": "counter",
|
|
66
87
|
"x-chart-label": "Errors",
|
|
67
|
-
|
|
68
|
-
|
|
88
|
+
// Off by default: a raw per-bucket error COUNT scales with how many runs
|
|
89
|
+
// land in the bucket, so it has no stable baseline and drifts with traffic
|
|
90
|
+
// volume. The percent form of the same signal (`successRate`) is the one
|
|
91
|
+
// that alerts; this absolute twin stays chart-only to avoid duplicate,
|
|
92
|
+
// noisy alerting.
|
|
93
|
+
"x-anomaly-enabled": false,
|
|
69
94
|
}),
|
|
70
95
|
};
|
|
71
96
|
|
|
@@ -86,6 +111,17 @@ export class HttpHealthCheckStrategy implements HealthCheckStrategy<
|
|
|
86
111
|
description = "HTTP endpoint health monitoring";
|
|
87
112
|
category = StrategyCategory.NETWORKING;
|
|
88
113
|
|
|
114
|
+
/**
|
|
115
|
+
* DNS resolver used by the SSRF guard. Defaults to the system resolver
|
|
116
|
+
* (`resolveAndValidateHost`'s built-in). Injectable so tests can drive the
|
|
117
|
+
* guard deterministically without real network DNS.
|
|
118
|
+
*/
|
|
119
|
+
constructor(
|
|
120
|
+
private readonly lookupFn?: (
|
|
121
|
+
hostname: string,
|
|
122
|
+
) => Promise<Array<{ address: string; family: number }>>,
|
|
123
|
+
) {}
|
|
124
|
+
|
|
89
125
|
config: Versioned<HttpHealthCheckConfig> = new Versioned({
|
|
90
126
|
version: 3, // v3 for createClient pattern with action params moved to RequestCollector
|
|
91
127
|
schema: httpHealthCheckConfigSchema,
|
|
@@ -174,33 +210,118 @@ export class HttpHealthCheckStrategy implements HealthCheckStrategy<
|
|
|
174
210
|
): Promise<ConnectedClient<HttpTransportClient>> {
|
|
175
211
|
const validatedConfig = this.config.validate(config);
|
|
176
212
|
|
|
213
|
+
// SSRF secure default: deny cloud-metadata + link-local always, plus any
|
|
214
|
+
// operator-configured extra ranges. This collector runs IN-PROCESS on the
|
|
215
|
+
// trusted core, so without this guard a healthcheck author could point a
|
|
216
|
+
// check at http://169.254.169.254/... and read the response body.
|
|
217
|
+
const denyCidrs = [
|
|
218
|
+
...DEFAULT_EGRESS_DENY_CIDRS,
|
|
219
|
+
...(validatedConfig.egressDenyCidrs ?? []),
|
|
220
|
+
];
|
|
221
|
+
const lookupFn = this.lookupFn;
|
|
222
|
+
|
|
223
|
+
// Mutable per-run timings holder. `exec` writes the request's transport
|
|
224
|
+
// phases here so the executor can lift them into `metadata.timings`.
|
|
225
|
+
const timings: TransportTimings = {};
|
|
226
|
+
|
|
177
227
|
const client: HttpTransportClient = {
|
|
178
228
|
async exec(request: HttpRequest): Promise<HttpResponse> {
|
|
179
229
|
const controller = new AbortController();
|
|
180
|
-
const
|
|
181
|
-
|
|
182
|
-
request.timeout ?? validatedConfig.timeout,
|
|
183
|
-
);
|
|
230
|
+
const timeoutMs = request.timeout ?? validatedConfig.timeout;
|
|
231
|
+
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
184
232
|
|
|
185
233
|
try {
|
|
234
|
+
// SSRF guard: resolve the host and reject any denied range (cloud
|
|
235
|
+
// metadata / link-local, plus operator extras) BEFORE issuing the
|
|
236
|
+
// request. We then `fetch` the ORIGINAL url verbatim - we do NOT pin
|
|
237
|
+
// the connection to the resolved IP. Pinning (rewriting the URL host
|
|
238
|
+
// to the IP + overriding the Host header + TLS SNI) breaks HTTP/2
|
|
239
|
+
// origins, whose authority comes from the URL's `:authority`
|
|
240
|
+
// pseudo-header, not the `Host` header - which is exactly what made
|
|
241
|
+
// real hosts like google.com answer 404/429 instead of 200. Issuing
|
|
242
|
+
// the original request keeps it byte-identical to a plain fetch.
|
|
243
|
+
//
|
|
244
|
+
// Trade-off: because `fetch` re-resolves DNS itself, this pre-flight
|
|
245
|
+
// validation has a DNS-rebind TOCTOU window (a host could resolve to
|
|
246
|
+
// an allowed IP here and a denied one at connect time). It still
|
|
247
|
+
// blocks the common cases - a host that statically resolves to a
|
|
248
|
+
// denied range, and direct denied IP literals. The resolved IP is
|
|
249
|
+
// reused only for the best-effort timing probe below.
|
|
250
|
+
const requestUrl = new URL(request.url);
|
|
251
|
+
const dnsStart = performance.now();
|
|
252
|
+
const target = await resolveAndValidateHost({
|
|
253
|
+
host: requestUrl.hostname,
|
|
254
|
+
denyCidrs,
|
|
255
|
+
...(lookupFn === undefined ? {} : { lookupFn }),
|
|
256
|
+
});
|
|
257
|
+
const dnsMs = Math.max(0, performance.now() - dnsStart);
|
|
258
|
+
const isHttps = requestUrl.protocol === "https:";
|
|
259
|
+
|
|
260
|
+
// Connect/TLS timing comes from a short-lived raw probe to the SAME
|
|
261
|
+
// validated IP: Bun's `fetch` socket exposes no connect/handshake
|
|
262
|
+
// events, but raw net/tls sockets do. Best-effort, runs alongside the
|
|
263
|
+
// request, and never fails the check.
|
|
264
|
+
const probePromise = probeConnectTiming({
|
|
265
|
+
ip: target.ip,
|
|
266
|
+
port:
|
|
267
|
+
requestUrl.port === ""
|
|
268
|
+
? isHttps
|
|
269
|
+
? 443
|
|
270
|
+
: 80
|
|
271
|
+
: Number(requestUrl.port),
|
|
272
|
+
tls: isHttps,
|
|
273
|
+
servername: requestUrl.hostname,
|
|
274
|
+
timeoutMs,
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
// `fetch` resolves at the response HEADERS (time-to-first-byte);
|
|
278
|
+
// consuming the body is the transfer phase.
|
|
279
|
+
const fetchStart = performance.now();
|
|
186
280
|
const response = await fetch(request.url, {
|
|
187
281
|
method: request.method,
|
|
188
282
|
headers: request.headers,
|
|
189
283
|
body: request.body,
|
|
190
284
|
signal: controller.signal,
|
|
191
285
|
});
|
|
286
|
+
const ttfbMs = Math.max(0, performance.now() - fetchStart);
|
|
192
287
|
|
|
193
|
-
// Read body BEFORE clearing timeout - body streaming can also hang
|
|
288
|
+
// Read body BEFORE clearing the timeout - body streaming can also hang.
|
|
289
|
+
const transferStart = performance.now();
|
|
194
290
|
const body = await response.text();
|
|
291
|
+
const transferMs = Math.max(0, performance.now() - transferStart);
|
|
195
292
|
|
|
196
293
|
clearTimeout(timeoutId);
|
|
197
|
-
const headers: Record<string, string> = {};
|
|
198
294
|
|
|
199
|
-
|
|
200
|
-
|
|
295
|
+
const probe = await probePromise;
|
|
296
|
+
// Server wait = time-to-first-byte minus the connection setup we could
|
|
297
|
+
// measure. Clamped: the probe is a parallel connection, so on a warm
|
|
298
|
+
// path its setup can exceed the request's ttfb.
|
|
299
|
+
const waitMs = Math.max(
|
|
300
|
+
0,
|
|
301
|
+
ttfbMs - ((probe.connectMs ?? 0) + (probe.tlsMs ?? 0)),
|
|
302
|
+
);
|
|
303
|
+
|
|
304
|
+
// Last-request-wins: reset then write this request's phases.
|
|
305
|
+
delete timings.dnsMs;
|
|
306
|
+
delete timings.connectMs;
|
|
307
|
+
delete timings.tlsMs;
|
|
308
|
+
delete timings.waitMs;
|
|
309
|
+
delete timings.transferMs;
|
|
310
|
+
delete timings.processingMs;
|
|
311
|
+
timings.dnsMs = dnsMs;
|
|
312
|
+
if (probe.connectMs !== undefined) timings.connectMs = probe.connectMs;
|
|
313
|
+
if (probe.tlsMs !== undefined) timings.tlsMs = probe.tlsMs;
|
|
314
|
+
timings.waitMs = waitMs;
|
|
315
|
+
timings.transferMs = transferMs;
|
|
316
|
+
|
|
317
|
+
const headers: Record<string, string> = {};
|
|
318
|
+
for (const [key, value] of response.headers) {
|
|
201
319
|
headers[key.toLowerCase()] = value;
|
|
202
|
-
}
|
|
320
|
+
}
|
|
203
321
|
|
|
322
|
+
// A received response - INCLUDING a 4xx/5xx - returns normally and is
|
|
323
|
+
// an assertable metric (never an error). Only a genuine transport
|
|
324
|
+
// failure (DNS, connect, TLS, timeout, abort) throws.
|
|
204
325
|
return {
|
|
205
326
|
statusCode: response.status,
|
|
206
327
|
statusText: response.statusText,
|
|
@@ -217,6 +338,7 @@ export class HttpHealthCheckStrategy implements HealthCheckStrategy<
|
|
|
217
338
|
|
|
218
339
|
return {
|
|
219
340
|
client,
|
|
341
|
+
timings,
|
|
220
342
|
close: () => {
|
|
221
343
|
// HTTP is stateless, nothing to close
|
|
222
344
|
},
|
package/src/transport-client.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { TransportClient } from "@checkstack/common";
|
|
2
|
+
import type { TransportTimings } from "@checkstack/backend-api";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* HTTP request configuration.
|
|
@@ -20,6 +21,12 @@ export interface HttpResponse {
|
|
|
20
21
|
headers: Record<string, string>;
|
|
21
22
|
body: string;
|
|
22
23
|
contentType?: string;
|
|
24
|
+
/**
|
|
25
|
+
* Structured transport timing breakdown for this request (DNS / connect /
|
|
26
|
+
* TLS / wait / transfer), populated by the instrumented node-http client.
|
|
27
|
+
* Absent if the request never produced socket events.
|
|
28
|
+
*/
|
|
29
|
+
timings?: TransportTimings;
|
|
23
30
|
}
|
|
24
31
|
|
|
25
32
|
/**
|