---
slug: controller-plugin
title: 控制器插件（WASM）开发
titleEn: Controller WASM Plugin Development
summary: 订阅并处理控制器转发的 TCP 指令：能力声明、订阅、ctl_* 主机函数与全部内置函数逐个说明。
summaryEn: 'Subscribe and handle controller TCP messages: capabilities, subscriptions, every ctl_* host function and builtin.'
category: 控制器
order: 20
enabled: true
updatedAt: "2026-10-05"
---
# 控制器插件（WASM）开发

[[toc]]

> 本文面向**第三方开发者**：只要你有 TestSecScan 官方发行包与本文档，就能从零写出一个被控制器加载、订阅并处理 TCP 指令的 WASM 插件。阅读顺序建议：先看第一节判断"要不要写控制器插件"，再看第五节弄清"宿主到底怎么加载并调用我"，然后照第六节跑通最小工程，最后按第七节**逐个**查阅 API（每个函数都给了可直接复制的代码片段）。

## 一、它是什么，什么时候该写控制器插件

控制器是 TestSecScan 的"大脑"：GUI、扫描节点、AI 渗透代理都通过 TCP 与它通信。**控制器插件**是一种运行在控制器进程内、由 wazero 沙箱执行的 WASM 模块，它能在控制器**内置指令处理之前**截获转发过来的 TCP 消息：

- 控制器每收到一条 TCP 消息，先把消息按插件的**订阅规则**（`command1` / `command2` / `commandA`）匹配一遍；
- 命中的插件被逐个调用，插件从 `stdin` 读到消息 JSON，可调用 `ctl_*` 主机函数读数据、写日志、回发消息、调用控制器业务指令；
- 插件返回 `handled=true` 时，控制器**跳过该消息的内置处理**；返回 `false`（默认）时继续走内置逻辑。多个插件都命中时全部执行，只要有任意一个返回 `true` 就跳过内置处理。

**什么时候适合写控制器插件：**

- 想给控制器增加一条自定义 TCP 指令（GUI 发 `CommandA=Xxx`，插件处理并回包），又不想改控制器源码；
- 想在既有指令上做旁路观察、审计、改写转发（订阅相同指令、`handled=false` 放行）；
- 想把多条控制器业务指令编排成一次调用（例如插件内连续 `Command("GetTaskList", ...)` + `Command("GetVulnList", ...)` 汇总后回推 GUI）；
- 想用控制器的既有能力做轻量自动化（定时/统计/健康检查类）。

**什么时候不该用它：**

- 处理单个流量包、产出漏洞 —— 这是扫描节点的 **WASM POC 模板**，宿主函数是 `scan_*`，与本文的 `ctl_*` 完全不通用；
- 钩住扫描节点业务点（任务/流量/MITM）—— 这是**应用插件**（`app_*`）；
- 作为大模型可调用的工具 —— 这是 **AiAgent 外部工具**。

> [!IMPORTANT]
> 四套扩展点的**宿主函数命名空间互不重叠**。把 `scan.HTTP(...)` 写进控制器插件、或把 `ctl.SendTCP(...)` 写进扫描节点插件，都会因宿主未导出对应函数而**实例化失败**。判断标准很简单：产物放进控制器的插件目录、用 `ctl_*`，就是控制器插件。

## 二、插件目录结构与签名

控制器从运行目录下的 `CtlConfig/plugins` 扫描插件，兼容两种布局：

- **标准（下载/分享）布局**：`CtlConfig/plugins/<uuid>/Plugin/Controller/<uuid>/`
- **历史解压布局**：`CtlConfig/plugins/Plugin/Controller/<uuid>/`

标准布局的完整结构：

```text
CtlConfig/plugins/
└── <uuid>/                                # 插件根目录（外层 uuid）
    ├── plugin.config.json                 # 可选：ctl_config / ConfigGet 读取的 Key/Value
    └── Plugin/
        └── Controller/
            └── <uuid>/                    # 控制器目标目录（内层 uuid，目录名即插件 UUID）
                ├── info.yaml              # 插件元数据（pluginUUID/author/languages 等）
                ├── build/controller.wasm  # 控制器 WASM 产物（优先取 controller.wasm）
                └── sig.json               # Ed25519 签名：{userPubkey,userSignature,signStatus}
```

| 文件 | 作用 | 是否必需 |
|---|---|---|
| `info.yaml` | 元数据：`pluginUUID`、`author`、`languages.cn.name` / `languages.en.name`（插件名按语言取）、`targets.controller.runtime: wasm` | 建议 |
| `build/controller.wasm` | 控制器 WASM 产物；目录下任意 `*.wasm` 都能被识别，但 **`controller.wasm` 优先** | 必需 |
| `sig.json` | Ed25519 签名信息，**无签名/校验失败一律不加载** | 必需 |
| `plugin.config.json` | 插件配置 Key/Value；宿主从内层目录上溯三级读取，即外层 `<uuid>/plugin.config.json` | 可选 |

### 2.1 签名要求（无签名不加载）

控制器只加载签名验证通过的插件。宿主会读取同目录的 `sig.json` 并做签名校验：

1. 读 `sig.json`，解析为 `WasmSigInfo{userPubkey, userSignature, signStatus}`；
2. 用 `userPubkey`（base64 的 Ed25519 公钥）对 `build/*.wasm` 的**全部字节**做 `ed25519.Verify`；
3. 验签通过 → `verified`（加载）；不通过 → `verify_failed`；缺 `sig.json` 或 `userSignature` 为空 → `unsigned`；后两者都会被跳过并在日志里记录。

生成签名（Go 示例，ed25519 对 wasm 全部字节签名后 base64 写入 `sig.json`）：

```go
priv, _ := ed25519.GenerateKey(rand.Reader)
wasm, _ := os.ReadFile("controller.wasm")
sig := ed25519.Sign(priv, wasm)
sigJSON, _ := json.Marshal(map[string]string{
    "userPubkey":    base64.StdEncoding.EncodeToString(priv.Public().(ed25519.PublicKey)),
    "userSignature": base64.StdEncoding.EncodeToString(sig),
    "signStatus":    "signed",
})
os.WriteFile("sig.json", sigJSON, 0644)
```

> [!TIP]
> 本地开发不必手写签名脚本：把插件放到控制器默认加载目录 `CtlConfig/plugins/<uuid>/` 后，在 GUI「我的插件」页右键 **一键签名**（指令 `SignPluginLocal`，用控制器本地 Ed25519 私钥对 `Controller`/`scan` 的 `build/*.wasm` 签名并写 `sig.json`）。签名成功后控制器会**自动重载**插件。

## 三、ABI 与生命周期

控制器对插件**没有状态**：宿主每次调用都是"喂一条消息、跑一次、收结果"。注意"每次调用 = 一次全新实例"——插件进程内的全局变量不会跨消息保留，需要状态就落到控制器（`Command` / `SendTCP` / `EditWebTaskStatus`）。

宿主与插件的交互（Go 插件由 SDK 的 `Run` 自动完成）：

1. **写 stdin**：宿主把消息 JSON 写入插件进程的标准输入；
2. **执行 `_start`**：实例化 wasm 模块并调用 `_start`（对 Go 就是 `func main`）；
3. **解析 stdout 行协议**：宿主逐行扫描标准输出，识别 `[INIT]`、`[LOG]`、`[RESP]` 三种前缀。

| 行前缀 | 内容 | 说明 |
|---|---|---|
| `[INIT]` | `{"name":"..","version":"..","capabilities":["tcp.handle"],"subscribe":[{command1,command2,commandA}]}` | 能力 + 订阅声明；每次运行首行输出，宿主动态刷新 |
| `[LOG]` | 文本 | 调试日志，进控制器日志（GUI 可见） |
| `[RESP]` | `{"handled":true或false,"error":".."}` | 处理结果，最后一次为准；`handled=true` 跳过内置处理 |

stdin 里的消息 JSON（对应 SDK 的 `Msg`）：

```json
{
  "uuid": "发送方UUID",
  "source": "0",
  "command1": "MainGui",
  "command2": "GetTaskList",
  "command3": "目标UUID",
  "command4": "回信UUID",
  "commandA": "业务动作名",
  "data": "<原始 Data（JSON 字符串或原始文本）>"
}
```

**启动探测**：控制器加载插件时会先以**空消息** `{}` 执行一次，收集 `[INIT]` 里的 `capabilities` 与 `subscribe`；探测失败或超时的插件会被直接移除，不进入运行态。探测之后，能力门控（是否调用插件）才生效。

> [!NOTE]
> Go 的 `wasip1` `main` 正常返回后会自动 `proc_exit(0)`，wazero 会把它当成一个"错误"。宿主对这种情况做了兼容：**只要 stdout 里已经出现 `[RESP]`，本次执行就视为正常结束**。所以插件务必保证在 `Run`（或手写 main）末尾输出 `[RESP]`。

## 四、能力声明与订阅（双通道）

### 4.1 能力门控：未声明不调用

宿主在调用插件前先检查它是否**具备对应能力点**；没写/没导出该能力的插件**根本不会被实例化或执行**（严格门控，避免每个插件都空跑浪费资源与时间）。当前唯一的能力点：

| 能力点 | 含义 |
|---|---|
| `tcp.handle` | 接收并处理控制器转发的 TCP 消息（还需订阅命中才会转发） |

能力判定是**双通道**，任一命中即视为具备：

1. **`[INIT]` 声明**：插件在 `[INIT]` 的 `capabilities` 数组中声明显式实现的能力点（Go SDK 用 `Capability` / `Capabilities` / `CapabilityAll` 填写）；
2. **导出函数自动检测**：用 Go 1.25 的 `//go:wasmexport` 导出 `plugin_handle` 函数，宿主编译模块后按"导出函数 → 能力点"映射自动识别为 `tcp.handle`（当前映射仅 `plugin_handle` → `tcp.handle`）。

```go
// 方式一：显式声明
func init() {
    ctl.Capability(ctl.CapTCPHandle)
}

// 方式二：导出函数自动检测（与方式一取并集）
//go:wasmexport plugin_handle
func pluginHandle() {}
```

> [!WARNING]
> 不声明 `tcp.handle` 的插件，**无论订阅怎么写都不会被调用**。这是最常见的"代码没问题但收不到消息"的原因。

### 4.2 订阅匹配规则

`Subscribe(command1, command2, commandA)` 每次调用追加一条订阅规则。宿主的订阅匹配规则如下：

- **插件至少要调用一次 `Subscribe`**：如果一条订阅都没声明（订阅列表为空），插件不匹配任何消息；
- 单条订阅中三个字段**全为空** → 匹配**全部**消息；
- 否则，**非空字段必须逐字相等**才算命中（空字段是通配）。
- 多个订阅之间是**或**关系，任一命中即转发。

`commandA` 取自消息 `Data` 里的业务动作名（JSON 字段 `CommandA`；宿主解析不到时会退回原始 `Data` 再做一次兜底解析）。

```go
ctl.Subscribe("MainGui", "GetTaskList", "") // 命中 MainGui/GetTaskList 的任意 CommandA
ctl.Subscribe("", "", "Ping")               // 命中任意来源、CommandA=Ping 的消息
ctl.Subscribe("", "", "")                   // 命中全部消息（谨慎：会收到所有 TCP 指令）
```

## 五、入口在哪：宿主怎么加载并调用你的插件

前面讲了"插件长什么样"，这一节回答第三方开发者最关心的问题：**宿主到底在什么时机、以什么顺序加载并调用我的插件？** 整条链路按下面 10 步走。

### 5.1 完整链路（分步）

1. **扫描目录**。控制器启动时扫描运行目录下的 `CtlConfig/plugins`：名为 `Plugin` 的子目录按**历史布局** `Plugin/Controller/<uuid>` 解析，其它子目录按**标准布局** `<uuid>/Plugin/Controller/<uuid>` 解析；`Controller` 下的每个子目录名就是**插件 UUID**。
2. **读取元数据与产物**。宿主读取 `info.yaml`（取 `author` 与第一个 `languages.*.name` 作为插件名），再在 `build/` 下找 `*.wasm`（`controller.wasm` 优先）；**找不到任何 wasm 的目录直接忽略**（不是控制器插件）。
3. **验签**。宿主读取同目录 `sig.json` 做 `ed25519.Verify`；只有验签通过的插件进入运行集合，`unsigned` / `verify_failed` 记入跳过列表并跳过。
4. **编译与建运行时**。被调用的那一刻（或启动探测时）宿主才会为插件创建运行时：读 wasm 字节 → 建 wazero 运行时 → 实例化 `wasi_snapshot_preview1` → 注册全部 `env.ctl_*` 主机函数 → 编译模块。编译完成后收集模块的**导出函数**，作为导出函数能力通道的依据。
5. **启动探测**。宿主对每个验签通过的插件用**空消息** `{}` 跑一次，收集 `[INIT]` 里的 `subscribe` 与 `capabilities`；**探测失败/超时的插件会被移出运行集合**（日志键 `Ctl.Log.WasmPlugin-01`）。所以订阅与能力在探测后即已就绪。
6. **每条消息到达时的入口**。控制器在内置指令处理**之前**先把消息交给插件运行时，由它做订阅匹配与能力门控。插件运行时先算出 `commandA`（取自 `Data` 里的 `CommandA`，为空则按原始 `Data` 兜底解析），再对全部插件做订阅匹配，得到命中集合。
7. **能力门控**。对每个命中的插件再检查它是否具备 `tcp.handle` 能力；**没声明/没导出的插件直接跳过**（日志键 `Ctl.Log.WasmPlugin-02`，且不会创建运行时）。
8. **单次调用**。宿主取插件运行时，起独立 goroutine 并配一个 **watchdog 定时器**（默认 1 分钟）：把消息 JSON 写入插件 stdin、实例化模块并调用 `_start`，再按行解析 stdout 的 `[INIT]/[LOG]/[RESP]`。
9. **handled 的意义**。宿主取 `[RESP]` 里的 `handled`，多个插件的 `handled` 取**或**。若最终为 `true`，控制器立即跳过该消息的**全部内置处理**；为 `false` 则继续走内置逻辑。
10. **重载与自愈**。GUI 触发指令 `ReloadWasmPlugins` 或在 GUI 一键签名（`SignPluginLocal`）后，控制器都会关闭旧运行时并重新扫描加载。若某次调用超时，watchdog 会把该运行时标记 `poisoned`，下次调用时自动**重建**（重新编译 + 新实例），保证一次卡死不拖累后续消息。

> [!IMPORTANT]
> "入口"有两层含义：**模块入口**永远是 wasm 的 `_start`（Go 的 `func main`），由宿主调用；**注册入口**是你插件包里的 `func init()`，SDK 会在 `main` 里把 `init` 阶段声明的 `SetInfo/Subscribe/Capability` 打成 `[INIT]` 输出。第三方开发者只需要写 `init()` + `main()`，不用碰 ABI。

## 六、快速开始：一个可运行的 Go 控制器插件

### 6.1 工程结构

官方 Go SDK 随发行包提供（包名 `ctl`，`go.mod` 里 `module ctl`）。新建你的插件工程：

```text
my-controller-plugin/
├── go.mod
└── main.go
```

`go.mod`（用 `replace` 指向 SDK 目录，第三方开发者按自己的 SDK 解压路径填写）：

```text
module myplugin

go 1.25.0

require ctl v0.0.0

replace ctl => <SDK路径>/sdk/go
```

### 6.2 main.go

```go
package main

import "ctl"

func init() {
	ctl.SetInfo("我的控制器插件", "1.0.0")
	ctl.Capability(ctl.CapTCPHandle)                    // 声明能力：接收 TCP 消息（未声明不会被调用）
	ctl.Subscribe("MainGui", "GetTaskList", "")         // 订阅 MainGui/GetTaskList
	ctl.Subscribe("", "", "Ping")                       // 订阅 CommandA=Ping
}

func main() {
	ctl.Run(func(msg ctl.Msg) bool {
		ctl.Log("收到消息: " + msg.Command1 + "/" + msg.Command2 + "/" + msg.CommandA)
		switch msg.CommandA {
		case "Ping":
			// 注意：SendTCPJson 传 map/结构体，SDK 自动 json.Marshal，别传 []byte
			ctl.SendTCPJson("MainGui", "Pong", msg.Command4, "", "PongResult", map[string]any{
				"pong": true,
				"md5":  ctl.MD5("hello"), // 返回值是 JSON 编码字符串（见 7.4）
			})
			return true // 跳过控制器内置处理
		case "List":
			res := ctl.Command("GetTaskList", `{"isDelete":false}`) // 原始响应 JSON
			ctl.SendTCP("MainGui", "ListResult", msg.Command4, "", res)
			return true
		}
		return false // 观察但不接管
	})
}
```

### 6.3 编译与放置

```bash
# 编译（Go 1.25+，产物更小时可加 -trimpath -ldflags=-s -w）
GOOS=wasip1 GOARCH=wasm CGO_ENABLED=0 go build -o controller.wasm .
```

把产物放到控制器加载目录，并用 GUI 一键签名：

```text
CtlConfig/plugins/<uuid>/Plugin/Controller/<uuid>/build/controller.wasm
```

1. 复制整个标准目录结构（含 `info.yaml`）到 `CtlConfig/plugins/<uuid>/`；
2. GUI「我的插件」页右键该插件 →「一键签名」写入 `sig.json`；
3. 控制器自动加载；若插件之前已在运行，可用指令 `ReloadWasmPlugins` 重载；
4. 在 GUI 里发一条 `CommandA=Ping` 的指令，观察控制器日志里的 `WASM插件[<uuid>]:` 前缀日志与 `Pong` 回包。

> [!TIP]
> 通过 TestSecScan 构建服务（VulnService）编译时，源码文件顶部的 `//go:build ignore` 会被自动剥离，这样同一份源码既能被控制器模块 `go build` 跳过，又能被构建服务以 `wasip1` 编译。

## 七、SDK 公开 API 逐个精讲

以下全部来自官方 Go SDK `ctl.go`（包名 `ctl`）。每个符号一个四级标题，依次给出：作用、签名、参数表、返回、可复制代码片段、注意事项。除 `init()` 阶段的声明类函数（`SetInfo` / `Subscribe` / `Capability*`）外，其余均在 `Run` 的 handler 内调用。

> [!CAUTION]
> **返回值编码（最容易踩的坑）**：`Call` 及其 29 个便捷封装（`MD5` / `Base64Encode` / `JSONGet` / `NowStr` 等）返回的是宿主 `ctl_call` 结果经 `json.Marshal` 处理后的 **JSON 编码字符串**，字符串类结果会**带双引号**（如 `"5d41...c592"`），JSON 类结果还会被转义（如 `"{\"name\":\"x\"}"`）。要取出真正的文本，必须自己解一次。推荐在插件里加一个小工具函数，本节所有内置函数片段都据此书写：

```go
// ctlText 把 SDK 内置函数包装的返回值（JSON 编码字符串）解回普通文本。
// 第二个返回值 false 表示取出失败——通常是宿主返回了 {"error":"..."} 错误对象。
func ctlText(r string) (string, bool) {
	var s string
	if err := json.Unmarshal([]byte(r), &s); err != nil {
		return r, false
	}
	return s, true
}
```

> [!NOTE]
> 与此相对，`Command` / `T` / `ConfigGet` / `CopyTcpUUID` 返回的是**原始**文本或 JSON（不经二次编码），可直接使用。这是"内置函数便捷封装"与"主机 API"在返回格式上的关键差异。

### 7.1 类型与常量

#### `Msg`

- **作用**：宿主每次调用插件时，从 `stdin` 传给 handler 的一条控制器 TCP 消息；插件的核心输入。
- **签名**：

```go
type Msg struct {
	UUID     string `json:"uuid"`     // 发送方 UUID
	Source   string `json:"source"`   // 来源 0=GUI 1=控制器 2=扫描节点
	Command1 string `json:"command1"` // 一级指令，如 MainGui / scan
	Command2 string `json:"command2"` // 二级指令，如 GetTaskList
	Command3 string `json:"command3"` // 三级指令：目标 UUID
	Command4 string `json:"command4"` // 四级指令：回信 UUID
	CommandA string `json:"commandA"` // 业务动作名（Data 内 CommandA）
	Data     string `json:"data"`     // 原始 Data（JSON 字符串或原始文本）
}
```

- **字段表**（取值来源逐个说明）：

| 字段 | JSON 键 | 类型 | 取值来源 | 示例值 |
|---|---|---|---|---|
| `UUID` | `uuid` | string | 宿主填 `tcpData.UUID`，即 TCP 帧来源节点/连接的唯一标识 | `"gui-1a2b3c"` |
| `Source` | `source` | string | 宿主填 `tcpData.Source`：`"0"`=GUI、`"1"`=控制器、`"2"`=扫描节点、`"3"`=AiAgent | `"0"` |
| `Command1` | `command1` | string | 一级指令；控制器据此分派（`MainGui` / `scan` / `waitHandle` 等） | `"MainGui"` |
| `Command2` | `command2` | string | 二级指令（业务分组） | `"GetTaskList"` |
| `Command3` | `command3` | string | 三级指令：本帧目标（UUID / `gui` / `scan` / `all`） | `"gui"` |
| `Command4` | `command4` | string | 四级指令：**回信 UUID**；回包时通常填进 `SendTCP` 的 `cmd3` | `"gui-1a2b3c"` |
| `CommandA` | `commandA` | string | 业务动作名，宿主从 `Data` 的 `CommandA` 字段解析 | `"Ping"` |
| `Data` | `data` | string | 原始 `Data` 字节按字符串给到插件（可能是 JSON 字符串，也可能是纯文本） | `"{\"CommandA\":\"Ping\"}"` |

- **代码片段**（从消息里取值判断来源、解析内嵌 JSON）：

```go
func main() {
	ctl.Run(func(msg ctl.Msg) bool {
		// 1) 判断来源：只信任 GUI 发来的指令（0=GUI）
		if msg.Source != "0" {
			ctl.Log("忽略非 GUI 来源: " + msg.Source)
			return false
		}
		// 2) 回信目标：大多数回包场景用 msg.Command4
		replyTo := msg.Command4
		// 3) Data 常是 JSON 字符串，自行解出业务字段
		var body struct {
			Keyword string `json:"keyword"`
		}
		if err := json.Unmarshal([]byte(msg.Data), &body); err != nil {
			ctl.Log("Data 不是 JSON: " + msg.Data)
		}
		ctl.Log("关键词=" + body.Keyword + " 回给=" + replyTo)
		return false
	})
}
```

- **注意事项**：
  - `Source` 是**字符串**（`"0"`/`"1"`…），不是数字，别写 `msg.Source == 0`。
  - `Data` 不保证是 JSON；`json.Unmarshal` 前先判空、判错。
  - 一次调用只处理**一条**消息；`Msg` 不会跨消息复用。

#### `Subscription`

- **作用**：描述"我要订阅哪些 TCP 消息"，由 `Subscribe` 追加、`Run` 打进 `[INIT]` 供宿主匹配。
- **签名**：

```go
type Subscription struct {
	Command1 string `json:"command1"`
	Command2 string `json:"command2"`
	CommandA string `json:"commandA"`
}
```

- **字段表**：

| 字段 | JSON 键 | 类型 | 含义 | 怎么用 |
|---|---|---|---|---|
| `Command1` | `command1` | string | 一级指令匹配项 | 空=通配；非空须**逐字等于** `msg.Command1` |
| `Command2` | `command2` | string | 二级指令匹配项 | 空=通配；非空须逐字等于 `msg.Command2` |
| `CommandA` | `commandA` | string | 业务动作名匹配项 | 空=通配；非空须逐字等于 `msg.CommandA` |

- **代码片段**（三种典型订阅；一般不必手写该结构体，直接用 `Subscribe`）：

```go
func init() {
	ctl.Capability(ctl.CapTCPHandle)
	// 等价于追加 Subscription{Command1:"MainGui",Command2:"GetTaskList",CommandA:""}
	ctl.Subscribe("MainGui", "GetTaskList", "")
	// 三者全空 = 订阅所有消息
	ctl.Subscribe("", "", "")
}
```

- **注意事项**：
  - 一条都没订阅 → 插件**匹配不到任何消息**（不是匹配全部）。
  - 三个字段是**与**关系（都非空时要全中），多条订阅间是**或**关系。

#### `CapTCPHandle`

- **作用**：唯一能力点常量；声明它才表示"本插件处理控制器转发的 TCP 消息"。
- **签名**：`const CapTCPHandle = "tcp.handle"`（无参数）。
- **返回**：字符串常量 `"tcp.handle"`。
- **代码片段**：

```go
func init() {
	ctl.Capability(ctl.CapTCPHandle) // 声明能力；漏写则永远收不到消息
}
```

- **注意事项**：能力是**严格门控**——未声明（且未导出 `plugin_handle`）的插件不会被执行；配合订阅命中才会收到消息。

### 7.2 生命周期与声明

这些函数在包的 `init()` 里调用，用于设置元数据、订阅与能力。

#### `SetInfo(name, version string)`

- **作用**：设置插件在 `[INIT]` 里上报的名称与版本，便于日志与 GUI 展示识别。
- **签名**：`SetInfo(name, version string)`。
- **参数表**：

| 参数 | 类型 | 传什么 | 从哪来 | 示例值 |
|---|---|---|---|---|
| `name` | string | 插件显示名 | 你自定义 | `"任务统计面板"` |
| `version` | string | 版本号 | 你自定义 | `"1.0.0"` |

- **返回**：无。
- **代码片段**：

```go
func init() {
	ctl.SetInfo("任务统计面板", "1.0.0")
}
```

- **注意事项**：可省略，默认 `wasm-plugin` / `1.0.0`；名称建议与 `info.yaml` 的 `languages.*.name` 保持一致。

#### `Subscribe(command1, command2, commandA string)`

- **作用**：追加一条订阅规则，声明"哪些消息需要转发给我"。
- **签名**：`Subscribe(command1, command2, commandA string)`。
- **参数表**：

| 参数 | 类型 | 传什么 | 从哪来 | 示例值 |
|---|---|---|---|---|
| `command1` | string | 一级指令，空=通配 | 控制器指令名 | `"MainGui"` |
| `command2` | string | 二级指令，空=通配 | 控制器指令名 | `"GetTaskList"` |
| `commandA` | string | 业务动作名，空=通配 | 你自定义的 `CommandA` | `"Ping"` |

- **返回**：无（追加，不覆盖）。
- **代码片段**：

```go
func init() {
	ctl.Subscribe("MainGui", "GetTaskList", "") // 命中 MainGui/GetTaskList 任意 CommandA
	ctl.Subscribe("", "", "Ping")               // 命中任意来源、CommandA=Ping
}
```

- **注意事项**：多次调用是**叠加**；全空那一组会订阅所有消息（流量大，慎用）；至少要调用一次。

#### `Capability(name string)`

- **作用**：声明本插件实现了某个公开能力点（如 `CapTCPHandle`），通过能力门控。
- **签名**：`Capability(name string)`。
- **参数表**：

| 参数 | 类型 | 传什么 | 从哪来 | 示例值 |
|---|---|---|---|---|
| `name` | string | 能力点名称 | `ctl.CapTCPHandle` | `"tcp.handle"` |

- **返回**：无（内部按名去重；`name == ""` 忽略）。
- **代码片段**：

```go
func init() {
	ctl.Capability(ctl.CapTCPHandle)
}
```

- **注意事项**：重复声明的同名能力只保留一份；未声明 `tcp.handle` 时插件不会被调用。

#### `Capabilities(names ...string)`

- **作用**：一次声明多个能力点，等价于连续调用 `Capability`。
- **签名**：`Capabilities(names ...string)`。
- **参数表**：

| 参数 | 类型 | 传什么 | 从哪来 | 示例值 |
|---|---|---|---|---|
| `names` | `...string` | 能力点列表 | 常量 / 字符串 | `ctl.CapTCPHandle` |

- **返回**：无。
- **代码片段**：

```go
func init() {
	ctl.Capabilities(ctl.CapTCPHandle) // 未来新增能力点时在此追加
}
```

- **注意事项**：空字符串元素会被 `Capability` 忽略；不确定时优先只声明实际实现的能力。

#### `CapabilityAll()`

- **作用**：声明当前 SDK 已知的**全部**能力点（当前即 `tcp.handle`），等价旧版"全部接收"行为。
- **签名**：`CapabilityAll()`。
- **参数表**：无参数。
- **返回**：无（内部调用 `Capabilities(CapTCPHandle)`）。
- **代码片段**：

```go
func init() {
	ctl.CapabilityAll() // 等价于 ctl.Capability(ctl.CapTCPHandle)
}
```

- **注意事项**：能力点会随 ABI 升级增多，用它等于把所有能力都声明了，可能被未来的能力点触发；**建议只声明实际实现的能力**。

#### `Run(fn func(msg Msg) (handled bool))`

- **作用**：驱动插件主循环：输出 `[INIT]` → 读 `stdin` 消息 → 调 handler → 输出 `[RESP]`。必须在 `main()` 里调用。
- **签名**：`Run(fn func(msg Msg) (handled bool))`。
- **参数表**：

| 参数 | 类型 | 传什么 | 从哪来 | 示例值 |
|---|---|---|---|---|
| `fn` | `func(msg Msg) bool` | 消息处理器；返回 `true`=已处理（跳过内置），`false`=放行 | 你实现 | 见下 |

- **返回**：无（内部写出 `[RESP] {"handled":..,"error":".."}`）。
- **代码片段**：

```go
func main() {
	ctl.Run(func(msg ctl.Msg) bool {
		if msg.CommandA == "Ping" {
			ctl.Log("收到 Ping")
			ctl.SendTCPJson("MainGui", "Pong", msg.Command4, "", "PongResult", map[string]any{"pong": true})
			return true // 已处理：控制器跳过内置处理
		}
		return false // 放行：控制器继续内置处理
	})
}
```

- **注意事项**：
  - handler 返回 `true` 会**真正阻断**该消息的内置处理，务必确认你已完整替代其功能。
  - `Run` 内部对 handler 做了 `recover`：插件 panic 会转成 `[RESP] {"handled":false,"error":"插件 panic: ..."}`，不会让控制器崩溃。
  - 若 `stdin` 为空，`Run` 会在输出 `[INIT]` 后直接返回（**不输出 `[RESP]`**）；宿主正常总会写入至少一个 `{}`，所以实际不会走到这一分支。

### 7.3 主机 API

以下函数由 SDK 封装 `env.ctl_*` 主机函数，全部在 `Run` 的 handler 内调用。除 `Call` 外，它们的返回值都是**原始**字符串/JSON。

#### `Log(msg string)`

- **作用**：往控制器日志写一条调试信息，是排查插件问题的首选手段。
- **签名**：`Log(msg string)`。
- **参数表**：

| 参数 | 类型 | 传什么 | 从哪来 | 示例值 |
|---|---|---|---|---|
| `msg` | string | 任意日志文本 | 你拼接 | `"收到消息: Ping"` |

- **返回**：无。宿主机以 `WASM插件[<uuid>]: <msg>` 前缀写入控制器日志（GUI 可见）。
- **代码片段**：

```go
ctl.Log("处理开始，CommandA=" + msg.CommandA + " 来源=" + msg.UUID)
```

- **注意事项**：日志走 `ctl_log`，与 stdout 的 `[LOG]` 行同路进日志；日志量大时注意截断长文本（`msg` 建议控制在几百字符内）。

#### `SendTCP(cmd1, cmd2, cmd3, cmd4, data string)`

- **作用**：发送一条 TCP 数据给 GUI/扫描节点/其它连接；`data` 原样送出，适合自定义协议或已拼好的字符串。
- **签名**：`SendTCP(cmd1, cmd2, cmd3, cmd4, data string)`。
- **参数表**：

| 参数 | 类型 | 传什么 | 从哪来 | 示例值 |
|---|---|---|---|---|
| `cmd1` | string | 一级指令 | 目标端协议约定 | `"MainGui"` |
| `cmd2` | string | 二级指令 | 目标端协议约定 | `"Pong"` |
| `cmd3` | string | 目标：`gui` / `scan` / `all` / 具体 UUID | 回包用 `msg.Command4` | `msg.Command4` |
| `cmd4` | string | 回信 UUID；空则自动填当前 UUID | 通常 `""` | `""` |
| `data` | string | 原始数据字符串（不序列化） | 你构造 | `` `{"pong":true}` `` |

- **返回**：无。底层不绑定具体连接，按 `cmd3` 路由。
- **代码片段**：

```go
// 回包给发来消息的 GUI：cmd3 用 msg.Command4
ctl.SendTCP("MainGui", "Pong", msg.Command4, "", `{"pong":true}`)
// 广播给所有 GUI
ctl.SendTCP("MainGui", "Notice", "gui", "", "server busy")
```

- **注意事项**：`cmd3` 写错（如把回信 UUID 填成 `gui`）会导致回包发不到目标；需要 `{CommandA,Data}` 包裹时改用 `SendTCPJson`。

#### `SendTCPJson(cmd1, cmd2, cmd3, cmd4, commandA string, data any)`

- **作用**：发送 TCP 数据并自动包成 `{CommandA, Data}` 结构，是回包给 GUI 的标准做法。
- **签名**：`SendTCPJson(cmd1, cmd2, cmd3, cmd4, commandA string, data any)`。
- **参数表**：

| 参数 | 类型 | 传什么 | 从哪来 | 示例值 |
|---|---|---|---|---|
| `cmd1` | string | 一级指令 | 目标端协议约定 | `"MainGui"` |
| `cmd2` | string | 二级指令 | 目标端协议约定 | `"Pong"` |
| `cmd3` | string | 目标路由 | 回包用 `msg.Command4` | `msg.Command4` |
| `cmd4` | string | 回信 UUID | 通常 `""` | `""` |
| `commandA` | string | 业务动作名（放进 `Data.CommandA`） | 你自定义 | `"PongResult"` |
| `data` | `any` | **值对象**（map / struct / slice） | 你构造 | `map[string]any{"pong": true}` |

- **返回**：无。SDK 内部对 `data` 做一次 `json.Marshal`，宿主再解回对象调用 `SendTcpDataJson`。
- **代码片段**：

```go
type Pong struct {
	Pong   bool   `json:"pong"`
	Plugin string `json:"plugin"`
}
ctl.SendTCPJson("MainGui", "Pong", msg.Command4, "", "PongResult", Pong{Pong: true, Plugin: "my-ping"})
```

- **注意事项**：
  - **传结构体 / map / slice，不要传 `[]byte`**——`[]byte` 会被 `json.Marshal` 编成 base64 字符串（本仓库真实事故，见第十节）。
  - 要发原始字符串（不做序列化）请用 `SendTCP`。

#### `CommandHandler(command string, args ...any)`

- **作用**：触发控制器的指令回调入口（如 `MsgUpdataConfig`），用于"通知控制器做某事"，而不是像 `Command` 那样取响应。
- **签名**：`CommandHandler(command string, args ...any)`。
- **参数表**：

| 参数 | 类型 | 传什么 | 从哪来 | 示例值 |
|---|---|---|---|---|
| `command` | string | 回调名 | 控制器约定 | `"MsgUpdataConfig"` |
| `args` | `...any` | 回调参数 | 你构造 | `map[string]any{"key": "v"}` |

- **返回**：无（SDK 把 `args` 编成 JSON 数组，宿主解成 `[]any` 后转交控制器回调入口）。
- **代码片段**：

```go
ctl.CommandHandler("MsgUpdataConfig", map[string]any{"reload": true})
```

- **注意事项**：`args` 是**变参**，会被整体编成 JSON 数组；与 `Command`（返回响应）用途不同，别混用。

#### `TcpReceivedData(td string)`

- **作用**：把一条完整的 TCP 数据重新注入控制器分发流程，实现"插件主动制造一条消息"。
- **签名**：`TcpReceivedData(td string)`。
- **参数表**：

| 参数 | 类型 | 传什么 | 从哪来 | 示例值 |
|---|---|---|---|---|
| `td` | string | 一条完整 TCP 数据 JSON（含一/二/三级指令与 Data 字段） | 你构造 | `{"Command1Str":"MainGui","Command2Str":"X","Data":"{}"}` |

- **返回**：无。宿主把这段 JSON 解析成一条完整的 TCP 数据后重新走分发；重注入深度超过 **5 层**会被静默丢弃。
- **代码片段**：

```go
ctl.TcpReceivedData(`{"Command1Str":"MainGui","Command2Str":"Refresh","Command4Str":"` + msg.Command4 + `","Data":"{}"}`)
```

- **注意事项**：重注入会**再次匹配订阅**，容易形成环；宿主全局计数最多 5 层，超限丢弃。

#### `TcpOnClientDisconnect(uuid string)`

- **作用**：模拟某个客户端连接断开，触发控制器的断开清理逻辑（连接表清理等）。
- **签名**：`TcpOnClientDisconnect(uuid string)`。
- **参数表**：

| 参数 | 类型 | 传什么 | 从哪来 | 示例值 |
|---|---|---|---|---|
| `uuid` | string | 要断开的连接 UUID | `msg.UUID` / `CopyTcpUUID` 结果 | `"gui-1a2b3c"` |

- **返回**：无。
- **代码片段**：

```go
ctl.TcpOnClientDisconnect(msg.UUID)
```

- **注意事项**：会让控制器认为该连接已断开，仅在你确实需要清理时调用。

#### `CopyTcpUUID() string`

- **作用**：获取当前控制器维护的 TCP 连接表（**已脱敏**，不含连接句柄），用于排查"谁在线"。
- **签名**：`CopyTcpUUID() string`。
- **参数表**：无参数。
- **返回**：JSON 数组字符串，元素字段如下：

| 字段 | 类型 | 含义 | 怎么用 |
|---|---|---|---|
| `uuid` | string | 连接唯一标识 | 作为 `TcpOnClientDisconnect` 入参 |
| `source` | string | 来源（`0`/`1`/`2`/`3`） | 判断是 GUI 还是节点 |
| `nodeIp` | string | 对端地址（IP:Port） | 展示 / 定位节点 |
| `connected` | bool | 是否在线 | 过滤离线连接 |

- **代码片段**：

```go
var conns []map[string]any
_ = json.Unmarshal([]byte(ctl.CopyTcpUUID()), &conns)
for _, c := range conns {
	ctl.Log("连接 " + c["uuid"].(string) + " 来源=" + c["source"].(string))
}
```

- **注意事项**：返回的是脱敏数据，取不到真实 `net.Conn` 句柄，不能用于直接读写连接。

#### `StartProxyServer(addr, taskJSON string)`

- **作用**：按任务配置启动一个被动代理监听（多端口），用于把流量接入扫描。
- **签名**：`StartProxyServer(addr, taskJSON string)`。
- **参数表**：

| 参数 | 类型 | 传什么 | 从哪来 | 示例值 |
|---|---|---|---|---|
| `addr` | string | 监听地址 | 你构造 | `"0.0.0.0:8080"` |
| `taskJSON` | string | 任务配置 JSON（字段与控制器任务配置一致） | `Command("GetTaskDetails", ...)` 结果改造 | 见下 |

- **返回**：无。宿主把 `taskJSON` 解析成任务配置后启动被动代理监听。
- **代码片段**：

```go
taskJSON := `{"taskIde":"task-001","taskName":"proxy-demo"}`
ctl.StartProxyServer("0.0.0.0:8080", taskJSON)
```

- **注意事项**：`taskJSON` 需与控制器任务配置字段匹配；启动失败只会体现为日志，不返回错误给插件。

#### `StopProxyListener(taskIde string)`

- **作用**：停止指定任务的代理监听。
- **签名**：`StopProxyListener(taskIde string)`。
- **参数表**：

| 参数 | 类型 | 传什么 | 从哪来 | 示例值 |
|---|---|---|---|---|
| `taskIde` | string | 任务 ID | 任务配置 | `"task-001"` |

- **返回**：无。
- **代码片段**：

```go
ctl.StopProxyListener("task-001")
```

- **注意事项**：`taskIde` 不存在时静默忽略，不会报错。

#### `StopAllProxyListeners()`

- **作用**：一键停止全部代理监听（清理/紧急停止）。
- **签名**：`StopAllProxyListeners()`。
- **参数表**：无参数。
- **返回**：无。
- **代码片段**：

```go
ctl.StopAllProxyListeners()
```

- **注意事项**：影响所有任务，属于全局操作，谨慎调用。

#### `EditWebTaskStatus(taskJSON string)`

- **作用**：修改任务状态并**直接写 DB**。
- **签名**：`EditWebTaskStatus(taskJSON string)`。
- **参数表**：

| 参数 | 类型 | 传什么 | 从哪来 | 示例值 |
|---|---|---|---|---|
| `taskJSON` | string | 任务配置 JSON（字段与控制器任务配置一致） | 你构造 | `{"taskIde":"task-001","status":2}` |

- **返回**：无（宿主解析任务配置后修改任务状态并写库）。
- **代码片段**：

```go
ctl.EditWebTaskStatus(`{"taskIde":"task-001","taskName":"demo","status":2}`)
```

- **注意事项**：这是**写操作**（真实改库），务必先校验 `msg.Source` 与 `msg.CommandA`，避免被任意消息触发。

#### `Command(name, argsJSON string) string`

- **作用**：调用控制器**已注册**的业务指令，取回与 GUI 收到的**一致**的响应 JSON；是插件编排控制器能力的主入口。
- **签名**：`Command(name, argsJSON string) string`。
- **参数表**：

| 参数 | 类型 | 传什么 | 从哪来 | 示例值 |
|---|---|---|---|---|
| `name` | string | 指令名（白名单，见第八节） | 第八节清单 | `"GetTaskList"` |
| `argsJSON` | string | 参数 JSON（与 GUI 发的 `Data` 一致） | 你构造 | `{"isDelete":false,"page":1,"pageSize":20}` |

- **返回**：**原始**响应 JSON 字符串（不经二次编码）。成功：handler 写给 GUI 的响应；操作型无响应时：`{"success":true}`；未注册指令：`{"success":false,"error":"未注入的控制器指令: xxx"}`。
- **代码片段**：

```go
res := ctl.Command("GetTaskList", `{"isDelete":false,"page":1,"pageSize":20}`)
// 先判断错误
if strings.Contains(res, `"success":false`) {
	ctl.Log("调用失败: " + res)
} else {
	var data map[string]any
	_ = json.Unmarshal([]byte(res), &data)
	ctl.Log("任务总数=" + ctlTextOr(ctl.JSONGet([]byte(res), "data.total")))
}
```

- **注意事项**：
  - 只能调用第八节清单里的指令；未注册一律返回 `{"success":false,...}`（白名单原则）。
  - 写操作型指令会真实改库/下发节点；默认只用查询类，写操作前校验来源与动作。
  - 返回值是**原始 JSON**，不要再用 `ctlText` 解（那是内置函数的规则）。

#### `Call(funcID uint32, argsJSON string) string`

- **作用**：调用内置基础函数的底层统一入口；SDK 的 29 个便捷封装都基于它。
- **签名**：`Call(funcID uint32, argsJSON string) string`。
- **参数表**：

| 参数 | 类型 | 传什么 | 从哪来 | 示例值 |
|---|---|---|---|---|
| `funcID` | uint32 | 函数 ID（见第九节） | 第九节全表 | `30` |
| `argsJSON` | string | **参数数组**的 JSON（注意是数组，不是对象） | 你构造 | `[3,16]` |

- **返回**：**JSON 编码字符串**。字符串类结果带引号（如 `"aGVsbG8="`）；函数报错返回错误对象 `{"error":"..."}`；未知 ID 返回 `{"error":"未知的内置函数 ID"}`。
- **代码片段**：

```go
r := ctl.Call(30, `[3,16]`) // rand_str：小写+数字、长度 16
if s, ok := ctlText(r); ok {
	ctl.Log("随机串=" + s) // 例：sijjkjuc123
} else {
	ctl.Log("内置函数报错: " + r)
}
```

- **注意事项**：参数是**数组**（`[3,16]`），写成对象会取不到参数；返回值需 `ctlText` 解出普通文本。

#### `T(key string) string`

- **作用**：读取当前语言下本插件的语言包文案，实现界面/日志多语言。
- **签名**：`T(key string) string`。
- **参数表**：

| 参数 | 类型 | 传什么 | 从哪来 | 示例值 |
|---|---|---|---|---|
| `key` | string | 语言包键（**不含**语言与插件前缀） | 你在语言包里定义的键 | `"Pong.Title"` |

- **返回**：**原始**文本。宿主按键空间 `<语言>.<插件UUID>.<key>` 查找（语言取控制器当前语言，缺省 `cn`）；缺失时回退为 `key` 本身。
- **代码片段**：

```go
title := ctl.T("Pong.Title") // 中文环境取 cn.<uuid>.Pong.Title，缺失则返回 "Pong.Title"
ctl.Log(title)
```

- **注意事项**：只需传 `key`，**不要**自己拼 `<语言>.<uuid>.` 前缀；文案缺失不会报错，只会回退成 key。

#### `ConfigGet(key string) string`

- **作用**：读取插件配置（外层 `<uuid>/plugin.config.json` 的 Key/Value）。
- **签名**：`ConfigGet(key string) string`。
- **参数表**：

| 参数 | 类型 | 传什么 | 从哪来 | 示例值 |
|---|---|---|---|---|
| `key` | string | 配置键 | `plugin.config.json` | `"apiKey"` |

- **返回**：**原始**配置值字符串；**键不存在返回空串 `""`**。
- **代码片段**：

```go
apiKey := ctl.ConfigGet("apiKey")
if apiKey == "" {
	ctl.Log("未配置 apiKey，跳过")
	return false
}
```

- **注意事项**：键不存在是**空串**而不是报错，判空即判"未配置"；配置文件缺失时所有键都为空串。

#### `SetTimeout(ms int64)`

- **作用**：延长/调整**本次执行**的超时，避免长任务被 watchdog 掐断。
- **签名**：`SetTimeout(ms int64)`。
- **参数表**：

| 参数 | 类型 | 传什么 | 从哪来 | 示例值 |
|---|---|---|---|---|
| `ms` | int64 | 超时毫秒数 | 你估算 | `120000`（2 分钟） |

- **返回**：无（同时重置宿主 watchdog 定时器；`ms<=0` 忽略，超过 10 分钟按 10 分钟截断）。
- **代码片段**：

```go
ctl.SetTimeout(120000) // 本次最多执行 2 分钟
heavyWork()
```

- **注意事项**：超时是基于"**一次调用**"的；单次超过 10 分钟仍会被 watchdog 掐断并标记 `poisoned`，大任务应拆成多条消息分次处理。

### 7.4 内置函数便捷封装

SDK 把常用内置函数封成了 29 个 Go 函数，签名与 `funcID` 一一对应（全表见第九节）。**它们全部返回 JSON 编码字符串**——请用本节的 `ctlText` 解出真正的文本（`TimeToTimestamp` 例外，见其小节）。

**编码转换（9 个）**

#### `Base64Encode(data string)`

- 作用：Base64 编码，把中文/二进制塞进只允许 ASCII 的字段。参数 `data` 待编码字符串。返回 JSON 编码字符串（`"aGVsbG8="`）。
```go
enc, _ := ctlText(ctl.Base64Encode("hello")) // aGVsbG8=
```
- 注意：直接拼接会带引号，务必先 `ctlText`。

#### `Base64Decode(data string)`

- 作用：Base64 解码。参数 `data` 为 Base64 文本。返回 JSON 编码字符串（原始字节按文本）。
```go
raw, ok := ctlText(ctl.Base64Decode("aGVsbG8=")) // ok=true, raw=hello
```
- 注意：非法 Base64 会走错误分支，`ok=false`，此时应判失败。

#### `HexEncode(data string)`

- 作用：字节串转十六进制（无空格）。参数 `data` 原始字符串。返回 JSON 编码字符串。
```go
h, _ := ctlText(ctl.HexEncode("hi")) // 6869
```
- 注意：`HexDecode` 是它的逆操作，成对使用。

#### `HexDecode(hex string)`

- 作用：十六进制转字节串。参数 `hex` 十六进制文本。返回 JSON 编码字符串。
```go
b, _ := ctlText(ctl.HexDecode("68656c6c6f")) // hello
```
- 注意：奇数长度/非法字符会报错（`ctlText` 返回 false）。

#### `URLEncode(input string)`

- 作用：URL 编码。参数 `input` 待编码文本。返回 JSON 编码字符串。
```go
u, _ := ctlText(ctl.URLEncode("a b")) // a+b
```
- 注意：空格编成 `+`；需要 `%20` 风格时自行替换。

#### `URLDecode(input string)`

- 作用：URL 解码。参数 `input` 已编码文本。返回 JSON 编码字符串。
```go
u, _ := ctlText(ctl.URLDecode("a%20b")) // a b
```
- 注意：非法 `%` 序列会报错，判 `ok` 再使用。

#### `HTMLEncode(data string)`

- 作用：HTML 实体编码（如 `<`→`&lt;`）。参数 `data` 原始文本。返回 JSON 编码字符串。
```go
h, _ := ctlText(ctl.HTMLEncode("<a>")) // &lt;a&gt;
```
- 注意：结果里的 `&`/`<` 会被 JSON 转义为 `\u0026` 等，`ctlText` 会自动还原。

#### `HTMLDecode(data string)`

- 作用：HTML 实体解码。参数 `data` 实体文本。返回 JSON 编码字符串。
```go
h, _ := ctlText(ctl.HTMLDecode("&lt;a&gt;")) // <a>
```
- 注意：与 `HTMLEncode` 成对；只解实体，不解析标签结构。

#### `CharsetConvert(data, targetEncoding string, autoDetect bool)`

- 作用：字符编码转换（如 GBK→UTF-8），处理乱码响应体。参数 `data` 原始字节串、`targetEncoding` 目标编码（如 `"utf-8"`）、`autoDetect` 是否自动探测源编码。返回 JSON 编码字符串。
```go
s, ok := ctlText(ctl.CharsetConvert(gbkString, "utf-8", true))
```
- 注意：探测失败或编码名不支持会报错；`autoDetect=true` 时才忽略源编码猜测。

**哈希与校验（8 个）**

#### `MD5(data string)`

- 作用：MD5 校验值，做去重键/指纹。参数 `data` 原始字符串。返回 JSON 编码字符串（十六进制小写）。
```go
v, _ := ctlText(ctl.MD5("hello")) // 5d41402abc4b2a76b9719d911017c592
```
- 注意：MD5 不安全，别用于签名/口令。

#### `SHA1(data string)`

- 作用：SHA1 校验值。参数 `data` 原始字符串。返回 JSON 编码字符串（十六进制小写）。
```go
v, _ := ctlText(ctl.SHA1("hello")) // aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d
```
- 注意：与 MD5 同样不推荐用于安全场景。

#### `SHA224(data string)`

- 作用：SHA224 校验值。参数 `data` 原始字符串。返回 JSON 编码字符串（十六进制小写）。
```go
v, _ := ctlText(ctl.SHA224("hello"))
```
- 注意：输出长度与 SHA256 不同（56 字符），别混用比较。

#### `SHA256(data string)`

- 作用：SHA256 校验值（推荐）。参数 `data` 原始字符串。返回 JSON 编码字符串（十六进制小写）。
```go
v, _ := ctlText(ctl.SHA256("hello")) // 2cf24dba...938b9824
```
- 注意：同输入同输出，适合做内容指纹。

#### `SHA384(data string)`

- 作用：SHA384 校验值。参数 `data` 原始字符串。返回 JSON 编码字符串（十六进制小写）。
```go
v, _ := ctlText(ctl.SHA384("hello"))
```
- 注意：属于 SHA-2 家族，长度 96 字符。

#### `SHA512(data string)`

- 作用：SHA512 校验值。参数 `data` 原始字符串。返回 JSON 编码字符串（十六进制小写）。
```go
v, _ := ctlText(ctl.SHA512("hello"))
```
- 注意：长度 128 字符，输出较长。

#### `CRC32(data string)`

- 作用：CRC32 校验（快速、非加密）。参数 `data` 原始字符串。返回 JSON 编码字符串（十六进制）。
```go
v, _ := ctlText(ctl.CRC32("hello")) // 3610a686
```
- 注意：仅用于校验/去重，不能当安全哈希。

#### `CRC64(data string)`

- 作用：CRC64 校验。参数 `data` 原始字符串。返回 JSON 编码字符串（十六进制）。
```go
v, _ := ctlText(ctl.CRC64("hello"))
```
- 注意：同样非加密用途。

**随机与字符串截取（4 个）**

#### `RandStr(mode, length int)`

- 作用：生成随机字符串，做 token/占位符。参数 `mode` 位掩码（1 小写、2 数字、4 大写、8 特殊，可组合）、`length` 长度。返回 JSON 编码字符串。
```go
tok, _ := ctlText(ctl.RandStr(3, 16)) // 小写+数字，长度 16，如 sijjkjuc12345678
```
- 注意：`mode` 是**按位或**组合（`3` = 小写+数字），`mode=0` 可能取不到字符。

#### `LeftOf(s, sep string)`

- 作用：取分隔符**左侧**内容。参数 `s` 源文本、`sep` 分隔符。返回 JSON 编码字符串。
```go
v, _ := ctlText(ctl.LeftOf("aa.bb", ".")) // aa
```
- 注意：不含分隔符本身；分隔符不存在时的行为以实际返回为准。

#### `RightOf(s, sep string)`

- 作用：取分隔符**右侧**内容。参数 `s` 源文本、`sep` 分隔符。返回 JSON 编码字符串。
```go
v, _ := ctlText(ctl.RightOf("aa.bb", ".")) // bb
```
- 注意：多个分隔符时通常取第一个之后的全部内容。

#### `MiddleOf(s, left, right string)`

- 作用：取左右标记**之间**的内容，适合从 HTML/报文里抠字段。参数 `s` 源文本、`left` 左标记、`right` 右标记。返回 JSON 编码字符串。
```go
v, _ := ctlText(ctl.MiddleOf("aa<b>cc", "<", ">")) // b
```
- 注意：标记须按出现顺序成对存在，否则可能取到空串。

**JSON 与时间（4 个）**

#### `JSONGet(data []byte, path string)`

- 作用：按路径读取 JSON 的**字符串值**，从指令响应里取字段最常用。参数 `data` JSON 原始字节、`path` 路径（如 `a.b[0].c`）。返回 JSON 编码字符串（值本身）。
```go
res := ctl.Command("GetTaskList", `{"isDelete":false}`)
total, _ := ctlText(ctl.JSONGet([]byte(res), "data.total")) // 取出字符串形式的值
```
- 注意：路径写错/不存在会报错或返回空；先用 `ctl.Log(res)` 确认结构。

#### `JSONGetRaw(data []byte, path string)`

- 作用：按路径读取 JSON 的**原始值**（数组/对象不丢结构）。参数 `data` JSON 字节、`path` 路径。返回 JSON 编码字符串（需两步解）。
```go
raw, _ := ctlText(ctl.JSONGetRaw([]byte(res), "data.list")) // 得到 "[{...},{...}]" 文本
var list []map[string]any
_ = json.Unmarshal([]byte(raw), &list) // 再解一次拿结构化列表
```
- 注意：返回值是把原始 JSON 当字符串再 JSON 编码，**需要先 `ctlText` 再 `json.Unmarshal` 两次解码**。

#### `TimeToTimestamp(timeStr string, isMilli bool) int64`

- 作用：时间字符串转时间戳。参数 `timeStr` 时间文本、`isMilli` 是否毫秒。**返回 int64**。
```go
// 注意：当前实现里该函数恒返回 0（SDK 把带引号的 JSON 字符串直接按整数反序列化会失败）。
// 需要时间戳请走底层 Call(60) 再自行解析：
r, _ := ctlText(ctl.Call(60, `["2026-10-05 12:00:00",false]`)) // "1791201600"
ts, _ := strconv.ParseInt(r, 10, 64)
```
- 注意：这是当前 SDK 的已知问题，建议改用上面的 `Call(60)` 写法；`isMilli` 决定秒/毫秒。

#### `TimestampToStr(ts int64, isMilli bool)`

- 作用：时间戳转时间字符串。参数 `ts` 时间戳、`isMilli` 是否毫秒。返回 JSON 编码字符串。
```go
v, _ := ctlText(ctl.TimestampToStr(1696512000, false)) // 2023-10-05 21:20:00
```
- 注意：`isMilli` 要与 `ts` 的单位一致。

**其他（4 个）**

#### `UUID(prefix string)`

- 作用：生成带前缀的唯一 ID，做临时标识。参数 `prefix` 前缀字符串（可为 `""`）。返回 JSON 编码字符串。
```go
id, _ := ctlText(ctl.UUID("ping-")) // ping-8003d72b-45a5-4b9f-9185-8884e0ad5d33
```
- 注意：前缀直接拼接，别带引号；生成的是随机 UUID。

#### `NowStr()`

- 作用：取当前时间字符串 `2006-01-02 15:04:05`。参数：无。返回 JSON 编码字符串。
```go
now, _ := ctlText(ctl.NowStr()) // 2026-10-05 14:04:50
```
- 注意：时间是控制器**宿主**所在机器的本地时间。

#### `YAMLToJSON(y string)`

- 作用：YAML 文本转 JSON，处理配置/模板。参数 `y` YAML 文本。返回 JSON 编码字符串（内容是被转义的 JSON 文本）。
```go
j, _ := ctlText(ctl.YAMLToJSON("name: x")) // {"name":"x"}
```
- 注意：结果需 `ctlText` 去引号/反转义，才是可读 JSON。

#### `JSONToYAML(j string)`

- 作用：JSON 文本转 YAML。参数 `j` JSON 文本。返回 JSON 编码字符串。
```go
y, ok := ctlText(ctl.JSONToYAML(`{"name":"x"}`))
```
- 注意：输入必须是合法 JSON，否则 `ok=false`。

> [!NOTE]
> 还有几个内置函数**没有便捷封装**，需用 `Call` 直接调：`to_str`(1) 等 1–8 类型转换、`text_between`(43)、`csv_to_line`(44)、`csv_clean`(45)、`format_time`(62)。例如：

```go
r := ctl.Call(43, `["aaSTARTmidENDbb","START","END",0,"",false]`)
s, _ := ctlText(r) // {"pos":13,"text":"mid"}（已去引号，需再 json.Unmarshal 取字段）
if s != "" {
	var tb struct {
		Pos  int    `json:"pos"`
		Text string `json:"text"`
	}
	_ = json.Unmarshal([]byte(s), &tb)
	ctl.Log(fmt.Sprintf("命中位置=%d 文本=%s", tb.Pos, tb.Text))
}
```

## 八、Command 可调用的控制器业务指令清单

`Command(name, argsJSON)` 只能调用控制器**已注册**的指令（白名单，完整清单即下表）。参数均为 JSON 字符串，分页字段 `page` / `pageSize` 可选。下表按类别整理，**带 `⚠` 的是有副作用的操作型指令**（写库 / 写文件 / 下发节点），插件作者务必谨慎。

### 8.1 任务管理

| 指令 | 参数 | 说明 |
|---|---|---|
| `GetTaskList` | `{"isDelete":false,"page":1,"pageSize":20,"search":""}` | 任务列表（`isDelete=false` 未删除 / `true` 已删除） |
| `GetTaskDetails` | `{"taskIde":"task-xxx"}` | 任务配置 |
| `GetTaskScanResult` | `{"taskIde":"task-xxx"}` | 该任务全部漏洞数组 |
| `SaveTask` ⚠ | `{"startTask":true,"taskIde":"..","taskName":"..",...}` | 保存 / 启动任务 |
| `ChangeTaskStatus` ⚠ | `{"taskIde":"..",...}` | 修改任务状态 |
| `SoftDeleteTasks` ⚠ | taskIde 列表 | 软删除任务 |
| `DeleteTasks` ⚠ | taskIde 列表 | 永久删除任务 |
| `RecoverTasks` ⚠ | taskIde 列表 | 恢复已删除任务 |

```go
// 查询任务列表，并读取返回 JSON 的字段
res := ctl.Command("GetTaskList", `{"isDelete":false,"page":1,"pageSize":20}`)
ctl.Log("任务列表: " + res)
if total, ok := ctlText(ctl.JSONGet([]byte(res), "data.total")); ok {
	ctl.Log("总数=" + total)
}
```

### 8.2 漏洞管理

| 指令 | 参数 | 说明 |
|---|---|---|
| `GetVulnList` | `{"templateId":"..","searchKey":"..","page":1,"pageSize":20}` | 漏洞列表（漏洞管理页） |
| `NewWebTaskGetVulnList` | 同上 | 漏洞列表（新建任务页） |
| `AddVuln` ⚠ | 漏洞表单字段 | 添加漏洞（写 poc 文件并重载） |
| `UpdateVuln` ⚠ | 漏洞表单字段 | 编辑漏洞 |
| `UpdateVulnRank` ⚠ | `{"vulnHash":"..","rank":"4","operator":"..","remark":".."}` | 修改漏洞等级（全局状态 + 历史） |
| `GetVulnHistories` | `{"vulnHash":".."}` | 漏洞等级修改历史 |
| `GetPackDetails` | `{"taskIde":"..","vulnHash":".."}` | 漏洞数据包详情 |
| `VerifyVuln` ⚠ | `{"taskIde":"..","vulnHash":"..", ...}` | 验证漏洞（转发扫描节点复测） |

```go
res := ctl.Command("GetVulnList", `{"searchKey":"sql","page":1,"pageSize":20}`)
// 若响应是数组/对象，用 JSONGetRaw 取列表再二次解析
if raw, ok := ctlText(ctl.JSONGetRaw([]byte(res), "data.list")); ok {
	var list []map[string]any
	_ = json.Unmarshal([]byte(raw), &list)
	ctl.Log(fmt.Sprintf("命中 %d 条漏洞", len(list)))
}
```

### 8.3 模板管理

| 指令 | 参数 | 说明 |
|---|---|---|
| `GetVulnTemplateList` | `{"page":1,"pageSize":20}` | 漏洞模板列表 |
| `newWebTaskGetVulnTemplateList` | 同上 | 漏洞模板列表（新建任务页） |
| `AddVulnToTemplate` ⚠ | VulnTemplate | 添加漏洞模板 |
| `EditVulnToTemplate` ⚠ | VulnTemplate | 编辑漏洞模板 |
| `delVulnTemplate` ⚠ | `{"templateIde":".."}` | 删除漏洞模板 |

```go
res := ctl.Command("GetVulnTemplateList", `{"page":1,"pageSize":20}`)
ctl.Log("模板列表: " + res)
```

### 8.4 路径扫描

| 指令 | 参数 | 说明 |
|---|---|---|
| `GetPathScanTemplateList` | `{"page":1,"pageSize":20}` | 路径扫描模板列表 |
| `SavePathScanTemplate` ⚠ | 模板对象 | 保存路径扫描模板 |
| `SearchGetDirectoryDictList` | `{"keyword":"..","page":1,"pageSize":20}` | 搜索目录字典 |
| `DelDictionaryPaths` ⚠ | `{"paths":[...]}` | 删除字典路径 |
| `SaveImportedDirectoryDict` ⚠ | 字典对象 | 导入字典 |

```go
res := ctl.Command("SearchGetDirectoryDictList", `{"keyword":"admin","page":1,"pageSize":20}`)
ctl.Log("字典搜索结果: " + res)
```

### 8.5 Hook 规则

| 指令 | 参数 | 说明 |
|---|---|---|
| `GetHookRulesList` | `{"page":1,"pageSize":20}` | Hook 规则列表 |
| `UpdateHookList` ⚠ | Hook 规则对象 | 更新 Hook 规则（会下发扫描节点） |
| `GetVulnKeyword` | `{"keyword":".."}` | Hook 关键字快速搜索 |
| `GetTaskHookTrafficList` | `{"taskIde":".."}` | 任务 Hook 流量列表 |
| `GetAiHookToolList` | — | 可 Hook 的 AI 工具清单（下拉候选） |

```go
res := ctl.Command("GetHookRulesList", `{"page":1,"pageSize":20}`)
ctl.Log("Hook 规则: " + res)
```

### 8.6 系统 / 配置 / 市场 / 插件

| 指令 | 参数 | 说明 |
|---|---|---|
| `GetGuiConfig` | `{"uuid":".."}` | 获取 GUI 配置（含扫描节点列表） |
| `setGuiConfig` ⚠ | GuiConfig | 保存配置 |
| `GetPluginMarketList` | `{"page":1,"pageSize":20,"keyword":"..","all":false}` | 插件市场列表（`all=true` 全量） |
| `GetPluginLocalList` | — | 控制器本地已下载插件 uuid 列表 |
| `GetPluginDetail` | `{"uuid":"..","target":"gui或controller或scan"}` | 读取插件目标目录源码文件 |
| `SavePluginDetail` ⚠ | `{"uuid":"..","target":"..","content":".."}` | 保存插件源码 |
| `DeletePluginFile` ⚠ | `{"uuid":"..","target":"..","file":".."}` | 删除插件文件 |
| `SavePluginConfig` ⚠ | `{"uuid":"..","config":{...}}` | 保存插件配置并下发扫描节点 |
| `GetPocMarketList` | `{"page":1,"pageSize":20,"keyword":".."}` | POC 商店列表 |
| `SyncMarketHashes` ⚠ | — | 手动刷新市场哈希对比 |
| `GetMarketCompareResult` | `{"taskIde":".."}` | 本地 POC 市场对比结果 |
| `DownloadPocMarket` ⚠ | `{"uuids":[...]}` | 授权下载 POC 到本地 |
| `SharePocToMarket` ⚠ | `{"uuids":[...]}` | 分享 POC 到市场待审核 |
| `ReloadWasmPlugins` ⚠ | — | 重载本地 WASM 插件（控制器专用） |

```go
// 列出控制器本地已下载插件，用于确认某个插件是否在本地
res := ctl.Command("GetPluginLocalList", `{}`)
ctl.Log("本地插件: " + res)
```

> [!CAUTION]
> 操作型指令会**真实改变控制器状态**：`SaveTask` / `DeleteTasks` 改任务、`AddVuln` / `UpdateVulnRank` 改漏洞库、`UpdateHookList` / `SavePluginConfig` 会**下发扫描节点**。写插件时应默认只用查询类指令；确需写操作时，务必先校验 `msg` 里的来源与 `CommandA`，避免被任意消息触发。

## 九、内置基础函数 ID 全表

`Call(funcID, argsJSON)` 的第一个参数是函数 ID，第二个参数是**参数数组的 JSON**。SDK 便捷封装与下表 ID 一一对应（控制器在扫描节点基础上扩展了 ID≥70）。**表中"返回"指内置函数语义值；用 SDK 便捷封装取回时是它的 JSON 编码字符串（见 7.4）。**

| ID | 函数 | 参数（数组元素顺序） | 返回语义 |
|---|---|---|---|
| 1 | `to_str` | value | 任意值转字符串 |
| 2 | `to_int` | value | 任意值转整数 |
| 3 | `to_int64` | value | 任意值转 int64 |
| 4 | `to_uint32` | value | 任意值转 uint32 |
| 5 | `to_uint64` | value | 任意值转 uint64 |
| 6 | `to_float` | value | 任意值转浮点数 |
| 7 | `to_bool` | value | 任意值转布尔 |
| 8 | `to_bytes` | value | 任意值转字节串（字符串形式返回） |
| 10 | `base64_encode` | data | Base64 编码 |
| 11 | `base64_decode` | data | Base64 解码 |
| 12 | `bytes_to_hex` | data | 字节转十六进制（无空格） |
| 13 | `hex_to_bytes` | hex | 十六进制转字节串 |
| 14 | `url_encode` | input | URL 编码 |
| 15 | `url_decode` | input | URL 解码 |
| 16 | `html_encode` | data | HTML 实体编码 |
| 17 | `html_decode` | data | HTML 实体解码 |
| 18 | `charset_convert` | data、targetEncoding、autoDetect | 字符编码转换（如 GBK→UTF-8） |
| 20 | `md5` | data | MD5 十六进制字符串 |
| 21 | `sha1` | data | SHA1 十六进制字符串 |
| 22 | `sha224` | data | SHA224 十六进制字符串 |
| 23 | `sha256` | data | SHA256 十六进制字符串 |
| 24 | `sha384` | data | SHA384 十六进制字符串 |
| 25 | `sha512` | data | SHA512 十六进制字符串 |
| 26 | `crc32` | data | CRC32 十六进制字符串 |
| 27 | `crc64` | data | CRC64 十六进制字符串 |
| 30 | `rand_str` | mode（1 小写、2 数字、4 大写、8 特殊，可组合）、length | 随机字符串 |
| 40 | `left_of` | s、keyWord | 分隔符左侧内容 |
| 41 | `right_of` | s、keyWord | 分隔符右侧内容 |
| 42 | `middle_of` | s、left、right | 两标记之间的内容 |
| 43 | `text_between` | source、start、end、startPosition、offset、fallbackToSource | `{"pos":n,"text":""}` |
| 44 | `csv_to_line` | fields、lineBreak | 字符串切片转一行 CSV |
| 45 | `csv_clean` | fields | 清洗字符串切片（JSON 数组） |
| 50 | `json_get` | jsonData、path | 按路径读取 JSON 值（字符串） |
| 51 | `json_get_raw` | jsonData、path | 按路径读取 JSON 原始值 |
| 60 | `timestamp` | timeStr、isMilli | 时间转时间戳 |
| 61 | `timestamp_str` | ts、isMilli | 时间戳转时间字符串 |
| 62 | `format_time` | now、param | 时间偏移计算 |
| 70 | `uuid` | prefix | 带前缀 UUID（控制器扩展） |
| 71 | `now_str` | — | 当前时间 `2006-01-02 15:04:05`（控制器扩展） |
| 72 | `yaml_to_json` | yaml | YAML 文本转 JSON（控制器扩展） |
| 73 | `json_to_yaml` | json | JSON 文本转 YAML（控制器扩展） |

> [!NOTE]
> `Call` 的参数是**数组**，例如 `ctl.Call(30, `[3, 16]`)`（小写+数字、长度 16）、`ctl.Call(70, `["task-"]`)`。未知 ID 返回 `{"error":"未知的内置函数 ID"}`；函数报错返回 `{"error":"..."}`。

**组合示例一：取响应 JSON 字段 + Base64 解码**

```go
res := ctl.Command("GetTaskList", `{"isDelete":false}`)          // 原始响应 JSON
b64, _ := ctlText(ctl.JSONGet([]byte(res), "data.rawBody"))       // 取出 base64 字段
body, _ := ctlText(ctl.Base64Decode(b64))                         // 解码成明文
ctl.Log("正文=" + body)
```

**组合示例二：时间戳往返**

```go
// 时间字符串 → 时间戳（用 Call(60)，因为 TimeToTimestamp 当前恒为 0）
r, _ := ctlText(ctl.Call(60, `["2026-10-05 12:00:00",false]`))
ts, _ := strconv.ParseInt(r, 10, 64)
// 时间戳 → 时间字符串
back, _ := ctlText(ctl.TimestampToStr(ts, false))                 // 2026-10-05 12:00:00
ctl.Log(fmt.Sprintf("ts=%d back=%s", ts, back))
```

**组合示例三：text_between 抠文本 + MD5 指纹**

```go
r, _ := ctlText(ctl.Call(43, `["aaSTARTmidENDbb","START","END",0,"",false]`))
var tb struct {
	Pos  int    `json:"pos"`
	Text string `json:"text"`
}
_ = json.Unmarshal([]byte(r), &tb)
fp, _ := ctlText(ctl.MD5(tb.Text))
ctl.Log(fmt.Sprintf("文本=%s 指纹=%s", tb.Text, fp))
```

## 十、参数与 IPC 注意事项

### 10.1 SendTCP 的 cmd3 路由

`SendTCP` / `SendTCPJson` 的 `conn` 恒为 `nil`，目标由 **`cmd3`** 决定：

| cmd3 值 | 路由目标 |
|---|---|
| `gui` | 所有 GUI |
| `scan` | 所有扫描节点 |
| `all` | 全部（GUI + 扫描节点 + …） |
| 其它值 | 具体节点 / 连接的 UUID |

回包给发来消息的 GUI 时，惯例是把消息的 `msg.Command4`（回信 UUID）作为目标填进 `cmd3`；若只是广播用 `gui`。

### 10.2 传结构体，不要自己 json.Marshal 成 []byte

`SendTCPJson` 的 `data any` 参数会被 SDK 内部 `json.Marshal` 一次。**正确做法是直接传结构体 / map / slice**：

```go
// 正确：传 map，SDK 编成 {"md5":"...","ok":true}
ctl.SendTCPJson("MainGui", "Result", msg.Command4, "", "EncodeResult",
    map[string]any{"md5": ctl.MD5("hello"), "ok": true})

// 错误：传 []byte，json.Marshal 会把字节切片编成 base64 字符串
// ctl.SendTCPJson(..., "EncodeResult", []byte(`{"ok":true}`))
```

> [!WARNING]
> 这是本仓库真实踩过的坑：**把已经 `json.Marshal` 好的 `[]byte` 再交给会自动序列化的接口，外层会被编成 base64 字符串**，GUI 收到的是一串乱码般的 base64，而不是 JSON 对象。规则：需要 `{CommandA,Data}` 包裹时用 `SendTCPJson` 并传**值对象**；需要发原始字符串（不做序列化）时用 `SendTCP`。

### 10.3 重注入深度

`TcpReceivedData` 会把消息重新注入控制器分发。宿主有**全局重注入深度计数，最多 5 层**：插件 A 处理消息时重注入 → 又命中插件 → 再重注入……超过 5 层会被直接丢弃，防止插件之间形成死循环互相触发。

### 10.4 两种返回值格式，别搞混

这是本 SDK 最容易出错的地方，务必记牢：

| 调用 | 返回格式 | 怎么用 |
|---|---|---|
| `Command` / `T` / `ConfigGet` / `CopyTcpUUID` | **原始**文本 / JSON | 直接使用；`Command` 结果可直接 `json.Unmarshal` |
| `Call` 及 29 个内置便捷封装 | **JSON 编码字符串**（带引号/转义） | 先用 `ctlText` 解出普通文本（见 7.4） |
| 内置函数报错 / 未知 ID | 错误对象 `{"error":"..."}` | `ctlText` 会返回 `false`，据此判失败 |

## 十一、超时与稳定性

控制器对插件执行做了多层保护，**插件卡死不会阻塞控制器主进程与其它消息处理**：

- **默认执行超时 1 分钟**；插件可在处理过程中调用 `SetTimeout(ms)` 延长，**硬上限 10 分钟**（超过按 10 分钟截断），`ms<=0` 忽略。
- **watchdog**：整个 `_start` 调用跑在独立 goroutine，宿主用定时器计时；超时立即返回错误。`SetTimeout` 会重置这个定时器。
- **poisoned 自动重建**：超时后该插件的运行时被标记 `poisoned`，**下次调用时自动重建**运行时（重新编译 + 新实例），避免一次卡死永久拖累后续消息。
- **wazero 硬上限 context**：在 watchdog 之外再叠一层 `maxTimeout` 的 context，防止插件永久占用。
- **panic / trap 兜底**：`Run` 内 `recover`；宿主调用协程也有 `recover`。插件 panic 只影响本次调用。
- **加载/编译也受超时保护**：模块编译在带超时的 context 下进行，恶意或畸形 wasm 不会卡住加载流程。

> [!IMPORTANT]
> 超时是基于"**一次调用**"的。如果插件需要长时间工作（例如多批次查询汇总），应在处理内定期 `SetTimeout`，或把大任务拆成多条 TCP 消息分次处理——单次超过 10 分钟一定会被 watchdog 掐断并重建运行时。

## 十二、调试手册

### 12.1 日志在哪里看

- 插件 `Log(...)` 与 stdout 的 `[LOG]` 行，都会以 **`WASM插件[<uuid>]: <内容>`** 前缀进控制器日志（语言包键 `Ctl.Log.WasmPlugin-04`），在 GUI 的控制器日志面板可见；
- 插件返回的 `error`（`[RESP]` 里的 error 字段）以 **`WASM插件[<uuid>] 返回错误: <error>`** 记录（`Ctl.Log.WasmPlugin-05`）；
- 加载/调用相关：探测失败 `Ctl.Log.WasmPlugin-01`、未声明能力被跳过 `Ctl.Log.WasmPlugin-02`、执行失败 `Ctl.Log.WasmPlugin-03`。

### 12.2 5 分钟最小验证流程

1. **编译**：`GOOS=wasip1 GOARCH=wasm CGO_ENABLED=0 go build -o controller.wasm .`
2. **放置**：拷到 `CtlConfig/plugins/my-ping/Plugin/Controller/my-ping/build/controller.wasm`，并带上 `info.yaml`。
3. **一键签名**：GUI「我的插件」页右键该插件 →「一键签名」（写 `sig.json` 并自动重载）。
4. **重载**：若未自动加载，发指令 `ReloadWasmPlugins`。
5. **发命令**：在 GUI 发一条命中订阅的指令（如 `CommandA=Ping`）。
6. **看日志**：控制器日志应出现 `WASM插件[<uuid>]: 收到消息...`；同时 GUI 应收到你 `SendTCP` 的回包。

### 12.3 症状 → 可能原因 → 解决

| 症状 | 可能原因 | 解决 |
|---|---|---|
| 代码没问题但收不到任何消息 | 没声明 `tcp.handle` 能力 | `Capability(ctl.CapTCPHandle)` 或导出 `plugin_handle` |
| 订阅命中也不触发 | 从未调用 `Subscribe`（订阅列表为空） | 至少 `Subscribe` 一次；要全收用 `Subscribe("","","")` |
| 只收到部分消息 | 订阅字段大小写/拼写不符 | `command1`/`command2` 用控制器实际指令名，`commandA` 对应 `Data.CommandA` |
| 插件根本不加载，只有日志 | 未签名 / 验签失败 | GUI 一键签名（`SignPluginLocal`）或按第二节生成 `sig.json` |
| 日志报“编译 wasm 模块失败” | 交叉编译参数错 / SDK 版本不符 | 用 `GOOS=wasip1 GOARCH=wasm`；确认 import "ctl" 与 replace 指向 SDK |
| 执行超时、之后又恢复 | 单次执行超 1 分钟被 watchdog 掐断，运行时 `poisoned` | 内联调用 `SetTimeout`，或拆消息分次处理（≤10 分钟） |
| 回包到了但内置处理没生效 | handler 误返回 `true` | 只在你完整接管时才 `return true`；旁路观察返回 `false` |
| 回包发不到目标 | `SendTCP` 的 `cmd3` 路由写错 | GUI 回包填 `msg.Command4`，广播填 `gui` |
| GUI 收到乱码 base64 | 把 `[]byte` 传给了 `SendTCPJson` | 传结构体/map；要发原始字符串用 `SendTCP` |
| 逻辑互相触发、日志刷屏 | `TcpReceivedData` 形成环，超过 5 层被丢弃 | 收紧重注入触发条件，避免订阅与重注入互相命中 |
| `ConfigGet` 取不到值 | 键不存在（返回空串）或没放 `plugin.config.json` | 判空处理；确认配置在外层 `<uuid>/plugin.config.json` |
| 内置函数取到带引号的值 | `Call`/封装返回 JSON 编码字符串 | 用 `ctlText` 解出普通文本（见 7.4） |
| 时间戳总是 0 | `TimeToTimestamp` 当前恒返回 0 | 改用 `ctlText(ctl.Call(60, ...))` + `strconv.ParseInt` |
| 英文模式下插件文案是中文 | 文案硬编码 | 走 `T(key)` 语言包，键空间 `<语言>.<插件UUID>.<key>` |

### 12.4 怎样确认某个插件是否被加载

- **看日志**：加载成功不出专门日志，但**启动探测**会以空消息跑一次，插件里若在 handler 顶部 `Log` 就会打出来；加载失败会看到 `WASM插件 <uuid> 启动探测失败`（`WasmPlugin-01`）。
- **用连接表**：调用 `CopyTcpUUID()` 观察控制器当前的连接与来源，确认链路存在。
- **查本地插件列表**：用 `Command("GetPluginLocalList", "{}")` 取控制器本地已下载的插件 uuid 列表，核对你的 `<uuid>` 在不在。
- **触发一次**：发一条订阅命中的指令，看是否出现 `WASM插件[<uuid>]:` 日志；没有则按 12.3 逐条排查。

## 十三、本地开发与热加载调试

推荐本地迭代流程：

1. **放默认加载目录**：把插件放到控制器运行目录下的 `CtlConfig/plugins/<uuid>/Plugin/Controller/<uuid>/`（标准布局），或直接拷到 `PluginTest/` 用示例工程对照；
2. **一键签名**：GUI「我的插件」页右键 →「一键签名」（`SignPluginLocal`）。**未签名不会加载**；
3. **加载 / 重载**：
   - 首次放好后控制器启动或扫描时自动加载；
   - 迭代时用指令 `ReloadWasmPlugins`（也可作为 TCP 指令由 GUI 触发）重载全部本地 WASM 插件；
4. **看日志**：插件 `Log` 与 `[LOG]` 行都会进控制器日志，前缀为 `WASM插件[<uuid>]: `；加载失败 / 验签失败 / 启动探测失败也有对应日志；
5. **触发指令**：从 GUI 发一条命中订阅的消息（`CommandA=Ping` 之类），或用调试工具构造 TCP 帧。

> [!TIP]
> 迭代时若改了 `[INIT]` 里的订阅或能力，**必须重载**（`ReloadWasmPlugins`）才会生效——宿主在启动探测时收集订阅与能力，运行期虽会按每次 `[INIT]` 刷新，但重载是最稳妥的做法。

## 十四、注意事项清单

| 坑 | 表现 | 规避 |
|---|---|---|
| 没声明 `tcp.handle` | 代码正确但永远收不到消息 | `Capability(ctl.CapTCPHandle)` 或导出 `plugin_handle` |
| 没调用 `Subscribe` | 订阅列表为空，任何消息都不匹配 | 至少 `Subscribe` 一次；要全收就 `Subscribe("","","")` |
| 订阅字段写错大小写 | 只收到部分消息或不收 | `command1`/`command2` 用控制器实际指令名，`commandA` 对应 `Data.CommandA` |
| 传 `[]byte` 给 `SendTCPJson` | GUI 收到 base64 字符串而非 JSON | 传结构体 / map / slice；要发原始字符串用 `SendTCP` |
| `cmd3` 填错 | 回包发不到目标 | GUI 回包填 `msg.Command4`，广播填 `gui` |
| 未签名 / 验签失败 | 插件不加载，只有日志 | GUI 一键签名，或按第二节生成 `sig.json` |
| 依赖插件进程内全局状态 | 跨消息状态丢失 | 每次调用是全新实例，状态落控制器或配置 |
| 单次执行太久 | 被 watchdog 掐断、poisoned 重建 | 拆消息分次处理，或合理 `SetTimeout`（≤10 分钟） |
| 把 `Call` 结果直接当文本 | 得到带引号/转义的值 | 用 `ctlText` 解出普通文本（见 7.4） |
| `TimeToTimestamp` 取到 0 | 时间戳恒为 0 | 改用 `ctlText(ctl.Call(60, ...))` + `strconv.ParseInt` |
| `Command` 调用未注册指令 | 返回 `{"success":false,"error":"未注入的控制器指令: xxx"}` | 只用第八节清单里的指令名 |
| 盲目调用写操作指令 | 真实改库 / 下发节点 | 默认只用查询类，写操作前校验来源与动作 |
| 重注入形成环 | 超过 5 层被静默丢弃 | 控制 `TcpReceivedData` 的触发条件 |
| 忘了 `[RESP]` | 执行被判定失败 | 用 SDK 的 `Run`（自动输出），手写 ABI 时务必输出 `[RESP]` |
| 语言文案硬编码 | 英文模式下仍显示中文 | 走 `T(key)` 语言包，键空间 `<语言>.<插件UUID>.<key>` |
| `ConfigGet` 键不存在 | 返回空串而不是报错 | 判空处理，区分"未配置" |

## 十五、完整示例工程

以下示例均可直接照抄修改，均基于官方 Go SDK（`import "ctl"`），`init()` 做声明、`main()` 调 `Run`。

### 15.1 示例一：Ping/Pong 链路探测

**场景**：GUI 发一条 `CommandA=Ping` 的指令，插件回一条 `Pong`，用于验证 GUI→控制器→插件链路是否通畅。

**订阅**：`commandA=Ping`（任意来源）。

```go
package main

import "ctl"

func init() {
	ctl.SetInfo("连通性测试助手", "1.0.0")
	ctl.Capability(ctl.CapTCPHandle)
	ctl.Subscribe("", "", "Ping") // 只订阅 CommandA=Ping
}

func main() {
	ctl.Run(func(msg ctl.Msg) bool {
		if msg.CommandA != "Ping" {
			return false // 放行，交给控制器内置处理
		}
		ctl.Log("收到 Ping，来自 " + msg.UUID + "（source=" + msg.Source + "）")
		ctl.SendTCPJson("MainGui", "Pong", msg.Command4, "", "PongResult", map[string]any{
			"pong":   true,
			"plugin": "my-ping",
			"now":    ctl.NowStr(),
			"uuid":   ctl.UUID("ping-"),
		})
		return true // 已处理，跳过内置处理
	})
}
```

**编译与验证**：

```bash
GOOS=wasip1 GOARCH=wasm CGO_ENABLED=0 go build -o controller.wasm .
```

放到 `CtlConfig/plugins/my-ping/Plugin/Controller/my-ping/build/controller.wasm`，一键签名后在 GUI 发 `CommandA=Ping`，应看到日志与 `PongResult` 回包。

### 15.2 示例二：任务列表统计并回推

**场景**：GUI 发 `CommandA=TaskStats`，插件调用控制器的 `GetTaskList` 查询任务，做一次统计后用内置 JSON 函数抽取总数，再把统计结果回推 GUI。

**订阅**：`commandA=TaskStats`。

```go
package main

import (
	"encoding/json"

	"ctl"
)

func main() {
	ctl.Run(func(msg ctl.Msg) bool {
		if msg.CommandA != "TaskStats" {
			return false
		}
		// 1) 调用控制器业务指令，拿到原始响应 JSON
		res := ctl.Command("GetTaskList", `{"isDelete":false,"page":1,"pageSize":20}`)
		// 2) 先打一次日志确认结构，再用内置函数抽取字段
		ctl.Log("GetTaskList 返回: " + res)
		total, _ := ctlText(ctl.JSONGet([]byte(res), "data.total"))
		// 3) 回推给发起方（cmd3 用 msg.Command4）
		ctl.SendTCPJson("MainGui", "TaskStatsResult", msg.Command4, "", "TaskStatsResult",
			map[string]any{
				"totalRaw": total,
				"raw":      res,
				"stamp":    ctl.NowStr(),
			})
		return true
	})
}

// ctlText 解出内置函数返回的 JSON 编码字符串（见 7.4）
func ctlText(r string) (string, bool) {
	var s string
	if err := json.Unmarshal([]byte(r), &s); err != nil {
		return r, false
	}
	return s, true
}
```

> [!NOTE]
> 业务指令返回结构以你的控制器版本为准，用 `JSONGet` / `JSONGetRaw` 前先打印一次 `res`（`ctl.Log(res)`）确认路径；拿不到也可以把 `res` 原样透传给 GUI，由前端解析。

### 15.3 其他语言 SDK

同一套 ABI 提供 C / C++ / Rust SDK，与 Go SDK 一起随发行包提供：`ctl_sdk.h`（C）、`ctl_sdk.hpp`（C++）、`ctl_sdk.rs`（Rust）。三者的能力点用**逗号分隔字符串**在 `write_init` 传入（如 `"tcp.handle"`，无能力传 `""`），订阅用 `command1|command2|commandA`（`|` 分隔；Go SDK 用 `Subscribe` 追加）。

C 示例：

```c
#include "ctl_sdk.h"

int main(void) {
    ctl_write_init("测试插件", "1.0.0", "tcp.handle", "||Ping");
    char msg[65536];
    ctl_read_stdin(msg, sizeof(msg));
    if (strstr(msg, "\"commandA\":\"Ping\"")) {
        ctl_send_tcp_json("MainGui", "Pong", "", "", "PongResult", "{\"pong\":1}");
        ctl_write_resp(1, "");
    } else {
        ctl_write_resp(0, "");
    }
    return 0;
}
```

C++ 示例：

```cpp
#include "ctl_sdk.hpp"

int main() {
    ctl::write_init("测试插件", "1.0.0", "tcp.handle", "MainGui", "GetTaskList", "");
    std::string msg = ctl::read_stdin();
    if (msg.find("\"commandA\":\"Ping\"") != std::string::npos) {
        ctl::send_tcp("MainGui", "Pong", "", "", "{\"pong\":1}");
        ctl::write_resp(true, "");
    } else {
        ctl::write_resp(false, "");
    }
    return 0;
}
```

Rust 示例：

```rust
fn main() {
    ctl_sdk::write_init("测试插件", "1.0.0", "tcp.handle", "MainGui", "GetTaskList", "");
    let msg = ctl_sdk::read_stdin();
    if msg.contains(r#""commandA":"Ping""#) {
        ctl_sdk::send_tcp("MainGui", "Pong", "", "", r#"{"pong":1}"#);
        ctl_sdk::write_resp(true, "");
    } else {
        ctl_sdk::write_resp(false, "");
    }
}
```

编译：`clang --target=wasm32-wasi -O2 -o plugin.wasm main.c`（C++ 用 `clang++`，Rust 用 `rustc --target wasm32-wasi -O`）。产物同样需要签名、放标准目录、`controller.wasm` 命名才会被加载。

> [!TIP]
> 用 C / C++ / Rust 时，`ctl_command` / `ctl_call` / `ctl_t` / `ctl_config` 都返回"写入 out 缓冲区的字节数"，记得按返回长度截取；能力门控同样需要 `write_init` 的能力列表里含 `tcp.handle`，否则不会被调用。C/C++/Rust 的 `ctl_call` 结果同样是 JSON 编码字符串，用之前需自行去引号。

<!-- en -->
# Controller Plugin (WASM) Development

[[toc]]

> This document is aimed at **third-party developers**: with the official TestSecScan release package and this document, you can write from scratch a WASM plugin that the controller loads, subscribes to, and invokes to handle TCP commands. Suggested reading order: read Section 1 first to decide whether you need a controller plugin, then Section 5 to understand exactly how the host loads and invokes you, then follow Section 6 to get a minimal project running, and finally use Section 7 to look up the API **one by one** (every function comes with a copy-ready code snippet).

## 1. What It Is, and When to Write a Controller Plugin

The controller is the "brain" of TestSecScan: the GUI, the scan nodes, and the AI penetration agent all talk to it over TCP. A **controller plugin** is a WASM module that runs inside the controller process and is executed by the wazero sandbox; it can intercept forwarded TCP messages **before the controller's built-in command handling**:

- Every time the controller receives a TCP message, it first matches the message against each plugin's **subscription rules** (`command1` / `command2` / `commandA`);
- Plugins that match are invoked one by one; a plugin reads the message JSON from `stdin` and can call `ctl_*` host functions to read data, write logs, send messages back, and invoke controller business commands;
- When a plugin returns `handled=true`, the controller **skips built-in handling of that message**; when it returns `false` (the default), built-in logic continues. If several plugins match, all of them run, and if any one returns `true`, built-in handling is skipped.

**When a controller plugin is the right choice:**

- You want to add a custom TCP command to the controller (the GUI sends `CommandA=Xxx`, your plugin handles it and replies) without modifying the controller source;
- You want to observe, audit, or rewrite-and-forward existing commands on the side (subscribe to the same command and return `handled=false` to let it through);
- You want to orchestrate several controller business commands into a single call (for example, call `Command("GetTaskList", ...)` and `Command("GetVulnList", ...)` in sequence inside the plugin and push an aggregate back to the GUI);
- You want lightweight automation built on the controller's existing capabilities (scheduling, statistics, health checks).

**When you should not use it:**

- Handling an individual traffic packet and producing findings — that is the scan node's **WASM POC template**, whose host functions are `scan_*` and are not interchangeable with the `ctl_*` functions in this document;
- Hooking scan node business points (task / traffic / MITM) — that is an **application plugin** (`app_*`);
- Acting as a tool that a large model can call — that is an **AiAgent external tool**.

> [!IMPORTANT]
> The four extension points have **non-overlapping host function namespaces**. Putting `scan.HTTP(...)` into a controller plugin, or `ctl.SendTCP(...)` into a scan node plugin, will cause **instantiation to fail** because the host does not export the corresponding function. The test is simple: if the artifact lives in the controller's plugin directory and uses `ctl_*`, it is a controller plugin.

## 2. Plugin Directory Layout and Signing

The controller scans plugins from `CtlConfig/plugins` under its working directory and supports two layouts:

- **Standard (download/share) layout**: `CtlConfig/plugins/<uuid>/Plugin/Controller/<uuid>/`
- **Legacy extracted layout**: `CtlConfig/plugins/Plugin/Controller/<uuid>/`

The complete standard layout:

```text
CtlConfig/plugins/
└── <uuid>/                                # 插件根目录（外层 uuid）
    ├── plugin.config.json                 # 可选：ctl_config / ConfigGet 读取的 Key/Value
    └── Plugin/
        └── Controller/
            └── <uuid>/                    # 控制器目标目录（内层 uuid，目录名即插件 UUID）
                ├── info.yaml              # 插件元数据（pluginUUID/author/languages 等）
                ├── build/controller.wasm  # 控制器 WASM 产物（优先取 controller.wasm）
                └── sig.json               # Ed25519 签名：{userPubkey,userSignature,signStatus}
```

| File | Purpose | Required |
|---|---|---|
| `info.yaml` | Metadata: `pluginUUID`, `author`, `languages.cn.name` / `languages.en.name` (the plugin name is chosen by language), `targets.controller.runtime: wasm` | Recommended |
| `build/controller.wasm` | The controller WASM artifact; any `*.wasm` in the directory is recognized, but **`controller.wasm` takes priority** | Required |
| `sig.json` | Ed25519 signing information; **no signature or a failed verification means the plugin is never loaded** | Required |
| `plugin.config.json` | Plugin configuration Key/Value; the host reads it three levels up from the inner directory, i.e. the outer `<uuid>/plugin.config.json` | Optional |

### 2.1 Signing Requirements (No Signature, No Loading)

The controller loads only plugins that pass signature verification. The host reads `sig.json` from the same directory and verifies the signature:

1. Read `sig.json` and parse it as `WasmSigInfo{userPubkey, userSignature, signStatus}`;
2. Use `userPubkey` (a base64 Ed25519 public key) to run `ed25519.Verify` over **all bytes** of `build/*.wasm`;
3. Verification passes → `verified` (loaded); fails → `verify_failed`; missing `sig.json` or an empty `userSignature` → `unsigned`; the last two are skipped and recorded in the log.

Generating a signature (Go example: sign all the wasm bytes with ed25519, then base64-encode the result into `sig.json`):

```go
priv, _ := ed25519.GenerateKey(rand.Reader)
wasm, _ := os.ReadFile("controller.wasm")
sig := ed25519.Sign(priv, wasm)
sigJSON, _ := json.Marshal(map[string]string{
    "userPubkey":    base64.StdEncoding.EncodeToString(priv.Public().(ed25519.PublicKey)),
    "userSignature": base64.StdEncoding.EncodeToString(sig),
    "signStatus":    "signed",
})
os.WriteFile("sig.json", sigJSON, 0644)
```

> [!TIP]
> For local development you do not need to write a signing script by hand: place the plugin under the controller's default load directory `CtlConfig/plugins/<uuid>/`, then on the GUI "My Plugins" page right-click the plugin and choose **One-Click Sign** (command `SignPluginLocal`, which signs `build/*.wasm` under `Controller`/`scan` with the controller's local Ed25519 private key and writes `sig.json`). After signing succeeds the controller **reloads** the plugin automatically.

## 3. ABI and Lifecycle

The controller keeps **no state** for plugins: every host invocation is "feed one message, run once, collect the result". Note that "each invocation = a brand-new instance" — global variables inside the plugin process are not preserved across messages; if you need state, keep it in the controller (`Command` / `SendTCP` / `EditWebTaskStatus`).

How the host and the plugin interact (for Go plugins, the SDK's `Run` does all of this for you):

1. **Write stdin**: the host writes the message JSON to the plugin process's standard input;
2. **Execute `_start`**: instantiate the wasm module and call `_start` (for Go, that is `func main`);
3. **Parse the stdout line protocol**: the host scans standard output line by line and recognizes the prefixes `[INIT]`, `[LOG]`, and `[RESP]`.

| Line prefix | Content | Description |
|---|---|---|
| `[INIT]` | `{"name":"..","version":"..","capabilities":["tcp.handle"],"subscribe":[{command1,command2,commandA}]}` | Capability and subscription declaration; output on the first line of every run and refreshed dynamically by the host |
| `[LOG]` | Text | Debug log, written to the controller log (visible in the GUI) |
| `[RESP]` | `{"handled":true或false,"error":".."}` | Handling result; the last one wins; `handled=true` skips built-in handling |

The message JSON on stdin (which maps to the SDK's `Msg`):

```json
{
  "uuid": "发送方UUID",
  "source": "0",
  "command1": "MainGui",
  "command2": "GetTaskList",
  "command3": "目标UUID",
  "command4": "回信UUID",
  "commandA": "业务动作名",
  "data": "<原始 Data（JSON 字符串或原始文本）>"
}
```

**Startup probe**: when loading a plugin, the controller first executes it once with an **empty message** `{}` to collect `capabilities` and `subscribe` from `[INIT]`; plugins whose probe fails or times out are removed outright and never become active. Only after the probe does capability gating (whether to invoke a plugin) take effect.

> [!NOTE]
> A normal return from Go's `wasip1` `main` automatically triggers `proc_exit(0)`, which wazero treats as an "error". The host tolerates this case: **as long as `[RESP]` has already appeared on stdout, the execution is considered to have finished normally**. So make sure your plugin emits `[RESP]` at the end of `Run` (or at the end of your hand-written main).

## 4. Capability Declaration and Subscription (Dual Channel)

### 4.1 Capability Gating: No Declaration, No Invocation

Before invoking a plugin, the host first checks whether it **has the corresponding capability**; a plugin that neither declares nor exports that capability **is never instantiated or executed** (strict gating, so that not every plugin is run for nothing, wasting resources and time). There is currently one capability:

| Capability | Meaning |
|---|---|
| `tcp.handle` | Receive and handle TCP messages forwarded by the controller (a subscription must also match before a message is forwarded) |

Capability detection is **dual-channel**: a hit on either channel counts as having the capability:

1. **`[INIT]` declaration**: the plugin declares explicitly implemented capabilities in the `capabilities` array of `[INIT]` (in the Go SDK, fill them with `Capability` / `Capabilities` / `CapabilityAll`);
2. **Exported function auto-detection**: export a `plugin_handle` function with Go 1.25's `//go:wasmexport`; after compiling the module, the host automatically recognizes it as `tcp.handle` through the "exported function → capability" mapping (the current mapping contains only `plugin_handle` → `tcp.handle`).

```go
// 方式一：显式声明
func init() {
    ctl.Capability(ctl.CapTCPHandle)
}

// 方式二：导出函数自动检测（与方式一取并集）
//go:wasmexport plugin_handle
func pluginHandle() {}
```

> [!WARNING]
> A plugin that does not declare `tcp.handle` **is never invoked, no matter how its subscriptions are written**. This is the most common reason for "my code looks fine but I receive no messages".

### 4.2 Subscription Matching Rules

Each call to `Subscribe(command1, command2, commandA)` appends one subscription rule. The host matches subscriptions as follows:

- **A plugin must call `Subscribe` at least once**: if no subscription is declared (the subscription list is empty), the plugin matches no messages;
- If all three fields of a single subscription are **empty** → it matches **all** messages;
- Otherwise, **non-empty fields must be exactly equal, character for character**, to count as a hit (an empty field is a wildcard).
- Multiple subscriptions are combined with **OR**: a hit on any one of them forwards the message.

`commandA` comes from the business action name inside the message's `Data` (the JSON field `CommandA`; when the host cannot parse it, it falls back to the raw `Data` for one more best-effort parse).

```go
ctl.Subscribe("MainGui", "GetTaskList", "") // 命中 MainGui/GetTaskList 的任意 CommandA
ctl.Subscribe("", "", "Ping")               // 命中任意来源、CommandA=Ping 的消息
ctl.Subscribe("", "", "")                   // 命中全部消息（谨慎：会收到所有 TCP 指令）
```

## 5. Where the Entry Point Is: How the Host Loads and Invokes Your Plugin

The previous sections described what a plugin looks like; this section answers the question third-party developers care about most: **at what point, and in what order, does the host load and invoke my plugin?** The whole flow runs through the 10 steps below.

### 5.1 The Complete Flow (Step by Step)

1. **Scan the directory.** At startup the controller scans `CtlConfig/plugins` under its working directory: a subdirectory named `Plugin` is parsed with the **legacy layout** `Plugin/Controller/<uuid>`, while other subdirectories are parsed with the **standard layout** `<uuid>/Plugin/Controller/<uuid>`; each subdirectory name under `Controller` is a **plugin UUID**.
2. **Read metadata and artifact.** The host reads `info.yaml` (taking `author` and the first `languages.*.name` as the plugin name), then looks for `*.wasm` under `build/` (`controller.wasm` first); **a directory with no wasm at all is ignored outright** (it is not a controller plugin).
3. **Verify the signature.** The host reads `sig.json` from the same directory and runs `ed25519.Verify`; only plugins that pass verification enter the running set, while `unsigned` / `verify_failed` ones are recorded in the skip list and skipped.
4. **Compile and build the runtime.** Only at the moment of invocation (or during the startup probe) does the host create a runtime for the plugin: read the wasm bytes → create a wazero runtime → instantiate `wasi_snapshot_preview1` → register all `env.ctl_*` host functions → compile the module. After compilation it collects the module's **exported functions** as the basis of the exported-function capability channel.
5. **Startup probe.** For every plugin that passed signature verification, the host runs it once with an **empty message** `{}` to collect `subscribe` and `capabilities` from `[INIT]`; **plugins whose probe fails or times out are removed from the running set** (log key `Ctl.Log.WasmPlugin-01`). Subscriptions and capabilities are therefore ready right after the probe.
6. **The entry point for each arriving message.** Before built-in command handling, the controller first hands the message to the plugin runtime, which performs subscription matching and capability gating. The plugin runtime first computes `commandA` (from `CommandA` in `Data`; if empty, it falls back to parsing the raw `Data`), then matches subscriptions across all plugins to obtain the hit set.
7. **Capability gating.** For each matched plugin it then checks whether the plugin has the `tcp.handle` capability; **plugins that neither declare nor export it are skipped outright** (log key `Ctl.Log.WasmPlugin-02`, and no runtime is created).
8. **A single invocation.** The host takes the plugin runtime, starts a separate goroutine and attaches a **watchdog timer** (1 minute by default): it writes the message JSON to the plugin's stdin, instantiates the module and calls `_start`, then parses `[INIT]/[LOG]/[RESP]` from stdout line by line.
9. **The meaning of handled.** The host takes `handled` from `[RESP]`, and combines the `handled` values of multiple plugins with **OR**. If the final value is `true`, the controller immediately skips **all built-in handling** of that message; if it is `false`, built-in logic continues.
10. **Reload and self-healing.** When the GUI triggers the `ReloadWasmPlugins` command, or after a one-click sign in the GUI (`SignPluginLocal`), the controller closes the old runtimes and rescans/reloads. If one invocation times out, the watchdog marks that runtime `poisoned`, and it is automatically **rebuilt** on the next invocation (recompiled + new instance), so that a single hang does not drag down subsequent messages.

> [!IMPORTANT]
> "Entry point" has two meanings: the **module entry point** is always the wasm `_start` (Go's `func main`), called by the host; the **registration entry point** is `func init()` in your plugin package, and the SDK turns the `SetInfo/Subscribe/Capability` declarations made during `init` into the `[INIT]` output inside `main`. Third-party developers only need to write `init()` + `main()` and never touch the ABI.

## 6. Quick Start: A Runnable Go Controller Plugin

### 6.1 Project Structure

The official Go SDK ships with the release package (package name `ctl`, `module ctl` in `go.mod`). Create your plugin project:

```text
my-controller-plugin/
├── go.mod
└── main.go
```

`go.mod` (use `replace` to point at the SDK directory; third-party developers should fill in their own SDK extraction path):

```text
module myplugin

go 1.25.0

require ctl v0.0.0

replace ctl => <SDK路径>/sdk/go
```

### 6.2 main.go

```go
package main

import "ctl"

func init() {
	ctl.SetInfo("我的控制器插件", "1.0.0")
	ctl.Capability(ctl.CapTCPHandle)                    // 声明能力：接收 TCP 消息（未声明不会被调用）
	ctl.Subscribe("MainGui", "GetTaskList", "")         // 订阅 MainGui/GetTaskList
	ctl.Subscribe("", "", "Ping")                       // 订阅 CommandA=Ping
}

func main() {
	ctl.Run(func(msg ctl.Msg) bool {
		ctl.Log("收到消息: " + msg.Command1 + "/" + msg.Command2 + "/" + msg.CommandA)
		switch msg.CommandA {
		case "Ping":
			// 注意：SendTCPJson 传 map/结构体，SDK 自动 json.Marshal，别传 []byte
			ctl.SendTCPJson("MainGui", "Pong", msg.Command4, "", "PongResult", map[string]any{
				"pong": true,
				"md5":  ctl.MD5("hello"), // 返回值是 JSON 编码字符串（见 7.4）
			})
			return true // 跳过控制器内置处理
		case "List":
			res := ctl.Command("GetTaskList", `{"isDelete":false}`) // 原始响应 JSON
			ctl.SendTCP("MainGui", "ListResult", msg.Command4, "", res)
			return true
		}
		return false // 观察但不接管
	})
}
```

### 6.3 Build and Deploy

```bash
# 编译（Go 1.25+，产物更小时可加 -trimpath -ldflags=-s -w）
GOOS=wasip1 GOARCH=wasm CGO_ENABLED=0 go build -o controller.wasm .
```

Put the artifact into the controller's load directory and sign it with the GUI's one-click signing:

```text
CtlConfig/plugins/<uuid>/Plugin/Controller/<uuid>/build/controller.wasm
```

1. Copy the whole standard directory structure (including `info.yaml`) to `CtlConfig/plugins/<uuid>/`;
2. On the GUI "My Plugins" page, right-click the plugin → "One-Click Sign" to write `sig.json`;
3. The controller loads it automatically; if the plugin was already running, reload it with the `ReloadWasmPlugins` command;
4. Send a `CommandA=Ping` command from the GUI and watch for the `WASM插件[<uuid>]:` prefix in the controller log and for the `Pong` reply.

> [!TIP]
> When you build through the TestSecScan build service (VulnService), a `//go:build ignore` line at the top of the source file is stripped automatically, so the same source can be skipped by the controller module's `go build` while still being compiled as `wasip1` by the build service.

## 7. The SDK Public API, One Function at a Time

Everything below comes from the official Go SDK `ctl.go` (package `ctl`). Each symbol gets its own level-4 heading covering, in order: purpose, signature, parameter table, return value, copy-ready snippet, and caveats. Except for the declaration functions used during `init()` (`SetInfo` / `Subscribe` / `Capability*`), all of them are called inside the `Run` handler.

> [!CAUTION]
> **Return value encoding (the easiest trap to fall into)**: `Call` and its 29 convenience wrappers (`MD5` / `Base64Encode` / `JSONGet` / `NowStr`, etc.) return the host `ctl_call` result as a **JSON-encoded string** after `json.Marshal`; string results come **wrapped in double quotes** (e.g. `"5d41...c592"`), and JSON results are additionally escaped (e.g. `"{\"name\":\"x\"}"`). To get the real text you must decode it yourself. It is recommended to add a small helper function to your plugin; all builtin snippets in this section are written with it:

```go
// ctlText 把 SDK 内置函数包装的返回值（JSON 编码字符串）解回普通文本。
// 第二个返回值 false 表示取出失败——通常是宿主返回了 {"error":"..."} 错误对象。
func ctlText(r string) (string, bool) {
	var s string
	if err := json.Unmarshal([]byte(r), &s); err != nil {
		return r, false
	}
	return s, true
}
```

> [!NOTE]
> By contrast, `Command` / `T` / `ConfigGet` / `CopyTcpUUID` return **raw** text or JSON (without a second encoding pass) and can be used directly. This is the key return-format difference between the "builtin convenience wrappers" and the "host API".

### 7.1 Types and Constants

#### `Msg`

- **Purpose**: a single controller TCP message that the host passes from `stdin` to the handler on every plugin invocation; the plugin's core input.
- **Signature**:

```go
type Msg struct {
	UUID     string `json:"uuid"`     // 发送方 UUID
	Source   string `json:"source"`   // 来源 0=GUI 1=控制器 2=扫描节点
	Command1 string `json:"command1"` // 一级指令，如 MainGui / scan
	Command2 string `json:"command2"` // 二级指令，如 GetTaskList
	Command3 string `json:"command3"` // 三级指令：目标 UUID
	Command4 string `json:"command4"` // 四级指令：回信 UUID
	CommandA string `json:"commandA"` // 业务动作名（Data 内 CommandA）
	Data     string `json:"data"`     // 原始 Data（JSON 字符串或原始文本）
}
```

- **Field table** (each value source explained):

| Field | JSON key | Type | Value source | Example |
|---|---|---|---|---|
| `UUID` | `uuid` | string | The host fills `tcpData.UUID`, the unique identifier of the node/connection that sent the TCP frame | `"gui-1a2b3c"` |
| `Source` | `source` | string | The host fills `tcpData.Source`: `"0"`=GUI, `"1"`=controller, `"2"`=scan node, `"3"`=AiAgent | `"0"` |
| `Command1` | `command1` | string | First-level command; the controller dispatches on it (`MainGui` / `scan` / `waitHandle`, etc.) | `"MainGui"` |
| `Command2` | `command2` | string | Second-level command (business group) | `"GetTaskList"` |
| `Command3` | `command3` | string | Third-level command: the target of this frame (UUID / `gui` / `scan` / `all`) | `"gui"` |
| `Command4` | `command4` | string | Fourth-level command: the **reply UUID**; when replying, it is usually passed as `cmd3` of `SendTCP` | `"gui-1a2b3c"` |
| `CommandA` | `commandA` | string | Business action name, parsed by the host from the `CommandA` field of `Data` | `"Ping"` |
| `Data` | `data` | string | The raw `Data` bytes handed to the plugin as a string (possibly a JSON string, possibly plain text) | `"{\"CommandA\":\"Ping\"}"` |

- **Snippet** (read values from the message to check the source and parse embedded JSON):

```go
func main() {
	ctl.Run(func(msg ctl.Msg) bool {
		// 1) 判断来源：只信任 GUI 发来的指令（0=GUI）
		if msg.Source != "0" {
			ctl.Log("忽略非 GUI 来源: " + msg.Source)
			return false
		}
		// 2) 回信目标：大多数回包场景用 msg.Command4
		replyTo := msg.Command4
		// 3) Data 常是 JSON 字符串，自行解出业务字段
		var body struct {
			Keyword string `json:"keyword"`
		}
		if err := json.Unmarshal([]byte(msg.Data), &body); err != nil {
			ctl.Log("Data 不是 JSON: " + msg.Data)
		}
		ctl.Log("关键词=" + body.Keyword + " 回给=" + replyTo)
		return false
	})
}
```

- **Caveats**:
  - `Source` is a **string** (`"0"`/`"1"`…), not a number — do not write `msg.Source == 0`.
  - `Data` is not guaranteed to be JSON; check for empty values and handle errors before calling `json.Unmarshal`.
  - One invocation handles exactly **one** message; `Msg` is never reused across messages.

#### `Subscription`

- **Purpose**: describes "which TCP messages I want to subscribe to"; appended by `Subscribe` and emitted by `Run` into `[INIT]` for host matching.
- **Signature**:

```go
type Subscription struct {
	Command1 string `json:"command1"`
	Command2 string `json:"command2"`
	CommandA string `json:"commandA"`
}
```

- **Field table**:

| Field | JSON key | Type | Meaning | Usage |
|---|---|---|---|---|
| `Command1` | `command1` | string | First-level command match | Empty = wildcard; non-empty must be **exactly equal** to `msg.Command1` |
| `Command2` | `command2` | string | Second-level command match | Empty = wildcard; non-empty must be exactly equal to `msg.Command2` |
| `CommandA` | `commandA` | string | Business action name match | Empty = wildcard; non-empty must be exactly equal to `msg.CommandA` |

- **Snippet** (three typical subscriptions; you normally do not need to write this struct by hand — just use `Subscribe`):

```go
func init() {
	ctl.Capability(ctl.CapTCPHandle)
	// 等价于追加 Subscription{Command1:"MainGui",Command2:"GetTaskList",CommandA:""}
	ctl.Subscribe("MainGui", "GetTaskList", "")
	// 三者全空 = 订阅所有消息
	ctl.Subscribe("", "", "")
}
```

- **Caveats**:
  - Subscribing to nothing → the plugin **matches no messages** (not all of them).
  - The three fields are combined with **AND** (when all are non-empty, all must match); multiple subscriptions are combined with **OR**.

#### `CapTCPHandle`

- **Purpose**: the only capability constant; declaring it is what says "this plugin handles TCP messages forwarded by the controller".
- **Signature**: `const CapTCPHandle = "tcp.handle"` (no parameters).
- **Returns**: the string constant `"tcp.handle"`.
- **Snippet**:

```go
func init() {
	ctl.Capability(ctl.CapTCPHandle) // 声明能力；漏写则永远收不到消息
}
```

- **Caveats**: capability is **strictly gated** — a plugin that does not declare it (and does not export `plugin_handle`) is never executed; a matching subscription is also required to receive messages.

### 7.2 Lifecycle and Declarations

These functions are called from the package's `init()` to set metadata, subscriptions, and capabilities.

#### `SetInfo(name, version string)`

- **Purpose**: set the name and version the plugin reports in `[INIT]`, so logs and GUI displays can identify it.
- **Signature**: `SetInfo(name, version string)`.
- **Parameter table**:

| Parameter | Type | What to pass | Where it comes from | Example |
|---|---|---|---|---|
| `name` | string | Plugin display name | Yours to define | `"任务统计面板"` |
| `version` | string | Version number | Yours to define | `"1.0.0"` |

- **Returns**: nothing.
- **Snippet**:

```go
func init() {
	ctl.SetInfo("任务统计面板", "1.0.0")
}
```

- **Caveats**: optional; defaults to `wasm-plugin` / `1.0.0`. Keep the name consistent with `languages.*.name` in `info.yaml`.

#### `Subscribe(command1, command2, commandA string)`

- **Purpose**: append one subscription rule, declaring "which messages should be forwarded to me".
- **Signature**: `Subscribe(command1, command2, commandA string)`.
- **Parameter table**:

| Parameter | Type | What to pass | Where it comes from | Example |
|---|---|---|---|---|
| `command1` | string | First-level command; empty = wildcard | Controller command name | `"MainGui"` |
| `command2` | string | Second-level command; empty = wildcard | Controller command name | `"GetTaskList"` |
| `commandA` | string | Business action name; empty = wildcard | Your own `CommandA` | `"Ping"` |

- **Returns**: nothing (appends; does not overwrite).
- **Snippet**:

```go
func init() {
	ctl.Subscribe("MainGui", "GetTaskList", "") // 命中 MainGui/GetTaskList 任意 CommandA
	ctl.Subscribe("", "", "Ping")               // 命中任意来源、CommandA=Ping
}
```

- **Caveats**: repeated calls **accumulate**; the all-empty rule subscribes to every message (heavy traffic, use with care); call it at least once.

#### `Capability(name string)`

- **Purpose**: declare that this plugin implements a public capability (such as `CapTCPHandle`) and thus passes capability gating.
- **Signature**: `Capability(name string)`.
- **Parameter table**:

| Parameter | Type | What to pass | Where it comes from | Example |
|---|---|---|---|---|
| `name` | string | Capability name | `ctl.CapTCPHandle` | `"tcp.handle"` |

- **Returns**: nothing (deduplicated by name internally; `name == ""` is ignored).
- **Snippet**:

```go
func init() {
	ctl.Capability(ctl.CapTCPHandle)
}
```

- **Caveats**: declaring the same capability repeatedly keeps only one entry; without `tcp.handle` the plugin is never invoked.

#### `Capabilities(names ...string)`

- **Purpose**: declare several capabilities at once; equivalent to calling `Capability` repeatedly.
- **Signature**: `Capabilities(names ...string)`.
- **Parameter table**:

| Parameter | Type | What to pass | Where it comes from | Example |
|---|---|---|---|---|
| `names` | `...string` | List of capabilities | Constants / strings | `ctl.CapTCPHandle` |

- **Returns**: nothing.
- **Snippet**:

```go
func init() {
	ctl.Capabilities(ctl.CapTCPHandle) // 未来新增能力点时在此追加
}
```

- **Caveats**: empty-string elements are ignored by `Capability`; when in doubt, declare only the capabilities you actually implement.

#### `CapabilityAll()`

- **Purpose**: declare **all** capabilities known to the current SDK (currently just `tcp.handle`), equivalent to the old "receive everything" behavior.
- **Signature**: `CapabilityAll()`.
- **Parameter table**: no parameters.
- **Returns**: nothing (internally calls `Capabilities(CapTCPHandle)`).
- **Snippet**:

```go
func init() {
	ctl.CapabilityAll() // 等价于 ctl.Capability(ctl.CapTCPHandle)
}
```

- **Caveats**: capabilities will grow as the ABI evolves; using this declares them all and may be triggered by future capabilities. **Declare only what you actually implement.**

#### `Run(fn func(msg Msg) (handled bool))`

- **Purpose**: drive the plugin's main loop: emit `[INIT]` → read the message from `stdin` → call the handler → emit `[RESP]`. Must be called in `main()`.
- **Signature**: `Run(fn func(msg Msg) (handled bool))`.
- **Parameter table**:

| Parameter | Type | What to pass | Where it comes from | Example |
|---|---|---|---|---|
| `fn` | `func(msg Msg) bool` | Message handler; return `true`=handled (skip built-ins), `false`=pass through | Your implementation | see below |

- **Returns**: nothing (internally writes `[RESP] {"handled":..,"error":".."}`).
- **Snippet**:

```go
func main() {
	ctl.Run(func(msg ctl.Msg) bool {
		if msg.CommandA == "Ping" {
			ctl.Log("收到 Ping")
			ctl.SendTCPJson("MainGui", "Pong", msg.Command4, "", "PongResult", map[string]any{"pong": true})
			return true // 已处理：控制器跳过内置处理
		}
		return false // 放行：控制器继续内置处理
	})
}
```

- **Caveats**:
  - Returning `true` from the handler **actually blocks** built-in handling of that message, so make sure you have fully replaced its functionality.
  - `Run` wraps the handler in `recover`: a plugin panic becomes `[RESP] {"handled":false,"error":"插件 panic: ..."}` and never crashes the controller.
  - If `stdin` is empty, `Run` emits `[INIT]` and returns immediately (**without `[RESP]`**); the host always writes at least one `{}`, so in practice this branch is never reached.

### 7.3 Host API

The functions below are SDK wrappers around `env.ctl_*` host functions; all of them are called inside the `Run` handler. Except for `Call`, their return values are **raw** strings/JSON.

#### `Log(msg string)`

- **Purpose**: write a debug message to the controller log; the first tool to reach for when troubleshooting a plugin.
- **Signature**: `Log(msg string)`.
- **Parameter table**:

| Parameter | Type | What to pass | Where it comes from | Example |
|---|---|---|---|---|
| `msg` | string | Any log text | You build it | `"收到消息: Ping"` |

- **Returns**: nothing. The host writes it to the controller log (visible in the GUI) with the prefix `WASM插件[<uuid>]: <msg>`.
- **Snippet**:

```go
ctl.Log("处理开始，CommandA=" + msg.CommandA + " 来源=" + msg.UUID)
```

- **Caveats**: logs go through `ctl_log` and reach the log the same way as stdout `[LOG]` lines; when logging heavily, truncate long text (`msg` should stay within a few hundred characters).

#### `SendTCP(cmd1, cmd2, cmd3, cmd4, data string)`

- **Purpose**: send a TCP payload to the GUI / scan nodes / other connections; `data` is sent as-is, which suits custom protocols or pre-built strings.
- **Signature**: `SendTCP(cmd1, cmd2, cmd3, cmd4, data string)`.
- **Parameter table**:

| Parameter | Type | What to pass | Where it comes from | Example |
|---|---|---|---|---|
| `cmd1` | string | First-level command | Target-side protocol contract | `"MainGui"` |
| `cmd2` | string | Second-level command | Target-side protocol contract | `"Pong"` |
| `cmd3` | string | Target: `gui` / `scan` / `all` / a concrete UUID | Use `msg.Command4` when replying | `msg.Command4` |
| `cmd4` | string | Reply UUID; empty means the current UUID is filled in | Usually `""` | `""` |
| `data` | string | Raw data string (not serialized) | You build it | `` `{"pong":true}` `` |

- **Returns**: nothing. The call is not bound to a concrete connection; routing is decided by `cmd3`.
- **Snippet**:

```go
// 回包给发来消息的 GUI：cmd3 用 msg.Command4
ctl.SendTCP("MainGui", "Pong", msg.Command4, "", `{"pong":true}`)
// 广播给所有 GUI
ctl.SendTCP("MainGui", "Notice", "gui", "", "server busy")
```

- **Caveats**: a wrong `cmd3` (for example, using `gui` instead of the reply UUID) means the reply never reaches its target; if you need `{CommandA,Data}` wrapping, use `SendTCPJson` instead.

#### `SendTCPJson(cmd1, cmd2, cmd3, cmd4, commandA string, data any)`

- **Purpose**: send TCP data automatically wrapped as `{CommandA, Data}`; the standard way to reply to the GUI.
- **Signature**: `SendTCPJson(cmd1, cmd2, cmd3, cmd4, commandA string, data any)`.
- **Parameter table**:

| Parameter | Type | What to pass | Where it comes from | Example |
|---|---|---|---|---|
| `cmd1` | string | First-level command | Target-side protocol contract | `"MainGui"` |
| `cmd2` | string | Second-level command | Target-side protocol contract | `"Pong"` |
| `cmd3` | string | Target route | Use `msg.Command4` when replying | `msg.Command4` |
| `cmd4` | string | Reply UUID | Usually `""` | `""` |
| `commandA` | string | Business action name (placed into `Data.CommandA`) | Yours to define | `"PongResult"` |
| `data` | `any` | A **value object** (map / struct / slice) | You build it | `map[string]any{"pong": true}` |

- **Returns**: nothing. The SDK runs `json.Marshal` on `data` once; the host then decodes it back into an object and calls `SendTcpDataJson`.
- **Snippet**:

```go
type Pong struct {
	Pong   bool   `json:"pong"`
	Plugin string `json:"plugin"`
}
ctl.SendTCPJson("MainGui", "Pong", msg.Command4, "", "PongResult", Pong{Pong: true, Plugin: "my-ping"})
```

- **Caveats**:
  - **Pass a struct / map / slice, never a `[]byte`** — a `[]byte` is encoded by `json.Marshal` into a base64 string (a real incident in this repository; see Section 10).
  - To send a raw string without serialization, use `SendTCP`.

#### `CommandHandler(command string, args ...any)`

- **Purpose**: trigger the controller's command callback entry point (such as `MsgUpdataConfig`), used to "tell the controller to do something" rather than to retrieve a response as `Command` does.
- **Signature**: `CommandHandler(command string, args ...any)`.
- **Parameter table**:

| Parameter | Type | What to pass | Where it comes from | Example |
|---|---|---|---|---|
| `command` | string | Callback name | Controller contract | `"MsgUpdataConfig"` |
| `args` | `...any` | Callback arguments | You build them | `map[string]any{"key": "v"}` |

- **Returns**: nothing (the SDK encodes `args` as a JSON array; the host decodes it into `[]any` and forwards it to the controller's callback entry point).
- **Snippet**:

```go
ctl.CommandHandler("MsgUpdataConfig", map[string]any{"reload": true})
```

- **Caveats**: `args` is **variadic** and is encoded as a single JSON array; its purpose differs from `Command` (which returns a response), so do not mix them up.

#### `TcpReceivedData(td string)`

- **Purpose**: re-inject a complete TCP payload into the controller's dispatch flow, letting a plugin "manufacture a message" on its own.
- **Signature**: `TcpReceivedData(td string)`.
- **Parameter table**:

| Parameter | Type | What to pass | Where it comes from | Example |
|---|---|---|---|---|
| `td` | string | A complete TCP data JSON (with first/second/third-level commands and the Data field) | You build it | `{"Command1Str":"MainGui","Command2Str":"X","Data":"{}"}` |

- **Returns**: nothing. The host parses this JSON into a complete TCP data item and sends it through dispatch again; re-injection deeper than **5 levels** is silently dropped.
- **Snippet**:

```go
ctl.TcpReceivedData(`{"Command1Str":"MainGui","Command2Str":"Refresh","Command4Str":"` + msg.Command4 + `","Data":"{}"}`)
```

- **Caveats**: re-injection **matches subscriptions again**, which easily forms a cycle; the host counts globally up to 5 levels and drops anything beyond that.

#### `TcpOnClientDisconnect(uuid string)`

- **Purpose**: simulate a client connection disconnecting, triggering the controller's disconnect cleanup logic (connection table cleanup, etc.).
- **Signature**: `TcpOnClientDisconnect(uuid string)`.
- **Parameter table**:

| Parameter | Type | What to pass | Where it comes from | Example |
|---|---|---|---|---|
| `uuid` | string | UUID of the connection to disconnect | `msg.UUID` / the result of `CopyTcpUUID` | `"gui-1a2b3c"` |

- **Returns**: nothing.
- **Snippet**:

```go
ctl.TcpOnClientDisconnect(msg.UUID)
```

- **Caveats**: this makes the controller believe the connection is gone; call it only when you genuinely need the cleanup.

#### `CopyTcpUUID() string`

- **Purpose**: obtain the TCP connection table currently maintained by the controller (**desensitized**, with no connection handles), for troubleshooting "who is online".
- **Signature**: `CopyTcpUUID() string`.
- **Parameter table**: no parameters.
- **Returns**: a JSON array string whose elements have the following fields:

| Field | Type | Meaning | Usage |
|---|---|---|---|
| `uuid` | string | Unique connection identifier | Pass to `TcpOnClientDisconnect` |
| `source` | string | Source (`0`/`1`/`2`/`3`) | Tell a GUI from a node |
| `nodeIp` | string | Peer address (IP:Port) | Display / locate a node |
| `connected` | bool | Whether it is online | Filter out offline connections |

- **Snippet**:

```go
var conns []map[string]any
_ = json.Unmarshal([]byte(ctl.CopyTcpUUID()), &conns)
for _, c := range conns {
	ctl.Log("连接 " + c["uuid"].(string) + " 来源=" + c["source"].(string))
}
```

- **Caveats**: the returned data is desensitized; you cannot obtain a real `net.Conn` handle from it, so it cannot be used to read from or write to a connection directly.

#### `StartProxyServer(addr, taskJSON string)`

- **Purpose**: start a passive proxy listener (multi-port) from a task configuration, to bring traffic into scanning.
- **Signature**: `StartProxyServer(addr, taskJSON string)`.
- **Parameter table**:

| Parameter | Type | What to pass | Where it comes from | Example |
|---|---|---|---|---|
| `addr` | string | Listen address | You build it | `"0.0.0.0:8080"` |
| `taskJSON` | string | Task configuration JSON (fields match the controller task configuration) | Adapted from the result of `Command("GetTaskDetails", ...)` | see below |

- **Returns**: nothing. The host parses `taskJSON` into a task configuration and then starts the passive proxy listener.
- **Snippet**:

```go
taskJSON := `{"taskIde":"task-001","taskName":"proxy-demo"}`
ctl.StartProxyServer("0.0.0.0:8080", taskJSON)
```

- **Caveats**: `taskJSON` must match the controller's task configuration fields; a failure to start only shows up in the log and is not returned to the plugin.

#### `StopProxyListener(taskIde string)`

- **Purpose**: stop the proxy listener of the specified task.
- **Signature**: `StopProxyListener(taskIde string)`.
- **Parameter table**:

| Parameter | Type | What to pass | Where it comes from | Example |
|---|---|---|---|---|
| `taskIde` | string | Task ID | Task configuration | `"task-001"` |

- **Returns**: nothing.
- **Snippet**:

```go
ctl.StopProxyListener("task-001")
```

- **Caveats**: a non-existent `taskIde` is silently ignored and does not raise an error.

#### `StopAllProxyListeners()`

- **Purpose**: stop all proxy listeners at once (cleanup / emergency stop).
- **Signature**: `StopAllProxyListeners()`.
- **Parameter table**: no parameters.
- **Returns**: nothing.
- **Snippet**:

```go
ctl.StopAllProxyListeners()
```

- **Caveats**: this affects every task and is a global operation; call it with care.

#### `EditWebTaskStatus(taskJSON string)`

- **Purpose**: change a task's status and **write it straight to the DB**.
- **Signature**: `EditWebTaskStatus(taskJSON string)`.
- **Parameter table**:

| Parameter | Type | What to pass | Where it comes from | Example |
|---|---|---|---|---|
| `taskJSON` | string | Task configuration JSON (fields match the controller task configuration) | You build it | `{"taskIde":"task-001","status":2}` |

- **Returns**: nothing (the host parses the task configuration, changes the task status, and writes to the DB).
- **Snippet**:

```go
ctl.EditWebTaskStatus(`{"taskIde":"task-001","taskName":"demo","status":2}`)
```

- **Caveats**: this is a **write operation** (it really changes the DB), so always validate `msg.Source` and `msg.CommandA` first to avoid being triggered by arbitrary messages.

#### `Command(name, argsJSON string) string`

- **Purpose**: invoke a controller **registered** business command and get back the **same** response JSON the GUI would receive; the main entry point for a plugin to orchestrate controller capabilities.
- **Signature**: `Command(name, argsJSON string) string`.
- **Parameter table**:

| Parameter | Type | What to pass | Where it comes from | Example |
|---|---|---|---|---|
| `name` | string | Command name (whitelist, see Section 8) | The list in Section 8 | `"GetTaskList"` |
| `argsJSON` | string | Argument JSON (identical to the `Data` the GUI sends) | You build it | `{"isDelete":false,"page":1,"pageSize":20}` |

- **Returns**: the **raw** response JSON string (no second encoding pass). On success: the response the handler writes to the GUI; for an operation with no response: `{"success":true}`; for an unregistered command: `{"success":false,"error":"未注入的控制器指令: xxx"}`.
- **Snippet**:

```go
res := ctl.Command("GetTaskList", `{"isDelete":false,"page":1,"pageSize":20}`)
// 先判断错误
if strings.Contains(res, `"success":false`) {
	ctl.Log("调用失败: " + res)
} else {
	var data map[string]any
	_ = json.Unmarshal([]byte(res), &data)
	ctl.Log("任务总数=" + ctlTextOr(ctl.JSONGet([]byte(res), "data.total")))
}
```

- **Caveats**:
  - Only commands from the Section 8 list can be called; anything unregistered returns `{"success":false,...}` (whitelist principle).
  - Write-type commands really modify the DB / push to nodes; by default use query commands only, and validate the source and action before any write.
  - The return value is **raw JSON**; do not decode it again with `ctlText` (that rule applies to builtin functions).

#### `Call(funcID uint32, argsJSON string) string`

- **Purpose**: the low-level unified entry point for calling builtin functions; the SDK's 29 convenience wrappers are all built on it.
- **Signature**: `Call(funcID uint32, argsJSON string) string`.
- **Parameter table**:

| Parameter | Type | What to pass | Where it comes from | Example |
|---|---|---|---|---|
| `funcID` | uint32 | Function ID (see Section 9) | The full table in Section 9 | `30` |
| `argsJSON` | string | JSON of the **argument array** (note: an array, not an object) | You build it | `[3,16]` |

- **Returns**: a **JSON-encoded string**. String results carry quotes (e.g. `"aGVsbG8="`); a function error returns an error object `{"error":"..."}`; an unknown ID returns `{"error":"未知的内置函数 ID"}`.
- **Snippet**:

```go
r := ctl.Call(30, `[3,16]`) // rand_str：小写+数字、长度 16
if s, ok := ctlText(r); ok {
	ctl.Log("随机串=" + s) // 例：sijjkjuc123
} else {
	ctl.Log("内置函数报错: " + r)
}
```

- **Caveats**: the arguments are an **array** (`[3,16]`); writing an object means the parameters cannot be read; the return value needs `ctlText` to decode into ordinary text.

#### `T(key string) string`

- **Purpose**: read this plugin's localization text for the current language, enabling multilingual UI text and logs.
- **Signature**: `T(key string) string`.
- **Parameter table**:

| Parameter | Type | What to pass | Where it comes from | Example |
|---|---|---|---|---|
| `key` | string | Language-pack key (**without** the language and plugin prefix) | A key you defined in the language pack | `"Pong.Title"` |

- **Returns**: **raw** text. The host looks it up under the key space `<language>.<pluginUUID>.<key>` (the language is the controller's current language, defaulting to `cn`); a missing entry falls back to `key` itself.
- **Snippet**:

```go
title := ctl.T("Pong.Title") // 中文环境取 cn.<uuid>.Pong.Title，缺失则返回 "Pong.Title"
ctl.Log(title)
```

- **Caveats**: pass only `key`; do **not** build the `<language>.<uuid>.` prefix yourself. A missing entry does not raise an error — it just falls back to the key.

#### `ConfigGet(key string) string`

- **Purpose**: read plugin configuration (the Key/Value pairs in the outer `<uuid>/plugin.config.json`).
- **Signature**: `ConfigGet(key string) string`.
- **Parameter table**:

| Parameter | Type | What to pass | Where it comes from | Example |
|---|---|---|---|---|
| `key` | string | Configuration key | `plugin.config.json` | `"apiKey"` |

- **Returns**: the **raw** configuration value string; **a missing key returns the empty string `""`**.
- **Snippet**:

```go
apiKey := ctl.ConfigGet("apiKey")
if apiKey == "" {
	ctl.Log("未配置 apiKey，跳过")
	return false
}
```

- **Caveats**: a missing key is an **empty string**, not an error, so an emptiness check is the "not configured" check; when the configuration file is absent, every key is an empty string.

#### `SetTimeout(ms int64)`

- **Purpose**: extend or adjust the timeout of **this execution**, so long tasks are not cut off by the watchdog.
- **Signature**: `SetTimeout(ms int64)`.
- **Parameter table**:

| Parameter | Type | What to pass | Where it comes from | Example |
|---|---|---|---|---|
| `ms` | int64 | Timeout in milliseconds | Your estimate | `120000` (2 minutes) |

- **Returns**: nothing (it also resets the host watchdog timer; `ms<=0` is ignored, and anything above 10 minutes is clamped to 10 minutes).
- **Snippet**:

```go
ctl.SetTimeout(120000) // 本次最多执行 2 分钟
heavyWork()
```

- **Caveats**: the timeout is per **single invocation**; anything longer than 10 minutes is still cut off by the watchdog and marked `poisoned`, so large tasks should be split across several messages.

### 7.4 Builtin Function Convenience Wrappers

The SDK wraps commonly used builtin functions into 29 Go functions whose signatures map one-to-one to `funcID` (the full table is in Section 9). **They all return JSON-encoded strings** — use the `ctlText` helper from this section to decode the real text (the exception is `TimeToTimestamp`; see its own section).

**Encoding conversions (9)**

#### `Base64Encode(data string)`

- Purpose: Base64 encoding, to squeeze non-ASCII text or binary data into ASCII-only fields. Parameter `data` is the string to encode. Returns a JSON-encoded string (`"aGVsbG8="`).
```go
enc, _ := ctlText(ctl.Base64Encode("hello")) // aGVsbG8=
```
- Note: concatenating the result directly leaves the quotes in place; always run it through `ctlText` first.

#### `Base64Decode(data string)`

- Purpose: Base64 decoding. Parameter `data` is Base64 text. Returns a JSON-encoded string (the original bytes as text).
```go
raw, ok := ctlText(ctl.Base64Decode("aGVsbG8=")) // ok=true, raw=hello
```
- Note: invalid Base64 takes the error branch, so `ok=false`; treat that as a failure.

#### `HexEncode(data string)`

- Purpose: convert a byte string to hexadecimal (no spaces). Parameter `data` is the original string. Returns a JSON-encoded string.
```go
h, _ := ctlText(ctl.HexEncode("hi")) // 6869
```
- Note: `HexDecode` is its inverse; use them as a pair.

#### `HexDecode(hex string)`

- Purpose: convert hexadecimal to a byte string. Parameter `hex` is hexadecimal text. Returns a JSON-encoded string.
```go
b, _ := ctlText(ctl.HexDecode("68656c6c6f")) // hello
```
- Note: an odd length or an invalid character raises an error (`ctlText` returns false).

#### `URLEncode(input string)`

- Purpose: URL encoding. Parameter `input` is the text to encode. Returns a JSON-encoded string.
```go
u, _ := ctlText(ctl.URLEncode("a b")) // a+b
```
- Note: spaces become `+`; replace them yourself when you need the `%20` style.

#### `URLDecode(input string)`

- Purpose: URL decoding. Parameter `input` is already-encoded text. Returns a JSON-encoded string.
```go
u, _ := ctlText(ctl.URLDecode("a%20b")) // a b
```
- Note: an invalid `%` sequence raises an error; check `ok` before using the value.

#### `HTMLEncode(data string)`

- Purpose: HTML entity encoding (for example `<`→`&lt;`). Parameter `data` is the original text. Returns a JSON-encoded string.
```go
h, _ := ctlText(ctl.HTMLEncode("<a>")) // &lt;a&gt;
```
- Note: `&`/`<` in the result are escaped by JSON into `\u0026` and the like; `ctlText` restores them automatically.

#### `HTMLDecode(data string)`

- Purpose: HTML entity decoding. Parameter `data` is entity text. Returns a JSON-encoded string.
```go
h, _ := ctlText(ctl.HTMLDecode("&lt;a&gt;")) // <a>
```
- Note: the counterpart of `HTMLEncode`; it only decodes entities and does not parse tag structure.

#### `CharsetConvert(data, targetEncoding string, autoDetect bool)`

- Purpose: character encoding conversion (for example GBK→UTF-8), to handle mojibake response bodies. Parameter `data` is the original byte string, `targetEncoding` the target encoding (such as `"utf-8"`), and `autoDetect` whether to detect the source encoding automatically. Returns a JSON-encoded string.
```go
s, ok := ctlText(ctl.CharsetConvert(gbkString, "utf-8", true))
```
- Note: a failed detection or an unsupported encoding name raises an error; the source-encoding guess is only skipped when `autoDetect=true`.

**Hashing and checksums (8)**

#### `MD5(data string)`

- Purpose: MD5 checksum, for deduplication keys/fingerprints. Parameter `data` is the original string. Returns a JSON-encoded string (lowercase hexadecimal).
```go
v, _ := ctlText(ctl.MD5("hello")) // 5d41402abc4b2a76b9719d911017c592
```
- Note: MD5 is not secure; do not use it for signing or passwords.

#### `SHA1(data string)`

- Purpose: SHA1 checksum. Parameter `data` is the original string. Returns a JSON-encoded string (lowercase hexadecimal).
```go
v, _ := ctlText(ctl.SHA1("hello")) // aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d
```
- Note: like MD5, it is not recommended for security-sensitive use.

#### `SHA224(data string)`

- Purpose: SHA224 checksum. Parameter `data` is the original string. Returns a JSON-encoded string (lowercase hexadecimal).
```go
v, _ := ctlText(ctl.SHA224("hello"))
```
- Note: its output length differs from SHA256 (56 characters); do not compare one against the other.

#### `SHA256(data string)`

- Purpose: SHA256 checksum (recommended). Parameter `data` is the original string. Returns a JSON-encoded string (lowercase hexadecimal).
```go
v, _ := ctlText(ctl.SHA256("hello")) // 2cf24dba...938b9824
```
- Note: the same input always gives the same output, which makes it suitable for content fingerprints.

#### `SHA384(data string)`

- Purpose: SHA384 checksum. Parameter `data` is the original string. Returns a JSON-encoded string (lowercase hexadecimal).
```go
v, _ := ctlText(ctl.SHA384("hello"))
```
- Note: part of the SHA-2 family, with a length of 96 characters.

#### `SHA512(data string)`

- Purpose: SHA512 checksum. Parameter `data` is the original string. Returns a JSON-encoded string (lowercase hexadecimal).
```go
v, _ := ctlText(ctl.SHA512("hello"))
```
- Note: 128 characters long, so the output is fairly large.

#### `CRC32(data string)`

- Purpose: CRC32 checksum (fast, non-cryptographic). Parameter `data` is the original string. Returns a JSON-encoded string (hexadecimal).
```go
v, _ := ctlText(ctl.CRC32("hello")) // 3610a686
```
- Note: use it only for checksums/deduplication, never as a security hash.

#### `CRC64(data string)`

- Purpose: CRC64 checksum. Parameter `data` is the original string. Returns a JSON-encoded string (hexadecimal).
```go
v, _ := ctlText(ctl.CRC64("hello"))
```
- Note: likewise non-cryptographic.

**Random and string slicing (4)**

#### `RandStr(mode, length int)`

- Purpose: generate a random string for tokens/placeholders. Parameter `mode` is a bitmask (1 lowercase, 2 digits, 4 uppercase, 8 special; combinable), `length` the length. Returns a JSON-encoded string.
```go
tok, _ := ctlText(ctl.RandStr(3, 16)) // 小写+数字，长度 16，如 sijjkjuc12345678
```
- Note: `mode` is combined with **bitwise OR** (`3` = lowercase + digits); `mode=0` may yield no characters at all.

#### `LeftOf(s, sep string)`

- Purpose: take the content to the **left** of a separator. Parameter `s` is the source text, `sep` the separator. Returns a JSON-encoded string.
```go
v, _ := ctlText(ctl.LeftOf("aa.bb", ".")) // aa
```
- Note: the separator itself is excluded; the behavior when the separator is missing follows the actual return value.

#### `RightOf(s, sep string)`

- Purpose: take the content to the **right** of a separator. Parameter `s` is the source text, `sep` the separator. Returns a JSON-encoded string.
```go
v, _ := ctlText(ctl.RightOf("aa.bb", ".")) // bb
```
- Note: with several separators it usually returns everything after the first one.

#### `MiddleOf(s, left, right string)`

- Purpose: take the content **between** a left and a right marker; handy for extracting fields from HTML or protocol messages. Parameter `s` is the source text, `left` the left marker, `right` the right marker. Returns a JSON-encoded string.
```go
v, _ := ctlText(ctl.MiddleOf("aa<b>cc", "<", ">")) // b
```
- Note: the markers must exist as a pair in occurrence order, otherwise an empty string may be returned.

**JSON and time (4)**

#### `JSONGet(data []byte, path string)`

- Purpose: read a JSON **string value** by path; the most common way to pull fields out of a command response. Parameter `data` is the raw JSON bytes, `path` the path (such as `a.b[0].c`). Returns a JSON-encoded string (the value itself).
```go
res := ctl.Command("GetTaskList", `{"isDelete":false}`)
total, _ := ctlText(ctl.JSONGet([]byte(res), "data.total")) // 取出字符串形式的值
```
- Note: a wrong or non-existent path raises an error or returns empty; print `res` with `ctl.Log(res)` first to confirm the structure.

#### `JSONGetRaw(data []byte, path string)`

- Purpose: read a JSON **raw value** by path (arrays/objects keep their structure). Parameter `data` is the JSON bytes, `path` the path. Returns a JSON-encoded string (needs two decoding steps).
```go
raw, _ := ctlText(ctl.JSONGetRaw([]byte(res), "data.list")) // 得到 "[{...},{...}]" 文本
var list []map[string]any
_ = json.Unmarshal([]byte(raw), &list) // 再解一次拿结构化列表
```
- Note: the return value is the raw JSON treated as a string and then JSON-encoded again, so you must **run `ctlText` and then `json.Unmarshal` — two decoding steps**.

#### `TimeToTimestamp(timeStr string, isMilli bool) int64`

- Purpose: convert a time string to a timestamp. Parameter `timeStr` is the time text, `isMilli` whether milliseconds. **Returns an int64**.
```go
// 注意：当前实现里该函数恒返回 0（SDK 把带引号的 JSON 字符串直接按整数反序列化会失败）。
// 需要时间戳请走底层 Call(60) 再自行解析：
r, _ := ctlText(ctl.Call(60, `["2026-10-05 12:00:00",false]`)) // "1791201600"
ts, _ := strconv.ParseInt(r, 10, 64)
```
- Note: this is a known issue in the current SDK; prefer the `Call(60)` form shown above. `isMilli` decides seconds vs. milliseconds.

#### `TimestampToStr(ts int64, isMilli bool)`

- Purpose: convert a timestamp to a time string. Parameter `ts` is the timestamp, `isMilli` whether milliseconds. Returns a JSON-encoded string.
```go
v, _ := ctlText(ctl.TimestampToStr(1696512000, false)) // 2023-10-05 21:20:00
```
- Note: `isMilli` must match the unit of `ts`.

**Other (4)**

#### `UUID(prefix string)`

- Purpose: generate a unique ID with a prefix, for temporary identifiers. Parameter `prefix` is the prefix string (may be `""`). Returns a JSON-encoded string.
```go
id, _ := ctlText(ctl.UUID("ping-")) // ping-8003d72b-45a5-4b9f-9185-8884e0ad5d33
```
- Note: the prefix is concatenated directly, so do not add quotes; the generated value is a random UUID.

#### `NowStr()`

- Purpose: get the current time string `2006-01-02 15:04:05`. Parameters: none. Returns a JSON-encoded string.
```go
now, _ := ctlText(ctl.NowStr()) // 2026-10-05 14:04:50
```
- Note: the time is the local time of the machine hosting the controller.

#### `YAMLToJSON(y string)`

- Purpose: convert YAML text to JSON, for handling configuration/templates. Parameter `y` is YAML text. Returns a JSON-encoded string (its content is the escaped JSON text).
```go
j, _ := ctlText(ctl.YAMLToJSON("name: x")) // {"name":"x"}
```
- Note: only after `ctlText` strips the quotes and unescapes it do you get readable JSON.

#### `JSONToYAML(j string)`

- Purpose: convert JSON text to YAML. Parameter `j` is JSON text. Returns a JSON-encoded string.
```go
y, ok := ctlText(ctl.JSONToYAML(`{"name":"x"}`))
```
- Note: the input must be valid JSON, otherwise `ok=false`.

> [!NOTE]
> A few builtin functions **have no convenience wrapper** and must be called directly with `Call`: the 1–8 type conversions such as `to_str`(1), `text_between`(43), `csv_to_line`(44), `csv_clean`(45), and `format_time`(62). For example:

```go
r := ctl.Call(43, `["aaSTARTmidENDbb","START","END",0,"",false]`)
s, _ := ctlText(r) // {"pos":13,"text":"mid"}（已去引号，需再 json.Unmarshal 取字段）
if s != "" {
	var tb struct {
		Pos  int    `json:"pos"`
		Text string `json:"text"`
	}
	_ = json.Unmarshal([]byte(s), &tb)
	ctl.Log(fmt.Sprintf("命中位置=%d 文本=%s", tb.Pos, tb.Text))
}
```

## 8. List of Controller Business Commands Callable from Command

`Command(name, argsJSON)` can invoke only commands that the controller has **registered** (a whitelist; the complete list is the table below). Arguments are always JSON strings, and the paging fields `page` / `pageSize` are optional. The table is organized by category; entries marked **⚠** are operation-type commands with side effects (writing the DB / writing files / pushing to nodes), so plugin authors must be careful.

### 8.1 Task Management

| Command | Arguments | Description |
|---|---|---|
| `GetTaskList` | `{"isDelete":false,"page":1,"pageSize":20,"search":""}` | Task list (`isDelete=false` for not deleted / `true` for deleted) |
| `GetTaskDetails` | `{"taskIde":"task-xxx"}` | Task configuration |
| `GetTaskScanResult` | `{"taskIde":"task-xxx"}` | Array of all findings for that task |
| `SaveTask` ⚠ | `{"startTask":true,"taskIde":"..","taskName":"..",...}` | Save / start a task |
| `ChangeTaskStatus` ⚠ | `{"taskIde":"..",...}` | Change task status |
| `SoftDeleteTasks` ⚠ | list of taskIde | Soft-delete tasks |
| `DeleteTasks` ⚠ | list of taskIde | Permanently delete tasks |
| `RecoverTasks` ⚠ | list of taskIde | Recover deleted tasks |

```go
// 查询任务列表，并读取返回 JSON 的字段
res := ctl.Command("GetTaskList", `{"isDelete":false,"page":1,"pageSize":20}`)
ctl.Log("任务列表: " + res)
if total, ok := ctlText(ctl.JSONGet([]byte(res), "data.total")); ok {
	ctl.Log("总数=" + total)
}
```

### 8.2 Vulnerability Management

| Command | Arguments | Description |
|---|---|---|
| `GetVulnList` | `{"templateId":"..","searchKey":"..","page":1,"pageSize":20}` | Vulnerability list (vulnerability management page) |
| `NewWebTaskGetVulnList` | same as above | Vulnerability list (new task page) |
| `AddVuln` ⚠ | vulnerability form fields | Add a vulnerability (writes the poc file and reloads) |
| `UpdateVuln` ⚠ | vulnerability form fields | Edit a vulnerability |
| `UpdateVulnRank` ⚠ | `{"vulnHash":"..","rank":"4","operator":"..","remark":".."}` | Change vulnerability severity (global state + history) |
| `GetVulnHistories` | `{"vulnHash":".."}` | Severity change history for a vulnerability |
| `GetPackDetails` | `{"taskIde":"..","vulnHash":".."}` | Vulnerability packet details |
| `VerifyVuln` ⚠ | `{"taskIde":"..","vulnHash":"..", ...}` | Verify a vulnerability (forwarded to a scan node for retest) |

```go
res := ctl.Command("GetVulnList", `{"searchKey":"sql","page":1,"pageSize":20}`)
// 若响应是数组/对象，用 JSONGetRaw 取列表再二次解析
if raw, ok := ctlText(ctl.JSONGetRaw([]byte(res), "data.list")); ok {
	var list []map[string]any
	_ = json.Unmarshal([]byte(raw), &list)
	ctl.Log(fmt.Sprintf("命中 %d 条漏洞", len(list)))
}
```

### 8.3 Template Management

| Command | Arguments | Description |
|---|---|---|
| `GetVulnTemplateList` | `{"page":1,"pageSize":20}` | Vulnerability template list |
| `newWebTaskGetVulnTemplateList` | same as above | Vulnerability template list (new task page) |
| `AddVulnToTemplate` ⚠ | VulnTemplate | Add a vulnerability template |
| `EditVulnToTemplate` ⚠ | VulnTemplate | Edit a vulnerability template |
| `delVulnTemplate` ⚠ | `{"templateIde":".."}` | Delete a vulnerability template |

```go
res := ctl.Command("GetVulnTemplateList", `{"page":1,"pageSize":20}`)
ctl.Log("模板列表: " + res)
```

### 8.4 Path Scanning

| Command | Arguments | Description |
|---|---|---|
| `GetPathScanTemplateList` | `{"page":1,"pageSize":20}` | Path scan template list |
| `SavePathScanTemplate` ⚠ | template object | Save a path scan template |
| `SearchGetDirectoryDictList` | `{"keyword":"..","page":1,"pageSize":20}` | Search directory dictionaries |
| `DelDictionaryPaths` ⚠ | `{"paths":[...]}` | Delete dictionary paths |
| `SaveImportedDirectoryDict` ⚠ | dictionary object | Import a dictionary |

```go
res := ctl.Command("SearchGetDirectoryDictList", `{"keyword":"admin","page":1,"pageSize":20}`)
ctl.Log("字典搜索结果: " + res)
```

### 8.5 Hook Rules

| Command | Arguments | Description |
|---|---|---|
| `GetHookRulesList` | `{"page":1,"pageSize":20}` | Hook rule list |
| `UpdateHookList` ⚠ | Hook rule object | Update Hook rules (pushed to scan nodes) |
| `GetVulnKeyword` | `{"keyword":".."}` | Quick Hook keyword search |
| `GetTaskHookTrafficList` | `{"taskIde":".."}` | Hook traffic list for a task |
| `GetAiHookToolList` | — | List of hookable AI tools (dropdown candidates) |

```go
res := ctl.Command("GetHookRulesList", `{"page":1,"pageSize":20}`)
ctl.Log("Hook 规则: " + res)
```

### 8.6 System / Configuration / Market / Plugins

| Command | Arguments | Description |
|---|---|---|
| `GetGuiConfig` | `{"uuid":".."}` | Get GUI configuration (including the scan node list) |
| `setGuiConfig` ⚠ | GuiConfig | Save configuration |
| `GetPluginMarketList` | `{"page":1,"pageSize":20,"keyword":"..","all":false}` | Plugin market list (`all=true` for the full list) |
| `GetPluginLocalList` | — | List of plugin UUIDs already downloaded locally by the controller |
| `GetPluginDetail` | `{"uuid":"..","target":"gui或controller或scan"}` | Read source files from the plugin's target directory |
| `SavePluginDetail` ⚠ | `{"uuid":"..","target":"..","content":".."}` | Save plugin source |
| `DeletePluginFile` ⚠ | `{"uuid":"..","target":"..","file":".."}` | Delete a plugin file |
| `SavePluginConfig` ⚠ | `{"uuid":"..","config":{...}}` | Save plugin configuration and push it to scan nodes |
| `GetPocMarketList` | `{"page":1,"pageSize":20,"keyword":".."}` | POC store list |
| `SyncMarketHashes` ⚠ | — | Manually refresh the market hash comparison |
| `GetMarketCompareResult` | `{"taskIde":".."}` | Local POC market comparison result |
| `DownloadPocMarket` ⚠ | `{"uuids":[...]}` | Download authorized POCs locally |
| `SharePocToMarket` ⚠ | `{"uuids":[...]}` | Share POCs to the market for review |
| `ReloadWasmPlugins` ⚠ | — | Reload local WASM plugins (controller only) |

```go
// 列出控制器本地已下载插件，用于确认某个插件是否在本地
res := ctl.Command("GetPluginLocalList", `{}`)
ctl.Log("本地插件: " + res)
```

> [!CAUTION]
> Operation-type commands **really change controller state**: `SaveTask` / `DeleteTasks` modify tasks, `AddVuln` / `UpdateVulnRank` modify the vulnerability database, and `UpdateHookList` / `SavePluginConfig` **push to scan nodes**. When writing a plugin you should use query commands only by default; when a write is genuinely required, always validate the source and `CommandA` in `msg` first so that arbitrary messages cannot trigger it.

## 9. Full Table of Builtin Function IDs

The first argument of `Call(funcID, argsJSON)` is the function ID and the second is the **JSON of the argument array**. The SDK convenience wrappers map one-to-one to the IDs below (the controller extends the scan node's set with IDs ≥ 70). **The "Returns" column gives the semantic value of the builtin function; when you retrieve it through an SDK convenience wrapper you get its JSON-encoded string (see 7.4).**

| ID | Function | Arguments (array element order) | Return semantics |
|---|---|---|---|
| 1 | `to_str` | value | Any value to string |
| 2 | `to_int` | value | Any value to integer |
| 3 | `to_int64` | value | Any value to int64 |
| 4 | `to_uint32` | value | Any value to uint32 |
| 5 | `to_uint64` | value | Any value to uint64 |
| 6 | `to_float` | value | Any value to floating point |
| 7 | `to_bool` | value | Any value to boolean |
| 8 | `to_bytes` | value | Any value to a byte string (returned as a string) |
| 10 | `base64_encode` | data | Base64 encoding |
| 11 | `base64_decode` | data | Base64 decoding |
| 12 | `bytes_to_hex` | data | Bytes to hexadecimal (no spaces) |
| 13 | `hex_to_bytes` | hex | Hexadecimal to byte string |
| 14 | `url_encode` | input | URL encoding |
| 15 | `url_decode` | input | URL decoding |
| 16 | `html_encode` | data | HTML entity encoding |
| 17 | `html_decode` | data | HTML entity decoding |
| 18 | `charset_convert` | data, targetEncoding, autoDetect | Character encoding conversion (e.g. GBK→UTF-8) |
| 20 | `md5` | data | MD5 hexadecimal string |
| 21 | `sha1` | data | SHA1 hexadecimal string |
| 22 | `sha224` | data | SHA224 hexadecimal string |
| 23 | `sha256` | data | SHA256 hexadecimal string |
| 24 | `sha384` | data | SHA384 hexadecimal string |
| 25 | `sha512` | data | SHA512 hexadecimal string |
| 26 | `crc32` | data | CRC32 hexadecimal string |
| 27 | `crc64` | data | CRC64 hexadecimal string |
| 30 | `rand_str` | mode (1 lowercase, 2 digits, 4 uppercase, 8 special; combinable), length | Random string |
| 40 | `left_of` | s, keyWord | Content to the left of the separator |
| 41 | `right_of` | s, keyWord | Content to the right of the separator |
| 42 | `middle_of` | s, left, right | Content between two markers |
| 43 | `text_between` | source, start, end, startPosition, offset, fallbackToSource | `{"pos":n,"text":""}` |
| 44 | `csv_to_line` | fields, lineBreak | Convert a string slice into a single CSV line |
| 45 | `csv_clean` | fields | Clean a string slice (JSON array) |
| 50 | `json_get` | jsonData, path | Read a JSON value by path (string) |
| 51 | `json_get_raw` | jsonData, path | Read a raw JSON value by path |
| 60 | `timestamp` | timeStr, isMilli | Time to timestamp |
| 61 | `timestamp_str` | ts, isMilli | Timestamp to time string |
| 62 | `format_time` | now, param | Time offset calculation |
| 70 | `uuid` | prefix | Prefixed UUID (controller extension) |
| 71 | `now_str` | — | Current time `2006-01-02 15:04:05` (controller extension) |
| 72 | `yaml_to_json` | yaml | YAML text to JSON (controller extension) |
| 73 | `json_to_yaml` | json | JSON text to YAML (controller extension) |

> [!NOTE]
> The arguments of `Call` are an **array**, for example `ctl.Call(30, `[3, 16]`)` (lowercase + digits, length 16), `ctl.Call(70, `["task-"]`)`. An unknown ID returns `{"error":"未知的内置函数 ID"}`; a function error returns `{"error":"..."}`.

**Combined example 1: read a response JSON field + Base64-decode it**

```go
res := ctl.Command("GetTaskList", `{"isDelete":false}`)          // 原始响应 JSON
b64, _ := ctlText(ctl.JSONGet([]byte(res), "data.rawBody"))       // 取出 base64 字段
body, _ := ctlText(ctl.Base64Decode(b64))                         // 解码成明文
ctl.Log("正文=" + body)
```

**Combined example 2: timestamp round trip**

```go
// 时间字符串 → 时间戳（用 Call(60)，因为 TimeToTimestamp 当前恒为 0）
r, _ := ctlText(ctl.Call(60, `["2026-10-05 12:00:00",false]`))
ts, _ := strconv.ParseInt(r, 10, 64)
// 时间戳 → 时间字符串
back, _ := ctlText(ctl.TimestampToStr(ts, false))                 // 2026-10-05 12:00:00
ctl.Log(fmt.Sprintf("ts=%d back=%s", ts, back))
```

**Combined example 3: extract text with text_between + MD5 fingerprint**

```go
r, _ := ctlText(ctl.Call(43, `["aaSTARTmidENDbb","START","END",0,"",false]`))
var tb struct {
	Pos  int    `json:"pos"`
	Text string `json:"text"`
}
_ = json.Unmarshal([]byte(r), &tb)
fp, _ := ctlText(ctl.MD5(tb.Text))
ctl.Log(fmt.Sprintf("文本=%s 指纹=%s", tb.Text, fp))
```

## 10. Argument and IPC Precautions

### 10.1 cmd3 Routing in SendTCP

The `conn` of `SendTCP` / `SendTCPJson` is always `nil`; the target is decided by **`cmd3`**:

| cmd3 value | Routing target |
|---|---|
| `gui` | All GUIs |
| `scan` | All scan nodes |
| `all` | Everything (GUI + scan nodes + …) |
| Any other value | The UUID of a specific node / connection |

When replying to the GUI that sent the message, the convention is to put the message's `msg.Command4` (the reply UUID) into `cmd3`; for a broadcast, use `gui`.

### 10.2 Pass Structs, Do Not json.Marshal Them into []byte Yourself

The `data any` parameter of `SendTCPJson` is run through `json.Marshal` once inside the SDK. **The correct approach is to pass a struct / map / slice directly**:

```go
// 正确：传 map，SDK 编成 {"md5":"...","ok":true}
ctl.SendTCPJson("MainGui", "Result", msg.Command4, "", "EncodeResult",
    map[string]any{"md5": ctl.MD5("hello"), "ok": true})

// 错误：传 []byte，json.Marshal 会把字节切片编成 base64 字符串
// ctl.SendTCPJson(..., "EncodeResult", []byte(`{"ok":true}`))
```

> [!WARNING]
> This is a pitfall this repository has actually hit: **handing an already `json.Marshal`-ed `[]byte` to an interface that serializes automatically encodes the outer layer as a base64 string**, so the GUI receives gibberish base64 instead of a JSON object. The rule: when you need `{CommandA,Data}` wrapping, use `SendTCPJson` and pass a **value object**; when you need to send a raw string (no serialization), use `SendTCP`.

### 10.3 Re-injection Depth

`TcpReceivedData` re-injects the message into controller dispatch. The host keeps a **global re-injection depth counter, capped at 5 levels**: plugin A re-injects while handling a message → another plugin matches → re-injects again… anything beyond 5 levels is dropped outright, preventing plugins from triggering each other in an endless loop.

### 10.4 Two Return Formats — Do Not Mix Them Up

This is where this SDK goes wrong most easily, so remember it well:

| Call | Return format | Usage |
|---|---|---|
| `Command` / `T` / `ConfigGet` / `CopyTcpUUID` | **Raw** text / JSON | Use directly; the `Command` result can be passed straight to `json.Unmarshal` |
| `Call` and the 29 builtin convenience wrappers | **JSON-encoded string** (quoted/escaped) | Decode to plain text with `ctlText` first (see 7.4) |
| Builtin function error / unknown ID | Error object `{"error":"..."}` | `ctlText` returns `false`; use that to detect failure |

## 11. Timeouts and Stability

The controller applies several layers of protection to plugin execution, so **a hung plugin never blocks the controller's main process or the handling of other messages**:

- **Default execution timeout: 1 minute**; a plugin can extend it during handling with `SetTimeout(ms)`, up to a **hard cap of 10 minutes** (anything beyond is clamped to 10 minutes); `ms<=0` is ignored.
- **watchdog**: the whole `_start` call runs in a separate goroutine and is timed by a host timer; on timeout an error is returned immediately. `SetTimeout` resets that timer.
- **poisoned auto-rebuild**: after a timeout the plugin's runtime is marked `poisoned` and is **rebuilt automatically on the next invocation** (recompiled + new instance), so one hang does not permanently drag down subsequent messages.
- **wazero hard-cap context**: on top of the watchdog, another context with `maxTimeout` is layered on to prevent a plugin from occupying resources forever.
- **panic / trap fallback**: `recover` inside `Run`, and the host's invoking goroutine also has `recover`. A plugin panic affects only that invocation.
- **Loading/compilation is timeout-protected too**: module compilation runs under a context with a timeout, so a malicious or malformed wasm cannot stall the load flow.

> [!IMPORTANT]
> The timeout applies per **single invocation**. If a plugin needs to work for a long time (for example, aggregating several batches of queries), call `SetTimeout` periodically inside the handler, or split the large task across several TCP messages — a single invocation that exceeds 10 minutes is always cut off by the watchdog and its runtime rebuilt.

## 12. Debugging Handbook

### 12.1 Where to Find the Logs

- A plugin's `Log(...)` and stdout `[LOG]` lines both enter the controller log with the prefix **`WASM插件[<uuid>]: <内容>`** (language-pack key `Ctl.Log.WasmPlugin-04`) and are visible in the GUI's controller log panel;
- The `error` returned by a plugin (the error field in `[RESP]`) is recorded as **`WASM插件[<uuid>] 返回错误: <error>`** (`Ctl.Log.WasmPlugin-05`);
- Load/invocation related: probe failure `Ctl.Log.WasmPlugin-01`, skipped for an undeclared capability `Ctl.Log.WasmPlugin-02`, execution failure `Ctl.Log.WasmPlugin-03`.

### 12.2 Five-Minute Minimal Verification Flow

1. **Build**: `GOOS=wasip1 GOARCH=wasm CGO_ENABLED=0 go build -o controller.wasm .`
2. **Deploy**: copy it to `CtlConfig/plugins/my-ping/Plugin/Controller/my-ping/build/controller.wasm`, together with `info.yaml`.
3. **One-click sign**: right-click the plugin on the GUI "My Plugins" page → "One-Click Sign" (writes `sig.json` and reloads automatically).
4. **Reload**: if it was not loaded automatically, send the `ReloadWasmPlugins` command.
5. **Send a command**: from the GUI, send a command that matches the subscription (such as `CommandA=Ping`).
6. **Check the log**: the controller log should show `WASM插件[<uuid>]: 收到消息...`; at the same time the GUI should receive the reply you sent with `SendTCP`.

### 12.3 Symptom → Possible Cause → Fix

| Symptom | Possible cause | Fix |
|---|---|---|
| The code looks fine but no message ever arrives | The `tcp.handle` capability is not declared | `Capability(ctl.CapTCPHandle)` or export `plugin_handle` |
| A subscription matches but nothing triggers | `Subscribe` was never called (the subscription list is empty) | Call `Subscribe` at least once; to receive everything use `Subscribe("","","")` |
| Only some messages arrive | A subscription field has the wrong case/spelling | Use the controller's actual command names for `command1`/`command2`; `commandA` corresponds to `Data.CommandA` |
| The plugin never loads, only log lines appear | Not signed / signature verification failed | Use the GUI one-click sign (`SignPluginLocal`) or generate `sig.json` as described in Section 2 |
| The log reports "编译 wasm 模块失败" (failed to compile the wasm module) | Wrong cross-compilation flags / SDK version mismatch | Use `GOOS=wasip1 GOARCH=wasm`; make sure `import "ctl"` and the replace directive point at the SDK |
| Execution times out and then recovers | A single execution exceeded 1 minute and was cut off by the watchdog, leaving the runtime `poisoned` | Call `SetTimeout` inline, or split the work across messages (≤10 minutes) |
| The reply arrives but built-in handling no longer takes effect | The handler mistakenly returns `true` | Only `return true` when you fully take over; return `false` for side-channel observation |
| The reply never reaches its target | The `cmd3` route of `SendTCP` is wrong | Put `msg.Command4` in for a GUI reply, `gui` for a broadcast |
| The GUI receives gibberish base64 | A `[]byte` was passed to `SendTCPJson` | Pass a struct/map; use `SendTCP` to send a raw string |
| Logic triggers itself and floods the log | `TcpReceivedData` forms a cycle; anything beyond 5 levels is dropped | Tighten the re-injection trigger conditions so subscriptions and re-injections do not match each other |
| `ConfigGet` returns nothing | The key does not exist (an empty string is returned) or `plugin.config.json` is missing | Handle the empty string; make sure the configuration is in the outer `<uuid>/plugin.config.json` |
| A builtin function returns a quoted value | `Call`/the wrappers return a JSON-encoded string | Decode it to plain text with `ctlText` (see 7.4) |
| The timestamp is always 0 | `TimeToTimestamp` currently always returns 0 | Use `ctlText(ctl.Call(60, ...))` + `strconv.ParseInt` instead |
| Plugin text is still Chinese in English mode | The text is hard-coded | Use the `T(key)` language pack with the key space `<语言>.<插件UUID>.<key>` |

### 12.4 How to Confirm a Plugin Was Loaded

- **Check the log**: a successful load produces no dedicated log line, but the **startup probe** runs once with an empty message; if your handler calls `Log` at the top, it will appear. A failed load shows `WASM插件 <uuid> 启动探测失败` (`WasmPlugin-01`).
- **Use the connection table**: call `CopyTcpUUID()` to inspect the controller's current connections and sources, confirming the link exists.
- **Check the local plugin list**: call `Command("GetPluginLocalList", "{}")` to get the list of plugin UUIDs downloaded locally by the controller and verify your `<uuid>` is there.
- **Trigger once**: send a command that matches the subscription and see whether a `WASM插件[<uuid>]:` log line appears; if not, work through 12.3 item by item.

## 13. Local Development and Hot-Reload Debugging

Recommended local iteration flow:

1. **Put it in the default load directory**: place the plugin under the controller's working directory at `CtlConfig/plugins/<uuid>/Plugin/Controller/<uuid>/` (the standard layout), or copy it into `PluginTest/` to compare against the sample project;
2. **One-click sign**: right-click on the GUI "My Plugins" page → "One-Click Sign" (`SignPluginLocal`). **Unsigned plugins are never loaded**;
3. **Load / reload**:
   - After the first deployment, the controller loads it automatically at startup or when it scans;
   - During iteration, use the `ReloadWasmPlugins` command (it can also be triggered from the GUI as a TCP command) to reload all local WASM plugins;
4. **Check the log**: a plugin's `Log` and `[LOG]` lines both go to the controller log, prefixed `WASM插件[<uuid>]: `; failed loads, failed signature verification, and failed startup probes also produce corresponding log lines;
5. **Trigger a command**: send a message that matches the subscription from the GUI (such as `CommandA=Ping`), or build a TCP frame with your own debugging tool.

> [!TIP]
> If you change the subscriptions or capabilities in `[INIT]` during iteration, you **must reload** (`ReloadWasmPlugins`) for them to take effect — the host collects subscriptions and capabilities during the startup probe, and although they are refreshed from every `[INIT]` at runtime, a reload is the most reliable approach.

## 14. Checklist of Pitfalls

| Pitfall | Symptom | Avoidance |
|---|---|---|
| `tcp.handle` not declared | The code is correct but no message ever arrives | `Capability(ctl.CapTCPHandle)` or export `plugin_handle` |
| `Subscribe` never called | The subscription list is empty, so no message matches | Call `Subscribe` at least once; to receive everything use `Subscribe("","","")` |
| Wrong case in a subscription field | Only some messages arrive, or none | Use the controller's actual command names for `command1`/`command2`; `commandA` corresponds to `Data.CommandA` |
| Passing `[]byte` to `SendTCPJson` | The GUI receives a base64 string instead of JSON | Pass a struct / map / slice; use `SendTCP` to send a raw string |
| Wrong `cmd3` | The reply never reaches its target | Put `msg.Command4` in for a GUI reply, `gui` for a broadcast |
| Not signed / signature verification failed | The plugin is not loaded and only log lines appear | Use GUI one-click signing, or generate `sig.json` as described in Section 2 |
| Relying on in-process global state | State is lost across messages | Every invocation is a brand-new instance; keep state in the controller or in configuration |
| A single execution takes too long | Cut off by the watchdog and rebuilt as poisoned | Split the work across messages, or use `SetTimeout` sensibly (≤10 minutes) |
| Treating a `Call` result as plain text | You get a quoted/escaped value | Decode it to plain text with `ctlText` (see 7.4) |
| `TimeToTimestamp` returns 0 | The timestamp is always 0 | Use `ctlText(ctl.Call(60, ...))` + `strconv.ParseInt` instead |
| `Command` calls an unregistered command | It returns `{"success":false,"error":"未注入的控制器指令: xxx"}` | Use only command names from the Section 8 list |
| Blindly calling write commands | The DB is really changed / nodes are pushed | Use query commands only by default; validate the source and action before any write |
| Re-injection forms a cycle | Anything beyond 5 levels is silently dropped | Constrain the trigger conditions of `TcpReceivedData` |
| `[RESP]` is forgotten | The execution is judged failed | Use the SDK's `Run` (it emits it automatically); if you hand-write the ABI, always emit `[RESP]` |
| Hard-coded UI text | Chinese is still shown in English mode | Use the `T(key)` language pack with the key space `<语言>.<插件UUID>.<key>` |
| `ConfigGet` key does not exist | An empty string is returned instead of an error | Handle the empty string to distinguish "not configured" |

## 15. Complete Sample Projects

The examples below can be copied and adapted directly; all are based on the official Go SDK (`import "ctl"`), with declarations in `init()` and `Run` called from `main()`.

### 15.1 Example 1: Ping/Pong Link Probe

**Scenario**: the GUI sends a command with `CommandA=Ping` and the plugin replies with `Pong`, verifying that the GUI → controller → plugin link works.

**Subscription**: `commandA=Ping` (any source).

```go
package main

import "ctl"

func init() {
	ctl.SetInfo("连通性测试助手", "1.0.0")
	ctl.Capability(ctl.CapTCPHandle)
	ctl.Subscribe("", "", "Ping") // 只订阅 CommandA=Ping
}

func main() {
	ctl.Run(func(msg ctl.Msg) bool {
		if msg.CommandA != "Ping" {
			return false // 放行，交给控制器内置处理
		}
		ctl.Log("收到 Ping，来自 " + msg.UUID + "（source=" + msg.Source + "）")
		ctl.SendTCPJson("MainGui", "Pong", msg.Command4, "", "PongResult", map[string]any{
			"pong":   true,
			"plugin": "my-ping",
			"now":    ctl.NowStr(),
			"uuid":   ctl.UUID("ping-"),
		})
		return true // 已处理，跳过内置处理
	})
}
```

**Build and verify**:

```bash
GOOS=wasip1 GOARCH=wasm CGO_ENABLED=0 go build -o controller.wasm .
```

Place it at `CtlConfig/plugins/my-ping/Plugin/Controller/my-ping/build/controller.wasm`; after one-click signing, send `CommandA=Ping` in the GUI and you should see the log and the `PongResult` reply.

### 15.2 Example 2: Task List Statistics Pushed Back

**Scenario**: the GUI sends `CommandA=TaskStats`; the plugin calls the controller's `GetTaskList` to query tasks, extracts the total with a builtin JSON function, and pushes the statistics back to the GUI.

**Subscription**: `commandA=TaskStats`.

```go
package main

import (
	"encoding/json"

	"ctl"
)

func main() {
	ctl.Run(func(msg ctl.Msg) bool {
		if msg.CommandA != "TaskStats" {
			return false
		}
		// 1) 调用控制器业务指令，拿到原始响应 JSON
		res := ctl.Command("GetTaskList", `{"isDelete":false,"page":1,"pageSize":20}`)
		// 2) 先打一次日志确认结构，再用内置函数抽取字段
		ctl.Log("GetTaskList 返回: " + res)
		total, _ := ctlText(ctl.JSONGet([]byte(res), "data.total"))
		// 3) 回推给发起方（cmd3 用 msg.Command4）
		ctl.SendTCPJson("MainGui", "TaskStatsResult", msg.Command4, "", "TaskStatsResult",
			map[string]any{
				"totalRaw": total,
				"raw":      res,
				"stamp":    ctl.NowStr(),
			})
		return true
	})
}

// ctlText 解出内置函数返回的 JSON 编码字符串（见 7.4）
func ctlText(r string) (string, bool) {
	var s string
	if err := json.Unmarshal([]byte(r), &s); err != nil {
		return r, false
	}
	return s, true
}
```

> [!NOTE]
> The structure returned by a business command depends on your controller version; before using `JSONGet` / `JSONGetRaw`, print `res` once (`ctl.Log(res)`) to confirm the path. If you cannot extract it, you can also pass `res` through to the GUI unchanged and let the frontend parse it.

### 15.3 SDKs for Other Languages

The same ABI is available as C / C++ / Rust SDKs, shipped with the release package alongside the Go SDK: `ctl_sdk.h` (C), `ctl_sdk.hpp` (C++), and `ctl_sdk.rs` (Rust). For all three, capabilities are passed to `write_init` as a **comma-separated string** (such as `"tcp.handle"`, or `""` for none), and subscriptions use `command1|command2|commandA` (separated by `|`; the Go SDK appends them with `Subscribe`).

C example:

```c
#include "ctl_sdk.h"

int main(void) {
    ctl_write_init("测试插件", "1.0.0", "tcp.handle", "||Ping");
    char msg[65536];
    ctl_read_stdin(msg, sizeof(msg));
    if (strstr(msg, "\"commandA\":\"Ping\"")) {
        ctl_send_tcp_json("MainGui", "Pong", "", "", "PongResult", "{\"pong\":1}");
        ctl_write_resp(1, "");
    } else {
        ctl_write_resp(0, "");
    }
    return 0;
}
```

C++ example:

```cpp
#include "ctl_sdk.hpp"

int main() {
    ctl::write_init("测试插件", "1.0.0", "tcp.handle", "MainGui", "GetTaskList", "");
    std::string msg = ctl::read_stdin();
    if (msg.find("\"commandA\":\"Ping\"") != std::string::npos) {
        ctl::send_tcp("MainGui", "Pong", "", "", "{\"pong\":1}");
        ctl::write_resp(true, "");
    } else {
        ctl::write_resp(false, "");
    }
    return 0;
}
```

Rust example:

```rust
fn main() {
    ctl_sdk::write_init("测试插件", "1.0.0", "tcp.handle", "MainGui", "GetTaskList", "");
    let msg = ctl_sdk::read_stdin();
    if msg.contains(r#""commandA":"Ping""#) {
        ctl_sdk::send_tcp("MainGui", "Pong", "", "", r#"{"pong":1}"#);
        ctl_sdk::write_resp(true, "");
    } else {
        ctl_sdk::write_resp(false, "");
    }
}
```

Build: `clang --target=wasm32-wasi -O2 -o plugin.wasm main.c` (use `clang++` for C++ and `rustc --target wasm32-wasi -O` for Rust). The artifact must likewise be signed, placed in the standard directory, and named `controller.wasm` to be loaded.

> [!TIP]
> With C / C++ / Rust, `ctl_command` / `ctl_call` / `ctl_t` / `ctl_config` all return "the number of bytes written to the out buffer", so remember to slice by the returned length. Capability gating likewise requires `tcp.handle` in the capability list passed to `write_init`, or the plugin is never invoked. The `ctl_call` result in C/C++/Rust is also a JSON-encoded string, so strip the quotes yourself before using it.
