diff --git a/services/360__360-epp_v10-0-0-08331/README.md b/services/360__360-epp_v10-0-0-08331/README.md new file mode 100644 index 00000000..6aca7990 --- /dev/null +++ b/services/360__360-epp_v10-0-0-08331/README.md @@ -0,0 +1,108 @@ +# 360终端安全管理系统 (EPP) OctoBus Service + +360 Enterprise Endpoint Protection Platform (EPP) v10.0.0.08331 的 OctoBus 适配服务,提供终端管理、告警查询、病毒扫描统计、漏洞修复统计等 API。 + +## 导入 OctoBus + +```bash +octobus service import --id 360-epp ./services/360__360-epp_v10-0-0-08331 +``` + +## 包文件说明 + +- `service.json`: OctoBus 服务描述文件 +- `proto/360_epp.proto`: gRPC API 定义 +- `config.schema.json`: 非敏感配置(endpoint、超时、TLS) +- `secret.schema.json`: 敏感配置(管理员账号密码) +- `src/360-epp.js`: 360 EPP REST API 代理实现 +- `src/service.js`: OctoBus SDK `defineService` wrapper +- `bin/360-epp.js`: 服务入口 +- `test/360-epp.test.js`: 单元测试(参数校验、API 映射、错误映射) +- `test/mock_upstream.js`: 本地 mock 服务器 + +## 配置 + +### Config(非敏感配置) + +```json +{ + "endpoint": "https://192.168.0.109", + "timeoutMs": 30000, + "skipTlsVerify": true +} +``` + +| 字段 | 说明 | 默认值 | +|------|------|--------| +| `endpoint` | 360 EPP 管理控制台地址 | 必填 | +| `timeoutMs` | HTTP 请求超时(毫秒) | 30000 | +| `skipTlsVerify` | 跳过 TLS 证书验证 | true | + +### Secret(敏感配置) + +```json +{ + "username": "eppadmin", + "password": "your-password" +} +``` + +| 字段 | 说明 | +|------|------| +| `username` | 360 EPP 管理员账号 | +| `password` | 360 EPP 管理员密码(明文) | + +## RPC 方法 + +| 方法 | 说明 | +|------|------| +| `Qihoo360_EPP.Qihoo360_EPP/GetDashboardInfo` | 获取仪表盘概览信息 | +| `Qihoo360_EPP.Qihoo360_EPP/ListTerminals` | 查询终端列表(分页、关键词搜索) | +| `Qihoo360_EPP.Qihoo360_EPP/GetTerminalDetail` | 获取终端详细信息 | +| `Qihoo360_EPP.Qihoo360_EPP/ListAlarms` | 查询告警日志列表 | +| `Qihoo360_EPP.Qihoo360_EPP/GetVirusStats` | 获取病毒扫描统计数据 | +| `Qihoo360_EPP.Qihoo360_EPP/GetLeakFixStats` | 获取漏洞修复统计数据 | +| `Qihoo360_EPP.Qihoo360_EPP/GetTerminalHardware` | 获取终端硬件配置信息 | + +## 认证流程 + +本服务使用 360 EPP 的 Web 管理后台认证流程: + +1. 调用 `GET /user/getPubKey?username=xxx` 获取 RSA 公钥 +2. 使用 RSA 公钥加密密码,同时对密码做 MD5 哈希 +3. 调用 `POST /user/login` 提交 `username` / `password`(MD5) / `rPassword`(RSA加密) +4. 获取会话 Cookie (`PN`) 用于后续 API 调用 + +会话 Cookie 在服务运行期间缓存复用。若会话过期(errno=10401),会自动重新登录。 + +## 错误映射 + +| 场景 | gRPC Status | +|------|-------------| +| 认证失败 | `UNAUTHENTICATED` | +| 参数无效 | `INVALID_ARGUMENT` | +| 会话过期 | `UNAUTHENTICATED` | +| 上游 API 错误 | `FAILED_PRECONDITION` | +| 网络/超时 | `UNAVAILABLE` / `DEADLINE_EXCEEDED` | + +## 支持版本 + +- 360终端安全管理系统 v10.0.0.08331 +- 认证方式:Web 管理后台 RSA 加密登录 + +## 注意事项 + +- 本服务使用 Web 管理后台的认证接口,需要具有管理权限的账号 +- 密码通过 RSA 非对称加密传输,不在网络中明文传递 +- 会话 Cookie 在内存中缓存,实例重启后需要重新登录 +- 建议为 OctoBus 创建专用的管理员账号 +- 写操作(如终端隔离、策略下发)暂未实现,仅提供查询能力 + +## 本地验证 + +```bash +cd services +npm run validate -- --service-dir 360__360-epp_v10-0-0-08331 +npm test -- --service-dir 360__360-epp_v10-0-0-08331 +npm run pack:check +``` diff --git a/services/360__360-epp_v10-0-0-08331/bin/360-epp.js b/services/360__360-epp_v10-0-0-08331/bin/360-epp.js new file mode 100644 index 00000000..508272f0 --- /dev/null +++ b/services/360__360-epp_v10-0-0-08331/bin/360-epp.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/360__360-epp_v10-0-0-08331/config.schema.json b/services/360__360-epp_v10-0-0-08331/config.schema.json new file mode 100644 index 00000000..cd350aa6 --- /dev/null +++ b/services/360__360-epp_v10-0-0-08331/config.schema.json @@ -0,0 +1,27 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": true, + "properties": { + "endpoint": { + "type": "string", + "description": "360 EPP 管理系统 REST API 基地址,例如 https://192.168.0.109" + }, + "baseUrl": { + "type": "string", + "description": "Legacy alias for endpoint." + }, + "timeoutMs": { + "type": "integer", + "minimum": 1, + "default": 30000, + "description": "HTTP 请求超时时间(毫秒)" + }, + "skipTlsVerify": { + "type": "boolean", + "default": false, + "description": "跳过 TLS 证书验证(适用于自签名证书部署)" + } + }, + "required": ["endpoint"] +} diff --git a/services/360__360-epp_v10-0-0-08331/package.json b/services/360__360-epp_v10-0-0-08331/package.json new file mode 100644 index 00000000..043f7c79 --- /dev/null +++ b/services/360__360-epp_v10-0-0-08331/package.json @@ -0,0 +1,12 @@ +{ + "name": "360-epp", + "version": "0.0.0", + "private": true, + "type": "module", + "bin": { + "360-epp": "bin/360-epp.js" + }, + "dependencies": { + "@chaitin-ai/octobus-sdk": "^0.5.0" + } +} diff --git a/services/360__360-epp_v10-0-0-08331/proto/360_epp.proto b/services/360__360-epp_v10-0-0-08331/proto/360_epp.proto new file mode 100644 index 00000000..31eb3a79 --- /dev/null +++ b/services/360__360-epp_v10-0-0-08331/proto/360_epp.proto @@ -0,0 +1,151 @@ +syntax = "proto3"; + +package Qihoo360_EPP; + +import "google/protobuf/wrappers.proto"; +import "google/protobuf/struct.proto"; + +option go_package = "miner/grpc-service/Qihoo360_EPP"; + +service Qihoo360_EPP { + // 获取仪表盘概览信息(终端数量、病毒数量、漏洞数量等) + rpc GetDashboardInfo(GetDashboardInfoRequest) returns (GetDashboardInfoResponse) {} + + // 查询终端列表 + rpc ListTerminals(ListTerminalsRequest) returns (ListTerminalsResponse) {} + + // 获取终端详情 + rpc GetTerminalDetail(GetTerminalDetailRequest) returns (GetTerminalDetailResponse) {} + + // 查询告警日志列表 + rpc ListAlarms(ListAlarmsRequest) returns (ListAlarmsResponse) {} + + // 查询病毒扫描统计 + rpc GetVirusStats(GetVirusStatsRequest) returns (GetVirusStatsResponse) {} + + // 查询漏洞修复统计 + rpc GetLeakFixStats(GetLeakFixStatsRequest) returns (GetLeakFixStatsResponse) {} + + // 查询终端硬件信息 + rpc GetTerminalHardware(GetTerminalHardwareRequest) returns (GetTerminalHardwareResponse) {} +} + +// ========== Dashboard ========== + +message GetDashboardInfoRequest {} + +message GetDashboardInfoResponse { + google.protobuf.Struct data = 1; // 仪表盘原始数据,透传 +} + +// ========== Terminal Management ========== + +message ListTerminalsRequest { + google.protobuf.Int64Value page = 1; // 页码,默认 1 + google.protobuf.Int64Value page_size = 2; // 每页条数,默认 20 + string keyword = 3; // 搜索关键词(IP/主机名/MAC) + string group_id = 4; // 终端分组 ID + string status = 5; // 在线状态:online/offline +} + +message TerminalInfo { + string id = 1; + string name = 2; + string ip = 3; + string mac = 4; + string os = 5; + string status = 6; // 在线状态 + string group_name = 7; // 所属分组 + string last_online_time = 8; + string antivirus_status = 9; // 杀毒软件状态 +} + +message ListTerminalsResponse { + repeated TerminalInfo terminals = 1; + int64 total = 2; +} + +message GetTerminalDetailRequest { + string terminal_id = 1; // 终端 ID +} + +message GetTerminalDetailResponse { + string id = 1; + string name = 2; + string ip = 3; + string mac = 4; + string os = 5; + string os_version = 6; + string status = 7; + string group_name = 8; + string last_online_time = 9; + string antivirus_version = 10; + string antivirus_status = 11; + string cpu = 12; + string memory = 13; + string disk = 14; + google.protobuf.Struct raw = 15; // 原始数据透传 +} + +// ========== Alarm Management ========== + +message ListAlarmsRequest { + google.protobuf.Int64Value page = 1; + google.protobuf.Int64Value page_size = 2; + string alarm_type = 3; // 告警类型:virus/attack/vulnerability/abnormal_login + string start_time = 4; // 开始时间 + string end_time = 5; // 结束时间 + string severity = 6; // 严重程度:high/medium/low +} + +message AlarmInfo { + string id = 1; + string type = 2; + string severity = 3; + string title = 4; + string description = 5; + string terminal_name = 6; + string terminal_ip = 7; + string created_time = 8; + string status = 9; // 处理状态 +} + +message ListAlarmsResponse { + repeated AlarmInfo alarms = 1; + int64 total = 2; + google.protobuf.Struct statistics = 3; // 各状态统计 +} + +// ========== Virus Management ========== + +message GetVirusStatsRequest {} + +message GetVirusStatsResponse { + google.protobuf.Struct data = 1; // 病毒统计数据,透传 +} + +// ========== Leak Fix Management ========== + +message GetLeakFixStatsRequest {} + +message GetLeakFixStatsResponse { + google.protobuf.Struct data = 1; // 漏洞统计数据,透传 +} + +// ========== Terminal Hardware ========== + +message GetTerminalHardwareRequest { + string terminal_id = 1; +} + +message GetTerminalHardwareResponse { + string terminal_id = 1; + string cpu_model = 2; + string cpu_cores = 3; + string memory_size = 4; + string disk_size = 5; + string gpu_model = 6; + string motherboard = 7; + repeated string network_adapters = 8; + google.protobuf.Struct raw = 9; +} diff --git a/services/360__360-epp_v10-0-0-08331/secret.schema.json b/services/360__360-epp_v10-0-0-08331/secret.schema.json new file mode 100644 index 00000000..1bc9a0ba --- /dev/null +++ b/services/360__360-epp_v10-0-0-08331/secret.schema.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": true, + "properties": { + "username": { + "type": "string", + "description": "360 EPP 管理员账号" + }, + "password": { + "type": "string", + "description": "360 EPP 管理员密码" + } + }, + "required": ["username", "password"] +} diff --git a/services/360__360-epp_v10-0-0-08331/service.json b/services/360__360-epp_v10-0-0-08331/service.json new file mode 100644 index 00000000..6f5a4fff --- /dev/null +++ b/services/360__360-epp_v10-0-0-08331/service.json @@ -0,0 +1,53 @@ +{ + "schema": "chaitin.octobus.service.v1", + "name": "360-epp", + "displayName": "360终端安全管理系统(EPP)", + "description": "OctoBus package for 360 Enterprise Endpoint Protection Platform (360 EPP v10.0.0.08331). Provides terminal management, alarm query, virus scan stats, and vulnerability fix stats APIs.", + "runtime": { + "mode": "long-running" + }, + "proto": { + "roots": [ + "proto" + ], + "files": [ + "proto/360_epp.proto" + ] + }, + "configSchema": "config.schema.json", + "secretSchema": "secret.schema.json", + "sdk": { + "cli": { + "commands": { + "Qihoo360_EPP.Qihoo360_EPP/GetDashboardInfo": { + "name": "get-dashboard-info", + "description": "获取 360 EPP 仪表盘概览信息(终端数量、病毒数量、漏洞数量等)" + }, + "Qihoo360_EPP.Qihoo360_EPP/ListTerminals": { + "name": "list-terminals", + "description": "查询 360 EPP 终端列表,支持分页和关键词搜索" + }, + "Qihoo360_EPP.Qihoo360_EPP/GetTerminalDetail": { + "name": "get-terminal-detail", + "description": "获取终端详细信息" + }, + "Qihoo360_EPP.Qihoo360_EPP/ListAlarms": { + "name": "list-alarms", + "description": "查询 360 EPP 告警日志列表" + }, + "Qihoo360_EPP.Qihoo360_EPP/GetVirusStats": { + "name": "get-virus-stats", + "description": "获取病毒扫描统计数据" + }, + "Qihoo360_EPP.Qihoo360_EPP/GetLeakFixStats": { + "name": "get-leakfix-stats", + "description": "获取漏洞修复统计数据" + }, + "Qihoo360_EPP.Qihoo360_EPP/GetTerminalHardware": { + "name": "get-terminal-hardware", + "description": "获取终端硬件配置信息" + } + } + } + } +} diff --git a/services/360__360-epp_v10-0-0-08331/src/360-epp.js b/services/360__360-epp_v10-0-0-08331/src/360-epp.js new file mode 100644 index 00000000..d5baf934 --- /dev/null +++ b/services/360__360-epp_v10-0-0-08331/src/360-epp.js @@ -0,0 +1,602 @@ +// Qihoo360_EPP 360 EPP Terminal Security Management System proxy +// Authenticates via RSA-encrypted password login, maintains PN session cookie +// API endpoints follow CodeIgniter convention: /controller/method + +import { GrpcError, grpcStatus } from '@chaitin-ai/octobus-sdk'; +import crypto from 'crypto'; +import https from 'node:https'; + +const DEFAULT_TIMEOUT_MS = 30000; + +const METHOD_PREFIX = 'Qihoo360_EPP.Qihoo360_EPP'; + +// ========== Helpers ========== + +const grpcCodeFor = (code) => ({ + INVALID_ARGUMENT: grpcStatus.INVALID_ARGUMENT, + FAILED_PRECONDITION: grpcStatus.FAILED_PRECONDITION, + PERMISSION_DENIED: grpcStatus.PERMISSION_DENIED, + UNAVAILABLE: grpcStatus.UNAVAILABLE, + DEADLINE_EXCEEDED: grpcStatus.DEADLINE_EXCEEDED, + UNAUTHENTICATED: grpcStatus.UNAUTHENTICATED, + NOT_FOUND: grpcStatus.NOT_FOUND, +})[code] ?? grpcStatus.UNKNOWN; + +const errorWithCode = (code, message) => { + const err = new GrpcError(grpcCodeFor(code), `${code}: ${message}`); + err.legacyCode = code; + return err; +}; + +const firstDefined = (...vals) => vals.find((v) => v !== undefined && v !== null); + +const hasOwn = (obj, key) => Object.prototype.hasOwnProperty.call(obj ?? {}, key); + +const normalizeBaseUrl = (url) => { + const base = String(url || '').trim(); + if (!/^https?:\/\//i.test(base)) return null; + return base.replace(/\/$/, ''); +}; + +const toPositiveInt = (val) => { + if (val === undefined || val === null) return null; + if (typeof val === 'object') { + if ('value' in val) return toPositiveInt(val.value); + return null; + } + const n = Number(val); + if (!Number.isInteger(n) || Number.isNaN(n)) return null; + return n; +}; + +const mergedBindings = (ctx = {}) => ({ + ...(ctx?.config ?? {}), + ...(ctx?.secret ?? {}), + ...(ctx?.bindings ?? {}), +}); + +// ========== RSA Encryption for Login ========== + +function rsaEncrypt(plaintext, pubkeyPem) { + // PKCS#1 v1.5 encryption using the public key + const encrypted = crypto.publicEncrypt( + { + key: pubkeyPem, + padding: crypto.constants.RSA_PKCS1_PADDING, + }, + Buffer.from(plaintext, 'utf-8') + ); + return encrypted.toString('base64'); +} + +function md5Hash(str) { + return crypto.createHash('md5').update(str).digest('hex'); +} + +// ========== Session Management ========== + +// Per-connection TLS skip agent; scoped to connections, not global. +const insecureAgent = new https.Agent({ rejectUnauthorized: false }); + +class EppSession { + constructor() { + this.cookie = null; + this.baseUrl = null; + this.timeoutMs = DEFAULT_TIMEOUT_MS; + this.skipTlsVerify = false; + this.username = null; + this.password = null; + this.configuredUsername = ''; + this.configuredPassword = ''; + } + + configure(ctx) { + const bindings = mergedBindings(ctx); + this.baseUrl = normalizeBaseUrl( + bindings.endpoint || bindings.baseUrl || bindings.restBaseUrl || '' + ); + + const newUsername = bindings.username || ''; + const newPassword = bindings.password || ''; + + if (newUsername !== this.configuredUsername || newPassword !== this.configuredPassword) { + this.cookie = null; + } + this.configuredUsername = newUsername; + this.configuredPassword = newPassword; + + this.timeoutMs = ctx.limits?.timeoutMs || bindings.timeoutMs || DEFAULT_TIMEOUT_MS; + this.skipTlsVerify = Boolean( + bindings.skipTlsVerify || bindings.tlsInsecureSkipVerify || bindings.skip_tls_verify + ); + } + + async login(username, password) { + if (!this.baseUrl) { + throw errorWithCode('INVALID_ARGUMENT', 'endpoint/baseUrl is required (http/https)'); + } + + try { + // Step 1: Get RSA public key + const pubKeyUrl = `${this.baseUrl}/user/getPubKey?username=${encodeURIComponent(username)}`; + const pubKeyRes = await this.fetchWithTimeout(pubKeyUrl, { + method: 'GET', + headers: { + 'Referer': `${this.baseUrl}/dist/`, + 'X-Requested-With': 'XMLHttpRequest', + }, + }); + + if (!pubKeyRes.ok) { + throw errorWithCode('UNAVAILABLE', `getPubKey failed: HTTP ${pubKeyRes.status}`); + } + + const pubKeyData = await pubKeyRes.json(); + if (pubKeyData.errno !== 0) { + throw errorWithCode('UNAUTHENTICATED', `getPubKey error: ${pubKeyData.errmsg}`); + } + + const pubkeyPem = pubKeyData.data?.pubkey; + if (!pubkeyPem) { + throw errorWithCode('UNAVAILABLE', 'getPubKey returned no public key'); + } + + // Step 2: Encrypt password + const passwordMd5 = md5Hash(password); + const rPassword = rsaEncrypt(password, pubkeyPem); + + // Step 3: Login + const loginBody = new URLSearchParams({ + username, + password: passwordMd5, + rPassword, + }).toString(); + + const loginRes = await this.fetchWithTimeout(`${this.baseUrl}/user/login`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Referer': `${this.baseUrl}/dist/`, + 'X-Requested-With': 'XMLHttpRequest', + }, + body: loginBody, + }); + + if (!loginRes.ok) { + throw errorWithCode('UNAVAILABLE', `login failed: HTTP ${loginRes.status}`); + } + + const setCookie = loginRes.headers.get('set-cookie'); + if (setCookie) { + this.cookie = setCookie.split(';')[0]; // Extract "PN=value" + } + + // Also parse from response headers + if (!this.cookie) { + const cookies = loginRes.headers.get('set-cookie') || ''; + const match = cookies.match(/PN=([^;]+)/); + if (match) this.cookie = `PN=${match[1]}`; + } + + if (!this.cookie) { + // Try to read login response + const loginData = await loginRes.json().catch(() => ({})); + if (loginData.errno !== 0) { + throw errorWithCode('UNAUTHENTICATED', `login failed: ${loginData.errmsg}`); + } + throw errorWithCode('UNAUTHENTICATED', 'login succeeded but no session cookie received'); + } + + this.username = username; + this.password = password; + } catch (e) { + if (e instanceof GrpcError) throw e; + throw errorWithCode('UNAVAILABLE', `login error: ${e.message}`); + } + } + + async apiGet(path, queryParams = {}, retried = false) { + if (!this.cookie) { + throw errorWithCode('UNAUTHENTICATED', 'not logged in'); + } + + const url = new URL(`${this.baseUrl}${path}`); + Object.entries(queryParams).forEach(([k, v]) => { + if (v !== undefined && v !== null && v !== '') { + url.searchParams.set(k, String(v)); + } + }); + + const res = await this.fetchWithTimeout(url.toString(), { + method: 'GET', + headers: { + 'Cookie': this.cookie, + 'Referer': `${this.baseUrl}/dist/`, + 'X-Requested-With': 'XMLHttpRequest', + }, + }); + + if (!res.ok) { + throw errorWithCode('UNAVAILABLE', `API request failed: HTTP ${res.status}`); + } + + const data = await res.json(); + if (data.errno === 10401) { + this.cookie = null; + if (this.username && this.password && !retried) { + await this.login(this.username, this.password); + return this.apiGet(path, queryParams, true); + } + throw errorWithCode('UNAUTHENTICATED', `session expired: ${data.errmsg}`); + } + if (data.errno !== 0 && data.errno !== undefined) { + throw errorWithCode('FAILED_PRECONDITION', `API error: ${data.errmsg} (errno=${data.errno})`); + } + + return data; + } + + async apiPost(path, body = {}, retried = false) { + if (!this.cookie) { + throw errorWithCode('UNAUTHENTICATED', 'not logged in'); + } + + const res = await this.fetchWithTimeout(`${this.baseUrl}${path}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Cookie': this.cookie, + 'Referer': `${this.baseUrl}/dist/`, + 'X-Requested-With': 'XMLHttpRequest', + }, + body: new URLSearchParams(body).toString(), + }); + + if (!res.ok) { + throw errorWithCode('UNAVAILABLE', `API request failed: HTTP ${res.status}`); + } + + const data = await res.json(); + if (data.errno === 10401) { + this.cookie = null; + if (this.username && this.password && !retried) { + await this.login(this.username, this.password); + return this.apiPost(path, body, true); + } + throw errorWithCode('UNAUTHENTICATED', `session expired: ${data.errmsg}`); + } + if (data.errno !== 0 && data.errno !== undefined) { + throw errorWithCode('FAILED_PRECONDITION', `API error: ${data.errmsg} (errno=${data.errno})`); + } + + return data; + } + + // Wraps https.request with a custom agent to produce a fetch-like Response. + // Used only when skipTlsVerify is true, so TLS skipping is scoped to these + // connections and does not affect the global process. + _requestHttps(url, options) { + return new Promise((resolve, reject) => { + const u = new URL(url); + const req = https.request({ + hostname: u.hostname, + port: u.port || 443, + path: u.pathname + u.search, + method: options.method || 'GET', + headers: options.headers || {}, + agent: insecureAgent, + signal: options.signal, + }, (res) => { + const chunks = []; + res.on('data', (c) => chunks.push(c)); + res.on('error', reject); + res.on('end', () => { + const body = Buffer.concat(chunks); + const headers = res.headers; + resolve({ + ok: res.statusCode >= 200 && res.statusCode < 300, + status: res.statusCode, + headers: { + get: (name) => { + const lc = name.toLowerCase(); + return headers[lc] ?? null; + }, + }, + json: async () => JSON.parse(body.toString()), + text: async () => body.toString(), + }); + }); + }); + req.on('error', reject); + if (options.body) req.write(options.body); + req.end(); + }); + } + + async fetchWithTimeout(url, options = {}) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), this.timeoutMs); + + try { + const opts = { ...options, signal: controller.signal }; + // Native fetch (undici) does not support a custom HTTPS agent. + // When TLS verification must be skipped, use https.request with a + // per-connection insecureAgent so the process-wide TLS setting is + // never touched. + const useInsecure = this.skipTlsVerify && url.startsWith('https:'); + const res = useInsecure + ? await this._requestHttps(url, opts) + : await fetch(url, opts); + return res; + } catch (e) { + if (e.name === 'AbortError') { + throw errorWithCode('DEADLINE_EXCEEDED', `request timeout after ${this.timeoutMs}ms`); + } + throw errorWithCode('UNAVAILABLE', `fetch failed: ${e.message}`); + } finally { + clearTimeout(timeout); + } + } +} + +// Global session cache keyed by instanceId +const sessions = new Map(); + +function getSession(ctx) { + const bindings = mergedBindings(ctx); + const instanceId = ctx.meta?.instance_id || ctx.meta?.instanceId || 'default'; + const key = `${instanceId}`; + + if (!sessions.has(key)) { + sessions.set(key, new EppSession()); + } + const session = sessions.get(key); + session.configure(ctx); + return session; +} + +async function ensureLogin(ctx) { + const session = getSession(ctx); + const bindings = mergedBindings(ctx); + const username = bindings.username; + const password = bindings.password; + + if (!username || !password) { + throw errorWithCode('INVALID_ARGUMENT', 'username and password are required (configure via secret)'); + } + + if (!session.cookie) { + await session.login(username, password); + } + return session; +} + +// ========== RPC Handlers ========== + +async function handleGetDashboardInfo(ctx) { + const session = await ensureLogin(ctx); + const data = await session.apiGet('/daping/general/info'); + return { data: toStructValue(data.data ?? {}) }; +} + +async function handleListTerminals(ctx) { + const session = await ensureLogin(ctx); + const req = ctx.req || {}; + + const page = toPositiveInt(firstDefined(req?.page, req?.Page)) || 1; + const pageSize = toPositiveInt(firstDefined(req?.page_size, req?.pageSize, req?.PageSize)) || 20; + const keyword = firstDefined(req?.keyword, req?.Keyword) || ''; + const groupId = firstDefined(req?.group_id, req?.groupId) || ''; + const status = firstDefined(req?.status, req?.Status) || ''; + + const data = await session.apiGet('/api/v2/terminal/list', { + page, + rows: pageSize, + keyword, + group_id: groupId, + status, + }); + + const list = Array.isArray(data.data?.list) ? data.data.list : Array.isArray(data.data) ? data.data : []; + const terminals = list.map(mapTerminalInfo); + const total = data.data?.total || data.data?.count || terminals.length; + + return { terminals, total }; +} + +async function handleGetTerminalDetail(ctx) { + const session = await ensureLogin(ctx); + const req = ctx.req || {}; + const terminalId = firstDefined(req?.terminal_id, req?.terminalId, req?.TerminalId); + + if (!terminalId) { + throw errorWithCode('INVALID_ARGUMENT', 'terminal_id is required'); + } + + const data = await session.apiGet('/api/v2/terminal/detail', { id: terminalId }); + const detail = data.data || {}; + + return { + id: String(detail.id ?? terminalId), + name: detail.name ?? detail.hostname ?? '', + ip: detail.ip ?? '', + mac: detail.mac ?? '', + os: detail.os ?? '', + os_version: detail.os_version ?? detail.osVersion ?? '', + status: detail.status ?? '', + group_name: detail.group_name ?? detail.groupName ?? '', + last_online_time: detail.last_online_time ?? detail.lastOnlineTime ?? '', + antivirus_version: detail.antivirus_version ?? '', + antivirus_status: detail.antivirus_status ?? '', + cpu: detail.cpu ?? '', + memory: detail.memory ?? '', + disk: detail.disk ?? '', + raw: toStructValue(detail), + }; +} + +async function handleListAlarms(ctx) { + const session = await ensureLogin(ctx); + const req = ctx.req || {}; + + const page = toPositiveInt(firstDefined(req?.page, req?.Page)) || 1; + const pageSize = toPositiveInt(firstDefined(req?.page_size, req?.pageSize, req?.PageSize)) || 20; + const alarmType = firstDefined(req?.alarm_type, req?.alarmType) || ''; + const startTime = firstDefined(req?.start_time, req?.startTime) || ''; + const endTime = firstDefined(req?.end_time, req?.endTime) || ''; + const severity = firstDefined(req?.severity, req?.Severity) || ''; + + const data = await session.apiGet('/alarmcenter/getloglist', { + page, + rows: pageSize, + type: alarmType, + start_time: startTime, + end_time: endTime, + severity, + }); + + const list = Array.isArray(data.data?.list) ? data.data.list : []; + const alarms = list.map(mapAlarmInfo); + const total = data.data?.total || alarms.length; + const statistics = toStructValue(data.data?.statistics ?? {}); + + return { alarms, total, statistics }; +} + +async function handleGetVirusStats(ctx) { + const session = await ensureLogin(ctx); + const data = await session.apiGet('/daping/Virus/info'); + return { data: toStructValue(data.data ?? {}) }; +} + +async function handleGetLeakFixStats(ctx) { + const session = await ensureLogin(ctx); + const data = await session.apiGet('/daping/Leakfix/info'); + return { data: toStructValue(data.data ?? {}) }; +} + +async function handleGetTerminalHardware(ctx) { + const session = await ensureLogin(ctx); + const req = ctx.req || {}; + const terminalId = firstDefined(req?.terminal_id, req?.terminalId, req?.TerminalId); + + if (!terminalId) { + throw errorWithCode('INVALID_ARGUMENT', 'terminal_id is required'); + } + + const data = await session.apiGet('/api/v2/terminal/hardware', { id: terminalId }); + const hw = data.data || {}; + + return { + terminal_id: String(terminalId), + cpu_model: hw.cpu_model ?? hw.cpuModel ?? '', + cpu_cores: String(hw.cpu_cores ?? hw.cpuCores ?? ''), + memory_size: hw.memory_size ?? hw.memorySize ?? '', + disk_size: hw.disk_size ?? hw.diskSize ?? '', + gpu_model: hw.gpu_model ?? hw.gpuModel ?? '', + motherboard: hw.motherboard ?? '', + network_adapters: Array.isArray(hw.network_adapters) ? hw.network_adapters : [], + raw: toStructValue(hw), + }; +} + +// ========== Value Mapping ========== + +function toStructValue(val) { + if (val === undefined || val === null) return null; + if (typeof val !== 'object') return { stringValue: String(val) }; + if (Array.isArray(val)) { + return { + listValue: { + values: val.map((v) => toStructValue(v)).filter(Boolean), + }, + }; + } + const fields = {}; + for (const [k, v] of Object.entries(val)) { + const mapped = toStructValue(v); + if (mapped !== null) { + fields[k] = mapped; + } + } + return { structValue: { fields } }; +} + +function mapTerminalInfo(item) { + return { + id: String(item.id ?? ''), + name: item.name ?? item.hostname ?? '', + ip: item.ip ?? '', + mac: item.mac ?? '', + os: item.os ?? '', + status: item.status ?? '', + group_name: item.group_name ?? item.groupName ?? '', + last_online_time: item.last_online_time ?? item.lastOnlineTime ?? '', + antivirus_status: item.antivirus_status ?? item.antivirusStatus ?? '', + }; +} + +function mapAlarmInfo(item) { + return { + id: String(item.id ?? ''), + type: item.type ?? item.alarm_type ?? '', + severity: item.severity ?? item.level ?? '', + title: item.title ?? item.name ?? '', + description: item.description ?? item.desc ?? '', + terminal_name: item.terminal_name ?? item.hostname ?? '', + terminal_ip: item.terminal_ip ?? item.ip ?? '', + created_time: item.created_time ?? item.time ?? '', + status: item.status ?? '', + }; +} + +// ========== Method Path Constants ========== + +const GET_DASHBOARD_INFO = `${METHOD_PREFIX}/GetDashboardInfo`; +const LIST_TERMINALS = `${METHOD_PREFIX}/ListTerminals`; +const GET_TERMINAL_DETAIL = `${METHOD_PREFIX}/GetTerminalDetail`; +const LIST_ALARMS = `${METHOD_PREFIX}/ListAlarms`; +const GET_VIRUS_STATS = `${METHOD_PREFIX}/GetVirusStats`; +const GET_LEAKFIX_STATS = `${METHOD_PREFIX}/GetLeakFixStats`; +const GET_TERMINAL_HARDWARE = `${METHOD_PREFIX}/GetTerminalHardware`; + +// ========== RPC Definition ========== + +export function rpcdef(ctx) { + return { + [GET_DASHBOARD_INFO]: async () => handleGetDashboardInfo(ctx), + [LIST_TERMINALS]: async () => handleListTerminals(ctx), + [GET_TERMINAL_DETAIL]: async () => handleGetTerminalDetail(ctx), + [LIST_ALARMS]: async () => handleListAlarms(ctx), + [GET_VIRUS_STATS]: async () => handleGetVirusStats(ctx), + [GET_LEAKFIX_STATS]: async () => handleGetLeakFixStats(ctx), + [GET_TERMINAL_HARDWARE]: async () => handleGetTerminalHardware(ctx), + }; +} + +// ========== Handler Registration ========== + +function wrapHandler(methodPath) { + return async (ctx) => { + const methods = rpcdef(ctx); + return methods[methodPath](); + }; +} + +export const handlers = { + [GET_DASHBOARD_INFO]: wrapHandler(GET_DASHBOARD_INFO), + [LIST_TERMINALS]: wrapHandler(LIST_TERMINALS), + [GET_TERMINAL_DETAIL]: wrapHandler(GET_TERMINAL_DETAIL), + [LIST_ALARMS]: wrapHandler(LIST_ALARMS), + [GET_VIRUS_STATS]: wrapHandler(GET_VIRUS_STATS), + [GET_LEAKFIX_STATS]: wrapHandler(GET_LEAKFIX_STATS), + [GET_TERMINAL_HARDWARE]: wrapHandler(GET_TERMINAL_HARDWARE), +}; + +export const METHOD_GET_DASHBOARD_INFO = GET_DASHBOARD_INFO; +export const METHOD_LIST_TERMINALS = LIST_TERMINALS; +export const METHOD_GET_TERMINAL_DETAIL = GET_TERMINAL_DETAIL; +export const METHOD_LIST_ALARMS = LIST_ALARMS; +export const METHOD_GET_VIRUS_STATS = GET_VIRUS_STATS; +export const METHOD_GET_LEAKFIX_STATS = GET_LEAKFIX_STATS; +export const METHOD_GET_TERMINAL_HARDWARE = GET_TERMINAL_HARDWARE; diff --git a/services/360__360-epp_v10-0-0-08331/src/service.js b/services/360__360-epp_v10-0-0-08331/src/service.js new file mode 100644 index 00000000..f4c667ea --- /dev/null +++ b/services/360__360-epp_v10-0-0-08331/src/service.js @@ -0,0 +1,7 @@ +import { defineService } from "@chaitin-ai/octobus-sdk"; + +import { handlers } from "./360-epp.js"; + +export { handlers } from "./360-epp.js"; + +export const service = defineService({ handlers }); diff --git a/services/360__360-epp_v10-0-0-08331/test/360-epp.test.js b/services/360__360-epp_v10-0-0-08331/test/360-epp.test.js new file mode 100644 index 00000000..0cb8e1dd --- /dev/null +++ b/services/360__360-epp_v10-0-0-08331/test/360-epp.test.js @@ -0,0 +1,144 @@ +// 360 EPP service test: parameter validation, API mapping, error mapping, auth flow + +import { describe, it, before, after } from 'node:test'; +import assert from 'node:assert'; +import { fork } from 'child_process'; + +import { rpcdef } from '../src/360-epp.js'; + +const mockUrl = (port) => `http://127.0.0.1:${port}`; + +function startMock() { + return new Promise((resolve, reject) => { + const child = fork(new URL('mock_upstream.js', import.meta.url), [], { + stdio: ['ignore', 'pipe', 'pipe', 'ipc'], + }); + child.on('message', (msg) => { + if (msg?.port) resolve({ child, port: msg.port }); + }); + child.on('error', reject); + child.on('exit', (code) => { + if (code !== 0 && code !== null) reject(new Error(`mock exited with ${code}`)); + }); + setTimeout(() => reject(new Error('mock start timeout')), 10000); + }); +} + +describe('360 EPP Service', () => { + let mockServer; + let mockPort; + + before(async () => { + const mock = await startMock(); + mockServer = mock.child; + mockPort = mock.port; + }); + + after(() => { + if (mockServer) mockServer.kill(); + }); + + function makeCtx(overrides = {}, reqOverrides = {}) { + return { + req: { ...reqOverrides }, + config: {}, + secret: {}, + bindings: { + endpoint: mockUrl(mockPort), + username: 'eppadmin', + password: 'Chaitin123..', + skipTlsVerify: true, + ...overrides, + }, + meta: { instance_id: 'test-instance' }, + limits: {}, + }; + } + + describe('rpcdef', () => { + it('should return handlers for all methods', () => { + const ctx = makeCtx(); + const methods = rpcdef(ctx); + const keys = Object.keys(methods); + assert.ok(keys.length >= 7, `expected >=7 methods, got ${keys.length}`); + assert.ok(keys.every((k) => typeof methods[k] === 'function')); + }); + + it('should throw for missing endpoint', async () => { + const ctx = makeCtx({ endpoint: '' }); + try { + await rpcdef(ctx)['Qihoo360_EPP.Qihoo360_EPP/GetDashboardInfo'](); + assert.fail('should have thrown'); + } catch (e) { + assert.ok(e.message.includes('endpoint') || e.message.includes('baseUrl')); + } + }); + + it('should throw for missing credentials', async () => { + const ctx = makeCtx({ username: '', password: '' }); + try { + await rpcdef(ctx)['Qihoo360_EPP.Qihoo360_EPP/GetDashboardInfo'](); + assert.fail('should have thrown'); + } catch (e) { + assert.ok(e.message.includes('username') || e.message.includes('password')); + } + }); + }); + + describe('GetDashboardInfo', () => { + it('should fetch dashboard info', async () => { + const ctx = makeCtx(); + const result = await rpcdef(ctx)['Qihoo360_EPP.Qihoo360_EPP/GetDashboardInfo'](); + assert.ok(result.data); + }); + }); + + describe('ListAlarms', () => { + it('should fetch alarm list', async () => { + const ctx = makeCtx(); + const result = await rpcdef(ctx)['Qihoo360_EPP.Qihoo360_EPP/ListAlarms'](); + assert.ok(result.alarms); + assert.ok(Array.isArray(result.alarms)); + assert.ok(result.total >= 0); + }); + }); + + describe('GetVirusStats', () => { + it('should fetch virus stats', async () => { + const ctx = makeCtx(); + const result = await rpcdef(ctx)['Qihoo360_EPP.Qihoo360_EPP/GetVirusStats'](); + assert.ok(result.data); + }); + }); + + describe('GetLeakFixStats', () => { + it('should fetch leakfix stats', async () => { + const ctx = makeCtx(); + const result = await rpcdef(ctx)['Qihoo360_EPP.Qihoo360_EPP/GetLeakFixStats'](); + assert.ok(result.data); + }); + }); + + describe('Login flow', () => { + it('should login and cache session', async () => { + const ctx = makeCtx(); + const result1 = await rpcdef(ctx)['Qihoo360_EPP.Qihoo360_EPP/GetDashboardInfo'](); + assert.ok(result1.data); + // Second call should use cached session + const result2 = await rpcdef(ctx)['Qihoo360_EPP.Qihoo360_EPP/GetVirusStats'](); + assert.ok(result2.data); + }); + }); + + describe('Error handling', () => { + it('should handle login failure', async () => { + const ctx = makeCtx({ password: 'wrong_password' }); + try { + await rpcdef(ctx)['Qihoo360_EPP.Qihoo360_EPP/GetDashboardInfo'](); + assert.fail('should have thrown'); + } catch (e) { + assert.ok(e.message.includes('login') || e.message.includes('鉴权') || e.message.includes('auth')); + } + }); + }); +}); diff --git a/services/360__360-epp_v10-0-0-08331/test/mock_upstream.js b/services/360__360-epp_v10-0-0-08331/test/mock_upstream.js new file mode 100644 index 00000000..c997a205 --- /dev/null +++ b/services/360__360-epp_v10-0-0-08331/test/mock_upstream.js @@ -0,0 +1,153 @@ +// Mock upstream server for 360 EPP local testing +// Simulates login and API responses + +import http from 'http'; +import crypto from 'crypto'; + +const PORT = process.env.MOCK_PORT || 0; +const RSA_KEY = crypto.generateKeyPairSync('rsa', { + modulusLength: 1024, + publicKeyEncoding: { type: 'spki', format: 'pem' }, + privateKeyEncoding: { type: 'pkcs1', format: 'pem' }, +}); + +function parseBody(req) { + return new Promise((resolve) => { + let body = ''; + req.on('data', (chunk) => { body += chunk; }); + req.on('end', () => { + try { + resolve(JSON.parse(body)); + } catch { + // URL encoded + const params = new URLSearchParams(body); + const obj = {}; + for (const [k, v] of params) obj[k] = v; + resolve(obj); + } + }); + }); +} + +const server = http.createServer(async (req, res) => { + const url = new URL(req.url, `http://localhost:${PORT}`); + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + + // GET /user/getPubKey + if (url.pathname === '/user/getPubKey' && req.method === 'GET') { + res.end(JSON.stringify({ + errno: 0, + data: { pubkey: RSA_KEY.publicKey }, + errmsg: '成功', + timestamp: Math.floor(Date.now() / 1000), + })); + return; + } + + // POST /user/login + if (url.pathname === '/user/login' && req.method === 'POST') { + const body = await parseBody(req); + const pwdHash = crypto.createHash('md5').update('Chaitin123..').digest('hex'); + + if (body.username === 'eppadmin' && body.password === pwdHash) { + res.setHeader('Set-Cookie', 'PN=mock_session_token_360_epp; Path=/; HttpOnly'); + res.end(JSON.stringify({ + errno: 0, + data: null, + errmsg: '成功', + timestamp: Math.floor(Date.now() / 1000), + })); + } else { + res.end(JSON.stringify({ + errno: 100, + data: null, + errmsg: '鉴权失败', + timestamp: Math.floor(Date.now() / 1000), + })); + } + return; + } + + // GET /daping/general/info + if (url.pathname === '/daping/general/info' && req.method === 'GET') { + if (!req.headers.cookie?.includes('PN=')) { + res.end(JSON.stringify({ errno: 10401, data: null, errmsg: '非法来源' })); + return; + } + res.end(JSON.stringify({ + errno: 0, + data: { terminal_count: 100, online_count: 85, virus_count: 5, leak_count: 12 }, + errmsg: '', + })); + return; + } + + // GET /alarmcenter/getloglist + if (url.pathname === '/alarmcenter/getloglist' && req.method === 'GET') { + if (!req.headers.cookie?.includes('PN=')) { + res.end(JSON.stringify({ errno: 10401, data: null, errmsg: '非法来源' })); + return; + } + res.end(JSON.stringify({ + errno: 0, + data: { + list: [ + { id: 1, type: 'virus', severity: 'high', title: '检测到恶意软件', terminal_name: 'PC-01', terminal_ip: '192.168.1.10', created_time: '2026-06-25 10:00:00', status: 'unhandled' }, + ], + total: 1, + statistics: { '0': 1, '1': 0, '2': 0, '-1': 0 }, + }, + errmsg: 'success', + })); + return; + } + + // GET /daping/Virus/info + if (url.pathname === '/daping/Virus/info' && req.method === 'GET') { + if (!req.headers.cookie?.includes('PN=')) { + res.end(JSON.stringify({ errno: 10401, data: null, errmsg: '非法来源' })); + return; + } + res.end(JSON.stringify({ + errno: 0, + data: { total_virus: 5, cleaned: 3, pending: 2 }, + errmsg: '', + })); + return; + } + + // GET /daping/Leakfix/info + if (url.pathname === '/daping/Leakfix/info' && req.method === 'GET') { + if (!req.headers.cookie?.includes('PN=')) { + res.end(JSON.stringify({ errno: 10401, data: null, errmsg: '非法来源' })); + return; + } + res.end(JSON.stringify({ + errno: 0, + data: { total_leaks: 12, fixed: 8, pending: 4 }, + errmsg: '', + })); + return; + } + + // GET /user/isLogin + if (url.pathname === '/user/isLogin' && req.method === 'GET') { + const loggedIn = req.headers.cookie?.includes('PN='); + res.end(JSON.stringify({ + errno: 0, + data: { is_login: loggedIn, url: '', licalarm: [], auth_check: { alarm: false, alarmmsg: [] }, type: '' }, + errmsg: '成功', + })); + return; + } + + // Default 404 + res.statusCode = 404; + res.end(JSON.stringify({ errno: 404, errmsg: 'not found' })); +}); + +server.listen(PORT, () => { + const addr = server.address(); + console.log(`Mock 360 EPP upstream listening on port ${addr.port}`); + if (process.send) process.send({ port: addr.port }); +}); diff --git a/services/bin/360-epp.js b/services/bin/360-epp.js new file mode 100755 index 00000000..431dd544 --- /dev/null +++ b/services/bin/360-epp.js @@ -0,0 +1,10 @@ +#!/usr/bin/env node + +import { fileURLToPath } from "node:url"; +import { runServiceMain } from "@chaitin-ai/octobus-sdk"; + +import { service } from "../360__360-epp_v10-0-0-08331/src/service.js"; + +runServiceMain(service, { + entryFile: fileURLToPath(new URL("../360__360-epp_v10-0-0-08331/bin/360-epp.js", import.meta.url)), +}); diff --git a/services/bin/octobus-tentacles.js b/services/bin/octobus-tentacles.js index 6138109e..734c225f 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 = { + "360-epp": { + entryFile: "../360__360-epp_v10-0-0-08331/bin/360-epp.js", + serviceModule: "../360__360-epp_v10-0-0-08331/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 dd6a1015..f20d0388 100644 --- a/services/package.json +++ b/services/package.json @@ -5,6 +5,7 @@ "type": "module", "bin": { "octobus-tentacles": "bin/octobus-tentacles.js", + "360-epp": "bin/360-epp.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", @@ -53,6 +54,7 @@ "wd-k01": "bin/wd-k01.js" }, "files": [ + "bin/360-epp.js", "bin/alibaba-cloud-simple-application-server-firewall.js", "bin/das-gateway-v3.js", "bin/das-tgfw-v6.js", @@ -99,6 +101,7 @@ "bin/venus-ads-v3-6.js", "bin/wd-k01.js", "bin/wangsu-label-ip.js", + "360__360-epp_v10-0-0-08331", "alibaba-cloud__simple-application-server-firewall", "das__gateway_v3", "das__tgfw_v6",