跳到正文

通道 ​

中枢和每个接入程序之间有一条 WebSocket,叫通道;微子 0.5.2 起它的协议号是 3。接入程序有两种角色:agent 是受管机器上的被控端服务,client 是一个人的桌面客户端或手机应用。本页列出协议 3 的全部词汇:链接、证书指纹、两个 HTTP 端点、帧、流、两种角色各自交换的文档,以及错误码。在本仓库之外写一个接入程序,用到的就是这些。

端口与证书指纹 ​

通道在被控端端口上,默认 8443,这个端口只经 TLS 提供 /api/channel。面板端口是另一个;面板开不开 HTTPS,通道都不变。

被控端端口上的证书是自签名的,有效期十年,它的指纹就是中枢的全部身份。

规则值
钉住的是什么证书 DER 编码的 SHA-256 摘要,64 个小写十六进制字符
什么时候校验每次 TLS 握手之后,任何请求字节发出之前
证书链和主机名校验关闭
TLS 最低版本1.2
链接里的某个地址指纹不符关闭套接字,终止加入,不再试下一个地址
https 地址没有配指纹不连接

加入链接 ​

客户端的链接在中枢的 客户端 页生成,被控端的链接在 设备 页生成。客户端的加入步骤见安装客户端。被控端的加入步骤见安装被控端。

链接的形式是 neutrino://enroll/<payload>,其中 payload 是一个 JSON 对象的 base64url 编码:

json
{
  "urls": ["https://192.168.100.1:8443", "https://100.92.14.7:8443"],
  "token": "sB1nYt9Qk2_pL0wV7xR4cZ8f",
  "fp": "<sha256-hex>",
  "role": "client",
  "overlays": [
    {
      "provider": "netbird",
      "setup_key": "...",
      "management_url": "",
      "fqdn": "hub.netbird.cloud",
      "hub_address": "100.88.0.1"
    }
  ]
}
字段内容
urls中枢在被控端端口上开放的每个地址;被控端按顺序逐个尝试,客户端同时拨全部地址
token加入凭证,三十分钟内有效,只能用一次
fp要钉住的指纹
roleclient 或 agent
overlays只在客户端链接里有,和客户端状态里的 overlays 是同一个列表,按生成链接时的默认权限取;默认权限不含 overlay 时为空

客户端拿到被控端链接时返回 link_not_for_client,被控端拿到客户端链接时返回 link_not_for_agent;两个错误码的 params 都写明链接的 role。base64url 的字母表里没有 shell 会拆开或 URL 要转义的字符,链接不加引号也能直接粘贴。

加入与离开 ​

端点请求体返回
POST /api/channel/join{ticket, role, protocol, machine_id, name, software, platform}{id, token}:绑定 id,以及之后每个 hello 都带的令牌
POST /api/channel/leave{id, token}{};设备那一行留在 设备 页上,只去掉令牌;客户端那一行连同它的 AI 网关密钥一起删除

machine_id 和 platform 描述机器本身,两种角色从不同的地方读取:

字段被控端发送客户端发送
machine_id/etc/machine-id,没有就用 /var/lib/dbus/machine-id,再没有就为空第一次读状态文件时生成并保存的 uuid4 十六进制串
platform{os, family, arch, version}:linux、windows 或 darwin;debian、rhel 或空;amd64、arm64 或 armhf;Linux 上是 glibc 版本(2.36),Windows 上是构建号(26100),macOS 上是产品版本(15.3.1),读不到时为空{os, family, arch},Linux 以外 family 为空

中枢拿被控端的 version 和模块的最低版本比较,低于最低版本的机器上,这个模块显示为系统不支持。不带 version 的被控端不受这条限制。

客户端从中枢已有记录的那次安装再次加入时,回到原来那一行,密钥、开关和最近一次汇报都保留。

状态码detail什么时候
409protocol_too_old,params {peer, hub, min}协议号低于中枢的 PROTOCOL_MIN
409protocol_too_new,params {peer, hub, min}协议号高于中枢的 PROTOCOL
409role_mismatch,params {role}凭证是为另一种角色生成的
401ticket_spent凭证不存在、已过期或已用过
401binding_unknownleave 给出的 id 和令牌对不上任何绑定

中枢先查协议号,再读凭证,协议号不合格时凭证不会用掉。HTTP 错误的响应体是 {"detail": {"code": "...", "params": {}}}。

套接字 ​

套接字是 wss://<hub-address>:8443/api/channel/socket,<hub-address> 是中枢的某个地址,每次都新建一条钉住指纹的连接。文本帧是一个 JSON 对象,type 取帧名之一;二进制帧是大端序的 u32 流 id,后面跟这个流的字节。

握手 ​

两个方向的第一帧都是身份卡,形状相同。套接字打开后十秒内发出 hello,中枢回 welcome 或 refused。

字段hello(上行)welcome(下行)
protocol这个构建的协议号,3中枢的协议号,3
roleagent 或 clienthub
id绑定 id中枢自己的 id,一个 uuid
name主机名,或绑定的名字中枢在 设置 页上的名字
softwareneutrino_agent/0.5.2 或 neutrino_client/0.5.2neutrino_hub/0.5.2
token绑定令牌无

加入了多台中枢的客户端,按中枢的 id 区分它们,用 name 显示。hello 不合格时,中枢回 refused {code, params},然后以 4000 关闭;第一帧超时、是二进制、类型不对或读不懂时,错误码是 hello_invalid。

帧 ​

帧方向内容
hello上行身份卡,带 token
welcome下行中枢的身份卡
refused下行{code, params},随后以 4000 关闭
state下行{hash, ...sections}:应当成立的状态
report上行{state_hash, ...sections}:实际的状态
open双向{stream, kind, ...args}:一个流开始
close双向{stream, code, params}:流结束,带着结果
credit双向{stream, bytes}:发送方还能再发这么多字节
ping上行{nonce}:接入程序的往返探测;nonce 是它自己的字符串,最多 64 个字符
pong下行{nonce}:中枢在同一个套接字上马上回答,原样带回 ping 的 nonce
二进制双向<u32 stream id><bytes>;UDP 的 connect 流上是 <u32 stream id><u16 source><一个数据报>

中枢每 20 秒在套接字上发一次 WebSocket ping,60 秒内收不到 pong 就断开套接字。这是通道上唯一的 WebSocket ping,客户端自己不发。

客户端每 20 秒发一个 ping 帧,通道换到新套接字之后马上再发一个,welcome 之前不发。客户端只认 nonce 等于这个套接字上最后一个 ping 的 pong,从发出那个 ping 到收到它的时间就是 rtt_ms,客户端自己记下,不发回中枢;其他 nonce 的 pong 丢掉。被控端发 ping 时中枢同样回答,中枢不记录任何东西。

被控端和客户端 45 秒收不到任何东西,就把套接字当作已断,按 5~60 秒的退避间隔重连,每次失败间隔加倍。

客户端怎么拨中枢 ​

客户端的一轮拨号同时拨中枢的全部地址。这些地址是:中枢在各个已连上虚拟网里的地址、中枢的名字、绑定里的 urls。每个地址各有自己的连接时限。哪条套接字先用钉住的指纹完成 TLS,它就是这一轮的套接字;客户端马上关掉这一轮的其他套接字,只在它上面发 hello。一轮只发一次 hello,因为中枢对一个绑定只留一条套接字,第二个 hello 会以 4010 关掉第一条。

一轮失败后,客户端等一段时间再拨下一轮。等待从 5 秒起,每失败一轮翻一倍,最长 60 秒;一轮成功,或网络变化一次,等待回到 5 秒。两轮之间没有探测,也没有定时的轮次。

下面四种事算网络变化,每一种都马上开始一轮:

  • 中枢的某个虚拟网在本机连上。
  • 本机的网络变了:网口接上或断开,手机在移动网络和 Wi-Fi 之间切换。
  • 中枢发来的 urls 和绑定里的不同。
  • 已连上的虚拟网里有对端出现或离开。

中枢没连着时,网络变化结束当前的等待和正在拨的那一轮,还在等连接时限的拨号直接关掉,新的一轮马上开始。中枢连着时,新的一轮在现有通道旁边跑。

客户端在发 hello 之前就知道每个地址走哪条路:虚拟网里的地址归那个引擎,relay_url 归中继,本机所在网络里的地址是 lan,其余是 direct。路从好到差排成这样:

名次路
1lan
2direct
3netbird、easytier
4relay
规则原因
连着时,新一轮的胜者名次更高,或名次相同而 TLS 握手更快,通道才换过去换通道会断掉上面的每个流
不比现有通道好的胜者,在 hello 之前关掉一个绑定只留一条套接字,在它上面发 hello 会关掉现有通道
更好的胜者发 hello,中枢以 4010 关掉旧套接字;客户端不把这次 4010 当作别的客户端顶替换通道的是客户端自己

通道换到新套接字后,客户端马上发一个 ping,之后每 20 秒一个,往返时间的算法见帧。中枢那一行的两个标签随之更新:通道走的路,和取整后的 rtt_ms。

流层 ​

每个操作都是一个流:open 是请求,close 是回复,kind 是方法名。

方面规则
id中枢从 0 开始用偶数 id,对端从 1 开始用奇数 id,两边不会撞号
字节一个二进制帧装一个流的字节;流的文本输出一行一帧
额度credit {stream, bytes} 再给这么多字节的额度;窗口 1 MiB,单帧最大 64 KiB
结果任何一方都能发 close {stream, code, params} 结束流;code 为空时 params 是结果,有 code 时是拒绝
只关一次一方已关闭的流,另一方不再回 close
未知的种类没有处理程序的 kind 以 kind_unknown 关闭;command 流上未知的动词以 verb_unknown 关闭

客户端的状态 ​

客户端的汇报里 state_hash 和中枢的不同时,或汇报带 is_refresh: true 时,或中枢自己的状态变了时,中枢下发 state。

段内容
hash一个不透明的字符串,客户端在每次汇报里原样带回
is_disabled在 客户端 页停用这个客户端后为 true:列表为空,所有操作都返回 client_disabled
services这个客户端有权使用的已发布条目,按它的套接字来源地址解析
urls中枢提供通道的每个地址,客户端存下来,每一轮连接都拨
relay_urlurls 里属于 SSH 中继的那一个,中继关着时为空;客户端靠它判断一个地址走的是哪条路
overlays客户端加入中枢每个虚拟网要用的材料,首选的排在前面
terminals客户端有权打开 shell 或运行命令的受管机器,每台是 {device_id, name, is_online, is_shell_allowed, is_exec_allowed, sessions}
is_panel_allowed客户端未停用且权限含 panel 时为 true:能经 connect 打开中枢的面板
reached_through这个客户端的套接字以哪条路连到中枢:lan、direct、netbird、easytier 或 relay

客户端按 reached_through 在中枢那一行显示路径,见客户端怎么拨中枢。

虚拟网 ​

overlays 里每个有材料的运行中虚拟网占一个对象,NetBird 排在前面。这台机器没运行虚拟网、客户端已停用、客户端的权限不含 overlay、中枢的保险库已锁时,列表为空。

json
[
  {
    "provider": "netbird",
    "setup_key": "...",
    "management_url": "",
    "fqdn": "hub.netbird.cloud",
    "hub_address": "100.88.0.1"
  },
  {
    "provider": "easytier",
    "mode": "manual",
    "network_name": "...",
    "network_secret": "...",
    "peer": "tcp://203.0.113.7:11010",
    "hub_address": "10.0.0.1"
  }
]
提供方与模式字段
netbirdsetup_key,可重复使用的 key;management_url,用 NetBird 官方管理面时为空;fqdn,中枢在网络上的名字
easytier,manualnetwork_name、network_secret;peer,即 tcp://<join-host>:11010
easytier,consoleconfig_server,带账户令牌的控制台地址;is_secure_mode

三种形状都带 hub_address:中枢自己在这个网络里的地址,不带前缀长度,不知道时为空。客户端连上这个网络后,把它和别的地址一起拨。

终端与会话 ​

客户端的权限包含 terminal 或 exec 时,terminals 列出每台受管机器,中枢自己的也在内;每种权限指定了机器时,只列那几台,is_shell_allowed 和 is_exec_allowed 说明这台机器允许哪一种。每一项的 sessions 是这个客户端在这台机器上看得见的会话:它自己打开的全部会话,加上它有终端权限的机器上共享的会话。按 started_at 排序,机器离线时为空。

字段内容
session_id打开方生成的 id,一个 uuid
accountshell 运行所用的账户
started_at打开时间,Unix 秒
titleshell 最后设置的标题,或 shell 名
owner谁打开的,即中枢在 shell 的 open 上盖的标记
is_attached现在有没有流连着
is_persistent最后一个流关闭后会话是否保留
is_shared对这台机器有终端权限的客户端是否都能连上;共享的会话在最后一个流关闭后也保留
attached_count现在连着几个流
device_id持有这个会话的机器
device_name中枢对那台机器的称呼
is_owned是否由这个客户端打开;只有拥有者能设置 is_persistent 和 is_shared
owner_name拥有者的名字:中枢的名字,或打开它的客户端的名字

中枢在发给机器的每个 shell 的 open 上盖 owner:客户端的流是 client:<id>,面板的终端是 hub。被控端的通道建立或断开时,或某台机器的会话列表变化时,中枢给每个客户端推送状态。

客户端的汇报 ​

json
{
  "type": "report",
  "state_hash": "<state-hash>",
  "machine": {
    "hostname": "laptop",
    "platform": { "os": "darwin", "family": "", "arch": "arm64" },
    "os_machine_id": "<machine-id>"
  }
}

收到 welcome 后发一次汇报,之后每 30 秒一次,每应用完一份状态再发一次。收到第一份状态之前,state_hash 为空。刷新时发出的汇报带 is_refresh: true,中枢不管 hash 是否相同,都回整份状态。

os_machine_id 是操作系统给这台机器的 id:Linux 上读 /etc/machine-id,macOS 上读 IOPlatformUUID,Windows 上读 MachineGuid,读不到时为空。中枢只用它判断哪些条目由客户端所在的这台机器提供。

服务条目 ​

services 里每个条目是 {id, type, title, payload, is_healthy, source, description, description_code, description_params, device_id, device_name, is_own_machine}。

typepayload
web{url, is_token_required};只有 VS Code、code-server、CloudCLI 和 Codeg 实例带 is_token_required 且为真
port{host, port, protocol};protocol 是 tcp 或 udp,没有这一项就是 tcp。同一个端口号两种协议都用时,是两个条目
ai{endpoint, protocol, models},protocol 是 openai
file{protocol, host, share, users},protocol 是 smb
rdp{protocol, host, port, attention, platform_os},protocol 是 rustdesk,platform_os 是共享机器的 linux、windows 或 darwin

payload 里的 host、port、url 和 endpoint 是服务在中枢网络上的位置,按客户端套接字的来源地址解析。客户端只把它显示给人看,一个也不拨;到服务的每个字节都走 connect 流。rdp 条目的 attention 写明共享桌面的那台机器前要先做什么:rdp_nobody_seated、rdp_screen_not_allowed,或为空。file 条目的 users 列出能打开这个共享的账户,手动声明的共享这一项为空。

字段内容
is_healthy最近一次探测的结果,从没探测过时为 null
sourcemodule、declared 或 device
description_code来源说明的错误码形式,由程序自己翻成文字:ai_gateway、container、declared、device_share、gitea_module、samba_module、vscode_module、code_server_module、cloudcli_module、codeg_module
description_params那句话要用的值;vscode_module、code_server_module、cloudcli_module 和 codeg_module 用 {host, account}
device_id提供这个条目的受管机器的 id;手动声明的记录和中枢自己的网关为空。程序按机器保存的东西,以这个 id 为键
device_name提供这个条目的机器名;中枢记录里没有这台机器时为空
is_own_machine提供条目的机器就是客户端所在的机器时为 true,依据是汇报里的 os_machine_id

客户端打开的流 ​

service 流 ​

open {kind: service, id} 请求某个条目要从中枢取的材料。中枢按下面的顺序检查,第一项不通过的检查决定 close 的错误码:

code什么时候
binding_unknown没有哪个客户端行的 id 是这条套接字绑定的 id
client_disabled这个客户端在 客户端 页上停用了
service_unknown为这个客户端解析出的列表里没有这个 id
permission_denied这个客户端的权限不含条目的类型,或不含提供条目的机器
rdp_not_shared条目所在的机器已停止共享桌面
vault_locked中枢的保险库已锁,打不开 AI 密钥、VS Code 令牌,或 code-server、CloudCLI、Codeg 的密钥

不带错误码的 close 带回材料。带 is_token_required 的 web 条目,客户端在自己转发的地址上加 ?tkn=<token> 打开,从不打开条目自己的地址:

条目close 的 params
rdp{password}:共享桌面那台机器的座位密码
ai{api_key, model}:这个客户端自己的密钥,网关每个请求都核对它;以及网关提供的第一个模型
description_code 为 vscode_module 的 web{token}:实例的连接令牌
description_code 为 code_server_module、cloudcli_module 或 codeg_module 的 web{token}:中枢只为这次回答签发的令牌,60 秒内有效,只能用一次
其他 web、port、file空;payload 已经是客户端需要的全部

connect 流 ​

open {kind: connect, id} 是到一个已发布条目的一条 TCP 连接,open {kind: connect, is_panel: true} 是到中枢自己面板的一条。客户端的本地监听每接受一条连接,就开一个流;UDP 的 port 条目在转发期间只开一个流。中枢做 service 流的那些检查,再加上流数上限;这里没有 vault_locked:

code什么时候
binding_unknown没有哪个客户端行的 id 是这条套接字绑定的 id
client_disabled这个客户端在 客户端 页上停用了
connect_limit {limit}这条套接字上已经开着 256 个 connect 流
service_unknown {service_id}为这个客户端解析出的列表里没有这个 id
permission_denied {kind}权限不含条目的类型;连面板时不含 panel
rdp_not_shared {service_id}条目所在的机器已停止共享桌面
agent_offline {device}提供条目的机器没有通道,或通道在流还开着时断了
connect_failed {reason}拨不通对端;reason 是 refused、timeout(10 秒后)或 unreachable
port_not_published {port}提供条目的机器已不再发布这个端口
条目中枢把流接到哪里
受管机器提供的条目那台机器的被控端,由它拨本机回环上的端口
手动声明的记录记录自己的地址,由中枢去拨
ai中枢回环上的网关
面板中枢回环上面板的 HTTP 端口,不跳转到 HTTPS

字节以二进制帧双向传送,受额度约束,窗口和单帧大小与 shell 流相同。任一端读到文件结束,发完读到的字节后,流以空 params 关闭;收到 close 的一方写完手上的字节,再关自己的套接字。流没有半关闭;客户端的套接字断了,上面所有 connect 流一并结束。

UDP 流上每个二进制帧是一个数据报,前面是它的 source:大端 u16,即数据报在客户端机器上的发出端口。回复带着它所回复的 source。额度不够一帧的一方直接丢掉这个数据报;某个 source 60 秒没有数据报来往,远端就忘掉它。

shell 与 command 流 ​

open中枢做什么
{kind: shell, device_id, cols, rows, session_id, is_resumed, is_shared}在那台机器上以 session_id 打开 shell,标为这个客户端的,is_shared 为真时共享,双向转发终端字节;机器保留着这个 id 的会话时,和已经连着的流一起连到那个会话,先发最近的输出;is_resumed: true 只连已有会话,机器上没有时以 session_unknown 关闭
{kind: command, module: agent, verb: resize, shell, cols, rows}调整客户端自己那个 shell 流的大小;没有这个流时返回 shell_unknown
{kind: command, module: agent, verb: persist, session_id, is_persistent, is_shared}设置最后一个流关闭后会话是否保留,以及会话是否共享;没带的那个值不变
{kind: command, module: agent, verb: stop_session, session_id}在会话所在的机器上结束它

一个会话能同时连多个流:每个流都收到全部输出,任何一个流的输入都进 shell,shell 的列数和行数各取所有连着的窗口里最小的那个。中枢在机器上打开任何东西之前,shell 流可能以 binding_unknown、client_disabled、permission_denied {kind: terminal} 或 agent_offline {device} 关闭。没有哪台机器保留这个会话时,persist 和 stop_session 以 session_unknown 关闭;会话不归这个客户端时,persist 以 session_not_owned 关闭,stop_session 能结束客户端看得见的任何会话。被控端重启或升级时,那台机器上的会话全部结束。

exec 流 ​

有 exec 权限的客户端打开 exec {device_id, argv, is_tty, cols, rows},在一台机器上运行一条命令;中枢向那台机器的被控端打开同样的流,不记会话,也不盖 owner。流可能以 permission_denied {kind: exec}、shell_program_unusable {path}(argv 为空,或程序在机器上不能运行)或 agent_offline {device} 关闭。机器发来的每一帧开头多一个字节,表示文件描述符:1 是 stdout,2 是 stderr,带 is_tty 时全是 1;客户端发去的帧是命令的 stdin。文本帧 eof {stream} 表示输入结束,被控端随即关掉进程的 stdin。关闭时带 exit_code,即命令自己的退出码,被信号结束时是 128 加信号号。resize 可以指向带 is_tty 打开的 exec 流,和指向 shell 流一样。

被控端的段 ​

被控端的两份文档包含下面这些段;modules 里的条目来自中枢的模块清单,按被控端的 platform 解析。

段发给被控端的 state被控端的 report
machine{hostname, platform, accounts, metrics, sessions}
network{link: {interface, mac, address}, interfaces: [{name, mac, addresses}]}
modules{<name>: {want, config, install, uninstall, retry_mark}}{<name>: {state, is_active, code, params, details}}
desktop{seat_password}{is_shared, origin, account, share_id, port, attention, connected_count}
ai_tools{is_enabled, base_url, api_key, tool_configs, accounts: [{account, password}], cc_switch_version, retry_mark}{accounts: [{account, state, code, params, has_records}]}
urls中枢提供通道的每个地址
error{code, params}:被控端最近一次值得显示的失败
字段内容
retry_mark一个短的随机串,有人对失败的模块或失败的 AI 工具设置再按一次操作时,中枢写入新的一个;它只改变哈希,别的什么都不表示
modules 汇报里的 stateabsent、installed、stopped、running、installing、uninstalling、queued、failed 或 unsupported;queued 表示被控端正在先处理另一个模块
desktop 汇报里的 originswitch 是模块页开关打开的共享,command 是旧版 nagent rdp start 留下的共享;没在共享时为空
ai_tools 的 state 里的开关is_enabled 为真时带网关地址、这台机器的网关密钥和各工具的模型;为假时只有 cc_switch_version 和 retry_mark
ai_tools 汇报里每个账户的 stateswitched、switched_back 或 failed;失败时带 {code, params},例如 cc_switch_download_failed

被控端每 5 秒汇报一次。中枢向它打开 shell、exec、file、command 和 connect 流,它向中枢打开 log 和 package 流。一个 connect {port, protocol} 流是被控端在 127.0.0.1 上拨出的一条 TCP 连接,容器端口只发布在某一个地址上时拨那个地址;UDP 条目则是发往那个端口的全部数据报。没有 protocol 就是 tcp。机器此刻没有在这个协议上发布的端口以 port_not_published {port} 关闭,拨不通以 connect_failed {reason} 关闭。

拒绝与绑定 ​

拒绝在任何地方都是 {code, params}:HTTP 错误里的 detail、refused 帧,或流的 close 上的错误码。拒绝不影响绑定;程序记下它,一分钟后再发 hello。

错误码出现在对绑定的影响
protocol_too_oldjoin、hello保留
protocol_too_newjoin、hello保留
role_mismatchjoin、hello保留
hello_invalidhello保留
ticket_spentjoin还没有绑定;要生成一条新链接
binding_unknownhello、leave、流解除:程序删掉自己的绑定,要用新链接重新加入
kind_unknown、verb_unknown流保留

只有 binding_unknown 解除绑定,因为它表示那一行已在面板上删除,而且只有持有钉住证书的中枢发得出它。

关闭码含义
4000refused;它前面的 refused 帧带着错误码
4010replaced:同一个绑定又开了一条套接字。客户端刚把通道换到新套接字时,旧套接字收到它属于正常;其他时候表示另一个程序用同一个绑定接入了,程序不自动重连,人在客户端里按 重新连接 后才连

协议号 ​

每个构建声明一个整数 PROTOCOL,包的版本号不参与准入。中枢还有 PROTOCOL_MIN,协议号在 PROTOCOL_MIN 到 PROTOCOL 之间的对端才能接入。

PROTOCOL首个次版本
10.3.0
20.4.0
30.5.2

0.5.2 起 PROTOCOL_MIN 是 3。协议 3 把链接和客户端状态里的 overlay(一个对象或 null)改名为 overlays(一个列表)。0.3 和 0.4 的被控端或客户端接入 0.5.2 的中枢时返回 protocol_too_old,也不会从中枢自动升级。0.4 的中枢不能升级到 0.5.2:全新安装的 0.5.2 中枢有新的证书,每台机器都装 0.5.2 的包,再用新链接加入。

读取宽松,写出严格。不认识的字段忽略,不认识的 kind 以 kind_unknown 关闭,程序只发自己协议号定义过的内容。增加 kind、字段或错误码时协议号不变;删掉任何东西、改变含义,或改动链接、凭证、证书指纹,以及 hello、state、report、open 这四个词时,协议号加一。

微子 · 个人开发基础设施