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 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.
重要

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}
FilePurposeRequired
info.yamlMetadata: pluginUUID, author, languages.cn.name / languages.en.name (the plugin name is chosen by language), targets.controller.runtime: wasmRecommended
build/controller.wasmThe controller WASM artifact; any *.wasm in the directory is recognized, but controller.wasm takes priorityRequired
sig.jsonEd25519 signing information; no signature or a failed verification means the plugin is never loadedRequired
plugin.config.jsonPlugin configuration Key/Value; the host reads it three levels up from the inner directory, i.e. the outer <uuid>/plugin.config.jsonOptional

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):

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):

  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 prefixContentDescription
[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]TextDebug 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:

CapabilityMeaning
tcp.handleReceive 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).
// 方式一:显式声明
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 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).

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.
重要

"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
  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.
提示

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 stdin to 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):
FieldJSON keyTypeValue sourceExample
UUIDuuidstringThe host fills tcpData.UUID, the unique identifier of the node/connection that sent the TCP frame"gui-1a2b3c"
SourcesourcestringThe host fills tcpData.Source: "0"=GUI, "1"=controller, "2"=scan node, "3"=AiAgent"0"
Command1command1stringFirst-level command; the controller dispatches on it (MainGui / scan / waitHandle, etc.)"MainGui"
Command2command2stringSecond-level command (business group)"GetTaskList"
Command3command3stringThird-level command: the target of this frame (UUID / gui / scan / all)"gui"
Command4command4stringFourth-level command: the reply UUID; when replying, it is usually passed as cmd3 of SendTCP"gui-1a2b3c"
CommandAcommandAstringBusiness action name, parsed by the host from the CommandA field of Data"Ping"
DatadatastringThe 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:
    • 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:
type Subscription struct {
	Command1 string `json:"command1"`
	Command2 string `json:"command2"`
	CommandA string `json:"commandA"`
}
  • Field table:
FieldJSON keyTypeMeaningUsage
Command1command1stringFirst-level command matchEmpty = wildcard; non-empty must be exactly equal to msg.Command1
Command2command2stringSecond-level command matchEmpty = wildcard; non-empty must be exactly equal to msg.Command2
CommandAcommandAstringBusiness action name matchEmpty = 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:
ParameterTypeWhat to passWhere it comes fromExample
namestringPlugin display nameYours to define"任务统计面板"
versionstringVersion numberYours 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 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:
ParameterTypeWhat to passWhere it comes fromExample
command1stringFirst-level command; empty = wildcardController command name"MainGui"
command2stringSecond-level command; empty = wildcardController command name"GetTaskList"
commandAstringBusiness action name; empty = wildcardYour 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:
ParameterTypeWhat to passWhere it comes fromExample
namestringCapability namectl.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.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:
ParameterTypeWhat to passWhere it comes fromExample
names...stringList of capabilitiesConstants / stringsctl.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 from stdin → call the handler → emit [RESP]. Must be called in main().
  • Signature: Run(fn func(msg Msg) (handled bool)).
  • Parameter table:
ParameterTypeWhat to passWhere it comes fromExample
fnfunc(msg Msg) boolMessage handler; return true=handled (skip built-ins), false=pass throughYour implementationsee 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 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:
ParameterTypeWhat to passWhere it comes fromExample
msgstringAny log textYou 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_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:
ParameterTypeWhat to passWhere it comes fromExample
cmd1stringFirst-level commandTarget-side protocol contract"MainGui"
cmd2stringSecond-level commandTarget-side protocol contract"Pong"
cmd3stringTarget: gui / scan / all / a concrete UUIDUse msg.Command4 when replyingmsg.Command4
cmd4stringReply UUID; empty means the current UUID is filled inUsually """"
datastringRaw 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, 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:
ParameterTypeWhat to passWhere it comes fromExample
cmd1stringFirst-level commandTarget-side protocol contract"MainGui"
cmd2stringSecond-level commandTarget-side protocol contract"Pong"
cmd3stringTarget routeUse msg.Command4 when replyingmsg.Command4
cmd4stringReply UUIDUsually """"
commandAstringBusiness action name (placed into Data.CommandA)Yours to define"PongResult"
dataanyA value object (map / struct / slice)You build itmap[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:
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:
ParameterTypeWhat to passWhere it comes fromExample
commandstringCallback nameController contract"MsgUpdataConfig"
args...anyCallback argumentsYou build themmap[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:
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:
ParameterTypeWhat to passWhere it comes fromExample
tdstringA 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:
ParameterTypeWhat to passWhere it comes fromExample
uuidstringUUID of the connection to disconnectmsg.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:
FieldTypeMeaningUsage
uuidstringUnique connection identifierPass to TcpOnClientDisconnect
sourcestringSource (0/1/2/3)Tell a GUI from a node
nodeIpstringPeer address (IP:Port)Display / locate a node
connectedboolWhether it is onlineFilter 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.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:
ParameterTypeWhat to passWhere it comes fromExample
addrstringListen addressYou build it"0.0.0.0:8080"
taskJSONstringTask 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:
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:
ParameterTypeWhat to passWhere it comes fromExample
taskIdestringTask IDTask configuration"task-001"
  • Returns: nothing.
  • Snippet:
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:
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:
ParameterTypeWhat to passWhere it comes fromExample
taskJSONstringTask 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.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:
ParameterTypeWhat to passWhere it comes fromExample
namestringCommand name (whitelist, see Section 8)The list in Section 8"GetTaskList"
argsJSONstringArgument 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).

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:
ParameterTypeWhat to passWhere it comes fromExample
funcIDuint32Function ID (see Section 9)The full table in Section 930
argsJSONstringJSON 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 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:
ParameterTypeWhat to passWhere it comes fromExample
keystringLanguage-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:
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:
ParameterTypeWhat to passWhere it comes fromExample
keystringConfiguration keyplugin.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:
ParameterTypeWhat to passWhere it comes fromExample
msint64Timeout in millisecondsYour estimate120000 (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:
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=").
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).
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.
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.
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.
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.
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.
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.
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.
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).
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).
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).
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).
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).
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).
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).
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).
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.
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.
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.
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.
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).
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).
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.
// 注意:当前实现里该函数恒返回 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.
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.
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 y is 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 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.
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

CommandArgumentsDescription
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 taskIdeSoft-delete tasks
DeleteTasks ⚠list of taskIdePermanently delete tasks
RecoverTasks ⚠list of taskIdeRecover 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

CommandArgumentsDescription
GetVulnList{"templateId":"..","searchKey":"..","page":1,"pageSize":20}Vulnerability list (vulnerability management page)
NewWebTaskGetVulnListsame as aboveVulnerability list (new task page)
AddVuln ⚠vulnerability form fieldsAdd a vulnerability (writes the poc file and reloads)
UpdateVuln ⚠vulnerability form fieldsEdit 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

CommandArgumentsDescription
GetVulnTemplateList{"page":1,"pageSize":20}Vulnerability template list
newWebTaskGetVulnTemplateListsame as aboveVulnerability template list (new task page)
AddVulnToTemplate ⚠VulnTemplateAdd a vulnerability template
EditVulnToTemplate ⚠VulnTemplateEdit a vulnerability template
delVulnTemplate ⚠{"templateIde":".."}Delete a vulnerability template
res := ctl.Command("GetVulnTemplateList", `{"page":1,"pageSize":20}`)
ctl.Log("模板列表: " + res)

8.4 Path Scanning

CommandArgumentsDescription
GetPathScanTemplateList{"page":1,"pageSize":20}Path scan template list
SavePathScanTemplate ⚠template objectSave a path scan template
SearchGetDirectoryDictList{"keyword":"..","page":1,"pageSize":20}Search directory dictionaries
DelDictionaryPaths ⚠{"paths":[...]}Delete dictionary paths
SaveImportedDirectoryDict ⚠dictionary objectImport a dictionary
res := ctl.Command("SearchGetDirectoryDictList", `{"keyword":"admin","page":1,"pageSize":20}`)
ctl.Log("字典搜索结果: " + res)

8.5 Hook Rules

CommandArgumentsDescription
GetHookRulesList{"page":1,"pageSize":20}Hook rule list
UpdateHookList ⚠Hook rule objectUpdate 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

CommandArgumentsDescription
GetGuiConfig{"uuid":".."}Get GUI configuration (including the scan node list)
setGuiConfig ⚠GuiConfigSave 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).

IDFunctionArguments (array element order)Return semantics
1to_strvalueAny value to string
2to_intvalueAny value to integer
3to_int64valueAny value to int64
4to_uint32valueAny value to uint32
5to_uint64valueAny value to uint64
6to_floatvalueAny value to floating point
7to_boolvalueAny value to boolean
8to_bytesvalueAny value to a byte string (returned as a string)
10base64_encodedataBase64 encoding
11base64_decodedataBase64 decoding
12bytes_to_hexdataBytes to hexadecimal (no spaces)
13hex_to_byteshexHexadecimal to byte string
14url_encodeinputURL encoding
15url_decodeinputURL decoding
16html_encodedataHTML entity encoding
17html_decodedataHTML entity decoding
18charset_convertdata, targetEncoding, autoDetectCharacter encoding conversion (e.g. GBK→UTF-8)
20md5dataMD5 hexadecimal string
21sha1dataSHA1 hexadecimal string
22sha224dataSHA224 hexadecimal string
23sha256dataSHA256 hexadecimal string
24sha384dataSHA384 hexadecimal string
25sha512dataSHA512 hexadecimal string
26crc32dataCRC32 hexadecimal string
27crc64dataCRC64 hexadecimal string
30rand_strmode (1 lowercase, 2 digits, 4 uppercase, 8 special; combinable), lengthRandom string
40left_ofs, keyWordContent to the left of the separator
41right_ofs, keyWordContent to the right of the separator
42middle_ofs, left, rightContent between two markers
43text_betweensource, start, end, startPosition, offset, fallbackToSource{"pos":n,"text":""}
44csv_to_linefields, lineBreakConvert a string slice into a single CSV line
45csv_cleanfieldsClean a string slice (JSON array)
50json_getjsonData, pathRead a JSON value by path (string)
51json_get_rawjsonData, pathRead a raw JSON value by path
60timestamptimeStr, isMilliTime to timestamp
61timestamp_strts, isMilliTimestamp to time string
62format_timenow, paramTime offset calculation
70uuidprefixPrefixed UUID (controller extension)
71now_str—Current time 2006-01-02 15:04:05 (controller extension)
72yaml_to_jsonyamlYAML text to JSON (controller extension)
73json_to_yamljsonJSON 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 valueRouting target
guiAll GUIs
scanAll scan nodes
allEverything (GUI + scan nodes + …)
Any other valueThe 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:

CallReturn formatUsage
Command / T / ConfigGet / CopyTcpUUIDRaw text / JSONUse directly; the Command result can be passed straight to json.Unmarshal
Call and the 29 builtin convenience wrappersJSON-encoded string (quoted/escaped)Decode to plain text with ctlText first (see 7.4)
Builtin function error / unknown IDError 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.
重要

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

SymptomPossible causeFix
The code looks fine but no message ever arrivesThe tcp.handle capability is not declaredCapability(ctl.CapTCPHandle) or export plugin_handle
A subscription matches but nothing triggersSubscribe was never called (the subscription list is empty)Call Subscribe at least once; to receive everything use Subscribe("","","")
Only some messages arriveA subscription field has the wrong case/spellingUse the controller's actual command names for command1/command2; commandA corresponds to Data.CommandA
The plugin never loads, only log lines appearNot signed / signature verification failedUse 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 mismatchUse GOOS=wasip1 GOARCH=wasm; make sure import "ctl" and the replace directive point at the SDK
Execution times out and then recoversA single execution exceeded 1 minute and was cut off by the watchdog, leaving the runtime poisonedCall SetTimeout inline, or split the work across messages (≤10 minutes)
The reply arrives but built-in handling no longer takes effectThe handler mistakenly returns trueOnly return true when you fully take over; return false for side-channel observation
The reply never reaches its targetThe cmd3 route of SendTCP is wrongPut msg.Command4 in for a GUI reply, gui for a broadcast
The GUI receives gibberish base64A []byte was passed to SendTCPJsonPass a struct/map; use SendTCP to send a raw string
Logic triggers itself and floods the logTcpReceivedData forms a cycle; anything beyond 5 levels is droppedTighten the re-injection trigger conditions so subscriptions and re-injections do not match each other
ConfigGet returns nothingThe key does not exist (an empty string is returned) or plugin.config.json is missingHandle the empty string; make sure the configuration is in the outer <uuid>/plugin.config.json
A builtin function returns a quoted valueCall/the wrappers return a JSON-encoded stringDecode it to plain text with ctlText (see 7.4)
The timestamp is always 0TimeToTimestamp currently always returns 0Use ctlText(ctl.Call(60, ...)) + strconv.ParseInt instead
Plugin text is still Chinese in English modeThe text is hard-codedUse 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.
提示

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

PitfallSymptomAvoidance
tcp.handle not declaredThe code is correct but no message ever arrivesCapability(ctl.CapTCPHandle) or export plugin_handle
Subscribe never calledThe subscription list is empty, so no message matchesCall Subscribe at least once; to receive everything use Subscribe("","","")
Wrong case in a subscription fieldOnly some messages arrive, or noneUse the controller's actual command names for command1/command2; commandA corresponds to Data.CommandA
Passing []byte to SendTCPJsonThe GUI receives a base64 string instead of JSONPass a struct / map / slice; use SendTCP to send a raw string
Wrong cmd3The reply never reaches its targetPut msg.Command4 in for a GUI reply, gui for a broadcast
Not signed / signature verification failedThe plugin is not loaded and only log lines appearUse GUI one-click signing, or generate sig.json as described in Section 2
Relying on in-process global stateState is lost across messagesEvery invocation is a brand-new instance; keep state in the controller or in configuration
A single execution takes too longCut off by the watchdog and rebuilt as poisonedSplit the work across messages, or use SetTimeout sensibly (≤10 minutes)
Treating a Call result as plain textYou get a quoted/escaped valueDecode it to plain text with ctlText (see 7.4)
TimeToTimestamp returns 0The timestamp is always 0Use ctlText(ctl.Call(60, ...)) + strconv.ParseInt instead
Command calls an unregistered commandIt returns {"success":false,"error":"未注入的控制器指令: xxx"}Use only command names from the Section 8 list
Blindly calling write commandsThe DB is really changed / nodes are pushedUse query commands only by default; validate the source and action before any write
Re-injection forms a cycleAnything beyond 5 levels is silently droppedConstrain the trigger conditions of TcpReceivedData
[RESP] is forgottenThe execution is judged failedUse the SDK's Run (it emits it automatically); if you hand-write the ABI, always emit [RESP]
Hard-coded UI textChinese is still shown in English modeUse the T(key) language pack with the key space <语言>.<插件UUID>.<key>
ConfigGet key does not existAn empty string is returned instead of an errorHandle 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().

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.

本文档随 SDK 源码同步更新;新增或修改公开接口后会同步到此页。