外观
密钥托管与恢复
WireGuard 节点密钥的保管方式、可选的离线托管,以及丢失后的恢复路径。
设计见 fleet 供给收权与安全模型,工具参数见 CLI 与脚本。
密钥在哪里
控制面数据库是唯一事实源。 node_wireguard_keys 表按节点存一对 X25519 密钥,私钥以 base64 明文列存放;materialize 时无条件注入每个 WG 接口的 private_key_ref 下发给 agent。
节点不生成、不持有独立密钥:agent 拿到的私钥来自 DesiredState,渲染进 rendered/wireguard/<iface>.conf 后 bind 挂载给 wg-gateway 容器。状态目录里没有单独的密钥文件,但渲染出的 conf 本身就含明文私钥——实测文件模式 0644、目录链 0755,节点上任何本地用户可读。
这带来一个必须正视的结论:
⚠️ 密钥的暴露面有两处:控制面数据库(
node_wireguard_keys.private_key明文列),以及每一台节点的渲染目录。库备份、对公网发布的 PostgreSQL 端口、能读该表的凭据、节点上的本地用户——都要按「持有全网隧道密钥」的级别对待。
换取的是密钥口径的收敛——此前私钥以副本散落在每个接口 spec 里、节点各自生成,公钥分叉过。取舍与迁移见 fleet 供给收权。
一致性闸门(始终生效)
agent 每轮 reconcile(apply 模式且接入控制面时)会做一次公钥对账:
- 从 DesiredState 的 WG 接口取节点密钥,推导公钥;
POST /control/v1/agent/wireguard-keys上报;- 控制面与
Node.wireguard_public_key(密钥表公钥的只读投影)比对。
| 结果 | 含义 |
|---|---|
stored | 首次登记。同事务把「对端是本节点」的内部 peering 重新物化,让对端拉到新公钥 |
matched | 一致,放行 |
rejected | 不一致 → 409 并回滚事务,agent 抛错中止本轮 reconcile |
409 的意义是:节点绝不用偏离的密钥拉隧道。遇到它要查清哪一侧是对的,而不是绕过——要改密钥只能走显式轮换(set_node_key 加全接口重物化)。
离线托管(可选,当前生产未启用)
托管在密钥落库之外再存一份用离线公钥加密的副本,用于「数据库本身也没了」的场景。
机制完整存在于代码中,但生产实例没有配置 [server] recovery_public_key,因此:
GET /control/v1/agent/recovery-public-key返回{"configured": false};- agent 不做封装,上报里
private_key_escrow为null; nodes.wireguard_private_key_escrow列恒为空。
要启用就补配恢复公钥,agent 下一轮 reconcile 即开始上交密文。
一、离线生成恢复密钥对(只做一次)
在一台离线、可信的机器上——不是控制面,也不是节点:
bash
python deploy/tools/dn42-recover/dn42_recover.py keygen --out-dir ./recovery-keys
# 产出:recovery-private.pem(口令加密,离线妥善保管)
# recovery-public.pem(交给控制面)recovery-private.pem 用口令加密,放保险箱或密码管理器,绝不上传任何服务器。
二、把恢复公钥配给控制面
toml
# control-server.toml
[server]
recovery_public_key = "/etc/dn42-control/recovery-public.pem"
# 也可直接内联 PEM 文本取值既可以是文件路径也可以是内联 PEM(以 -----BEGIN 开头判定)。给的是路径且文件不存在时启动期 fail-fast。
配好后控制面经 GET /control/v1/agent/recovery-public-key 把公钥与指纹下发给 agent。
三、agent 自动上交密文
下一轮 reconcile 起,agent 在上报公钥的同时用 RSA-OAEP 把私钥封装成密文一并提交,控制面存进 nodes.wireguard_private_key_escrow。
公钥一致(matched)时也会刷新密文——换了恢复公钥之后,旧密文会被新的替换。
恢复路径
按丢失范围分两种,走的路完全不同。
只丢节点(数据库还在)
不需要托管。 密钥本来就在控制面:重装节点、让 agent 重新注册,materialize 会把原私钥连同其余配置一起下发,对端 peer 无需任何改动。
这是绝大多数情况,也是密钥落库带来的直接好处。
数据库也丢了(需要托管,且需事先启用)
前提是启用过托管、且手上有导出的密文与离线恢复私钥。
从备份或残留数据里取出该节点的密文(base64 blob)。
在离线机器上解封:
bashpython deploy/tools/dn42-recover/dn42_recover.py recover \ --recovery-private ./recovery-keys/recovery-private.pem \ --ciphertext <escrow-blob 或文件> \ --expect-public <该节点已知 WG 公钥> # 可选校验--expect-public校验解出的私钥确实对应预期公钥,防止恢复错节点。把还原出的私钥灌回控制面的密钥表(显式轮换路径
set_node_key,或POST /admin/fleet/migrate-keys的收养逻辑),再重物化该节点。注意方向:私钥要回到控制面,不是回到节点的状态目录——节点侧已经没有密钥文件这个概念了。
没启用托管、数据库又没有可用备份时,只能给节点换一对新密钥并通知全部外部对端更新公钥;内部对端会随 materialize 自动跟随。
安全要点
- 恢复私钥永不出现在控制面或节点,只在离线
dn42-recover里短暂使用。 dn42_recover.py只依赖dn42_common.crypto,与控制面和 agent 的代码路径隔离。- 托管密文与私钥明文存在同一个数据库里(分别在
nodes与node_wireguard_keys两张表)。所以托管防的是「数据库整体丢失」,不防「数据库被读取」——后者要靠库的访问控制。 - 常规备份策略应当把
node_wireguard_keys视为最高敏感级别的表。