From 7bf6bead9346e7173885f530c2588c5d05cb20f7 Mon Sep 17 00:00:00 2001 From: duguwanglong Date: Fri, 22 May 2026 15:00:26 +0800 Subject: [PATCH] feat(hub,device): add device plugin type and fix versioned service_id derivation Introduce a first-class "device" plugin type in the Hub marketplace, classified by `integration_type: device` in `_provider.yaml` and installed under `~/.flocks/plugins/tools/device/`. Device plugins now surface in the marketplace listing, get recognized during device setup, and are uninstalled with the correct type. Also fixes two regressions surfaced by versioned device plugins whose own `service_id` already contains a `_v...` token (e.g. `onesig_api` for both v2.5.3 D20260321 and D20250710): * `storage_key_to_service_id` previously stripped trailing version suffixes with a greedy regex, collapsing e.g. `onesig_v2_5_3_D20250710_api_v2_5_3_D20250710` to `onesig`. It now prefers the exact `ApiServiceDescriptor` cache mapping and falls back to a non-greedy regex that removes only the last suffix. * `row_to_device` recomputes `service_id` on read so historical rows with a corrupted column self-heal in the response. * `device_startup` adds a one-shot migration that rewrites stale `device_integrations.service_id` rows. * Device CRUD routes now derive `service_id` from the row's `storage_key` instead of trusting the stored column, keeping `sync_service_tool_state` aligned with the descriptor-aware logic. * Frontend tool filter in the device detail panel now matches on the exact versioned `storage_key`, so two versions of the same product no longer cross-contaminate the displayed tool list. Bundled `_provider.yaml` files for `onesig_v2_5_3_D20250710`, `sangfor_af_v8_0_48` and `sangfor_af_v8_0_85` are tagged with `integration_type: device`; their legacy `tools/api/` copies are removed in favour of the canonical `tools/device/` layout. Tests cover both the new plugin-type discovery path and the service_id derivation fix. Co-authored-by: Cursor --- .../onesig_v2_5_3_D20250710/_provider.yaml | 158 -- .../api/onesig_v2_5_3_D20250710/_test.yaml | 116 - .../onesig_v2_5_3_D20250710.handler.py | 2270 ----------------- .../onesig_v2_5_3_D20250710_assets.yaml | 90 - .../onesig_v2_5_3_D20250710_device.yaml | 400 --- .../onesig_v2_5_3_D20250710_helper.yaml | 56 - .../onesig_v2_5_3_D20250710_login.yaml | 134 - .../onesig_v2_5_3_D20250710_monitoring.yaml | 256 -- .../onesig_v2_5_3_D20250710_strategy.yaml | 268 -- .../api/sangfor_af_v8_0_48/_provider.yaml | 55 - .../tools/api/sangfor_af_v8_0_48/_test.yaml | 103 - .../sangfor_af_v8_0_48/sangfor_af.handler.py | 689 ----- .../sangfor_af_v48_auth.yaml | 44 - .../sangfor_af_v48_network.yaml | 65 - .../sangfor_af_v48_objects.yaml | 152 -- .../sangfor_af_v48_ops.yaml | 165 -- .../sangfor_af_v48_status.yaml | 91 - .../sangfor_af_v48_system.yaml | 53 - .../api/sangfor_af_v8_0_85/_provider.yaml | 52 - .../tools/api/sangfor_af_v8_0_85/_test.yaml | 100 - .../sangfor_af_v8_0_85/sangfor_af.handler.py | 681 ----- .../sangfor_af_v85_auth.yaml | 44 - .../sangfor_af_v85_monitor.yaml | 184 -- .../sangfor_af_v85_network.yaml | 65 - .../sangfor_af_v85_objects.yaml | 152 -- .../sangfor_af_v85_ops.yaml | 165 -- .../sangfor_af_v85_status.yaml | 91 - .../sangfor_af_v85_system.yaml | 53 - .../onesig_v2_5_3_D20250710/_provider.yaml | 1 + .../device/sangfor_af_v8_0_48/_provider.yaml | 1 + .../device/sangfor_af_v8_0_85/_provider.yaml | 1 + flocks/hub/catalog.py | 62 +- flocks/hub/installer.py | 35 +- flocks/hub/local.py | 44 +- flocks/hub/models.py | 2 +- flocks/server/routes/device.py | 13 +- flocks/server/routes/provider.py | 56 +- flocks/tool/device/startup.py | 36 + flocks/tool/device/store.py | 64 +- tests/hub/test_bundled_tools.py | 42 +- tests/hub/test_hub_catalog.py | 10 +- tests/tool/test_device_store_service_id.py | 156 ++ webui/src/api/hub.ts | 2 +- webui/src/pages/DeviceIntegration/index.tsx | 23 +- webui/src/pages/Hub/index.tsx | 24 +- 45 files changed, 517 insertions(+), 6807 deletions(-) delete mode 100644 .flocks/flockshub/plugins/tools/api/onesig_v2_5_3_D20250710/_provider.yaml delete mode 100644 .flocks/flockshub/plugins/tools/api/onesig_v2_5_3_D20250710/_test.yaml delete mode 100644 .flocks/flockshub/plugins/tools/api/onesig_v2_5_3_D20250710/onesig_v2_5_3_D20250710.handler.py delete mode 100644 .flocks/flockshub/plugins/tools/api/onesig_v2_5_3_D20250710/onesig_v2_5_3_D20250710_assets.yaml delete mode 100644 .flocks/flockshub/plugins/tools/api/onesig_v2_5_3_D20250710/onesig_v2_5_3_D20250710_device.yaml delete mode 100644 .flocks/flockshub/plugins/tools/api/onesig_v2_5_3_D20250710/onesig_v2_5_3_D20250710_helper.yaml delete mode 100644 .flocks/flockshub/plugins/tools/api/onesig_v2_5_3_D20250710/onesig_v2_5_3_D20250710_login.yaml delete mode 100644 .flocks/flockshub/plugins/tools/api/onesig_v2_5_3_D20250710/onesig_v2_5_3_D20250710_monitoring.yaml delete mode 100644 .flocks/flockshub/plugins/tools/api/onesig_v2_5_3_D20250710/onesig_v2_5_3_D20250710_strategy.yaml delete mode 100644 .flocks/flockshub/plugins/tools/api/sangfor_af_v8_0_48/_provider.yaml delete mode 100644 .flocks/flockshub/plugins/tools/api/sangfor_af_v8_0_48/_test.yaml delete mode 100644 .flocks/flockshub/plugins/tools/api/sangfor_af_v8_0_48/sangfor_af.handler.py delete mode 100644 .flocks/flockshub/plugins/tools/api/sangfor_af_v8_0_48/sangfor_af_v48_auth.yaml delete mode 100644 .flocks/flockshub/plugins/tools/api/sangfor_af_v8_0_48/sangfor_af_v48_network.yaml delete mode 100644 .flocks/flockshub/plugins/tools/api/sangfor_af_v8_0_48/sangfor_af_v48_objects.yaml delete mode 100644 .flocks/flockshub/plugins/tools/api/sangfor_af_v8_0_48/sangfor_af_v48_ops.yaml delete mode 100644 .flocks/flockshub/plugins/tools/api/sangfor_af_v8_0_48/sangfor_af_v48_status.yaml delete mode 100644 .flocks/flockshub/plugins/tools/api/sangfor_af_v8_0_48/sangfor_af_v48_system.yaml delete mode 100644 .flocks/flockshub/plugins/tools/api/sangfor_af_v8_0_85/_provider.yaml delete mode 100644 .flocks/flockshub/plugins/tools/api/sangfor_af_v8_0_85/_test.yaml delete mode 100644 .flocks/flockshub/plugins/tools/api/sangfor_af_v8_0_85/sangfor_af.handler.py delete mode 100644 .flocks/flockshub/plugins/tools/api/sangfor_af_v8_0_85/sangfor_af_v85_auth.yaml delete mode 100644 .flocks/flockshub/plugins/tools/api/sangfor_af_v8_0_85/sangfor_af_v85_monitor.yaml delete mode 100644 .flocks/flockshub/plugins/tools/api/sangfor_af_v8_0_85/sangfor_af_v85_network.yaml delete mode 100644 .flocks/flockshub/plugins/tools/api/sangfor_af_v8_0_85/sangfor_af_v85_objects.yaml delete mode 100644 .flocks/flockshub/plugins/tools/api/sangfor_af_v8_0_85/sangfor_af_v85_ops.yaml delete mode 100644 .flocks/flockshub/plugins/tools/api/sangfor_af_v8_0_85/sangfor_af_v85_status.yaml delete mode 100644 .flocks/flockshub/plugins/tools/api/sangfor_af_v8_0_85/sangfor_af_v85_system.yaml create mode 100644 tests/tool/test_device_store_service_id.py diff --git a/.flocks/flockshub/plugins/tools/api/onesig_v2_5_3_D20250710/_provider.yaml b/.flocks/flockshub/plugins/tools/api/onesig_v2_5_3_D20250710/_provider.yaml deleted file mode 100644 index 092522873..000000000 --- a/.flocks/flockshub/plugins/tools/api/onesig_v2_5_3_D20250710/_provider.yaml +++ /dev/null @@ -1,158 +0,0 @@ -name: onesig_v2_5_3_D20250710 -service_id: onesig_v2_5_3_D20250710_api -version: "2.5.3 D20250710" -description: > - OneSIG (Secure Internet Gateway) Web API service — *older v2.5 firmware* - variant. Authentication still uses cookie session, but the login endpoint - receives the **plaintext** password directly (no `GET /v3/pubkey` and no - RSA-OAEP encryption on `POST /v3/login`). All other behaviour mirrors the - standard `onesig` plugin: cookie persistence, captcha / TOTP fallback, - session auto-relogin, and RSA-OAEP encryption of sensitive write fields - (change_password, user_create / user_delete, audit-log purge, interface - password, device upgrade password, …). - - Configure host base URL, username and password separately in the credentials - form. The optional captcha / TOTP fields are only required when the device - has captcha or two-factor authentication enabled. -description_cn: > - OneSIG(安全互联网网关)Web API 服务 —— *老版本 v2.5 固件* 变体。认证仍走 - Cookie 会话,但登录接口直接发送**明文**密码(不再调 `GET /v3/pubkey`,不对 - `POST /v3/login` 的 password 字段做 RSA-OAEP 加密)。其余行为与标准 `onesig` - 插件保持一致:Cookie 持久化、验证码 / TOTP 兜底、会话自动重登,以及对*非登录* - 敏感写字段(改密、删用户、删审计、接口启停、设备升级口令等)的 RSA-OAEP 加密。 - - 配置页需分别填写 Base URL、用户名、密码;当设备开启图形验证码或 TOTP 时, - 对应字段可在调用 `onesig_v2_5_3_D20250710_login` 时按需传入。 -auth: - type: custom - secret: onesig_v2_5_3_D20250710_password -credential_fields: - - key: base_url - label: Base URL - storage: config - config_key: base_url - input_type: url - required: true - placeholder: "https://device.example.com" - - key: api_prefix - label: API Prefix - storage: config - config_key: api_prefix - input_type: text - required: false - default: "" - placeholder: "留空(默认)或 /api" - - key: username - label: Username - storage: config - config_key: username - input_type: text - required: true - - key: password - label: Password (plaintext on login) - storage: secret - config_key: password - secret_id: onesig_v2_5_3_D20250710_password - input_type: password - required: true - - key: oaep_hash - label: RSA-OAEP Hash (sensitive write fields only) - storage: config - config_key: oaep_hash - input_type: text - required: false - default: "sha1" - placeholder: "sha1 或 sha256" -defaults: - api_prefix: "" - oaep_hash: "sha1" - timeout: 60 - category: custom - product_version: "2.5.3 D20250710" -notes: | - OneSIG v2.5 老版本(onesig_v2_5_3_D20250710)使用 Cookie 会话鉴权 + 明文密码登录。 - - 常见配置示例: - - api_services.onesig_v2_5_3_D20250710_api.base_url = "https://device.example.com" - - api_services.onesig_v2_5_3_D20250710_api.api_prefix = "" # 大部分 v2.5.x 部署留空;reverse-proxy 部署才填 "/api" - - api_services.onesig_v2_5_3_D20250710_api.username = "admin" - - api_services.onesig_v2_5_3_D20250710_api.password = "{secret:onesig_v2_5_3_D20250710_password}" - - api_services.onesig_v2_5_3_D20250710_api.oaep_hash = "sha1" # 仅用于「敏感写字段」加密;登录与该项无关 - - 与新版 `onesig` 插件的差异(仅一处): - - 登录链路:本插件 **跳过** `GET /v3/pubkey` 与 RSA-OAEP 加密, - `POST /v3/login` 的 body 形如: - - { "username": "admin", "password": "<明文密码>", "captcha"?: "...", "checksum"?: "..." } - - 老固件早期发布版本只接受明文密码,发送 RSA 密文会被解析成乱码并返回 - `responseCode=1009 / 1017`。 - - 其他链路与新版完全一致(已沿用同一份 handler 代码): - - Cookie 会话维持 + `.secret.json` 持久化(key 形如 - `onesig_v2_5_3_D20250710_session_cookie__`); - - 401 / responseCode 1019..1022 自动 force-relogin; - - 改密、删用户、清审计、接口启停、设备升级 等接口里的 `password` / - `oldPassword` / `newPassword` / `dupPassword` 仍由处理器在发送前 - 用最新公钥(每次重新拉 `/v3/pubkey`)做 RSA-OAEP 加密;调用方继续 - 传入**明文**即可,请勿手动加密。 - - `api_prefix` 选取建议(与新版相同): - - 默认值 `""`(空)。绝大多数 OneSIG v2.5.x 设备 nginx 已经把 `/v3/` - 直接路由到后端,`https://host/v3/captcha` 直接返回 JSON。 - - 如果设备前面挂了反向代理(路径是 `https://host/api/v3/...`),把 - `api_prefix` 显式改成 `"/api"`。 - - 看到日志 `HTTP 404` 几乎都是 `api_prefix` 没对齐:把当前值取反 - (空 ↔ "/api")再试。 - - `verify_ssl` 由表单底部「SSL 验证」开关控制,按以下优先级取值: - 1. `verify_ssl` (主键) - 2. `ssl_verify` (兼容别名) - 3. `verifySsl` (历史 camelCase 别名) - 4. `custom_settings.verify_ssl` (WebUI 表单写入位置) - 5. 环境变量 `ONESIG_V2_5_3_D20250710_VERIFY_SSL` (本插件专用) - 6. 环境变量 `ONESIG_VERIFY_SSL` (与新版插件共用兜底) - 7. 兜底默认值 `False`(**默认关闭证书验证**) - - ⚠️ 安全提示:本插件登录阶段以**明文**形式下发密码,请仅在「内网受控网络 - + HTTPS + verify_ssl=true」的部署上启用,避免凭据泄露。 - - Cookie 会话持久化(`persist_cookies`): - - 处理器会在登录成功后把 aiohttp CookieJar 序列化成 JSON 写入 - `~/.flocks/config/.secret.json`(与口令同居一份 0600 权限文件), - key 形如 `onesig_v2_5_3_D20250710_session_cookie__`。 - - 进程重启时若该条 secret 还在且至少有一个 cookie 未过期,会直接灌回 - jar 并跳过 captcha→/v3/login→/v3/account 整条链路;如果设备已经 - 吊销该 cookie,首次业务请求拿到 401 / responseCode 1019..1022 时 - 会触发自动重登一次(与原行为一致)。 - - `logout` 动作会同步删除磁盘上的 cookie;`close` 不会删(用于优雅 - 关停时保留持久化数据)。 - - 字段优先级: - 1. `persist_cookies` - 2. `persistCookies` - 3. `custom_settings.persist_cookies` - 4. 环境变量 `ONESIG_V2_5_3_D20250710_PERSIST_COOKIES` - 5. 环境变量 `ONESIG_PERSIST_COOKIES` - 6. 兜底默认值 `True`(**默认开启持久化**) - - 说明: - - 文档接口路径形如 `/v3/...`;处理器在拼接 URL 时会自动加上 `api_prefix`, - 请按部署环境调整。 - - 当设备启用图形验证码或 TOTP 时,可在 `onesig_v2_5_3_D20250710_login` 工具的 - `login` 动作中传入 `captcha` / `totp` 参数;TOTP 一次性口令本身不加密。 - - 处理器在 401 / responseCode 1019..1022 等会话失效场景自动重新登录并重试一次; - 也可以手动调用 `onesig_v2_5_3_D20250710_login` 的 `login` / `logout` 动作管理会话。 - - 敏感字段加密(与新版 onesig 完全一致,与登录链路*独立*): - - 改密、删除用户、清空审计、接口启停、设备升级等接口的 `password` 字段 - 仍由处理器自动 RSA-OAEP 加密(每次拉最新 `/v3/pubkey`,对齐前端 - `clearRSACache`)。 - - 哈希参数由 `oaep_hash` 控制,默认 `sha1`;如改密返回 `1009/1017` - 但口令正确,请尝试切换为 `sha256`: - api_services.onesig_v2_5_3_D20250710_api.oaep_hash = "sha256" - - multipart 上传: - - 多个 `导入 / 上传 / 升级` 类接口(asset_import、tls_cert_create、 - system_upgrade、device_upgrade_upload、basic_information_import、 - basic_license_upload、backup_import 等)会以 `multipart/form-data` - 提交本地文件,调用方需提供 `file_path` 指向本地绝对路径。 diff --git a/.flocks/flockshub/plugins/tools/api/onesig_v2_5_3_D20250710/_test.yaml b/.flocks/flockshub/plugins/tools/api/onesig_v2_5_3_D20250710/_test.yaml deleted file mode 100644 index 64a97abee..000000000 --- a/.flocks/flockshub/plugins/tools/api/onesig_v2_5_3_D20250710/_test.yaml +++ /dev/null @@ -1,116 +0,0 @@ -schema_version: 1 -provider: onesig_v2_5_3_D20250710_api - -# Service-level connectivity probe. -# `basic_version` is a lightweight GET that verifies the device is reachable -# and the session cookie / credentials are valid, without side-effects. -connectivity: - tool: onesig_v2_5_3_D20250710_device - params: - action: basic_version - -# Tool-level test samples shown in the WebUI ToolDetailDrawer drop-down. -# `label` is the default (English) display string; `label_cn` is the -# optional Chinese override picked when the WebUI runs in zh-CN. -fixtures: - onesig_v2_5_3_D20250710_login: - - label: "Get current account info" - label_cn: "获取当前登录账户信息" - tags: [smoke] - params: - action: get_account - assert: - success: true - - - label: "Get product news" - label_cn: "获取产品公告" - tags: [smoke] - params: - action: get_product_news - - onesig_v2_5_3_D20250710_monitoring: - - label: "List threat type options" - label_cn: "获取威胁类型枚举" - tags: [smoke] - params: - action: common_threat_type_list - assert: - success: true - - - label: "Get dashboard status" - label_cn: "获取仪表板状态" - tags: [smoke] - params: - action: dashboard_status - - - label: "Get device platform status" - label_cn: "获取设备平台状态" - tags: [smoke] - params: - action: device_platform_status - - onesig_v2_5_3_D20250710_strategy: - - label: "Get blacklist location options" - label_cn: "获取黑名单地理位置枚举" - tags: [smoke] - params: - action: blacklist_location_options - assert: - success: true - - - label: "List whitelist entries (page 1)" - label_cn: "查询全局白名单列表(第 1 页)" - tags: [smoke] - params: - action: whitelist_list - - onesig_v2_5_3_D20250710_assets: - - label: "Get asset group tree" - label_cn: "获取资产分组树" - tags: [smoke] - params: - action: common_asset_group_tree - assert: - success: true - - - label: "List assets (page 1)" - label_cn: "查询资产列表(第 1 页)" - tags: [smoke] - params: - action: asset_list - - onesig_v2_5_3_D20250710_device: - - label: "Get device version" - label_cn: "获取设备版本信息" - tags: [smoke] - params: - action: basic_version - assert: - success: true - - - label: "Get license info" - label_cn: "获取许可证信息" - tags: [smoke] - params: - action: basic_license_get - - - label: "Get device network status" - label_cn: "获取设备网络状态" - tags: [smoke] - params: - action: device_network_status - - onesig_v2_5_3_D20250710_helper: - - label: "Get product version" - label_cn: "获取产品版本" - tags: [smoke] - params: - action: product_version - assert: - success: true - - - label: "List documents (page 1)" - label_cn: "获取文档列表(第 1 页)" - tags: [smoke] - params: - action: document_list diff --git a/.flocks/flockshub/plugins/tools/api/onesig_v2_5_3_D20250710/onesig_v2_5_3_D20250710.handler.py b/.flocks/flockshub/plugins/tools/api/onesig_v2_5_3_D20250710/onesig_v2_5_3_D20250710.handler.py deleted file mode 100644 index b8c95f151..000000000 --- a/.flocks/flockshub/plugins/tools/api/onesig_v2_5_3_D20250710/onesig_v2_5_3_D20250710.handler.py +++ /dev/null @@ -1,2270 +0,0 @@ -from __future__ import annotations - -import asyncio -import base64 -import hashlib -import json -import os -import ssl -import time -from email.utils import parsedate_to_datetime -from http.cookies import Morsel, SimpleCookie -from typing import Any, Optional - -import aiohttp -from yarl import URL - -from flocks.config.config_writer import ConfigWriter -from flocks.tool.registry import ToolContext, ToolResult - - -SERVICE_ID = "onesig_v2_5_3_D20250710_api" - -# OneSIG v2.5.x 老版本(``onesig_v2_5_3_D20250710``)设备直接监听 ``/v3/...``,没有 -# 任何额外前缀;与新版插件对齐,``api_prefix`` 留空即可。需要前缀的部署在 UI -# 上把 ``api_prefix`` 设成 ``"/api"`` 等值即可覆盖。 -DEFAULT_API_PREFIX = "" -# OAEP 哈希仅用于*非登录*的敏感字段加密(改密、删用户、删审计日志等), -# 登录阶段已切换为明文密码。保留该配置项是为了与新版 onesig 共享同一份 -# encrypt_fields 写操作链路。 -DEFAULT_OAEP_HASH = "sha1" -DEFAULT_TIMEOUT = 60 -DEFAULT_VERIFY_SSL = False -DEFAULT_PERSIST_COOKIES = True - -# Bumped whenever the on-disk shape under -# ``onesig_v2_5_3_D20250710_session_cookie__*`` changes in an incompatible way; -# older snapshots are silently discarded. Prefix is intentionally distinct -# from the encrypted-login plugin so the two coexist without clobbering -# each other's persisted cookie jars. -_COOKIE_SNAPSHOT_VERSION = 1 -_COOKIE_SECRET_PREFIX = "onesig_v2_5_3_D20250710_session_cookie__" - -_RESPONSE_CODE_OK = 0 -_RESPONSE_CODE_TOTP_REQUIRED = 1012 -_RESPONSE_CODE_DEFAULT_PWD = 1010 -_RESPONSE_CODE_PWD_EXPIRED = 1011 - -_SESSION_EXPIRED_RESPONSE_CODES = frozenset({1019, 1020, 1021, 1022}) -_SESSION_EXPIRED_HTTP_STATUSES = frozenset({401, 403}) - - -def _get_secret_manager() -> Any: - from flocks.security import get_secret_manager - - return get_secret_manager() - - -def _resolve_ref(value: Any) -> Optional[str]: - if value is None: - return None - if not isinstance(value, str): - return str(value) - if value.startswith("{secret:") and value.endswith("}"): - return _get_secret_manager().get(value[len("{secret:") : -1]) - if value.startswith("{env:") and value.endswith("}"): - return os.getenv(value[len("{env:") : -1]) - return value - - -def _service_config() -> dict[str, Any]: - raw = ConfigWriter.get_api_service_raw(SERVICE_ID) - return raw if isinstance(raw, dict) else {} - - -def _coerce_bool(value: Any, default: bool = True) -> bool: - if isinstance(value, bool): - return value - if value is None: - return default - if isinstance(value, (int, float)): - return bool(value) - text = str(value).strip().lower() - if text in {"1", "true", "yes", "y", "on"}: - return True - if text in {"0", "false", "no", "n", "off"}: - return False - return default - - -def _resolve_verify_ssl(raw: dict[str, Any]) -> bool: - """Resolve the SSL verification toggle from a service-config dict. - - Mirrors the PR #193 cross-handler convention so that the WebUI's - "SSL verify" switch (which writes to ``custom_settings.verify_ssl``) - drives onesec / ngtip / qingteng / onesig **uniformly**. Lookup order: - - 1. ``raw["verify_ssl"]`` - canonical - 2. ``raw["ssl_verify"]`` - snake_case alias (PR #193) - 3. ``raw["verifySsl"]`` - camelCase alias (onesig legacy) - 4. ``raw["custom_settings"]["verify_ssl"]`` - WebUI generic toggle - 5. ``ONESIG_VERIFY_SSL`` env var - onesig-specific override - 6. fallback to ``DEFAULT_VERIFY_SSL`` - default ``False`` (parity - with onesec / ngtip / qingteng - after PR #193): OneSIG is - almost always deployed as a - private gateway with self- - signed certs, so the open- - box behaviour is to skip - validation. Flip the toggle - on to enforce certificate - checks for public / signed - deployments. - - String values are normalised through ``_coerce_bool`` so that any of - ``"true"/"false"/"1"/"0"/"yes"/"no"/"on"/"off"`` work consistently with - the rest of the codebase. - """ - candidates: list[Any] = [ - raw.get("verify_ssl"), - raw.get("ssl_verify"), - raw.get("verifySsl"), - ] - custom = raw.get("custom_settings") - if isinstance(custom, dict): - candidates.append(custom.get("verify_ssl")) - # Prefer the plaintext-login-specific env var, fall back to the shared - # ``ONESIG_VERIFY_SSL`` so a single CLI/container env can drive both - # plugins side-by-side without re-declaring the toggle twice. - candidates.append(os.getenv("ONESIG_V2_5_3_D20250710_VERIFY_SSL")) - candidates.append(os.getenv("ONESIG_VERIFY_SSL")) - - for value in candidates: - if value is None: - continue - return _coerce_bool(value, default=DEFAULT_VERIFY_SSL) - return DEFAULT_VERIFY_SSL - - -def _resolve_persist_cookies(raw: dict[str, Any]) -> bool: - """Resolve the cookie-persistence toggle. - - OneSIG sessions are cookie-based; persisting the jar to ``.secret.json`` - lets a flocks restart skip the captcha → pubkey → /v3/login → /v3/account - chain (~4 RTT) and reuse the still-valid cookie until the device returns - 401 / responseCode 1019..1022, at which point the existing auto-relogin - path takes over. - - Same shape as :func:`_resolve_verify_ssl`: - - 1. ``raw["persist_cookies"]`` - canonical - 2. ``raw["persistCookies"]`` - camelCase alias - 3. ``raw["custom_settings"]["persist_cookies"]`` - WebUI generic toggle - 4. ``ONESIG_PERSIST_COOKIES`` env var - CLI / container - 5. fallback to ``DEFAULT_PERSIST_COOKIES`` - ``True`` - """ - candidates: list[Any] = [ - raw.get("persist_cookies"), - raw.get("persistCookies"), - ] - custom = raw.get("custom_settings") - if isinstance(custom, dict): - candidates.append(custom.get("persist_cookies")) - candidates.append(os.getenv("ONESIG_V2_5_3_D20250710_PERSIST_COOKIES")) - candidates.append(os.getenv("ONESIG_PERSIST_COOKIES")) - - for value in candidates: - if value is None: - continue - return _coerce_bool(value, default=DEFAULT_PERSIST_COOKIES) - return DEFAULT_PERSIST_COOKIES - - -class OneSIGRuntimeConfig: - """Resolved runtime configuration for a single OneSIG service entry.""" - - def __init__( - self, - *, - base_url: str, - api_prefix: str, - username: str, - password: str, - oaep_hash: str, - verify_ssl: bool, - timeout: int, - persist_cookies: bool = DEFAULT_PERSIST_COOKIES, - ) -> None: - self.base_url = base_url - self.api_prefix = api_prefix - self.username = username - self.password = password - self.oaep_hash = oaep_hash - self.verify_ssl = verify_ssl - self.timeout = timeout - self.persist_cookies = persist_cookies - - @property - def session_key(self) -> str: - return f"{self.base_url}|{self.username}" - - def build_url(self, path: str) -> str: - path = path if path.startswith("/") else "/" + path - prefix = self.api_prefix.rstrip("/") - return f"{self.base_url}{prefix}{path}" - - -def _resolve_runtime_config() -> OneSIGRuntimeConfig: - raw = _service_config() - base_url = ( - _resolve_ref(raw.get("base_url")) - or _resolve_ref(raw.get("baseUrl")) - or os.getenv("ONESIG_V2_5_3_D20250710_BASE_URL") - or os.getenv("ONESIG_BASE_URL") - ) - if not base_url: - raise ValueError( - "OneSIG (older v2.5) base_url not configured. Set " - "api_services.onesig_v2_5_3_D20250710_api.base_url or " - "ONESIG_V2_5_3_D20250710_BASE_URL." - ) - base_url = base_url.rstrip("/") - - api_prefix = ( - _resolve_ref(raw.get("api_prefix")) - or _resolve_ref(raw.get("apiPrefix")) - or os.getenv("ONESIG_V2_5_3_D20250710_API_PREFIX") - or os.getenv("ONESIG_API_PREFIX") - or DEFAULT_API_PREFIX - ) - if api_prefix and not api_prefix.startswith("/"): - api_prefix = "/" + api_prefix - api_prefix = api_prefix.rstrip("/") - - username = ( - _resolve_ref(raw.get("username")) - or _resolve_ref(raw.get("user")) - or os.getenv("ONESIG_V2_5_3_D20250710_USERNAME") - or os.getenv("ONESIG_USERNAME") - ) - if not username: - raise ValueError( - "OneSIG (older v2.5) username not configured. Set " - "api_services.onesig_v2_5_3_D20250710_api.username or " - "ONESIG_V2_5_3_D20250710_USERNAME." - ) - - secret_manager = _get_secret_manager() - password = ( - _resolve_ref(raw.get("password")) - or secret_manager.get("onesig_v2_5_3_D20250710_password") - or secret_manager.get(f"{SERVICE_ID}_password") - or os.getenv("ONESIG_V2_5_3_D20250710_PASSWORD") - or os.getenv("ONESIG_PASSWORD") - ) - if not password: - raise ValueError( - "OneSIG (older v2.5) password not configured. Save it as the " - "onesig_v2_5_3_D20250710_password secret or set ONESIG_V2_5_3_D20250710_PASSWORD." - ) - - # OAEP hash 只对 *非登录* 的敏感字段(改密 / 删用户 / 删审计 / 接口启停 / - # 升级口令)有效;登录阶段已改为明文,详见 ``OneSIGSession.login``。 - oaep_hash = ( - _resolve_ref(raw.get("oaep_hash")) - or _resolve_ref(raw.get("oaepHash")) - or os.getenv("ONESIG_V2_5_3_D20250710_OAEP_HASH") - or os.getenv("ONESIG_OAEP_HASH") - or DEFAULT_OAEP_HASH - ).lower() - if oaep_hash not in {"sha1", "sha256"}: - oaep_hash = DEFAULT_OAEP_HASH - - verify_ssl = _resolve_verify_ssl(raw) - persist_cookies = _resolve_persist_cookies(raw) - - timeout_raw = raw.get("timeout", DEFAULT_TIMEOUT) - try: - timeout = int(timeout_raw) - except (TypeError, ValueError): - timeout = DEFAULT_TIMEOUT - - return OneSIGRuntimeConfig( - base_url=base_url, - api_prefix=api_prefix, - username=username, - password=password, - oaep_hash=oaep_hash, - verify_ssl=verify_ssl, - timeout=timeout, - persist_cookies=persist_cookies, - ) - - -def _rsa_oaep_encrypt(pem_pubkey: str, plain: str, oaep_hash: str) -> str: - """Encrypt `plain` with RSA-OAEP using the provided PEM public key.""" - from cryptography.hazmat.backends import default_backend - from cryptography.hazmat.primitives import hashes, serialization - from cryptography.hazmat.primitives.asymmetric import padding - - hash_alg = hashes.SHA1() if oaep_hash == "sha1" else hashes.SHA256() - pub = serialization.load_pem_public_key( - pem_pubkey.encode("utf-8"), backend=default_backend() - ) - cipher = pub.encrypt( - plain.encode("utf-8"), - padding.OAEP( - mgf=padding.MGF1(algorithm=hash_alg), - algorithm=hash_alg, - label=None, - ), - ) - return base64.b64encode(cipher).decode("ascii") - - -def _ssl_context(verify_ssl: bool) -> Any: - if verify_ssl: - return None - ctx = ssl.create_default_context() - ctx.check_hostname = False - ctx.verify_mode = ssl.CERT_NONE - return ctx - - -# --------------------------------------------------------------------------- -# Cookie persistence (.secret.json round-trip) -# --------------------------------------------------------------------------- -# OneSIG sessions are cookie-based and the device hands out a session cookie -# only after the captcha → pubkey → /v3/login dance. To avoid re-running that -# 4-RTT dance after every flocks restart we serialise the cookie jar to the -# existing ``~/.flocks/config/.secret.json`` (mode 0600) under a per-device -# secret_id, then re-hydrate it when a fresh ``OneSIGSession`` is constructed. -# -# Format choices intentionally avoid ``aiohttp.CookieJar.save/load`` (pickle): -# - JSON keeps the file human-readable for ops debugging. -# - JSON is immune to deserialisation-as-RCE if .secret.json ever leaks -# write privileges. -# - The shape is versioned so future changes can simply bump -# ``_COOKIE_SNAPSHOT_VERSION`` and discard older snapshots. - - -def _cookie_secret_id(base_url: str, username: str) -> str: - """Stable, filesystem-/JSON-safe secret id for a (device, account) pair. - - The pair is hashed (sha1, truncated) so URL/IP/port special chars never - leak into the secret_id namespace, and so the same secret slot is reused - across reconfigurations of the same logical session. - """ - digest = hashlib.sha1( - f"{base_url}|{username}".encode("utf-8") - ).hexdigest()[:12] - return f"{_COOKIE_SECRET_PREFIX}{digest}" - - -def _cookies_to_snapshot(jar: aiohttp.CookieJar) -> list[dict[str, Any]]: - """Snapshot every Morsel in ``jar`` into a list of plain JSON-able dicts.""" - rows: list[dict[str, Any]] = [] - for morsel in jar: # iterates over http.cookies.Morsel - rows.append( - { - "name": morsel.key, - "value": morsel.value, - "domain": morsel["domain"] or "", - "path": morsel["path"] or "/", - "expires": morsel["expires"] or "", - "secure": bool(morsel["secure"]), - "httponly": bool(morsel["httponly"]), - } - ) - return rows - - -def _is_cookie_expired(expires: str, *, now: Optional[float] = None) -> bool: - """Best-effort check on RFC 1123 ``Expires`` string. Unparseable → False - (defer to aiohttp's own jar logic; we don't want to silently drop cookies - just because the device returned a non-standard expires format).""" - if not expires: - return False - try: - exp_dt = parsedate_to_datetime(expires) - except (TypeError, ValueError): - return False - if exp_dt is None: - return False - ts = exp_dt.timestamp() - return ts <= (now if now is not None else time.time()) - - -def _snapshot_into_jar( - jar: aiohttp.CookieJar, - rows: list[dict[str, Any]], - base_url: str, -) -> int: - """Inflate ``rows`` (output of :func:`_cookies_to_snapshot`) back into - an aiohttp jar. Already-expired cookies are silently dropped. Returns - the number of cookies actually injected.""" - if not rows: - return 0 - sc: SimpleCookie = SimpleCookie() - injected = 0 - for row in rows: - name = row.get("name") or "" - if not name: - continue - if _is_cookie_expired(row.get("expires") or ""): - continue - value = row.get("value") or "" - sc[name] = value - m: Morsel = sc[name] - if row.get("domain"): - m["domain"] = row["domain"] - if row.get("path"): - m["path"] = row["path"] - if row.get("expires"): - m["expires"] = row["expires"] - if row.get("secure"): - m["secure"] = True - if row.get("httponly"): - m["httponly"] = True - injected += 1 - if injected == 0: - return 0 - # response_url seeds the jar's domain/path bookkeeping for any cookie - # that didn't carry an explicit Domain attribute (typical for OneSIG, - # which scopes cookies to the device host). - try: - response_url = URL(base_url) - except Exception: - response_url = URL("http://localhost") - jar.update_cookies(sc, response_url=response_url) - return injected - - -def _load_cookie_snapshot(secret_id: str) -> Optional[dict[str, Any]]: - """Pull a previously persisted snapshot dict, dropping malformed or - fully-expired payloads. Returns ``None`` when there is nothing usable. - - Failure modes (corrupt JSON, version mismatch, all cookies expired) are - swallowed so a poisoned secret never breaks the calling tool — the - handler will simply fall through to a fresh login.""" - raw = _get_secret_manager().get(secret_id) - if not raw: - return None - try: - data = json.loads(raw) - except (json.JSONDecodeError, TypeError): - return None - if not isinstance(data, dict): - return None - if data.get("version") != _COOKIE_SNAPSHOT_VERSION: - return None - cookies = data.get("cookies") - if not isinstance(cookies, list): - return None - fresh = [ - c for c in cookies - if isinstance(c, dict) - and c.get("name") - and not _is_cookie_expired(c.get("expires") or "") - ] - if not fresh: - return None - data["cookies"] = fresh - return data - - -def _save_cookie_snapshot(secret_id: str, snapshot: dict[str, Any]) -> bool: - """Persist a snapshot. Best-effort: returns False on I/O failure but - never raises — cookie persistence is an optimisation, not a hard - requirement of the request path.""" - try: - _get_secret_manager().set( - secret_id, json.dumps(snapshot, separators=(",", ":")) - ) - return True - except Exception: - return False - - -def _delete_cookie_snapshot(secret_id: str) -> bool: - try: - return _get_secret_manager().delete(secret_id) - except Exception: - return False - - -class OneSIGSession: - """Cookie-based session for a OneSIG device. - - A single instance owns an aiohttp ClientSession with its own cookie jar and - handles the captcha → pubkey → login flow. Concurrent callers reuse the - same logged-in session and share the auto-relogin lock. - """ - - def __init__(self, config: OneSIGRuntimeConfig) -> None: - self.config = config - self._session: Optional[aiohttp.ClientSession] = None - self._logged_in = False - self._login_lock = asyncio.Lock() - # Cookies waiting to be injected into the jar at the next - # ``_ensure_session`` call. Populated synchronously from - # ``.secret.json`` here so that tests / dispatchers can observe - # ``_logged_in`` immediately after construction. - self._pending_cookies: Optional[list[dict[str, Any]]] = None - self._cookies_loaded = False - - if self.config.persist_cookies: - snapshot = _load_cookie_snapshot(self._cookie_secret_id) - cookies = (snapshot or {}).get("cookies") if snapshot else None - if cookies: - self._pending_cookies = cookies - # Trust the persisted cookie. If the device has rotated / - # invalidated it the existing 401-or-1019..1022 auto-relogin - # path in ``request()`` will repair the session on first - # business call. No extra RTT lost vs. unconditional login. - self._logged_in = True - - @property - def _cookie_secret_id(self) -> str: - return _cookie_secret_id(self.config.base_url, self.config.username) - - async def _ensure_session(self) -> aiohttp.ClientSession: - if self._session is None or self._session.closed: - jar = aiohttp.CookieJar(unsafe=True) - if self._pending_cookies and not self._cookies_loaded: - try: - _snapshot_into_jar( - jar, self._pending_cookies, self.config.base_url - ) - except Exception: - # Bad on-disk snapshot must not block the request path: - # forget the persisted cookies and force a clean login. - self._logged_in = False - self._cookies_loaded = True - self._pending_cookies = None - self._session = aiohttp.ClientSession(cookie_jar=jar) - return self._session - - def _persist_cookies(self) -> None: - """Snapshot the current jar to ``.secret.json``. Called after every - successful login / re-login. No-op when persistence is disabled or - the jar is empty. - - Best-effort: any exception (missing ``cookie_jar`` on a swapped-in - test double, FS error, JSON encode bug, …) is swallowed so the - request path is never blocked by a persistence side-effect.""" - if not self.config.persist_cookies: - return - if self._session is None or self._session.closed: - return - try: - jar = getattr(self._session, "cookie_jar", None) - if jar is None: - return - rows = _cookies_to_snapshot(jar) - if not rows: - return - snapshot = { - "version": _COOKIE_SNAPSHOT_VERSION, - "session_key": self.config.session_key, - "saved_at": int(time.time()), - "cookies": rows, - } - _save_cookie_snapshot(self._cookie_secret_id, snapshot) - except Exception: - return - - def _drop_persisted_cookies(self) -> None: - if self.config.persist_cookies: - _delete_cookie_snapshot(self._cookie_secret_id) - - async def close(self) -> None: - # ``close`` is for graceful shutdown (e.g. process exit). It does NOT - # drop the persisted cookie — that's the whole point of persistence. - # Use ``logout()`` for an explicit invalidation instead. - if self._session is not None and not self._session.closed: - await self._session.close() - self._session = None - self._logged_in = False - - async def login( - self, - *, - captcha: Optional[str] = None, - totp: Optional[str] = None, - force: bool = False, - ) -> dict[str, Any]: - """Run the *plaintext-password* login flow for OneSIG v2.5 older devices. - - Sequence: ``GET /v3/captcha`` → ``POST /v3/login`` (with plaintext - ``password``) → optional ``POST /v3/login/totp``. The - ``GET /v3/pubkey`` + RSA-OAEP encryption step from the standard - ``onesig`` plugin is intentionally omitted here — older firmware - rejects RSA ciphertext on the login endpoint and only accepts the - raw password. - - Returns the final account info on success. Raises ValueError on - failure with a human-readable explanation. - """ - async with self._login_lock: - if self._logged_in and not force: - account = await self._raw_request_json("GET", "/v3/account") - if isinstance(account, dict) and account.get("responseCode") == _RESPONSE_CODE_OK: - return account.get("data", {}) or {} - self._logged_in = False - - captcha_info = await self._raw_request_json("GET", "/v3/captcha") - captcha_data = ( - captcha_info.get("data", {}) if isinstance(captcha_info, dict) else {} - ) - enable_captcha = bool(captcha_data.get("enableCaptcha")) - enable_totp_inline = bool(captcha_data.get("enableTotp")) - if enable_captcha and not captcha: - raise ValueError( - "OneSIG 设备已启用图形验证码,请通过 onesig_v2_5_3_D20250710_login(action='login', captcha='...') 提供。" - ) - if enable_totp_inline and not totp: - # Inline mode: the captcha endpoint already advertises - # `enableTotp=true`, meaning the device wants the TOTP code on - # the same login form (the `checksum` field) rather than the - # post-login QR-scan flow. Refuse early so the caller does not - # see an opaque 1017 / "checksum 不能为空" later. - raise ValueError( - "OneSIG 设备已启用 inline TOTP(同屏「用户口令」),请通过 " - "onesig_v2_5_3_D20250710_login(action='login', totp='...') 提供动态口令或恢复码。" - ) - - # --------------------------------------------------------- - # OneSIG v2.5 老版本走 *明文* 密码登录链路 —— - # - # 与新版 ``onesig`` 插件最显著的区别:跳过 ``GET /v3/pubkey`` + - # RSA-OAEP 加密这一对步骤,直接把 ``self.config.password`` - # 的明文塞进 ``payload["password"]`` 发给 ``POST /v3/login``。 - # - # 设计取舍: - # * 老版本设备早期部署(v2.5 之前的中间发布或定制版)只接受 - # 明文密码登录,下发 RSA 密文反而会被解析成乱码导致 - # ``responseCode=1009`` / ``1017``。 - # * 业务路径上的*敏感写字段*(改密、删用户、删审计、接口启停、 - # 设备升级口令等)仍走 ``encrypt_with_pubkey`` / - # ``_encrypt_body_fields``,复用同一份 OAEP 实现。换言之: - # **只有登录阶段是明文**,写操作字段加密保持与新版一致。 - # * 该插件应仅用于内网受控网络中的老固件,避免明文凭证暴露 - # 于未加密信道;HTTPS + 启用 ``verify_ssl`` 是强烈建议项。 - # --------------------------------------------------------- - payload: dict[str, Any] = { - "username": self.config.username, - "password": self.config.password, - } - if enable_captcha and captcha: - payload["captcha"] = captcha - if enable_totp_inline and totp: - payload["checksum"] = totp - - login_resp = await self._raw_request_json( - "POST", "/v3/login", json_body=payload - ) - if not isinstance(login_resp, dict): - raise ValueError(f"登录返回非 JSON:{login_resp!r}") - - response_code = login_resp.get("responseCode") - if response_code == _RESPONSE_CODE_TOTP_REQUIRED: - if not totp: - raise ValueError( - "OneSIG 设备要求扫码 TOTP 二次验证,请在 login 调用中传入 `totp` 参数。" - ) - totp_resp = await self._raw_request_json( - "POST", - "/v3/login/totp", - json_body={"checksum": totp}, - ) - if ( - not isinstance(totp_resp, dict) - or totp_resp.get("responseCode") != _RESPONSE_CODE_OK - ): - raise ValueError( - f"TOTP 二次验证失败:{(totp_resp or {}).get('verboseMsg', totp_resp)}" - ) - elif response_code in (_RESPONSE_CODE_DEFAULT_PWD, _RESPONSE_CODE_PWD_EXPIRED): - raise ValueError( - f"OneSIG 要求修改密码(responseCode={response_code}:{login_resp.get('verboseMsg')})。" - " 请先通过控制台或 `onesig_v2_5_3_D20250710_login(action='change_password', ...)` 修改密码。" - ) - elif response_code != _RESPONSE_CODE_OK: - raise ValueError( - f"OneSIG 登录失败(responseCode={response_code}):{login_resp.get('verboseMsg')}" - ) - - self._logged_in = True - # Persist immediately on successful login (before /v3/account so - # cookie survives even if the verification call fails). Best- - # effort: failures here never break the calling tool. - self._persist_cookies() - account_resp = await self._raw_request_json("GET", "/v3/account") - if ( - isinstance(account_resp, dict) - and account_resp.get("responseCode") == _RESPONSE_CODE_OK - ): - return account_resp.get("data", {}) or {} - return {} - - async def logout(self) -> dict[str, Any]: - try: - resp = await self._raw_request_json("POST", "/v3/logout", json_body={}) - finally: - self._logged_in = False - # Logout invalidates the cookie server-side; remove the local - # snapshot too so the next process doesn't try to reuse a dead - # session and pay an extra round-trip discovering it. - self._drop_persisted_cookies() - if isinstance(resp, dict): - return resp - return {} - - async def _raw_request_json( - self, - method: str, - path: str, - *, - params: Optional[dict[str, Any]] = None, - json_body: Optional[Any] = None, - ) -> Any: - """Issue a raw request and parse the JSON envelope. Used for login/logout/account.""" - session = await self._ensure_session() - url = self.config.build_url(path) - request_params = {"lang": "zh"} - if params: - request_params.update({k: v for k, v in params.items() if v is not None}) - kwargs: dict[str, Any] = { - "params": request_params, - "timeout": aiohttp.ClientTimeout(total=self.config.timeout), - "ssl": _ssl_context(self.config.verify_ssl), - } - if json_body is not None: - kwargs["json"] = json_body - kwargs["headers"] = {"Content-Type": "application/json"} - async with session.request(method.upper(), url, **kwargs) as resp: - text = await resp.text() - try: - parsed = await resp.json(content_type=None) - except Exception: - parsed = None - # ``aiohttp.ClientResponse.json(content_type=None)`` returns - # ``None`` for empty / whitespace-only bodies *without raising*, - # which used to surface as the very confusing - # ``无法从 /v3/pubkey 获取 RSA 公钥:None`` - # 在错误日志里 — 看不到 status / URL,根本没法定位到「``/api`` 前 - # 缀错了导致 404」之类的根因。这里把 ``None`` 也统一塞进 fallback - # 字典,让上层的报错带上 status 和 body 摘要。 - if parsed is None or not isinstance(parsed, (dict, list)): - return { - "_status": resp.status, - "_url": str(resp.url), - "_text": text[:500], - } - return parsed - - async def encrypt_with_pubkey(self, plain: str) -> str: - """Fetch the latest /v3/pubkey and RSA-OAEP encrypt the given plaintext. - - Mirrors the front-end "clearRSACache → fresh pubkey → encrypt" pattern - (`@/util/rsa.js`) used for sensitive write operations. - """ - pubkey_resp = await self._raw_request_json("GET", "/v3/pubkey") - pubkey = (pubkey_resp or {}).get("data", {}).get("pubkey") - if not pubkey: - raise ValueError( - f"无法从 /v3/pubkey 获取 RSA 公钥用于字段加密:{pubkey_resp!r}" - ) - return _rsa_oaep_encrypt(pubkey, plain, self.config.oaep_hash) - - async def request( - self, - method: str, - path: str, - *, - params: Optional[dict[str, Any]] = None, - json_body: Optional[Any] = None, - form_data: Optional["aiohttp.FormData"] = None, - captcha: Optional[str] = None, - totp: Optional[str] = None, - _retry: bool = True, - ) -> tuple[int, dict[str, Any], bytes, str]: - """Issue an authenticated request, auto-relogin on session expiry. - - Pass either ``json_body`` (JSON request) or ``form_data`` - (``multipart/form-data`` upload). Passing both raises ValueError. - - Returns ``(status, json_envelope, body_bytes, content_type)``. When the - response is JSON, ``json_envelope`` contains the parsed payload and - ``body_bytes`` is empty. When the response is binary, ``body_bytes`` - carries the raw bytes for the caller to persist. - """ - if json_body is not None and form_data is not None: - raise ValueError("request() cannot accept both json_body and form_data") - - if not self._logged_in: - await self.login(captcha=captcha, totp=totp) - - session = await self._ensure_session() - url = self.config.build_url(path) - request_params: dict[str, Any] = {"lang": "zh"} - if params: - request_params.update({k: v for k, v in params.items() if v is not None}) - - kwargs: dict[str, Any] = { - "params": request_params, - "timeout": aiohttp.ClientTimeout(total=self.config.timeout), - "ssl": _ssl_context(self.config.verify_ssl), - } - if json_body is not None: - kwargs["json"] = json_body - kwargs["headers"] = {"Content-Type": "application/json"} - elif form_data is not None: - # aiohttp sets Content-Type with the boundary for FormData - # automatically; do not override. - kwargs["data"] = form_data - - async with session.request(method.upper(), url, **kwargs) as resp: - status = resp.status - content_type = resp.headers.get("Content-Type", "") or "" - if "application/json" in content_type: - envelope = await resp.json(content_type=None) - body_bytes = b"" - else: - body_bytes = await resp.read() - envelope = {} - - envelope_dict = envelope if isinstance(envelope, dict) else {"data": envelope} - response_code = envelope_dict.get("responseCode") if envelope_dict else None - session_expired = ( - status in _SESSION_EXPIRED_HTTP_STATUSES - or response_code in _SESSION_EXPIRED_RESPONSE_CODES - ) - if session_expired and _retry: - self._logged_in = False - await self.login(captcha=captcha, totp=totp, force=True) - # form_data is single-shot; caller must rebuild on retry. We can - # only retransmit JSON requests automatically. - if form_data is not None: - return status, envelope_dict, body_bytes, content_type - return await self.request( - method, - path, - params=params, - json_body=json_body, - captcha=captcha, - totp=totp, - _retry=False, - ) - return status, envelope_dict, body_bytes, content_type - - -_SESSIONS: dict[str, OneSIGSession] = {} -_SESSIONS_LOCK = asyncio.Lock() - - -async def _get_session(config: OneSIGRuntimeConfig) -> OneSIGSession: - async with _SESSIONS_LOCK: - sess = _SESSIONS.get(config.session_key) - if sess is None: - sess = OneSIGSession(config) - _SESSIONS[config.session_key] = sess - else: - sess.config = config - return sess - - -# --------------------------------------------------------------------------- -# Action specifications -# --------------------------------------------------------------------------- - - -_RESERVED_PARAM_KEYS = frozenset({"action", "captcha", "totp", "file_path"}) - - -class ActionSpec: - """Declarative spec for a single OneSIG endpoint action.""" - - def __init__( - self, - method: str, - path: str, - *, - body_keys: Optional[list[str]] = None, - query_keys: Optional[list[str]] = None, - passthrough_body: bool = False, - binary: bool = False, - required: Optional[list[str]] = None, - multipart: bool = False, - multipart_file_field: str = "file", - encrypt_fields: Optional[tuple[str, ...]] = None, - ) -> None: - self.method = method.upper() - self.path = path - self.body_keys = body_keys or [] - self.query_keys = query_keys or [] - self.passthrough_body = passthrough_body - self.binary = binary - self.required = required or [] - # multipart=True: send the body as multipart/form-data; the local file - # path comes from the reserved `file_path` param and is uploaded under - # `multipart_file_field` (default `file`, per OneSIG docs). - self.multipart = multipart - self.multipart_file_field = multipart_file_field - # Body fields whose plaintext value must be RSA-OAEP encrypted with the - # current /v3/pubkey before being sent (typical: ("password",) or - # ("password", "dupPassword")). - self.encrypt_fields = tuple(encrypt_fields or ()) - - def build_request( - self, params: dict[str, Any] - ) -> tuple[Optional[dict[str, Any]], Optional[Any]]: - query = {k: params[k] for k in self.query_keys if params.get(k) is not None} - - body: Optional[Any] = None - if self.method == "GET" and not self.multipart: - # OneSIG 的 GET list 接口对 query 里的分页/过滤参数要求很严 - # (e.g. /v3/apikey/list 不带 pageNo/pageSize 直接回 1004)。 - # body_keys 里声明过的字段先入 query;剩余非保留、非已声明的字段 - # 也透传进 query,避免调用方传了 pageNo/severity 这类常见过滤 - # 项被 handler 静默丢掉。 - for key in self.body_keys: - if params.get(key) is not None: - query[key] = params[key] - for k, v in params.items(): - if v is None: - continue - if k in _RESERVED_PARAM_KEYS: - continue - if k in self.query_keys or k in self.body_keys: - continue - query[k] = v - else: - if self.passthrough_body: - body = { - k: v - for k, v in params.items() - if k not in _RESERVED_PARAM_KEYS - and k not in self.query_keys - and v is not None - } - else: - body = {k: params[k] for k in self.body_keys if params.get(k) is not None} - return (query or None), body - - -def _has_value(value: Any) -> bool: - if value is None: - return False - if isinstance(value, str): - return value.strip() != "" - if isinstance(value, (list, tuple, set, dict)): - return len(value) > 0 - return True - - -def _validate_required(spec: ActionSpec, action: str, params: dict[str, Any]) -> Optional[str]: - missing = [k for k in spec.required if not _has_value(params.get(k))] - if missing: - return f"Missing required parameters for {action}: {', '.join(missing)}" - return None - - -# Login / Account / Password ------------------------------------------------ - -LOGIN_ACTION_SPECS: dict[str, ActionSpec] = { - "get_captcha": ActionSpec("GET", "/v3/captcha"), - "get_pubkey": ActionSpec("GET", "/v3/pubkey"), - "get_account": ActionSpec("GET", "/v3/account"), - "get_recovery_code": ActionSpec("GET", "/v3/user/recoveryCode"), - "regenerate_recovery_code": ActionSpec("PUT", "/v3/user/recoveryCode"), - "get_product_news": ActionSpec("GET", "/v3/product/news"), - "mark_product_news_read": ActionSpec( - "PUT", "/v3/product/news", passthrough_body=True - ), -} - -# Monitoring --------------------------------------------------------------- - -MONITORING_ACTION_SPECS: dict[str, ActionSpec] = { - # dashboard - "dashboard_overview": ActionSpec("POST", "/v3/dashboard/overview", passthrough_body=True), - "dashboard_outbound": ActionSpec("POST", "/v3/dashboard/outbound", passthrough_body=True), - "dashboard_inbound": ActionSpec("POST", "/v3/dashboard/inbound", passthrough_body=True), - "dashboard_zeroday": ActionSpec("POST", "/v3/dashboard/zeroday", passthrough_body=True), - "dashboard_status": ActionSpec("GET", "/v3/dashboard/status"), - "dashboard_ioc_type_sum": ActionSpec("GET", "/v3/dashboard/ioctypesum"), - "set_custom_config": ActionSpec("PUT", "/v3/setting/customConfig", passthrough_body=True), - # overview - "common_threat_type_list": ActionSpec("GET", "/v3/common/threatTypeList"), - "overview_event_inbound": ActionSpec("POST", "/v3/overview/eventInbound", passthrough_body=True), - "overview_event_outbound": ActionSpec("POST", "/v3/overview/eventOutbound", passthrough_body=True), - "overview_export_event_inbound": ActionSpec( - "POST", "/v3/overview/exportEventInbound", passthrough_body=True, binary=True - ), - "overview_export_event_outbound": ActionSpec( - "POST", "/v3/overview/exportEventOutbound", passthrough_body=True, binary=True - ), - # OneSIG v2.5.3 实测:文档标 `incIntervalSec` 选填,但服务端实际必填, - # 不带直接回 1004 "请求数据非法"。前端总是从页面 ref 取轮询间隔传入。 - "overview_asset_brief": ActionSpec( - "POST", - "/v3/overview/assetBrief", - passthrough_body=True, - required=["startTime", "endTime", "incIntervalSec"], - ), - "overview_asset_top": ActionSpec("POST", "/v3/overview/assetTop", passthrough_body=True), - # OneSIG v2.5.3 实测:除文档明示的 startTime/endTime 外,`type` 与 - # `pageNo`/`pageSize` 也是必填,不带任何一个均回 1004。 - "overview_event_inbound_agg": ActionSpec( - "POST", - "/v3/overview/eventInboundAgg", - passthrough_body=True, - required=["startTime", "endTime", "type", "pageNo", "pageSize"], - ), - "overview_export_event_inbound_agg": ActionSpec( - "POST", "/v3/overview/exportEventInboundAgg", passthrough_body=True, binary=True - ), - "overview_event_outbound_agg": ActionSpec("POST", "/v3/overview/eventOutboundAgg", passthrough_body=True), - "overview_export_event_outbound_agg": ActionSpec( - "POST", "/v3/overview/exportEventOutboundAgg", passthrough_body=True, binary=True - ), - "overview_event_recent_agg": ActionSpec("POST", "/v3/overview/eventRecentAgg", passthrough_body=True), - # OneSIG v2.5.3 实测:`interval` 文档标选填,实际必填("1 DAY" / "1 HOUR"), - # 否则回 1004。前端按时间窗自动派发 "1 DAY" 或 "1 HOUR"。 - "overview_event_trend": ActionSpec( - "POST", - "/v3/overview/eventTrend", - passthrough_body=True, - required=["startTime", "endTime", "interval"], - ), - "overview_traffic_trend": ActionSpec("POST", "/v3/overview/trafficTrend", passthrough_body=True), - # OneSIG v2.5.3 实测:`incIntervalSec` 文档标选填,实际必填。 - "overview_stat": ActionSpec( - "POST", - "/v3/overview/stat", - passthrough_body=True, - required=["startTime", "endTime", "incIntervalSec"], - ), - "overview_threat_type_proportion": ActionSpec( - "POST", "/v3/overview/threatTypeProportion", passthrough_body=True - ), - "overview_ioc_type_proportion": ActionSpec( - "POST", "/v3/overview/iocTypeProportion", passthrough_body=True - ), - "overview_export_threat_type_proportion": ActionSpec( - "POST", "/v3/overview/exportThreatTypeProportion", passthrough_body=True, binary=True - ), - "overview_export_ioc_type_proportion": ActionSpec( - "POST", "/v3/overview/exportIocTypeProportion", passthrough_body=True, binary=True - ), - "get_overview_config": ActionSpec("GET", "/v3/setting/overviewConfig"), - "set_overview_config": ActionSpec("PUT", "/v3/setting/overviewConfig", passthrough_body=True), - # status - "device_platform_status": ActionSpec("GET", "/v3/device/platformStatus"), - # OneSIG v2.5.3 文档:模式 A 用 (time, module);模式 B 用 (startTime, endTime, - # module, ifName)。`module` 必传但前端在 index.jsx 初次批拉时传空串即可, - # 因此这里把 `time` 列为强制必填,调用方至少要给一个时间锚。 - "device_system_status": ActionSpec( - "GET", - "/v3/device/systemStatus", - body_keys=["time", "module", "startTime", "endTime", "ifName"], - required=["time"], - ), - "device_network_status": ActionSpec("GET", "/v3/device/networkStatus"), - "common_interface_list": ActionSpec("GET", "/v3/common/interfaceList"), - "basic_cpu_attr": ActionSpec("GET", "/v3/basic/cpuAttr"), - # alert hosts - "alert_host_stat": ActionSpec( - "GET", "/v3/alertHost/stat", body_keys=["startTime", "endTime", "assetGroup"] - ), - "alert_host_tree": ActionSpec("POST", "/v3/alertHost/tree", passthrough_body=True), - "alert_host_list": ActionSpec( - "POST", - "/v3/alertHost/list", - passthrough_body=True, - required=["startTime", "endTime"], - ), - "alert_host_export": ActionSpec( - "POST", "/v3/alertHost/export", passthrough_body=True, binary=True - ), - # OneSIG 文档 monitoringHostdetail.md:必填 startTime + endTime + source - # (source = 当前告警主机 IP,由列表行 `detailData.source` 带入)。 - "alert_host_detail": ActionSpec( - "POST", - "/v3/alertHost/detail", - passthrough_body=True, - required=["startTime", "endTime", "source"], - ), - "alert_host_detail_list": ActionSpec( - "POST", - "/v3/alertHost/detail/list", - passthrough_body=True, - required=["startTime", "endTime", "source"], - ), - "alert_host_detail_export": ActionSpec( - "POST", "/v3/alertHost/detail/export", passthrough_body=True, binary=True - ), - "common_asset_type_list": ActionSpec("GET", "/v3/common/assetTypeList"), - # inbound / outbound threats - "event_inbound_stat": ActionSpec( - "GET", "/v3/event/inbound/stat", body_keys=["startTime", "endTime", "assetGroup"] - ), - "event_inbound_list": ActionSpec( - "POST", - "/v3/event/inbound/list", - passthrough_body=True, - required=["startTime", "endTime"], - ), - "event_inbound_export": ActionSpec( - "POST", "/v3/event/inbound/export", passthrough_body=True, binary=True - ), - # OneSIG 文档 monitoringInboundThreat.md:必填 startTime + endTime; - # 实测仅传时间窗仍可能 1004,因为入站详情弹窗在生产路径上始终带 `threatTag` - # 等行级威胁元数据,建议调用方一并传入 (`threatName` / `threatTag` / - # `threatType` 三选一非空)。 - "event_inbound_detail": ActionSpec( - "POST", - "/v3/event/inbound/detail", - passthrough_body=True, - required=["startTime", "endTime"], - ), - "event_inbound_detail_trend": ActionSpec( - "POST", - "/v3/event/inbound/detail/trend", - passthrough_body=True, - required=["startTime", "endTime"], - ), - "event_inbound_detail_list": ActionSpec( - "POST", - "/v3/event/inbound/detail/list", - passthrough_body=True, - required=["startTime", "endTime"], - ), - "event_inbound_detail_export": ActionSpec( - "POST", "/v3/event/inbound/detail/export", passthrough_body=True, binary=True - ), - "port_protect_group_list": ActionSpec( - "POST", "/v3/portProtectGroup/list", passthrough_body=True - ), - "web_custom_column_set": ActionSpec( - "PUT", "/v3/webCustomColumn/set", passthrough_body=True - ), - "event_outbound_stat": ActionSpec( - "GET", "/v3/event/outbound/stat", body_keys=["startTime", "endTime", "assetGroup"] - ), - "event_outbound_list": ActionSpec( - "POST", - "/v3/event/outbound/list", - passthrough_body=True, - required=["startTime", "endTime"], - ), - "event_outbound_export": ActionSpec( - "POST", "/v3/event/outbound/export", passthrough_body=True, binary=True - ), - # OneSIG 文档 monitoringOutboundThreat.md:必填 startTime + endTime; - # 与入站系列同理,弹窗内联动行上下文(`threatName` 等)后才能稳定返回数据。 - "event_outbound_detail": ActionSpec( - "POST", - "/v3/event/outbound/detail", - passthrough_body=True, - required=["startTime", "endTime"], - ), - "event_outbound_detail_trend": ActionSpec( - "POST", - "/v3/event/outbound/detail/trend", - passthrough_body=True, - required=["startTime", "endTime"], - ), - "event_outbound_detail_list": ActionSpec( - "POST", - "/v3/event/outbound/detail/list", - passthrough_body=True, - required=["startTime", "endTime"], - ), - "event_outbound_detail_export": ActionSpec( - "POST", "/v3/event/outbound/detail/export", passthrough_body=True, binary=True - ), - "set_dnslog_config": ActionSpec( - "PUT", "/v3/setting/dnslogConfig", passthrough_body=True - ), - # report - "get_notice_config": ActionSpec("GET", "/v3/setting/noticeConfig"), - "report_form_create": ActionSpec("POST", "/v3/report/form", passthrough_body=True), - "report_form_list": ActionSpec( - "POST", "/v3/report/form/list", passthrough_body=True - ), - "report_form_download": ActionSpec( - "GET", - "/v3/report/form/download", - body_keys=["uniqueId", "fileName"], - binary=True, - ), - "report_form_delete": ActionSpec("DELETE", "/v3/report/form", passthrough_body=True), - "report_task_list": ActionSpec( - "POST", "/v3/report/task/list", passthrough_body=True - ), - "report_task_create": ActionSpec("POST", "/v3/report/task", passthrough_body=True), - "report_task_update": ActionSpec("PUT", "/v3/report/task", passthrough_body=True), - "report_task_delete": ActionSpec("DELETE", "/v3/report/task", passthrough_body=True), - "report_task_test": ActionSpec("POST", "/v3/report/task/test", passthrough_body=True), - # shared monitoring - "common_asset_group_tree": ActionSpec("GET", "/v3/common/assetGroupTree"), - "ips_rule_create": ActionSpec("POST", "/v3/ips/rule", passthrough_body=True), - "ips_rule_apply": ActionSpec("POST", "/v3/ips/rule/apply", passthrough_body=True), - "ips_ruleset_namelist": ActionSpec("POST", "/v3/ips/ruleset/namelist", passthrough_body=True), - # OneSIG 文档:必填 ruleId + assetIp(用于查询「当前」生效的规则集名称)。 - "ips_ruleset_referred": ActionSpec( - "POST", - "/v3/ips/ruleset/referred", - passthrough_body=True, - required=["ruleId", "assetIp"], - ), - "logaccess_stat": ActionSpec("GET", "/v3/logAccess/stat"), - "get_dnslog_config": ActionSpec("GET", "/v3/setting/dnslogConfig"), -} - -# Strategy ---------------------------------------------------------------- - -STRATEGY_ACTION_SPECS: dict[str, ActionSpec] = { - # whitelist - "whitelist_add": ActionSpec("POST", "/v3/globalWhitelist", passthrough_body=True), - "whitelist_update": ActionSpec("PUT", "/v3/globalWhitelist", passthrough_body=True), - "whitelist_delete": ActionSpec("DELETE", "/v3/globalWhitelist", passthrough_body=True), - "whitelist_export": ActionSpec( - "POST", "/v3/globalWhitelist/export", passthrough_body=True, binary=True - ), - "whitelist_import": ActionSpec( - "POST", "/v3/globalWhitelist/import", passthrough_body=True - ), - "whitelist_template": ActionSpec( - "GET", "/v3/globalWhitelist/template", binary=True - ), - "whitelist_list": ActionSpec("POST", "/v3/globalWhitelist/list", passthrough_body=True), - "whitelist_remove_batch": ActionSpec( - "DELETE", "/v3/globalWhitelist/remove", passthrough_body=True - ), - # blacklist - "blacklist_location_options": ActionSpec("GET", "/v3/blacklist/location"), - "blacklist_add": ActionSpec("POST", "/v3/globalBlacklist", passthrough_body=True), - "blacklist_update": ActionSpec("PUT", "/v3/globalBlacklist", passthrough_body=True), - "blacklist_delete": ActionSpec("DELETE", "/v3/globalBlacklist", passthrough_body=True), - # OneSIG 文档:必填 blackList(待校验的黑名单对象数组)。 - "blacklist_check": ActionSpec( - "POST", - "/v3/globalBlacklist/check", - passthrough_body=True, - required=["blackList"], - ), - "blacklist_export": ActionSpec( - "POST", "/v3/globalBlacklist/export", passthrough_body=True, binary=True - ), - "blacklist_import": ActionSpec("POST", "/v3/globalBlacklist/import", passthrough_body=True), - "blacklist_template": ActionSpec("GET", "/v3/globalBlacklist/template", binary=True), - "blacklist_list": ActionSpec("POST", "/v3/globalBlacklist/list", passthrough_body=True), - "blacklist_remove_batch": ActionSpec( - "DELETE", "/v3/globalBlacklist/remove", passthrough_body=True - ), - # multi-block - # OneSIG 文档:必填 name + startTime + endTime + pageNo + pageSize; - # `name` 缺失时直接 1004。 - "multiblock_executelog_list": ActionSpec( - "POST", - "/v3/multiblock/executelog", - passthrough_body=True, - required=["name", "startTime", "endTime", "pageNo", "pageSize"], - ), - "multiblock_executelog_export": ActionSpec( - "POST", "/v3/multiblock/executelog/export", passthrough_body=True, binary=True - ), - "multiblock_rule_delete": ActionSpec( - "DELETE", "/v3/multiblock/rule", passthrough_body=True - ), - "multiblock_rule_active": ActionSpec( - "POST", "/v3/multiblock/rule/active", passthrough_body=True - ), - "multiblock_rule_dict": ActionSpec( - "POST", "/v3/multiblock/rule/dict", passthrough_body=True - ), - "multiblock_rule_list": ActionSpec( - "POST", "/v3/multiblock/rule/list", passthrough_body=True - ), - # OneSIG 文档:必填 name(多维封锁规则名称)。 - "multiblock_rule_get": ActionSpec( - "POST", - "/v3/multiblock/rule/get", - passthrough_body=True, - required=["name"], - ), - # OneSIG 文档:预运行需带规则核心字段;前端在发起前会移除 blockTime/ - # blockDirection/showCommit。 - "multiblock_rule_preview": ActionSpec( - "POST", - "/v3/multiblock/rule/preview", - passthrough_body=True, - required=[ - "name", - "detectTimeNum", - "detectTimeUnit", - "detectDirection", - "detectGroups", - "blockType", - ], - ), - "multiblock_rule_create": ActionSpec( - "POST", "/v3/multiblock/rule", passthrough_body=True - ), - "multiblock_rule_update": ActionSpec( - "PUT", "/v3/multiblock/rule", passthrough_body=True - ), - # api keys - "apikey_delete": ActionSpec("DELETE", "/v3/apikey", passthrough_body=True), - "apikey_update": ActionSpec("PUT", "/v3/apikey", passthrough_body=True), - "apikey_create": ActionSpec("POST", "/v3/apikey", passthrough_body=True), - # OneSIG 服务端 GET list 接口要求 query 里至少带 pageNo/pageSize, - # 否则统一回 responseCode=1004 "请求数据非法"。 - "apikey_list": ActionSpec( - "GET", "/v3/apikey/list", body_keys=["pageNo", "pageSize"] - ), - # OneSIG 文档 strategyApi.md:query 必填 key + password - # (二次校验登录密码用于查看 secret,敏感字段勿入日志)。 - "apikey_secret": ActionSpec( - "GET", - "/v3/apikey/secret", - body_keys=["key", "password"], - required=["key", "password"], - ), - # syslog auto-blacklist - "auto_blacklist_delete": ActionSpec( - "DELETE", "/v3/autoBlacklist", passthrough_body=True - ), - # OneSIG 文档:必填 name + port + srcIp + protocol + direction - # (步骤 1 → 步骤 2 之间的接入配置重复性校验)。 - "auto_blacklist_check": ActionSpec( - "POST", - "/v3/autoBlacklist/check", - passthrough_body=True, - required=["name", "port", "srcIp", "protocol", "direction"], - ), - "auto_blacklist_create": ActionSpec( - "POST", "/v3/autoBlacklist", passthrough_body=True - ), - "auto_blacklist_update": ActionSpec( - "PUT", "/v3/autoBlacklist", passthrough_body=True - ), - "auto_blacklist_list": ActionSpec( - "POST", "/v3/autoBlacklist/list", passthrough_body=True - ), - # OneSIG 文档:query 仅 `srcIp` 必填(来自 syslog 接入配置的源 IP)。 - "auto_blacklist_trend": ActionSpec( - "GET", - "/v3/autoBlacklist/trend", - body_keys=["srcIp", "startTime", "endTime"], - required=["srcIp"], - ), - # OneSIG 文档:必填 srcIp + protocol + direction(入站/出站)。 - "auto_blacklist_sample": ActionSpec( - "POST", - "/v3/autoBlacklist/sample", - passthrough_body=True, - required=["srcIp", "protocol", "direction"], - ), - # ftp/sftp linkage - "linkage_delete": ActionSpec("DELETE", "/v3/linkage", passthrough_body=True), - "linkage_create": ActionSpec("POST", "/v3/linkage", passthrough_body=True), - "linkage_update": ActionSpec("PUT", "/v3/linkage", passthrough_body=True), - "linkage_enable": ActionSpec("POST", "/v3/linkage/enable", passthrough_body=True), - # 注意:尽管命名是 "info",OneSIG 服务端在响应前会触发一次 FTP/SFTP - # 连通性测试(v2.5.3 实测:传 uniqueId=dummy 直接返回 1309 - # "FTP联通测试失败[dummy]")。换言之这是个有副作用的接口,仅在确 - # 实需要重新探活时再调用,普通"读配置"用 linkage_list 即可。 - # OneSIG 文档:query 必填 uniqueId(联动配置 ID);password 选填, - # 仅在 FTPAccount 查看密钥时拼接。 - # 注意:尽管命名是 "info",OneSIG 服务端在响应前会触发一次 FTP/SFTP - # 连通性测试(v2.5.3 实测:传 uniqueId=dummy 直接返回 1309 - # "FTP联通测试失败[dummy]")。换言之这是个有副作用的接口。 - "linkage_info": ActionSpec( - "GET", - "/v3/linkage/info", - body_keys=["uniqueId", "password"], - required=["uniqueId"], - ), - "linkage_list": ActionSpec("POST", "/v3/linkage/list", passthrough_body=True), - "linkage_template": ActionSpec("GET", "/v3/linkage/template", binary=True), - "linkage_test": ActionSpec("POST", "/v3/linkage/test", passthrough_body=True), - # IPS - "ips_rule_create": ActionSpec("POST", "/v3/ips/rule", passthrough_body=True), - "ips_rule_all": ActionSpec("POST", "/v3/ips/rule/all", passthrough_body=True), - "ips_rule_apply": ActionSpec("POST", "/v3/ips/rule/apply", passthrough_body=True), - "ips_rule_list": ActionSpec("POST", "/v3/ips/rule/list", passthrough_body=True), - "ips_ruleset_create": ActionSpec("POST", "/v3/ips/ruleset", passthrough_body=True), - "ips_ruleset_update": ActionSpec("PUT", "/v3/ips/ruleset", passthrough_body=True), - "ips_ruleset_delete": ActionSpec("DELETE", "/v3/ips/ruleset", passthrough_body=True), - # OneSIG 文档:必填 name(IPS 规则集名称)。 - "ips_ruleset_info": ActionSpec( - "POST", - "/v3/ips/ruleset/info", - passthrough_body=True, - required=["name"], - ), - "ips_ruleset_list": ActionSpec("POST", "/v3/ips/ruleset/list", passthrough_body=True), - "ips_ruleset_namelist": ActionSpec( - "POST", "/v3/ips/ruleset/namelist", passthrough_body=True - ), - "ips_threat_types": ActionSpec("POST", "/v3/ips/threatTypes", passthrough_body=True), - # HTTP protect (HTTP blacklist) - "http_blacklist_delete": ActionSpec( - "DELETE", "/v3/httpBlacklist", passthrough_body=True - ), - "http_blacklist_enable": ActionSpec( - "POST", "/v3/httpBlacklist/enable", passthrough_body=True - ), - "http_blacklist_export": ActionSpec( - "POST", "/v3/httpBlacklist/export", passthrough_body=True, binary=True - ), - "http_blacklist_list": ActionSpec( - "POST", "/v3/httpBlacklist/list", passthrough_body=True - ), - "http_blacklist_create": ActionSpec( - "POST", "/v3/httpBlacklist", passthrough_body=True - ), - "http_blacklist_update": ActionSpec( - "PUT", "/v3/httpBlacklist", passthrough_body=True - ), - "get_advanced_config": ActionSpec("GET", "/v3/setting/advancedConfig"), - "set_advanced_config": ActionSpec("PUT", "/v3/setting/advancedConfig", passthrough_body=True), - "get_xff_config": ActionSpec("GET", "/v3/setting/xffConfig"), - "set_xff_config": ActionSpec("PUT", "/v3/setting/xffConfig", passthrough_body=True), - # port protect groups - "port_protect_group_delete": ActionSpec( - "DELETE", "/v3/portProtectGroup", passthrough_body=True - ), - "port_protect_group_create": ActionSpec( - "POST", "/v3/portProtectGroup", passthrough_body=True - ), - "port_protect_group_update": ActionSpec( - "PUT", "/v3/portProtectGroup", passthrough_body=True - ), - "port_protect_group_clone": ActionSpec( - "POST", "/v3/portProtectGroup/clone", passthrough_body=True - ), - "port_protect_group_default_info": ActionSpec( - "GET", "/v3/portProtectGroup/defaultInfo" - ), - "port_protect_group_list_full": ActionSpec( - "POST", "/v3/portProtectGroup/list", passthrough_body=True - ), - "port_protect_port_delete": ActionSpec( - "DELETE", "/v3/portProtectGroup/port", passthrough_body=True - ), - "port_protect_port_create": ActionSpec( - "POST", "/v3/portProtectGroup/port", passthrough_body=True - ), - "port_protect_port_update": ActionSpec( - "PUT", "/v3/portProtectGroup/port", passthrough_body=True - ), - "port_protect_port_export": ActionSpec( - "POST", "/v3/portProtectGroup/port/export", passthrough_body=True, binary=True - ), - # OneSIG 文档:必填 groupName + pageNo + pageSize;search 超过 32 字符 - # 前端拦截。 - "port_protect_port_list": ActionSpec( - "POST", - "/v3/portProtectGroup/port/list", - passthrough_body=True, - required=["groupName", "pageNo", "pageSize"], - ), - "port_protect_port_onekey_import": ActionSpec( - "POST", "/v3/portProtectGroup/port/onekeyImport", passthrough_body=True - ), - "port_protect_port_onekey_status": ActionSpec( - "GET", "/v3/portProtectGroup/port/onekeyImport" - ), - "port_protect_portinfo": ActionSpec( - "POST", "/v3/portProtectGroup/portinfo", passthrough_body=True - ), - # strategy page (custom protection policy) - "device_onekey_bypass": ActionSpec( - "POST", "/v3/device/onekeyBypass", passthrough_body=True - ), - "protection_policy_delete": ActionSpec( - "DELETE", "/v3/protection/policy", passthrough_body=True - ), - "protection_policy_update": ActionSpec( - "PUT", "/v3/protection/policy", passthrough_body=True - ), - "protection_policy_get": ActionSpec( - "GET", "/v3/protection/policy", body_keys=["uniqueId"] - ), - "protection_policy_tree": ActionSpec("GET", "/v3/protection/policy/tree"), - "set_scan_config": ActionSpec("PUT", "/v3/setting/scanConfig", passthrough_body=True), -} - -# Assets ------------------------------------------------------------------ - -ASSETS_ACTION_SPECS: dict[str, ActionSpec] = { - "asset_delete": ActionSpec("DELETE", "/v3/asset", passthrough_body=True), - "asset_create": ActionSpec("POST", "/v3/asset", passthrough_body=True), - "asset_update": ActionSpec("PUT", "/v3/asset", passthrough_body=True), - "asset_export": ActionSpec("POST", "/v3/asset/export", passthrough_body=True, binary=True), - "asset_group_delete": ActionSpec("DELETE", "/v3/asset/group", passthrough_body=True), - "asset_group_get": ActionSpec("GET", "/v3/asset/group"), - "asset_group_create": ActionSpec("POST", "/v3/asset/group", passthrough_body=True), - "asset_group_update": ActionSpec("PUT", "/v3/asset/group", passthrough_body=True), - "asset_import": ActionSpec( - "POST", "/v3/asset/import", passthrough_body=True, multipart=True - ), - "asset_template": ActionSpec("GET", "/v3/asset/template", binary=True), - "asset_list": ActionSpec("POST", "/v3/asset/list", passthrough_body=True), - "asset_type_delete": ActionSpec("DELETE", "/v3/asset/type", passthrough_body=True), - "asset_type_get": ActionSpec("GET", "/v3/asset/type"), - "asset_type_create": ActionSpec("POST", "/v3/asset/type", passthrough_body=True), - "common_asset_group_tree": ActionSpec("GET", "/v3/common/assetGroupTree"), -} - -# Device ------------------------------------------------------------------ - -DEVICE_ACTION_SPECS: dict[str, ActionSpec] = { - # alert policy - "alert_policy_list": ActionSpec( - "POST", "/v3/alert/policy/list", passthrough_body=True - ), - "alert_policy_enable": ActionSpec( - "POST", "/v3/alert/policy/enable", passthrough_body=True - ), - "alert_policy_delete": ActionSpec( - "DELETE", "/v3/alert/policy", passthrough_body=True - ), - "alert_policy_create": ActionSpec( - "POST", "/v3/alert/policy", passthrough_body=True - ), - "alert_policy_update": ActionSpec( - "PUT", "/v3/alert/policy", passthrough_body=True - ), - "alert_policy_export": ActionSpec( - "POST", "/v3/alert/policy/export", passthrough_body=True, binary=True - ), - # OneSIG 文档:必填 search + type; - # - syslog: search 形如 "UDP:192.168.0.1:514" - # - webhook: search 形如 "type:url" - "alert_policy_find_by_config": ActionSpec( - "POST", - "/v3/alert/policy/findByConfig", - passthrough_body=True, - required=["search", "type"], - ), - "alert_policy_object": ActionSpec( - "POST", "/v3/alert/policy/object", passthrough_body=True - ), - "get_notice_config": ActionSpec("GET", "/v3/setting/noticeConfig"), - "set_notice_config": ActionSpec("PUT", "/v3/setting/noticeConfig", passthrough_body=True), - "get_notice_send_key": ActionSpec("GET", "/v3/setting/noticeConfig/sendKey"), - "test_email": ActionSpec("POST", "/v3/test/email", passthrough_body=True), - "test_syslog": ActionSpec("POST", "/v3/test/syslog", passthrough_body=True), - "test_webhook": ActionSpec("POST", "/v3/test/webhook", passthrough_body=True), - # audit logs - "aclog_stat": ActionSpec("GET", "/v3/aclog/stat", body_keys=["startTime", "endTime"]), - "aclog_list": ActionSpec("POST", "/v3/aclog/list", passthrough_body=True), - "aclog_export": ActionSpec( - "POST", "/v3/aclog/export", passthrough_body=True, binary=True - ), - "aclog_delete": ActionSpec( - "DELETE", - "/v3/aclog", - passthrough_body=True, - encrypt_fields=("password",), - ), - "get_clean_config": ActionSpec("GET", "/v3/setting/cleanConfig"), - "set_clean_config": ActionSpec("PUT", "/v3/setting/cleanConfig", passthrough_body=True), - # users / login mgmt - "user_list": ActionSpec("POST", "/v3/user/list", passthrough_body=True), - "user_export": ActionSpec( - "POST", "/v3/user/export", passthrough_body=True, binary=True - ), - "user_delete": ActionSpec( - "DELETE", - "/v3/user", - passthrough_body=True, - encrypt_fields=("password",), - ), - "user_secret_reset": ActionSpec( - "PUT", - "/v3/user/secret/reset", - passthrough_body=True, - encrypt_fields=("password",), - ), - "user_create": ActionSpec( - "POST", - "/v3/user", - passthrough_body=True, - encrypt_fields=("password", "dupPassword"), - ), - "user_update": ActionSpec("PUT", "/v3/user", passthrough_body=True), - "get_login_config": ActionSpec("GET", "/v3/setting/loginConfig"), - "set_login_config": ActionSpec("PUT", "/v3/setting/loginConfig", passthrough_body=True), - # HTTPS decryption - "get_decrypt_config": ActionSpec("GET", "/v3/setting/decryptConfig"), - "set_decrypt_config": ActionSpec("PUT", "/v3/setting/decryptConfig", passthrough_body=True), - "get_detect_config": ActionSpec("GET", "/v3/setting/detectConfig"), - "set_detect_config": ActionSpec("PUT", "/v3/setting/detectConfig", passthrough_body=True), - "tls_decrypt_policy_list": ActionSpec( - "POST", "/v3/tls/decrypt/policy/list", passthrough_body=True - ), - "tls_decrypt_policy_create": ActionSpec( - "POST", "/v3/tls/decrypt/policy", passthrough_body=True - ), - "tls_decrypt_policy_update": ActionSpec( - "PUT", "/v3/tls/decrypt/policy", passthrough_body=True - ), - "tls_decrypt_policy_enable": ActionSpec( - "POST", "/v3/tls/decrypt/policy/enable", passthrough_body=True - ), - "tls_decrypt_policy_delete": ActionSpec( - "DELETE", "/v3/tls/decrypt/policy", passthrough_body=True - ), - "tls_decrypt_policy_batch": ActionSpec( - "POST", "/v3/tls/decrypt/policy/batch", passthrough_body=True - ), - "tls_cert_list": ActionSpec("POST", "/v3/tls/cert/list", passthrough_body=True), - "tls_cert_create": ActionSpec( - "POST", "/v3/tls/cert", passthrough_body=True, multipart=True, - multipart_file_field="certFile", - ), - "tls_cert_update": ActionSpec( - "PUT", "/v3/tls/cert", passthrough_body=True, multipart=True, - multipart_file_field="certFile", - ), - "tls_cert_delete": ActionSpec("DELETE", "/v3/tls/cert", passthrough_body=True), - "tls_cert_set_default": ActionSpec( - "POST", "/v3/tls/cert/set_default", passthrough_body=True - ), - "tls_detect_list": ActionSpec("POST", "/v3/tls/detect/list", passthrough_body=True), - # OneSIG 文档:必填 server + port + orderBy + sortBy - # (父行 serverAddress/serverPort,固定 desc / updateTime)。 - "tls_detect_list_detail": ActionSpec( - "POST", - "/v3/tls/detect/list/detail", - passthrough_body=True, - required=["server", "port", "orderBy", "sortBy"], - ), - "tls_detect_delete": ActionSpec("DELETE", "/v3/tls/detect", passthrough_body=True), - "tls_detect_group": ActionSpec("POST", "/v3/tls/detect/group", passthrough_body=True), - "tls_detect_group_export": ActionSpec( - "POST", "/v3/tls/detect/group/export", passthrough_body=True, binary=True - ), - "tls_detect_list_export": ActionSpec( - "POST", "/v3/tls/detect/list/export", passthrough_body=True, binary=True - ), - # deploy guide / interface - "interface_list": ActionSpec("GET", "/v3/interface/list"), - "interface_update": ActionSpec( - "PUT", - "/v3/interface", - passthrough_body=True, - encrypt_fields=("password",), - ), - "interface_check_loop": ActionSpec( - "POST", "/v3/interface/check/loop", passthrough_body=True - ), - "interface_relation_list": ActionSpec("GET", "/v3/interface/relation/list"), - # OneSIG 文档:必填 workMode(listen/bridge/vline);不传直接 1004。 - "interface_select_list": ActionSpec( - "GET", - "/v3/interface/select/list", - body_keys=["workMode", "name", "itemName"], - required=["workMode"], - ), - "interface_virtual_line_create": ActionSpec( - "POST", "/v3/interface/virtualLine", passthrough_body=True - ), - "interface_virtual_line_update": ActionSpec( - "PUT", "/v3/interface/virtualLine", passthrough_body=True - ), - "interface_virtual_line_delete": ActionSpec( - "DELETE", "/v3/interface/virtualLine", passthrough_body=True - ), - "interface_listen_create": ActionSpec( - "POST", "/v3/interface/listen", passthrough_body=True - ), - "interface_listen_update": ActionSpec( - "PUT", "/v3/interface/listen", passthrough_body=True - ), - "interface_listen_delete": ActionSpec( - "DELETE", "/v3/interface/listen", passthrough_body=True - ), - "interface_bridge_create": ActionSpec( - "POST", "/v3/interface/bridge", passthrough_body=True - ), - "interface_bridge_update": ActionSpec( - "PUT", "/v3/interface/bridge", passthrough_body=True - ), - "interface_bridge_delete": ActionSpec( - "DELETE", "/v3/interface/bridge", passthrough_body=True - ), - # routes - "route_outif_list": ActionSpec("GET", "/v3/route/outIf/list"), - "route_static_list": ActionSpec("POST", "/v3/route/static/list", passthrough_body=True), - "route_static_create": ActionSpec("POST", "/v3/route/static", passthrough_body=True), - "route_static_update": ActionSpec("PUT", "/v3/route/static", passthrough_body=True), - "route_static_delete": ActionSpec( - "DELETE", "/v3/route/static", passthrough_body=True - ), - "route_table_list": ActionSpec("POST", "/v3/route/table/list", passthrough_body=True), - "ipv6_route_static_list": ActionSpec( - "POST", "/v3/ipv6Route/static/list", passthrough_body=True - ), - "ipv6_route_static_create": ActionSpec( - "POST", "/v3/ipv6Route/static", passthrough_body=True - ), - "ipv6_route_static_update": ActionSpec( - "PUT", "/v3/ipv6Route/static", passthrough_body=True - ), - "ipv6_route_static_delete": ActionSpec( - "DELETE", "/v3/ipv6Route/static", passthrough_body=True - ), - "ipv6_route_table_list": ActionSpec( - "POST", "/v3/ipv6Route/table/list", passthrough_body=True - ), - # DNS config - "get_dns_config": ActionSpec("GET", "/v3/setting/dnsConfig"), - "set_dns_config": ActionSpec("PUT", "/v3/setting/dnsConfig", passthrough_body=True), - "hosts_get": ActionSpec("GET", "/v3/setting/hosts"), - "hosts_create": ActionSpec("POST", "/v3/setting/hosts", passthrough_body=True), - "hosts_update": ActionSpec("PUT", "/v3/setting/hosts", passthrough_body=True), - "hosts_delete": ActionSpec("DELETE", "/v3/setting/hosts", passthrough_body=True), - "test_network": ActionSpec("GET", "/v3/test/network"), - # proxy / agent - "get_proxy_config": ActionSpec("GET", "/v3/setting/proxyConfig"), - "set_proxy_config": ActionSpec("PUT", "/v3/setting/proxyConfig", passthrough_body=True), - "test_proxy": ActionSpec("POST", "/v3/test/proxy", passthrough_body=True), - # HA - "ha_status": ActionSpec("GET", "/v3/ha/status"), - "get_ha_config": ActionSpec("GET", "/v3/setting/haConfig"), - "set_ha_config": ActionSpec("PUT", "/v3/setting/haConfig", passthrough_body=True), - "ha_module_list": ActionSpec("GET", "/v3/ha/moduleList"), - "ha_compare_config": ActionSpec("POST", "/v3/ha/compareConfig", passthrough_body=True), - "ha_switching": ActionSpec("PUT", "/v3/ha/switching", passthrough_body=True), - "ha_sync_config": ActionSpec("POST", "/v3/ha/syncConfig", passthrough_body=True), - # OneSIG 文档:必填 syncId(由 ha_sync_config 返回的任务 ID)。 - "ha_sync_status": ActionSpec( - "GET", "/v3/ha/syncStatus", body_keys=["syncId"], required=["syncId"] - ), - # centralized control (OneCC) - "onecc_status": ActionSpec("GET", "/v3/setting/oneccConfig/status"), - "get_onecc_config": ActionSpec("GET", "/v3/setting/oneccConfig"), - "set_onecc_config": ActionSpec("PUT", "/v3/setting/oneccConfig", passthrough_body=True), - "set_onecc_status": ActionSpec("PUT", "/v3/setting/oneccConfig/status", passthrough_body=True), - "test_onecc": ActionSpec("POST", "/v3/test/onecc", passthrough_body=True), - # device config - "device_quick_bypass": ActionSpec( - "POST", "/v3/device/quickBypass", passthrough_body=True - ), - "device_upgrade_record_list": ActionSpec( - "POST", "/v3/device/upgradeRecord/list", passthrough_body=True - ), - "get_upgrade_config": ActionSpec("GET", "/v3/setting/upgradeConfig"), - "set_upgrade_config": ActionSpec( - "PUT", "/v3/setting/upgradeConfig", passthrough_body=True - ), - "basic_version": ActionSpec("GET", "/v3/basic/version"), - "device_upgrade_info": ActionSpec("GET", "/v3/device/upgradeInfo"), - "device_download_package": ActionSpec( - "POST", "/v3/device/downloadPackage", passthrough_body=True - ), - # /v3/device/upgrade has two flavors: - # * 已下载包升级: JSON body with `name` (Query) + `password` (RSA); - # * 本地上传包: multipart with file + `password`. - # Default to JSON; supply `file_path` to use multipart. - "device_upgrade": ActionSpec( - "POST", - "/v3/device/upgrade", - passthrough_body=True, - encrypt_fields=("password",), - ), - "device_upgrade_upload": ActionSpec( - "POST", - "/v3/device/upgrade", - passthrough_body=True, - multipart=True, - encrypt_fields=("password",), - ), - "system_upgrade": ActionSpec( - "POST", "/v3/system/upgrade", passthrough_body=True, multipart=True - ), - "device_custom_get": ActionSpec("GET", "/v3/device/custom"), - "device_custom_set": ActionSpec("PUT", "/v3/device/custom", passthrough_body=True), - "device_reboot": ActionSpec("POST", "/v3/device/reboot", passthrough_body=True), - "device_shutdown": ActionSpec("POST", "/v3/device/shutdown", passthrough_body=True), - "device_reinit": ActionSpec("POST", "/v3/device/reinit", passthrough_body=True), - "device_system_timezone": ActionSpec("GET", "/v3/device/systemTimeZone"), - "device_system_time_get": ActionSpec("GET", "/v3/device/systemTime"), - "device_system_time_set": ActionSpec("PUT", "/v3/device/systemTime", passthrough_body=True), - "get_storage_config": ActionSpec("GET", "/v3/setting/storageConfig"), - "set_storage_config": ActionSpec("PUT", "/v3/setting/storageConfig", passthrough_body=True), - "backup_recover_progress": ActionSpec("GET", "/v3/backup/recover/progress"), - "backup_list": ActionSpec("POST", "/v3/backup/list", passthrough_body=True), - "backup_create": ActionSpec("POST", "/v3/backup", passthrough_body=True), - "backup_download": ActionSpec( - "GET", "/v3/backup/download", body_keys=["uniqueId"], binary=True - ), - "backup_recover": ActionSpec("POST", "/v3/backup/recover", passthrough_body=True), - "backup_delete": ActionSpec("DELETE", "/v3/backup", passthrough_body=True), - "backup_update": ActionSpec("PUT", "/v3/backup", passthrough_body=True), - "backup_import": ActionSpec( - "POST", "/v3/backup/import", passthrough_body=True, multipart=True - ), - # OneSIG 文档:必填 pageNo + pageSize + type("DNS" 或 "DHCP")。 - "logaccess_list": ActionSpec( - "POST", - "/v3/logAccess/list", - passthrough_body=True, - required=["pageNo", "pageSize", "type"], - ), - "logaccess_delete": ActionSpec("DELETE", "/v3/logAccess", passthrough_body=True), - "logaccess_create": ActionSpec("POST", "/v3/logAccess", passthrough_body=True), - "logaccess_update": ActionSpec("PUT", "/v3/logAccess", passthrough_body=True), - # OneSIG 文档:必填 srcIp + protocol + type("DNS" 或 "DHCP")。 - "logaccess_sample": ActionSpec( - "POST", - "/v3/logAccess/sample", - passthrough_body=True, - required=["srcIp", "protocol", "type"], - ), - "logaccess_test": ActionSpec("POST", "/v3/logAccess/test", passthrough_body=True), - "logaccess_check": ActionSpec("GET", "/v3/logAccess/check", body_keys=["name"]), - # system info - "basic_license_get": ActionSpec("GET", "/v3/basic/license"), - "basic_connect_status": ActionSpec("GET", "/v3/basic/connectStatus"), - "basic_information": ActionSpec("GET", "/v3/basic/information"), - "basic_information_enable": ActionSpec( - "POST", "/v3/basic/information/enable", passthrough_body=True - ), - "basic_information_import": ActionSpec( - "POST", "/v3/basic/information", passthrough_body=True, multipart=True - ), - "basic_license_upload": ActionSpec( - "POST", "/v3/basic/license", passthrough_body=True, multipart=True - ), - "mdr_service_status": ActionSpec("GET", "/v3/mdrService/status"), - "mdr_service_enable": ActionSpec( - "PUT", "/v3/mdrService/enable", passthrough_body=True - ), - # system diagnosis - "device_coredump_list": ActionSpec("GET", "/v3/device/coredump"), - "device_coredump_download": ActionSpec( - "POST", "/v3/device/coredumpDownload", passthrough_body=True, binary=True - ), - "device_coredump_delete": ActionSpec( - "DELETE", "/v3/device/coredump", passthrough_body=True - ), - "device_pcap_get": ActionSpec("GET", "/v3/device/pcap"), - "device_pcap_set": ActionSpec("PUT", "/v3/device/pcap", passthrough_body=True), - "device_pcap_file_list": ActionSpec("GET", "/v3/device/pcapFile"), - "device_pcap_download": ActionSpec( - "POST", "/v3/device/pcapDownload", passthrough_body=True, binary=True - ), - "device_pcap_file_delete": ActionSpec( - "DELETE", "/v3/device/pcapFile", passthrough_body=True - ), -} - -# Helper ------------------------------------------------------------------ - -HELPER_ACTION_SPECS: dict[str, ActionSpec] = { - "document_list": ActionSpec("POST", "/v3/document/list", passthrough_body=True), - # OneSIG 文档 helperDocs.md:query 必填 `id`(来自文档列表项), - # 而非 `fileName`。返回值是路径字符串(非对象),前端拼接 baseUrl 后 open。 - "document_preview": ActionSpec( - "GET", - "/v3/document/preview", - body_keys=["id"], - required=["id"], - ), - "product_news_get": ActionSpec("GET", "/v3/product/news"), - "product_news_mark_read": ActionSpec( - "PUT", "/v3/product/news", passthrough_body=True - ), - "product_version": ActionSpec("GET", "/v3/product/version"), - "product_issue": ActionSpec("POST", "/v3/product/issue", passthrough_body=True), -} - - -GROUP_SPECS: dict[str, dict[str, ActionSpec]] = { - "login": LOGIN_ACTION_SPECS, - "monitoring": MONITORING_ACTION_SPECS, - "strategy": STRATEGY_ACTION_SPECS, - "assets": ASSETS_ACTION_SPECS, - "device": DEVICE_ACTION_SPECS, - "helper": HELPER_ACTION_SPECS, -} - -# Lightweight read-only actions used by `action="test"` for connectivity check. -_CONNECTIVITY_TEST_ACTIONS: dict[str, str] = { - "login": "get_account", - "monitoring": "common_threat_type_list", - "strategy": "blacklist_location_options", - "assets": "common_asset_group_tree", - "device": "basic_version", - "helper": "product_version", -} - - -# --------------------------------------------------------------------------- -# Output handling -# --------------------------------------------------------------------------- - - -def _outputs_dir() -> str: - """Resolve the daily outputs directory used to persist binary downloads.""" - import datetime - from pathlib import Path - - try: - from flocks.workspace.manager import WorkspaceManager - - ws = WorkspaceManager.get_instance() - base = Path(ws.get_workspace_dir()) / "outputs" / datetime.date.today().isoformat() - except Exception: - base = Path.home() / ".flocks" / "workspace" / "outputs" / datetime.date.today().isoformat() - base.mkdir(parents=True, exist_ok=True) - return str(base) - - -def _save_binary(path: str, body: bytes, content_type: str) -> str: - import datetime - from pathlib import Path - - safe_name = path.strip("/").replace("/", "_") or "download" - ext = "" - ct = content_type.lower() - if "csv" in ct: - ext = ".csv" - elif "excel" in ct or "spreadsheet" in ct or "xlsx" in ct: - ext = ".xlsx" - elif "zip" in ct: - ext = ".zip" - elif "pdf" in ct: - ext = ".pdf" - elif "octet-stream" in ct: - ext = ".bin" - timestamp = datetime.datetime.now().strftime("%Y%m%dT%H%M%S") - target = Path(_outputs_dir()) / f"onesig_v2_5_3_D20250710_{safe_name}_{timestamp}{ext}" - target.write_bytes(body) - return str(target) - - -def _envelope_to_result(action: str, envelope: dict[str, Any]) -> ToolResult: - metadata = {"source": "OneSIG", "api": action} - response_code = envelope.get("responseCode") - if response_code is not None and response_code != _RESPONSE_CODE_OK: - msg = envelope.get("verboseMsg") or envelope.get("verbose_msg") or "Unknown error" - return ToolResult( - success=False, - error=f"OneSIG API error (responseCode={response_code}): {msg}", - output=envelope, - metadata=metadata, - ) - if "data" in envelope: - return ToolResult(success=True, output=envelope.get("data"), metadata=metadata) - return ToolResult(success=True, output=envelope, metadata=metadata) - - -# --------------------------------------------------------------------------- -# Dispatch -# --------------------------------------------------------------------------- - - -async def _encrypt_body_fields( - session: "OneSIGSession", - spec: ActionSpec, - body: Optional[dict[str, Any]], -) -> Optional[dict[str, Any]]: - """Replace each plaintext field listed in ``spec.encrypt_fields`` with its - RSA-OAEP ciphertext (Base64). A fresh pubkey is fetched per call to match - the front-end's ``clearRSACache`` behavior. No-op if body is None.""" - if not spec.encrypt_fields or body is None or not isinstance(body, dict): - return body - has_target = any(_has_value(body.get(f)) for f in spec.encrypt_fields) - if not has_target: - return body - pubkey_resp = await session._raw_request_json("GET", "/v3/pubkey") - pubkey = (pubkey_resp or {}).get("data", {}).get("pubkey") - if not pubkey: - raise ValueError( - f"无法从 /v3/pubkey 获取 RSA 公钥用于加密字段 {list(spec.encrypt_fields)}: {pubkey_resp!r}" - ) - out = dict(body) - for field in spec.encrypt_fields: - plain = out.get(field) - if isinstance(plain, str) and plain: - out[field] = _rsa_oaep_encrypt(pubkey, plain, session.config.oaep_hash) - return out - - -def _build_form_data( - spec: ActionSpec, - body: Optional[dict[str, Any]], - file_path: Optional[str], -) -> "aiohttp.FormData": - """Assemble a multipart/form-data payload: the file under - ``spec.multipart_file_field`` plus any non-file fields from ``body``.""" - if not file_path: - raise ValueError( - f"multipart 接口 {spec.path} 需要 `file_path` 参数指向待上传的本地文件" - ) - from pathlib import Path as _Path - - fp = _Path(file_path).expanduser() - if not fp.is_file(): - raise ValueError(f"file_path 指向的文件不存在或不是常规文件:{fp}") - - form = aiohttp.FormData() - fname = fp.name - # Stream the file via an open handle; aiohttp closes it on send completion. - form.add_field( - spec.multipart_file_field, - fp.open("rb"), - filename=fname, - content_type="application/octet-stream", - ) - if isinstance(body, dict): - for k, v in body.items(): - if v is None: - continue - if isinstance(v, (dict, list, tuple)): - import json as _json - - form.add_field(k, _json.dumps(v, ensure_ascii=False)) - elif isinstance(v, bool): - form.add_field(k, "true" if v else "false") - else: - form.add_field(k, str(v)) - return form - - -async def _execute_action( - group: str, - action: str, - params: dict[str, Any], -) -> ToolResult: - spec_map = GROUP_SPECS[group] - spec = spec_map[action] - - validation_error = _validate_required(spec, action, params) - if validation_error: - return ToolResult(success=False, error=validation_error) - - try: - config = _resolve_runtime_config() - except ValueError as exc: - return ToolResult(success=False, error=str(exc)) - - captcha = params.get("captcha") - totp = params.get("totp") - - session = await _get_session(config) - try: - query, body = spec.build_request(params) - body = await _encrypt_body_fields(session, spec, body) - - if spec.multipart: - form = _build_form_data(spec, body, params.get("file_path")) - status, envelope, body_bytes, content_type = await session.request( - spec.method, - spec.path, - params=query, - form_data=form, - captcha=captcha, - totp=totp, - ) - else: - status, envelope, body_bytes, content_type = await session.request( - spec.method, - spec.path, - params=query, - json_body=body, - captcha=captcha, - totp=totp, - ) - except ValueError as exc: - return ToolResult(success=False, error=str(exc)) - except aiohttp.ClientError as exc: - return ToolResult(success=False, error=f"Request failed: {exc}") - except Exception as exc: # pragma: no cover - defensive - return ToolResult(success=False, error=f"Unexpected error: {exc}") - - metadata: dict[str, Any] = { - "source": "OneSIG", - "api": action, - "method": spec.method, - "path": spec.path, - "http_status": status, - } - if isinstance(envelope, dict): - if "responseCode" in envelope: - metadata["response_code"] = envelope.get("responseCode") - verbose_msg = envelope.get("verboseMsg") or envelope.get("verbose_msg") - if verbose_msg: - metadata["verbose_msg"] = verbose_msg - - if spec.binary or (body_bytes and not envelope): - if status >= 400: - return ToolResult( - success=False, - error=f"HTTP {status} from {spec.path}", - metadata=metadata, - ) - saved_path = _save_binary(spec.path, body_bytes, content_type) - metadata["saved_path"] = saved_path - metadata["binary_size"] = len(body_bytes) - metadata["content_type"] = content_type - return ToolResult( - success=True, - output={ - "saved_path": saved_path, - "size": len(body_bytes), - "content_type": content_type, - }, - metadata=metadata, - ) - - if status >= 400 and not envelope: - return ToolResult( - success=False, - error=f"HTTP {status} from {spec.path}", - metadata=metadata, - ) - - result = _envelope_to_result(action, envelope or {}) - merged_meta = dict(result.metadata or {}) - merged_meta.update(metadata) - result.metadata = merged_meta - return result - - -async def _dispatch_group( - ctx: ToolContext, - group: str, - action: str, - **params: Any, -) -> ToolResult: - del ctx - if action == "test": - test_action = _CONNECTIVITY_TEST_ACTIONS.get(group) - if test_action: - return await _execute_action(group, test_action, params) - spec_map = GROUP_SPECS[group] - if action not in spec_map and action not in {"login", "logout", "change_password"}: - available = ", ".join(sorted(spec_map)) - return ToolResult( - success=False, - error=f"Unsupported {group} action: {action}. Available actions: {available}", - ) - if group == "login": - if action == "login": - return await _login_action(params) - if action == "logout": - return await _logout_action() - if action == "change_password": - return await _change_password_action(params) - return await _execute_action(group, action, params) - - -async def _login_action(params: dict[str, Any]) -> ToolResult: - try: - config = _resolve_runtime_config() - except ValueError as exc: - return ToolResult(success=False, error=str(exc)) - session = await _get_session(config) - try: - account = await session.login( - captcha=params.get("captcha"), - totp=params.get("totp"), - force=True, - ) - except ValueError as exc: - return ToolResult(success=False, error=str(exc)) - return ToolResult( - success=True, - output={"account": account, "message": "Logged in to OneSIG"}, - metadata={"source": "OneSIG", "api": "login"}, - ) - - -async def _logout_action() -> ToolResult: - try: - config = _resolve_runtime_config() - except ValueError as exc: - return ToolResult(success=False, error=str(exc)) - session = await _get_session(config) - try: - envelope = await session.logout() - except ValueError as exc: - return ToolResult(success=False, error=str(exc)) - finally: - await session.close() - async with _SESSIONS_LOCK: - _SESSIONS.pop(config.session_key, None) - return _envelope_to_result("logout", envelope or {}) - - -async def _change_password_action(params: dict[str, Any]) -> ToolResult: - """Change the current user's password (RSA-OAEP encrypts each field).""" - new_password = params.get("new_password") or params.get("newPassword") - old_password = params.get("old_password") or params.get("oldPassword") - dup_password = params.get("dup_password") or params.get("dupPassword") or new_password - if not new_password: - return ToolResult( - success=False, - error="change_password 缺少 new_password 参数", - ) - try: - config = _resolve_runtime_config() - except ValueError as exc: - return ToolResult(success=False, error=str(exc)) - session = await _get_session(config) - try: - if not session._logged_in: - await session.login(captcha=params.get("captcha"), totp=params.get("totp")) - pubkey_resp = await session._raw_request_json("GET", "/v3/pubkey") - pubkey = (pubkey_resp or {}).get("data", {}).get("pubkey") - if not pubkey: - return ToolResult( - success=False, - error=f"无法获取 RSA 公钥用于改密:{pubkey_resp!r}", - ) - body: dict[str, Any] = { - "username": params.get("username") or config.username, - "newPassword": _rsa_oaep_encrypt(pubkey, new_password, config.oaep_hash), - "dupPassword": _rsa_oaep_encrypt(pubkey, dup_password, config.oaep_hash), - } - if old_password: - body["oldPassword"] = _rsa_oaep_encrypt(pubkey, old_password, config.oaep_hash) - status, envelope, _, _ = await session.request( - "PUT", "/v3/user/password", json_body=body - ) - except ValueError as exc: - return ToolResult(success=False, error=str(exc)) - if status >= 400 and not envelope: - return ToolResult(success=False, error=f"HTTP {status} from /v3/user/password") - return _envelope_to_result("change_password", envelope or {}) - - -# --------------------------------------------------------------------------- -# Public group entry points (referenced from YAML handler stanzas) -# --------------------------------------------------------------------------- - - -async def login(ctx: ToolContext, action: str = "login", **params: Any) -> ToolResult: - return await _dispatch_group(ctx, "login", action, **params) - - -async def monitoring(ctx: ToolContext, action: str, **params: Any) -> ToolResult: - return await _dispatch_group(ctx, "monitoring", action, **params) - - -async def strategy(ctx: ToolContext, action: str, **params: Any) -> ToolResult: - return await _dispatch_group(ctx, "strategy", action, **params) - - -async def assets(ctx: ToolContext, action: str, **params: Any) -> ToolResult: - return await _dispatch_group(ctx, "assets", action, **params) - - -async def device(ctx: ToolContext, action: str, **params: Any) -> ToolResult: - return await _dispatch_group(ctx, "device", action, **params) - - -async def helper(ctx: ToolContext, action: str, **params: Any) -> ToolResult: - return await _dispatch_group(ctx, "helper", action, **params) diff --git a/.flocks/flockshub/plugins/tools/api/onesig_v2_5_3_D20250710/onesig_v2_5_3_D20250710_assets.yaml b/.flocks/flockshub/plugins/tools/api/onesig_v2_5_3_D20250710/onesig_v2_5_3_D20250710_assets.yaml deleted file mode 100644 index 7de1f2857..000000000 --- a/.flocks/flockshub/plugins/tools/api/onesig_v2_5_3_D20250710/onesig_v2_5_3_D20250710_assets.yaml +++ /dev/null @@ -1,90 +0,0 @@ -name: onesig_v2_5_3_D20250710_assets -description: > - OneSIG asset management grouped tool. Use the `action` parameter to manage - assets, asset groups, asset types, and import / export workflows under - `/assets/segment`. -description_cn: > - OneSIG 资产管理分组工具。通过 `action` 调用资产、资产组、资产类型以及导入/导出 - 相关接口,对应 Web 控制台 `/assets/segment` 资产配置页。 -category: custom -enabled: true -requires_confirmation: true -provider: onesig_v2_5_3_D20250710_api -inputSchema: - type: object - properties: - action: - type: string - description: | - Assets 分组动作名: - - asset_delete DELETE /v3/asset 删除资产 - - asset_create POST /v3/asset 新增资产 - - asset_update PUT /v3/asset 更新资产 - - asset_export POST /v3/asset/export 导出资产(文件) - - asset_group_delete DELETE /v3/asset/group 删除资产组 - - asset_group_get GET /v3/asset/group 查询资产组 - - asset_group_create POST /v3/asset/group 新增资产组 - - asset_group_update PUT /v3/asset/group 更新资产组 - - asset_import POST /v3/asset/import 导入资产(multipart:传 file_path) - - asset_template GET /v3/asset/template 导入模板(文件) - - asset_list POST /v3/asset/list 分页查询资产列表 - - asset_type_delete DELETE /v3/asset/type 删除资产类型 - - asset_type_get GET /v3/asset/type 查询资产类型 - - asset_type_create POST /v3/asset/type 新增资产类型 - - common_asset_group_tree GET /v3/common/assetGroupTree 资产组树 - enum: - - asset_delete - - asset_create - - asset_update - - asset_export - - asset_group_delete - - asset_group_get - - asset_group_create - - asset_group_update - - asset_import - - asset_template - - asset_list - - asset_type_delete - - asset_type_get - - asset_type_create - - common_asset_group_tree - uniqueId: - type: string - description: 资产/资产组/资产类型 ID - uniqueIds: - type: array - items: - type: string - description: 批量删除使用的 ID 列表 - name: - type: string - description: 名称 - ip: - type: string - description: IP / CIDR / 网段 - assetGroup: - type: string - description: 资产组路径 - assetType: - type: string - description: 资产类型 - pageNo: - type: integer - default: 1 - pageSize: - type: integer - default: 20 - search: - type: string - description: 模糊查询关键字 - file_path: - type: string - description: | - 本地待上传文件的绝对路径;仅 multipart 上传类动作使用: - - asset_import .csv 资产导入文件(字段名 `file`) - required: - - action -handler: - type: script - script_file: onesig_v2_5_3_D20250710.handler.py - function: assets diff --git a/.flocks/flockshub/plugins/tools/api/onesig_v2_5_3_D20250710/onesig_v2_5_3_D20250710_device.yaml b/.flocks/flockshub/plugins/tools/api/onesig_v2_5_3_D20250710/onesig_v2_5_3_D20250710_device.yaml deleted file mode 100644 index 157be8e46..000000000 --- a/.flocks/flockshub/plugins/tools/api/onesig_v2_5_3_D20250710/onesig_v2_5_3_D20250710_device.yaml +++ /dev/null @@ -1,400 +0,0 @@ -name: onesig_v2_5_3_D20250710_device -description: > - OneSIG device management grouped tool. Use the `action` parameter to access - alert / notification policies, audit logs, user / login management, HTTPS - decryption, deployment guide, network interfaces, routes, DNS, HA, OneCC - (centralized control), system info, license, MDR service, system diagnosis, - and the device configuration / backup / upgrade flows under `/device/*`. -description_cn: > - OneSIG 平台管理分组工具。通过 `action` 调用告警/通知策略、管理日志、用户/登录安全 - 策略、HTTPS 解密、部署引导、接口、路由、DNS、HA 高可用、一体化管控、系统信息、 - 许可证、MDR 服务、设备诊断以及备份/升级等接口(覆盖 Web 控制台 `/device/*` 子页)。 -category: custom -enabled: true -requires_confirmation: true -provider: onesig_v2_5_3_D20250710_api -inputSchema: - type: object - properties: - action: - type: string - description: | - Device 分组动作名(按 UI 子页归类): - - # 通知配置 (/device/alert) - - alert_policy_list POST /v3/alert/policy/list - - alert_policy_enable POST /v3/alert/policy/enable - - alert_policy_delete DELETE /v3/alert/policy - - alert_policy_create POST /v3/alert/policy - - alert_policy_update PUT /v3/alert/policy - - alert_policy_export POST /v3/alert/policy/export (文件) - - alert_policy_find_by_config POST /v3/alert/policy/findByConfig - - alert_policy_object POST /v3/alert/policy/object - - get_notice_config GET /v3/setting/noticeConfig - - set_notice_config PUT /v3/setting/noticeConfig - - get_notice_send_key GET /v3/setting/noticeConfig/sendKey - - test_email POST /v3/test/email - - test_syslog POST /v3/test/syslog - - test_webhook POST /v3/test/webhook - - # 审计日志 (/device/audit) - - aclog_stat GET /v3/aclog/stat - - aclog_list POST /v3/aclog/list - - aclog_export POST /v3/aclog/export (文件) - - aclog_delete DELETE /v3/aclog (password 自动 RSA 加密) - - get_clean_config GET /v3/setting/cleanConfig - - set_clean_config PUT /v3/setting/cleanConfig - - # 登录管理 (/device/loginManagement) - - user_list POST /v3/user/list - - user_export POST /v3/user/export (文件) - - user_delete DELETE /v3/user (password 自动 RSA 加密) - - user_secret_reset PUT /v3/user/secret/reset (password 自动 RSA 加密) - - user_create POST /v3/user (password / dupPassword 自动 RSA 加密) - - user_update PUT /v3/user - - get_login_config GET /v3/setting/loginConfig - - set_login_config PUT /v3/setting/loginConfig - - # HTTPS 解密 (/device/httpsDecryption) - - get_decrypt_config GET /v3/setting/decryptConfig - - set_decrypt_config PUT /v3/setting/decryptConfig - - get_detect_config GET /v3/setting/detectConfig - - set_detect_config PUT /v3/setting/detectConfig - - tls_decrypt_policy_list POST /v3/tls/decrypt/policy/list - - tls_decrypt_policy_create POST /v3/tls/decrypt/policy - - tls_decrypt_policy_update PUT /v3/tls/decrypt/policy - - tls_decrypt_policy_enable POST /v3/tls/decrypt/policy/enable - - tls_decrypt_policy_delete DELETE /v3/tls/decrypt/policy - - tls_decrypt_policy_batch POST /v3/tls/decrypt/policy/batch - - tls_cert_list POST /v3/tls/cert/list - - tls_cert_create POST /v3/tls/cert (multipart:file_path 指向 .crt/.pem,字段名 certFile) - - tls_cert_update PUT /v3/tls/cert (multipart:同 tls_cert_create,body 需带 uniqueId) - - tls_cert_delete DELETE /v3/tls/cert - - tls_cert_set_default POST /v3/tls/cert/set_default - - tls_detect_list POST /v3/tls/detect/list - - tls_detect_list_detail POST /v3/tls/detect/list/detail - - tls_detect_delete DELETE /v3/tls/detect - - tls_detect_group POST /v3/tls/detect/group - - tls_detect_group_export POST /v3/tls/detect/group/export (文件) - - tls_detect_list_export POST /v3/tls/detect/list/export (文件) - - # 接口 / 部署引导 (/device/interface, /device/deployguide) - - interface_list GET /v3/interface/list - - interface_update PUT /v3/interface (启停场景 password 自动 RSA 加密) - - interface_check_loop POST /v3/interface/check/loop - - interface_relation_list GET /v3/interface/relation/list - - interface_select_list GET /v3/interface/select/list - - interface_virtual_line_create POST /v3/interface/virtualLine - - interface_virtual_line_update PUT /v3/interface/virtualLine - - interface_virtual_line_delete DELETE /v3/interface/virtualLine - - interface_listen_create POST /v3/interface/listen - - interface_listen_update PUT /v3/interface/listen - - interface_listen_delete DELETE /v3/interface/listen - - interface_bridge_create POST /v3/interface/bridge - - interface_bridge_update PUT /v3/interface/bridge - - interface_bridge_delete DELETE /v3/interface/bridge - - # 路由 (/device/route) - - route_outif_list GET /v3/route/outIf/list - - route_static_list POST /v3/route/static/list - - route_static_create POST /v3/route/static - - route_static_update PUT /v3/route/static - - route_static_delete DELETE /v3/route/static - - route_table_list POST /v3/route/table/list - - ipv6_route_static_list POST /v3/ipv6Route/static/list - - ipv6_route_static_create POST /v3/ipv6Route/static - - ipv6_route_static_update PUT /v3/ipv6Route/static - - ipv6_route_static_delete DELETE /v3/ipv6Route/static - - ipv6_route_table_list POST /v3/ipv6Route/table/list - - # DNS 配置 (/device/system_dns) - - get_dns_config GET /v3/setting/dnsConfig - - set_dns_config PUT /v3/setting/dnsConfig - - hosts_get GET /v3/setting/hosts - - hosts_create POST /v3/setting/hosts - - hosts_update PUT /v3/setting/hosts - - hosts_delete DELETE /v3/setting/hosts - - test_network GET /v3/test/network - - # 代理 (/device/agent) - - get_proxy_config GET /v3/setting/proxyConfig - - set_proxy_config PUT /v3/setting/proxyConfig - - test_proxy POST /v3/test/proxy - - # 高可用 (/device/high_availability) - - ha_status GET /v3/ha/status - - get_ha_config GET /v3/setting/haConfig - - set_ha_config PUT /v3/setting/haConfig - - ha_module_list GET /v3/ha/moduleList - - ha_compare_config POST /v3/ha/compareConfig - - ha_switching PUT /v3/ha/switching - - ha_sync_config POST /v3/ha/syncConfig - - ha_sync_status GET /v3/ha/syncStatus - - # 集中管控 (/device/centralized_control) - - onecc_status GET /v3/setting/oneccConfig/status - - get_onecc_config GET /v3/setting/oneccConfig - - set_onecc_config PUT /v3/setting/oneccConfig - - set_onecc_status PUT /v3/setting/oneccConfig/status - - test_onecc POST /v3/test/onecc - - # 设备配置 / 升级 / 备份 / 日志外发 (/device/deviceConfig) - - device_quick_bypass POST /v3/device/quickBypass - - device_upgrade_record_list POST /v3/device/upgradeRecord/list - - get_upgrade_config GET /v3/setting/upgradeConfig - - set_upgrade_config PUT /v3/setting/upgradeConfig - - basic_version GET /v3/basic/version - - device_upgrade_info GET /v3/device/upgradeInfo - - device_download_package POST /v3/device/downloadPackage - - device_upgrade POST /v3/device/upgrade (已下载包升级;password 自动 RSA 加密) - - device_upgrade_upload POST /v3/device/upgrade (本地上传包升级;multipart + password 自动 RSA 加密) - - system_upgrade POST /v3/system/upgrade (multipart:file_path 指向系统升级包) - - device_custom_get GET /v3/device/custom - - device_custom_set PUT /v3/device/custom - - device_reboot POST /v3/device/reboot - - device_shutdown POST /v3/device/shutdown - - device_reinit POST /v3/device/reinit - - device_system_timezone GET /v3/device/systemTimeZone - - device_system_time_get GET /v3/device/systemTime - - device_system_time_set PUT /v3/device/systemTime - - get_storage_config GET /v3/setting/storageConfig - - set_storage_config PUT /v3/setting/storageConfig - - backup_recover_progress GET /v3/backup/recover/progress - - backup_list POST /v3/backup/list - - backup_create POST /v3/backup - - backup_download GET /v3/backup/download (文件) - - backup_recover POST /v3/backup/recover - - backup_delete DELETE /v3/backup - - backup_update PUT /v3/backup - - backup_import POST /v3/backup/import (multipart:file_path 指向备份包) - - logaccess_list POST /v3/logAccess/list - - logaccess_delete DELETE /v3/logAccess - - logaccess_create POST /v3/logAccess - - logaccess_update PUT /v3/logAccess - - logaccess_sample POST /v3/logAccess/sample - - logaccess_test POST /v3/logAccess/test - - logaccess_check GET /v3/logAccess/check - - # 基本信息 (/device/system_info) - - basic_license_get GET /v3/basic/license - - basic_connect_status GET /v3/basic/connectStatus - - basic_information GET /v3/basic/information - - basic_information_enable POST /v3/basic/information/enable - - basic_information_import POST /v3/basic/information (multipart:file_path 指向离线情报库更新包,Query 需 name=...) - - basic_license_upload POST /v3/basic/license (multipart:file_path 指向授权文件) - - mdr_service_status GET /v3/mdrService/status - - mdr_service_enable PUT /v3/mdrService/enable - - # 设备诊断 (/device/system_diagnosis) - - device_coredump_list GET /v3/device/coredump - - device_coredump_download POST /v3/device/coredumpDownload (文件) - - device_coredump_delete DELETE /v3/device/coredump - - device_pcap_get GET /v3/device/pcap - - device_pcap_set PUT /v3/device/pcap - - device_pcap_file_list GET /v3/device/pcapFile - - device_pcap_download POST /v3/device/pcapDownload (文件) - - device_pcap_file_delete DELETE /v3/device/pcapFile - enum: - - alert_policy_list - - alert_policy_enable - - alert_policy_delete - - alert_policy_create - - alert_policy_update - - alert_policy_export - - alert_policy_find_by_config - - alert_policy_object - - get_notice_config - - set_notice_config - - get_notice_send_key - - test_email - - test_syslog - - test_webhook - - aclog_stat - - aclog_list - - aclog_export - - aclog_delete - - get_clean_config - - set_clean_config - - user_list - - user_export - - user_delete - - user_secret_reset - - user_create - - user_update - - get_login_config - - set_login_config - - get_decrypt_config - - set_decrypt_config - - get_detect_config - - set_detect_config - - tls_decrypt_policy_list - - tls_decrypt_policy_create - - tls_decrypt_policy_update - - tls_decrypt_policy_enable - - tls_decrypt_policy_delete - - tls_decrypt_policy_batch - - tls_cert_list - - tls_cert_create - - tls_cert_update - - tls_cert_delete - - tls_cert_set_default - - tls_detect_list - - tls_detect_list_detail - - tls_detect_delete - - tls_detect_group - - tls_detect_group_export - - tls_detect_list_export - - interface_list - - interface_update - - interface_check_loop - - interface_relation_list - - interface_select_list - - interface_virtual_line_create - - interface_virtual_line_update - - interface_virtual_line_delete - - interface_listen_create - - interface_listen_update - - interface_listen_delete - - interface_bridge_create - - interface_bridge_update - - interface_bridge_delete - - route_outif_list - - route_static_list - - route_static_create - - route_static_update - - route_static_delete - - route_table_list - - ipv6_route_static_list - - ipv6_route_static_create - - ipv6_route_static_update - - ipv6_route_static_delete - - ipv6_route_table_list - - get_dns_config - - set_dns_config - - hosts_get - - hosts_create - - hosts_update - - hosts_delete - - test_network - - get_proxy_config - - set_proxy_config - - test_proxy - - ha_status - - get_ha_config - - set_ha_config - - ha_module_list - - ha_compare_config - - ha_switching - - ha_sync_config - - ha_sync_status - - onecc_status - - get_onecc_config - - set_onecc_config - - set_onecc_status - - test_onecc - - device_quick_bypass - - device_upgrade_record_list - - get_upgrade_config - - set_upgrade_config - - basic_version - - device_upgrade_info - - device_download_package - - device_upgrade - - device_upgrade_upload - - system_upgrade - - device_custom_get - - device_custom_set - - device_reboot - - device_shutdown - - device_reinit - - device_system_timezone - - device_system_time_get - - device_system_time_set - - get_storage_config - - set_storage_config - - backup_recover_progress - - backup_list - - backup_create - - backup_download - - backup_recover - - backup_delete - - backup_update - - backup_import - - logaccess_list - - logaccess_delete - - logaccess_create - - logaccess_update - - logaccess_sample - - logaccess_test - - logaccess_check - - basic_license_get - - basic_connect_status - - basic_information - - basic_information_enable - - basic_information_import - - basic_license_upload - - mdr_service_status - - mdr_service_enable - - device_coredump_list - - device_coredump_download - - device_coredump_delete - - device_pcap_get - - device_pcap_set - - device_pcap_file_list - - device_pcap_download - - device_pcap_file_delete - uniqueId: - type: string - description: 资源唯一 ID(删除/下载等场景) - uniqueIds: - type: array - items: - type: string - description: 批量删除使用的 ID 列表 - pageNo: - type: integer - default: 1 - pageSize: - type: integer - default: 20 - startTime: - type: integer - description: 起始时间戳(管理日志统计 / 列表 / 导出场景使用) - endTime: - type: integer - description: 结束时间戳 - name: - type: string - description: 名称(用户名、配置项名称等) - enable: - type: boolean - description: 启停标识 - password: - type: string - description: | - 敏感写操作所需的当前用户登录密码(明文)。处理器会自动用 `/v3/pubkey` - 最新公钥做 RSA-OAEP 加密后再发送,无需调用方手动加密。 - 适用:aclog_delete、user_delete、user_secret_reset、user_create - (同时配合 dupPassword)、interface_update(接口启停)、 - device_upgrade、device_upgrade_upload。 - dupPassword: - type: string - description: | - 新增用户(user_create)使用的二次确认密码(明文)。处理器会与 - `password` 一起用同一公钥 RSA-OAEP 加密。 - file_path: - type: string - description: | - 本地待上传文件的绝对路径;仅 multipart 上传类动作使用: - - tls_cert_create / tls_cert_update .crt/.pem 证书文件(字段名 `certFile`) - - basic_information_import 离线情报库更新包(字段名 `file`,Query 需 `name`) - - basic_license_upload 授权 license 文件(字段名 `file`) - - system_upgrade 系统升级包(字段名 `file`) - - device_upgrade_upload 设备升级包(字段名 `file`,body 同时要求 password) - - backup_import 配置备份包(字段名 `file`) - required: - - action -handler: - type: script - script_file: onesig_v2_5_3_D20250710.handler.py - function: device diff --git a/.flocks/flockshub/plugins/tools/api/onesig_v2_5_3_D20250710/onesig_v2_5_3_D20250710_helper.yaml b/.flocks/flockshub/plugins/tools/api/onesig_v2_5_3_D20250710/onesig_v2_5_3_D20250710_helper.yaml deleted file mode 100644 index be0c6f9a0..000000000 --- a/.flocks/flockshub/plugins/tools/api/onesig_v2_5_3_D20250710/onesig_v2_5_3_D20250710_helper.yaml +++ /dev/null @@ -1,56 +0,0 @@ -name: onesig_v2_5_3_D20250710_helper -description: > - OneSIG help center grouped tool. Use the `action` parameter to access help - documentation, product news red dots, version information and feedback - endpoints under the `/helper/*` routes. -description_cn: > - OneSIG 帮助中心分组工具。通过 `action` 调用帮助文档、产品动态红点、版本信息以及 - 问题反馈接口,对应 Web 控制台 `/helper/*` 子页。 -category: custom -enabled: true -requires_confirmation: false -provider: onesig_v2_5_3_D20250710_api -inputSchema: - type: object - properties: - action: - type: string - description: | - Helper 分组动作名: - - document_list POST /v3/document/list 分页查询帮助文档 - - document_preview GET /v3/document/preview 预览帮助文档(需 fileName) - - product_news_get GET /v3/product/news 产品动态红点 - - product_news_mark_read PUT /v3/product/news 标记产品动态已读 - - product_version GET /v3/product/version 产品版本信息 - - product_issue POST /v3/product/issue 提交产品问题反馈 - enum: - - document_list - - document_preview - - product_news_get - - product_news_mark_read - - product_version - - product_issue - fileName: - type: string - description: 帮助文档文件名(document_preview 必填) - pageNo: - type: integer - default: 1 - pageSize: - type: integer - default: 20 - search: - type: string - description: 文档列表模糊搜索关键字 - documentUpdate: - type: boolean - description: 标记帮助文档红点已读 - versionUpdate: - type: boolean - description: 标记版本更新红点已读 - required: - - action -handler: - type: script - script_file: onesig_v2_5_3_D20250710.handler.py - function: helper diff --git a/.flocks/flockshub/plugins/tools/api/onesig_v2_5_3_D20250710/onesig_v2_5_3_D20250710_login.yaml b/.flocks/flockshub/plugins/tools/api/onesig_v2_5_3_D20250710/onesig_v2_5_3_D20250710_login.yaml deleted file mode 100644 index a73f9b618..000000000 --- a/.flocks/flockshub/plugins/tools/api/onesig_v2_5_3_D20250710/onesig_v2_5_3_D20250710_login.yaml +++ /dev/null @@ -1,134 +0,0 @@ -name: onesig_v2_5_3_D20250710_login -description: > - OneSIG (older v2.5) login / session management grouped tool. Same surface - as the standard `onesig_login`, but the login endpoint receives the - **plaintext** password directly (no `GET /v3/pubkey`, no RSA-OAEP on - `POST /v3/login`). Use the `action` parameter to log in (build a cookie - session), log out, change the current user's password, pull captcha / - pubkey / account info, and manage product news / TOTP recovery codes. - Most other `onesig_v2_5_3_D20250710_*` tools auto-login on demand so explicit - `login` calls are only needed when captcha or TOTP is required. - - Auth-flow endpoints handled internally (NOT registered as ActionSpecs): - - GET /v3/captcha fetched implicitly during login - - POST /v3/login driven by `login` action — body carries - `username` + **plaintext** `password` - - POST /v3/login/totp driven by `login` action when responseCode 1012 - or when the device requires inline TOTP - - POST /v3/logout driven by `logout` action - - GET /v3/pubkey still fetched *only* before sensitive *non-login* - write fields (change_password, user_create, …) - - PUT /v3/user/password driven by `change_password` action (each of - oldPassword / newPassword / dupPassword is - auto RSA-OAEP encrypted with the latest pubkey) -description_cn: > - OneSIG(v2.5 老版本)登录/会话管理工具。接口形态与标准 `onesig_login` 完全 - 一致,区别仅在于登录链路:本插件 `POST /v3/login` 直接发送**明文**密码, - 不再调 `GET /v3/pubkey` 也不做 RSA-OAEP 加密。 - 通过 `action` 调用登录、登出、改密、拉取验证码 / 账户信息,以及产品动态、 - TOTP 恢复码等接口。其他 `onesig_v2_5_3_D20250710_*` 工具会按需自动登录,仅在设备 - 启用图形验证码或 TOTP 时需要显式调用 `login` 并传入 `captcha`/`totp`。 - - 专用鉴权流程(已在处理器内部封装,不以 ActionSpec 形式开放): - - GET /v3/captcha 登录时自动拉取,判断是否需 captcha / inline TOTP - - POST /v3/login 由 `login` 动作驱动;body 形如 - `{username, password=<明文>, captcha?, checksum?}` - - POST /v3/login/totp `login` 动作在 responseCode 1012 或 inline-TOTP - 场景下自动续走 - - POST /v3/logout 由 `logout` 动作驱动 - - GET /v3/pubkey 仅在 *非登录* 的敏感写字段(改密 / 删用户 / - 删审计 / 接口启停 / 升级口令)发送前才拉取 - - PUT /v3/user/password 由 `change_password` 驱动;oldPassword / - newPassword / dupPassword 三个字段会用最新公钥 - 分别做 RSA-OAEP 加密(无需调用方手动加密) -category: custom -enabled: true -requires_confirmation: true -provider: onesig_v2_5_3_D20250710_api -inputSchema: - type: object - properties: - action: - type: string - description: | - Login 分组动作名,可选值: - - login - 用途: 显式触发登录流程(拉 captcha → POST /v3/login 携带**明文**密码,必要时再走 /v3/login/totp) - 必填: 无(用户名密码取自服务凭据) - 常用: `captcha`、`totp` - 风险提示: 大多数业务调用会按需自动登录,仅在启用验证码 / TOTP 时需手动登录; - 注意本插件**登录阶段以明文形式下发密码**,建议仅在内网受控网络 + HTTPS + verify_ssl=true 下使用 - - logout - 用途: 退出当前会话(POST /v3/logout)并清空本地 Cookie - 必填: 无 - 常用: 无 - 风险提示: 后续调用会重新登录 - - change_password - 用途: 修改当前用户密码(PUT /v3/user/password,三个密码字段会自动 RSA-OAEP 加密) - 必填: `new_password` - 常用: `old_password`、`dup_password`、`username` - 风险提示: 写操作;密码长度需 8~20 且含字母+数字+特殊字符 - - get_captcha - 用途: 获取图形验证码状态(GET /v3/captcha) - 必填: 无 - - get_pubkey - 用途: 获取登录 RSA 公钥(GET /v3/pubkey) - 必填: 无 - - get_account - 用途: 获取当前账户信息(GET /v3/account) - 必填: 无 - - get_recovery_code - 用途: 查看 TOTP 恢复码(GET /v3/user/recoveryCode) - 必填: 无 - - regenerate_recovery_code - 用途: 重新生成 TOTP 恢复码(PUT /v3/user/recoveryCode) - 必填: 无 - 风险提示: 旧恢复码立即失效 - - get_product_news - 用途: 获取产品动态红点状态(GET /v3/product/news) - 必填: 无 - - mark_product_news_read - 用途: 标记产品动态已读(PUT /v3/product/news) - 必填: 至少一个布尔字段(如 `documentUpdate=false`) - 风险提示: 写操作 - enum: - - login - - logout - - change_password - - get_captcha - - get_pubkey - - get_account - - get_recovery_code - - regenerate_recovery_code - - get_product_news - - mark_product_news_read - captcha: - type: string - description: 当设备启用图形验证码时填写(4 位)。仅在 `login` 动作有效。 - totp: - type: string - description: 当设备启用 TOTP(双因素)时填写动态口令或恢复码。仅在 `login` 动作有效。 - username: - type: string - description: 改密时使用的用户名,默认取服务凭据中的 `username`。 - new_password: - type: string - description: 新密码明文,长度 8~20,需包含字母、数字与特殊字符。仅在 `change_password` 动作必填。 - old_password: - type: string - description: 当前密码明文。强制改密分支可省略;常规改密必填。 - dup_password: - type: string - description: 新密码确认明文,留空时与 `new_password` 相同。 - documentUpdate: - type: boolean - description: 标记帮助文档红点是否已读(mark_product_news_read 使用)。 - versionUpdate: - type: boolean - description: 标记版本更新红点是否已读(mark_product_news_read 使用)。 - required: - - action -handler: - type: script - script_file: onesig_v2_5_3_D20250710.handler.py - function: login diff --git a/.flocks/flockshub/plugins/tools/api/onesig_v2_5_3_D20250710/onesig_v2_5_3_D20250710_monitoring.yaml b/.flocks/flockshub/plugins/tools/api/onesig_v2_5_3_D20250710/onesig_v2_5_3_D20250710_monitoring.yaml deleted file mode 100644 index c92802b0f..000000000 --- a/.flocks/flockshub/plugins/tools/api/onesig_v2_5_3_D20250710/onesig_v2_5_3_D20250710_monitoring.yaml +++ /dev/null @@ -1,256 +0,0 @@ -name: onesig_v2_5_3_D20250710_monitoring -description: > - OneSIG monitoring grouped tool. Use the `action` parameter to access - dashboard, threat overview, device status, alert host, inbound / outbound - threat events, and report management APIs. The handler accepts arbitrary - filter / pagination keys and forwards them to the JSON body, matching the - Web console behaviour. -description_cn: > - OneSIG 监控分组工具。通过 `action` 调用仪表盘、威胁防护大屏、设备状态、失陷主机、 - 入站 / 出站威胁事件、报告管理等接口。处理器会把所有过滤 / 分页字段透传到 JSON - 请求体,与 Web 控制台调用方式保持一致。 -category: custom -enabled: true -requires_confirmation: true -provider: onesig_v2_5_3_D20250710_api -inputSchema: - type: object - properties: - action: - type: string - description: | - Monitoring 分组动作名,按页面分类: - - # 仪表盘 (/monitoring/dashboard) - - dashboard_overview POST /v3/dashboard/overview 仪表盘总览数据 - - dashboard_outbound POST /v3/dashboard/outbound 仪表盘出站数据 - - dashboard_inbound POST /v3/dashboard/inbound 仪表盘入站数据 - - dashboard_zeroday POST /v3/dashboard/zeroday 仪表盘零日数据 - - dashboard_status GET /v3/dashboard/status 仪表盘服务状态 - - dashboard_ioc_type_sum GET /v3/dashboard/ioctypesum IOC 类型汇总 - - set_custom_config PUT /v3/setting/customConfig 更新仪表盘自定义配置(写) - - # 威胁防护大屏 (/monitoring/overview) - - common_threat_type_list GET /v3/common/threatTypeList 威胁类型列表 - - overview_event_inbound POST /v3/overview/eventInbound 入站事件 - - overview_event_outbound POST /v3/overview/eventOutbound 出站事件 - - overview_export_event_inbound POST /v3/overview/exportEventInbound 导出入站事件(文件) - - overview_export_event_outbound POST /v3/overview/exportEventOutbound 导出出站事件(文件) - - overview_asset_brief POST /v3/overview/assetBrief 资产简报 - - overview_asset_top POST /v3/overview/assetTop 资产 TOP - - overview_event_inbound_agg POST /v3/overview/eventInboundAgg 入站聚合 - - overview_export_event_inbound_agg POST /v3/overview/exportEventInboundAgg 导出入站聚合(文件) - - overview_event_outbound_agg POST /v3/overview/eventOutboundAgg 出站聚合 - - overview_export_event_outbound_agg POST /v3/overview/exportEventOutboundAgg 导出出站聚合(文件) - - overview_event_recent_agg POST /v3/overview/eventRecentAgg 近期事件聚合 - - overview_event_trend POST /v3/overview/eventTrend 事件趋势 - - overview_traffic_trend POST /v3/overview/trafficTrend 流量趋势 - - overview_stat POST /v3/overview/stat 总览统计卡片 - - overview_threat_type_proportion POST /v3/overview/threatTypeProportion 威胁类型占比 - - overview_ioc_type_proportion POST /v3/overview/iocTypeProportion IOC 类型占比 - - overview_export_threat_type_proportion POST /v3/overview/exportThreatTypeProportion 导出威胁类型占比(文件) - - overview_export_ioc_type_proportion POST /v3/overview/exportIocTypeProportion 导出 IOC 类型占比(文件) - - get_overview_config GET /v3/setting/overviewConfig 总览展示配置 - - set_overview_config PUT /v3/setting/overviewConfig 更新总览展示配置(写) - - # 设备状态 (/monitoring/status) - - device_platform_status GET /v3/device/platformStatus 平台运行状态 - - device_system_status GET /v3/device/systemStatus 系统资源状态 - - device_network_status GET /v3/device/networkStatus 网络状态 - - common_interface_list GET /v3/common/interfaceList 网口列表 - - basic_cpu_attr GET /v3/basic/cpuAttr CPU 属性 - - # 失陷主机 (/monitoring/hosts, /monitoring/hostdetail) - - alert_host_stat GET /v3/alertHost/stat 失陷主机统计 - - alert_host_tree POST /v3/alertHost/tree 失陷主机资产树 - - alert_host_list POST /v3/alertHost/list 分页查询失陷主机(必填 startTime/endTime) - - alert_host_export POST /v3/alertHost/export 导出失陷主机(文件) - - alert_host_detail POST /v3/alertHost/detail 失陷主机详情概览 - - alert_host_detail_list POST /v3/alertHost/detail/list 失陷主机详情关联列表 - - alert_host_detail_export POST /v3/alertHost/detail/export 导出失陷主机详情(文件) - - common_asset_type_list GET /v3/common/assetTypeList 资产类型列表 - - # 入站威胁 (/monitoring/inbound_threat) - - event_inbound_stat GET /v3/event/inbound/stat 入站威胁统计 - - event_inbound_list POST /v3/event/inbound/list 分页查询入站威胁(必填 startTime/endTime) - - event_inbound_export POST /v3/event/inbound/export 导出入站威胁(文件) - - event_inbound_detail POST /v3/event/inbound/detail 入站威胁详情 - - event_inbound_detail_trend POST /v3/event/inbound/detail/trend 入站威胁详情趋势 - - event_inbound_detail_list POST /v3/event/inbound/detail/list 入站威胁详情列表 - - event_inbound_detail_export POST /v3/event/inbound/detail/export 导出入站威胁详情(文件) - - port_protect_group_list POST /v3/portProtectGroup/list 端口防护组列表(共享) - - web_custom_column_set PUT /v3/webCustomColumn/set 保存自定义列配置(写) - - # 出站威胁 (/monitoring/outbound_threat) - - event_outbound_stat GET /v3/event/outbound/stat 出站威胁统计 - - event_outbound_list POST /v3/event/outbound/list 分页查询出站威胁(必填 startTime/endTime) - - event_outbound_export POST /v3/event/outbound/export 导出出站威胁(文件) - - event_outbound_detail POST /v3/event/outbound/detail 出站威胁详情 - - event_outbound_detail_trend POST /v3/event/outbound/detail/trend 出站威胁详情趋势 - - event_outbound_detail_list POST /v3/event/outbound/detail/list 出站威胁详情列表 - - event_outbound_detail_export POST /v3/event/outbound/detail/export 导出出站威胁详情(文件) - - set_dnslog_config PUT /v3/setting/dnslogConfig 更新 DNS 日志接入配置(写) - - # 报告管理 (/monitoring/report) - - get_notice_config GET /v3/setting/noticeConfig 通知配置 - - report_form_create POST /v3/report/form 新增报表表单 - - report_form_list POST /v3/report/form/list 分页查询报表表单 - - report_form_download GET /v3/report/form/download 下载报表(文件,需 uniqueId) - - report_form_delete DELETE /v3/report/form 删除报表表单 - - report_task_list POST /v3/report/task/list 分页查询报表任务 - - report_task_create POST /v3/report/task 新增报表任务 - - report_task_update PUT /v3/report/task 更新报表任务 - - report_task_delete DELETE /v3/report/task 删除报表任务 - - report_task_test POST /v3/report/task/test 测试报表任务 - - # 共享 (monitoring layout) - - common_asset_group_tree GET /v3/common/assetGroupTree 资产组树 - - ips_rule_create POST /v3/ips/rule 新增 IPS 自定义规则 - - ips_rule_apply POST /v3/ips/rule/apply 应用 IPS 规则变更 - - ips_ruleset_namelist POST /v3/ips/ruleset/namelist 规则集名称列表 - - ips_ruleset_referred POST /v3/ips/ruleset/referred 规则集引用关系 - - logaccess_stat GET /v3/logAccess/stat 日志接入统计 - - get_dnslog_config GET /v3/setting/dnslogConfig DNS 日志接入配置 - enum: - - dashboard_overview - - dashboard_outbound - - dashboard_inbound - - dashboard_zeroday - - dashboard_status - - dashboard_ioc_type_sum - - set_custom_config - - common_threat_type_list - - overview_event_inbound - - overview_event_outbound - - overview_export_event_inbound - - overview_export_event_outbound - - overview_asset_brief - - overview_asset_top - - overview_event_inbound_agg - - overview_export_event_inbound_agg - - overview_event_outbound_agg - - overview_export_event_outbound_agg - - overview_event_recent_agg - - overview_event_trend - - overview_traffic_trend - - overview_stat - - overview_threat_type_proportion - - overview_ioc_type_proportion - - overview_export_threat_type_proportion - - overview_export_ioc_type_proportion - - get_overview_config - - set_overview_config - - device_platform_status - - device_system_status - - device_network_status - - common_interface_list - - basic_cpu_attr - - alert_host_stat - - alert_host_tree - - alert_host_list - - alert_host_export - - alert_host_detail - - alert_host_detail_list - - alert_host_detail_export - - common_asset_type_list - - event_inbound_stat - - event_inbound_list - - event_inbound_export - - event_inbound_detail - - event_inbound_detail_trend - - event_inbound_detail_list - - event_inbound_detail_export - - port_protect_group_list - - web_custom_column_set - - event_outbound_stat - - event_outbound_list - - event_outbound_export - - event_outbound_detail - - event_outbound_detail_trend - - event_outbound_detail_list - - event_outbound_detail_export - - set_dnslog_config - - get_notice_config - - report_form_create - - report_form_list - - report_form_download - - report_form_delete - - report_task_list - - report_task_create - - report_task_update - - report_task_delete - - report_task_test - - common_asset_group_tree - - ips_rule_create - - ips_rule_apply - - ips_ruleset_namelist - - ips_ruleset_referred - - logaccess_stat - - get_dnslog_config - startTime: - type: integer - description: 起始时间戳(Unix 秒) - endTime: - type: integer - description: 结束时间戳(Unix 秒) - pageNo: - type: integer - default: 1 - description: 分页页码 - pageSize: - type: integer - default: 20 - description: 分页大小 - sortBy: - type: string - description: 排序字段 - orderBy: - type: string - description: 排序方向,`asc` / `desc` - search: - type: string - description: 模糊搜索关键字 - severity: - type: array - items: - type: integer - description: 威胁等级过滤 - threatType: - type: array - items: - type: string - description: 威胁类型过滤 - assetType: - type: array - items: - type: string - description: 资产类型过滤 - threatLabel: - type: array - items: - type: string - description: 特定威胁标签过滤 - threatState: - type: string - description: 失陷主机状态过滤 - assetGroup: - type: string - description: 资产组路径,`全部` 转为空串 - direction: - type: string - description: 威胁方向(inbound/outbound) - isTls: - type: boolean - description: 是否仅 TLS 加密流量 - uniqueId: - type: string - description: 资源唯一 ID(删除/下载/详情等) - fileName: - type: string - description: 文件名(下载报表表单) - required: - - action -handler: - type: script - script_file: onesig_v2_5_3_D20250710.handler.py - function: monitoring diff --git a/.flocks/flockshub/plugins/tools/api/onesig_v2_5_3_D20250710/onesig_v2_5_3_D20250710_strategy.yaml b/.flocks/flockshub/plugins/tools/api/onesig_v2_5_3_D20250710/onesig_v2_5_3_D20250710_strategy.yaml deleted file mode 100644 index 54d423b42..000000000 --- a/.flocks/flockshub/plugins/tools/api/onesig_v2_5_3_D20250710/onesig_v2_5_3_D20250710_strategy.yaml +++ /dev/null @@ -1,268 +0,0 @@ -name: onesig_v2_5_3_D20250710_strategy -description: > - OneSIG strategy grouped tool. Use the `action` parameter to manage protection - policies including global whitelist / blacklist, multi-dimensional blocking, - API keys, syslog auto-blacklist, FTP / SFTP linkage, IPS rules and rule sets, - HTTP blacklist, port protection groups, and the strategy landing page itself. -description_cn: > - OneSIG 防护策略分组工具。通过 `action` 调用全局白/黑名单、多维封锁、API 联动密钥、 - Syslog 自动封禁、FTP/SFTP 联动、IPS 规则和规则集、HTTP 黑名单、端口防护组以及策略 - 首页相关接口。多数写操作具有破坏性,请谨慎调用。 -category: custom -enabled: true -requires_confirmation: true -provider: onesig_v2_5_3_D20250710_api -inputSchema: - type: object - properties: - action: - type: string - description: | - Strategy 分组动作名(按 UI 子页归类): - - # 白名单 (/strategy/whitelist) - - whitelist_add POST /v3/globalWhitelist 新增(多方向) - - whitelist_update PUT /v3/globalWhitelist 更新(编辑单方向) - - whitelist_delete DELETE /v3/globalWhitelist 删除(uniqueIds 数组) - - whitelist_export POST /v3/globalWhitelist/export 导出(文件) - - whitelist_import POST /v3/globalWhitelist/import 导入 - - whitelist_template GET /v3/globalWhitelist/template 导入模板(文件) - - whitelist_list POST /v3/globalWhitelist/list 分页查询 - - whitelist_remove_batch DELETE /v3/globalWhitelist/remove 批量移除 - - # 黑名单 (/strategy/blacklist) - - blacklist_location_options GET /v3/blacklist/location 地理位置选项 - - blacklist_add POST /v3/globalBlacklist 新增 - - blacklist_update PUT /v3/globalBlacklist 更新 - - blacklist_delete DELETE /v3/globalBlacklist 删除 - - blacklist_check POST /v3/globalBlacklist/check 冲突校验 - - blacklist_export POST /v3/globalBlacklist/export 导出(文件) - - blacklist_import POST /v3/globalBlacklist/import 导入 - - blacklist_template GET /v3/globalBlacklist/template 导入模板(文件) - - blacklist_list POST /v3/globalBlacklist/list 分页查询 - - blacklist_remove_batch DELETE /v3/globalBlacklist/remove 批量移除 - - # 多维封锁 (/strategy/multi_block, /strategy/add_multi_block) - - multiblock_executelog_list POST /v3/multiblock/executelog - - multiblock_executelog_export POST /v3/multiblock/executelog/export (文件) - - multiblock_rule_delete DELETE /v3/multiblock/rule - - multiblock_rule_active POST /v3/multiblock/rule/active - - multiblock_rule_dict POST /v3/multiblock/rule/dict - - multiblock_rule_list POST /v3/multiblock/rule/list - - multiblock_rule_get POST /v3/multiblock/rule/get - - multiblock_rule_preview POST /v3/multiblock/rule/preview - - multiblock_rule_create POST /v3/multiblock/rule - - multiblock_rule_update PUT /v3/multiblock/rule - - # API 联动 (/strategy/api) - - apikey_delete DELETE /v3/apikey - - apikey_update PUT /v3/apikey - - apikey_create POST /v3/apikey - - apikey_list GET /v3/apikey/list - - apikey_secret GET /v3/apikey/secret 需 uniqueId - - # Syslog 自动封禁 (/strategy/syslog) - - auto_blacklist_delete DELETE /v3/autoBlacklist - - auto_blacklist_check POST /v3/autoBlacklist/check - - auto_blacklist_create POST /v3/autoBlacklist - - auto_blacklist_update PUT /v3/autoBlacklist - - auto_blacklist_list POST /v3/autoBlacklist/list - - auto_blacklist_trend GET /v3/autoBlacklist/trend 需 startTime/endTime - - auto_blacklist_sample POST /v3/autoBlacklist/sample - - # FTP/SFTP 联动 (/strategy/ftp) - - linkage_delete DELETE /v3/linkage - - linkage_create POST /v3/linkage - - linkage_update PUT /v3/linkage - - linkage_enable POST /v3/linkage/enable - - linkage_info GET /v3/linkage/info 需 uniqueId - - linkage_list POST /v3/linkage/list - - linkage_template GET /v3/linkage/template (文件) - - linkage_test POST /v3/linkage/test - - # 入侵防护 (/strategy/intrusion_prevention) - - ips_rule_create POST /v3/ips/rule - - ips_rule_all POST /v3/ips/rule/all 全量操作 - - ips_rule_apply POST /v3/ips/rule/apply 应用变更 - - ips_rule_list POST /v3/ips/rule/list - - ips_ruleset_create POST /v3/ips/ruleset - - ips_ruleset_update PUT /v3/ips/ruleset - - ips_ruleset_delete DELETE /v3/ips/ruleset - - ips_ruleset_info POST /v3/ips/ruleset/info - - ips_ruleset_list POST /v3/ips/ruleset/list - - ips_ruleset_namelist POST /v3/ips/ruleset/namelist - - ips_threat_types POST /v3/ips/threatTypes - - # HTTP 防护 (/strategy/httpProtect) - - http_blacklist_delete DELETE /v3/httpBlacklist - - http_blacklist_enable POST /v3/httpBlacklist/enable - - http_blacklist_export POST /v3/httpBlacklist/export (文件) - - http_blacklist_list POST /v3/httpBlacklist/list - - http_blacklist_create POST /v3/httpBlacklist - - http_blacklist_update PUT /v3/httpBlacklist - - get_advanced_config GET /v3/setting/advancedConfig 共享高级配置 - - set_advanced_config PUT /v3/setting/advancedConfig 共享高级配置(写) - - get_xff_config GET /v3/setting/xffConfig - - set_xff_config PUT /v3/setting/xffConfig - - # 高危端口防护 (/strategy/portProtect) - - port_protect_group_delete DELETE /v3/portProtectGroup - - port_protect_group_create POST /v3/portProtectGroup - - port_protect_group_update PUT /v3/portProtectGroup - - port_protect_group_clone POST /v3/portProtectGroup/clone - - port_protect_group_default_info GET /v3/portProtectGroup/defaultInfo - - port_protect_group_list_full POST /v3/portProtectGroup/list - - port_protect_port_delete DELETE /v3/portProtectGroup/port - - port_protect_port_create POST /v3/portProtectGroup/port - - port_protect_port_update PUT /v3/portProtectGroup/port - - port_protect_port_export POST /v3/portProtectGroup/port/export (文件) - - port_protect_port_list POST /v3/portProtectGroup/port/list - - port_protect_port_onekey_import POST /v3/portProtectGroup/port/onekeyImport - - port_protect_port_onekey_status GET /v3/portProtectGroup/port/onekeyImport - - port_protect_portinfo POST /v3/portProtectGroup/portinfo - - # 策略首页 (/strategy/strategy) - - device_onekey_bypass POST /v3/device/onekeyBypass - - protection_policy_delete DELETE /v3/protection/policy - - protection_policy_update PUT /v3/protection/policy - - protection_policy_get GET /v3/protection/policy 需 uniqueId - - protection_policy_tree GET /v3/protection/policy/tree - - set_scan_config PUT /v3/setting/scanConfig - enum: - - whitelist_add - - whitelist_update - - whitelist_delete - - whitelist_export - - whitelist_import - - whitelist_template - - whitelist_list - - whitelist_remove_batch - - blacklist_location_options - - blacklist_add - - blacklist_update - - blacklist_delete - - blacklist_check - - blacklist_export - - blacklist_import - - blacklist_template - - blacklist_list - - blacklist_remove_batch - - multiblock_executelog_list - - multiblock_executelog_export - - multiblock_rule_delete - - multiblock_rule_active - - multiblock_rule_dict - - multiblock_rule_list - - multiblock_rule_get - - multiblock_rule_preview - - multiblock_rule_create - - multiblock_rule_update - - apikey_delete - - apikey_update - - apikey_create - - apikey_list - - apikey_secret - - auto_blacklist_delete - - auto_blacklist_check - - auto_blacklist_create - - auto_blacklist_update - - auto_blacklist_list - - auto_blacklist_trend - - auto_blacklist_sample - - linkage_delete - - linkage_create - - linkage_update - - linkage_enable - - linkage_info - - linkage_list - - linkage_template - - linkage_test - - ips_rule_create - - ips_rule_all - - ips_rule_apply - - ips_rule_list - - ips_ruleset_create - - ips_ruleset_update - - ips_ruleset_delete - - ips_ruleset_info - - ips_ruleset_list - - ips_ruleset_namelist - - ips_threat_types - - http_blacklist_delete - - http_blacklist_enable - - http_blacklist_export - - http_blacklist_list - - http_blacklist_create - - http_blacklist_update - - get_advanced_config - - set_advanced_config - - get_xff_config - - set_xff_config - - port_protect_group_delete - - port_protect_group_create - - port_protect_group_update - - port_protect_group_clone - - port_protect_group_default_info - - port_protect_group_list_full - - port_protect_port_delete - - port_protect_port_create - - port_protect_port_update - - port_protect_port_export - - port_protect_port_list - - port_protect_port_onekey_import - - port_protect_port_onekey_status - - port_protect_portinfo - - device_onekey_bypass - - protection_policy_delete - - protection_policy_update - - protection_policy_get - - protection_policy_tree - - set_scan_config - uniqueId: - type: string - description: 资源唯一 ID(GET / DELETE 单条目时使用) - uniqueIds: - type: array - items: - type: string - description: 批量删除使用的 ID 列表 - whiteList: - type: array - items: - type: object - description: 白名单方向数组(whitelist_add 必填,元素含 `direction`) - direction: - type: string - description: 白名单方向(whitelist_update 必填,可选 inbound/outbound/both) - condition: - type: array - items: - type: object - description: 白/黑名单条件列表 - comments: - type: string - description: 备注 - pageNo: - type: integer - default: 1 - pageSize: - type: integer - default: 20 - startTime: - type: integer - description: 起始时间戳(auto_blacklist_trend 等使用) - endTime: - type: integer - description: 结束时间戳 - name: - type: string - description: 名称(如 API 联动密钥名 / 端口防护组名) - enable: - type: boolean - description: 启停标识 - required: - - action -handler: - type: script - script_file: onesig_v2_5_3_D20250710.handler.py - function: strategy diff --git a/.flocks/flockshub/plugins/tools/api/sangfor_af_v8_0_48/_provider.yaml b/.flocks/flockshub/plugins/tools/api/sangfor_af_v8_0_48/_provider.yaml deleted file mode 100644 index e5dbcd188..000000000 --- a/.flocks/flockshub/plugins/tools/api/sangfor_af_v8_0_48/_provider.yaml +++ /dev/null @@ -1,55 +0,0 @@ -name: sangfor_af -service_id: sangfor_af -version: "8.0.48" -description: > - Sangfor AF (Application Firewall) v8.0.48 REST API service. - Uses session-based cookie authentication: call login to obtain - a token, then supply it via Cookie header in subsequent requests. -description_cn: > - 深信服 AF 下一代防火墙 v8.0.48 REST API 服务。 - 使用基于 Session 的 Cookie 认证:首先调用 login 获取 token, - 后续请求在 Cookie 中携带 token。基础 URL 为设备管理地址(如 https://192.168.1.1)。 -auth: - type: custom - secret: sangfor_af_v8_0_48_username - secret_secret: sangfor_af_v8_0_48_password -credential_fields: - - key: username - label: 管理员用户名 - storage: secret - config_key: username - secret_id: sangfor_af_v8_0_48_username - input_type: text - required: true - - key: password - label: 管理员密码 - storage: secret - config_key: password - secret_id: sangfor_af_v8_0_48_password - input_type: password - required: true - - key: base_url - label: 设备地址 (Base URL) - storage: config - config_key: base_url - input_type: url - default: "https://192.168.1.1" -defaults: - base_url: "https://192.168.1.1" - timeout: 60 - category: custom - product_version: "8.0.48" -notes: | - 深信服 AF v8.0.48 API 认证流程: - 1. 在 AF WebUI「系统 → 管理员账号」勾选 WEBAPI 权限。 - 2. Handler 自动调用 POST /api/v1/namespaces/public/login, - 用配置的用户名/密码换取 token 并缓存(带 keepalive 自动续期)。 - 3. 后续所有请求由 Handler 自动注入 Cookie: token=。 - 4. token 默认 10 分钟无操作后失效,缓存命中失败时会自动重新登录。 - - `verify_ssl` 由表单底部「SSL 验证」开关控制,下列字段都会被 - 识别为 SSL 验证开关,按以下优先级取值: - 1. `verify_ssl` (主键) - 2. `ssl_verify` (兼容别名) - 3. `custom_settings.verify_ssl` (WebUI 表单写入位置) - 4. 兜底默认值 `False`(**默认关闭证书验证**,AF 设备通常使用自签名证书) diff --git a/.flocks/flockshub/plugins/tools/api/sangfor_af_v8_0_48/_test.yaml b/.flocks/flockshub/plugins/tools/api/sangfor_af_v8_0_48/_test.yaml deleted file mode 100644 index 18f46895e..000000000 --- a/.flocks/flockshub/plugins/tools/api/sangfor_af_v8_0_48/_test.yaml +++ /dev/null @@ -1,103 +0,0 @@ -schema_version: 1 -provider: sangfor_af - -# Service-level connectivity probe. -# get_system_version is a lightweight read-only GET that verifies -# authentication and basic reachability. -connectivity: - tool: sangfor_af_v48_status - params: - action: get_system_version - -# Tool-level test samples shown in the WebUI ToolDetailDrawer drop-down. -fixtures: - sangfor_af_v48_auth: - - label: "Session keepalive" - label_cn: "刷新 Session 保活" - tags: [smoke] - params: - action: keepalive - assert: - success: true - - sangfor_af_v48_status: - - label: "Get system version" - label_cn: "获取系统版本信息" - tags: [smoke] - params: - action: get_system_version - assert: - success: true - - - label: "Get CPU usage" - label_cn: "获取 CPU 使用率" - tags: [smoke] - params: - action: get_cpu_usage - - - label: "Get memory usage" - label_cn: "获取内存使用率" - tags: [smoke] - params: - action: get_memory_usage - - sangfor_af_v48_ops: - - label: "List blacklist entries" - label_cn: "查询黑名单列表" - tags: [smoke, blacklist] - params: - action: get_blackwhitelist - type: BLACK - assert: - success: true - - - label: "List whitelist entries" - label_cn: "查询白名单列表" - tags: [smoke, whitelist] - params: - action: get_blackwhitelist - type: WHITE - - - label: "List blocked attacker IPs" - label_cn: "查询封锁攻击者 IP 列表" - tags: [smoke, blockip] - params: - action: get_blockip_list - assert: - success: true - - sangfor_af_v48_objects: - - label: "List IP groups" - label_cn: "查询 IP 地址组列表" - tags: [smoke] - params: - action: get_ipgroups - assert: - success: true - - - label: "List services" - label_cn: "查询服务对象列表" - tags: [smoke] - params: - action: get_services - assert: - success: true - - sangfor_af_v48_network: - - label: "List routing table (all routes)" - label_cn: "查询完整路由表" - tags: [smoke] - params: - action: get_routes - routeType: ALL_ROUTE - assert: - success: true - - sangfor_af_v48_system: - - label: "List admin accounts" - label_cn: "查询管理员账户列表" - tags: [smoke] - params: - action: get_accounts - assert: - success: true diff --git a/.flocks/flockshub/plugins/tools/api/sangfor_af_v8_0_48/sangfor_af.handler.py b/.flocks/flockshub/plugins/tools/api/sangfor_af_v8_0_48/sangfor_af.handler.py deleted file mode 100644 index d6c01d890..000000000 --- a/.flocks/flockshub/plugins/tools/api/sangfor_af_v8_0_48/sangfor_af.handler.py +++ /dev/null @@ -1,689 +0,0 @@ -""" -Sangfor AF (Application Firewall) v8.0.48 API Handler. - -Authentication: - - Session-based: POST /api/v1/namespaces/public/login → token - - All subsequent requests: Cookie: token= - - Token expires after ~10 min of inactivity (keepalive resets timer) - -API base URL: https:// -Namespace: /api/v1/namespaces/public/ -Batch ops: /api/batch/v1/namespaces/public/ -""" -from __future__ import annotations - -import os -from typing import Any, Callable, Optional - -import aiohttp - -from flocks.config.config_writer import ConfigWriter -from flocks.tool.registry import ToolContext, ToolResult - -# ── Constants ──────────────────────────────────────────────────────────────── - -SERVICE_ID = "sangfor_af_v8_0_48" -DEFAULT_BASE_URL = "https://192.168.1.1" -DEFAULT_TIMEOUT = 60 -NAMESPACE = "public" - -API_V1 = f"/api/v1/namespaces/{NAMESPACE}" -API_BATCH = f"/api/batch/v1/namespaces/{NAMESPACE}" - -# In-process token cache: {base_url: token} -_TOKEN_CACHE: dict[str, str] = {} - - -# ── Secret / Config helpers ─────────────────────────────────────────────────── - -def _get_secret_manager(): - from flocks.security import get_secret_manager - return get_secret_manager() - - -def _resolve_ref(value: Any) -> Optional[str]: - if value is None: - return None - if not isinstance(value, str): - return str(value) - if value.startswith("{secret:") and value.endswith("}"): - return _get_secret_manager().get(value[len("{secret:"): -1]) - if value.startswith("{env:") and value.endswith("}"): - return os.getenv(value[len("{env:"): -1]) - return value - - -def _service_config() -> dict[str, Any]: - raw = ConfigWriter.get_api_service_raw(SERVICE_ID) - return raw if isinstance(raw, dict) else {} - - -def _resolve_verify_ssl(raw: dict[str, Any]) -> bool: - """Read verify_ssl with the same priority as sangfor_sip / onesec: - verify_ssl > ssl_verify > custom_settings.verify_ssl > False. - AF devices commonly use self-signed certs, so default is False. - """ - value = raw.get("verify_ssl") - if value is None: - value = raw.get("ssl_verify") - if value is None: - custom = raw.get("custom_settings") - if isinstance(custom, dict): - value = custom.get("verify_ssl") - if isinstance(value, bool): - return value - if isinstance(value, str): - return value.strip().lower() in {"1", "true", "yes", "on"} - return False - - -def _resolve_runtime_config() -> tuple[str, int, str, str, bool]: - """Returns (base_url, timeout, username, password, verify_ssl).""" - raw = _service_config() - base_url = ( - _resolve_ref(raw.get("base_url")) or DEFAULT_BASE_URL - ).rstrip("/") - timeout = raw.get("timeout", DEFAULT_TIMEOUT) - try: - timeout = int(timeout) - except (TypeError, ValueError): - timeout = DEFAULT_TIMEOUT - - sm = _get_secret_manager() - - username = ( - _resolve_ref(raw.get("username")) - or sm.get("sangfor_af_v8_0_48_username") - or os.getenv("AF_USERNAME") - ) - password = ( - _resolve_ref(raw.get("password")) - or sm.get("sangfor_af_v8_0_48_password") - or os.getenv("AF_PASSWORD") - ) - - if not username or not password: - raise ValueError( - "AF API credentials not configured. " - "Please set username and password in the service configuration." - ) - return base_url, timeout, username, password, _resolve_verify_ssl(raw) - - -# ── Session / Token management ──────────────────────────────────────────────── - -async def _login( - session: aiohttp.ClientSession, - base_url: str, - username: str, - password: str, - verify_ssl: bool, -) -> tuple[Optional[str], Optional[str]]: - """Login and return (token, error_message).""" - url = f"{base_url}{API_V1}/login" - try: - async with session.post( - url, - json={"name": username, "password": password}, - ssl=verify_ssl, - ) as resp: - data = await resp.json(content_type=None) - except aiohttp.ClientError as exc: - return None, f"AF login request failed: {exc}" - - code = data.get("code") - if code != 0: - msg = data.get("message", "Unknown error") - return None, f"AF login failed (code={code}): {msg}" - - token = ( - data.get("data", {}).get("loginResult", {}).get("token") - ) - if not token: - return None, "AF login succeeded but no token returned" - return token, None - - -async def _get_token( - session: aiohttp.ClientSession, - base_url: str, - username: str, - password: str, - verify_ssl: bool, -) -> tuple[Optional[str], Optional[str]]: - """Return cached token or obtain a new one.""" - cached = _TOKEN_CACHE.get(base_url) - if cached: - # Validate by keepalive - try: - async with session.get( - f"{base_url}{API_V1}/keepalive", - headers={"Cookie": f"token={cached}"}, - ssl=verify_ssl, - ) as resp: - ka_data = await resp.json(content_type=None) - if ka_data.get("code") == 0: - return cached, None - except Exception: - pass - - token, err = await _login(session, base_url, username, password, verify_ssl) - if err: - return None, err - _TOKEN_CACHE[base_url] = token - return token, None - - -# ── Low-level HTTP ──────────────────────────────────────────────────────────── - -def _pick(params: dict[str, Any], *keys: str) -> dict[str, Any]: - return {k: params[k] for k in keys if k in params and params[k] is not None} - - -def _af_result(action: str, payload: Any) -> ToolResult: - metadata = {"source": "Sangfor AF", "api": action, "version": "8.0.48"} - if isinstance(payload, dict): - code = payload.get("code") - if code not in (None, 0): - msg = payload.get("message", "Unknown error") - return ToolResult( - success=False, - error=f"AF API error (code={code}): {msg}", - metadata=metadata, - ) - return ToolResult( - success=True, - output=payload.get("data", payload), - metadata=metadata, - ) - return ToolResult(success=True, output=payload, metadata=metadata) - - -async def _call( - method: str, - path: str, - params: Optional[dict[str, Any]] = None, - json: Optional[Any] = None, - action: str = "", -) -> ToolResult: - """Execute an authenticated AF API request.""" - try: - base_url, timeout, username, password, verify_ssl = _resolve_runtime_config() - except ValueError as exc: - return ToolResult(success=False, error=str(exc)) - - headers = {"Content-Type": "application/json"} - - async with aiohttp.ClientSession( - timeout=aiohttp.ClientTimeout(total=timeout) - ) as session: - token, err = await _get_token(session, base_url, username, password, verify_ssl) - if err: - return ToolResult(success=False, error=err) - - headers["Cookie"] = f"token={token}" - url = f"{base_url}{path}" - - try: - async with session.request( - method.upper(), - url, - params=params, - json=json, - headers=headers, - ssl=verify_ssl, - ) as resp: - if resp.status >= 400: - text = await resp.text() - return ToolResult( - success=False, - error=f"HTTP {resp.status}: {text[:500]}", - ) - data = await resp.json(content_type=None) - except aiohttp.ClientError as exc: - return ToolResult(success=False, error=f"Request failed: {exc}") - except Exception as exc: - return ToolResult(success=False, error=f"Unexpected error: {exc}") - - return _af_result(action or path.rsplit("/", 1)[-1], data) - - -# ── Action specs ───────────────────────────────────────────────────────────── - -class ActionSpec: - def __init__( - self, - method: str, - path_template: str, - param_builder: Callable[[dict[str, Any]], tuple[ - Optional[dict], Optional[Any] - ]], - required: tuple[str, ...] = (), - ) -> None: - self.method = method - self.path_template = path_template - self.param_builder = param_builder - self.required = required - - def build_path(self, params: dict[str, Any]) -> str: - try: - return self.path_template.format(**params) - except KeyError: - return self.path_template - - -# ── Auth actions ────────────────────────────────────────────────────────────── - -async def _do_login(ctx: ToolContext, **params: Any) -> ToolResult: - """Explicitly login and refresh the cached token.""" - del ctx - try: - base_url, timeout, username, password, verify_ssl = _resolve_runtime_config() - except ValueError as exc: - return ToolResult(success=False, error=str(exc)) - - async with aiohttp.ClientSession( - timeout=aiohttp.ClientTimeout(total=timeout) - ) as session: - token, err = await _login(session, base_url, username, password, verify_ssl) - if err: - return ToolResult(success=False, error=err) - _TOKEN_CACHE[base_url] = token - return ToolResult( - success=True, - output={"token": token, "message": "Login successful"}, - metadata={"source": "Sangfor AF", "api": "login", "version": "8.0.48"}, - ) - - -async def _do_logout(ctx: ToolContext, **params: Any) -> ToolResult: - del ctx - result = await _call("POST", f"{API_V1}/logout", action="logout") - try: - base_url, *_ = _resolve_runtime_config() - _TOKEN_CACHE.pop(base_url, None) - except ValueError: - pass - return result - - -async def _do_keepalive(ctx: ToolContext, **params: Any) -> ToolResult: - del ctx - return await _call("GET", f"{API_V1}/keepalive", action="keepalive") - - -# ── Objects actions ────────────────────────────────────────────────────────── - -async def _do_get_ipgroups(ctx: ToolContext, **params: Any) -> ToolResult: - del ctx - query = _pick( - params, - "_start", "_length", "businessType", "__nameprefix", "important", - "_search", "_order", "_sortby", "addressType", - ) - return await _call("GET", f"{API_V1}/ipgroups", params=query, action="get_ipgroups") - - -async def _do_get_ipgroup(ctx: ToolContext, **params: Any) -> ToolResult: - del ctx - uuid = params.get("uuid", "") - return await _call("GET", f"{API_V1}/ipgroups/{uuid}", action="get_ipgroup") - - -async def _do_create_ipgroup(ctx: ToolContext, **params: Any) -> ToolResult: - del ctx - body = _pick( - params, - "name", "businessType", "description", "addressType", "important", - "ipRanges", "creator", - ) - return await _call("POST", f"{API_V1}/ipgroups", json={"obj": body}, action="create_ipgroup") - - -async def _do_update_ipgroup(ctx: ToolContext, **params: Any) -> ToolResult: - del ctx - uuid = params.get("uuid", "") - body = _pick( - params, - "name", "businessType", "description", "addressType", "important", - "ipRanges", "creator", - ) - return await _call("PATCH", f"{API_V1}/ipgroups/{uuid}", json={"obj": body}, action="update_ipgroup") - - -async def _do_delete_ipgroup(ctx: ToolContext, **params: Any) -> ToolResult: - del ctx - uuid = params.get("uuid", "") - return await _call("DELETE", f"{API_V1}/ipgroups/{uuid}", action="delete_ipgroup") - - -async def _do_get_services(ctx: ToolContext, **params: Any) -> ToolResult: - del ctx - query = _pick(params, "_start", "_length", "_search", "_order", "_sortby", "serviceType") - return await _call("GET", f"{API_V1}/services", params=query, action="get_services") - - -async def _do_get_service(ctx: ToolContext, **params: Any) -> ToolResult: - del ctx - uuid = params.get("uuid", "") - return await _call("GET", f"{API_V1}/services/{uuid}", action="get_service") - - -# ── Operations center actions ───────────────────────────────────────────────── - -async def _do_get_blackwhitelist(ctx: ToolContext, **params: Any) -> ToolResult: - del ctx - query = _pick(params, "type", "_start", "_length", "_search", "_order", "description") - return await _call("GET", f"{API_V1}/whiteblacklist", params=query, action="get_blackwhitelist") - - -async def _do_add_blackwhitelist(ctx: ToolContext, **params: Any) -> ToolResult: - del ctx - body = _pick(params, "url", "type", "enable", "description", "domain") - return await _call("POST", f"{API_V1}/whiteblacklist", json={"obj": body}, action="add_blackwhitelist") - - -async def _do_batch_add_blackwhitelist(ctx: ToolContext, **params: Any) -> ToolResult: - del ctx - items = params.get("items", []) - return await _call( - "POST", - f"{API_BATCH}/whiteblacklist", - json=items, - action="batch_add_blackwhitelist", - ) - - -async def _do_delete_blackwhitelist(ctx: ToolContext, **params: Any) -> ToolResult: - del ctx - url_param = params.get("url", "") - list_type = params.get("type", "") - query = {"type": list_type} if list_type else None - return await _call( - "DELETE", - f"{API_V1}/whiteblacklist/{url_param}", - params=query, - action="delete_blackwhitelist", - ) - - -async def _do_batch_delete_blackwhitelist(ctx: ToolContext, **params: Any) -> ToolResult: - del ctx - items = params.get("items", []) - return await _call( - "POST", - f"{API_BATCH}/whiteblacklist", - params={"_method": "DELETE"}, - json=items, - action="batch_delete_blackwhitelist", - ) - - -async def _do_get_blockip_list(ctx: ToolContext, **params: Any) -> ToolResult: - del ctx - query = _pick(params, "_start", "_length", "_sortby", "_order", "creator", "fuzzyIP") - return await _call("GET", f"{API_V1}/blockip", params=query, action="get_blockip_list") - - -async def _do_batch_add_blockip(ctx: ToolContext, **params: Any) -> ToolResult: - del ctx - items = params.get("items", []) - query = _pick(params, "aifwType") - return await _call( - "POST", - f"{API_BATCH}/blockip", - params=query or None, - json=items, - action="batch_add_blockip", - ) - - -async def _do_batch_delete_blockip(ctx: ToolContext, **params: Any) -> ToolResult: - del ctx - items = params.get("items", []) - return await _call( - "POST", - f"{API_BATCH}/blockip", - params={"_method": "DELETE"}, - json=items, - action="batch_delete_blockip", - ) - - -async def _do_clear_blockip(ctx: ToolContext, **params: Any) -> ToolResult: - del ctx - query = _pick(params, "creator") - return await _call( - "DELETE", - f"{API_V1}/blockip", - params=query or None, - action="clear_blockip", - ) - - -async def _do_get_blockip_auto_config(ctx: ToolContext, **params: Any) -> ToolResult: - del ctx - return await _call("GET", f"{API_V1}/blockip/autoconfig", action="get_blockip_auto_config") - - -async def _do_set_blockip_auto_config(ctx: ToolContext, **params: Any) -> ToolResult: - del ctx - body = _pick(params, "blockTime") - return await _call( - "PUT", - f"{API_V1}/blockip/autoconfig", - json={"obj": body}, - action="set_blockip_auto_config", - ) - - -# ── Status / device info actions ────────────────────────────────────────────── - -async def _do_get_memory_usage(ctx: ToolContext, **params: Any) -> ToolResult: - del ctx - return await _call("GET", f"{API_V1}/memoryusage", action="get_memory_usage") - - -async def _do_get_cpu_usage(ctx: ToolContext, **params: Any) -> ToolResult: - del ctx - return await _call("GET", f"{API_V1}/cpuusage", action="get_cpu_usage") - - -async def _do_get_disk_usage(ctx: ToolContext, **params: Any) -> ToolResult: - del ctx - return await _call("GET", f"{API_V1}/diskusage", action="get_disk_usage") - - -async def _do_get_system_version(ctx: ToolContext, **params: Any) -> ToolResult: - del ctx - query = _pick(params, "filter") - return await _call( - "GET", f"{API_V1}/systemversion", - params=query or None, - action="get_system_version", - ) - - -async def _do_get_interface_status(ctx: ToolContext, **params: Any) -> ToolResult: - del ctx - # AF8.0.x: /interfacestatus returns 1002; use /interfaces (list) or - # /interfaces/status?interfaceName= (single interface query). - iface = params.get("interfaceNames") or params.get("interfaceName") or "" - if iface: - return await _call( - "GET", f"{API_V1}/interfaces/status", - params={"interfaceName": iface}, - action="get_interface_status", - ) - return await _call("GET", f"{API_V1}/interfaces", action="get_interface_status") - - -async def _do_get_runtime_status(ctx: ToolContext, **params: Any) -> ToolResult: - del ctx - return await _call("GET", f"{API_V1}/runtimestatus", action="get_runtime_status") - - -async def _do_get_current_time(ctx: ToolContext, **params: Any) -> ToolResult: - del ctx - return await _call("GET", f"{API_V1}/currenttime", action="get_current_time") - - -# ── Network / routing actions ───────────────────────────────────────────────── - -async def _do_get_routes(ctx: ToolContext, **params: Any) -> ToolResult: - del ctx - query = _pick(params, "_start", "_length", "routeType", "_search") - return await _call("GET", f"{API_V1}/routes", params=query or None, action="get_routes") - - -async def _do_get_routes_ipv6(ctx: ToolContext, **params: Any) -> ToolResult: - del ctx - query = _pick(params, "_start", "_length", "routeType", "_search") - return await _call("GET", f"{API_V1}/routes/ipv6", params=query or None, action="get_routes_ipv6") - - -# ── Admin account actions ───────────────────────────────────────────────────── - -async def _do_get_accounts(ctx: ToolContext, **params: Any) -> ToolResult: - del ctx - query = _pick(params, "_start", "_length", "_search", "enable") - return await _call("GET", f"{API_V1}/account", params=query or None, action="get_accounts") - - -async def _do_get_account(ctx: ToolContext, **params: Any) -> ToolResult: - del ctx - name = params.get("name", "") - return await _call("GET", f"{API_V1}/account/{name}", action="get_account") - - -# ── Action dispatch ─────────────────────────────────────────────────────────── - -_ACTION_MAP: dict[str, Callable] = { - # Auth - "login": _do_login, - "logout": _do_logout, - "keepalive": _do_keepalive, - # Objects - "get_ipgroups": _do_get_ipgroups, - "get_ipgroup": _do_get_ipgroup, - "create_ipgroup": _do_create_ipgroup, - "update_ipgroup": _do_update_ipgroup, - "delete_ipgroup": _do_delete_ipgroup, - "get_services": _do_get_services, - "get_service": _do_get_service, - # Operations center - blacklist/whitelist - "get_blackwhitelist": _do_get_blackwhitelist, - "add_blackwhitelist": _do_add_blackwhitelist, - "batch_add_blackwhitelist": _do_batch_add_blackwhitelist, - "delete_blackwhitelist": _do_delete_blackwhitelist, - "batch_delete_blackwhitelist": _do_batch_delete_blackwhitelist, - # Operations center - blocked IPs - "get_blockip_list": _do_get_blockip_list, - "batch_add_blockip": _do_batch_add_blockip, - "batch_delete_blockip": _do_batch_delete_blockip, - "clear_blockip": _do_clear_blockip, - "get_blockip_auto_config": _do_get_blockip_auto_config, - "set_blockip_auto_config": _do_set_blockip_auto_config, - # Status - "get_memory_usage": _do_get_memory_usage, - "get_cpu_usage": _do_get_cpu_usage, - "get_disk_usage": _do_get_disk_usage, - "get_system_version": _do_get_system_version, - "get_interface_status": _do_get_interface_status, - "get_runtime_status": _do_get_runtime_status, - "get_current_time": _do_get_current_time, - # Network - "get_routes": _do_get_routes, - "get_routes_ipv6": _do_get_routes_ipv6, - # System - "get_accounts": _do_get_accounts, - "get_account": _do_get_account, -} - -GROUP_ACTIONS: dict[str, set[str]] = { - "auth": {"login", "logout", "keepalive"}, - "objects": {"get_ipgroups", "get_ipgroup", "create_ipgroup", "update_ipgroup", "delete_ipgroup", "get_services", "get_service"}, - "ops": { - "get_blackwhitelist", "add_blackwhitelist", "batch_add_blackwhitelist", - "delete_blackwhitelist", "batch_delete_blackwhitelist", - "get_blockip_list", "batch_add_blockip", "batch_delete_blockip", - "clear_blockip", "get_blockip_auto_config", "set_blockip_auto_config", - }, - "status": { - "get_memory_usage", "get_cpu_usage", "get_disk_usage", - "get_system_version", "get_interface_status", - "get_runtime_status", "get_current_time", - }, - "network": {"get_routes", "get_routes_ipv6"}, - "system": {"get_accounts", "get_account"}, -} - -_CONNECTIVITY_TEST_ACTIONS: dict[str, str] = { - "auth": "keepalive", - "objects": "get_ipgroups", - "ops": "get_blackwhitelist", - "status": "get_system_version", - "network": "get_routes", - "system": "get_accounts", -} - - -async def unified_ops(ctx: ToolContext, action: str, **params: Any) -> ToolResult: - handler = _ACTION_MAP.get(action) - if handler is None: - available = ", ".join(sorted(_ACTION_MAP)) - return ToolResult( - success=False, - error=f"Unknown action: {action}. Available: {available}", - ) - return await handler(ctx, **params) - - -async def _dispatch_group(ctx: ToolContext, group: str, action: str, **params: Any) -> ToolResult: - if action == "test": - test_action = _CONNECTIVITY_TEST_ACTIONS.get(group, "get_system_version") - return await unified_ops(ctx, action=test_action, **params) - if action not in GROUP_ACTIONS[group]: - available = ", ".join(sorted(GROUP_ACTIONS[group])) - return ToolResult( - success=False, - error=f"Unsupported {group} action: {action}. Available: {available}", - ) - return await unified_ops(ctx, action=action, **params) - - -async def auth(ctx: ToolContext, action: str, **params: Any) -> ToolResult: - return await _dispatch_group(ctx, "auth", action, **params) - - -async def objects(ctx: ToolContext, action: str, **params: Any) -> ToolResult: - return await _dispatch_group(ctx, "objects", action, **params) - - -async def ops(ctx: ToolContext, action: str, **params: Any) -> ToolResult: - return await _dispatch_group(ctx, "ops", action, **params) - - -async def status(ctx: ToolContext, action: str, **params: Any) -> ToolResult: - return await _dispatch_group(ctx, "status", action, **params) - - -async def network(ctx: ToolContext, action: str, **params: Any) -> ToolResult: - return await _dispatch_group(ctx, "network", action, **params) - - -async def system(ctx: ToolContext, action: str, **params: Any) -> ToolResult: - return await _dispatch_group(ctx, "system", action, **params) - - -def _make_action_function(action: str): - async def _tool(ctx: ToolContext, **kwargs: Any) -> ToolResult: - return await unified_ops(ctx, action=action, **kwargs) - _tool.__name__ = action - return _tool - - -for _action_name in _ACTION_MAP: - globals()[_action_name] = _make_action_function(_action_name) - -del _action_name diff --git a/.flocks/flockshub/plugins/tools/api/sangfor_af_v8_0_48/sangfor_af_v48_auth.yaml b/.flocks/flockshub/plugins/tools/api/sangfor_af_v8_0_48/sangfor_af_v48_auth.yaml deleted file mode 100644 index ecb99db68..000000000 --- a/.flocks/flockshub/plugins/tools/api/sangfor_af_v8_0_48/sangfor_af_v48_auth.yaml +++ /dev/null @@ -1,44 +0,0 @@ -name: sangfor_af_v48_auth -description: > - Sangfor AF v8.0.48 authentication tool. Use the `action` parameter to - login, logout, or keep the session alive. Token is cached automatically - after a successful login. -description_cn: > - 深信服 AF v8.0.48 认证工具。通过 `action` 参数调用登录、注销或 token 保活接口。 - 登录成功后 token 会自动缓存,后续调用无需手动传 token。 -category: custom -enabled: true -requires_confirmation: false -provider: sangfor_af -inputSchema: - type: object - properties: - action: - type: string - description: | - 认证动作名,可选值: - - login - 用途: 登录设备,获取 session token(token 自动缓存) - 必填: 无(用户名/密码从服务配置读取) - 风险提示: 只读认证接口 - 是否任务型: 否 - - logout - 用途: 注销当前登录 session,清除 token 缓存 - 必填: 无 - 风险提示: 写操作,注销后需重新登录 - 是否任务型: 否 - - keepalive - 用途: 刷新 token 超时计时器,保持 session 活跃 - 必填: 无 - 风险提示: 只读接口 - 是否任务型: 否 - enum: - - login - - logout - - keepalive - required: - - action -handler: - type: script - script_file: sangfor_af.handler.py - function: auth diff --git a/.flocks/flockshub/plugins/tools/api/sangfor_af_v8_0_48/sangfor_af_v48_network.yaml b/.flocks/flockshub/plugins/tools/api/sangfor_af_v8_0_48/sangfor_af_v48_network.yaml deleted file mode 100644 index 5dbdd334b..000000000 --- a/.flocks/flockshub/plugins/tools/api/sangfor_af_v8_0_48/sangfor_af_v48_network.yaml +++ /dev/null @@ -1,65 +0,0 @@ -name: sangfor_af_v48_network -description: > - Sangfor AF v8.0.48 network tool. Query routing tables (IPv4 and IPv6) - and network-related status information. -description_cn: > - 深信服 AF v8.0.48 网络工具。通过 `action` 参数查询路由表(IPv4/IPv6) - 及网络相关状态信息。 -category: custom -enabled: true -requires_confirmation: false -provider: sangfor_af -inputSchema: - type: object - properties: - action: - type: string - description: | - 网络查询动作名,可选值: - - get_routes - 用途: 获取后台 IPv4 路由信息列表 - 必填: 无 - 常用: routeType(ALL_ROUTE/STATIC_ROUTE/DIRECT_ROUTE 等)、_start、_length - 风险提示: 只读查询接口 - 是否任务型: 否 - - get_routes_ipv6 - 用途: 获取后台 IPv6 路由信息列表 - 必填: 无 - 常用: routeType、_start、_length - 风险提示: 只读查询接口 - 是否任务型: 否 - enum: - - get_routes - - get_routes_ipv6 - routeType: - type: string - description: > - 路由类型过滤:ALL_ROUTE=所有路由,STATIC_ROUTE=静态路由, - DIRECT_ROUTE=直连路由,OSPF_ROUTE=OSPF路由,RIP_ROUTE=RIP路由, - VPN_ROUTE=VPN路由,SSL_VPN_ROUTE=SSL VPN路由, - IBGP_ROUTE=IBGP路由,EBGP_ROUTE=EBGP路由 - enum: - - ALL_ROUTE - - STATIC_ROUTE - - DIRECT_ROUTE - - OSPF_ROUTE - - RIP_ROUTE - - VPN_ROUTE - - SSL_VPN_ROUTE - - IBGP_ROUTE - - EBGP_ROUTE - _start: - type: integer - description: 分页起始位置(从0开始) - _length: - type: integer - description: 每页最大返回数量(最大200,默认100) - _search: - type: string - description: 模糊搜索关键字 - required: - - action -handler: - type: script - script_file: sangfor_af.handler.py - function: network diff --git a/.flocks/flockshub/plugins/tools/api/sangfor_af_v8_0_48/sangfor_af_v48_objects.yaml b/.flocks/flockshub/plugins/tools/api/sangfor_af_v8_0_48/sangfor_af_v48_objects.yaml deleted file mode 100644 index 69ec70cf5..000000000 --- a/.flocks/flockshub/plugins/tools/api/sangfor_af_v8_0_48/sangfor_af_v48_objects.yaml +++ /dev/null @@ -1,152 +0,0 @@ -name: sangfor_af_v48_objects -description: > - Sangfor AF v8.0.48 objects management tool. Query, create, update, and - delete network IP group objects and services (protocol/port definitions) - used in firewall policies. -description_cn: > - 深信服 AF v8.0.48 对象管理工具。通过 `action` 参数查询、创建、修改和删除 - IP 地址组对象及服务对象(协议/端口定义),这些对象被防火墙策略引用。 -category: custom -enabled: true -requires_confirmation: true -provider: sangfor_af -inputSchema: - type: object - properties: - action: - type: string - description: | - 对象管理动作名,可选值: - - ## IP 地址组 - - get_ipgroups - 用途: 查询符合条件的 IP 地址组列表 - 必填: 无 - 常用: _start、_length、businessType、__nameprefix、important、_search - 风险提示: 只读查询接口 - 是否任务型: 否 - - get_ipgroup - 用途: 获取单个 IP 地址组详情 - 必填: uuid - 风险提示: 只读查询接口 - 是否任务型: 否 - - create_ipgroup - 用途: 创建新的 IP 地址组 - 必填: name、businessType - 常用: ipRanges、addressType、description、important - 风险提示: 写操作;创建后可被防火墙策略引用 - 是否任务型: 否 - - update_ipgroup - 用途: 增量更新(PATCH)指定 IP 地址组 - 必填: uuid - 常用: name、ipRanges、description - 风险提示: 写操作;修改 IP 组会影响引用该组的所有策略 - 是否任务型: 否 - - delete_ipgroup - 用途: 删除指定 IP 地址组 - 必填: uuid - 风险提示: 高风险写操作;如有策略引用该组将删除失败 - 是否任务型: 否 - - ## 服务对象 - - get_services - 用途: 查询服务或服务组列表(预定义或自定义) - 必填: 无 - 常用: _start、_length、_search、serviceType - 风险提示: 只读查询接口 - 是否任务型: 否 - - get_service - 用途: 获取单个服务或服务组详情 - 必填: uuid - 风险提示: 只读查询接口 - 是否任务型: 否 - enum: - - get_ipgroups - - get_ipgroup - - create_ipgroup - - update_ipgroup - - delete_ipgroup - - get_services - - get_service - - uuid: - type: string - description: IP地址组或服务对象的唯一标识符(32字符UUID) - name: - type: string - description: 对象名称(最大95字符) - businessType: - type: string - description: > - IP地址组业务类型:IP=IP地址,ADDRGROUP=地址组, - USER=用户地址,BUSINESS=业务地址 - enum: - - IP - - ADDRGROUP - - USER - - BUSINESS - addressType: - type: string - description: "IP协议版本:IPV4 或 IPV6" - enum: - - IPV4 - - IPV6 - important: - type: string - description: "重要级别:COMMON=普通,CORE=核心" - enum: - - COMMON - - CORE - ipRanges: - type: array - items: - type: object - properties: - start: - type: string - description: IP范围起始地址(如 192.168.1.1) - end: - type: string - description: IP范围结束地址(如 192.168.1.254) - description: IP地址范围列表 - description: - type: string - description: 对象描述(最大95字符) - creator: - type: string - description: 创建者名称 - serviceType: - type: string - description: "服务类型过滤:SERVICE=单个服务,SERVICEGROUP=服务组" - enum: - - SERVICE - - SERVICEGROUP - - # Pagination - _start: - type: integer - description: 分页起始位置(从0开始) - _length: - type: integer - description: 每页最大返回数量(最大200,默认100) - __nameprefix: - type: string - description: 按名称前缀过滤(最大95字符) - _search: - type: string - description: 模糊搜索关键字(最大95字符) - _order: - type: string - description: "排序方向:asc 或 desc" - enum: - - asc - - desc - _sortby: - type: string - description: 排序字段名 - required: - - action -handler: - type: script - script_file: sangfor_af.handler.py - function: objects diff --git a/.flocks/flockshub/plugins/tools/api/sangfor_af_v8_0_48/sangfor_af_v48_ops.yaml b/.flocks/flockshub/plugins/tools/api/sangfor_af_v8_0_48/sangfor_af_v48_ops.yaml deleted file mode 100644 index 6ae193b4f..000000000 --- a/.flocks/flockshub/plugins/tools/api/sangfor_af_v8_0_48/sangfor_af_v48_ops.yaml +++ /dev/null @@ -1,165 +0,0 @@ -name: sangfor_af_v48_ops -description: > - Sangfor AF v8.0.48 operations center tool. Manages blacklist/whitelist - entries (IPs, domains, URLs) and blocked attacker IPs via the `action` - parameter. Key security triage actions for SOC workflows. -description_cn: > - 深信服 AF v8.0.48 运营中心工具。通过 `action` 参数管理黑白名单(IP/域名/URL) - 和封锁攻击者 IP。是 SOC 安全处置的核心接口。 -category: custom -enabled: true -requires_confirmation: true -provider: sangfor_af -inputSchema: - type: object - properties: - action: - type: string - description: | - 运营中心动作名,可选值: - - ## 黑白名单管理 - - get_blackwhitelist - 用途: 查询黑白名单列表(IP/域名/URL) - 必填: 无 - 常用: type(BLACK/WHITE)、_start、_length - 风险提示: 只读查询接口 - 是否任务型: 否 - - add_blackwhitelist - 用途: 添加单条黑白名单 - 必填: url(IP/域名/URL)、type(BLACK/WHITE) - 常用: enable、description、domain(0=IP,1=域名,2=URL) - 风险提示: 写操作;添加黑名单会拦截对应流量 - 是否任务型: 否 - - batch_add_blackwhitelist - 用途: 批量添加黑白名单 - 必填: items(数组,每项含 url/type 字段) - 风险提示: 写操作,批量添加黑名单影响面大 - 是否任务型: 否 - - delete_blackwhitelist - 用途: 删除单条黑白名单 - 必填: url(条目的 IP/域名/URL) - 常用: type(BLACK/WHITE) - 风险提示: 写操作,删除白名单可能导致误拦截 - 是否任务型: 否 - - batch_delete_blackwhitelist - 用途: 批量删除黑白名单 - 必填: items(数组,每项含 url 字段) - 风险提示: 写操作,批量删除影响面大 - 是否任务型: 否 - - ## 封锁攻击者 IP - - get_blockip_list - 用途: 查询当前封锁攻击者 IP 列表 - 必填: 无 - 常用: _start、_length、fuzzyIP(模糊搜索)、creator(AF/SIP) - 风险提示: 只读查询接口 - 是否任务型: 否 - - batch_add_blockip - 用途: 批量封锁攻击者 IP - 必填: items(数组,每项含 srcIP、dstIP 等字段) - 常用: aifwType(MANUAL/AUTO) - 风险提示: 高风险写操作;封锁 IP 会拦截其所有流量 - 是否任务型: 否 - - batch_delete_blockip - 用途: 批量解封攻击者 IP - 必填: items(数组,每项含 srcIP、dstIP 等字段) - 风险提示: 写操作,解封恶意 IP 存在安全风险 - 是否任务型: 否 - - clear_blockip - 用途: 清空封锁攻击者 IP 列表 - 必填: 无 - 常用: creator(AF/SIP,指定清除哪类封锁) - 风险提示: 高风险写操作;会清除所有封锁 IP - 是否任务型: 否 - - get_blockip_auto_config - 用途: 获取自动封锁攻击者时长配置 - 必填: 无 - 风险提示: 只读查询接口 - 是否任务型: 否 - - set_blockip_auto_config - 用途: 修改自动封锁攻击者时长 - 必填: blockTime(封锁时长,单位秒) - 风险提示: 写操作,影响自动封锁策略 - 是否任务型: 否 - enum: - - get_blackwhitelist - - add_blackwhitelist - - batch_add_blackwhitelist - - delete_blackwhitelist - - batch_delete_blackwhitelist - - get_blockip_list - - batch_add_blockip - - batch_delete_blockip - - clear_blockip - - get_blockip_auto_config - - set_blockip_auto_config - - # Blacklist/whitelist params - url: - type: string - description: IP地址、域名或URL(黑白名单条目值) - type: - type: string - description: "名单类型:BLACK(黑名单)或 WHITE(白名单)" - enum: - - BLACK - - WHITE - enable: - type: boolean - description: 是否启用该条目,默认 true - description: - type: string - description: 条目描述信息(最大95字符) - domain: - type: integer - description: "条目类型:0=IP地址,1=域名,2=URL" - enum: [0, 1, 2] - items: - type: array - items: - type: object - description: 批量操作时的条目数组,每项至少包含 url(黑白名单)或 srcIP/dstIP(封锁IP) - - # Block IP params - fuzzyIP: - type: string - description: 模糊搜索IP关键字(最大15字符) - creator: - type: string - description: "封锁来源身份:AF(防火墙自身)或 SIP(安全感知平台)" - enum: - - AF - - SIP - aifwType: - type: string - description: "添加封锁IP的类型:MANUAL(手动)或 AUTO(自动,需要 creator=SIP)" - enum: - - MANUAL - - AUTO - blockTime: - type: integer - description: 自动封锁时长(秒) - - # Pagination - _start: - type: integer - description: 分页起始位置(从0开始) - _length: - type: integer - description: 每页最大返回数量(最大200,默认100) - _sortby: - type: string - description: 排序字段名 - _order: - type: string - description: "排序方向:asc(升序)或 desc(降序)" - enum: - - asc - - desc - required: - - action -handler: - type: script - script_file: sangfor_af.handler.py - function: ops diff --git a/.flocks/flockshub/plugins/tools/api/sangfor_af_v8_0_48/sangfor_af_v48_status.yaml b/.flocks/flockshub/plugins/tools/api/sangfor_af_v8_0_48/sangfor_af_v48_status.yaml deleted file mode 100644 index f09276a15..000000000 --- a/.flocks/flockshub/plugins/tools/api/sangfor_af_v8_0_48/sangfor_af_v48_status.yaml +++ /dev/null @@ -1,91 +0,0 @@ -name: sangfor_af_v48_status -description: > - Sangfor AF v8.0.48 device status tool. Query system resource usage - (CPU, memory, disk), firmware version, network interface status, - current time, and system uptime. -description_cn: > - 深信服 AF v8.0.48 状态中心工具。通过 `action` 参数查询系统资源(CPU/内存/磁盘)、 - 固件版本、网口状态、当前时间及系统运行时长等信息。 -category: custom -enabled: true -requires_confirmation: false -provider: sangfor_af -inputSchema: - type: object - properties: - action: - type: string - description: | - 状态查询动作名,可选值: - - get_memory_usage - 用途: 获取当前内存使用率(百分比) - 必填: 无 - 风险提示: 只读查询接口 - 是否任务型: 否 - - get_cpu_usage - 用途: 获取当前 CPU 使用率(百分比) - 必填: 无 - 风险提示: 只读查询接口 - 是否任务型: 否 - - get_disk_usage - 用途: 获取当前磁盘使用率(百分比) - 必填: 无 - 风险提示: 只读查询接口 - 是否任务型: 否 - - get_system_version - 用途: 获取 AF 系统固件版本信息 - 必填: 无 - 常用: filter(ALL/FULL/MAJOR/MINOR 等) - 风险提示: 只读查询接口 - 是否任务型: 否 - - get_interface_status - 用途: 获取指定网口或全部网口的状态(流速、连接状态) - 必填: 无 - 常用: interfaceNames(如 eth0,不传则获取全部) - 风险提示: 只读查询接口 - 是否任务型: 否 - - get_runtime_status - 用途: 获取系统运行时长(uptime) - 必填: 无 - 风险提示: 只读查询接口 - 是否任务型: 否 - - get_current_time - 用途: 获取设备当前时间 - 必填: 无 - 风险提示: 只读查询接口 - 是否任务型: 否 - enum: - - get_memory_usage - - get_cpu_usage - - get_disk_usage - - get_system_version - - get_interface_status - - get_runtime_status - - get_current_time - filter: - type: string - description: > - 版本信息过滤(仅用于 get_system_version): - ALL=显示所有,FULL=完整版本号,MAJOR=主版本号,MINOR=次版本号, - INCREASE=增版本号,BUILD=创建日期,EN=是否英文版,HF=是否HF版,B=是否Beta版 - enum: - - ALL - - FULL - - MAJOR - - MINOR - - INCREASE - - BUILD - - EN - - HF - - B - - R - - ADD - interfaceNames: - type: string - description: 网口名称(如 eth0),用于 get_interface_status;不填则获取全部接口 - required: - - action -handler: - type: script - script_file: sangfor_af.handler.py - function: status diff --git a/.flocks/flockshub/plugins/tools/api/sangfor_af_v8_0_48/sangfor_af_v48_system.yaml b/.flocks/flockshub/plugins/tools/api/sangfor_af_v8_0_48/sangfor_af_v48_system.yaml deleted file mode 100644 index e41c6c291..000000000 --- a/.flocks/flockshub/plugins/tools/api/sangfor_af_v8_0_48/sangfor_af_v48_system.yaml +++ /dev/null @@ -1,53 +0,0 @@ -name: sangfor_af_v48_system -description: > - Sangfor AF v8.0.48 system management tool. Query and manage administrator - accounts on the AF device. -description_cn: > - 深信服 AF v8.0.48 系统管理工具。通过 `action` 参数查询和管理 AF 设备上的 - 管理员账户信息。 -category: custom -enabled: true -requires_confirmation: true -provider: sangfor_af -inputSchema: - type: object - properties: - action: - type: string - description: | - 系统管理动作名,可选值: - - get_accounts - 用途: 查询所有管理员账户列表 - 必填: 无 - 常用: _start、_length、enable - 风险提示: 只读查询接口 - 是否任务型: 否 - - get_account - 用途: 查询指定管理员账户详情 - 必填: name(账户名) - 风险提示: 只读查询接口 - 是否任务型: 否 - enum: - - get_accounts - - get_account - name: - type: string - description: 管理员账户名(用于 get_account) - enable: - type: boolean - description: 按启用/禁用状态过滤账户 - _start: - type: integer - description: 分页起始位置(从0开始) - _length: - type: integer - description: 每页最大返回数量 - _search: - type: string - description: 模糊搜索关键字 - required: - - action -handler: - type: script - script_file: sangfor_af.handler.py - function: system diff --git a/.flocks/flockshub/plugins/tools/api/sangfor_af_v8_0_85/_provider.yaml b/.flocks/flockshub/plugins/tools/api/sangfor_af_v8_0_85/_provider.yaml deleted file mode 100644 index b3566602a..000000000 --- a/.flocks/flockshub/plugins/tools/api/sangfor_af_v8_0_85/_provider.yaml +++ /dev/null @@ -1,52 +0,0 @@ -name: sangfor_af -service_id: sangfor_af -version: "8.0.85" -description: > - Sangfor AF (Application Firewall) v8.0.85 REST API service. - Uses session-based cookie authentication: call login to obtain - a token, then supply it via Cookie header in subsequent requests. - This version adds monitoring (session/traffic/statistics) APIs - compared to v8.0.48. -description_cn: > - 深信服 AF 下一代防火墙 v8.0.85 REST API 服务(对应 AF8.0.95 发布版文档)。 - 使用基于 Session 的 Cookie 认证:首先调用 login 获取 token, - 后续请求在 Cookie 中携带 token。在 v8.0.48 基础上新增监控(会话/流量/统计)相关 API。 -auth: - type: custom - secret: sangfor_af_v8_0_85_username - secret_secret: sangfor_af_v8_0_85_password -credential_fields: - - key: username - label: 管理员用户名 - storage: secret - config_key: username - secret_id: sangfor_af_v8_0_85_username - input_type: text - required: true - - key: password - label: 管理员密码 - storage: secret - config_key: password - secret_id: sangfor_af_v8_0_85_password - input_type: password - required: true - - key: base_url - label: 设备地址 (Base URL) - storage: config - config_key: base_url - input_type: url - default: "https://192.168.1.1" -defaults: - base_url: "https://192.168.1.1" - timeout: 60 - category: custom - product_version: "8.0.85" -notes: | - 深信服 AF v8.0.85 API 认证流程(同 v8.0.48): - 1. 在 AF WebUI「系统 → 管理员账号」勾选 WEBAPI 权限。 - 2. Handler 自动用用户名/密码换取 token 并缓存(带 keepalive 自动续期)。 - 3. 后续所有请求由 Handler 自动注入 Cookie: token=。 - 4. token 默认 10 分钟无操作后失效,缓存失效会自动重新登录。 - 5. 本版本在 v8.0.48 基础上新增了监控相关 API(会话/流量统计等)。 - - `verify_ssl` 由表单底部「SSL 验证」开关控制(默认关闭,与 sangfor_sip 一致)。 diff --git a/.flocks/flockshub/plugins/tools/api/sangfor_af_v8_0_85/_test.yaml b/.flocks/flockshub/plugins/tools/api/sangfor_af_v8_0_85/_test.yaml deleted file mode 100644 index 2e70f0c4a..000000000 --- a/.flocks/flockshub/plugins/tools/api/sangfor_af_v8_0_85/_test.yaml +++ /dev/null @@ -1,100 +0,0 @@ -schema_version: 1 -provider: sangfor_af - -connectivity: - tool: sangfor_af_v85_status - params: - action: get_system_version - -fixtures: - sangfor_af_v85_auth: - - label: "Session keepalive" - label_cn: "刷新 Session 保活" - tags: [smoke] - params: - action: keepalive - assert: - success: true - - sangfor_af_v85_status: - - label: "Get system version" - label_cn: "获取系统版本信息" - tags: [smoke] - params: - action: get_system_version - assert: - success: true - - - label: "Get CPU usage" - label_cn: "获取 CPU 使用率" - tags: [smoke] - params: - action: get_cpu_usage - - sangfor_af_v85_monitor: - - label: "Get session summary" - label_cn: "获取会话概要信息" - tags: [smoke, monitor] - params: - action: get_session_summary - assert: - success: true - - - label: "Get daily new sessions" - label_cn: "获取每日新建会话信息" - tags: [monitor] - params: - action: get_session_dailys - - - label: "Get user traffic top 10" - label_cn: "获取用户流量排行前10名" - tags: [monitor, traffic] - params: - action: get_user_traffic_rank - topNumber: 10 - - - label: "Get app traffic ranking" - label_cn: "获取应用流量排行" - tags: [monitor, traffic] - params: - action: get_app_traffic_rank - - - label: "Get active sessions" - label_cn: "获取实时活跃会话列表" - tags: [monitor, session] - params: - action: get_sessions - - sangfor_af_v85_ops: - - label: "List blocked attacker IPs" - label_cn: "查询封锁攻击者 IP 列表" - tags: [smoke, blockip] - params: - action: get_blockip_list - assert: - success: true - - - label: "List blacklist entries" - label_cn: "查询黑名单列表" - tags: [smoke, blacklist] - params: - action: get_blackwhitelist - type: BLACK - - sangfor_af_v85_objects: - - label: "List IP groups" - label_cn: "查询 IP 地址组列表" - tags: [smoke] - params: - action: get_ipgroups - assert: - success: true - - sangfor_af_v85_network: - - label: "List routing table" - label_cn: "查询路由表" - tags: [smoke] - params: - action: get_routes - assert: - success: true diff --git a/.flocks/flockshub/plugins/tools/api/sangfor_af_v8_0_85/sangfor_af.handler.py b/.flocks/flockshub/plugins/tools/api/sangfor_af_v8_0_85/sangfor_af.handler.py deleted file mode 100644 index 3a5793bd0..000000000 --- a/.flocks/flockshub/plugins/tools/api/sangfor_af_v8_0_85/sangfor_af.handler.py +++ /dev/null @@ -1,681 +0,0 @@ -""" -Sangfor AF (Application Firewall) v8.0.85 API Handler. - -Extends v8.0.48 with additional monitoring APIs: - - Session monitoring (traffic ranking, session counts, session list) - - Statistics (packet loss, buffer, hash table, etc.) - - Log/alarm settings - -Authentication: same as v8.0.48 (session-based Cookie token). -API base URL: https:// -Namespace: /api/v1/namespaces/public/ -Batch ops: /api/batch/v1/namespaces/public/ -""" -from __future__ import annotations - -import os -from typing import Any, Callable, Optional - -import aiohttp - -from flocks.config.config_writer import ConfigWriter -from flocks.tool.registry import ToolContext, ToolResult - -# ── Constants ──────────────────────────────────────────────────────────────── - -SERVICE_ID = "sangfor_af_v8_0_85" -DEFAULT_BASE_URL = "https://192.168.1.1" -DEFAULT_TIMEOUT = 60 -NAMESPACE = "public" - -API_V1 = f"/api/v1/namespaces/{NAMESPACE}" -API_BATCH = f"/api/batch/v1/namespaces/{NAMESPACE}" - -_TOKEN_CACHE: dict[str, str] = {} - - -# ── Secret / Config helpers ─────────────────────────────────────────────────── - -def _get_secret_manager(): - from flocks.security import get_secret_manager - return get_secret_manager() - - -def _resolve_ref(value: Any) -> Optional[str]: - if value is None: - return None - if not isinstance(value, str): - return str(value) - if value.startswith("{secret:") and value.endswith("}"): - return _get_secret_manager().get(value[len("{secret:"): -1]) - if value.startswith("{env:") and value.endswith("}"): - return os.getenv(value[len("{env:"): -1]) - return value - - -def _service_config() -> dict[str, Any]: - raw = ConfigWriter.get_api_service_raw(SERVICE_ID) - return raw if isinstance(raw, dict) else {} - - -def _resolve_verify_ssl(raw: dict[str, Any]) -> bool: - """Read verify_ssl with the same priority as sangfor_sip / onesec: - verify_ssl > ssl_verify > custom_settings.verify_ssl > False. - AF devices commonly use self-signed certs, so default is False. - """ - value = raw.get("verify_ssl") - if value is None: - value = raw.get("ssl_verify") - if value is None: - custom = raw.get("custom_settings") - if isinstance(custom, dict): - value = custom.get("verify_ssl") - if isinstance(value, bool): - return value - if isinstance(value, str): - return value.strip().lower() in {"1", "true", "yes", "on"} - return False - - -def _resolve_runtime_config() -> tuple[str, int, str, str, bool]: - raw = _service_config() - base_url = (_resolve_ref(raw.get("base_url")) or DEFAULT_BASE_URL).rstrip("/") - timeout = raw.get("timeout", DEFAULT_TIMEOUT) - try: - timeout = int(timeout) - except (TypeError, ValueError): - timeout = DEFAULT_TIMEOUT - - sm = _get_secret_manager() - username = ( - _resolve_ref(raw.get("username")) - or sm.get("sangfor_af_v8_0_85_username") - or os.getenv("AF_USERNAME") - ) - password = ( - _resolve_ref(raw.get("password")) - or sm.get("sangfor_af_v8_0_85_password") - or os.getenv("AF_PASSWORD") - ) - if not username or not password: - raise ValueError( - "AF API credentials not configured. " - "Please set username and password in the sangfor_af_v8_0_85 service configuration." - ) - return base_url, timeout, username, password, _resolve_verify_ssl(raw) - - -# ── Session / Token management ──────────────────────────────────────────────── - -async def _login(session, base_url, username, password, verify_ssl): - url = f"{base_url}{API_V1}/login" - try: - async with session.post( - url, - json={"name": username, "password": password}, - ssl=verify_ssl, - ) as resp: - data = await resp.json(content_type=None) - except aiohttp.ClientError as exc: - return None, f"AF login request failed: {exc}" - code = data.get("code") - if code != 0: - return None, f"AF login failed (code={code}): {data.get('message', 'Unknown error')}" - token = data.get("data", {}).get("loginResult", {}).get("token") - if not token: - return None, "AF login succeeded but no token returned" - return token, None - - -async def _get_token(session, base_url, username, password, verify_ssl): - cached = _TOKEN_CACHE.get(base_url) - if cached: - try: - async with session.get( - f"{base_url}{API_V1}/keepalive", - headers={"Cookie": f"token={cached}"}, - ssl=verify_ssl, - ) as resp: - ka = await resp.json(content_type=None) - if ka.get("code") == 0: - return cached, None - except Exception: - pass - token, err = await _login(session, base_url, username, password, verify_ssl) - if err: - return None, err - _TOKEN_CACHE[base_url] = token - return token, None - - -# ── Low-level HTTP ──────────────────────────────────────────────────────────── - -def _pick(params: dict[str, Any], *keys: str) -> dict[str, Any]: - return {k: params[k] for k in keys if k in params and params[k] is not None} - - -def _af_result(action: str, payload: Any, version: str = "8.0.85") -> ToolResult: - metadata = {"source": "Sangfor AF", "api": action, "version": version} - if isinstance(payload, dict): - code = payload.get("code") - if code not in (None, 0): - msg = payload.get("message", "Unknown error") - return ToolResult(success=False, error=f"AF API error (code={code}): {msg}", metadata=metadata) - return ToolResult(success=True, output=payload.get("data", payload), metadata=metadata) - return ToolResult(success=True, output=payload, metadata=metadata) - - -async def _call( - method: str, - path: str, - params: Optional[dict[str, Any]] = None, - json: Optional[Any] = None, - action: str = "", -) -> ToolResult: - try: - base_url, timeout, username, password, verify_ssl = _resolve_runtime_config() - except ValueError as exc: - return ToolResult(success=False, error=str(exc)) - - headers = {"Content-Type": "application/json"} - async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=timeout)) as session: - token, err = await _get_token(session, base_url, username, password, verify_ssl) - if err: - return ToolResult(success=False, error=err) - headers["Cookie"] = f"token={token}" - url = f"{base_url}{path}" - try: - async with session.request( - method.upper(), url, params=params, json=json, headers=headers, ssl=verify_ssl, - ) as resp: - if resp.status >= 400: - text = await resp.text() - return ToolResult(success=False, error=f"HTTP {resp.status}: {text[:500]}") - data = await resp.json(content_type=None) - except aiohttp.ClientError as exc: - return ToolResult(success=False, error=f"Request failed: {exc}") - except Exception as exc: - return ToolResult(success=False, error=f"Unexpected error: {exc}") - return _af_result(action or path.rsplit("/", 1)[-1], data) - - -# ── Auth actions ────────────────────────────────────────────────────────────── - -async def _do_login(ctx: ToolContext, **params: Any) -> ToolResult: - del ctx - try: - base_url, timeout, username, password, verify_ssl = _resolve_runtime_config() - except ValueError as exc: - return ToolResult(success=False, error=str(exc)) - async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=timeout)) as session: - token, err = await _login(session, base_url, username, password, verify_ssl) - if err: - return ToolResult(success=False, error=err) - _TOKEN_CACHE[base_url] = token - return ToolResult( - success=True, - output={"token": token, "message": "Login successful"}, - metadata={"source": "Sangfor AF", "api": "login", "version": "8.0.85"}, - ) - - -async def _do_logout(ctx: ToolContext, **params: Any) -> ToolResult: - del ctx - result = await _call("POST", f"{API_V1}/logout", action="logout") - try: - base_url, *_ = _resolve_runtime_config() - _TOKEN_CACHE.pop(base_url, None) - except ValueError: - pass - return result - - -async def _do_keepalive(ctx: ToolContext, **params: Any) -> ToolResult: - del ctx - return await _call("GET", f"{API_V1}/keepalive", action="keepalive") - - -# ── Objects actions ────────────────────────────────────────────────────────── - -async def _do_get_ipgroups(ctx: ToolContext, **params: Any) -> ToolResult: - del ctx - query = _pick(params, "_start", "_length", "businessType", "__nameprefix", "important", "_search", "_order", "_sortby", "addressType") - return await _call("GET", f"{API_V1}/ipgroups", params=query, action="get_ipgroups") - - -async def _do_get_ipgroup(ctx: ToolContext, **params: Any) -> ToolResult: - del ctx - return await _call("GET", f"{API_V1}/ipgroups/{params.get('uuid', '')}", action="get_ipgroup") - - -async def _do_create_ipgroup(ctx: ToolContext, **params: Any) -> ToolResult: - del ctx - body = _pick(params, "name", "businessType", "description", "addressType", "important", "ipRanges", "creator") - return await _call("POST", f"{API_V1}/ipgroups", json={"obj": body}, action="create_ipgroup") - - -async def _do_update_ipgroup(ctx: ToolContext, **params: Any) -> ToolResult: - del ctx - body = _pick(params, "name", "businessType", "description", "addressType", "important", "ipRanges") - return await _call("PATCH", f"{API_V1}/ipgroups/{params.get('uuid', '')}", json={"obj": body}, action="update_ipgroup") - - -async def _do_delete_ipgroup(ctx: ToolContext, **params: Any) -> ToolResult: - del ctx - return await _call("DELETE", f"{API_V1}/ipgroups/{params.get('uuid', '')}", action="delete_ipgroup") - - -async def _do_get_services(ctx: ToolContext, **params: Any) -> ToolResult: - del ctx - query = _pick(params, "_start", "_length", "_search", "_order", "_sortby", "serviceType") - return await _call("GET", f"{API_V1}/services", params=query, action="get_services") - - -async def _do_get_service(ctx: ToolContext, **params: Any) -> ToolResult: - del ctx - return await _call("GET", f"{API_V1}/services/{params.get('uuid', '')}", action="get_service") - - -# ── Monitoring actions (new in v8.0.85) ────────────────────────────────────── - -async def _do_get_user_traffic_rank(ctx: ToolContext, **params: Any) -> ToolResult: - del ctx - body = _pick(params, "topNumber", "vsys", "line", "applicationType", "filterObject") - return await _call( - "POST", - f"{API_V1}/topusertraffics", - params={"_method": "GET"}, - json=body or {}, - action="get_user_traffic_rank", - ) - - -async def _do_get_ip_traffic_trend(ctx: ToolContext, **params: Any) -> ToolResult: - del ctx - # /iptraffics is not a paged endpoint; _start/_length must not be sent. - # topNumber must be int — AF returns code=1001 for any non-int value. - query = _pick(params, "vsys", "topNumber", "unit", "minutes") - if "topNumber" in query: - try: - query["topNumber"] = int(query["topNumber"]) - except (TypeError, ValueError): - pass - return await _call("GET", f"{API_V1}/iptraffics", params=query or None, action="get_ip_traffic_trend") - - -async def _do_get_app_traffic_rank(ctx: ToolContext, **params: Any) -> ToolResult: - del ctx - query = _pick(params, "vsys", "line", "topNumber") - if "topNumber" in query: - try: - query["topNumber"] = int(query["topNumber"]) - except (TypeError, ValueError): - pass - return await _call("GET", f"{API_V1}/apptrafficrank", params=query or None, action="get_app_traffic_rank") - - -async def _do_get_session_dailys(ctx: ToolContext, **params: Any) -> ToolResult: - del ctx - query = _pick(params, "vsys", "ip") - return await _call("GET", f"{API_V1}/sessiondailys", params=query or None, action="get_session_dailys") - - -async def _do_get_session_details(ctx: ToolContext, **params: Any) -> ToolResult: - del ctx - # Endpoint needs explicit filters; without them AF returns 1004 "没有返回值". - query = _pick(params, "vsys", "srcIP", "dstIP", "protocol", "srcPort", "dstPort") - return await _call("GET", f"{API_V1}/sessiondetails", params=query or None, action="get_session_details") - - -async def _do_get_session_count_trend(ctx: ToolContext, **params: Any) -> ToolResult: - del ctx - query = _pick(params, "vsys", "minutes") - return await _call("GET", f"{API_V1}/sessioncounttrend", params=query or None, action="get_session_count_trend") - - -async def _do_get_session_src_ip(ctx: ToolContext, **params: Any) -> ToolResult: - del ctx - # srcIP is required; AF returns 1004 "没有返回值" when omitted. - query = _pick(params, "vsys", "srcIP") - return await _call("GET", f"{API_V1}/sessionsrcip", params=query or None, action="get_session_src_ip") - - -async def _do_get_session_count_rank(ctx: ToolContext, **params: Any) -> ToolResult: - del ctx - query = _pick(params, "_start", "_length", "vsys", "topNumber") - return await _call("GET", f"{API_V1}/sessioncountrank", params=query or None, action="get_session_count_rank") - - -async def _do_get_session_summary(ctx: ToolContext, **params: Any) -> ToolResult: - del ctx - query = _pick(params, "vsys") - return await _call("GET", f"{API_V1}/sessionsummary", params=query or None, action="get_session_summary") - - -async def _do_get_monitor_ips(ctx: ToolContext, **params: Any) -> ToolResult: - del ctx - query = _pick(params, "_start", "_length") - return await _call("GET", f"{API_V1}/monitorips", params=query or None, action="get_monitor_ips") - - -async def _do_get_sessions(ctx: ToolContext, **params: Any) -> ToolResult: - del ctx - body = _pick(params, "_start", "_length", "vsys", "srcIP", "dstIP", "protocol", "srcPort", "dstPort") - # AF8.0.x requires POST + ?_method=GET for /sessions; plain GET returns 1002. - return await _call("POST", f"{API_V1}/sessions", params={"_method": "GET"}, json=body or {}, action="get_sessions") - - -# Statistics (monitoring sub-section) -async def _do_get_packet_drop_stats(ctx: ToolContext, **params: Any) -> ToolResult: - del ctx - query = _pick(params, "_start", "_length") - return await _call("GET", f"{API_V1}/mbufdroppointstatistics", params=query or None, action="get_packet_drop_stats") - - -async def _do_clear_packet_drop_stats(ctx: ToolContext, **params: Any) -> ToolResult: - del ctx - return await _call("DELETE", f"{API_V1}/mbufdroppointstatistics", action="clear_packet_drop_stats") - - -async def _do_get_mbuf_stats(ctx: ToolContext, **params: Any) -> ToolResult: - del ctx - return await _call("GET", f"{API_V1}/mbufstatistics", action="get_mbuf_stats") - - -async def _do_get_hash_table_stats(ctx: ToolContext, **params: Any) -> ToolResult: - del ctx - query = _pick(params, "_start", "_length") - return await _call("GET", f"{API_V1}/hashtablestatistics", params=query or None, action="get_hash_table_stats") - - -# ── Operations center actions ───────────────────────────────────────────────── - -async def _do_get_blackwhitelist(ctx: ToolContext, **params: Any) -> ToolResult: - del ctx - query = _pick(params, "type", "_start", "_length", "_search", "_order", "description") - return await _call("GET", f"{API_V1}/whiteblacklist", params=query, action="get_blackwhitelist") - - -async def _do_add_blackwhitelist(ctx: ToolContext, **params: Any) -> ToolResult: - del ctx - body = _pick(params, "url", "type", "enable", "description", "domain") - return await _call("POST", f"{API_V1}/whiteblacklist", json={"obj": body}, action="add_blackwhitelist") - - -async def _do_batch_add_blackwhitelist(ctx: ToolContext, **params: Any) -> ToolResult: - del ctx - return await _call("POST", f"{API_BATCH}/whiteblacklist", json=params.get("items", []), action="batch_add_blackwhitelist") - - -async def _do_delete_blackwhitelist(ctx: ToolContext, **params: Any) -> ToolResult: - del ctx - url_param = params.get("url", "") - list_type = params.get("type", "") - query = {"type": list_type} if list_type else None - return await _call("DELETE", f"{API_V1}/whiteblacklist/{url_param}", params=query, action="delete_blackwhitelist") - - -async def _do_batch_delete_blackwhitelist(ctx: ToolContext, **params: Any) -> ToolResult: - del ctx - return await _call("POST", f"{API_BATCH}/whiteblacklist", params={"_method": "DELETE"}, json=params.get("items", []), action="batch_delete_blackwhitelist") - - -async def _do_get_blockip_list(ctx: ToolContext, **params: Any) -> ToolResult: - del ctx - query = _pick(params, "_start", "_length", "_sortby", "_order", "creator", "fuzzyIP") - return await _call("GET", f"{API_V1}/blockip", params=query, action="get_blockip_list") - - -async def _do_batch_add_blockip(ctx: ToolContext, **params: Any) -> ToolResult: - del ctx - query = _pick(params, "aifwType") - return await _call("POST", f"{API_BATCH}/blockip", params=query or None, json=params.get("items", []), action="batch_add_blockip") - - -async def _do_batch_delete_blockip(ctx: ToolContext, **params: Any) -> ToolResult: - del ctx - return await _call("POST", f"{API_BATCH}/blockip", params={"_method": "DELETE"}, json=params.get("items", []), action="batch_delete_blockip") - - -async def _do_clear_blockip(ctx: ToolContext, **params: Any) -> ToolResult: - del ctx - query = _pick(params, "creator") - return await _call("DELETE", f"{API_V1}/blockip", params=query or None, action="clear_blockip") - - -async def _do_get_blockip_auto_config(ctx: ToolContext, **params: Any) -> ToolResult: - del ctx - return await _call("GET", f"{API_V1}/blockip/autoconfig", action="get_blockip_auto_config") - - -async def _do_set_blockip_auto_config(ctx: ToolContext, **params: Any) -> ToolResult: - del ctx - return await _call("PUT", f"{API_V1}/blockip/autoconfig", json={"obj": _pick(params, "blockTime")}, action="set_blockip_auto_config") - - -# ── Status actions ──────────────────────────────────────────────────────────── - -async def _do_get_memory_usage(ctx: ToolContext, **params: Any) -> ToolResult: - del ctx - return await _call("GET", f"{API_V1}/memoryusage", action="get_memory_usage") - - -async def _do_get_cpu_usage(ctx: ToolContext, **params: Any) -> ToolResult: - del ctx - return await _call("GET", f"{API_V1}/cpuusage", action="get_cpu_usage") - - -async def _do_get_disk_usage(ctx: ToolContext, **params: Any) -> ToolResult: - del ctx - return await _call("GET", f"{API_V1}/diskusage", action="get_disk_usage") - - -async def _do_get_system_version(ctx: ToolContext, **params: Any) -> ToolResult: - del ctx - query = _pick(params, "filter") - return await _call("GET", f"{API_V1}/systemversion", params=query or None, action="get_system_version") - - -async def _do_get_interface_status(ctx: ToolContext, **params: Any) -> ToolResult: - del ctx - # AF8.0.x: /interfacestatus returns 1002; use /interfaces (list) or - # /interfaces/status?interfaceName= (single interface query). - iface = params.get("interfaceNames") or params.get("interfaceName") or "" - if iface: - return await _call( - "GET", f"{API_V1}/interfaces/status", - params={"interfaceName": iface}, - action="get_interface_status", - ) - return await _call("GET", f"{API_V1}/interfaces", action="get_interface_status") - - -async def _do_get_runtime_status(ctx: ToolContext, **params: Any) -> ToolResult: - del ctx - return await _call("GET", f"{API_V1}/runtimestatus", action="get_runtime_status") - - -async def _do_get_current_time(ctx: ToolContext, **params: Any) -> ToolResult: - del ctx - return await _call("GET", f"{API_V1}/currenttime", action="get_current_time") - - -# ── Network actions ─────────────────────────────────────────────────────────── - -async def _do_get_routes(ctx: ToolContext, **params: Any) -> ToolResult: - del ctx - query = _pick(params, "_start", "_length", "routeType", "_search") - return await _call("GET", f"{API_V1}/routes", params=query or None, action="get_routes") - - -async def _do_get_routes_ipv6(ctx: ToolContext, **params: Any) -> ToolResult: - del ctx - query = _pick(params, "_start", "_length", "routeType", "_search") - return await _call("GET", f"{API_V1}/routes/ipv6", params=query or None, action="get_routes_ipv6") - - -# ── System actions ──────────────────────────────────────────────────────────── - -async def _do_get_accounts(ctx: ToolContext, **params: Any) -> ToolResult: - del ctx - query = _pick(params, "_start", "_length", "_search", "enable") - return await _call("GET", f"{API_V1}/account", params=query or None, action="get_accounts") - - -async def _do_get_account(ctx: ToolContext, **params: Any) -> ToolResult: - del ctx - return await _call("GET", f"{API_V1}/account/{params.get('name', '')}", action="get_account") - - -# ── Action dispatch ─────────────────────────────────────────────────────────── - -_ACTION_MAP: dict[str, Callable] = { - # Auth - "login": _do_login, - "logout": _do_logout, - "keepalive": _do_keepalive, - # Objects - "get_ipgroups": _do_get_ipgroups, - "get_ipgroup": _do_get_ipgroup, - "create_ipgroup": _do_create_ipgroup, - "update_ipgroup": _do_update_ipgroup, - "delete_ipgroup": _do_delete_ipgroup, - "get_services": _do_get_services, - "get_service": _do_get_service, - # Monitoring (new in v8.0.85) - "get_user_traffic_rank": _do_get_user_traffic_rank, - "get_ip_traffic_trend": _do_get_ip_traffic_trend, - "get_app_traffic_rank": _do_get_app_traffic_rank, - "get_session_dailys": _do_get_session_dailys, - "get_session_details": _do_get_session_details, - "get_session_count_trend": _do_get_session_count_trend, - "get_session_src_ip": _do_get_session_src_ip, - "get_session_count_rank": _do_get_session_count_rank, - "get_session_summary": _do_get_session_summary, - "get_monitor_ips": _do_get_monitor_ips, - "get_sessions": _do_get_sessions, - "get_packet_drop_stats": _do_get_packet_drop_stats, - "clear_packet_drop_stats": _do_clear_packet_drop_stats, - "get_mbuf_stats": _do_get_mbuf_stats, - "get_hash_table_stats": _do_get_hash_table_stats, - # Operations center - "get_blackwhitelist": _do_get_blackwhitelist, - "add_blackwhitelist": _do_add_blackwhitelist, - "batch_add_blackwhitelist": _do_batch_add_blackwhitelist, - "delete_blackwhitelist": _do_delete_blackwhitelist, - "batch_delete_blackwhitelist": _do_batch_delete_blackwhitelist, - "get_blockip_list": _do_get_blockip_list, - "batch_add_blockip": _do_batch_add_blockip, - "batch_delete_blockip": _do_batch_delete_blockip, - "clear_blockip": _do_clear_blockip, - "get_blockip_auto_config": _do_get_blockip_auto_config, - "set_blockip_auto_config": _do_set_blockip_auto_config, - # Status - "get_memory_usage": _do_get_memory_usage, - "get_cpu_usage": _do_get_cpu_usage, - "get_disk_usage": _do_get_disk_usage, - "get_system_version": _do_get_system_version, - "get_interface_status": _do_get_interface_status, - "get_runtime_status": _do_get_runtime_status, - "get_current_time": _do_get_current_time, - # Network - "get_routes": _do_get_routes, - "get_routes_ipv6": _do_get_routes_ipv6, - # System - "get_accounts": _do_get_accounts, - "get_account": _do_get_account, -} - -GROUP_ACTIONS: dict[str, set[str]] = { - "auth": {"login", "logout", "keepalive"}, - "objects": {"get_ipgroups", "get_ipgroup", "create_ipgroup", "update_ipgroup", "delete_ipgroup", "get_services", "get_service"}, - "monitor": { - "get_user_traffic_rank", "get_ip_traffic_trend", "get_app_traffic_rank", - "get_session_dailys", "get_session_details", "get_session_count_trend", - "get_session_src_ip", "get_session_count_rank", "get_session_summary", - "get_monitor_ips", "get_sessions", - "get_packet_drop_stats", "clear_packet_drop_stats", - "get_mbuf_stats", "get_hash_table_stats", - }, - "ops": { - "get_blackwhitelist", "add_blackwhitelist", "batch_add_blackwhitelist", - "delete_blackwhitelist", "batch_delete_blackwhitelist", - "get_blockip_list", "batch_add_blockip", "batch_delete_blockip", - "clear_blockip", "get_blockip_auto_config", "set_blockip_auto_config", - }, - "status": { - "get_memory_usage", "get_cpu_usage", "get_disk_usage", - "get_system_version", "get_interface_status", - "get_runtime_status", "get_current_time", - }, - "network": {"get_routes", "get_routes_ipv6"}, - "system": {"get_accounts", "get_account"}, -} - -_CONNECTIVITY_TEST_ACTIONS: dict[str, str] = { - "auth": "keepalive", - "objects": "get_ipgroups", - "monitor": "get_session_summary", - "ops": "get_blackwhitelist", - "status": "get_system_version", - "network": "get_routes", - "system": "get_accounts", -} - - -async def unified_ops(ctx: ToolContext, action: str, **params: Any) -> ToolResult: - handler = _ACTION_MAP.get(action) - if handler is None: - available = ", ".join(sorted(_ACTION_MAP)) - return ToolResult(success=False, error=f"Unknown action: {action}. Available: {available}") - return await handler(ctx, **params) - - -async def _dispatch_group(ctx: ToolContext, group: str, action: str, **params: Any) -> ToolResult: - if action == "test": - return await unified_ops(ctx, action=_CONNECTIVITY_TEST_ACTIONS.get(group, "get_system_version"), **params) - if action not in GROUP_ACTIONS[group]: - available = ", ".join(sorted(GROUP_ACTIONS[group])) - return ToolResult(success=False, error=f"Unsupported {group} action: {action}. Available: {available}") - return await unified_ops(ctx, action=action, **params) - - -async def auth(ctx: ToolContext, action: str, **params: Any) -> ToolResult: - return await _dispatch_group(ctx, "auth", action, **params) - - -async def objects(ctx: ToolContext, action: str, **params: Any) -> ToolResult: - return await _dispatch_group(ctx, "objects", action, **params) - - -async def monitor(ctx: ToolContext, action: str, **params: Any) -> ToolResult: - return await _dispatch_group(ctx, "monitor", action, **params) - - -async def ops(ctx: ToolContext, action: str, **params: Any) -> ToolResult: - return await _dispatch_group(ctx, "ops", action, **params) - - -async def status(ctx: ToolContext, action: str, **params: Any) -> ToolResult: - return await _dispatch_group(ctx, "status", action, **params) - - -async def network(ctx: ToolContext, action: str, **params: Any) -> ToolResult: - return await _dispatch_group(ctx, "network", action, **params) - - -async def system(ctx: ToolContext, action: str, **params: Any) -> ToolResult: - return await _dispatch_group(ctx, "system", action, **params) - - -def _make_action_function(action: str): - async def _tool(ctx: ToolContext, **kwargs: Any) -> ToolResult: - return await unified_ops(ctx, action=action, **kwargs) - _tool.__name__ = action - return _tool - - -for _action_name in _ACTION_MAP: - globals()[_action_name] = _make_action_function(_action_name) - -del _action_name diff --git a/.flocks/flockshub/plugins/tools/api/sangfor_af_v8_0_85/sangfor_af_v85_auth.yaml b/.flocks/flockshub/plugins/tools/api/sangfor_af_v8_0_85/sangfor_af_v85_auth.yaml deleted file mode 100644 index 69718131a..000000000 --- a/.flocks/flockshub/plugins/tools/api/sangfor_af_v8_0_85/sangfor_af_v85_auth.yaml +++ /dev/null @@ -1,44 +0,0 @@ -name: sangfor_af_v85_auth -description: > - Sangfor AF v8.0.48 authentication tool. Use the `action` parameter to - login, logout, or keep the session alive. Token is cached automatically - after a successful login. -description_cn: > - 深信服 AF v8.0.48 认证工具。通过 `action` 参数调用登录、注销或 token 保活接口。 - 登录成功后 token 会自动缓存,后续调用无需手动传 token。 -category: custom -enabled: true -requires_confirmation: false -provider: sangfor_af -inputSchema: - type: object - properties: - action: - type: string - description: | - 认证动作名,可选值: - - login - 用途: 登录设备,获取 session token(token 自动缓存) - 必填: 无(用户名/密码从服务配置读取) - 风险提示: 只读认证接口 - 是否任务型: 否 - - logout - 用途: 注销当前登录 session,清除 token 缓存 - 必填: 无 - 风险提示: 写操作,注销后需重新登录 - 是否任务型: 否 - - keepalive - 用途: 刷新 token 超时计时器,保持 session 活跃 - 必填: 无 - 风险提示: 只读接口 - 是否任务型: 否 - enum: - - login - - logout - - keepalive - required: - - action -handler: - type: script - script_file: sangfor_af.handler.py - function: auth diff --git a/.flocks/flockshub/plugins/tools/api/sangfor_af_v8_0_85/sangfor_af_v85_monitor.yaml b/.flocks/flockshub/plugins/tools/api/sangfor_af_v8_0_85/sangfor_af_v85_monitor.yaml deleted file mode 100644 index 374fac6c6..000000000 --- a/.flocks/flockshub/plugins/tools/api/sangfor_af_v8_0_85/sangfor_af_v85_monitor.yaml +++ /dev/null @@ -1,184 +0,0 @@ -name: sangfor_af_v85_monitor -description: > - Sangfor AF v8.0.85 monitoring tool. Provides real-time and historical - session data, traffic rankings, network statistics, and packet diagnostics. - These APIs are new in v8.0.85 and not available in v8.0.48. -description_cn: > - 深信服 AF v8.0.85 监控工具(v8.0.48 中不含此功能)。通过 `action` 参数 - 查询实时/历史会话数据、流量排行、网络统计及报文诊断信息。 -category: custom -enabled: true -requires_confirmation: false -provider: sangfor_af -inputSchema: - type: object - properties: - action: - type: string - description: | - 监控动作名,可选值: - - ## 流量排行 - - get_user_traffic_rank - 用途: 获取用户流量排行(Top N 用户) - 必填: 无 - 常用: topNumber(前N名,默认10)、vsys、line、applicationType - 风险提示: 只读接口 - 是否任务型: 否 - - get_ip_traffic_trend - 用途: 获取 IP 流量趋势曲线(指定前5或10名IP) - 必填: 无 - 常用: topNumber、vsys、unit、minutes - 风险提示: 只读接口 - 是否任务型: 否 - - get_app_traffic_rank - 用途: 获取应用流量排行(Top N 应用) - 必填: 无 - 常用: topNumber、vsys、line - 风险提示: 只读接口 - 是否任务型: 否 - - ## 会话排行与统计 - - get_session_dailys - 用途: 获取每日新建会话信息 - 必填: 无 - 常用: vsys、ip、_start、_length - 风险提示: 只读接口 - 是否任务型: 否 - - get_session_details - 用途: 获取会话详情列表(含5层信息) - 必填: 无 - 常用: vsys、srcIP、dstIP、protocol、_start、_length - 风险提示: 只读接口 - 是否任务型: 否 - - get_session_count_trend - 用途: 获取会话数量趋势折线图数据 - 必填: 无 - 常用: vsys、minutes(最近N分钟,默认60) - 风险提示: 只读接口 - 是否任务型: 否 - - get_session_src_ip - 用途: 获取指定源IP的会话详情(按目的IP分组) - 必填: 无 - 常用: srcIP、vsys、_start、_length - 风险提示: 只读接口 - 是否任务型: 否 - - get_session_count_rank - 用途: 获取会话数量排行(Top N 源IP) - 必填: 无 - 常用: topNumber、vsys - 风险提示: 只读接口 - 是否任务型: 否 - - get_session_summary - 用途: 获取会话概要信息(总数、协议分布等) - 必填: 无 - 常用: vsys - 风险提示: 只读接口 - 是否任务型: 否 - - get_monitor_ips - 用途: 获取配置中心监听列表IP范围 - 必填: 无 - 常用: _start、_length - 风险提示: 只读接口 - 是否任务型: 否 - - get_sessions - 用途: 获取实时会话列表(当前活跃连接) - 必填: 无 - 常用: vsys、srcIP、dstIP、protocol、srcPort、dstPort、_start、_length - 风险提示: 只读接口 - 是否任务型: 否 - - ## 统计与诊断 - - get_packet_drop_stats - 用途: 获取 mbuf 丢包点统计信息列表 - 必填: 无 - 风险提示: 只读接口 - 是否任务型: 否 - - clear_packet_drop_stats - 用途: 清除后台丢包统计信息 - 必填: 无 - 风险提示: 写操作,清除统计数据不可恢复 - 是否任务型: 否 - - get_mbuf_stats - 用途: 获取 mbuf 内存统计信息 - 必填: 无 - 风险提示: 只读接口 - 是否任务型: 否 - - get_hash_table_stats - 用途: 获取哈希表统计列表 - 必填: 无 - 常用: _start、_length - 风险提示: 只读接口 - 是否任务型: 否 - enum: - - get_user_traffic_rank - - get_ip_traffic_trend - - get_app_traffic_rank - - get_session_dailys - - get_session_details - - get_session_count_trend - - get_session_src_ip - - get_session_count_rank - - get_session_summary - - get_monitor_ips - - get_sessions - - get_packet_drop_stats - - clear_packet_drop_stats - - get_mbuf_stats - - get_hash_table_stats - - topNumber: - type: integer - description: 排行榜取前N名(如5或10) - vsys: - type: string - description: 虚拟系统名称(通常为 public,可省略) - line: - type: integer - description: "线路编号过滤,0=全部(范围0-256)" - applicationType: - type: array - items: - type: string - description: 应用类型过滤列表 - filterObject: - type: object - description: > - 用户流量排行过滤对象: - objectType=GROUP/USER/IP,对应 groups/users/ip 数组 - unit: - type: string - description: "流量单位(如 bps, Kbps, Mbps)" - minutes: - type: integer - description: 查询最近N分钟的数据(默认60) - ip: - type: string - description: IP地址过滤(格式:IPv4/IPv6) - srcIP: - type: string - description: 源IP地址过滤(格式:IPv4/IPv6) - dstIP: - type: string - description: 目的IP地址过滤 - protocol: - type: string - description: "协议过滤:TCP/UDP/ICMP/OTHER" - srcPort: - type: integer - description: 源端口过滤(0-65535) - dstPort: - type: integer - description: 目的端口过滤(0-65535) - _start: - type: integer - description: 分页起始位置(从0开始) - _length: - type: integer - description: 每页最大返回数量(最大200,默认100) - required: - - action -handler: - type: script - script_file: sangfor_af.handler.py - function: monitor diff --git a/.flocks/flockshub/plugins/tools/api/sangfor_af_v8_0_85/sangfor_af_v85_network.yaml b/.flocks/flockshub/plugins/tools/api/sangfor_af_v8_0_85/sangfor_af_v85_network.yaml deleted file mode 100644 index 149f1aee8..000000000 --- a/.flocks/flockshub/plugins/tools/api/sangfor_af_v8_0_85/sangfor_af_v85_network.yaml +++ /dev/null @@ -1,65 +0,0 @@ -name: sangfor_af_v85_network -description: > - Sangfor AF v8.0.48 network tool. Query routing tables (IPv4 and IPv6) - and network-related status information. -description_cn: > - 深信服 AF v8.0.48 网络工具。通过 `action` 参数查询路由表(IPv4/IPv6) - 及网络相关状态信息。 -category: custom -enabled: true -requires_confirmation: false -provider: sangfor_af -inputSchema: - type: object - properties: - action: - type: string - description: | - 网络查询动作名,可选值: - - get_routes - 用途: 获取后台 IPv4 路由信息列表 - 必填: 无 - 常用: routeType(ALL_ROUTE/STATIC_ROUTE/DIRECT_ROUTE 等)、_start、_length - 风险提示: 只读查询接口 - 是否任务型: 否 - - get_routes_ipv6 - 用途: 获取后台 IPv6 路由信息列表 - 必填: 无 - 常用: routeType、_start、_length - 风险提示: 只读查询接口 - 是否任务型: 否 - enum: - - get_routes - - get_routes_ipv6 - routeType: - type: string - description: > - 路由类型过滤:ALL_ROUTE=所有路由,STATIC_ROUTE=静态路由, - DIRECT_ROUTE=直连路由,OSPF_ROUTE=OSPF路由,RIP_ROUTE=RIP路由, - VPN_ROUTE=VPN路由,SSL_VPN_ROUTE=SSL VPN路由, - IBGP_ROUTE=IBGP路由,EBGP_ROUTE=EBGP路由 - enum: - - ALL_ROUTE - - STATIC_ROUTE - - DIRECT_ROUTE - - OSPF_ROUTE - - RIP_ROUTE - - VPN_ROUTE - - SSL_VPN_ROUTE - - IBGP_ROUTE - - EBGP_ROUTE - _start: - type: integer - description: 分页起始位置(从0开始) - _length: - type: integer - description: 每页最大返回数量(最大200,默认100) - _search: - type: string - description: 模糊搜索关键字 - required: - - action -handler: - type: script - script_file: sangfor_af.handler.py - function: network diff --git a/.flocks/flockshub/plugins/tools/api/sangfor_af_v8_0_85/sangfor_af_v85_objects.yaml b/.flocks/flockshub/plugins/tools/api/sangfor_af_v8_0_85/sangfor_af_v85_objects.yaml deleted file mode 100644 index 180b47774..000000000 --- a/.flocks/flockshub/plugins/tools/api/sangfor_af_v8_0_85/sangfor_af_v85_objects.yaml +++ /dev/null @@ -1,152 +0,0 @@ -name: sangfor_af_v85_objects -description: > - Sangfor AF v8.0.48 objects management tool. Query, create, update, and - delete network IP group objects and services (protocol/port definitions) - used in firewall policies. -description_cn: > - 深信服 AF v8.0.48 对象管理工具。通过 `action` 参数查询、创建、修改和删除 - IP 地址组对象及服务对象(协议/端口定义),这些对象被防火墙策略引用。 -category: custom -enabled: true -requires_confirmation: true -provider: sangfor_af -inputSchema: - type: object - properties: - action: - type: string - description: | - 对象管理动作名,可选值: - - ## IP 地址组 - - get_ipgroups - 用途: 查询符合条件的 IP 地址组列表 - 必填: 无 - 常用: _start、_length、businessType、__nameprefix、important、_search - 风险提示: 只读查询接口 - 是否任务型: 否 - - get_ipgroup - 用途: 获取单个 IP 地址组详情 - 必填: uuid - 风险提示: 只读查询接口 - 是否任务型: 否 - - create_ipgroup - 用途: 创建新的 IP 地址组 - 必填: name、businessType - 常用: ipRanges、addressType、description、important - 风险提示: 写操作;创建后可被防火墙策略引用 - 是否任务型: 否 - - update_ipgroup - 用途: 增量更新(PATCH)指定 IP 地址组 - 必填: uuid - 常用: name、ipRanges、description - 风险提示: 写操作;修改 IP 组会影响引用该组的所有策略 - 是否任务型: 否 - - delete_ipgroup - 用途: 删除指定 IP 地址组 - 必填: uuid - 风险提示: 高风险写操作;如有策略引用该组将删除失败 - 是否任务型: 否 - - ## 服务对象 - - get_services - 用途: 查询服务或服务组列表(预定义或自定义) - 必填: 无 - 常用: _start、_length、_search、serviceType - 风险提示: 只读查询接口 - 是否任务型: 否 - - get_service - 用途: 获取单个服务或服务组详情 - 必填: uuid - 风险提示: 只读查询接口 - 是否任务型: 否 - enum: - - get_ipgroups - - get_ipgroup - - create_ipgroup - - update_ipgroup - - delete_ipgroup - - get_services - - get_service - - uuid: - type: string - description: IP地址组或服务对象的唯一标识符(32字符UUID) - name: - type: string - description: 对象名称(最大95字符) - businessType: - type: string - description: > - IP地址组业务类型:IP=IP地址,ADDRGROUP=地址组, - USER=用户地址,BUSINESS=业务地址 - enum: - - IP - - ADDRGROUP - - USER - - BUSINESS - addressType: - type: string - description: "IP协议版本:IPV4 或 IPV6" - enum: - - IPV4 - - IPV6 - important: - type: string - description: "重要级别:COMMON=普通,CORE=核心" - enum: - - COMMON - - CORE - ipRanges: - type: array - items: - type: object - properties: - start: - type: string - description: IP范围起始地址(如 192.168.1.1) - end: - type: string - description: IP范围结束地址(如 192.168.1.254) - description: IP地址范围列表 - description: - type: string - description: 对象描述(最大95字符) - creator: - type: string - description: 创建者名称 - serviceType: - type: string - description: "服务类型过滤:SERVICE=单个服务,SERVICEGROUP=服务组" - enum: - - SERVICE - - SERVICEGROUP - - # Pagination - _start: - type: integer - description: 分页起始位置(从0开始) - _length: - type: integer - description: 每页最大返回数量(最大200,默认100) - __nameprefix: - type: string - description: 按名称前缀过滤(最大95字符) - _search: - type: string - description: 模糊搜索关键字(最大95字符) - _order: - type: string - description: "排序方向:asc 或 desc" - enum: - - asc - - desc - _sortby: - type: string - description: 排序字段名 - required: - - action -handler: - type: script - script_file: sangfor_af.handler.py - function: objects diff --git a/.flocks/flockshub/plugins/tools/api/sangfor_af_v8_0_85/sangfor_af_v85_ops.yaml b/.flocks/flockshub/plugins/tools/api/sangfor_af_v8_0_85/sangfor_af_v85_ops.yaml deleted file mode 100644 index a46585d4a..000000000 --- a/.flocks/flockshub/plugins/tools/api/sangfor_af_v8_0_85/sangfor_af_v85_ops.yaml +++ /dev/null @@ -1,165 +0,0 @@ -name: sangfor_af_v85_ops -description: > - Sangfor AF v8.0.48 operations center tool. Manages blacklist/whitelist - entries (IPs, domains, URLs) and blocked attacker IPs via the `action` - parameter. Key security triage actions for SOC workflows. -description_cn: > - 深信服 AF v8.0.48 运营中心工具。通过 `action` 参数管理黑白名单(IP/域名/URL) - 和封锁攻击者 IP。是 SOC 安全处置的核心接口。 -category: custom -enabled: true -requires_confirmation: true -provider: sangfor_af -inputSchema: - type: object - properties: - action: - type: string - description: | - 运营中心动作名,可选值: - - ## 黑白名单管理 - - get_blackwhitelist - 用途: 查询黑白名单列表(IP/域名/URL) - 必填: 无 - 常用: type(BLACK/WHITE)、_start、_length - 风险提示: 只读查询接口 - 是否任务型: 否 - - add_blackwhitelist - 用途: 添加单条黑白名单 - 必填: url(IP/域名/URL)、type(BLACK/WHITE) - 常用: enable、description、domain(0=IP,1=域名,2=URL) - 风险提示: 写操作;添加黑名单会拦截对应流量 - 是否任务型: 否 - - batch_add_blackwhitelist - 用途: 批量添加黑白名单 - 必填: items(数组,每项含 url/type 字段) - 风险提示: 写操作,批量添加黑名单影响面大 - 是否任务型: 否 - - delete_blackwhitelist - 用途: 删除单条黑白名单 - 必填: url(条目的 IP/域名/URL) - 常用: type(BLACK/WHITE) - 风险提示: 写操作,删除白名单可能导致误拦截 - 是否任务型: 否 - - batch_delete_blackwhitelist - 用途: 批量删除黑白名单 - 必填: items(数组,每项含 url 字段) - 风险提示: 写操作,批量删除影响面大 - 是否任务型: 否 - - ## 封锁攻击者 IP - - get_blockip_list - 用途: 查询当前封锁攻击者 IP 列表 - 必填: 无 - 常用: _start、_length、fuzzyIP(模糊搜索)、creator(AF/SIP) - 风险提示: 只读查询接口 - 是否任务型: 否 - - batch_add_blockip - 用途: 批量封锁攻击者 IP - 必填: items(数组,每项含 srcIP、dstIP 等字段) - 常用: aifwType(MANUAL/AUTO) - 风险提示: 高风险写操作;封锁 IP 会拦截其所有流量 - 是否任务型: 否 - - batch_delete_blockip - 用途: 批量解封攻击者 IP - 必填: items(数组,每项含 srcIP、dstIP 等字段) - 风险提示: 写操作,解封恶意 IP 存在安全风险 - 是否任务型: 否 - - clear_blockip - 用途: 清空封锁攻击者 IP 列表 - 必填: 无 - 常用: creator(AF/SIP,指定清除哪类封锁) - 风险提示: 高风险写操作;会清除所有封锁 IP - 是否任务型: 否 - - get_blockip_auto_config - 用途: 获取自动封锁攻击者时长配置 - 必填: 无 - 风险提示: 只读查询接口 - 是否任务型: 否 - - set_blockip_auto_config - 用途: 修改自动封锁攻击者时长 - 必填: blockTime(封锁时长,单位秒) - 风险提示: 写操作,影响自动封锁策略 - 是否任务型: 否 - enum: - - get_blackwhitelist - - add_blackwhitelist - - batch_add_blackwhitelist - - delete_blackwhitelist - - batch_delete_blackwhitelist - - get_blockip_list - - batch_add_blockip - - batch_delete_blockip - - clear_blockip - - get_blockip_auto_config - - set_blockip_auto_config - - # Blacklist/whitelist params - url: - type: string - description: IP地址、域名或URL(黑白名单条目值) - type: - type: string - description: "名单类型:BLACK(黑名单)或 WHITE(白名单)" - enum: - - BLACK - - WHITE - enable: - type: boolean - description: 是否启用该条目,默认 true - description: - type: string - description: 条目描述信息(最大95字符) - domain: - type: integer - description: "条目类型:0=IP地址,1=域名,2=URL" - enum: [0, 1, 2] - items: - type: array - items: - type: object - description: 批量操作时的条目数组,每项至少包含 url(黑白名单)或 srcIP/dstIP(封锁IP) - - # Block IP params - fuzzyIP: - type: string - description: 模糊搜索IP关键字(最大15字符) - creator: - type: string - description: "封锁来源身份:AF(防火墙自身)或 SIP(安全感知平台)" - enum: - - AF - - SIP - aifwType: - type: string - description: "添加封锁IP的类型:MANUAL(手动)或 AUTO(自动,需要 creator=SIP)" - enum: - - MANUAL - - AUTO - blockTime: - type: integer - description: 自动封锁时长(秒) - - # Pagination - _start: - type: integer - description: 分页起始位置(从0开始) - _length: - type: integer - description: 每页最大返回数量(最大200,默认100) - _sortby: - type: string - description: 排序字段名 - _order: - type: string - description: "排序方向:asc(升序)或 desc(降序)" - enum: - - asc - - desc - required: - - action -handler: - type: script - script_file: sangfor_af.handler.py - function: ops diff --git a/.flocks/flockshub/plugins/tools/api/sangfor_af_v8_0_85/sangfor_af_v85_status.yaml b/.flocks/flockshub/plugins/tools/api/sangfor_af_v8_0_85/sangfor_af_v85_status.yaml deleted file mode 100644 index a1c01a441..000000000 --- a/.flocks/flockshub/plugins/tools/api/sangfor_af_v8_0_85/sangfor_af_v85_status.yaml +++ /dev/null @@ -1,91 +0,0 @@ -name: sangfor_af_v85_status -description: > - Sangfor AF v8.0.48 device status tool. Query system resource usage - (CPU, memory, disk), firmware version, network interface status, - current time, and system uptime. -description_cn: > - 深信服 AF v8.0.48 状态中心工具。通过 `action` 参数查询系统资源(CPU/内存/磁盘)、 - 固件版本、网口状态、当前时间及系统运行时长等信息。 -category: custom -enabled: true -requires_confirmation: false -provider: sangfor_af -inputSchema: - type: object - properties: - action: - type: string - description: | - 状态查询动作名,可选值: - - get_memory_usage - 用途: 获取当前内存使用率(百分比) - 必填: 无 - 风险提示: 只读查询接口 - 是否任务型: 否 - - get_cpu_usage - 用途: 获取当前 CPU 使用率(百分比) - 必填: 无 - 风险提示: 只读查询接口 - 是否任务型: 否 - - get_disk_usage - 用途: 获取当前磁盘使用率(百分比) - 必填: 无 - 风险提示: 只读查询接口 - 是否任务型: 否 - - get_system_version - 用途: 获取 AF 系统固件版本信息 - 必填: 无 - 常用: filter(ALL/FULL/MAJOR/MINOR 等) - 风险提示: 只读查询接口 - 是否任务型: 否 - - get_interface_status - 用途: 获取指定网口或全部网口的状态(流速、连接状态) - 必填: 无 - 常用: interfaceNames(如 eth0,不传则获取全部) - 风险提示: 只读查询接口 - 是否任务型: 否 - - get_runtime_status - 用途: 获取系统运行时长(uptime) - 必填: 无 - 风险提示: 只读查询接口 - 是否任务型: 否 - - get_current_time - 用途: 获取设备当前时间 - 必填: 无 - 风险提示: 只读查询接口 - 是否任务型: 否 - enum: - - get_memory_usage - - get_cpu_usage - - get_disk_usage - - get_system_version - - get_interface_status - - get_runtime_status - - get_current_time - filter: - type: string - description: > - 版本信息过滤(仅用于 get_system_version): - ALL=显示所有,FULL=完整版本号,MAJOR=主版本号,MINOR=次版本号, - INCREASE=增版本号,BUILD=创建日期,EN=是否英文版,HF=是否HF版,B=是否Beta版 - enum: - - ALL - - FULL - - MAJOR - - MINOR - - INCREASE - - BUILD - - EN - - HF - - B - - R - - ADD - interfaceNames: - type: string - description: 网口名称(如 eth0),用于 get_interface_status;不填则获取全部接口 - required: - - action -handler: - type: script - script_file: sangfor_af.handler.py - function: status diff --git a/.flocks/flockshub/plugins/tools/api/sangfor_af_v8_0_85/sangfor_af_v85_system.yaml b/.flocks/flockshub/plugins/tools/api/sangfor_af_v8_0_85/sangfor_af_v85_system.yaml deleted file mode 100644 index 1ad154a95..000000000 --- a/.flocks/flockshub/plugins/tools/api/sangfor_af_v8_0_85/sangfor_af_v85_system.yaml +++ /dev/null @@ -1,53 +0,0 @@ -name: sangfor_af_v85_system -description: > - Sangfor AF v8.0.48 system management tool. Query and manage administrator - accounts on the AF device. -description_cn: > - 深信服 AF v8.0.48 系统管理工具。通过 `action` 参数查询和管理 AF 设备上的 - 管理员账户信息。 -category: custom -enabled: true -requires_confirmation: true -provider: sangfor_af -inputSchema: - type: object - properties: - action: - type: string - description: | - 系统管理动作名,可选值: - - get_accounts - 用途: 查询所有管理员账户列表 - 必填: 无 - 常用: _start、_length、enable - 风险提示: 只读查询接口 - 是否任务型: 否 - - get_account - 用途: 查询指定管理员账户详情 - 必填: name(账户名) - 风险提示: 只读查询接口 - 是否任务型: 否 - enum: - - get_accounts - - get_account - name: - type: string - description: 管理员账户名(用于 get_account) - enable: - type: boolean - description: 按启用/禁用状态过滤账户 - _start: - type: integer - description: 分页起始位置(从0开始) - _length: - type: integer - description: 每页最大返回数量 - _search: - type: string - description: 模糊搜索关键字 - required: - - action -handler: - type: script - script_file: sangfor_af.handler.py - function: system diff --git a/.flocks/flockshub/plugins/tools/device/onesig_v2_5_3_D20250710/_provider.yaml b/.flocks/flockshub/plugins/tools/device/onesig_v2_5_3_D20250710/_provider.yaml index c11832f19..66407e936 100644 --- a/.flocks/flockshub/plugins/tools/device/onesig_v2_5_3_D20250710/_provider.yaml +++ b/.flocks/flockshub/plugins/tools/device/onesig_v2_5_3_D20250710/_provider.yaml @@ -2,6 +2,7 @@ name: onesig_v2_5_3_D20250710 vendor: threatbook service_id: onesig_v2_5_3_D20250710_api version: "2.5.3 D20250710" +integration_type: device description: > OneSIG (Secure Internet Gateway) Web API service — *older v2.5 firmware* variant. Authentication still uses cookie session, but the login endpoint diff --git a/.flocks/flockshub/plugins/tools/device/sangfor_af_v8_0_48/_provider.yaml b/.flocks/flockshub/plugins/tools/device/sangfor_af_v8_0_48/_provider.yaml index 64971d348..2c57b476b 100644 --- a/.flocks/flockshub/plugins/tools/device/sangfor_af_v8_0_48/_provider.yaml +++ b/.flocks/flockshub/plugins/tools/device/sangfor_af_v8_0_48/_provider.yaml @@ -2,6 +2,7 @@ name: sangfor_af vendor: sangfor service_id: sangfor_af version: "8.0.48" +integration_type: device description: > Sangfor AF (Application Firewall) v8.0.48 REST API service. Uses session-based cookie authentication: call login to obtain diff --git a/.flocks/flockshub/plugins/tools/device/sangfor_af_v8_0_85/_provider.yaml b/.flocks/flockshub/plugins/tools/device/sangfor_af_v8_0_85/_provider.yaml index b77adbbc3..111b35034 100644 --- a/.flocks/flockshub/plugins/tools/device/sangfor_af_v8_0_85/_provider.yaml +++ b/.flocks/flockshub/plugins/tools/device/sangfor_af_v8_0_85/_provider.yaml @@ -2,6 +2,7 @@ name: sangfor_af vendor: sangfor service_id: sangfor_af version: "8.0.85" +integration_type: device description: > Sangfor AF (Application Firewall) v8.0.85 REST API service. Uses session-based cookie authentication: call login to obtain diff --git a/flocks/hub/catalog.py b/flocks/hub/catalog.py index 3d0701b96..2f681cc74 100644 --- a/flocks/hub/catalog.py +++ b/flocks/hub/catalog.py @@ -105,6 +105,14 @@ def manifest_path(plugin_type: PluginType, plugin_id: str) -> Path: direct = root / "plugins" / f"{plugin_type}s" / plugin_id / "manifest.json" if direct.is_file(): return direct + # Device plugins ship inside ``plugins/tools/device//`` — there + # is no ``plugins/devices/`` directory. Try the canonical tool path + # before falling back to the index lookup so on-disk manifest files + # (when present) are still picked up. + if plugin_type == "device": + tool_direct = root / "plugins" / "tools" / "device" / plugin_id / "manifest.json" + if tool_direct.is_file(): + return tool_direct manifest_rel = _manifest_path_lookup().get((plugin_type, plugin_id)) if manifest_rel: path = (root / manifest_rel).resolve() @@ -331,6 +339,14 @@ def _tool_tags(plugin_id: str, description: str) -> list[str]: return _safe_tags(inferred) +def _provider_integration_type(provider: dict[str, Any]) -> Optional[str]: + value = provider.get("integration_type") + if not isinstance(value, str): + return None + cleaned = value.strip().lower() + return cleaned or None + + def _tool_manifest(plugin_id: str, root: Path) -> Optional[HubPluginManifest]: if not _has_direct_tool_payload(root): return None @@ -350,8 +366,18 @@ def _tool_manifest(plugin_id: str, root: Path) -> Optional[HubPluginManifest]: for path in sorted(root.iterdir(), key=lambda item: item.name) if path.is_file() and path.suffix in {".yaml", ".yml", ".py"} ] + # ``integration_type: device`` in ``_provider.yaml`` upgrades the + # plugin to the first-class ``device`` Hub type. This drives: + # * the marketplace "Device" tab (filterable as ``type=device``), + # * the standard install path ``/tools/device//`` + # (resolved by ``hub.local.install_root("device", ...)``), + # * the device-access wizard, which still consumes + # ``api_services[storage_key]`` and filters by ``integration_type`` + # so devices keep showing up there too. + integration_type = _provider_integration_type(provider) + plugin_type: PluginType = "device" if integration_type == "device" else "tool" return _base_manifest( - plugin_type="tool", + plugin_type=plugin_type, plugin_id=plugin_id, name=str(provider.get("name") or first_tool.get("name") or plugin_id), description=description, @@ -386,8 +412,12 @@ def _system_plugin_roots() -> dict[tuple[PluginType, str], Path]: tools_root = local.install_root("tool", "project") if tools_root.is_dir(): for directory in sorted((path for path in tools_root.rglob("*") if path.is_dir()), key=lambda item: item.as_posix()): - if _tool_manifest(directory.name, directory): - roots[("tool", directory.name)] = directory + manifest = _tool_manifest(directory.name, directory) + if manifest: + # The manifest type already reflects ``integration_type: + # device`` (see :func:`_tool_manifest`), so we just defer + # to it instead of hardcoding ``"tool"``. + roots[(manifest.type, directory.name)] = directory return roots @@ -426,12 +456,15 @@ def _bundled_tool_roots() -> dict[tuple[PluginType, str], Path]: continue if directory.parent == tools_root and name in {"api", "device", "python", "mcp", "generated"}: continue - if not _tool_manifest(name, directory): + manifest = _tool_manifest(name, directory) + if not manifest: continue # First-wins: keep the highest-priority bundled root entry # and let later collisions fall through silently — same - # contract as ``_system_plugin_roots``. - roots.setdefault(("tool", name), directory) + # contract as ``_system_plugin_roots``. ``manifest.type`` + # honours ``integration_type: device`` so device plugins + # surface with ``("device", id)`` keys. + roots.setdefault((manifest.type, name), directory) return roots @@ -449,7 +482,10 @@ def system_plugin_root(plugin_type: PluginType, plugin_id: str) -> Optional[Path installed = _system_plugin_roots().get((plugin_type, plugin_id)) if installed is not None: return installed - if plugin_type == "tool": + # ``tool`` and ``device`` plugins are both materialised as bundled + # flockshub directories under ``plugins/tools/`` — the device variant + # just sets ``integration_type: device`` in ``_provider.yaml``. + if plugin_type in {"tool", "device"}: return _bundled_tool_roots().get((plugin_type, plugin_id)) return None @@ -468,8 +504,16 @@ def _manifest_for_system_root(plugin_type: PluginType, plugin_id: str, root: Pat return _agent_manifest(plugin_id, root) if plugin_type == "workflow": return _workflow_manifest(plugin_id, root) - if plugin_type == "tool": - return _tool_manifest(plugin_id, root) + if plugin_type in {"tool", "device"}: + # ``_tool_manifest`` infers the final ``device`` vs ``tool`` + # split from ``_provider.yaml``'s ``integration_type``. Callers + # that already know the expected type still pass through here + # so we can confirm the inferred type matches the requested one + # (e.g. avoid returning a device manifest for a tool query). + manifest = _tool_manifest(plugin_id, root) + if manifest and manifest.type != plugin_type: + return None + return manifest return None diff --git a/flocks/hub/installer.py b/flocks/hub/installer.py index ad12dd191..c28cb25f0 100644 --- a/flocks/hub/installer.py +++ b/flocks/hub/installer.py @@ -52,10 +52,21 @@ def _resolve_install_destination( (``api/``, ``python/``, ``mcp/``, ``generated/``) we install to ``///`` — regardless of whether the source is the bundled flockshub copy or an existing project-level install - being re-installed at user scope. All other plugin types and - sources without a recognised group prefix fall back to the - standard ``//`` layout. + being re-installed at user scope. + + For ``plugin_type == "device"`` we always install to + ``/tools/device//`` (resolved through + :func:`local.install_dir`). That keeps every device plugin in a + canonical location regardless of how the source was laid out, and + matches the search root used by + :func:`flocks.config.api_versioning._api_plugin_roots`. + + All other plugin types and sources without a recognised group + prefix fall back to the standard ``//`` layout. """ + if plugin_type == "device": + return local.install_dir(plugin_type, plugin_id, scope) + if plugin_type != "tool": return local.install_dir(plugin_type, plugin_id, scope) @@ -112,7 +123,13 @@ async def _refresh_runtime(plugin_type: PluginType) -> None: from flocks.agent.registry import Agent Agent.invalidate_cache() - elif plugin_type == "tool": + elif plugin_type in {"tool", "device"}: + # ``device`` plugins live under ``/tools/device//`` + # and are loaded by the same ``ToolRegistry`` machinery as ``tool`` + # plugins — refreshing one means refreshing both, so a freshly + # installed device is picked up by both the Tool API summary and + # the Device Access wizard (the latter consumes + # ``api_services[storage_key]`` shaped by ``discover_api_service_descriptors``). from flocks.config.api_versioning import discover_api_service_descriptors from flocks.tool.registry import ToolRegistry @@ -220,8 +237,14 @@ async def uninstall_plugin(plugin_type: PluginType, plugin_id: str) -> bool: raise ValueError("Only user-managed Hub plugin installs can be removed") # Capture provider metadata BEFORE rmtree — once the dir is gone we # can't read its ``_provider.yaml`` to know which api_services keys - # were derived from it. - orphan_keys = _collect_storage_keys(install_path) if plugin_type == "tool" else [] + # were derived from it. ``device`` plugins reuse the same provider + # yaml machinery as ``tool``/``api`` plugins, so we collect orphan + # storage keys for both types. + orphan_keys = ( + _collect_storage_keys(install_path) + if plugin_type in {"tool", "device"} + else [] + ) shutil.rmtree(install_path) local.remove_installed_record(plugin_type, plugin_id) _cleanup_orphan_api_services(orphan_keys) diff --git a/flocks/hub/local.py b/flocks/hub/local.py index 5a58a51ed..68e4ddc3a 100644 --- a/flocks/hub/local.py +++ b/flocks/hub/local.py @@ -28,6 +28,11 @@ def install_root(plugin_type: PluginType, scope: str = "global") -> Path: return root / "agents" if plugin_type == "workflow": return root / "workflows" + if plugin_type == "device": + # Device plugins live as a subdirectory of tools/ so the runtime + # tool loader (which expects ``/tools///``) + # picks them up alongside api/ and python/ groups. + return root / "tools" / "device" return root / "tools" @@ -94,7 +99,7 @@ def has_install_payload(plugin_type: PluginType, path: Path) -> bool: return (path / "agent.yaml").is_file() if plugin_type == "workflow": return (path / "workflow.json").is_file() or (path / "workflow.md").is_file() - if plugin_type == "tool": + if plugin_type in {"tool", "device"}: if path.is_file(): return path.suffix in {".yaml", ".yml", ".py"} return any(candidate.is_file() for candidate in path.rglob("*.yaml")) or any( @@ -139,12 +144,27 @@ def infer_local_install(plugin_type: PluginType, plugin_id: str) -> Optional[Pat for base in (install_root("tool", "global"), install_root("tool", "project")): if not base.is_dir(): continue - for nested in (base / "api" / plugin_id, base / "mcp" / plugin_id, base / "generated" / plugin_id): + for nested in ( + base / "api" / plugin_id, + base / "device" / plugin_id, + base / "mcp" / plugin_id, + base / "generated" / plugin_id, + ): if has_install_payload(plugin_type, nested): return nested for candidate in base.rglob(f"{plugin_id}.yaml"): if has_install_payload(plugin_type, candidate.parent): return candidate.parent + if plugin_type == "device": + # Device installs live under ``/device//``. We already + # checked the canonical path above via ``install_dir``; the loop + # here catches legacy installs that may have been written into + # the bare ``//`` location before ``device`` became + # a first-class plugin type. + for base in (install_root("tool", "global"), install_root("tool", "project")): + legacy = base / plugin_id + if has_install_payload("tool", legacy): + return legacy return None @@ -168,19 +188,33 @@ def infer_local_installs() -> dict[tuple[PluginType, str], Path]: for child in base.iterdir(): if child.is_dir() and has_install_payload("tool", child): result.setdefault(("tool", child.name), child) - for group in ("api", "mcp", "generated"): + # Tools live under ``///`` where ``group`` is + # one of api/device/mcp/generated. ``device`` is a first-class + # plugin type on the Hub layer (driven by ``integration_type: + # device`` in ``_provider.yaml``), so we surface those entries + # keyed as ``("device", id)`` instead of ``("tool", id)`` to keep + # the catalog state in sync with the runtime install path. + for group in ("api", "device", "mcp", "generated"): group_dir = base / group if not group_dir.is_dir(): continue + entry_type: PluginType = "device" if group == "device" else "tool" for child in group_dir.iterdir(): if child.is_dir() and has_install_payload("tool", child): - result.setdefault(("tool", child.name), child) + result.setdefault((entry_type, child.name), child) for candidate in base.rglob("*"): if not candidate.is_file() or candidate.name == "__init__.py": continue if candidate.suffix not in {".yaml", ".yml", ".py"}: continue if has_install_payload("tool", candidate.parent): - result.setdefault(("tool", candidate.stem), candidate.parent) + # Preserve the directory-derived classification populated + # above; never downgrade a previously-classified device + # entry back to tool just because we found another yaml + # in the same package. + stem_key = candidate.stem + if (("device", stem_key) in result) or (("tool", stem_key) in result): + continue + result.setdefault(("tool", stem_key), candidate.parent) return result diff --git a/flocks/hub/models.py b/flocks/hub/models.py index ce2e092be..8227336ea 100644 --- a/flocks/hub/models.py +++ b/flocks/hub/models.py @@ -7,7 +7,7 @@ from pydantic import BaseModel, ConfigDict, Field -PluginType = Literal["skill", "agent", "tool", "workflow"] +PluginType = Literal["skill", "agent", "tool", "device", "workflow"] PluginState = Literal[ "available", "installed", diff --git a/flocks/server/routes/device.py b/flocks/server/routes/device.py index b2adf69ff..924ef5f19 100644 --- a/flocks/server/routes/device.py +++ b/flocks/server/routes/device.py @@ -193,7 +193,14 @@ async def route_update_device(device_id: str, body: DeviceIntegrationUpdate): verify_ssl=new_ssl, db_fields=new_fields, ) - await sync_service_tool_state(row["service_id"]) + # Recompute ``service_id`` from the row's ``storage_key`` instead of + # trusting the stored column. Rows created before the descriptor- + # aware ``storage_key_to_service_id`` fix may carry a too-greedy + # value (e.g. ``onesig`` instead of ``onesig_v2_5_3_D20250710_api``); + # using the column directly would route this sync to the wrong key + # bucket and leave ``api_services[storage_key].enabled`` stale, + # which in turn keeps tools wrongly exposed to the LLM. + await sync_service_tool_state(storage_key_to_service_id(row["storage_key"])) return await route_get_device(device_id) @@ -202,11 +209,13 @@ async def route_delete_device(device_id: str): row = await fetch_device(device_id) if row is None: raise HTTPException(status_code=http_status.HTTP_404_NOT_FOUND, detail="Device not found") - service_id: str = row["service_id"] # Capture storage_key BEFORE deletion: once the row is gone the DB query # inside sync_service_tool_state can no longer see it, so if this was the # last instance for that storage_key the tool would never get disabled. storage_key: str = row["storage_key"] + # Always derive service_id from the live storage_key — see comment in + # ``route_update_device`` for why we don't trust the stored column. + service_id: str = storage_key_to_service_id(storage_key) db_fields: dict = json.loads(row["fields"] or "{}") delete_secrets(device_id, db_fields) diff --git a/flocks/server/routes/provider.py b/flocks/server/routes/provider.py index 1e2206ce3..53e579a27 100644 --- a/flocks/server/routes/provider.py +++ b/flocks/server/routes/provider.py @@ -1205,16 +1205,21 @@ async def update_api_service(provider_id: str, request: APIServiceUpdateRequest) raise HTTPException(status_code=500, detail=str(e)) -def _find_user_installed_tool_plugin_for(storage_key: str) -> Optional[str]: - """Return the ``installed.json`` plugin id that backs *storage_key*. +def _find_user_installed_tool_plugin_for(storage_key: str) -> Optional[tuple[str, str]]: + """Return ``(plugin_type, plugin_id)`` backing *storage_key*, if any. Walks the discovered ``ApiServiceDescriptor`` set, finds the ``_provider.yaml`` matching *storage_key*, and resolves it to the - enclosing user-installed plugin directory id. Returns ``None`` if - no descriptor matches OR the descriptor lives outside - ``~/.flocks/plugins/`` (project-level / built-in installs are kept - intact — only user-level installs cascade). + enclosing user-installed plugin directory id. ``plugin_type`` is + ``"device"`` when the descriptor lives under ``/device/`` or + when ``_provider.yaml`` declares ``integration_type: device`` — + otherwise ``"tool"``. Returns ``None`` if no descriptor matches OR + the descriptor lives outside ``~/.flocks/plugins/`` (project-level + / built-in installs are kept intact — only user-level installs + cascade). """ + import yaml + from flocks.config.api_versioning import discover_api_service_descriptors from flocks.hub import local as hub_local @@ -1228,13 +1233,34 @@ def _find_user_installed_tool_plugin_for(storage_key: str) -> Optional[str]: except ValueError: return None # Not under the user-level install root. plugin_id = plugin_dir.name + + plugin_type = "tool" + if plugin_dir.parent.name == "device": + plugin_type = "device" + else: + try: + provider_yaml = yaml.safe_load(descriptor.provider_yaml.read_text(encoding="utf-8")) + if ( + isinstance(provider_yaml, dict) + and str(provider_yaml.get("integration_type") or "").strip().lower() == "device" + ): + plugin_type = "device" + except Exception: + pass + # Confirm the catalog tracks this as an installed plugin so # we never try to "uninstall" a stray on-disk dir. - record = hub_local.get_record("tool", plugin_id) + record = hub_local.get_record(plugin_type, plugin_id) if record is not None: - return plugin_id - if hub_local.infer_local_install("tool", plugin_id) is not None: - return plugin_id + return (plugin_type, plugin_id) + # Fall back to the opposite type when the record was saved + # before ``device`` became a first-class Hub type. + legacy_type = "tool" if plugin_type == "device" else "device" + legacy_record = hub_local.get_record(legacy_type, plugin_id) + if legacy_record is not None: + return (legacy_type, plugin_id) + if hub_local.infer_local_install(plugin_type, plugin_id) is not None: + return (plugin_type, plugin_id) return None @@ -1273,7 +1299,7 @@ async def delete_api_service(provider_id: str) -> Dict[str, Any]: # ``remove_api_service`` runs, ``discover_api_service_descriptors`` # still works (it scans ``_provider.yaml`` on disk, not config), # but doing this first keeps the order easy to reason about. - backing_plugin_id = _find_user_installed_tool_plugin_for(provider_id) + backing_plugin = _find_user_installed_tool_plugin_for(provider_id) removed_config = ConfigWriter.remove_api_service(provider_id) @@ -1290,19 +1316,23 @@ async def delete_api_service(provider_id: str) -> Dict[str, Any]: matched_count = _set_api_service_tools_enabled(provider_id, False) uninstalled_plugin = False - if backing_plugin_id: + if backing_plugin is not None: + backing_plugin_type, backing_plugin_id = backing_plugin try: # ``uninstall_plugin`` itself calls # ``_cleanup_orphan_api_services`` for the plugin's # storage_keys, but ``provider_id`` is already gone from # config at this point so that pass is a no-op for it. - uninstalled_plugin = await uninstall_plugin("tool", backing_plugin_id) + uninstalled_plugin = await uninstall_plugin(backing_plugin_type, backing_plugin_id) except Exception as exc: # pragma: no cover - defensive log.warning("api_service.delete.plugin_uninstall_failed", { "provider_id": provider_id, + "plugin_type": backing_plugin_type, "plugin_id": backing_plugin_id, "error": str(exc), }) + else: + backing_plugin_id = None if not removed_config and not deleted_secret and not uninstalled_plugin: raise HTTPException(status_code=404, detail="API service not found") diff --git a/flocks/tool/device/startup.py b/flocks/tool/device/startup.py index 9b50e9442..9a2a5e33f 100644 --- a/flocks/tool/device/startup.py +++ b/flocks/tool/device/startup.py @@ -20,9 +20,45 @@ async def device_startup() -> None: await ensure_default_group() await migrate_from_config() + await _heal_stale_service_ids() await _sync_all() +async def _heal_stale_service_ids() -> None: + """Rewrite ``device_integrations.service_id`` rows that disagree with + the descriptor-aware ``storage_key_to_service_id``. + + Rows written by older builds (or before the plugin's + ``_provider.yaml`` was installed) may carry an over-stripped value — + e.g. ``onesig`` instead of ``onesig_v2_5_3_D20250710_api`` for plugins + whose ``service_id`` already contains its own ``_v…`` token. + + Self-healing once at startup keeps the live route handlers and the + sync loop honest without forcing the user to re-create each device. + """ + try: + from flocks.tool.device.store import storage_key_to_service_id + + async with Storage.connect(Storage.get_db_path()) as db: + cur = await db.execute("SELECT id, storage_key, service_id FROM device_integrations") + rows = await cur.fetchall() + updates: list[tuple[str, str]] = [] + for row in rows: + derived = storage_key_to_service_id(row["storage_key"] or "") + if derived and derived != (row["service_id"] or ""): + updates.append((derived, row["id"])) + for new_sid, dev_id in updates: + await db.execute( + "UPDATE device_integrations SET service_id = ? WHERE id = ?", + (new_sid, dev_id), + ) + if updates: + await db.commit() + log.info("tool.device.startup.service_id_healed", {"count": len(updates)}) + except Exception as exc: + log.warn("tool.device.startup.heal_failed", {"error": str(exc)}) + + async def _sync_all() -> None: """Re-sync tool visibility for every service_id we know about. diff --git a/flocks/tool/device/store.py b/flocks/tool/device/store.py index 7a42cc349..98d8fe41d 100644 --- a/flocks/tool/device/store.py +++ b/flocks/tool/device/store.py @@ -51,9 +51,58 @@ def _bump_revision() -> None: # Key derivation # --------------------------------------------------------------------------- +# Matches the trailing ``_v`` segment added by +# :func:`flocks.config.api_versioning.derive_storage_key`. Kept anchored to the +# end of the string so we only strip the *last* version suffix when falling +# back without descriptor data. +_TRAILING_VERSION_SUFFIX = re.compile(r"_v[A-Za-z0-9]+(?:_[A-Za-z0-9]+)*$") + + def storage_key_to_service_id(storage_key: str) -> str: - """Strip the version suffix: ``sangfor_af_v8_0_106`` → ``sangfor_af``.""" - return re.sub(r"_v[\w.]+$", "", storage_key, flags=re.IGNORECASE) + """Recover the bare ``service_id`` from a ``derive_storage_key`` result. + + Examples:: + + sangfor_af_v8_0_106 → sangfor_af + onesig_api_v2_5_3_D20260321 → onesig_api + onesig_v2_5_3_D20250710_api_v2_5_3_D20250710 → onesig_v2_5_3_D20250710_api + + The last example is the tricky one: when the plugin author has baked + a version into ``service_id`` itself (so the ``_provider.yaml`` + declares ``service_id: onesig_v2_5_3_D20250710_api``), the resulting + storage_key contains *two* ``_v…`` segments. A naive + ``re.sub(r"_v[\\w.]+$", "")`` is greedy from the leftmost ``_v`` and + would strip both segments back to ``onesig``, which then fails to + resolve in :func:`api_service_schema._load_provider_yaml_metadata` + (no descriptor has that bare service_id) — leaving the device-add + form blank. + + To stay correct in that case we consult the descriptor registry + first (an exact ``storage_key → service_id`` mapping, populated by + ``discover_api_service_descriptors``), and only fall back to the + regex heuristic when no descriptor matches *and* the input still + carries a single trailing ``_v…`` suffix. + """ + if not storage_key: + return storage_key + # Prefer the descriptor-driven mapping so we honour whatever + # ``service_id`` the plugin's ``_provider.yaml`` declared. This also + # handles the corner case where the plugin's ``service_id`` already + # contains its own ``_v…`` token and the naive regex would + # over-strip back to a prefix that nothing maps to. + try: + from flocks.config.api_versioning import discover_api_service_descriptors + + for descriptor in discover_api_service_descriptors(): + if descriptor.storage_key == storage_key: + return descriptor.service_id + except Exception: # pragma: no cover - defensive (e.g. test isolation) + pass + + # Fallback: anchored, non-greedy match against the trailing ``_v…`` + # segment. Keeps backward compat for storage keys whose plugin is + # missing from the descriptor cache (e.g. dangling config rows). + return _TRAILING_VERSION_SUFFIX.sub("", storage_key) def _now_ms() -> int: @@ -67,12 +116,19 @@ def _now_ms() -> int: def row_to_device(row: aiosqlite.Row) -> DeviceIntegration: raw_fields: Dict[str, str] = json.loads(row["fields"] or "{}") display, has_value = mask_for_display(raw_fields) + # Recompute service_id from storage_key so historically-wrong rows + # (created before ``storage_key_to_service_id`` learned to consult + # the descriptor cache) self-heal on read. The DB column stays as a + # write-side hint; the canonical mapping is always the descriptor's + # ``service_id`` for the row's ``storage_key``. + storage_key = row["storage_key"] + derived_service_id = storage_key_to_service_id(storage_key) if storage_key else row["service_id"] return DeviceIntegration( id=row["id"], group_id=row["group_id"] or DEFAULT_GROUP_ID, name=row["name"], - storage_key=row["storage_key"], - service_id=row["service_id"], + storage_key=storage_key, + service_id=derived_service_id or row["service_id"], enabled=bool(row["enabled"]), verify_ssl=bool(row["verify_ssl"]), fields=display, diff --git a/tests/hub/test_bundled_tools.py b/tests/hub/test_bundled_tools.py index 4e0aa40dd..a14b1a52b 100644 --- a/tests/hub/test_bundled_tools.py +++ b/tests/hub/test_bundled_tools.py @@ -455,13 +455,51 @@ async def test_finds_user_installed_plugin_by_storage_key(self, isolated_hub): ) await install_plugin("tool", "onesig_v2_5_3_D20250710") - plugin_id = _find_user_installed_tool_plugin_for("onesig_api_v2_5_3_D20250710") - assert plugin_id == "onesig_v2_5_3_D20250710" + result = _find_user_installed_tool_plugin_for("onesig_api_v2_5_3_D20250710") + # ``_find_user_installed_tool_plugin_for`` now returns + # ``(plugin_type, plugin_id)`` so the delete cascade can route + # uninstall to the right type (``tool`` or ``device``). + assert result == ("tool", "onesig_v2_5_3_D20250710") async def test_returns_none_when_storage_key_unknown(self, isolated_hub): from flocks.server.routes.provider import _find_user_installed_tool_plugin_for assert _find_user_installed_tool_plugin_for("nonexistent_v9_9_9") is None + async def test_finds_user_installed_device_plugin(self, isolated_hub): + """Devices (``integration_type: device``) should resolve back as + ``("device", id)`` so the api-service delete cascade uninstalls + them through the device-typed Hub flow. + """ + from flocks.server.routes.provider import _find_user_installed_tool_plugin_for + + bundled_plugin = _write_bundled_tool( + isolated_hub["bundled"], + plugin_id="dev_under_device", + service_id="device_under_test_api", + version="1.0.0", + group="device", + ) + # Mark it as a device plugin via the provider metadata. + provider_yaml = bundled_plugin / "_provider.yaml" + provider_yaml.write_text( + yaml.safe_dump( + { + "name": "dev_under_device", + "service_id": "device_under_test_api", + "version": "1.0.0", + "integration_type": "device", + "description": "device fixture", + }, + allow_unicode=True, + ), + encoding="utf-8", + ) + + await install_plugin("device", "dev_under_device") + + result = _find_user_installed_tool_plugin_for("device_under_test_api_v1_0_0") + assert result == ("device", "dev_under_device") + async def test_returns_none_for_project_level_plugin(self, isolated_hub, monkeypatch): """Project-level plugins (under ``/.flocks/plugins/``) are NOT considered "user-installed" — we don't want a delete from diff --git a/tests/hub/test_hub_catalog.py b/tests/hub/test_hub_catalog.py index 5cb691110..ae4911a1d 100644 --- a/tests/hub/test_hub_catalog.py +++ b/tests/hub/test_hub_catalog.py @@ -40,7 +40,10 @@ def isolated_hub_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): def test_bundled_hub_catalog_loads(): entries = list_catalog() assert entries - assert {entry.type for entry in entries} >= {"skill", "agent", "tool", "workflow"} + # ``device`` is a first-class Hub type alongside skill/agent/tool/workflow: + # entries with ``integration_type: device`` in ``_provider.yaml`` surface + # under ``type=device`` instead of ``type=tool``. + assert {entry.type for entry in entries} >= {"skill", "agent", "tool", "device", "workflow"} def test_pentest_agents_are_listed_in_agent_catalog(): @@ -67,7 +70,10 @@ def test_project_builtin_plugins_are_listed_as_installed(): assert by_key[("skill", "tdp-use")].native is True assert by_key[("agent", "ndr-analyst")].state == "installed" assert by_key[("workflow", "tdp_alert_triage")].state == "installed" - assert by_key[("tool", "tdp_v3_3_10")].state == "installed" + # ``tdp_v3_3_10`` declares ``integration_type: device`` in + # ``_provider.yaml``, so it surfaces as a ``device`` plugin (not + # ``tool``) in the Hub catalog. + assert by_key[("device", "tdp_v3_3_10")].state == "installed" manifest = load_manifest("skill", "tdp-use") assert manifest.id == "tdp-use" diff --git a/tests/tool/test_device_store_service_id.py b/tests/tool/test_device_store_service_id.py new file mode 100644 index 000000000..527871e9f --- /dev/null +++ b/tests/tool/test_device_store_service_id.py @@ -0,0 +1,156 @@ +"""Tests for ``storage_key_to_service_id`` and ``row_to_device``. + +Regression: when the plugin author bakes the version into the +``service_id`` itself (so ``_provider.yaml`` declares +``service_id: onesig_v2_5_3_D20250710_api``), the resulting +``storage_key`` carries *two* ``_v…`` segments. The naive greedy regex +``re.sub(r"_v[\\w.]+$", "")`` strips both back to ``onesig`` — which +then fails to resolve any ``_provider.yaml`` metadata, so the +"add device" form renders blank instead of showing the credential +fields. + +These tests lock in the descriptor-aware resolution + the read-time +recompute in :func:`row_to_device` that self-heals already-saved rows. +""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import MagicMock + +import pytest +import yaml + +from flocks.tool.device.store import row_to_device, storage_key_to_service_id + + +@pytest.fixture(autouse=True) +def reset_descriptor_cache(monkeypatch, tmp_path): + """Point the descriptor scanner at an isolated tools dir for each test.""" + from flocks.config import api_versioning as versioning + + home = tmp_path / "home" + project = tmp_path / "project" + home.mkdir() + project.mkdir() + monkeypatch.setenv("HOME", str(home)) + monkeypatch.chdir(project) + versioning._reset_descriptor_cache() + yield + versioning._reset_descriptor_cache() + + +def _drop_plugin(home: Path, *, plugin_id: str, service_id: str, version: str) -> None: + plugin_dir = home / ".flocks" / "plugins" / "tools" / "device" / plugin_id + plugin_dir.mkdir(parents=True) + (plugin_dir / "_provider.yaml").write_text( + yaml.safe_dump( + { + "name": plugin_id, + "service_id": service_id, + "version": version, + "integration_type": "device", + "credential_fields": [{"key": "base_url", "label": "Base URL"}], + }, + allow_unicode=True, + ), + encoding="utf-8", + ) + + +class TestStorageKeyToServiceId: + def test_strips_trailing_version_suffix(self): + # No descriptor needed — pure regex fallback path. + assert storage_key_to_service_id("sangfor_af_v8_0_106") == "sangfor_af" + assert storage_key_to_service_id("tdp_api_v3_3_10") == "tdp_api" + assert storage_key_to_service_id("ngsoc_api_v4_15_1") == "ngsoc_api" + + def test_returns_unversioned_input_unchanged(self): + assert storage_key_to_service_id("foo") == "foo" + assert storage_key_to_service_id("") == "" + + def test_descriptor_lookup_prefers_provider_service_id(self, tmp_path): + """When the plugin's ``service_id`` already contains its own + ``_v…`` token, descriptor lookup must return the full declared + ``service_id`` instead of greedily peeling both segments. + + Without the descriptor-aware path, the naive regex collapses + ``onesig_v2_5_3_D20250710_api_v2_5_3_D20250710`` to ``onesig`` + and the device-add form ends up with no credential fields. + """ + _drop_plugin( + Path.home(), + plugin_id="onesig_v2_5_3_D20250710", + service_id="onesig_v2_5_3_D20250710_api", + version="2.5.3 D20250710", + ) + from flocks.config.api_versioning import ( + discover_api_service_descriptors, + ) + + discover_api_service_descriptors(refresh=True) + + result = storage_key_to_service_id( + "onesig_v2_5_3_D20250710_api_v2_5_3_D20250710" + ) + assert result == "onesig_v2_5_3_D20250710_api", ( + "descriptor lookup must restore the plugin's declared " + "service_id even when it contains its own _v… token" + ) + + def test_fallback_when_no_descriptor(self, tmp_path): + """Without a descriptor we fall back to the anchored regex. + + The fallback intentionally stays best-effort — its job is to + avoid crashing for stale config rows whose backing plugin has + already been uninstalled. + """ + # No plugin dropped → empty descriptor cache. + assert ( + storage_key_to_service_id("dangling_plugin_v1_0_0") + == "dangling_plugin" + ) + + +class TestRowToDeviceSelfHeals: + def test_recomputes_service_id_from_storage_key(self, tmp_path): + """A row created before the fix may have a wrong ``service_id`` + stored in the DB (e.g. ``onesig`` instead of + ``onesig_v2_5_3_D20250710_api``). ``row_to_device`` should heal + it on read by recomputing from the row's ``storage_key`` — + otherwise the device-edit form keeps showing blank credentials. + """ + _drop_plugin( + Path.home(), + plugin_id="onesig_v2_5_3_D20250710", + service_id="onesig_v2_5_3_D20250710_api", + version="2.5.3 D20250710", + ) + from flocks.config.api_versioning import ( + discover_api_service_descriptors, + ) + + discover_api_service_descriptors(refresh=True) + + row = MagicMock() + row.__getitem__.side_effect = { + "id": "dev-1", + "group_id": "default-room", + "name": "OneSIG-older", + "storage_key": "onesig_v2_5_3_D20250710_api_v2_5_3_D20250710", + # Intentionally wrong — pretend the row was saved before the fix. + "service_id": "onesig", + "enabled": 1, + "verify_ssl": 0, + "fields": "{}", + "status": "unknown", + "message": None, + "latency_ms": None, + "checked_at": None, + "created_at": 0, + "updated_at": 0, + }.__getitem__ + + device = row_to_device(row) + assert device.service_id == "onesig_v2_5_3_D20250710_api" + assert device.storage_key == "onesig_v2_5_3_D20250710_api_v2_5_3_D20250710" diff --git a/webui/src/api/hub.ts b/webui/src/api/hub.ts index 7b26ebd2f..e9a93aa1e 100644 --- a/webui/src/api/hub.ts +++ b/webui/src/api/hub.ts @@ -1,6 +1,6 @@ import client from './client'; -export type HubPluginType = 'skill' | 'agent' | 'tool' | 'workflow'; +export type HubPluginType = 'skill' | 'agent' | 'tool' | 'device' | 'workflow'; export type HubPluginState = | 'available' | 'installed' diff --git a/webui/src/pages/DeviceIntegration/index.tsx b/webui/src/pages/DeviceIntegration/index.tsx index 92e9b72d6..4c8ef30a9 100644 --- a/webui/src/pages/DeviceIntegration/index.tsx +++ b/webui/src/pages/DeviceIntegration/index.tsx @@ -362,6 +362,16 @@ function DeviceConfigPanel({ device, template, vendorKey, onSave, onDelete, onCl const originalMasked = useRef>({}); const serviceId = device?.service_id ?? template?.id ?? ''; + // ``storage_key`` is the versioned, unambiguous identifier + // (e.g. ``onesig_api_v2_5_3_D20260321``) that tool registrations + // surface as ``ToolInfoResponse.source_name``. Use it directly for + // tool filtering so two devices that happen to share a service_id + // prefix (``onesig`` vs ``onesig_pro``) — or a plugin whose + // ``service_id`` already contains a ``_v…`` token — never bleed each + // other's tools into the device-edit panel. ``template?.id`` is also + // the storage_key (set by the wizard), so the create-mode form + // resolves to the right key too. + const storageKey = device?.storage_key ?? template?.id ?? ''; const vendor = vendorKey ? vendorPresentation(vendorKey) : undefined; useEffect(() => { @@ -391,12 +401,13 @@ function DeviceConfigPanel({ device, template, vendorKey, onSave, onDelete, onCl toolAPI.list() .then((res) => { + // Match against the device's storage_key exactly. The tool + // listing endpoint sets ``source_name = tool.provider``, which + // in turn equals the plugin's storage_key (see + // ``flocks/tool/tool_loader.py``). An exact comparison keeps + // multi-version installs of the same product cleanly isolated. const matched = (res.data || []).filter( - (t) => t.source_name && ( - t.source_name === serviceId || - t.source_name.startsWith(serviceId + '_') || - serviceId.startsWith(t.source_name) - ) + (t) => !!storageKey && t.source_name === storageKey, ); setServiceTools(matched); const initEnabled: Record = {}; @@ -404,7 +415,7 @@ function DeviceConfigPanel({ device, template, vendorKey, onSave, onDelete, onCl setToolEnabled(initEnabled); }) .catch(() => {}); - }, [device, serviceId]); + }, [device, serviceId, storageKey]); const handleSave = async () => { if (!name.trim()) { toast.error('请填写设备名称'); return; } diff --git a/webui/src/pages/Hub/index.tsx b/webui/src/pages/Hub/index.tsx index 92a787892..876a16396 100644 --- a/webui/src/pages/Hub/index.tsx +++ b/webui/src/pages/Hub/index.tsx @@ -57,9 +57,25 @@ const TYPE_LABEL: Record = { skill: 'Skill', agent: 'Agent', tool: 'Tool', + device: 'Device', workflow: 'Workflow', }; +const TYPE_LABEL_CN: Record = { + skill: 'Skill', + agent: 'Agent', + tool: 'Tool', + device: '设备', + workflow: 'Workflow', +}; + +function formatPluginTypeLabel(type: HubPluginType, language: string): string { + if (language.toLowerCase().startsWith('zh')) { + return TYPE_LABEL_CN[type] ?? TYPE_LABEL[type]; + } + return TYPE_LABEL[type]; +} + const HUB_TEXT = { zh: { description: '浏览随 Flocks 打包的本地插件广场,并安装到本机插件目录。', @@ -389,9 +405,9 @@ export default function HubPage() { onChange={value => setTypeFilter(value as HubPluginType | '')} options={[ { value: '', label: text.all }, - ...(['skill', 'agent', 'tool', 'workflow'] as HubPluginType[]).map(type => ({ + ...(['skill', 'agent', 'tool', 'device', 'workflow'] as HubPluginType[]).map(type => ({ value: type, - label: TYPE_LABEL[type], + label: formatPluginTypeLabel(type, i18n.language), count: facetCounts.type[type] ?? 0, })), ]} @@ -628,7 +644,7 @@ function HubTable({ items, actionId, tagLabels, language, text, onSelect, onActi {items.map(item => ( - {TYPE_LABEL[item.type]} + {formatPluginTypeLabel(item.type, language)}