1
0

feat: 新增 TCP checker,支持端口可达性探测与 banner 读取

- 新增 src/server/checker/runner/tcp/ 自包含目录(types/schema/validate/execute/expect)
- 注册 TcpChecker 到 checkerRegistry,schema/engine/store/config-loader 自动委托
- 支持 expect.connected 正反向语义(默认期待可达,可配置期待不可达)
- 支持 readBanner opt-in banner 读取,受 bannerReadTimeout + maxBannerBytes 双重限制
- 复用电有 expect/operator/duration/failure 基础设施
- 新增 3 个测试文件 51 条用例(execute/validate/expect),全量 634 测试通过
- 更新 README/DEVELOPMENT/probes.example.yaml,新增 tcp-checker capability spec
This commit is contained in:
2026-05-17 23:53:37 +08:00
parent 31fd3a2a43
commit 0a9a9016be
18 changed files with 1841 additions and 8 deletions

View File

@@ -0,0 +1,358 @@
import { isError } from "es-toolkit";
import type { CheckResult, RawTargetConfig } from "../../types";
import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types";
import type { ResolvedTcpTarget, TcpExpectConfig, TcpTargetConfig } from "./types";
import { checkDuration } from "../../expect/duration";
import { errorFailure } from "../../expect/failure";
import { parseSize } from "../../utils";
import { checkBanner, checkConnected } from "./expect";
import { tcpCheckerSchemas } from "./schema";
import { validateTcpConfig } from "./validate";
const DEFAULT_BANNER_READ_TIMEOUT = 2000;
const DEFAULT_MAX_BANNER_BYTES = 4096;
type ConnectAndBannerResult =
| { banner?: string; bannerExceeded?: boolean; ok: true; socket: { close(): void } }
| { error: string; ok: false };
export class TcpChecker implements CheckerDefinition<ResolvedTcpTarget> {
readonly configKey = "tcp";
readonly schemas = tcpCheckerSchemas;
readonly type = "tcp";
async execute(t: ResolvedTcpTarget, ctx: CheckerContext): Promise<CheckResult> {
const timestamp = new Date().toISOString();
const start = performance.now();
const expect = t.expect;
try {
const connectResult = await connectAndMaybeReadBanner(
t.tcp.host,
t.tcp.port,
t.tcp.readBanner,
t.tcp.bannerReadTimeout,
t.tcp.maxBannerBytes,
ctx.signal,
);
if (!connectResult.ok) {
const durationMs = Math.round(performance.now() - start);
if (expect?.connected === false) {
return {
durationMs,
failure: null,
matched: true,
statusDetail: connectResult.error,
targetId: t.id,
timestamp,
};
}
return {
durationMs,
failure: errorFailure("connect", "connect", connectResult.error),
matched: false,
statusDetail: null,
targetId: t.id,
timestamp,
};
}
const socket = connectResult.socket;
if (ctx.signal.aborted) {
closeSocket(socket);
const durationMs = Math.round(performance.now() - start);
return {
durationMs,
failure: errorFailure("connect", "connect", `连接超时 (${t.timeoutMs}ms)`),
matched: false,
statusDetail: null,
targetId: t.id,
timestamp,
};
}
const expectedConnected = expect?.connected ?? true;
const connectedResult = checkConnected(true, expectedConnected);
if (!connectedResult.matched) {
closeSocket(socket);
const durationMs = Math.round(performance.now() - start);
return {
durationMs,
failure: connectedResult.failure,
matched: false,
statusDetail: "connected",
targetId: t.id,
timestamp,
};
}
if (connectResult.bannerExceeded) {
closeSocket(socket);
const durationMs = Math.round(performance.now() - start);
return {
durationMs,
failure: errorFailure("banner", "banner", `banner 数据超过 ${t.tcp.maxBannerBytes} 字节限制`),
matched: false,
statusDetail: null,
targetId: t.id,
timestamp,
};
}
const banner = connectResult.banner ?? "";
closeSocket(socket);
if (expect?.banner) {
const bannerCheck = checkBanner(banner, expect.banner);
if (!bannerCheck.matched) {
const durationMs = Math.round(performance.now() - start);
return {
durationMs,
failure: bannerCheck.failure,
matched: false,
statusDetail: banner ? truncateBanner(banner) : null,
targetId: t.id,
timestamp,
};
}
}
const durationMs = Math.round(performance.now() - start);
const durationResult = checkDuration(durationMs, expect?.maxDurationMs);
if (!durationResult.matched) {
return {
durationMs,
failure: durationResult.failure,
matched: false,
statusDetail: buildStatusDetail(banner, durationMs),
targetId: t.id,
timestamp,
};
}
return {
durationMs,
failure: null,
matched: true,
statusDetail: buildStatusDetail(banner, durationMs),
targetId: t.id,
timestamp,
};
} catch (error) {
const durationMs = Math.round(performance.now() - start);
return {
durationMs,
failure: errorFailure(
"connect",
"connect",
ctx.signal.aborted ? `连接超时 (${t.timeoutMs}ms)` : isError(error) ? error.message : String(error),
),
matched: false,
statusDetail: null,
targetId: t.id,
timestamp,
};
}
}
resolve(target: RawTargetConfig, context: ResolveContext): ResolvedTcpTarget {
const t = target as RawTargetConfig & { tcp: TcpTargetConfig; type: "tcp" };
const tcpDefaults = context.defaults["tcp"] as
| undefined
| { bannerReadTimeout?: number; maxBannerBytes?: number | string };
const maxBannerBytes = parseSize(t.tcp.maxBannerBytes ?? tcpDefaults?.maxBannerBytes ?? DEFAULT_MAX_BANNER_BYTES);
const bannerReadTimeout = t.tcp.bannerReadTimeout ?? tcpDefaults?.bannerReadTimeout ?? DEFAULT_BANNER_READ_TIMEOUT;
return {
description: null,
expect: target.expect as TcpExpectConfig | undefined,
group: target.group ?? "default",
id: t.id,
intervalMs: context.defaultIntervalMs,
name: t.name ?? null,
tcp: {
bannerReadTimeout,
host: t.tcp.host,
maxBannerBytes,
port: t.tcp.port,
readBanner: t.tcp.readBanner ?? false,
},
timeoutMs: context.defaultTimeoutMs,
type: "tcp",
} satisfies ResolvedTcpTarget;
}
serialize(t: ResolvedTcpTarget): { config: string; target: string } {
return {
config: JSON.stringify({
bannerReadTimeout: t.tcp.bannerReadTimeout,
host: t.tcp.host,
maxBannerBytes: t.tcp.maxBannerBytes,
port: t.tcp.port,
readBanner: t.tcp.readBanner,
}),
target: `${t.tcp.host}:${t.tcp.port}`,
};
}
validate(input: CheckerValidationInput) {
return validateTcpConfig(input);
}
}
function assembleChunks(chunks: Uint8Array[], totalBytes: number): Uint8Array {
const result = new Uint8Array(totalBytes);
let offset = 0;
for (const chunk of chunks) {
result.set(chunk, offset);
offset += chunk.byteLength;
}
return result;
}
function buildStatusDetail(banner: string, durationMs: number): string {
const base = `connected in ${durationMs}ms`;
if (!banner) return base;
return `${base}, banner: ${truncateBanner(banner)}`;
}
function closeSocket(socket: { close(): void }) {
try {
socket.close();
} catch {
/* best-effort close */
}
}
async function connectAndMaybeReadBanner(
hostname: string,
port: number,
readBanner: boolean,
bannerTimeoutMs: number,
maxBannerBytes: number,
signal: AbortSignal,
): Promise<ConnectAndBannerResult> {
const chunks: Uint8Array[] = [];
let totalBytes = 0;
let bannerSettled = false;
let bannerExceeded = false;
let bannerResolve: ((value: void) => void) | undefined;
const bannerPromise = new Promise<void>((resolve) => {
bannerResolve = resolve;
});
const socketHandlers: Record<string, (...args: unknown[]) => void> = {
close() {
if (readBanner && !bannerSettled) {
bannerSettled = true;
bannerResolve!();
}
},
data(_socket: unknown, data: unknown) {
if (!readBanner || bannerSettled) return;
const bytes = data as Uint8Array;
totalBytes += bytes.byteLength;
if (totalBytes > maxBannerBytes) {
bannerSettled = true;
bannerExceeded = true;
bannerResolve!();
return;
}
chunks.push(bytes);
},
drain() {
// Bun socket handler 必填项TCP checker 不关注 drain 事件
},
end() {
if (readBanner && !bannerSettled) {
bannerSettled = true;
bannerResolve!();
}
},
error() {
if (readBanner && !bannerSettled) {
bannerSettled = true;
bannerResolve!();
}
},
open() {
// Bun socket handler 必填项,连接成功由 Bun.connect() resolve 表示
},
};
try {
const socket = await Bun.connect({
hostname,
port,
socket: socketHandlers,
});
if (signal.aborted) {
closeSocket(socket);
return { error: "连接已取消", ok: false };
}
if (!readBanner) {
return { bannerExceeded: false, ok: true, socket };
}
const timer = setTimeout(() => {
if (bannerSettled) return;
bannerSettled = true;
bannerResolve!();
}, bannerTimeoutMs);
const onAbort = () => {
if (bannerSettled) return;
bannerSettled = true;
clearTimeout(timer);
bannerResolve!();
};
if (signal.aborted) {
clearTimeout(timer);
closeSocket(socket);
return { error: "连接已取消", ok: false };
}
signal.addEventListener("abort", onAbort, { once: true });
await bannerPromise;
clearTimeout(timer);
signal.removeEventListener("abort", onAbort);
if (bannerExceeded) {
return { bannerExceeded: true, ok: true, socket };
}
const banner = new TextDecoder().decode(assembleChunks(chunks, totalBytes));
return { banner, bannerExceeded: false, ok: true, socket };
} catch (error) {
if (signal.aborted) {
return { error: "连接超时", ok: false };
}
const message = isError(error) ? error.message : String(error);
return { error: simplifyConnectError(message), ok: false };
}
}
function simplifyConnectError(message: string): string {
const lower = message.toLowerCase();
if (lower.includes("econnrefused") || lower.includes("connection refused")) return "connection refused";
if (lower.includes("enoent") || lower.includes("not found")) return "host not found";
if (lower.includes("etimedout") || lower.includes("timed out")) return "connection timed out";
if (lower.includes("econnreset") || lower.includes("reset")) return "connection reset";
if (lower.includes("enetwork") || lower.includes("network")) return "network error";
return message;
}
function truncateBanner(banner: string, maxLen = 80): string {
if (banner.length <= maxLen) return banner;
return `${banner.slice(0, maxLen)}`;
}

View File

@@ -0,0 +1,30 @@
import type { ExpectResult } from "../../expect/types";
import type { ExpectOperator } from "../../types";
import { mismatchFailure } from "../../expect/failure";
import { applyOperator } from "../../expect/operator";
export function checkBanner(banner: string, op: ExpectOperator): ExpectResult {
const matched = applyOperator(banner, op);
if (!matched) {
return {
failure: mismatchFailure("banner", "banner", op, banner, `banner 不满足条件`),
matched: false,
};
}
return { failure: null, matched: true };
}
export function checkConnected(connected: boolean, expected: boolean): ExpectResult {
if (connected === expected) return { failure: null, matched: true };
if (!connected && expected) {
return {
failure: mismatchFailure("connected", "connected", true, false, "期望端口可达但连接失败"),
matched: false,
};
}
return {
failure: mismatchFailure("connected", "connected", false, true, "期望端口不可达但连接成功"),
matched: false,
};
}

View File

@@ -0,0 +1 @@
export { TcpChecker } from "./execute";

View File

@@ -0,0 +1,33 @@
import { Type } from "@sinclair/typebox";
import type { CheckerSchemas } from "../types";
import { createPureOperatorSchema, sizeSchema } from "../../schema/fragments";
export const tcpCheckerSchemas: CheckerSchemas = {
config: Type.Object(
{
bannerReadTimeout: Type.Optional(Type.Number({ minimum: 0 })),
host: Type.String({ minLength: 1 }),
maxBannerBytes: Type.Optional(sizeSchema),
port: Type.Integer({ maximum: 65535, minimum: 1 }),
readBanner: Type.Optional(Type.Boolean()),
},
{ additionalProperties: false },
),
defaults: Type.Object(
{
bannerReadTimeout: Type.Optional(Type.Number({ minimum: 0 })),
maxBannerBytes: Type.Optional(sizeSchema),
},
{ additionalProperties: false },
),
expect: Type.Object(
{
banner: Type.Optional(createPureOperatorSchema()),
connected: Type.Optional(Type.Boolean()),
maxDurationMs: Type.Optional(Type.Number({ minimum: 0 })),
},
{ additionalProperties: false },
),
};

View File

@@ -0,0 +1,38 @@
import type { ExpectOperator, ResolvedTargetBase } from "../../types";
export interface ResolvedTcpConfig {
bannerReadTimeout: number;
host: string;
maxBannerBytes: number;
port: number;
readBanner: boolean;
}
export interface ResolvedTcpTarget extends ResolvedTargetBase {
expect?: TcpExpectConfig;
group: string;
intervalMs: number;
name: null | string;
tcp: ResolvedTcpConfig;
timeoutMs: number;
type: "tcp";
}
export interface TcpDefaultsConfig {
bannerReadTimeout?: number;
maxBannerBytes?: number | string;
}
export interface TcpExpectConfig {
banner?: ExpectOperator;
connected?: boolean;
maxDurationMs?: number;
}
export interface TcpTargetConfig {
bannerReadTimeout?: number;
host: string;
maxBannerBytes?: number | string;
port: number;
readBanner?: boolean;
}

View File

@@ -0,0 +1,163 @@
import { isNumber, isPlainObject, isString } from "es-toolkit";
import type { ConfigValidationIssue } from "../../schema/issues";
import type { CheckerValidationInput } from "../types";
import { validateOperatorObject } from "../../expect/validate-operator";
import { issue, joinPath } from "../../schema/issues";
export function validateTcpConfig(input: CheckerValidationInput): ConfigValidationIssue[] {
const issues: ConfigValidationIssue[] = [];
issues.push(...validateTcpDefaults(input));
for (let i = 0; i < input.targets.length; i++) {
const target = input.targets[i] as unknown;
if (!isPlainObject(target)) continue;
if (target["type"] !== "tcp") continue;
issues.push(...validateTcpTarget(target, `targets[${i}]`));
}
return issues;
}
function getTargetName(target: Record<string, unknown>): string | undefined {
if (isString(target["name"])) return target["name"];
return isString(target["id"]) ? target["id"] : undefined;
}
function isNonNegativeFiniteNumber(value: unknown): boolean {
return isNumber(value) && Number.isFinite(value) && value >= 0;
}
function validateTcpDefaults(input: CheckerValidationInput): ConfigValidationIssue[] {
const issues: ConfigValidationIssue[] = [];
const defaults = input.defaults["tcp"];
if (defaults === undefined || defaults === null || !isPlainObject(defaults)) return issues;
const targetName = "defaults.tcp";
if (defaults["bannerReadTimeout"] !== undefined && !isNonNegativeFiniteNumber(defaults["bannerReadTimeout"])) {
issues.push(issue("invalid-type", "defaults.tcp.bannerReadTimeout", "必须为非负有限数字", targetName));
}
if (defaults["maxBannerBytes"] !== undefined) {
if (
!isString(defaults["maxBannerBytes"]) &&
!(
isNumber(defaults["maxBannerBytes"]) &&
Number.isFinite(defaults["maxBannerBytes"]) &&
defaults["maxBannerBytes"] >= 0
)
) {
issues.push(issue("invalid-value", "defaults.tcp.maxBannerBytes", "必须为合法 size 值", targetName));
}
}
const allowedKeys = new Set(["bannerReadTimeout", "maxBannerBytes"]);
for (const key of Object.keys(defaults)) {
if (!allowedKeys.has(key)) {
issues.push(issue("unknown-field", joinPath("defaults.tcp", key), "是未知字段", targetName));
}
}
return issues;
}
function validateTcpExpect(
target: Record<string, unknown>,
path: string,
readBanner: boolean,
): ConfigValidationIssue[] {
const targetName = getTargetName(target);
const expect = target["expect"];
if (expect === undefined || expect === null || !isPlainObject(expect)) return [];
const issues: ConfigValidationIssue[] = [];
const expectPath = joinPath(path, "expect");
if (expect["connected"] !== undefined && typeof expect["connected"] !== "boolean") {
issues.push(issue("invalid-type", joinPath(expectPath, "connected"), "必须为布尔值", targetName));
}
if (expect["maxDurationMs"] !== undefined && !isNonNegativeFiniteNumber(expect["maxDurationMs"])) {
issues.push(issue("invalid-type", joinPath(expectPath, "maxDurationMs"), "必须为非负有限数字", targetName));
}
if (expect["banner"] !== undefined) {
if (!readBanner) {
issues.push(
issue("invalid-value", joinPath(expectPath, "banner"), "banner 断言需要启用 tcp.readBanner", targetName),
);
} else {
issues.push(...validateOperatorObject(expect["banner"], joinPath(expectPath, "banner"), targetName));
}
}
const allowedKeys = new Set(["banner", "connected", "maxDurationMs"]);
for (const key of Object.keys(expect)) {
if (!allowedKeys.has(key)) {
issues.push(issue("unknown-field", joinPath(expectPath, key), "是未知字段", targetName));
}
}
return issues;
}
function validateTcpTarget(target: Record<string, unknown>, path: string): ConfigValidationIssue[] {
const issues: ConfigValidationIssue[] = [];
const targetName = getTargetName(target);
const tcp = target["tcp"];
if (!isPlainObject(tcp)) {
issues.push(issue("required", joinPath(path, "tcp"), "缺少 tcp 配置分组", targetName));
issues.push(...validateTcpExpect(target, path, false));
return issues;
}
if (!isString(tcp["host"]) || tcp["host"].trim() === "") {
issues.push(issue("required", joinPath(joinPath(path, "tcp"), "host"), "缺少 tcp.host 字段", targetName));
}
if (tcp["port"] === undefined) {
issues.push(issue("required", joinPath(joinPath(path, "tcp"), "port"), "缺少 tcp.port 字段", targetName));
} else if (!isNumber(tcp["port"]) || !Number.isInteger(tcp["port"]) || tcp["port"] < 1 || tcp["port"] > 65535) {
issues.push(
issue("invalid-value", joinPath(joinPath(path, "tcp"), "port"), "必须为 1-65535 之间的整数", targetName),
);
}
if (tcp["readBanner"] !== undefined && typeof tcp["readBanner"] !== "boolean") {
issues.push(issue("invalid-type", joinPath(joinPath(path, "tcp"), "readBanner"), "必须为布尔值", targetName));
}
if (tcp["bannerReadTimeout"] !== undefined && !isNonNegativeFiniteNumber(tcp["bannerReadTimeout"])) {
issues.push(
issue("invalid-type", joinPath(joinPath(path, "tcp"), "bannerReadTimeout"), "必须为非负有限数字", targetName),
);
}
if (tcp["maxBannerBytes"] !== undefined) {
if (
!isString(tcp["maxBannerBytes"]) &&
!(isNumber(tcp["maxBannerBytes"]) && Number.isFinite(tcp["maxBannerBytes"]) && tcp["maxBannerBytes"] >= 0)
) {
issues.push(
issue("invalid-value", joinPath(joinPath(path, "tcp"), "maxBannerBytes"), "必须为合法 size 值", targetName),
);
}
}
const allowedTcpKeys = new Set(["bannerReadTimeout", "host", "maxBannerBytes", "port", "readBanner"]);
for (const key of Object.keys(tcp)) {
if (!allowedTcpKeys.has(key)) {
issues.push(issue("unknown-field", joinPath(joinPath(path, "tcp"), key), "是未知字段", targetName));
}
}
const readBanner = tcp["readBanner"] === true;
issues.push(...validateTcpExpect(target, path, readBanner));
return issues;
}
export { validateTcpDefaults };