安全传输:端到端配置
此页面是用于在三种拓扑结构中安全地将客户端连接到守护进程的完整配置参考:
- 客户端直接连接守护进程 - 双向 TLS WSS,无中继。
- 守护进程到中继 - 守护进程与指定的中继保持出站桥接,因此即使位于 NAT/CGNAT 后也可访问。
- 客户端到中继再到守护进程 - 客户端_通过_该中继连接到守护进程,而真正的客户端<->守护进程 mTLS 仍在守护进程处终止。
如需查看 60 秒快速入门,请参阅远程设置 (WSS);本页将深入介绍每个选项的调节方法。
心智模型:两个信封,一个信任边界
存在两层 TLS,但其中只有一层是安全边界:
- 内层 mTLS(真正的边界)。 仅支持 TLS 1.3,双向认证。客户端出示由守护进程签发的证书;守护进程出示其服务端叶证书。这是 RPC 平面。该平面上不存在仅服务端认证或未认证的路径——始终要求客户端证书。该证书仅证明_设备_身份;它不是 RPC 凭据。每个远程
initialize还必须携带一个标识_主体_的 bearerauth_token,否则守护进程会以AUTH_REQUIRED(-32010)拒绝该请求。参见 Bearer token。 - 外层 TLS(元数据边界)。 当中继位于路径中时,中继会终止外层 TLS + WebSocket 会话,并转发无法解读的密文。它从不持有能够读取内部 RPC 的密钥。在直连拓扑中不存在外层。
默认端口(均可配置):
| Plane | 默认 | 配置 |
|---|---|---|
| 守护进程 WSS(内部 mTLS RPC) | 9781 | [wss].port |
| 守护进程注册端点 | 9782 | [enroll].port |
| 中继(外层 TLS + WS) | 8443 | 中继 --bind / [bind] |
在全文中,<data_dir> 表示守护进程的数据目录(通常为 ~/.zeroclaw),而 <config-dir> 表示客户端的 zerocode 配置目录(--config-dir,通常为 ~/.zeroclaw)。配置文件不会展开 ~;请使用绝对路径。
拓扑 1:客户端直接连接到守护进程
zerocode ===== mutual-TLS WSS (TLS 1.3) =====> daemon [wss] :9781
1a. 守护进程端
启用 WSS 监听器。安全的默认设置是在首次启动时让守护进程自动生成自己的 CA 和服务器证书,这样您无需手动管理任何 TLS 材料:
[wss]
enabled = true
# bind = "0.0.0.0" # 默认值
# port = 9781 # 默认值
# 将 cert_path/key_path 留空,以便在首次启动时自动在
# <data_dir>/tls/ 下生成服务器证书。仅在需要使用自己的服务器证书时设置它们。
首次启动且 [wss].enabled = true 时,守护进程会在 <data_dir>/tls/(目录模式 0700)下写入:
| 文件 | 目的 | 模式 |
|---|---|---|
ca.crt | 每个守护进程的 CA 证书(公开) | 默认 umask |
ca.key | CA 私钥(用于签署客户端证书) | 0600 |
server.crt | WSS 服务器叶证书(SAN:localhost、127.0.0.1) | 默认 umask |
server.key | WSS 服务器私钥 | 0600 |
私钥会以 0600 权限写入;公有证书使用进程的 umask。tls/ 目录本身为 0700。
CA 绝不会静默轮换:如果 ca.crt 和 ca.key 存在,就会重复使用。自动生成的 CA 有效期为 10 年;服务器叶证书有效期约为 27 个月;签发的客户端证书有效期为 30 天。
开放端口(sudo ufw allow 9781/tcp)并启动守护进程。您应该会看到一条日志,显示 WSS 监听器已在 0.0.0.0:9781 上启动。
客户端现在需要证书。有两种获取方式。
1b. 客户端 - 选项 A:注册(推荐)
注册功能会通过一个受配对控制且经服务器身份验证的端点,在无需手动处理证书的情况下为无证书客户端提供其首个证书。启用它:
[enroll]
enabled = true
# bind = "0.0.0.0" # 默认
# port = 9782 # 默认
# 需要启用 [wss],并提供守护进程 CA 密钥(使用上方自动生成的密钥,或 BYO+key)。
# 如果缺少 CA 密钥,端点将默认拒绝连接,并且必须通过带外方式配置证书。
守护进程启动时,会将一次性 配对码 和 短认证字符串(SAS) 输出到其控制台/日志中。该代码只能使用一次,并在生成 10 分钟后过期——它是签发证书的唯一持有者凭据,而且会出现在控制台和日志中,因此复制的代码必须在操作员使用后不久停止工作。过期的代码会被拒绝并清除;需要新的代码时,请通过网关的配对 API 生成替代代码。然后,在工作站上:
# 交互式:无证书客户端在首次连接时自动注册。
zerocode --connect wss://<remote-host>:9781
# 或明确地/以非交互方式:
zerocode --enroll --connect wss://<remote-host>:9781
zerocode 会提示输入配对码,在本地生成 P-256 密钥和 CSR (私钥永远不会离开设备),并显示 SAS。请确认 SAS 与守护进程打印的 SAS 匹配(这可以检测出中间人 CA),然后它会在 <config-dir>/tls/ 下缓存:
| 文件 | 目的 | 模式 |
|---|---|---|
client.crt | 已签发的客户端证书 | 默认 umask |
client.key | 客户端私钥(在本地生成) | 0600 |
ca.crt | 为 RPC 平面固定的守护进程 CA 证书链 | 默认 umask |
profile.json | 已缓存的 device_id、not_after、中继配置文件 | 默认 umask |
后续运行会复用已缓存的证书,而无需重新注册。证书会在其有效期约 50%(约 15 天)时通过现有的 mTLS 会话自动续期;已吊销的证书无法自行续期。
注册只会颁发证书,不会颁发 bearer token。在守护进程接受会话之前,需要先与网关配对以获取 zc_... 令牌,并通过 ZEROCLAW_AUTH_TOKEN、auth_token_file 或 auth_token 将其提供给 zerocode(参见 Bearer 令牌)。令牌配置完成后,只需运行 zerocode --connect wss://<remote-host>:9781(如果配置中已设置 uri,则直接运行 zerocode),无需再添加其他参数。
注册端点默认值:--enroll-host 默认为 --connect 的主机;--enroll-port 默认为 9782。
首个版本有意要求每次注册都使用配对码。在客户端拥有明确的无代码信任锚(例如固定的守护进程 CA 指纹)之前,保留的 allow_unpaired_enrollment 配置项会在守护进程启动时被拒绝。这样可以避免临时注册 TLS 路径变成盲目的首次使用时信任。
客户端 - 选项 B:由操作员签发的证书
如果您更愿意在守护进程上生成证书并将其复制出来:
# 在守护进程主机上。--out-dir 还会写入可直接使用的 ca.crt/client.crt/client.key。
zeroclaw security issue-client-cert --name my-laptop --out-dir /tmp/my-laptop-tls
添加 --force 以覆盖此名称的现有证书
将这三个文件复制到客户端的 <config-dir>/tls/,并分别命名为 ca.crt、client.crt、client.key(这样 zerocode --connect wss://host:9781 就会自动找到它们),或者显式指定它们的位置:
zerocode --connect wss://<remote-host>:9781 \
--tls-ca-cert /path/ca.crt \
--tls-client-cert /path/client.crt \
--tls-client-key /path/client.key
等效配置(因此直接使用 zerocode 即可):
[connection.wss]
uri = "wss://<remote-host>:9781"
[connection.wss.tls]
ca_cert_path = "/abs/path/ca.crt"
client_cert_path = "/abs/path/client.crt"
client_key_path = "/abs/path/client.key"
zerocode 仍需要 bearer token,可通过 ZEROCLAW_AUTH_TOKEN 环境变量提供,或在 [connection.wss] 下通过 auth_token_file / auth_token 配置。
未注册证书的客户端在未完成注册的情况下访问 WSS 平面时,会收到明确的“请先注册”消息(同时,守护进程会记录被拒绝的未迁移客户端)——绝不会静默挂起。
--tls-skip-verify仅放宽针对自签名开发守护进程的 服务端 验证;客户端证书仍然是必需的。
拓扑 2:守护进程到中继
daemon ====== outbound: register + bridge ======> relay :8443
[relay] (blind forwarder)
守护进程连接到中继,证明一个稳定的 Ed25519 身份,并注册一个 node-id。客户端随后连接到该 node-id(拓扑 3)。中继始终只转发密文。
2a. 运行中继(zerorelay)
使用 relay.toml 进行配置(参见 apps/zerorelay/relay.example.toml);每个命令行标志都会覆盖文件中的对应值。[admission] 部分会在 SIGHUP 时热重载;如果重载会在未显式启用的情况下将公共中继变为开放且无需令牌的准入,则会被拒绝,并继续使用之前的策略。
# relay.toml
bind = "0.0.0.0:8443"
[tls]
# 省略 cert/key 即可在首次运行时于 dir 中自动生成外层 TLS 证书(无需 openssl)。
# 将 sans 设置为中继的公网主机名或 IP 地址。
dir = "/data/tls"
sans = ["relay.example.com"]
# 或者使用您自己的证书(例如受公共 CA 签发的证书):
# cert = "/etc/zerorelay/fullchain.pem"
# key = "/etc/zerorelay/privkey.pem"
[admission]
# "open" 允许任何已签名的守护进程接入(受拒绝列表约束);"allowlist" 仅允许列出的守护进程公钥指纹接入。拒绝规则始终优先。
mode = "open"
allow = []
deny = []
# 公网(非回环)中继必须对注册进行限制:在此设置共享密钥(每个守护进程通过 [relay] token 提交该密钥),或使用 mode = "allowlist"。
# 为通过 `zeroclaw relay claim` 注册的守护进程提供服务的中继必须使用 mode = "allowlist",且不得设置 relay_token(参见 2d)。
# 否则,位于公网绑定地址上且未设置令牌的开放中继将拒绝启动,因为互联网上的任何守护进程都可以注册并抢占未认领的节点 ID。(用于本地开发的回环绑定地址不受此限制;如需有意开放公网中继,可设置 allow_public_open = true 覆盖此行为。)
relay_token = "change-me-to-a-long-random-secret"
[limits]
max_conns_per_node = 256
idle_timeout_secs = 300
lease_ttl_secs = 300
accept_burst_per_ip = 30
accept_rate_per_ip = 10.0
connect_burst_per_node = 60
connect_rate_per_node = 20.0
运行它:
zerorelay --config /etc/zerorelay/relay.toml
# 同样,仅使用标志(公开绑定需要令牌或允许列表,否则 the
# relay refuses to start):
zerorelay --bind 0.0.0.0:8443 --tls-san relay.example.com \
--relay-token change-me-to-a-long-random-secret
省略 --tls-cert/--tls-key 时,中继会在 TLS 目录下自行生成 CA 和服务器证书(解析顺序:$ZERORELAY_DATA_DIR/tls,否则为 $HOME/.zerorelay/tls,再否则为 ./zerorelay/tls);SAN 中始终包含 localhost 和 127.0.0.1。自行生成的 ca.crt 是守护进程/客户端用于信任中继外层 TLS 的证书。
准入。 open 模式加上可选的 relay_token 是最简单的访问控制方式。allowlist 模式根据守护进程注册公钥的指纹进行匹配(守护进程 <data_dir>/relay/registration.key 中 Ed25519 密钥的 SHA-256 十六进制值);将指纹添加到 allow 中(并使用 kill -HUP <pid> 重新加载)。节点 ID 会与其首个注册者的公钥绑定,因此不同的密钥无法劫持正在运行的节点 ID(会收到 node_taken)。
中继在所有模式下都会先于允许列表检查 relay_token。因此,提供自助认领(2d)的中继必须使用 allowlist,且不设置 relay_token:认领过程从不下发共享令牌,已认领的守护进程否则会被 forbidden: bad relay token 拒绝。对于公共中继,允许列表本身已足以进行访问控制,因此令牌在此并无额外作用。
Docker. apps/zerorelay/Dockerfile 采用 distroless 运行,并使用 CMD ["--config", "/etc/zerorelay/relay.toml"] 和无 shell 的 zerorelay healthcheck --addr HEALTHCHECK;compose.yaml 将卷挂载到 /data,从而使自行配置的 TLS 得以持久化。暴露 8443 端口。
2b. 将守护进程指向中继服务器
[wss]
enabled = true # 必需:中继转发到本地 WSS 监听器
[relay]
enabled = true
url = "relay.example.com:8443"
# node_id:留空(推荐),以自动生成并持久化一个随机的 128 位
# 能力凭证,存储于 <data_dir>/relay/node_id。仅在需要固定特定 ID 时设置。
# token = "change-me" # 如果已设置,则必须与中继的 [admission].relay_token 匹配
# 对中继外部证书的信任——选择一个:
relay_ca_path = "/path/to/relay/ca.crt" # 信任中继的(自签名)CA
# tofu = true # 或在首次使用时固定中继叶证书
# relay_insecure = true # 或跳过外部验证(仅限开发环境)
# (对于使用公共 CA 的中继,将这三个选项全部留空,以使用内置公共根证书)
[relay] 要求启用 [wss](中继会桥接到 127.0.0.1:<wss.port>),如果 url 为空则默认拒绝。守护进程启动时会记录节点 ID,并附带提示 “将此作为 –relay-node 提供给客户端”;你也可以从 <data_dir>/relay/node_id 中读取该 ID。守护进程的稳定注册密钥会创建于 <data_dir>/relay/registration.key(0600)。
外层证书信任优先级(从高到低):relay_insecure > relay_ca_path > <data_dir>/relay/relay_pin 中存储的 pin(显式配置或 TOFU)> tofu > 公共根证书。因此,配置 CA 会替换过时的 pin,但不会将其删除。使用 tofu = true 时,观测到的 relay 叶证书指纹会被固定到 <data_dir>/relay/relay_pin,注册过程会将同一个 pin 传递给客户端,以便它们固定同一个叶证书。
2c.(可选)node-id 轮换和外部 mTLS
[relay]
node_id_rotation_days = 30 # auto-rotate the auto-minted id every N days (0 = never)
轮换会生成一个新的 ID,并让它与旧 ID 并行运行 10 分钟的宽限期,以免正在进行中的客户端连接被中断,随后停用旧 ID;客户端会在下一次证书续期时通过带内方式获取新 ID。现在可使用 zeroclaw security relay-rotate-node-id 强制执行一次(仅限 auto-mint 模式;固定的 node_id 永不轮换)。
对于也在外层对守护进程进行身份验证的中继,请在中继上设置 [admission].outer_client_auth = "required" + outer_client_ca,并在守护进程上设置 [relay].outer_client_cert / outer_client_key。这会在外层 TLS 上添加额外配置,且不会触及内部 mTLS。
2d. 通过 zeroclaw relay claim 自助注册
当中继与 ZeroRelay 控制平面一同运行时,运维人员可以使用一次性认领令牌注册守护进程,而无需手动编辑中继的允许列表。控制平面会签发该令牌,并打印需要在守护进程主机上运行的命令:
zeroclaw relay claim <TOKEN> --control https://control.example.com
守护进程使用其现有的注册密钥对声明进行签名,因此控制平面加入允许列表的指纹,正是守护进程用于注册的那个密钥。验证成功后,控制平面将该指纹添加到中继的允许列表并重新加载中继,守护进程写入 [relay] 下的 enabled、url、node_id 和 relay_host。启动或重启守护进程即可完成注册。
认领前:
- 中继必须仅凭指纹准入。 使用
mode = "allowlist",并保持relay_token未设置(参见 2a)。声明不会下发共享令牌。 - 启用
[wss]。 在启用 WSS 监听器之前,中继会拒绝注册。未启用时提交的声明仍会被保存,并在启用[wss]后的下一次启动时生效。 - 先为中继的外层证书配置信任,如步骤 2b 所示(
relay_ca_path、tofu或公共根证书)。该声明只写入上述四个字段,并保持[relay]的其余部分不变。 - 请勿通过环境变量覆盖来设置由声明管理的字段。 如果
relay.enabled、relay.url、relay.node_id或relay.relay_host被覆盖,声明会在发送任何内容之前拒绝执行,因为该覆盖会丢弃已写入的值。
--control 必须是 https,或者位于回环地址上的 http。重定向和代理均已禁用,以确保令牌不会发送到其他源。每次拒绝都会显示 no config was written。令牌只能使用一次:一旦控制平面接受该令牌,它即失效,即使守护进程随后拒绝该响应(例如中继地址不可用)也是如此。在这种情况下,请先修复原因,然后重新生成新令牌。
拓扑 3:客户端到中继再到守护进程
zerocode ==outer TLS+WS==> relay ==forwards ciphertext==> daemon
\________________ inner mutual-TLS (TLS 1.3) terminates here _______________/
这结合了拓扑 1 和 2:客户端需要一个内部客户端证书(按照 1b 进行注册)以及中继协调信息(地址、node-id,以及对中继外部证书的信任)。
3a. 简便路径:注册携带中继配置文件
当守护进程配置了 [relay] 时,其注册响应会包含一个中继配置(relay_url、node_id 和中继的叶证书 relay_cert_pin)。因此,一次注册即可配置所有内容:
zerocode --enroll --connect wss://<daemon-host>:9781
zerocode 会将内部证书 和 中继配置缓存到 <config-dir>/tls/profile.json。之后,不带任何选项的普通 zerocode 会通过中继访问守护进程——它已经知道中继地址、节点 ID 和 PIN。
3b. 手动路径
显式向客户端提供中继坐标。内部证书仍来自注册或 --tls-*(拓扑 1):
zerocode \
--relay relay.example.com:8443 \
--relay-node <node-id-from-daemon-log> \
--relay-ca /path/to/relay/ca.crt
# inner mTLS material: from <config-dir>/tls (after enrolling), or pass --tls-* flags
为中继的外部证书准确选择一种信任模式,与守护进程保持一致:
| 标志 | 含义 |
|---|---|
--relay-ca <pem> | 信任中继的(自签名)CA |
--relay-pin <sha256> | 固定中继的外层叶证书(通常在注册时提供) |
--relay-tofu | 首次使用时信任;将 pin 持久化到 <config-dir>/relay/relay_pin |
--relay-insecure | 跳过外部验证(仅限开发/自签名) |
| (none) | 使用内置的公共根证书(public-CA 中继) |
--relay-host <name> | 覆盖预期的外部证书 SAN(默认为 --relay 主机) |
配置等效项(因此直接使用 zerocode 即可):
[connection.wss]
relay_url = "relay.example.com:8443"
relay_node = "<node-id>"
中继的外层信任(–relay-ca / –relay-pin / –relay-tofu / –relay-insecure)由标志或缓存的注册 PIN提供,而不是由[connection.wss]键提供。
优先直连,失败时回退到中继
如果同时为客户端提供直接地址和中继,它会优先使用直接路径,回退到中继,然后重新探测并迁移回直接路径:
zerocode --connect wss://<daemon-host>:9781 \
--relay relay.example.com:8443 --relay-node <node-id>
调优(位于 [connection.wss] 中):
| 键 | 默认 | 含义 |
|---|---|---|
direct_attempts | 2 | 在回退到中继之前尝试直接连接 |
direct_timeout_secs | 3 | 每次尝试直接连接的超时时间 |
reprobe_secs | 30 | 重新探测频率,以迁移回直接模式(0 禁用) |
在仅中继模式下(不使用 --connect/uri),内部 WSS URL 默认为 wss://127.0.0.1:9781,因为内部 mTLS 在守护进程的回环监听器处终止;中继地址仅是 TCP 拨号目标。
3d. 通过中继前端入口进行浏览器注册(可选启用,仅限注册)
中继可以提供一个浏览器注册页面(“frontdoor”):打开中继 URL,输入节点 ID 和配对码,将短认证字符串与守护进程控制台进行比对,然后浏览器即可获得客户端证书。
仅用于注册。 没有浏览器会话层级:注册完成后,页面会提供证书和私钥,并指引你使用 zerocode。守护进程的 RPC 层要求 mTLS,而浏览器无法通过原生 WebSocket API 提供客户端证书,因此浏览器会话要么需要在页面 JavaScript 中实现 TLS(这是有意不提供的,见下文),要么需要守护进程支持由中继终止的会话模式。这是一个尚待解决的设计问题,而不是缺失的功能。
它默认关闭,因为它会在两个不同方面削弱所用浏览器的盲转发保证:
- 中继服务器是可信的代码来源。 在此处注册的浏览器会运行由中继服务器提供的注册 JavaScript。若中继服务器被攻破,攻击者可能替换代码,从而泄露页面所处理的内容。
- 中继是注册流程中的一个参与方。 守护进程的注册端点使用 TLS,中继只转发这些字节而不解密,因此浏览器无法自行完成该交换。中继因此代表浏览器执行:它先获取守护进程 CA,然后携带配对码,发起经 CA 固定的
POST /enroll请求。中继能够看到配对码和签发的证书,并且可能利用其观察到的配对码为自己注册一个客户端。
中继服务器拿不到私钥:私钥由浏览器通过 WebCrypto 生成,浏览器只传输 CSR。
短认证字符串(SAS)仍可作为检测器使用:如果中继替换为其他 daemon CA,生成的 SAS 将与 daemon 控制台上的不一致,此时应停止操作。但对于你选择信任的中继,它无法保护你:通过 frontdoor 进行的浏览器注册是由中继终止,而非端到端。
此前版本的 frontdoor 在中继提供的 JavaScript 中内置了一套手写的 TLS 1.3 和 X.509 客户端,让浏览器能够直接与守护进程通信。这部分已删除,而非移植。在提供给用户的 JavaScript 中运行未经审计的 TLS 协议栈,其信任保障比坦诚披露的、由中继终止 TLS 的方案更差;而且无论如何,它也无法解决可信代码来源的问题。
缩小范围是有边界的:在所有模式下,RPC 平面都在中继之后保持端到端 mTLS;zerocode/原生注册永远不会执行由中继提供的代码,也不会在中继处终止。如果需要让中继保持完全不受信任,请使用这些方式(参见第 1b/1c/3a 节)。
# relay.toml
[frontdoor]
enabled = true # default false; read the trust note above first
When disabled, plain HTTP requests to the relay get a 404, and browsers cannot enroll through it; daemon registration, zerocode clients, and the tunneled RPC plane are unaffected. The enrollment page itself carries the same trust note, and zerorelay logs a warning at startup while the frontdoor is on.
预填充的注册链接
为了让手机用户免于输入冗长的节点 ID,链接可以同时携带节点 ID 和配对码,页面加载时会自动填充这两个字段:
https://relay.example.com/#node=<node-id>&code=<pairing-code>
https://relay.example.com/?node=<node-id>&code=<pairing-code>
优先使用 # 形式。URL 片段不会发送到任何服务器,因此配对码不会出现在反向代理和 CDN 的访问日志中。? 形式同样可用。中继服务在路由前会丢弃查询字符串,从不记录或回显它,并且通过 cache-control: no-store 和 referrer-policy: no-referrer 提供页面。
该页面只会填充表单字段,不会获取代理 CA,也不会提交任何内容:用户仍需点击获取代理 CA,并确认短认证字符串。不符合节点 ID 或配对码格式的值会被忽略。页面读取这两个参数后,会立即将其从地址栏和当前历史记录条目中移除,因此复制或收藏的 URL 不会包含配对码。
浏览器仍可能在其自身的历史数据库中记录链接最初被打开时的样子(Chrome 对 ? 和 # 两种形式都会记录);任何页面都无法改写这份记录。真正让这份副本无害的是验证码本身:它在首次成功注册后即被消耗,并在生成十分钟后过期,因此已存储的链接要么已失效,要么已过期。对于包含未使用验证码的链接,应将其视同验证码本身对待,并尽量在生成后及时完成注册。
配置参考
守护进程 [wss]
| 键 | 默认 | 描述 |
|---|---|---|
enabled | false | 启用双向 TLS WSS 监听器 |
bind | 0.0.0.0 | 绑定地址 |
port | 9781 | 监听端口 |
cert_path | (空) | 自带服务器证书;留空则在 <data_dir>/tls/ 下自动生成 |
key_path | (空) | 自行提供服务器密钥;留空则自动生成 |
守护进程 [wss.client_auth](可选;无论如何,mTLS 始终启用)
| 键 | 默认 | 描述 |
|---|---|---|
enabled | false | 使用自带的 CA;为 false 时,守护进程使用其自动生成的 CA |
ca_cert_path | (空) | 用于验证客户端证书的 PEM CA(BYO 模式) |
pinned_certs | [] | 如果不为空,则仅接受与这些 SHA-256 指纹匹配的客户端证书 |
crl_path | (空) | 已吊销指纹文件;为空时使用由账本生成的 <data_dir>/tls/revoked |
守护进程 [enroll]
| 键 | 默认 | 描述 |
|---|---|---|
enabled | false | 启用注册端点(需要 [wss] + CA 密钥) |
bind | 0.0.0.0 | 绑定地址 |
port | 9782 | 监听端口 |
allow_unpaired_enrollment | (空) | 保留;在无代码客户端信任锚存在之前,将拒绝非空值 |
守护进程 [relay]
| 键 | 默认 | 描述 |
|---|---|---|
enabled | false | 启用中继桥接(需要 [wss]) |
url | (空) | 中继地址 host:port;启用时必填 |
node_id | (空) | 留空时自动生成并持久化一个 128 位 ID;设置后固定使用一个 |
token | (空,机密) | 注册时提供的共享密钥 |
relay_ca_path | (空) | 中继外部证书的 PEM CA;为空时使用公共根证书 |
relay_host | (空) | 预期的外部证书 SAN;留空时从 url 派生 |
relay_insecure | false | 跳过外部证书验证(仅限开发环境) |
tofu | false | 首次使用时,将 relay 叶节点固定到 <data_dir>/relay/relay_pin |
outer_client_cert | (空) | 用于中继准入的 Daemon 外层 mTLS 客户端证书 |
outer_client_key | (空) | outer_client_cert 的密钥 |
node_id_rotation_days | 0 | 每 N 天自动轮换自动生成的 node-id(0 = 从不) |
Relay relay.toml
| Section.key | 默认 | 描述 |
|---|---|---|
bind | 0.0.0.0:8443 | 监听地址(守护进程 + 客户端) |
[tls].cert / .key | (自助预配) | 外部 TLS 身份;两者均省略以自行配置 |
[tls].dir | 数据目录 /tls | 自配置证书的写入位置 |
[tls].sans | [] | 额外 SAN(始终包含 localhost、127.0.0.1) |
[admission].mode | open | open 或 allowlist |
[admission].allow / .deny | [] | 守护进程公钥指纹(拒绝优先) |
[admission].relay_token | (none) | 可选的共享密钥门控 |
[admission].outer_client_auth | off | off / optional / required(外层 mTLS) |
[admission].outer_client_ca | (none) | 外部客户端证书的 PEM CA |
[admission].route_by_client_cert | false | 根据外层证书 CN 的 node-id 进行路由 |
[limits].max_conns_per_node | 256 | 每个 node-id 的并发客户端连接数 |
[limits].idle_timeout_secs | 300 | 在 N 秒后丢弃空闲客户端连接 |
[limits].lease_ttl_secs | 300 | 注册时公布的租约 TTL(v1 中仅供参考:WebSocket 存活状态才是真正的清理规则) |
[limits].accept_burst_per_ip / accept_rate_per_ip | 30 / 10.0 | 每个 IP 的握手令牌桶 |
[limits].connect_burst_per_node / connect_rate_per_node | 60 / 20.0 | 每节点连接令牌桶 |
[frontdoor].enabled | false | 提供浏览器注册页面(参见 3d:使中继同时成为可信代码来源和浏览器注册中的主体) |
[limits].max_pending_handshakes | 256 | 已通过 accept 但尚未分类的套接字 |
[limits].handshake_timeout_secs | 10 | TLS、WS 升级和签名注册共用一个截止时间 |
[limits].max_registered_nodes | 1024 | 同时注册的守护进程(N+1 获得 registry_full) |
zerorelay CLI(会覆盖 relay.toml)
--config --bind --tls-cert --tls-key --tls-dir --tls-san(可重复指定)--registration-mode --allow(可重复指定)--deny(可重复指定)--relay-token --max-conns-per-node --idle-timeout-secs --lease-ttl-secs --status-file --frontdoor(需显式启用;信任相关影响参见 3d)。子命令:healthcheck [--addr 127.0.0.1:8443]、status --file <path>。
zerocode [connection.wss] 和 CLI
[connection.wss] 键 | 默认 | CLI 覆盖 |
|---|---|---|
uri | (none) | --connect |
relay_url | (none) | --relay(需要 --relay-node) |
relay_node | (none) | --relay-node(需要 --relay) |
direct_attempts | 2 | - |
direct_timeout_secs | 3 | - |
reprobe_secs | 30 | - |
auth_token | (none) | ZEROCLAW_AUTH_TOKEN 环境变量(优先级高于两个 token 配置项) |
auth_token_file | (none) | - (优先于 auth_token;必须仅限所有者访问) |
auth_provider | native(配对令牌) | - (例如 oidc.<alias>) |
[connection.wss.tls] 键 | 默认 | CLI 覆盖 |
|---|---|---|
ca_cert_path | <config-dir>/tls/ca.crt | --tls-ca-cert |
client_cert_path | <config-dir>/tls/client.crt | --tls-client-cert(需要密钥) |
client_key_path | <config-dir>/tls/client.key | --tls-client-key(需要证书) |
skip_verify | false | --tls-skip-verify |
Relay 的外部信任和注册仅限于 CLI/缓存:--relay-ca --relay-host --relay-insecure --relay-pin --relay-tofu --relay-client-cert --relay-client-key --enroll --enroll-host --enroll-port。
文件布局
守护进程 <data_dir>/:
tls/ca.crt tls/ca.key per-daemon CA (key 0600)
tls/server.crt tls/server.key WSS server leaf (key 0600)
tls/ledger.db issued-cert ledger (SQLite)
tls/revoked revoked fingerprints (handshake-checked)
relay/registration.key Ed25519 relay identity (0600)
relay/node_id auto-minted node-id
relay/relay_pin pinned relay outer-leaf fingerprint (TOFU)
客户端 <config-dir>/:
tls/client.crt tls/client.key client identity (key 0600)
tls/ca.crt pinned daemon CA
tls/profile.json device_id, not_after, cached relay profile
relay/relay_pin relay outer-leaf pin (--relay-tofu)
Relay <tls-dir>/: ca.crt、server.crt、server.key(自行配置)。
升级现有的 issued-cert 账本
tls/ledger.db 中记录了架构版本。新守护进程首次打开由早期版本写入的账本时,会在原位置重建该账本,同时保留每个已签发和已吊销的证书,以及其设备 ID、有效期和审计操作者。重建在单个事务中运行:如果无法完成,守护进程将拒绝启动,并保留原始账本不变,而不会留下迁移了一半的状态;错误信息会指明该文件。无需操作员执行任何操作,已注册设备也无需重新注册。
未交付的证书
守护进程会先将签发记录写入台账,然后才把证书交给注册客户端、续期客户端,或写入操作员的 issue-client-cert 文件。这个顺序是有意为之:如果顺序相反,某人手中可能会有一张由 CA 签名的有效证书,却没有对应的台账记录;而台账从未记录过的证书无法列出或撤销。
代价是,中间发生的故障——例如客户端在响应过程中断开连接或重命名失败——会留下一条针对无人收到的证书的 active 记录。此类记录会单独作为_未交付_记录进行跟踪,并且在其存在时间超过一小时后撤销,时间点取以下各项中最早者:
- 任何新证书的签发或续期,
- 任何注册连接,
- 任何守护进程重启或其他账本打开操作。
这不是后台计时器。完全不执行证书操作的守护进程会将扫描推迟到下一次执行此类操作时;在此之前,证书不会出现在 tls/revoked 中,仍可通过 WSS 握手,但无法续期。任何注册或续期流量——包括身份验证失败的连接——都足以对其进行同步.
经对账的证书会被撤销,而不会删除:该行会保留在账本中,其指纹会像操作员撤销的证书一样写入 tls/revoked,审计主体记录为 reconcile:undelivered,以便在审计轨迹中将两者区分开来。security list-client-certs 只显示 ACTIVE 证书,因此经过对账的证书会从该列表中消失——在事件响应期间,请读取 tls/revoked 或审计日志来查看撤销历史。如果设备报告称它 did 接收到的证书已停止工作,请查找该审计主体——这意味着证书交付记录已丢失,解决方法是重新注册或重新签发。
验证和故障排除
- **守护进程已启动:**查找
0.0.0.0:9781上的 WSS 监听日志,以及使用中继时的node_id日志行。 - **无证书连接被拒绝:**这是 mTLS 平面上的预期行为——请先完成注册(拓扑 1b)。该消息需要采取相应操作,并非卡住。
- Relay 可访问:
zerorelay healthcheck --addr <host>:8443退出码为 0。 - **中继指标:**使用
--status-file <path>运行,然后执行zerorelay status --file <path>(仅计数,绝不包含有效载荷)。 - **撤销丢失的设备:**在守护进程账本中执行撤销会生成
<data_dir>/tls/revoked;证书会在下一次握手时被拒绝,且无法自行续期。 - **注册时 SAS 不匹配:**客户端拒绝持久化证书并中止。不匹配意味着你收到的 CA 不是守护进程的 CA——在重试前调查可能存在的中间人攻击。
scripts/dev/mtls-relay-testbed.sh 中提供了一个自包含的端到端测试工具,它会启动守护进程和中继、签发证书、通过网络完成注册,并测试全部三种拓扑;请将其作为一个完整示例来阅读。