插件编写说明

客户端里的测速、选线、分流规则都可以换成插件。插件是一个带清单文件的目录, 用任意语言写,通过 stdin/stdout 上的 JSON 协议与客户端对话。

适用客户端 ≥ 1.3.39 协议 apiVersion 1.0 下载这份文档(Markdown)

客户端一切功能皆有插件组成。

插件可以给客户端加自己的功能入口,界面由客户端渲染。 你可以用任意语言写一个独立程序,用标准输入输出和客户端对话。

适用客户端 ≥ 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 取值客户端会调的方法用途
noderoutegetNodes提供节点
selectorselect决定用哪个节点
speedtestertest给节点测速
transformertransform改配置文本
rulesgetRules追加分流规则
notifynotify发通知
subsourcelistSubscriptions提供订阅
hookshook在连接流程的几个节点被调用
taskstask周期任务,到点被调用
actionsinvoke插件自带的功能入口

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直接启动
.pypython、python3 或 py(在 PATH 里找)
.jsnode
.ps1powershell -NoProfile -ExecutionPolicy Bypass -File
.cmd、.batcmd /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参与编写