From 9346dc064b407e3720fceee07b7900b03a68efcb Mon Sep 17 00:00:00 2001 From: hilariacrucita <3362488788@qq.com> Date: Fri, 26 Jun 2026 20:04:52 +0800 Subject: [PATCH 1/3] feat: add ailpha-platform service package --- services/ailpha__platform/README.md | 47 ++ .../ailpha__platform/bin/ailpha-platform.js | 7 + services/ailpha__platform/config.schema.json | 33 ++ services/ailpha__platform/package.json | 12 + .../proto/ailpha_platform.proto | 123 +++++ services/ailpha__platform/secret.schema.json | 15 + services/ailpha__platform/service.json | 49 ++ .../ailpha__platform/src/ailpha-platform.js | 432 ++++++++++++++++++ services/ailpha__platform/src/service.js | 7 + .../test/ailpha-platform.test.js | 249 ++++++++++ .../ailpha__platform/test/mock_upstream.js | 75 +++ services/bin/ailpha-platform.js | 10 + services/bin/octobus-tentacles.js | 4 + services/package.json | 3 + 14 files changed, 1066 insertions(+) create mode 100644 services/ailpha__platform/README.md create mode 100644 services/ailpha__platform/bin/ailpha-platform.js create mode 100644 services/ailpha__platform/config.schema.json create mode 100644 services/ailpha__platform/package.json create mode 100644 services/ailpha__platform/proto/ailpha_platform.proto create mode 100644 services/ailpha__platform/secret.schema.json create mode 100644 services/ailpha__platform/service.json create mode 100644 services/ailpha__platform/src/ailpha-platform.js create mode 100644 services/ailpha__platform/src/service.js create mode 100644 services/ailpha__platform/test/ailpha-platform.test.js create mode 100644 services/ailpha__platform/test/mock_upstream.js create mode 100644 services/bin/ailpha-platform.js diff --git a/services/ailpha__platform/README.md b/services/ailpha__platform/README.md new file mode 100644 index 00000000..a7907484 --- /dev/null +++ b/services/ailpha__platform/README.md @@ -0,0 +1,47 @@ +# AiLPHA Security Platform + +OctoBus service package for the AiLPHA SIEM/SOC platform (V5.1) REST API. + +## Import + +```bash +octobus service import --id ailpha-platform ./services/ailpha__platform +``` + +## Configuration + +Set `endpoint` to the platform base URL. `timeoutMs` (default 1500), `headers`, and `skipTlsVerify` are optional. + +```json +{ + "endpoint": "https://ailpha.example.com", + "timeoutMs": 3000 +} +``` + +Secret: `apiKey` sent as the `apiKey` HTTP header. + +```json +{ + "apiKey": "" +} +``` + +## Behavior + +- `ListMergeAlarms` calls `GET /openapi/v2.0/merge-alarms` with optional filters (`order_by`, `page`, `size`, `condition`, `connect_type`, `start_time`, `end_time`). Returns alarm list as Struct. +- `GetMergeAlarmDetail` calls `GET /openapi/v1.0/merge-alarm/detail`. Requires `agg_condition` and `window_id`. +- `UpdateMergeAlarmStatus` calls `POST /openapi/v2.0/merge-alarms/status`. Requires `alarm_status` and a selector (`condition` or `start_time`+`end_time`). +- `ListLinkageStrategies` calls `GET /openapi/v1.0/linkage-strategies` to list blocking strategies. +- `BlockIp` calls `POST /openapi/v1.0/linkage-strategies/{ids}/accessIp`. +- `UnblockIp` calls `DELETE /openapi/v1.0/linkage-strategies/{ids}/blockIp`. Idempotent: 404 returns empty success. +- Missing endpoint or API key returns `INVALID_ARGUMENT`. HTTP 401 maps to `UNAUTHENTICATED`. HTTP 403 maps to `PERMISSION_DENIED`. HTTP 404 (non-idempotent) maps to `NOT_FOUND`. Other 4xx maps to `FAILED_PRECONDITION`. Network errors and 5xx map to `UNAVAILABLE`. + +## Local Checks + +```bash +cd services +npm run validate -- --service-dir ailpha__platform +npm test -- --service-dir ailpha__platform --coverage +npm run pack:check +``` diff --git a/services/ailpha__platform/bin/ailpha-platform.js b/services/ailpha__platform/bin/ailpha-platform.js new file mode 100644 index 00000000..508272f0 --- /dev/null +++ b/services/ailpha__platform/bin/ailpha-platform.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/ailpha__platform/config.schema.json b/services/ailpha__platform/config.schema.json new file mode 100644 index 00000000..0126bee3 --- /dev/null +++ b/services/ailpha__platform/config.schema.json @@ -0,0 +1,33 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": true, + "properties": { + "endpoint": { + "type": "string", + "description": "AiLPHA platform base URL, for example https://ailpha.example.com (the /openapi paths are appended)." + }, + "baseUrl": { + "type": "string", + "description": "Legacy alias for endpoint." + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "Optional extra HTTP headers sent to AiLPHA." + }, + "timeoutMs": { + "type": "integer", + "minimum": 1, + "default": 1500, + "description": "HTTP timeout in milliseconds." + }, + "skipTlsVerify": { + "type": "boolean", + "default": false, + "description": "Skip TLS certificate verification for private AiLPHA deployments." + } + } +} diff --git a/services/ailpha__platform/package.json b/services/ailpha__platform/package.json new file mode 100644 index 00000000..7eded71d --- /dev/null +++ b/services/ailpha__platform/package.json @@ -0,0 +1,12 @@ +{ + "name": "ailpha-platform", + "version": "0.0.0", + "private": true, + "type": "module", + "bin": { + "ailpha-platform": "bin/ailpha-platform.js" + }, + "dependencies": { + "@chaitin-ai/octobus-sdk": "^0.5.0" + } +} diff --git a/services/ailpha__platform/proto/ailpha_platform.proto b/services/ailpha__platform/proto/ailpha_platform.proto new file mode 100644 index 00000000..6eac8c63 --- /dev/null +++ b/services/ailpha__platform/proto/ailpha_platform.proto @@ -0,0 +1,123 @@ +syntax = "proto3"; + +package AiLPHA_Platform; + +import "google/protobuf/struct.proto"; +import "google/protobuf/wrappers.proto"; + +option go_package = "miner/grpc-service/AiLPHA_Platform"; + +// AiLPHA 安全分析与管理平台 (SIEM/SOC) - 归并告警查询/详情/处置 + 联动策略封禁/解封。 +// 认证:apiKey HTTP 头。响应形如 { "$page":N, "$size":N, "total":N, "data":... },无 code/msg 信封,成败看 HTTP 状态码。 +// 大对象(告警/策略)字段繁多,统一用 google.protobuf.Struct 原样透传。 +service AiLPHA_Platform { + // GET /openapi/v2.0/merge-alarms — 归并告警列表查询(只读,核心) + rpc ListMergeAlarms(ListMergeAlarmsRequest) returns (ListMergeAlarmsResponse) {} + + // GET /openapi/v1.0/merge-alarm/detail — 按 aggCondition + windowId 查询单条归并告警详情(只读) + rpc GetMergeAlarmDetail(GetMergeAlarmDetailRequest) returns (GetMergeAlarmDetailResponse) {} + + // POST /openapi/v2.0/merge-alarms/status — 写:批量处置归并告警 + rpc UpdateMergeAlarmStatus(UpdateMergeAlarmStatusRequest) returns (UpdateMergeAlarmStatusResponse) {} + + // GET /openapi/v1.0/linkage-strategies — 联动策略列表(只读,用于获取策略 id) + rpc ListLinkageStrategies(ListLinkageStrategiesRequest) returns (ListLinkageStrategiesResponse) {} + + // POST /openapi/v1.0/linkage-strategies/{ids}/accessIp — 写:对指定联动策略下发/生效 IP 阻断 + rpc BlockIp(BlockIpRequest) returns (BlockIpResponse) {} + + // DELETE /openapi/v1.0/linkage-strategies/{ids}/blockIp — 写:解除指定联动策略的 IP 阻断(缺失幂等) + rpc UnblockIp(UnblockIpRequest) returns (UnblockIpResponse) {} +} + +// ============================ ListMergeAlarms ============================ + +message ListMergeAlarmsRequest { + google.protobuf.StringValue order_by = 1; // $orderBy,例 "endTime desc",原样透传 + google.protobuf.Int64Value page = 2; // $page,>=1 + google.protobuf.Int64Value size = 3; // $size,1~1000 + google.protobuf.StringValue cascade_org_id = 4; // cascadeOrgId 组织架构ID + google.protobuf.StringValue condition = 5; // condition,AiQL 查询语句 + string connect_type = 6; // connectType:ALL/DIRECT_CONNECT/ONLY_CURRENT;ALL 或空则省略 + google.protobuf.StringValue end_time = 7; // endTime "yyyy-MM-dd HH:mm:ss",默认当天 23:59:59 + google.protobuf.BoolValue field_mapping = 8; // fieldMapping 是否属性映射 + google.protobuf.StringValue params = 9; // params + google.protobuf.StringValue start_time = 10; // startTime,默认当天 00:00:00 +} + +message ListMergeAlarmsResponse { + int64 page = 1; // $page + int64 size = 2; // $size + int64 total = 3; // 总数 + repeated google.protobuf.Struct data = 4; // 告警对象列表,原样透传 + string order_by = 5; // $orderBy 实际生效排序 +} + +// ============================ GetMergeAlarmDetail ============================ + +message GetMergeAlarmDetailRequest { + string agg_condition = 1; // aggCondition 归并字段MD5,必填 + string window_id = 2; // windowId 时间窗口id,必填 +} + +message GetMergeAlarmDetailResponse { + google.protobuf.Struct detail = 1; // 单条告警详情,原样透传 +} + +// ============================ UpdateMergeAlarmStatus ============================ + +message UpdateMergeAlarmStatusRequest { + string alarm_status = 1; // alarmStatus 处置状态,必填,例 falsePositives/processed/unprocessed/ignored + google.protobuf.StringValue alarm_notes = 2; // alarmNotes 处置备注 + google.protobuf.StringValue alarm_source = 3; // alarmSource 告警来源,例 MSS + google.protobuf.StringValue condition = 4; // condition;与 start_time+end_time 二选一作为选择器 + google.protobuf.StringValue start_time = 5; // startTime + google.protobuf.StringValue end_time = 6; // endTime + google.protobuf.StringValue url = 7; // url +} + +message UpdateMergeAlarmStatusResponse { + int64 page = 1; // $page + int64 size = 2; // $size + string data = 3; // 处置结果描述 +} + +// ============================ ListLinkageStrategies ============================ + +message ListLinkageStrategiesRequest { + google.protobuf.StringValue order_by = 1; // $orderBy,可排序字段:blockIp/effectTime/status/linkDevice + google.protobuf.Int64Value page = 2; // $page,>=1 + google.protobuf.Int64Value size = 3; // $size,1~1000 + google.protobuf.StringValue age = 4; // age +} + +message ListLinkageStrategiesResponse { + int64 page = 1; // $page + int64 size = 2; // $size + int64 total = 3; // 总数 + repeated google.protobuf.Struct data = 4; // 联动策略列表,原样透传 +} + +// ============================ BlockIp ============================ + +message BlockIpRequest { + repeated string ids = 1; // 联动策略 id 列表,必填且非空;逗号拼接进 path,元素不可含 "/" +} + +message BlockIpResponse { + int64 page = 1; // $page + int64 size = 2; // $size + string data = 3; // 操作结果描述 +} + +// ============================ UnblockIp ============================ + +message UnblockIpRequest { + repeated string ids = 1; // 联动策略 id 列表,必填且非空 +} + +message UnblockIpResponse { + int64 page = 1; // $page + int64 size = 2; // $size + string data = 3; // 操作结果描述;策略不存在(404)时为空字符串(幂等) +} diff --git a/services/ailpha__platform/secret.schema.json b/services/ailpha__platform/secret.schema.json new file mode 100644 index 00000000..d6551157 --- /dev/null +++ b/services/ailpha__platform/secret.schema.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": true, + "properties": { + "apiKey": { + "type": "string", + "description": "AiLPHA API key (login token), sent as the apiKey HTTP header. Created via 用户管理 → 新增API Key." + }, + "api_key": { + "type": "string", + "description": "Legacy alias for apiKey." + } + } +} diff --git a/services/ailpha__platform/service.json b/services/ailpha__platform/service.json new file mode 100644 index 00000000..20e6a4c0 --- /dev/null +++ b/services/ailpha__platform/service.json @@ -0,0 +1,49 @@ +{ + "schema": "chaitin.octobus.service.v1", + "name": "ailpha-platform", + "displayName": "AiLPHA Security Platform", + "description": "OctoBus package for the AiLPHA security analysis & management platform (SIEM/SOC): merged-alarm query/detail/disposition and linkage-strategy IP block/unblock through the /openapi REST API.", + "runtime": { + "mode": "long-running" + }, + "proto": { + "roots": [ + "proto" + ], + "files": [ + "proto/ailpha_platform.proto" + ] + }, + "configSchema": "config.schema.json", + "secretSchema": "secret.schema.json", + "sdk": { + "cli": { + "commands": { + "AiLPHA_Platform.AiLPHA_Platform/ListMergeAlarms": { + "name": "list-merge-alarms", + "description": "List AiLPHA merged alarms." + }, + "AiLPHA_Platform.AiLPHA_Platform/GetMergeAlarmDetail": { + "name": "get-merge-alarm-detail", + "description": "Get an AiLPHA merged alarm detail." + }, + "AiLPHA_Platform.AiLPHA_Platform/UpdateMergeAlarmStatus": { + "name": "update-merge-alarm-status", + "description": "Update (dispose) AiLPHA merged alarm status." + }, + "AiLPHA_Platform.AiLPHA_Platform/ListLinkageStrategies": { + "name": "list-linkage-strategies", + "description": "List AiLPHA linkage strategies." + }, + "AiLPHA_Platform.AiLPHA_Platform/BlockIp": { + "name": "block-ip", + "description": "Apply IP block on AiLPHA linkage strategies." + }, + "AiLPHA_Platform.AiLPHA_Platform/UnblockIp": { + "name": "unblock-ip", + "description": "Remove IP block from AiLPHA linkage strategies." + } + } + } + } +} diff --git a/services/ailpha__platform/src/ailpha-platform.js b/services/ailpha__platform/src/ailpha-platform.js new file mode 100644 index 00000000..31c42a4d --- /dev/null +++ b/services/ailpha__platform/src/ailpha-platform.js @@ -0,0 +1,432 @@ +// AiLPHA_Platform — proxy for the AiLPHA SIEM/SOC platform /openapi REST API. +// Methods: ListMergeAlarms, GetMergeAlarmDetail, UpdateMergeAlarmStatus, +// ListLinkageStrategies, BlockIp, UnblockIp. +// Bindings (config): endpoint/baseUrl, headers, timeoutMs, skipTlsVerify. +// Bindings (secret): apiKey (sent as the `apiKey` HTTP header). + +import { GrpcError, grpcStatus } from '@chaitin-ai/octobus-sdk'; + +const DEFAULT_TIMEOUT_MS = 1500; + +const PKG = 'AiLPHA_Platform.AiLPHA_Platform'; +const LIST_ALARMS_PATH = `/${PKG}/ListMergeAlarms`; +const ALARM_DETAIL_PATH = `/${PKG}/GetMergeAlarmDetail`; +const ALARM_STATUS_PATH = `/${PKG}/UpdateMergeAlarmStatus`; +const LIST_LINKAGE_PATH = `/${PKG}/ListLinkageStrategies`; +const BLOCK_IP_PATH = `/${PKG}/BlockIp`; +const UNBLOCK_IP_PATH = `/${PKG}/UnblockIp`; + +const API_LIST_ALARMS = '/openapi/v2.0/merge-alarms'; +const API_ALARM_DETAIL = '/openapi/v1.0/merge-alarm/detail'; +const API_ALARM_STATUS = '/openapi/v2.0/merge-alarms/status'; +const API_LIST_LINKAGE = '/openapi/v1.0/linkage-strategies'; + +const CONNECT_TYPES = ['ALL', 'DIRECT_CONNECT', 'ONLY_CURRENT']; +const LINKAGE_SORT_KEYS = ['blockIp', 'effectTime', 'status', 'linkDevice']; +const MAX_PAGE_SIZE = 1000; + +const grpcCodeFor = (code) => ({ + INVALID_ARGUMENT: grpcStatus.INVALID_ARGUMENT, + FAILED_PRECONDITION: grpcStatus.FAILED_PRECONDITION, + PERMISSION_DENIED: grpcStatus.PERMISSION_DENIED, + UNAUTHENTICATED: grpcStatus.UNAUTHENTICATED, + NOT_FOUND: grpcStatus.NOT_FOUND, + UNAVAILABLE: grpcStatus.UNAVAILABLE, + DEADLINE_EXCEEDED: grpcStatus.DEADLINE_EXCEEDED, +})[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 = (...vals) => vals.find((v) => v !== undefined && v !== null); + +const mergedBindings = (ctx = {}) => ({ + ...(ctx?.config ?? {}), + ...(ctx?.secret ?? {}), + ...(ctx?.bindings ?? {}), +}); + +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 normalizeBaseUrl = (url) => { + const base = String(url || '').trim(); + if (!/^https?:\/\//i.test(base)) return null; + return base.replace(/\/$/, ''); +}; + +const unwrapValue = (source) => { + if (source !== null && typeof source === 'object' && 'value' in source) return source.value; + return source; +}; + +const toOptionalString = (val) => { + const raw = unwrapValue(val); + if (raw === undefined || raw === null) return undefined; + const str = String(raw); + return str === '' ? undefined : str; +}; + +const toInt = (val) => { + const raw = unwrapValue(val); + if (raw === undefined || raw === null || raw === '') return 0; + const n = Number(raw); + return Number.isFinite(n) ? Math.trunc(n) : 0; +}; + +const toOptionalBool = (val) => { + const raw = unwrapValue(val); + if (raw === undefined || raw === null || raw === '') return undefined; + if (typeof raw === 'boolean') return raw; + const s = String(raw).trim().toLowerCase(); + if (s === 'true' || s === '1') return true; + if (s === 'false' || s === '0') return false; + return undefined; +}; + +const toPageSize = (val, field) => { + const raw = unwrapValue(val); + if (raw === undefined || raw === null || raw === '' || raw === 0) return undefined; + const n = Number(raw); + if (!Number.isInteger(n) || Number.isNaN(n)) { + throw errorWithCode('INVALID_ARGUMENT', `${field} must be an integer`); + } + if (field === 'page' && n < 1) throw errorWithCode('INVALID_ARGUMENT', 'page must be >= 1'); + if (field === 'size' && (n < 1 || n > MAX_PAGE_SIZE)) { + throw errorWithCode('INVALID_ARGUMENT', `size must be in [1, ${MAX_PAGE_SIZE}]`); + } + return n; +}; + +const str = (v) => { + const raw = unwrapValue(v); + return raw === undefined || raw === null ? '' : String(raw); +}; + +const requireNonEmpty = (val, field) => { + const s = toOptionalString(val); + if (s === undefined) throw errorWithCode('INVALID_ARGUMENT', `${field} is required`); + return s; +}; + +const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v); +const normalizeStruct = (v) => (isPlainObject(v) ? v : {}); + +const requireIds = (req) => { + const raw = firstDefined(req?.ids, req?.Ids); + if (!Array.isArray(raw)) throw errorWithCode('INVALID_ARGUMENT', 'ids must be a non-empty array'); + if (raw.length === 0) throw errorWithCode('INVALID_ARGUMENT', 'ids must be non-empty'); + return raw.map((item) => { + if (item === undefined || item === null || String(item).trim() === '') { + throw errorWithCode('INVALID_ARGUMENT', 'ids elements must be non-empty strings'); + } + const s = String(item).trim(); + if (s.includes('/')) throw errorWithCode('INVALID_ARGUMENT', 'ids elements must not contain "/"'); + return s; + }); +}; + +// Maps a {$page,$size,total,data[]} list envelope. +const mapListResponse = (json) => { + const j = isPlainObject(json) ? json : {}; + return { + page: toInt(j.$page), + size: toInt(j.$size), + total: toInt(j.total), + data: Array.isArray(j.data) ? j.data.map(normalizeStruct) : [], + order_by: str(j.$orderBy), + }; +}; + +// Maps a {$page,$size,data:"..."} write envelope. +const mapWriteResponse = (json) => { + const j = isPlainObject(json) ? json : {}; + return { page: toInt(j.$page), size: toInt(j.$size), data: str(j.data) }; +}; + +export function rpcdef(ctx) { + const bindings = mergedBindings(ctx); + const endpoint = bindings.endpoint || bindings.baseUrl || bindings.base_url || ''; + const timeoutMs = ctx.limits?.timeoutMs || Number(bindings.timeoutMs) || DEFAULT_TIMEOUT_MS; + const baseHeaders = parseHeaders(bindings.headers); + const meta = ctx.meta || {}; + const skipTlsVerify = Boolean( + bindings.skipTlsVerify || bindings.skip_tls_verify || bindings.tlsInsecureSkipVerify || bindings.tls_insecure_skip_verify, + ); + const apiKey = toOptionalString(bindings.apiKey || bindings.api_key); + + const buildHeaders = (extra = {}) => { + if (!apiKey) throw errorWithCode('INVALID_ARGUMENT', 'apiKey is required'); + return { + ...baseHeaders, + apiKey, + 'x-engine-instance': meta.instance_id || meta.instanceId || 'octobus-ailpha', + 'x-request-id': meta.request_id || meta.requestId || 'unknown', + ...extra, + }; + }; + + const tlsOptions = () => (skipTlsVerify ? { insecureSkipVerify: true, tlsInsecureSkipVerify: true } : {}); + + const buildQuery = (pairs) => { + const parts = []; + for (const [k, v] of pairs) { + if (v === undefined) continue; + parts.push(`${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`); + } + return parts.length ? `?${parts.join('&')}` : ''; + }; + + // Calls AiLPHA; on non-2xx throws a gRPC error carrying .httpStatus. Returns parsed JSON (or null). + const callAiLPHA = async (path, { method, bodyObj } = {}) => { + const baseUrl = normalizeBaseUrl(endpoint); + if (!baseUrl) throw errorWithCode('INVALID_ARGUMENT', 'endpoint/baseUrl is required (http/https)'); + const hasBody = bodyObj !== undefined; + const headers = buildHeaders(hasBody ? { 'content-type': 'application/json' } : {}); + + let res; + try { + res = await fetch(`${baseUrl}${path}`, { + method, + headers, + body: hasBody ? JSON.stringify(bodyObj) : undefined, + timeoutMs, + ...tlsOptions(), + }); + } catch (e) { + const reason = e?.cause?.message || e?.message || 'fetch failed'; + throw errorWithCode('UNAVAILABLE', reason); + } + + const text = await res.text(); + if (!res.ok) { + let code; + if (res.status === 401) code = 'UNAUTHENTICATED'; + else if (res.status === 403) code = 'PERMISSION_DENIED'; + else if (res.status === 404) code = 'NOT_FOUND'; + else if (res.status >= 400 && res.status < 500) code = 'FAILED_PRECONDITION'; + else code = 'UNAVAILABLE'; + const err = errorWithCode(code, `upstream http ${res.status}: ${text}`); + err.httpStatus = res.status; + throw err; + } + + if (!text.trim()) return null; + try { + return JSON.parse(text); + } catch { + throw errorWithCode('UNKNOWN', 'response is not valid JSON'); + } + }; + + const callListMergeAlarms = async (req) => { + const orderBy = toOptionalString(firstDefined(req?.order_by, req?.orderBy)); + const page = toPageSize(firstDefined(req?.page, req?.Page), 'page'); + const size = toPageSize(firstDefined(req?.size, req?.Size), 'size'); + const cascadeOrgId = toOptionalString(firstDefined(req?.cascade_org_id, req?.cascadeOrgId)); + const condition = toOptionalString(req?.condition); + const endTime = toOptionalString(firstDefined(req?.end_time, req?.endTime)); + const startTime = toOptionalString(firstDefined(req?.start_time, req?.startTime)); + const params = toOptionalString(req?.params); + const fieldMapping = toOptionalBool(firstDefined(req?.field_mapping, req?.fieldMapping)); + + let connectType = toOptionalString(firstDefined(req?.connect_type, req?.connectType)); + if (connectType !== undefined) { + if (!CONNECT_TYPES.includes(connectType)) { + throw errorWithCode('INVALID_ARGUMENT', `connect_type must be one of ${CONNECT_TYPES.join(', ')}`); + } + if (connectType === 'ALL') connectType = undefined; // ALL = omit + } + + const query = buildQuery([ + ['$orderBy', orderBy], + ['$page', page], + ['$size', size], + ['cascadeOrgId', cascadeOrgId], + ['condition', condition], + ['connectType', connectType], + ['endTime', endTime], + ['fieldMapping', fieldMapping === undefined ? undefined : String(fieldMapping)], + ['params', params], + ['startTime', startTime], + ]); + + const json = await callAiLPHA(`${API_LIST_ALARMS}${query}`, { method: 'GET' }); + return mapListResponse(json); + }; + + const callGetMergeAlarmDetail = async (req) => { + const aggCondition = requireNonEmpty(firstDefined(req?.agg_condition, req?.aggCondition), 'agg_condition'); + const windowId = requireNonEmpty(firstDefined(req?.window_id, req?.windowId), 'window_id'); + const query = buildQuery([['aggCondition', aggCondition], ['windowId', windowId]]); + const json = await callAiLPHA(`${API_ALARM_DETAIL}${query}`, { method: 'GET' }); + return { detail: normalizeStruct(json) }; + }; + + const callUpdateMergeAlarmStatus = async (req) => { + const alarmStatus = requireNonEmpty(firstDefined(req?.alarm_status, req?.alarmStatus), 'alarm_status'); + const condition = toOptionalString(req?.condition); + const startTime = toOptionalString(firstDefined(req?.start_time, req?.startTime)); + const endTime = toOptionalString(firstDefined(req?.end_time, req?.endTime)); + if (condition === undefined && !(startTime !== undefined && endTime !== undefined)) { + throw errorWithCode('INVALID_ARGUMENT', 'provide condition, or both start_time and end_time, as the selector'); + } + + const body = { alarmStatus }; + const alarmNotes = toOptionalString(firstDefined(req?.alarm_notes, req?.alarmNotes)); + if (alarmNotes !== undefined) body.alarmNotes = alarmNotes; + const alarmSource = toOptionalString(firstDefined(req?.alarm_source, req?.alarmSource)); + if (alarmSource !== undefined) body.alarmSource = alarmSource; + if (condition !== undefined) body.condition = condition; + if (startTime !== undefined) body.startTime = startTime; + if (endTime !== undefined) body.endTime = endTime; + const url = toOptionalString(req?.url); + if (url !== undefined) body.url = url; + + const json = await callAiLPHA(API_ALARM_STATUS, { method: 'POST', bodyObj: body }); + return mapWriteResponse(json); + }; + + const callListLinkageStrategies = async (req) => { + const page = toPageSize(firstDefined(req?.page, req?.Page), 'page'); + const size = toPageSize(firstDefined(req?.size, req?.Size), 'size'); + const age = toOptionalString(req?.age); + + const orderBy = toOptionalString(firstDefined(req?.order_by, req?.orderBy)); + if (orderBy !== undefined) { + const key = orderBy.replace(/^-/, '').replace(/\s+(asc|desc)$/i, '').trim(); + if (!LINKAGE_SORT_KEYS.includes(key)) { + throw errorWithCode('INVALID_ARGUMENT', `order_by key must be one of ${LINKAGE_SORT_KEYS.join(', ')}`); + } + } + + const query = buildQuery([ + ['$orderBy', orderBy], + ['$page', page], + ['$size', size], + ['age', age], + ]); + const json = await callAiLPHA(`${API_LIST_LINKAGE}${query}`, { method: 'GET' }); + return mapListResponse(json); + }; + + const callBlockIp = async (req) => { + const ids = requireIds(req); + const path = `${API_LIST_LINKAGE}/${ids.map(encodeURIComponent).join(',')}/accessIp`; + const json = await callAiLPHA(path, { method: 'POST' }); + return mapWriteResponse(json); + }; + + const callUnblockIp = async (req) => { + const ids = requireIds(req); + const path = `${API_LIST_LINKAGE}/${ids.map(encodeURIComponent).join(',')}/blockIp`; + try { + const json = await callAiLPHA(path, { method: 'DELETE' }); + return mapWriteResponse(json); + } catch (e) { + if (e?.httpStatus === 404) return { page: 0, size: 0, data: '' }; // idempotent on absence + throw e; + } + }; + + return { + [LIST_ALARMS_PATH]: async () => callListMergeAlarms(ctx.req ?? {}), + [ALARM_DETAIL_PATH]: async () => callGetMergeAlarmDetail(ctx.req ?? {}), + [ALARM_STATUS_PATH]: async () => callUpdateMergeAlarmStatus(ctx.req ?? {}), + [LIST_LINKAGE_PATH]: async () => callListLinkageStrategies(ctx.req ?? {}), + [BLOCK_IP_PATH]: async () => callBlockIp(ctx.req ?? {}), + [UNBLOCK_IP_PATH]: async () => callUnblockIp(ctx.req ?? {}), + }; +} + +const mergeCtx = (baseCtx, innerCtx) => ({ + ...(baseCtx ?? {}), + ...(innerCtx ?? {}), + bindings: { ...(baseCtx?.bindings ?? {}), ...(innerCtx?.bindings ?? {}) }, + config: { ...(baseCtx?.config ?? {}), ...(innerCtx?.config ?? {}) }, + secret: { ...(baseCtx?.secret ?? {}), ...(innerCtx?.secret ?? {}) }, + limits: innerCtx?.limits ?? baseCtx?.limits ?? {}, + meta: innerCtx?.meta ?? baseCtx?.meta ?? {}, + metadata: innerCtx?.metadata ?? baseCtx?.metadata ?? {}, + getMetadata: innerCtx?.getMetadata ?? baseCtx?.getMetadata, +}); + +const resolveCallContext = (baseCtx, reqOrCtx, maybeInnerCtx) => { + if (maybeInnerCtx !== undefined) { + return { req: reqOrCtx ?? {}, ctx: mergeCtx(baseCtx, maybeInnerCtx) }; + } + const innerCtx = reqOrCtx ?? {}; + return { + req: innerCtx.request ?? innerCtx.req ?? {}, + ctx: mergeCtx(baseCtx, innerCtx), + }; +}; + +const wrapLegacyHandler = (baseCtx, methodPath) => async (reqOrCtx, maybeInnerCtx) => { + const call = resolveCallContext(baseCtx, reqOrCtx, maybeInnerCtx); + const legacyCtx = { ...call.ctx, req: call.req }; + return rpcdef(legacyCtx)[methodPath](); +}; + +const registerHandlers = (ctx = {}) => ({ + [LIST_ALARMS_PATH]: wrapLegacyHandler(ctx, LIST_ALARMS_PATH), + [ALARM_DETAIL_PATH]: wrapLegacyHandler(ctx, ALARM_DETAIL_PATH), + [ALARM_STATUS_PATH]: wrapLegacyHandler(ctx, ALARM_STATUS_PATH), + [LIST_LINKAGE_PATH]: wrapLegacyHandler(ctx, LIST_LINKAGE_PATH), + [BLOCK_IP_PATH]: wrapLegacyHandler(ctx, BLOCK_IP_PATH), + [UNBLOCK_IP_PATH]: wrapLegacyHandler(ctx, UNBLOCK_IP_PATH), +}); + +export const METHOD_LIST_ALARMS_FULL = `${PKG}/ListMergeAlarms`; +export const METHOD_ALARM_DETAIL_FULL = `${PKG}/GetMergeAlarmDetail`; +export const METHOD_ALARM_STATUS_FULL = `${PKG}/UpdateMergeAlarmStatus`; +export const METHOD_LIST_LINKAGE_FULL = `${PKG}/ListLinkageStrategies`; +export const METHOD_BLOCK_IP_FULL = `${PKG}/BlockIp`; +export const METHOD_UNBLOCK_IP_FULL = `${PKG}/UnblockIp`; + +const sdkHandlers = registerHandlers({}); + +export const handlers = { + [METHOD_LIST_ALARMS_FULL]: (ctx) => sdkHandlers[LIST_ALARMS_PATH](ctx), + [METHOD_ALARM_DETAIL_FULL]: (ctx) => sdkHandlers[ALARM_DETAIL_PATH](ctx), + [METHOD_ALARM_STATUS_FULL]: (ctx) => sdkHandlers[ALARM_STATUS_PATH](ctx), + [METHOD_LIST_LINKAGE_FULL]: (ctx) => sdkHandlers[LIST_LINKAGE_PATH](ctx), + [METHOD_BLOCK_IP_FULL]: (ctx) => sdkHandlers[BLOCK_IP_PATH](ctx), + [METHOD_UNBLOCK_IP_FULL]: (ctx) => sdkHandlers[UNBLOCK_IP_PATH](ctx), +}; + +export const _test = { + errorWithCode, + firstDefined, + mapListResponse, + mapWriteResponse, + mergedBindings, + normalizeBaseUrl, + normalizeStruct, + parseHeaders, + registerHandlers, + requireIds, + requireNonEmpty, + resolveCallContext, + toInt, + toOptionalBool, + toOptionalString, + toPageSize, + CONNECT_TYPES, + LINKAGE_SORT_KEYS, +}; diff --git a/services/ailpha__platform/src/service.js b/services/ailpha__platform/src/service.js new file mode 100644 index 00000000..b700238a --- /dev/null +++ b/services/ailpha__platform/src/service.js @@ -0,0 +1,7 @@ +import { defineService } from "@chaitin-ai/octobus-sdk"; + +import { handlers } from "./ailpha-platform.js"; + +export { handlers } from "./ailpha-platform.js"; + +export const service = defineService({ handlers }); diff --git a/services/ailpha__platform/test/ailpha-platform.test.js b/services/ailpha__platform/test/ailpha-platform.test.js new file mode 100644 index 00000000..e8153337 --- /dev/null +++ b/services/ailpha__platform/test/ailpha-platform.test.js @@ -0,0 +1,249 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +const PKG = 'AiLPHA_Platform.AiLPHA_Platform'; +const listAlarmsPath = `/${PKG}/ListMergeAlarms`; +const detailPath = `/${PKG}/GetMergeAlarmDetail`; +const statusPath = `/${PKG}/UpdateMergeAlarmStatus`; +const listLinkagePath = `/${PKG}/ListLinkageStrategies`; +const blockPath = `/${PKG}/BlockIp`; +const unblockPath = `/${PKG}/UnblockIp`; + +const buildCtx = (req = {}, overrides = {}) => ({ + bindings: { endpoint: 'https://ailpha.example.com', apiKey: 'key-1', ...overrides.bindings }, + limits: { timeoutMs: 10_000, ...overrides.limits }, + meta: { instance_id: 'inst', request_id: 'req', ...overrides.meta }, + req, +}); + +const setFetch = (impl) => { + global.fetch = async (url, init) => { + const out = await impl(url, init); + if (out && out.throwNetwork) throw new Error(out.throwNetwork); + const status = out?.status ?? 200; + const text = out?.raw !== undefined ? out.raw : JSON.stringify(out?.body ?? {}); + return { ok: status >= 200 && status < 300, status, async text() { return text; } }; + }; +}; + +const loadRpc = async (req, overrides = {}) => { + const { rpcdef } = await import('../src/ailpha-platform.js'); + return rpcdef(buildCtx(req, overrides)); +}; + +const ok = (body) => ({ body }); + +test('internal helpers: bindings, headers, conversions, struct, ids', async () => { + const { _test } = await import('../src/ailpha-platform.js'); + + assert.deepEqual(_test.mergedBindings({ + config: { endpoint: 'http://c', keep: 'c' }, secret: { apiKey: 's' }, bindings: { endpoint: 'http://b' }, + }), { endpoint: 'http://b', keep: 'c', apiKey: 's' }); + + assert.deepEqual(_test.parseHeaders('{"X":"1"}'), { X: '1' }); + assert.deepEqual(_test.parseHeaders('{bad'), {}); + assert.deepEqual(_test.parseHeaders('[1]'), {}); + assert.deepEqual(_test.parseHeaders(['x']), {}); + assert.equal(_test.normalizeBaseUrl('https://h/'), 'https://h'); + assert.equal(_test.normalizeBaseUrl('ftp://x'), null); + + assert.equal(_test.toOptionalString({ value: 'x' }), 'x'); + assert.equal(_test.toOptionalString(''), undefined); + assert.equal(_test.toInt({ value: '7' }), 7); + assert.equal(_test.toInt('bad'), 0); + assert.equal(_test.toOptionalBool('true'), true); + assert.equal(_test.toOptionalBool(0), false); + assert.equal(_test.toOptionalBool(undefined), undefined); + assert.equal(_test.toOptionalBool('1'), true); + assert.equal(_test.toOptionalBool('0'), false); + assert.equal(_test.toOptionalBool('maybe'), undefined); + assert.equal(_test.toOptionalString(null), undefined); + + assert.equal(_test.toPageSize(undefined, 'page'), undefined); + assert.equal(_test.toPageSize(0, 'page'), undefined); + assert.equal(_test.toPageSize(2, 'page'), 2); + assert.throws(() => _test.toPageSize(0.5, 'page'), /must be an integer/); + assert.throws(() => _test.toPageSize(-1, 'page'), /page must be >= 1/); + assert.throws(() => _test.toPageSize(5000, 'size'), /size must be in/); + + assert.deepEqual(_test.normalizeStruct({ a: 1 }), { a: 1 }); + assert.deepEqual(_test.normalizeStruct('x'), {}); + + assert.deepEqual(_test.requireIds({ ids: [' a ', 'b'] }), ['a', 'b']); + assert.throws(() => _test.requireIds({ ids: 'x' }), /must be a non-empty array/); + assert.throws(() => _test.requireIds({ ids: [] }), /non-empty/); + assert.throws(() => _test.requireIds({ ids: [''] }), /non-empty strings/); + assert.throws(() => _test.requireIds({ ids: ['a/b'] }), /must not contain/); + + assert.deepEqual(_test.mapListResponse({ $page: 1, $size: 10, total: 3, data: [{ a: 1 }, 'x'], $orderBy: 'endTime desc' }), + { page: 1, size: 10, total: 3, data: [{ a: 1 }, {}], order_by: 'endTime desc' }); + assert.deepEqual(_test.mapWriteResponse({ $page: 0, $size: 0, data: 'done' }), { page: 0, size: 0, data: 'done' }); + + assert.equal(_test.requireNonEmpty('a', 'f'), 'a'); + assert.throws(() => _test.requireNonEmpty('', 'f'), /f is required/); +}); + +test('ListMergeAlarms builds query (incl ALL->omit) and maps data', async () => { + let url; + setFetch((u, init) => { + url = u; assert.equal(init.method, 'GET'); + assert.equal(init.headers.apiKey, 'key-1'); + assert.equal(init.headers['x-request-id'], 'req'); + return ok({ $page: 1, $size: 10, total: 2, data: [{ alarmName: ['x'] }, { alarmName: ['y'] }], $orderBy: 'endTime desc' }); + }); + const out = await (await loadRpc({ page: 1, size: 10, condition: 'srcAddress="1.1.1.1"', connect_type: 'ALL', order_by: 'endTime desc', field_mapping: true }))[listAlarmsPath](); + assert.equal(out.total, 2); + assert.equal(out.data.length, 2); + assert.match(url, /\/openapi\/v2\.0\/merge-alarms\?/); + assert.match(url, /%24page=1/); + assert.match(url, /condition=srcAddress/); + assert.match(url, /fieldMapping=true/); + assert.ok(!/connectType/.test(url)); // ALL omitted +}); + +test('ListMergeAlarms passes DIRECT_CONNECT and rejects bad connect_type/paging', async () => { + let url; + setFetch((u) => { url = u; return ok({ $page: 1, $size: 10, total: 0, data: [] }); }); + await (await loadRpc({ connect_type: 'DIRECT_CONNECT' }))[listAlarmsPath](); + assert.match(url, /connectType=DIRECT_CONNECT/); + + await assert.rejects((await loadRpc({ connect_type: 'BOGUS' }))[listAlarmsPath](), /connect_type must be one of/); + await assert.rejects((await loadRpc({ page: -1 }))[listAlarmsPath](), /page must be >= 1/); +}); + +test('GetMergeAlarmDetail requires agg_condition + window_id, maps detail', async () => { + let url; + setFetch((u) => { url = u; return ok({ baasAlarmUuid: 'abc', alarmName: 'x' }); }); + const out = await (await loadRpc({ agg_condition: 'md5x', window_id: 'w1' }))[detailPath](); + assert.deepEqual(out.detail, { baasAlarmUuid: 'abc', alarmName: 'x' }); + assert.match(url, /aggCondition=md5x/); + assert.match(url, /windowId=w1/); + + await assert.rejects((await loadRpc({ window_id: 'w1' }))[detailPath](), /agg_condition is required/); + await assert.rejects((await loadRpc({ agg_condition: 'm' }))[detailPath](), /window_id is required/); +}); + +test('UpdateMergeAlarmStatus requires alarm_status + selector, builds body', async () => { + let body; + setFetch((u, init) => { + assert.equal(init.method, 'POST'); + assert.equal(init.headers['content-type'], 'application/json'); + body = JSON.parse(init.body); + return ok({ $page: 0, $size: 0, data: '归并告警批量处置中' }); + }); + const out = await (await loadRpc({ alarm_status: 'falsePositives', alarm_notes: 'fp', start_time: '2023-07-01 00:00:00', end_time: '2023-07-01 23:59:59' }))[statusPath](); + assert.equal(out.data, '归并告警批量处置中'); + assert.deepEqual(body, { alarmStatus: 'falsePositives', alarmNotes: 'fp', startTime: '2023-07-01 00:00:00', endTime: '2023-07-01 23:59:59' }); + + await assert.rejects((await loadRpc({ alarm_notes: 'x', condition: 'c' }))[statusPath](), /alarm_status is required/); + await assert.rejects((await loadRpc({ alarm_status: 'processed' }))[statusPath](), /provide condition, or both start_time and end_time/); + + // condition-only selector works + let body2; + setFetch((u, init) => { body2 = JSON.parse(init.body); return ok({ $page: 0, $size: 0, data: 'ok' }); }); + await (await loadRpc({ alarm_status: 'processed', condition: 'alarmName="x"' }))[statusPath](); + assert.deepEqual(body2, { alarmStatus: 'processed', condition: 'alarmName="x"' }); +}); + +test('ListLinkageStrategies validates order_by allow-list and maps data', async () => { + let url; + setFetch((u) => { url = u; return ok({ $page: 1, $size: 5, total: 1, data: [{ id: 's1', blockIp: '1.1.1.1' }] }); }); + const out = await (await loadRpc({ order_by: 'blockIp desc', page: 1, size: 5 }))[listLinkagePath](); + assert.equal(out.total, 1); + assert.equal(out.data[0].id, 's1'); + assert.match(url, /%24orderBy=blockIp/); + + await assert.rejects((await loadRpc({ order_by: 'nope' }))[listLinkagePath](), /order_by key must be one of/); +}); + +test('BlockIp joins ids into path and maps result', async () => { + let url; + setFetch((u, init) => { url = u; assert.equal(init.method, 'POST'); return ok({ $page: 0, $size: 0, data: '联动成功' }); }); + const out = await (await loadRpc({ ids: ['s1', 's2'] }))[blockPath](); + assert.equal(out.data, '联动成功'); + assert.match(url, /\/linkage-strategies\/s1,s2\/accessIp$/); + + await assert.rejects((await loadRpc({ ids: [] }))[blockPath](), /non-empty/); +}); + +test('UnblockIp deletes and is idempotent on 404', async () => { + let url; + setFetch((u, init) => { url = u; assert.equal(init.method, 'DELETE'); return ok({ $page: 0, $size: 0, data: '解除成功' }); }); + const out = await (await loadRpc({ ids: ['s1'] }))[unblockPath](); + assert.equal(out.data, '解除成功'); + assert.match(url, /\/linkage-strategies\/s1\/blockIp$/); + + setFetch(() => ({ status: 404, raw: 'not found' })); + const out2 = await (await loadRpc({ ids: ['gone'] }))[unblockPath](); + assert.deepEqual(out2, { page: 0, size: 0, data: '' }); + + // non-404 errors are re-thrown + setFetch(() => ({ status: 500, raw: 'boom' })); + await assert.rejects((await loadRpc({ ids: ['s1'] }))[unblockPath](), /UNAVAILABLE.*http 500/); +}); + +test('auth: missing apiKey errors', async () => { + setFetch(() => ok({ $page: 1, $size: 10, total: 0, data: [] })); + await assert.rejects( + (await loadRpc({}, { bindings: { endpoint: 'https://h', apiKey: '' } }))[listAlarmsPath](), + /apiKey is required/, + ); +}); + +test('error mapping: endpoint, 401/403/404/500, network, bad json, empty body', async () => { + await assert.rejects( + (await loadRpc({}, { bindings: { endpoint: '', apiKey: 'k' } }))[listAlarmsPath](), + /endpoint\/baseUrl is required/, + ); + + setFetch(() => ({ status: 401, raw: 'no auth' })); + await assert.rejects((await loadRpc({}))[listAlarmsPath](), /UNAUTHENTICATED.*http 401/); + + setFetch(() => ({ status: 403, raw: 'forbidden' })); + await assert.rejects((await loadRpc({}))[listAlarmsPath](), /PERMISSION_DENIED.*http 403/); + + setFetch(() => ({ status: 404, raw: 'nf' })); + await assert.rejects((await loadRpc({ agg_condition: 'a', window_id: 'w' }))[detailPath](), /NOT_FOUND.*http 404/); + + setFetch(() => ({ status: 500, raw: 'boom' })); + await assert.rejects((await loadRpc({}))[listAlarmsPath](), /UNAVAILABLE.*http 500/); + + setFetch(() => ({ throwNetwork: 'ECONNREFUSED' })); + await assert.rejects((await loadRpc({}))[listAlarmsPath](), /UNAVAILABLE.*ECONNREFUSED/); + + setFetch(() => ({ status: 200, raw: 'not-json{' })); + await assert.rejects((await loadRpc({}))[listAlarmsPath](), /UNKNOWN.*not valid JSON/); + + setFetch(() => ({ status: 200, raw: '' })); + const out = await (await loadRpc({}))[listAlarmsPath](); + assert.deepEqual(out, { page: 0, size: 0, total: 0, data: [], order_by: '' }); +}); + +test('handlers / registerHandlers run through the legacy ctx wrapper', async () => { + setFetch(() => ok({ $page: 1, $size: 10, total: 1, data: [{ id: 'a' }] })); + const { handlers, _test } = await import('../src/ailpha-platform.js'); + const out = await handlers[`${PKG}/ListMergeAlarms`]({ bindings: { endpoint: 'https://h', apiKey: 'k' }, req: {} }); + assert.equal(out.total, 1); + + const reg = _test.registerHandlers({ bindings: { endpoint: 'https://h', apiKey: 'k' } }); + const out2 = await reg[`/${PKG}/ListMergeAlarms`]({}, { meta: { request_id: 'r2' } }); + assert.equal(out2.data[0].id, 'a'); +}); + +test('all six handler entry points are reachable', async () => { + setFetch(() => ok({ $page: 0, $size: 0, total: 0, data: [], $orderBy: '' })); + const { handlers } = await import('../src/ailpha-platform.js'); + const ctx = (req) => ({ bindings: { endpoint: 'https://h', apiKey: 'k' }, req }); + const reqs = { + ListMergeAlarms: {}, + GetMergeAlarmDetail: { agg_condition: 'a', window_id: 'w' }, + UpdateMergeAlarmStatus: { alarm_status: 'processed', condition: 'c' }, + ListLinkageStrategies: {}, + BlockIp: { ids: ['s1'] }, + UnblockIp: { ids: ['s1'] }, + }; + for (const [m, req] of Object.entries(reqs)) { + const out = await handlers[`${PKG}/${m}`](ctx(req)); + assert.ok(out && typeof out === 'object', `${m} returned an object`); + } +}); diff --git a/services/ailpha__platform/test/mock_upstream.js b/services/ailpha__platform/test/mock_upstream.js new file mode 100644 index 00000000..71e18ada --- /dev/null +++ b/services/ailpha__platform/test/mock_upstream.js @@ -0,0 +1,75 @@ +// Mock upstream for the AiLPHA platform /openapi API. +// For manual/integration runs: HTTP_PORT=18110 node test/mock_upstream.js +import http from 'node:http'; + +const httpPort = Number(process.env.HTTP_PORT || 18110); +const log = (...args) => console.log('[mock-ailpha]', ...args); + +// seed alarms + linkage strategies +const alarms = [ + { baasAlarmUuid: 'uuid-1', aggCondition: 'agg-1', windowId: 'w-1', alarmName: ['恶意文件攻击'], threatSeverity: 'Medium', alarmStatus: 'unprocessed', srcAddress: ['110.170.147.50'], destAddress: ['114.242.248.81'], canDisposalTheAlarm: true }, +]; +const strategies = new Map([ + ['s1', { id: 's1', blockIp: '110.170.147.50', status: 'inactive', linkDevice: 'fw-1', effectTime: 0 }], +]); + +const sendJson = (res, status, obj) => { + res.writeHead(status, { 'content-type': 'application/json' }); + res.end(JSON.stringify(obj)); +}; +const readBody = (req) => new Promise((resolve) => { + const chunks = []; + req.on('data', (c) => chunks.push(c)); + req.on('end', () => { const raw = Buffer.concat(chunks).toString(); resolve(raw ? JSON.parse(raw) : {}); }); +}); + +const server = http.createServer(async (req, res) => { + const url = new URL(req.url, 'http://localhost'); + const p = url.pathname; + if (!req.headers['apikey']) return sendJson(res, 401, {}); + + if (p === '/openapi/v2.0/merge-alarms' && req.method === 'GET') { + const page = Number(url.searchParams.get('$page')) || 1; + const size = Number(url.searchParams.get('$size')) || 10; + return sendJson(res, 200, { $page: page, $size: size, total: alarms.length, data: alarms, $orderBy: url.searchParams.get('$orderBy') || 'endTime desc' }); + } + + if (p === '/openapi/v1.0/merge-alarm/detail' && req.method === 'GET') { + const agg = url.searchParams.get('aggCondition'); + const found = alarms.find((a) => a.aggCondition === agg); + if (!found) return sendJson(res, 404, {}); + return sendJson(res, 200, found); + } + + if (p === '/openapi/v2.0/merge-alarms/status' && req.method === 'POST') { + const body = await readBody(req); + if (!body.alarmStatus) return sendJson(res, 400, {}); + alarms.forEach((a) => { a.alarmStatus = body.alarmStatus; }); + return sendJson(res, 200, { $page: 0, $size: 0, data: '归并告警批量处置中' }); + } + + if (p === '/openapi/v1.0/linkage-strategies' && req.method === 'GET') { + const list = Array.from(strategies.values()); + return sendJson(res, 200, { $page: 1, $size: 10, total: list.length, data: list }); + } + + const m = p.match(/^\/openapi\/v1\.0\/linkage-strategies\/([^/]+)\/(accessIp|blockIp)$/); + if (m) { + const ids = decodeURIComponent(m[1]).split(','); + const action = m[2]; + const known = ids.filter((id) => strategies.has(id)); + if (known.length === 0) return sendJson(res, 404, {}); + if (action === 'accessIp' && req.method === 'POST') { + known.forEach((id) => { strategies.get(id).status = 'active'; }); + return sendJson(res, 200, { $page: 0, $size: 0, data: '联动策略成功' }); + } + if (action === 'blockIp' && req.method === 'DELETE') { + known.forEach((id) => { strategies.get(id).status = 'inactive'; }); + return sendJson(res, 200, { $page: 0, $size: 0, data: '解除联动策略成功' }); + } + } + + return sendJson(res, 404, {}); +}); + +server.listen(httpPort, () => log(`listening on :${httpPort}`)); diff --git a/services/bin/ailpha-platform.js b/services/bin/ailpha-platform.js new file mode 100644 index 00000000..9475d41b --- /dev/null +++ b/services/bin/ailpha-platform.js @@ -0,0 +1,10 @@ +#!/usr/bin/env node + +import { fileURLToPath } from "node:url"; +import { runServiceMain } from "@chaitin-ai/octobus-sdk"; + +import { service } from "../ailpha__platform/src/service.js"; + +runServiceMain(service, { + entryFile: fileURLToPath(new URL("../ailpha__platform/bin/ailpha-platform.js", import.meta.url)), +}); diff --git a/services/bin/octobus-tentacles.js b/services/bin/octobus-tentacles.js index b7e72164..4fba1ae4 100755 --- a/services/bin/octobus-tentacles.js +++ b/services/bin/octobus-tentacles.js @@ -5,6 +5,10 @@ import { runServiceMain } from "@chaitin-ai/octobus-sdk"; import { Command } from "commander"; const services = { + "ailpha-platform": { + entryFile: "../ailpha__platform/bin/ailpha-platform.js", + serviceModule: "../ailpha__platform/src/service.js", + }, "alibaba-cloud-simple-application-server-firewall": { entryFile: "../alibaba-cloud__simple-application-server-firewall/bin/alibaba-cloud-simple-application-server-firewall.js", serviceModule: "../alibaba-cloud__simple-application-server-firewall/src/service.js", diff --git a/services/package.json b/services/package.json index 6137405c..232dc967 100644 --- a/services/package.json +++ b/services/package.json @@ -5,6 +5,7 @@ "type": "module", "bin": { "octobus-tentacles": "bin/octobus-tentacles.js", + "ailpha-platform": "bin/ailpha-platform.js", "alibaba-cloud-simple-application-server-firewall": "bin/alibaba-cloud-simple-application-server-firewall.js", "das-gateway-v3": "bin/das-gateway-v3.js", "das-tgfw-v6": "bin/das-tgfw-v6.js", @@ -50,6 +51,7 @@ "wd-k01": "bin/wd-k01.js" }, "files": [ + "bin/ailpha-platform.js", "bin/alibaba-cloud-simple-application-server-firewall.js", "bin/das-gateway-v3.js", "bin/das-tgfw-v6.js", @@ -93,6 +95,7 @@ "bin/venus-ads-v3-6.js", "bin/wd-k01.js", "bin/wangsu-label-ip.js", + "ailpha__platform", "alibaba-cloud__simple-application-server-firewall", "das__gateway_v3", "das__tgfw_v6", From ff0a2c0481aaec50a603b77a7cbcecde66eb1717 Mon Sep 17 00:00:00 2001 From: hilariacrucita <3362488788@qq.com> Date: Thu, 9 Jul 2026 15:17:50 +0800 Subject: [PATCH 2/3] fix: mark service bin entries as executable for package validation --- services/ailpha__platform/bin/ailpha-platform.js | 0 services/bin/ailpha-platform.js | 0 services/bin/m01-intelligence.js | 0 services/bin/octobus-tentacles.js | 0 services/m01__intelligence/bin/m01-intelligence.js | 0 5 files changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 services/ailpha__platform/bin/ailpha-platform.js mode change 100644 => 100755 services/bin/ailpha-platform.js mode change 100644 => 100755 services/bin/m01-intelligence.js mode change 100644 => 100755 services/bin/octobus-tentacles.js mode change 100644 => 100755 services/m01__intelligence/bin/m01-intelligence.js diff --git a/services/ailpha__platform/bin/ailpha-platform.js b/services/ailpha__platform/bin/ailpha-platform.js old mode 100644 new mode 100755 diff --git a/services/bin/ailpha-platform.js b/services/bin/ailpha-platform.js old mode 100644 new mode 100755 diff --git a/services/bin/m01-intelligence.js b/services/bin/m01-intelligence.js old mode 100644 new mode 100755 diff --git a/services/bin/octobus-tentacles.js b/services/bin/octobus-tentacles.js old mode 100644 new mode 100755 diff --git a/services/m01__intelligence/bin/m01-intelligence.js b/services/m01__intelligence/bin/m01-intelligence.js old mode 100644 new mode 100755 From 251a12347dc45ace0d6c30a48b0ad467b9d8b090 Mon Sep 17 00:00:00 2001 From: hilariacrucita <3362488788@qq.com> Date: Thu, 9 Jul 2026 15:22:00 +0800 Subject: [PATCH 3/3] fix(ailpha-platform): enforce timeout via AbortSignal and TLS skip via undici dispatcher --- services/ailpha__platform/README.md | 2 ++ services/ailpha__platform/package.json | 3 ++- .../ailpha__platform/src/ailpha-platform.js | 15 +++++++++-- .../test/ailpha-platform.test.js | 27 +++++++++++++++++++ 4 files changed, 44 insertions(+), 3 deletions(-) diff --git a/services/ailpha__platform/README.md b/services/ailpha__platform/README.md index a7907484..cfc5d0b6 100644 --- a/services/ailpha__platform/README.md +++ b/services/ailpha__platform/README.md @@ -12,6 +12,8 @@ octobus service import --id ailpha-platform ./services/ailpha__platform Set `endpoint` to the platform base URL. `timeoutMs` (default 1500), `headers`, and `skipTlsVerify` are optional. +`timeoutMs` is enforced with `AbortSignal.timeout`; `skipTlsVerify` uses a per-request undici dispatcher and does not change global TLS settings. + ```json { "endpoint": "https://ailpha.example.com", diff --git a/services/ailpha__platform/package.json b/services/ailpha__platform/package.json index 7eded71d..713e24ab 100644 --- a/services/ailpha__platform/package.json +++ b/services/ailpha__platform/package.json @@ -7,6 +7,7 @@ "ailpha-platform": "bin/ailpha-platform.js" }, "dependencies": { - "@chaitin-ai/octobus-sdk": "^0.5.0" + "@chaitin-ai/octobus-sdk": "^0.5.0", + "undici": "^7.16.0" } } diff --git a/services/ailpha__platform/src/ailpha-platform.js b/services/ailpha__platform/src/ailpha-platform.js index 31c42a4d..66a208a7 100644 --- a/services/ailpha__platform/src/ailpha-platform.js +++ b/services/ailpha__platform/src/ailpha-platform.js @@ -5,9 +5,17 @@ // Bindings (secret): apiKey (sent as the `apiKey` HTTP header). import { GrpcError, grpcStatus } from '@chaitin-ai/octobus-sdk'; +import { Agent } from 'undici'; const DEFAULT_TIMEOUT_MS = 1500; +let insecureTlsDispatcher; + +const getInsecureTlsDispatcher = () => { + insecureTlsDispatcher ??= new Agent({ connect: { rejectUnauthorized: false } }); + return insecureTlsDispatcher; +}; + const PKG = 'AiLPHA_Platform.AiLPHA_Platform'; const LIST_ALARMS_PATH = `/${PKG}/ListMergeAlarms`; const ALARM_DETAIL_PATH = `/${PKG}/GetMergeAlarmDetail`; @@ -181,7 +189,7 @@ export function rpcdef(ctx) { }; }; - const tlsOptions = () => (skipTlsVerify ? { insecureSkipVerify: true, tlsInsecureSkipVerify: true } : {}); + const tlsOptions = () => (skipTlsVerify ? { dispatcher: getInsecureTlsDispatcher() } : {}); const buildQuery = (pairs) => { const parts = []; @@ -205,10 +213,13 @@ export function rpcdef(ctx) { method, headers, body: hasBody ? JSON.stringify(bodyObj) : undefined, - timeoutMs, + signal: AbortSignal.timeout(timeoutMs), ...tlsOptions(), }); } catch (e) { + if (e?.name === 'TimeoutError') { + throw errorWithCode('DEADLINE_EXCEEDED', `request timed out after ${timeoutMs}ms`); + } const reason = e?.cause?.message || e?.message || 'fetch failed'; throw errorWithCode('UNAVAILABLE', reason); } diff --git a/services/ailpha__platform/test/ailpha-platform.test.js b/services/ailpha__platform/test/ailpha-platform.test.js index e8153337..78f6dc9d 100644 --- a/services/ailpha__platform/test/ailpha-platform.test.js +++ b/services/ailpha__platform/test/ailpha-platform.test.js @@ -219,6 +219,33 @@ test('error mapping: endpoint, 401/403/404/500, network, bad json, empty body', assert.deepEqual(out, { page: 0, size: 0, total: 0, data: [], order_by: '' }); }); +test('timeout uses AbortSignal and maps TimeoutError to DEADLINE_EXCEEDED', async () => { + let init; + setFetch((u, i) => { init = i; return ok({ $page: 0, $size: 0, total: 0, data: [] }); }); + await (await loadRpc({}))[listAlarmsPath](); + assert.ok(init.signal instanceof AbortSignal, 'fetch receives an AbortSignal'); + assert.equal('timeoutMs' in init, false, 'no non-standard timeoutMs option'); + + setFetch(() => { + const e = new Error('The operation was aborted due to timeout'); + e.name = 'TimeoutError'; + throw e; + }); + await assert.rejects((await loadRpc({}))[listAlarmsPath](), /DEADLINE_EXCEEDED.*timed out after/); +}); + +test('skipTlsVerify passes an undici dispatcher instead of non-standard options', async () => { + let init; + setFetch((u, i) => { init = i; return ok({ $page: 0, $size: 0, total: 0, data: [] }); }); + + await (await loadRpc({}))[listAlarmsPath](); + assert.equal(init.dispatcher, undefined, 'no dispatcher by default'); + + await (await loadRpc({}, { bindings: { skipTlsVerify: true } }))[listAlarmsPath](); + assert.ok(init.dispatcher, 'dispatcher set when skipTlsVerify=true'); + assert.equal('insecureSkipVerify' in init, false, 'no non-standard TLS options'); +}); + test('handlers / registerHandlers run through the legacy ctx wrapper', async () => { setFetch(() => ok({ $page: 1, $size: 10, total: 1, data: [{ id: 'a' }] })); const { handlers, _test } = await import('../src/ailpha-platform.js');