MITM 独立 worker 协议
本文档定义了 sing-box 为外部 MITM worker 使用的 worker 侧协议。
该协议与具体运行时无关。sing-box 只要求一个已连接的 Unix 域套接字字节流,不假设特定语言或嵌入式运行环境。
生命周期
- sing-box 与 worker 主进程建立一条 Unix 域套接字流。
StreamID = 0专用于启动协商。- sing-box 发送
HELLO。 - worker 回复
HELLO。 - sing-box 发送
CAPS,声明选中的协议版本和协商后的能力位集。 - worker 回复完全匹配的
CAPS。 - 只有双方
CAPS完全一致后,才允许开始真实流量。 - 每个被拦截的请求或 HTTP/2 流都在同一条 socket 上使用一个逻辑
StreamID。
如果协商失败,sing-box 会在服务真实流量前拒绝该 worker。
帧格式
所有帧都使用相同的 9 字节头:
StreamID,大端序 32 位无符号整数。Type,8 位无符号帧类型。Length,大端序 32 位无符号负载长度。
StreamID = 0 仅用于会话协商。
帧类型
| 值 | 名称 |
|---|---|
0x01 |
control |
0x02 |
request_headers |
0x03 |
request_body |
0x04 |
request_trailers |
0x05 |
response_headers |
0x06 |
response_body |
0x07 |
response_trailers |
request_body 和 response_body 的负载是原始字节。其余负载都是 UTF-8 JSON 对象,并且严格拒绝未知字段。
控制帧
control 负载以一个额外的子类型字节开头,后面跟一个 JSON 对象。
| 值 | 子类型 | 作用域 |
|---|---|---|
0x01 |
HELLO |
StreamID = 0 的启动协商 |
0x02 |
OPEN |
每个逻辑流的打开与 worker 决策 |
0x03 |
END |
半关闭或完整关闭一个逻辑流 |
0x04 |
ABORT |
中止或重置一个逻辑流 |
0x05 |
ERROR |
结构化协议或运行时错误 |
0x06 |
WINDOW |
可选的流控额度更新 |
0x07 |
CAPS |
会话启动阶段的能力确认 |
未知的关键控制子类型属于致命协议错误。
版本与能力协商
HELLO
min_version/max_version,支持的闭区间版本范围。required_capabilities,最终协商结果中必须存在的能力位。supported_capabilities,该端理解的完整能力位集合。
sing-box 会拒绝以下情况:
- 双方版本范围没有交集。
- 必需能力位不是支持能力位的子集。
- 出现未知能力位。
CAPS
双方必须发送完全一致的选定版本与协商后能力位集。
任何不匹配都会导致启动失败。
能力位
| 位 | 名称 | 含义 |
|---|---|---|
1 << 0 |
request_headers |
可交换请求头 |
1 << 1 |
request_body |
允许请求体帧 |
1 << 2 |
request_trailers |
允许请求尾部帧 |
1 << 3 |
response_headers |
可交换响应头 |
1 << 4 |
response_body |
允许响应体帧 |
1 << 5 |
response_trailers |
允许响应尾部帧 |
1 << 6 |
direct_response |
worker 可以直接短路返回响应 |
1 << 7 |
pass_through |
worker 可以显式放弃拦截 |
1 << 8 |
window_updates |
允许 WINDOW 帧 |
如果某个帧依赖的能力没有被协商成功,worker 不得发送该帧。
打开逻辑流
sing-box 会在非零 StreamID 上用 OPEN 打开每个逻辑流:
{
"protocol": "http/1.1",
"module": "capture",
"request_body": true,
"request_trailers": true,
"response_body": true,
"response_trailers": true,
"is_connect": false,
"is_extended_connect": false
}
worker 必须返回自己的 OPEN 决策:
允许值:
interceptdirect_responsepass_through
direct_response 需要协商到 direct_response 能力。
pass_through 需要协商到 pass_through 能力。
头部负载
sing-box → worker 请求头
{
"method": "GET",
"scheme": "https",
"authority": "example.com",
"path": "/v1/data?ok=1",
"headers": {
"accept": ["application/json"]
}
}
worker → sing-box 请求变更
{
"authority": "rewritten.example",
"path": "/internal/data?ok=1",
"headers": {
"x-policy": ["capture"]
}
}
请求变更帧中不包含 method。任何尝试发送 method、outbound、tls、fsm 或其他未知字段的行为都会被拒绝。
响应头
对于 direct_response,第一帧 response_headers 必须携带 status_code。
Trailers
允许的 worker 输出
worker 只能执行以下操作:
- 修改请求头、请求体和请求尾部。
- 修改响应头、响应体和响应尾部。
- 返回直接响应。
- 中止或重置逻辑流。
- 显式返回
pass_through。 - 仅在协商到
window_updates后发送WINDOW。
禁止的 worker 输出
worker 不得尝试在显式合同之外控制 sing-box。
会被拒绝的示例:
- 修改请求方法。
- 选择出站或修改出站标签。
- 修改 TLS 引擎或证书。
- 发送泛化的 FSM、调度器或传输层控制。
- 在普通头映射中注入 HTTP 伪头。
- 通过普通头映射修改
Host,而不是使用authority。 - 发送未知能力位。
- 在 JSON 负载中携带未知字段。
- 出现错误流转,例如在
response_headers之前发送response_body。
流关闭与错误
END
有效 target:
requestresponsestream
ABORT
ERROR
WINDOW
只有在协商到 window_updates 后,WINDOW 才合法。
失败语义
- 协商失败属于会话级致命错误。sing-box 会关闭 worker 连接,不接收流量。
- 解析失败、未知关键子类型、禁止变更或不可能的状态流转,都属于确定性协议失败。
- 对于已支持的 MITM 流量,sing-box 遇到 worker 协议失败时会 fail-closed,而不是静默绕过策略。
pass_through必须显式返回。缺少有效 worker 决策不等于隐式放行。
脱敏要求
worker 应当把所有解密后的 HTTP 内容视为敏感数据。
- sing-box 的常规观测输出不会记录解密后的消息体字节。
- sing-box 的常规观测输出不会记录
Authorization、Proxy-Authorization、Cookie、Set-Cookie、各类 token 头以及 API key 等敏感头。 - 除非运维方单独构建了安全审计链路,否则 worker 自身日志也应遵循相同原则。
结构化 MITM 事件中可见的请求元数据仅限于模块标签、协议、决策、authority/path/query、安全头以及消息体是否存在或大小等脱敏字段。
示例收发流程,带请求改写的拦截
S→W stream=0 control/HELLO {"min_version":1,"max_version":1,...}
W→S stream=0 control/HELLO {"min_version":1,"max_version":1,...}
S→W stream=0 control/CAPS {"version":1,"capabilities":255}
W→S stream=0 control/CAPS {"version":1,"capabilities":255}
S→W stream=1 control/OPEN {"protocol":"http/1.1","module":"capture","request_body":true}
S→W stream=1 request_headers {"method":"GET","scheme":"https","authority":"example.com","path":"/v1/data"}
W→S stream=1 control/OPEN {"decision":"intercept"}
W→S stream=1 request_headers {"authority":"rewritten.example","path":"/internal/data"}
S→W stream=1 request_body <bytes>
S→W stream=1 control/END {"target":"request"}
W→S stream=1 response_headers {"status_code":200,"headers":{"x-worker":["1"]}}
W→S stream=1 response_body <bytes>
W→S stream=1 control/END {"target":"response"}
示例收发流程,直接响应
S→W stream=7 control/OPEN {"protocol":"h2","module":"capture"}
S→W stream=7 request_headers {"method":"GET","scheme":"https","authority":"api.example","path":"/healthz"}
W→S stream=7 control/OPEN {"decision":"direct_response"}
W→S stream=7 response_headers {"status_code":503,"headers":{"content-type":["text/plain"]}}
W→S stream=7 response_body <"worker unavailable">
W→S stream=7 control/END {"target":"response"}
示例收发流程,显式 pass-through
S→W stream=11 control/OPEN {"protocol":"http/1.1","module":"capture"}
S→W stream=11 request_headers {"method":"CONNECT","authority":"db.example:443"}
W→S stream=11 control/OPEN {"decision":"pass_through"}
W→S stream=11 control/END {"target":"stream"}
独立 worker 的实现说明
- 把 UDS 连接当成有序字节流处理,不要依赖 socket 层的消息边界。
StreamID和Length都使用大端序解析。StreamID = 0只用于协商。- 你的解析器也应拒绝或显式暴露未知字段,不要静默忽略合同变化。
- 不要尝试逆向控制 sing-box 的内部实现。如果合同没有暴露该能力,worker 就不能控制它。