diff --git a/apps/api/src/services/health-check-service.ts b/apps/api/src/services/health-check-service.ts index eea8011..b47e6b0 100644 --- a/apps/api/src/services/health-check-service.ts +++ b/apps/api/src/services/health-check-service.ts @@ -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, diff --git a/apps/api/test/health-check.test.ts b/apps/api/test/health-check.test.ts index c9354dc..362bdc6 100644 --- a/apps/api/test/health-check.test.ts +++ b/apps/api/test/health-check.test.ts @@ -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((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((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,