From 25877f315b01be07aa7c4048655860628767b9fd Mon Sep 17 00:00:00 2001 From: qinglang Date: Fri, 3 Jul 2026 17:37:58 +0800 Subject: [PATCH] feat: add Sangfor AF service adapter --- services/bin/octobus-tentacles.js | 4 + services/bin/sangfor-af-v8-0-35r1.js | 10 + services/package.json | 7 +- services/sangfor__af_v8-0-35r1/README.md | 153 ++++++++ .../bin/sangfor-af-v8-0-35r1.js | 10 + .../sangfor__af_v8-0-35r1/config.schema.json | 29 ++ services/sangfor__af_v8-0-35r1/package.json | 13 + .../proto/sangfor_af_v8_0_35r1.proto | 78 ++++ .../sangfor__af_v8-0-35r1/secret.schema.json | 18 + services/sangfor__af_v8-0-35r1/service.json | 41 +++ .../src/sangfor-af-v8-0-35r1.js | 337 ++++++++++++++++++ services/sangfor__af_v8-0-35r1/src/service.js | 7 + .../test/mock_upstream.js | 131 +++++++ .../test/sangfor-af-v8-0-35r1.test.js | 168 +++++++++ 14 files changed, 1004 insertions(+), 2 deletions(-) create mode 100755 services/bin/sangfor-af-v8-0-35r1.js create mode 100644 services/sangfor__af_v8-0-35r1/README.md create mode 100755 services/sangfor__af_v8-0-35r1/bin/sangfor-af-v8-0-35r1.js create mode 100644 services/sangfor__af_v8-0-35r1/config.schema.json create mode 100644 services/sangfor__af_v8-0-35r1/package.json create mode 100644 services/sangfor__af_v8-0-35r1/proto/sangfor_af_v8_0_35r1.proto create mode 100644 services/sangfor__af_v8-0-35r1/secret.schema.json create mode 100644 services/sangfor__af_v8-0-35r1/service.json create mode 100644 services/sangfor__af_v8-0-35r1/src/sangfor-af-v8-0-35r1.js create mode 100644 services/sangfor__af_v8-0-35r1/src/service.js create mode 100644 services/sangfor__af_v8-0-35r1/test/mock_upstream.js create mode 100644 services/sangfor__af_v8-0-35r1/test/sangfor-af-v8-0-35r1.test.js diff --git a/services/bin/octobus-tentacles.js b/services/bin/octobus-tentacles.js index 62d5d389..b4933dc8 100755 --- a/services/bin/octobus-tentacles.js +++ b/services/bin/octobus-tentacles.js @@ -165,6 +165,10 @@ const services = { entryFile: "../ruijie__behavior_firewall_r2-3-2-t0/bin/ruijie-behavior-firewall-r2-3-2-t0.js", serviceModule: "../ruijie__behavior_firewall_r2-3-2-t0/src/service.js", }, + "sangfor-af-v8-0-35r1": { + entryFile: "../sangfor__af_v8-0-35r1/bin/sangfor-af-v8-0-35r1.js", + serviceModule: "../sangfor__af_v8-0-35r1/src/service.js", + }, "sangfor-fw-v8-0-45": { entryFile: "../sangfor__fw_v8-0-45/bin/sangfor-fw-v8-0-45.js", serviceModule: "../sangfor__fw_v8-0-45/src/service.js", diff --git a/services/bin/sangfor-af-v8-0-35r1.js b/services/bin/sangfor-af-v8-0-35r1.js new file mode 100755 index 00000000..54b1de19 --- /dev/null +++ b/services/bin/sangfor-af-v8-0-35r1.js @@ -0,0 +1,10 @@ +#!/usr/bin/env node + +import { fileURLToPath } from "node:url"; +import { runServiceMain } from "@chaitin-ai/octobus-sdk"; + +import { service } from "../sangfor__af_v8-0-35r1/src/service.js"; + +runServiceMain(service, { + entryFile: fileURLToPath(new URL("../sangfor__af_v8-0-35r1/bin/sangfor-af-v8-0-35r1.js", import.meta.url)), +}); diff --git a/services/package.json b/services/package.json index 6b181372..a5879ff3 100644 --- a/services/package.json +++ b/services/package.json @@ -67,7 +67,8 @@ "volcengine-cloud-firewall": "bin/volcengine-cloud-firewall.js", "wangsu-label-ip": "bin/wangsu-label-ip.js", "wd-k01": "bin/wd-k01.js", - "opencti": "bin/opencti.js" + "opencti": "bin/opencti.js", + "sangfor-af-v8-0-35r1": "bin/sangfor-af-v8-0-35r1.js" }, "files": [ "bin/alibaba-cloud-simple-application-server-firewall.js", @@ -197,7 +198,9 @@ "fofa__network-space-mapper", "wangsu__label-ip", "scripts", - "bin/octobus-tentacles.js" + "bin/octobus-tentacles.js", + "bin/sangfor-af-v8-0-35r1.js", + "sangfor__af_v8-0-35r1" ], "scripts": { "validate": "node scripts/validate-service-package.mjs", diff --git a/services/sangfor__af_v8-0-35r1/README.md b/services/sangfor__af_v8-0-35r1/README.md new file mode 100644 index 00000000..a8225769 --- /dev/null +++ b/services/sangfor__af_v8-0-35r1/README.md @@ -0,0 +1,153 @@ +# Sangfor AF 8.0.35R1 + +OctoBus service adapter for Sangfor AF 8.0.35R1 black/white list entries. + +## Supported capabilities + +This first version supports the unified Sangfor AF black/white list API: + +- `ListWhiteBlackListEntries` — list blacklist or whitelist entries +- `GetWhiteBlackListEntry` — get one entry +- `CreateWhiteBlackListEntry` — create one entry +- `DeleteWhiteBlackListEntry` — delete one entry + +Blacklist and whitelist are selected with `type`: + +- `BLACK` — blacklist +- `WHITE` — whitelist + +Batch create, batch delete, update, batch update, and clear-all custom entries are intentionally not included in this first version. + +## Authentication + +The adapter logs in with username/password: + +```text +POST /api/v1/namespaces/{namespace}/login +``` + +Sangfor AF returns `data.loginResult.token`. Business requests send the token as: + +```text +Cookie: token= +``` + +Tokens, passwords, and cookies must not be committed or included in PR evidence. + +## Config + +```json +{ + "host": "https://af.example.test", + "namespace": "public", + "timeoutMs": 10000, + "skipTlsVerify": false +} +``` + +- `host`: direct Sangfor AF base URL. The formal target is direct device access, not an external proxy. +- `namespace`: Sangfor AF API namespace. Defaults to `public`. +- `timeoutMs`: upstream timeout in milliseconds. +- `skipTlsVerify`: set to `true` only for controlled test devices with self-signed certificates. + +## Secret + +```json +{ + "username": "", + "password": "" +} +``` + +## Example requests + +List whitelist entries: + +```json +{ + "type": "WHITE", + "start": 0, + "length": 100 +} +``` + +List blacklist entries: + +```json +{ + "type": "BLACK", + "start": 0, + "length": 100 +} +``` + +Create a controlled blacklist test entry: + +```json +{ + "url": "198.51.100.203", + "type": "BLACK", + "enable": true, + "description": "octobus-test-delete-me" +} +``` + +Delete the controlled test entry: + +```json +{ + "url": "198.51.100.203", + "type": "BLACK" +} +``` + +## Risk boundary + +Risk classification: `writable`. + +This adapter can create and delete single black/white list entries. It does not expose batch operations or clear-all operations. For write validation, use a dedicated test object and delete it after verification. + +Create defaults: + +- `enable`: defaults to `true` +- `type`: required, `BLACK` or `WHITE` +- rollback path: call `DeleteWhiteBlackListEntry` with the same `url` and `type` + +## Suggested capset + +Default operational capset: + +- `ListWhiteBlackListEntries` +- `GetWhiteBlackListEntry` + +Controlled operation capset: + +- add `CreateWhiteBlackListEntry` +- add `DeleteWhiteBlackListEntry` + +Only expose write methods to agents that are allowed to change AF black/white list entries. + +## Validation + +Local checks: + +```bash +cd services +npm run validate -- --service-dir sangfor__af_v8-0-35r1 +npm test -- --service-dir sangfor__af_v8-0-35r1 +npm run pack:check +``` + +Live validation performed during development through an approved test path confirmed: + +- login returned AF `code: 0` +- whitelist list returned AF `code: 0` and 28 entries +- blacklist list returned AF `code: 0` and 0 entries + +The proxy cookie used for the development test path is not part of this adapter design. Production usage should configure `host` as a direct Sangfor AF device base URL. + +## Known limitations + +- Only Sangfor AF 8.0.35R1 black/white list entry APIs are covered. +- Batch operations and clear-all operations are intentionally omitted. +- The adapter returns Sangfor AF business fields useful to operators and does not expose the raw upstream response body. diff --git a/services/sangfor__af_v8-0-35r1/bin/sangfor-af-v8-0-35r1.js b/services/sangfor__af_v8-0-35r1/bin/sangfor-af-v8-0-35r1.js new file mode 100755 index 00000000..236db771 --- /dev/null +++ b/services/sangfor__af_v8-0-35r1/bin/sangfor-af-v8-0-35r1.js @@ -0,0 +1,10 @@ +#!/usr/bin/env node + +import { fileURLToPath } from 'node:url'; +import { runServiceMain } from '@chaitin-ai/octobus-sdk'; + +import { service } from '../src/service.js'; + +runServiceMain(service, { + entryFile: fileURLToPath(import.meta.url), +}); diff --git a/services/sangfor__af_v8-0-35r1/config.schema.json b/services/sangfor__af_v8-0-35r1/config.schema.json new file mode 100644 index 00000000..d89e4e73 --- /dev/null +++ b/services/sangfor__af_v8-0-35r1/config.schema.json @@ -0,0 +1,29 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "required": [ + "host" + ], + "properties": { + "host": { + "type": "string", + "description": "Direct Sangfor AF base URL such as https://192.0.2.10 or https://192.0.2.10:4433" + }, + "namespace": { + "type": "string", + "default": "public", + "description": "Sangfor AF API namespace" + }, + "timeoutMs": { + "type": "integer", + "default": 10000, + "minimum": 1, + "description": "Upstream request timeout in milliseconds" + }, + "skipTlsVerify": { + "type": "boolean", + "default": false, + "description": "Skip upstream TLS certificate verification for test devices with self-signed certificates" + } + } +} diff --git a/services/sangfor__af_v8-0-35r1/package.json b/services/sangfor__af_v8-0-35r1/package.json new file mode 100644 index 00000000..d54e76a3 --- /dev/null +++ b/services/sangfor__af_v8-0-35r1/package.json @@ -0,0 +1,13 @@ +{ + "name": "sangfor-af-v8-0-35r1", + "version": "0.0.0", + "private": true, + "type": "module", + "bin": { + "sangfor-af-v8-0-35r1": "bin/sangfor-af-v8-0-35r1.js" + }, + "dependencies": { + "@chaitin-ai/octobus-sdk": "^0.6.0", + "undici": "^7.16.0" + } +} diff --git a/services/sangfor__af_v8-0-35r1/proto/sangfor_af_v8_0_35r1.proto b/services/sangfor__af_v8-0-35r1/proto/sangfor_af_v8_0_35r1.proto new file mode 100644 index 00000000..9d1141d1 --- /dev/null +++ b/services/sangfor__af_v8-0-35r1/proto/sangfor_af_v8_0_35r1.proto @@ -0,0 +1,78 @@ +syntax = "proto3"; + +package Sangfor_AF_V8035R1; + +option go_package = "miner/grpc-service/Sangfor_AF_V8035R1"; + +service Sangfor_AF_V8035R1 { + rpc ListWhiteBlackListEntries(ListWhiteBlackListEntriesRequest) returns (ListWhiteBlackListEntriesResponse) {} + rpc GetWhiteBlackListEntry(GetWhiteBlackListEntryRequest) returns (GetWhiteBlackListEntryResponse) {} + rpc CreateWhiteBlackListEntry(CreateWhiteBlackListEntryRequest) returns (CreateWhiteBlackListEntryResponse) {} + rpc DeleteWhiteBlackListEntry(DeleteWhiteBlackListEntryRequest) returns (DeleteWhiteBlackListEntryResponse) {} +} + +enum WhiteBlackListType { + WHITE_BLACK_LIST_TYPE_UNSPECIFIED = 0; + BLACK = 1; + WHITE = 2; +} + +message WhiteBlackListEntry { + string url = 1; + WhiteBlackListType type = 2; + bool enable = 3; + int32 domain = 4; + bool is_default = 5; + string description = 6; + string create_time = 7; +} + +message ListWhiteBlackListEntriesRequest { + WhiteBlackListType type = 1; + int32 start = 2; + int32 length = 3; + string search = 4; + string url = 5; + string description = 6; + string sort_by = 7; + string order = 8; +} + +message ListWhiteBlackListEntriesResponse { + int32 total_items = 1; + int32 total_pages = 2; + int32 page_number = 3; + int32 page_size = 4; + int32 items_offset = 5; + int32 item_length = 6; + repeated WhiteBlackListEntry items = 7; +} + +message GetWhiteBlackListEntryRequest { + string url = 1; + WhiteBlackListType type = 2; +} + +message GetWhiteBlackListEntryResponse { + WhiteBlackListEntry entry = 1; +} + +message CreateWhiteBlackListEntryRequest { + string url = 1; + WhiteBlackListType type = 2; + optional bool enable = 3; + string description = 4; +} + +message CreateWhiteBlackListEntryResponse { + WhiteBlackListEntry entry = 1; +} + +message DeleteWhiteBlackListEntryRequest { + string url = 1; + WhiteBlackListType type = 2; +} + +message DeleteWhiteBlackListEntryResponse { + WhiteBlackListEntry entry = 1; +} diff --git a/services/sangfor__af_v8-0-35r1/secret.schema.json b/services/sangfor__af_v8-0-35r1/secret.schema.json new file mode 100644 index 00000000..f44c9ac5 --- /dev/null +++ b/services/sangfor__af_v8-0-35r1/secret.schema.json @@ -0,0 +1,18 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "required": [ + "username", + "password" + ], + "properties": { + "username": { + "type": "string", + "description": "Sangfor AF administrator username" + }, + "password": { + "type": "string", + "description": "Sangfor AF administrator password" + } + } +} diff --git a/services/sangfor__af_v8-0-35r1/service.json b/services/sangfor__af_v8-0-35r1/service.json new file mode 100644 index 00000000..9ca3c269 --- /dev/null +++ b/services/sangfor__af_v8-0-35r1/service.json @@ -0,0 +1,41 @@ +{ + "schema": "chaitin.octobus.service.v1", + "name": "sangfor-af-v8-0-35r1", + "displayName": "Sangfor AF 8.0.35R1", + "description": "OctoBus package for Sangfor AF 8.0.35R1 black/white list query, create, and delete APIs.", + "runtime": { + "mode": "long-running" + }, + "proto": { + "roots": [ + "proto" + ], + "files": [ + "proto/sangfor_af_v8_0_35r1.proto" + ] + }, + "configSchema": "config.schema.json", + "secretSchema": "secret.schema.json", + "sdk": { + "cli": { + "commands": { + "Sangfor_AF_V8035R1.Sangfor_AF_V8035R1/ListWhiteBlackListEntries": { + "name": "list-white-black-list-entries", + "description": "List Sangfor AF blacklist or whitelist entries." + }, + "Sangfor_AF_V8035R1.Sangfor_AF_V8035R1/GetWhiteBlackListEntry": { + "name": "get-white-black-list-entry", + "description": "Get one Sangfor AF blacklist or whitelist entry." + }, + "Sangfor_AF_V8035R1.Sangfor_AF_V8035R1/CreateWhiteBlackListEntry": { + "name": "create-white-black-list-entry", + "description": "Create one Sangfor AF blacklist or whitelist entry." + }, + "Sangfor_AF_V8035R1.Sangfor_AF_V8035R1/DeleteWhiteBlackListEntry": { + "name": "delete-white-black-list-entry", + "description": "Delete one Sangfor AF blacklist or whitelist entry." + } + } + } + } +} diff --git a/services/sangfor__af_v8-0-35r1/src/sangfor-af-v8-0-35r1.js b/services/sangfor__af_v8-0-35r1/src/sangfor-af-v8-0-35r1.js new file mode 100644 index 00000000..653d1da0 --- /dev/null +++ b/services/sangfor__af_v8-0-35r1/src/sangfor-af-v8-0-35r1.js @@ -0,0 +1,337 @@ +import { GrpcError, grpcStatus } from '@chaitin-ai/octobus-sdk'; +import { Agent } from 'undici'; + +export const LIST_PATH = '/Sangfor_AF_V8035R1.Sangfor_AF_V8035R1/ListWhiteBlackListEntries'; +export const GET_PATH = '/Sangfor_AF_V8035R1.Sangfor_AF_V8035R1/GetWhiteBlackListEntry'; +export const CREATE_PATH = '/Sangfor_AF_V8035R1.Sangfor_AF_V8035R1/CreateWhiteBlackListEntry'; +export const DELETE_PATH = '/Sangfor_AF_V8035R1.Sangfor_AF_V8035R1/DeleteWhiteBlackListEntry'; + +export const METHOD_LIST_FULL = 'Sangfor_AF_V8035R1.Sangfor_AF_V8035R1/ListWhiteBlackListEntries'; +export const METHOD_GET_FULL = 'Sangfor_AF_V8035R1.Sangfor_AF_V8035R1/GetWhiteBlackListEntry'; +export const METHOD_CREATE_FULL = 'Sangfor_AF_V8035R1.Sangfor_AF_V8035R1/CreateWhiteBlackListEntry'; +export const METHOD_DELETE_FULL = 'Sangfor_AF_V8035R1.Sangfor_AF_V8035R1/DeleteWhiteBlackListEntry'; + +export const DEFAULT_TIMEOUT_MS = 10000; + +const AUTH_EXPIRED_CODES = new Set([1003, 1012]); +const sessionCache = new Map(); +let insecureTlsDispatcher; + +const hasOwn = (obj, key) => Object.prototype.hasOwnProperty.call(obj ?? {}, key); +export const firstDefined = (...values) => values.find((value) => value !== undefined && value !== null && value !== ''); + +const unwrapScalar = (value) => { + if (value === undefined || value === null) return undefined; + if (typeof value === 'object' && value !== null && hasOwn(value, 'value')) return unwrapScalar(value.value); + return value; +}; + +const toTrimmedString = (value) => { + const raw = unwrapScalar(value); + if (raw === undefined || raw === null) return ''; + return String(raw).trim(); +}; + +const toBoolean = (value, fallback = false) => { + const raw = unwrapScalar(value); + if (raw === undefined || raw === null || raw === '') return fallback; + if (typeof raw === 'boolean') return raw; + if (typeof raw === 'number') return raw !== 0; + if (typeof raw === 'string') { + const text = raw.trim().toLowerCase(); + if (['true', '1', 'yes', 'y', 'on'].includes(text)) return true; + if (['false', '0', 'no', 'n', 'off'].includes(text)) return false; + } + return fallback; +}; + +const toInteger = (value, fallback) => { + const raw = unwrapScalar(value); + if (raw === undefined || raw === null || raw === '') return fallback; + const num = Number(raw); + return Number.isFinite(num) ? Math.trunc(num) : fallback; +}; + +const errorWithStatus = (status, message, afCode) => { + const err = new GrpcError(status, message); + if (afCode !== undefined) err.afCode = afCode; + return err; +}; + +const grpcStatusForAfCode = (code) => { + if (code === 1) return grpcStatus.UNAUTHENTICATED; + if (code === 13) return grpcStatus.PERMISSION_DENIED; + if ([22, 1001, 1005].includes(code)) return grpcStatus.INVALID_ARGUMENT; + if ([1003, 1012].includes(code)) return grpcStatus.UNAUTHENTICATED; + if (code === 1004) return grpcStatus.NOT_FOUND; + if (code === 110) return grpcStatus.DEADLINE_EXCEEDED; + return grpcStatus.FAILED_PRECONDITION; +}; + +const afError = (code, message) => errorWithStatus(grpcStatusForAfCode(code), `Sangfor AF API error ${code}: ${message || 'unknown error'}`, code); + +const normalizeBaseUrl = (value) => { + const raw = toTrimmedString(value).replace(/\/+$/, ''); + if (!/^https?:\/\//i.test(raw)) return ''; + return raw; +}; + +const normalizeNamespace = (value) => toTrimmedString(value) || 'public'; + +const mergedBindings = (ctx = {}) => ({ + ...(ctx.config ?? {}), + ...(ctx.bindings ?? {}), + ...(ctx.secret ?? {}), +}); + +const resolveCallContext = (ctx = {}) => ({ + ...ctx, + config: ctx.config ?? {}, + secret: ctx.secret ?? {}, + bindings: mergedBindings(ctx), + limits: ctx.limits ?? {}, + meta: ctx.meta ?? {}, + req: ctx.request ?? ctx.req ?? {}, +}); + +const requestFromContext = (ctx = {}) => ctx.request ?? ctx.req ?? {}; + +const resolveConfig = (ctx) => { + const bindings = ctx.bindings ?? mergedBindings(ctx); + const host = normalizeBaseUrl(firstDefined(bindings.host, bindings.baseUrl, bindings.base_url, bindings.restBaseUrl, bindings.rest_base_url)); + if (!host) throw errorWithStatus(grpcStatus.INVALID_ARGUMENT, 'host is required and must be an http(s) URL'); + return { + host, + namespace: normalizeNamespace(firstDefined(bindings.namespace, bindings.nameSpace)), + timeoutMs: toInteger(firstDefined(ctx.limits?.timeoutMs, bindings.timeoutMs, bindings.timeout_ms), DEFAULT_TIMEOUT_MS), + skipTlsVerify: toBoolean(firstDefined(bindings.skipTlsVerify, bindings.skip_tls_verify, bindings.tlsInsecureSkipVerify, bindings.insecureSkipVerify), false), + }; +}; + +const resolveSecret = (ctx) => { + const bindings = ctx.bindings ?? mergedBindings(ctx); + const username = toTrimmedString(firstDefined(bindings.username, bindings.user, ctx.secret?.username, ctx.secret?.user)); + const password = toTrimmedString(firstDefined(bindings.password, ctx.secret?.password)); + if (!username) throw errorWithStatus(grpcStatus.INVALID_ARGUMENT, 'username is required'); + if (!password) throw errorWithStatus(grpcStatus.INVALID_ARGUMENT, 'password is required'); + return { username, password }; +}; + +const getSessionKey = (ctx, host, namespace) => `${ctx.meta?.instance_id || ctx.meta?.instanceId || 'default'}::${host}::${namespace}`; +const getSession = (ctx, host, namespace) => sessionCache.get(getSessionKey(ctx, host, namespace)); +const setSession = (ctx, host, namespace, token) => sessionCache.set(getSessionKey(ctx, host, namespace), { token }); +const clearSession = (ctx, host, namespace) => sessionCache.delete(getSessionKey(ctx, host, namespace)); + +const getInsecureTlsDispatcher = () => { + insecureTlsDispatcher ??= new Agent({ connect: { rejectUnauthorized: false } }); + return insecureTlsDispatcher; +}; + +const buildTlsOptions = (config) => (config.skipTlsVerify ? { dispatcher: getInsecureTlsDispatcher() } : {}); + +const namespacePath = (namespace, suffix = '') => `/api/v1/namespaces/${encodeURIComponent(namespace)}${suffix}`; + +const parseJson = (text) => { + try { + return JSON.parse(text); + } catch { + throw errorWithStatus(grpcStatus.UNKNOWN, 'upstream response is not valid JSON'); + } +}; + +const requestAf = async (ctx, method, path, { query = {}, body, token } = {}) => { + const config = resolveConfig(ctx); + const url = new URL(`${config.host}${path}`); + for (const [key, value] of Object.entries(query)) { + if (value !== undefined && value !== null && value !== '') url.searchParams.set(key, String(value)); + } + const headers = { Accept: 'application/json, text/plain, */*' }; + if (body !== undefined) headers['Content-Type'] = 'application/json'; + if (token) headers.Cookie = `token=${encodeURIComponent(token)}`; + + let response; + try { + response = await fetch(String(url), { + method, + headers, + body: body === undefined ? undefined : JSON.stringify(body), + signal: AbortSignal.timeout(config.timeoutMs), + ...buildTlsOptions(config), + }); + } catch (err) { + const msg = err?.name === 'TimeoutError' || err?.code === 'ABORT_ERR' + ? 'upstream request timed out' + : (err?.cause?.message || err?.message || 'fetch failed'); + const status = msg.includes('timed out') ? grpcStatus.DEADLINE_EXCEEDED : grpcStatus.UNAVAILABLE; + throw errorWithStatus(status, msg); + } + + const text = await response.text(); + if (response.status >= 500) throw errorWithStatus(grpcStatus.UNAVAILABLE, `upstream HTTP ${response.status}: ${text}`); + if (!text.trim()) throw errorWithStatus(grpcStatus.UNKNOWN, 'upstream response body is empty'); + const json = parseJson(text); + const code = Number(json?.code ?? 0); + if (code !== 0) throw afError(code, json?.message); + return json; +}; + +const login = async (ctx) => { + const config = resolveConfig(ctx); + const { username, password } = resolveSecret(ctx); + const json = await requestAf(ctx, 'POST', namespacePath(config.namespace, '/login'), { + body: { name: username, password }, + }); + const token = toTrimmedString(json?.data?.loginResult?.token); + if (!token) throw errorWithStatus(grpcStatus.UNKNOWN, 'login succeeded but token is empty'); + setSession(ctx, config.host, config.namespace, token); + return token; +}; + +const withAuthRetry = async (ctx, fn) => { + const config = resolveConfig(ctx); + let token = getSession(ctx, config.host, config.namespace)?.token; + if (!token) token = await login(ctx); + try { + return await fn(token); + } catch (err) { + if (!AUTH_EXPIRED_CODES.has(err.afCode)) throw err; + clearSession(ctx, config.host, config.namespace); + token = await login(ctx); + return fn(token); + } +}; + +const typeToUpstream = (value, { required = false } = {}) => { + const raw = unwrapScalar(value); + if (raw === undefined || raw === null || raw === '' || raw === 0 || raw === 'WHITE_BLACK_LIST_TYPE_UNSPECIFIED') { + if (required) throw errorWithStatus(grpcStatus.INVALID_ARGUMENT, 'type is required'); + return undefined; + } + if (raw === 1 || String(raw).toUpperCase() === 'BLACK') return 'BLACK'; + if (raw === 2 || String(raw).toUpperCase() === 'WHITE') return 'WHITE'; + throw errorWithStatus(grpcStatus.INVALID_ARGUMENT, 'type must be BLACK or WHITE'); +}; + +const upstreamToType = (value) => { + const text = String(value || '').toUpperCase(); + if (text === 'BLACK') return 'BLACK'; + if (text === 'WHITE') return 'WHITE'; + return 'WHITE_BLACK_LIST_TYPE_UNSPECIFIED'; +}; + +const mapEntry = (entry = {}) => ({ + url: toTrimmedString(entry.url), + type: upstreamToType(entry.type), + enable: Boolean(entry.enable), + domain: toInteger(entry.domain, 0), + is_default: Boolean(entry.isDefault ?? entry.is_default), + description: toTrimmedString(entry.description), + create_time: toTrimmedString(entry.createTime ?? entry.create_time), +}); + +const requireUrl = (value) => { + const url = toTrimmedString(value); + if (!url) throw errorWithStatus(grpcStatus.INVALID_ARGUMENT, 'url is required'); + return url; +}; + +const handleListWhiteBlackListEntries = async (ctx) => { + const req = requestFromContext(ctx); + const config = resolveConfig(ctx); + const type = typeToUpstream(firstDefined(req.type)); + const length = Math.min(Math.max(toInteger(firstDefined(req.length), 100), 1), 200); + const query = { + type, + _start: Math.max(toInteger(firstDefined(req.start), 0), 0), + _length: length, + _search: firstDefined(req.search), + url: firstDefined(req.url), + description: firstDefined(req.description), + _sortby: firstDefined(req.sort_by, req.sortBy), + _order: firstDefined(req.order), + }; + const json = await withAuthRetry(ctx, (token) => requestAf(ctx, 'GET', namespacePath(config.namespace, '/whiteblacklist'), { query, token })); + const data = json.data || {}; + return { + total_items: toInteger(data.totalItems ?? data.total_items, 0), + total_pages: toInteger(data.totalPages ?? data.total_pages, 0), + page_number: toInteger(data.pageNumber ?? data.page_number, 0), + page_size: toInteger(data.pageSize ?? data.page_size, 0), + items_offset: toInteger(data.itemsOffset ?? data.items_offset, 0), + item_length: toInteger(data.itemLength ?? data.item_length, 0), + items: Array.isArray(data.items) ? data.items.map(mapEntry) : [], + }; +}; + +const handleGetWhiteBlackListEntry = async (ctx) => { + const req = requestFromContext(ctx); + const config = resolveConfig(ctx); + const url = requireUrl(firstDefined(req.url)); + const type = typeToUpstream(firstDefined(req.type), { required: false }); + const json = await withAuthRetry(ctx, (token) => requestAf(ctx, 'GET', namespacePath(config.namespace, `/whiteblacklist/${encodeURIComponent(url)}`), { query: { type }, token })); + return { entry: mapEntry(json.data || {}) }; +}; + +const handleCreateWhiteBlackListEntry = async (ctx) => { + const req = requestFromContext(ctx); + const config = resolveConfig(ctx); + const url = requireUrl(firstDefined(req.url)); + const type = typeToUpstream(firstDefined(req.type), { required: true }); + const enable = firstDefined(req.enable) === undefined ? true : toBoolean(req.enable, true); + const body = { url, type, enable }; + const description = toTrimmedString(firstDefined(req.description)); + if (description) body.description = description; + const json = await withAuthRetry(ctx, (token) => requestAf(ctx, 'POST', namespacePath(config.namespace, '/whiteblacklist'), { body, token })); + return { entry: mapEntry(json.data || {}) }; +}; + +const handleDeleteWhiteBlackListEntry = async (ctx) => { + const req = requestFromContext(ctx); + const config = resolveConfig(ctx); + const url = requireUrl(firstDefined(req.url)); + const type = typeToUpstream(firstDefined(req.type), { required: true }); + const json = await withAuthRetry(ctx, (token) => requestAf(ctx, 'DELETE', namespacePath(config.namespace, `/whiteblacklist/${encodeURIComponent(url)}`), { query: { type }, token })); + return { entry: mapEntry(json.data || {}) }; +}; + +export function rpcdef(ctx = {}) { + const callCtx = resolveCallContext(ctx); + return { + [LIST_PATH]: async (req) => handleListWhiteBlackListEntries({ ...callCtx, request: req ?? callCtx.req }), + [GET_PATH]: async (req) => handleGetWhiteBlackListEntry({ ...callCtx, request: req ?? callCtx.req }), + [CREATE_PATH]: async (req) => handleCreateWhiteBlackListEntry({ ...callCtx, request: req ?? callCtx.req }), + [DELETE_PATH]: async (req) => handleDeleteWhiteBlackListEntry({ ...callCtx, request: req ?? callCtx.req }), + }; +} + +export const handlers = { + [METHOD_LIST_FULL]: (ctx = {}) => handleListWhiteBlackListEntries(resolveCallContext(ctx)), + [METHOD_GET_FULL]: (ctx = {}) => handleGetWhiteBlackListEntry(resolveCallContext(ctx)), + [METHOD_CREATE_FULL]: (ctx = {}) => handleCreateWhiteBlackListEntry(resolveCallContext(ctx)), + [METHOD_DELETE_FULL]: (ctx = {}) => handleDeleteWhiteBlackListEntry(resolveCallContext(ctx)), +}; + +export const _test = { + afError, + buildTlsOptions, + clearSession, + errorWithStatus, + firstDefined, + getSession, + login, + mapEntry, + namespacePath, + normalizeBaseUrl, + requestAf, + resolveCallContext, + resolveConfig, + resolveSecret, + sessionCache, + setSession, + toBoolean, + toInteger, + toTrimmedString, + typeToUpstream, + upstreamToType, + withAuthRetry, +}; diff --git a/services/sangfor__af_v8-0-35r1/src/service.js b/services/sangfor__af_v8-0-35r1/src/service.js new file mode 100644 index 00000000..d63419a0 --- /dev/null +++ b/services/sangfor__af_v8-0-35r1/src/service.js @@ -0,0 +1,7 @@ +import { defineService } from '@chaitin-ai/octobus-sdk'; + +import { handlers } from './sangfor-af-v8-0-35r1.js'; + +export { handlers } from './sangfor-af-v8-0-35r1.js'; + +export const service = defineService({ handlers }); diff --git a/services/sangfor__af_v8-0-35r1/test/mock_upstream.js b/services/sangfor__af_v8-0-35r1/test/mock_upstream.js new file mode 100644 index 00000000..1b705ac3 --- /dev/null +++ b/services/sangfor__af_v8-0-35r1/test/mock_upstream.js @@ -0,0 +1,131 @@ +/* node:coverage disable */ +import http from 'node:http'; + +const DEFAULT_ENTRIES = [ + { url: '8.8.0.1', type: 'WHITE', enable: true, domain: 0, isDefault: false, description: 'csspIp', createTime: '2026-06-25 19:04:47' }, + { url: 'device.scloud.sangfor.com', type: 'WHITE', enable: true, domain: 1, isDefault: true, description: 'device.scloud.sangfor.com', createTime: '2011-07-01 08:30:00' }, +]; + +export const createMockServer = async ({ username = 'api_user', password = 'SuperSecret!' } = {}) => { + const state = { + tokenSeq: 0, + validTokens: new Set(), + requests: [], + entries: DEFAULT_ENTRIES.map((entry) => ({ ...entry })), + expireNextBusinessRequest: false, + nextErrorCode: null, + }; + + const sendJson = (res, payload, status = 200) => { + res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' }); + res.end(JSON.stringify(payload)); + }; + + const readJsonBody = (req) => new Promise((resolve, reject) => { + let raw = ''; + req.on('data', (chunk) => { raw += chunk; }); + req.on('end', () => { + if (!raw.trim()) return resolve(null); + try { resolve(JSON.parse(raw)); } catch (err) { reject(err); } + }); + req.on('error', reject); + }); + + const cookieToken = (req) => { + const cookie = String(req.headers.cookie || ''); + const match = cookie.match(/(?:^|;\s*)token=([^;]+)/); + return match ? decodeURIComponent(match[1]) : ''; + }; + + const requireSession = (req, res) => { + if (state.nextErrorCode != null) { + const code = state.nextErrorCode; + state.nextErrorCode = null; + sendJson(res, { code, message: `forced ${code}`, data: '' }); + return false; + } + if (state.expireNextBusinessRequest) { + state.expireNextBusinessRequest = false; + sendJson(res, { code: 1003, message: '未登陆', data: '' }); + return false; + } + const token = cookieToken(req); + if (!state.validTokens.has(token)) { + sendJson(res, { code: 1003, message: '未登陆', data: '' }); + return false; + } + return true; + }; + + const normalizeType = (value) => String(value || '').toUpperCase(); + + const server = http.createServer((req, res) => { + (async () => { + const parsed = new URL(req.url, 'http://127.0.0.1'); + const body = await readJsonBody(req); + state.requests.push({ method: req.method, url: req.url, pathname: parsed.pathname, searchParams: Object.fromEntries(parsed.searchParams), body, headers: req.headers }); + + if (req.method === 'POST' && parsed.pathname === '/api/v1/namespaces/public/login') { + if (body?.name !== username || body?.password !== password) { + sendJson(res, { code: 1, message: '密码或用户名错误', data: '' }); + return; + } + const token = `token-${++state.tokenSeq}`; + state.validTokens.add(token); + sendJson(res, { code: 0, message: '成功', data: { name: username, loginResult: { token }, role: 'COMMON' } }); + return; + } + + if (parsed.pathname === '/api/v1/namespaces/public/whiteblacklist') { + if (!requireSession(req, res)) return; + if (req.method === 'GET') { + const type = normalizeType(parsed.searchParams.get('type')); + const start = Number(parsed.searchParams.get('_start') || 0); + const length = Number(parsed.searchParams.get('_length') || 100); + const items = state.entries.filter((entry) => !type || entry.type === type); + const pageItems = items.slice(start, start + length); + sendJson(res, { code: 0, message: '成功', data: { itemsOffset: start, items: pageItems, totalItems: items.length, pageNumber: 0, totalPages: Math.ceil(items.length / length), pageSize: length, itemLength: pageItems.length } }); + return; + } + if (req.method === 'POST') { + const entry = { url: String(body?.url || ''), type: normalizeType(body?.type), enable: body?.enable !== false, domain: 0, isDefault: false, description: String(body?.description || ''), createTime: '2026-07-03 00:00:00' }; + state.entries = state.entries.filter((item) => !(item.url === entry.url && item.type === entry.type)); + state.entries.push(entry); + sendJson(res, { code: 0, message: '成功', data: entry }); + return; + } + } + + const match = parsed.pathname.match(/^\/api\/v1\/namespaces\/public\/whiteblacklist\/(.+)$/); + if (match) { + if (!requireSession(req, res)) return; + const url = decodeURIComponent(match[1]); + const type = normalizeType(parsed.searchParams.get('type')); + const index = state.entries.findIndex((entry) => entry.url === url && (!type || entry.type === type)); + if (req.method === 'GET') { + if (index < 0) return sendJson(res, { code: 1004, message: '对象不存在', data: '' }); + sendJson(res, { code: 0, message: '成功', data: state.entries[index] }); + return; + } + if (req.method === 'DELETE') { + if (index < 0) return sendJson(res, { code: 1004, message: '对象不存在', data: '' }); + const [removed] = state.entries.splice(index, 1); + sendJson(res, { code: 0, message: '成功', data: removed }); + return; + } + } + + res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' }); + res.end('not found'); + })().catch((err) => sendJson(res, { code: 1007, message: err?.message || 'internal error', data: '' }, 500)); + }); + + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const { port } = server.address(); + return { + state, + requests: state.requests, + url: `http://127.0.0.1:${port}`, + close: () => new Promise((resolve, reject) => server.close((err) => (err ? reject(err) : resolve()))), + }; +}; diff --git a/services/sangfor__af_v8-0-35r1/test/sangfor-af-v8-0-35r1.test.js b/services/sangfor__af_v8-0-35r1/test/sangfor-af-v8-0-35r1.test.js new file mode 100644 index 00000000..7394762a --- /dev/null +++ b/services/sangfor__af_v8-0-35r1/test/sangfor-af-v8-0-35r1.test.js @@ -0,0 +1,168 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { GrpcError, grpcStatus } from '@chaitin-ai/octobus-sdk'; + +import { + METHOD_CREATE_FULL, + METHOD_DELETE_FULL, + METHOD_GET_FULL, + METHOD_LIST_FULL, + _test, + handlers, + rpcdef, +} from '../src/sangfor-af-v8-0-35r1.js'; +import { service } from '../src/service.js'; +import { createMockServer } from './mock_upstream.js'; + +let seq = 0; + +const buildCtx = (host, overrides = {}) => ({ + config: { host, namespace: 'public', timeoutMs: 10000, skipTlsVerify: false, ...(overrides.config || {}) }, + secret: { username: 'api_user', password: 'SuperSecret!', ...(overrides.secret || {}) }, + meta: { instance_id: overrides.instance_id || `inst-${++seq}`, request_id: 'req' }, + request: overrides.request || {}, +}); + +const callHandler = (method, host, request = {}, overrides = {}) => handlers[method](buildCtx(host, { ...overrides, request })); + +const expectGrpc = async (fn, code) => { + let caught; + try { + await fn(); + } catch (err) { + caught = err; + } + assert.ok(caught, 'expected GrpcError'); + assert.ok(caught instanceof GrpcError); + assert.equal(caught.code, code); + return caught; +}; + +test.afterEach(() => { + _test.sessionCache.clear(); +}); + +test('service exports defineService result and handlers', () => { + assert.equal(typeof service, 'object'); + assert.equal(typeof handlers[METHOD_LIST_FULL], 'function'); + assert.equal(typeof handlers[METHOD_GET_FULL], 'function'); + assert.equal(typeof handlers[METHOD_CREATE_FULL], 'function'); + assert.equal(typeof handlers[METHOD_DELETE_FULL], 'function'); +}); + +test('ListWhiteBlackListEntries logs in and sends token cookie', async () => { + const mock = await createMockServer(); + try { + const res = await callHandler(METHOD_LIST_FULL, mock.url, { type: 'WHITE', start: 0, length: 5 }); + assert.equal(res.total_items, 2); + assert.equal(mock.requests[0].method, 'POST'); + assert.equal(mock.requests[0].pathname, '/api/v1/namespaces/public/login'); + assert.deepEqual(mock.requests[0].body, { name: 'api_user', password: 'SuperSecret!' }); + assert.equal(mock.requests[1].method, 'GET'); + assert.equal(mock.requests[1].pathname, '/api/v1/namespaces/public/whiteblacklist'); + assert.equal(mock.requests[1].searchParams.type, 'WHITE'); + assert.match(mock.requests[1].headers.cookie, /token=token-1/); + } finally { + await mock.close(); + } +}); + +test('ListWhiteBlackListEntries maps pagination and entries', async () => { + const mock = await createMockServer(); + try { + const res = await rpcdef(buildCtx(mock.url, { request: { type: 2, start: 0, length: 1, sort_by: 'url', order: 'asc' } }))[ + '/Sangfor_AF_V8035R1.Sangfor_AF_V8035R1/ListWhiteBlackListEntries' + ](); + assert.equal(res.total_items, 2); + assert.equal(res.page_size, 1); + assert.equal(res.item_length, 1); + assert.equal(res.items[0].url, '8.8.0.1'); + assert.equal(res.items[0].type, 'WHITE'); + assert.equal(res.items[0].is_default, false); + assert.equal(mock.requests[1].searchParams._sortby, 'url'); + } finally { + await mock.close(); + } +}); + +test('GetWhiteBlackListEntry URL-encodes url path segment', async () => { + const mock = await createMockServer(); + try { + const res = await callHandler(METHOD_GET_FULL, mock.url, { url: 'device.scloud.sangfor.com', type: 'WHITE' }); + assert.equal(res.entry.url, 'device.scloud.sangfor.com'); + assert.equal(mock.requests[1].pathname, '/api/v1/namespaces/public/whiteblacklist/device.scloud.sangfor.com'); + } finally { + await mock.close(); + } +}); + +test('CreateWhiteBlackListEntry sends expected body and defaults enable true', async () => { + const mock = await createMockServer(); + try { + const res = await callHandler(METHOD_CREATE_FULL, mock.url, { url: '198.51.100.203', type: 'BLACK', description: 'octobus-test' }); + assert.equal(res.entry.url, '198.51.100.203'); + assert.equal(res.entry.type, 'BLACK'); + assert.equal(mock.requests[1].method, 'POST'); + assert.deepEqual(mock.requests[1].body, { url: '198.51.100.203', type: 'BLACK', enable: true, description: 'octobus-test' }); + } finally { + await mock.close(); + } +}); + +test('DeleteWhiteBlackListEntry sends DELETE with encoded url and type query', async () => { + const mock = await createMockServer(); + try { + await callHandler(METHOD_CREATE_FULL, mock.url, { url: '198.51.100.203', type: 'BLACK' }); + const res = await callHandler(METHOD_DELETE_FULL, mock.url, { url: '198.51.100.203', type: 1 }); + assert.equal(res.entry.url, '198.51.100.203'); + const req = mock.requests.at(-1); + assert.equal(req.method, 'DELETE'); + assert.equal(req.pathname, '/api/v1/namespaces/public/whiteblacklist/198.51.100.203'); + assert.equal(req.searchParams.type, 'BLACK'); + } finally { + await mock.close(); + } +}); + +test('expired token code triggers one relogin and retry', async () => { + const mock = await createMockServer(); + try { + mock.state.expireNextBusinessRequest = true; + const res = await callHandler(METHOD_LIST_FULL, mock.url, { type: 'WHITE' }); + assert.equal(res.total_items, 2); + assert.equal(mock.requests.filter((req) => req.pathname.endsWith('/login')).length, 2); + assert.match(mock.requests.at(-1).headers.cookie, /token=token-2/); + } finally { + await mock.close(); + } +}); + +test('AF error codes map to expected GrpcError status', async () => { + const mock = await createMockServer(); + try { + await expectGrpc(() => callHandler(METHOD_LIST_FULL, mock.url, {}, { secret: { password: 'wrong' } }), grpcStatus.UNAUTHENTICATED); + const cases = [ + [13, grpcStatus.PERMISSION_DENIED], + [22, grpcStatus.INVALID_ARGUMENT], + [1004, grpcStatus.NOT_FOUND], + [110, grpcStatus.DEADLINE_EXCEEDED], + [1008, grpcStatus.FAILED_PRECONDITION], + ]; + for (const [afCode, status] of cases) { + mock.state.nextErrorCode = afCode; + await expectGrpc(() => callHandler(METHOD_LIST_FULL, mock.url, { type: 'WHITE' }, { instance_id: `err-${afCode}` }), status); + } + } finally { + await mock.close(); + } +}); + +test('helpers validate inputs and aliases', async () => { + assert.equal(_test.typeToUpstream('black'), 'BLACK'); + assert.equal(_test.typeToUpstream(2), 'WHITE'); + assert.throws(() => _test.typeToUpstream('bad'), /type must be BLACK or WHITE/); + assert.equal(_test.normalizeBaseUrl('https://example.test///'), 'https://example.test'); + assert.equal(_test.normalizeBaseUrl('example.test'), ''); + await expectGrpc(() => handlers[METHOD_GET_FULL](buildCtx('https://example.test', { request: {} })), grpcStatus.INVALID_ARGUMENT); +});