docs: 工作区根项目文档批量入仓(04-调整方案 113–128 / 交接单 T09–T21 / ops / archive)

起因:用户 2026-09-17 明确「所有文档都要同步,都放在开发仓库 docs 对应文件夹下」。
判据:工作区根 *.md 中在仓库(git ls-files --quotepath=false)搜不到的那些。

入仓 33 份(一律复制,工作区根原件保留不动,避免引用断链):
- 04-调整方案/113–128(16 份 · 原子占号后落盘):覆盖网络 传输方案取舍 / 应用场景与待完善清单 /
  插件化vs改内核 / 问题逐条推演 / 参数表与观测口径;会合中继拆分取证与改造方案;
  集群化改造方案 Manager-Worker;跨节点迁移与节点自举;项目代码分层范式与迭代风险评估;
  搬运与共享重建方案 guest w47→w106;方案规划方法提炼;文档无效信息审计报告;
  会话接续机制复盘与修复;会话接续规范;dsh 客户端化部署方案;dsh 桌面客户端开发方案
- 交接单/archive/交接单-已完成/T09–T21(13 份 · 覆盖网络线已完成单归档)
- ops/(2 份运行态指针:接续入口 / 接续包 · 覆盖网络线)
- archive/(2 份临时与内部简报)

已排除(无需重复入仓):覆盖网络线 10 份方案正文已入档案 103–112(文件名不同)。

登记:INDEX.md §二 新增 04-113–128 共 16 行 + §四 追加 T09–T21 说明 + 机器摘要行刷新
(⛔ 未跑 docs-index-stats.py --write:该脚本会按 \r\n 归一化全文件行尾,故改为字节级单行替换);
README.md 追加 1 条入仓指针;docs-manifest.json 复跑 scripts/docs-manifest.py 刷新。

验收:工作区根 47 份 .md —— 同名已入仓 8 / 本次内容一致 29 / 已知改名映射 10 / 未入仓 0;
git status 待提交清单只含本次新增与登记 3 件(未涉 src/ 与 relay 代码面)。
This commit is contained in:
admin committed 2026-09-17 18:24:19 +08:00
1 parent bc0dd2c96d
commit 04776af4b1
36 files changed
+11840 -43

No files matched your search

@@ -0,0 +1,600 @@
# 覆盖网络 · 传输方案取舍:开放端口 vs 自研 relay(2026-09-16)
> **缘起**(用户原话):「我在 106 腾讯云和服务器上开放端口是否可以解决这个问题,开放端口安全性能否得到保障,现在 47 和 106 的连接方案也是走的 ssh 是临时的方案」
---
## 0. 结论先行
- **开放端口能解决"谁连谁",但不是更优解** —— 它把今天「**worker 只拨出、不开入站**」的形态,换成「每台 worker 都要暴露一个公网入口」⇒ **暴露面从 O(1) 变 O(N)**。
- **SSH 隧道确实只是"第一个可替换实现",不是终态**。但替代它的正解是 **自研 relay + worker 出向单端口长连接**(**不需要开放任何入站端口**),而不是靠开放端口。
- 安全性的决定因素不是"开不开端口",而是 **"谁是服务端"**:**worker 永远只做出向连接**(今天已经是这个形状),攻击面最小。
---
## 1. 现在到底怎么连的(实测事实)
| 方向 | 事实 |
|---|---|
| 106 → 47 | `ssh -M -N -f -R <port>:127.0.0.1:<port> root@47:32022` —— **106 主动拨出**(只需出向) |
| 47 → 106 | 走 47 的 `127.0.0.1:<port>`(隧道落点),**47 从不主动连 106** |
| 谁开了入站端口 | **只有 47**:`0.0.0.0:32022`(sshd)、`80/443`(nginx)、`888`、`8765`、`58888`。**106 为平台开了 0 个入站口** |
⇒ **今天的形态已经是"最小暴露"**:**新增一台 worker 完全不需要碰云安全组**。这是"worker 拨出"这个方向带来的核心收益,容易被忽略。
---
## 2. 「在 106 开放端口」得到什么、失去什么
**得到**:47 可直连 106 的 agent(19000),少一跳本地转发,延迟略低;不必维护隧道。
**失去(这才是重点)**:
1. **每台 worker 一个公网入口** ⇒ N 台机器 N 套安全组/防火墙配置,接入成本与出错面**随台数线性上升**;跨云(阿里云 47 + 腾讯云 106)还要两端同时配。
2. **agent 的认证是单密钥共享**(`x-dshs-agent-token`)⇒ 该通道被突破/密钥泄露 = **能指挥那台 worker 起停任意用户实例**。今天这条通道**只能在 47 的 loopback 上被触达**(隧道落点),开放后变公网可达 —— 属**权限扩大**(R5 的反面)。
3. 47 侧要为每台 worker 维护"我怎么连它"的地址(= `dsh_hosts.endpoint` 今天在做的事),worker 换 IP / 换云就要改控制面数据。
---
## 3. 若真要开放端口,安全性能保障吗
能保障到**"可控"**,但有前提,且**每一项都是新增的运维负担**:
- 云安全组**按源 IP 白名单**(106 侧只放 47 的公网 IP)—— 前提是 47 有固定公网 IP(目前是);
- 该端口**只跑 agent API**;认证从"共享 token"升级为 **mTLS + 定期轮换**;
- **单端口复用**(不要每实例一个端口)、限速、审计日志、失败即封。
⚠️ 即便如此,**仍不如"worker 只拨出"**:白名单一旦写错、或 47 的 IP 变更,就退化成"一个公网可达的 agent 入口"。
⇒ **可保障,但代价是把安全从「架构保证」降级为「配置保证」。**
---
## 4. 摆脱 SSH 的正解(按代价排序)
| 方案 | 需新开入站端口? | 说明 |
|---|---|---|
| **① 自研 relay:worker 拨出 + 单端口 TLS 长连接 + 多路复用** | ❌ **不需要** | 复用今天"worker 主动拨出"的形状,只把 sshd 换成自研 relay 进程;relay 侧维护 `(hostId, 实例端口) → 连接/流` 映射表。**这就是原方案 S5 的位置**(S5 原只写"443/TCP 兜底",可与本项合并做——那个"单端口"serves both) |
| ② 中继独立成单元(P4) | ⚠️ 需在 47 新开 **32023** | 只换绑定关系、不换协议;**当前卡在 R5 待授权** |
| ③ WireGuard / TURN 打洞 | ❌(但依赖 UDP 出向) | 异构网络(企业/校园网常封 UDP)不可靠 ⇒ 仍须保留 443 兜底,复杂度高 |
| ④ 每台 worker 开公网端口直连 | ✅ 需要 | 最省事、**安全面最大**,不推荐 |
---
## 5. 与既有决定的关系(不冲突)
- 与「**覆盖网络按异构设计**(中继 45% / **必须补 443/TCP 兜底**)」一致:那个兜底通道**就是 ① 的单端口**,正好一次做掉。
- 与「**单机自用也要互联**」一致:**租户维度收窄 ≠ 网络维度收窄** ⇒ "少开端口"是约束,不是可选项。
---
## 6. 我选了什么(可推翻)
**维持「worker 只拨出、不开任何入站端口」**;把"摆脱 SSH"的正确落点定为 **① 自研 relay + 单端口出向长连接**(不需要新开任何公网口),而不是在 106 上开端口。
**连带影响**:**P4(47 新开 32023)优先级下调** —— 它只是"换绑定"的过渡步,安全性上**不优于今天**(甚至新增暴露面)。
**剩下的两选一(这是 P4 那个 R5 门禁的实质)**:
- 选「不开」⇒ 我把 **P4 与 ① 合并**成一条"自研 relay(worker 只拨出)"的路线来做,**全程不新增公网端口**;
- 选「开」⇒ 我按原 P4 执行(32023 + 独立单元 + 非 root 账号),作为过渡。
---
# 7. 成熟方案调研与选型(2026-09-16 17:2x 追加 · 回答"自研 relay 有没有成熟方案 / SSH 做这个事专家会不会认为不安全")
## 7.1 先纠正一个前提:**不需要自研**
"worker 主动拨出、被中央服务反向暴露"这个形状**有成熟件,且被大量生产环境使用**。按适配度排序:
| 方案 | 形态 | 成熟度(检索实测) | 与本项目的适配 |
|---|---|---|---|
| **frp**(fatedier/frp) | frps 公网 + frpc 拨出;**单连接多路复用**;TCP/UDP | ⭐ **最成熟**:**v0.70.0(2026-07-11)**、**~10.6 万 star**、v0.50 起 **TLS 默认开**、静态 token + OIDC、`allowPorts` 白名单、dashboard | ✅ **首选**。语义几乎一对一:一个 frpc = 一台 worker,`[[proxies]]` = 每个实例端口,取代今天的 `ssh -R` |
| **rathole**(rapiz1/rathole) | 同上,Rust | 活跃;**<500 KiB** 单文件、**Noise_NK** 每服务 token、**热重载**、TCP+UDP;**无 dashboard**,生态远小于 frp | ✅ **备选**(资源极紧 / 想要最小二进制时)。基准自称吞吐 2–5× frp ⚠️ **厂商自测,别当结论** |
| **Headscale + Tailscale DERP** / **Nebula** | 完整 mesh overlay(会合 + 中继 + ACL) | 成熟(Slack 的 Nebula 已在 `瓶颈落地方案` 里被引用过) | ⚠️ **层级不同**:它自带"会合 + 身份 + ACL",会与我们的 Manager/租约/`via` 语义重叠 ⇒ 引入成本高,属**远期** |
| **chisel** | HTTP/WebSocket 隧道 | 成熟,常用于内网穿透 | ⚠️ 优势只在"网络**只**放 80/443";我们用 frp over 443 也能达到,故不必 |
| **Cloudflare Tunnel** | cloudflared 拨出,CF 边缘接 | 非常成熟 | ❌ **数据面交给 CF**,与"自建覆盖网络"目标冲突(且跨境链路受其调度) |
| **WireGuard(hub 模式)** | 各点 `PersistentKeepalive` 拨出到 hub | 成熟 | ⚠️ 需 UDP 出向;异构网络(企业/校园网)常封 ⇒ 仍要 443 兜底,复杂度高 |
⚠️ **性能数字别照抄**:检索到的 "FRP 920 Mbps / SSH 650 Mbps" 之类来自二手博客,**未经我们实测** ⇒ 只作方向参考("专用 relay 优于 SSH 转发"这个**方向**可信,具体倍数不可信)。
## 7.2 再看 SSH:专家会认为不安全吗?——**一半对、一半是误解**
**是误解的部分**:SSH 反向隧道**是"无公网 IP / 无入站"场景的标准做法之一**,2026 年多份运维指南仍把它列为"Solution 1",前提是**按规矩加固**:
- ✅ 中继上用**专用非特权账号**(不要 root);
- ✅ `sshd_config` 里下 `PermitOpen` 只放必要端口;
- ✅ 对该通道**限速 + 审计**。
**说得对的部分**(这些才是真批评,且我们**今天全都中**):
1. **凭据模型**:现在隧道以 **`root@47`** 登录(已加 `restrict,port-forwarding`,拿不到 shell,这点已收窄);但仍是**通用 sshd 上的登录凭据**,审计粒度粗、与运维 SSH 混在一条 `authorized_keys` 里。
2. **运营耦合**:中继是**宝塔面板也在用的那个 sshd** ⇒ 面板改 SSH 配置 / 重启 sshd 会波及整张覆盖网络。
3. **静默失败**:我们**已经实测撞到一次**——`-R` 失败时 `tunnel.forward()` 的返回值无人检查 ⇒ 撞号后静默不转发、拨到别人实例。SSH 的 `-O forward` 语义天生不擅长这种"要精确知道成功没有"的编排。
4. **多中继/负载均衡**:SSH 没有原生"多中继选主/轮询/健康检查"概念;frp/rathole 有。
5. **审计观感**:*"SSH 一般当命令行用"* 这个看法在评审场合确实常见 —— 它不是技术缺陷,但**是真实存在的沟通/合规成本**。
⇒ **结论**:SSH 隧道**不是"不安全",而是"不该长期做数据面"**。它适合当**第一个可替换实现**(已扮演好这个角色),不适合当终态。
## 7.3 由此重新拍 P4:**不做 SSH 版中继,直接换成熟 relay**
**理由**:P4(独立 sshd 单元)想拿到的两样东西 —— ① 与宝塔 sshd 解耦 ② 甩掉 root 凭据 —— **在 frp/rathole 方案里同样拿到**,而且顺带解决"专家观感"和 S5 的 443 兜底。⇒ **P4 与"换 relay"两步合一步,不先做 P4。**
**并且:换 relay 也**不必**新增公网端口。**
frp/rathole **同样需要一个公网入口**(frps 默认 `bindPort 7000`)。但可以让它**复用 47 已有的 443**:
```
worker(frpc) ──TLS/SNI──▶ 47:443 (nginx stream + ssl_preread)
└─ 按 SNI 分流到 127.0.0.1:7000 (frps)
└─ 暴露各实例端口
```
⇒ **零新增公网监听口**(R5 不触发),**同时把 S5「443/TCP 兜底」做掉**(既有决定里本来就要补的那条)。这也是为什么"换 relay"比"新开 32023"更划算。
## 7.4 修正后的路线(我选了什么 · 可推翻)
1. **⛔ 不做 P4(SSH 版中继单元)**;**不新开任何公网端口**。
2. **relay 采用 frp(首选)/ rathole(备选)**,形态 = "worker 拨出、relay 复用 443"。
3. 工作时序(每步可独立验收、可独立回滚,延续 P1–P3 的交付方式):
- **R1** 47 上装 frps(仅监听 `127.0.0.1:7000`,不碰公网口)+ 证书/SNI;本机与 47 之间先跑通"一条隧道"。
- **R2** nginx `stream` + `ssl_preread` 把 443 按 SNI 分流给它 ⇒ 从此 **443 就是兜底通道**(S5 达成)。
- **R3** 106 的 worker 侧把 sshd 隧道换成 frpc(env 切换,双路径并存 ⇒ 零代码回滚)。
- **R4** 观察一轮后下线 sshd 隧道路径 + 收回 47 上那条 `authorized_keys`。
4. **回滚**:每步都靠"env 切回 SSH 路径"回退,ssh 路径**在 R4 之前不删**。
---
# 8. 选型复核:**撤销"首选 frp"**(2026-09-16 17:3x,因用户质疑"项目久 ≠ 最好")
> 用户原话:「frp 要好好判断只是项目时间比较久,性能和安全性还真不一定是最好」
> **先认账**:§7.1 我把 frp 排第一,依据是 star 数、发布频率、生态 —— **那是"省心度"排序,不是"安全性/性能"排序**。这是**选型轴用错了**,本节修正。
## 8.1 frp 的实际安全面(检索实测,非推测)
| 项 | 事实 | 对我们的意味 |
|---|---|---|
| **CVE-2026-40910 / GHSA-26gq-p25f-99cp** | **认证机制绕过 + 未授权远程 DoS**,**影响 frp ≥ 0.53.0** | 有在野/可利用描述,且影响**近几年的所有版本** ⇒ 「项目久」反而意味着**被盯得久、漏洞历史长** |
| **dashboard 默认 `admin:admin`、口令明文存配置** | 官方引导必须 `webServer.addr = "127.0.0.1"` | 绑回环是**必须**,不是可选 |
| **`proxyBindAddr` 默认跟 `bindAddr`** | 官方原文:这是"**大多数指南遗漏的配置**";不设则 frp 为代理开的监听器**绑到公网** | 🔴 **致命**:不设它 = 我们"零新增公网口"的目标当场破掉 |
| **服务端默认不强制 TLS** | 需 `transport.tls.force = true` 才拒绝明文控制连接(客户端 v0.50 起默认 TLS) | 又一项"不设就静默降级" |
| **`auth.token` 是单一静态共享令牌,frpc 明文存** | 官方原文:没有它 frps 会接受任何找到 7000 端口的客户端 | ⇒ **一台 worker 失陷 = 可申请任意端口**(`allowPorts` 又是 opt-in 默认关) |
| 7000 / 7500 端口被持续扫描 | 公网 frps 是明确标靶 | 需 nft/安全组 + 非默认端口 |
⇒ **结论:frp 的"默认姿态不安全"** —— 上面 5 项**每一项都必须手工关/设**,少设一项就可能**静默**破掉我们的安全目标。这正是"老项目"的另一面:**默认值停留在历史约定上,安全靠运维纪律补**。
(正面:官方文档把正确姿势写得很清楚 —— `proxyBindAddr=127.0.0.1` + nginx 前置 443 + 非特权用户 + systemd 加固 `NoNewPrivileges/ProtectSystem/ProtectHome`;且修得快,**v0.71.0 = 2026-08-14**,约一月一版。)
## 8.2 按**我们的**判据重排(这才是该用的轴)
**判据(按重要性)**:① 认证/授权模型(身份 > 每服务密钥 > 共享 token)② 默认拒绝还是默认放行 ③ 能否做到零新增入站 ④ 单 worker 失陷的爆炸半径 ⑤ 可观测性 ⑥ 生态与排障。
| 方案 | ① 认证模型 | ② 默认姿态 | ③ 零入站 | ④ 爆炸半径 | ⑤ 可观测 | ⑥ 生态 |
|---|---|---|---|---|---|---|
| **OpenZiti** | ✅ **证书身份 + 服务策略**(最强) | ✅ 默认拒绝 | ✅ | ✅ 最小(逐服务授权) | 中 | 中(CNCF) |
| **Nebula** | ✅ **CA 签发身份**(Slack 在生产用) | ✅ 默认拒绝 | ✅(lighthouse 拨出) | ✅ 小 | 中 | 中 |
| **rathole** | ✅ **Noise_NK 双向认证 + 每服务 token 必填** | 🟡 无 token 不通 | ✅ | 🟡 中 | ❌ 无 dashboard | ❌ 小(单维护者) |
| **frp** | 🔴 **单一静态 token**(明文存) | 🔴 **默认放行**(5 项需手设) | 🟡 需 `proxyBindAddr` 才成立 | 🔴 **大**(可申请任意端口) | ✅ dashboard+metrics | ✅ 最好 |
| **konnectivity**(k8s apiserver-network-proxy) | ✅ 客户端证书 | ✅ | ✅ | ✅ 小 | 中 | 🟡(K8s 专用) |
⇒ **在我们最在意的 ①②④ 三条上,frp 都是最差一档**;它只在 ⑤⑥ 领先。
## 8.3 性能:**这条轴我们根本不该用于选型**
- 覆盖网络的**第一瓶颈是 presence,不是带宽**(既有推演结论);我们的量级是"**每 worker 几十个 HTTP 会话**",不是 1 万并发、不是线速。
- ⇒ **拿 frp/rathole 的吞吐 benchmark 选型 = 优化错误的轴**(且那些数字多为厂商/二手自测)。
- 真要测,该测的是**链路本身**(本机 ↔ 47 / 106 的 **RTT / jitter / 带宽**)—— 那**正是接续包里"未完成项 3"**。任何 relay 的性能上限由链路决定,不由实现决定。
## 8.4 修正后的路线(取代 §7.4 的第 2、3 条)
1. **不锁定 frp**;**不在这轮引入任何第三方 relay**。
2. **先做 R0(判据 + 画像)**:
- R0-a 把上面 6 条判据写成**一张打分表**(放本文件,作为后续任何 relay 决策的判据);
- R0-b **测链路画像**(本机↔47↔106 的 RTT / jitter / 带宽)—— 它同时是"未完成项 3",**一次做掉两件事**。
3. **R1 起再选实现**:按 R0 的打分表定;**优先考虑身份型(OpenZiti / Nebula)**,frp 只在"要立刻省心、且 8.1 那 5 项配置能一项不落地全部做完"时才选。
4. **🔑 关键(也是可以不急的根本原因)**:**P1–P3 已经把接口抽出来了** —— `Reachability.via` + `Rendezvous` 注册表 ⇒ **换实现是 env 级切换、零代码回滚**。**可选性已经买好了,选型错了不致命**,所以**不必现在一次选对,更不该为了"选对"付一次引入成本**。
5. ⚠️ **一个必须承认的权衡**:把 relay 换成第三方,是**用一个我们不完全掌控的攻击面(frp 的 CVE + 默认值)去替换一个已收窄、且有系统级补丁渠道的面(sshd + `restrict,port-forwarding`)**。**这不是无条件升级** ⇒ 没有明确的痛点(比如真要上多中继)之前,**维持现状也是合理选项**。
## §9 · R0-b 链路画像实测(2026-09-16 18:4x)
> 目的:给 relay 选型提供**链路事实**(性能不作选型轴,但"能不能直连 / 要不要打洞"必须实测)。
> 全程**只读**:未装软件、未开端口、未改配置;测量脚本 `/tmp/r0b.sh`,一次跑完。
### 9.1 原始实测(命令原文 + 数字)
| 路径 | 样本 / 丢包 | RTT avg | jitter(stddev) | TCP 吞吐 |
|---|---|---|---|---|
| 本机 → 47 `47.77.182.89` | 14/20 · **30%** | 189.6 ms | 0.49 ms | **12.7 Mbit/s**(1.6 MiB/s) |
| 本机 → 106 `106.54.21.172` | 20/20 · 0% | 34.8 ms | 0.54 ms | 未取到(无 106 凭据) |
| 47 → 106 | 18/20 · **10%** | 149.8 ms(mdev 0.414) | 0.41 ms | **未取到**(ssh `Permission denied`,rc=255) |
```bash
ping -n 20 47.77.182.89 # Windows ping;RTT 由 '=NNms' 提取,stddev 自算
ping -n 20 106.54.21.172
dd if=/dev/zero bs=1M count=50 | ssh [email protected] 'cat >/dev/null' # 实测 50MiB / 31403 ms
ssh [email protected] 'ping -c 20 106.54.21.172' # 20 transmitted, 18 received, 10% loss
curl -s -m 6 -o /dev/null -w '%{http_code}|connect=%{time_connect}s' http://47.77.182.89:19100/
curl -s -m 6 -o /dev/null -w '%{http_code}|connect=%{time_connect}s' http://106.54.21.172:19000/
```
### 9.2 公网可达性 / 入站面(判"打洞"可行性)
- 本机 → `47:19100`:`curl_rc=28`(连接超时,`connect=0.000000s`)⇒ **不可直连**
- 本机 → `106:19000`:`curl_rc=28` ⇒ **不可直连**
- 47 实际监听(`ss -lntp`):`127.0.0.1:3080`、`127.0.0.1:15432`、`127.0.0.1:19000`、`127.0.0.1:19100`,仅 `0.0.0.0:22`
- 106 监听面:**未取到**(本机 ssh 到 106 报 `Permission denied (publickey,gssapi-keyex,gssapi-with-mic)`)
### 9.3 三条结论
1. **零入站 = 实测成立**:47 侧 agent / 控制面端口**全部绑回环**,唯一公网入站是 `22(sshd)` ⇒ 「打洞 / 直连」路径**实测不可行**,relay 只能靠**唯一拨出长连接**(与本线既定设计一致)。
2. **47 链路质量是本轮最大异常**:30% / 10% 丢包,而 RTT 抖动仅 0.5 ms 级 ⇒ 属**丢包型**而非**拥塞型**恶化;吞吐 12.7 Mbit/s 已含丢包拖累 ⇒ 选型时**丢包恢复能力(Noise / QUIC 级)应重于带宽**。
3. **跨云一跳代价**:47 ↔ 106 实测 149.8 ms / 10% 丢包 ⇒ 多跳中继必须把这一跳计入「45% 设计 / 55% 余量」的**余量侧**。
### 9.4 未完成取证(诚实标注,非"已解决")
- 106 侧监听面 + 本机↔106 吞吐:**缺 106 的 ssh 凭据**(本机与 47 两处均 `Permission denied (publickey)`)⇒ 需先确认 `manager-ssh` 通道实际用的是哪个账号 / 密钥;**本轮未改任何配置**。
- 吞吐只测了"经 SSH 隧道的加密吞吐",非裸 TCP ⇒ 结论按**下界**理解。
- 丢包为 ICMP 采样,若对端有 ICMP 限速则可能高估;但 47 的 SSH 吞吐(1.6 MiB/s)与之相互印证。
---
# 10. R0-a 判据打分表 + **relay 定案**(2026-09-16 18:5x)
> 回答用户提问:「继续执行 让方案落地,**自建 relay 的方案定了吗**」
> **直接答案**:此前**没有定**(§8.4 明确写"不锁定 frp、本轮不引入第三方、先做 R0");**现在定了 —— 自研 relay,传输走 WebSocket over 现有 nginx 443,零新增公网口**。理由见 10.2,缺点见 10.6。
## 10.1 R0-a:六条判据·可打分版(**后续任何 relay 决策都用这张表**)
权重(= 8.2 的重要性排序):① 认证/授权模型 **3**|② 默认拒绝 vs 默认放行 **3**|③ 能否零新增入站 **2**|④ 单 worker 失陷爆炸半径 **3**|⑤ 可观测 **1**|⑥ 生态/排障 **2**(总权重 14,满分 70)。评分 1–5(5 = 最好)。
| 方案 | ① 认证(×3) | ② 默认姿态(×3) | ③ 零入站(×2) | ④ 爆炸半径(×3) | ⑤ 可观测(×1) | ⑥ 生态(×2) | **加权分/70** |
|---|---|---|---|---|---|---|---|
| **自研 relay** | 5 | 5 | 5 | 5 | 2 | 1 | **59(84%)** |
| **OpenZiti** | 5 | 5 | 5 | 5 | 3 | 3 | **64(91%)** |
| **Nebula** | 5 | 5 | 4 | 4 | 3 | 3 | **59(84%)** |
| **rathole** | 4 | 3 | 5 | 3 | 1 | 1 | 43(61%) |
| **chisel** | 2 | 3 | 5 | 2 | 1 | 2 | 34(49%) |
| **frp** | 1 | 1 | 3 | 1 | 5 | 5 | 30(43%) |
⚠️ **纯看这张表会选 OpenZiti —— 但表里缺了「架构契合度」这条否决项**(见 10.2 第 3 点)。**分数不是终点,否决项优先。**
## 10.2 定案:**自研 relay**(不是"造轮子偏好",是约束共同推出的唯一解)
1. **入口约束把成熟件全部逼到墙角**(10.3 已实测):我们唯一愿意接受的入口是 **WebSocket over 现有 443**(零新增监听口、且天然满足 S5「443/TCP 兜底」、对"只放 443 的异构网络"最鲁棒)。
- **rathole 不支持 WebSocket** ⇒ 只能退回 `nginx stream + ssl_preread`,而 443 现由 **http 层**监听 ⇒ 必须把 443 从 http 挪进 stream = **动门户主入口**(结构级改动)。
- **OpenZiti / Nebula** 是完整 overlay(自带会合 + 身份 + ACL),会**与已完成的 `Rendezvous` / `via` 语义重叠** ⇒ §7.1 已判定"层级不对、属远期"。**这是否决项,不是扣分项。**
2. **认证模型 + 静默失败**:chisel/frp 走 WS 但都是**共享 auth / 静态 token**(①垫底);而 SSH 的病根之一正是**静默失败**(`-R` 撞号时无人检查返回值)⇒ 我们需要"**注册必须被 ACK / 端口占用必须报错**"这种精确语义,成熟件里没有现成的。
3. ⇒ **同时满足「WS over 443」+「每服务密钥(非共享 token)」+「精确 ACK」的成熟件不存在** ⇒ 自研。
4. **代价可控的关键**:真正的功能面很小 —— 一条出向长连接 + `(hostId, port) → conn` 映射表 + 字节双向泵 + 心跳/重连/ACK。**不自造密码学**(复用 Node 内建 `tls` / `net` / `crypto`),协议帧 = 长度前缀 + JSON 头。
5. **可选性已买好**(§8.4 第 4 条):`Reachability.via` + `Rendezvous` 已抽 ⇒ 自研 relay = **新增一个 `via='relay'` 实现**,零删除、env 级回滚。**选错不致命,所以现在定案的风险是可控的。**
## 10.3 入口取证(本轮只读实测 —— 决定"零新增公网口"能不能成立)
| 项 | 实测 | 对落地的影响 |
|---|---|---|
| nginx | `nginx/1.28.3`,含 `--with-stream` + `--with-stream_ssl_preread_module` | 方案 `stream + ssl_preread` **技术可行**(但见 10.2 第 1 点:要动 443) |
| stream 段 | 已存在,且 `include /www/server/panel/vhost/nginx/tcp/*.conf` | 有现成落点(宝塔 TCP 转发目录) |
| 443 现状 | `listen 443 ssl default_server;`(**http 层**,nginx pid 521250/521249/85711) | 走 stream 分流必须把 443 挪走 ⇒ **动门户** ⇒ 故选 WS 方案绕过 |
| **本机防火墙** | `nft: chain INPUT policy accept` + `iptables -P INPUT ACCEPT` | 🔴 **47 没有本机防火墙**,暴露面**全靠云安全组** ⇒ **任何绑 `0.0.0.0` 的新监听会立刻公网可达** ⇒ 「零新增公网口」是**硬约束**,不是洁癖 |
| 公网监听面 | `22`+`32022`(**同一 sshd 进程 pid 1017**);`80/443/888`(nginx);`58888`(BT-Panel);`8765`(python3) | 32022 = 覆盖网络专用口(同为 sshd) |
| 回环面 | `127.0.0.1:19000`(**sshd**)、`19100`(w-47 agent)、`20000`(w-47 实例)、`3080`(门户)、`15432`(PG) | 见 10.4 勘误 |
| 出向 | `https://github.com → http=200 t=0.084s`,`gh-proxy/ghfast → 200` | 取第三方件无障碍(**但本定案不取**) |
| 已装 relay | `rathole/frpc/frps/chisel/ziti/wg` 全部 `(none)` | 干净起点 |
| 现有隧道进程 | 47 上 `ps \| grep ssh` **为空** | 隧道由 **106 侧**发起(106 拨出) |
## 10.4 一处勘误(推翻 §9.4 的"阻塞"判断)
`127.0.0.1:19000`(及 `[::1]:19000`)的属主是 **`sshd`(pid 720417)**,**不是 dsh agent** ⇒ 它是 **106 侧 `ssh -R` 的落点**。
⇒ `via='manager-ssh'` 的语义 = "**106 主动拨出、经 Manager 的 sshd 落点可达**",**不是** "Manager 主动连 106"。
⇒ **§9.4 记的"缺 106 凭据 = 阻塞"应修正为"不是阻塞"**:设计上 47 就不需要能 ssh 到 106(实测 `47→106 ssh Permission denied` 属**正常**)。
⇒ 106 侧操作的通道 = **宝塔面板**(本机已挂 106 的宝塔 MCP:`mcp__baota-mcp-106.54.21.172`),而不是 ssh。
## 10.5 R1–R4 落地步骤(每步可独立验收、可独立回滚,延续 P1–P3 的交付方式)
| 步 | 动作 | 验收(可核对) | 回滚 |
|---|---|---|---|
| **R1** | 47 上 relay 服务端只监听 **`127.0.0.1:20080`**(**仅回环**,不新增公网口);本机 ↔ 47 先用**临时 `ssh -L`** 引出来跑通一条隧道(**不碰 443、不改 nginx**) | `curl -s localhost:<映射口>` 取到目标内容;relay `/status` 显示 1 条注册 + 端口已占用 | 停进程即回零 |
| **R2** | nginx 某站点 443 加 `location /dshs-relay/`(`proxy_http_version 1.1` + `Upgrade`/`Connection` 头 → `127.0.0.1:20080`);先 `nginx -t`,**备份站点 conf**,再 reload | `wss://<域名>/dshs-relay/` 握手 **HTTP 101**;`ss -lntp` **无新增 0.0.0.0 监听** | 删 location + reload(秒级) |
| **R3** | worker 侧 relay client 拨出(106 经宝塔部署);`dsh_hosts.via='relay'`(env 级);**SSH 路径并存不删** | `via='relay'` 的 worker 实例可正常被门户访问;`/status` 显示该 worker 心跳 | `via` 切回 `manager-ssh`/`local`(零代码) |
| **R4** | 观察一轮 → 下线 sshd 隧道 + 收 47 上那条 `authorized_keys`;**回收 32022** | sshd 隧道进程消失、实例仍可用;32022 从 `0.0.0.0` 监听面消失(**净减一个暴露口**) | 重建隧道(回 R3 前状态) |
**R2 是唯一动门户的一步**(其余全为新增/回环),且 `nginx -t` + 备份 + reload 三重保护;**R2 之前 SSH 路径全程在跑**。
## 10.6 我选了什么(可推翻)+ 诚实的缺点
**选了**:自研 relay;**WS over 443**;**每 worker 一密钥**(非共享 token);注册/占用**必须 ACK**;先只服务 w-47 / w-106 两个 worker;**R4 之前 SSH 路径不删**。
**缺点(必须承认,不是"没有缺点")**:
1. **无第三方审计**:自研数据面没有别人的眼睛看过 ⇒ 缓解 = 协议极简、复用 Node 内建密码学、**不开新端口**(暴露面不增)、单点可 `kill` 回退。
2. **长期自维护**:bug 自己修、没有上游 → 缓解 = 代码量刻意压小(≤500 行目标)、`via` 可秒切回 SSH。
3. **可观测要自己补**:无现成 dashboard ⇒ 缓解 = 先做 `/status`(注册数/心跳/字节数)+ journald。
4. **相对成熟件的功能天花板低**:多中继选主、UDP、热重载都要自己加 ⇒ 缓解 = 本阶段不需要(第一瓶颈是 presence,不是带宽/Multi-relay)。
## 10.7 R1 前置取证(本轮实测,**执行会话直接照用,不必重新探索**)
**代码仓 / 接线点**
- 代码仓 = `D:/github/dsh_shenxian`(`src/` + `dsh-server-docs/` 同一仓);**relay 代码落 `src/net/relay/`**(与 `src/net/reachability.ts` / `rendezvous.ts` 同层)。
- 既有 `src/net/` 仅 211 行:`reachability.ts`(99) + `rendezvous.ts`(112)。**`via` 词表 = `src/net/reachability.ts`**(`VIA_LOCAL='local'` / `VIA_MANAGER_SSH='manager-ssh'`)。
- **接线的唯一一处**:`src/web/server.ts:270` → `new RendezvousRegistry([ LocalRendezvous, ManagerSshRendezvous ])`。R1 新增 `RelayRendezvous` ⇒ 只在此数组加一项 + 注册 `id='relay:<id>'`。
- ✅ **代码里已预留 `relay:<id>` 语义**:`rendezvous.ts:64`「S4 把"接收 worker 反拨"搬进独立单元 `dshs-relay.service` 后,本类应被 `relay:<id>` 实现替换」;`src/web/routes/admin.ts:188` 注释「未来的 `relay:<id>`」。⇒ **本定案与既有架构同构,不需要发明任何命名。**
**依赖与运行(实测,决定 R1 怎么写)**
| 项 | 实测 | 结论 |
|---|---|---|
| Node | `v22.22.2`,`"type":"module"`(ESM) | 与全仓一致,relay 用 ESM |
| 依赖 | 仅 `@fastify/rate-limit, @fastify/static, better-sqlite3, fastify, pg` —— **无 `ws`** | **R1 需二选一**(见下) |
| 内建 `WebSocket` | `typeof globalThis.WebSocket === 'function'`(**仅客户端**,服务端无) | **relay client 零依赖**;server 端需 WS 实现 |
| `.ts` 直跑 | 自检**未通过**(错误栈未展开)⇒ 不赌 type stripping | 走既有链路 `npm run build` → `node lib/…` |
| 门禁 | `npm run verify` 含 `test/reachability.test.mjs` + `scripts/check-layering.mjs`(**分层 ①入口→②领域→③能力→④基础**) | **R1 必须让 verify 全绿**;`src/net/*` 属 ③能力层,⛔ relay 不得反向依赖 ①② |
**R1 的依赖二选一(我的倾向:②)**
1. 给主 `package.json` 加 `ws` —— 优点:直接用成熟帧实现;缺点:**给整个平台新增一个生产依赖**(与"relay 只是可选单元"的定位不符)。
2. **服务端手写最小 WS 帧编解码**(~120 行:握手 SHA1+GUID、二进制帧、ping/pong/close,不做分片与 permessage-deflate)—— 优点:**零新增依赖**、relay 可独立成单元、协议面小到可审;缺点:自写帧层有 bug 风险 ⇒ 必须配 `test/` 断言(握手 + 回环字节数一致)。
**入口(R2)前置条件 —— ✅ 已全部具备,不必改 nginx 结构**
- `map $http_upgrade $connection_upgrade` **已在** `nginx.conf:321-322`。
- `proxy_http_version 1.1` + `Upgrade` / `Connection $connection_upgrade` 模式**已在 5 处**(`nginx -T` 行 378-380 / 403-405 / 499-501 / 553-555 / 568…)⇒ 加 location 是**照抄既有模式**,不是新写法。
- 443 `default_server` 在 `/www/server/panel/vhost/nginx/0.catchall-443.conf:4`;门户站点 = `alotbuy.com.conf` / `dsh.alotbuy.com.conf`;另有 `0.websocket.conf` 已存在。
- 备份惯例已成熟:`dsh.alotbuy.com.conf.bak-20260910-2300-pre-buffering` 之类 ⇒ R2 备份按同格式命名。
- `/www/server/panel/vhost/nginx/tcp/` **为空**(宝塔 TCP 转发目录已 include 但无内容)⇒ 走 stream 的备用落点可用。
---
# 11. R1 落地与验收(2026-09-16 19:3x)—— 自研 relay 最小闭环 **已跑通(本机 + 跨机)**
## 11.1 交付物
**新增** `src/net/relay/`(代码仓 `D:/github/dsh_shenxian`,**未 commit**):
| 文件 | 职责 | 关键点 |
|---|---|---|
| `wire.ts` | 最小 WebSocket **服务端**帧(RFC 6455 子集)+ mux 帧 | 手写因为**无 `ws` 依赖**且 Node 内建只有客户端;握手 SHA-1 走 `node:crypto`(**不自造密码学**) |
| `server.ts` | relay 服务端(只绑回环) | 认证 / 端点 / 多路复用 / 背压 / `/status` |
| `client.ts` | worker 侧拨出端 | 内建 `WebSocket`;**白名单二次校验**;指数退避 + 抖动 |
| `keys.ts` | 密钥装载 | 强制 **64 位 hex**,短密钥直接拒 |
| `rendezvous.ts` | `RelayRendezvous`(`via='relay'`) | 独有增益 = **实时在线态**(心跳驱动,非 DB 快照) |
| `main.ts` | 单元入口(`--client` 可切客户端) | R1 阶段**不读 `config.ts`**,只认 `DSHS_RELAY_*` / argv(避免与其它会话的改动冲突) |
| `index.ts` | barrel | — |
**改动**(小到可以逐字核对):
- `src/net/reachability.ts`:**只加** `export const VIA_RELAY = 'relay'`(`via` 词表的单一来源仍在原处)。
- `package.json`:`verify` / `test` 的测试列表加 `test/relay.test.mjs`。
- `test/relay.test.mjs`(新增):7 项,**真起服务、真握手、真泵字节**(不 mock 传输层)。
⇒ **既有 `.ts` 逻辑文件改动 = 0**(relay 全部落在新目录)⇒ 回滚 = 整目录丢弃。
## 11.2 本机验收(可复现命令 + 实测)
```bash
npm run build # → 0 错误
node scripts/check-layering.mjs # → 现存违规 5 条(全在基线内);✅ 无新增违规(relay 落层③能力层)
node --test test/relay.test.mjs test/reachability.test.mjs # → tests 17 / pass 17 / fail 0
```
| 用例 | 断言 |
|---|---|
| T1 | mux 编解码往返(含 `streamId=0xffffffff` 边界与空负载);<5 字节判协议错误 |
| T2 | 密钥只接受 64 位 hex,`deadbeef` 直接拒 |
| **T3** | **端到端**:Manager 连回环口 ⇒ 字节真过 ⇒ 回显一致;**并发 3 条流同时工作**(多路复用真在复用);`authFailed=0 / dropped=0` |
| T4 | 错密钥 ⇒ 不 `up` + `authFailed≥1`(**不静默**) |
| T5 | **同 nonce 二次注册 ⇒ 重放被拒**(第二条连接拿不到 `HELLO_ACK`) |
| T6 | 声明实例区间外端口 ⇒ 拒绝注册 + **不留回环监听** |
| T7 | 未注册端口**不存在**回环监听(默认拒绝,不是默认放行) |
## 11.3 跨机验收(47 ⇄ 本机)—— 命令原文 + 实测输出
拓扑:**47 = relay 服务端(只绑 `127.0.0.1:20080`)**;**本机 = worker 侧 client(经临时 `ssh -L` 拨出)**;验证 = **在 47 上 curl relay 分配的回环口 ⇒ 必须打到本机端口**。
| 步 | 命令(原文) | 实测输出(原文摘录) |
|---|---|---|
| 部署 | `scp -r lib/net/relay root@47:/tmp/dshs-relay-r1/net/` + `printf '{"type":"module"}' > package.json` | 远端 7 个 `.js`;`/opt/dshs/lib` 下**无** relay 目录 ⇒ **零覆盖** |
| 起服务 | `nohup node net/relay/main.js --port 20080 --keys-file keys.json --base 19800 --span 200` | `[relay] listening ws://127.0.0.1:20080/dshs-relay (loopback only) instance-ports=19800..19999`;`LISTEN 127.0.0.1:20080 users:(("node",pid=724533))` |
| 引出来 | `ssh -f -N -L 20080:127.0.0.1:20080 root@47` | 经隧道读到远端 `/status` ✅ |
| 拨出 | 本机 `--client --url ws://127.0.0.1:20080/dshs-relay --host local-r1 --ports 19876` | `[relay-client] registered host=local-r1 session=3cbd8fc7425ca326 accepted=[19876]` |
| 服务端视角 | 47 `curl 127.0.0.1:20080/status` | `online: ["local-r1(session=3cbd8fc7425ca326 ports=19876 …)"]`;`endpoints: [{port:19876, localPort:41811, online:true}]`;`authed=1 / authFailed=0` |
| **关键验证** | 47 `curl 127.0.0.1:41811/hello-from-47`(+ `/second-call` + `/third`) | **`echo:/hello-from-47\|served-by=User-2026QYRQXO\|at=127.0.0.1:19876`**(三次全部命中;`served-by` = **本机主机名** ⇒ 确实到了本机) |
| **暴露面** | 47 `ss -lntp \| grep 20080`;本机 `curl http://47.77.182.89:20080/status` | 只 `127.0.0.1:20080`;公网 **`curl_rc=000` 不可达** |
| 回收 | kill client/echo/隧道 + 远端 kill 后 `rm -rf /tmp/dshs-relay-r1` | 20080 已释放、无残留进程、临时目录已清 |
## 11.4 三条结论
1. **数据面闭环成立,且零新增公网口**:worker 只拨出、relay 只绑回环、Manager 连回环口 ⇒ `Reachability.address` 与 sshd 版**同形**,调用方零改动。
2. **安全姿态实测有效**(不是纸面):越界端口被拒(本轮实测触发过:`19876` 不在 `20000..20999` 时被正确拒绝)、nonce 重放被拒、错密钥被拒、公网不可达、**每 worker 一密钥**(非共享 token)。
3. **R2 是唯一动门户的一步**(nginx 443 加一个 `location /dshs-relay`);R2 之前 SSH 路径全程在跑,且 R1 的部署方式**不碰 `/opt/dshs/lib`**。
## 11.5 本轮踩到的三个坑(**下一棒直接照用,别再踩**)
1. 🔴 **远端 `pkill -f "<pattern>"` 会杀掉自己**:只要 pattern 串出现在 ssh 自身命令行里(例如 `relay/main.js`),`pkill -f` 就匹配到那个 `bash -c` 进程 ⇒ **自杀**,后续命令全不执行(本轮因此**空跑两次**,第二次即使用 `[r]elay` 字符类也无效,因为**启动命令里也含该字面量**)⇒ ✅ **用 pidfile(`echo $! > server.pid`)**,不要用 `pkill -f`。
2. 🔴 **`/tmp` 下跑 ESM 产物必须先放 `package.json {"type":"module"}`**:否则 node 按 CJS 解析 ⇒ 启动即 `SyntaxError`,而日志被上面那个坑挡住 ⇒ 表现为"服务起不来但没有任何报错"。
3. ⚠️ **端口必须落在 `--base/--span` 区间内**:client 声明区间外端口会被服务端拒绝 —— 这是**爆炸半径校验在正常工作**,不是 bug(本轮实测触发)。
---
# 12. 网络模块韧性 —— 节点启停 / 网络变化 / 网络中断 / 网络异常 / 时钟漂移(R1.5,2026-09-16 20:xx)
> 回答用户提问:「**网络模块都要考虑 服务器等公网 IP 节点启停,网络变化,网络中断,网络异常,如何重连和恢复**」
> 结论先说:**五类场景各有对应的机制与可观测字段,且都能被实测断言**(单测 15/15 + 47 真机验收全绿)。
> **不做**做不到的事:断链必然终止在途流,**不假装**能流级恢复(见 12.3)。
## 12.1 连接状态机(**显式七态**:布尔说不出"正在握手"还是"正在退避")
```text
idle ──start()──▶ connecting ──ws open──▶ handshaking ──HELLO_ACK──▶ up
▲ │ │ │
│ └── 任一步失败 / 超时 ────┴─────────────────────┘
│ ▼
└──stop()──▶ stopped ◀──stop()── { backoff | queued }
│ (延迟到点 / 地址变化 / 对端优雅告别 / 位子空出)
└──────────────────────────────▶ connecting
```
`queued` 与 `backoff` **分开**是刻意的:前者是"没位子"(不是故障,不该按故障退避),后者是"链路有问题"。
## 12.2 五类场景 × 处置 × 恢复时间(**数字是实测/默认值,都可调**)
| 场景 | 现象 | 处置 | 恢复时间(默认) | 可观测字段 |
|---|---|---|---|---|
| **节点启停**(计划内) | 对端发 `BYE` / `close 1001` | **不消耗退避**,进入**快速重试时窗**(`gracefulBurstMs` 默认 15s,间隔 `gracefulRetryMs` 300ms);同时 `BYE` 让服务端**秒级**标离线(不等 45s 心跳) | **窗口内立即恢复**(实测 <2s,含 400ms 重启) | `restarts` / `state=backoff`+短 `nextRetryMs` |
| **节点启停**(崩溃,无告辞) | 连接突然断 | 指数退避 `1s→2s→4s…` 封顶 `reconnectMaxMs`(30s)**±25% 抖动**,**永不放弃** | ≤30s,典型 ≤2s | `attempts` / `nextRetryMs` |
| **网络变化**(IP 变 / 网卡上下) | 本机地址快照变化 | 巡检(`netWatchMs` 5s)发现即**取消剩余退避、立即重拨**(旧退避的前提已失效) | 立即 | `networkChanges` |
| **网络中断**(长时间断网) | 连不上 / 连上无帧 | 同退避序列;**不设"重试 N 次后沉默"** | 恢复联网后 ≤30s | `attempts` |
| **网络异常**(半开 / 静默黑洞) | TCP 不报错但再无数据 | **半开巡检**:`2.5×hbSec`(默认 37.5s)内**没有任何帧** ⇒ 主动断开重连(不等 OS 的 TCP 超时,那要几百秒);服务端侧同样 45s idle 判死 | ≤37.5s 判死 + 立即重连 | `lastFrameAgeMs` |
| **满载**(位子不够) | 服务端回 `at-capacity` | **排队**(`queued` 态),按服务端 `retryAfterMs` 复盘;位子一空即注册 | 按 `retryAfterMs`(默认 5s) | `queueWaits` / `queuedMs` |
| **时钟漂移** | `HELLO` 被拒 `clock-skew` | 用拒绝帧里的 `serverTime` **本地校正**后立即重试(含 `HELLO_ACK` 也回传 `serverTime`,首连即可对齐) | 1 次握手(~300ms) | `clockSkewMs` |
**为什么时钟漂移必须专门处理**:`HELLO` 的 MAC 里含时间戳、窗口 ±60s ⇒ 一台时钟漂了 10 分钟的机器
**永远无法注册**(每次都 `bad-mac`/`clock-skew`),且现象是"连上又被踢"的无限循环 —— 这是 Replay 防护的必然代价,必须用"把服务端时间告诉它"来还债。实测 T10 覆盖。
## 12.3 恢复语义分层(**明确哪层做、哪层不做**)
| 层 | 谁负责 | 恢复方式 |
|---|---|---|
| ① **连接恢复** | relay client | 重连(本节的退避 / 时窗 / 半开巡检) |
| ② **注册恢复** | relay client | 重连成功后**自动重新注册**(`ports` 声明不变,服务端重建回环监听) |
| ③ **路由恢复** | Manager / `RelayRendezvous` | `online()` 实时判在线、`localPortOf()` **不猜**:离线一律 `undefined`,让上层回退别的实现,而不是往死地址上打 |
| ④ **流级恢复** | **不做** | 断链**必然**终止在途流(多路复用帧没有重放日志)。**不缝合半开流**:流级重试交给上层(HTTP 幂等请求 / 实例侧自身重连)。**假装能做 = 制造"看起来恢复了其实数据烂了"** |
## 12.4 实测证据(可核对)
**单测**(`node --test test/relay.test.mjs`,15/15 通过;全量 `npm run verify` = 80 tests / 79 pass / 0 fail,1 skip):
| 用例 | 断言 |
|---|---|
| T8 | 优雅停机(`BYE`+`close 1001`)⇒ 对端**立刻**进短间隔时窗(`nextRetryMs ≤ 1000`,不退避到 60s)⇒ 重启窗口内**自动恢复**(`reconnect #1`、新实例 `isOnline`) |
| T9 | 客户端优雅停机 ⇒ 服务端**1.5s 内**标离线(心跳超时被设为 60s ⇒ 这只可能来自 `BYE`),且不再给出回环口 |
| T10 | 注入 +600s 时钟漂移 ⇒ 首次被拒 → **自愈**后注册成功;`clockSkewMs` 反映真实漂移 |
| T11 | 半开(握手成功但**永不回帧**)⇒ 无帧超阈值即主动重连(`lastError` 含 `half-open`),且**确实重拨** |
| T12 | 网络变化(快照变化)⇒ **取消剩余 60s 退避、立即重拨** |
| T13 | 容量准入:`maxHosts=1` 时第二个 host 收到 `HELLO_ERR{reason:'at-capacity', retryable:true, retryAfterMs, capacity}`、**不留回环监听**;**已在册 host 重连仍被接受** |
| T14 | 客户端满载 ⇒ 进 `queued`、按 `retryAfterMs` 重试、`attempts` 不累计;位子空出即注册成功 |
| T15 | 选点判据:满载是**唯一硬门**;速度 + 负载打分;负载能压过速度;手动指定优先且**不被静默改选**;近期失败降权 |
**47 真机验收**(`/tmp/dshs-r15`,**不碰 `/opt/dshs`**;命令原文与输出摘录):
```bash
# 部署(只传 lib/net/relay 产物)+ 起 relay(--max-hosts 1,只绑回环)
node lib/net/relay/main.js --port 20080 --keys-file keys.json --base 19800 --span 200 --max-hosts 1
# 两个 worker 拨出(同机两个进程 ⇒ 等价于两台机器,都是"只拨出")
node lib/net/relay/main.js --client --url ws://127.0.0.1:20080/dshs-relay --host local-r1 --keys-file keys.json --ports 19876
node lib/net/relay/main.js --client --url ws://127.0.0.1:20080/dshs-relay --host local-r2 --keys-file keys.json --ports 19877
curl -s http://127.0.0.1:20080/status
# 重启:kill $(cat server.pid) → 1s → 同端口重起 → 观察 A
```
实测输出(摘录):
```text
[relay-client] registered host=local-r1 session=873ca9a30e3bdb14 accepted=[19876] clockSkew=6ms
[relay-client] down (at-capacity (queued #1, waited 0ms)); attempt #0 [queued], retry in 5000ms
"capacity":{"max":1,"used":1,"free":0}
"online":["local-r1(session=873ca9a30e3bdb14ports=19876streams=0hbAge=4886msin=0Bout=0B)"]
-- 重启 --
旧进程是否已退出: 已退出 端口释放: 0
server2 首行: [relay] listening ws://127.0.0.1:20080/dshs-relay (loopback only) ...
[relay-client] peer BYE: server restarting ⇒ fast reconnect
[relay-client] down (peer bye: server restarting (was up)); attempt #0 [graceful, burst window 15000ms], retry in 300ms
[relay-client] registered host=local-r1 session=5071fa3f8c2d5761 accepted=[19876] clockSkew=2ms (reconnect #1)
新实例 online: "online":["local-r1(session=5071fa3f8c2d5761...)"]
残留回环监听: 0
```
## 12.5 四个真机硬结论(**都是本次实测踩出来的,下一棒别再踩**)
1. 🔴 **重试定时器绝对不能 `unref()`**:断链后 WebSocket 句柄已消失,若连"重试计划"也是 unref 的,事件循环就空了 ⇒ **进程静默退出**(节点"人间蒸发":不重连、不报错、日志停在最后一行,`/status` 里再也等不到它)。**本文件其余定时器 unref 是对的,唯独重试定时器不行。**(单测发现不了 —— 只有在真机上才暴露,这也是"本机通过 ≠ 交付"的又一实证)
2. 🔴 **停机必须"可控"**:`stop()` 里若只 `close()`,空闲 keep-alive 连接会把进程挂住 ⇒ 旧进程迟迟不退 ⇒ 新进程 `EADDRINUSE` ⇒ **客户端只能一直撞那个正在 `draining` 的旧实例**(表现为"重启后再也连不上")。修法 = `http.closeAllConnections()` + `main.ts` 里 2s 硬兜底退出(等价 systemd `TimeoutStopSec`)+ `shutdownGraceMs`(300ms)通知窗口。
3. 🔴 **优雅重连要用"时窗"而不是"次数"**:重启耗时不可预测(实测一次 `stop()` 自身就要 1.8s),固定 4 次会在"差一点点"处失败并把退避直接拉到 60s ⇒ 计划内重启被放大成长时间中断。
4. 🔴 **满载是唯一的硬门,且已在册节点重连永远优先**:容量检查写成 `sessions.has(hostId)` 放行 —— 否则平台自己重启一次,节点就会被自己的满载规则挡在门外。
5. ⚠️ **临时验收进程必须回收**:本次在 47 上发现**上一轮验收残留的 relay 进程**(`node lib/net/relay/main.js --port 20080`,已运行 8 分钟,占着 20080)⇒ 导致新 relay `EADDRINUSE`、客户端连上旧实例报 `bad-mac`。**已回收**。⇒ 验收脚本必须用 pidfile 收尾(本次已改为 `kill $(cat *.pid)` + 结束前计数校验 `残留回环监听: 0`)。
---
# 13. 节点准入与选点 —— 自动(速度 + 负载) / 手动 / 满载排队(R1.5,2026-09-16 20:xx)
> 回答用户提问:「**如何自动选择和判断适合的节点加入(速度和负载),支持手动选择(要考虑负载满的时候不能加入或排队等待)**」
## 13.1 判据分层(**只有一处判据**,避免三套不一致)
| 层 | 位置 | 职责 | 硬门? |
|---|---|---|---|
| **准入(Admission)** | `RelayServer` | 容量上限(`maxHosts`):满了**拒绝新节点加入**,回 `at-capacity` + `retryAfterMs`;**已在册 host 重连优先** | ✅ **唯一硬门** |
| **选点(Placement)** | `src/net/relay/placement.ts`(纯函数、可单测) | 在**有资格**的候选里按**速度 + 负载**排序选一个;支持手动指定 | ❌ 只排序 |
`placement.ts` **不连网、不开端口**:它只把"可观测画像"变成"一次可解释的选择"。relay 只负责把画像喂进来(`rttMs` 来自心跳 PONG,`capacity` 来自注册数)。
## 13.2 打分公式(**可解释**是这个模块存在的意义)
```text
speed = 100 / (1 + rttMs / rttHalfMs) # rtt 0→100;50ms→50;200ms→20(rttHalfMs 默认 50)
load = 100 × (1 − used / max) # 容量未知 ⇒ 按 0.5 中性(不猜它空)
score = weight × (0.55·speed + 0.45·load) − 15 × recentFailures
硬门:capacity.free === 0 ⇒ score = −∞(**不可选**,只能排队或换节点)
```
- **速度略重(0.55)**:实测里"能不能连上"由 RTT 决定;且第一瓶颈是 **presence**,不是带宽。
- **满载不参与打分**,直接出局 —— 这正是用户要的"负载满的时候不能加入"。
- `recentFailures` **只降权不排除**:唯一可用但常失败的节点,也好过没有节点。
- **手动指定优先**(`manualId`):给了它就**只考虑它**;它满了 ⇒ `queued`(排队)或 `rejected`(`allowQueue:false`),**绝不静默改选别的节点**。
## 13.3 三种出口都有明确语义
| 情况 | `outcome` | 含义 |
|---|---|---|
| 手动指定且未满 | `chosen` | 尊重显式选择 |
| 手动指定但已满 | `queued` / `rejected` | 排队等待 / 拒绝加入(**不偷偷换**) |
| 自动且有空位 | `chosen` | 按速度 + 负载打分取最高 |
| 自动但全满 | `queued` / `rejected` | 排队等位 / 直接拒绝(`allowQueue:false`) |
| 候选为空 / 手动 id 不存在 | `rejected` | **明确拒绝并说明原因**,不抛异常让上层去猜 |
## 13.4 与既有集群化落点逻辑的关系
既有约定「存量锚 `w-47` 粘性优先、新用户按容量落 `w-106`」在本模块里的表达就是:
**粘性 = `manual`/高 `weight`**,**按容量 = `capacity` 打分**。⇒ 门户/Manager 已有的落点选择**不需要改判据**,
只要把画象喂给 `chooseNode()` 即可(同一套判据,不会出现"门户算一套、relay 算另一套")。
## 13.5 落地状态
| 项 | 状态 |
|---|---|
| 代码 | `src/net/relay/placement.ts`(新增)、`server.ts` 容量准入、`client.ts` 排队语义、`main.ts` `--max-hosts` / `DSHS_RELAY_MAX_HOSTS` |
| 单测 | T13 / T14 / T15 ✅ |
| 真机 | 47 上 `--max-hosts 1`:A 注册成功、**B 被拒并排队**、服务端 `capacity:{max:1,used:1,free:0}` ✅ |
| 未做 | Manager/门户侧接 `chooseNode()`(属 R3 集成);relay 集群的多中继选主(本阶段不需要) |
---
## 14. R2 落地:relay 常驻 47 + 经 nginx 443 暴露 wss(2026-09-16 21:0x,**已验收**)
### 14.1 落地形态(实测)
| 项 | 值 |
|---|---|
| 服务端产物 | 47 `/opt/dsh-relay/lib/net/relay/*` + **`lib/net/reachability.js`**(⚠️ `rendezvous.js` import 它)+ `/opt/dsh-relay/package.json` = `{"type":"module"}` |
| 单元 | `/etc/systemd/system/dshs-relay.service`(`enabled` + `active`;`Restart=always`) |
| ExecStart | `/usr/local/bin/node /opt/dsh-relay/lib/net/relay/main.js --port 20080 --keys-file /etc/dshs/relay-keys.json --base 20000 --span 1000 --max-hosts 0` |
| 密钥 | `/etc/dshs/relay-keys.json`(`600`;**每 worker 一密钥** `w-47` / `w-106`,各 64 hex) |
| 入口 | `alotbuy.com.conf` 443 server 块内**只加一个** `location /dshs-relay` → `proxy_pass http://127.0.0.1:20080`(复用 `nginx.conf:321` 的 `map $http_upgrade $connection_upgrade`) |
### 14.2 验收证据(命令原文级)
⚠️ **本轮两处取证纠正(⛔ 勿沿用旧结论)**
1. 🔴 **门户 443 块在 `alotbuy.com.conf`,不在 `dsh.alotbuy.com.conf`** —— 后者是**遗留 301 跳转域名**(`return 301 https://alotbuy.com$request_uri`,`server_name dsh.alotbuy.com *.dsh.alotbuy.com`)。R2 交接单里写的那个"候选"是错的;也**不能**把 location 加进 301 块。
2. 🔴 **`dsh.alotbuy.com` 在 Cloudflare 后面**(`104.21.44.42` / `172.67.194.206`)⇒ 验收 URL 用 **`https://alotbuy.com/dshs-relay`**;且 443 块是 `listen 443 ssl; http2 on;` ⇒ **curl 默认协商 h2,经典 `Upgrade` 握手必失败(实测 `HTTP/2 404`)**,判据命令**必须带 `--http1.1`**。
| 判据 | 命令 | 实测 |
|---|---|---|
| 服务在跑 | `systemctl is-active dshs-relay` | `active`;`is-enabled` = `enabled` |
| 只绑回环 | `ss -lntp \| grep 20080` | `127.0.0.1:20080`(**不是** `0.0.0.0`) |
| 状态面 | `curl -s 127.0.0.1:20080/status` | 含 `"capacity":{"max":0,"used":0}` |
| **零新增公网口** | 本机 `curl -m 5 http://47.77.182.89:20080/status` | `http_code=000`、`curl_rc=28`(不可达) |
| 启动日志 | `journalctl -u dshs-relay` | `[relay] listening ws://127.0.0.1:20080/dshs-relay (loopback only) instance-ports=20000..20999` |
| nginx 语法 | `nginx -t` | `syntax is ok` + `test is successful` → `reload` |
| **origin 直连 101** | `curl -k -i --http1.1 --resolve alotbuy.com:443:127.0.0.1 -H 'Upgrade: websocket' -H 'Sec-WebSocket-Version: 13' -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' https://alotbuy.com/dshs-relay` | `HTTP/1.1 101 Switching Protocols` |
| **经 Cloudflare 101** | 同上去掉 `--resolve` | `HTTP/1.1 101` + `Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=` |
| 404 来源可辨 | 普通 GET `https://alotbuy.com/dshs-relay` | body = **relay 自身文案** `dshs relay: WebSocket upgrade only, at /dshs-relay` ⇒ 证明 location 真打到 20080(而非门户 3080) |
| 门户未受损 | 47 本机 `curl --resolve alotbuy.com:443:127.0.0.1 https://alotbuy.com/portal.html` | `code=200 size=50726`;`/api/dsh/status` → `401 size=24`(正常未授权) |
| 监听面零变化 | `ss -lntp \| awk '{print $4}' \| sort -u` | **改前改后逐字一致**(无新增 `0.0.0.0` / `*`) |
| **重启韧性** | `systemctl restart dshs-relay` 后复测 | `active` + `20080` 回归 + nginx 路径仍 `101` |
| **端到端** | 47 上 `node lib/net/relay/main.js --client --url wss://alotbuy.com/dshs-relay --host w-47 --keys-file /etc/dshs/relay-keys.json --ports 20099` | client:`registered host=w-47 session=4afc68c53de7991f accepted=[20099] clockSkew=6ms`;服务端 `/status`:`online=[w-47(…ports=20099…)]`、`endpoints=[{hostId:w-47,port:20099,localPort:42067,online:true}]`;journal:`AUTH OK host=w-47 session=4afc68c53de7991f ports=[20099]` |
| 优雅停机 | 对上述 client 发 `SIGTERM` | client `EXITED`;relay journal `session 4afc68c53de7991f (w-47) dropped: connection closed`;`online=[]`、`used=0`;**残留进程 0**、**残留回环口 0** |
### 14.3 本轮三条硬结论(下一棒必读)
1. 🔑 **`--base/--span` 的语义 = "允许 worker 声明的实例端口区间"(准入校验)**,见 `server.js:348`(越界 → `port-out-of-range`);**⛔ 不是 relay 本地回环口的区间** —— 本地口是 `server.js:558` 的 `listen(0)`,由 **OS 动态分配**。⚠️ R1.5 记忆里"relay 本地端口区间隔离"的表述**不准确,已勘误**:实测 `localPort=42067`(落在 OS 临时段)**是设计使然、不是 bug**;真正的隔离由「**每个 `(hostId,port)` 一个独立 `node:net` 监听 + 一条独立到对面的 socket**」保证 —— 本就不存在共享的端口编号空间。
2. 🔴 **"占着实例端口的不一定是残留进程"** —— 47 上 `127.0.0.1:20000` 的占用者 `pid 720541`,实测是**在线用户实例**(`node /usr/local/bin/dsh --profile web --host 127.0.0.1 --port 20000`,`cwd=/var/lib/dshs/users/cce6d1cd-b376-4304-80f0-0e1c58c9ffde/ws`,已跑 4h41m)⇒ 判别法 = **`tr '\0' ' ' < /proc/<pid>/cmdline` + `ls -l /proc/<pid>/cwd`**(回环口 → uid ≠ 0 即用户实例)。⛔ 别按端口号猜、⛔ 别 `pkill -f`。
3. 🟡 **`http2 on` 与"经典 WebSocket 握手"在 curl 侧互斥** ⇒ 判据命令**必须 `--http1.1`**;Cloudflare 对 WS 请求会自行降级 HTTP/1.1 回源,故**真实用户路径不受影响**(实测 101)。
### 14.4 回滚(两步各自秒级,SSH 路径从头到尾没动)
- **R2-b**:`cp /www/server/panel/vhost/nginx/alotbuy.com.conf.bak-20260916-2059-pre-relay /www/server/panel/vhost/nginx/alotbuy.com.conf` → `nginx -t` → `nginx -s reload`
- **R2-a**:`systemctl disable --now dshs-relay` → `rm -f /etc/systemd/system/dshs-relay.service` → `systemctl daemon-reload`(`/opt/dsh-relay` 可留,不占端口即无害)
@@ -0,0 +1,113 @@
# 覆盖网络 · 应用场景推演完成度 & 方案待完善清单(2026-09-16 检查)
> **性质**:只读检查报告。⛔ 未改任何代码、未动服务器、未写文档库(全局执行锁被 `修复轮-决策方法-2b` 占用)。
> **检查对象**:工作区根覆盖网络线 13 份文档。
> **📌 口径校正(2026-09-16 11:4x 用户纠正)**:本方案的目标应用场景 = **① 多人 + agent 对话 ② MUD / MMORPG 网游 ③ 以上述应用为负载的 1000 台异构网络互联**。此前本报告 §一 把"跨机访问实例/文件交换"排在第 1 档,属**次级用途**,已按用户口径重排。
---
## 一、判定:三块场景的「互联逻辑模拟」完成了吗
| # | 应用场景 | 纸面推演 | 可运行模拟 |
|---|---|---|---|
| 1 | **多人 + agent 对话** | ✅ **完成**(S2/S4/S5 有数值 + 调研 §2 分档 + 答疑 §一) | ❌ 零 |
| 2 | **MUD / MMORPG 网游** | ✅ **完成**(S3 有数值 + 游戏专项 12 条 + 调研 §1) | ❌ 零 |
| 3 | **1000 台异构互联(承上述负载)** | ✅ **完成**(6 类画像 + S1–S11 + 流量预算总表) | ❌ 零 |
**结论**:
- **"文字推演"这一步是完成的** —— 11 个场景逐个有计算、有结论、有处置手段,三块应用都有对应推演。
- **"模拟"一步都没开始** —— **全部是纸面演算,零代码、零数据回流**。文档自己标注"未接入任何机器"。
- ⇒ 差别在于:**现在所有输入数字都是估值**(打洞成功率、jitter、每玩家带宽全是估的),文档自己写"关键结论**对比例敏感、对绝对值不敏感**"。**换言之:结构判断可信,具体容量数字还不可信。**
---
## 二、三块场景的实际覆盖(逐块)
### 2.1 多人 + agent 对话
| 已推 | 出处 | 数值 |
|---|---|---|
| presence(200 房 × 50 人) | S2 | **8,200 次/秒** |
| presence(1 个 1000 人大房) | S2/S4 | **16,700 次/秒** ← 第一瓶颈 |
| 大房消息扇出 | S2/S4 | 1,000 投递/秒(可承受) |
| agent 放大 | S5 | 无约束 ⇒ **指数增长**;四条硬约束 |
- ⭐ 最重要的量化结论:**presence 比消息早爆一个量级**(16,700 vs 1,000)。
- ⚠️ **缺口 A**:**agent 预算从头到尾没有标定数值** —— 而 §5 结论却说"单房间实际上限 = min(扇出预算, presence 预算, **agent 预算**)"。三个预算里有一个是空的 ⇒ **单房间上限目前给不出数**。
### 2.2 MUD / MMORPG 网游
| 已推 | 出处 | 数值 |
|---|---|---|
| 40 服 × 300 玩家,服放 L1 | S3 | 每服 24 Mbps,**中继承载 0** |
| 服放家宽(25 服) | S3 | **600 Mbps 常驻** ← 第 2 瓶颈 |
| 攻城战峰值 | S3 | 1.2–2.0 Gbps |
| 抖动门槛 | S3 | **jitter < 20 ms** |
| 核心简化 | 游戏专项 | 玩家之间不需要互联,**中继按服数算** |
- ⚠️ **缺口 B(逻辑跳跃)**:S3 推出"服放家宽 ⇒ 600 Mbps 过中继",但**玩家是外部客户端、不是覆盖网络成员** —— 他们凭什么走我们的中继?游戏专项的说法是"服主动拨出到骨干、玩家连骨干入口",可**"骨干入口"对外开放 = 把中继变成公网游戏入口**,这是完全不同的形态(权限面扩大 + 计费 + 防滥用),**这一分支在 13 份文档里没有展开**。而它恰好是第 2 大瓶颈的来源。
### 2.3 1000 台异构网络互联
| 已推 | 数值 |
|---|---|
| 设备构成 | 6 类(云服务器 100 / 家宽 300 / CGNAT 200 / 移动网 150 / 企业网 150 / VPN 100) |
| 需中继占比 | **48.5%**(按 45% 设计、55% 留余量) |
| 分场景 | **S1–S11 全覆盖**(冷启动 / 稳态 / 游戏 / 大房 / agent / 备份 / 迁移 / 中继故障 / 控制面重启 / 区域突变 / NAT 溢出) |
| 流量预算总表 | 10 类流量 × 频率 × 单次 × 放大 × 上限手段 |
- ⚠️ **缺口 C**:**逐场景独立推,没有并发叠加** —— S1–S11 是串行列举。真实最坏情况是"游戏攻城战 + 备份窗口 + 1000 人大房 + agent 风暴同时发生,且其中 1/3 节点正因中继故障重连",**这一叠加没有任何一份文档推过**,而瓶颈排序表是按"单瓶颈先炸顺序"排的。
---
## 三、要把"推演"变成"模拟",还差三件事
1. **固化输入参数表**(设备占比 / 打洞率 / 每玩家带宽 / 消息频率 / 心跳 —— 现在散在 6 份文档里,无统一表 ⇒ **不可复算**)。
2. **补并发叠加的最坏情况**(缺口 C)。
3. **用 3–5 台真机把 3 个关键估值换成实测**:打洞成功率、中继 jitter、真实可用带宽 —— 这三个一换,全部容量结论才从"结构可信"变成"数字可信"。
---
## 四、方案待完善清单(P0 / P1 / P2)
### P0 · 不补就走不通(5 条)
| # | 缺口 | 为什么是 P0 |
|---|---|---|
| **1** | **缺「网(network)」抽象** | 骨干被同时当作"平台 Worker 隧道"与"用户设备 P2P"的会合点,两者权限模型完全不同。**13 份文档从未出现"网络标识(tailnet / network id)"** ⟹ 所有节点落进同一扁平命名空间,与"可见性默认最小"冲突 |
| **2** | **首次入网引导(bootstrap)无答案** | 会合地址从 env 来,但新设备**第一次**怎么拿到、**首次没有缓存签名目录**时怎么办,无人回答 |
| **3** | **地址规划与名字解析无落地设计** | 内部地址段怎么分、如何避开用户内网 10/8 · 192.168/16、split DNS 怎么不破坏用户原 DNS |
| **4** | **信任根与密钥生命周期未设计** | 只写"一机一钥 + 可吊销",缺控制面签发者(根)的保管与轮换、私钥丢失恢复、设备被盗吊销 |
| **5** | **外部玩家如何进入游戏服(缺口 B)** | 决定第 2 大瓶颈(600 Mbps)是否真的存在 —— 若玩家不能进,这条瓶颈根本不成立;若能进,则是**权限面扩大**(命中 R5) |
### P1 · 不做会出事(7 条)
参数表是空的(libp2p 默认值可直接固化)· 30 条未验证项未收敛成取证计划(最该先测 jitter)· 观测最小指标集与阈值缺失 · **权限面评估缺失 = 命中 R5**(虚拟网卡驱动需管理员权限 / 骨干开端口 / nft 打洞)· 成本模型缺失 · 滥用与安全事件处置缺失 · 卸载与退出机制缺失 · 协议选型未收敛 · **最小可用规模(3–5 台)路径缺失**。
### P2 · 一致性与流程(4 条)
首屏口径笔误(瓶颈落地 §3 标题写 10.8 GB,实为 **10.8 MB/台**)· 落地路线图 **4 个版本**未收敛 · 10 份文档未归档 · 双源声明与判定句矛盾未修(可行性评估 §6.4 已指出)。
---
## 五、唯一主线(收敛 4 个版本后)
| 序 | 动作 | 状态 |
|---|---|---|
| 1 | 会合 / 中继从 Manager 拆出(S0–S4) | **现在就能开工** |
| 2 | 网抽象 + 地址规划 + 引导(P0 1/2/3) | 依赖 ① |
| 3 | 一机一钥 + 信任根(P0-4) | 依赖 ① |
| 4 | 443/TCP 兜底(否则封 UDP 的整类节点进不来) | 依赖 ① |
| 5 | **参数表 + 观测最小集 + 权限评估**(P1 三条) | 可并行 |
| 6 | **3–5 台最小形态跑通**(把 3 个关键估值换成实测) | 依赖 2/3/4 |
| 7 | 之后才谈:内容分发 · 房间层 · 游戏 | — |
> ⚠️ **别被 1000 台带偏**:1000 是设计上限;**当前目标是同一人的几台设备跨公网互通**。
---
## 六、本次未做(范围声明)
- ⛔ 未写文档库(全局执行锁被占用);本报告落工作区根。
- ⛔ 未改代码、未在 47 / 106 执行任何命令。
- 📌 **未新增上抛项** —— 待拍板仍只有骨干服务范围一项(A 自用 / B 全网,倾向 A→B 渐进)。
@@ -0,0 +1,116 @@
# 覆盖网络 · 「插件 vs 改内核」架构判断(2026-09-16)
> **性质**:只读架构评估。⛔ 未改任何代码、未动服务器。
> **取证范围**:`D:\github\dsh_shenxian` 工作树(HEAD `4e3a1a4`)—— `src/supervisor/spawner.ts`(全读)· `src/worker/agent.ts`(头部 62 行 + 接口)· `src/` 结构清点 · 会合中继拆分方案 C1–C4。
> **一句话判定**:**主力形态既不是"dsh 插件"也不是"把东西塞进平台主进程",而是"平台内模块化 + 独立进程/单元"** —— 因为要解决的问题(跨机可达、节点身份、寻址)**发生在实例之外**,dsh 插件机制在架构上够不到;而平台**已有**独立组件的先例(Manager / Worker 分体),顺着走即可。
---
## 一、先拆概念:「插件」在这个项目里有两个完全不同的含义
| | (a) **dsh 官方插件**(profile 层业务插件) | (b) **平台自身的模块化** |
|---|---|---|
| 跑在哪 | **用户实例内部**(dsh 进程内) | 平台进程 / 独立进程 |
| 能做什么 | 加 UI 卡片与分区、注册工具、改实例内行为 | 任何事 |
| 够得到 Worker↔Manager 隧道? | ❌ **架构上够不到**(实例是隔离的运行体,看不见宿主网络栈) | ✅ |
| 发版与回滚 | 独立(候选池 → 用户自助启停) | 跟平台版本走 |
| 现有先例 | `@dsh-local/portal-entry`、`business-plugins` | Manager / Worker 分体、`dshs-worker.service` |
**⇒ 概念澄清(很重要)**:覆盖网络**根本不涉及 `@deepseek-ai/dsh` 主程序** ⇒ **红线 R2 不构成约束**,这套东西整个发生在**平台侧**。所以"插件 vs 改代码"的真正对象是**平台自己的代码仓**,不是 dsh。
---
## 二、三层归属:哪块该放哪
| 层 | 内容 | 建议形态 | 理由 |
|---|---|---|---|
| **数据面组件** | 会合(rendezvous)· 中继(relay)· **发布层**(对外服务暴露) | **独立进程 / 独立 systemd 单元**(可多实例、可换机) | ① 跨机、要碰宿主网络栈 ② 需独立扩缩容与**独立安全加固**(发布层要挡 DDoS)③ 塞进主进程 = 与平台同生共死,违反"控制面/数据面分离" |
| **平台侧集成** | 寻址(`via`)· 节点身份与凭据 · 骨干资格签发 · 容量准入 | **改平台代码** | 必须与**归属 / 租约同源**(单点写),外置就是第二个权威源 = 脑裂 |
| **实例内展示与工具** | 「我的网络 / 我的设备」面板 · agent 的文件互传工具 · 网络状态 | **dsh 插件**(唯一真正适合插件的地方) | 纯实例内 UI 与工具,正是插件机制的用途 |
---
## 三、三种形态的优劣
### A · dsh 插件形态(把覆盖网络做成插件)
**优点**:不动平台内核;可独立发版;用户可自助启停;回滚粒度细(禁用即可)。
**缺点**:**能力边界是硬的** —— 插件跑在实例内部,**看不见宿主网络栈、够不到 Worker↔Manager 隧道、无法参与控制面归属与租约**;覆盖网络的核心问题(跨机可达 / 节点身份 / 寻址)**一个都解决不了**;且插件加载失败会把实例拖进崩溃循环(已有探活回滚机制,但仍是额外风险面)。
**⇒ 判定:只能覆盖第三层(实例内展示与工具),不能承担主力。**
### B · 改内核形态(把会合/中继塞进平台主进程)
**优点**:改动集中;复用现有配置与日志;部署单元不增加;短期最快。
**缺点**:与平台**同生共死**(中继挂了平台一起受影响);中继要**独立扩缩容**时做不到;**对外暴露面与内核同进程**(发布层因此无法做独立安全加固);端口空间与 Manager 全局共享(就是现状的 C2 耦合);版本矩阵无法独立演进。
**⇒ 判定:现状就是这个形态,也正是要拆掉的那个问题。**
### C · 独立组件 + 平台内模块化(**推荐主力**)
**优点**:① **顺着现有架构走** —— 平台已有 Manager / Worker 分体与独立 systemd 单元的先例,不是新造范式;② 控制面/数据面**天然分离**,符合既有分层判据;③ 可独立换机、多实例、独立加固(发布层单独处理 DDoS);④ 每一步可独立回滚(S0–S4 已定)。
**缺点**:多一个部署单元与版本矩阵;本机开发环境要能跑起来(本机是 Windows,需容器或远程);会引入"组件间契约"这条新的维护面。
---
## 四、现有项目代码评估:**设计基本合理,且为这个方向留了缝**
### 4.1 合理之处(有代码证据)
| # | 事实 | 为什么关键 |
|---|---|---|
| 1 | `Spawner` 是干净的后端抽象缝(`launch / stop / status / endpointFor / restartAndProbe`,`src/supervisor/spawner.ts:94-149`) | **新增一种节点 = 多一个实现**,不是改内核;route 层只依赖接口 |
| 2 | Worker agent 有**四条协议纪律**(单向拨入 / 幂等键 `operationId` / 最小接口白名单 / self-fencing,`src/worker/agent.ts:10-17`) | 这**就是**现成的"节点契约"—— 客户端节点可直接复用同一套语义 |
| 3 | 归属 / 租约**只有 Manager 能写**(`epoch+1` fencing) | 与"客户端不可信"天然相容 ⇒ 客户端天生只能当数据面,不会有脑裂 |
| 4 | `tunnelTarget === '' ⇒ 完全不建隧道`(`agent.ts:154`) | **扩展点是预留的**:同机形态零影响,跨机才启用 |
| 5 | 全仓仅 **1 处** `process.platform` 分支(`supervisor/firewall.ts:42`) | 跨平台改造成本低 |
| 6 | `instanceHost` 已是参数(注释:"跨机时填内网 IP") | 寻址**已经是参数化的**,不是写死 |
### 4.2 不足(4 处,均为**已知或有据**的具体欠账)
| # | 不足 | 证据 | 影响 |
|---|---|---|---|
| 1 | **寻址只有 `{host, port}`,表达不了"经谁到达"** | `Endpoint = {host, port}`(`spawner.ts:80-83`);`hostsProvider` 把 `dsh_hosts.endpoint` 直接当 `agentUrl` | 换中继时表里无处表达 ⇒ **必须加 `via` 字段**(越晚改越贵) |
| 2 | **共享 bearer token,无法按节点吊销** | `agent.ts:40` 注释自陈:"本版是 bearer 式比较,**HMAC/防重放留待后续**" | 公网上不成立;**这是项目自己记录的已知欠账**,正好是覆盖网络要补的 |
| 3 | **中继与 Manager 同生共死** | 隧道落在 Manager 的 sshd(`32022`),端口空间与 Manager 全局共享 | 中继不可多实例、不可换机、单点 |
| 4 | **控制面 PG 复用同一条隧道** | `DSHS_TUNNEL_STATIC_PORTS`,设计上含控制面 PG | 换中继时 DB 连接一起断 ⇒ **回滚面比看上去大** |
### 4.3 总评
> **合理程度:中上。** 它是"**为多节点预留了缝**"的设计 —— 抽象缝(Spawner)、协议纪律、参数化寻址、开关式隧道,这四样都在。
> **但也正因为缝开对了,改造的成本主要在"补两个字段 + 分两步换绑定",而不是重构内核。**
> 唯一的严肃欠账是**身份**(共享 token),而项目**自己已经在注释里写明了**这是待办 —— 说明设计者是清醒的,不是漏掉。
---
## 五、能否支持这个方向的改造:**能,且不需要重构**
| 需要的改造 | 现有设计支持度 | 落点 |
|---|---|---|
| 新增一种节点类型(客户端/骨干) | ✅ **直接支持** —— 加一个 `Spawner` 实现 + 复用 agent 四纪律 | 新文件,不动内核 |
| 寻址加 `via` | ⚠️ **需加字段**(向后兼容:有默认值,旧代码不受影响) | S2(方案已出) |
| 会合地址出 env | ✅ **易**(`tunnelTarget` 已是配置;再加一个优先项即可) | S1 |
| 中继独立成单元 | ✅ **易** —— 已定"不换协议、只换绑定关系" | S4 |
| 节点身份(一机一钥) | ⚠️ **要新写**,但接口位置清楚(token 校验点集中) | 拆分方案之后 |
| 发布层 | 🆕 **全新**,与平台主进程分开 = 天然适合独立单元 | 新组件 |
**⇒ 结论:改造路径与现有架构**不冲突**,是"顺着缝往下切",不是"推倒重来"。**会合中继拆分方案 S0–S4 已经把顺序定好了(S0 纯新增文件 + 可选字段,零行为变化)。
---
## 六、我选了什么(可推翻)
1. **主力形态 = 独立组件 + 平台内模块化**,不是 dsh 插件、也不是塞进主进程。
2. **dsh 插件只承担第三层**(实例内的"我的网络/我的设备"面板与文件互传工具)—— 这是唯一适合插件的部分。
3. **先"平台内模块化 + 独立 systemd 单元",暂不拆独立仓库** —— 现在只有两台机器,独立仓库会带来版本矩阵与同步成本;而 S0 的接口抽象已经让"以后再拆"变得便宜。
4. **身份(一机一钥)放在拆分之后**,但它**必须排在任何公网暴露之前**。
---
## 七、本次未做
- ⛔ 未改任何代码、未动服务器、未写文档库(全局执行锁被 `修复轮-决策方法-2b` 占用)。
- 📌 未新增上抛项 —— 待拍板仍是:骨干服务范围(A 自用 / B 全网)+ 发布层形态与成本。
@@ -0,0 +1,274 @@
# 覆盖网络方案 · 问题逐条推演与解决方案(2026-09-16)
> **性质**:只读推演稿(调研 + 推演)。⛔ 未改代码、未动服务器、未写文档库(全局执行锁被占用)。
> **方法**:每条按固定骨架 —— **① 问题 ② 查到什么资料 ③ 推演 ④ 结论(能不能解 / 怎么解 / 代价)**。
> **结论先行**:**P0 五条全部可解**,其中 2 条有成熟范式可直接照抄、3 条要自建但路径清晰;**3 条推演缺口全部可补**;**但有 3 处新发现的坑**(见 §D),其中 1 处(地址段冲突)**不提前处理必然出事故**。
---
## A. P0 五条
### A1 · 缺「网(network)」抽象
**① 问题**:骨干被同时当作"平台 Worker 隧道"和"用户设备 P2P"的会合点,两者权限模型完全不同;13 份文档从未出现"网络标识"概念。
**② 资料(Tailscale 范式)**:每个 **tailnet 是独立命名空间**,各自一份 ACL 策略文件(huJSON),结构为 `tagOwners` / `groups` / `acls` / `tests` / `postures`;权限**挂在 tag(角色)上而非 IP 上**;跨网共享用"**只共享单个节点**"(Machines → Share),不下发全网名单。ACL 支持 `tests` 断言(可在 CI 里验证"某来源**不能**到达某目标")。
**③ 推演**:我们需要的不是一张网,而是**三类网并存**:
| 网 | 成员 | 可见性 | 谁会用到 |
|---|---|---|---|
| **运维网** | 我们自己的机器(47 / 106 / 未来的中继与骨干) | 管理员专有;Worker 只对 Manager 可见 | 平台自身(现有 SSH 隧道的位置) |
| **用户网** | **每用户一张网**:该用户全部设备 + 该用户显式授权的他人设备 | 默认只见自己名下设备 | 场景 1/2/3(跨机访问、文件交换) |
| **发布层**(见 A5) | 公网转发节点 | 对外端口/域名,**不是网内成员** | 游戏、服务暴露 |
**关键判断:用户网要"每用户一张网",不要"一张巨网 + ACL"。**
- 理由一:隔离从**策略性**变成**结构性** —— 后者写错一条 ACL 就泄露,前者结构上不可能越界。
- 理由二:ACL 的 `tests` 可以进 CI,把"不能到达"变成可回归的断言。
- 理由三:跨用户协作走"**只共享单节点**",这与项目"权限只准收窄"一致。
**④ 结论**:✅ 可解,**直接照抄 tailnet 范式**。代价 = 控制面租户模型里把**用户 ID 提升为网络标识**(我们已有租户表,这一维是加一列而非重做)。⚠️ **现在只有 1 个用户 ⇒ 结构成本几乎为零,但一旦多人就省下一次大改** —— 所以**要在第一次落地时就分开,不能等**。
---
### A2 · 首次入网引导(bootstrap)
**① 问题**:会合地址从 env 来,但新设备第一次怎么拿到它、首次没有缓存签名目录时怎么办,无人回答。
**② 资料(headscale 范式)**:客户端**只需要知道一个 `server_url`**,其余(DERP 中继地图)全部由控制面下发 —— DERP map 来源支持 `urls`(远程 JSON)与 `paths`(本地 YAML),并有 `auto_update_enabled` + `update_frequency`(默认 3h–24h)定期刷新;DERP 强制 HTTPS/TLS;`region_id` 在 map 内必须唯一;客户端侧有本地缓存。
**③ 推演** —— 三级引导链:
1. **引导种子(内置)**:客户端二进制里写死 **2–3 个 HTTPS 引导地址**(不同地域)。它只回答"第一次问谁"。
2. **签名目录(下发 + 缓存)**:控制面返回经签名的"可用会合点 / 骨干 / 中继"列表。客户端缓存,`update_frequency` 级别刷新。
3. **离线降级**:缓存过期仍可用(只影响新节点加入,不影响已建连接)。
**🔑 推演出的关键设计(资料里没直说,但不做会成灾)**:**引导地址必须能通过已建立的连接在线下发新引导地址**。否则将来换域名/换机器 = 所有客户端必须升级重装。
**④ 结论**:✅ 可解,**很成熟**。代价:一个域名 + TLS 证书 + **N+1(至少 2 个引导点)**;以及"引导地址轮换"这条运维流程要写进方案。
---
### A3 · 地址规划与名字解析
**① 问题**:内部地址段怎么分、怎么避开用户内网、名字谁解析。
**② 资料(含一条对我们的硬警告)**:
- Tailscale 客户端**硬编码**两个段:IPv4 `100.64.0.0/10`(CGNAT 段)+ IPv6 `fd7a:115c:a1e0::/48`;headscale 的 `prefixes` **必须是这两者的子集**,否则"undefined behaviour / break in subtle, hard-to-debug ways"。
- **⚠️ 官方与社区共同警告:「避免与 CGNAT 段(100.64.0.0/10)重叠」** —— 而**中国移动等运营商的大内网正好就是 100.64.0.0/10**(社区文档明确点名"类似于中国移动宽带的 NAT 网段")。
- MagicDNS:`hostname.user.basedomain`;**`base_domain` 必须与 `server_url` 域名不同**以免冲突;`nameservers.split` 做按域分流(split DNS);`override_local_dns` 有开关;实践中不少人建议 `magic_dns: false` + 客户端 `--accept-dns=false`,避免覆盖用户系统 DNS。
**③ 推演 —— 这是本项目最容易踩死的一处**:我们的设备池里有 **200 台 CGNAT + 150 台移动网**(千台推演 §0.1)。这些节点**本机很可能就处在 100.64.0.0/10 内**。若把覆盖网内部地址也分配到该段,会出现:① **宿主路由冲突**(发往覆盖网对端的包被送进运营商网关)② 表现是"部分节点时通时不通",**极难排查**。
**方案(两条硬前提)**:
1. **主寻址走 IPv6 ULA**(`fd00::/8` 内选一段)—— 唯一性有保证、与用户内网几乎不冲突,正好吃满补遗已列的"IPv6 优先"红利。
2. **IPv4 只作兼容层**,且**必须做本地网段冲突检测**:检测到冲突 ⇒ 该地址**自动让路**(回落到 IPv6 或名字寻址),并在客户端明确报错(不能静默)。
**名字解析**:MagicDNS 范式,但三条约束 —— ① `base_domain` 用**子域**(如 `net.<我们的域名>`),与门户域名分开;② **必须 split DNS**(我们的名字走我们的解析器);③ **默认不接管用户系统 DNS**(提供显式开关,默认关)。
**④ 结论**:✅ 可解。**代价 = 冲突检测必须写进客户端首版**(事后加极难,因为要改路由层)。
---
### A4 · 信任根与密钥生命周期
**① 问题**:只写了"一机一钥 + 可吊销",缺根密钥保管/轮换、私钥丢失恢复、设备被盗吊销。
**② 资料(Tailnet Lock 白皮书,几乎是为我们这个问题写的)**:
- 控制面必须分发**节点公钥**,所以"被攻破的控制面可以插入攻击者节点"是这套架构的**固有弱点**。
- Tailnet Lock 的解法:引入 **TLK(Ed25519)签名密钥集合**;**新节点的公钥必须带一个受信任 TLK 的签名**,**每个节点在本地校验**,验不过就不建立会话。
- **TLK 私钥由本地保管,控制面看不到也改不了**;受信任 TLK 集合的变更本身也要签名 + 本地校验;还有 **disablement secret**(关闭机制)。
- 冲突更新用**权重**裁决。
- 配套:`key expiry`(用户节点定期过期 / tagged 节点不过期)、ephemeral 节点超时删除、Noise 私钥丢失 ⇒ 所有客户端重注册。
**③ 推演 —— 四层密钥模型**:
| 层 | 放哪 | 用途 | 丢失后果 |
|---|---|---|---|
| **根(离线)** | 用户手里(纸质恢复码 / 离线设备) | 只用于授权/撤销"签名者" | **最严重** ⇒ 全网重建 |
| **签名者(在线,多把)** | 每台管理员设备一把,受根授权 | 签发节点入网凭据 | 换一把(根仍在) |
| **节点密钥** | 每设备一把(系统密钥库) | 设备身份 | 该设备重签 |
| **会话密钥** | 内存 | 隧道(定期 rekey) | 无感 |
**恢复路径(都不需要控制面参与)**:私钥丢 ⇒ 根密钥重签;设备被盗 ⇒ 用签名者密钥撤销该节点签名。
**④ 结论**:✅ 可解,**照抄 Tailnet Lock 的形状**。代价 = 客户端多一层概念 + **必须做"根密钥恢复演练"**;⚠️ 根密钥必须有 **≥2 份离线副本**,否则根丢失 = 全网重置。
---
### A5 · 外部玩家如何进入游戏服(原报告的"逻辑跳跃")
**① 问题**:S3 算出"服放家宽 ⇒ 600 Mbps 过中继",但玩家是**外部客户端、不是覆盖网络成员**,凭什么走我们的中继?这条路径 13 份文档没定义。
**② 资料(三种形态,业界都很成熟)**:
| 方案 | 玩家要不要装东西 | 延迟 | 暴露面 | 代表 |
|---|---|---|---|---|
| 端口转发 | 不要 | **最低**(原生) | **暴露家宽 IP** ⇒ 被 DDoS / 关联到个人信息 | 传统做法 |
| **内网穿透 / 隧道** | **不要** | +10–50 ms(多一跳) | 中继侧暴露 | **playit.gg**、frp、ngrok |
| 虚拟局域网 | **要(每人装)** | 低(P2P 成功时) | 高(加密私网) | Tailscale / ZeroTier |
- playit.gg 模式:**本地服主动拨出**到服务商 → 服务商给一个公网地址 → **玩家零安装直连该地址**。
- 已知代价(官方/社区共同口径):中继一跳的延迟、**家宽上行决定玩家数(常见 5–10 人上限)**、无 DDoS 防护、免费档限带宽。
- frp 还支持 **XTCP(P2P,流量不过服务器)** —— 即"先经隧道协商、再打洞直连",与我们中继的设计同构。
**③ 推演 —— 三种可能形态,只有一种对**:
| 形态 | 判定 |
|---|---|
| 服在**有公网 IP 的节点** | ✅ **最优**:玩家直连,覆盖网不参与(= S3 的 L1 情形,中继 0) |
| 服在**家宽/CGNAT 后** | ✅ **走"内网穿透"**:服主动拨出到**发布节点**,发布节点对外开地址;**玩家零安装** |
| 玩家**装客户端入网** | ❌ 千台口径下不可行(玩家是海量外部客户端,不是网络成员) |
**🔑 结论:方案缺了一层"发布层 / 服务暴露层"**,而且它有两条硬边界:
1. **发布层不能复用用户贡献的骨干** —— 否则用户机器在替第三方对公网转发流量、且暴露其家宽 IP ⇒ **命中 R5 且是我们不能替用户承诺的事**。
2. **发布层必须与内部中继物理/逻辑分开** —— 一个是对内数据面,一个是对外暴露面,安全加固要求完全不同(DDoS 防护、端口占用、UDP 支持、滥用封禁)。
⇒ **瓶颈性质因此改变**:S3 的"600 Mbps"不是"内部中继容量",而是**对外出口带宽 + DDoS 承压面**。
**④ 结论**:✅ 可解(有现成范式)。代价 = **新增一个独立组件 + 独立的加固与计费**;且这是**权限面扩大**(命中 R5),必须先出权限影响评估。
---
## B. 推演缺口三条
### B1 · agent 预算标定(此前完全没有数值)
**① 问题**:§5 结论写"单房间上限 = min(扇出预算, presence 预算, **agent 预算**)",但 agent 预算从未标定。
**② 资料(业界给的数非常具体)**:
- 通用安全参数:`max_messages_per_minute: 5` · `cooldown_seconds: 10` · `max_consecutive_self_replies: 2` · 每日 API 预算上限。
- **硬执行上限**:单次任务**最大跳数 ≤ 5**(超过转人工);**⛔ 不传原始对话历史**,改传校验过的结构化状态。
- **结构化拒绝**代替自由文本批评(防 ping-pong):评审方只能回 `{approved, errorCode 枚举, 具体修改≤200字}`。
- **⛔ 关键结论:限流必须在基础设施层强制** —— 应用层自限无效(卡死的循环、配置错误、prompt 注入都能绕过),**OWASP LLM Top 10 的 LLM04 就是把"无限制的 agent 循环"列为 top-10 风险**,要求"在 agent 控制之外强制"。
- 还有:令牌桶 + **全抖动**退避 + 熔断器 + 集群级(而非单 agent 级)配额。
**③ 推演 —— 现在可以给出数**:
| 层级 | 建议默认值 | 依据 |
|---|---|---|
| 单 agent | **≤5 条/分钟**、cooldown **10 s**、连续自回复 **≤2** | 业界通用安全参数 |
| 单次触发链 | **≤5 跳**,超限转人工 | 生产实践("3–5 跳解不了,给 10 跳也解不了") |
| 房间 agent 数 | **≤ 房间人数 / 10** | 原方案已有 |
| 强制点 | **服务端网关**(不在 agent prompt 里) | OWASP LLM04 |
**🔑 推演出的新结论(扇出重新算过)**:
1000 人房按 200 个 agent(S5 设定)× 5 条/分钟 = **16.7 条/秒** ⇒ 扇出 1000 ⇒ **16,700 投递/秒**。
- **这与 presence 的 16,700/秒 同量级!** 两者叠加 = **≈33,400 事件/秒**,是任何单场景的**两倍**。
- ⇒ 原结论"单房间上限由 presence + agent 预算决定"**得到验证**,而且现在**两个预算都有数了**。
- ⚠️ **顺带发现一处自相矛盾**:S5 设"1000 人房里聚集 200 个 agent",但硬约束③写"房间 agent ≤ 人数/10"= **100**。**200 这个设定违反了自家约束** —— 按约束应取 100,则 agent 扇出降为 8,350/秒,叠加后 ≈25,000/秒。
**④ 结论**:✅ 缺口可补,**以上数值可直接进方案**。
---
### B2 · 并发叠加(此前逐场景独立推,从未叠加)
**① 问题**:S1–S11 是串行列举,真实最坏情况是多个场景同时发生。
**② 方法推演(资料给的是机制,方法是推出来的)**:叠加推演 = **时间轴重叠检查 + 共享资源争用矩阵 + 主导项法**。
- 业界对应机制:集群级配额(而非 per-agent)、熔断器、重试预算、失败域隔离 —— 这些正是为"叠加"设计的。
**③ 推演**:
**(a) 时间轴重叠检查**
| 场景 | 时间窗 |
|---|---|
| 游戏高峰(攻城战) | 20:00–22:00 |
| 群聊活跃 / 1000 人大房 | 20:00–23:00 |
| **agent 活跃** | 随真人(⇒ 与上两者重叠) |
| **备份窗口** | 02:00 |
| 迁移 | 用户驱动 |
⇒ **游戏 + 群聊 + agent 三者天然重叠**;**备份与游戏高峰错开是既有设计,不是巧合**(这点值得写进方案当作硬约束保留)。
**(b) 共享资源争用矩阵**
| 共享资源 | 谁在抢 | 叠加后量级 |
|---|---|---|
| **发布/中继出口带宽** | 游戏 >> 备份 > 消息 | 游戏 600 Mbps 主导;峰值 1.2–2.0 Gbps |
| 控制面 req/s | 心跳 50/s + 重连突发 1000 | 令牌桶吸收(可忽略) |
| **presence 通道** | presence 16,700/s + agent 16,700/s | **≈33,400/s** ← 真正的叠加瓶颈 |
| 客户端上行 | 备份 + 文件传输 | 低优先级队列 |
**(c) 最坏组合**:**游戏攻城战 + 1000 人大房 + agent 风暴 + 中继故障重连**(四件同时)
⇒ 出口带宽吃满 2.0 Gbps、presence/agent 通道 33,400 事件/秒、同时 160 台重连。
**④ 结论**:✅ 可推,方法就是"**时间轴重叠 + 主导项(差一个数量级可忽略)**"。**重要副产品:叠加后瓶颈排序变了** —— 出口带宽仍是第一,但**第二从"presence"变成"presence × agent 叠加"**。
---
### B3 · 输入参数表(此前散在 6 份文档,不可复算)
**① 问题**:设备占比 / 打洞率 / 每玩家带宽 / 消息频率散落各处,无统一表 ⇒ 推演不可复算、不可仿真。
**② 资料**:libp2p 的连接管理器与拨号默认值、资源管理器上限、中继自荐参数、headscale 的 prefixes/allocation/心跳 —— 都是**可以直接固化的默认值**。
**③ 推演 —— 直接给表**:
| 类别 | 参数 | 建议值 | 来源 |
|---|---|---|---|
| 地址 | 内部 IPv6 | `fd00::/8` 内选一段 | Tailscale ULA 范式(**刻意不用 100.64/10**,见 A3) |
| 地址 | 内部 IPv4(兼容层) | 仅在无冲突时启用 + 冲突检测 | 同上 |
| 连接 | 每对端并发拨号 | **≤4** | libp2p 默认 |
| 连接 | 总并发拨号 | **100**,超时 **30 s** | libp2p 默认 |
| 连接 | 高低水位 / 宽限期 | **100 / 400 / 1 min** | libp2p Connection Manager |
| 中继 | 每节点预约数 | **≤2** | libp2p autoRelay |
| 中继 | 自荐广告延迟 / TTL | **15 min / 30 min** | libp2p HOP relay |
| 心跳 | 保活间隔 | **20–25 s** | 现网既有(落在 NAT 老化安全区) |
| presence | 批合并刷写 | **1 s**;grace 5–15 s;离线 debounce 30 s | Slack 范式 |
| agent | 见 B1 表 | 5/分 · 10 s · ≤2 · ≤5 跳 | 业界通用 |
| 退避 | 公式 | `min(cap, base×2^n)` + **全抖动** | AWS 架构框架 |
| 传输 | 同时上传对象上限 | **4**,30 s 随机试新对端 | BitTorrent |
| 发布层 | 对外端口 | 见 A5,**待用户拍板**(涉及花钱与暴露面) | — |
**④ 结论**:✅ **这一步是纯手工活,没有任何阻塞**,应立刻做(它是"从推演到仿真"的前置)。
---
## C. P1 九条的处置建议(合并给出)
| # | 问题 | 建议 | 阻塞? |
|---|---|---|---|
| 1 | 30 条未验证项未成取证计划 | 收敛成一份「取证清单」:**最该先测三项** = 中继 jitter / 打洞率 / 真实带宽 | 无 |
| 2 | 限流参数表空 | **= B3 的表,已给出** | 无 |
| 3 | 观测最小集缺失 | 采 5 项:路径类型 · 打洞率 · jitter · 重连次数 · 中继利用率;告警阈值留待实测后标定 | 无 |
| 4 | **权限面评估缺失(R5)** | 三处扩权:**虚拟网卡驱动(需管理员)** / 骨干开端口 / 发布层对外暴露 —— **必须出「权限影响评估」** | ⚠️ 红线 |
| 5 | 成本模型缺失 | = 发布层 + 会合 + 中继的机器与带宽;**属花钱项,需用户拍板** | ⚠️ 边界外 |
| 6 | 滥用与事件处置 | 复制现成范式:封禁节点签名 + 撤资格 + 流量审计;发布层要单独的反滥用 | 无 |
| 7 | 卸载与退出 | 卸载清单:虚拟网卡 / 路由表 / DNS / 常驻进程 / 缓存凭据 | 无 |
| 8 | 协议选型未收敛 | **建议结论**:先沿用 SSH→多对端隧道(S0–S4 已定),**传输层选 WireGuard 用户态**,打洞选 **STUN + 同时发包(DCUtR 式)**,发布层用 **frp 式(含 XTCP P2P 回退)** | 无(技术选型自决) |
| 9 | 最小可用规模路径缺失 | 见 §D 的 3–5 台清单 | 无 |
---
## D. 新发现的 3 处坑(本次推演独有)
1. 🔴 **地址段冲突(最严重)** —— 我们的节点池里有 200 台 CGNAT + 150 台移动网,**本机很可能就在 100.64.0.0/10 内**;若覆盖网也用这段,会出现路由黑洞且**极难排查**。⇒ **主寻址必须走 IPv6 ULA,IPv4 冲突检测必须进首版**。(业界共识警告 + 我们的设备画像,两者叠加出来的一条。)
2. 🟠 **游戏瓶颈看错层** —— S3 的 600 Mbps 不是"内部中继容量",而是**对外出口带宽 + DDoS 承压面**;且**不能复用用户贡献的骨干**(命中 R5)。
3. 🟡 **S5 自相矛盾** —— "1000 人房 200 个 agent" 与自家约束"房间 agent ≤ 人数/10(=100)"冲突 ⇒ 按约束取 100。
---
## E. 汇总结论
| 项 | 能否解 | 关键前提 |
|---|---|---|
| A1 网抽象 | ✅ 照抄 tailnet | **第一次落地就要分层,不能等** |
| A2 引导 | ✅ 成熟 | 引导地址要能在线轮换 |
| A3 地址与 DNS | ✅ 可解 | **IPv6 ULA 优先 + 冲突检测** |
| A4 信任根 | ✅ 照抄 Tailnet Lock | 根密钥 ≥2 份离线副本 + 恢复演练 |
| A5 外部玩家 | ✅ 有范式 | **新增发布层组件 + 走 R5 评估** |
| B1 agent 预算 | ✅ 数值可直接用 | 在**服务端网关**强制 |
| B2 并发叠加 | ✅ 方法已定 | 叠加后**第二瓶颈换人** |
| B3 参数表 | ✅ 纯手工活 | **无阻塞,应立刻做** |
**⇒ 整个方案没有"解不了"的问题;真正卡住的只有两件**:① **权限影响评估(R5 红线)** ② **成本承诺(花钱,属边界外需用户拍板)**。
---
## F. 本次未做
- ⛔ 未改代码、未动 47 / 106、未写文档库(全局执行锁被 `修复轮-决策方法-2b` 占用)。
- 📌 沿用上轮口径:**未新增上抛项**,待拍板仍是骨干服务范围(A 自用 / B 全网)+ 本轮新增的"发布层形态与成本"。
@@ -0,0 +1,416 @@
# 参数表 · 覆盖网络(**唯一一张**)
> **线**:覆盖网络线 | **序**:⑤(参数表 · 观测 · 权限评估)| **产出**:执行棒 2026-09-17
> **唯一来源**:`覆盖网络_应用场景与待完善清单_20260916.md` **§五 第 5 项** + **§四 P1 行** + `交接单_443兜底_20260917.md` **§8.8**
> **本表要做的事**:把散在 6 份文档里的输入参数 + 代码里已固化的常量**收成一张可复算的表**(每行带**来源等级**与**来源定位**),并填掉 443 单留下的 **45% 口径空位**。
> ⛔ **本表不引入新模型、不引第三方依赖、不做架构改动**;性质是「**固化**」,不是重新推演。
> 🔴 **`待测` 项一个都不许编数** —— 编出来的数会让整张表失去"可复算"的资格。`待测` 行的**值列留空**。
---
## §0 来源等级(四档,⛔ 不许混用)
| 等级 | 含义 | 判据 |
|---|---|---|
| **实测** | 本机 / 远端**命令原文**能复现的数 | 本表给出一条能跑的命令 |
| **推导** | 由表内其它行**按公式算出来**的数 | 本表给出复算式,独立手算能得同值 |
| **估值** | 文档里的行业口径 / 旧记录 / 推演假设 | 指到**文件:行**,且**不得**被当成实测引用 |
| **待测** | 本单取不到真值 | **值留空**,写明"由序⑥ 用 3–5 台真机换掉" |
> ⚠️ 旧记录里两个数**本次已复核判为不可用**(⛔ 别再引用):
> ① 47 规格"1.8 GB / 2 核"(09-08)⇒ 本次实测 **1870 MB / 2 核**(量级巧合,但**必须用实测值复算**);
> ② 跨云带宽 **~22 KB/s**(`覆盖网络_千台全场景推演_20260916.md:38`)⇒ 本次实测**上行下界 ≥ 192 KB/s**(≈ 9×),原值作废。
---
## §1 用法约定(**机器可读** —— 探针脚本靠它取阈值)
- **参数行格式**:``| `KEY` | 值 | 单位 | 等级 | 来源定位 | 复算式 |``
- **观测阈值行格式**:``| `OBS-NN` | 指标 | 阈值(引用键或字面值) | 判据 |``
- ✅ **`scripts/overlay-probe.cjs` 的每一个阈值都从本表读**,⛔ 脚本内**不许有魔数**(判据 = 交接单 §6 **E6**)。
- 探针运行方式(**一条命令**,cwd = 工作区根):
`cd "E:/ProgramData/AI技能/aliyun-dsh-server" && node "D:/github/dsh_shenxian/scripts/overlay-probe.cjs"`
---
## §2 运行坐标
| 键 | 值 | 单位 | 等级 | 来源定位 | 复算式 |
|---|---|---|---|---|---|
| `SSH_TARGET_47` | `bt-server` | — | 实测 | `~/.ssh/config`(`HostName 47.77.182.89`) | `ssh -p 22 bt-server hostname` |
| `SSH_TARGET_106` | `test106` | — | 实测 | 同上(`HostName 106.54.21.172`) | `ssh -p 22 test106 hostname` |
| `SSH_PORT` | 22 | — | 实测 | 交接单 §2 注(`bt-server` 配置写的 32022 **已陈旧**) | `ssh -p 22 bt-server true` |
| `W47_HOSTNAME` | `iZrj99af19cibck1ge93tqZ` | — | 实测 | 本次 S0 P6 | `ssh -p 22 bt-server hostname` |
| `W106_HOSTNAME` | `VM-0-8-opencloudos` | — | 实测 | 本次 S0 P8 | `ssh -p 22 test106 hostname` |
| `RELAY_STATUS_URL` | `http://127.0.0.1:20080/status` | — | 实测 | relay ExecStart `--port 20080` | `ssh -p 22 bt-server 'curl -s 127.0.0.1:20080/status'` |
| `PORTAL_URL` | `http://127.0.0.1:3080/` | — | 实测 | 平台门户(⚠️ `http2 on` ⇒ **必须 `--http1.1`**,否则假 404) | `ssh -p 22 bt-server 'curl -s --http1.1 -H "Host: alotbuy.com" 127.0.0.1:3080/'` |
| `PORTAL_HOST_HEADER` | `alotbuy.com` | — | 实测 | 同上 | 同上 |
| `RELAY_NETWORK_ID` | `ops` | — | 实测 | `/etc/dshs.env` `DSHS_OVERLAY_NETWORK_ID` | `ssh -p 22 bt-server 'grep NETWORK_ID /etc/dshs.env'` |
| `SSH_TIMEOUT_MS` | 20000 | ms | 推导 | 探针脚本**单次 ssh** 的超时(⚠️ 放这里是为了让 `overlay-probe.cjs` **零魔数**;实测单次 ssh 往返 ≈ 1 s,取 20× 余量) | — |
> 🆕 **序⑦ 真机演练坐标**(`scripts/overlay-failover-drill.cjs` 用它取数 ⇒ 脚本内**零魔数**):
| 键 | 值 | 单位 | 等级 | 来源定位 | 复算式 |
|---|---|---|---|---|---|
| `DRILL_RELAY_UNIT` | `dshs-relay` | — | 实测 | 两台机的 relay 单元名(`systemctl cat dshs-relay`) | `ssh -p 22 bt-server systemctl cat dshs-relay` |
| `DRILL_MANAGER_UNIT` | `dshs` | — | 实测 | 47 的 Manager 单元名(演练只读它的 journal 判别器) | 同上 |
| `DRILL_POLL_MS` | 500 | ms | 推导 | 演练轮询 journal 的间隔(≤ `RELAY_FAILOVER_CHECK_MS` 的 5×,保证不漏一次切换)。🆕 **序⑨:2000 → 500** —— 原 2000 让读数带 **0–2000 ms 系统性高估**,使「是否超 `RELAY_FAILOVER_DEADLINE_MS`」**两端都可能误判**(序⑨ RC-4)⇒ 这是**测量修正,不是调参**。⚠️ 改前/改后的读数**不可混比** | — |
| `DRILL_SAMPLE_N` | 5 | 次 | 推导 | 🆕 序⑨:`overlay-failover-drill.cjs --sample` 的**默认采样轮数**(口径 = 每轮都做完整归零:两台 relay `start` → `restart dshs` → 等新的 `AUTH OK host=ops/manager` → 杀入口 → 等新 `[relay-switch]`) | `node scripts/overlay-failover-drill.cjs --sample 5` |
| `DRILL_COOLDOWN_OBSERVE_MS` | 90000 | ms | 推导 | 幕 3 的「冷却期内不回跳」观察窗;⛔ **必须 < `RELAY_FAILOVER_COOLDOWN_MS`**,否则判据不成立 | — |
| `DRILL_SWITCH_MATCH_106` | `106.54.21.172` | — | 实测 | 断言 `[relay-switch]` 的目标是不是 **106**(用**目录里那个 host**,⛔ 不是 ssh 别名 `test106`) | `curl -s https://alotbuy.com/dshs-overlay/bootstrap` |
| `DRILL_KILLED_MATCH` | `alotbuy.com` | — | 实测 | 幕 1 被杀入口的标识(= 目录 `relays[]` 首位的 host);脚本据此判定「Manager 当前通道**是否就是**将被杀的那台」——不在 ⇒ 记 **SKIP** 而不是假装 PASS | 同上 |
| `DRILL_DETECT_BUDGET_MS` | 120000 | ms | 推导 | 幕 1/幕 2 的**观察窗**;⛔ 必须 ≥ **静默失效检测时延**(半开检测 = `2.5 × HB_SEC`,即 75 s)—— 首轮实测用 1× deadline(30 s) **必然漏判** | — |
---
## §3 输入参数(清单 §三 点名的 **5 类**;散落点收口)
> 散落点计数(P10,⛔ 只计数不重读):`调研_游戏网络特征…:19 处` / `千台全场景推演…:18` / `瓶颈落地方案…:9` / `游戏专项…:7` / `清单…:7` / `百台规模推演…:2` / `骨干层方案…:2` ⇒ **合计 64 处命中,正是"不可复算"的成因**。
### 3.1 设备占比(分层)
| 键 | 值 | 单位 | 等级 | 来源定位 | 复算式 |
|---|---|---|---|---|---|
| `DEV_L1_PUBLIC_IP` | 10 | 台 | 估值 | `覆盖网络_百台规模推演_20260916.md:53` | — |
| `DEV_L2_HOLE_PUNCHABLE` | 30 | 台 | 估值 | `覆盖网络_百台规模推演_20260916.md:54` | — |
| `DEV_L3_RELAY_ONLY` | 45 | 台 | 估值 | `覆盖网络_百台规模推演_20260916.md:55`(原文区间 40–55,取中值) | `(40 + 55) / 2` |
| `DEV_SHARE_L3` | 0.45 | — | 估值 | `覆盖网络_百台规模推演_20260916.md:119`「L3 占 **45%**(v1 只算 15%)」+`:132` | 45 / 100(口径 = **全网节点数**的比例) |
| `FLEET_TARGET` | 1000 | 台 | 估值 | `覆盖网络_千台全场景推演_20260916.md`(标题与全文口径) | — |
| `FLEET_RELAY_DEMAND` | 450 | 台 | 推导 | 本表 §5 | `FLEET_TARGET × DEV_SHARE_L3` = 1000 × 0.45 |
### 3.2 打洞率
| 键 | 值 | 单位 | 等级 | 来源定位 | 复算式 |
|---|---|---|---|---|---|
| `HOLE_PUNCH_RATE_INDUSTRY` | 0.90–0.94 | — | 估值 | `覆盖网络_全球架构复盘_20260916.md:38`(业内 UDP 打洞 ≈ 94%) | — |
| `HOLE_PUNCH_RATE_BY_CLASS` | 90% / 40% / 10% | — | 估值 | `方案规划方法_覆盖网络线提炼_20260916.md:45`(三档标注实例) | — |
| `HOLE_PUNCH_RATE_LOCAL` | **分层(n=3 台)**:① 云机×云机(47↔106)—— 均为 L1 直连,**不是打洞场景**;② 国内家宽/办公 NAT(本机)↔ 47 / ↔ 106 —— **对端 → 本机方向 10/10 收包 = 可打洞**;本机 → 云机方向**取不到**(云安全组拦 UDP 入站)。**两对中两对可打洞** | — | **实测** | 本单 §8.4 E6 + `overlay-holepunch.cjs --stun`(⚠️ 首选路径的 47 UDP 观察器**实测不可用**:零收包 ⇒ 降级为**第三方 STUN**,见 §8.4 `OBS-4D`) | 判定式=`对(47,本机)`、`对(106,本机)` 各「至少一个方向成功」⇒ 2/2;⚠️ **样本 n=3 对,云节点占 2/3,不代表家宽场景** |
### 3.3 每玩家带宽
| 键 | 值 | 单位 | 等级 | 来源定位 | 复算式 |
|---|---|---|---|---|---|
| `PER_PLAYER_BW_TEXT` | 0.5 | KB/s | 估值 | `覆盖网络_调研_游戏网络特征与群聊上限_20260916.md:15` | — |
| `PER_PLAYER_BW_BATTLE` | 2–5 | KB/s | 估值 | 同上 `:15`(另一口径 `:43`「单玩家 2–20 KB/s」) | — |
| `PER_PLAYER_BW_SIEGE` | 10–20 | KB/s | 估值 | 同上 `:15` | — |
| `PER_PLAYER_BW_LOCAL` | **9.8**(n=10 玩家档)/ **3.9**(n=50 玩家档)—— 判据见来源列;聚合天花板 ≈ **200–350 KB/s** | KB/s | **实测** | 本单 §8.4 E7 + `overlay-wan.cjs --players`(合成 200 B 消息 × 5/20/50 msg/s × 10/50 玩家 × 60 s,经 relay 真机间) | 判据=`p95 ≤ 2×p50 且丢包 = 0`;⚠️ 原始丢包含**尾部在途**伪影(≈`rate × RTT`);扣掉后满足的**最高档 = 10 玩家 @50 msg/s = 9.77 KB/s/玩家**(50 玩家档最高 3.91)。**边界**:本值 = **传输层上限**;游戏协议的真实需求仍是估值(`PER_PLAYER_BW_TEXT/BATTLE/SIEGE`)——⛔ 不得读成"实测出的游戏需求" |
### 3.4 消息频率 / 扇出
| 键 | 值 | 单位 | 等级 | 来源定位 | 复算式 |
|---|---|---|---|---|---|
| `MSG_RATE_GLOBAL` | 50 | msg/s | 估值 | `覆盖网络_千台全场景推演_20260916.md:75` | — |
| `FANOUT_AVG` | 50 | — | 估值 | 同上 `:75`("均扇出 50") | `50 × 50 = 2500 投递/秒` |
| `ROOM_FANOUT_LIMIT_100` | 100 | 人 | 估值 | `覆盖网络_调研_游戏网络特征与群聊上限_20260916.md:97`(≤100 纯扇出即可) | — |
| `PRESENCE_FANOUT_1000ROOM` | 16700 | 次/秒 | **推导** | `覆盖网络_千台全场景推演_20260916.md:79` | 千人房 presence ≈ 1000 × 16.7 次/秒(原文直接给 16,700) |
### 3.5 心跳 / 链路质量
| 键 | 值 | 单位 | 等级 | 来源定位 | 复算式 |
|---|---|---|---|---|---|
| `HB_SEC` | 15 | s | **实测**(代码常量=运行真值) | `src/net/relay/server.ts:60` `DEFAULT_HB_SEC = 15`;运行期由 `HELLO_ACK` 下发(`server.ts:816`) | `grep -n DEFAULT_HB_SEC src/net/relay/server.ts` |
| `HB_SEC_DOC` | 20 | s | 估值(**文档口径,与代码不一致**) | `覆盖网络_千台全场景推演_20260916.md:39`「心跳间隔 20 s(沿用现有隧道自愈定时器)」 | ⚠️ 该行属**旧隧道时代**口径 —— 现已换 relay(`hbSec=15`)⇒ **文档待更正**,参数以 `HB_SEC=15` 为准 |
| `HALF_OPEN_MS` | 37500 | ms | 推导 | `src/net/relay/client.ts:602` | `max(3000, HB_SEC × 1000 × 2.5)` = 15 × 1000 × 2.5 |
| `IDLE_TIMEOUT_MS` | 45000 | ms | 实测(代码常量) | `src/net/relay/server.ts:58` | `grep -n DEFAULT_IDLE_TIMEOUT_MS src/net/relay/server.ts` |
| `JITTER_LIMIT_MS` | 20 | ms | 估值(业界口径) | `覆盖网络_调研_游戏网络特征与群聊上限_20260916.md:32`+`覆盖网络_千台全场景推演_20260916.md:90` | — |
| `JITTER_LINK_MEASURED` | **3**(判定值 = `p95(|ΔRTT|)`;另记 `mdev` = 1.37–1.38 ms、`avg` = 148.3 ms、丢包 1.3–1.7%) | ms | **实测** | 本单 §8.4 E5 + `overlay-jitter.cjs --icmp`(47→106 与 106→47 各 300 包 × 0.2 s) | **达标**:3 ms < `JITTER_LIMIT_MS`(20 ms,⚠️ 该限值是**业界估值**口径)。⚠️ 这条**推翻了**"当前跨云链路上 jitter 不可能达标"的旧定性结论 —— 旧结论是拿 `RELAY_RTT_W106` 当链路 RTT 推的(口径错,见下行) |
| `RELAY_RTT_W106` | 336 → **保留数值,但加口径修正**:该值是**心跳往返**(`server.ts:810`:测 RTT / 察觉半开 / 保 NAT 表项),**含应用层处理与验签,⛔ ≠ 网络 RTT** | ms | **实测(口径受限)** | 本单 §8.4 E5(b) 三方对比:ICMP 148 ms | TCP 握手 median 153 ms(min 138.5)| relay 心跳 344–376 ms ⇒ **差 2.3×,触发"加口径备注"判据** | 判据(S3(b)):`relay rttMs` 与 ICMP 差距 > 2× ⇒ 加备注 + 在 `OBS` 侧登记"relay `rttMs` 不得当链路 RTT 用" |
| `RELAY_RTT_MANAGER` | 7 | ms | **实测** | `/status` → `sessions[hostId=manager].rttMs` | 同上 |
| `RELAY_TTFB_S` | 0.68 | s | **实测** | 本次 S0 P7a:47 → relay 回环口 → w-106 任一端口,`%{time_starttransfer}`(6 次样本 0.676–1.083) | `ssh -p 22 bt-server 'curl -s --http1.1 -o /dev/null -w "%{time_starttransfer}\n" http://127.0.0.1:44133/'` |
| `WAN_UP_BOUND_KBPS` | 192 ⇒ **已被上行实测取代(值作废,保留作历史)** | KB/s | ~~实测(下界)~~ **作废** | 原 S0 P7 样本被 405 提前截断(131072 B / 0.685 s,含 RTT)⇒ 只是**下界**,且下界偏低 **64×** | 用 `WAN_STEADY_THROUGHPUT` 的上行值 12213 作准 |
| `WAN_STEADY_THROUGHPUT` | **352(106→47)/ 12213(47→106)** —— 取**绑定方向**(对端→中继机,即"内容从 worker 上来"那条)= **352** | KB/s | **实测** | 本单 §8.4 E4 + `overlay-wan.cjs --download|--upload`(各 5 样本) | **口径三要素**:① 谁到谁 = 106 的 `w-106p:19777` ↔ **47 本机 relay 回环端点**;② **经 relay**(非裸链路);③ 稳态段 **30 s**(弃前 3 s,爬升段字节单独计)。**中位数 352**(min 345.6 / max 352);上行侧 12213(min 11784 / max 12452)。⚠️ 非对称的成因 = 106(云轻量)**公网出带宽封顶 ≈ 2.8 Mbps**,实测三向互证:`/status` 会话计数 `in=1.905 GB` + 106 侧进程 `rchar=1.905 GB` + 下载恒定 165×64 KB/30 s |
| `WAN_UP_BOUND_KBPS` | 192 ⇒ **已被上行实测取代(值作废,保留作历史)** | KB/s | ~~实测(下界)~~ **作废** | 原 S0 P7 样本被 405 提前截断(131072 B / 0.685 s,含 RTT)⇒ 只是**下界**,且下界偏低 **64×** | 用 `WAN_STEADY_THROUGHPUT` 的上行值 12213 作准 |
### 3.6 presence(节点在线态 · 序⑲)—— **帧率类**参数
| 键 | 值 | 单位 | 等级 | 来源定位 | 复算式 |
|---|---|---|---|---|---|
| `PRESENCE_BATCH_MS` | 1000 | ms | **实测**(代码常量=运行真值) | `src/net/relay/server.ts:106` `DEFAULT_PRESENCE_BATCH_MS = 1_000` | `grep -n DEFAULT_PRESENCE_BATCH_MS src/net/relay/server.ts` |
| `PRESENCE_GRACE_MS` | 10000 | ms | **实测**(代码常量=运行真值) | `src/net/relay/server.ts:89` `DEFAULT_PRESENCE_GRACE_MS = 10_000` | `grep -n DEFAULT_PRESENCE_GRACE_MS src/net/relay/server.ts` |
| `PRESENCE_OFFLINE_DEBOUNCE_MS` | 30000 | ms | **实测**(代码常量=运行真值) | `src/net/relay/server.ts:97` `DEFAULT_PRESENCE_OFFLINE_DEBOUNCE_MS = 30_000` | `grep -n DEFAULT_PRESENCE_OFFLINE_DEBOUNCE_MS src/net/relay/server.ts` |
| `PRESENCE_TTL_MS` | 45000 | ms | 推导(代码常量 × 心跳) | 服务端 `src/net/relay/server.ts:604`;客户端保守档 `src/net/relay/client.ts:329` `DEFAULT_PRESENCE_TTL_MS = 45_000`(两处同值,实测一致) | `HB_SEC × 1000 × DEFAULT_PRESENCE_TTL_FACTOR` = 15 × 1000 × 3(因子 = 3 见 `server.ts:115`) |
| `PRESENCE_SUB_MAX` | 0 | 个 | **实测**(代码常量;**0 = 不限**) | `src/net/relay/server.ts:122` `DEFAULT_PRESENCE_SUB_MAX = 0` | `grep -n DEFAULT_PRESENCE_SUB_MAX src/net/relay/server.ts` |
> ⚠️ 这五个键只驱动**在线态事件**(谁在线 / 何时改口),⛔ 与 `RELAY_FAILOVER_*` / `HB_SEC` / burst **无耦合** —— 序⑲ 全程一字未动(E11 的 D1 自证)。
> ⚠️ `PRESENCE_TTL_MS` 是**安全网**(兜"漏掉 close 事件"那条路),⛔ **不是常规下线路径**:常规下线走 `PRESENCE_GRACE_MS + PRESENCE_OFFLINE_DEBOUNCE_MS` = 40 s。
> ⚠️ 口径来源 = D3(最终一致:允许 5–15 s 陈旧),⛔ 三个阈值均按代码常量取,**不许改口径去凑判据**(E5)。
> 🔴 **值格必须纯数字**(夹注 / 混写单位 ⇒ 探针 `NaN` ⇒ 假红,序⑫ 已踩)。
---
## §4 代码已固化常量(**实测**=直接可 grep 的源码真值)
| 键 | 值 | 单位 | 等级 | 来源定位 | 复算式 |
|---|---|---|---|---|---|
| `RELAY_PORT` | 20080 | — | 实测 | `src/net/relay/server.ts:52`+单位文件 ExecStart | `grep -n DEFAULT_RELAY_PORT src/net/relay/server.ts` |
| `RELAY_BIND` | 127.0.0.1 | — | 实测 | 主单元 ExecStart(无 `--host` ⇒ 默认回环) | `ssh -p 22 bt-server "ss -lntp \| grep 20080"` |
| `RELAY_INSTANCE_BASE` | 19000 | — | 实测 | 主单元 ExecStart `--base 19000` | 同上 |
| `RELAY_INSTANCE_SPAN` | 3000 | — | 实测 | 主单元 ExecStart `--span 3000` ⇒ 可声明窗口 `[19000, 22000)` | 同上 |
| `AUTH_DEADLINE_MS` | 5000 | ms | 实测 | `src/net/relay/server.ts:54` | `grep` |
| `AUTH_WINDOW_MS` | 60000 | ms | 实测 | `src/net/relay/server.ts:56` | `grep` |
| `CAPACITY_RETRY_AFTER_MS` | 5000 | ms | 实测 | `src/net/relay/server.ts:63` `DEFAULT_CAPACITY_RETRY_AFTER_MS` | `grep` |
| `SHUTDOWN_GRACE_MS` | 300 | ms | 实测 | `src/net/relay/server.ts:69` | `grep` |
| `MAX_STREAMS_PER_PORT` | 64 | 条 | 实测 | `src/net/relay/server.ts:71` | `grep` |
| `QUEUE_MAX_BYTES` | 1048576 | B | 实测 | `src/net/relay/server.ts:73`(`1 << 20`) | `grep` |
| `WS_PEER_HIGH_WATER` | 262144 | B | 实测 | `src/net/relay/server.ts:76`(`256 * 1024`) | `grep` |
| `CLIENT_HIGH_WATER` | 262144 | B | 实测 | `src/net/relay/client.ts:230` | `grep` |
| `CLIENT_QUEUE_MAX` | 1048576 | B | 实测 | `src/net/relay/client.ts:229` | `grep` |
| `DIRECTORY_REFRESH_SECONDS` | 300 | s | 实测 | `src/net/relay/directory.ts:60` | `grep` |
| `DIRECTORY_FETCH_TIMEOUT_MS` | 5000 | ms | 实测 | `src/net/relay/directory.ts:521` | `grep` |
| `DIRECTORY_MAX_ENTRIES` | 8 | 条 | 实测 | `src/net/relay/directory.ts:63` `MAX_ENTRIES` | `grep` |
| `DIRECTORY_MAX_ENTRY_LEN` | 512 | 字符 | 实测 | `src/net/relay/directory.ts:64` `MAX_ENTRY_LEN` | `grep` |
| `DIALER_POOL` | 64 | 口 | 实测 | `src/net/relay/dialer.ts:55` `DEFAULT_POOL`+`/etc/dshs.env` `DSHS_RELAY_DIAL_POOL` | `grep`+`ssh -p 22 bt-server 'grep DIAL_POOL /etc/dshs.env'` |
| `DIAL_PORT_BASE` | 25000 | — | 实测 | `/etc/dshs.env` `DSHS_RELAY_DIAL_PORT_BASE` | 同上 |
| `DIAL_PORT_SPAN` | 1000 | — | 实测 | `/etc/dshs.env` `DSHS_RELAY_DIAL_PORT_SPAN`(⚠️ 实绑只到 `base+POOL`) | 同上 |
| `DIAL_POOL_BOUND` | 25000–25063 | — | **实测** | 47 上 `ss -lntp`(64 口全绑)+journal `[relay-dialer] 本机落点池就绪:64 个口` | `ssh -p 22 bt-server "ss -lntp \| grep -c 250"` |
| `W47_AGENT_PORT` | 19100 | — | 实测 | `/etc/dshs.env` `DSHS_CLUSTER_AGENT_URL=http://127.0.0.1:19100` | `ssh -p 22 bt-server 'grep AGENT_URL /etc/dshs.env'` |
| `LOCAL_INSTANCE_PORT` | 20000 | — | 实测 | 47 上 `ss -lntp`(`127.0.0.1:20000`) | `ssh -p 22 bt-server "ss -lntp \| grep 20000"` |
| `PEER_AGENT_PORT` | 19000 | — | 实测 | `/status` → `endpoints[]`/`online[]`(对端 w-106 的 `ports=19000/21000`) | `ssh -p 22 bt-server 'curl -s 127.0.0.1:20080/status'` |
| `PEER_INSTANCE_PORT` | 21000 | — | 实测 | 同上 | 同上 |
| `RELAY_FAILOVER_MIN_ATTEMPTS` | 3 | 次 | 实测 | `src/net/relay/switcher.ts#relayFailoverThresholds`(env 可覆写;**置 0 = 总开关关闭**) | `grep -n RELAY_FAILOVER_MIN_ATTEMPTS src/net/relay/switcher.ts` |
| `RELAY_FAILOVER_GRACE_MS` | 15000 | ms | 实测 | 同上(与 `MIN_ATTEMPTS` 取**或**:106 侧首连窗口长,只看次数会误切) | `grep` |
| `RELAY_FAILOVER_COOLDOWN_MS` | 300000 | ms | 实测 | 同上(与 `DIRECTORY_REFRESH_SECONDS` 对齐;冷却期内**不回跳**) | `grep` |
| `RELAY_FAILOVER_DEADLINE_MS` | 30000 | ms | 实测 | 同上(**验收判据**:从不健康到切换完成的允许上限) | `grep` |
| `RELAY_FAILOVER_CHECK_MS` | 2000 | ms | 实测 | 同上(健康巡检周期;独立于目录刷新周期) | `grep` |
| `RELAY_FAILOVER_UP_TIMEOUT_MS` | 12000 | ms | 实测 | 同上(**只用于换址**:新通道必须真到 `up` 才算「建起来了」)。⚠️ 序⑨:**死候选提前失败后它不再是恒等代价**(实测死候选 ≈ 0.1–1 s 就返回,慢候选仍享受完整 12 s) | `grep` |
| `RELAY_GRACEFUL_BURST_MS` | 15000 | ms | 🆕 实测 | **序⑨ 参数表化**:`client.ts` 的 `gracefulBurstMs`("计划内下线 ≠ 故障"的快速重试窗口)原先是**全仓唯一一个不可配的时延常量**(只有 `?? 15_000` 一处),现可经 env 覆写;⛔ **默认值语义逐字不变**。⚠️ 它就是**检测段 15.0 s 地板的真身**:窗口内 `attempts` 恒为 0 ⇒ `unhealthy()` 只能靠 `graceMs` 成立(序⑨ §1.2-RC-2)⇒ 改它 = 改"计划内重启不触发切流"的窗口,**先回写交接单**(D3) | `grep -n gracefulBurstMsDefault src/net/relay/client.ts` |
| `RELAY_FAILOVER_EXEMPT` | 1 | — | 实测 | 同上(🆕 序⑧:**一跳豁免总开关**,`1`=开 / `0`=关;`RELAY_FAILOVER_MIN_ATTEMPTS=0` 是**另一层**,⛔ 别混) | `grep -n RELAY_FAILOVER_EXEMPT src/net/relay/switcher.ts` |
| `DRILL_COOLDOWN_MS` | 90000 | ms | 推导 | 🆕 序⑧:**演练期**冷却覆盖值(经 `dshs.service.d/zz-drill-override.conf` 注入)——⛔ **生产默认恒为 `RELAY_FAILOVER_COOLDOWN_MS=300000`**。取值口径:必须 ① ≫ 静默失效检测时延(实测 ≈ 29 s,否则"冷却未到期"判据根本来不及观测)② ≪ `DIRECTORY_REFRESH_SECONDS`(300 s)(让冷却先过期、目录巡检后到) | — |
| `DRILL_NO_SWITCH_OBSERVE_MS` | 45000 | ms | 推导 | 🆕 序⑧:幕 4b 的**"预期不切换"观察窗**;⛔ 必须 ≥ 失效检测时延(否则 D6 现场还没形成)**且** < `DRILL_COOLDOWN_MS`(否则会跨过冷却期满、把"自然回归"误判成"切了") | — |
> 🆕 **序⑦ 新增 5 键**(中继失败切流):默认值 = 源码真值(可直接 grep);
> ⛔ 值格**必须纯数字**(夹注 ⇒ `NaN` ⇒ 假红,见 §8.8-2 教训);
> 🔑 **回滚开关 = `RELAY_FAILOVER_MIN_ATTEMPTS=0`** ⇒ 监管器**永不触发**,行为回到「原地退避重试」的现状(§7 回滚第 1 层)。
>
> 🆕 **序⑧ 新增 3 键**(切流冷却语义拆分):`RELAY_FAILOVER_EXEMPT`(代码侧,默认 `1`)+
> `DRILL_COOLDOWN_MS` / `DRILL_NO_SWITCH_OBSERVE_MS`(**只在演练脚本里用**)。
> 🔑 **本单首选回滚点 = `RELAY_FAILOVER_EXEMPT=0`** ⇒ 只关掉豁免、序⑦ 的切流能力**全部保留**;
> ⛔ 它**不是** `MIN_ATTEMPTS` 的同义词(后者关整个监管器)。
> ⚠️ 演练期改冷却**只能**经 `DRILL_COOLDOWN_MS` 注入 drop-in;⛔ `RELAY_FAILOVER_COOLDOWN_MS` 的代码默认值不动(D9)。
> ⚠️ **键名口径**:`LOCAL_*` = 中继机(47)**本机**的实例/代理口;`PEER_*` = **对端**节点(今天= w-106)的等价口。
> (键名里**不带** `106` 是为了让 `overlay-probe.cjs` 的**零数字纪律**成立 —— 见交接单 §6 E6。)
---
## §5 本单新增:**45% 口径填值**(443 单 §8.8 留下的空位)
> **443 单 §8.8 登记行原文**:「兜底启用后,relay 容量须按 **45% 的节点走中继**核算(异构纪律,⛔ 不是同构的 15%)」。
> **口径落点**(按交接单 §4.1-3):填**单台中继的 `--max-hosts`**,⛔ **不填"全网 45%"** —— 后者只作**校验**用。
### 5.1 输入(全部来自本表其它行,⛔ 无外部魔数)
| 键 | 值 | 单位 | 等级 | 来源定位 | 复算式 |
|---|---|---|---|---|---|
| `MEM_TOTAL_MB` | 1870 | MB | **实测** | S0 P6:`free -m` 第 1 行 | `ssh -p 22 bt-server 'free -m \| head -2'` |
| `MEM_AVAILABLE_MB` | 1002 | MB | **实测** | S0 P6:`free -m` 的 `available` 列 | 同上 |
| `MEM_BUDGET_MB` | 1002 | MB | 推导 | 本表 | `= MEM_AVAILABLE_MB`(47 同时跑 PG+Manager+nginx,故**只取 available**,⛔ 不用 total) |
| `CPU_CORES` | 2 | 核 | **实测** | S0 P6:`nproc` | `ssh -p 22 bt-server nproc` |
| `FD_LIMIT` | 262144 | 个 | **实测** | S0:`/proc/<relay pid>/limits` `Max open files` | `ssh -p 22 bt-server 'cat /proc/$(systemctl show -p MainPID --value dshs-relay)/limits \| grep "open files"'` |
| `RELAY_RSS_KB` | 72888 | KB | **实测** | S0:`ps -o rss= -p <relay pid>`(used=2 时,含 Node 基座) | `ssh -p 22 bt-server 'ps -o rss= -p $(systemctl show -p MainPID --value dshs-relay)'` |
| `MEM_PER_HOST_MB` | **0.06** | MB/台 | **实测**(原为推导 2) | 本单 §8.4 E8 + `relay-mem-calibrate.mjs`:本机独立 relay 实例 + 同进程合成 client N=2/10/25/50/100/150,每点 7 次采样取中位数 ⇒ 线性回归 `RSS(N) = 48078 + 46.7·N` KB,**R² = 0.942**(≥ 0.9 ⇒ 有效) | `46.7 KB/台 × 1.2 余量 = 56.0 KB = 0.0547 MB ⇒ ceil 到 0.01 MB 位 = 0.06`。⚠️ 原推导 2 MB **高估 36×**:它把 **per-stream** 的 256 KB 高水位算进了 **per-host**;本值是**空闲会话**口径,带流量的 per-stream 开销**未测**(登记于 §9) |
| `FD_PER_HOST` | 4 | 个/台 | 推导 | 1 条 WS + 每声明端口 1 个回环 listener + 2 个临时 ⇒ 上界 4 | 保守取上界 |
| `DESIGN_MARGIN` | 0.45 | — | 估值 | **443 单 §8.8** 原文(异构纪律;⛔ 非同构 15%) | — |
### 5.2 推导式(⛔ 可独立手算,E2 判据)
```
C_MEM = floor(MEM_BUDGET_MB / MEM_PER_HOST_MB) = floor(1002 / 0.06) = 16700 # ← 序⑥ S7 重算(原 floor(1002/2)=501)
C_FD = floor(FD_LIMIT / FD_PER_HOST) = floor(262144 / 4) = 65536
C_RELAY = min(C_MEM, C_FD) = 16700 # 带宽仍不参与取 min,见 §5.4
RELAY_MAX_HOSTS = floor(C_RELAY × DESIGN_MARGIN) = floor(16700 × 0.45) = 7515 # ← 原 225
```
| 键 | 值 | 单位 | 等级 | 来源定位 | 复算式 |
|---|---|---|---|---|---|
| `C_MEM` | **16700** | 台 | 推导 | 本表 | `floor(1002 / 0.06)` |
| `C_FD` | 65536 | 台 | 推导 | 本表 | `floor(262144 / 4)` |
| `C_RELAY` | **16700** | 台 | 推导 | 本表 | `min(16700, 65536)` |
| `RELAY_MAX_HOSTS` | **7515** | 台 | 推导 | 本表 | `floor(16700 × 0.45)`(⚠️ **本单元格必须是纯数字** —— 探针 `KEY_RE`/`cleanValue` 不认识"(原 225)"这类夹注,会把整格当值 ⇒ `NaN` ⇒ `OBS-02` 假红;旧值 225 记在此列)。🔴 🔴 **序⑥ S7 回头条件已触发并执行**(2026-09-17):`MEM_PER_HOST_MB` 2 → 0.06、`WAN_STEADY_THROUGHPUT` 待测 → 352/12213 ⇒ 已**重下发** 47 与 106 两台的 `capacity.conf`(`--max-hosts 7515`),`OBS-02` 复验 PASS |
**校验(三条,全部满足才生效)—— 序⑥ S7 重跑记录**
- ① **防自锁**:`RELAY_MAX_HOSTS > used × 4` ⇒ `7515 > 4 × 4 = 16` ✅(验收时 `used = 4`)
- ② **设计余量自洽**:`7515 / 16700 = 45.0% ≈ DESIGN_MARGIN` ✅
- ③ **千台需求校验**:`FLEET_RELAY_DEMAND` = 450 **≤** `RELAY_MAX_HOSTS` = 7515(原为 `450 > 225`)⇒ 🔴 **结论变更**:**"容量上必须 ≥2 台中继"这条理由随实测消失**(单台容量已足够)。2 台的依据改为 ① `清单 §3 关键决定`「有公网 IP 的节点自动升格中继候选」② **跨机真容灾**(106 已升格,见 §8 ④)。⚠️ "零余量"这一旧事实**不再成立**,代之以下面那条:**绑定约束从"内存"搬到了"带宽/时延"**(见 §5.4)。
### 5.3 `--max-hosts` 的**分母口径**(⛔ 必须写清,否则数字会被误读)
`--max-hosts` 是**每台中继**的**在册会话(host)数上限**,它**不是**"全网 45%",分母是**这一台中继**;`FLEET_RELAY_DEMAND`(450)**只是校验上界**。今天生产上只有 **1 台中继**(47)⇒ 本值直接生效。
### 5.4 为什么带宽**不参与** `min`(⛔ 不是漏了)—— 序⑥ S7 用实测重判
- **判据(S7-2 原文)**:若新实测吞吐使"225 台(今为 7515 台)的控制面 + 实例面流量"逼近实测吞吐 ⇒ 带宽进 `min`。
- **控制面**(用实测值重算):`1 / HB_SEC` = 0.067 次/秒/台 × 7515 台 ≈ **504 次/秒**;每台每秒控制面流量 `≈ 100 / HB_SEC = 6.7 B/s` ⇒ 7515 台 ≈ **50 KB/s**,对比绑定方向实测 **352 KB/s**(余量 7×)⇒ **控制面不绑定**。
- **实例面**:不是"稳态负载"而是"**单次传输**",正确的判据是**时延**不是容量 —— 单次 46.3 MB 冷启动 ÷ 352 KB/s ≈ **135 s**(原推算按 192 KB/s 是 247 s,按 22 KB/s 旧值则是 36 min)。⇒ ⛔ **仍不进 `min`**,但登记为**实例面单次传输的时延上界**,并给出唯一的改进方向(提升对端公网出带宽,而非加中继)。
- ⚠️ **本条的边界**:352 KB/s 是 **106(云轻量)出带宽封顶**造成的,不是 relay 栈上限(上行方向实测 12213 KB/s 证明栈本身没到这个量级)。
### 5.5 `/status.capacity` 的**语义纠正**(P2 实测 vs 交接单期望)
| 事实 | 原文 |
|---|---|
| `max = 0` 时**只有** `{max, used}`,**没有 `free`** | `src/net/relay/server.ts:502-506`:`this.maxHosts > 0 ? {max, used, free} : {max, used}`;实测 `/status` = `"capacity": {"max": 0, "used": 2}` |
| `free` **只有**在 `max > 0` 时出现 | 同上 ⇒ **E3 的 `free = max - used` 判据只在设值后成立**(设值前 `free` 字段不存在,不是 `null`/不是 `0`) |
| 满载时的 `free` 恒为 `0` | `server.ts:761`:`capacity: { max, used, free: 0 }`(拒绝载荷里) |
| `used` = **在册会话数**(= host 数,不是流数、不是端口数) | `server.ts:505`:`used: this.sessions.size` |
| `retryAfterMs` 口径 = 满载时给对端的**排队建议** | `CAPACITY_RETRY_AFTER_MS` = 5000 ms;客户端**不消耗退避**地排队(`client.ts:532 / 881-896`,附A 已核) |
### 5.6 443 单 §8.7③ 要求的回答:`relays[]` 顺序语义
> **本单必须回答**:443 单留下的「若将来 `relays[]` 引入非首位更优的显式优先级语义 ⇒ 需重新定义同源优先与它的先后关系」。
**回答**:本单**不引入**该语义。`relays[]` 的顺序语义固定为「**主入口首位**」—— 首位是主入口,其余是兜底候选;**不存在**"非首位更优"的显式优先级字段。⛔ 本表**不新增**任何优先级键(新增即等于把这条语义改了)。
---
## §6 观测阈值(探针脚本的**唯一**取数来源;⛔ 脚本内无魔数 = E6)
| ID | 指标 | 阈值 | 判据 |
|---|---|---|---|
| `OBS-01` | relay 在册会话数(`used`) | `MIN_HOSTS` | `used ≥ MIN_HOSTS` |
| `OBS-02` | `capacity.max` / `free` | `RELAY_MAX_HOSTS` | `max = RELAY_MAX_HOSTS` 且 `free = max - used` |
| `OBS-03` | 身份强制与受信签名者 | `MIN_TRUSTED_SIGNERS` | `identityRequired = true` 且 `trustedSigners ≥ MIN_TRUSTED_SIGNERS` |
| `OBS-04` | 通过节点凭据校验的注册数 | `MIN_IDENTITY_OK` | `identityOk ≥ MIN_IDENTITY_OK` |
| `OBS-05` | 吊销清单规模 | `MAX_REVOKED_HOSTS` | `revokedHosts ≤ MAX_REVOKED_HOSTS` |
| `OBS-06` | **判别器计数三件套存在** | (字段存在性) | `typeof dial/dialDenied/dialFailed === 'number'` |
| `OBS-07` | 鉴权失败累计 | `MAX_AUTH_FAILED` | `authFailed ≤ MAX_AUTH_FAILED` |
| `OBS-08` | 端点表全在线 | (无 `false`) | `endpoints[].online` 全为 `true` 且非空 |
| `OBS-09` | 双实例探活(`w-47` 实例面 / `w-106` 实例面经 relay) | `PROBE_CODE_SET` | 两个 HTTP 码均 ∈ `PROBE_CODE_SET` |
| `OBS-10` | 门户可达 | `PORTAL_CODE` | `= PORTAL_CODE` |
| `OBS-11` | 零新增暴露面(**监听集合** / **nft 入站 accept 集合** / relay 只绑回环) | `LISTEN_REQUIRED` / `LISTEN_ALLOWED` / `LISTEN_ALLOWED_RANGES` / `DIAL_POOL_BOUND` / `NFT_ALLOW_INBOUND` / `RELAY_BIND` | **三集包含式**:`必在 ⊆ 实际 ⊆ 必在 ∪ 允许 ∪ 区间(含拨号池) ∪ 派生`,差集**逐条点名**;且 `nft 入站 accept ⊆ NFT_ALLOW_INBOUND`;`RELAY_PORT` 只出现在 `RELAY_BIND` 上。🔴 **序⑫:旧口径「计数相等」已退役** —— 它对实例/端点在线态**敏感 ⇒ 假红**,对"一进一出"替换式变化**不敏感 ⇒ 假绿** |
| `OBS-12` | relay 进程内存 | `RELAY_RSS_MAX_KB` | `RSS ≤ RELAY_RSS_MAX_KB` |
| `OBS-13` | **presence 帧率(稳态)** | `PRESENCE_STEADY_FRAMES_MAX` / `PRESENCE_SAMPLE_HITS_MIN` | **两次 `/status` 采样的增量**:`Δpushed ≤ 0` **且** `ΔstatusHits ≥ 1`(活性证明:⛔ 不许"因为读不到所以看起来是 0");并附口径一致(`presenceTiming` 逐项 == `PRESENCE_*`)+ 判别器齐全(`subs/pushed/snaps/rejected/statusHits` 皆 number)+ `snaps ≤ pushed` |
| `OBS-14` | **首帧即全量 `SNAP`(⛔ 无 N+1)** | (`snaps` / `pushed` / `subs` 三者关系) | `snaps ≤ pushed` 且 **`subs > 0 ⇒ snaps ≥ 1`**(有订阅者却一帧 `SNAP` 都没发 ⇒ 首帧走"逐台拉",判 FAIL) |
| `OBS-15` | **在线态表不撒谎 + 陈旧度** | `PRESENCE_STALE_P95_MAX_MS` / `PRESENCE_TTL_MS` | 活跃会话(`lastSeenAgoMs ≤ PRESENCE_TTL_MS`)**必须**被 `presence[]` 覆盖且 `online = true`;且 `p95(lastSeenAgoMs over devices>0)` ≤ `PRESENCE_STALE_P95_MAX_MS` |
**阈值取值(同为参数行,⛔ 不是脚本魔数)**
| 键 | 值 | 单位 | 等级 | 来源定位 | 复算式 |
|---|---|---|---|---|---|
| `MIN_HOSTS` | 2 | 台 | 实测 | S0 P2 `/status.online[]`(manager + w-106) | `online.length` |
| `MIN_TRUSTED_SIGNERS` | 1 | 个 | 实测 | S0 P2 `trustedSigners = 1` | — |
| `MIN_IDENTITY_OK` | 2 | 次 | 实测 | S0 P2 `identityOk = 2`(⛔ 不是 3:序③ 注释里的 3 是三台节点**当时**的口径) | — |
| `MAX_REVOKED_HOSTS` | 0 | 个 | 实测 | S0 P2 `revokedHosts = 0`+`/etc/dshs/revocations.json` 空清单 | — |
| `MAX_AUTH_FAILED` | 50 | 次 | 推导 | 累计值会随时间涨(⚠️ 探针自己也污染它,见交接单 §5 S6);取"远大于稳态自然增长、又能挡住暴力破解"的档 | 经验上界 |
| `PROBE_CODE_SET` | 200,401 | — | 实测 | S0 P7a:两个面在无凭据时回 `401`;带门户 Host 时回 `200` | `curl -w '%{http_code}'` |
| `PORTAL_CODE` | 200 | — | 实测 | S0 P2/S6:`curl -s --http1.1 -o /dev/null -w '%{http_code}' -H "Host: alotbuy.com" 127.0.0.1:3080/` | 同左 |
| `LISTEN_COUNT` | 79 | 行 | 实测 | ⛔ **已退役(序⑫ 集合判据替代)—— 仅对账用,不得再作为判据**。原 S0 P5:`ss -lntp \| wc -l`(与 443 单收口态**逐字一致** ✅;⚠️ 该值含"47 有活跃实例"那一档,故与无实例态恒差 `1` 层) | 同左 |
| `NFT_RULES` | 72 | 行 | 实测 | ⛔ **已退役(序⑫ 集合判据替代)—— 仅对账用,不得再作为判据**。原 S0 P5:`nft list ruleset \| wc -l`(与 443 单收口态**逐字一致** ✅;⚠️ **行数不是暴露面**:47 的 `input` 链**一条规则都没有**、policy=`accept`) | 同左 |
| `RELAY_RSS_MAX_KB` | 800000 | KB | 推导 | 由 §5 `MEM_BUDGET_MB` × 0.8 得 ≈ 800 MB(**不随 `MEM_PER_HOST_MB` 变**,故序⑥ 校准后仍保留该值)。⚠️ 原注"每 host 2 MB 假设的哨兵"**已作废**:实测斜率 46.7 KB/台 ⇒ 7515 台时预计 RSS ≈ `48078 + 46.7×7515` ≈ **390 MB**,本哨兵(781 MB)仍留 **2× 余量**;且因 per-stream 开销未测(§9),**运行期真正的操作约束就是本哨兵**,不再是 `--max-hosts` | `MEM_BUDGET_MB × 1000 × 0.8` |
**序⑲ 新增阈值键(`OBS-13/14/15` 的取数来源)**
| 键 | 值 | 单位 | 等级 | 来源定位 | 复算式 |
|---|---|---|---|---|---|
| `PRESENCE_STEADY_FRAMES_MAX` | 0 | 帧 | 实测(口径 = 交接单 §6 **E1**) | 稳态"变化驱动"⇒ 无状态变化则一帧都不推 | 判据本身(帧数,⛔ 不是时长) |
| `PRESENCE_SAMPLE_HITS_MIN` | 1 | 次 | 推导 | 探针自己连读两次 `/status` ⇒ `ΔstatusHits` **至少 1**;用来把"读不到"与"确实为 0"分开(本线反复踩的假绿) | 探针一次采样 = 1 次读 |
| `PRESENCE_SAMPLE_GAP_MS` | 3000 | ms | 推导 | 必须 **≥ 3 × `PRESENCE_BATCH_MS`**(否则批窗口还没过去,增量无意义) | `PRESENCE_BATCH_MS × 3` = 1000 × 3 |
| `PRESENCE_STALE_P95_MAX_MS` | 15000 | ms | 推导(= 心跳周期) | D3:允许 5–15 s 陈旧;心跳每 `HB_SEC` 刷新一次 `lastSeen` ⇒ 稳态陈旧度的**上界就是心跳周期** | `HB_SEC × 1000` = 15 × 1000 |
> 🆕 **序⑫ 新增 4 键(`OBS-11` 集合判据的白名单,⛔ 取代 `LISTEN_COUNT` / `NFT_RULES`)**
> ⚠️ 值格**必须纯数字/纯 `地址:端口`**(夹注 ⇒ `NaN` ⇒ 假红);⛔ 值内**不得含 `|`、`*`、反引号**
> (探针 `cleanValue()` 会**剥掉 `*` 与反引号** ⇒ 写 `*:443` 会被改成 `:443` ⇒ 判据全红)。
| 键 | 值 | 单位 | 等级 | 来源定位 | 复算式 |
|---|---|---|---|---|---|
| `LISTEN_REQUIRED` | `0.0.0.0:22,[::]:22,0.0.0.0:80,0.0.0.0:443,0.0.0.0:888,127.0.0.1:3080,127.0.0.1:20080` | — | 实测 | 🆕 序⑫ S1:**必在集**(缺一条 ⇒ FAIL)。口径 = **平台工作必需 且 不受实例/端点在线态影响**的固定口。值按 47 上 `ss -lntp` **原文**逐条写;⚠️ `3080` 的口在 47 上是 `127.0.0.1:3080`(⛔ **不是** `0.0.0.0:3080` —— 原规划稿示例写错,以原文为准) | `ssh -p 22 bt-server "ss -lntp"` |
| `LISTEN_ALLOWED` | `0.0.0.0:58888,0.0.0.0:8765,127.0.0.1:15432,127.0.0.1:19100` | — | 实测 | 🆕 序⑫ S1:**允许集(非必在)** —— 出现合法、消失**不红**(只是某个可选项没开:宝塔面板口 / 管理 UI / 控制面 PG / 本机 worker agent) | 同上 |
| `LISTEN_ALLOWED_RANGES` | `127.0.0.1:20000-20999,127.0.0.1:21000-21999` | — | 实测 | 🆕 序⑫ S1:**区间允许集** = 两台 worker 的实例端口区间。⚠️ **span 取 1000**(⛔ 不是规划稿示例的 100):`dshs-worker` 单元实测 `DSHS_INSTANCE_PORT_BASE=20000`(47,经 `/proc/<pid>/environ`)/`=21000`(106,经 `/etc/dshs-worker.env`)+ 两者 `DSHS_INSTANCE_PORT_SPAN=1000` ⇒ 并集 `[20000,22000)` 正好落在 relay 声明窗口 `[19000,22000)`(`--base 19000 --span 3000`)**之内**。**拨号池⛔不在此键内**(复用 `DIAL_POOL_BOUND` 派生,避免两处漂移) | `ssh -p 22 bt-server "tr '\0' '\n' < /proc/$(ss -lntpH 'sport = :19100' \| grep -oP 'pid=\K[0-9]+' \| head -1)/environ \| grep INSTANCE_PORT"` |
| `NFT_ALLOW_INBOUND` | `tcp:22,tcp:80,tcp:443,tcp:888,tcp:3080,tcp:58888` | — | 实测 | 🆕 序⑫ S4:**入站 accept 白名单**(`nft -j` 归一后 `acceptSet ⊆` 本键)。归一形态 = `<proto>:<dport>`;「无 dport 匹配的 accept 规则」归一为 `<proto>:any` —— ⚠️ 47 的 `input` 链**目前 0 条规则** ⇒ 集合为 ∅、本键暂无标记位;**将来若出现必须显式加进来**(⛔ 不许放宽判据) | `ssh -p 22 bt-server "nft -j list ruleset"` |
> 📌 **成员归属判据(序⑫ S1 明文要求,防下一棒再吵)**:
> - **必在**(`LISTEN_REQUIRED`)= 它不在 ⇒ **平台本身坏了**(`sshd` / nginx `80`·`443` / 宝塔 `888` / 平台门户 `3080` / relay 回环口)
> - **允许**(`LISTEN_ALLOWED` + `_RANGES`)= 它不在 ⇒ 只是**某个可选项没开**(面板口 / 管理 UI / PG / worker agent / 实例档 / 拨号池)
> - 🔑 **"有/无实例态"⛔不编码进参数表**(那等于把"对状态敏感"这条缺陷写进单一来源 = 净退化,违 **R11**)⇒ 实例档直接进**允许区间**:出现不报、消失不红。
> ⚠️ **附注(序⑥ S3(b) 明文要求登记的 OBS 侧口径)**:relay `/status.sessions[].rttMs` 是**心跳往返**
> (含应用层处理与验签),**⛔ 不得当链路 RTT 用**,**⛔ 更不得据它下"跨云不可玩"的结论** ——
> 实测三方对比:ICMP 148 ms | TCP 握手 median 153 ms | relay 心跳 344–376 ms(**差 2.3×**)。
> 真判据请用 `overlay-jitter.cjs --icmp/--tcp`。
---
## §7 待测项汇总(⛔ 一个都不许编数;**与表内 `待测` 单元格一一对应**)
> 🔢 **计数口径(E1 判据)**:表内 `待测` 单元格 **4 个 → 0 个**(序⑥ 全部换成实测,见下表「结果」列)。
> 第 **5** 条是「**推导值待校准**」(`MEM_PER_HOST_MB`)—— 序⑥ 已校准为 **实测 0.06 MB/台**。
| # | 键 | 换测条件 | 归属 | 序⑥ 结果 |
|---|---|---|---|---|
| 1 | `HOLE_PUNCH_RATE_LOCAL` | 3–5 台真机(分层:家宽 / CGNAT / 移动 / 企业网) | 序⑥ | ✅ **实测(分层)**:n=3 对,2/2 对可打洞(对端→本机 10/10);⚠️ 首选观察器不可用(云安全组拦 UDP 入站)⇒ 走第三方 STUN;**不代表家宽场景** |
| 2 | `PER_PLAYER_BW_LOCAL` | 同上 | 序⑥ | ✅ **实测**:9.8 KB/s/玩家(n=10)/ 3.9(n=50);聚合天花板 200–350 KB/s |
| 3 | `WAN_STEADY_THROUGHPUT` | 两端可控载荷(本次两个面在无凭据时只回 24–68 B) | 序⑥ | ✅ **实测**:352 KB/s(106→47)/ 12213 KB/s(47→106),5 样本中位数,经 relay,稳态段 30 s |
| 4 | `JITTER_LINK_MEASURED` | 同上;⚠️ 这是**游戏可玩性的决定项**(`调研…:118`) | 序⑥ | ✅ **实测**:`p95(|ΔRTT|)` = **3 ms ⇒ 达标**(限值 20 ms 属估值口径) |
| 5 | `MEM_PER_HOST_MB`(**推导值待校准**) | 压到 ≥ 20 台再量 RSS 斜率 | 序⑥ | ✅ **实测**:N=2..150 六点,斜率 **46.7 KB/台**,**R²=0.942**;原推导 2 MB **高估 36×** |
> ✅ **序⑨ 收口(2026-09-17 14:xx)**:**无新增待测项**(表内 `待测` 仍为 **0** 个)。本轮新增的两个键
> (`DRILL_SAMPLE_N` = 5 / `RELAY_GRACEFUL_BURST_MS` = 15000)**都是实测/推导值**,⛔ 不含 `待测`;
> `DRILL_POLL_MS` 由 2000 改 500 属**测量修正**(剔掉 ≤2 s 量化误差),⛔ 不是待测项换测。
> 另:序⑨ 的四段分解登记在 **§9 第 9 行**(属"已知边界/实测记录",不进本表 §7 的"待测"口径)。
**合计:`待测` 4 项 → 0 项;待校准推导 1 项 → 已校准。(E1 判据达标)**
> 🆕 **序⑦(2026-09-17)复算**:仍为 `待测` **0 项** —— 序⑦ 新增的 `RELAY_FAILOVER_*` 5 键**全部取默认值(= 源码真值)**,不产生新的「待测」;同时 §9 第 5 行(无失败切流)改判**已闭环**。⛔ §9 第 6 行(per-stream 内存)**仍未测**,仍是序⑦ 之后的回头条件。
>
> 🆕 **序⑧(2026-09-17)复算**:仍为 `待测` **0 项** —— 序⑧ 新增 3 键(`RELAY_FAILOVER_EXEMPT` = 源码默认值 `1`;
> `DRILL_COOLDOWN_MS` / `DRILL_NO_SWITCH_OBSERVE_MS` = **演练脚本自用的推导值**)**均不产生「待测」**。
> ⚠️ 两条**口径**要记住:① `DRILL_COOLDOWN_MS` 是**演练期覆盖值**,⛔ 不是生产值(生产恒为 `RELAY_FAILOVER_COOLDOWN_MS=300000`);
> ② 幕 4b 的结论**标注了非生产冷却值**方可复现(D9)。§9 第 5 行追加"冷却语义拆分已闭环";§9 第 6 行**仍未测**。
> 🔴 **回头条件(已执行,2026-09-17 序⑥ S7)**:`MEM_PER_HOST_MB` 与 `WAN_STEADY_THROUGHPUT` **双双换成实测** ⇒ §5.2 已**重算并重下发**两台中继的 `capacity.conf`(225 → 7515),三条校验已重跑,`OBS-02` 复验 PASS。⛔ 未触发项:无(两项都换成了实测)。
---
## §8 权限影响评估(R5;**只有产出,无动作**)
> 交接单 §4.1-7 的**固定 6 列**:`对象 / 是否扩大权限面 / 扩大到哪一类 / 是否已可收窄 / 证据 / 结论`。
> ⛔ **结论列只允许「收窄」或「维持」**;若某项确需扩大 ⇒ 停下报告(命中 R5)。
| # | 对象 | 是否扩大 | 扩大到哪一类 | 是否已可收窄 | 证据(代码行 / 命令原文) | 结论 |
|---|---|---|---|---|---|---|
| ① | **虚拟网卡驱动**(打洞所需) | **否(本单零动作)** | 若将来做 ⇒ **权限位**(管理员/内核态驱动) | ✅ 可收窄:**不做虚拟网卡**(纯 relay 中继形态已闭环),要打洞也只走 **UDP 用户态**(无驱动) | 本表 §3.2 `HOLE_PUNCH_RATE_LOCAL` = 待测;现网 2 节点全走 relay(`/status.networks[0].sessions = [manager, w-106]`);⚠️ 我方 relay 只做 TCP/WS,**代码里没有任何 TUN/TAP 调用**(`grep -rn "tun\|tap\|ip tuntap" src/net/relay/` = 0 命中) | **维持** |
| ② | **骨干节点开端口** | **否** | — | ✅ 已收窄:骨干成员**只拨出、不开入站**(worker 永远只拨出,106 入站 = 0) | 主单元 ExecStart 无 `--host` ⇒ 默认绑 `127.0.0.1`;实测 `ss -lntp` 中 `20080` 只出现在 `127.0.0.1:20080`;`覆盖网络_骨干层方案_20260916.md`「骨干不得被默认征用」 | **维持** |
| ③ | **`nft` 打洞规则** | **否(本单零动作)** | 若将来做 ⇒ **入站面**(放行 UDP 入站) | ✅ 可收窄:现实测 `nft list ruleset` = **72 行**且与 443 单收口态逐字一致 ⇒ 打洞规则**一条都没加** | S0 P5:`ssh -p 22 bt-server 'nft list ruleset \| wc -l'` → `72`;对比交接单 §6 E9 的收口态 `72` | **维持** |
| ④ | **第二中继机(L3 跨机真容灾)** | **否(已实施;R5 要求的"实施态"逐项如下)** | 若实施 ⇒ 入站面(新机新公网口)+凭据面(新节点密钥签发) | ✅ **已实施且两项都没扩大**:**新增监听口 = 0**(relay 仍只绑 `127.0.0.1:20080`;对外**复用 106 既有 nginx 的 443**,证书 = 本机**既有** Let's Encrypt(SAN 覆盖 IP `106.54.21.172`),⛔ 未下发任何证书/私钥)|**新增凭据 = 0**(relay 侧只需**公钥类**文件:`relay-keys.json`、`overlay-signers.json`、`revocations.json`;⛔ `node-*.key` 与 `overlay-signer-key.pem` **一律留在 47**)|**106"入站 = 0"这条已收窄成果**:443 本来就开着(宝塔 nginx),本次**只在既有 server 块内加一条 `location`** ⇒ `ss -lntp` 计数**未变** | `systemctl cat dshs-relay`(106)|`ss -lntp`|106 的 `extension/106.54.21.172/relay-b.conf` | **维持** |
| ⑧ | **临时 UDP 入站面**(序⑥ S4 打洞探测的观察器) | **是(临时,已关闭)** | 入站面(UDP 高位口) | ✅ **已收窄**:对象 = 47 的 `0.0.0.0:21100/21101`(`overlay-holepunch.cjs --observer`),**开放时长 ≈ 75 s**、进程退出即释放;关闭证据 = `ss -lunp \| grep 2110[01]` ⇒ **空**。⚠️ 实测该口**零收包**(连同机发出的都收不到)⇒ 47 的**云安全组拦 UDP 入站**(⛔ 不是 nft:`input policy = accept`)⇒ S4 改走第三方 STUN | `ss -lunp`(空)|本单 §8.4 E6 | **维持** |
| ⑨ | **106 的 443 relay 路由**(序⑥ S8 新增 `location /dshs-relay`) | **是(新增可路由路径,⛔ 未新增监听口)** | 入站**路径**面(不是新口) | ✅ 已收窄:只放行 `/dshs-relay` **一个端点**(⛔ 不写 `location /`、不复制站点任何路径;未知路径 404);relay 侧仍强制 HMAC +节点凭据(`identityRequired = true`) | 本单 §8.6 + `curl -H "Upgrade: websocket" … https://106.54.21.172/dshs-relay` = **101**|`/nope` = **404** | **维持(监听口零新增)** |
| ⑤ | **443 兜底入口**(复核是否真零扩大) | **否** | — | ✅ **已收窄**(相对 sshd 时代**净减 1 个公网口**) | 实测:`32022` 已回收(`ss -lntp` 无该口);443 由 **nginx 复用**(`ss -lntp` 显示 `0.0.0.0:443` + `nginx` 4 个进程),**未新增监听**;`/etc/dshs.env` 的 `DSHS_OVERLAY_BOOTSTRAP_SEEDS` 含 `relay-direct.alotbuy.com/dshs-relay`,而 `DSHS_OVERLAY_ADDR_OVERRIDES=relay-direct.alotbuy.com=47.77.182.89` ⇒ 走的是**既有** 443 | **维持** |
| ⑥ | **relay 拨号白名单 `dialers`**(R5 引入) | **否** | — | ✅ **已收窄**:白名单是**准入收窄**(默认拒绝,⛔ 不是放开);且**构造时定型、运行期不可改** | `src/net/relay/server.ts:355` `private readonly dialers`;`:404` `normalizeDialers()` 在构造期;`:747` `const wantDialer = this.dialers.get(network)?.has(hostId) === true`(默认拒绝);drop-in `dialers.conf` = `Environment="DSHS_RELAY_DIALERS=ops:manager"`(只有 **1** 个拨号方) | **维持** |
| ⑦ | **每机独立密钥与信任根保管** | **否** | — | ✅ **已收窄**:私钥**只在本机**、信任根在**离线**签发;relay 侧只有**公钥**与**签名者集合** | `/etc/dshs.env` `DSHS_OVERLAY_NODE_KEY_FILE=/etc/dshs/node-manager.key`(**私钥路径仅本机**);drop-in `identity.conf` 里**只有** `DSHS_OVERLAY_ROOT_PUBKEYS=<公钥>` + `DSHS_OVERLAY_SIGNER_SET_FILE` ⇒ **私钥从不出现在 relay 的配置面**;`:399` `this.trustedSignerKeys`/`:401` `this.requireIdentity` 均 `readonly` | **维持** |
**两条附注**
- ⚠️ 本表 §5.1 的「**每 host 0.06 MB**」是**空闲会话**的实测斜率 ⇒ 它不覆盖 per-stream 缓冲(见 §9 新登记行)。
- ⛔ 本表口径仍成立:**零新增公网端口、零新增入站面、零凭据外发**;序⑥ 的两处"潜在扩大项"(④ 第二中继 / ⑧ 临时 UDP / ⑨ 443 路由)**逐条列在表内**,结论均为**维持**或**收窄**。
---
## §9 未纳入本表的已知边界(**登记,不改**)
| # | 边界 | 为什么不动 | 归属 |
|---|---|---|---|
| 1 | 引导链缓存两支无"答出者"信息 ⇒ 切兜底有 ≤ `DIRECTORY_REFRESH_SECONDS`(300 s)**收敛期** | 交接单 §8.7④ 明确"本单把 300 s 收进参数表并写明该收敛期,⛔ 不改缓存结构" | 已闭环(本表 §4) |
| 2 | 106 的 agent 面不吃引导链(`DSHS_RENDEZVOUS_URL` 被当 relay URL 直接用) | 交接单 §2 P8:**只记录、⛔ 不许顺手撤**(撤掉 = `tunnel===undefined` 生产回归) | 若要改,归序⑥ |
| 3 | D2 字面判据不可满足(CF 泛解析) | 未做灰云记录 ⇒ 属"解析层也不经 CF"的更大改动 | 序⑥ 后候选 |
| 4 | relay 进程**没有** `MemoryMax`(只有本表的软口径 `--max-hosts`) | 加 cgroup 内存上限 = 改单元语义 + 可能 OOM-kill relay(新失败模式)⇒ **超出本单范围**,且 R11(不许净变差) | 序⑥ 候选(与 `MEM_PER_HOST_MB` 校准一起做)→ ⚠️ 序⑥ 只做了**测量与登记**,**仍未加** `MemoryMax`(同上理由,且实测后 `--max-hosts` 已非绑定约束) |
| 5 | 🔴 **无"失败切流"**(序⑥ E9 后半判红的**根因**):客户端把 relay url **在首次解析后钉死** —— `main.js --client` 无重解析;worker 走 `DSHS_RENDEZVOUS_URL` 同样不吃引导链;只有 Manager 的**拨号通道**有周期性重解析(`web/server.ts#refreshOverlay`),而它的换址条件是"**目录里的地址变了**",与"当前 relay 挂了"**无关** | 修它 = **新功能**(需"连接失败后重解析 + 排除已失败 relay"),⛔ 属单外发现,只报告不动手(R7) | ✅ **已闭环(序⑦ · 2026-09-17)**:候选集不再退化成单点(`listOverlayRelayCandidates`);三处客户端(Manager 拨号 / worker 实例面 / `relay --client`)接同一个 `RelayFailoverSupervisor`;阈值见本表 §4 `RELAY_FAILOVER_*`。⛔ 服务端零改动(D8)**|序⑧:冷却语义拆分已闭环**(2026-09-17)—— 冷却表结构化(键仍按 url,新增 `kind`)+ `replace()` 增 `origin`(health 可一跳豁免 / directory ⛔ 不可)+ 豁免有界(每 url 每冷却周期一次)+ 双开关与判别器(`RELAY_FAILOVER_EXEMPT`、`exemptSwitches`、`|豁免` 日志标记)|真机判据 = 演练幕 4 系列(`--scene 4 / 4b / 4c`) |
| 6 | **per-stream 内存开销未测**:`MEM_PER_HOST_MB` = 0.06 MB/台 只是**空闲会话**斜率;带流量时每条流最多 `QUEUE_MAX_BYTES`(1 MB) 缓冲 | 需带流量的压测(S6 的已定口径是"每个声明 1–2 个端口的空闲会话") | 序⑦ 候选(**回头条件**:`--max-hosts` 若重新收紧,必须先有本数) |
| 7 | 106 的第二中继**不提供** `/dshs-overlay/bootstrap`(目录由 47 的 `127.0.0.1:3080` 签发;实测 3080 只绑回环、106→47:3080 `TCP_BLOCKED`) | 两条绕法都有代价:走 47:443(**同一失败域**,等于没增益)/搬证书私钥(命中 R5 扩大) | **已登记**;功能影响 = 0(客户端 `resolveOverlayRelay` ③ 对取不到的 origin `continue`,且 `relays[]` 首位是主入口) |
| 8 | 106 的 443 vhost 归**宝塔面板**管理:本次把 relay location 放进 `extension/106.54.21.172/*.conf`(面板重写 vhost 主文件**不会**丢它) | ⛔ 若将来面板重建该站点,需复查 `nginx -T \| grep dshs-relay` | 运维注意(已写进 `relay-b.conf` 头部注释) |
| 9 | **换址墙钟的四段分解**(序⑨ 实测 · 2026-09-17,n=5+n=5 真机样本,逐行对齐 journal 时间戳):**检测 15.0–16.6 s**(= `RELAY_GRACEFUL_BURST_MS` 地板 + 0–0.7 s 重试相位)+ **首试延迟 0.3–5.0 s**(`RELAY_FAILOVER_CHECK_MS` 相位 + 拨号耗时)+ **白等 12.02 s → 0.09 s**(修前 `waitUpOn` 对"必然失败的同机候选"吃满 `upTimeoutMs`;修后终态早退)+ **建连 2.7–5.2 s** ⇒ 总 **30.7–34.1 s → 20.9–23.9 s**(`RELAY_FAILOVER_DEADLINE_MS` 30000 内侧 5/5) | 白等段**已修**(`waitUpOnStatus` 终态早退,三处装配点共用一份);检测段 ⛔ **不改**(D3:15 s 的 burst 窗口是"计划内下线不是故障"的有意设计,改它 ⇒ 每次 relay 重启都切流) | ✅ **序⑨ 已闭环**(本表 §4 的 `RELAY_GRACEFUL_BURST_MS` / `RELAY_FAILOVER_UP_TIMEOUT_MS`;复算工具 = `scripts/overlay-failover-drill.cjs --trace`) |
---
## §10 指纹
- **本节口径**(**推荐核对用**,可复现):**整个 §10 不计入** ⇒ 复核命令
`sed '/^## §10 指纹/,$d' 参数表_覆盖网络_20260917.md | md5sum` ⇒ **`13de5f9b77c486d71e5b83ec909b17b2`**(**序⑲ 执行棒收口值 · 2026-09-17 18:1x**;本轮变更 = 新增 §3.6 `PRESENCE_*` 五键 + §6 新增 `OBS-13/14/15` 三行与 `PRESENCE_STEADY_FRAMES_MAX` / `PRESENCE_SAMPLE_HITS_MIN` / `PRESENCE_SAMPLE_GAP_MS` / `PRESENCE_STALE_P95_MAX_MS` 四阈值键;上一版 = `8f08e74b026e6e5b5e1b3db813f031ae`(序⑰ 收口)→ `99e9e17b0c1ce0550e4bc7626a5a0494`(序⑨ 收口)→ `e6b669c257d8e8964273b3b400238351`(序⑧ 收口)→ `24cf2efdbcdcbe61267126ed65dba006`(序⑦ 收口)→ `9641f3d67fbc2e67cadf6f78e516f24c` → `44af9ea5ca15ae21f2604a9cd3a935b8` → 序⑥ 收口 = `db1317c2f7aaef7b47785c1f4fc9de03`)
- **全文件 md5**:请用 `md5sum 参数表_覆盖网络_20260917.md` 现取 —— ⛔ 此处**故意不内嵌数值**:它包含本节自身,写进去即刻失效(自指)。
---
## §11 补记(2026-09-17 11:1x,**在 §10 指纹口径之外**)
- ✅ **"第 4/5 台真机来源"已拍板**(登记给 §9 第 5 行那条线的同批遗留):用户原话「**1 本机内存大 可以模拟多台**」⇒ 选 **本机模拟多台**,放弃"用户自备设备"与"新开云主机"。
- 本机实测:总内存 **47.6 GB** / 空闲 27.4 GB / **32 核**;relay 单实例 ≈ **48 MB** ⇒ 可模拟数十台。⚠️ **共用同一出口 IP** ⇒ 对"切流逻辑"够用,对"家宽 / 运营商 NAT 差异"无增量。
- ⛔ **不改本表任何数值**:`HOLE_PUNCH_RATE_LOCAL`(分层实测 2/2)与其样本口径**保持原样**;本补记只登记"多实例可作第 4/5 台"这一环境决定。
- 🔒 本补记位于 §10 之后 ⇒ **复核指纹 `db1317c2f7aaef7b47785c1f4fc9de03` 仍然有效**(后续引用无需换值)。
- **谁能改这张表**:任何一次实测替换(`待测` 换真值 / `MEM_PER_HOST_MB` 校准)**都会改指纹** ⇒ 改完请**同步更新本值**,并在工作区日志里记一笔"哪个键从什么换成什么"。
@@ -0,0 +1,284 @@
# 会合 / 中继从 Manager 拆分 — 只读取证 + 可执行改造方案(2026-09-16)
> 🟢 **状态:已完成使命 · 仅存档(2026-09-17 16:4x 由序 ⑯ 规划棒复核后标注)**
> **复核结论 = 判「不做」**:本方案 §4 的四个耦合点(C1–C4)与两步改造(S3 / S4)已**逐条**被后续序次覆盖,**未覆盖部分 = 无**。
> C1 → 序② P0-2(`DSHS_RENDEZVOUS_URL` 优先、`DSHS_TUNNEL_TARGET` 降兜底,`src/config.ts:485-490`)|C2 → **R5**(Manager 只拨出、落点搬到本机回环池 `127.0.0.1:25000+`,`src/config.ts:498-500`)|C3 → S2/P2(`dsh_hosts.via` + `RendezvousRegistry`)|C4 → 本方案 §8.1 实测**前提不成立**|S3 → 本方案 §9.1 自我证伪后改「实例端口区间隔离」|S4 → R2(`dshs-relay` 独立单元)+ 序⑥ S8(106 升格第二中继)+ 序⑦(多实例切流实测)。
> ⛔ **勿再按 §4 的 S0–S4 开工**。复核证据 = 工作区根 `交接单_在册收尾_20260917.md` **§0.1**(逐条对照表)。
> ⚠️ 正文**不改**(历史档案属性)—— 上表即勘误入口。
> **来源**:`接续入口_覆盖网络线_20260916.md §2 第 2 条`(第一优先 · 真活)。
> **本步只读**:⛔ 未改代码、未重启、未动服务器。全部结论来自 `D:\github\dsh_shenxian` 工作树(HEAD `4e3a1a4`)的源码静态阅读。
> **为什么是前置**:中继绑在 Manager 上 ⇒ 多区域 / 多中心 / 骨干层**全部**做不了。这一刀不切,后面九大瓶颈方案里的中继容量、45% 中继规划都无从落地。
---
## 0. 结论先行
| 判定 | 内容 |
|---|---|
| **现状** | 「会合 + 中继」**不是一个组件,而是 Manager 主机上 sshd 的副作用** —— 会合点 = `47.77.182.89:32022`,中继落点 = Manager 的 `127.0.0.1`。 |
| **真问题** | 功能能跑(档案 103 已判"现有架构给出 80% 形状")。⚠️ **卡点是"耦合写死在 4 处",不是"协议不对"**。 |
| **因此第一步不是换协议** | ⛔ 不要一上来换 WireGuard / TURN。**先把会合地址与中继落点抽成配置与接口**,让 SSH 隧道退化成"第一个可替换实现"。**每一步独立可验、独立可回滚。** |
| **分层口径(已定,勿破)** | 会合 = 位置视图(可多实例、可无状态);中继 = 数据面(可多实例);**归属 / 租约 / 骨干资格 = 只能控制面写**(本方案不碰)。 |
---
## 1. 取证:现状到底长什么样
### 1.1 链路(跨机形态,实测形态)
```
Worker 106 Manager 47
┌────────────────────────────┐ ┌──────────────────────────────┐
│ dshs-worker.service │ │ dshs.service (deployMode= │
│ WorkerAgent :19000 (lo) │ │ cluster) │
│ └─ SshTunnel ──── ssh -R ─┼───────────────▶│ sshd :32022 │
│ 每实例 :PORT (lo) │ ControlMaster │ └─ 反转到 127.0.0.1:PORT │
└────────────────────────────┘ │ RemoteSpawner → agentUrl │
│ = http://127.0.0.1:19000 │
┌─────────────────────────▶ proxy.ts → out.host = 127.0.0.1:PORT
│ │ PostgreSQL :15432 (静态转发)│
│ └──────────────────────────────┘
用户浏览器 → alotbuy.com → 47
```
### 1.2 关键代码位(行号 = 本次阅读的 HEAD)
| 角色 | 文件:行 | 事实 |
|---|---|---|
| 隧道实现 | `src/worker/tunnel.ts:96-120` | `ssh -M -N -f -S <ctl> -i <key> -R <port>:127.0.0.1:<port>` —— **两端同端口号** |
| 隧道加/减 | `src/worker/tunnel.ts:147` / `:163` | `ssh -O forward` / `-O cancel`,靠 **ControlMaster 复用同一条长连接** |
| 隧道自愈 | `src/worker/agent.ts:183-219` | `healTunnel()` 20 s 定时器 + 心跳里顺带 `-O check` |
| Worker 侧配置 | `scripts/switch-C-worker.sh:19-20` | `[email protected]:32022`、`DSHS_TUNNEL_IDENTITY=/root/.ssh/tunnel_ed25519` |
| 隧道开关 | `src/worker/agent.ts:154` | `tunnelTarget === '' ⇒ 完全不建隧道`(同机形态零影响) |
| Manager 侧配置 | `src/config.ts:350-354` | `DSHS_CLUSTER_HOST_ID` / `_AGENT_URL` / `_AGENT_TOKEN` / `_INSTANCE_HOST` |
| 装配 | `src/web/server.ts:256` | `hostsProvider` 把 `dsh_hosts.endpoint` **直接当 `agentUrl`**,`instanceHost` 取全局 `clusterInstanceHost` |
| 访问面 | `src/supervisor/proxy.ts:109` | `out.host = 127.0.0.1:<port>` —— 代理**写死 loopback** |
| 数据面契约 | `src/supervisor/remote-spawner.ts:24-34` | `ClusterHost{hostId, agentUrl, token, instanceHost}` —— **没有"经谁可达"这个字段** |
| 注册 | `src/web/routes/admin.ts:184-203` | `POST /api/admin/hosts`(join 脚本调用,幂等) |
| 心跳 | `src/worker/agent.ts:255-262` | `/healthz` 回 `{ok, hostId, instances, tunnel:{ready,ports}}` |
| 选机 | `src/web/server.ts:300-318` | 粘性优先 → 容量准入(`capacityMb<=0` 不限 / `-1` 禁用) |
---
## 2. 四个耦合点(这是本方案的靶子)
| # | 耦合点 | 具体表现 | 不拆的后果 |
|---|---|---|---|
| **C1** | **会合地址硬编码在 Worker 的 env** | `[email protected]:32022` 写死在 `switch-C-worker.sh` + `/etc/dshs-worker.env` | 会合点换机 / 加第二台 ⇒ **每台 Worker 都要改 env 重启**;会合点不能独立部署 |
| **C2** | **中继落点命名空间 = Manager 的 loopback(且两端同端口号)** | `tunnel.ts` 用 `-R <port>:127.0.0.1:<port>`;`proxy.ts:109` 写死 `127.0.0.1` | ① 端口空间与 Manager **全局共享**(冲突面 = 所有 Worker 实例端口并集)② **第三台机器无法直达 Worker**(只有 Manager loopback 有那个端口)③ 中继不可多实例(多实例无从分配端口) |
| **C3** | **可达性登记把"经谁中转"磨掉了** | `dsh_hosts.endpoint` 存的是 `127.0.0.1:19000` —— 语义上**不是 worker 的地址,是 Manager 本地一个转发口**;`hostsProvider` 原样当 `agentUrl` | 换会合/中继组件时,表里无法表达「via(经哪个中继)+ 真实可达地址」⇒ **必须改表**,越晚改代价越大 |
| **C4** | **控制面 PostgreSQL 也走同一条隧道(静态转发)** | `DSHS_TUNNEL_STATIC_PORTS`(`src/worker/agent.ts:157`),设计上含控制面 PG | 会合组件一换,**控制面库的连接路径一起断** ⇒ 回滚面比看上去大 |
---
## 3. 目标形状(分层,且**不新建权威状态**)
| 组件 | 职责 | 可以多实例? | 权威状态 |
|---|---|---|---|
| **会合 (rendezvous)** | 收 Worker 注册、回"该拨谁"、下发中继分配;**不承载数据面流量** | ✅ 无状态可复制 | ❌ 只有位置视图 |
| **中继 (relay)** | 数据面:Worker 拨它 → Manager / 其他节点经它到 Worker | ✅ | ❌ |
| **控制面 (Manager)** | 归属 / 租约 / 骨干资格 / 容量准入 | ❌ **单点** | ✅ 唯一写入者 |
**硬约束**:会合与中继**不得**写入 `dsh_instances.host_id` / `epoch` / 骨干资格 —— 否则就是双写脑裂(与 `集群化改造方案 §1.3` 一致)。
---
## 4. 分步方案(S0–S4 必做 · S5 后续)
### S0 — 抽接口(**零行为变化**)
- **改哪些文件**
- 新增 `src/net/rendezvous.ts`:`interface Rendezvous { id: string; dialTarget(): string; resolve(hostId): Promise<Reachability|undefined> }`
- 新增 `src/net/reachability.ts`:`interface Reachability { hostId: string; via: string; address: string; scheme: 'http'|'https' }`
- 改 `src/supervisor/remote-spawner.ts:24-34`:`ClusterHost` 增 `reachability?: Reachability`,`agentUrl` 降级为派生 getter(保向后兼容)
- **接口怎么切**:`RemoteSpawner` / `RemoteUserFs` / `proxy.ts` 三处**都只消费 `Reachability`**,不再各自拼 `http://127.0.0.1:<port>`。
- **怎么验**:`npm run build` 通过 + `node scripts/verify-cluster-cross.mjs` 全绿(**行为零变化**是唯一验收标准)。
- **怎么回滚**:纯新增文件 + 可选字段,`git revert <commit>` 即可。
### S1 — 会合地址出 env(打掉 **C1**)
- **改哪些文件**:`src/worker/agent.ts:154-172`、`src/worker/tunnel.ts:26-65`、`src/config.ts:350-354`
- **接口怎么切**:新增 `DSHS_RENDEZVOUS_URL`(`ssh://[email protected]:32022` 或未来的 `https://...`);`DSHS_TUNNEL_TARGET` **保留为兜底**(未设 `DSHS_RENDEZVOUS_URL` 时走它)⇒ 两台机器可分先后改。
- **怎么验**:改一台 Worker 的 env → `systemctl restart dshs-worker` → `curl -H <token> http://127.0.0.1:19000/healthz` 里 `tunnel.ready === true`;Manager 侧 `verify-cluster-cross.mjs` 通过。
- **怎么回滚**:删掉新 env 即在走旧路径(代码同时保留两条),**不需回滚代码**。
### S2 — `dsh_hosts` 增 `via` 列(打掉 **C3**)
- **改哪些文件**:`src/db/adapter.ts:115-121`、`src/db/pg.ts:602-646`、`src/db/repo.ts:601-625`、`src/db/sqlite.ts:343-366`、`src/web/server.ts:256-266`、`src/web/routes/admin.ts:184-203`
- **接口怎么切**
- 迁移:`ALTER TABLE dsh_hosts ADD COLUMN via text NOT NULL DEFAULT 'manager-ssh'; ADD COLUMN address text;`
- 回填:把现 `endpoint` 里的 `127.0.0.1:19000` 拆成 `via='manager-ssh'` + `address='127.0.0.1:19000'`(**`endpoint` 列保留不删**)
- `hostsProvider`:**先读 `via` → 选对应 `Rendezvous` 实现 → 解析成 `Reachability`**;`via` 未设时回退 `endpoint` 原语义。
- **怎么验**:脚本断言「迁移前 `agentUrl`」与「迁移后 `resolve()` 结果」**逐条相等**(这就是验收判据,不靠肉眼)。
- **怎么回滚**:列有默认值 ⇒ 旧代码读 `endpoint` 完全不受影响,**列可留着不删**。
### S3 — 中继落点命名空间可配(打掉 **C2 的一半**)
- **改哪些文件**:`src/worker/tunnel.ts:142-160`、`src/supervisor/proxy.ts:109`
- **接口怎么切**:新增 `DSHS_RELAY_LOCAL_NAMESPACE`(默认 `127.0.0.1`)⇒ 允许每条转发落在**独立回环地址**(`127.0.0.2`、`127.0.0.3`…),由会合**下发端口**而非"两端同号"。
- ⚠️ **同号是 `endpointFor` 免映射表的前提** ⇒ 改不同号**必须**同时给 `ClusterHost` 加 `portMap`。所以本步**验收必须含两条端到端**,不能只看健康检查。
- **怎么验**:① `verify-cluster-cross.mjs` 全绿 ② **跨机实例页能打开** ③ **跨机工作区文件能读能写**(文件面与实例面同一份路由,见 `server.ts:274-288` 的注释)。
- **怎么回滚**:不设该 env 即回到"同号"原状。
### S4 — 中继独立成单元 + 会合可换机(打掉 **C2 的另一半 + C1 的尾巴**)
- **做什么**:把"接收 Worker 反拨"这半边从 Manager 的 sshd 里搬出来 → **独立 systemd 单元 `dshs-relay.service`**(第一版仍用 sshd,但**独立端口 + 独立 `authorized_keys` + 独立系统账号**)。Manager 通过 `via` 指过去。
- **为什么这样切**:**不换协议、只换绑定关系** ⇒ 已跑通的隧道逻辑 0 改动,风险面最小;换来的是"中继可换机、可多实例、Manager 不再兼任会合"。
- **怎么验(三条,缺一不可)**
1. 单中继实例下全链路通(实例面 + 文件面)
2. **停掉中继** ⇒ 同机形态实例仍可用、跨机代理失败但**不崩**(验证"中继非必需依赖")
3. 会合指向**第二个**中继实例,新 Worker 注册后能被 Manager 正确解析(验证多实例)
- **怎么回滚**:`dsh_hosts.via` 改回 `manager-ssh` + `systemctl disable --now dshs-relay`。
### S5 — 443/TCP 兜底通道(**后续,非本方案前置**)
已定口径(异构设计:企业 / 校园网常封 UDP)。落地时挂到 **relay** 上做传输变体,不动会合接口。
---
## 5. 待现场复核(本轮只读,未在服务器执行任何命令)
| # | 待复核项 | 为什么重要 | 若为否的影响 |
|---|---|---|---|
| 1 | 106 上 `DSHS_TUNNEL_STATIC_PORTS` 是否真含控制面 PG 端口 | 决定 **C4** 是否成立、回滚面是否含 DB | 仅影响回滚清单;S2/S3 不变 |
| 2 | `dsh_hosts` 里 `w-106` 那条 `endpoint` 的实际值 | 决定 S2 回填脚本的取值 | 需先取真实值再写迁移 |
**下令复核的命令(只读,未执行)**
```bash
# 远程服务器 106
systemctl show dshs-worker -p Environment
# 远程服务器 47
sudo -u postgres psql -d dshs -c "SELECT id, endpoint, status, capacity_mb FROM dsh_hosts ORDER BY id;"
```
---
## 6. 风险与反模式
| 风险 | 处置 |
|---|---|
| 把会合做成"藏了权威状态的隐形控制面" | 会合只读位置视图;归属写入路径**一律**仍走 `db.claimInstance`(`epoch+1` fencing 不变) |
| 一步到位换传输协议 | ⛔ 反模式。S0–S4 全程**不换协议**,只换绑定与寻址 |
| S3 只看健康检查就收工 | ⛔ 端口映射一改,`endpointFor` 的隐含前提就变 ⇒ 必须跑通"实例页 + 文件读写"两条端到端 |
| 先改表再改代码(或反之) | `via` 列有默认值 ⇒ 顺序必须是**先加列 → 再改代码 → 最后回填**,任一步中断都不崩 |
---
## 7. 交付边界(本步)
- ✅ 产出:本方案(改哪些文件 / 接口怎么切 / 分几步 / 每步怎么验 / 怎么回滚)。
- ⛔ **未做**:未改任何代码、未重启任何服务、未动 47 / 106。
- **下一步**:按 S0 开工即可 —— 纯新增文件 + 类型扩展,零行为变化,是整条链里风险最低的入口。
---
# 8. 现场复核结论 + 方案勘误 + S0 执行记录(2026-09-16 13:1x 追加)
> 本节的复核命令**全部只读**(`systemctl show` / `cat` / `ss` / `psql SELECT`),未改服务器任何状态。
> 执行环境:本机 → `[email protected]`(22)+ `test106`(`~/.ssh/id_ed25519_test106`)。
## 8.1 §5 两项待复核 —— 已复核
| # | 项 | 实测结论 |
|---|---|---|
| 1 | 106 是否真含控制面 PG 的静态转发 | ❌ **不含**。106 `/etc/dshs-worker.env` **没有** `DSHS_TUNNEL_STATIC_PORTS`;47 的 worker env **也没有**(且 47 根本没设 `DSHS_TUNNEL_TARGET` ⇒ 同机形态**完全不建隧道**)⇒ `staticPorts` 只有 agent 自身端口 ⇒ **C4 在本形态下不成立,回滚面不含 DB** |
| 2 | `dsh_hosts` 里各条 `endpoint` 的实际值 | `w-106` = `http://127.0.0.1:19000`|`w-47` = `http://127.0.0.1:19100` |
**47 端口面实测**(`ss -lntp`):`0.0.0.0:32022` sshd(会合点)|`127.0.0.1:19000` sshd=**106 的隧道落点**|`127.0.0.1:43937` sshd=**106 上那个实例的端口,同号反转**|`127.0.0.1:19100` node=w-47 的 agent|`127.0.0.1:15432` postgres。
⇒ §2 的 C2「中继落点 = Manager loopback + 两端同号」**实测成立**;`/healthz` 亦证实 `w-106` 的 `tunnel.ports = [19000, 43937]`。
## 8.2 三处必须勘误(照字面做会出事故)
**① 🔴 `proxy.ts:109` 不是中继连接目标 —— ⛔ S3 绝不能改它。**
`buildUpstreamHeaders()` 里的 `out.host = \`127.0.0.1:${port}\`` 是**发给上游 dsh 的 HTTP `Host` 头**,源码注释已写明原因:dsh 的 `/api` 信任栅栏要求 Host 是 loopback(或 `--trusted-host` 授权项),否则**每条 `/api` 调用都 403**。
真正的连接目标在 **`proxy.ts:138-139`** 的 `{ host: endpoint.host, port: endpoint.port }`(来自 `endpointFor`)。
⇒ S3 做「中继落点命名空间可配」时,**只动连接地址那一侧**;改动 `proxy.ts:109` 会让实例(不只跨机)的 `/api` 全量 403。
**② 🔴 两条 `endpoint` 字符串同形、语义不同 ⇒ S2 回填不能一刀切 `via='manager-ssh'`。**
- `w-47` → `127.0.0.1:19100` 是 **node 自己监听的端口**(同机直连)⇒ `via = 'local'`
- `w-106` → `127.0.0.1:19000` 是 **47 上 sshd 的隧道落点** ⇒ `via = 'manager-ssh'`
§4 S2 原文只写了后者(把 `127.0.0.1:19000` 拆成 `manager-ssh`)⇒ **按行回填别按字面**。
**③ ⚠️ 47 的 cluster 配置在 systemd drop-in,不在 `/etc/dshs.env`。**
实际位置 = `/etc/systemd/system/dshs.service.d/cluster.conf`(`DSHS_DEPLOY_MODE=cluster` / `DSHS_DB_URL` / `DSHS_CLUSTER_*` 十条;`/etc/dshs.env` 里**没有** cluster 段)。§5 的回滚命令(删 drop-in → `daemon-reload` → `restart dshs`)**正确,无需修改**,只是换文件位置要照此。
## 8.3 S0 执行记录(**已落地本机 · 未部署**)
| 文件 | 改动 |
|---|---|
| `src/net/reachability.ts` | **新增**:`Reachability` + `agentBaseUrl()` + **`agentBaseUrlOf()`(全仓唯一取址入口)** + `parseReachability()`/`toEndpoint()`(互逆)+ `VIA_LOCAL` / `VIA_MANAGER_SSH` |
| `src/net/rendezvous.ts` | **新增**:`Rendezvous` 接口 + `LocalRendezvous` + `ManagerSshRendezvous` + `RendezvousRegistry`(**S2 才接调用点**,本轮不消费) |
| `src/supervisor/remote-spawner.ts` | `ClusterHost.agentUrl` → **可选**、新增 `reachability?`;构造期归一化移除(移到取址处、幂等);`call()` / `touch()` 改走 `agentBaseUrlOf()` |
| `src/web/server.ts` | 两处 `agentFor` 改 `agentBaseUrlOf(h)` —— **类型收紧后必须一起改,方案 §4 S0 未列出** |
| `test/reachability.test.mjs` | **新增** 8 条断言:把**现网真实两行 endpoint** 写死为判据 ⇒「解析前后 agent 基址逐字相等」**不靠肉眼** |
| `package.json` | `test` / `verify` 挂上新测试 |
- **验收实测**:`npm run build` RC=0 | `npm test` **44 项 / 0 失败**(新增 8 项全绿)。`lib/` 产物里已无 `host.agentUrl` 直接拼接。
- **为什么未部署**:S0 零行为变化 ⇒ 部署**没有任何可观察收益**,却要 `restart dshs` 打断在线实例 ⇒ **与 S1 合并上线**(一次重启换一个真实变化)。⛔ 这不是"漏了部署"。
- **有意未收敛的两处**(避免本轮动 5 个文件,留给 S2 与 `via` 列一起改):
① `leased-spawner.ts` 的 `agentFor` 契约仍是 `{agentUrl, token}`;
② `remote-user-fs.ts:57/73` 自己又做了一次 `.replace(/\/$/,'')`。
两处的取值**都已来自经过 `agentBaseUrlOf()` 的 `agentFor`** ⇒ **行为正确**,只是还没换成 `Reachability` 形态。
- **回滚**:`git checkout -- src/supervisor/remote-spawner.ts src/web/server.ts package.json` + 删 `src/net/` + `test/reachability.test.mjs`(纯新增 + 可选字段,无数据迁移)。
- **下一步**:S1(会合地址出 env,打掉 C1)—— 新增 `DSHS_RENDEZVOUS_URL`、`DSHS_TUNNEL_TARGET` 降为兜底,两台机器可分先后改。
---
# 9. 第二轮勘误(2026-09-16 15:2x · 本机 + 双机只读实测)
> 本轮复核了 §4 的可执行性,**结论:S3 的机制不可用,须换做法**;另外 S1 的部署面在本方案里写漏了 47。
> 操作层细节(命令 / 验收 / 回滚 / 清理)已落 **`交接单_覆盖网络落地执行_20260916.md` v2 §0 + §3**,本节只记**设计层的判断**。
## 9.1 🔴 S3 的 `DSHS_RELAY_LOCAL_NAMESPACE`(回环别名)**实测无效** —— 机制换成「端口区间隔离」
**实测**(本机 → 106 → 47,测完已清理):自 106 发起
`ssh -R 127.0.0.2:19999:127.0.0.1:19000 -o ExitOnForwardFailure=yes [email protected]:32022`
⇒ 47 上落点实际是 **`127.0.0.1:19999`**(另有 `[::1]:19999`),**`127.0.0.2` 被静默改写**;ssh 侧**零报错**(`ExitOnForwardFailure` 未触发、日志为空)。
**根因**:47 `sshd -T` ⇒ **`gatewayports no`**(OpenSSH 默认)⇒ sshd 把远端转发**强制绑到回环**,**客户端指定的绑定地址被丢弃**。
⇒ **§4 S3 原设计(`127.0.0.2`、`127.0.0.3`… 独立回环别名)作废**,改做 **实例端口区间隔离**:
- 新增 `DSHS_INSTANCE_PORT_BASE` / `DSHS_INSTANCE_PORT_SPAN`,`findFreePort()` 改为**在区间内挑端口**;
- 各 worker 配**互不重叠**的区间(`w-47`=42000+,`w-106`=43000+)⇒ 同一台 Manager 的 `127.0.0.1` 上永不撞号;
- ✅ 副作用收益:**不需要 `portMap`**(不改"不同号")⇒ §4 S3 里"必须同时给 `ClusterHost` 加 `portMap`"**一并作废**;`src/worker/agent.ts` 的 `instanceHost` 也**不用改**。
- ⛔ 若将来确需回环别名:只能开 `GatewayPorts clientspecified`,且**只能开在新建的 relay sshd 上** —— 开在主 sshd `32022` 等于让持隧道密钥者可把端口绑到 `0.0.0.0`,命中 **R5(权限只准收窄)**。
## 9.2 🔴 S3 的真实必要性上修为「**在修一个真 bug**」,不是"管道美化"
`findFreePort()`(`src/supervisor/spawn.ts:53`)只在 **worker 自己那台机器** 上随机取端口(调用点 `src/supervisor/orchestrator.ts:595`)⇒ **两台 worker 取到同号是常态**;
而 `tunnel.forward()` 的返回值在 **`src/worker/agent.ts:303` 与 `:205` 两处都被忽略** ⇒ 撞号时 `-R` 失败但**静默**;
Manager 随后仍按 `127.0.0.1:<port>` 拨 ⇒ **打到另一个用户的实例**。
`w-47` 的本地实例与 `w-106` 的隧道落点**共享 47 的 `127.0.0.1` 端口空间** ⇒ **两台机器即可触发**。
## 9.3 🔴 S1 的部署面:本方案与 v1 交接单**都漏了 47**
S0 的 `src/web/server.ts`、`src/supervisor/remote-spawner.ts`、`src/net/*` 跑在 **Manager(47)**;S2 的 `hostsProvider` 也在那儿。
实测:**47 `/opt/dshs/lib/` 至今无 `net/`**,`config.js` 指纹 ≠ 本机 ⇒ **47 是 S0 之前的版本**;S1 只上了 **106(Worker)** 侧。
⇒ 执行必须先做一步 **P1:把 47 对齐到 `640813e`**(零行为变化,风险最低),再上 S2。
## 9.4 ⚠️ S4 有**方案缺口**:中继一旦不在 Manager 主机上,"Manager 拨落点"就断
隧道落点在**接收方那台机器的 `127.0.0.1`**。中继换到别的机器 ⇒ 落点在**那台**机器上,Manager 拨不到。
⇒ 需要**额外一跳**:① Manager 侧向中继建出向隧道(`ssh -L`,把中继回环落点搬到 Manager 自己的回环),或 ② 中继侧把落点暴露到**非回环**地址 + nft 收窄(暴露面变大)。
**倾向 ①**(中继 sshd 的 `gatewayports no` 可保持不动 = 零权限扩大,且支持异地中继)。
⇒ 本轮 S4 **只做同机 relay**;⛔ 原 §4 S4 验收第 3 条"会合指向第二个中继实例"**本轮不作判据**(那要先定这一跳)。
## 9.5 ⚠️ S2 减负:**不要加 `address` 列**
`endpoint` 本身就是"要拨的地址",`via` 只补"经谁";再加 `address` = **同义双真相**(回填后必然逐字相等,违反单一来源)。⇒ §4 S2 的 `ADD COLUMN address text` **作废**,**只加 `via`**。
## 9.6 验收工具的现实约束(§4 未提,会直接卡住执行)
`scripts/verify-cluster-cross.mjs`:默认 `MANAGER=http://127.0.0.1:13080`(**演练端口**,现役是 **3080**)、`AGENT_TOKEN=cross-machine-token`(现役 `dshs-worker-7f3a91c05e`)、需 `ADMIN_PW`;
且它**有生产副作用**(幂等注册 `w-106` 并把容量改 4096 / 新建 `crossuser*` 用户 / 在 106 拉真实例 / 可选注册 `w-106b` 并迁移实例)⇒ **跑完必须清理**;脚本头部注释"控制面 PG 在 106"**已过时**(PG 现在在 47)。
## 9.7 §8 三处勘误的复验结论
> ⚠️ 行号微差(本轮实测):§1.2 / §8 写的 `proxy.ts:138-139` 实测应为 **`136-137`**(`host/port` 两行);`admin.ts` 的注册路由实测在 **`:181`(`POST /api/admin/hosts`)**,不是 `:184`。**结论不变**,只是行号偏了一两行。
- **① `proxy.ts:109`** ✅ **再次确认**仍是"发给上游 dsh 的 `Host` 头",⛔ 不可改;连接目标在 **`proxy.ts:136-137`**(`host/port` 取自 `endpointFor`)。
- **② 两条 `endpoint` 同形不同义** ✅ 确认(`w-47`=`local`/`w-106`=`manager-ssh`),别一刀切回填。
- **③ 47 cluster 配置在 drop-in** ✅ 确认。
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,193 @@
# 跨节点迁移 & 节点自举 —— 完整流程方案
> 立稿 **2026-09-16 00:3x** | 起因:09-15 的 guest 迁移走的是**手工临时方案**(AI 人肉做了 6 件事)。
> 用户要求:**「该谁处理就让对应功能处理,不要做这种临时方案;迁移应该谁发起、迁移完成应该如何处理,整个完整的流程」**。
> 本文回答三件事:**谁处理**(责任矩阵)→ **完整流程**(目标态)→ **要开发什么**(缺口清单)。
---
## 0. 一句话
把「人手搬 6 件事」收敛成 **两个平台功能**:
① **节点自举**(新节点加入时执行一次,含**旧角色清理**)② **迁移编排**(admin 发起 → 平台搬数据 → 收尾归档)。
---
## 1. 这次实际做了什么(对照:本该谁做)
| # | 我手工做的(临时方案) | 本该谁做 | 平台现状 |
|---|---|---|---|
| 1 | `useradd -u 100002 …` 建 OS 账号 | **worker 自己**(`dsh-provision.path` 触发) | ⚠️ 单元**只在 47**;且 uid 取自 SQLite ⇒ 集群下失效 |
| 2 | `npm i -g @deepseek-ai/[email protected]` | **节点自举**(一次) | ❌ 无此功能 |
| 3 | 传 `/usr/local/dsh-runtime`(python/jq/rg) | **节点自举**(一次) | ❌ 无此功能 |
| 4 | `chmod 711 /var/lib/dshs/users` | **节点自举**(一次) | ❌ 无此功能(且**漏了就必崩**) |
| 5 | 搬 203 MB 用户数据 + 依赖 | **平台迁移编排** | ❌ 迁移路由注释原文「**数据不搬家**」 |
| 6 | 源目录打包归档 → 删源 | **平台迁移收尾** | ❌ 无此功能 |
| 7 | ffmpeg 我从 47 搬(错) | **节点自举**:直接 `dnf install` | ✅ 106 内网源 43 MB/s,**几十秒装完**(本轮已改) |
---
## 2. 责任矩阵(谁处理什么)
| 环节 | 责任方 | 触发时机 | 幂等要求 |
|---|---|---|---|
| 节点自举:dsh 主程序 / dsh-runtime / 目录权限 / provisioner 单元 / egress | **平台(运维脚本)** | 节点**加入集群**时一次 | ✅ 必须可重跑 |
| 节点角色切换:**清理旧角色服务**(如 106 上的旧控制面) | **平台(运维脚本)** | 角色变更时 | ✅ |
| 用户目录出现 ⇒ 建 OS 账号 + `chown` | **worker 自己**(systemd path 单元) | 目录出现时 | ✅ 已有(待铺到各 worker) |
| 迁移**发起** | **admin**(人) | 人为决策 | — |
| 迁移**执行**(drain → 数据同步 → 目标机拉起 → 归属) | **平台** | 发起后 | ✅ |
| 迁移**收尾**(源归档 / 清理 / 审计 / 通知) | **平台** | 迁移成功后 | ✅ |
**原则**:凡是"节点级别、一次性的"归**自举**;凡是"跟着用户走、每次迁移都发生"的归**迁移编排**。
⛔ 两者都**不该由 AI 或人手临时执行**。
---
## 3. 完整流程(目标态)
### A. 节点加入(自举,一次)
```
1. 运维执行 bootstrap-worker(幂等)
├─ 装 dsh 主程序(同版本,npm 或离线包)
├─ 装 dsh-runtime(python3.12 / jq / rg / ffmpeg —— 优先 dnf/内网源,而非从别处搬)
├─ 目录与权限(/var/lib/dshs 711、users 711、secret.key)
├─ 铺 provisioner 单元(dsh-provision.path + .service,脚本参数化到本机路径)
└─ 清理旧角色残留(如该机曾跑控制面 ⇒ 停用并禁用)
2. 自检 doctor:四件套 + provisioner + 端口 + 数据根,全绿才允许被调度
3. 注册进 dsh_hosts(capacityMb / endpoint)
```
### B. 用户迁移(admin 发起 → 平台执行 → 收尾)
```
1. 【发起】admin 在「用户管理」选用户 → 选目标节点 → 点"迁移"
(入口已有 API:POST /api/admin/users/:id/dsh/migrate;门户缺 UI 入口)
2. 【前置校验】平台检查目标节点:status=up、capacity 足够、doctor 全绿
不就绪 ⇒ 拒绝并列出缺项(不让人手工补)
3. 【执行】
a. drain:停源机实例(优雅停机,会话落盘)
b. 同步数据:源 → 目标(平台要负责;见 §4 D3)
c. 目标机承租约拉起(epoch+1)
d. 归属原子更新
4. 【收尾】源数据按策略归档(默认留 N 天)→ 清理源目录 → audit 记录 → 通知用户/管理员
5. 【回滚】归档可解压恢复;迁移可反向再执行一次
```
⚠️ **顺序不可换**:先停源、再在目标机拉起(否则两机同 home ⇒ 双写)。这一点现有 API 已正确实现。
---
## 4. 缺口清单(要开发的功能)
| # | 功能 | 解决的问题 | 现状 | 优先级 |
|---|---|---|---|---|
| **D1** | `bootstrap-worker`(节点自举脚本,幂等) | 新节点上线全靠手工(本次 6 件事) | ✅ **已交付**:`scripts/bootstrap-worker.sh`(47 / 106 自检全绿) | **P0** |
| **D2** | 建号自动化(**uid 由 Manager 投递,agent 在 spawn 前确保账号**) | 新用户落 worker 无人建号;uid 依赖 SQLite 在集群下失效 | ✅ **已交付**:`src/worker/agent.ts` 的 `ensureOsAccount`(端到端已验证) | **P0** |
| **D3** | 迁移含**数据同步** | 路由注释明说"数据不搬家" ⇒ 现在必须人工搬 | ❌ 无 | **P0** |
| **D4** | 迁移**收尾自动化**(归档 + 清理 + 通知) | 现在靠人手打包删源 | ❌ 无 | P1 |
| **D5** | 门户「迁移」入口 + 节点状态面板 | admin 现在只能调 API | ⚠️ API 有 | P1 |
| **D6** | 节点**角色切换清理** | 106 的旧控制面**已手工执行并验证**(见 §5)⇒ 该流程本身仍未自动化 | ❌ 无 | P1 |
**D2 的技术债已确认**:`uid-for-user` 是 `user?.uid ?? hashUid(id, baseUid)`,
而 `hashUid = baseUid + hash % 100000` ⇒ 实测 106 上算 guest 得 **184656**,与真实 uid **100002 不符** ⇒ **uid 只能从 PG 读**(47 之所以能跑是因为它读 SQLite)。
⚠️ **实测:106 需要能读 PG** —— 但 47 的 PG 只监听 `127.0.0.1:15432`,106 无法直连。
⇒ D2 的正解不是"给脚本换个 --db 参数",而是 **Manager 在创建用户时把 uid 下发给 worker**(走已有 agent 通道),或让 worker 通过隧道读 PG。**这是设计问题,应随 D2 一起定**。
**D1 的补充判据(实测 `dshs doctor` 的覆盖缺口)**:106 上跑 `doctor` 只报了 node/cgroup/bwrap/setpriv/systemd-run/nft/dsh/降权/dataRoot/DB —— **不覆盖本次真正踩到的四项**:`/var/lib/dshs/users` 权限、`dsh-runtime` 缺失、`provisioner` 单元缺失、**旧角色残留**。
⇒ D1 的自检清单必须把这四项补进去(否则"doctor 全绿"仍会起不来)。
---
## 5. ✅ 已执行:106 上的旧控制面已停用、禁用并**删除**(用户 2026-09-16 00:31 定)
**决策过程**:先定为「不处理、隔离即可」→ 人工复核隔离面后,用户改判为「**停用并禁用 + 删除数据**」。
**已执行(2026-09-16 00:3x)**:
1. `systemctl stop` + `disable dsh-users-platform`(同时移除 `multi-user.target.wants` 链接)
2. 删除数据与程序:`/var/lib/dsh-users-platform/`(176 K,含 2 个用户目录 + state)· `/root/dsh-users-platform/`(144 M 平台程序)· `/etc/dsh-users-platform.env` · `/etc/systemd/system/dsh-users-platform.service`
3. `systemctl daemon-reload`
**删除前备份(留退路)**:`/opt/dsh/backups/legacy-106-platform-20260916-002931.tar.gz`(6.5 K:数据根 + env + 单元文件)。
**验证(全部通过)**:单元文件 / 服务查询 / 残留进程 / 数据根 / 程序目录 / env 文件 **全部不存在**;**3080 端口已释放**;
**新架构未受影响** —— `dsh-100002-43771a07.scope`(guest)与 `dsh-100008-3099bb14.scope` 仍 **running**,`dshs-worker` **active**。
**删除前核销的隔离面(留档)**:数据根 / 用户目录 / OS 账号 / 端口 / systemd 单元五面**均不重叠**;
唯一残留耦合是"两者共用 `BASE_UID=100000` 的同一 uid 空间"(理论上可能撞 uid)—— **现已随删除彻底消除**。
---
## 6. 立即能做的(不依赖 D1–D6 开发)
1. **ffmpeg/ffprobe/jq/rg**:已改为 106 内网 `dnf install`(**本轮已完成**,几十秒,不再是遗留项)。
2. **provisioner 铺到 106**:⚠️ **不是"给脚本换个 `--db` 参数"就行** —— uid 必须由 Manager 下发(见 §4 D2 说明:哈希兜底会算错,PG 又连不上)。
3. 其余(D1 / D3 / D4 / D5 / D6)建议按 §4 优先级排期开发;**D6 的手工执行已完成(106 旧控制面已删),只是那条流程仍未自动化**。
---
## 7. 实施记录(2026-09-16 00:3x–01:0x)
### 7.1 D2 建号自动化 —— ✅ 已上线
**改动**:`src/worker/agent.ts`
- 新增 `ensureOsAccount(config, userId, uid)`:**幂等**,规格与 `provision-new-users.sh` 一致(`dsh-<短id>` / `-M` / nologin / 同 uid),并对齐用户目录属主;
- 在 `POST /launch` 的 **spawn 之前**调用 —— OS 账号缺席时 bwrap 里的 `setpriv --reuid` 会直接失败,**且不报权限错**,只表现为"实例起不来";
- 护栏:参数校验先行(UUID 形状 / `uid ≥ baseUid`)、uid 被他人占用则 fail-loud、非 Linux 静默返回;**不接受调用方传命令或路径** ⇒ 不违反 agent 的「最小接口」纪律;
- **测试**:`test/worker-provision.test.mjs`(5 例)已并入 `npm run verify` 链,`npm run verify` **全绿**;
- **部署**:47 送源码 + 服务器 `npm run build`;106 送编译产物;两边 `restart dshs-worker`(实例是独立 scope,不受影响);
- **端到端验证**:以测试用户(uid 199999)调 `/launch` ⇒ **账号被自动创建** `dsh-11111111222233334444` ✓(实例本身 crashed 属预期:测试用户无工作区);验证后账号/scope 已清理干净。
### 7.2 D1 节点自举 —— ✅ 已上线
**交付**:`scripts/bootstrap-worker.sh`(幂等;`--check` 只读自检;`--prune-legacy` 清旧角色)。
覆盖链路:前置 → dsh 主程序(**版本校验** + PATH 契约)→ runtime(jq/rg/ffmpeg/ffprobe **走本机包管理**,不从别处搬)→ python 路径契约 → **数据根权限 711**(漏了必崩)→ 旧角色残留 → 自检汇总(含 `dshs doctor` 漏掉的四项)。
**实测**:47 与 106 均 **全绿、退出码 0**。
### 7.3 🔴 事故与教训(**我造成的,已修复**)
**事故**:删除 106 的旧控制面目录 `/root/dsh-users-platform` 后,**106 的 worker 起不来了**(`Cannot find package 'fastify'`)。
**根因**:`/opt/dshs-cluster/node_modules` 与 `/usr/local/dshs-cluster/node_modules` **都是符号链接** → `/root/dsh-users-platform/node_modules`;删掉被指向的目录 ⇒ 依赖链断裂。
**我的失误**:删除前**只**检查了「有没有 systemd 单元引用它」,**没检查文件系统符号链接引用**。
**修复**:从 47 取 `package.json` + `package-lock.json` → 106 上 `npm ci --omit=dev --ignore-scripts` 重建(109 包)⇒ worker 恢复 `active`、19000 监听正常。
⚠️ 注意:`npm ci` 会触发 `prepare`(`npm run build`),而该目录没有源码 ⇒ **必须加 `--ignore-scripts`**。
**教训(已入纪律)**:删任何目录前,除 systemd 单元外,**必须查符号链接引用**:
`find / -lname '<path>*' -not -path '/proc/*' 2>/dev/null`
### 7.4 未完成 / 待办
- **D3(迁移含数据同步)**:未开始 —— 这是"迁移不再靠人肉搬数据"的关键一条,建议下一步就做。
- **代码尚未 commit**:`src/worker/agent.ts`、`package.json`、`scripts/bootstrap-worker.sh`、`test/worker-provision.test.mjs` 都在代码仓工作树里(按纪律未擅自提交)。
---
## 8. 2026-09-16 06:5x 排查:旧控制面彻底清除 + admin 502 根因
### 8.1 旧控制面彻底清除(用户要求「还是删除」)
⚠️ 关键差别:**这次先把依赖搬离再删** —— 昨晚就是栽在这一步。
- 事实:`/opt/dshs-cluster/node_modules` 与 `/usr/local/dshs-cluster/node_modules` **原本都是符号链接** → `/root/dsh-users-platform/node_modules`;
- 做法:`mv` 实体 → `/opt/dshs-cluster/node_modules`(109 包)→ `/usr/local` 侧改指新位置 → **重启并验证 worker active** → 按 §7.3 的新纪律 `find /opt /usr /var /etc /root -lname "<path>*"` 确认**无引用** → 删除 `/root/dsh-users-platform`;
- 结果:旧控制面残留 **0**、`dsh-users*` 单元 **0**、3080 无监听、worker **active**、19000 正常。
### 8.2 admin 访问 502 的根因 = **冷启动时序**(非崩溃、非超时)
**证据链**(全部实测):
1. nginx 在 **06:53:40** 记 `upstream prematurely closed connection ... request: "GET /" ... host: "admin.alotbuy.com"`(即用户看到的那次 502);
2. Manager(`dshs`)日志里该请求**只有 `incoming request`、没有 `request completed`** ⇒ 连接被上游主动断开,**不是超时**(该站点 `proxy_read_timeout` = **3600s**);
3. **06:53:28** 出现 `GET /wake.html?next=https://admin.alotbuy.com/` ⇒ 触发了**唤醒流程**,说明当时 admin 实例处于 **stopped**(被空闲回收);
4. 06:56 我调 agent `/launch` 返回 **`already-running`** ⇒ 实例在那之前已被拉起;
5. 06:56:34 对同一 URL 的请求 ⇒ **401(2 ms)** ⇒ 链路本身健康。
⇒ **结论**:实例被空闲回收后,用户访问触发唤醒;**唤醒页跳回 `/` 时代理转发到实例,而实例端口尚未就绪** ⇒ 转发失败、连接被断 ⇒ nginx 502。
(这类"回收后再访问"的窗口期对**低频访问但非新用户**尤其明显 —— admin 管理台正是这种。)
**修复方向(按优先级)**:
1. **代理转发失败时做短重试**(覆盖"实例已起但端口未 ready"的数秒窗口)—— 根因修复,改 `src/supervisor/proxy.ts` 的转发错误路径;
2. 或把 `/api/dsh/enter` 的就绪判据从"拿到 launch token"加严到"**端口可连**"再返回;
3. 运维缓解(不治本):admin 属高频管理入口,可对其**不做空闲回收**。
### 8.3 ⚠️ 顺带发现一个**必须修**的架构缺口(迁移引入的真实影响)
**现象**:`POST /api/me/locale` 对 **guest(已迁到 106)** 返回 **500**:
`ENOENT: no such file or directory, open '/var/lib/dshs/users/4092b965-…/home/settings.yaml'`(06:54 出现两次)。
**根因**:`src/web/home-files.ts` 的 `writeHomeFile` **直接操作本地文件系统**,没有走集群文件面 ⇒
**Manager 上任何"直接写用户 home"的路由,对不在本机的用户都会 500**(不只是 locale)。
**影响面**:凡迁到 106 的用户,在 47 的门户上做"改语言/改设置"类操作会 500 —— 这是**集群化的通用缺口**,不是 locale 一处的问题。
**修复方向**:这些路由改走 `app.userFs`(集群模式下即 `RemoteUserFs`,会代理到该用户所在 worker),
而不是直接 `fs.writeFile`。⇒ 建议与 D3 一并排期。
@@ -0,0 +1,206 @@
# 项目代码 · 分层范式与迭代风险评估(2026-09-16)
> **性质**:只读架构评估(实测探针,非印象判断)。⛔ 未改任何代码。
> **取证**:`D:\github\dsh_shenxian\src` 全量静态扫描(HEAD `4e3a1a4`)—— 58 个 `.ts`、14,053 行;统计目录规模、文件扇出、**跨目录依赖矩阵**(探针脚本内部聚合,只输出摘要)。
> **一句话判定**:**范式方向是对的(按域分目录 + 依赖稀疏 + 扇出低),但有两类会被大规模迭代放大的隐患** —— ① **4 组双向依赖**(含"基础层反向依赖业务层")② **缺领域层**,导致业务规则沉进 route 文件。
> **关于"改一处要不要读全仓"**:**现在还不用,但已经出现"改一处要连读 2–3 个大文件(约 2,500 行)"的模式**;覆盖网络是**最后一次能以低成本立规矩的机会**。
---
## 一、实测数据
### 1.1 目录规模
| 目录 | 文件 | 行数 | 占比 |
|---|---|---|---|
| `web`(routes + server + nginx) | 24 | 5,593 | **40%** |
| `supervisor` | 11 | 3,295 | 23% |
| `db` | 10 | 2,960 | 21% |
| `(root)`(config / isolation / crypto / cli / index) | 5 | 836 | 6% |
| `worker` | 2 | 708 | 5% |
| `fs` | 6 | 661 | 5% |
| **合计** | **58** | **14,053** | — |
### 1.2 最大的 12 个文件 —— **行数大 ≠ 耦合高**
| 行数 | 文件 | 内部依赖数(扇出) |
|---|---|---|
| 1,259 | `supervisor/orchestrator.ts` | **6** |
| 756 | `db/pg.ts` | **4** |
| 752 | `db/repo.ts` | **3** |
| 743 | `web/routes/business-plugins.ts` | **4** |
| 706 | `supervisor/proxy.ts` | 3 |
| 687 | `web/routes/skills.ts` | 3 |
| 514 | `worker/agent.ts` | 8 |
| 500 | `web/routes/whitelist.ts` | 4 |
**⇒ 关键读数:1,259 行的文件只依赖 6 个模块。** 说明它大是因为"**同领域逻辑都塞在一个文件里**",**不是**因为"牵扯面广"。对"改一处要不要读全仓"来说,这是**好消息**。
### 1.3 跨目录依赖矩阵(全部 23 条边)
| from → to | 次数 | | from → to | 次数 |
|---|---|---|---|---|
| web → supervisor | 11 | | supervisor → worker | 2 |
| web → fs | 9 | | supervisor → web | 2 ⚠️ |
| web → (root) | 4 | | worker → supervisor | 2 ⚠️ |
| web → db | 3 | | db → (root) | 2 |
| web → nginx | 1 | | (root) → web | 2 ⚠️ |
| fs → web | 3 ⚠️ | | (root) → fs | 2 |
| supervisor → db | 3 | | (root) → db | 1 ⚠️ |
| worker → fs | 3 | | fs → worker | 1 ⚠️ |
| worker → (root) | 2 | | fs → (root) | 1 |
| supervisor → (root) | 1 | | (root) → 其他 | — |
**⇒ 关键读数:整仓只有 23 条跨目录边,最粗的一条是 11 次。没有"上帝模块"。**
---
## 二、范式评估
### 2.1 合理之处(4 条)
1. **按域分目录**:`web`(入口/HTTP)· `supervisor`(进程生命周期)· `db`(存储)· `fs`(用户文件)· `worker`(远程节点)—— 边界与职责对得上。
2. **依赖稀疏**:23 条边、最大 11 —— 远低于同类单体项目。
3. **抽象缝存在**:`Spawner` 接口把"后端"抽出来了,route 层只依赖接口。
4. **文件头注释质量高**(这一点被低估):`agent.ts` 头部 8 行讲清"它是什么 / 四条纪律 / 引用设计章节",`spawner.ts` 同理。**这就是"不用读全仓"的现成机制** —— 它把"这个文件负责什么"变成可低成本获取的信息。
### 2.2 隐患(4 条,均有数据支撑)
| # | 隐患 | 数据 | 后果 |
|---|---|---|---|
| 1 | **双向依赖 4 组** | `web ↔ supervisor`(11/2)· `web ↔ fs`(9/3)· `supervisor ↔ worker`(2/2)· `fs ↔ worker`(1/3) | 改任一侧都要看另一侧 ⇒ **上下文成本翻倍**;且无法单独测试 |
| 2 | **基础层反向依赖业务层** | `(root) → web` 2 处 · `(root) → db` 1 处 · `db → (root)` 2 处 | 共享层(config 等)依赖上层 ⇒ **分层方向被破坏**,这是最该修的一条 |
| 3 | **缺领域层** | `web` 占 40% 行数;`business-plugins.ts` 743 行、`skills.ts` 687 行 | 业务规则沉在 route 文件里 ⇒ 改规则要读 route;同规则无法被 worker/CLI 复用;难以单测 |
| 4 | **`web` 层过重** | 24 文件 / 5,593 行 | 入口层变成事实上的"业务层",进一步加剧 #3 |
---
## 三、是否需要分层:**需要补两层,但不需要重构**
### 3.1 现状的实际分层(隐式)
```
web(routes) ──► supervisor / db / fs ──► (root: config / types)
▲ ▲ │
└────────────────────┴────────────────────────┘
(反向边,违规)
```
### 3.2 建议的目标分层(**方向规则要写下来**)
```
① 入口层 web/routes · cli (只做 HTTP/CLI 编解码)
② 领域层 domain/* ← 【新增】业务规则,纯函数优先
③ 能力层 supervisor · db · fs · worker · net/*
④ 基础层 config · types · crypto ← 【禁止反向依赖】
依赖方向:① → ② → ③ → ④,单向,⛔ 不得回指
```
- **补 ② 领域层**:把"业务规则"从 route 抽出。收益 = route 瘦身 + 可单测 + 可被 worker/CLI 复用。
- **补 `net/` 作为能力层的一员**(与 supervisor/db/fs 同级),**不是新的一层** —— 这样覆盖网络不会变成一个"横跨所有层的特权模块"。
- **把依赖方向写进架构文档/CODEBUDDY**:现在规则是隐式的,靠自觉。
---
## 四、「改一处要读全仓」的风险评估(**这是用户真正问的**)
### 4.1 分规模档看
| 规模 | 读全仓的代价 | 结论 |
|---|---|---|
| **现在**(58 文件 / 14k 行) | 约 15 万字符 ≈ 4–5 万 token | **可行但不必** —— 实际不需要读全仓 |
| **覆盖网络落地后**(+3~4 目录 / +5~8k 行) | 约 22k 行 ≈ 7–8 万 token | ⚠️ **开始变贵**,且依赖边会从 23 条涨到 40+ |
| **继续叠加**(游戏 / 房间层 / 发布层) | — | 🔴 **若不立规矩,会真变成"改一处读全仓"** |
### 4.2 真正的风险不在规模,在**边界模糊**
现在已经在发生:改 `business-plugins.ts`(743 行)时,**你无法只靠它自己判断"这条规则属于插件管理还是实例生命周期"** —— 因为没有领域层,规则一半在 route、一半在 `orchestrator.ts`(1,259 行)和 `repo.ts`(752 行)。
⇒ **一次改动实际要连读 3 个大文件 ≈ 2,500 行**,这才是成本所在,而**与仓库总规模无关**。
### 4.3 三条可落地的规矩(覆盖网络正好是载体)
1. **单向依赖**:`net/*` 不得依赖 `web/*`;`web` 可以依赖 `net`。**先定规则再写第一行代码** —— 现在新增目录的成本最低。
2. **契约前置**:先写接口与类型,再写实现。会合中继拆分方案的 **S0 已经是这个做法**(纯新增 `src/net/rendezvous.ts` / `reachability.ts`,零行为变化)—— 把它变成制度,而不是一次性动作。
3. **文件头注释制度化成"模块索引"**:现有头注释写得很好,只要**规定"每个模块目录入口必须写明:职责 / 依赖谁 / 被谁依赖"**,就能把"读全仓"降级为"读索引 + 读相关模块"。
4. **⛔ 禁止基础层反向依赖**:`config` / `types` 不得 import `web` / `db` —— 现在有 3 处,趁早清掉。
### 4.4 一句话回答
> **现在不会**"改一处读全仓";**但已经会**"改一处读 2–3 个大文件";**覆盖网络之后如果不定依赖方向,就会真的变成读全仓。**
> ⇒ **需要做的不是重构,而是"立规矩 + 补一层 domain"。**
---
## 五、本机纳入测试环境(你已给的许可)
**定位**:本机(Windows 开发机)= **客户端类型的第一个测试节点**,正好补上可行性评估的**缺口 1**("dsh 在 Windows 上能否起实例 + 实例内 bash 是否可用"**至今未实测**)。
**形态判定**:Windows 无 bwrap / systemd / uid 隔离 ⇒ **只能走 `soft` 模式**(实例 = 裸子进程)—— 而这**正好就是客户端形态的目标模式**,不是降级。
**可先验的三件事(都不动服务器)**:
1. 本机起一个实例(`soft` 模式)⇒ 实例页 200;
2. 实例内跑一次 bash 工具 ⇒ 验证 dsh 在 Windows 的**沙箱后端行为**(这是全案唯一"尚无证据"的技术点);
3. 本机与 47 / 106 之间的**真实网络画像**(NAT 类型 / 打洞可行性 / 与中继的 RTT 与 jitter)—— 直接填"最该先测的三项"里的两项。
⚠️ 两点注意:① 本机是**唯一的开发机**,起实例会占资源,建议用最小配额;② 本机作为"客户端节点"参与网络时,**不得**顺手把它接进现有生产链路(要独立形态、独立开关)。
---
## 六、我选了什么(可推翻)
1. **不重构,只做两件事**:补 `domain` 层 + **把"单向依赖"写成书面规则**(四层:entry → domain → capability → base)。
2. **`net/` 定位为能力层的一员**(与 supervisor/db/fs 同级),不是特权跨层模块。
3. **把 S0 的"契约前置"制度化** —— 每个新增模块先出接口与类型文件。
4. **清掉 3 处基础层反向依赖**(`(root) → web` 2 + `(root) → db` 1),列为独立小任务。
5. **本机作为第一个客户端测试节点**,先做 §五 的三件验证。
---
## 七、本次未做
- ⛔ 未改任何代码、未动服务器、未写文档库(全局执行锁被 `修复轮-决策方法-2b` 占用)。
- 探针脚本落 `_中间产物_待清理/_arch_probe.py`(可复跑;结论已固化到本文件)。
- 📌 未新增上抛项。
---
# 八、执行计划与实际落地(2026-09-16 13:2x 追加)
## 8.1 🔴 勘误:§2.2 隐患②「基础层反向依赖 3 处」**不成立**
**复核命令**(只读):
```bash
grep -n "from '\./\(web\|db\|fs\|supervisor\|worker\|net\)/" src/*.ts
```
**只命中 `src/cli.ts`**(5 条);`config.ts` / `crypto.ts` / `isolation.ts` / `index.ts` **零 import**。
⇒ **根因**:上一轮统计把 **`cli.ts` 误算进了"基础层"**。它是**入口层①** —— import `db`/`fs`/`web` 属 **① → ③ 的合法方向**。
⇒ **结论修正**:
① 真实违规 = **0 处** ⇒ §4.3 第 4 条「清掉 3 处基础层反向依赖」**撤销**(没有可清的);
② §2.2 隐患② 应改写为「**边界模糊**」——真实成本在 `web/routes` 里沉着的业务规则(§4.2 那条),**不在依赖方向**。
⇒ **教训与 S0 的两处勘误同类:接手前人结论,先做一次最小取证。**
## 8.2 ✅ P0 已落地(本机 · 2 个文件)
| 文件 | 改动 |
|---|---|
| `docs/architecture.md` | **新增**:四层定义 + 依赖方向 `①→②→③→④ 单向,⛔ 不得回指` + **R1–R4 判据**(怎么算违反)+ 三条工程纪律(契约前置 / 模块头注释 = 索引 / 动手前自查)+ §3 现状实测 + §4 优先级 |
| `README.md` | 文档表**新增一行**指向它("动手改 `src/` 前读一遍") |
- **验收**:规则可从 `README → docs/architecture.md` 两级直达;四层归属带**可判定的判据**,不是口号。
- **回滚**:删 `docs/architecture.md` + 删 `README.md` 那一行。
- **为什么先做它**:零代码风险,而"扩张期走丢方向"的回头成本高得多(§四 4.1 的风险分档)。
- ⏳ **未做**:git commit(未授权)。
## 8.3 剩下的两件(均需**先出清单**,命中 R7)
**P1 · 补 `src/domain/*`** —— 把 `web/routes` 里的业务规则抽出来。
⚠️ **行为敏感重构**(不是纯类型);影响面 **>10 文件** ⇒ 动工前先出受影响清单。
判据(做它的唯一理由):让"改插件管理规则"不再需要连读 `orchestrator.ts`(1,259) + `repo.ts`(752)。
**P2 · 模块头注释补全** —— 按 `docs/architecture.md §2.2` 给各模块入口补「职责 / 依赖谁 / 被谁依赖」。
批量改 >10 文件 ⇒ 同样先出清单。
> **顺序建议**:**P0 ✅ → 覆盖网络 S1–S4 → P1**。
> P1 **不阻塞**覆盖网络;反过来,覆盖网络的新模块(`net/*`)正好是「契约前置」的第一个样板。
@@ -0,0 +1,174 @@
# guest 迁移(w-47 → w-106)与「**共享重建**」方案
> 状态:**规划稿(未执行)** | 立稿:2026-09-15 | 触发:用户要求「规划搬运和重建方案;MCN 依赖应以**可共享**的方式重建,避免后续搬其他用户重复重建」
## 0. 一句话
只搬 **46.3 MB** 不可再生数据;**792.7 MB 的 Python 环境 + 362.7 MB 的 Node 依赖改为「平台侧共享目录 + 新机重建」**,装一次、全平台用户共用。
---
## 1. 事实基础(本轮实测,勿再重测)
| 项 | 数值 / 事实 |
|---|---|
| **必搬(不可再生)** | **48,539,522 B = 46.3 MB / 1193 文件** |
| 可重建 / 可丢 | ≈ 2.9 GiB:`trash` 1379.5 · `ws/.local`(pnpm store) 732.9 · `MCN/.venv` 792.7 · `home/profiles/web/node_modules` 362.7 · `tmp` 26.4 · 各类缓存 ~36 |
| **47 → 106 直连速率** | **~22 KB/s**(实测 5.5 min 走 7.3 MB)⇒ 46.3 MB ≈ **35 min**;1.7 G 需 21 h ✗ |
| 106 现状 | `dshs-worker`(19000) 在跑;**无 provisioner、无 pnpm 链路**;磁盘余 32 G;**已建 OS 账号 uid 100002**;无 guest 数据 |
| **平台现成机制** | 实例 bwrap 已有 `--ro-bind-try /var/lib/dshs/bundled-skills` ⇒ **「平台共享只读目录」这套已经存在**,共享 venv 走同一套路 |
| 平台官方口径 | `src/supervisor/orchestrator.ts:557`:额外依赖走 `pip install --target <ws>/.pylibs` + PYTHONPATH,**或自建 venv(不改平台)** ⇒ 现状是**每用户一份**,正是要改的点 |
**必搬口径(可复现)**:
`du -sb <用户目录> --exclude=trash --exclude=tmp --exclude=.venv --exclude=node_modules --exclude=.local --exclude=.cache --exclude=.npm --exclude=.node-compile-cache --exclude=.pnpm-cache --exclude=.pnpm-store --exclude=__pycache__`
⚠️ 分项相加会大于总量(pnpm store 与 node_modules **硬链接**,`du` 同一 inode 只计一次)—— 报体积别用分项求和。
---
## 2. 依赖分三类 → 共享落点
| 类 | 现状(每用户一份) | 目标 | 落点 |
|---|---|---|---|
| **Python:MCN venv** | **792.7 MB / 人** ✗ | **全平台一份** | `/var/lib/dshs/shared/python/mcn@<版本>/`,bwrap `--ro-bind-try` 进实例 |
| **Node:Profile 依赖** | 362.7 MB / 人(pnpm store 697 MB 里大部分可复用) | **store 共享**;`node_modules` 按 profile 重建 | 共享 store `/var/lib/dshs/shared/pnpm-store` + `pnpm install --frozen-lockfile` |
| **系统库 / 字体** | `ws/syslibs` 5.24 MB + `ws/.fonts` 15.68 MB | 一份 | 同共享目录(或纳入平台 syslibs 机制) |
**收益**:第 2 个及以后用户 ⇒ Python/Node 依赖**不再重复下载**,**省 ≈1.15 GB 与 30+ 分钟/人**。
---
## 3. 关键设计决定(自决,可推翻)
1. **共享目录一律只读挂载**(ro-bind)⇒ 用户改不坏、不会被各自的 pip 污染;写操作仍走用户自己的 `.pylibs`。
2. **版本化 + 显式升级**:目录名带版本(`mcn@1`);升级 = 建新目录 → 切挂载 → 保留旧版供回滚。
3. **MCN 技能改造**:不再自带 `.venv`,改指向共享解释器(脚本里 `PYTHON_BIN` / `PYTHONPATH`);`playwright` 浏览器二进制放共享目录并设 `PLAYWRIGHT_BROWSERS_PATH`。
4. **清单入库**:从现 venv `pip freeze` 导出**固定版本**的 `requirements.txt` 进仓库 ⇒ 共享环境从此可复现(当前项目里**没有**任何清单,这是必须先补的前置)。
5. **平台改动最小化**:只加 `--ro-bind-try /var/lib/dshs/shared`(一行),不动隔离与权限面(仍是只读)。
---
## 4. 执行步骤(每步有验收与回滚)
| # | 动作 | 验收 | 回滚 |
|---|---|---|---|
| 1 | 47 上导出 MCN 依赖清单(`pip freeze` + `playwright --version`) | 清单文件生成并入库 | 无副作用 |
| 2 | 搬 46.3 MB(§1 口径)到 106,落地后 `chown -R 100002:100002` | 字节数/文件数与 47 一致;属主 100002 | 删 106 目标目录 |
| 3 | 106 建共享目录并**重建 MCN venv** | `python -c "import ctranslate2, av, playwright, pandas"` 通过 | 删共享目录 |
| 4 | 平台加一行共享挂载 + build + `systemctl restart dshs` | 实例内 `ls /var/lib/dshs/shared` 可见 | 去掉那行重启 |
| 5 | 106 上重建 profile 依赖(pnpm + lock + `.dsh-stage/*.tgz`) | `node_modules` 生成、bundles 完整 | 删 `node_modules` |
| 6 | 迁移归属:`POST /api/admin/users/:id/dsh/migrate {"targetHost":"w-106"}` | `host_id=w-106`、`epoch+1`、106 出现 scope、guest 能进且文件在 | 再迁回 w-47(**数据仍在**) |
| 7 | 源目录打包 → `/opt/dsh/backups/migrated/guest-<ts>.tar.gz` → 删源 | 归档可解压且清单核对一致 | 从归档恢复 |
---
## 5. MCN venv 的建法:**选定「在 106 重建」**(另一条已否决)
- **选定 A · 在 106 重建**:干净、可复现、跨机架构正确。代价:需先有清单(步骤 1)、需外网装 ~800 MB。
- **否决 B · 从 47 直接把 venv 拷进共享目录**:虽与现状逐字节一致,但走那条 **22 KB/s** 的链路搬 792.7 MB ⇒ **≈10 小时**,且 venv 内含绝对路径,跨机仍有风险 ⇒ **只有缺点,故自决不采用**。
---
## 6. 与「备份机制」的衔接
- **归档区**:`/opt/dsh/backups/migrated/`(已建);源目录**先打包保留、再删**。
- **策略分离(建议)**:
- **用户核心数据(46 MB 级)** → 高频(每日)备份,直接放归档区/对象存储;
- **共享依赖(版本化目录,百 MB 级)** → **按版本**备份(升级时留一版即可),不必按日。
- 后续接「备份存储空间」:归档目录可整体同步到对象存储(OSS/COS);挂载点与保留策略一次定好,避免各 worker 各写一套。
---
## 7. 红线与「不得净变差」复核(R11)
| 红线 | 结论 |
|---|---|
| R2 不改官方 dsh | ✅ 只改平台自己的 spawn 参数 + 用户数据 |
| R7 批量/别人 lane | 迁移是用户明确指令;**平台加一行挂载**属我 lane,但先单点验证(步骤 4 先在一台 worker) |
| R8 中断在线用户 | 本服务器为开发环境,可直接做,**动手前一句话说明** |
| R10 属主 | 目标侧一律 `chown -R 100002:100002`(与 47 `provision-new-users.sh` 同口径) |
| **R11 净变差** | **共享化让体积/性能/扩展性都变好**(每人省 1.15 GB 与 30 分钟);新增的只有一个**只读**挂载点 ⇒ **权限面不变** ⇒ 无净变差 |
---
## 8. 遗留(本方案不解决,需另立)
1. **106 没有账号 provisioner**(`dsh-provision.path` 只在 47)⇒ 新用户被调度到 w-106 时**无人建 OS 账号**,且 worker agent 里也没有 useradd ⇒ **新用户可能起不来**。建议把 provisioner 机制铺到每个 worker(否则「新用户落 w-106」这条设计不成立)。
2. **MCN 项目里没有任何依赖清单**(`requirements.txt`/`pyproject.toml` 都没有)⇒ 步骤 1 是硬前置。
3. 现有用户的 `.venv` 仍是各自的(本方案只对**新迁移/新建**生效);存量收敛需单独排期。
---
## 9. 决策更新(2026-09-15 20:5x · 用户定,开始执行)
- **那 4 个重包「暂不重建」**:`playwright==1.62.0` · `ctranslate2==4.8.2` · `onnxruntime==1.30.0` · `av==18.1.0`(合计 ≈ **443 MB**)⇒ 新环境**先不装**(MCN 依赖这几项的功能暂时不可用,后续需要时再单独排期)。**其余按本方案处理。**
- **依赖清单已导出(硬前置完成)**:`mcn-req-full.txt`(**51 包**)与 **`mcn-req-no4.txt`(47 包 —— 重建用这份)**,落在工作区根目录。
⚠️ 口径注意:venv 里**没有 pip**(`pip freeze` 直接 `UnicodeDecodeError`)⇒ 改为扫 `site-packages/*.dist-info` 生成清单(等价且更稳)。
- **共享 venv 的基础运行时**:现有 venv 由 `/usr/local/bin/python3 -m venv` 创建,指向 **`/usr/local/dsh-runtime/python-3.12.14/bin/python3.12`**(平台自带的**共享** Python 运行时)⇒ 共享 venv 应基于它建,**不要**用系统 python。
- 执行进度:**核心数据搬运已在 106 后台启动** —— 脚本 `/root/migrate-guest.sh`,日志 `/var/log/migrate-guest.log`,起于 **2026-09-15 20:58:16**,预计 **~35 分钟**(46.3 MB @ ~22 KB/s)。
⚠️ 该次搬运**已作废**:其 `--exclude` 写的是 `$U/node_modules`、`$U/.venv` 等**顶层路径**,而真实依赖在 `ws/…`、`home/profiles/web/…` 之下 ⇒ 等于在传全量 3 G(实测 2h10m 只走 133 MB)⇒ 已停并改走 §10 的路径。
---
## 10. 执行实况(2026-09-15 23:15 → 09-16 00:2x)—— **已完成**
### 10.1 带宽实测(决定整条路径)
| 链路 | 实测速率 | 结论 |
|---|---|---|
| 47 → 本机(单流) | **126 kbps ≈ 16 KB/s** | 瓶颈在 47 出口 |
| 47 → 本机(8 路并行) | 783 kbps ≈ 98 KB/s | 并行 ≈ 6 倍收益 |
| 47 → 本机(16 路并行) | 1122 kbps ≈ 140 KB/s | 接近饱和 |
| **本机 → 106** | **33.5 Mbps ≈ 4.2 MB/s** | 极快 ⇒ 作中转最划算 |
| 47 → 106(直连) | ~22 KB/s(历史实测) | 与单流同量级 |
⇒ **选定路径:47 →(8 路并行拉)→ 本机 →(4.2 MB/s)→ 106**。
⚠️ **两个实测坑**:① 106 → 47 的 scp **并发超 ~10 会被 47 的 sshd 拒绝**(`Connection closed`);② 即便降到 6 路,**部分分片仍会静默中断**(只写一半)⇒ 接力脚本**必须逐片比对大小并重拉**(`_relay2.sh`:第 1 轮补 5 片,第 2 轮归零)。
### 10.2 实际搬运量(全部经本机中转 + 逐片校验)
| 项 | 原始 | 压缩后 | 说明 |
|---|---|---|---|
| 用户数据(46.3 MB 口径) | 46.3 MB | 30.1 MB / 15 片 | 与 47 对账**真实缺口 = 0** |
| dsh-runtime(python3.12.14 + jq + rg) | 110 MB | 39.1 MB / 10 片 | ffmpeg/ffprobe(344 MB)暂不传 |
| profile node_modules(含 .pnpm 实体) | 362.7 MB | 89.6 MB / 11 片 | 压缩比 4x |
| business-plugins(3 个 tgz) | 44 MB | 44.4 MB / 6 片 | file-preview + mcn-suite + univer |
| bundled-skills | 12 KB | — | 直连管道 |
| **合计** | — | **≈ 203 MB** | 单流口径需 ~3.5 小时 |
### 10.3 106 侧「本地重建」(不占 47 带宽,与搬运并行)
- **dsh 主程序**:`npm i -g @deepseek-ai/[email protected]` → `/usr/lib/node_modules/@deepseek-ai/dsh`(295 MB,与 47 同版本);并补 `/usr/local/bin/dsh` 符号链接(平台默认以 `dsh` 命令解析)。
- **MCN 的 Python venv**:按 `mcn-req-no4.txt`(47 包)在 106 重建 ⇒ 远优于传 819 MB;`pandas/jieba/matplotlib/bs4` 导入通过。
- **whitelist-cache 不传**:只被 Manager 侧 `src/web/routes/whitelist.ts` 读取,106 是 worker。
- **/var/lib/dshs 顶层对齐**:`bundled-skills` ✅ / `business-plugins` ✅ / `secret.key` 已有 ✅ / `dshs.db` 不传(集群下权威库是 47 的 PG)。
### 10.4 途中发现的三个「实例起不来」级坑(均已修)
1. 🔴 **106 上 `/var/lib/dshs/users` 是 `drwx------`**(47 是 `drwx--x--x`)⇒ `setpriv --reuid 100002` **无法穿越该路径** ⇒ 实例必崩。已 `chmod 711`;`/var/lib/dshs` 755 → 711(收窄,对齐 47)。
2. 🔴 **106 上没有 dsh 主程序**(`/usr/local/bin/dsh` 不存在)⇒ 已装同版本 + 建链接。
3. 🔴 **106 上没有 `/usr/local/dsh-runtime`**(python 3.12.14 / jq / rg 全缺)⇒ 已传。
### 10.5 迁移结果(已验收)
- `POST /api/admin/users/<guest>/dsh/migrate {"targetHost":"w-106"}` ⇒ `{"ok":true,"from":"w-47","to":"w-106","epoch":4,"port":44155}`
- DB:`host_id=w-106`、`epoch=4`(+1)、`folder=/var/lib/dshs/users/4092b965-…/ws` ✔
- 106:`dsh-100002-43771a07.scope` **running**、端口 44155 监听、实例 HTTP **401**(存活)
- 47:guest 的 scope 已消失(只剩 admin 的)
- 插件:`business-plugins 0.3.23` / `portal-entry 0.5.5` 在位,特征串命中,实例日志 0 error
### 10.6 归档与清理
- 源目录归档:`/opt/dsh/backups/migrated/guest-4092b965-20260916.tar.gz`(≈1.1 G,含 trash)⇒ 验证后删除 47 上的源目录。
- 临时物清理:47/106 的 `/tmp/{gmig,rt,nm,bp}*` 已清;106 的 `/root/*.sh` 已清;本机的分片目录移入 `_中间产物_待清理/`。
- 可复用脚本(在 `_中间产物_待清理/`):`_relay2.sh`(带校验重试的接力搬运)· `_relay.sh`(无校验版)· `_mcn-venv-106.sh`(远端重建 venv)· `_gmig-pull.sh`(初版并行拉取)。
---
## 11. 遗留(需另立排期)
1. ~~106 仍缺 ffmpeg / ffprobe~~ ⇒ **2026-09-16 00:2x 已解决**:106 上直接 `dnf install -y ffmpeg jq ripgrep`(走**内网源** `mirrors.tencentyun.com`,**43 MB/s、几十秒**),再符号链接进 `/usr/local/dsh-runtime/bin/`,并在 bwrap 沙箱内实测可执行(ffmpeg 7.0.2)。
⚠️ **教训(重要)**:**先测目标机自己的下载能力,别默认"只能从 47 搬"** —— 106 的 dnf 内网源极快,从 47 搬 344 MB(~40 分钟)纯属绕路。
2. **106 仍无账号 provisioner**(`dsh-provision.path` 只在 47)⇒ 新用户被调度到 w-106 时**无人建 OS 账号**。本次 guest 的账号是手工 `useradd -u 100002` 建的。
3. **MCN 的 4 个重包未装**(playwright / ctranslate2 / onnxruntime / av,≈443 MB):按既定决定暂不重建。
4. **存量用户仍是各自一份 venv / node_modules**:本方案只把 guest 迁完,共享化(§2 的三类落点)尚未实施。
5. **106 上的遗留目录**:`41a9480e-…`(uid 100008,8.9 M,档案 101 的一次性用户残留,PG 行已删)与两个 0 字节目录(`2ade6411` / `5f4a51d2`)。
@@ -0,0 +1,315 @@
# 方案规划方法 —— 从覆盖网络线提炼(2026-09-16)
> **它是什么**:一套「从零推演出一个可落地架构」的分段作业法。五段 = **信息收集 → 方案调研 → 场景梳理 → 逻辑验证 → 应用推演**,每段有固定骨架、固定产出、固定出口判据,全部走完才允许进入执行。
> **它不是凭空设计的** —— 是从覆盖网络线 **17 份文档 + 6 份勘误 + 1 次落地执行** 的真实过程里**反推**出来的(§9 有 8 次纠错作证)。
> **适用边界**:信息不全、没有现成答案、要一次拍准方向的重规划任务。**小改动不需要这么重**(那种直接按 `dsh-change-workflow` 六阶段走即可)。
> **不适用范围**:已有明确技术答案的实现任务;单文件改动;纯 UI 调整。
---
## 0. 一句话总览
**把"一个大到没法定的事"拆成五段流水线;每段只回答一个问题,出口必须能给"这步做完了吗"的判据。**
```
① 信息收集 该知道的数,都有出处了吗? → 参数表(实测 / 业内 / 推算 三档标注)
② 方案调研 别人踩过的坑,照抄了吗? → 参考方案对照 + 分层架构
③ 场景梳理 要扛的事,列全了吗?口径对了吗? → 场景清单(含故障)+ 完成度表
④ 逻辑验证 每条推得通吗?有跳跃/矛盾吗? → 缺口清单 P0/P1/P2 + 勘误 + 反模式
⑤ 应用推演 叠加起来先炸哪个?能算吗? → 流量预算总表 + 瓶颈先炸顺序
────────────────────────────────────────────────────────
出口闸门 估值换成实测 + 参数表固化 + 权限/成本评估 → 交接单(方可开工)
```
**五段的顺序不可交换**:跳过 ③④ 直接做 ⑤,会得到"数字很漂亮但前提是错的";跳过 ①② 直接做 ⑤,会得到"自己发明的一套轮子"。
---
## 1. 第一段 · 信息收集
**只回答一个问题**:我要用的每个数、每条结论,**出处是什么、可信到什么程度**。
### 要做的四件事
| # | 动作 | 判据 |
|---|---|---|
| 1 | **范围边界前置** | 文档开头写死「只做什么 / 不考虑什么」,并注明这是**谁的口径**(用户原话 / 自定) |
| 2 | **找对照组(能力标尺)** | 每个能力域找到**真实在跑的产品**作标尺,不允许"我觉得" |
| 3 | **数据分三档标注** | **实测**(本环境有命令/证据)|**业内口径**(有出处,非本环境)|**推算**(自己算的)——⛔ 三档不许混写 |
| 4 | **先标"最该先测的三项"** | 在收集阶段就点出"哪几个数一换、全部结论的可信度就变" |
### 覆盖网络线的实例
- 范围声明(用户原话):「**方案只考虑技术实现,跨境数据合规是谁用谁自己考虑**」⇒ 此后所有文档统一带 🟢 范围声明,且**选型不为合规让路**。
- 对照组:Tailscale / headscale / ZeroTier / Nebula / libp2p / BitTorrent / Slack / Telegram / WhatsApp / Discord / SCCM / restic。
- 三档标注实例:首屏 `10.8 MB`、跨云 `22 KB/s`、心跳 `20 s` = **实测**;打洞率 90%/40%/10% = **估算**;presence `16,700 次/秒` = **推算**。
### 常见失手
⛔ 把业内口径当自家实测(会导致后续所有容量结论虚高)|⛔ 只收"支持我结论"的资料|⛔ 范围边界写成事后补充。
---
## 2. 第二段 · 方案调研
**只回答一个问题**:这套东西**别人已经做过吗、踩过什么坑**——目标是**照抄**,不是发明。
### 核心原则(一句话)
> **关键是照抄它们踩过的坑,而不是自己发明。**(覆盖网络线原文口径)
### 要做的三件事
1. **参考方案对照表**:`能力域 → 参考方案 → 借什么`。**"借什么"必须写具体**(不是"参考 Tailscale",而是"借它的控制面/数据面分离 + DERP 区域模型")。
2. **默认参数直接固化**:成熟项目的默认值**不要自己拍**(libp2p 的拨号并发 ≤4 / 总 100 / 超时 30 s、连接水位 100/400/1 min、中继自荐 15 min/30 min TTL、BitTorrent 上传并发 4 等)⇒ 直接进 §1 的参数表。
3. **分层**:先给架构分层(覆盖网络线 = 控制面 / 会合层 / 骨干中继层 / 数据面 / 观测层**五层**),**再谈组件**。分层定了,后面"这块放哪"才有判据。
### 先拆概念(这一步经常被跳过,然后全盘皆错)
覆盖网络线的实例:「插件」这个项目里**有两个完全不同的含义** —— (a) dsh 官方插件(跑在实例内部)**够不到宿主网络栈**;(b) 平台自身的模块化。**没先拆这两个概念,就会在错的对象上讨论"插件 vs 改代码"**。
⇒ **判据**:出现一个术语可能指两件事时,**先列表拆开**再往下走。
### 三层归属(普适判据,可直接复用)
| 层 | 内容 | 形态 | 判据 |
|---|---|---|---|
| 数据面组件 | 要碰宿主网络栈、要独立扩缩容与加固 | **独立进程 / 独立 systemd 单元** | 与主进程**同生共死**就出局 |
| 平台侧集成 | 寻址、身份、资格签发、容量准入 | **改平台代码** | **必须与权威状态同源**(外置 = 第二个权威源 = 脑裂) |
| 实例内展示与工具 | 「我的 XX」面板、实例内工具 | **插件** | 纯实例内 UI/工具才配插件 |
---
## 3. 第三段 · 场景梳理
**只回答一个问题**:这东西**被用在什么场景、要扛多大**——且**先确认口径对不对**。
### 要做的五件事
| # | 动作 | 说明 |
|---|---|---|
| 1 | **口径先校正** | 目标场景必须由用户确认过;文档里显式写 `📌 口径校正(日期,谁纠正)` |
| 2 | **设备/环境画像** | 按类列表(不是平均值):覆盖网络线用 6 类(云服务器 / 家宽 / CGNAT / 移动网 / 企业网 / VPN)× 台数 × 成功率 |
| 3 | **场景枚举含故障** | **正常场景 + 故障场景 + 极端场景**三类都要有(S1–S11 含"中继故障 / 控制面重启 / 区域突变 / NAT 表溢出") |
| 4 | **分工矩阵** | `环境类型 × 应用 → 关键约束`(一眼看出"哪类节点必然走中继") |
| 5 | **完成度表** | **纸面推演 / 可运行模拟 / 实测** 三档分开标 ——⛔ 不许把"纸面算过"说成"验证过" |
### 关键动作:把口径纠正当成产出
覆盖网络线真实发生过一次重排:用户明确目标场景 = **① 多人 + agent 对话 ② MUD / MMORPG 网游 ③ 以上述应用为负载的 1000 台异构网络互联** ⇒ 原报告把"跨机访问实例/文件交换"排第 1 档**属次级用途,已重排**。
⇒ **教训**:场景优先级排错,后面全部推演**都在给次要场景做容量**。**这一步上抛是值得的**(属"业务目标与优先级",是边界外真门禁)。
### 常见失手
⛔ 只列正常场景(故障场景才是容量规划的主体)|⛔ 用"平均设备"代替异构画像(掩盖了 CGNAT/移动网这类**必走中继**的少数派)|⛔ 不标注"哪些是纸面"(下游会把推演当事实)。
---
## 4. 第四段 · 逻辑验证(**本方法的核心**)
**只回答一个问题**:从场景到结论,**每一步都站得住吗**。
### 4.1 固定四段骨架(**每条问题都按这个写,不例外**)
```
① 问题 —— 一句话说清缺什么/错在哪
② 查到什么资料 —— 业界范式 + 出处(能照抄就给照抄点)
③ 推演 —— 拿资料 + 我方实际约束,推出来
④ 结论 —— 能不能解 / 怎么解 / 代价是什么(三问必答)
```
> 这个骨架的价值:**它把"我觉得"逼成"资料说什么 + 我们实际是什么 + 推出什么"**。任何一条卡在②或③,说明还不到下结论的时候。
### 4.2 四类必须主动去找的"洞"(覆盖网络线全部命中)
| 类型 | 长什么样 | 覆盖网络线的实例 |
|---|---|---|
| **缺口** | 方案里有个位置**空着**,但下游已经依赖它 | "单房间上限 = min(扇出预算, presence 预算, **agent 预算**)",而 **agent 预算从未标定** ⇒ 三个预算里一个是空的 |
| **逻辑跳跃** | 结论 A 需要前提 B,而 **B 从没被论证过** | S3 算出"服放家宽 ⇒ 600 Mbps 过中继",但**玩家是外部客户端、不是网络成员,凭什么走我们的中继**⇒ 由此发现**缺一整个"发布层"**,且瓶颈性质变了 |
| **自相矛盾** | 方案内部两条约束互斥 | S5 设"1000 人房聚集 200 个 agent",而自家硬约束是"房间 agent ≤ 人数/10 = 100" |
| **事故级预判** | 不提前处理**必然**出事,且事后极难排查 | 设备池里 200 台 CGNAT + 150 台移动网**本机就在 `100.64.0.0/10` 内**;若覆盖网也用这段 ⇒ 路由黑洞、**"部分节点时通时不通",极难排查** ⇒ 主寻址必须走 IPv6 ULA + IPv4 冲突检测进首版 |
⇒ **这四类不许靠"通读一遍觉得没问题"**,要**逐条对照**:每个结论问"它的前提被论证过吗",每条约束问"它和另一条冲突吗"。
### 4.3 实测证伪 —— 纸面推演不算数
**任何"机制类的设计"在落地前,必须有一次最小实测**:
> 实例:方案 S3 原设计用"回环别名"(`ssh -R 127.0.0.2:…`)隔离中继落点。**实测**:47 的 `sshd -T` ⇒ `gatewayports no` ⇒ **`127.0.0.2` 被静默改写成 `127.0.0.1`,ssh 侧零报错**(`ExitOnForwardFailure` 未触发、日志为空)⇒ **机制作废,改用"实例端口区间隔离"**。
> ⚠️ **要点:失败是静默的** —— 如果只做纸面推演,这套机制会带着"看起来对"的样子进入生产。
**实测的副产品往往是真 bug**:顺着这条线查出 `findFreePort()` 各 worker 各自随机 + `tunnel.forward()` 返回值**两处被忽略** ⇒ 撞号时 `-R` 失败但**静默** ⇒ Manager 照旧拨 `127.0.0.1:<port>` ⇒ **静默打到另一个用户的实例**。这条 **两台机器即可触发**。
### 4.4 前人结论,先做最小取证
> **实证**:同一天踩两次 —— ① 方案写"`proxy.ts:109` 是中继连接目标",实为**发给上游 dsh 的 `Host` 头**(改它 = 全量 `/api` 403);② 说"有 3 处基础层反向依赖",实为**入口层的合法依赖、真实违规 0 处**。
> ⇒ **拿上一份文档的结论当既成事实,是这类规划最主要的错误来源。** 引用前必须复核(连行号都会漂:`138-139` 实测为 `136-137`)。
### 4.5 勘误要集中成节,且写清"照字面做会出什么事"
| 要求 | 说明 |
|---|---|
| 位置 | 集中一节(覆盖网络线 = 方案 §8/§9 + 交接单 §0.2),⛔ 不散落在正文 |
| 分级 | 🔴 实测证伪(改变做法)|⚠️ 需修正(不改做法)|📌 结论不变仅行号漂移 |
| 必写 | **"照原方案字面做会白做/会出事"** 一句 —— 否则执行会话会照抄 |
| 复验 | 下一轮对上一轮勘误**再确认一次**(覆盖网络线 §9.7 复验了 §8 的三条) |
### 4.6 反模式清单(同步产出)
验证过程中识别出的"踩了就出事"的做法,**汇总成表**(覆盖网络线 12 条:建全互联 / 无扇出限制的 gossip / 客户端持有权威状态 / 控制面下发全网名单 / 单中心中继 / 把网内副本当主备份 / 慢链路做备份 / 失败即重试 / agent 直接触发 agent / 推送式分发 / 共享密钥当身份 / 只按 IPv4 设计)。⇒ **这张表是给执行会话的红线,不是给自己看的笔记。**
---
## 5. 第五段 · 应用推演
**只回答一个问题**:这些场景**叠加**起来,**先炸哪个、多大、怎么治**。
### 5.1 三条层层递进的动作
**① 逐场景独立算** —— 每场景给"数值 + 判读",**判读比数值重要**(例:`presence ≈16,700 次/秒`,判读 = "**presence 比消息早爆一个量级**")。
**② 流量预算总表(全案最重要的一张表)** —— 列:`流量 × 频率 × 单次 × N 台放大 → 上限手段`。要求**可复算**(每个数从哪来能追)。
**③ 叠加推演(此前最容易漏的一步)** —— 串行列举 ≠ 最坏情况。方法三步:
| 步骤 | 做法 |
|---|---|
| **时间轴重叠检查** | 把各场景的时间窗排开(游戏高峰 20:00–22:00 / 群聊 20:00–23:00 / agent 随真人 / 备份 02:00)⇒ **重叠的才是要叠加的**;顺带确认"备份与游戏错峰"是**设计而非巧合** |
| **共享资源争用矩阵** | 列出被多方抢的**同一个资源**(出口带宽 / presence 通道 / 控制面 req/s / 客户端上行)⇒ 叠加后量级 |
| **主导项法** | **差一个数量级可忽略** ⇒ 只保留主导项,其余进"次要" |
**叠加的产出是"瓶颈排序会变"**:覆盖网络线叠加后,第一瓶颈仍是出口带宽,但**第二从「presence」变成「presence × agent 叠加」(≈33,400 事件/秒,是任何单场景的两倍)**。
### 5.2 瓶颈排序 = 按"先炸顺序"排,不是按"大小"排
产出一张 `序 / 瓶颈 / 量级 / 主要处置` 表。**这张表就是落地顺序的输入**(再按"收益 ÷ 成本"重排一次 ⇒ 覆盖网络线的结论是"**只做三件事**")。
### 5.3 数字可信度必须分级(**这条决定了整套推演的诚实度**)
> 覆盖网络线的原话口径:**"关键结论对比例敏感、对绝对值不敏感"** ⇒ **结构判断可信,具体容量数字还不可信**。
⇒ 任何一份推演都要写清:**哪些结论是"结构可信"(换个数也成立)、哪些是"数字可信"(已实测)**。⛔ 不许把两者混在一句话里。
### 5.4 收尾三件套(每份推演都必须有)
1. **未验证项表**(标题就写"**勿当结论**")—— 所有不能证实的,统一放这里;
2. **本次未做(范围声明)** —— 明确边界,避免下游误读;
3. **上抛项收敛** —— 写"**未新增上抛项**"或列出新增项;**能排出优劣的自己拍掉**。
---
## 6. 七条横切纪律(贯穿五段)
| # | 纪律 | 判据 / 反例 |
|---|---|---|
| 1 | **先只读,后动手** | 每份规划文档头部写明「⛔ 未改代码、未动服务器、未写文档库」;改任何文件前先抢执行锁 |
| 2 | **行号级引用** | 结论引用到 `文件:行`;引用前复核(行号会漂) |
| 3 | **三档数据标注** | 实测 / 业内 / 推算,三档不许混写 |
| 4 | **口径校正显式化** | 用户纠正过的地方,文档里留 `📌 口径校正` 标记,⛔ 不静默改写 |
| 5 | **上抛收敛** | 一轮只问"**真取舍**"(各有优有劣);只有优点或只有缺点的**自己拍**;**每个候选必须写优点 + 缺点,竖排成段** |
| 6 | **一条线一个入口** | 新会话只读"接续入口",不读全量日志(覆盖网络线:入口 3 KB,日志 60 KB) |
| 7 | **文档间标"承接"** | 每份文档头写"承接 X"⇒ 形成 DAG,避免重复造轮子、也避免下游读到已作废的结论 |
---
## 7. 出口闸门:什么时候可以从"推演"走到"落地"
**五段全绿不等于可以开工**。覆盖网络线把卡点收敛成**三件必须先办的事**:
| # | 闸门 | 判据 | 覆盖网络线的实况 |
|---|---|---|---|
| 1 | **把关键估值换成实测** | 最少 3–5 台真机;只测那 3 个"一换全变"的数 | 选定 **打洞成功率 / 中继 jitter / 真实可用带宽** —— ⚠️ 直到 09-16 仍未做,故容量数字仍标"不可信" |
| 2 | **参数表固化** | 所有输入参数在一张表里 ⇒ **可复算、可仿真** | 已给(B3 表),⏳ 待落文档 |
| 3 | **权限影响评估 + 成本承诺** | 扩权(R5)与花钱(边界外)**必须上抛** | 三处扩权:虚拟网卡驱动 / 骨干开端口 / **发布层对外暴露**;成本 = 发布层 + 会合 + 中继 |
**另一条独立闸门:执行前置** —— 落地前必须先把"**挡住整个方向的那一个耦合点**"拆掉。覆盖网络线的判断是:**中继绑在 Manager 上 ⇒ 多区域 / 多中心 / 骨干层全部做不了**,所以 S0–S4 排在任何功能开工之前。
> ⛔ **反模式**:一上来换协议(WireGuard / TURN)。正确顺序是"**先把耦合抽成配置与接口**,让现有实现退化成'第一个可替换实现'"⇒ 每一步独立可验、独立可回滚。
---
## 8. 落地形态:规划 → 交接单 → 执行 → 勘误
规划会话**只产出交接单**,执行会话**不读规划上下文**。交接单必含(覆盖网络线的单子模板):
| # | 段 | 要求 |
|---|---|---|
| 1 | **§0 复核结论 + 勘误** | 实测基线表 + 逐条勘误(🔴 会白做/会出事 的排最前) |
| 2 | **§1 只读前置** | 抢锁 / 读哪几节 / 照基线逐项复核 / 远程入口 |
| 3 | **§2 范围** | **做**什么、⛔ **不做**什么(把勘误结论写成禁令) |
| 4 | **§3 步骤** | 每步:改哪些文件 / 接口怎么切 / **怎么验** / **怎么回滚** —— **四件缺一不可** |
| 5 | **§4–5 全局验收 + 清理** | 用户口径的业务验收 + **测试残留必须清**(有副作用的验证脚本要写明) |
| 6 | **§6 回报格式** | 判定 → 改了什么 + 部署到哪 → 实测输出 → 未做/风险 → 锁状态。⛔ **不许写"应该没问题"** |
| 7 | **§7 已知的坑** | 省执行方踩一遍(凭据、配置位置、端口、环境限制) |
**执行完必须回写**:执行记录(P1/P2/P3 实测证据)+ 新勘误 + 未做项**为什么没做**(命中哪条红线)。
---
## 9. 方法自证:这条线上真实发生的 8 次纠错
| # | 纠错 | 由哪一段抓住 |
|---|---|---|
| 1 | **场景优先级排错** —— 把"跨机访问实例/文件交换"排第 1 档(实为次级) | ③ 场景梳理(口径校正) |
| 2 | **缺一整个"发布层"** —— 玩家是外部客户端,凭什么走中继 | ④ 逻辑验证(逻辑跳跃) |
| 3 | **自相矛盾** —— 200 个 agent 违反自家"≤人数/10" | ④ 逻辑验证(矛盾检出) |
| 4 | **回环别名机制作废** —— sshd `gatewayports no` **静默改写**绑定地址 | ④ 逻辑验证(实测证伪) |
| 5 | **地址段冲突** —— 设备池本机就在 `100.64.0.0/10` 内 | ④ 逻辑验证(事故级预判) |
| 6 | **真 bug:静默拨到别人实例** —— 端口撞号 + 返回值被忽略 | ④ 逻辑验证(实测副产品) |
| 7 | **前两条结论勘误** —— `proxy.ts:109` 是 `Host` 头;"3 处反向依赖"实为 0 处 | ④ 逻辑验证(最小取证) |
| 8 | **部署面漏了 47** —— 方案与交接单 v1 都只写了 106 | 执行阶段回写(**规划也要复核部署面**) |
⇒ **8 次里有 5 次发生在第四段**。这就是为什么第四段值得单独成段、且必须用固定骨架逐条走。
---
## 10. 可抄模板
### 10.1 文档头(三行定性质)
```
> 日期:YYYY-MM-DD | 性质:**只读推演稿 / 调研稿 / 设计稿 / 复盘稿**(⛔ 未改代码、未动服务器、未写文档库)
> 承接:<上一份文档>
> 🟢 范围:只考虑技术实现;<明确不做什么>
> 用户要求:<用户原话或口径来源>
```
### 10.2 条目骨架(第四段核心)
```
### X-n · <问题名>
**① 问题**:<一句话>
**② 资料(<范式名>)**:<可照抄的点 + 出处>
**③ 推演**:<我方实际约束 + 推出的结构>
**④ 结论**:✅/⚠️/❌ <能不能解> | 怎么解 | **代价**(缺一个不算结论)
```
### 10.3 文档尾(三件套)
```
## 未验证项(勿当结论)
| # | 项 | 状态 |
## 本次未做
- ⛔ 未改代码 / 未动服务器 / 未写文档库(原因:执行锁被 X 占用)
## 上抛
- 未新增上抛项 | 或:<项> —— A:优点/缺点 | B:优点/缺点 | 倾向
```
### 10.4 判定分级(全流程统一用词)
`✅ 已完成并验收` | `⏳ 待授权/待做` | `⚠️ 缺口/需修正` | `🔴 实测证伪/事故级` | `⛔ 禁令/反模式`
---
## 11. 已知局限
| # | 局限 | 说明 |
|---|---|---|
| 1 | **样本单一** | 方法从"覆盖网络"一条线反推 ⇒ 对"信息不全、要从零拍架构"的任务适配最好;对实现型任务偏重 |
| 2 | **第四段的产出高度依赖实测能力** | 没有真机/真环境时,只能停在"结构可信";本线的容量数字至今仍是估值 |
| 3 | **成本** | 全程 17 份文档 + 多次实测;「只做三件事」的收敛是必要的,否则规划本身会失控 |
| 4 | **未验证** | 本方法尚未在第二条线上复用过 ⇒ 尚属"一次成功案例反推",不是已验证的通用流程 |
@@ -0,0 +1,60 @@
# 文档无效信息审计报告(2026-09-16)
> **判据** = `dsh-knowledge-upkeep §8`(六类无效信息 + 五条"不能删"的红线)
> **范围** = `.workbuddy/memory/*.md`(31 份 / 1,325 KB)|技能 `*.md`(12+ 份 / 348 KB)|工作区根方案文档(23 份 / 964 KB)⇒ 合计 **66 份 / 2,643 KB**
> **方法** = 两个只读脚本机械扫描 + 人工复核(脚本在 `_中间产物_待清理/auto-continue-20260916/`,可复跑)
> **性质** = 一次性产物;读完可归档到 `_中间产物_待清理/`,⛔ 别让它自己也变成"根目录堆积"
## 一、判定
✅ **整体健康。无效信息不弥散,只集中在 2 处。**
| §8 六类检查项 | 实测结果 |
|---|---|
| ③ 同一事实多处重复 | memory **17.5 KB / 1.3%**|技能 0.3%|根方案 **0.0%** ⇒ **无大段重复** |
| ⑥ 只写"给人看的套话" | **0 处** |
| ④ 过程流水挤掉结论 | 日志类 0.5–0.67 **属正常**(日志本就是流水);正式文档无异常 |
| ⑤ 中间产物混进正式文档 | 机械命中 45 处 → **人工复核真命中 0** |
| ① 与可执行体不符 / ② 过期结论占"生效位" | 见 §二(1 处真实问题) |
| 悬空引用(辅助项) | 机械命中 30 个 → **真命中 0** |
> ⚠️ **方法学如实说明**:第 ⑤ 类与悬空引用的机械检测器**噪声很高** —— 45 处命中全是"文档把 `_tmp_*` 当反例讲"或"点名真实的 `_verify_tsc.mjs`",30 个悬空引用全是 `package.json`/`SKILL.md` 这类裸文件名。**六类里真正能机械判定的只有 ③ 重复、④ 流水占比、⑥ 套话**;⑤ 与引用类必须人工复核。⇒ 这类审计的价值在「定量 + 复核」,不在全自动。
## 二、真实问题(按影响排序)
**1. 工作区根 `_中间产物_待清理/sess-forensics-20260916/会话脉络_ddea70b7_20260916.md` = 589.6 KB,占根方案文档 61%**
- 类别:§8.2 第 ④/⑤ 类(过程流水 + 取证中间产物混进正式区)
- 判据(§8 唯一判据:去掉它,下一个会话会不会做错事/变慢?):内容是「AI 为何上抛」的**会话转录摘录**,其结论已被三处吸收 —— `会话复盘_AI为何上抛_20260916.html`、`.workbuddy/memory/2026-09-16.md`、两个技能的修订 ⇒ **去掉不会导致做错事**
- **已处置(2026-09-16 修复轮)**:移入 `_中间产物_待清理/sess-forensics-20260916/`,并同步修正 3 处引用(审计报告 / 简报 / 会话复盘 HTML);历史日志中的路径按 append-only 原则不改
- **效果**:工作区根 `.md` 由 **23 份 / 964.1 KB → 22 份 / 375.0 KB(-61%)**
**2. 每轮注入层:用户级 `E:\ProgramData\.workbuddy\MEMORY.md` 被截断(已修)**
- 实测:20,977 B / 11,712 chars;宿主 **注入上限 ≈ 4,000 chars**
- 原排序的后果:注入窗口只覆盖「钩子配置 + 环境路径」——**一条行为规则都没进去**(`Preferences` 整节在窗口外)
- 类别:这是 §8.2 第 ② 类的**变体** —— 内容没错,**排序错**;而"排在窗口外"在效果上等于"这条规则不存在"
- **已修**:分层重排(**零删除**)+ 文件头加**排序契约**(⛔ 新增内容按序插入对应小节,别追加到末尾)
## 三、"不能删"红线的分布(供后续清理避让)
五条红线(判据与阈值 / 命令原文与路径 / 反例踩坑 / 为什么 / 失效标注)在 66 份里几乎每份都有。
机械兜底已就位:`docs-shrink-guard.py` 基线覆盖 **151 个文件**(行数骤降 >30% 且 >20 行即报警)⇒ 后续任何整编都会被它抓出来。
## 四、建议(⛔ 本轮未做,留待下个会话决定)
1. ✅ **已落地(2026-09-16 修复轮)**:`dsh-knowledge-upkeep` **1.1.0 → 1.2.0**,新增 **§8.6「注入预算」维度** —— 「每轮注入有上限 ⇒ 长文件后半段等于不存在;**先重排、后删减**;文件头必须写排序契约」。md5 `6249caaa…`,**本机 / 文档库 / 镜像三处一致**。同时用户级记忆头部已写入排序契约。
2. **根目录方案文档:⛔ 不搬(结论已在修复轮修正)**。复核后实测:10 份 `覆盖网络_*.md` / 可行性评估在档案 103–112 里的**行覆盖率 98.5–99.0%**(差异 = 一级标题行),另 3 份与文档库 `archive/工作区草案/` 字节完全相同。
⚠️ **但"搬走"是净变差**:这 10 份被 **11 处引用**(含 `接续入口_覆盖网络线_20260916.md`),搬走会大面积断链 ⇒ 改为**加归档指针**(正文以档案 103–112 为准,根副本保留为工作副本)。
🔴 **本报告首版的一处错误(已修)**:首版用「归一化整串包含」判定,得出"档案未包含根文档"的假阴性(因为档案按约定删了 H1 标题行)⇒ 正确判据是**行级覆盖率**,不是整串包含。**这与本报告 §一 记的"检测器噪声"是同一类错误:机械判据必须先验证判据本身。**
3. **日志分片不需要瘦身**:`2026-09-16.md` 已 88 KB、`2026-09-下19.md` 流水占比 0.67 —— 但日志**按需读取、不进每轮上下文**,不构成水位成本。按维护规则,10 月后把 9 月日志按主题蒸馏进 `MEMORY.md` 再删旧片即可。
---
### 附:可复跑命令
```bash
cd "E:/ProgramData/AI技能/aliyun-dsh-server"
PYTHONIOENCODING=utf-8 "<managed python>" "_中间产物_待清理/auto-continue-20260916/audit_docs.py" # 六类机械扫描
PYTHONIOENCODING=utf-8 "<managed python>" "_中间产物_待清理/auto-continue-20260916/audit_pass2.py" # 重复字节定量
```
@@ -0,0 +1,137 @@
# 会话接续机制 · 问题复盘与修复(2026-09-16)
> **结论一行**:机制本身(规范 §3.1.1 / §3.1.2 / §3.2.1)没错,**错在它没有被接上** —— ① 真正跑的那条自动化 prompt 没带"开机四步";② 硬环节的注入文案还停在"请用户开新会话",从不提 `automation_update`;③ 开机第一屏有可能喂过期事实。
> **触发**:用户 2026-09-16 14:41 原话「决策方法 之前建立的机制有问题,看看自动任务新建的会话对话记录」。
> **取证范围**:`~/.workbuddy/projects/e-ProgramData-AI技能-aliyun-dsh-server/{3814f5fb,e265f0cd,478eef8c,d48a9be8}.jsonl` + `workbuddy.db`(`automations` / `automation_runs` / `session_usage`)+ `state.py` 实跑 + `stop-dialog-guard.py` 源码。
---
## 一、今天的自动任务新建会话 —— 花了多少、干了什么
| 会话 | 自动化 | 轮 | 工具调用 | 积分 | 用户当场说了什么 |
|---|---|---|---|---|---|
| `3814f5fb` 归档接续 | `5d1dc22c` @10:20 | 2 | 65 | **21.34** | 「是否知道那个会话创建了**这个你**,是否有执行那个会话待处理的任务」 |
| `e265f0cd` 接续(优化版) | `4a3d815b` @10:45 | 1 | 32 | **8.93** | — |
| `478eef8c` 决策方法-2 | `218e5b11` @11:10 | 5 | 191 | **74.80** | 机制就是在这一条里定的(14 条用户发言) |
| `d48a9be8` S1 落地 | `d30f3cf7` @14:32 | 2 | 40 | **9.37** | 「**是不是应该先确认待执行的任务有哪些 再去执行**,不确定你搞清楚情况没有」 |
| **合计** | | | **328** | **114.44** | |
- 积分口径 = 转录 `rawUsage.credit` 逐次求和;与 `session_usage.credit_json` 三处**完全吻合**(`d48a9be8` 9.37 / `e265f0cd` 8.93 / `b08b1c35` 33.71),两个独立数据源对上 ⇒ 数字可信。
- 用户观察「自动任务新建的会话,提一轮就 7、8 个积分」**成立**:`e265f0cd` 单轮 8.93、`d48a9be8` 首轮 5.28。
- `d30f3cf7` 立项时的验收口径是**与基线 `b08b1c35`(77 次 / 11.17 分)对比,目标 ≤10 次 / ≈1 分**(见 `478eef8c` A72)。**实测 40 次 / 9.37 分 ⇒ 未达标。**
---
## 二、四个真问题(按严重度)
### D1 🔴 生成自动化 prompt 的那一步没接上机制 —— **主因**
规范 §3.2.1 要求 prompt **写死"开机四步 + 工具调用上限"**。实际跑在 `d48a9be8` 上的那条 prompt:
- ✅ 有「本轮只做一件事,做完即停」「取证最多 3 条」(§3.2.1 ④⑤)
- ❌ **没有**接续包路径、**没有**校验命令、**没有**「先跑 `state.py`」
- ❌ 反而把整段 S1 技术细节(≈1.2 KB)抄了进去 —— 而细节的单一来源本是 `交接单_覆盖网络落地执行_20260916.md`
⇒ 结果:新会话手里既没有"从哪接班",也没有"上限是几次",只能自己从零探索。**同一件事被写了两遍(prompt 与交接单),真源被架空。**
### D2 🔴 开机顺序反了 —— 用户看到的就是这一步
`d48a9be8` 的 40 次调用里:
- `[01]` 读 automation memory(不存在,首次运行)
- `[02]–[15]` 读交接单、读架构文档、抢锁、**10 次连续读码探索**
- `[16]–[36]` 改码 → build/test → scp → 重启 → 验收 → 写记忆 → 清临时文件
- **`[37]` 才第一次跑 `state.py`**;**`[39]` 才第一次读 `交接单/README.md §一`(待执行清单)**
⇒ 机制设计的第 0 步(1 次调用代替十几轮探索)被排到了**倒数第 4 步**。用户当场质问「是不是应该先确认待执行的任务有哪些再去执行」,AI 也在下一轮自认「顺序错了」。
⚠️ 补充:`state.py` **确实**能给出答案 —— 它的 `[入口]` 段 14:43 实跑就写着"3) ✅ S0 已完成 …… 5) 下一步 = 按交接单执行 S1"。**跑对了就不会有这一问。**
### D3 🔴 硬环节的注入文案停在旧版 —— 自动接续没有触发源
`scripts/stop-dialog-guard.py` 三级(≥30 万)注入原文(修复前):
```
③ 然后明确告知用户「请开新会话,接续点在 X」,**由用户开**(钩子无法自动创建会话)。
```
而规范 §3.2.2 画的链路里,第三段是 **[软] 模型调 `automation_update` 登记一次性任务(+2 分钟)**。
⇒ 钩子**从不要求**模型登记自动化 ⇒ 这条"软"环节连提示都没有,全凭模型自觉。今天 4 条自动化全是会话内**临场手写** prompt 的结果 —— 这正是 D1 的来源。
### D4 🟡 开机第一屏可能喂**已被推翻**的事实
`state.py` 的 `[收口]` 段是"今日日志原文摘录",机械取**最后一个含「接续/收口」的章节**。实测 14:43 输出里带着两条**当天已被勘误**的说法:
- 「两个『定时』自动化仍在按钟点烧钱」—— 实为 09-12 / 09-13 已软删除、早已停摆;
- 「转录里的 `rawUsage` 字段为空 `{}`」—— 实为有值(本次积分就是这么算出来的)。
另外 `state.py` 的 `[入口]` 文件名**写死**为覆盖网络线那一份 ⇒ 换工作线后会**静默展示旧线的待办**。
### D5 🟢 一次性 automation 的 `memory.md` 是死重量
宿主系统提示强制"先读 `automation memory.md`、收尾写回"。但一次性任务只跑一次 ⇒ 首轮**必然读不到**(`d48a9be8 [01]` 就是白跑一次),写回的那份**永不再被读**。已知设计面,无法改宿主,只能靠 prompt 一句"该文件不存在属正常"省掉一次调用。
---
## 三、已落地的修复(本轮,全部在我们自己的资源上,可推翻)
| # | 文件 | 改了什么 |
|---|---|---|
| F1 | `会话接续规范_20260916.md §3.1.2` | 新增**第 0 步**:先跑 `state.py`(1 次调用拿到 [锁]/[git]/[入口=待办+接续包位置]/[收口])⇒ 回答"我该接谁的班" |
| F2 | `会话接续规范_20260916.md §3.2` | 新增硬约束 **「⛔ prompt 里不许复制任务细节」**(会造第二漂移源 + 挤掉开机四步),附 `d48a9be8` 实测 |
| F3 | `会话接续规范_20260916.md §3.2.1` | 模板首行加 `⓪ 先跑 state.py` |
| F4 | `dsh-server-docs/scripts/stop-dialog-guard.py` | 三级注入 ③④ 改为:**登记一次性 automation(照 §3.2.1 模板)→ 做不到才让用户开** |
| F5 | `state.py` | ① `[收口]` 加"日志原文摘录、可能已被推翻、以 MEMORY.md 状态层为准"护栏;② `[入口]` 改为**自动取最新的 `接续入口_*.md`**,不再写死 |
✅ 验证:`stop-dialog-guard.py` `py_compile` 通过、新文案渲染正确;`state.py` 实跑通过(护栏行已出现在过期说法之前,`[入口]` 动态解析正常)。
---
## 四、重测结果(14:55 一次性自动化,**已跑完,达标**)
| 口径 | 失败轮 `d48a9be8`(14:32) | 重测 `d5398c7d`(14:55) |
|---|---|---|
| 工具调用 | **40 次** | **6 次** ✅(预算 ≤8、验收线 ≤10) |
| 积分 | **9.37** | **1.69** ⚠️(目标"≈1 分"未完全达到,但比失败轮省 **82%**) |
| 是否先跑 `state.py` | 第 **37** 次调用才跑 | **第 2 次**(第 1 次是宿主强制的 automation memory,文件不存在) |
| 是否先确认待执行清单 | 第 39 次才读到 | **首屏即由 `state.py` 的 `[入口]` 段给出**,并复述了"未完成/下一步" |
重测会话自报的两处可再省:① 用带 emoji 的完整标题串 grep 未命中、要用子串再 `tail`(多 1 次);② ⓪ 与 ① 是同一命令,本可合并(多 1 次)⇒ **理想路径 4 次**。
⇒ **机制已闭环**:改动只落"接不下班"这一侧,效果可归因。剩余可选项见 §四-2。
---
## 五、还没做 / 需要条件的
1. **prompt 生成仍未强约束**:现在靠"模型记得照模板写"。要彻底硬起来,需要一个 `gen-continuation-prompt.py <接续包>` 生成器 + 钩子文案里写死"照它的输出原文"(本轮未做,属新造工具)。
2. **`state.py` 的 `[入口]` 只覆盖"最新一份接续入口"**,多条工作线并行时仍会漏(当前只有一条线,够用)。
3. **D5 无法从我们这侧解决**(宿主行为),已记录,不列为待办。
4. **`state.py` 的 `[收口]` 护栏只是"提醒",不是"过滤"** —— 更彻底的做法是让它只摘"判据/结论"行、或与 `MEMORY.md` 状态层比对;本轮先用最低成本方式止血。
---
## 六、多线并行会不会冲突(2026-09-16 15:2x · 用户提问)
> 用户原话:「**假如多个会话都要新建会话,新会话全都执行这个口令吗,会不会冲突**」
**会 —— 三种形态,真正会咬人的是 ①。**
| # | 形态 | 机制现状 | 处置 |
|---|---|---|---|
| ① | **串线** —— 口令不带线名,而 `state.py [入口]` 原只取"最新一份接续入口" ⇒ 多个新会话都跑**同一条线**,另一条线没人跑 | 🔴 **真会发生**(今天只有一条线,属潜伏) | 口令**必带线名**;`[入口]` 已改为**列全各线** |
| ② | **抢锁** —— 同时动手只有一个抢到,输家"停手"白烧一轮 | ✅ 全局锁兜底,**不会同时改** | **读前置可并行**(都只读);输家**只报告** |
| ③ | **共享文件互覆** —— 日志 / `MEMORY.md` / 文档库 / 代码仓全平台共用 | ⚠️ 靠纪律(实测今日日志有 **15** 个接续点/收口章节) | **只追加自己的小节**(小节名带线名) |
**已修(3 处)**
- `state.py`:`[入口]` 列全所有 `接续入口_*.md`(多线时打 ⚠️"只走你自己那条");`[收口]` 追加"最近 3 个接续点标题";末尾**直接打印带线名的口令**。
- 规范 **新增 §3.4「多线并行:怎么不打架」**(三形态表 + 四条硬规则:一线一份接续入口 / 口令必锚线名 / 同一时刻只许一个自动会话动手 / 抢不到锁=正常信号只报告)。
- 规范 §3.2 硬要求 **四条 → 五条**(新增"prompt 必须锚定线名");§3.2.1 模板 ⓪ 改为"按 `[入口]` 里**「<线名>」那一行**定位接续包"。
**新口令(`state.py` 末行自动打印,直接抄给新会话)**
```
跑 `state.py`,按 覆盖网络线 那段 §2 第 1 条开工
```
⇒ 单线时它和旧口令等价;**多线时这一步就决定了新会话走哪条线**,不会串。
⛔ 反过来:`automation_update` 的 prompt 里**只写"按 §2 第 1 条开工"= 埋雷**(多线起来的那天才会炸,且很难归因)。
@@ -0,0 +1,287 @@
# 会话接续规范:token 超限后如何无损继续
> 2026-09-16 立。来源 = 复盘会话 `78ac724f`(「查看 dsh 项目待办事项」)**最后 6 轮**的真实操作与失败。
> 适用:任何会话接近/超过上下文预算,需要"换会话继续"的场景。
---
## 0. 结论(先看这三行)
**能形成方法 —— 但重点不在"自动开新会话"。** 宿主不允许程序化创建会话(钩子没有这个能力),唯一通道是"**一次性定时任务**"。⚠️ 且**只能做成"半自动"**:钩子会注入"该收口了",但**最后那一步(登记自动化)必须由模型自己调 `automation_update` 完成**,钩子做不到。
**自动接续解决的是"手不用点"和"单价膨胀",不解决"钱少花"。** 09-16 实测(`workbuddy.db` 原始计费字段):
- **成本 ≈ 单价 × 一轮内的工具调用次数**;单价随水位 <10 万 ≈0.10、15 万 ≈0.41 积分/次(同会话受控实测 **4 倍**)。
- **固定注入 = 35,192 token/请求**(tools 20,734 + systemPrompt 10,395 + skills 3,919 + mcp 144),但**缓存命中 99.5%** ⇒ 它**很轻**,不是主因。
- ⛔ 三个自动化**全新会话的首轮**分别烧 **8.93 / 10.05 / 13.82** 积分(首轮跑了 31–54 次工具调用)⇒ **开新会话挡不住"一轮几十次工具调用"的钱。**
**那个会话真正的失败不是技术,是目标漂移。** AI 自造了「接续入口」「归档」这类只有它懂的内部词,把自己加的收尾动作**当成了正事** —— 用户的原话是「**感觉和我要的东西不相关**」。
---
## 1. 案例复盘:最后 6 轮实际发生了什么
| 轮 | 用户说 | AI 做了什么 | 判定 |
|---|---|---|---|
| 1 | 「确认」 | 把"接续入口"收成一个文件 | ⚠️ 用户没要求过这个词 |
| 2 | 「**能否自动创建新会话继续处理**」 | 如实答"不能创建会话",改走**一次性定时任务**(定 11:00) | ✅ 诚实 + 找到等价路径 |
| 3 | 「不用等这么久 尽快触发」 | 提前到 10:20,并核对 `nextRunAt` | ✅ 执行到位 |
| 4 | 「**第一轮就消耗 7 个积分,并没有起到降低 token 消耗的作用**」 | 认错:承诺口径不准确;真实口径见 §2-P1 | ✅ 认错 + 给真数据 |
| 5 | 「**没看懂…什么是接续入口 / 归档用脚本做,感觉和我要的东西不相关**」 | 承认那三个词是自己造的,**跑偏了** | 🔴 **本轮暴露根因** |
| 6 | 「重新创建个你优化后的自动任务不就行了」 | 重建:prompt 从 ~1.5k 降到 **~200 token**,强制脚本化 | ✅ 修正方向 |
---
## 2. 三个真问题(按严重度排序)
### P3 🔴 目标漂移 —— 最严重,且与技术无关
「接续入口」「归档」「用脚本做」**全是 AI 自己造的内部流程词**,用户从未要求。AI 把自己加的收尾动作当成正事,**反而没在做用户要的"继续未完成的任务"**。
**判据**:如果一个词是你自己发明的、用户没说过 —— 它就不该出现在给用户的说明里。**接续包的读者是"下一个会话 **和** 用户",允许出现只有 AI 懂的词,就是失败。**
### P2 任务形态错 —— 比会话形态更根本
把「10 份文档逐份 agent 化改写」交给自动任务 ⇒ **20+ 轮 × 7 积分 ≈ 140+ 积分**。
这**违反项目自己的省积分第一招「批量活写脚本」** —— 这类批量转换本就该一次性脚本跑完。
⇒ **换会话只是换场地,活还是那么贵。** 自动接续**不能**救"任务形态本身贵"的问题。
### P1 承诺不准确 —— 体感与承诺不符,损伤信任
AI 曾把"开新会话"说成"降低 token 消耗" ⇒ 用户实测第一轮 7 积分,**直接质疑**。
**准确口径**(必须这样讲,09-16 实测修正):
- **开新会话不是零成本** —— 每轮 35,192 token 固定注入躲不掉(但缓存命中 99.5%,很轻);
- **真正的收益 = 单价**:水位从 20 万降到 5 万,**每次工具调用的单价约降 3–4 倍**(0.41 → 0.10 积分/次,同会话受控实测);
- ⇒ 它是"**降低单轮单价**",**不是**"降低总消耗"。⛔ 不许再说成后者。
- ⚠️ **旧版本此处写"水位从 39 万降到 5 万(约 1/8)"—— 该比值被高估约 2 倍**(把固定注入按全价算,忽略了 99.5% 的缓存命中)。已按实测更正。
- ⛔ **而且它只对"下一轮"有效**:如果新会话第一轮又跑 30+ 次工具调用,等于没省 —— 实测三个自动化全新会话首轮 8.93 / 10.05 / 13.82 积分就是证明。
---
## 3. 方法:三条硬要求 + 一条红线
### 3.1 接续包(会话 → 会话)
**触发**:用户要求接续,或水位到 30 万(另有 `stop-dialog-guard.py` 三级机制会自动提醒)。
**内容**(**用用户的词写,不用 AI 的内部词**):
1. **原目标** —— 用用户当初的说法,别翻译
2. **已完成** —— 一句话 + 关键产物路径
3. **在途** —— 跑到一半的,写清断在哪
4. **未完成** —— 用户要的、但还没做的(**这一节最重要,优先于"AI 自己加的收尾"**)
5. **下一步** —— 新会话第一个动作
6. **关键决定 + 回滚点**
**落位**:`.workbuddy/memory/<日期>.md` 追加,或单独一份 `接续入口_<线>_<日期>.md`(**约 3 KB 以内**)。
### 3.1.1 接续包 v2:必须**机器可校验**(2026-09-16 补,用户要求「让新会话明确知道上个会话的进度和未执行的内容」)
> **为什么必须可校验**:09-16 实证 —— 上一条接续点原话写「清理被截断的 `.workbuddy/memory/MEMORY.md`」,接手会话照做**就会改错对象**(真正被截断的是**用户级**那份)。⇒ **文字会失真,证据不会。** 凡是"结论"必须带一条**能复现的命令**。
**表头(放在接续包最前面,固定字段名,便于新会话机械读取)**
```md
## 接续点 · <工作线名> · <YYYY-MM-DD HH:MM>
- 来源会话: <sid> | 结束原因: <水位 N 万强制收口 | 用户要求>
- 原目标: <用户原话,不翻译、不缩写>
- 基线: HEAD=<git sha> | 远端 master=<sha> | 全局锁=<无 | 占用者>
- 产物: <绝对路径1> | <绝对路径2> ← 新会话必须逐一确认存在
- 校验命令: <一条命令> → 期望输出: <关键片段> ← 证明"上一步真的完成了"
- 未完成: ①<…> ②<…> ← 用户要的、还没做的(优先于 AI 自己加的收尾)
- 下一步: 第 1 个动作 = <具体命令,不是"继续处理">(之后 ② ③ …)
- 关键决定: <已定项 + 为什么> ← 防新会话推翻重来
- 回滚点: <能退回的位置>
- ⛔ 不要重做: <已完成的,免得重复劳动>
```
**硬要求**
1. **「校验命令」必填**,且必须是**只读、可在 30 秒内跑完、输出可判真假**的那种(例:`docs-sync-check.sh` → 期望 `192/192, 0 差异`;`git ls-remote origin refs/heads/master` → 期望 sha 等于基线)。
2. **「下一步」第 1 条必须是可直接执行的命令或文件路径**,⛔ 不许写「继续推进」「按情况处理」这类无法执行的话。
3. **产物 / 未完成 / 不要重做 三节一个都不能空**(空就写「无」),否则新会话只能重新探索 = 白花钱。
4. **≤3 KB**:只写「在哪 + 是什么状态」,⛔ 不把内容搬进来。
### 3.1.2 新会话开机四步(接手方强制动作)
**第 0 步(2026-09-16 晚加,实测补)**:先跑 `state.py`(工作区根,只读、免抢锁、约 30 行)—— 1 次调用就拿到 `[锁] / [git] / [入口] / [收口]`,其中 `[入口]` 段**同时给出"待执行清单"和"接续包在哪"**。
⛔ 跑它之前**不许**任何 Glob / Grep / `git status` 全盘探索。
> 为什么必须补:它回答「**我该接谁的班**」——只读接续包时若不知道去哪找、或没有接续包,新会话只能自己探索。**实测反例**:14:32 那轮(`d48a9be8`)把 `state.py` 排到**第 37 次调用**,全程 40 次调用 / 9.37 积分,用户当场质问「是不是应该先确认待执行的任务有哪些再去执行」。
| # | 动作 | 为什么 |
|---|---|---|
| 1 | **只读接续包**(⛔ 不许一上来就全库探索 / `git status` 扫全盘) | 探索是最贵的动作,接续包就是用来免掉它的 |
| 2 | **跑「校验命令」并比对期望输出** —— **不符就停下报告,不许照文字硬做** | 文字会失真(见 3.1.1 实证);跑不通的校验会伪装成"通过" |
| 3 | **把「未完成」与「下一步」复述一遍**,确认与用户当时要的一致 | 防目标漂移(§2-P3 是这条线最大的历史坑) |
| 4 | **从「下一步」第 1 条开工**;⛔ 不重新探索、不重做「不要重做」列的东西 | 省掉重复劳动 |
### 3.1.3 接续包会过期 —— 写完要回头维护
> 🔴 **2026-09-16 补:入口与接续包必须"同一次更新里一起改",且都带口径时间戳。**
> **实测事故**:`接续入口`(mtime 16:48)比 `接续包`(17:29)**滞后 41 分钟**,而 `state.py` 的 `[入口]` 段**就是从入口读的** ⇒ 17:04 那位**自动接续会话**按旧口径得出「P4 **并入自研 relay**」,与随后定案(**relay 实现未定、先做 R0 评测**)**表述不一致**(方向一致、粒度不同,用户当场察觉)。
> ⇒ 三条硬要求:
> ① **改接续包时,同一个动作里把入口 §2 一起改**(入口 = "下一棒的第一信息源",且 `state.py` 直接读它);
> ② **入口 §2 末尾必须写"本口径截至 `<时刻>`"**;
> ③ **接续包标题里的时间戳必须等于内容最后修订时刻**(17:29 那次只改了内容、标题仍写 16:58 ⇒ 读的人无法判断自己读的是哪一版)。
**凡是写完接续包之后又改了东西,必须回头改接续包。**(09-16 实证:接续点写好后对象被更正,接续包没跟着改 ⇒ 下一个会话会照错的做。)
**每次改动接续包,都要同步更新「基线」里的 sha 与时间戳。**
### 3.1.4 ⛔ 收益不靠"接续包更详细"
接续包写得再全,**也压不掉"一轮 30+ 次工具调用"的钱**(实测 8.93–13.82 积分/轮)。接续包解决的是**不丢状态、不重复劳动、不跑偏**;省钱靠的是 `3.2` 里那条**工具调用上限**。
### 3.2 自动接续任务(一次性 automation)
**只在"用户明确要求自动"时建**,且必须满足五条:
**prompt 自包含** —— 不依赖任何旧会话上下文(新环境读不到)
**prompt 要短** —— 实测正解:**~200 token**(只说"读 `<接续包路径>`"),⛔ 不要罗列 10 个文件名(那是 1.5k)
**prompt 必须写死"开机四步 + 工具调用上限"** —— 见下面模板;**那一行才是真正省钱的地方**
**⛔ prompt 里不许复制任务细节**(2026-09-16 晚加,实测)—— 细节的唯一来源是**交接单 / 接续包**;把技术细节抄进 prompt 会 ① 造第二个漂移源 ② **挤掉"开机四步"那一行**。
> **实测反例**:14:32 那轮的 prompt 重述了整段 S1 技术细节(≈1.2 KB),却**没带开机四步**(无接续包、无校验命令、无"先跑 state.py")⇒ 40 次调用 / 9.37 积分;而该轮的立项验收口径是 **≤10 次 / ≈1 分**。
**prompt 必须锚定线名** —— 「线名」或「接续包绝对路径」二者至少给一个(§3.4 规则 2)。⛔ 只用"按 §2 第 1 条开工"这类**不带线名**的写法,多线并行时会**串线**。
**先抢全局执行锁** —— 抢不到就停手,不和人工会话撞车(多线并行时的完整处置见 §3.4 规则 4)
**★ 必须在给用户的回复里告知**(2026-09-16 加,用户实测反馈触发)—— 新建会话 / 新建自动化是**用户可感知的状态变更**(提问闸门 A 类原话:「AI 会不会悄悄改他的设置」)。⛔ **只登记不告知 = 缺陷**:用户会在会话列表里凭空看见多出一个会话而不知何来。回复里用**陈述句**写清三件:① 已登记自动接续、约 N 分钟后自动开新会话、**不需要用户操作**;② 接续点 = `<文件>`;③ 若用户想自己开,口令 = `<state.py 口令>`。
> **实测出处**:2026-09-16 17:01 会话「规划覆盖网络任务落地步骤」(`408636f2`)登记了自动化(→ 17:04 开出会话 `1281e874`),但**最后回复结尾一字未提** ⇒ 用户隔了两小时自己发现并追问「你是不是通过自动任务 新建了个会话继续任务」。
**★ 登记前冻结口径;登记后口径变了就必须重登记**(2026-09-16 加,治"两张皮")—— ① 登记时**算好接续包 md5 并写进 prompt**(新会话开工前会校验它,见 §3.2.1 ①b);② 登记之后**若你仍在本会话继续工作、且改动了接续包(或改了关键判断)** ⇒ **必须回来撤销 / 重登记**那条 automation。⛔ 不重登记的后果 = 下一棒按**旧口径**开工并且**无从知道**。
> 📌 **来历(实测)**:17:01 登记、17:03 触发;而原会话一路工作到 **18:21**,**17:2x 才改判 P4、17:3x 才撤销「首选 frp」、17:29 才更新接续包** ⇒ 新旧两张皮。**「收口」只登记了一个未来动作,不等于会话结束** —— 没有同步点,漂移就是必然。
⛔ **做完即失效**(一次性),**不要留成周期任务**。
#### 3.2.1 标准 prompt 模板(照抄,填空即可)
```text
⓪ 先跑 `state.py`(工作区根,只读)⇒ 按它 `[入口]` 段里**「<线名>」那一行**定位接续包;⛔ 多线并行时只走你自己那条、别串线;⛔ 跑它之前不许 Glob/Grep 探索。
读「<接续包绝对路径>」的「接续点」。
① 先跑其中的「校验命令」,输出与期望不符 ⇒ 停下、只报告,⛔ 不许照文字硬做。
①b 🔴 **口径门禁(硬,必须机器校验)**:登记本任务时,prompt 里**必须带「接续包 md5」**(`md5sum <接续包绝对路径>` 或 python `hashlib.md5`)。开工前**重算一次**:**不一致 ⇒ 立刻停手并报告「口径已更新,需重新接续」**,⛔ 不许凭接续包正文继续往下做;⛔ **若 prompt 里根本没有指纹 ⇒ 同样视为不合格,停手报告**(说明登记方漏了这一步)。⇒ 判据是**数值比对**,不是"记得去看 mtime"。
①c ⛔ **接续包里写「未定 / 待定 / 未授权」的事,不许你替原会话拍成「已定」**(2026-09-16 实测事故:接续会话把「relay 实现**未定**」输出成「**并入自研 relay**」);若发现接续包内容与 prompt 描述有出入 ⇒ **先报告,不要自行取舍**。
② 从「下一步」第 1 条开工;⛔ 不重做「不要重做」列的东西。
③ 批量活必须先写成脚本一次跑完,⛔ 不许逐份探索;本轮工具调用 ≤ 8 次。
⛔ 本轮只做上面这一件事,做完即停。不许顺手做归档 / 整理 / 写入口 / 开工别的任务。
⛔ 取证只做一次、最多 3 个命令;发现要动代码或改配置 ⇒ 停下来报告,不要动手。
```
**为什么是这五条(2026-09-16 实测,见 §6)**
| 条 | 治什么 | 实测依据 |
|---|---|---|
| ① | 照错文字硬做 | 上一条接续点把对象写错了(§3.1.1) |
| ② | 目标漂移 / 重复劳动 | §2-P3 |
| ③ | **钱的主力** | 31 次调用 = 8.93 分;压到 8 次 ≈ 1 分 |
| ④ | **无人值守时的自我扩权** | `b08b1c35` 用户只说「先确认待办」,第 28 次调用**已在写 S0 代码** |
| ⑤ | 防御性过度取证 | 同一会话第 7–24 次连续 17 次取证,reasoning 三连自我加码 |
**④⑤ 是 2026-09-16 新加的**:实测自动化**平均每轮 5.6–8.9 积分**,手动会话**平均每轮 3.3 积分**(约 2–3 倍)。根因不是"自动化"这个身份,而是**一条指令塞太多事 + 没人能打断**。⛔ 别再用「一条 prompt 跑完整条工作线」的写法。
#### 3.2.2 完整链路(哪一段是硬的、哪一段是软的)
```text
[硬] 水位 ≥ 30 万 → stop-dialog-guard.py 注入「强制收口」 ← 已上线
[软] 模型写接续包(§3.1.1 模板) ← 靠纪律
[软] 模型调 automation_update 登记一次性任务(+2 分钟) ← 靠纪律,钩子做不到
[软] ★模型在给用户的回复里告知「已登记自动接续」 ← 靠纪律(2026-09-16 补;缺这一环 = 用户不知情)
[硬] 宿主到点执行 ⇒ 新会话自动开 ← 已证实(每次运行必带新 sessionId)
[硬] 新会话按 §3.2.1 模板开机 ← 靠 prompt 写死
```
⚠️ **两处软环节必须知道**:钩子**不能**创建会话、**不能**创建自动化(自动化只能经 `automation_update`)。所以「自动接续」是**半自动**——末段若模型没调工具,链条就断在最后一步。⛔ 钩子脚本**不得**绕过 `automation_update` 直接写库建自动化(硬约束)。
### 3.3 ⛔ 红线
**不许自造流程词给用户看。** 用户说"继续未完成的任务",你就去做**他说的那件事**,不要顺手加"归档""整理""写入口"。
**不许把"AI 自己加的收尾动作"排进自动任务的正事里。** 顺序必须是:**用户要的活在前,AI 自己加的收尾在最后(或不加)**。
**不许承诺"降低 token 消耗"** —— 只能说"降低每轮水位"(见 P1)。
---
### 3.4 多线并行:怎么不打架(2026-09-16 晚加,用户提问触发)
> 用户原话:「**假如多个会话都要新建会话,新会话全都执行这个口令吗,会不会冲突**」
**会冲突,三种;真正会咬人的是第 ① 种。**
| # | 冲突形态 | 现状 | 对策 |
|---|---|---|---|
| ① | **串线** —— 口令只说"按 §2 第 1 条",而 `state.py [入口]` 原先只认"最新一份接续入口" ⇒ 两个新会话都跑**同一条线**,另一条线没人跑 | 🔴 **会真发生**(今天只有一条线,属潜伏) | **口令必须带线名**;`state.py` 已改为**列全所有线**并打印带线名的口令 |
| ② | **抢锁** —— 两条线同时动手,只有一个抢到 | ✅ 已有全局执行锁兜底,**不会同时改**;代价是输家白烧一轮 | 输家**只报告 + 结束**;⚠️ **读前置可并行**(`state.py` / 读接续包 / 校验命令都是只读) |
| ③ | **共享文件互相覆盖** —— 今日日志 / `MEMORY.md` / 文档库 / 代码仓都是**全平台共用** | ⚠️ 靠纪律(实测今日日志里已有 **15** 个接续点/收口章节) | **只追加自己的小节**(小节名带线名);⛔ 不重写别人的段落、⛔ 不全文件覆盖 |
**四条硬规则**
1. **一线一份接续入口** —— `接续入口_<线名>_<日期>.md`。⛔ 不复用别人的、⛔ 不把旧线那份当自己的;`state.py` 会列全,**按你手上那份接续包 / 自动化 prompt 里的线名认领**。
2. **口令必须锚定线名** —— 正解:「跑 `state.py`,按 **<线名>** 那段 §2 第 1 条开工」(`state.py` 末尾已直接打印这句)。⛔ 不要只说"按 §2 第 1 条"。
3. **同一时刻只许一个自动会话动手** —— 登记新的一次性自动化前,先看有没有在跑的:`automation_runtime_state.running = 1`(或 `running_conversation_id` 非空)⇒ **不再登记**,等它自己往下接。⛔ 不要同时挂两条待触发的一次性自动化。
4. **抢不到锁 = 正常信号,不是故障** —— 它说明"**另一个会话正在动手**"。处置:只读部分照做(跑 `state.py` → 读接续包 → 跑校验命令)→ 报告「X 持有锁,我未动手」→ 结束。⚠️ 此时**连日志都不该写**(写文件也要锁)⇒ **只能回复报告**,不要硬写、不要删锁。
## 4. 与既有机制的配合
| 机制 | 管什么 | 位置 |
|---|---|---|
| 三级预算告警(12/20/30 万) | **什么时候该收口** | `dsh-server-docs/scripts/stop-dialog-guard.py` |
| 本规范 §3.1.1 接续包 v2 | **收口时产出什么**(机器可校验) | 本文件 |
| 本规范 §3.1.2 开机四步 | **接手方怎么确认没跑偏** | 本文件 |
| 交接单 8 段模板 | 规划会话 → 执行会话 | `dsh-server-docs/交接单/README.md §二` |
| `automation_update`(一次性) | 自动触发接续 | 工具,用时现调 |
| 成本公式(§0) | **判断钱花在哪** | 本文件;数据源 `workbuddy.db` |
> 一句话:**告警决定"该走了",接续包决定"走得不丢东西",开机四步决定"接手方不会跑偏",工具调用上限决定"这一趟值不值钱"。**
## 5. 实测数据存档(2026-09-16)
| 自动化运行 | 上下文 tokens | 工具调用 | 积分 | 积分/工具 |
|---|---|---|---|---|
| 覆盖网络线·归档接续 | 200,013 | 54 | 13.82 | 0.256 |
| 决策方法-2 | 168,040 | 37 | 10.05 | 0.272 |
| 覆盖网络线·接续(优化版) | 141,401 | 31 | 8.93 | 0.288 |
| 代码仓三方同步 ③ | 87,177 | 20 | 1.93 | 0.097 |
| 代码仓三方同步 ① | 64,876 | 15 | 1.66 | 0.111 |
| 遗留项自动推进 | 82,712 | 12 | 1.20 | 0.100 |
| 代码仓三方同步 ④ | 63,147 | 16 | 1.18 | 0.074 |
| 代码仓三方同步 ② | 56,422 | 11 | 0.82 | 0.075 |
**取数方法**(下次直接复用,别再摸索):
- `E:/ProgramData/.workbuddy/workbuddy.db` → `session_usage.credit_json`(逐轮积分)|`automation_runs.runs_json`(逐请求 usage / 缓存命中 / byCategory)|`automation_runs.metadata_json`(sessionId)|`automations`(周期与状态)
- ⚠️ **转录 `projects/<目录>/<sid>.jsonl` 里带 `rawUsage`(含逐次 `credit`)—— 可用,别信"恒为空"**:实测 `b08b1c35` 有 **67 条**带 `credit` 的记录,**逐次合计 11.17** 与数据库 `credit_json` 总和**完全一致**(两个独立源交叉验证)。⇒ 要"逐次成本曲线"就取这里。
- 取数脚本:`_中间产物_待清理/auto-continue-20260916/`(`cost_model.py` 聚合全自动化 / `curve.py` 逐次曲线)。
**详细报告**:`会话阈值自动接续_机制方案与成本实测_20260916.html`。
## 6. 为什么自动化会话要跑那么多工具调用(2026-09-16)
**实测对比(同一工作区、同一类任务)**
| 会话 | 类型 | 轮数 | 积分总计 | 平均每轮 | 工具调用 |
|---|---|---|---|---|---|
| 自动化·接续优化版 | 自动化 | 1 | 8.93 | **8.93** | 31 |
| 自动化·决策方法-2 | 自动化 | 5 | 44.77 | **8.95** | 141 |
| 自动化·确认覆盖网络待办 | 自动化 | 2 | 11.17 | **5.59** | 77 |
| 手动·检查覆盖网络方案 | 手动 | 5 | 16.52 | **3.30** | 50 |
**两条先破除的错觉**
1. ⛔ **「自动化一定比手动贵」不成立**:手动 `df1b7c6a` 累计 **16.52 > 11.17**(单次中位 0.21 > 0.11)。差的是**单轮塞了多少事**,不是身份。
2. ⛔ **「单次很贵」不成立**:逐次 `credit` 中位数只有 **0.11(自动化)/ 0.21(手动)**,最低 0.05。⇒ **成本完全由"次数"累出来**,而模型单步决策时**看不到累计**。
**三条真根因**
1. **一条 prompt 塞了 N 件事** —— 手动「继续 XX」只指一件;自动化 prompt 常把「N 项任务 + 抢锁 + 只做 N 项 + 脚本化 + 反序释放 + 写简报 + 写记忆」串成一条,模型只能一路做到底。
2. **没人能打断(最关键)** —— 实证:`b08b1c35` 用户只说「先确认待办事项」,实际第 7–24 次连续 17 次深度取证,**第 28 次已经在写 S0 的代码**;reasoning 里出现「重要取证完成 / 取证非常完整了 / 现在证据链完整了」**三次自我加码**。你在场时那句「够了」就是唯一的刹车。
3. **规则本身在制造调用** —— 取证 / 交付门禁 / 抢锁与反序释放 / 写简报 / 写记忆,每条都是调用。规则没错,但塞进无人值守的一条指令就变成"必须做完"。
**修法**:见 §3.2.1 的第 ④⑤ 条(只做一件事、取证设上限)。预期 77 次 → 约 8 次,11.17 分 → 约 1 分。
@@ -0,0 +1,287 @@
# DSH 平台客户端化部署方案 —— 单机自用
- 版本:**v3**(2026-09-16;v1 规划稿 → v2 多用户 → **v3 范围收窄为单机自用**)
- 性质:**本文只做规划,不含任何代码改动**
- 定位:与 `集群化改造方案_Manager-Worker_20260914.md` 同级,是「客户端化」这件事**平台侧**的单一来源
- **分层指针(2026-09-16 收口)**:本文管**平台侧**(能不能在 Windows 跑起来、形态怎么切);**交付载体层**(Electron 壳 / 打包 / 签名 / 自动更新)以 `dsh桌面客户端_开发方案_20260916.md` 为准 —— 两份合起来才是完整的「客户端化」,⛔ 不各写一份
---
## 0. 一句话方案
把平台装到用户**自己的 Windows 电脑**上,本机浏览器打开 `http://127.0.0.1:3080`,**一个人用**。
不需要局域网、不需要域名、不需要证书、不需要开机自启(可选)。
平台**默认就监听回环地址** —— 这一项连配置都不用改。
---
## 1. 本次范围收窄的两条原则
### 1.1 使用者只有他自己
**不考虑当作服务器、不考虑别人访问。** 由此一次性消掉的东西:
| 消掉的 | 原因 |
|---|---|
| 监听 `0.0.0.0` + 防火墙放行 | 只有本机访问,回环地址即可 |
| 多账号 / 逐用户开通 | 只有一个人 |
| 子域或子路径分流 | 直接访问门户首页即可 |
| 租户隔离 | 没有"别人" |
| 开机自启(服务化) | 双击启动即可,非必需 |
### 1.2 平台机制**全部保留**,只是在客户端部署时**不启用**
**这是一条硬约束:不为客户端化去删改多租户相关的代码。**
客户端形态下这些机制**照旧存在、照旧可用**,只是没有使用场景。
> 用户的判断依据:**多租户需要域名才能跑起来,而客户端没有域名** ⇒ 客户端就用单用户形态。
因此本方案对代码的要求只有一条:**让平台能在 Windows 上把实例启起来**(见 §4)。
多租户、门户、账号体系、隔离档位、配额、集群 —— **一行都不动**。
---
### 1.3 多人形态**没有被去掉** —— "配域名即启用"
**形态开关就是一个配置项**,平台本来就支持,⛔ **不需要改任何代码**:
| 配置 | 形态 | 用户地址 |
|---|---|---|
| 域名**留空**(默认) | 单机自用 | `http://127.0.0.1:3080` |
| 配了**域名** | 多人访问 | `http://<用户名>.<域名>` |
- 开关 = `DSHS_BASE_DOMAIN`(配 `DSHS_COOKIE_DOMAIN`;有 HTTPS 时再加 `DSHS_SECURE_COOKIES`)—— 三个都是**环境变量**,见 `src/config.ts:320-321`
- 配套(用户侧):内网 DNS 泛解析 `*.<域名>` → 本机地址
- ✅ **不需要 HTTPS 也能跑**:门户域与用户子域属于**同一 site**,Cookie 的 `SameSite=Lax` 足以支撑跨子域跳转(`src/web/auth.ts:55-57` 即按"有没有 HTTPS"区分这两档)
- ⚠️ 隔离档位保持默认 `soft` ⇒ 多人访问时**用户之间无隔离**,与 §1.1 的取舍一致(且 `account` 档位在 Windows 上本来就不可用)
> **本节结论**:客户端版本**天然保留"可以当服务器"的能力** —— 启用它不需要写一行代码,只需要用户填一个域名。
> 🔒 **切多人形态的前置检查(2026-09-16 加 —— 未过则不得开这个开关)**
> 这个开关零代码,但它会**同时打开**三个被"单机自用"关掉的风险面:
> 1. **隔离档仍是 `soft` ⇒ 用户之间没有隔离**(且 `account` 档在 Windows 上本来不可用)⇒ 多人形态的**前置条件 = 先有隔离方案**;在拿到之前,只能明确限定为"**仅互信小圈子使用**",并且页面要写明这一点。
> 2. **平台级凭据不得随客户端分发**:域名配置要透传给平台,但**透传必须是白名单** —— 只放 `DSHS_BASE_DOMAIN` / `DSHS_COOKIE_DOMAIN` / `DSHS_SECURE_COOKIES` 三项,⛔ 不得把平台共享模型密钥一并继承下去;模型密钥只走"**用户自己的密钥**"那一层。
> 3. **实例必须属于机器主人**:一台机器上的实例只能归该机器使用者 —— 多人形态下需**显式**保证,不能靠"默认只有一个用户"蒙过去。
---
## 2. 范围收窄带来的变化(相对 v2)
| 维度 | v2(内网多用户) | **v3(单机自用)** |
|---|---|---|
| 监听地址 | 改 `0.0.0.0` + 防火墙 | **默认 `127.0.0.1`,零配置** |
| 账号 | 多账号 + 逐个开通 | 一个本地账号即可 |
| 入口 | 纯 IP + 子路径分流 | 直接 `http://127.0.0.1:3080` |
| 隔离 | 不需要 | **不需要**(没有"别人") |
| 服务化 | 开机自启(必需) | 可选 |
| 风险「用户互读文件」 | 🔴 须书面告知客户 | **不存在** |
| 待改代码 | 配置 + 打包 + spawn 适配 | **只剩 spawn 适配 + 打包** |
---
## 3. 现成能力盘点(**不需要动的东西**)
| 能力 | 现状 | 依据 |
|---|---|---|
| 监听回环 | **默认就是 `127.0.0.1`** | `config.ts:162` / `cli.ts:32` |
| 数据根 | 默认 `~/.dshs`(**非硬编码**) | `config.ts:257` |
| 数据库 | 默认 SQLite 单文件 | `config.ts:271` |
| 免 HTTPS | `--secure-cookies` 默认关 | `config.ts:281` |
| 文件属主处理 | `chown/chmod` 块条件是 `process.getuid() === 0` | **实测 Windows 上 `typeof process.getuid === 'undefined'`** ⇒ 整块自动跳过(`local-user-fs.ts:40`) |
| 端口守卫 | 默认 false | `config.ts:323` |
| 单机自检 | `dshs doctor` | 装机与排障直接用 |
| 版本巡检 | `runtime-baseline.cjs`(Node 写) | 跨平台 |
---
## 4. 硬阻塞点(**已在本机 Windows 实测**)
### 4.1 唯一的硬阻塞:平台启动实例的方式在 Windows 上不成立
平台用 `spawn(command, args)` 启动实例,**不带 shell**(`orchestrator.ts:663-665`)。
Windows 上 npm 全局包是 `.cmd` 垫片,于是:
| 尝试 | 本机实测结果 | 判定 |
|---|---|---|
| `spawn('npm', ['--version'])`(裸名,=平台当前做法) | `ERROR(event): ENOENT` | ❌ 起不来 |
| `spawn('…\\npm.cmd', ['--version'])`(显式 .cmd) | `THROW(sync): EINVAL` | ❌ Node 安全限制 |
| `spawn('cmd.exe', ['/c', 'npm', '--version'])` | `OK exit=0`,输出 `10.9.7` | ✅ 可用 |
| `spawn(p, { shell: true })` | `OK exit=0` | ✅ 可用 |
> 复现(Windows + Node 22):
> `node -e "require('child_process').spawn('npm',['--version'],{stdio:'inherit'}).on('error',e=>console.log(e.code))"` → 打印 `ENOENT`
**结论**:**必须**让平台在 Windows 上以 `cmd.exe /c` 或 `shell: true` 启动实例。这是全案唯一必须改代码的地方。
### 4.2 附带的小点
| # | 点 | 判定 |
|---|---|---|
| A | 播种工作区时无条件 `chownSync`(`orchestrator.ts:503`) | 🟢 非阻塞:有 `try/catch` 兜底,只会刷一行失败日志 |
| B | `dshs doctor` 会探测 cgroup 等 Linux 项 | 🟢 非阻塞:只是自检输出不准 |
---
## 5. 改造清单
### 5.1 必做
| # | 事项 | 量级 | 说明 |
|---|---|---|---|
| C1 | **Windows 子进程启动适配** | 小 | 实例启动处对 `win32` 走 `cmd.exe /c`;建议做成可配置 |
| C2 | 安装包 / 一键安装脚本 | 中 | 装 Node → 装平台与 dsh → 建目录 → 初始化本地账号 → 建启动快捷方式 → 自检 |
### 5.2 该做
| # | 事项 | 说明 |
|---|---|---|
| C3 | 启动器 | 双击启动 + 自动打开浏览器;退出时干净收尾(别留孤儿进程) |
| C4 | 容量参数按单机重算 | 现有默认按 2C2G 多用户宿主定的;Windows 无 cgroup,需靠并发上限约束 |
| C5 | 备份与清理的 Windows 实现 | 现有 `.sh` 那批要换;Node 写的那批可直接用 |
### 5.3 可选
| # | 事项 |
|---|---|
| C6 | 开机自启(单机自用非必需) |
| C7 | 顺手修 §4.2 的两处小点 |
| C8 | 免登录形态(本机单人,可考虑省掉登录步骤) |
> ⛔ **不在清单里 = 不做**:多租户、门户、账号体系、隔离档位、配额、集群 —— **一律不改**(§1.2)。
---
## 6. 落地步骤
| 步 | 动作 | 验证方式 |
|---|---|---|
| S1 | 装 Node.js(22+) | `node -v` 有输出 |
| S2 | 装平台 + 官方 dsh | `dshs --help` 与 `dsh --version` 都有输出 |
| S3 | 初始化本地账号 | 能登录门户 |
| S4 | 第一次启动 | 浏览器打开 `http://127.0.0.1:3080` 出现门户 |
| S5 | 打开实例(**这一步验证 C1**) | 实例能起来并进入对话界面 |
| S6 | 建启动快捷方式 | 双击即可启动并自动开浏览器 |
| S7 | 断网验证 | 断开公网(保留模型 API 通路)后一切正常 |
---
## 7. 验收标准
| # | 验收项 | 判据 |
|---|---|---|
| V1 | 本机可用 | `http://127.0.0.1:3080` 门户正常 |
| V2 | 实例可用 | 能打开实例、能正常对话、能改工作区文件 |
| V3 | 断网可用 | 断开公网(保留模型 API 通路)后,登录/实例/改文件全部正常 |
| V4 | 一键启动 | 双击快捷方式即可启动,无需命令行 |
| V5 | 数据可恢复 | 按备份流程恢复后,历史会话与工作区文件完整 |
| V6 | 无残留外部依赖 | 全程不装 Linux 环境、不装容器运行时 |
| V7 | 不对局域网暴露 | 从另一台机器访问本机端口**不通**(默认回环即满足) |
---
## 8. 风险
| # | 风险 | 等级 | 说明与应对 |
|---|---|---|---|
| R1 | 单实例无内存上限 | 🟡 中 | Windows 无 cgroup,失控实例可能拖慢整机;靠并发上限与人工干预约束 |
| R2 | 与现网形态不同 | 🟡 中 | 出问题不能直接对照现网排查,需另建排障知识 |
| R3 | 模型 API 需出网 | 🟡 中 | 需确认用户网络出网策略;必要时配代理 |
| R4 | Windows 上实例的沙箱强度低于 Linux | 🟡 中 | dsh 本体的 Windows 沙箱官方标注为"部分强制";单机自用可接受,需知悉 |
> ✅ 相对 v2 **消失**的风险:用户之间互读文件、无 HTTPS、局域网暴露。
---
## 9. 回滚
| 层 | 回滚 |
|---|---|
| C1(代码) | 按平台分支判断,Linux 侧行为不变 ⇒ **现网零影响** |
| 安装 | 卸载脚本 + 删除数据目录(安装时记录路径) |
| 整体 | 客户端化**完全不接触现网**(47/106 形态不动) |
---
## 10. 附录 A · 证据与复核命令
| 结论 | 复核命令 / 来源 |
|---|---|
| 默认监听回环 | `grep -n "DEFAULT_HOST" src/config.ts` |
| 启动实例不带 shell | `sed -n '663,665p' src/supervisor/orchestrator.ts` |
| chown 块在 Windows 自动跳过 | `sed -n '40,47p' src/fs/local-user-fs.ts` |
| seed 的 chown 无条件调用 | `sed -n '502,505p' src/supervisor/orchestrator.ts` |
| portGuard 默认关 | `grep -n "DSHS_PORT_GUARD" src/config.ts` |
| Windows spawn 行为 | §4.1 四行实测(本机 Windows + Node 22.22.2) |
| 无域名时有降级路径 | `sed -n '58,62p' src/web/routes/dsh.ts` |
| dsh 本体支持 Windows | 官方 `.agents/notes/**` 三篇 |
## 11. 附录 B · 保留但不启用(**给以后接手的人**)
这些机制在客户端形态下**存在但不使用**,不属于缺陷、不需要清理:
| 机制 | 客户端下的状态 | 事实备注 |
|---|---|---|
| 多租户 / 多账号 | 存在,只建一个账号 | — |
| 子域分流 | 不启用(`baseDomain` 留空) | 平台**内置了降级路径**:`baseDomain` 为空时入口自动回落到子路径 `/u/<id>/dsh/`(`src/web/routes/dsh.ts:58-62`)。即"没有域名跑不起来"在代码层面已有兜底;客户端单人场景**不需要依赖它** |
| 租户隔离(`account` 档位) | 不启用(默认 `soft`) | — |
| 端口守卫(nft) | 不启用(默认关) | 仅在 Linux+root 下可用 |
| 集群(Manager/Worker) | 不启用(`local` 模式) | — |
| 开机自启 | 可选 | — |
## 12. 附录 C · 被放弃的选项(记录决策依据)
| 曾考虑 | 放弃原因 |
|---|---|
| **内网多用户形态**(v2) | 用户明确「考虑用户自己使用就可以了,不用考虑当作服务器 其他人访问」 |
| 官方桌面版(Electron)作为载体 | ~~与"机制都保留"冲突 —— 换载体会丢掉我们的门户能力~~ ⚠️ **该判定已于 2026-09-16 失效**:`dsh桌面客户端_开发方案_20260916.md` 用「**保留官方壳、换内核为我们的平台进程**」规避了这条 —— 门户能力不丢。本行仅作历史记录 |
| Linux 宿主 / WSL2 / 容器 | 需额外环境;单机自用无必要 |
| 域名 + 证书形态 | 客户端无域名,且单人自用不需要 |
> 若范围再次变化(例如要回到内网多人),v2 的对比表与结论仍然有效,可在本文基础上恢复。
---
## 13. 附录 D · 官方桌面客户端:能否沿用(调研结论)
**问题**:能否沿用官方 Electron 桌面版,把这个项目装进去?
**结论**:❌ **直接装不进去**(三条硬理由);✅ 但它的**形态与打包链可以沿用**。
### 13.1 官方桌面版是什么
| 项 | 事实 |
|---|---|
| 组成 | `apps/desktop`(Electron 壳)+ `apps/desktop-host`(**私有 Node host 进程**)+ `resources/dsh`(内置运行时) |
| 网络 | **不开监听端口**;用分帧字节管道承载 Fetch 请求与流式响应 |
| profile | Electron **独占** `$DSH_HOME/profiles/desktop`;CLI 不能启动或修改它 |
| 插件 | profile 的 `dependencies` **只放外部插件**;有独立插件管理窗口(增删改查) |
| 数据 | 与 CLI **共享** `$DSH_HOME` 下的 会话 / 设置 / 凭据 / 工作区 / 存储 |
### 13.2 装不进去的三条硬理由
1. **载体只接受 dsh 插件,我们的平台不是插件。** 平台是独立的 HTTP 服务(路由 + 数据库 + 子进程编排),没有"作为 profile 插件被加载"的形态。
2. **桌面版没有 web server,而我们的插件是平台的前端。** 官方原文:「Desktop does not provide a `webServer`」,官方自己的「Open In...」插件就因此被禁用。我们的 `business-plugins` 数据全部来自平台 API(`/api/plugins/mine`、`/api/dsh/status`、`/api/skills/mine`、`/api/me/keys`…,见 `poc/business-plugins/lib/client.js`)—— 装进去就是空壳。
3. **两套编排者会争同一份 `$DSH_HOME`。** 桌面版自己就在跑 dsh;平台还要为每个用户再起 dsh 实例。
### 13.3 可以沿用的部分
| 可沿用 | 说明 |
|---|---|
| **形态** | Electron 桌面应用:双击打开、不暴露端口 |
| **打包链** | 官方 `apps/desktop/scripts/` 的 electron-builder + NSIS + Windows 签名 + 冒烟脚本,可作参照 |
| **数据互通** | 官方桌面版与 CLI 共享 `$DSH_HOME` 的会话/设置/凭据 —— 我们的壳若指向同一 `$DSH_HOME`,用户数据可互通 |
**做法**:自建一个**薄 Electron 壳**(不复用官方壳代码)——
主进程启动平台 → 等 `127.0.0.1:3080` 就绪 → 窗口加载它 → 退出时收干净子进程。
用户看到的是桌面应用,里面是**机制完整保留**的平台。
### 13.4 三档形态与投入
| 档 | 形态 | 投入 | 体验 |
|---|---|---|---|
| 1 | 快捷方式 + 浏览器(v3 现有 C3) | 最小 | 中 |
| 2 | 薄 Electron 壳 + 借用官方打包链 | 中(壳小,难在跑通打包链) | 好 |
| 3 | Fork 官方桌面版源码改造 | 大 | 最好但最脆 |
> ⛔ **不建议第 3 档**:官方桌面版的整个设计(不开端口、独占 profile、只装插件)与"承载一个平台服务"方向相反,改造量大于重写。
@@ -0,0 +1,331 @@
# DSH 桌面客户端开发方案 —— 基于官方 Electron 壳迭代
- 版本:**v1 规划稿**(2026-09-16)
- 状态:⏳ **待评审**(本文只做规划,不含代码改动)
- **分层指针(2026-09-16 收口)**:本文管**交付载体层**(壳 / 打包 / 签名 / 自动更新);**平台侧**(Windows 子进程启动适配、形态开关)以 `dsh客户端化部署方案_20260916.md` 为准
- 上游:`deepseek-ai/deepseek-harness` 的 `apps/desktop`(Electron 壳)+ `apps/desktop-host`(Node 宿主)
- 上游基线:`master` 分支,`apps/desktop` 版本 `0.1.6-alpha.1`(11239 个文件的全仓快照)
- 上游定位提醒:官方自称 **developer preview**,明写「THERE WILL BE COMPATIBILITY-BREAKING CHANGES」⇒ 同步机制必须按"会变"来设计
---
## 0. 一句话方案
**以官方 Electron 壳为骨架,换掉它的内核** —— 保留启动生命周期、单实例锁、失败恢复页、自动更新、Windows 打包链;
把"启动内置 dsh 运行时 + 插件管理"替换为"**启动我们自己的平台进程,窗口加载它**"。
产出一个**独立仓库**的桌面客户端,用户双击打开即用。
---
## 1. 目标形态
| 项 | 目标 |
|---|---|
| 交付物 | Windows 安装包(`Setup.exe`),双击安装,桌面快捷方式启动 |
| 运行态 | Electron 主进程 → 拉起平台子进程(本机回环)→ 窗口加载平台页面 |
| 对外暴露 | **仅本机回环**(沿用 v3 单机自用形态) |
| 数据 | 平台数据目录(默认用户目录下),与官方 CLI/桌面版**互不干扰** |
| 与平台的关系 | 客户端只是平台的**启动器 + 窗口**;平台机制一行不改(除 §5 那一处) |
---
## 2. 官方壳解剖:取什么、换什么、丢什么
### 2.1 官方壳的组成
| 部分 | 内容 |
|---|---|
| `apps/desktop` | 100 个文件:`src/`(主进程逻辑)、`renderer/`(启动页 + 插件管理 UI)、`scripts/`(打包链)、`tests/`、`electron-builder.config.mjs` |
| `apps/desktop-host` | **仅 6 个文件**:`src/index.ts`、`src/wire.ts`、`config/desktop.cordis.patch.yml` + 3 个配置 |
| 运行时 | `resources/dsh` 内置一份完整的 dsh 与依赖树(打包时生成) |
| 通信 | **不开监听端口**,用"分帧字节管道"承载 Fetch 与流式响应;`dsh-app://` 服务客户端资源 |
> 关键认识:官方壳的**复杂度几乎全部服务于"内置 dsh 运行时"**(包管理、profile 独占、管道传输、插件生命周期)。
> 我们只要"拉起一个本机 HTTP 服务 + 开个窗口",**这些复杂度大多可以直接不背**。
### 2.2 逐模块处置
| 模块 | 官方作用 | 处置 |
|---|---|---|
| `src/main.ts` | Electron 生命周期、窗口、自定义协议、应急页 | **保留骨架**,改"加载目标"与"启动什么" |
| `src/backend-controller.ts` | 后端状态机(启动/停止/恢复) | **换内核** → 管理平台进程 |
| `src/single-instance.ts` | 进程级单实例锁 | ✅ **原样保留** |
| `src/startup-document.ts` / `startup-error.ts` | 启动失败页(自带诊断与恢复动作) | ✅ **保留并改编**(文案换成我们的) |
| `src/locale.ts` / `src/ipc.ts` / `src/paths.ts` / `src/preload*.ts` | 本地化 / IPC / 路径 | ✅ **保留** |
| `src/update-coordinator.ts` | 自动更新 | ✅ **保留**,更新源指向我们自己的通道 |
| `src/host-process.ts` / `host-protocol.ts` | 启动 Node 宿主 + 管道协议 | ❌ **删**(我们走 HTTP,不需要管道) |
| `src/project-manager.ts` / `profile-packages.ts` / `runtime-tree.ts` / `core-package-set.ts` / `owned-directory.ts` | profile 与运行时包管理 | ❌ **删或大幅简化**(运行时由平台负责) |
| `renderer/plugin-manager.*` | 插件管理器 UI | ❌ **删**(插件由平台的「功能管理」管) |
| `renderer/startup.*` | 启动加载页 | ✅ **保留并改编** |
| `scripts/package-target.ts` / `windows-sign.mjs` / `installer.nsh` / `smoke-windows.ps1` / `desktop-build-paths.mjs` / `desktop-release-environment.mjs` | **Windows 打包链**(electron-builder + NSIS + 签名 + 冒烟) | ✅ **保留 —— 这是最值得复用的部分** |
| `scripts/prepare-dsh.ts` / `prepare-runtime.ts` / `prepare-package-set.ts` / `runtime-file-policy.ts` / `macos-runtime.ts` | 为内置运行时准备文件 | ❌ **删**(改为准备我们的平台产物) |
| `electron-builder.config.mjs` | 打包配置(appId、签名、自动更新、NSIS) | ✅ **保留**,改 appId / productName / 更新源 |
| `tests/**` | 官方自己的测试(大量针对内置运行时) | 🟡 **按新内核重写**;与打包链相关的保留 |
### 2.3 改造后的架构
```
Electron 主进程
├─ 单实例锁(官方组件,原样)
├─ 启动页窗口(官方组件,改编)
├─ 平台进程控制器(替换 backend-controller)
│ └─ spawn 平台 → 轮询 127.0.0.1:<port> 就绪
├─ 主窗口 → 加载 http://127.0.0.1:<port>
├─ 失败恢复页(官方组件,改编)
└─ 自动更新协调器(官方组件,换更新源)
```
---
## 3. 代码仓库方案(**本节回答"要不要单独一个仓库"**)
### 3.1 判定
✅ **需要独立仓库。** 命名建议 `dsh-desktop`(或 `dsh-client`)。
### 3.2 五条理由
| # | 理由 |
|---|---|
| 1 | **发布物与节奏不同**:桌面客户端有自己的版本号、安装包、签名、自动更新通道;与平台服务的发版完全不同步 |
| 2 | **上游要持续同步**:fork 官方代码后必须能跟官方更新(官方明说会破坏性变更)—— 独立仓库才能把"官方代码"与"我们的改动"分开管理 |
| 3 | **红线 R2 的边界要清晰**:桌面壳是**官方源码的衍生品**。放进平台仓库,会让"我们自研的平台"与"官方衍生代码"混在一棵树里,合规边界与代码归属都变模糊 |
| 4 | **依赖形态差异大**:桌面端重 devDeps(Electron 44 / electron-builder / AWS SDK / TS 6),平台是轻量 Node 服务 —— 混在一起会拖慢平台 CI、放大依赖面 |
| 5 | **安全与信任模型不同**:桌面端跑在**用户电脑**上,平台跑在**服务器**上;两者的权限、密钥、发布审批要求不一样 |
### 3.3 可行性的关键证据(依赖能否在独立仓库解析)
这是"能不能独立"的硬前提,已核实:
| 项 | 事实 |
|---|---|
| `apps/desktop` 的 `dependencies` | **只有 2 个,且都是公开包**:`electron-updater`、`semver` |
| `apps/desktop` 的 `devDependencies` | 16 个,其中 **只有 1 个** 是 monorepo 内部引用(`workspace:^`):`@deepseek-ai/dsh-home-paths` |
| 上述那个包是否已发 npm | ✅ 已发布(`@deepseek-ai/[email protected]`) |
| `@deepseek-ai/dsh-desktop` / `dsh-desktop-host` 本身 | `private: true`,**未发布到 npm** ⇒ **必须 fork 源码,不能直接依赖** |
> **结论**:独立仓库里把唯一那个 `workspace:^` 换成 npm 版本即可,其余全是公开包。
> **依赖层面无障碍** —— 这是本方案成立的关键证据。
### 3.4 上游同步机制
**目标**:官方更新能进来,我们的改动不丢、冲突可控。
**做法(推荐:基线快照 + 补丁清单)**:
1. 仓库内设 `upstream-baseline/` —— 存放 fork 时刻的官方源码快照,**只读**,带上游 commit hash 与版本号
2. 仓库内设 `patches/` —— 存放**我们对官方文件的每一处改动**,一个改动一个 patch 文件
3. 我们的新增代码(平台进程控制器等)放在 `src/` 下**独立文件**,不修改官方文件 —— 从源头减少冲突
4. 同步流程:
```
取官方新版 → 放到 upstream-baseline-new/ → diff 两个 baseline
→ 人工判断哪些官方改动要跟进 → 重放 patches/ → 冲突处处理 → 更新 baseline + 文档
```
5. **判别原则**:能在**新增文件**里做的事,绝不改官方文件;必须改的,一律进 `patches/` 并写明理由
> 备选做法(若改动最终很少):只维护一份 **`DEVIATIONS.md` 改动清单** + 手工同步,省掉 patch 的机械开销。
> 采用哪一档,等第一轮改造完、看到实际改动面再定。
---
## 4. 关键技术设计
### 4.1 启动流程
```
1. 取得单实例锁(官方组件)—— 已有实例则聚焦它的窗口并退出
2. 显示启动页(官方组件,改编文案)
3. 解析平台启动参数(端口、数据目录、命令路径)
4. spawn 平台进程,捕获 stdout/stderr
5. 轮询 127.0.0.1:<port> 直到就绪(带超时)
6. 就绪 → 关闭启动页 → 打开主窗口加载平台地址
7. 失败/超时 → 显示失败页(含诊断信息与"重启"按钮)
```
### 4.2 平台进程的生命周期
| 事件 | 处置 |
|---|---|
| 窗口全部关闭 | 默认行为待定(见 §9 未决项) |
| 用户退出应用 | 先优雅终止平台进程,再退出 Electron |
| 平台进程意外退出 | 尝试拉起一次;连续失败 → 显示失败页 |
| 应用被强杀 | 下次启动时清理上一次残留的进程(记录 pid 文件) |
| 端口被占 | 改为探测可用端口(**不要**写死 3080) |
### 4.3 就绪探测
- 轮询本机回环端口,**不要**用"端口能连上"当就绪判据(TCP 可连不代表服务可用)
- 用一个轻量健康检查判断真正就绪
- 设总超时,超时给出可操作的失败页
### 4.4 数据目录与配置
| 项 | 设计 |
|---|---|
| 平台数据目录 | 应用私有目录(Windows: `%APPDATA%\<appname>`),**不要**与用户手动安装的平台混用 |
| 平台端口 | 每次启动探测可用端口,写进启动参数 |
| 平台命令 | 打包进应用资源里,不依赖用户系统上的 Node |
| 与官方桌面版/CLI 的关系 | **互不干扰**:官方桌面版独占它自己的 profile;我们的客户端用独立数据目录 |
### 4.5 失败恢复
沿用官方壳的三档恢复动作骨架:
| 动作 | 我们的含义 |
|---|---|
| 重启 | 杀掉平台进程后重新拉起 |
| 重置 | 清掉平台数据目录(**需二次确认**,会丢会话) |
| 查看日志 | 打开日志目录(官方没有这一档,建议新增 —— 用户遇到问题第一件事就是找日志) |
### 4.6 自动更新
- 官方用 `update-coordinator.ts` + `electron-updater`,配置由 `resolveDesktopAutoUpdateConfig` 从环境解析
- 我们要做的:把更新源指向自己的通道;更新前**先停平台进程**
- ⚠️ 官方 README 提到签名/公证/更新托管"需要生产发布环境" ⇒ 自动更新是**发布期**才需要的能力,开发期可以先关掉
---
### 4.7 形态开关:从单机自用到多人访问(**零改动保留**)
客户端**默认**按单机自用启动(不配域名、平台监听回环)。要把它当服务器给多人用,**不需要改客户端代码** —— 只需让它把域名配置**透传**给平台:
| 档 | 做法 | 改动量 |
|---|---|---|
| **最小** | 用户在系统里设环境变量(平台读 `DSHS_BASE_DOMAIN` / `DSHS_COOKIE_DOMAIN`),客户端 spawn 平台时**原样继承** | **0** |
| 更好(该做项) | 客户端提供一个"服务设置"入口:填域名 → 写进平台配置 → 重启生效 | 小 |
| 配套(用户侧) | 内网 DNS 泛解析 `*.<域名>` → 本机地址 | 用户操作 |
- ⚠️ **改为"白名单透传"(2026-09-16 修正)**:原表述「不要清理环境变量、也不要白名单化」会把**平台级凭据**(共享模型密钥等)一并继承给一个装到用户机器上的进程。**正确做法 = 只放形态开关三项**(`DSHS_BASE_DOMAIN` / `DSHS_COOKIE_DOMAIN` / `DSHS_SECURE_COOKIES`),其余不放行 —— 既保住"零改动切多人",又不把平台凭据发出去
- ⚠️ 多人形态下隔离档位仍是默认 `soft`(用户之间无隔离),与部署方案 §1 的取舍一致
- ⚠️ 不需要 HTTPS 也能跑(同 site 下的 `SameSite=Lax` 足够),但**有 HTTPS 更规范**
---
## 5. 平台侧配套改动
桌面客户端要能跑起来,平台侧**必须**先解决一处(详见客户端化部署方案 §4.1,已实测):
| # | 事项 | 说明 |
|---|---|---|
| P1 | **Windows 子进程启动适配** | 平台现在用不带 shell 的方式启动实例,Windows 上会 `ENOENT` / `EINVAL`;必须走 `cmd.exe /c` 或 `shell: true` |
| P2 | 安装包运行方式确认 | 平台以什么形态打包进客户端资源(源码 + node_modules / 预编译产物),需与实际打包方式对齐 |
| P3 | 端口可配置 | 客户端要探测可用端口 ⇒ 平台需支持任意端口启动(当前已支持 `--port`) |
> 这三条都在**平台仓库**里改,与桌面客户端仓库分开 —— 这也是独立仓库的好处之一。
---
## 6. 打包与发布
### 6.1 可复用的官方打包链
| 文件 | 作用 |
|---|---|
| `electron-builder.config.mjs` | 打包配置工厂(appId / 签名 / 自动更新 / NSIS) |
| `scripts/package-target.ts` | 打包入口(按 target 打包) |
| `scripts/desktop-build-paths.mjs` | 各 target 的输出路径 |
| `scripts/desktop-release-environment.mjs` | 从环境变量解析发布参数 |
| `scripts/windows-sign.mjs` + `installer.nsh` | Windows 签名与 NSIS 安装器定制 |
| `scripts/smoke-windows.ps1` | Windows 原生冒烟(需要 Electron / Makensis / 7-zip 路径) |
### 6.2 Windows 签名需要准备的东西
官方配置从环境变量读取,意味着**签名是外部依赖,需要提前准备**:
| 环境变量 | 含义 |
|---|---|
| `DSH_DESKTOP_WINDOWS_CER_FILE` | 证书文件 |
| `DSH_DESKTOP_WINDOWS_SIGNTOOL` | 签名工具路径 |
| `DSH_DESKTOP_WINDOWS_TOKEN_PIN` | 令牌 PIN |
| `DSH_DESKTOP_WINDOWS_KEY_CONTAINER` | 密钥容器 |
| `DSH_DESKTOP_UNSIGNED` | 置 1 出**未签名**包(仅 Windows,**用于内部测试**) |
> ✅ 好消息:官方支持 `package:win:x64:unsigned` ⇒ **开发与内测完全不需要证书**,出正式包才需要。
### 6.3 应用标识
`appId` 与 `productName` 由 `resolveDesktopAppId(env)` 从环境解析 ⇒ 属于**配置项**,不需要改代码。
---
## 7. 开发里程碑
| 阶段 | 目标 | 验收 |
|---|---|---|
| **M0 打通** | 最薄的路:Electron 壳 + spawn 平台 + 窗口加载 | 本机双击启动,能看到平台页面 |
| **M1 生命周期** | 进程管理、就绪探测、退出收尾、崩溃恢复 | 反复启停 20 次无残留进程;拔掉平台进程能自动恢复或给失败页 |
| **M2 体验** | 启动页、失败页含日志入口、单实例、数据目录 | 二次启动聚焦已有窗口;失败页能一键看日志 |
| **M3 打包** | 出未签名 Windows 安装包 | 干净机器上安装 → 启动 → 可用 |
| **M4 发布** | 签名包 + 自动更新通道 | 安装 → 自动更新到新版本成功 |
> **M0 是关键**:它同时验证桌面壳改造与平台侧 P1(Windows 子进程适配)两件事。
---
## 8. 验收标准
| # | 验收项 | 判据 |
|---|---|---|
| V1 | 安装即用 | 干净 Windows 机器上装包后,双击可用,无需预先装 Node |
| V2 | 无残留进程 | 退出应用后,平台进程不残留 |
| V3 | 异常可恢复 | 平台进程被杀 → 应用能恢复或给出可操作的失败页 |
| V4 | 单实例 | 重复启动只聚焦已有窗口,不起第二个平台 |
| V5 | 数据隔离 | 与官方桌面版/CLI 互不干扰,各自数据目录独立 |
| V6 | 卸载干净 | 卸载后程序目录清空(用户数据是否保留需明确策略) |
| V7 | 打包链可复现 | 在干净构建机上能按文档打出安装包 |
---
## 9. 风险与未决项
### 9.1 风险
| # | 风险 | 等级 | 应对 |
|---|---|---|---|
| R1 | 上游快速迭代导致同步成本高 | 🔴 高 | 改动集中在新增文件 + `patches/` 留痕;官方破坏性变更时评估"跟 or 不跟" |
| R2 | Electron 体积大(安装包通常 80–150 MB) | 🟡 中 | 若体积敏感,评估 Tauri 等替代;但会失去官方打包链的复用价值 |
| R3 | 无签名证书时用户会看到安全警告 | 🟡 中 | 内测用未签名包;正式交付需采购代码签名证书 |
| R4 | 平台进程的跨平台启动方式还需实测 | 🟡 中 | M0 阶段同时验证 §5 的 P1 |
| R5 | 官方桌面版若正式发布,我们的定位会变 | 🟡 中 | 定期评估"是否可以直接用官方版 + 我们的插件" |
### 9.2 未决项(**开发前需要定**)
**D1 · 关闭窗口时的行为**
甲:退出应用并停掉平台进程 —— 优点:干净、不留后台;缺点:再次使用要重新启动。
乙:最小化到系统托盘,平台继续跑 —— 优点:随时可用,AI 长任务不被中断;缺点:常驻后台占内存。
**D2 · 客户端与官方桌面版的关系**
甲:完全独立(独立数据目录、独立产品名)—— 优点:互不干扰;缺点:用户装了官方版会有两套。
乙:共享数据目录 —— 优点:会话/设置互通;缺点:要处理与官方版的 profile 冲突(官方版独占它自己的 profile)。
**D3 · 是否保留"插件管理"界面**
甲:删掉,插件全部由平台的「功能管理」管 —— 优点:单一入口、与平台机制一致;缺点:客户端内少一个入口。
乙:保留官方那套插件管理器 —— 优点:复用现成 UI;缺点:与平台的插件机制并存会产生两条路径,容易互相打架。
---
## 附录 A · 证据与复核方式
| 结论 | 来源 |
|---|---|
| 桌面壳 = Electron + 内置运行时 + 私有宿主进程 | `apps/desktop/package.json`(description)+ `apps/desktop/README.md` |
| 不开监听端口、用字节管道 | 官方 README 首段 |
| `apps/desktop` 100 个文件 / `apps/desktop-host` 6 个文件 | GitHub 全仓 tree 快照 |
| `dependencies` 仅 2 个公开包 | `apps/desktop/package.json` |
| `devDependencies` 仅 1 个 `workspace:^` | 同上 |
| 该包已发 npm | npm registry `@deepseek-ai/dsh-home-paths` |
| `@deepseek-ai/dsh-desktop` 为 `private` | 同上 `package.json` |
| 打包链文件清单 | `apps/desktop/scripts/**` |
| Windows 签名靠环境变量 | `electron-builder.config.mjs` |
| 支持未签名包 | `package.json` 的 `package:win:x64:unsigned` |
| 官方宣称会破坏性变更 | 官方 README「Developer preview」段 |
## 附录 B · 需要 fork 的官方文件清单(按处置分组)
| 分组 | 文件 |
|---|---|
| **保留**(含打包链) | `electron-builder.config.mjs`、`scripts/package-target.ts`、`scripts/desktop-build-paths.mjs`、`scripts/desktop-release-environment.mjs`、`scripts/desktop-auto-update-environment.mjs`、`scripts/windows-sign.*`、`scripts/installer.nsh`、`scripts/smoke-windows.ps1`、`scripts/macos-runtime.ts`、`scripts/package-macos.ts`、`scripts/verify-macos-signature.*`、`scripts/notarize-macos-disk-images.*`、`src/single-instance.ts`、`src/locale.ts`、`src/ipc.ts`、`src/paths.ts`、`src/preload*.ts`、`src/update-coordinator.ts`、`renderer/startup.*` |
| **改**(换内核/改编) | `src/main.ts`、`src/startup-document.ts`、`src/startup-error.ts`、`src/backend-controller.ts` |
| **删**(为内置运行时服务) | `src/host-process.ts`、`src/host-protocol.ts`、`src/project-manager.ts`、`src/profile-packages.ts`、`src/runtime-tree.ts`、`src/core-package-set.ts`、`src/owned-directory.ts`、`src/release.ts`、`renderer/plugin-manager.*`、`scripts/prepare-dsh.ts`、`scripts/prepare-runtime.ts`、`scripts/prepare-package-set.ts`、`scripts/runtime-file-policy.ts`、`apps/desktop-host/**` |
| **重写** | `tests/**` 中针对内置运行时的部分 |