From d7d70187890680f451a8799cdfe19533127be896 Mon Sep 17 00:00:00 2001 From: "jiashuai.ning" <3258639272@qq.com> Date: Wed, 1 Jul 2026 00:07:57 +0800 Subject: [PATCH 1/4] feat(services): add CTDSG FW service package --- services/bin/ctdsg-fw.js | 10 + services/bin/octobus-tentacles.js | 4 + services/ctdsg__fw/README.md | 151 +++++++ services/ctdsg__fw/bin/ctdsg-fw.js | 6 + services/ctdsg__fw/config.schema.json | 78 ++++ services/ctdsg__fw/package.json | 12 + services/ctdsg__fw/proto/ctdsg_fw.proto | 50 +++ services/ctdsg__fw/secret.schema.json | 15 + services/ctdsg__fw/service.json | 41 ++ services/ctdsg__fw/src/ctdsg-fw.js | 496 +++++++++++++++++++++++ services/ctdsg__fw/src/service.js | 7 + services/ctdsg__fw/test/ctdsg-fw.test.js | 362 +++++++++++++++++ services/ctdsg__fw/test/mock_upstream.js | 88 ++++ services/package.json | 3 + 14 files changed, 1323 insertions(+) create mode 100755 services/bin/ctdsg-fw.js create mode 100644 services/ctdsg__fw/README.md create mode 100755 services/ctdsg__fw/bin/ctdsg-fw.js create mode 100644 services/ctdsg__fw/config.schema.json create mode 100644 services/ctdsg__fw/package.json create mode 100644 services/ctdsg__fw/proto/ctdsg_fw.proto create mode 100644 services/ctdsg__fw/secret.schema.json create mode 100644 services/ctdsg__fw/service.json create mode 100644 services/ctdsg__fw/src/ctdsg-fw.js create mode 100644 services/ctdsg__fw/src/service.js create mode 100644 services/ctdsg__fw/test/ctdsg-fw.test.js create mode 100644 services/ctdsg__fw/test/mock_upstream.js diff --git a/services/bin/ctdsg-fw.js b/services/bin/ctdsg-fw.js new file mode 100755 index 00000000..c5f20d17 --- /dev/null +++ b/services/bin/ctdsg-fw.js @@ -0,0 +1,10 @@ +#!/usr/bin/env node + +import { fileURLToPath } from "node:url"; +import { runServiceMain } from "@chaitin-ai/octobus-sdk"; + +import { service } from "../ctdsg__fw/src/service.js"; + +runServiceMain(service, { + entryFile: fileURLToPath(new URL("../ctdsg__fw/bin/ctdsg-fw.js", import.meta.url)), +}); diff --git a/services/bin/octobus-tentacles.js b/services/bin/octobus-tentacles.js index 42338e5c..3880405b 100755 --- a/services/bin/octobus-tentacles.js +++ b/services/bin/octobus-tentacles.js @@ -21,6 +21,10 @@ const services = { entryFile: "../chaitin__cloudatlas/bin/cloudatlas.js", serviceModule: "../chaitin__cloudatlas/src/service.js", }, + "ctdsg-fw": { + entryFile: "../ctdsg__fw/bin/ctdsg-fw.js", + serviceModule: "../ctdsg__fw/src/service.js", + }, "das-gateway-v3": { entryFile: "../das__gateway_v3/bin/das-gateway-v3.js", serviceModule: "../das__gateway_v3/src/service.js", diff --git a/services/ctdsg__fw/README.md b/services/ctdsg__fw/README.md new file mode 100644 index 00000000..7197c862 --- /dev/null +++ b/services/ctdsg__fw/README.md @@ -0,0 +1,151 @@ +# CTDSG FW + +OctoBus service package for CTDSG blacklist IP and domain block or unblock APIs. + +## Import + +```bash +octobus service import --id ctdsg-fw ./services//ctdsg__fw +``` + +## Behavior + +- `BlockIP` maps to `POST {host}/api.php/inter/Inter?opt=addPatchblack2` and sends a single-element JSON array with newline-separated `ip_area` entries. +- `UnblockIP` maps to `POST {host}/api.php/inter/Inter?opt=delblack2` and sends newline-separated `name` entries. +- `BlockDomain` maps to `POST {host}/api.php/inter/Inter?opt=addPatchblack2` and sends a single-element JSON array with newline-separated `domainname` entries. +- `UnblockDomain` maps to `POST {host}/api.php/inter/Inter?opt=delblack2` and sends newline-separated `name` entries. +- Requests are signed with CTDSG HMAC-MD5 headers: `hy-bz-api-app-id`, `hy-bz-api-timestamp`, and `hy-bz-api-signature`. +- Self-signed device certificates are handled inside the adapter when `skipTlsVerify` / `tlsInsecureSkipVerify` / `insecureSkipVerify` is enabled. +- HTTP responses are returned as normalized `DeviceHttpResponse` objects, including status, response headers, raw body, parsed JSON when available, and effective URL. + +## Gateway Setup + +Start the gateway: + +```bash +./bin/octobus serve +``` + +Import the CTDSG service package: + +```bash +./bin/octobus service import ctdsg-fw ./services//ctdsg__fw +``` + +Create and start a test instance: + +```bash +./bin/octobus instance create ctdsg-fw-test \ + --service ctdsg-fw \ + --config-json '{"host":"https://10.211.194.22:9090","appId":"hybzapi","skipTlsVerify":true}' \ + --secret-json '{"secretKey":"REDACTED"}' +``` + +Create a capset and expose the instance: + +```bash +./bin/octobus capset create dev --name DevAgent +./bin/octobus capset add-instance dev ctdsg-fw-test +``` + +Confirm the exposed catalog: + +```bash +./bin/octobus catalog dev --all --json +``` + +## Connect RPC Calls + +### BlockIP + +```bash +curl -X POST \ + http://127.0.0.1:9000/capsets/dev/connect/ctdsg-fw-test/CTDSG_FW.CTDSG_FW/BlockIP \ + -H 'Content-Type: application/json' \ + -d '{"ips":["43.143.110.163"],"permanent":false,"punishTime":1,"timeUnit":1}' +``` + +### UnblockIP + +```bash +curl -X POST \ + http://127.0.0.1:9000/capsets/dev/connect/ctdsg-fw-test/CTDSG_FW.CTDSG_FW/UnblockIP \ + -H 'Content-Type: application/json' \ + -d '{"ips":["43.143.110.163"]}' +``` + +### BlockDomain + +```bash +curl -X POST \ + http://127.0.0.1:9000/capsets/dev/connect/ctdsg-fw-test/CTDSG_FW.CTDSG_FW/BlockDomain \ + -H 'Content-Type: application/json' \ + -d '{"domains":["fget-career.com"],"permanent":false,"punishTime":1,"timeUnit":1}' +``` + +### UnblockDomain + +```bash +curl -X POST \ + http://127.0.0.1:9000/capsets/dev/connect/ctdsg-fw-test/CTDSG_FW.CTDSG_FW/UnblockDomain \ + -H 'Content-Type: application/json' \ + -d '{"domains":["fget-career.com"]}' +``` + +## MCP Calls + +List tools: + +```bash +curl -X POST http://127.0.0.1:9000/capsets/dev/mcp \ + -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' +``` + +Current tool names: + +- `ctdsg-fw__ctdsg-fw-test__block_domain` +- `ctdsg-fw__ctdsg-fw-test__block_i_p` +- `ctdsg-fw__ctdsg-fw-test__unblock_domain` +- `ctdsg-fw__ctdsg-fw-test__unblock_i_p` + +Call `BlockIP` through MCP: + +```bash +curl -X POST http://127.0.0.1:9000/capsets/dev/mcp \ + -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"ctdsg-fw__ctdsg-fw-test__block_i_p","arguments":{"ips":["43.143.110.163"],"permanent":false,"punish_time":1,"time_unit":1}}}' +``` + +## What Is Already Verified + +Already verified against the real gateway + device path: + +- `BlockIP` ✅ +- `UnblockIP` ✅ +- `BlockDomain` ✅ +- `UnblockDomain` ✅ + +Verified instance / capset used during testing: + +- capset: `dev` +- instance: `ctdsg-fw-test` + +## Test Notes + +- For self-signed devices, set `skipTlsVerify: true` in the instance config. +- `appId` and `secretKey` must match the device’s real configuration. +- Preserve the following when collecting evidence for PRs: + - request body + - response body + - HTTP status + - `effectiveUrl` + +## Local Checks + +```bash +cd services +npm run validate -- --service-dir ctdsg__fw +npm test -- --service-dir ctdsg__fw +npm run pack:check +``` diff --git a/services/ctdsg__fw/bin/ctdsg-fw.js b/services/ctdsg__fw/bin/ctdsg-fw.js new file mode 100755 index 00000000..8e0066ef --- /dev/null +++ b/services/ctdsg__fw/bin/ctdsg-fw.js @@ -0,0 +1,6 @@ +#!/usr/bin/env node +import { runServiceMain } from '@chaitin-ai/octobus-sdk'; + +import { service } from '../src/service.js'; + +runServiceMain(service); diff --git a/services/ctdsg__fw/config.schema.json b/services/ctdsg__fw/config.schema.json new file mode 100644 index 00000000..2a59078c --- /dev/null +++ b/services/ctdsg__fw/config.schema.json @@ -0,0 +1,78 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": true, + "properties": { + "host": { + "type": "string", + "description": "CTDSG management base URL with http(s) scheme and port, for example https://198.51.100.10:9090." + }, + "restBaseUrl": { + "type": "string", + "description": "Alias for host." + }, + "baseUrl": { + "type": "string", + "description": "Alias for host." + }, + "rest_base_url": { + "type": "string", + "description": "Alias for host." + }, + "base_url": { + "type": "string", + "description": "Alias for host." + }, + "appId": { + "type": "string", + "default": "hybzapi", + "description": "CTDSG signature app id. Defaults to hybzapi per the device API document." + }, + "app_id": { + "type": "string", + "description": "Alias for appId." + }, + "apiPath": { + "type": "string", + "default": "/api.php/inter/Inter", + "description": "Management API path prefix." + }, + "api_path": { + "type": "string", + "description": "Alias for apiPath." + }, + "timeoutMs": { + "type": "integer", + "minimum": 1, + "default": 5000, + "description": "HTTP timeout in milliseconds." + }, + "timeout_ms": { + "type": "integer", + "minimum": 1, + "description": "Alias for timeoutMs." + }, + "skipTlsVerify": { + "type": "boolean", + "default": false, + "description": "Skip TLS certificate verification for private deployments." + }, + "tlsInsecureSkipVerify": { + "type": "boolean", + "default": false, + "description": "Legacy alias for skipTlsVerify." + }, + "insecureSkipVerify": { + "type": "boolean", + "default": false, + "description": "Alias for skipTlsVerify." + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "Optional additional HTTP headers." + } + } +} diff --git a/services/ctdsg__fw/package.json b/services/ctdsg__fw/package.json new file mode 100644 index 00000000..8986cfc9 --- /dev/null +++ b/services/ctdsg__fw/package.json @@ -0,0 +1,12 @@ +{ + "name": "ctdsg-fw", + "version": "0.0.0", + "private": true, + "type": "module", + "bin": { + "ctdsg-fw": "bin/ctdsg-fw.js" + }, + "dependencies": { + "@chaitin-ai/octobus-sdk": "^0.5.0" + } +} diff --git a/services/ctdsg__fw/proto/ctdsg_fw.proto b/services/ctdsg__fw/proto/ctdsg_fw.proto new file mode 100644 index 00000000..3f8bf550 --- /dev/null +++ b/services/ctdsg__fw/proto/ctdsg_fw.proto @@ -0,0 +1,50 @@ +syntax = "proto3"; + +package CTDSG_FW; + +import "google/protobuf/struct.proto"; +import "google/protobuf/wrappers.proto"; + +option go_package = "miner/grpc-service/CTDSG_FW"; + +service CTDSG_FW { + rpc BlockIP(BlockIPRequest) returns (DeviceHttpResponse) {} + rpc UnblockIP(UnblockIPRequest) returns (DeviceHttpResponse) {} + rpc BlockDomain(BlockDomainRequest) returns (DeviceHttpResponse) {} + rpc UnblockDomain(UnblockDomainRequest) returns (DeviceHttpResponse) {} +} + +message BlockIPRequest { + repeated string ips = 1; + google.protobuf.BoolValue permanent = 2; + google.protobuf.UInt32Value punish_time = 3; + google.protobuf.UInt32Value time_unit = 4; +} + +message UnblockIPRequest { + repeated string ips = 1; +} + +message BlockDomainRequest { + repeated string domains = 1; + google.protobuf.BoolValue permanent = 2; + google.protobuf.UInt32Value punish_time = 3; + google.protobuf.UInt32Value time_unit = 4; +} + +message UnblockDomainRequest { + repeated string domains = 1; +} + +message DeviceHttpResponse { + int32 status_code = 1; + repeated HttpHeader headers = 2; + string raw_body = 3; + google.protobuf.Struct body_json = 4; + string effective_url = 5; +} + +message HttpHeader { + string key = 1; + repeated string values = 2; +} diff --git a/services/ctdsg__fw/secret.schema.json b/services/ctdsg__fw/secret.schema.json new file mode 100644 index 00000000..3f06b07c --- /dev/null +++ b/services/ctdsg__fw/secret.schema.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": true, + "properties": { + "secretKey": { + "type": "string", + "description": "CTDSG HMAC-MD5 signing secret key." + }, + "secret_key": { + "type": "string", + "description": "Alias for secretKey." + } + } +} diff --git a/services/ctdsg__fw/service.json b/services/ctdsg__fw/service.json new file mode 100644 index 00000000..4da0c22f --- /dev/null +++ b/services/ctdsg__fw/service.json @@ -0,0 +1,41 @@ +{ + "schema": "chaitin.octobus.service.v1", + "name": "ctdsg-fw", + "displayName": "CTDSG FW", + "description": "OctoBus package for CTDSG blacklist IP and domain block or unblock APIs.", + "runtime": { + "mode": "long-running" + }, + "proto": { + "roots": [ + "proto" + ], + "files": [ + "proto/ctdsg_fw.proto" + ] + }, + "configSchema": "config.schema.json", + "secretSchema": "secret.schema.json", + "sdk": { + "cli": { + "commands": { + "CTDSG_FW.CTDSG_FW/BlockIP": { + "name": "block-ip", + "description": "Add one or more IPs into the CTDSG blacklist." + }, + "CTDSG_FW.CTDSG_FW/UnblockIP": { + "name": "unblock-ip", + "description": "Remove one or more IPs from the CTDSG blacklist." + }, + "CTDSG_FW.CTDSG_FW/BlockDomain": { + "name": "block-domain", + "description": "Add one or more domains into the CTDSG blacklist." + }, + "CTDSG_FW.CTDSG_FW/UnblockDomain": { + "name": "unblock-domain", + "description": "Remove one or more domains from the CTDSG blacklist." + } + } + } + } +} diff --git a/services/ctdsg__fw/src/ctdsg-fw.js b/services/ctdsg__fw/src/ctdsg-fw.js new file mode 100644 index 00000000..ae29c23a --- /dev/null +++ b/services/ctdsg__fw/src/ctdsg-fw.js @@ -0,0 +1,496 @@ +import { createHmac } from 'node:crypto'; + +import { GrpcError, grpcStatus } from '@chaitin-ai/octobus-sdk'; + +export const BLOCK_IP_PATH = '/CTDSG_FW.CTDSG_FW/BlockIP'; +export const UNBLOCK_IP_PATH = '/CTDSG_FW.CTDSG_FW/UnblockIP'; +export const BLOCK_DOMAIN_PATH = '/CTDSG_FW.CTDSG_FW/BlockDomain'; +export const UNBLOCK_DOMAIN_PATH = '/CTDSG_FW.CTDSG_FW/UnblockDomain'; + +export const METHOD_BLOCK_IP_FULL = 'CTDSG_FW.CTDSG_FW/BlockIP'; +export const METHOD_UNBLOCK_IP_FULL = 'CTDSG_FW.CTDSG_FW/UnblockIP'; +export const METHOD_BLOCK_DOMAIN_FULL = 'CTDSG_FW.CTDSG_FW/BlockDomain'; +export const METHOD_UNBLOCK_DOMAIN_FULL = 'CTDSG_FW.CTDSG_FW/UnblockDomain'; + +export const DEFAULT_TIMEOUT_MS = 5000; +export const DEFAULT_APP_ID = 'hybzapi'; +export const DEFAULT_API_PATH = '/api.php/inter/Inter'; +export const TIME_PERMANENT = 1; +export const TIME_TEMPORARY = 0; +export const TYPE_IP = 0; +export const TYPE_DOMAIN = 1; + +const grpcCodeFor = (code) => ({ + INVALID_ARGUMENT: grpcStatus.INVALID_ARGUMENT, + FAILED_PRECONDITION: grpcStatus.FAILED_PRECONDITION, + 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 unwrapScalar = (value) => { + if (value === undefined || value === null) return undefined; + if (typeof value === 'object' && value !== null && hasOwn(value, 'value')) return unwrapScalar(value.value); + return value; +}; + +const firstDefined = (...vals) => vals.find((val) => val !== undefined && val !== null); + +const normalizeString = (value) => { + const raw = unwrapScalar(value); + if (raw === undefined || raw === null) return ''; + return String(raw).trim(); +}; + +const optionalString = (value) => { + const text = normalizeString(value); + return text ? text : undefined; +}; + +const requireString = (value, fieldName) => { + const text = normalizeString(value); + if (!text) throw errorWithCode('INVALID_ARGUMENT', `${fieldName} is required`); + return text; +}; + +const optionalUint32 = (value) => { + const raw = unwrapScalar(value); + if (raw === undefined || raw === null || raw === '') return undefined; + const num = Number(raw); + if (!Number.isFinite(num) || Number.isNaN(num) || num < 0) return undefined; + return Math.trunc(num); +}; + +const optionalBoolean = (value) => { + const raw = unwrapScalar(value); + if (raw === undefined || raw === null || raw === '') return undefined; + if (typeof raw === 'boolean') return raw; + if (typeof raw === 'number') return raw !== 0; + if (typeof raw === 'string') { + const normalized = raw.trim().toLowerCase(); + if (['true', '1', 'yes', 'y', 'on'].includes(normalized)) return true; + if (['false', '0', 'no', 'n', 'off'].includes(normalized)) return false; + } + return undefined; +}; + +const mergedBindings = (ctx = {}) => ({ + ...(ctx?.config ?? {}), + ...(ctx?.secret ?? {}), + ...(ctx?.bindings ?? {}), +}); + +const resolveCallContext = (ctx = {}) => ({ + ...ctx, + bindings: mergedBindings(ctx), + limits: ctx.limits ?? {}, + meta: ctx.meta ?? {}, + req: ctx.req ?? ctx.request ?? {}, +}); + +const normalizeBaseUrl = (value) => { + const text = optionalString(value); + if (!text) return ''; + if (!/^https?:\/\//i.test(text)) return ''; + return text.replace(/\/+$/, ''); +}; + +const resolveHost = (ctx = {}) => { + const req = ctx.req || {}; + const bindings = ctx.bindings || {}; + for (const candidate of [ + req.host, + req.baseUrl, + req.base_url, + bindings.host, + bindings.restBaseUrl, + bindings.baseUrl, + bindings.rest_base_url, + bindings.base_url, + ]) { + const normalized = normalizeBaseUrl(candidate); + if (normalized) return normalized; + } + throw errorWithCode('INVALID_ARGUMENT', 'host/baseUrl is required and must include http/https'); +}; + +const resolveAppId = (ctx = {}) => { + const req = ctx.req || {}; + const bindings = ctx.bindings || {}; + return optionalString(firstDefined(req.appId, req.app_id, bindings.appId, bindings.app_id)) || DEFAULT_APP_ID; +}; + +const resolveApiPath = (ctx = {}) => { + const req = ctx.req || {}; + const bindings = ctx.bindings || {}; + const path = optionalString(firstDefined(req.apiPath, req.api_path, bindings.apiPath, bindings.api_path)) || DEFAULT_API_PATH; + return path.startsWith('/') ? path : `/${path}`; +}; + +const resolveSecretKey = (ctx = {}) => { + const req = ctx.req || {}; + const bindings = ctx.bindings || {}; + return requireString(firstDefined(req.secretKey, req.secret_key, bindings.secretKey, bindings.secret_key), 'secretKey'); +}; + +const resolveTimeoutMs = (ctx = {}) => { + const req = ctx.req || {}; + const bindings = ctx.bindings || {}; + const limits = ctx.limits || {}; + for (const candidate of [ + optionalUint32(req.timeoutMs), + optionalUint32(req.timeout_ms), + optionalUint32(bindings.timeoutMs), + optionalUint32(bindings.timeout_ms), + optionalUint32(limits.timeoutMs), + DEFAULT_TIMEOUT_MS, + ]) { + if (Number.isFinite(candidate) && candidate > 0) return Math.trunc(candidate); + } + /* node:coverage ignore next */ + return DEFAULT_TIMEOUT_MS; +}; + +const toBoolean = (value) => { + const raw = unwrapScalar(value); + if (typeof raw === 'boolean') return raw; + if (typeof raw === 'number') return raw !== 0; + if (typeof raw === 'string') { + const normalized = raw.trim().toLowerCase(); + if (['true', '1', 'yes', 'y', 'on'].includes(normalized)) return true; + if (['false', '0', 'no', 'n', 'off', ''].includes(normalized)) return false; + } + return false; +}; + +const shouldSkipTlsVerify = (ctx = {}) => { + const bindings = ctx.bindings || {}; + return toBoolean(bindings.skipTlsVerify) || toBoolean(bindings.tlsInsecureSkipVerify) || toBoolean(bindings.insecureSkipVerify); +}; + +const withTlsBypass = async (ctx, fn) => { + if (!shouldSkipTlsVerify(ctx)) return await fn(); + const hadValue = hasOwn(process.env, 'NODE_TLS_REJECT_UNAUTHORIZED'); + const previous = process.env.NODE_TLS_REJECT_UNAUTHORIZED; + process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; + try { + return await fn(); + } finally { + if (hadValue) process.env.NODE_TLS_REJECT_UNAUTHORIZED = previous; + else delete process.env.NODE_TLS_REJECT_UNAUTHORIZED; + } +}; + +const buildHeaders = (ctx = {}, extra = {}) => { + const bindings = ctx.bindings || {}; + const meta = ctx.meta || {}; + return { + ...(bindings.headers || {}), + 'x-engine-instance': meta.instance_id || meta.instanceId || 'unknown', + 'x-request-id': meta.request_id || meta.requestId || 'unknown', + ...extra, + }; +}; + +const buildUrl = (host, path, query = {}) => { + const base = host.replace(/\/+$/, ''); + const normalizedPath = String(path || '').replace(/^\/+/, ''); + const prefix = `${base}/${normalizedPath}`; + const pairs = []; + for (const [key, raw] of Object.entries(query)) { + if (raw === undefined || raw === null || raw === '') continue; + const values = Array.isArray(raw) ? raw : [raw]; + for (const value of values) pairs.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`); + } + return pairs.length ? `${prefix}?${pairs.join('&')}` : prefix; +}; + +const toBodyJson = (body) => JSON.stringify(body ?? {}); + +const createSignature = (bodyJson, timestamp, secretKey) => + createHmac('md5', secretKey).update(`${bodyJson}${timestamp}`).digest('hex'); + +const buildSignedHeaders = (ctx, bodyJson) => { + const timestamp = Math.floor(Date.now() / 1000).toString(); + const appId = resolveAppId(ctx); + const secretKey = resolveSecretKey(ctx); + const signature = createSignature(bodyJson, timestamp, secretKey); + return buildHeaders(ctx, { + 'content-type': 'application/json', + 'hy-bz-api-app-id': appId, + 'hy-bz-api-timestamp': timestamp, + 'hy-bz-api-signature': signature, + }); +}; + +const parseStringList = (value, fieldName) => { + const raw = unwrapScalar(value); + const source = raw ?? []; + if (!Array.isArray(source)) throw errorWithCode('INVALID_ARGUMENT', `${fieldName} must be an array`); + if (source.length === 0) throw errorWithCode('INVALID_ARGUMENT', `${fieldName} must be non-empty`); + return source.map((item, index) => { + const text = normalizeString(item); + if (!text) throw errorWithCode('INVALID_ARGUMENT', `${fieldName}[${index}] is blank`); + return text; + }); +}; + +const isIPv4 = (value) => { + const text = String(value || '').trim(); + const parts = text.split('.'); + if (parts.length !== 4) return false; + return parts.every((part) => /^\d+$/.test(part) && Number(part) >= 0 && Number(part) <= 255); +}; + +const isIPv6 = (value) => { + const text = String(value || '').trim(); + if (!text || text.includes('/')) return false; + if (!text.includes(':')) return false; + if ((text.match(/::/g) || []).length > 1) return false; + if (/::ffff:\d{1,3}(?:\.\d{1,3}){3}$/i.test(text)) { + return isIPv4(text.substring(text.lastIndexOf(':') + 1)); + } + if (!/^[0-9a-fA-F:.]+$/.test(text)) return false; + const parts = text.split('::'); + const left = parts[0] ? parts[0].split(':').filter(Boolean) : []; + const right = parts[1] ? parts[1].split(':').filter(Boolean) : []; + if (left.some((part) => part.length > 4) || right.some((part) => part.length > 4)) return false; + if (parts.length === 1 && left.length !== 8) return false; + if (parts.length === 2 && left.length + right.length >= 8) return false; + return true; +}; + +const validateIps = (value) => { + const ips = parseStringList(value, 'ips'); + for (let i = 0; i < ips.length; i += 1) { + if (!isIPv4(ips[i]) && !isIPv6(ips[i])) throw errorWithCode('INVALID_ARGUMENT', `ips[${i}] must be a valid IP address`); + } + return ips; +}; + +const DOMAIN_RE = /^(?:\*\.)?[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)+$/; + +const validateDomains = (value) => { + const domains = parseStringList(value, 'domains'); + for (let i = 0; i < domains.length; i += 1) { + if (!DOMAIN_RE.test(domains[i])) throw errorWithCode('INVALID_ARGUMENT', `domains[${i}] must be a valid domain`); + } + return domains; +}; + +const validateBlockTiming = (req = {}) => { + const permanent = optionalBoolean(req.permanent) ?? true; + if (permanent) return { permanent: true }; + const punishTime = optionalUint32(req.punish_time ?? req.punishTime); + const timeUnit = optionalUint32(req.time_unit ?? req.timeUnit); + if (!punishTime || punishTime <= 0) throw errorWithCode('INVALID_ARGUMENT', 'punish_time must be a positive integer when permanent is false'); + if (![1, 2, 3].includes(timeUnit)) throw errorWithCode('INVALID_ARGUMENT', 'time_unit must be one of 1, 2, or 3 when permanent is false'); + return { permanent: false, punishTime, timeUnit }; +}; + +const buildBlockBody = (targets, addrType, timing) => { + const body = { + action: 'save', + type: String(addrType), + time: String(timing.permanent ? TIME_PERMANENT : TIME_TEMPORARY), + }; + if (addrType === TYPE_IP) body.ip_area = targets.join('\n'); + if (addrType === TYPE_DOMAIN) body.domainname = targets.join('\n'); + if (!timing.permanent) { + body.punish_time = String(timing.punishTime); + body.time_unit = String(timing.timeUnit); + } + return body; +}; + +const buildUnblockBody = (targets, addrType) => ({ + name: targets.join('\n'), + addr_type: String(addrType), +}); + +const toStruct = (obj) => { + const fields = {}; + for (const [key, value] of Object.entries(obj || {})) fields[key] = toValue(value); + return { fields }; +}; + +const toValue = (value) => { + const raw = unwrapScalar(value); + if (raw === undefined || raw === null) return { nullValue: 'NULL_VALUE' }; + if (typeof raw === 'string') return { stringValue: raw }; + if (typeof raw === 'number') return { numberValue: raw }; + if (typeof raw === 'boolean') return { boolValue: raw }; + if (Array.isArray(raw)) return { listValue: { values: raw.map((item) => toValue(item)) } }; + if (typeof raw === 'object') return { structValue: toStruct(raw) }; + return { stringValue: String(raw) }; +}; + +const parseJsonObject = (text) => { + if (!String(text || '').trim()) return {}; + try { + const parsed = JSON.parse(text); + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}; + } catch { + return {}; + } +}; + +const extractHeaders = (res) => { + const map = new Map(); + const headers = res?.headers; + if (headers && typeof headers.forEach === 'function') { + headers.forEach((value, key) => { + const k = String(key || ''); + if (!k) return; + const existing = map.get(k) || []; + if (Array.isArray(value)) existing.push(...value.map(String)); + else existing.push(String(value ?? '')); + map.set(k, existing); + }); + } else if (headers && typeof headers.entries === 'function') { + for (const [key, value] of headers.entries()) map.set(String(key), [String(value ?? '')]); + } + return Array.from(map.entries()).map(([key, values]) => ({ key, values })); +}; + +const normalizeResponse = (status, headers, rawBody, effectiveUrl) => ({ + status_code: Number(status) || 0, + statusCode: Number(status) || 0, + headers, + raw_body: String(rawBody ?? ''), + rawBody: String(rawBody ?? ''), + body_json: toStruct(parseJsonObject(rawBody)), + bodyJson: parseJsonObject(rawBody), + effective_url: effectiveUrl, + effectiveUrl, +}); + +const fetchHttp = async (ctx, operation, body) => { + const host = resolveHost(ctx); + const url = buildUrl(host, resolveApiPath(ctx), { opt: operation }); + const bodyJson = toBodyJson(body); + let res; + try { + res = await withTlsBypass(ctx, () => fetch(url, { + method: 'POST', + timeoutMs: resolveTimeoutMs(ctx), + headers: buildSignedHeaders(ctx, bodyJson), + body: bodyJson, + })); + } catch (err) { + throw errorWithCode('UNAVAILABLE', err?.cause?.message || err?.message || 'fetch failed'); + } + const text = await res.text(); + return normalizeResponse(res.status, extractHeaders(res), text, url); +}; + +const handleBlockIp = (req, ctx) => { + const callCtx = resolveCallContext({ ...ctx, req }); + const ips = validateIps(callCtx.req?.ips); + const timing = validateBlockTiming(callCtx.req || {}); + return fetchHttp(callCtx, 'addPatchblack2', [buildBlockBody(ips, TYPE_IP, timing)]); +}; + +const handleUnblockIp = (req, ctx) => { + const callCtx = resolveCallContext({ ...ctx, req }); + const ips = validateIps(callCtx.req?.ips); + return fetchHttp(callCtx, 'delblack2', buildUnblockBody(ips, TYPE_IP)); +}; + +const handleBlockDomain = (req, ctx) => { + const callCtx = resolveCallContext({ ...ctx, req }); + const domains = validateDomains(callCtx.req?.domains); + const timing = validateBlockTiming(callCtx.req || {}); + return fetchHttp(callCtx, 'addPatchblack2', [buildBlockBody(domains, TYPE_DOMAIN, timing)]); +}; + +const handleUnblockDomain = (req, ctx) => { + const callCtx = resolveCallContext({ ...ctx, req }); + const domains = validateDomains(callCtx.req?.domains); + return fetchHttp(callCtx, 'delblack2', buildUnblockBody(domains, TYPE_DOMAIN)); +}; + +export function rpcdef(ctx = {}) { + const callCtx = resolveCallContext(ctx); + return { + [BLOCK_IP_PATH]: async (req) => handleBlockIp(req ?? callCtx.req ?? {}, callCtx), + [UNBLOCK_IP_PATH]: async (req) => handleUnblockIp(req ?? callCtx.req ?? {}, callCtx), + [BLOCK_DOMAIN_PATH]: async (req) => handleBlockDomain(req ?? callCtx.req ?? {}, callCtx), + [UNBLOCK_DOMAIN_PATH]: async (req) => handleUnblockDomain(req ?? callCtx.req ?? {}, callCtx), + }; +} + +const isSdkContext = (value) => Boolean( + value && + typeof value === 'object' && + ( + hasOwn(value, 'req') || + hasOwn(value, 'request') || + hasOwn(value, 'bindings') || + hasOwn(value, 'config') || + hasOwn(value, 'secret') || + hasOwn(value, 'meta') || + hasOwn(value, 'limits') + ) +); + +const makeHandler = (fn) => (reqOrCtx, maybeCtx) => { + if (maybeCtx === undefined && isSdkContext(reqOrCtx)) { + return fn(reqOrCtx.req ?? reqOrCtx.request ?? {}, reqOrCtx); + } + return fn(reqOrCtx ?? {}, maybeCtx ?? {}); +}; + +export const handlers = { + [METHOD_BLOCK_IP_FULL]: makeHandler(handleBlockIp), + [METHOD_UNBLOCK_IP_FULL]: makeHandler(handleUnblockIp), + [METHOD_BLOCK_DOMAIN_FULL]: makeHandler(handleBlockDomain), + [METHOD_UNBLOCK_DOMAIN_FULL]: makeHandler(handleUnblockDomain), +}; + +export const _test = { + buildBlockBody, + buildHeaders, + buildSignedHeaders, + shouldSkipTlsVerify, + withTlsBypass, + buildUnblockBody, + buildUrl, + createSignature, + errorWithCode, + extractHeaders, + fetchHttp, + isIPv4, + isIPv6, + normalizeBaseUrl, + normalizeResponse, + normalizeString, + optionalBoolean, + optionalString, + optionalUint32, + parseJsonObject, + parseStringList, + requireString, + resolveApiPath, + resolveAppId, + resolveCallContext, + resolveHost, + resolveSecretKey, + resolveTimeoutMs, + toBoolean, + toBodyJson, + toStruct, + toValue, + validateBlockTiming, + validateDomains, + validateIps, + isSdkContext, + makeHandler, +}; diff --git a/services/ctdsg__fw/src/service.js b/services/ctdsg__fw/src/service.js new file mode 100644 index 00000000..a4143353 --- /dev/null +++ b/services/ctdsg__fw/src/service.js @@ -0,0 +1,7 @@ +import { defineService } from '@chaitin-ai/octobus-sdk'; + +import { handlers } from './ctdsg-fw.js'; + +export { handlers } from './ctdsg-fw.js'; + +export const service = defineService({ handlers }); diff --git a/services/ctdsg__fw/test/ctdsg-fw.test.js b/services/ctdsg__fw/test/ctdsg-fw.test.js new file mode 100644 index 00000000..362e0084 --- /dev/null +++ b/services/ctdsg__fw/test/ctdsg-fw.test.js @@ -0,0 +1,362 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { GrpcError, grpcStatus } from '@chaitin-ai/octobus-sdk'; + +import { + BLOCK_DOMAIN_PATH, + BLOCK_IP_PATH, + METHOD_BLOCK_DOMAIN_FULL, + METHOD_BLOCK_IP_FULL, + METHOD_UNBLOCK_DOMAIN_FULL, + METHOD_UNBLOCK_IP_FULL, + UNBLOCK_DOMAIN_PATH, + UNBLOCK_IP_PATH, + _test, + handlers, + rpcdef, +} from '../src/ctdsg-fw.js'; +import { service } from '../src/service.js'; +import { createMockServer } from './mock_upstream.js'; + +const originalFetch = globalThis.fetch; + +const buildCtx = (overrides = {}) => ({ + bindings: { + host: 'http://127.0.0.1:19090', + appId: 'hybzapi', + secretKey: 'demo-secret', + headers: { 'X-Extra': 'demo' }, + ...(overrides.bindings || {}), + }, + config: overrides.config || {}, + secret: overrides.secret || {}, + limits: { timeoutMs: 10_000, ...(overrides.limits || {}) }, + meta: { instance_id: 'inst', request_id: 'req', ...(overrides.meta || {}) }, + req: overrides.req || {}, +}); + +const createHeaders = (entries = {}) => { + const map = new Map(); + for (const [key, value] of Object.entries(entries)) { + map.set(key, Array.isArray(value) ? value.map(String) : [String(value)]); + } + return { + forEach(callback) { + for (const [key, values] of map.entries()) { + for (const value of values) callback(value, key); + } + }, + entries() { + return map.entries(); + }, + }; +}; + +const response = (status, body, headers = {}) => ({ + status, + headers: createHeaders(headers), + text: async () => body, +}); + +const setFetch = (impl) => { + globalThis.fetch = impl; +}; + +const expectGrpcError = async (fn, legacyCode, checker = () => {}) => { + let caught; + try { + await fn(); + } catch (error) { + caught = error; + } + assert.ok(caught, 'expected function to reject'); + assert.ok(caught instanceof GrpcError); + assert.equal(caught.legacyCode, legacyCode); + assert.equal(caught.code, ({ + INVALID_ARGUMENT: grpcStatus.INVALID_ARGUMENT, + FAILED_PRECONDITION: grpcStatus.FAILED_PRECONDITION, + UNAVAILABLE: grpcStatus.UNAVAILABLE, + UNKNOWN: grpcStatus.UNKNOWN, + })[legacyCode]); + assert.match(caught.message, new RegExp(`^${legacyCode}:`)); + checker(caught); +}; + +test.afterEach(() => { + globalThis.fetch = originalFetch; +}); + +test('service exports defineService result and handlers', () => { + assert.equal(typeof service, 'object'); + assert.equal(typeof handlers[METHOD_BLOCK_IP_FULL], 'function'); + assert.equal(typeof handlers[METHOD_UNBLOCK_IP_FULL], 'function'); + assert.equal(typeof handlers[METHOD_BLOCK_DOMAIN_FULL], 'function'); + assert.equal(typeof handlers[METHOD_UNBLOCK_DOMAIN_FULL], 'function'); +}); + +test('BlockIP sends signed addblack2 request with newline-separated IPs', async () => { + let captured; + setFetch(async (url, init) => { + captured = { url: String(url), init }; + return response(200, JSON.stringify({ code: 0, msg: 'ok' }), { + 'content-type': 'application/json', + 'set-cookie': ['sid=abc'], + }); + }); + + const res = await rpcdef(buildCtx({ + req: { ips: ['192.0.2.10', '2001:db8::1'] }, + bindings: { host: 'https://ctdsg.local:9090/', skipTlsVerify: true }, + meta: { instance_id: 'inst-1', request_id: 'req-1' }, + }))[BLOCK_IP_PATH](); + + assert.equal(captured.init.method, 'POST'); + assert.equal(captured.init.timeoutMs, 10_000); + assert.equal(captured.init.headers['x-engine-instance'], 'inst-1'); + assert.equal(captured.init.headers['x-request-id'], 'req-1'); + assert.equal(captured.init.headers['hy-bz-api-app-id'], 'hybzapi'); + assert.ok(captured.init.headers['hy-bz-api-timestamp']); + assert.ok(captured.init.headers['hy-bz-api-signature']); + assert.equal(captured.init.headers['content-type'], 'application/json'); + assert.equal(captured.url, 'https://ctdsg.local:9090/api.php/inter/Inter?opt=addPatchblack2'); + + const body = JSON.parse(captured.init.body); + assert.deepEqual(body, [{ + action: 'save', + type: '0', + time: '1', + ip_area: '192.0.2.10\n2001:db8::1', + }]); + + const expectedSig = _test.createSignature(captured.init.body, captured.init.headers['hy-bz-api-timestamp'], 'demo-secret'); + assert.equal(captured.init.headers['hy-bz-api-signature'], expectedSig); + assert.equal(res.statusCode, 200); + assert.equal(res.bodyJson.code, 0); + assert.equal(res.body_json.fields.msg.stringValue, 'ok'); + assert.deepEqual(res.headers.find((h) => h.key === 'set-cookie')?.values, ['sid=abc']); +}); + +test('BlockDomain sends temporary addblack2 request', async () => { + let captured; + setFetch(async (url, init) => { + captured = { url: String(url), init }; + return response(200, JSON.stringify({ code: 0, msg: 'ok' })); + }); + + await rpcdef(buildCtx({ + req: { + domains: ['a.example.com', '*.example.org'], + permanent: false, + punish_time: 30, + time_unit: 1, + }, + }))[BLOCK_DOMAIN_PATH](); + + assert.equal(captured.url, 'http://127.0.0.1:19090/api.php/inter/Inter?opt=addPatchblack2'); + assert.deepEqual(JSON.parse(captured.init.body), [{ + action: 'save', + type: '1', + time: '0', + domainname: 'a.example.com\n*.example.org', + punish_time: '30', + time_unit: '1', + }]); +}); + +test('UnblockIP and UnblockDomain map single and multiple targets correctly', async () => { + const bodies = []; + setFetch(async (_url, init) => { + bodies.push(JSON.parse(init.body)); + return response(200, JSON.stringify({ code: 0 })); + }); + + await handlers[METHOD_UNBLOCK_IP_FULL]({ ips: ['192.0.2.10'] }, buildCtx()); + await handlers[METHOD_UNBLOCK_DOMAIN_FULL]({ domains: ['a.example.com', 'b.example.com'] }, buildCtx()); + + assert.deepEqual(bodies[0], { + name: '192.0.2.10', + addr_type: '0', + }); + assert.deepEqual(bodies[1], { + name: 'a.example.com\nb.example.com', + addr_type: '1', + }); +}); + +test('mock upstream receives signed requests for all CTDSG operations', async () => { + const server = await createMockServer(); + try { + const ctx = buildCtx({ bindings: { host: server.url } }); + await handlers[METHOD_BLOCK_IP_FULL]({ ips: ['192.0.2.10'] }, ctx); + await handlers[METHOD_UNBLOCK_IP_FULL]({ ips: ['192.0.2.10'] }, ctx); + await handlers[METHOD_BLOCK_DOMAIN_FULL]({ domains: ['demo.example.com'] }, ctx); + await handlers[METHOD_UNBLOCK_DOMAIN_FULL]({ domains: ['demo.example.com'] }, ctx); + + assert.equal(server.requests.length, 4); + assert.equal(server.requests[0].query.opt, 'addPatchblack2'); + assert.equal(server.requests[0].body[0].ip_area, '192.0.2.10'); + assert.equal(server.requests[1].query.opt, 'delblack2'); + assert.equal(server.requests[1].body.addr_type, '0'); + assert.equal(server.requests[2].body[0].domainname, 'demo.example.com'); + assert.equal(server.requests[3].body.addr_type, '1'); + for (const req of server.requests) { + assert.ok(req.headers['hy-bz-api-app-id']); + assert.ok(req.headers['hy-bz-api-timestamp']); + assert.ok(req.headers['hy-bz-api-signature']); + } + } finally { + await server.close(); + } +}); + +test('validation rejects missing config, invalid targets, and invalid temporary timing', async () => { + await expectGrpcError( + () => rpcdef(buildCtx({ bindings: { host: '', secretKey: 'x' }, req: { ips: ['192.0.2.1'] } }))[BLOCK_IP_PATH](), + 'INVALID_ARGUMENT', + (err) => assert.match(err.message, /host\/baseUrl is required/), + ); + assert.throws( + () => _test.resolveSecretKey({ req: { secretKey: '' }, bindings: {} }), + /INVALID_ARGUMENT: secretKey is required/, + ); + await expectGrpcError( + () => rpcdef(buildCtx({ req: { ips: ['bad-ip'] } }))[BLOCK_IP_PATH](), + 'INVALID_ARGUMENT', + (err) => assert.match(err.message, /must be a valid IP address/), + ); + await expectGrpcError( + () => rpcdef(buildCtx({ req: { domains: ['bad domain'] } }))[BLOCK_DOMAIN_PATH](), + 'INVALID_ARGUMENT', + (err) => assert.match(err.message, /must be a valid domain/), + ); + await expectGrpcError( + () => rpcdef(buildCtx({ req: { domains: ['a.example.com'], permanent: false, time_unit: 2 } }))[BLOCK_DOMAIN_PATH](), + 'INVALID_ARGUMENT', + (err) => assert.match(err.message, /punish_time must be a positive integer/), + ); + await expectGrpcError( + () => rpcdef(buildCtx({ req: { ips: ['192.0.2.1'], permanent: false, punish_time: 1, time_unit: 9 } }))[BLOCK_IP_PATH](), + 'INVALID_ARGUMENT', + (err) => assert.match(err.message, /time_unit must be one of 1, 2, or 3/), + ); +}); + +test('non-JSON body is preserved as raw text with empty parsed object', async () => { + setFetch(async () => response(403, 'permission denied')); + + const res = await rpcdef(buildCtx({ req: { ips: ['192.0.2.10'] } }))[BLOCK_IP_PATH](); + + assert.equal(res.statusCode, 403); + assert.equal(res.rawBody, 'permission denied'); + assert.deepEqual(res.bodyJson, {}); + assert.deepEqual(res.body_json, { fields: {} }); +}); + +test('network failures map to UNAVAILABLE', async () => { + setFetch(async () => { + throw Object.assign(new Error('boom'), { cause: new Error('socket hang up') }); + }); + + await expectGrpcError( + () => rpcdef(buildCtx({ req: { ips: ['192.0.2.10'] } }))[BLOCK_IP_PATH](), + 'UNAVAILABLE', + (err) => assert.match(err.message, /socket hang up/), + ); +}); + +test('helpers cover parsing, aliases, signatures, timing, and normalization branches', async () => { + assert.equal(_test.normalizeString({ value: ' x ' }), 'x'); + assert.equal(_test.optionalString(' '), undefined); + assert.equal(_test.optionalString(' value '), 'value'); + assert.equal(_test.optionalUint32({ value: 321 }), 321); + assert.equal(_test.optionalUint32('42.9'), 42); + assert.equal(_test.optionalUint32(-1), undefined); + assert.equal(_test.optionalBoolean(true), true); + assert.equal(_test.optionalBoolean('0'), false); + assert.equal(_test.optionalBoolean(undefined), undefined); + assert.equal(_test.optionalBoolean('maybe'), undefined); + assert.equal(_test.normalizeBaseUrl('https://example.test///'), 'https://example.test'); + assert.equal(_test.normalizeBaseUrl('example.test'), ''); + assert.equal(_test.resolveAppId({ req: {}, bindings: {} }), 'hybzapi'); + assert.equal(_test.resolveAppId({ req: { app_id: 'req-app' }, bindings: {} }), 'req-app'); + assert.equal(_test.resolveAppId({ req: {}, bindings: { app_id: 'binding-app' } }), 'binding-app'); + assert.equal(_test.resolveApiPath({ req: {}, bindings: {} }), '/api.php/inter/Inter'); + assert.equal(_test.resolveApiPath({ req: { api_path: 'api.php/inter/Inter' }, bindings: {} }), '/api.php/inter/Inter'); + assert.equal(_test.resolveApiPath({ req: {}, bindings: { apiPath: '/custom/path' } }), '/custom/path'); + assert.equal(_test.resolveHost({ req: { host: 'https://req-host.test/' }, bindings: {} }), 'https://req-host.test'); + assert.equal(_test.resolveHost({ req: { base_url: 'https://req.test/' }, bindings: {} }), 'https://req.test'); + assert.equal(_test.resolveHost({ req: {}, bindings: { restBaseUrl: 'https://binding.test/' } }), 'https://binding.test'); + assert.equal(_test.resolveSecretKey({ req: {}, bindings: { secret_key: 's' } }), 's'); + assert.equal(_test.resolveTimeoutMs({ req: { timeout_ms: 111 }, bindings: { timeoutMs: 222 }, limits: { timeoutMs: 333 } }), 111); + assert.equal(_test.resolveTimeoutMs({ req: {}, bindings: { timeout_ms: 223 }, limits: { timeoutMs: 333 } }), 223); + assert.equal(_test.resolveTimeoutMs({ req: {}, bindings: {}, limits: {} }), 5000); + assert.deepEqual(_test.resolveCallContext({ config: { host: 'h' }, secret: { secretKey: 's' }, bindings: { headers: { a: '1' } }, request: { ips: ['1.1.1.1'] } }).bindings, { + host: 'h', + secretKey: 's', + headers: { a: '1' }, + }); + assert.equal(_test.toBoolean('on'), true); + assert.equal(_test.toBoolean('off'), false); + assert.equal(_test.toBoolean('maybe'), false); + assert.equal(_test.shouldSkipTlsVerify({ bindings: {} }), false); + assert.equal(_test.shouldSkipTlsVerify({ bindings: { insecureSkipVerify: 'yes' } }), true); + assert.deepEqual(_test.buildHeaders({ bindings: {}, meta: { instanceId: 'camel-inst', requestId: 'camel-req' } }), { + 'x-engine-instance': 'camel-inst', + 'x-request-id': 'camel-req', + }); + assert.equal(_test.buildUrl('http://x.test/', '/p', { a: 'x y', b: ['1', '2'], c: '', d: null }), 'http://x.test/p?a=x%20y&b=1&b=2'); + assert.equal(_test.buildUrl('http://x.test', '', {}), 'http://x.test/'); + assert.equal(_test.toBodyJson({ a: 1 }), '{"a":1}'); + assert.equal(_test.createSignature('{"a":1}', '1700000000', 'secret'), '062632a8b32eae3c145fe84dd4ba4be4'); + assert.deepEqual(_test.validateBlockTiming({ permanent: true }), { permanent: true }); + assert.deepEqual(_test.validateBlockTiming({ permanent: false, punish_time: 10, time_unit: 2 }), { + permanent: false, + punishTime: 10, + timeUnit: 2, + }); + assert.equal(_test.isIPv4('192.0.2.1'), true); + assert.equal(_test.isIPv4('999.0.2.1'), false); + assert.equal(_test.isIPv6('2001:db8::1'), true); + assert.equal(_test.isIPv6('gggg::1'), false); + assert.equal(_test.isIPv6('::ffff:192.0.2.1'), true); + assert.deepEqual(_test.validateIps(['::ffff:192.0.2.1']), ['::ffff:192.0.2.1']); + assert.deepEqual(_test.validateIps(['192.0.2.1']), ['192.0.2.1']); + assert.deepEqual(_test.validateDomains(['*.example.org']), ['*.example.org']); + assert.deepEqual(_test.validateDomains(['a.example.com']), ['a.example.com']); + assert.deepEqual(_test.buildBlockBody(['a.example.com'], 1, { permanent: false, punishTime: 5, timeUnit: 3 }), { + action: 'save', + type: '1', + time: '0', + domainname: 'a.example.com', + punish_time: '5', + time_unit: '3', + }); + assert.deepEqual(_test.buildUnblockBody(['a.example.com', 'b.example.com'], 1), { name: 'a.example.com\nb.example.com', addr_type: '1' }); + const headers = _test.buildSignedHeaders(buildCtx(), '{"a":1}'); + assert.equal(headers['hy-bz-api-app-id'], 'hybzapi'); + assert.ok(headers['hy-bz-api-timestamp']); + assert.ok(headers['hy-bz-api-signature']); + const originalTlsEnv = process.env.NODE_TLS_REJECT_UNAUTHORIZED; + await _test.withTlsBypass({ bindings: { skipTlsVerify: true } }, async () => { + assert.equal(process.env.NODE_TLS_REJECT_UNAUTHORIZED, '0'); + }); + assert.equal(process.env.NODE_TLS_REJECT_UNAUTHORIZED, originalTlsEnv); + assert.deepEqual(_test.toStruct({ a: 1, b: null, c: [true] }), { + fields: { + a: { numberValue: 1 }, + b: { nullValue: 'NULL_VALUE' }, + c: { listValue: { values: [{ boolValue: true }] } }, + }, + }); + assert.deepEqual(_test.toValue('x'), { stringValue: 'x' }); + assert.deepEqual(_test.toValue(Symbol.for('x')), { stringValue: 'Symbol(x)' }); + assert.deepEqual(_test.toValue(undefined), { nullValue: 'NULL_VALUE' }); + assert.deepEqual(_test.parseJsonObject(''), {}); + assert.deepEqual(_test.parseJsonObject('[]'), {}); + assert.deepEqual(_test.parseJsonObject('not-json'), {}); + assert.deepEqual(_test.parseJsonObject('{"ok":true}'), { ok: true }); + assert.deepEqual(_test.normalizeResponse(200, [{ key: 'x', values: ['1'] }], '{"ok":true}', 'http://x').bodyJson, { ok: true }); + assert.deepEqual(_test.extractHeaders({ headers: createHeaders({ A: ['1', '2'] }) }), [{ key: 'A', values: ['1', '2'] }]); + assert.equal(_test.errorWithCode('FAILED_PRECONDITION', 'bad').legacyCode, 'FAILED_PRECONDITION'); +}); diff --git a/services/ctdsg__fw/test/mock_upstream.js b/services/ctdsg__fw/test/mock_upstream.js new file mode 100644 index 00000000..5fbb8312 --- /dev/null +++ b/services/ctdsg__fw/test/mock_upstream.js @@ -0,0 +1,88 @@ +/* node:coverage disable */ +import http from 'node:http'; + +export const createMockServer = async () => { + const requests = []; + + const jsonResponse = (res, status, payload, headers = {}) => { + res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', ...headers }); + res.end(JSON.stringify(payload)); + }; + + const textResponse = (res, status, body, headers = {}) => { + res.writeHead(status, headers); + res.end(body); + }; + + const parseBody = (req) => + new Promise((resolve) => { + let body = ''; + req.on('data', (chunk) => { + body += chunk; + }); + req.on('end', () => { + try { + resolve(body ? JSON.parse(body) : {}); + } catch { + resolve({ raw: body }); + } + }); + }); + + const server = http.createServer((req, res) => { + (async () => { + const url = new URL(req.url || '/', `http://${req.headers.host || '127.0.0.1'}`); + if (req.method === 'POST' && url.pathname === '/api.php/inter/Inter') { + const body = await parseBody(req); + const query = Object.fromEntries(url.searchParams); + requests.push({ method: req.method, url: req.url, query, body, headers: req.headers }); + + if (!req.headers['hy-bz-api-app-id'] || !req.headers['hy-bz-api-timestamp'] || !req.headers['hy-bz-api-signature']) { + jsonResponse(res, 401, { code: 401, msg: 'missing signature headers' }); + return; + } + + if (query.opt === 'addPatchblack2') { + jsonResponse(res, 200, { + code: 0, + msg: 'ok', + action: 'addPatchblack2', + payload: body, + }); + return; + } + + if (query.opt === 'delblack2') { + jsonResponse(res, 200, { + code: 0, + msg: 'ok', + action: 'delblack2', + payload: body, + }); + return; + } + + jsonResponse(res, 404, { code: 404, msg: 'unknown operation', opt: query.opt }); + return; + } + + if (req.method === 'GET' && url.pathname === '/plain-text') { + requests.push({ method: req.method, url: req.url, headers: req.headers }); + textResponse(res, 200, 'plain-response'); + return; + } + + jsonResponse(res, 404, { code: 404, msg: 'not found', path: url.pathname }); + })().catch((err) => { + jsonResponse(res, 500, { code: 500, msg: err?.message || 'internal error' }); + }); + }); + + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const { port } = server.address(); + return { + requests, + url: `http://127.0.0.1:${port}`, + close: () => new Promise((resolve, reject) => server.close((err) => (err ? reject(err) : resolve()))), + }; +}; diff --git a/services/package.json b/services/package.json index c60e470b..65cfee06 100644 --- a/services/package.json +++ b/services/package.json @@ -7,6 +7,7 @@ "octobus-tentacles": "bin/octobus-tentacles.js", "alibaba-cloud-simple-application-server-firewall": "bin/alibaba-cloud-simple-application-server-firewall.js", "cloudatlas": "bin/cloudatlas.js", + "ctdsg-fw": "bin/ctdsg-fw.js", "das-gateway-v3": "bin/das-gateway-v3.js", "das-tgfw-v6": "bin/das-tgfw-v6.js", "dbaudit": "bin/dbaudit.js", @@ -70,6 +71,7 @@ "files": [ "bin/alibaba-cloud-simple-application-server-firewall.js", "bin/cloudatlas.js", + "bin/ctdsg-fw.js", "bin/das-gateway-v3.js", "bin/das-tgfw-v6.js", "bin/dbaudit.js", @@ -131,6 +133,7 @@ "bin/wangsu-label-ip.js", "alibaba-cloud__simple-application-server-firewall", "chaitin__cloudatlas", + "ctdsg__fw", "das__gateway_v3", "das__tgfw_v6", "das__dbaudit", From cbdb87bead65be07634137753769a568f9e84935 Mon Sep 17 00:00:00 2001 From: "jiashuai.ning" <3258639272@qq.com> Date: Wed, 1 Jul 2026 00:47:10 +0800 Subject: [PATCH 2/4] fix(services): require explicit CTDSG appId --- services/ctdsg__fw/README.md | 2 +- services/ctdsg__fw/config.schema.json | 3 +-- services/ctdsg__fw/src/ctdsg-fw.js | 3 +-- services/ctdsg__fw/test/ctdsg-fw.test.js | 8 ++++---- 4 files changed, 7 insertions(+), 9 deletions(-) diff --git a/services/ctdsg__fw/README.md b/services/ctdsg__fw/README.md index 7197c862..77ae3149 100644 --- a/services/ctdsg__fw/README.md +++ b/services/ctdsg__fw/README.md @@ -37,7 +37,7 @@ Create and start a test instance: ```bash ./bin/octobus instance create ctdsg-fw-test \ --service ctdsg-fw \ - --config-json '{"host":"https://10.211.194.22:9090","appId":"hybzapi","skipTlsVerify":true}' \ + --config-json '{"host":"https://10.211.194.22:9090","appId":"h*****","skipTlsVerify":true}' \ --secret-json '{"secretKey":"REDACTED"}' ``` diff --git a/services/ctdsg__fw/config.schema.json b/services/ctdsg__fw/config.schema.json index 2a59078c..16a51dab 100644 --- a/services/ctdsg__fw/config.schema.json +++ b/services/ctdsg__fw/config.schema.json @@ -25,8 +25,7 @@ }, "appId": { "type": "string", - "default": "hybzapi", - "description": "CTDSG signature app id. Defaults to hybzapi per the device API document." + "description": "CTDSG signature app id. Must match the device configuration." }, "app_id": { "type": "string", diff --git a/services/ctdsg__fw/src/ctdsg-fw.js b/services/ctdsg__fw/src/ctdsg-fw.js index ae29c23a..0d7dc350 100644 --- a/services/ctdsg__fw/src/ctdsg-fw.js +++ b/services/ctdsg__fw/src/ctdsg-fw.js @@ -13,7 +13,6 @@ export const METHOD_BLOCK_DOMAIN_FULL = 'CTDSG_FW.CTDSG_FW/BlockDomain'; export const METHOD_UNBLOCK_DOMAIN_FULL = 'CTDSG_FW.CTDSG_FW/UnblockDomain'; export const DEFAULT_TIMEOUT_MS = 5000; -export const DEFAULT_APP_ID = 'hybzapi'; export const DEFAULT_API_PATH = '/api.php/inter/Inter'; export const TIME_PERMANENT = 1; export const TIME_TEMPORARY = 0; @@ -124,7 +123,7 @@ const resolveHost = (ctx = {}) => { const resolveAppId = (ctx = {}) => { const req = ctx.req || {}; const bindings = ctx.bindings || {}; - return optionalString(firstDefined(req.appId, req.app_id, bindings.appId, bindings.app_id)) || DEFAULT_APP_ID; + return requireString(firstDefined(req.appId, req.app_id, bindings.appId, bindings.app_id), 'appId'); }; const resolveApiPath = (ctx = {}) => { diff --git a/services/ctdsg__fw/test/ctdsg-fw.test.js b/services/ctdsg__fw/test/ctdsg-fw.test.js index 362e0084..648e604b 100644 --- a/services/ctdsg__fw/test/ctdsg-fw.test.js +++ b/services/ctdsg__fw/test/ctdsg-fw.test.js @@ -24,7 +24,7 @@ const originalFetch = globalThis.fetch; const buildCtx = (overrides = {}) => ({ bindings: { host: 'http://127.0.0.1:19090', - appId: 'hybzapi', + appId: 'mock-app-id', secretKey: 'demo-secret', headers: { 'X-Extra': 'demo' }, ...(overrides.bindings || {}), @@ -115,7 +115,7 @@ test('BlockIP sends signed addblack2 request with newline-separated IPs', async assert.equal(captured.init.timeoutMs, 10_000); assert.equal(captured.init.headers['x-engine-instance'], 'inst-1'); assert.equal(captured.init.headers['x-request-id'], 'req-1'); - assert.equal(captured.init.headers['hy-bz-api-app-id'], 'hybzapi'); + assert.equal(captured.init.headers['hy-bz-api-app-id'], 'mock-app-id'); assert.ok(captured.init.headers['hy-bz-api-timestamp']); assert.ok(captured.init.headers['hy-bz-api-signature']); assert.equal(captured.init.headers['content-type'], 'application/json'); @@ -278,7 +278,7 @@ test('helpers cover parsing, aliases, signatures, timing, and normalization bran assert.equal(_test.optionalBoolean('maybe'), undefined); assert.equal(_test.normalizeBaseUrl('https://example.test///'), 'https://example.test'); assert.equal(_test.normalizeBaseUrl('example.test'), ''); - assert.equal(_test.resolveAppId({ req: {}, bindings: {} }), 'hybzapi'); + assert.throws(() => _test.resolveAppId({ req: {}, bindings: {} }), /INVALID_ARGUMENT: appId is required/); assert.equal(_test.resolveAppId({ req: { app_id: 'req-app' }, bindings: {} }), 'req-app'); assert.equal(_test.resolveAppId({ req: {}, bindings: { app_id: 'binding-app' } }), 'binding-app'); assert.equal(_test.resolveApiPath({ req: {}, bindings: {} }), '/api.php/inter/Inter'); @@ -334,7 +334,7 @@ test('helpers cover parsing, aliases, signatures, timing, and normalization bran }); assert.deepEqual(_test.buildUnblockBody(['a.example.com', 'b.example.com'], 1), { name: 'a.example.com\nb.example.com', addr_type: '1' }); const headers = _test.buildSignedHeaders(buildCtx(), '{"a":1}'); - assert.equal(headers['hy-bz-api-app-id'], 'hybzapi'); + assert.equal(headers['hy-bz-api-app-id'], 'mock-app-id'); assert.ok(headers['hy-bz-api-timestamp']); assert.ok(headers['hy-bz-api-signature']); const originalTlsEnv = process.env.NODE_TLS_REJECT_UNAUTHORIZED; From e93e617a1803f16b997875e9e70634d800384249 Mon Sep 17 00:00:00 2001 From: "jiashuai.ning" <3258639272@qq.com> Date: Wed, 1 Jul 2026 01:05:44 +0800 Subject: [PATCH 3/4] fix(services): harden CTDSG TLS and config validation --- services/ctdsg__fw/config.schema.json | 1 + services/ctdsg__fw/src/ctdsg-fw.js | 76 +++++++++--------------- services/ctdsg__fw/test/ctdsg-fw.test.js | 9 +-- 3 files changed, 32 insertions(+), 54 deletions(-) diff --git a/services/ctdsg__fw/config.schema.json b/services/ctdsg__fw/config.schema.json index 16a51dab..44bad396 100644 --- a/services/ctdsg__fw/config.schema.json +++ b/services/ctdsg__fw/config.schema.json @@ -2,6 +2,7 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "additionalProperties": true, + "required": ["host", "appId"], "properties": { "host": { "type": "string", diff --git a/services/ctdsg__fw/src/ctdsg-fw.js b/services/ctdsg__fw/src/ctdsg-fw.js index 0d7dc350..7f20a4f2 100644 --- a/services/ctdsg__fw/src/ctdsg-fw.js +++ b/services/ctdsg__fw/src/ctdsg-fw.js @@ -1,5 +1,7 @@ import { createHmac } from 'node:crypto'; +import { Agent } from 'undici'; + import { GrpcError, grpcStatus } from '@chaitin-ai/octobus-sdk'; export const BLOCK_IP_PATH = '/CTDSG_FW.CTDSG_FW/BlockIP'; @@ -103,7 +105,6 @@ const normalizeBaseUrl = (value) => { const resolveHost = (ctx = {}) => { const req = ctx.req || {}; - const bindings = ctx.bindings || {}; for (const candidate of [ req.host, req.baseUrl, @@ -174,59 +175,36 @@ const shouldSkipTlsVerify = (ctx = {}) => { return toBoolean(bindings.skipTlsVerify) || toBoolean(bindings.tlsInsecureSkipVerify) || toBoolean(bindings.insecureSkipVerify); }; -const withTlsBypass = async (ctx, fn) => { - if (!shouldSkipTlsVerify(ctx)) return await fn(); - const hadValue = hasOwn(process.env, 'NODE_TLS_REJECT_UNAUTHORIZED'); - const previous = process.env.NODE_TLS_REJECT_UNAUTHORIZED; - process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; - try { - return await fn(); - } finally { - if (hadValue) process.env.NODE_TLS_REJECT_UNAUTHORIZED = previous; - else delete process.env.NODE_TLS_REJECT_UNAUTHORIZED; - } -}; - -const buildHeaders = (ctx = {}, extra = {}) => { +const buildTlsOptions = (ctx = {}) => { const bindings = ctx.bindings || {}; - const meta = ctx.meta || {}; + if (!shouldSkipTlsVerify(ctx)) return {}; return { - ...(bindings.headers || {}), - 'x-engine-instance': meta.instance_id || meta.instanceId || 'unknown', - 'x-request-id': meta.request_id || meta.requestId || 'unknown', - ...extra, + dispatcher: new Agent({ + connect: { + rejectUnauthorized: false, + }, + }), }; }; -const buildUrl = (host, path, query = {}) => { - const base = host.replace(/\/+$/, ''); - const normalizedPath = String(path || '').replace(/^\/+/, ''); - const prefix = `${base}/${normalizedPath}`; - const pairs = []; - for (const [key, raw] of Object.entries(query)) { - if (raw === undefined || raw === null || raw === '') continue; - const values = Array.isArray(raw) ? raw : [raw]; - for (const value of values) pairs.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`); +const fetchHttp = async (ctx, operation, body) => { + const host = resolveHost(ctx); + const url = buildUrl(host, resolveApiPath(ctx), { opt: operation }); + const bodyJson = toBodyJson(body); + let res; + try { + res = await fetch(url, { + method: 'POST', + signal: AbortSignal.timeout(resolveTimeoutMs(ctx)), + ...buildTlsOptions(ctx), + headers: buildSignedHeaders(ctx, bodyJson), + body: bodyJson, + }); + } catch (err) { + throw errorWithCode('UNAVAILABLE', err?.cause?.message || err?.message || 'fetch failed'); } - return pairs.length ? `${prefix}?${pairs.join('&')}` : prefix; -}; - -const toBodyJson = (body) => JSON.stringify(body ?? {}); - -const createSignature = (bodyJson, timestamp, secretKey) => - createHmac('md5', secretKey).update(`${bodyJson}${timestamp}`).digest('hex'); - -const buildSignedHeaders = (ctx, bodyJson) => { - const timestamp = Math.floor(Date.now() / 1000).toString(); - const appId = resolveAppId(ctx); - const secretKey = resolveSecretKey(ctx); - const signature = createSignature(bodyJson, timestamp, secretKey); - return buildHeaders(ctx, { - 'content-type': 'application/json', - 'hy-bz-api-app-id': appId, - 'hy-bz-api-timestamp': timestamp, - 'hy-bz-api-signature': signature, - }); + const text = await res.text(); + return normalizeResponse(res.status, extractHeaders(res), text, url); }; const parseStringList = (value, fieldName) => { @@ -490,6 +468,8 @@ export const _test = { validateBlockTiming, validateDomains, validateIps, + buildTlsOptions, + shouldSkipTlsVerify, isSdkContext, makeHandler, }; diff --git a/services/ctdsg__fw/test/ctdsg-fw.test.js b/services/ctdsg__fw/test/ctdsg-fw.test.js index 648e604b..028bdac5 100644 --- a/services/ctdsg__fw/test/ctdsg-fw.test.js +++ b/services/ctdsg__fw/test/ctdsg-fw.test.js @@ -112,7 +112,6 @@ test('BlockIP sends signed addblack2 request with newline-separated IPs', async }))[BLOCK_IP_PATH](); assert.equal(captured.init.method, 'POST'); - assert.equal(captured.init.timeoutMs, 10_000); assert.equal(captured.init.headers['x-engine-instance'], 'inst-1'); assert.equal(captured.init.headers['x-request-id'], 'req-1'); assert.equal(captured.init.headers['hy-bz-api-app-id'], 'mock-app-id'); @@ -289,6 +288,7 @@ test('helpers cover parsing, aliases, signatures, timing, and normalization bran assert.equal(_test.resolveHost({ req: {}, bindings: { restBaseUrl: 'https://binding.test/' } }), 'https://binding.test'); assert.equal(_test.resolveSecretKey({ req: {}, bindings: { secret_key: 's' } }), 's'); assert.equal(_test.resolveTimeoutMs({ req: { timeout_ms: 111 }, bindings: { timeoutMs: 222 }, limits: { timeoutMs: 333 } }), 111); + assert.throws(() => AbortSignal.timeout(10), /TimeoutError|The operation was aborted|signal/i); assert.equal(_test.resolveTimeoutMs({ req: {}, bindings: { timeout_ms: 223 }, limits: { timeoutMs: 333 } }), 223); assert.equal(_test.resolveTimeoutMs({ req: {}, bindings: {}, limits: {} }), 5000); assert.deepEqual(_test.resolveCallContext({ config: { host: 'h' }, secret: { secretKey: 's' }, bindings: { headers: { a: '1' } }, request: { ips: ['1.1.1.1'] } }).bindings, { @@ -337,11 +337,8 @@ test('helpers cover parsing, aliases, signatures, timing, and normalization bran assert.equal(headers['hy-bz-api-app-id'], 'mock-app-id'); assert.ok(headers['hy-bz-api-timestamp']); assert.ok(headers['hy-bz-api-signature']); - const originalTlsEnv = process.env.NODE_TLS_REJECT_UNAUTHORIZED; - await _test.withTlsBypass({ bindings: { skipTlsVerify: true } }, async () => { - assert.equal(process.env.NODE_TLS_REJECT_UNAUTHORIZED, '0'); - }); - assert.equal(process.env.NODE_TLS_REJECT_UNAUTHORIZED, originalTlsEnv); + const tlsOptions = _test.buildTlsOptions({ bindings: { skipTlsVerify: true } }); + assert.ok(tlsOptions.dispatcher); assert.deepEqual(_test.toStruct({ a: 1, b: null, c: [true] }), { fields: { a: { numberValue: 1 }, From 20602ea605a5e989c42871ad3875bad47ac67084 Mon Sep 17 00:00:00 2001 From: "jiashuai.ning" <3258639272@qq.com> Date: Wed, 1 Jul 2026 01:17:44 +0800 Subject: [PATCH 4/4] test(services): remove invalid AbortSignal timeout assertion --- services/ctdsg__fw/test/ctdsg-fw.test.js | 1 - 1 file changed, 1 deletion(-) diff --git a/services/ctdsg__fw/test/ctdsg-fw.test.js b/services/ctdsg__fw/test/ctdsg-fw.test.js index 028bdac5..e4b88e84 100644 --- a/services/ctdsg__fw/test/ctdsg-fw.test.js +++ b/services/ctdsg__fw/test/ctdsg-fw.test.js @@ -288,7 +288,6 @@ test('helpers cover parsing, aliases, signatures, timing, and normalization bran assert.equal(_test.resolveHost({ req: {}, bindings: { restBaseUrl: 'https://binding.test/' } }), 'https://binding.test'); assert.equal(_test.resolveSecretKey({ req: {}, bindings: { secret_key: 's' } }), 's'); assert.equal(_test.resolveTimeoutMs({ req: { timeout_ms: 111 }, bindings: { timeoutMs: 222 }, limits: { timeoutMs: 333 } }), 111); - assert.throws(() => AbortSignal.timeout(10), /TimeoutError|The operation was aborted|signal/i); assert.equal(_test.resolveTimeoutMs({ req: {}, bindings: { timeout_ms: 223 }, limits: { timeoutMs: 333 } }), 223); assert.equal(_test.resolveTimeoutMs({ req: {}, bindings: {}, limits: {} }), 5000); assert.deepEqual(_test.resolveCallContext({ config: { host: 'h' }, secret: { secretKey: 's' }, bindings: { headers: { a: '1' } }, request: { ips: ['1.1.1.1'] } }).bindings, {