文档库:目录改为编号制(01-规范/02-架构设计/03-数据库/04-调整方案/
05-交接单/06-ops/07-scripts/08-skills/09-archive),顶层散文件归入 01-规范/;
INDEX.md 与 docs-manifest.json 重刷(档案 146 篇);旧目录名引用全量对齐。
IM 线:src/im/**(SDK / hub / store / presence / ws / gateway-token)、
src/web/routes/im.ts、src/db/plugin-data/**、src/supervisor/plugin-assembly.ts
及对应 test/**。
插件线:poc/{im-agent-bridge,im-connection-gateway,im-conversation-tabs,
business-plugins-im,carbon-mcp-probe}、src/web/routes/{sessions,overlay-device}.ts、
src/net/relay/{device-grant,instance-credential}.ts。
仓库卫生:清出 40 个历史误入库 / 已改名文件(34 个交接单归档 + 6 个旧结构,
本地均有副本);dsh-server-docs/.gitignore 补 tmp/;交接单不入库(政策)。
20 KiB
149 · 覆盖网络顶层架构全貌(去中心化形态)
- 日期:2026-09-21
- 触发(用户原话):「这不是网状结构还是树形的,参考区块链的去中心化的逻辑 重新规划顶层架构」 |「是不是 没有ROOT,或者多个ROOT(区块链共识机制发送签名),ROOT只给manager发,manager给接入放发送」 |「考虑全面后 画整体图看网络架构 全貌是什么样子」
- 上游:148(多 Manager 多区域联邦形态)|序③ 四层密钥模型(
src/net/relay/identity.ts)|105 §3.2(骨干资格签发) - 状态:📋 方案(规划态 · 未实施)。本档零代码 / 零服务器改动;只做「全貌收敛 + 缺口登记」。
- 与前档的关系:不取代 148,而是修正 148 的两处过强表述,并把「信任图 / 数据图」分离后重画全貌。
- 同位文档:
交接单_一机一钥与信任根_20260917.md|覆盖网络_问题逐条推演与解决方案_20260916.md §A4
TL;DR
- 用户的三层结构判断是对的,代码里已经这么做了 —— 根只签签名者、签名者只签节点、节点本地校验。
- 「没有 ROOT」不成立,但 ROOT 已弱化到「只在签发那一刻存在」:不下发节点、不参与会话、不在数据路径上。
- 「多个 ROOT」已经支持,且是 any-of-N(任一即可)不是 M-of-N(多数共识) —— 见 §二 决定性证据。
- 架构全貌 = 两张图分离:信任图(中心化签发)|数据图(网状,无根无中心)。148 把这两张图画混了,是「看着像树」的根因。
- 🔴 一个必须先修的真缺口:
SignerSet/NodeGrant只有issuedAt,没有序号 ⇒ 旧名单可被重放,已撤销的签名者会「复活」。必须在 F2(区域名录)之前修,否则名录本身成为重放载体。 - 🔴 一处红线待拍板:148 §7.1 的「骨干资格签发权」在千区下必须改写(本区 Manager 签发 + 根背书),三条候选见 §五。
- ✅ 「Manager 可以升级为 ROOT」成立且代码天然支持 —— 根密钥与签名者密钥同形(
generateAuthorityKey = generateNodeKey,:517原文「同形」),角色由公钥被放进哪个清单决定,升级不需换密钥。⇒ N 台 = N 根,解决了「起初没有根」。⚠️ 但 G6 撤根机制是开放升级的前置条件(见 §二·补)。
一、先定性:这是什么形态,不是什么形态
| 说法 | 判定 |
|---|---|
| 「树形」 | ❌ 错。树的特征是「根既签发又是必经路径」。这里根只在签发那一刻存在,运行时不在数据路径上。 |
| 「网状」 | ⚠️ 半对。数据图是网状;信任图是中心化签发。两张图必须分开说,混着说就必然画成树。 |
| 「去中心化」 | ✅ 对,但要点在校验权下放:签发可以集中(离线根),校验必须在每个节点自己做(D2)。这才是去中心化的实质。 |
| 「区块链式共识」 | ❌ 不需要。区块链解决的是「互不信任的参与方就同一份账本达成一致」;这里信任根是用户自己的,不存在需要共识的对立方。引入共识只换来运维复杂度。真正要补的是防重放序号,比共识轻得多且足够。 |
二、「多个 ROOT」——代码里已经支持(决定性证据)
src/net/relay/identity.ts:492 return envList(env, 'DSHS_OVERLAY_ROOT_PUBKEYS') ← 复数,逗号分隔多把
src/net/relay/identity.ts:591 roots: readonly string[]
src/net/relay/identity.ts:610 const verdict = verifySignerSet(loaded.doc, loaded.sig, opts.roots)
关键在验签实现(identity.ts:301-314):
function verifySigned(payload: string, sig: unknown, keys: readonly string[]): IdentityReason | 'ok' {
if (typeof sig !== 'string' || sig.trim() === '') return 'no-signature'
const sigBuf = Buffer.from(sig.trim(), 'base64')
if (sigBuf.length !== 64) return 'bad-signature-length'
if (keys.length === 0) return 'no-trusted-keys' // 不可验 = 不接受
const data = Buffer.from(payload, 'utf8')
for (const spec of keys) { // ← 遍历
const key = publicKeyFrom(spec)
if (key === undefined) continue
if (cryptoVerify(null, data, key, sigBuf)) return 'ok' // ← 命中任意一把即通过
}
return 'signature-mismatch'
}
结论:for 循环 + 命中即 return 'ok' ⇒ any-of-N,不是 M-of-N。
| 面 | 含义 |
|---|---|
| ✅ 好处 | 根丢失 / 某把根保管出问题 ⇒ 其余根照样恢复。实打实的容灾。 |
| ⚠️ 代价 | N 把根里坏了一把,那一把单独就能授权一整批签名者,其他根拦不住。这是「恢复优先」而非「共谋防御」的设计取舍。 |
| 🔴 后果 | 多根时每一把根的保管等级必须一致(任一把沦陷 = 全网沦陷)。⛔ 不可「一把自留、几把随意外放」。 |
SignerSet 绑定网名(identity.ts:81:「签名者只在它被授权的网里有效」,跨网签发 ⇒ network-mismatch)⇒ 满足「ROOT 只给 manager 发」;signNodeGrant 跑在线签名者侧(identity.ts:395 注释「现网 = 47」)⇒ 满足「manager 给接入方发」。
二·补、「Manager 可以升级为 ROOT」——成立,且代码天然支持
用户原话:「还有manager 可以升级为 ROOT」
2.1 决定性前提:根密钥与签名者密钥同形
identity.ts:509 export function generateNodeKey(): { privateKeyPem: string; publicKey: string } { ... }
identity.ts:517 export const generateAuthorityKey = generateNodeKey
identity.ts:517 /** 生成一把 Ed25519 根 / 签名者密钥(**同形**,分开只为读起来不漏"这把是干什么用的")。 */
⇒ generateAuthorityKey 就是 generateNodeKey 的别名,「根」与「签名者」在密码学上没有任何区别。
2.2 由此推出的关键结论
| 结论 | 说明 |
|---|---|
| 角色不由密钥决定 | 「是根还是签名者」不取决于密钥怎么生成,而取决于它的公钥被放进哪个清单:进 SignerSet.signers[] ⇒ 它是签名者;进 DSHS_OVERLAY_ROOT_PUBKEYS ⇒ 它是根。 |
| 升级 = 改授权关系,⛔ 不需换密钥 | 华东 Manager 已有签名者密钥(init-signer 生成的 overlay-signer-key.pem)⇒ 升级为根不需要重新生成任何密钥,只需离线根把同一把公钥加入根清单并重新下发。 |
| 可降级 | 反向同样成立:从根清单移除该公钥 ⇒ 退回签名者身份,密钥不变。⚠️ 但移除同样受 G6 约束(见 §五 G6)。 |
2.3 升级路径(三步)
- 华东 Manager 已持有签名者密钥与
.pub公钥(init-signer已产出,scripts/overlay-keyring.cjs:142-149)。 - 离线根把该公钥追加进
DSHS_OVERLAY_ROOT_PUBKEYS(逗号分隔多值,identity.ts:492),重新下发到各节点 / relay。 - 各节点重新装载后,该 Manager 签出的
SignerSet开始被认;any-of-N 语义下与原有根并列生效。
2.4 这解决了「起初没有根」
信任闭环:第 1 台自己造根、自己签自己 ⇒ 第 2 台造自己的根、两把根互相列入对方清单 ⇒ N 台 = N 根,各根并列入受信清单。 ⇒ 不需要提前存在一个「总根」。联邦可以从任意一台开始自举,根集合随节点数增长而增长。这正是「没有 ROOT」那个直觉的正确落地形态 —— 不是真的没有根,而是没有唯一的根。
2.5 🔴 但代价必须写清楚(与 §二 同源)
| 风险 | 说明 |
|---|---|
| 🔴 任一根沦陷 = 全网沦陷 | any-of-N 下,新加入的一把根与原有根权限完全相同。升级动作降低了全网的整体安全下限到最弱那把根。⇒ 升级必须有准入门槛(谁有资格被升级、保管条件如何核验),⛔ 不能因为「技术上只需加一行」就随意升级。 |
| 🔴 升级当前不可逆(G6) | 撤根无任何机制(见 §五 G6)⇒ 一旦某台 Manager 升级为根且其密钥泄露,今天没有作废手段,只能逐台改 env 重下发。⇒ G6 是「允许升级」的前置条件:先有撤根机制,才谈得上安全地开放升级。 |
| ⚠️ 根集合无收敛机制 | 多根 + 可升格 ⇒ 根数量会单调增长,而 MAX_ENTRIES = 8 这类上限今天只存在于 directory.ts(地址目录),根清单无对应上限。⇒ 需要为根清单定容量与轮换规则。 |
⇒ 判定:方向成立、代码天然支持,但「开放升级」必须排在 G6(撤根机制)之后。 在此之前,升级只能是人工、离线、低频的动作。
三、全貌:两张图分离
3.1 信任图(只在「谁能入网」这一刻生效)
| 层 | 放哪 | 用途 | 边界 |
|---|---|---|---|
| 根(离线,可多把) | 用户手里 / 托管方 / 灾备 | 只授权·撤销「签名者」 | ⛔ 不签节点、⛔ 不加密数据、⛔ 不参与会话 |
| 签名者(在线,多把,按区) | 各区 Manager(/etc/dshs/overlay-signer-key.pem) |
签发本区节点入网凭据 | 换一把即可,根仍在 |
| 节点密钥(每机一把) | 每台机器(0600,属主正确) |
设备身份;对接时证明握有私钥 | 该设备重签 |
| 会话密钥(内存) | 隧道 | 传输加密 | 无感 |
根离线 ⇒ 不影响任何已入网节点运行;只影响「新节点入网」与「换签名者」。
3.2 数据图(运行时常态)
- 节点之间直接建隧道(打洞优先、relay 兜底);relay 只转发密文,不懂业务。
- ❌ 无根、❌ 无中心 —— 根不在数据路径上(这是与树的分水岭)。
- 一台 relay 可同时承载多张网(
main.ts:140-141注释原文:「relay 服务的网是每会话声明的,不是整机一个属性(一台 relay 可以同时承载多张网)」)。 - 跨网在「能不能拨」这一步就走不到:
dialers: Map<network, Set<hostId>>默认拒绝(registry.ts:5)。结构性隔离,不是配置约定。
3.3 权威归属(三层,判据三句)
| 权威 | 管什么 | 判据句 |
|---|---|---|
| 根 | 区域名录 · 用户全局唯一名 · 联邦吊销 | 「全局唯一」的标识只在根 |
| 区 Manager | 本区归属 · 租约 · 资格 | 「区域内唯一」的状态只在区 Manager、根 ⛔ 不镜像 |
| 区内 Worker/节点 | 本机运维数据 | 「跟着用户走」的数据留锚定区、⛔ 不跨区同步 |
明确不做(写下来防止后人当 bug 修):
- ⛔ 用户数据跨区同步
- ⛔ 区域 PG 互联或双向复制
- ⛔ 根当区域代理(根不转发用户业务流量)
- ⛔ 区域自取区名 / 自行扩区
四、全貌图(四张,正文见会话)
| 图 | 内容 |
|---|---|
| 图一 | 信任图 vs 数据图分离(本档 §三的文字版) |
| 图二 | 区域层与权威归属(根只管三件事 + 三区并列 + 三条「不做」) |
| 图三 | 设备入网六步 + 跨区访问路径(标 ✅/⚠️/🔴)+ 四条入口路径 A/B/C/D |
| 图四 | (上一轮)多根与多签名者三层拓扑 |
五、缺口登记(按修复顺序)
G1 🔴 SignerSet / NodeGrant 无序号 ⇒ 旧名单可重放(最高优先)
证据:
identity.ts:136 signerSetPayload = [TAG, version, network, issuedAt, signers.join(',')] ← issuedAt 参与签名
identity.ts:236-241 parseSignerSet 只检查 issuedAt 是合法 ISO 字符串
identity.ts:458 issuedAt 唯一被读取的位置是 verifyPeerGrant 的「签发时刻容差」判定(只查"太新")
identity.ts:57 version 恒 = IDENTITY_VERSION = 1(文档版本,不是清单版本)
⇒ 一份旧的 SignerSet(已撤销的签名者还在名单里)重放给节点,验签会通过 —— 因为签名本身是真的。
今天为何没爆:signer-set 文件由管理员下发、节点不主动拉取。联邦化之后「各区签名者名单要常态下发」一旦成立,这条路就敞开。
修法(小):SignerSet / RevocationList 加 serial: number;节点持久化「已见最大序号」,≤ 则拒。⛔ 不用 issuedAt 替代 —— 时钟不可信。
时机:🔴 必须在 F2(区域名录)之前。名录本身就是一份要下发的签名清单,会成为重放载体。
🔴 且 G1 是 G6 的前置:撤掉一把根的唯一补救 = 换新 SignerSet,而 SignerSet 无序号 ⇒「换新名单」这条路本身也不可靠。G1 与 G6 必须一起解决。
G2 🔴 区域名录缺失(三方缺口同源)
新机器不知道有哪些区可加入;自建区无法进联邦;区身份无背书载体 —— 三个症状,一个缺口。
实现建议:复用 directory.ts 已验证的机制(签名下发 + 本地缓存 + 长 TTL + 失败关闭),新建 DIRECTORY_PAYLOAD_TAG,独立条目上限。
⚠️ 与 DSHS_OVERLAY_ROOT_PUBKEYS 必须是两把不同的密钥、两个不同的用途(identity.ts:487-489 明文警告):那把签「地址目录」(可轮换的下发物),这把签「签名者名单」(身份根)。⛔ 不要合并成一个变量 —— 合并等于让「能换地址的人」顺带能加签名者。
G3 🔴 users 无区字段 ⇒ 跨区路由无从判断
schema.ts:112-123 无 host_id / region;username TEXT UNIQUE 只在单库内唯一。
⇒ 「路由到锚定区」这一步今天无判据。148 §三 G3 已登记。
G4 🔴 四条入口路径中 C / D 无通路
- A 新服务器首启 ⇒ 首管理员设置页(一次性)—— ⚠️ 今天只有 CLI(
cli.ts:203 bootstrapAdmin),无 Web 通路 - B 设备加入已有区 ⇒ ✅ 机制齐备(邀请码绑定 network + 区签发 + 本地校验)
- C 自建独立区(不联联邦)⇒ ⚠️ 今天无通路(但不需要任何根,理论上自成一网)
- D 自建区加入联邦 ⇒ 🔴 今天无通路(需根背书区身份,即 G2)
G5 🔴 骨干资格签发权红线(待拍板,同 148 §7.1)
105 §3.2 原文「骨干资格只能由控制面签发」⇒ 千区下须改写为「本区 Manager 签发 + 根背书」。
- A 严格照 105:根签发所有骨干资格。优点:与既有红线一致、实现最直接、审计面单一。缺点:根成为千区瓶颈、根离线时全区无法扩骨干、与「几百上千台」目标冲突。
- B 区签 + 根背书(148 倾向):本区 Manager 签,根只背书区身份。优点:根不在关键路径、可扩展到千区、与去中心化方向一致。缺点:须改写 105 红线、跨区信任传递多一跳、需要 G2 先落地。
- C 折中:日常区签,骨干级(跨区可见的)仍须根签。优点:风险最小、可渐进。缺点:两套规则并存、判据分叉、长期维护成本高。
🔴 F4 必须在 105 §五-2 之前拍板(148 §八)。
六、推进顺序(承接 148 §八 F1–F7,本次修正)
| 序 | 项 | 相对 148 的修正 |
|---|---|---|
| F1 | 区域身份 | 不变 |
| F1.5 | 🔴 签名清单序号(G1 + G6) | 新增,必须插在 F2 之前;序号机制同时是「作废一把根」的唯一载体 |
| F2 | 区域名录(G2) | 不变,但依赖 F1.5 |
| F3 | 用户锚点(G3) | 不变 |
| F4 | 权威归属改写(G5 · 待拍板) | 不变,🔴 必须在 105 §五-2 之前 |
| F5 | 跨区级联吊销 | 依赖 F1.5 的序号机制 |
| F6 | 跨区可见性 | 不变 |
| F7 | 插件版本真源下沉到区域 | 不变(business_plugins.tgz_path 存 Manager 绝对路径 ⇒ 跨区迁移指向错区) |
| F8 | 根集合治理(G7):上限 / 准入 / 轮换 + Manager 升格流程 | 新增,🔴 依赖 F1.5(G6 撤根机制) —— 撤根机制落地前,升格只允许人工离线低频操作 |
七、对 148 的修订(本次新增)
148 中以下两句必须改正,原文过强:
| 148 原文 | 问题 | 改为 |
|---|---|---|
| 「根=唯一权威」 | 混淆了「签发权威」与「校验权威」 | 「根=唯一的签发权威;校验在每台节点本地(identity.ts:420 D2)」 |
| 「根只在登录时被碰一次」 | 描述的是旧假设,非代码事实 | 「根只在签发签名者名单时被碰;运行时(含登录)完全不在路径上」 |
八、验收
DSHS_OVERLAY_ROOT_PUBKEYS配置多把根,任一把签的SignerSet均验通(验证 any-of-N)。- 构造一份旧
SignerSet(含已撤签名者)重放 ⇒ 当前会通过(复现 G1)⇒ 修后必须被拒。 - 新机器冷启动:仅凭邀请码 ⇒ 能列出可加入的区(G2 落地后)。
- 自建区加入联邦:根背书区身份 ⇒ 跨区互认(G2 + G5 落地后)。
- 撤一台设备 ⇒ 仅该设备的凭据失效,不牵动全网换密钥。
九、未验证
- G1 重放:未构造真实重放实验(
issuedAt不参与判定是读码推断,非实测)。⚠️ 落地前须实测复现。 DSHS_OVERLAY_ROOT_PUBKEYS生产实际配了几把 —— 未查(服务器侧)。overlay-keyring.cjs recover-root的恢复演练是否在真机跑过 —— 未查。- 🔴 新发现缺口 G6:撤根无机制,且比「撤签名者」更严重。
- 撤签名者:有机制 —— 根签一份新
SignerSet(identity.ts:103明文:「撤销签名者不走这里(那是"根签一份新的 SignerSet"的职责)」)。 - 撤根:无任何机制。
RevocationList只有hosts[]+nodeKeys[](identity.ts:107-111),不涉及根;revoke前缀的IdentityReason只有revoked-host/revoked-node-key(identity.ts:127-128)两种。 - ⇒ 多根形态下,一把根沦陷无法被作废 —— 只能改
DSHS_OVERLAY_ROOT_PUBKEYS并逐台机器重新下发,且 any-of-N 语义下,被撤那把若仍留在任一节点的 env 里就仍然有效。 - ⚠️ 且与 G1 重放叠加:撤掉一把根的唯一补救是换
SignerSet,而SignerSet无序号 ⇒ 旧SignerSet可重放 ⇒ 「换新名单」这条路本身也不可靠。 - ⇒ G1 与 G6 必须一起解决:序号机制既治重放,也是「作废一把根」的唯一载体。
- 撤签名者:有机制 —— 根签一份新
- 区之间是否允许互相签发(peer delegation)—— 未定,见 148 §7.2。
- 跨区可见性默认值 —— 倾向默认互不可见,待拍板。
- 🔴 G7(本节新增):根集合无容量上限、无轮换规则、无准入标准。 「Manager 可升级为 ROOT」(§二·补)一旦开放,根数量会单调增长;
MAX_ENTRIES = 8只约束directory.ts的地址目录,根清单无对应约束。⇒ 需定「根清单上限 / 升级准入条件 / 轮换流程」,且排在 G6 之后。 - 「升级为 ROOT」是否要求离线参与(即必须由现有根持有人在离线环境操作)—— 倾向是(
sign-signerset注释原文「⛔ 只在离线跑」),但若开放自助升级则需重新设计,未定。
附 · 自我批评
- 148 把「信任图」与「数据图」混在一张图里讲 ⇒ 必然画成树。本次分离后才是真实形态。这是本轮最有价值的修正。
- 上一轮我说「区块链共识不建议引入」是对的,但当时没查
verifySigned的实现就断言「多根已支持」—— 本次补上了代码证据(any-of-N)。 - G6(撤根无机制)是本次新发现的,上一轮和 148 都漏了。
档案号:149(.lock-149 原子占用后释放)
关联:148 · 147 · 144 · 序③ · 105 §3.2