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:
163
src/server/checker/runner/tcp/validate.ts
Normal file
163
src/server/checker/runner/tcp/validate.ts
Normal 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 };
|
||||
Reference in New Issue
Block a user