交接单按归属约定(正文落文档库、工作区只放指针)归档至 归档/交接单-20260924-归档/,含逐件判定 README: - 16 件已被文档库正式版取代(T09–T21 + 覆盖网络-24/25/26) - 4 件主题已被覆盖网络线入口汇总 - 7 件历史接续包/规划件 CODEBUDDY §9:收口清本棒 tmp、tmp 保留期 7 天、禁「待清理」中间态、 工作区入库只放文档与文件、不保留脚本副本、>60 KB 单文件须逐个判。
37 KiB
交接单 · 一机一钥 + 信任根(序 ③ / 清单 P0-4)
类型:执行棒(执行会话按本单开工,不读规划会话的上下文) 产出:覆盖网络线序 ③ —— 把「共享密钥 + 无成员资格校验」换成「一机一钥 + 离线信任根」的身份基线 上游:
接续入口_覆盖网络线_20260916.md §0/§2(唯一入口)|定序:③ → ④(443 兜底)→ ⑤(参数表·观测·权限评估) 前置已完工:① 会合/中继拆分(S0–S4 + R5)|② 网抽象 + 地址规划 + 引导(P0-1/2/3,单子交接单_网抽象与地址规划R6_20260916.md)⇒ ⛔ 不要重做 ①② 本单不含:443/TCP 兜底(序 ④)、参数表与观测(序 ⑤)、房间层/presence(第 7 步)
§1 目标
一句话:让「每台机器有自己的密钥、由离线信任根授权入网、可单台吊销且不牵动全网」这件事在 47(Manager)与 106(Worker)上真实跑通,并让 relay 的准入从「查共享 secret」变成「按 hostId 查成员资格」。
四条可验收的子目标:
- 四层密钥模型落地(形状照抄 Tailnet Lock):根(离线)→ 签名者(在线多把,受根授权)→ 节点密钥(每机一把)→ 会话密钥(内存,定期 rekey)。
- 入网 = 签名:节点公钥必须携带一个受信任签名者的签名,各节点在本地校验,验不过就不建立会话(⇒ 被攻破的控制面无法插入攻击者节点)。
- relay 准入改造:
keys表/文件从「单一共享凭据」改为按hostId索引,并补上 ② 单带回的待办② —— 「host 属哪张网」的成员资格校验(缺这一条 = 任何持有任意密钥的 host 都能进同一扁平命名空间)。 - 吊销与恢复可演练:撤销单台节点 ≠ 全网换密钥;根密钥有 ≥2 份离线副本且恢复演练跑过一次。
§2 只读前置(⛔ 只读,不改)
开工三件事(顺序不可颠倒):
"E:/ProgramData/.workbuddy/binaries/python/versions/3.13.12/python.exe" "E:/ProgramData/AI技能/aliyun-dsh-server/state.py"—— 看锁 / git 基线 / 在途单。- 抢锁:
bash "D:/github/dsh_shenxian/dsh-server-docs/scripts/handoff-guard.sh" --claim-exec "<你的会话名>"。抢不到 = 有会话在跑 = 停手(⛔ 不是"等一会儿再抢")。做完立即--release-exec。 - 读本单 §3 范围与 §4 决策点,确认没有需要现问用户的事项(若真命中红线,按 §4 的升级口径一句话问,⛔ 不许捆包)。
必读(按此顺序,读多少写多少,别全库摸底):
| # | 来源 | 读什么 |
|---|---|---|
| 1 | 覆盖网络_问题逐条推演与解决方案_20260916.md §A4(约 73–95 行) |
本单的方案正文:四层密钥模型表 + 恢复路径 + 结论(照抄 Tailnet Lock) |
| 2 | 覆盖网络_应用场景与待完善清单_20260916.md P0-4(第 80 行)+ §五 主线(93–103 行) |
缺口定义(根保管/轮换、私钥丢失恢复、被盗吊销)+ 序 ③ 的前后依赖 |
| 3 | 交接单_网抽象与地址规划R6_20260916.md 的 「执行进度」段 |
② 收官口径 + 待办② 原文(relay keys 按 hostId 索引、缺成员资格校验)⇒ 本单 §5-S3 的输入 |
| 4 | 交接单_relay落地R2-R4_20260916.md §9–§12 |
R2–R5 证据 + §12 的两条教训(A1 假 404:路由表未命中必须补齐再失败关闭,⛔ 绝不静默回退默认机;A2 state.py 锁信号) |
| 5 | 代码仓 src/net/relay/*(server / client / dialer / wire / duplex / main / index)、src/config.ts、src/db/*(迁移版本号,当前到 v8) |
现状密钥校验点、白名单机制(DSHS_RELAY_DIALERS)、迁移写法 |
| 6 | 47 上 /opt/dshs/relay-keys.json 与两个 drop-in(均有 .bak-r5* 备份) |
现状密钥长什么样(⛔ 只读;改前必须先备份成 .bak-r6*) |
环境要点(别现查):
- 47 = Manager(控制面库 = 47 的 PG13
127.0.0.1:15432,单元dshs-pg;PG 只能 TCP);106 = Workerw-106(19000);47 另跑本地 Workerw-47(19100)。 - 布局不对称:47 =
/opt/dshs/lib/,106 =/opt/dshs-cluster/lib/;部署 = 本机 build 后 scp(⛔ 不在服务器上改码)。 npm test必须 Node 22(E:/ProgramData/.workbuddy/binaries/node/versions/22.22.2-3/node.exe)。- 47 的 bubblewrap = 0.4.0(
--perms属 0.5+ ⇒ 用--tmpfs);106 = 0.11.0。 - 本机 shell:Python stdin/stdout 走
buffer显式 UTF-8;写文件newline="";含 Windows 路径的 Python 落.py再跑。
§3 范围
做:
- 密钥层次与落点(根 / 签名者 / 节点 / 会话四层)。
- 节点入网凭据的签发(签名者)与本地校验(各节点)。
- relay 准入改造:
keys按hostId索引 + 成员资格(host ∈ 哪张网)校验。 - 失败关闭:校验不过 ⇒ 明确拒(
403/ 专用错误码),⛔ 不得静默回退到共享凭据或默认 host(A1 教训)。 - 吊销路径(撤单台)与轮换路径(节点重签),各跑一次实测。
- 单测:新增用例先红后绿(照 A1 的做法),并挂进
npm test。
不做(⛔ 命中即越界):
- ⛔ 不自动升级 dsh(R1);⛔ 不改官方 dsh 主程序与缓存(R2)。
- ⛔ 不做序 ④(443/TCP 兜底)、序 ⑤(参数表/观测/权限评估)—— 各自的"信号出现"条件见 §3 末尾。
- ⛔ 不做房间层 / presence(第 7 步)。
- ⛔ 不新开任何公网端口:47 公网暴露面净增必须 = 0。
- ⛔ 不重做 ② 的任何一步(P0-1/2/3);⛔ 不重做 R0–R5;⛔ 不碰
32022/32023。 - ⛔ 不引入第三方 relay / 第三方 CA / 依赖型身份服务(自研路线已定)。
- ⛔ 不 commit、不 push(未获授权)。
- ⛔ 不做「网络变化 ⇒ 重新跑选点、换到更优中继」 —— 判据与代码已存在(
src/net/relay/placement.ts:速度+负载打分 / 满载=唯一硬门 / 失败降权 / 手动不被静默改选,T15 已验收),但未接线且当前只有 1 台中继(方案 §13.5「未做:Manager/门户侧接chooseNode();relay 集群的多中继选主」)⇒ 归属 序 ⑥(3–5 台最小形态)之后。本阶段「网络变化」的既有处置 = 取消剩余退避、立即重拨原地址(networkChanges,T12 已验收)。 - ⛔ 不做「应用级(presence / 房间 / 实例会话)断开重连后的状态恢复」 —— 归 第 7 步(仓库现无应用层代码)。本单只需保证身份层不成为它的阻塞项:节点密钥是"换点 / 重连"时唯一需要重新证明的东西。
✅ 不属于本单、但也不是缺口(回答「网络变化 / 断开重连怎么办」类提问时直接用这段,⛔ 不要再现查): 网络变化 ⇒ 立即重拨原地址、断开重连 ⇒ 连接 / 注册 / 路由三级恢复(流级恢复有意不做,交上层幂等重试)—— 均已实现并单测 + 47 真机验收,证据 = 方案 §12.2(五类场景×处置×恢复时间)/ §12.3(四级恢复语义)/ §12.4(T8–T15 + 真机输出)。 ⇒ ⛔ 不要在本单里重做;⛔ 也不要"顺手"给流级加重放日志 —— §12.3 ④ 明确判「不做」:假装能做 = 制造"看起来恢复了其实数据烂了"。 ⚠️ 唯一未覆盖的一层是应用级状态(见上一条),而那是第 7 步的事,不是本单的债。
越界信号(出现即停手,写进 §8):
- 需要扩大权限面(新挂载 / 放开遮蔽 / 暴露平台目录或 env / 放宽 nft / 提档位)⇒ R5,先出「权限影响评估」再问一句话。
- 需要外部玩家入网(P0-5 缺口 B)⇒ 那是独立一条,⛔ 不在本单内顺手做。
- 需要 VPN/代理类节点降级、443 兜底 ⇒ 记入 §8 的"交给序 ④",不在此实现。
§4 决策点
4.1 已定项(规划棒已拍,执行棒不得自行更改;要改必须回写 §8 并说明理由)
| # | 决定 | 理由 |
|---|---|---|
| D1 | 凭证形状 = 节点公钥 + 签名者签名信封(自签、无 x509、无第三方 CA) | 照抄 Tailnet Lock 的形状(§A4);x509 会引入 CA 生态与吊销列表搬运成本,而我们只需要"授权入网"一个语义 |
| D2 | 校验位置 = 节点本地(relay 侧只做辅助准入,不替代本地校验) | §A4 的核心:控制面被攻破仍插不进节点。若把校验只放在 relay,等于把根信任交给控制面 |
| D3 | 根密钥离线,在线签名者拿根授权;签名者可多把(≥1 把在 47) | §A4 四层模型;根在线 = 单点被攻破即全网伪造 |
| D4 | relay keys 改造与成员资格校验并入本单 |
入口 §0 已定序理由:② 待办② 与 ③ 天然衔接,拆成两棒会让"hostId 索引"和"签名校验"各改一遍同一处代码 |
| D5 | 失败关闭:任一校验未命中 ⇒ 显式拒绝 + 专用错误码,零请求发往默认机 | A1 教训(R2-R4 单 §12):静默回退 = 假 404 之源 |
| D6 | 迁移走新版本号 v9(当前 v8),⛔ 不改历史迁移 | 项目既有迁移纪律 |
4.2 交给执行棒自决(⛔ 不上升为提问)
判据:这些都有客观可判的优劣 ⇒ 自己排完序、选第一名、做掉,并在 §8 里写一句「我选了什么(可推翻)」。
- 密钥文件介质:
0600文件 / 系统密钥库 / 环境变量 —— 按"可备份性 × 权限最小 × 与现有 drop-in 机制一致"排序自选。 - 节点密钥算法:Ed25519(默认)vs 其他 —— 无特殊理由就用 Ed25519。
- relay 侧
keys的存储形态:relay-keys.json就地升级 vs 新增一张表 —— 以"能原子替换 + 可回滚"为先。 - 新增错误码 / 表结构 / 文件命名的具体取值。
- 重试与超时参数(在 §6 验收约束内自选)。
4.3 真需要用户拍板的(命中才问,且一轮只问这一句)
- 根密钥的离线保管人/介质(纸质恢复码放哪、离线副本存哪台设备)—— 这涉及用户手头的物理资产,不是技术选型可替代。
- 若用户此前未指定:执行棒按"本机 + 其一离线副本"落默认并继续,在 §8 里写清"现落点 = X,可推翻"。
- 根密钥的用途边界(⛔ 别搞错):它只用于授权 / 撤销「签名者」 —— 不签发节点、不加密数据、不参与会话。⇒ "根在线"不带来任何性能问题,唯一影响是 安全(泄露 ⇒ 可自行授权签名者 ⇒ 可插入任意节点,节点本地校验也拦不住,因为签名合法)与 恢复(丢失 ⇒ 极端情况下"全网重置")。
- 落点候选与判据(开发期用 C,收敛目标是 D;⛔ E 直接排除):
· A 纸质恢复码(打印/手抄种子,实体保管)—— 优:真离线、零电子攻击面、不受系统重装 / 云账号被锁 / 设备报废影响,成本≈0。劣:怕水火与搬家丢失、平台无法代管、被拍照即永久泄露(不像密钥文件还能改权限)。
· B 离线设备上的加密 keystore(U 盘 / 旧手机 / 离线笔记本 + 口令)—— 优:可机器读取、便于早期频繁签发、可存多份并校验完整性。劣:U 盘会坏 / 接口会淘汰、一旦插上联网机就不再"离线"、弱口令=等同于 A 泄露但更难发现。
· C 本工作区开发机的
0600文件(如~/.dshs/root.key)—— 优:零额外成本、可直接脚本化、恢复演练最省事,开发期推进最快。劣:本机不是沙箱且日常联网(浏览器 / 编辑器 / agent 同机)⇒ 泄露面大;⚠️ 它与"签名者"的天然落点同机 ⇒ 四层塌成两层,Tailnet Lock 的核心收益(控制面/签名者被攻破仍插不进节点)直接归零。⇒ 仅作开发期落点,必须标注"非最终"。 · D 两份异介质、异地副本(本机加密文件 + 纸质码 / 离线 U 盘)—— 优:满足 §A4「≥2 份离线副本」硬要求、单份损毁不致命、恢复演练可只动一份。劣:需维护两份一致(轮换时漏更新一份=埋雷)、保管成本翻倍;⚠️ 两份放在同屋 / 同云账号等于只有一份。 · E 平台服务器(47) —— 不建议:直接违反"根离线"这条设计本身;47 公网可达且已开 443 入站 ⇒ 攻击面最大;被攻破即同时拿到根 + 签名者 ⇒ 攻击者可另造一张网;且在 R5 上属"权限面 / 暴露面"变更,要单独出评估。
- 是否允许为校验链路新增一个常驻进程/单元 —— 只有在其扩大公网暴露面或占用新端口时才需问(否则命中 D1/D2 已定项,自决)。
4.4 技术实现裁决顺序(照 dsh-decision-method §4.4)
选型/写法冲突时依次看:① 权限最小(R5 精神)→ ② 失败关闭(D5)→ ③ 可回滚 → ④ 与现有一致(不新造第二套机制)→ ⑤ 性能。
§5 步骤(S0–S5,每步都要有可复跑的验收)
纪律:先单点验证再推广;每步做完立即写证据(命令原文级),别攒到最后;改任何文件前先确认已在 §2 第 2 步抢到的锁内。 备份纪律:47 上改动前,先把被改文件备份为
*.bak-r6-<实际时间>;回滚见 §7。
S0 · 只读取证(⛔ 不改任何东西)
- 摸清现状密钥校验点:
src/net/relay/*里"谁能连上 relay"是怎么判的、DSHS_RELAY_DIALERS白名单在哪判、relay-keys.json的实际结构。 - 摸清现状配置面:
src/config.ts里与 relay 身份相关的项;47/106 两个 drop-in 的 env 差异。 - 摸清DB 迁移写法(
src/db/*,看 v8 是怎么写的)。 - 出口判据:能用一段话画出"现在的身份链路"(谁拿什么凭据、在哪判、判不过会怎样),且指出至少一处"判不过会静默通过/静默回退"(若无,说明取证不足)。
S1 · 四层密钥模型落地(本机)
- 定义并实现四层的载体(根 / 签名者 / 节点 / 会话)—— 会话层若已有隧道 rekey 机制则复用,⛔ 不新造。
- 节点密钥每机一把,落点与权限(0600、属主正确)。
- 出口判据:
npm test绿;能列出两台机器各自的节点公钥指纹。
S2 · 签发与本地校验
- 签名者签发节点入网凭据;节点本地校验通过才建会话。
- 先红后绿:先写"签名不对 ⇒ 拒绝"的用例(必须观察到红),再实现。用例入
test/。 - 出口判据:① 单测含"篡改签名 ⇒ 拒绝"且先红后绿有记录;② 手工构造一个"未授权节点"⇒ 建不起会话,且日志里有明确拒绝原因。
S3 · relay 准入改造(hostId 索引 + 成员资格)
keys从共享凭据改为按hostId索引;补 host 属哪张网 的成员资格校验(输入 = ② 单待办②)。- 失败关闭:未命中 ⇒ 拒 + 专用错误码,零请求发往默认机。
- 出口判据:① 合法 host 正常连通(47 本地 + 106 跨机各一例);② 「拿 A 网的凭据进 B 网」被拒;③ 「伪造/未登记 hostId」被拒;④ 三种拒绝路径均有可观测日志。
S4 · 吊销与恢复演练
- 吊销单台:撤销 w-106 的节点签名 ⇒ w-106 被拒;47 与 w-47 不受影响(这就是"撤销单台 ≠ 全网换密钥")。
- 轮换/重签:w-106 重新签发后恢复连通。
- 根密钥恢复演练:用离线副本重建信任根,走一次完整流程并记录耗时与踩坑。
- 出口判据:三条演练全部有命令原文级输出;恢复演练结论可为"通过"或"未通过(写清卡在哪)",⛔ 不得只写"应该可以"。
S5 · 端到端复验 + 不退化检查
- 复验用户可见面:
admin(w-47) 与guest(w-106) 两个实例页 200;跨机文件面落盘仍 OK。 - 不退化判据:47 公网暴露面净增 = 0;
w-106仍via='relay';relay 常驻未掉。 - 对账:本机
lib/vs 47/opt/dshs/lib/0 差异(照 R5 的 29/29 对账做法)。 - 出口判据:§6 全部判据逐条打勾,未过的条目必须写明卡在哪 + 什么条件一出现必须回头解决(⛔ 不许用"暂时接受"糊过去)。
§6 验收(判据清单)
| # | 判据 | 怎么测 | 过 / 不过 |
|---|---|---|---|
| 1 | 每台机器有独立节点密钥,指纹互不相同 | 打印两机公钥指纹比对 | ☐ |
| 2 | 篡改签名的入网凭据建不起会话 | 单测(先红后绿)+ 手工构造 | ☐ |
| 3 | 未授权节点被拒,且日志有明确原因 | 手工构造未授权节点连接 | ☐ |
| 4 | relay 准入按 hostId,且校验成员资格 | 三例:合法 / 跨网 / 未登记 | ☐ |
| 5 | 失败关闭:任何未命中 ⇒ 显式拒绝,零请求发默认机 | 判别器 = relay 有无 DIAL;辅以 not_found=0 |
☐ |
| 6 | 撤销单台不牵动全网:撤 w-106 ⇒ 106 拒、47 与 w-47 正常 | S4 吊销演练输出 | ☐ |
| 7 | 重签后恢复连通 | S4 轮换演练输出 | ☐ |
| 8 | 根密钥 ≥2 份离线副本 + 恢复演练已跑 | 副本清点 + 演练记录 | ☐ |
| 9 | 密钥文件权限 0600、属主正确 |
stat 输出 |
☐ |
| 10 | 不退化:两实例页 200、跨机文件面 OK、w-106 via='relay' |
端到端复验 | ☐ |
| 11 | 47 公网暴露面净增 = 0 | 端口清点前后对比 | ☐ |
| 12 | npm test 全绿(含新增用例) |
Node 22 跑 | ☐ |
| 13 | 本机 lib/ vs 47 /opt/dshs/lib/ 0 差异 |
逐文件 hash 对账 | ☐ |
判别器(复用,⛔ 别自创):静默失效一律用判别器定位 —— ① relay 有没有 DIAL(没有 = 请求根本没出去);② 临时监听目标口把请求接住;③ 端点翻译只认 dsh_hosts.via 原文(⛔ 不是 reachability.via)。
§7 回滚
粒度:能单步回滚的单步回滚,⛔ 不整包推倒。
| 层级 | 动作 | 判据 |
|---|---|---|
| 配置层 | 恢复 *.bak-r6-* → systemctl daemon-reload → systemctl restart dshs |
两实例页回落 200 |
| relay 侧 | 恢复 relay-keys.json 旧表(改前必备份)→ 重启 relay 单元 |
47 本地 + 106 跨机连通恢复 |
| DB 迁移层 | v9 回滚 = 恢复表结构 + 重启(迁移脚本必须自带"回滚段"或至少一份导出) | dsh_hosts.via 等字段仍可读 |
| 代码层 | 回到改前 commit(工作区当前有未提交改动 ⇒ 先 git stash 或导出一份 diff 存档,⛔ 不许 checkout -- 抹掉别人的改动) |
npm test 绿 + 端到端复验 |
| 全量兜底 | cluster 回滚 = 删 drop-in dshs.service.d/cluster.conf → daemon-reload → restart dshs(⚠️ cluster 配置在这个 drop-in 里,⛔ 不在 /etc/dshs.env) |
退回单机 Manager 形态 |
⛔ 回滚禁区:不许用 rm -rf / 通配符清目录;不许 git add -A;不许对 INDEX.md 跑 git checkout --。
§8 回报格式(执行棒按此格式收口)
【序 ③ 一机一钥 + 信任根 · 执行回报(YYYY-MM-DD HH:MM)】
一句话判定:<做到哪一步 / 全绿 / 卡在哪>
改了什么(文件级,含行号):
· <本机:路径:行 —— 一句话>
· <47 / 106:drop-in / 文件 —— 一句话>
· 备份:<*.bak-r6-* 落点>
验收证据(命令原文级,逐条对 §6 编号):
· [1] <命令 ⇒ 关键输出>
· [2] ...
· [未过] [N] 卡在哪 = <证据>;已做到 = <到哪>;什么条件一出现必须回头解决 = <条件>
我选了什么(可推翻):
· <§4.2 的每一项一行>
未做 / 越界记录:
· <交序 ④ 的:443 兜底 | 交序 ⑤ 的:参数表/观测/权限评估 | 命中 R5 待评估的:…>
下一棒建议:<一句话 + 该读哪份文档>
指纹:md5(<本单路径>) = <hash>;54/106 产物 hash 对账 = <n/n>
收口动作(缺一即视为没做完):
- 释放锁:
--release-exec(⛔ 带锁结束 = 把所有人挡在门外)。 - 登记下一棒的一次性 automation(
automation_update,mode=create,scheduleType=once,约 +2 分钟),prompt 只写「跑state.py,按 覆盖网络线 §2「本轮动作」块开工 + 本轮只做一件事做完即停」——⛔ 不许把任务细节抄进 prompt。 - 在给用户的回复里用陈述句告知:已登记自动接续、约 N 分钟后自动开新会话、接续点 = X。
- 产出物调
present_files交付。 - ⛔ 不 commit、不 push。
附:本单相关的硬约束(复述,防走偏)
- R5 权限只准收窄;R7 只做被明确要求的事,额外发现的问题先报告后动手;R10 实例起不来先查属主/EACCES 再怀疑 OOM。
- hook 脚本必须走
sys.stdout.buffer.write(bytes)(文本层write+ 非 UTF-8 文案 ⇒ 静默放行)。 - 两条命令坑:
pkill -f匹配实际 argv;fetch静默丢Host头(多租户验证必须curl -H "Host: …")。 - 两条取数坑:本机
md5sum输出hash *path、远端hash path(先归一化再 diff);ssh '…'单引号里不能出现单引号(用cat > f <<"EOF"落文件)。 - 本机
refs/remotes/**写不进 ⇒ 分叉判定必须git ls-remote origin refs/heads/master取裸 sha(本单不涉及 push,仅备查)。
§8 执行回报(执行棒 · 2026-09-17 08:32)—— 已回填
【序 ③ 一机一钥 + 信任根 · 执行回报(2026-09-17 08:32)】
一句话判定:S0–S5 全走完。判据 [1]–[9] / [11] / [12] / [13] **全过**;判据 [10] **部分未过**
—— guest(w-106) 实例页 502,真因在**平台的远端实例凭据落盘**(`landModels →
writeHomeFile` 用**本地路径**写 w-106 的 home),**与序 ③ 无关**(覆盖网络侧已用
「经 relay 落点直连 w-106:21000」证明通路完好,见 [10])。
改了什么(文件级,含行号):
· 本机(代码仓 D:\github\dsh_shenxian)——
- `src/net/relay/identity.ts`(**新建** 657 行):四层密钥模型(根→签名者→节点→会话)+
签发/验签/吊销/本地校验的**纯函数与 IO 分区**;判据全在本模块(relay 只做辅助准入)。
- `src/net/relay/keys.ts`(**重写**):`RelayKeyEntry{network,secret}` + `RelayKeyMap`;
键从 `hostId` 改**逻辑名 `<net>/<hostId>`**;新增 `normalizeKeyRecord` / `isQualifiedName`
/ `lookupKey` / `describeKeyEntry` / `keyEntryOf`;`parseKeysInline` 切点 = **最后一个 `:`**。
- `src/net/relay/server.ts`(4 处):握手改 `lookupKey(keys, network, hostId)` +
`entry.network !== network ⇒ deny('network-mismatch')`;MAC 之后新增身份校验块
(`identity-no-trusted-signers` / `identity-incomplete` / `identity-${reason}`);
`authed()` 计数新增 `identityOk` / `identityRequired` / `trustedSigners` / `revokedHosts`。
- `src/net/relay/client.ts`:新增 `identity?` 字段与 `identityFields(challenge)`
(**MAC 输入串一字未改**,只追加字段)。
- `src/net/relay/main.ts`:`loadIdentityForServer()`(强制 + 无签名者 ⇒ **起动即抛**)
+ client 侧装配 `loadClientIdentity`。
- `src/net/relay/index.ts`(barrel 同步);`src/config.ts:513-531` 新增
`overlayNetworkId` / `overlayDirTrustedKeys` / `overlayNodeKeyFile` / `overlayNodeGrantFile`。
- `src/worker/relay-tunnel.ts`(+`identity` 选项)/ `src/worker/agent.ts:188`(接线)/
`src/web/server.ts`(Manager 拨号通道接线)。
- `scripts/overlay-keyring.cjs`(**新建** CLI):init-root / init-signer / init-node /
sign-signerset / issue-grant / sign-revocations / verify-grant / recover-root;
本次补 `sign-signerset --issued-at` 与 `recover-root` 的**判据②③**(原先 docstring 写了
三条判据、实现只有①,属文档与实现不一致,已补齐)。私钥一律 `writeSecret()` → 0600,
stdout 只打印公钥/指纹。
- `test/overlay-identity.test.mjs`(**新建** 25 例:A1–A4 签名者集合 / B1–B12 凭据校验 /
C1–C3 keys 带网 / D1–D7 端到端含 D5 **先红后绿** / E1 文件面);
既有测试按新语义更新:`test/relay.test.mjs`(T2)、`test/overlay-auth.test.mjs`(A6)、
`test/overlay-network.test.mjs`(U3/U4/U5);`package.json` test 脚本挂上 identity 用例。
· 47(Manager + relay)——
- 代码:`/opt/dsh-relay/lib/**`(备份 `lib.bak-r6-20260917-0815`)、
`/opt/dshs/lib/**`(备份 `lib.bak-r6-20260917-0820`);relay 对账 26/26、dshs 对账 148/148 全同。
- 密钥:`/etc/dshs/overlay-signer-key.pem`(600)+ `.pub`|`node-manager.key`(600)、
`node-w47.key`(600)|`node-manager.grant.json` / `node-w-47.grant.json` /
`node-w-106.grant.json`|`overlay-signers.json`(根签)|`revocations.json`(签名者签,当前空)。
- 单元:`dshs.service.d/identity.conf`(**新**,Manager 带凭据);
`dshs-relay.service.d/identity.conf`(**改**,备份 `.bak-r6-20260917-0830`;现为
ROOT_PUBKEYS + SIGNER_SET_FILE + REVOCATIONS_FILE + **REQUIRE_IDENTITY=1**)。
- `/etc/dshs-worker.env` 追加 2 行(`DSHS_OVERLAY_NODE_KEY_FILE` / `..._GRANT_FILE`)。
· 106(Worker)——
- 代码:`/opt/dshs-cluster/lib/**`(备份 `lib.bak-r6-20260917-0820`)。
- 密钥:`/etc/dshs/node.key`(600,**指纹 9a0189a4fb4e52f9**)→ 轮换后 `node.rot.key`
(600,**指纹 8cc227320405b986**);`node.grant.json` / `node-w-106.rot.grant.json`;
`overlay-signers.json`。
- `/etc/dshs-worker.env` 改 2 行(备份 `/etc/dshs-worker.env.bak-r6-20260917-0825`)。
· 本机(离线根):`E:\ProgramData\.dshs\{root.key(0600), root.pub, root.recovery-code.txt(0600)}`
· 备份:`lib.bak-r6-20260917-0815`(relay)/ `lib.bak-r6-20260917-0820`(47 dshs + 106 cluster)/
`identity.conf.bak-r6-20260917-0830`(relay drop-in)/ `dshs-worker.env.bak-r6-20260917-0825`(106)
指纹(一次性记全,后续对账用):
· 离线根 pub `bd6d1219571aa70904b227d3e5e86c3d0c08125312d1b5f9e31da4d71b914dd3` / fp `3f6523302720c531`
· 在线签名者 pub `bad464dfd53048efe7b8531029b3030eda49bc12930703d3a2a8f60e3a7daddf` / fp `ca6e5a1e329c22b5`
· 节点 manager fp `b60215af4c12f835`|w-47 fp `83269876c41644c0`|w-106 fp `9a0189a4fb4e52f9` → 轮换 fp `8cc227320405b986`
验收证据(命令原文级,逐条对 §6 编号):
· [1] 每机独立节点密钥、指纹互不相同 ⇒ 三份 grant 的 nodeKey 互不相同:
manager `2e1c…`(fp b60215af4c12f835) / w-47(fp 83269876c41644c0) / w-106(fp 9a0189a4fb4e52f9);
轮换实验再证「一机一钥可换」:106 `init-node --key /etc/dshs/node.rot.key` ⇒
`✓ 节点公钥:5e65572611ad6a37c487136086d7852e6fb29c7f530b3a37161eded57f31bf6a` / fp `8cc227320405b986`。
· [2] 篡改签名的入网凭据建不起会话**(先红后绿)** ⇒ `test/overlay-identity.test.mjs` B 组 12 例
(签名篡改 → `bad-signature`;凭据搬 hostId → `host-mismatch`;换密钥 → `key-mismatch`;
过期 → `expired`)+ D5「门关时同一份坏凭据能注册(红)→ 门开时被拒(绿)」。
· [3] 未授权节点被拒且**日志有明确原因** ⇒ 真机两条独立证据:
relay 侧 `[relay] AUTH DENY remote=127.0.0.1:33802 why=identity-revoked-host retryable=false`(×14);
106 侧 `[relay-client] HELLO rejected reason=identity-revoked-host retryable=false clockSkew≈189ms`。
· [4] relay 准入按 hostId + 校验成员资格 ⇒ `keys.ts` 键改**逻辑名**后:
合法(`AUTH OK host=ops/manager` / `host=ops/w-106`);跨网(`network-mismatch`,且
`overlay-auth` A6 / `overlay-network` U3/U4/U5 四处既有测试按**新语义**更新并通过);
未登记 hostId(`keys` 层即拒)。三种路径均有 `AUTH DENY why=…` 日志。
· [5] 失败关闭、零请求发默认机 ⇒ 吊销态下 guest 请求:
dshs 侧 `[relay-dialer] 拨 ops/w-106:19000 失败:relay client: dial w-106:19000 refused: target-offline`;
relay `refused` 计数 0→4;**未见任何回退默认机**(A1 的 `ensureHost` 失败关闭仍在位)。
· [6] 撤销单台不牵动全网 ⇒ 吊销 w-106 后:
`counters = {"authed":1,"authFailed":14,"refused":4,"identityOk":1,"identityRequired":true,"trustedSigners":1,"revokedHosts":1}`;
`online = ["manager"]`(**manager 会话 upForMs=180681 全程未断**);
`[恢复态] admin 实例页(w-47)HTTP 200 time=0.050s`;`dsh_hosts`:`w-47|local|ops|up`。
· [7] 重签后恢复连通 ⇒ 重签**空**吊销清单 + `restart dshs-relay`:
`[identity] 吊销清单:net=ops hosts=[] nodeKeys=0` → `[relay] AUTH OK host=ops/w-106 session=63607538827ee044 ports=[19000]`;
`counters`:`identityOk=3 / identityRequired=true / revokedHosts=0`;`online=["manager","w-106"]`。
🔑 附一条**更强的性质**:先在 106 上**轮换节点密钥**(换新 grant)后**仍被拒**
(`HELLO rejected reason=identity-revoked-host`)⇒ 证明「**撤 hostId 比换密钥更强**」。
· [8] 根密钥 ≥2 份离线副本 + 恢复演练 ⇒ **演练三判据全绿,耗时 794 ms**:
`✓ [判据①] 与期望根公钥逐字节一致`;
`✓ [判据②] 重建的根签出的 SignerSet(1 把)经原根公钥验签通过`;
`✓ [判据③] 签名与原根私钥签出的逐字节相同`(Ed25519 确定性 ⇒ 排除"看着像其实不是同一把")。
⚠️ **副本数 = 2 份「离线保管载体」但都在本机**:`root.key`(0600) + `root.recovery-code.txt`(0600,96 hex);
真离线介质(U 盘 / 纸)需你亲落 ⇒ 本条判 **部分过**。
(演练从**恢复码**重建,**不使用**原根文件;原根仅用于产出待比对的基准签名。演练产物含一份根私钥副本,已即时删除。)
· [9] 密钥文件权限 0600、属主正确 ⇒ 47 `600 root:root`:`node-manager.key` / `node-w47.key` /
`overlay-signer-key.pem` / `overlay-dir-key.pem` / `relay-keys.json`;106 `600 root:root`:
`node.key` / `node.rot.key` / `relay-keys.json`。(`.pub` 与**已签名清单**为 644,不含秘密。)
· [10] 不退化 ⇒ **三条过、一条未过**:
✅ admin(w-47) 实例页 `HTTP 200 time=0.050s`;
✅ `dsh_hosts`:`w-106|relay|ops|up`(`via='relay'` 保持);
✅ relay 常驻未掉(`systemctl is-active dshs-relay` = active,`identityRequired=true`);
✅ **覆盖网络侧端到端活证**:经 relay 本机落点 `curl -H "Host: guest.alotbuy.com" http://127.0.0.1:34539/`
⇒ `HTTP 401 68B`,与 **106 本机直连 `http://127.0.0.1:21000/` 的 `401 68B` 完全一致**
(relay `endpoints` 同时给出 w-106 的 `19000` 与 `21000`,`streams>0`、`in/out` 有流量);
⛔ **未过** guest(w-106) 实例页 = **nginx 502(0.70 s,确定性复现,非超时)**。
· [11] 47 公网暴露面净增 = 0 ⇒ 公网监听集合 =
`0.0.0.0:22 / 0.0.0.0:80 / 0.0.0.0:443 / 0.0.0.0:58888(BT-Panel) / 0.0.0.0:888 / 0.0.0.0:8765 / [::]:22`
—— **全是原有的**;序 ③ 新增的只有**回环**口:`127.0.0.1:20080`(relay)与 `127.0.0.1:25000+`(拨号落点池)。
· [12] npm test 全绿 ⇒ Node **v22.22.2**:`# tests 130 / # pass 129 / # fail 0 / # cancelled 0 / # skipped 1`
(31914 ms),随后 `verify-inject` 六项全 `✓`、`结论:全部合格 ✅`。
· [13] 本机 lib vs 47 /opt/dshs/lib **0 差异** ⇒ `local=148 remote=148`,路径不对称 0,内容不同 0;
另:relay 侧 `local=26 remote=26`,0 差异;
`scripts/overlay-keyring.cjs` md5 `0727cd436c3b923e48d0c8dd654d2ac4` **三处一致**(本机 / 47 / 106)。
· [未过] [10] 卡在哪 = ① `dsh_instances` 里 guest 记录 `status=stopped`(真机上实例却在跑),
⇒ 平台走 `RemoteSpawner.launch`;② 该路径上 `landModels → home-files.js:45 writeHomeFile`
打开 **`/var/lib/dshs/users/4092b965-…/home/.credentials.yaml`** 时 `ENOENT`
—— 这是**47 本地路径**,而 guest 的 home 自 09-16 22:51 起已在 **106**(47 上该目录只剩 `ws/`);
日志原文:`model landing failed, falling back to env injection Error: ENOENT: no such file or directory, open '…/home/.credentials.yaml'`,
栈:`writeHomeFile(home-files.js:45) → landModels(server.js:172) → resolveApiKey(server.js:200) →
RemoteSpawner.launch(remote-spawner.js:169)`;③ 该请求在 dshs 侧 `incoming request` 之后**无
`request completed`**,由 nginx 返 502。
**为什么判定与序 ③ 无关**:失败点在「凭据写 home 的本地路径」,与 relay 身份准入无交集;
且同一时刻的覆盖网络证据全绿 —— relay 已建立到 w-106 的 `19000`/`21000` 两条隧道、
`dial up` 成功、经隧道取实例回 401(活)。**序 ③ 只动了 identity / keys 逻辑名 / relay 准入 / 配置,
未触碰 `landModels`、`home-files`、`remote-spawner`、`dsh_instances` 状态机。**
已做到 = 网络面与身份面全部证明完好、admin 面 200、平台与实例两侧均无崩溃(`dshs` NRestarts=0、
`dshs-worker` NRestarts=0)。
什么条件一出现必须回头解决 = **① `landModels/writeHomeFile` 改为经 `RemoteUserFs` 写远端实例 home
(或平台对「实例已在跑但 DB 记 stopped」做状态对账);② 或 guest 实例状态回到 `running` 后,
立即重测判据 [10] 的 guest 页**。⇒ 建议并入序 ④ 的相邻缺陷清单,**不在本单范围内顺手改**(R7)。
我选了什么(可推翻):
· 密钥表键 = **逻辑名 `<net>/<hostId>`**(不是裸 hostId)—— 因 U5 暴露"同 hostId 无法存在于两张网"是净变差。
· 受信根 = `DSHS_OVERLAY_ROOT_PUBKEYS` **独立于** `DSHS_OVERLAY_DIR_PUBKEYS`(⛔ 不合并:合并等于让"能换地址的人"顺带能加签名者)。
· 复用 `directory.ts` 的 Ed25519 原语,**不新造第二套签名机制**(符合 D1/D6)。
· 强制身份**本轮就开**(`REQUIRE_IDENTITY=1`)而非留到下一轮:三台节点均已带身份且实测 `identityOk=3`,
吊销演练**只有开强制才可观察**;回滚 = 删该两行 + `daemon-reload` + `restart dshs-relay`(单步)。
· 106 节点密钥**就地轮换**(`node.rot.key` 取代 `node.key`,env 指向新文件)—— 顺带证明"一机一钥可换"。
· 密钥仪式工具**部署到 47/106**(`/opt/dsh-relay/scripts/`、`/opt/dshs-cluster/scripts/`),
因为**签名者私钥不出机器** ⇒ 签发/吊销必须在 47 上跑。
· 报告与单子同文件(§8 就地回填),不另开文件。
未做 / 越界记录:
· 交 **序 ④** 的:443/TCP 兜底(本单未碰)。
· 交 **序 ⑤** 的:参数表 / 观测 / 权限评估(本单未碰)。
· **命中 R5 待评估的:无** —— 本次所有改动都是**收窄**(relay 从"任何持任意密钥者都能声称任意合法网名"改为
"凭据 + 成员资格 + 吊销"三级;公网暴露面净增 0)。
· 额外发现、**只报告未动手**(R7):
① [10] 的平台侧远端凭据落盘缺陷(见上);
② `/opt/dshs/mksess*.cjs` 仍写 **`/var/lib/dshs/dshs.db`(SQLite,仅回滚用)**,
而权威库已是 47 的 PG13 ⇒ **该工具已失效**(本次改用 PG 直插临时 session 完成 R4 合规验收,用完即删,残留 0)。
· 本单**未 commit / 未 push**(按约束 4)。
下一棒建议:**序 ④ · 443/TCP 兜底** —— 先出**规划棒**(给序 ④ 出一份可执行交接单),
读 `D:\github\dsh_shenxian\dsh-server-docs\` 里 `覆盖网络_应用场景与待完善清单 §五` +
工作区根 `接续入口_覆盖网络线_20260916.md §0/§2`;⚠️ 把上面 [10] 的两条额外发现并入其相邻缺陷清单。
指纹:md5(本单) = 260a2e84a8a482cf08705885735986ee(回填前);本机/47/106 产物 hash 对账 = 148/148 + 26/26 + 3/3 全同
📌 回填说明:上面代码块是本节交付物本身(§8 要求的报告),未改动 §1–§7 任何一行。