插件编写说明
客户端里的测速、选线、分流规则都可以换成插件。插件是一个带清单文件的目录, 用任意语言写,通过 stdin/stdout 上的 JSON 协议与客户端对话。
客户端一切功能皆有插件组成。
插件可以给客户端加自己的功能入口,界面由客户端渲染。 你可以用任意语言写一个独立程序,用标准输入输出和客户端对话。
适用客户端 ≥ 1.3.39;协议 apiVersion 1.0。
1. HelloWorld
插件须一个目录,里面有两个文件:
plugins/
└─ cn.example.hello/
├─ plugin.json # 清单:用于客户端加载插件数据
└─ plugin.py # 入口:一个普通程序,读写标准输入输出
plugin.json
{
"schemaVersion": 1,
"id": "cn.example.hello",
"name": "示例插件",
"version": "1.0.0",
"entry": "plugin.py",
"author": "你的名字",
"description": "一个最小插件,什么都不做,只回答 hello。",
"capabilities": ["speedtester"]
}
plugin.py(Python 3,无第三方依赖)
import json
import sys
def reply(obj):
sys.stdout.write(json.dumps(obj, ensure_ascii=False) + "\n")
sys.stdout.flush()
for line in sys.stdin:
line = line.strip()
if not line:
continue
msg = json.loads(line)
method = msg.get("method")
if method == "hello":
reply({"id": msg["id"], "result": {"apiVersion": "1.0", "name": "示例插件"}})
elif method == "shutdown":
reply({"id": msg["id"], "result": {}})
break
else:
reply({"id": msg["id"], "error": "不支持的方法:" + str(method)})
把目录放进客户端的插件目录。入口在「设置 → 插件 → 打开插件目录」。 重启或重连后就能看到。
2. 清单格式
| 字段 | 必填 | 说明 |
|---|---|---|
schemaVersion | 是 | 目前固定写 1。 |
id | 是 | 唯一标识。只能用小写字母、数字、. 和 -,长度 6 到 64 字符,首尾不能是点。建议写成反向域名,如 cn.example.hello。 |
name | 是 | 显示名,可以用中文。 |
version | 是 | 语义化版本,如 1.0.0。 |
entry | 是 | 入口文件,路径相对插件目录。不能含 ..,不能是绝对路径。 |
capabilities | 是 | 至少一项,取值见第 4 节。 |
hooks | 否 | 要接收的生命周期事件,见第 4 节。 |
tasks | 否 | 周期任务列表,见第 4 节。 |
actions | 否 | 功能入口列表,见第 4 节。 |
author | 否 | 填作者名。 |
description | 否 | 声明插件身份、版本、入口与能力,供客户端发现、校验、加载并在插件列表中展示。 |
minHostVersion | 否 | 要求的客户端最低版本。 |
清单里可以写 // 注释和结尾逗号,客户端能容错解析。
3. 通信协议
与客户端通信必须使用json,并且不可将json拆分为多行
客户端发送:
{"id": 1, "method": "hello", "params": {"hostVersion": "1.3.33", "apiVersion": "1.0"}}
插件回给客户端:
{"id": 1, "result": {"apiVersion": "1.0", "name": "示例插件"}}
{"id": 2, "error": {"message": "出错了,原因"}}
| 方法 | 谁发起 | 说明 |
|---|---|---|
hello | 客户端 | 必做。握手,返回 apiVersion 和 name。版本不匹配时,客户端拒绝加载。 |
shutdown | 客户端 | 必做。收到后清理并退出。客户端等 1.5 秒,超时就强制结束进程。 |
| 其余方法 | 客户端 | 由声明的能力决定,见第 4 节。 |
没有 id 的行会被当成日志。 调试时往 stdout 写文字不会破坏协议。 正式版建议写到 stderr,避免和协议行混在一起。
4. 能力与调用
声明了哪项能力,客户端才会调对应的方法。所有 params 都是对象,返回值放在 result 里。
清单里 capabilities 一共十个取值。前九个让客户端在某个环节调用插件,actions 是给客户端加功能入口。 写了列表外的值,客户端会跳过这一项。
| capabilities 取值 | 客户端会调的方法 | 用途 |
|---|---|---|
noderoute | getNodes | 提供节点 |
selector | select | 决定用哪个节点 |
speedtester | test | 给节点测速 |
transformer | transform | 改配置文本 |
rules | getRules | 追加分流规则 |
notify | notify | 发通知 |
subsource | listSubscriptions | 提供订阅 |
hooks | hook | 在连接流程的几个节点被调用 |
tasks | task | 周期任务,到点被调用 |
actions | invoke | 插件自带的功能入口 |
speedtester:给节点测速
// 客户端 → 插件
{"id": 3, "method": "test", "params": {
"nodes": ["香港 01", "日本 02"],
"timeoutMs": 5000
}}
// 插件 → 客户端(每条一个节点,可以分批多次返回)
{"id": 3, "result": {"results": [
{"node": "香港 01", "delayMs": 42},
{"node": "日本 02", "delayMs": 118}
]}}
selector:决定用哪个节点
// 客户端 → 插件
{"id": 4, "method": "select", "params": {
"nodes": [{"name": "香港 01", "delayMs": 42}, {"name": "日本 02", "delayMs": 118}],
"current": "香港 01"
}}
// 插件 → 客户端
{"id": 4, "result": {"name": "香港 01", "reason": "延迟最低"}}
noderoute:提供节点
// 客户端 → 插件
{"id": 5, "method": "getNodes", "params": {}}
// 插件 → 客户端
{"id": 5, "result": {"nodes": [
{"name": "自定义节点", "type": "ss", "server": "1.2.3.4", "port": 8388,
"cipher": "aes-256-gcm", "password": "..."}
]}}
transformer:改配置
// 客户端 → 插件(yaml 是完整的配置文本)
{"id": 6, "method": "transform", "params": {"yaml": "proxies:\n - name: A\n..."}}
// 插件 → 客户端
{"id": 6, "result": {"yaml": "……改过之后的完整配置……"}}
插件获取到的是去除敏感信息后的配置。 节点地址、密码和 UUID 都会换成 0x…xxx 这样的占位符。 结构照常改,比如增删节点、改规则、调分组。返回后,客户端按同一张对照表换回真值。
有三条规则。占位符不透明,别去解析。原值相同的占位符也相同。 不要把占位符删掉,改成自己编的值,否则客户端会判定配置已损坏,整份退回内置实现。 你自己新增的节点写真实地址,这部分不参与还原。
subsource:提供订阅
{"id": 7, "method": "listSubscriptions"}
{"id": 7, "result": {"subscriptions": [
{"id": "my-sub", "name": "我的机场", "type": "clash", "url": "https://…"}
]}}
rules:提供分流规则
// 客户端 → 插件
{"id": 9, "method": "getRules", "params": {"existing": "<已有规则>"}}
// 插件 → 客户端(path 相对插件自己的目录,客户端会复制到内核目录)
{"id": 9, "result": {"rules": ["DOMAIN-SUFFIX,example.com,PROXY"]}}
notify:通知
{"id": 8, "method": "notify", "params": {"title": "连上了", "body": "当前节点:香港 01", "level": "info"}}
{"id": 8, "result": {}}
actions:给客户端加自己的功能入口
其余九项都是客户端在某个环节调用插件:选节点、测速、改配置、连接前后、定时任务。 actions 走另一条路,它给客户端加本来没有的功能。 须在清单里写好入口,客户端在插件窗口里把它们画出来,用户点一下就调用插件。
清单里怎么写:
{
"schemaVersion": 1,
"id": "cn.example.actions",
"name": "示例功能插件",
"version": "1.0.0",
"entry": "plugin.py",
"capabilities": ["actions"],
"actions": [
{"id": "show-status", "title": "看看当前状态"},
{"id": "echo", "title": "回显", "kind": "prompt", "placeholder": "随便输点什么"},
{"id": "enabled-marker", "title": "记住这个开关", "kind": "toggle"}
]
}
声明了 actions 就要写 actions 数组,写了数组就要声明 actions。少一边清单校验不过,插件不加载。
| actions 里的字段 | 必填 | 说明 |
|---|---|---|
id | 是 | 动作标识,调用时原样传回。字母、数字、.、-、_,最多 64 字符,同一个插件里不能重复。 |
title | 是 | 按钮或勾选框上的字,最多 40 字。 |
description | 否 | 一行说明,最多 200 字。 |
kind | 否 | command、prompt、toggle,默认 command。 |
placeholder | 否 | prompt 型输入框的提示文字。 |
confirm | 否 | 调用前先弹一次确认,默认 false。 |
confirmText | 否 | 确认框正文。不写就用 description。 |
一个插件最多 32 个动作。界面形状只有三种,客户端负责渲染,插件不能塞自己的 HTML 或窗口。
kind | 界面上是什么 | 调用时的 input |
|---|---|---|
command | 一个按钮 | 空串 |
prompt | 一个输入框加一个按钮 | 用户填的内容 |
toggle | 一个勾选框 | "true" 或 "false" |
界面由客户端画,插件只写声明。 插件跑在 AppContainer 容器里,拿不到桌面,也没有 UI 框架。 让外部插件往用户界面上随便画东西,本身也不该做。 所以插件只描述「我要一个什么形状的入口」,长什么样、放在哪,由客户端决定。 代价是表现能力有意收窄:只有按钮、输入框加按钮、勾选框三种。 想更花哨就得放宽这条,那是另一个决定,我们不打算顺手放宽。
调用协议:
// 客户端 → 插件
{"id": 10, "method": "invoke", "params": {
"action": "echo",
"input": "你好",
"host": {
"hostVersion": "1.3.37",
"connected": true,
"selectedNodeName": "香港 01",
"mode": "rule",
"connectionMode": "system"
}
}}
// 插件 → 客户端:成功,message 会直接弹给用户
{"id": 10, "result": {"ok": true, "message": "输入的是:你好"}}
// 插件 → 客户端:失败,message 写清原因
{"id": 10, "result": {"ok": false, "message": "输入框是空的,先输点内容"}}
params.host 是客户端下发的状态快照,只有上面五个字段。 它刻意不含节点地址、端口、密码、UUID 和订阅地址,插件拿不到真实节点凭据。 要按节点做判断,用名字就够了。
单次调用最多等 30 秒。其它能力默认只等 5 秒。 插件进程在客户端启动时拉起,invoke 复用它。 进程可能中途退出,别假设上次的内存还在,要记状态就写自己的插件目录。 超时、报错、返回坏数据都由客户端兜住,只弹一句提示,不影响客户端本身。
hooks:在连接流程里被调用
声明 hooks 能力后,客户端在三个时机调用你,方法名是 hook,事件名放在 params.event:
| 事件 | 什么时候触发 |
|---|---|
connect.before | 内核启动之前 |
connect.after | 连接成功之后 |
disconnect | 断开之后 |
清单里写要接收的事件:
{
"capabilities": ["hooks"],
"hooks": ["connect.before", "connect.after", "disconnect"]
}
客户端调用插件的示例:
{"id": 11, "method": "hook", "params": {
"event": "connect.after",
"host": {
"hostVersion": "1.3.37",
"connected": true,
"selectedNodeName": "香港 01",
"mode": "rule",
"connectionMode": "system"
}
}}
host 与 invoke 的 host 快照是同一份,只有版本、是否连接、节点名字、模式和接管方式五个字段。 它不含节点地址、端口、密码、UUID 和订阅地址。要按节点做判断,用名字就够了。
返回值不参与连接流程。钩子是顺带做的事,报错或超时只写一行日志,连接照常继续。
tasks:周期任务
声明 tasks 能力后,在 plugin.json 里写任务和间隔,客户端到点调用 task:
{
"capabilities": ["tasks"],
"tasks": [{"id": "daily", "everySeconds": 3600}]
}
{"id": 12, "method": "task", "params": {"id": "daily"}}
每个插件最多 8 个任务,everySeconds 取 30 到 86400 秒。 任务 id 只能用字母、数字、.、-、_,最多 64 个字符,同一个插件里不能重复。 任务的结果只用来记日志,不影响连接。
宿主服务:让客户端代插件发请求
宿主服务不需要写进 capabilities,任何插件都能用。 插件向客户端发请求时用 call 信封,不要用 id:
{"call": 1, "method": "http", "params": {"url": "https://example.com", "method": "GET"}}
客户端代发之后,按同一个 call 回包:
{"call": 1, "result": {"status": 200, "body": "...", "truncated": false}}
请求出错时回包带 error:
{"call": 1, "error": {"message": "不允许访问内网或本机地址"}}
取客户端状态快照:
{"call": 2, "method": "snapshot", "params": {}}
{"call": 2, "result": {
"hostVersion": "1.3.37",
"connected": true,
"selectedNodeName": "香港 01",
"mode": "rule",
"connectionMode": "system"
}}
边界:
- 只允许 http 和 https。其它协议会被拒绝。
- 不允许访问内网、回环、链路本地地址,域名解析之后是内网也一样拒绝。这条挡的是本机接口,比如
http://127.0.0.1:18081/。 - 请求头只放行
Content-Type和Accept,其余丢弃,所以不能带自定义 Cookie。 - 响应上限 1 MB,超过就截断,
truncated变true。单次请求最多等 15 秒。 - 插件拿不到客户端的配置文件,也不能读节点凭据,这是有意的。要改配置,只有
transformer那条路。 - 不认识的方法会返回错误,不会静默成功。
插件在沙箱里本来也能联网。宿主代发不是为了限制插件上网,而是把地址检查和超时、体积上限收到一处。
5. 发布到 GitHub 插件仓库
仓库是一个普通 Git 仓库,也可以是任意静态服务器上的目录。结构如下:
你的仓库/
├─ index.json # 索引:客户端读这个文件
└─ packages/
├─ cn.example.hello-1.0.0.zip
└─ cn.example.other-2.1.0.zip
index.json
{
"schemaVersion": 1,
"name": "示例插件仓库",
"updated": "2026-10-18",
"plugins": [
{
"id": "cn.example.hello",
"name": "示例插件",
"version": "1.0.0",
"description": "一个最小插件。",
"author": "插件名",
"url": "packages/cn.example.hello-1.0.0.zip",
"sha256": "把压缩包的 sha256 填在这里",
"capabilities": ["speedtester"]
}
]
}
url 可以是相对仓库根目录的路径,也可以是 https:// 开头的绝对地址。 只允许 https,http 和 file 地址会被拒绝。
压缩包要求:
- 根目录就是插件目录,里面有
plugin.json。外面多套一层也能装,客户端会自动进一层找。 - 压缩包里不能有
..这类越界路径,有就拒绝。 sha256必填。客户端下载后校验,对不上就拒绝安装。
算 sha256:
# Linux / macOS
sha256sum packages/cn.example.hello-1.0.0.zip
# Windows PowerShell
Get-FileHash packages\cn.example.hello-1.0.0.zip -Algorithm SHA256
把结果填进 index.json,是一串 64 位小写十六进制字符。
发布新版分三步:改 version,打新包,更新索引里的 url 和 sha256。 客户端安装时先把旧版本备份到 .backup/,安装失败会回滚。
6. 调试与排查
| 现象 | 常见原因 |
|---|---|
| 插件显示「加载失败」 | 清单字段不合法,或 entry 指向的文件不存在。插件列表里会写明原因。 |
| 显示「找不到解释器」 | .py 需要系统里有 python,.js 需要 node。也可以改用 .exe。 |
| 调用超时 | 你的程序没有输出,或输出被缓冲了。每行写完后要 flush。 |
| 握手被拒绝 | hello 返回的 apiVersion 不是 1.0。 |
| 日志里有你的输出 | 正常。没有 id 的行会被当成日志,方便调试。 |
客户端日志在 %LOCALAPPDATA%\Cncp\logs\cncp.log。 插件进程崩溃、调用超时、协议错误都记在这里。
启动方式对照:
| 扩展名 | 启动方式 |
|---|---|
.exe | 直接启动 |
.py | python、python3 或 py(在 PATH 里找) |
.js | node |
.ps1 | powershell -NoProfile -ExecutionPolicy Bypass -File |
.cmd、.bat | cmd /c |
7. 插件优先级
客户端自带的选线、测速也是插件,叫内置插件。开发的插件和它们实现同一套接口。 规则只有一条:外部插件优先。
同一个能力装了外部插件,就用外部插件的结果。外部插件报错、超时,或返回列表里没有的节点,就退回内置实现。 不需要「关掉内置功能」,装上外部插件就接管。
反过来也一样。插件不可使用时,用户不会失去测速,只是回到内置实现。 界面上看不出这次切换。
8. 各能力什么时候被调用
| 能力 | 调用对应的行为 |
|---|---|
noderoute | 每次连接时,节点并进配置(重名自动加「·插件」后缀) |
selector | 测速完成后,决定下次连接用哪个节点 |
speedtester | 点「测速」时 |
transformer | 每次连接时,拿到配置之后、启动内核之前 |
rules | 每次连接时,规则插到 rules: 段最前面 |
notify | 连接成功、断开时 |
subsource | 启动后扫描插件,订阅导入「管理订阅」列表 |
actions | 用户在插件窗口里点按钮、提交输入或勾选勾选框时 |
hooks | 连接前、连接成功后、断开后,按 hooks 里声明的事件 |
tasks | 按 tasks 里写的间隔,客户端到点调用 |
所有调用都遵循一条:失败不影响主流程。插件超时、报错或返回坏数据,客户端退回内置行为,并写一行日志。 用户看到的只是「插件好像没生效」。 actions 是例外:功能入口没有内置实现,失败时只弹一句提示,不会退回别的行为。 hooks 和 tasks 也一样:没有内置替代,失败只写日志,不打断连接。
9. 安全提醒
插件在容器里,网络没有隔离。
协议层已经去除敏感信息:改配置的插件只看到占位符,选线的插件只拿得到节点名。 客户端还会把插件放进 AppContainer 隔离容器运行。 容器里的插件读不到 `%LOCALAPPDATA%\Cncp` 下的订阅、设置和运行时配置,只能读写自己的插件目录。 容器不可用时,客户端退化成普通进程,并在插件窗口写明。 隔离的是文件和系统资源,网络不隔离。插件仍能把我们交给它的内容发出去, 所以底线不变:只安装你信任的插件。不确定时,用「插件 → 停用全部外部插件」回到内置实现。
- 插件拿不到你的账号密码,密码只在客户端主进程里。
- 插件之间互相隔离。一个插件崩溃或卡死,不影响其它插件,也不影响上网。
- 插件进程跑在 AppContainer 隔离容器里,读不到客户端数据目录。容器只有
internetClient能力,可以联网,没有文件系统能力。 - 从仓库安装会校验
sha256。这只保证下载完整,不代表作者可信。 - 仓库请求走代理链,默认是
gh-proxy.com。一个地址超时就换下一个。 - 客户端启动后会自动检查插件更新,有新版本会提示。
10. 本机数据怎么保存
插件能拿到的只是一部分数据。本机上还有别的数据,处理原则相同:能不暴露就不暴露。
| 东西 | 怎么处理 |
|---|---|
| 账号口令 | 用 DPAPI(当前用户)加密后存进 settings.json。DPAPI 不可用时拒绝保存,不退回明文。 |
| 设备私钥 | 用 DPAPI 加密。换机器或换用户就解不开。降级时用机器和用户派生密钥,绝不写明文。 |
节点订阅缓存subscriptions.cache | 每条记录都带 password、uuid 和 server,还是常驻文件,断网时靠它出节点。现在整段 JSON 用 DPAPI 加密落盘。拷走文件或翻备份,只能拿到密文。 |
订阅列表subscriptions.json | 订阅 URL 里带 token,等同于凭据。同样用 DPAPI 加密落盘。 |
| 运行期配置 | 不落盘(1.3.38 起)。mihomo 支持 -f - 从标准输入读配置,客户端把配置放在内存里直接喂给内核,写完关流。老版本留下的 runtime\runtime-config.yaml 在断开时擦除。 |
DPAPI 阻挡范围。 DPAPI(CurrentUser 范围)的主密钥绑在这台机器和这个用户上。 文件拷到别的机器、放进备份、被别人读到,都解不开。 它挡不住以你同一用户身份运行的恶意程序,因为对方本来就能调 DPAPI 解回来。 同一个用户下没有密码学意义上的隔离,这一点我们不夸大。
旧版本升级上来的情况:磁盘上原有的明文缓存还能读出来,不会让你一次性丢掉全部节点。 下一次保存时自动变成密文。
此文档DeepSeek -v4.1-flash-1017参与编写