Introduced a new `verify_tls` boolean option in health check configurations across various services, allowing users to specify whether to validate TLS certificates during health checks. Updated related components and services to accommodate this new option, ensuring proper handling in both the backend and frontend. Enhanced tests to validate the new functionality and ensure correct behavior with different configurations.
235 lines
7.1 KiB
TypeScript
235 lines
7.1 KiB
TypeScript
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";
|
|
|
|
function startTcpServer(): Promise<{ server: Server; port: number }> {
|
|
return new Promise((resolve) => {
|
|
const server = createServer();
|
|
server.listen(0, "127.0.0.1", () => {
|
|
const address = server.address();
|
|
const port =
|
|
typeof address === "object" && address ? address.port : 0;
|
|
resolve({ server, port });
|
|
});
|
|
});
|
|
}
|
|
|
|
describe("health-check URL helpers", () => {
|
|
it("buildHttpProbeUrl uses FQDN in URL (IP pinned via connector)", () => {
|
|
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;
|
|
|
|
beforeAll(async () => {
|
|
const started = await startTcpServer();
|
|
server = started.server;
|
|
port = started.port;
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
});
|
|
|
|
it("tcp probe succeeds for open port", async () => {
|
|
const target: HealthCheckTarget = {
|
|
scope: "binding",
|
|
ref_id: 1,
|
|
ip: "127.0.0.1",
|
|
hostname: "test.local",
|
|
type: "tcp",
|
|
port,
|
|
path: null,
|
|
expected_status: null,
|
|
timeout_ms: 1000,
|
|
verify_tls: false,
|
|
};
|
|
const result = await healthCheckService.probeTarget(target);
|
|
expect(result.ok).toBe(true);
|
|
expect(result.error).toBeNull();
|
|
expect(result.latencyMs).toBeGreaterThanOrEqual(0);
|
|
});
|
|
|
|
it("tcp probe fails for closed port", async () => {
|
|
const target: HealthCheckTarget = {
|
|
scope: "binding",
|
|
ref_id: 1,
|
|
ip: "127.0.0.1",
|
|
hostname: "test.local",
|
|
type: "tcp",
|
|
port: 1,
|
|
path: null,
|
|
expected_status: null,
|
|
timeout_ms: 500,
|
|
verify_tls: false,
|
|
};
|
|
const result = await healthCheckService.probeTarget(target);
|
|
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,
|
|
verify_tls: false,
|
|
};
|
|
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()));
|
|
}
|
|
});
|
|
|
|
it("physicalProbeKey collapses group+binding on same IP for tcp", async () => {
|
|
const { physicalProbeKey } = await import("../src/services/health-check-service.js");
|
|
const group: HealthCheckTarget = {
|
|
scope: "group",
|
|
ref_id: 1,
|
|
ip: "93.115.203.183",
|
|
hostname: "gt.rkns.top",
|
|
type: "tcp",
|
|
port: 443,
|
|
path: null,
|
|
expected_status: null,
|
|
timeout_ms: 3000,
|
|
verify_tls: false,
|
|
};
|
|
const binding: HealthCheckTarget = {
|
|
...group,
|
|
scope: "binding",
|
|
ref_id: 2,
|
|
hostname: "rutg.rkns.top",
|
|
};
|
|
expect(physicalProbeKey(group)).toBe(physicalProbeKey(binding));
|
|
});
|
|
});
|
|
|
|
describe("health-check state derivation via runAllChecks", () => {
|
|
it("marks ip down after threshold failures and up after recovery", async () => {
|
|
const { createMemoryDb, repos, runMigrations } = await import("@cfdm/db");
|
|
const { db, sqlite } = createMemoryDb();
|
|
runMigrations(sqlite);
|
|
|
|
const domain = repos.createDomain(db, null, "example.com", "zone-id");
|
|
const service = repos.createService(db, "Svc", "svc");
|
|
const binding = repos.insertBinding(
|
|
db,
|
|
domain.id,
|
|
service.id,
|
|
"@",
|
|
null,
|
|
);
|
|
repos.updateBindingLbConfig(db, binding.id, {
|
|
health_check_enabled: true,
|
|
health_check_type: "tcp",
|
|
health_check_port: 1,
|
|
health_check_timeout_ms: 200,
|
|
});
|
|
repos.replaceBindingIpsWithMeta(db, binding.id, [
|
|
{ ip: "127.0.0.1", weight: 1, priority: 1 },
|
|
]);
|
|
|
|
await healthCheckService.runAllChecks(db, {
|
|
thresholds: {
|
|
degradedFailures: 1,
|
|
downFailures: 2,
|
|
latencyWarnMs: 1000,
|
|
},
|
|
});
|
|
let status = repos.getIpHealthStatusRow(
|
|
db,
|
|
"binding",
|
|
binding.id,
|
|
"127.0.0.1",
|
|
);
|
|
expect(status?.status).toBe("degraded");
|
|
|
|
await healthCheckService.runAllChecks(db, {
|
|
thresholds: {
|
|
degradedFailures: 1,
|
|
downFailures: 2,
|
|
latencyWarnMs: 1000,
|
|
},
|
|
});
|
|
status = repos.getIpHealthStatusRow(
|
|
db,
|
|
"binding",
|
|
binding.id,
|
|
"127.0.0.1",
|
|
);
|
|
expect(status?.status).toBe("down");
|
|
});
|
|
|
|
it("listHealthCheckTargets includes verify_tls from binding config", async () => {
|
|
const { createMemoryDb, repos, runMigrations } = await import("@cfdm/db");
|
|
const { db, sqlite } = createMemoryDb();
|
|
runMigrations(sqlite);
|
|
|
|
const domain = repos.createDomain(db, null, "example.com", "zone-id");
|
|
const service = repos.createService(db, "Svc", "svc");
|
|
const binding = repos.insertBinding(db, domain.id, service.id, "@", null);
|
|
repos.updateBindingLbConfig(db, binding.id, {
|
|
health_check_enabled: true,
|
|
health_check_type: "http",
|
|
health_check_port: 443,
|
|
health_check_verify_tls: true,
|
|
});
|
|
repos.replaceBindingIpsWithMeta(db, binding.id, [
|
|
{ ip: "10.0.0.1", weight: 1, priority: 1 },
|
|
]);
|
|
|
|
const targets = repos.listHealthCheckTargets(db);
|
|
expect(targets).toHaveLength(1);
|
|
expect(targets[0]?.verify_tls).toBe(true);
|
|
});
|
|
});
|