feat(health-check): enhance HTTP probe functionality with URL helpers
Build and Push CFDM Docker Image / build-and-push (push) Successful in 1m51s
Build and Push CFDM Docker Image / create-release (push) Skipped
Build and Push CFDM Docker Image / update-wiki (push) Successful in 5s

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:
Denozordec
2026-07-20 03:41:55 +07:00
parent 9d8c2aff86
commit a58d91ff5e
2 changed files with 122 additions and 15 deletions
+60 -14
View File
@@ -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,
+62 -1
View File
@@ -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,