IPC 协议
本地进程间通信使用 JSON-line 帧协议,底层传输在 Unix 上为 Domain Socket,Windows 上为 Named Pipe。协议语义完全一致。
连接
| 平台 | 默认路径 | 传输 |
|---|---|---|
| Linux/macOS | 优先为 zero 可执行文件所在目录的 control.sock;无法取得可执行文件目录时才回退到 ~/.zero/control.sock | Unix Domain Socket (0600) |
| Windows | \\.\pipe\zero-control | Named Pipe |
| CLI 覆盖 | --control-socket /path/to/sock |
自定义客户端应显式约定同一路径,不要直接假定 Home 目录回退路径。以下 Unix 客户端示例使用 ~/.zero/control.sock,请先用 zero run config.json --control-socket "$HOME/.zero/control.sock" 启动,或把示例中的路径改为实际地址。CLI 查询可用 --socket PATH 指定相同地址。
IPC 不使用 HTTP Bearer Token;能够连接到 socket/pipe 的调用方会获得管理权限,因此应限制本地端点的 OS 访问权限。
帧格式
每条帧是一个完整的 JSON 对象,以 \n 结尾。支持在同一条连接上多路复用多个请求(请求-响应用 id 字段配对),也可以只发一个请求后关闭连接(id 可省略)。
id为不透明关联令牌:接受数字或字符串(如1、"req-123"、"znet-sink-1"、UUID),服务端原样回显,既不解析也不做数值运算——客户端可自由选择配对方案。下方示例统一用数字,仅作示意。
→ {"type":"query","id":1,"request":{"health":{}}}\n
← {"api_id":"zero.api.v1","ok":true,"id":1,"result":{"health":{"engine_build_id":"build-id",...}}}\n请求类型
Ping
→ {"type":"ping"}
← {"api_id":"zero.api.v1","ok":true,"result":"pong"}带 id 的多路复用形式:
→ {"type":"ping","id":42}
← {"api_id":"zero.api.v1","ok":true,"id":42,"result":"pong"}Query
request 字段使用 QueryRequest 的 externally-tagged 格式。每个变体名是 snake_case,值是查询参数对象(无参数时为空对象 {})。
→ {"type":"query","id":1,"request":{"health":{}}}
← {"api_id":"zero.api.v1","ok":true,"id":1,"result":{...}}关键:
request字段必须是一个 JSON 对象,包含一个变体名作为 key。不要用字符串("request":"runtime"是错的),不要用{"type":"runtime"}格式(那也是错的)。
| request | 说明 |
|---|---|
{"capabilities":{}} | 能力查询 |
{"health":{}} | 健康检查 |
{"config":{}} | 配置快照 |
{"runtime":{}} | 运行时状态(含统计、日志配置、活动流) |
{"stats":{}} | 统计摘要 |
{"principal_flows":{}} | 按主体的活跃流计数与会话注册表代次 |
{"active_flows":{"limit":100,"filter":{}}} | 活动流列表 |
{"recent_flows":{"limit":100,"filter":{}}} | 既有有限诊断窗口(兼容接口,不应作为 GUI 历史数据库) |
{"flow":{"flow_id":"42"}} | 单流详情 |
{"policies":{}} | 所有策略 |
{"policy":{"policy_tag":"proxy"}} | 单个策略 |
{"diagnostics":{}} | 诊断信息 |
{"sinks":{}} | 事件接收器状态 |
{"tun_status":{}} | TUN 虚拟网卡状态 |
连接页应订阅 flow 生命周期事件,以 flow.snapshot 重建活动连接,并在本地消费 flow.completed 维护历史。recent_flows 只保留为既有诊断兼容面,不用于断线重建或持久历史。
Command
→ {"type":"command","id":1,"method":"policies.select","params":{"policy_tag":"proxy","target_tag":"direct"}}
← {"api_id":"zero.api.v1","ok":true,"id":1,"result":{"accepted":true,"result":{"policy_tag":"proxy","selected":"direct"}}}id 可选,与 Query 一致。
支持的方法:
| method | params | 说明 |
|---|---|---|
policies.select | policy_tag, target_tag | 切换 selector 出站 |
policies.probe | policy_tag, operation_id? | 探测 url_test 组延迟(异步,结果经事件/查询取) |
flows.close | flow_id | 关闭指定流 |
config.validate | config (完整 JSON) | 验证配置 |
config.apply | config (完整 JSON) | 持久化并等待 proxy 与进程级服务热重建;失败回滚 |
config.apply_runtime | config (完整 JSON) | 不写回源文件,并等待 proxy 与进程级服务热重建;失败回滚 |
mode.set | mode, outbound? | 设置全局模式 |
tun.start | addr, tag, name?, mask?, secondary_addr?, mtu?, include_cidrs?, exclude_cidrs?, auto_route?, dual_stack?, strict_route?, dns_hijack? | 启动 TUN,约束见 HTTP 命令 |
tun.stop | {} | 停止 TUN |
tun.recover | {} | 立即审计并尝试恢复 TUN 路由,随后查询实际状态 |
diagnostics.probe_target | target_tag, operation_id? | 直连 TCP 可达性(不走代理,仅本机→server:port RTT) |
diagnostics.probe_outbound | target_tag, url?, operation_id? | 同步经代理单节点延迟;全局 runtime.latency_test_url 优先 |
diagnostics.dns_lookup | hostname | DNS 查询 |
diagnostics.dns_cache | domain?, limit? | 查询普通 DNS 缓存 |
diagnostics.fakeip_lookup | domain 或 ip | 查询已有 Fake-IP 映射 |
fakeip.clear | domain? 或 ip?;均省略清空全部 | 清理 Fake-IP 映射与持久状态 |
diagnostics.trace_route | target, port, protocol?, inbound_tag? | 路由追踪 |
命令成功时 response.result.accepted 表示接受,业务负载位于 response.result.result。diagnostics.probe_outbound 的目标不存在、URL 无效或网络探测失败也可能是成功命令信封,需继续检查业务负载的 reachable、terminal_status 和 error_code。探测的 operation_id、动态超时及合并语义见 HTTP 命令。
IPC Command 的 command 对象与 HTTP POST /api/v1/commands 的请求体使用相同格式:method 指定命令,params 提供参数。完整参数和响应说明见 HTTP 命令参考。
Subscribe
→ {"type":"subscribe","id":1,"events":["flow.started","flow.routed","flow.updated","flow.completed"]}
← {"api_id":"zero.api.v1","ok":true,"id":1,"result":"subscribed"}
← {"schema_id":"zero.event.v1","event_type":"flow.snapshot","payload":{"watermark":1024,"records":[...]}}
← {"schema_id":"zero.event.v1","event_id":"...","event_type":"flow.completed","occurred_at_unix_ms":1713500000000,...}
← :\n
← ...持续推送...id 可选,与 Query/Command 一致。
events 为可选的事件类型白名单,空或省略表示接收所有事件。
只要白名单包含任一 flow 生命周期事件,ACK 后的第一条 flow 事件就是 flow.snapshot。即使白名单没有显式列出 flow.snapshot,服务端也会发送该同步基线。客户端应先用 records 替换活动连接集合,再按 flow_id + revision 合并后续 flow.started、flow.routed、flow.updated 和 flow.completed。
Subscribe 的确认帧是 ApiResponse:一定包含 api_id 和 ok;id 只是请求关联 ID,只有请求带了 id 才会回显。后续事件帧是裸 ApiEvent:包含 schema_id、event_id、event_type,不包含 api_id 和 ok。客户端应使用顶层 ok 是否存在区分响应帧和事件帧,不应使用 id 区分。
事件格式与 SSE 完全相同 — 都是 zero_api::ApiEvent<serde_json::Value> JSON。消费者只需要一套解析代码即可同时消费 IPC 和 HTTP/SSE 两个通道的事件。事件信封详见 events.md。
连接保持期间服务端持续推送事件帧,同时可以继续发送 Query/Command/Ping 帧获取即时响应。心跳用 :\n(SSE 注释格式,客户端忽略)。
响应格式
IPC 响应使用统一信封格式(zero_api::ApiResponse),包含 api_id 字段用于协议标识。
{
"api_id": "zero.api.v1",
"ok": true,
"id": 1,
"result": { }
}api_id— 协议标识,始终为"zero.api.v1"id— 回显请求的id字段,用于多路复用时配对。请求不带id时此字段省略(JSON key 不存在)。请求 JSON 完全无法解析时此字段同样省略(服务端无法提取id)ok—true成功 /false失败result— 成功时的响应数据(失败时省略)error— 失败时的错误详情(成功时省略)
Query 响应的 result 格式
result 字段包含 externally-tagged 的 QueryResponse 枚举——一个变体名 key 包裹内部数据。每个变体名与请求变体名一致:
{
"api_id": "zero.api.v1",
"ok": true,
"id": 1,
"result": {
"health": {
"engine_build_id": "build-id",
"started_at_unix_ms": 1713500000000,
"healthy": true
}
}
}访问路径:response.result.health.engine_build_id
各变体名及内部数据结构:
| QueryResponse 变体 | result 内部 key | 内部数据 |
|---|---|---|
QueryRequest::Health | "health" | {engine_build_id, started_at_unix_ms, healthy} |
QueryRequest::Config | "config" | {mode, rule_count, listeners, outbounds, outbound_groups} |
QueryRequest::Runtime | "runtime" | {stats, log_level, active_sessions, ...} |
QueryRequest::Stats | "stats" | {active_sessions, total_started, bytes_up, bytes_down, ...} |
QueryRequest::ActiveFlows | "active_flows" | [flow, ...] |
QueryRequest::Policies | "policies" | [policy, ...] |
QueryRequest::Policy | "policy" | {tag, kind, outbounds, selected, ...} |
QueryRequest::Diagnostics | "diagnostics" | {healthy, active_sessions, ...} |
QueryRequest::Sinks | "sinks" | {sinks: [{name, pending, total_delivered, total_failed, replay_gaps, ...}]} |
QueryRequest::TunStatus | "tun_status" | 实际参数、地址族出口、健康及配置归属,见 TUN 状态 |
注意: 这是 IPC 通道的格式。HTTP 通道的
result字段不包含变体名 key——直接就是内部数据。例如 HTTPGET /api/v1/health返回result: {"engine_build_id":"build-id",...},而 IPC 返回result: {"health":{"engine_build_id":"build-id",...}}。
错误响应(result 和 id 在不存在时均省略,而非 null):
{
"api_id": "zero.api.v1",
"ok": false,
"id": 1,
"error": {
"code": "not_found",
"message": "flow `42` was not found",
"field_path": "flow_id"
}
}注意: 错误码使用 snake_case(如
not_found),与 HTTP 响应格式完全一致。
多路复用
同一条连接上发 subscribe 后不关闭连接,继续发送 Query/Command/Ping 帧:
→ {"type":"subscribe","id":1,"events":["flow.started","flow.routed","flow.updated","flow.completed"]}
← {"api_id":"zero.api.v1","ok":true,"id":1,"result":"subscribed"}
← {"schema_id":"zero.event.v1","event_type":"flow.snapshot","payload":{"watermark":1024,"records":[...]}}
← {"schema_id":"zero.event.v1","event_type":"flow.completed",...}
→ {"type":"query","id":2,"request":{"stats":{}}}
← {"api_id":"zero.api.v1","ok":true,"id":2,"result":{"stats":{"active_sessions":3,...}}}
→ {"type":"ping","id":3}
← {"api_id":"zero.api.v1","ok":true,"id":3,"result":"pong"}
← {"schema_id":"zero.event.v1","event_type":"flow.completed",...}这样只需要一条持久连接即可承载所有通信,无需为 query/command 单独创建短期连接。
内核日志
IPC server 在以下事件输出结构化日志,每条日志携带 active=N 表示当前活跃连接数:
| 事件 | 级别 | 示例 |
|---|---|---|
| 客户端连接 | debug | ipc client connected active=1 |
| 客户端正常断开 | debug | ipc client disconnected cleanly active=0 |
| 客户端异常断开 | warn | ipc client disconnected error=BrokenPipe active=0 |
| 连接处理失败 | warn | ipc connection failed error=... active=0 |
| 连接 task panic | error | ipc connection task panicked |
| 服务端就绪 | info | ipc server ready pipe=\\.\pipe\zero-control |
| 服务端停止 | info | ipc server stopped |
| Pipe connect 失败 | warn | named pipe connect failed error=... |
| Pipe create 失败 | error | failed to create named pipe error=... |
通过设置 RUST_LOG 控制日志级别:
RUST_LOG=zero=debug zero run config.json # 调试级别
RUST_LOG=zero=warn zero run config.json # 仅警告和错误诊断 pipe 实例耗尽问题时,观察 active=N 是否持续增长(无减)。正常情况下连接数和断开数应平衡。
客户端示例
CLI
zero status
zero select proxy direct
zero flows
zero policies
zero eventsPython(Unix)
import json, socket, os
SOCK = os.path.expanduser("~/.zero/control.sock")
def ipc_request(req):
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as s:
s.settimeout(90) # 探测预算与排队可能超过 5 秒
s.connect(SOCK)
s.sendall((json.dumps(req) + "\n").encode())
resp = b""
while b"\n" not in resp:
chunk = s.recv(4096)
if not chunk:
raise EOFError("IPC closed before a complete response")
resp += chunk
result = json.loads(resp.split(b"\n", 1)[0])
if not result.get("ok"):
raise RuntimeError(result.get("error"))
return result
# 查询健康状态(注意 request 格式:externally-tagged)
health = ipc_request({"type": "query", "id": 1, "request": {"health": {}}})
print(f"Build: {health['result']['health']['engine_build_id']}")
# 查询策略列表
policies = ipc_request({"type": "query", "id": 2, "request": {"policies": {}}})
for p in policies['result']['policies']:
print(f" {p['tag']} ({p['kind']}) → {p.get('selected', '-')}")
# 切换 selector
ipc_request({
"type": "command",
"id": 3,
"method": "policies.select",
"params": {"policy_tag": "proxy", "target_tag": "direct"}
})Python(Windows)
import json
PIPE = r"\\.\pipe\zero-control"
def ipc_request(req):
# Windows Named Pipe 用普通文件操作即可
with open(PIPE, "r+b", buffering=0) as f:
f.write((json.dumps(req) + "\n").encode())
f.flush()
resp = b""
while b"\n" not in resp:
chunk = f.read(4096)
if not chunk:
raise EOFError("IPC closed before a complete response")
resp += chunk
result = json.loads(resp.split(b"\n", 1)[0])
if not result.get("ok"):
raise RuntimeError(result.get("error"))
return result
# 用法与 Unix 示例完全相同
health = ipc_request({"type": "query", "id": 1, "request": {"health": {}}})
print(f"Build: {health['result']['health']['engine_build_id']}")Go(Unix)
package main
import (
"bufio"
"encoding/json"
"net"
"os"
)
func ipcRequest(req map[string]any) map[string]any {
conn, _ := net.Dial("unix", os.Getenv("HOME")+"/.zero/control.sock")
defer conn.Close()
data, _ := json.Marshal(req)
conn.Write(append(data, '\n'))
line, _ := bufio.NewReader(conn).ReadString('\n')
var resp map[string]any
json.Unmarshal([]byte(line), &resp)
return resp
}
func main() {
health := ipcRequest(map[string]any{
"type": "query",
"id": 1,
"request": map[string]any{"health": map[string]any{}},
})
_ = health
}Node.js / Electron(Unix + Windows)
const net = require('net');
const os = require('os');
const path = require('path');
// Unix 和 Windows 自动切换
const SOCK = process.platform === 'win32'
? '\\\\.\\pipe\\zero-control'
: path.join(os.homedir(), '.zero', 'control.sock');
function ipcRequest(req) {
return new Promise((resolve, reject) => {
let buffer = '';
let settled = false;
const client = net.createConnection(SOCK, () => {
client.write(JSON.stringify(req) + '\n');
});
const fail = (err) => {
if (settled) return;
settled = true;
client.destroy();
reject(err);
};
client.setEncoding('utf8');
client.setTimeout(90000, () => fail(new Error('IPC response timed out')));
client.on('data', (data) => {
buffer += data;
const end = buffer.indexOf('\n');
if (settled || end === -1) return; // 一个 JSON 帧可以跨多个 data 事件
try {
const parsed = JSON.parse(buffer.slice(0, end));
if (!parsed.ok) return fail(new Error(JSON.stringify(parsed.error)));
settled = true;
client.destroy();
resolve(parsed);
} catch (err) {
fail(err);
}
});
client.on('end', () => fail(new Error('IPC closed before a complete response')));
client.on('error', fail);
});
}
async function main() {
// 查询运行时状态
const resp = await ipcRequest({ type: 'query', id: 1, request: { runtime: {} } });
console.log(`活跃连接: ${resp.result.runtime.stats.active_sessions}`);
// 查询策略列表
const policies = await ipcRequest({ type: 'query', id: 2, request: { policies: {} } });
for (const p of policies.result.policies) {
console.log(` ${p.tag} (${p.kind}) → ${p.selected ?? '-'}`);
}
// 切换 selector
await ipcRequest({
type: 'command',
id: 3,
method: 'policies.select',
params: { policy_tag: 'proxy', target_tag: 'direct' }
});
}
main().catch(console.error);这些客户端示例只处理单次请求。订阅/多路复用客户端应持续缓冲到换行,先忽略空行与 : 心跳,再按顶层 ok 分发响应或事件,并用 id 配对响应。超时表示结果未知,尤其不能据此假定配置或 TUN 命令未执行。
错误处理
IPC 连接不存在
Zero 未启动时,socket/pipe 不存在,连接会失败:
// Node.js
client.on('error', (err) => {
if (err.code === 'ENOENT' || err.code === 'ECONNREFUSED') {
console.log('Zero is not running');
}
});Subscribe 后的帧顺序
Subscribe 请求的第一条响应是确认帧(ok:true, result:"subscribed",包含在 ApiResponse 信封中)。之后才是事件帧(裸 ApiEvent JSON,无信封包裹)。消费者需要区分这两种帧:
← {"api_id":"zero.api.v1","ok":true,"id":1,"result":"subscribed"} ← 确认帧(ApiResponse 信封)
← {"schema_id":"zero.event.v1","event_type":"flow.snapshot",...} ← 活动连接基线(裸 ApiEvent)
← {"schema_id":"zero.event.v1","event_type":"flow.completed",...} ← 生命周期事件(裸 ApiEvent)如果订阅不包含任何 flow 类型,则不会生成 flow.snapshot。重新连接并订阅后会得到一份新的活动快照;已经完成的连接由 GUI/消费者自行保存,内核不会通过快照重放历史。
判断方法:检查顶层 ok 字段。存在且为 true/false → 响应帧;不存在 → 事件帧。api_id 标识响应信封,schema_id 标识事件信封。id 只是请求关联 ID,不是帧类型判别字段;如果 subscribe 请求没有传 id,确认帧也不会包含 id。
命令执行失败
命令失败时 ok 为 false,error 包含错误详情:
{
"api_id": "zero.api.v1",
"ok": false,
"id": 2,
"error": {
"code": "not_found",
"message": "policy `nonexistent` was not found",
"field_path": "policy_tag"
}
}错误码列表:not_found, invalid_argument, permission_denied, insufficient_os_privilege, feature_disabled, conflict, unsupported, internal。

