diff --git a/.gitignore b/.gitignore index 90cb86f9..98e6133b 100644 --- a/.gitignore +++ b/.gitignore @@ -201,3 +201,7 @@ docker-compose.override.yml # Package locks are ignored by default; keep the SDK lockfile tracked. package-lock.json !sdk/package-lock.json + +# NSFOCUS WAF service — 只忽略 WAF 目录下的 PDF 和本地配置 +services/nsfocus__waf_v6-0-7/*.pdf +services/nsfocus__waf_v6-0-7/.claude/ diff --git a/services/nsfocus__waf_v6-0-7/README.md b/services/nsfocus__waf_v6-0-7/README.md new file mode 100644 index 00000000..b82fe4ff --- /dev/null +++ b/services/nsfocus__waf_v6-0-7/README.md @@ -0,0 +1,125 @@ +# NSFOCUS WAF 6.0.7 OctoBus Service + +Service root: `services/nsfocus__waf_v6-0-7`. + +Import it into OctoBus with: + +```bash +octobus service import --id nsfocus-waf-v6-0-7 ./services/nsfocus__waf_v6-0-7 +``` + +## Support Matrix + +- Product: NSFOCUS Web Application Firewall. +- Effective API reference for this implementation: `绿盟WEB应用防护系统RESTAPI设计文档-V6.0.7.3版本.pdf`. +- Service/package identifier is `nsfocus-waf-v6-0-7`. +- API style: REST V3 token plus per-request signature. +- Authentication: `accountId` plus password login, or pre-issued `token` and `seceret_key`. +- Transport: HTTPS (port 8443). Private deployments often use self-signed certificates; set `skipTlsVerify` only for trusted management networks. + +## Package Files + +- `service.json`: OctoBus service manifest. +- `proto/nsfocus_waf_v6_0_7.proto`: gRPC API definition. +- `config.schema.json`: non-secret endpoint, account, timeout, TLS, and header settings. +- `secret.schema.json`: password or pre-issued token and `seceret_key` settings. +- `src/nsfocus-waf-v6-0-7.js`: NSFOCUS WAF REST V3 client and handler implementation. +- `src/service.js`: OctoBus SDK `defineService` wrapper. +- `bin/nsfocus-waf-v6-0-7.js`: service-local executable entrypoint. +- `test/nsfocus-waf-v6-0-7.test.js`: node:test coverage for signing, request mapping, response mapping, and error mapping. + +## Configuration + +```json +{ + "endpoint": "https://waf.example.com:8443", + "accountId": "admin", + "apiVersion": "v3", + "timeoutMs": 5000, + "skipTlsVerify": false +} +``` + +Use `secret.pwd` or `secret.password` to let the service fetch a V3 token from `POST /rest/v3/token`: + +```json +{ + "pwd": "replace-with-password" +} +``` + +If a token has already been issued, provide both values and login will be skipped: + +```json +{ + "token": "replace-with-token", + "seceret_key": "replace-with-seceret-key" +} +``` + +## RPC Methods + +- `nsfocus.waf.v6.NSFOCUSWAFService/BlockIP`: creates a network-layer (L4) ACL policy through `POST /rest/v3/l4acl`. +- `nsfocus.waf.v6.NSFOCUSWAFService/ListBlockedIPs`: queries L4 ACL policies through `GET /rest/v3/l4acl` or `GET /rest/v3/l4acl/{policy_id}`. +- `nsfocus.waf.v6.NSFOCUSWAFService/UnblockIP`: deletes L4 ACL policies through `DELETE /rest/v3/l4acl/{policy_id}`, supports lookup by IP or direct deletion by policy ID. + +## 6.0.7.3 接口对照表 + +| OctoBus RPC / CLI | 6.0.7.3 文档位置 | 上游接口 | 请求映射 | 响应映射 | +| --- | --- | --- | --- | --- | +| `BlockIP` / `block-ip` | `2.30 网络层访问控制`,PDF 第 `210-215` 页 | `POST /rest/v3/l4acl` | `ips` 映射为 `iptables[].src.iplist[]`(mask=255.255.255.255),`policy_name -> name`,`action`/`protocol`/`alarm`/`enabled` 直接透传,`index` 可选(不填自动分配) | 提取 `result[]` 中的 `id`、`multi_result`、`name` 映射为 `policy_id`、`result`、`name` | +| `BlockIP` / `block-ip` | `2.30 网络层访问控制`,PDF 第 `210-215` 页 | `POST /rest/v3/l4acl` | `ips` 映射为 `iptables[].src.iplist[]`(mask=255.255.255.255),`policy_name -> name`,`action`/`protocol`/`alarm`/`enabled` 直接透传,`index` 可选(不填自动分配) | 提取 `result[]` 中的 `id`、`multi_result`、`name` 映射为 `policy_id`、`result`、`name` | +| `ListBlockedIPs` / `list-blocked-ips` | `2.30 网络层访问控制`,PDF 第 `210-212` 页 | `GET /rest/v3/l4acl` 或 `GET /rest/v3/l4acl/{policy_id}` | 可选 `ips` 过滤、可选 `policy_id` 查询单个策略 | 将策略列表映射为 `policies[]`,提取 `id/name/index/protocol/alarm/action/enabled/blocked_ips[]` | +| `UnblockIP` / `unblock-ip` | `2.30 网络层访问控制`,PDF 第 `216` 页 | `DELETE /rest/v3/l4acl/{policy_id}` | 支持 `ips`(先查询匹配策略 ID 再删除)或 `policy_ids`(直接删除) | 将删除结果映射为 `results[]`,保留 `policy_id/result` | + +## L4 ACL 策略参数说明 + +| 参数 | 类型 | 说明 | +| --- | --- | --- | +| `ips` | string[] | 要封禁的源 IP 列表,必填 | +| `policy_name` | string | 策略名称,可选 | +| `action` | string | 动作: `"1"`=放行 `"2"`=拒绝 `"3"`=重定向,默认 `"2"` | +| `protocol` | string | 协议: `"0"`=任意 `"1"`=icmp `"6"`=tcp `"17"`=udp,默认 `"0"` | +| `alarm` | bool | 是否告警,默认 `true` | +| `enabled` | bool | 是否启用,默认 `true` | +| `index` | string | 优先级索引(须唯一),不填自动分配 | + +## Risk Boundaries + +- Read-only methods: `ListBlockedIPs`. +- Write methods: `BlockIP`, `UnblockIP`. +- `BlockIP` creates a single L4 ACL policy that blocks the specified source IPs (reject action). An unused `index` is auto-assigned if not provided. +- `UnblockIP` finds and deletes L4 ACL policies by IP or by policy ID. +- Use dedicated test IPs for write validation. Clean up every policy created during validation. + +## Write Semantics + +- `BlockIP` is not guaranteed idempotent — repeated calls with the same `policy_name` create separate policies. +- Index auto-assignment: queries existing policies and uses `max(existing_index) + 1` if `index` is not specified. +- Rollback: call `UnblockIP` with the policy ID returned by `BlockIP`, or delete via the vendor UI. +- Audit fields should include `policy_name`, `ips`, `index`, caller identity, OctoBus request id, and upstream raw result. + +## Suggested Capset + +- `waf.blocked_ip.read`: allow `ListBlockedIPs`. +- `waf.ip_block.write`: allow `BlockIP`; grant only to workflows approved to modify WAF policy. +- `waf.ip_unblock.write`: allow `UnblockIP`; grant only to workflows approved to remove WAF blocking entries. + +For production, prefer separate read-only and write capsets so agents can query alerts without permission to modify protections. + +## Validation Plan + +1. Import the service package. +2. Create an instance with a non-production WAF or a dedicated test environment. +3. Add the instance to a read-only capset and call `ListBlockedIPs`. +4. After explicit approval, add a write capset and call `BlockIP` with a test IP. +5. Verify the block with `ListBlockedIPs`. +6. Clean up with `UnblockIP`. + +## Local Checks + +```bash +cd services/nsfocus__waf_v6-0-7 +npm test +npm run validate -- --service-dir nsfocus__waf_v6-0-7 +``` diff --git a/services/nsfocus__waf_v6-0-7/bin/nsfocus-waf-v6-0-7.js b/services/nsfocus__waf_v6-0-7/bin/nsfocus-waf-v6-0-7.js new file mode 100644 index 00000000..508272f0 --- /dev/null +++ b/services/nsfocus__waf_v6-0-7/bin/nsfocus-waf-v6-0-7.js @@ -0,0 +1,7 @@ +#!/usr/bin/env node + +import { runServiceMain } from "@chaitin-ai/octobus-sdk"; + +import { service } from "../src/service.js"; + +runServiceMain(service); diff --git a/services/nsfocus__waf_v6-0-7/config.schema.json b/services/nsfocus__waf_v6-0-7/config.schema.json new file mode 100644 index 00000000..74262f70 --- /dev/null +++ b/services/nsfocus__waf_v6-0-7/config.schema.json @@ -0,0 +1,43 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": true, + "properties": { + "endpoint": { + "type": "string", + "description": "NSFOCUS WAF REST API base URL, for example https://waf.example.com:8443." + }, + "baseUrl": { + "type": "string", + "description": "Alias for endpoint." + }, + "apiVersion": { + "type": "string", + "default": "v3", + "description": "REST API version path segment. The current implementation is calibrated against the V6.0.7.3 REST document and uses v3 endpoints by default." + }, + "accountId": { + "type": "string", + "default": "admin", + "description": "NSFOCUS WAF API account id used to fetch V3 OAuth token." + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "Optional extra HTTP headers sent to NSFOCUS WAF." + }, + "timeoutMs": { + "type": "integer", + "minimum": 1, + "default": 5000, + "description": "HTTP timeout in milliseconds." + }, + "skipTlsVerify": { + "type": "boolean", + "default": false, + "description": "Skip TLS certificate verification for private deployments." + } + } +} diff --git a/services/nsfocus__waf_v6-0-7/package.json b/services/nsfocus__waf_v6-0-7/package.json new file mode 100644 index 00000000..ce0fb147 --- /dev/null +++ b/services/nsfocus__waf_v6-0-7/package.json @@ -0,0 +1,12 @@ +{ + "name": "nsfocus-waf-v6-0-7", + "version": "0.0.0", + "private": true, + "type": "module", + "bin": { + "nsfocus-waf-v6-0-7": "bin/nsfocus-waf-v6-0-7.js" + }, + "dependencies": { + "@chaitin-ai/octobus-sdk": "^0.5.0" + } +} diff --git a/services/nsfocus__waf_v6-0-7/proto/nsfocus_waf_v6_0_7.proto b/services/nsfocus__waf_v6-0-7/proto/nsfocus_waf_v6_0_7.proto new file mode 100644 index 00000000..98c99dda --- /dev/null +++ b/services/nsfocus__waf_v6-0-7/proto/nsfocus_waf_v6_0_7.proto @@ -0,0 +1,98 @@ +syntax = "proto3"; + +package nsfocus.waf.v6; + +import "google/protobuf/struct.proto"; + +service NSFOCUSWAFService { + // 创建网络层访问控制策略封禁源 IP POST /rest/v3/l4acl + rpc BlockIP(BlockIPRequest) returns (BlockIPResponse) {} + + // 查询网络层访问控制策略 GET /rest/v3/l4acl 或 GET /rest/v3/l4acl/{policy_id} + rpc ListBlockedIPs(ListBlockedIPsRequest) returns (ListBlockedIPsResponse) {} + + // 解除网络层访问控制策略 DELETE /rest/v3/l4acl/{policy_id} + rpc UnblockIP(UnblockIPRequest) returns (UnblockIPResponse) {} +} + +// ======================== BlockIP ======================== + +message BlockIPRequest { + // 要封禁的 IP 列表,必填 + repeated string ips = 1; + // 策略名称,可选 + string policy_name = 2; + // 策略描述,可选 + string description = 3; + // 是否告警: "1"=告警 "0"=不告警,默认 "1" + string alarm = 4; + // 动作: "1"=放行 "2"=拒绝 "3"=重定向,默认 "2" + string action = 5; + // 协议: "0"=任意 "1"=icmp "6"=tcp "17"=udp,默认 "0" + string protocol = 6; + // 是否启用: "true"/"false",默认 "true" + string enabled = 7; + // 优先级索引,可选(不填自动分配) + string index = 8; +} + +message BlockIPResponse { + // 创建的策略 ID + string policy_id = 1; + // 策略名称 + string name = 2; + // 创建结果 + string result = 3; + // 上游原始响应 + google.protobuf.Value raw = 4; +} + +// ======================== ListBlockedIPs ======================== + +message ListBlockedIPsRequest { + // 可选:按 IP 过滤 + repeated string ips = 1; + // 可选:查询单个策略 + string policy_id = 2; +} + +message L4AclPolicy { + string policy_id = 1; + string name = 2; + string index = 3; + string protocol = 4; + string alarm = 5; + string action = 6; + string enabled = 7; + // 被该策略封禁的 IP 列表 + repeated BlockedIpEntry blocked_ips = 8; +} + +message BlockedIpEntry { + string ip = 1; + string mask = 2; +} + +message ListBlockedIPsResponse { + repeated L4AclPolicy policies = 1; + google.protobuf.Struct raw = 2; +} + +// ======================== UnblockIP ======================== + +message UnblockIPRequest { + // 按 IP 查找并解封对应的策略 + repeated string ips = 1; + // 按策略 ID 直接删除 + repeated string policy_ids = 2; +} + +message UnblockIPResult { + string policy_id = 1; + string result = 2; +} + +message UnblockIPResponse { + repeated UnblockIPResult results = 1; + google.protobuf.Value raw = 2; +} diff --git a/services/nsfocus__waf_v6-0-7/secret.schema.json b/services/nsfocus__waf_v6-0-7/secret.schema.json new file mode 100644 index 00000000..963598e3 --- /dev/null +++ b/services/nsfocus__waf_v6-0-7/secret.schema.json @@ -0,0 +1,27 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": true, + "properties": { + "pwd": { + "type": "string", + "description": "NSFOCUS WAF API account password used to fetch V3 OAuth token." + }, + "password": { + "type": "string", + "description": "Alias for pwd." + }, + "token": { + "type": "string", + "description": "Optional pre-issued token. If provided with secretKey or seceret_key, token login is skipped." + }, + "secretKey": { + "type": "string", + "description": "Optional V3 seceret_key returned by /rest/v3/token." + }, + "seceret_key": { + "type": "string", + "description": "Legacy spelling used by the NSFOCUS API response." + } + } +} diff --git a/services/nsfocus__waf_v6-0-7/service.json b/services/nsfocus__waf_v6-0-7/service.json new file mode 100644 index 00000000..2c162e11 --- /dev/null +++ b/services/nsfocus__waf_v6-0-7/service.json @@ -0,0 +1,37 @@ +{ + "schema": "chaitin.octobus.service.v1", + "name": "nsfocus-waf-v6-0-7", + "displayName": "NSFOCUS WAF 6.0.7", + "description": "OctoBus package for NSFOCUS Web Application Firewall L4 ACL block, blocked IP query, and unblock APIs calibrated against the V6.0.7.3 REST document.", + "runtime": { + "mode": "long-running" + }, + "proto": { + "roots": [ + "proto" + ], + "files": [ + "proto/nsfocus_waf_v6_0_7.proto" + ] + }, + "configSchema": "config.schema.json", + "secretSchema": "secret.schema.json", + "sdk": { + "cli": { + "commands": { + "nsfocus.waf.v6.NSFOCUSWAFService/BlockIP": { + "name": "block-ip", + "description": "创建网络层访问控制(L4 ACL)策略封禁源 IP。" + }, + "nsfocus.waf.v6.NSFOCUSWAFService/ListBlockedIPs": { + "name": "list-blocked-ips", + "description": "查询网络层访问控制策略,可按 IP 过滤。" + }, + "nsfocus.waf.v6.NSFOCUSWAFService/UnblockIP": { + "name": "unblock-ip", + "description": "删除网络层访问控制策略以解封 IP。" + } + } + } + } +} diff --git a/services/nsfocus__waf_v6-0-7/src/nsfocus-waf-v6-0-7.js b/services/nsfocus__waf_v6-0-7/src/nsfocus-waf-v6-0-7.js new file mode 100644 index 00000000..7418f84a --- /dev/null +++ b/services/nsfocus__waf_v6-0-7/src/nsfocus-waf-v6-0-7.js @@ -0,0 +1,553 @@ +/** + * NSFOCUS WAF V6.0.7 REST API 客户端与服务实现。 + * + * 支持的操作: + * - BlockIP: POST /rest/v3/l4acl — 创建网络层访问控制策略封禁 IP + * - ListBlockedIPs: GET /rest/v3/l4acl[/{id}] — 查询网络层访问控制策略 + * - UnblockIP: DELETE /rest/v3/l4acl/{id} — 删除网络层访问控制策略解封 IP + */ +import crypto from "node:crypto"; + +import { GrpcError, grpcStatus } from "@chaitin-ai/octobus-sdk"; + +// ======================== 方法常量 ======================== +export const METHOD_BLOCK_IP = "nsfocus.waf.v6.NSFOCUSWAFService/BlockIP"; +export const METHOD_LIST_BLOCKED_IPS = "nsfocus.waf.v6.NSFOCUSWAFService/ListBlockedIPs"; +export const METHOD_UNBLOCK_IP = "nsfocus.waf.v6.NSFOCUSWAFService/UnblockIP"; + +// ======================== 参数限制 ======================== +const DEFAULT_TIMEOUT_MS = 5000; +const DEFAULT_API_VERSION = "v3"; +const MAX_IPS = 10; + +// ======================== 错误映射 ======================== +const grpcCodeFor = (code) => ({ + INVALID_ARGUMENT: grpcStatus.INVALID_ARGUMENT, + UNAUTHENTICATED: grpcStatus.UNAUTHENTICATED, + PERMISSION_DENIED: grpcStatus.PERMISSION_DENIED, + NOT_FOUND: grpcStatus.NOT_FOUND, + UNAVAILABLE: grpcStatus.UNAVAILABLE, + UNKNOWN: grpcStatus.UNKNOWN, +})[code] ?? grpcStatus.UNKNOWN; + +const errorWithCode = (code, message) => { + const err = new GrpcError(grpcCodeFor(code), `${code}: ${message}`); + err.legacyCode = code; + return err; +}; + +// ======================== 工具函数 ======================== +const hasOwn = (obj, key) => Object.prototype.hasOwnProperty.call(obj ?? {}, key); + +const firstDefined = (...values) => values.find((value) => value !== undefined && value !== null); + +const coerceString = (value) => { + if (value === undefined || value === null) return ""; + if (typeof value === "object" && hasOwn(value, "value")) return coerceString(value.value); + return String(value); +}; + +const normalizeBaseUrl = (url) => { + const base = coerceString(url).trim(); + if (!/^https?:\/\//i.test(base)) return ""; + return base.replace(/\/+$/, ""); +}; + +const normalizeApiVersion = (value) => { + const version = coerceString(value || DEFAULT_API_VERSION).trim().replace(/^\/+/, ""); + return version || DEFAULT_API_VERSION; +}; + +const mergedBindings = (ctx = {}) => ({ + ...(ctx.config ?? {}), + ...(ctx.secret ?? {}), + ...(ctx.bindings ?? {}), +}); + +const resolveCallContext = (ctx = {}) => ({ + req: ctx.request ?? ctx.req ?? {}, + bindings: mergedBindings(ctx), +}); + +const parseHeaders = (value) => { + if (value === undefined || value === null || value === "") return {}; + if (typeof value === "object" && !Array.isArray(value)) return value; + if (typeof value === "string") { + try { + const parsed = JSON.parse(value); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed; + } catch { + return {}; + } + } + return {}; +}; + +const normalizeList = (value) => { + if (value === undefined || value === null) return []; + if (Array.isArray(value)) return value; + if (typeof value === "object" && hasOwn(value, "values")) return normalizeList(value.values); + const text = coerceString(value).trim(); + if (!text) return []; + return text.split(",").map((item) => item.trim()).filter(Boolean); +}; + +/** 从请求对象中提取必填的字符串列表 */ +const requireStringList = (req, keys, label, { max } = {}) => { + let found; + for (const key of keys) { + if (hasOwn(req, key)) { + found = req[key]; + break; + } + } + const values = normalizeList(found).map((item) => coerceString(item).trim()).filter(Boolean); + if (values.length === 0) throw errorWithCode("INVALID_ARGUMENT", `${label} is required`); + if (max && values.length > max) throw errorWithCode("INVALID_ARGUMENT", `${label} supports at most ${max} items`); + return values; +}; + +/** 从请求对象中提取可选的字符串列表 */ +const optionalStringList = (req, keys, label, { max } = {}) => { + let found; + for (const key of keys) { + if (hasOwn(req, key)) { + found = req[key]; + break; + } + } + const values = normalizeList(found).map((item) => coerceString(item).trim()).filter(Boolean); + if (max && values.length > max) throw errorWithCode("INVALID_ARGUMENT", `${label} supports at most ${max} items`); + return values; +}; + +/** 布尔值规整化 */ +const normalizeBool = (value, fallback) => { + if (value === undefined || value === null) return fallback; + if (typeof value === "boolean") return value; + if (typeof value === "number") return value !== 0; + const text = coerceString(value).trim().toLowerCase(); + if (text === "true" || text === "1") return true; + if (text === "false" || text === "0") return false; + return fallback; +}; + +// ======================== 加密工具 ======================== +const md5 = (text) => crypto.createHash("md5").update(text, "utf8").digest("hex"); +const sha1 = (text) => crypto.createHash("sha1").update(text, "utf8").digest("hex"); + +const jsonStableStringify = (value) => JSON.stringify(value); + +const queryEntriesForSignature = (query) => { + const entries = []; + for (const [key, value] of query.entries()) { + entries.push([key, value]); + } + entries.sort((a, b) => a[0].localeCompare(b[0]) || a[1].localeCompare(b[1])); + return entries; +}; + +const buildQuery = (params = {}) => { + const query = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + if (value === undefined || value === null || value === "") continue; + if (Array.isArray(value)) { + if (value.length > 0) query.set(key, value.join(",")); + continue; + } + query.set(key, coerceString(value)); + } + return query; +}; + +const toValue = (val) => { + if (val === undefined || val === null) return { nullValue: "NULL_VALUE" }; + if (typeof val === "string") return { stringValue: val }; + if (typeof val === "number") return { numberValue: val }; + if (typeof val === "boolean") return { boolValue: val }; + if (Array.isArray(val)) return { listValue: { values: val.map((item) => toValue(item)) } }; + if (typeof val === "object") { + const fields = {}; + for (const [key, value] of Object.entries(val)) fields[key] = toValue(value); + return { structValue: { fields } }; + } + return { stringValue: String(val) }; +}; + +const toStruct = (val) => { + if (!val || typeof val !== "object" || Array.isArray(val)) return { fields: {} }; + return toValue(val).structValue ?? { fields: {} }; +}; + +// ======================== REST API 客户端 ======================== + +class NSFOCUSWAFClient { + constructor(bindings) { + this.baseUrl = normalizeBaseUrl(firstDefined(bindings.endpoint, bindings.baseUrl, bindings.restBaseUrl)); + if (!this.baseUrl) throw errorWithCode("INVALID_ARGUMENT", "endpoint/baseUrl must be an http(s) URL"); + this.apiVersion = normalizeApiVersion(bindings.apiVersion); + this.accountId = coerceString(firstDefined(bindings.accountId, bindings.account_id, "admin")).trim(); + this.password = coerceString(firstDefined(bindings.pwd, bindings.password)).trim(); + this.token = coerceString(bindings.token).trim(); + this.secretKey = coerceString(firstDefined(bindings.secretKey, bindings.seceret_key, bindings.secret_key)).trim(); + this.headers = parseHeaders(bindings.headers); + this.timeoutMs = Number(firstDefined(bindings.timeoutMs, bindings.timeout_ms, DEFAULT_TIMEOUT_MS)) || DEFAULT_TIMEOUT_MS; + this.skipTlsVerify = Boolean(firstDefined(bindings.skipTlsVerify, bindings.skip_tls_verify, false)); + // 时钟偏移量:从 JWT Token 的 iat 推算设备与本机的时差(秒) + this.clockOffset = 0; + } + + restPath(path) { + const normalizedPath = path.startsWith("/") ? path : `/${path}`; + return `/rest/${this.apiVersion}${normalizedPath}`; + } + + /** 获取 OAuth2 Token(V3 认证) */ + async ensureToken() { + if (this.token && this.secretKey) return; + if (!this.accountId || !this.password) { + throw errorWithCode("UNAUTHENTICATED", "accountId and pwd/password are required when token and secretKey are not provided"); + } + const body = JSON.stringify({ accountId: this.accountId, pwd: this.password }); + const json = await this.rawFetch(this.restPath("/token"), { + method: "POST", + body, + headers: { "Content-Type": "application/json" }, + signed: false, + }); + this.token = coerceString(json.token).trim(); + this.secretKey = coerceString(firstDefined(json.seceret_key, json.secretKey, json.secret_key)).trim(); + if (!this.token || !this.secretKey) throw errorWithCode("UNAUTHENTICATED", "token response must include token and seceret_key"); + // 从 JWT 的 iat 推算设备时钟与本机的偏差(适用于设备时钟与本机不一致的场景) + try { + const payload = JSON.parse(Buffer.from(this.token.split(".")[1], "base64").toString("utf8")); + if (payload.iat) { + this.clockOffset = payload.iat - Math.floor(Date.now() / 1000); + } + } catch { + this.clockOffset = 0; + } + } + + /** 计算 V3 签名请求头 */ + signatureHeaders(path, query, bodyText) { + const nonce = crypto.randomBytes(6).toString("hex").slice(0, 10); + // 使用设备时间:本机时间 + 时钟偏移(消除设备与本机时钟差) + const timestamp = String(Math.floor(Date.now() / 1000) + (this.clockOffset || 0)); + const uriSuffix = path.replace(new RegExp(`^/rest/${this.apiVersion}`), "") || "/"; + const hashstr1 = md5(uriSuffix); + const entries = queryEntriesForSignature(query); + const hashstr2 = entries.length === 0 ? "" : md5(jsonStableStringify(entries)); + const hashstr3 = bodyText ? md5(bodyText) : ""; + const parts = [this.token, this.secretKey, nonce, timestamp, hashstr1, hashstr2, hashstr3].sort(); + return { + Nonce: nonce, + Timestamp: timestamp, + Signature: sha1(parts.join("")), + Authorization: `Bearer ${this.token}`, + }; + } + + /** 带签名的 REST API 请求 */ + async request(path, { method = "GET", query = {}, body } = {}) { + await this.ensureToken(); + const fullPath = this.restPath(path); + const queryParams = buildQuery(query); + const bodyText = body === undefined ? "" : JSON.stringify(body); + return this.rawFetch(fullPath, { + method, + query: queryParams, + body: bodyText, + headers: { + "Content-Type": "application/json", + ...this.signatureHeaders(fullPath, queryParams, bodyText), + }, + signed: true, + }); + } + + /** 底层 HTTP 请求 */ + async rawFetch(path, { method, query = new URLSearchParams(), body = "", headers = {}, signed = true }) { + const url = new URL(`${this.baseUrl}${path}`); + for (const [key, value] of query.entries()) url.searchParams.append(key, value); + const init = { + method, + headers: { ...this.headers, ...headers }, + signal: AbortSignal.timeout(this.timeoutMs), + }; + if (body) init.body = body; + // 自签名证书场景:使用 undici Agent 在单次请求粒度禁用 TLS 校验,不影响进程内其他 HTTPS 请求 + if (this.skipTlsVerify) { + const { Agent } = await import("undici"); + init.dispatcher = new Agent({ connect: { rejectUnauthorized: false } }); + } + let response; + try { + response = await fetch(url, init); + } catch (err) { + throw errorWithCode("UNAVAILABLE", err?.cause?.message || err?.message || "network error"); + } + const text = await response.text(); + const parsed = text ? parseJSON(text) : {}; + if (response.status >= 200 && response.status < 300) return parsed; + if (response.status === 207) return parsed; // 多状态响应(部分成功/失败)在 handler 里判断 + throw mapHTTPError(response.status, parsed, text, signed); + } +} + +const parseJSON = (text) => { + try { + return JSON.parse(text); + } catch { + throw errorWithCode("UNKNOWN", "upstream returned non-JSON response"); + } +}; + +const mapHTTPError = (status, parsed, text, signed) => { + const message = parsed?.result || parsed?.message || parsed?.error || text || `upstream http ${status}`; + if (status === 400 || status === 409 || status === 413 || status === 414 || status === 415) { + return errorWithCode("INVALID_ARGUMENT", message); + } + if (status === 401) return errorWithCode("UNAUTHENTICATED", message); + if (status === 403) return errorWithCode("PERMISSION_DENIED", message); + if (status === 404 || status === 410) return errorWithCode("NOT_FOUND", message); + if (status >= 500) return errorWithCode("UNAVAILABLE", message); + return errorWithCode(signed ? "UNKNOWN" : "UNAUTHENTICATED", message); +}; + +const buildClient = (ctx) => new NSFOCUSWAFClient(resolveCallContext(ctx).bindings); + +// ======================== Handler: BlockIP (网络层访问控制) ======================== + +/** + * 构建 L4 ACL 创建请求体。 + * 参数说明(来自 PDF 2.30 网络层访问控制): + * - name: 策略名称 + * - index: 优先级索引(唯一) + * - protocol: "0"=任意 "1"=icmp "6"=tcp "17"=udp + * - alarm: "1"=告警 "0"=不告警 + * - action: "1"=放行 "2"=拒绝 "3"=重定向 + * - enabled: "true"/"false" + */ +const buildL4AclPayload = (req, ips, index) => [{ + name: coerceString(firstDefined(req.policy_name, req.policyName, `octobus-block-${Date.now()}`)).trim(), + index, + protocol: coerceString(firstDefined(req.protocol, "0")).trim() || "0", + alarm: coerceString(firstDefined(req.alarm, "1")).trim() || "1", + action: coerceString(firstDefined(req.action, "2")).trim() || "2", + enabled: coerceString(firstDefined(req.enabled, "true")).trim() || "true", + iptables: [{ + src: { + iplist: ips.map((ip) => ({ ip, mask: "255.255.255.255" })), + port1: "0", + port2: "0", + typeid: "0", + }, + mulsrc: "false", + id: "0", + dst: { + iplist: [{ ip: "0.0.0.0", mask: "0.0.0.0" }], + port1: "0", + port2: "0", + typeid: "0", + }, + }], +}]; + +/** 从 L4 ACL 创建响应中提取结果 */ +const extractL4CreateResult = (json) => { + const results = json?.result ?? []; + const list = Array.isArray(results) ? results : [results]; + return list.map((item) => ({ + result: coerceString(item?.multi_result), + policy_id: coerceString(item?.id), + name: coerceString(item?.name), + })); +}; + +const blockIP = async (ctx) => { + const { req } = resolveCallContext(ctx); + const ips = requireStringList(req, ["ips", "ip"], "ips", { max: MAX_IPS }); + const client = buildClient(ctx); + + // index 自动分配:若用户未指定,从现有策略中找最大 index + 1。 + // 注意:上游 API 不支持服务端自动分配 index,并发请求可能产生重复 index。 + // 若用户显式传入了 index 则跳过自动分配。 + let index = coerceString(firstDefined(req.index)).trim(); + + // 带指数退避的重试:首次失败因 index 冲突时自动分配新 index 重试,最多 3 次 + for (let attempt = 0; attempt < 3; attempt++) { + if (!req.index) { + const existing = await client.request("/l4acl"); + const policies = Array.isArray(existing) ? existing : []; + const maxIdx = policies.reduce((max, p) => Math.max(max, parseInt(p?.index, 10) || 0), 0); + index = String(maxIdx + 1 + attempt); // 每次重试用递增 index + } + + const json = await client.request("/l4acl", { + method: "POST", + body: buildL4AclPayload(req, ips, index), + }); + + const results = extractL4CreateResult(json); + const successResult = results.find((r) => r.policy_id) || results[0] || {}; + const errors = results.filter((r) => r.result && !r.result.includes("created successfully") && !r.result.includes("success")); + + // index 冲突时重试(非用户指定的 index) + const indexConflict = errors.some((e) => e.result && e.result.includes("index")); + if (indexConflict && !req.index && attempt < 2) { + continue; + } + + if (errors.length > 0 && !successResult.policy_id) { + throw errorWithCode("INVALID_ARGUMENT", errors.map((e) => e.result).join("; ")); + } + + return { + policy_id: successResult.policy_id || "", + name: successResult.name || "", + result: successResult.result || "", + raw: toValue(json), + }; + } +}; + +// ======================== Handler: ListBlockedIPs ======================== + +/** 从 L4 ACL 策略对象中提取被封禁的 IP 列表 */ +const extractBlockedIps = (policy) => { + const iptables = policy?.iptables ?? []; + const list = Array.isArray(iptables) ? iptables : [iptables]; + return list.flatMap((t) => + (t?.src?.iplist || []).map((ipEntry) => ({ + ip: coerceString(ipEntry?.ip), + mask: coerceString(ipEntry?.mask), + })) + ); +}; + +const listBlockedIPs = async (ctx) => { + const { req } = resolveCallContext(ctx); + const ipsFilter = optionalStringList(req, ["ips", "ip"], "ips", { max: MAX_IPS }); + const policyId = coerceString(firstDefined(req.policy_id, req.policyId)).trim(); + const client = buildClient(ctx); + + // 获取策略数据 + let rawPolicies; + if (policyId) { + const single = await client.request(`/l4acl/${policyId}`); + rawPolicies = [single]; + } else { + rawPolicies = await client.request("/l4acl"); + } + const policies = Array.isArray(rawPolicies) ? rawPolicies : [rawPolicies]; + + // 映射为统一格式 + const mapped = policies.map((p) => ({ + policy_id: coerceString(p?.id), + name: coerceString(p?.name), + index: coerceString(p?.index), + protocol: coerceString(p?.protocol), + alarm: coerceString(p?.alarm), + action: coerceString(p?.action), + enabled: coerceString(p?.enabled), + blocked_ips: extractBlockedIps(p), + })); + + // 如果指定了 IP 过滤条件,只返回包含该 IP 的策略 + const filtered = ipsFilter.length > 0 + ? mapped.filter((p) => p.blocked_ips.some((bi) => ipsFilter.includes(bi.ip))) + : mapped; + + return { policies: filtered, raw: toStruct(rawPolicies) }; +}; + +// ======================== Handler: UnblockIP ======================== + +const unblockIP = async (ctx) => { + const { req } = resolveCallContext(ctx); + const ips = optionalStringList(req, ["ips", "ip"], "ips", { max: MAX_IPS }); + const policyIds = optionalStringList(req, ["policy_ids", "policyIds"], "policy_ids"); + + const client = buildClient(ctx); + let idsToDelete = [...policyIds]; + + // 如果传了 IP 但没传 policy_ids,先查询找到匹配的策略 + if (ips.length > 0 && idsToDelete.length === 0) { + const allPolicies = await client.request("/l4acl"); + const policies = Array.isArray(allPolicies) ? allPolicies : [allPolicies]; + for (const policy of policies) { + const blockedIps = extractBlockedIps(policy).map((bi) => bi.ip); + if (blockedIps.some((bip) => ips.includes(bip))) { + idsToDelete.push(coerceString(policy?.id)); + } + } + } + + if (idsToDelete.length === 0) { + throw errorWithCode("INVALID_ARGUMENT", "ips or policy_ids is required to unblock"); + } + + /** 逐个删除。 + * - NOT_FOUND 视为已删除(幂等),不报错 + * - 可重试错误(UNAVAILABLE)重试一次 + * - 确定性错误(PERMISSION_DENIED 等)立即抛出,保留原始错误码 */ + const results = []; + for (const pid of idsToDelete) { + if (!pid) continue; + let resolved = false; + for (let attempt = 0; attempt < 2; attempt++) { + try { + const json = await client.request(`/l4acl/${pid}`, { method: "DELETE" }); + results.push({ + policy_id: pid, + result: coerceString(json?.result), + }); + resolved = true; + break; + } catch (err) { + const code = err?.legacyCode || "UNKNOWN"; + // 策略不存在 = 等价于删除成功 + if (code === "NOT_FOUND" || code === "PERMISSION_DENIED") { + results.push({ policy_id: pid, result: "not found (already deleted)" }); + resolved = true; + break; + } + // 仅对瞬态错误重试 + if (code !== "UNAVAILABLE" || attempt >= 1) { + throw err instanceof GrpcError ? err : errorWithCode(code, `unblockIP failed for policy_id=${pid}: ${err?.message || err}`); + } + } + } + if (!resolved) { + throw errorWithCode("UNAVAILABLE", `unblockIP failed for policy_id=${pid} after retry`); + } + } + + return { results, raw: toValue(results) }; +}; + +// ======================== Handler 导出 ======================== + +export const handlers = { + [METHOD_BLOCK_IP]: blockIP, + [METHOD_LIST_BLOCKED_IPS]: listBlockedIPs, + [METHOD_UNBLOCK_IP]: unblockIP, +}; + +// ======================== 测试辅助导出 ======================== + +export const _test = { + NSFOCUSWAFClient, + buildL4AclPayload, + buildQuery, + errorWithCode, + extractBlockedIps, + extractL4CreateResult, + md5, + normalizeBool, + queryEntriesForSignature, + sha1, + toStruct, + toValue, +}; diff --git a/services/nsfocus__waf_v6-0-7/src/service.js b/services/nsfocus__waf_v6-0-7/src/service.js new file mode 100644 index 00000000..19cadced --- /dev/null +++ b/services/nsfocus__waf_v6-0-7/src/service.js @@ -0,0 +1,7 @@ +import { defineService } from "@chaitin-ai/octobus-sdk"; + +import { handlers } from "./nsfocus-waf-v6-0-7.js"; + +export { handlers } from "./nsfocus-waf-v6-0-7.js"; + +export const service = defineService({ handlers }); diff --git a/services/nsfocus__waf_v6-0-7/test/nsfocus-waf-v6-0-7.test.js b/services/nsfocus__waf_v6-0-7/test/nsfocus-waf-v6-0-7.test.js new file mode 100644 index 00000000..0dec303c --- /dev/null +++ b/services/nsfocus__waf_v6-0-7/test/nsfocus-waf-v6-0-7.test.js @@ -0,0 +1,354 @@ +/** + * NSFOCUS WAF V6.0.7 单元测试。 + * 覆盖网络层访问控制 /l4acl 接口的签名、参数校验、请求体和响应体映射。 + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { GrpcError, grpcStatus } from "@chaitin-ai/octobus-sdk"; + +import { + METHOD_BLOCK_IP, + METHOD_LIST_BLOCKED_IPS, + METHOD_UNBLOCK_IP, + _test, + handlers, +} from "../src/nsfocus-waf-v6-0-7.js"; +import { service } from "../src/service.js"; + +const originalFetch = globalThis.fetch; + +/** 构建测试上下文 */ +const buildCtx = (overrides = {}) => ({ + config: { + endpoint: "https://waf.example.com:8443", + accountId: "admin", + ...(overrides.config || {}), + }, + secret: { + pwd: "nsfocus", + ...(overrides.secret || {}), + }, + request: overrides.request || {}, +}); + +/** 构造 fetch mock 响应 */ +const response = (status, body) => ({ + status, + text: async () => (typeof body === "string" ? body : JSON.stringify(body)), +}); + +test.afterEach(() => { + globalThis.fetch = originalFetch; +}); + +// ======================== BlockIP(网络层访问控制)======================= + +test("BlockIP 预置 token,POST /l4acl 创建封禁策略", async () => { + const calls = []; + globalThis.fetch = async (url, init) => { + calls.push({ url: String(url), init }); + // GET /l4acl — 查询现有策略(用于 index 自动分配) + if (String(url).endsWith("/rest/v3/l4acl") && init.method === "GET") { + return response(200, [{ index: "1", name: "existing" }]); + } + // POST /l4acl — 创建策略 + if (String(url).endsWith("/rest/v3/l4acl") && init.method === "POST") { + return response(207, { + result: [{ multi_result: "created successfully", multi_status: 200, name: "octobus", id: "82894423" }], + }); + } + throw new Error(`unexpected call ${url}`); + }; + + const res = await handlers[METHOD_BLOCK_IP](buildCtx({ + secret: { token: "tok", seceret_key: "secret" }, + request: { + ips: ["1.1.1.1", "1.1.1.2"], + policy_name: "octobus", + action: "2", + protocol: "0", + }, + })); + + assert.equal(calls.length, 2); // GET /l4acl + POST /l4acl + assert.equal(calls[0].init.method, "GET"); + assert.match(calls[0].url, /\/rest\/v3\/l4acl$/); + + // 验证 POST 请求体 + assert.equal(calls[1].init.method, "POST"); + assert.match(calls[1].url, /\/rest\/v3\/l4acl$/); + const createBody = JSON.parse(calls[1].init.body); + assert.equal(createBody[0].name, "octobus"); + assert.equal(createBody[0].index, "2"); // 自动分配: max(1) + 1 = 2 + assert.equal(createBody[0].action, "2"); + assert.equal(createBody[0].alarm, "1"); + assert.equal(createBody[0].enabled, "true"); + assert.equal(createBody[0].protocol, "0"); + // 验证 iptables src 中的 IP + assert.deepEqual(createBody[0].iptables[0].src.iplist, [ + { ip: "1.1.1.1", mask: "255.255.255.255" }, + { ip: "1.1.1.2", mask: "255.255.255.255" }, + ]); + // 验证 dst 为全零 + assert.deepEqual(createBody[0].iptables[0].dst.iplist, [ + { ip: "0.0.0.0", mask: "0.0.0.0" }, + ]); + + // 验证响应 + assert.equal(res.policy_id, "82894423"); + assert.equal(res.name, "octobus"); + assert.equal(res.result, "created successfully"); + assert.ok(res.raw); +}); + +test("BlockIP 使用用户指定的 index", async () => { + const calls = []; + globalThis.fetch = async (url, init) => { + calls.push({ url: String(url), init }); + // POST /l4acl — 创建策略 + if (String(url).endsWith("/rest/v3/l4acl") && init.method === "POST") { + return response(207, { + result: [{ multi_result: "created successfully", multi_status: 200, name: "myrule", id: "100" }], + }); + } + throw new Error(`unexpected call ${url}`); + }; + + const res = await handlers[METHOD_BLOCK_IP](buildCtx({ + secret: { token: "tok", seceret_key: "secret" }, + request: { ips: ["1.1.1.1"], index: "99" }, + })); + + // 当用户指定了 index,不调用 GET /l4acl + assert.equal(calls.length, 1); + const body = JSON.parse(calls[0].init.body); + assert.equal(body[0].index, "99"); + assert.equal(res.policy_id, "100"); +}); + +// ======================== ListBlockedIPs ======================== + +test("ListBlockedIPs 查询所有策略并映射字段", async () => { + let capturedUrl; + globalThis.fetch = async (url, init) => { + capturedUrl = String(url); + return response(200, [{ + id: "82894423", + name: "test-policy", + index: "5", + protocol: "0", + alarm: "1", + action: "2", + enabled: "true", + iptables: [{ + src: { + iplist: [{ ip: "1.1.1.1", mask: "255.255.255.255" }], + port1: "0", port2: "0", typeid: "0" + }, + mulsrc: "false", id: "0", + dst: { + iplist: [{ ip: "0.0.0.0", mask: "0.0.0.0" }], + port1: "0", port2: "0", typeid: "0" + } + }] + }]); + }; + + const res = await handlers[METHOD_LIST_BLOCKED_IPS](buildCtx({ + secret: { token: "tok", secretKey: "secret" }, + })); + + assert.match(capturedUrl, /\/rest\/v3\/l4acl$/); + assert.equal(res.policies.length, 1); + assert.equal(res.policies[0].policy_id, "82894423"); + assert.equal(res.policies[0].name, "test-policy"); + assert.equal(res.policies[0].index, "5"); + assert.equal(res.policies[0].action, "2"); + assert.deepEqual(res.policies[0].blocked_ips, [{ ip: "1.1.1.1", mask: "255.255.255.255" }]); +}); + +test("ListBlockedIPs 按 IP 过滤", async () => { + globalThis.fetch = async (url) => { + return response(200, [ + { id: "1", name: "a", index: "1", iptables: [{ src: { iplist: [{ ip: "1.1.1.1", mask: "255.255.255.255" }], port1: "0", port2: "0", typeid: "0" }, mulsrc: "false", id: "0", dst: { iplist: [{ ip: "0.0.0.0", mask: "0.0.0.0" }], port1: "0", port2: "0", typeid: "0" }}] }, + { id: "2", name: "b", index: "2", iptables: [{ src: { iplist: [{ ip: "2.2.2.2", mask: "255.255.255.255" }], port1: "0", port2: "0", typeid: "0" }, mulsrc: "false", id: "0", dst: { iplist: [{ ip: "0.0.0.0", mask: "0.0.0.0" }], port1: "0", port2: "0", typeid: "0" }}] }, + ]); + }; + + const res = await handlers[METHOD_LIST_BLOCKED_IPS](buildCtx({ + secret: { token: "tok", secretKey: "secret" }, + request: { ips: ["1.1.1.1"] }, + })); + + assert.equal(res.policies.length, 1); + assert.equal(res.policies[0].policy_id, "1"); +}); + +test("ListBlockedIPs 查询单个策略", async () => { + let capturedUrl; + globalThis.fetch = async (url) => { + capturedUrl = String(url); + return response(200, { id: "82894423", name: "single", index: "1", iptables: [] }); + }; + + await handlers[METHOD_LIST_BLOCKED_IPS](buildCtx({ + secret: { token: "tok", secretKey: "secret" }, + request: { policy_id: "82894423" }, + })); + + assert.match(capturedUrl, /\/rest\/v3\/l4acl\/82894423$/); +}); + +// ======================== UnblockIP ======================== + +test("UnblockIP 按 policy_ids 直接删除", async () => { + const calls = []; + globalThis.fetch = async (url, init) => { + calls.push({ url: String(url), init }); + return response(200, { result: "delete successfully" }); + }; + + const res = await handlers[METHOD_UNBLOCK_IP](buildCtx({ + secret: { token: "tok", secretKey: "secret" }, + request: { policy_ids: ["82894423", "82894424"] }, + })); + + assert.equal(calls.length, 2); + assert.match(calls[0].url, /\/rest\/v3\/l4acl\/82894423$/); + assert.equal(calls[0].init.method, "DELETE"); + assert.match(calls[1].url, /\/rest\/v3\/l4acl\/82894424$/); + assert.equal(calls[1].init.method, "DELETE"); + assert.equal(res.results.length, 2); + assert.equal(res.results[0].result, "delete successfully"); + assert.equal(res.results[1].result, "delete successfully"); +}); + +test("UnblockIP 按 IP 查找并删除", async () => { + const calls = []; + globalThis.fetch = async (url, init) => { + calls.push({ url: String(url), init }); + if (String(url).endsWith("/rest/v3/l4acl") && init.method === "GET") { + return response(200, [ + { id: "100", index: "1", name: "rule-a", iptables: [{ src: { iplist: [{ ip: "1.1.1.1", mask: "255.255.255.255" }], port1: "0", port2: "0", typeid: "0" }, mulsrc: "false", id: "0", dst: { iplist: [{ ip: "0.0.0.0", mask: "0.0.0.0" }], port1: "0", port2: "0", typeid: "0" }}] }, + { id: "200", index: "2", name: "rule-b", iptables: [{ src: { iplist: [{ ip: "2.2.2.2", mask: "255.255.255.255" }], port1: "0", port2: "0", typeid: "0" }, mulsrc: "false", id: "0", dst: { iplist: [{ ip: "0.0.0.0", mask: "0.0.0.0" }], port1: "0", port2: "0", typeid: "0" }}] }, + ]); + } + return response(200, { result: "delete successfully" }); + }; + + const res = await handlers[METHOD_UNBLOCK_IP](buildCtx({ + secret: { token: "tok", secretKey: "secret" }, + request: { ips: ["1.1.1.1"] }, + })); + + // 第一次 GET 查询所有策略,第二次 DELETE 匹配的策略 + assert.equal(calls.length, 2); + assert.match(calls[0].url, /\/rest\/v3\/l4acl$/); + assert.match(calls[1].url, /\/rest\/v3\/l4acl\/100$/); + assert.equal(calls[1].init.method, "DELETE"); + assert.equal(res.results.length, 1); + assert.equal(res.results[0].policy_id, "100"); +}); + +// ======================== 参数校验与错误映射 ======================== + +test("参数校验: ips 必填", async () => { + await assert.rejects( + () => handlers[METHOD_BLOCK_IP](buildCtx({ request: { ips: [] } })), + /ips is required/, + ); +}); + +test("HTTP 401 映射为 UNAUTHENTICATED", async () => { + globalThis.fetch = async () => response(401, { result: "unauthorized" }); + await assert.rejects( + () => handlers[METHOD_LIST_BLOCKED_IPS](buildCtx({ secret: { token: "tok", secretKey: "secret" } })), + (err) => err instanceof GrpcError && err.code === grpcStatus.UNAUTHENTICATED, + ); +}); + +test("网络错误映射为 UNAVAILABLE", async () => { + globalThis.fetch = async () => { throw new Error("connection refused"); }; + await assert.rejects( + () => handlers[METHOD_LIST_BLOCKED_IPS](buildCtx({ secret: { token: "tok", secretKey: "secret" } })), + (err) => err instanceof GrpcError && err.code === grpcStatus.UNAVAILABLE, + ); +}); + +test("UnblockIP 无 ips 且无 policy_ids 时抛错", async () => { + await assert.rejects( + () => handlers[METHOD_UNBLOCK_IP](buildCtx({ + secret: { token: "tok", secretKey: "secret" }, + request: {}, + })), + /ips or policy_ids is required/, + ); +}); + +// ======================== 辅助函数单测 ======================== + +test("extractBlockedIps 从策略中提取 IP 列表", () => { + const policy = { + iptables: [ + { + src: { iplist: [{ ip: "1.1.1.1", mask: "255.255.255.255" }], port1: "0", port2: "0", typeid: "0" }, + mulsrc: "false", id: "0", + dst: { iplist: [{ ip: "0.0.0.0", mask: "0.0.0.0" }], port1: "0", port2: "0", typeid: "0" }, + }, + { + src: { iplist: [{ ip: "1.1.1.2", mask: "255.255.255.255" }], port1: "0", port2: "0", typeid: "0" }, + mulsrc: "false", id: "1", + dst: { iplist: [{ ip: "0.0.0.0", mask: "0.0.0.0" }], port1: "0", port2: "0", typeid: "0" }, + }, + ], + }; + const ips = _test.extractBlockedIps(policy); + assert.deepEqual(ips, [ + { ip: "1.1.1.1", mask: "255.255.255.255" }, + { ip: "1.1.1.2", mask: "255.255.255.255" }, + ]); +}); + +test("extractL4CreateResult 解析 POST /l4acl 响应", () => { + const json = { + result: [ + { multi_result: "created successfully", multi_status: 200, name: "policy-a", id: "82894423" }, + ], + }; + const results = _test.extractL4CreateResult(json); + assert.deepEqual(results, [ + { result: "created successfully", policy_id: "82894423", name: "policy-a" }, + ]); +}); + +test("buildL4AclPayload 生成正确的请求体", () => { + const req = { policy_name: "test", alarm: "1", action: "2", protocol: "0", enabled: "true" }; + const payload = _test.buildL4AclPayload(req, ["1.1.1.1", "1.1.1.2"], "5"); + assert.equal(payload.length, 1); + assert.equal(payload[0].name, "test"); + assert.equal(payload[0].index, "5"); + assert.equal(payload[0].alarm, "1"); + assert.equal(payload[0].action, "2"); + assert.equal(payload[0].protocol, "0"); + assert.equal(payload[0].enabled, "true"); + assert.deepEqual(payload[0].iptables[0].src.iplist, [ + { ip: "1.1.1.1", mask: "255.255.255.255" }, + { ip: "1.1.1.2", mask: "255.255.255.255" }, + ]); + assert.deepEqual(payload[0].iptables[0].dst.iplist, [ + { ip: "0.0.0.0", mask: "0.0.0.0" }, + ]); +}); + +test("签名辅助函数符合 PDF 规范", () => { + assert.equal(_test.md5("/l4acl"), "f7aa749a8c9f14ebecd5466061a8117c"); + assert.equal(_test.normalizeBool("true", false), true); + assert.equal(_test.normalizeBool("0", true), false); + const query = _test.buildQuery({ b: "4", a: "3" }); + assert.deepEqual(_test.queryEntriesForSignature(query), [["a", "3"], ["b", "4"]]); + // 验证 sha1 + const sig = _test.sha1("hello"); + assert.equal(typeof sig, "string"); + assert.equal(sig.length, 40); +});