feat(health-check): enhance HTTP probe functionality with URL helpers
Added new utility functions `hostForUrl` and `buildHttpProbeUrl` to improve URL construction for health checks. Updated `httpProbe` to utilize these functions, ensuring proper handling of FQDN and IP addresses. Enhanced tests to validate the new URL building logic and ensure correct behavior with different hostnames and IPs.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { connect } from "node:net";
|
||||
import { connect, isIP } from "node:net";
|
||||
import { resolve4, resolve6 } from "node:dns/promises";
|
||||
import { Agent, fetch as undiciFetch } from "undici";
|
||||
import { Agent, fetch as undiciFetch, interceptors } from "undici";
|
||||
import type { Db } from "@cfdm/db";
|
||||
import { repos } from "@cfdm/db";
|
||||
import type { HealthCheckTarget, IpHealthState } from "@cfdm/shared";
|
||||
@@ -18,6 +18,26 @@ export interface ProbeResult {
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
/** Bracket IPv6 for URL authority; leave IPv4/hostname as-is. */
|
||||
export function hostForUrl(ipOrHost: string): string {
|
||||
return isIP(ipOrHost) === 6 ? `[${ipOrHost}]` : ipOrHost;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build http(s) URL authority for the probe.
|
||||
* Prefer FQDN in the URL (correct Host/SNI); IP is pinned via DNS interceptor.
|
||||
*/
|
||||
export function buildHttpProbeUrl(
|
||||
urlHost: string,
|
||||
port: number,
|
||||
pathWithSlash: string,
|
||||
useTls: boolean,
|
||||
): string {
|
||||
const defaultPort = useTls ? 443 : 80;
|
||||
const portPart = port === defaultPort ? "" : `:${port}`;
|
||||
return `${useTls ? "https" : "http"}://${hostForUrl(urlHost)}${portPart}${pathWithSlash}`;
|
||||
}
|
||||
|
||||
function tcpProbe(
|
||||
ip: string,
|
||||
port: number,
|
||||
@@ -59,6 +79,10 @@ function tcpProbe(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP(S) probe: URL/Host/SNI use hostname (vhost), TCP connects to configured IP when numeric.
|
||||
* Avoids re-resolving FQDN via public DNS (which skewed group vs binding latency for the same IP).
|
||||
*/
|
||||
async function httpProbe(
|
||||
ip: string,
|
||||
target: HealthCheckTarget,
|
||||
@@ -69,21 +93,43 @@ async function httpProbe(
|
||||
const pathWithSlash = path.startsWith("/") ? path : `/${path}`;
|
||||
const port = target.port ?? 80;
|
||||
const useTls = port === 443;
|
||||
const urlHost = useTls ? target.hostname || ip : ip;
|
||||
const url = `${useTls ? "https" : "http"}://${urlHost}${pathWithSlash}`;
|
||||
const dispatcher =
|
||||
useTls && target.hostname
|
||||
? new Agent({
|
||||
connect: {
|
||||
servername: target.hostname,
|
||||
rejectUnauthorized: false,
|
||||
},
|
||||
})
|
||||
: undefined;
|
||||
const connectAddr = String(ip || "").trim();
|
||||
const headerHost = (target.hostname || "").trim() || connectAddr;
|
||||
const urlHost = headerHost;
|
||||
const url = buildHttpProbeUrl(urlHost, port, pathWithSlash, useTls);
|
||||
|
||||
const family = isIP(connectAddr);
|
||||
const pinToIp = family === 4 || family === 6;
|
||||
|
||||
let dispatcher: Agent | undefined;
|
||||
if (pinToIp) {
|
||||
// URL stays on FQDN (Host + SNI), lookup always returns the configured IP.
|
||||
dispatcher = new Agent({
|
||||
connect: {
|
||||
...(useTls && isIP(headerHost) === 0 ? { servername: headerHost } : {}),
|
||||
rejectUnauthorized: false,
|
||||
},
|
||||
}).compose(
|
||||
interceptors.dns({
|
||||
dualStack: false,
|
||||
affinity: family === 6 ? 6 : 4,
|
||||
lookup: (_origin, _opts, cb) => {
|
||||
cb(null, [{ address: connectAddr, family: family === 6 ? 6 : 4 }]);
|
||||
},
|
||||
}),
|
||||
) as Agent;
|
||||
} else if (useTls) {
|
||||
dispatcher = new Agent({
|
||||
connect: {
|
||||
...(isIP(headerHost) === 0 ? { servername: headerHost } : {}),
|
||||
rejectUnauthorized: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await undiciFetch(url, {
|
||||
method: "GET",
|
||||
headers: { Host: target.hostname || ip },
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
redirect: "manual",
|
||||
dispatcher,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it, beforeAll, afterAll } from "vitest";
|
||||
import { createServer, type Server } from "node:net";
|
||||
import { createServer as createHttpServer, type Server as HttpServer } from "node:http";
|
||||
import * as healthCheckService from "../src/services/health-check-service.js";
|
||||
import type { HealthCheckTarget } from "@cfdm/shared";
|
||||
|
||||
@@ -15,6 +16,20 @@ function startTcpServer(): Promise<{ server: Server; port: number }> {
|
||||
});
|
||||
}
|
||||
|
||||
describe("health-check URL helpers", () => {
|
||||
it("buildHttpProbeUrl uses FQDN in URL (IP pinned via DNS interceptor)", () => {
|
||||
expect(
|
||||
healthCheckService.buildHttpProbeUrl("gt.rkns.top", 443, "/", true),
|
||||
).toBe("https://gt.rkns.top/");
|
||||
expect(
|
||||
healthCheckService.buildHttpProbeUrl("gt.rkns.top", 8080, "/health", false),
|
||||
).toBe("http://gt.rkns.top:8080/health");
|
||||
expect(
|
||||
healthCheckService.buildHttpProbeUrl("2001:db8::1", 443, "/", true),
|
||||
).toBe("https://[2001:db8::1]/");
|
||||
});
|
||||
});
|
||||
|
||||
describe("health-check probeTarget", () => {
|
||||
let server: Server;
|
||||
let port: number;
|
||||
@@ -63,6 +78,53 @@ describe("health-check probeTarget", () => {
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).not.toBeNull();
|
||||
});
|
||||
|
||||
it("http probe hits IP with Host=hostname (same IP, different FQDN)", async () => {
|
||||
let seenHost: string | undefined;
|
||||
const httpServer: HttpServer = createHttpServer((req, res) => {
|
||||
seenHost = req.headers.host;
|
||||
res.writeHead(200);
|
||||
res.end("ok");
|
||||
});
|
||||
const httpPort = await new Promise<number>((resolve) => {
|
||||
httpServer.listen(0, "127.0.0.1", () => {
|
||||
const address = httpServer.address();
|
||||
resolve(typeof address === "object" && address ? address.port : 0);
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
const groupTarget: HealthCheckTarget = {
|
||||
scope: "group",
|
||||
ref_id: 1,
|
||||
ip: "127.0.0.1",
|
||||
hostname: "gt.rkns.top",
|
||||
type: "http",
|
||||
port: httpPort,
|
||||
path: "/",
|
||||
expected_status: 200,
|
||||
timeout_ms: 1000,
|
||||
};
|
||||
const bindingTarget: HealthCheckTarget = {
|
||||
...groupTarget,
|
||||
scope: "binding",
|
||||
hostname: "rutg.rkns.top",
|
||||
};
|
||||
|
||||
const groupResult = await healthCheckService.probeTarget(groupTarget);
|
||||
expect(groupResult.ok).toBe(true);
|
||||
expect(seenHost?.startsWith("gt.rkns.top")).toBe(true);
|
||||
|
||||
const bindingResult = await healthCheckService.probeTarget(bindingTarget);
|
||||
expect(bindingResult.ok).toBe(true);
|
||||
expect(seenHost?.startsWith("rutg.rkns.top")).toBe(true);
|
||||
|
||||
// Same loopback IP → latencies in the same ballpark (not ~1s DNS skew)
|
||||
expect(Math.abs(groupResult.latencyMs - bindingResult.latencyMs)).toBeLessThan(200);
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => httpServer.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("health-check state derivation via runAllChecks", () => {
|
||||
@@ -90,7 +152,6 @@ describe("health-check state derivation via runAllChecks", () => {
|
||||
{ ip: "127.0.0.1", weight: 1, priority: 1 },
|
||||
]);
|
||||
|
||||
// Запуск с closed port (port 1) — должен зафиксировать down после 2 проверок
|
||||
await healthCheckService.runAllChecks(db, {
|
||||
thresholds: {
|
||||
degradedFailures: 1,
|
||||
|
||||
Reference in New Issue
Block a user