Controller WASM Plugin Development
Subscribe and handle controller TCP messages: capabilities, subscriptions, every ctl_* host function and builtin.
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
stdinand can callctl_*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 returnsfalse(the default), built-in logic continues. If several plugins match, all of them run, and if any one returnstrue, 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=falseto let it through); - You want to orchestrate several controller business commands into a single call (for example, call
Command("GetTaskList", ...)andCommand("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 thectl_*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.
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:
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:
- Read
sig.jsonand parse it asWasmSigInfo{userPubkey, userSignature, signStatus}; - Use
userPubkey(a base64 Ed25519 public key) to runed25519.Verifyover all bytes ofbuild/*.wasm; - Verification passes →
verified(loaded); fails →verify_failed; missingsig.jsonor an emptyuserSignature→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):
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)
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):
- Write stdin: the host writes the message JSON to the plugin process's standard input;
- Execute
_start: instantiate the wasm module and call_start(for Go, that isfunc main); - 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):
{
"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.
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:
[INIT]declaration: the plugin declares explicitly implemented capabilities in thecapabilitiesarray of[INIT](in the Go SDK, fill them withCapability/Capabilities/CapabilityAll);- Exported function auto-detection: export a
plugin_handlefunction with Go 1.25's//go:wasmexport; after compiling the module, the host automatically recognizes it astcp.handlethrough the "exported function → capability" mapping (the current mapping contains onlyplugin_handle→tcp.handle).
// 方式一:显式声明
func init() {
ctl.Capability(ctl.CapTCPHandle)
}
// 方式二:导出函数自动检测(与方式一取并集)
//go:wasmexport plugin_handle
func pluginHandle() {}
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
Subscribeat 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).
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)
- Scan the directory. At startup the controller scans
CtlConfig/pluginsunder its working directory: a subdirectory namedPluginis parsed with the legacy layoutPlugin/Controller/<uuid>, while other subdirectories are parsed with the standard layout<uuid>/Plugin/Controller/<uuid>; each subdirectory name underControlleris a plugin UUID. - Read metadata and artifact. The host reads
info.yaml(takingauthorand the firstlanguages.*.nameas the plugin name), then looks for*.wasmunderbuild/(controller.wasmfirst); a directory with no wasm at all is ignored outright (it is not a controller plugin). - Verify the signature. The host reads
sig.jsonfrom the same directory and runsed25519.Verify; only plugins that pass verification enter the running set, whileunsigned/verify_failedones are recorded in the skip list and skipped. - 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 allenv.ctl_*host functions → compile the module. After compilation it collects the module's exported functions as the basis of the exported-function capability channel. - Startup probe. For every plugin that passed signature verification, the host runs it once with an empty message
{}to collectsubscribeandcapabilitiesfrom[INIT]; plugins whose probe fails or times out are removed from the running set (log keyCtl.Log.WasmPlugin-01). Subscriptions and capabilities are therefore ready right after the probe. - 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(fromCommandAinData; if empty, it falls back to parsing the rawData), then matches subscriptions across all plugins to obtain the hit set. - Capability gating. For each matched plugin it then checks whether the plugin has the
tcp.handlecapability; plugins that neither declare nor export it are skipped outright (log keyCtl.Log.WasmPlugin-02, and no runtime is created). - 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. - The meaning of handled. The host takes
handledfrom[RESP], and combines thehandledvalues of multiple plugins with OR. If the final value istrue, the controller immediately skips all built-in handling of that message; if it isfalse, built-in logic continues. - Reload and self-healing. When the GUI triggers the
ReloadWasmPluginscommand, 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 runtimepoisoned, and it is automatically rebuilt on the next invocation (recompiled + new instance), so that a single hang does not drag down subsequent messages.
"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:
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):
module myplugin
go 1.25.0
require ctl v0.0.0
replace ctl => <SDK路径>/sdk/go
6.2 main.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
# 编译(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:
CtlConfig/plugins/<uuid>/Plugin/Controller/<uuid>/build/controller.wasm
- Copy the whole standard directory structure (including
info.yaml) toCtlConfig/plugins/<uuid>/; - On the GUI "My Plugins" page, right-click the plugin → "One-Click Sign" to write
sig.json; - The controller loads it automatically; if the plugin was already running, reload it with the
ReloadWasmPluginscommand; - Send a
CommandA=Pingcommand from the GUI and watch for theWASM插件[<uuid>]:prefix in the controller log and for thePongreply.
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.
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:
// 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
}
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
stdinto the handler on every plugin invocation; the plugin's core input. - Signature:
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):
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:
Sourceis a string ("0"/"1"…), not a number — do not writemsg.Source == 0.Datais not guaranteed to be JSON; check for empty values and handle errors before callingjson.Unmarshal.- One invocation handles exactly one message;
Msgis never reused across messages.
Subscription
- Purpose: describes "which TCP messages I want to subscribe to"; appended by
Subscribeand emitted byRuninto[INIT]for host matching. - Signature:
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):
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:
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:
func init() {
ctl.SetInfo("任务统计面板", "1.0.0")
}
- Caveats: optional; defaults to
wasm-plugin/1.0.0. Keep the name consistent withlanguages.*.nameininfo.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:
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:
func init() {
ctl.Capability(ctl.CapTCPHandle)
}
- Caveats: declaring the same capability repeatedly keeps only one entry; without
tcp.handlethe plugin is never invoked.
Capabilities(names ...string)
- Purpose: declare several capabilities at once; equivalent to calling
Capabilityrepeatedly. - 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:
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:
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 fromstdin→ call the handler → emit[RESP]. Must be called inmain(). - 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:
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
truefrom the handler actually blocks built-in handling of that message, so make sure you have fully replaced its functionality. Runwraps the handler inrecover: a plugin panic becomes[RESP] {"handled":false,"error":"插件 panic: ..."}and never crashes the controller.- If
stdinis empty,Runemits[INIT]and returns immediately (without[RESP]); the host always writes at least one{}, so in practice this branch is never reached.
- Returning
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:
ctl.Log("处理开始,CommandA=" + msg.CommandA + " 来源=" + msg.UUID)
- Caveats: logs go through
ctl_logand reach the log the same way as stdout[LOG]lines; when logging heavily, truncate long text (msgshould 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;
datais 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:
// 回包给发来消息的 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, usingguiinstead of the reply UUID) means the reply never reaches its target; if you need{CommandA,Data}wrapping, useSendTCPJsoninstead.
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.Marshalondataonce; the host then decodes it back into an object and callsSendTcpDataJson. - Snippet:
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[]byteis encoded byjson.Marshalinto a base64 string (a real incident in this repository; see Section 10). - To send a raw string without serialization, use
SendTCP.
- Pass a struct / map / slice, never a
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 asCommanddoes. - 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
argsas a JSON array; the host decodes it into[]anyand forwards it to the controller's callback entry point). - Snippet:
ctl.CommandHandler("MsgUpdataConfig", map[string]any{"reload": true})
- Caveats:
argsis variadic and is encoded as a single JSON array; its purpose differs fromCommand(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:
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:
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:
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.Connhandle 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
taskJSONinto a task configuration and then starts the passive proxy listener. - Snippet:
taskJSON := `{"taskIde":"task-001","taskName":"proxy-demo"}`
ctl.StartProxyServer("0.0.0.0:8080", taskJSON)
- Caveats:
taskJSONmust 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:
ctl.StopProxyListener("task-001")
- Caveats: a non-existent
taskIdeis 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:
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:
ctl.EditWebTaskStatus(`{"taskIde":"task-001","taskName":"demo","status":2}`)
- Caveats: this is a write operation (it really changes the DB), so always validate
msg.Sourceandmsg.CommandAfirst 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:
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).
- Only commands from the Section 8 list can be called; anything unregistered returns
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:
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 needsctlTextto 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 tocn); a missing entry falls back tokeyitself. - Snippet:
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:
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<=0is ignored, and anything above 10 minutes is clamped to 10 minutes). - Snippet:
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
datais the string to encode. Returns a JSON-encoded string ("aGVsbG8=").
enc, _ := ctlText(ctl.Base64Encode("hello")) // aGVsbG8=
- Note: concatenating the result directly leaves the quotes in place; always run it through
ctlTextfirst.
Base64Decode(data string)
- Purpose: Base64 decoding. Parameter
datais Base64 text. Returns a JSON-encoded string (the original bytes as text).
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
datais the original string. Returns a JSON-encoded string.
h, _ := ctlText(ctl.HexEncode("hi")) // 6869
- Note:
HexDecodeis its inverse; use them as a pair.
HexDecode(hex string)
- Purpose: convert hexadecimal to a byte string. Parameter
hexis hexadecimal text. Returns a JSON-encoded string.
b, _ := ctlText(ctl.HexDecode("68656c6c6f")) // hello
- Note: an odd length or an invalid character raises an error (
ctlTextreturns false).
URLEncode(input string)
- Purpose: URL encoding. Parameter
inputis the text to encode. Returns a JSON-encoded string.
u, _ := ctlText(ctl.URLEncode("a b")) // a+b
- Note: spaces become
+; replace them yourself when you need the%20style.
URLDecode(input string)
- Purpose: URL decoding. Parameter
inputis already-encoded text. Returns a JSON-encoded string.
u, _ := ctlText(ctl.URLDecode("a%20b")) // a b
- Note: an invalid
%sequence raises an error; checkokbefore using the value.
HTMLEncode(data string)
- Purpose: HTML entity encoding (for example
<→<). Parameterdatais the original text. Returns a JSON-encoded string.
h, _ := ctlText(ctl.HTMLEncode("<a>")) // <a>
- Note:
&/<in the result are escaped by JSON into\u0026and the like;ctlTextrestores them automatically.
HTMLDecode(data string)
- Purpose: HTML entity decoding. Parameter
datais entity text. Returns a JSON-encoded string.
h, _ := ctlText(ctl.HTMLDecode("<a>")) // <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
datais the original byte string,targetEncodingthe target encoding (such as"utf-8"), andautoDetectwhether to detect the source encoding automatically. Returns a JSON-encoded string.
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
datais the original string. Returns a JSON-encoded string (lowercase hexadecimal).
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
datais the original string. Returns a JSON-encoded string (lowercase hexadecimal).
v, _ := ctlText(ctl.SHA1("hello")) // aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d
- Note: like MD5, it is not recommended for security-sensitive use.
SHA224(data string)
- Purpose: SHA224 checksum. Parameter
datais the original string. Returns a JSON-encoded string (lowercase hexadecimal).
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
datais the original string. Returns a JSON-encoded string (lowercase hexadecimal).
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
datais the original string. Returns a JSON-encoded string (lowercase hexadecimal).
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
datais the original string. Returns a JSON-encoded string (lowercase hexadecimal).
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
datais the original string. Returns a JSON-encoded string (hexadecimal).
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
datais the original string. Returns a JSON-encoded string (hexadecimal).
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
modeis a bitmask (1 lowercase, 2 digits, 4 uppercase, 8 special; combinable),lengththe length. Returns a JSON-encoded string.
tok, _ := ctlText(ctl.RandStr(3, 16)) // 小写+数字,长度 16,如 sijjkjuc12345678
- Note:
modeis combined with bitwise OR (3= lowercase + digits);mode=0may yield no characters at all.
LeftOf(s, sep string)
- Purpose: take the content to the left of a separator. Parameter
sis the source text,septhe separator. Returns a JSON-encoded string.
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
sis the source text,septhe separator. Returns a JSON-encoded string.
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
sis the source text,leftthe left marker,rightthe right marker. Returns a JSON-encoded string.
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
datais the raw JSON bytes,paththe path (such asa.b[0].c). Returns a JSON-encoded string (the value itself).
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
reswithctl.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
datais the JSON bytes,paththe path. Returns a JSON-encoded string (needs two decoding steps).
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
ctlTextand thenjson.Unmarshal— two decoding steps.
TimeToTimestamp(timeStr string, isMilli bool) int64
- Purpose: convert a time string to a timestamp. Parameter
timeStris the time text,isMilliwhether milliseconds. Returns an int64.
// 注意:当前实现里该函数恒返回 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.isMillidecides seconds vs. milliseconds.
TimestampToStr(ts int64, isMilli bool)
- Purpose: convert a timestamp to a time string. Parameter
tsis the timestamp,isMilliwhether milliseconds. Returns a JSON-encoded string.
v, _ := ctlText(ctl.TimestampToStr(1696512000, false)) // 2023-10-05 21:20:00
- Note:
isMillimust match the unit ofts.
Other (4)
UUID(prefix string)
- Purpose: generate a unique ID with a prefix, for temporary identifiers. Parameter
prefixis the prefix string (may be""). Returns a JSON-encoded string.
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.
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
yis YAML text. Returns a JSON-encoded string (its content is the escaped JSON text).
j, _ := ctlText(ctl.YAMLToJSON("name: x")) // {"name":"x"}
- Note: only after
ctlTextstrips the quotes and unescapes it do you get readable JSON.
JSONToYAML(j string)
- Purpose: convert JSON text to YAML. Parameter
jis JSON text. Returns a JSON-encoded string.
y, ok := ctlText(ctl.JSONToYAML(`{"name":"x"}`))
- Note: the input must be valid JSON, otherwise
ok=false.
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:
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 |
// 查询任务列表,并读取返回 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) |
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 |
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 |
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) |
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) |
// 列出控制器本地已下载插件,用于确认某个插件是否在本地
res := ctl.Command("GetPluginLocalList", `{}`)
ctl.Log("本地插件: " + res)
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) |
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
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
// 时间字符串 → 时间戳(用 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
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:
// 正确:传 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}`))
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<=0is ignored. - watchdog: the whole
_startcall runs in a separate goroutine and is timed by a host timer; on timeout an error is returned immediately.SetTimeoutresets that timer. - poisoned auto-rebuild: after a timeout the plugin's runtime is marked
poisonedand 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
maxTimeoutis layered on to prevent a plugin from occupying resources forever. - panic / trap fallback:
recoverinsideRun, and the host's invoking goroutine also hasrecover. 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.
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 prefixWASM插件[<uuid>]: <内容>(language-pack keyCtl.Log.WasmPlugin-04) and are visible in the GUI's controller log panel; - The
errorreturned by a plugin (the error field in[RESP]) is recorded asWASM插件[<uuid>] 返回错误: <error>(Ctl.Log.WasmPlugin-05); - Load/invocation related: probe failure
Ctl.Log.WasmPlugin-01, skipped for an undeclared capabilityCtl.Log.WasmPlugin-02, execution failureCtl.Log.WasmPlugin-03.
12.2 Five-Minute Minimal Verification Flow
- Build:
GOOS=wasip1 GOARCH=wasm CGO_ENABLED=0 go build -o controller.wasm . - Deploy: copy it to
CtlConfig/plugins/my-ping/Plugin/Controller/my-ping/build/controller.wasm, together withinfo.yaml. - One-click sign: right-click the plugin on the GUI "My Plugins" page → "One-Click Sign" (writes
sig.jsonand reloads automatically). - Reload: if it was not loaded automatically, send the
ReloadWasmPluginscommand. - Send a command: from the GUI, send a command that matches the subscription (such as
CommandA=Ping). - Check the log: the controller log should show
WASM插件[<uuid>]: 收到消息...; at the same time the GUI should receive the reply you sent withSendTCP.
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
Logat the top, it will appear. A failed load showsWASM插件 <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:
- 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 intoPluginTest/to compare against the sample project; - One-click sign: right-click on the GUI "My Plugins" page → "One-Click Sign" (
SignPluginLocal). Unsigned plugins are never loaded; - Load / reload:
- After the first deployment, the controller loads it automatically at startup or when it scans;
- During iteration, use the
ReloadWasmPluginscommand (it can also be triggered from the GUI as a TCP command) to reload all local WASM plugins;
- Check the log: a plugin's
Logand[LOG]lines both go to the controller log, prefixedWASM插件[<uuid>]:; failed loads, failed signature verification, and failed startup probes also produce corresponding log lines; - 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.
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).
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:
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.
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
}
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:
#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:
#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:
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.
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.