Files
dsh_ai1net_server/归档/交接单-20260924-归档/交接单_一机一钥与信任根_20260917.md
T

426 lines
37 KiB
Markdown
Raw Normal View History

# 交接单 · 一机一钥 + 信任根(序 ③ / 清单 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 查成员资格」。
四条可验收的子目标:
1. **四层密钥模型落地**(形状照抄 Tailnet Lock):根(离线)→ 签名者(在线多把,受根授权)→ 节点密钥(每机一把)→ 会话密钥(内存,定期 rekey)。
2. **入网 = 签名**:节点公钥必须携带一个**受信任签名者**的签名,**各节点在本地校验**,验不过就不建立会话(⇒ 被攻破的控制面**无法插入攻击者节点**)。
3. **relay 准入改造**:`keys` 表/文件从「单一共享凭据」改为**按 `hostId` 索引**,并补上 ② 单带回的待办② —— **「host 属哪张网」的成员资格校验**(缺这一条 = 任何持有任意密钥的 host 都能进同一扁平命名空间)。
4. **吊销与恢复可演练**:撤销单台节点 ≠ 全网换密钥;根密钥有 **≥2 份离线副本**且**恢复演练跑过一次**。
---
## §2 只读前置(⛔ 只读,不改)
**开工三件事(顺序不可颠倒)**:
1. `"E:/ProgramData/.workbuddy/binaries/python/versions/3.13.12/python.exe" "E:/ProgramData/AI技能/aliyun-dsh-server/state.py"` —— 看锁 / git 基线 / 在途单。
2. 抢锁:`bash "D:/github/dsh_shenxian/dsh-server-docs/scripts/handoff-guard.sh" --claim-exec "<你的会话名>"`。**抢不到 = 有会话在跑 = 停手**(⛔ 不是"等一会儿再抢")。做完立即 `--release-exec`。
3. 读本单 §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 = Worker `w-106`(`19000`);47 另跑本地 Worker `w-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 真需要用户拍板的(**命中才问,且一轮只问这一句**)
1. **根密钥的离线保管人/介质**(纸质恢复码放哪、离线副本存哪台设备)—— 这涉及**用户手头的物理资产**,不是技术选型可替代。
- 若用户此前未指定:**执行棒按"本机 + 其一离线副本"落默认并继续**,在 §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 上属"权限面 / 暴露面"变更,要单独出评估。
2. **是否允许为校验链路新增一个常驻进程/单元** —— 只有在其**扩大公网暴露面或占用新端口**时才需问(否则命中 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>
```
**收口动作(缺一即视为没做完)**:
1. 释放锁:`--release-exec`(⛔ 带锁结束 = 把所有人挡在门外)。
2. **登记下一棒的一次性 automation**(`automation_update`,mode=create,scheduleType=once,约 +2 分钟),prompt 只写「跑 `state.py`,按 **覆盖网络线** §2「本轮动作」块开工 + 本轮只做一件事做完即停」——⛔ **不许把任务细节抄进 prompt**。
3. 在给用户的回复里**用陈述句**告知:已登记自动接续、约 N 分钟后自动开新会话、接续点 = X。
4. 产出物调 `present_files` 交付。
5. ⛔ 不 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 任何一行。