diff --git a/services/bin/leadsec-tam.js b/services/bin/leadsec-tam.js new file mode 100755 index 00000000..8af6c889 --- /dev/null +++ b/services/bin/leadsec-tam.js @@ -0,0 +1,10 @@ +#!/usr/bin/env node + +import { fileURLToPath } from "node:url"; +import { runServiceMain } from "@chaitin-ai/octobus-sdk"; + +import { service } from "../leadsec__tam/src/service.js"; + +runServiceMain(service, { + entryFile: fileURLToPath(new URL("../leadsec__tam/bin/leadsec-tam.js", import.meta.url)), +}); diff --git a/services/bin/octobus-tentacles.js b/services/bin/octobus-tentacles.js index 4124b804..0e803f18 100755 --- a/services/bin/octobus-tentacles.js +++ b/services/bin/octobus-tentacles.js @@ -225,6 +225,10 @@ const services = { entryFile: "../venus__ads_v3-6/bin/venus-ads-v3-6.js", serviceModule: "../venus__ads_v3-6/src/service.js", }, + "leadsec-tam": { + entryFile: "../leadsec__tam/bin/leadsec-tam.js", + serviceModule: "../leadsec__tam/src/service.js", + }, "volcengine-cloud-firewall": { entryFile: "../volcengine__cloud-firewall/bin/volcengine-cloud-firewall.js", serviceModule: "../volcengine__cloud-firewall/src/service.js", diff --git a/services/leadsec__tam/README.md b/services/leadsec__tam/README.md new file mode 100644 index 00000000..6aa440be --- /dev/null +++ b/services/leadsec__tam/README.md @@ -0,0 +1,31 @@ +# leadsec-TAM + +OctoBus service adapter for Leadsec TAM anti-DDoS static IP blacklist and whitelist management. + +## Capabilities + +- `Leadsec_TAM.LeadsecTAMService/AddBlacklist`: add one or more IP addresses to the static blacklist. +- `Leadsec_TAM.LeadsecTAMService/AddWhitelist`: add one or more IP addresses to the static whitelist. + +## Configuration + +```json +{ + "baseUrl": "https://10.11.9.107:2018", + "skipTlsVerify": true, + "timeoutMs": 8000, + "language": "zh-cn", + "remark": "OctoBus" +} +``` + +## Secret + +```json +{ + "username": "admin", + "password": "********" +} +``` + +The adapter logs in through `/cnddos/v2.0/api/web_login/ddos`, submits static IP list entries through `/cnddos/v2.0/api/ip_bwlist/info`, and verifies the result through `/cnddos/v2.0/api/ip_bwlist/page_list`. diff --git a/services/leadsec__tam/bin/leadsec-tam.js b/services/leadsec__tam/bin/leadsec-tam.js new file mode 100755 index 00000000..baf4c883 --- /dev/null +++ b/services/leadsec__tam/bin/leadsec-tam.js @@ -0,0 +1,10 @@ +#!/usr/bin/env node + +import { fileURLToPath } from 'node:url'; +import { runServiceMain } from '@chaitin-ai/octobus-sdk'; + +import { service } from '../src/service.js'; + +await runServiceMain(service, { + entryFile: fileURLToPath(import.meta.url), +}); diff --git a/services/leadsec__tam/config.schema.json b/services/leadsec__tam/config.schema.json new file mode 100644 index 00000000..788c6e66 --- /dev/null +++ b/services/leadsec__tam/config.schema.json @@ -0,0 +1,32 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "additionalProperties": false, + "properties": { + "baseUrl": { + "type": "string", + "description": "Leadsec TAM base URL, for example https://10.11.9.107:2018." + }, + "timeoutMs": { + "type": "integer", + "minimum": 1000, + "default": 8000 + }, + "skipTlsVerify": { + "type": "boolean", + "default": false, + "description": "Set true for self-signed certificates in test environments." + }, + "language": { + "type": "string", + "default": "zh-cn" + }, + "remark": { + "type": "string", + "default": "OctoBus" + } + }, + "required": [ + "baseUrl" + ] +} diff --git a/services/leadsec__tam/package.json b/services/leadsec__tam/package.json new file mode 100644 index 00000000..ad2a6eb2 --- /dev/null +++ b/services/leadsec__tam/package.json @@ -0,0 +1,12 @@ +{ + "name": "leadsec-tam", + "version": "0.0.0", + "private": true, + "type": "module", + "bin": { + "leadsec-tam": "bin/leadsec-tam.js" + }, + "dependencies": { + "@chaitin-ai/octobus-sdk": "^0.5.0" + } +} diff --git a/services/leadsec__tam/proto/leadsec_tam.proto b/services/leadsec__tam/proto/leadsec_tam.proto new file mode 100644 index 00000000..a26fab90 --- /dev/null +++ b/services/leadsec__tam/proto/leadsec_tam.proto @@ -0,0 +1,34 @@ +syntax = "proto3"; + +package Leadsec_TAM; + +option go_package = "miner/grpc-service/Leadsec_TAM"; + +service LeadsecTAMService { + rpc AddBlacklist(AddIPListRequest) returns (AddIPListResponse) {} + rpc AddWhitelist(AddIPListRequest) returns (AddIPListResponse) {} +} + +message AddIPListRequest { + repeated string ip_list = 1; + string remark = 2; + string request_id = 3; +} + +enum OperationStatus { + OPERATION_STATUS_UNSPECIFIED = 0; + OPERATION_STATUS_SUCCESS = 1; + OPERATION_STATUS_FAILED = 2; +} + +message AddIPListResponse { + OperationStatus status = 1; + int32 requested_ip_count = 2; + int32 verified_ip_count = 3; + string upstream_result = 4; + string upstream_message = 5; + repeated string verified_ips = 6; + string request_id = 7; + string raw_add_response = 8; + string raw_verify_response = 9; +} diff --git a/services/leadsec__tam/secret.schema.json b/services/leadsec__tam/secret.schema.json new file mode 100644 index 00000000..b4017f95 --- /dev/null +++ b/services/leadsec__tam/secret.schema.json @@ -0,0 +1,17 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "additionalProperties": false, + "properties": { + "username": { + "type": "string" + }, + "password": { + "type": "string" + } + }, + "required": [ + "username", + "password" + ] +} diff --git a/services/leadsec__tam/service.json b/services/leadsec__tam/service.json new file mode 100644 index 00000000..58427569 --- /dev/null +++ b/services/leadsec__tam/service.json @@ -0,0 +1,33 @@ +{ + "schema": "chaitin.octobus.service.v1", + "name": "leadsec-tam", + "displayName": "leadsec-TAM", + "description": "Leadsec TAM anti-DDoS IP blacklist and whitelist management.", + "runtime": { + "mode": "long-running" + }, + "proto": { + "roots": [ + "proto" + ], + "files": [ + "proto/leadsec_tam.proto" + ] + }, + "configSchema": "config.schema.json", + "secretSchema": "secret.schema.json", + "sdk": { + "cli": { + "commands": { + "Leadsec_TAM.LeadsecTAMService/AddBlacklist": { + "name": "add-blacklist", + "description": "Add IP addresses to the Leadsec TAM static blacklist." + }, + "Leadsec_TAM.LeadsecTAMService/AddWhitelist": { + "name": "add-whitelist", + "description": "Add IP addresses to the Leadsec TAM static whitelist." + } + } + } + } +} diff --git a/services/leadsec__tam/src/leadsec-tam.js b/services/leadsec__tam/src/leadsec-tam.js new file mode 100644 index 00000000..36388a71 --- /dev/null +++ b/services/leadsec__tam/src/leadsec-tam.js @@ -0,0 +1,334 @@ +import { GrpcError, grpcStatus } from '@chaitin-ai/octobus-sdk'; + +export const METHOD_ADD_BLACKLIST_FULL = 'Leadsec_TAM.LeadsecTAMService/AddBlacklist'; +export const METHOD_ADD_WHITELIST_FULL = 'Leadsec_TAM.LeadsecTAMService/AddWhitelist'; + +export const LOGIN_PATH = '/cnddos/v2.0/api/web_login/ddos'; +export const ADD_PATH = '/cnddos/v2.0/api/ip_bwlist/info'; +export const VERIFY_PATH = '/cnddos/v2.0/api/ip_bwlist/page_list'; +export const LIST_TYPE_BLACK = 100; +export const LIST_TYPE_WHITE = 200; +export const IP_DIRECTION_ENABLED = 1; +export const RESULT_ALREADY_EXISTS = '-391201'; +export const DEFAULT_TIMEOUT_MS = 8000; +export const DEFAULT_LANGUAGE = 'zh-cn'; +export const DEFAULT_REMARK = 'OctoBus'; + +const grpcCodeFor = (code) => ({ + FAILED_PRECONDITION: grpcStatus.FAILED_PRECONDITION, + INVALID_ARGUMENT: grpcStatus.INVALID_ARGUMENT, + PERMISSION_DENIED: grpcStatus.PERMISSION_DENIED, + UNAVAILABLE: grpcStatus.UNAVAILABLE, + UNKNOWN: grpcStatus.UNKNOWN, +})[code] ?? grpcStatus.UNKNOWN; + +const errorWithCode = (code, message, details) => { + const err = new GrpcError(grpcCodeFor(code), `${code}: ${message}`); + err.legacyCode = code; + if (details !== undefined) err.details = details; + 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') { + if (hasOwn(value, 'value')) return unwrapScalar(value.value); + if (hasOwn(value, 'stringValue')) return unwrapScalar(value.stringValue); + if (hasOwn(value, 'numberValue')) return unwrapScalar(value.numberValue); + if (hasOwn(value, 'boolValue')) return unwrapScalar(value.boolValue); + } + return value; +}; + +const pickString = (value) => { + const raw = unwrapScalar(value); + if (raw === undefined || raw === null) return undefined; + if (typeof raw === 'string') return raw; + if (typeof raw === 'number' || typeof raw === 'boolean') return String(raw); + return undefined; +}; + +const pickFirstString = (values) => { + for (const value of values) { + const str = pickString(value); + if (str !== undefined && str.trim()) return str.trim(); + } + return undefined; +}; + +const pickBoolean = (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 undefined; +}; + +const optionalPositiveInt = (value) => { + const raw = unwrapScalar(value); + if (raw === undefined || raw === null || raw === '') return undefined; + const num = Number(raw); + return Number.isInteger(num) && num > 0 ? num : undefined; +}; + +const toArray = (value) => { + const raw = unwrapScalar(value); + if (Array.isArray(raw)) return raw; + if (raw && typeof raw === 'object' && Array.isArray(raw.values)) return raw.values; + return undefined; +}; + +const safeJSONStringify = (value) => { + try { + return JSON.stringify(value); + } catch { + return String(value); + } +}; + +const redactedBodySummary = (text = '') => String(text) + .replace(/(authorization|token|cookie|password|userpwd)\s*[:=]\s*["']?[^"'\s;&<>]+/gi, '$1=') + .slice(0, 200); + +const normalizeBaseUrl = (value) => { + const baseUrl = pickFirstString([value]); + if (!baseUrl || !/^https?:\/\//i.test(baseUrl)) return ''; + return baseUrl.replace(/\/+$/, ''); +}; + +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 buildEnv = (ctx = {}) => { + const callCtx = resolveCallContext(ctx); + const bindings = callCtx.bindings || {}; + const baseUrl = normalizeBaseUrl(pickFirstString([bindings.baseUrl, bindings.restBaseUrl, bindings.host])); + if (!baseUrl) throw errorWithCode('FAILED_PRECONDITION', 'bindings.baseUrl must be a valid http(s) URL'); + const username = pickFirstString([bindings.username, bindings.user]); + const password = pickFirstString([bindings.password, bindings.pass]); + if (!username || !password) throw errorWithCode('FAILED_PRECONDITION', 'bindings.username and bindings.password are required'); + return { + baseUrl, + username, + password, + language: pickFirstString([bindings.language]) || DEFAULT_LANGUAGE, + remark: pickFirstString([bindings.remark]) || DEFAULT_REMARK, + timeoutMs: optionalPositiveInt(bindings.timeoutMs) ?? optionalPositiveInt(callCtx.limits.timeoutMs) ?? DEFAULT_TIMEOUT_MS, + skipTlsVerify: pickBoolean(bindings.skipTlsVerify) ?? pickBoolean(bindings.tlsInsecureSkipVerify) ?? false, + meta: callCtx.meta, + }; +}; + +const isIPv4OrCidr = (value) => { + const text = String(value ?? '').trim(); + const [ip, mask] = text.split('/'); + if (mask !== undefined) { + if (!/^\d+$/.test(mask)) return false; + const maskNum = Number(mask); + if (maskNum < 0 || maskNum > 32) return false; + } + const parts = ip.split('.'); + if (parts.length !== 4) return false; + return parts.every((part) => /^\d+$/.test(part) && Number(part) >= 0 && Number(part) <= 255); +}; + +const ensureIpList = (req = {}) => { + const source = req.ip_list ?? req.ipList ?? req.ips ?? req.targets; + const arr = toArray(source); + if (!arr) throw errorWithCode('INVALID_ARGUMENT', 'ip_list is required and must be an array'); + const ips = arr.map((item) => pickString(item)?.trim() || '').filter(Boolean); + if (ips.length === 0) throw errorWithCode('INVALID_ARGUMENT', 'ip_list must contain at least one IP address'); + const invalid = ips.filter((ip) => !isIPv4OrCidr(ip)); + if (invalid.length) throw errorWithCode('INVALID_ARGUMENT', `invalid IPv4/CIDR values: ${invalid.join(', ')}`); + return [...new Set(ips)]; +}; + +const extractRequestId = (req = {}) => pickFirstString([req.request_id, req.requestId]) || ''; + +const makeFetchInit = (env, { method, token, body, headers = {} }) => { + const init = { + method, + headers: { + 'content-type': 'application/json', + ...headers, + }, + signal: AbortSignal.timeout(env.timeoutMs), + }; + if (token) init.headers.Authorization = `Bearer ${token}`; + if (body !== undefined) init.body = typeof body === 'string' ? body : JSON.stringify(body); + if (env.skipTlsVerify) { + init.insecureSkipVerify = true; + init.tlsInsecureSkipVerify = true; + } + return init; +}; + +const parseBody = (text) => { + if (!String(text || '').trim()) return {}; + try { + return JSON.parse(text); + } catch { + return { raw: text }; + } +}; + +const requestDevice = async (env, path, init, action) => { + let response; + try { + response = await fetch(`${env.baseUrl}${path}`, init); + } catch (err) { + throw errorWithCode('UNAVAILABLE', `${action} failed: ${err?.cause?.message || err?.message || 'fetch failed'}`); + } + const text = await response.text(); + const json = parseBody(text); + if (!response.ok) { + const code = response.status === 401 || response.status === 403 ? 'PERMISSION_DENIED' : 'FAILED_PRECONDITION'; + throw errorWithCode(code, `${action} upstream http ${response.status}: ${redactedBodySummary(text)}`, { status: response.status, body: json }); + } + return { status: response.status, text, json }; +}; + +const businessOK = (json = {}) => { + const result = json.result ?? json.code; + return result === undefined || result === 0 || result === '0'; +}; + +const extractBusinessMessage = (json = {}) => { + const message = json.message; + if (message === undefined || message === null) return ''; + if (typeof message === 'string') return message; + return safeJSONStringify(message); +}; + +const login = async (env) => { + const body = { + language: env.language, + username: Buffer.from(env.username).toString('base64'), + userpwd: Buffer.from(env.password).toString('base64'), + captchaId: '', + vrifyValue: '', + customize_time_out: '', + }; + const result = await requestDevice(env, LOGIN_PATH, makeFetchInit(env, { + method: 'POST', + headers: { HTTP_SIGN: 'PAGE' }, + body, + }), 'login'); + if (!businessOK(result.json)) throw errorWithCode('PERMISSION_DENIED', `login failed: ${extractBusinessMessage(result.json)}`, result.json); + const token = pickFirstString([result.json?.message?.token, result.json?.data?.access_token, result.json?.token]); + if (!token) throw errorWithCode('PERMISSION_DENIED', 'login response does not contain token', result.json); + return token; +}; + +const addIpList = async (env, token, listType, ips, remark) => { + const body = { + ipadd: ips, + ipdirection: IP_DIRECTION_ENABLED, + ipstate: listType, + remark, + }; + const result = await requestDevice(env, ADD_PATH, makeFetchInit(env, { + method: 'POST', + token, + body, + }), 'add ip list'); + const businessResult = String(result.json?.result ?? result.json?.code ?? ''); + if (!businessOK(result.json) && businessResult !== RESULT_ALREADY_EXISTS) { + throw errorWithCode('FAILED_PRECONDITION', `add ip list failed: ${extractBusinessMessage(result.json)}`, result.json); + } + return result; +}; + +const buildVerifyPath = (listType, ip) => `${VERIFY_PATH}?pagenum=1&pagesize=100&ordercolumn=ipadd&ordermode=asc&listtype=${listType}&condition=${encodeURIComponent(ip)}`; + +const extractItems = (json = {}) => { + const message = json.message; + if (Array.isArray(message)) return message; + if (Array.isArray(json.data)) return json.data; + if (Array.isArray(json.data?.items)) return json.data.items; + if (Array.isArray(json.items)) return json.items; + return []; +}; + +const verifyIpList = async (env, token, listType, ips) => { + const verified = new Set(); + let lastResult = null; + for (const ip of ips) { + const result = await requestDevice(env, buildVerifyPath(listType, ip), makeFetchInit(env, { + method: 'GET', + token, + }), 'verify ip list'); + lastResult = result; + for (const item of extractItems(result.json)) { + const itemIp = pickFirstString([item.ipadd, item.ip, item.ipaddr]); + if (itemIp === ip) verified.add(ip); + } + } + return { + verifiedIps: ips.filter((ip) => verified.has(ip)), + raw: lastResult, + }; +}; + +const runAdd = async (ctx, listType) => { + const callCtx = resolveCallContext(ctx); + const env = buildEnv(callCtx); + const ips = ensureIpList(callCtx.req); + const remark = pickFirstString([callCtx.req?.remark]) || env.remark; + const requestId = extractRequestId(callCtx.req); + const token = await login(env); + const addResult = await addIpList(env, token, listType, ips, remark); + const verifyResult = await verifyIpList(env, token, listType, ips); + const ok = verifyResult.verifiedIps.length === ips.length; + if (!ok) { + throw errorWithCode('FAILED_PRECONDITION', 'not all IP addresses were verified after add', { + requested: ips, + verified: verifyResult.verifiedIps, + addResponse: addResult.json, + verifyResponse: verifyResult.raw?.json, + }); + } + return { + status: 'OPERATION_STATUS_SUCCESS', + requested_ip_count: ips.length, + requestedIpCount: ips.length, + verified_ip_count: verifyResult.verifiedIps.length, + verifiedIpCount: verifyResult.verifiedIps.length, + upstream_result: String(addResult.json?.result ?? addResult.json?.code ?? ''), + upstreamResult: String(addResult.json?.result ?? addResult.json?.code ?? ''), + upstream_message: extractBusinessMessage(addResult.json), + upstreamMessage: extractBusinessMessage(addResult.json), + verified_ips: verifyResult.verifiedIps, + verifiedIps: verifyResult.verifiedIps, + request_id: requestId, + requestId, + raw_add_response: addResult.text, + rawAddResponse: addResult.text, + raw_verify_response: verifyResult.raw?.text || '', + rawVerifyResponse: verifyResult.raw?.text || '', + }; +}; + +export const handlers = { + [METHOD_ADD_BLACKLIST_FULL]: (ctx) => runAdd(ctx, LIST_TYPE_BLACK), + [METHOD_ADD_WHITELIST_FULL]: (ctx) => runAdd(ctx, LIST_TYPE_WHITE), + AddBlacklist: (ctx) => runAdd(ctx, LIST_TYPE_BLACK), + AddWhitelist: (ctx) => runAdd(ctx, LIST_TYPE_WHITE), +}; diff --git a/services/leadsec__tam/src/service.js b/services/leadsec__tam/src/service.js new file mode 100644 index 00000000..af92f202 --- /dev/null +++ b/services/leadsec__tam/src/service.js @@ -0,0 +1,7 @@ +import { defineService } from '@chaitin-ai/octobus-sdk'; + +import { handlers } from './leadsec-tam.js'; + +export { handlers } from './leadsec-tam.js'; + +export const service = defineService({ handlers }); diff --git a/services/leadsec__tam/test/leadsec-tam.test.js b/services/leadsec__tam/test/leadsec-tam.test.js new file mode 100644 index 00000000..f18a3be9 --- /dev/null +++ b/services/leadsec__tam/test/leadsec-tam.test.js @@ -0,0 +1,137 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { handlers, METHOD_ADD_BLACKLIST_FULL, METHOD_ADD_WHITELIST_FULL } from '../src/leadsec-tam.js'; +import { createMockUpstream } from './mock_upstream.js'; + +const withMock = async (fn) => { + const upstream = createMockUpstream(); + const baseUrl = await upstream.start(); + try { + await fn(baseUrl, upstream); + } finally { + await upstream.stop(); + } +}; + +const baseCtx = (baseUrl, req) => ({ + config: { + baseUrl, + timeoutMs: 5000, + }, + secret: { + username: 'admin', + password: 'secret', + }, + req, +}); + +describe('leadsec-TAM service', () => { + it('adds blacklist IP addresses and verifies them', async () => { + await withMock(async (baseUrl, upstream) => { + const result = await handlers[METHOD_ADD_BLACKLIST_FULL](baseCtx(baseUrl, { + ip_list: ['192.0.2.10'], + remark: 'test blacklist', + request_id: 'req-black', + })); + + assert.equal(result.status, 'OPERATION_STATUS_SUCCESS'); + assert.equal(result.request_id, 'req-black'); + assert.deepEqual(result.verified_ips, ['192.0.2.10']); + const addCall = upstream.calls.find((call) => call.method === 'POST' && call.path.endsWith('/ip_bwlist/info')); + assert.ok(addCall); + assert.deepEqual(JSON.parse(addCall.bodyText), { + ipadd: ['192.0.2.10'], + ipdirection: 1, + ipstate: 100, + remark: 'test blacklist', + }); + }); + }); + + it('adds whitelist IP addresses and verifies them', async () => { + await withMock(async (baseUrl, upstream) => { + const result = await handlers[METHOD_ADD_WHITELIST_FULL](baseCtx(baseUrl, { + ip_list: ['198.51.100.20'], + })); + + assert.equal(result.status, 'OPERATION_STATUS_SUCCESS'); + assert.deepEqual(result.verified_ips, ['198.51.100.20']); + const addCall = upstream.calls.find((call) => call.method === 'POST' && call.path.endsWith('/ip_bwlist/info')); + assert.equal(JSON.parse(addCall.bodyText).ipstate, 200); + }); + }); + + it('rejects invalid IP addresses', async () => { + await withMock(async (baseUrl) => { + await assert.rejects( + handlers[METHOD_ADD_BLACKLIST_FULL](baseCtx(baseUrl, { ip_list: ['999.1.1.1'] })), + /INVALID_ARGUMENT/, + ); + }); + }); + + it('uses AbortSignal timeout for upstream fetch calls', async () => { + const calls = []; + global.fetch = async (url, init = {}) => { + calls.push({ url, init }); + if (url.endsWith('/web_login/ddos')) { + return { + ok: true, + status: 200, + text: async () => JSON.stringify({ result: '0', message: { token: 'mock-token' } }), + }; + } + if (url.endsWith('/ip_bwlist/info')) { + return { + ok: true, + status: 200, + text: async () => JSON.stringify({ result: '0', message: null }), + }; + } + return { + ok: true, + status: 200, + text: async () => JSON.stringify({ result: '0', count: 1, message: [{ ipadd: '192.0.2.10' }] }), + }; + }; + + await handlers[METHOD_ADD_BLACKLIST_FULL](baseCtx('https://tam.example.local', { + ip_list: ['192.0.2.10'], + })); + + assert.ok(calls[0].init.signal instanceof AbortSignal); + assert.equal('timeoutMs' in calls[0].init, false); + }); + + it('does not verify IPs from count when upstream returns no items', async () => { + global.fetch = async (url) => { + if (url.endsWith('/web_login/ddos')) { + return { + ok: true, + status: 200, + text: async () => JSON.stringify({ result: '0', message: { token: 'mock-token' } }), + }; + } + if (url.endsWith('/ip_bwlist/info')) { + return { + ok: true, + status: 200, + text: async () => JSON.stringify({ result: '0', message: null }), + }; + } + return { + ok: true, + status: 200, + text: async () => JSON.stringify({ result: '0', count: 1, message: [] }), + }; + }; + + await assert.rejects( + handlers[METHOD_ADD_BLACKLIST_FULL](baseCtx('https://tam.example.local', { + ip_list: ['192.0.2.10'], + })), + /not all IP addresses were verified/, + ); + }); +}); diff --git a/services/leadsec__tam/test/live-tam.test.js b/services/leadsec__tam/test/live-tam.test.js new file mode 100644 index 00000000..69fbc0a9 --- /dev/null +++ b/services/leadsec__tam/test/live-tam.test.js @@ -0,0 +1,31 @@ +import { describe, it } from 'node:test'; + +import { handlers, METHOD_ADD_BLACKLIST_FULL, METHOD_ADD_WHITELIST_FULL } from '../src/leadsec-tam.js'; + +const LIVE_ENABLED = process.env.LEADSEC_TAM_LIVE === '1'; + +const liveCtx = (ip, method) => ({ + config: { + baseUrl: process.env.LEADSEC_TAM_BASE_URL, + skipTlsVerify: process.env.LEADSEC_TAM_SKIP_TLS_VERIFY !== '0', + remark: process.env.LEADSEC_TAM_REMARK || 'OctoBus live test', + }, + secret: { + username: process.env.LEADSEC_TAM_USERNAME, + password: process.env.LEADSEC_TAM_PASSWORD, + }, + req: { + ip_list: [ip], + request_id: `live-${method}-${Date.now()}`, + }, +}); + +describe('leadsec-TAM live test', { skip: !LIVE_ENABLED }, () => { + it('adds blacklist IP in a real Leadsec TAM environment', async () => { + await handlers[METHOD_ADD_BLACKLIST_FULL](liveCtx(process.env.LEADSEC_TAM_BLACK_IP || '203.0.113.10', 'black')); + }); + + it('adds whitelist IP in a real Leadsec TAM environment', async () => { + await handlers[METHOD_ADD_WHITELIST_FULL](liveCtx(process.env.LEADSEC_TAM_WHITE_IP || '203.0.113.11', 'white')); + }); +}); diff --git a/services/leadsec__tam/test/mock_upstream.js b/services/leadsec__tam/test/mock_upstream.js new file mode 100644 index 00000000..d3e9211e --- /dev/null +++ b/services/leadsec__tam/test/mock_upstream.js @@ -0,0 +1,76 @@ +import http from 'node:http'; + +export const createMockUpstream = () => { + const calls = []; + const stored = { + 100: new Set(), + 200: new Set(), + }; + + const server = http.createServer(async (req, res) => { + const chunks = []; + for await (const chunk of req) chunks.push(chunk); + const bodyText = Buffer.concat(chunks).toString('utf8'); + const url = new URL(req.url, 'http://127.0.0.1'); + calls.push({ + method: req.method, + path: url.pathname, + search: url.search, + headers: req.headers, + bodyText, + }); + + const send = (status, body) => { + res.writeHead(status, { 'content-type': 'application/json' }); + res.end(JSON.stringify(body)); + }; + + if (req.method === 'POST' && url.pathname === '/cnddos/v2.0/api/web_login/ddos') { + send(200, { + result: '0', + message: { + id: '1', + username: 'admin', + timeout_time: 30, + token: 'mock-token', + }, + }); + return; + } + + if (req.method === 'POST' && url.pathname === '/cnddos/v2.0/api/ip_bwlist/info') { + const body = JSON.parse(bodyText); + for (const ip of body.ipadd || []) stored[body.ipstate].add(ip); + send(200, { result: '0', message: null }); + return; + } + + if (req.method === 'GET' && url.pathname === '/cnddos/v2.0/api/ip_bwlist/page_list') { + const listType = Number(url.searchParams.get('listtype')); + const condition = url.searchParams.get('condition') || ''; + const items = [...(stored[listType] || [])] + .filter((ip) => !condition || ip.includes(condition)) + .map((ip) => ({ + ipadd: ip, + ipstate: listType, + ipdirection: 1, + remark: 'OctoBus', + })); + send(200, { result: '0', count: items.length, message: items }); + return; + } + + send(404, { result: '-1', message: 'not found' }); + }); + + return { + calls, + start: () => new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => { + const { port } = server.address(); + resolve(`http://127.0.0.1:${port}`); + }); + }), + stop: () => new Promise((resolve, reject) => server.close((err) => (err ? reject(err) : resolve()))), + }; +}; diff --git a/services/package.json b/services/package.json index 2fbea088..e175ecd0 100644 --- a/services/package.json +++ b/services/package.json @@ -60,6 +60,7 @@ "topsec-fw-2u": "bin/topsec-fw-2u.js", "topsec-waf-v3-2294-20238": "bin/topsec-waf-v3-2294-20238.js", "venus-ads-v3-6": "bin/venus-ads-v3-6.js", + "leadsec-tam": "bin/leadsec-tam.js", "volcengine-cloud-firewall": "bin/volcengine-cloud-firewall.js", "wangsu-label-ip": "bin/wangsu-label-ip.js", "wd-k01": "bin/wd-k01.js", @@ -124,6 +125,7 @@ "bin/topsec-fw-v3-7-6.js", "bin/topsec-waf-v3-2294-20238.js", "bin/venus-ads-v3-6.js", + "bin/leadsec-tam.js", "bin/volcengine-cloud-firewall.js", "bin/wd-k01.js", "bin/tencent-bh.js", @@ -186,6 +188,7 @@ "topsec__fw_v3-7-6", "topsec__waf_v3-2294-20238", "venus__ads_v3-6", + "leadsec__tam", "volcengine__cloud-firewall", "wd__k01", "tencent__bh",