# 客户端联动契约(CLIENT-CONTRACT) > 数据源:本任务(`infra/domains/`)产出的签名端点文档与公告文档。 > 消费方:app 服务层(doc/01 §2 服务层 / §4 客户端断网弹性、doc/06 §5 断网矩阵)。 > 本文件是客户端与分发面之间的**接口约定**。改 schema / 验签规则 / 兜底链顺序须双方评审。 ## 1. 文档类型与 schema 两类文档共用同一个**签名信封**(`sign.Envelope`),只是 `payload` 不同。 ### 1.1 信封(envelope) ```jsonc { "version": 3, // uint64,单调递增(防回滚) "issued_at": "2026-06-13T00:00:00Z", // RFC3339 UTC,仅供展示/审计 "key_id": "ed25519-2026q2", // 选择验签公钥 "payload": { /* 见下 */ }, "sig": "base64(ed25519 签名)" } ``` - 签名覆盖 **去掉 `sig` 后** 的规范化 JSON(键按字典序排序、无多余空白、数组顺序保留)。客户端验签前必须用同样的规范化算法重算签名字节。规范化定义见 `tools/internal/sign`(`Canonicalize`)。 - 客户端**只内置公钥**,绝不内置私钥(doc/06 §3)。 ### 1.2 端点文档(`endpoints.v1.json`)payload ```jsonc { "api_domains": ["api-a.example.com", "api-b.example.net"], // 必填,≥1,已去重排序 "mirror_urls": ["https://.../endpoints.v1.json", "..."], // 必填,≥1,下一份文档的镜像 "emergency_nodes_hint": ["builtin"], // 可选,指向应急节点参数的不敏感线索(非参数本身) "notice": { /* 见 1.3,可选内联公告 */ }, "channel": "" // 可选,渠道标识(见 §6) } ``` ### 1.3 公告文档(`notices.v1.json`)payload(/v1/notices 的静态镜像版) ```jsonc { "notices": [ { "id": "2026-q2-mirror-move", // 必填,唯一 "level": "warning", // info | warning | critical "title_zh": "…", "title_en": "…", // 必填(双语) "body_zh": "…", "body_en": "…", "url": "https://…", // 可选 "published_at": "2026-06-13T00:00:00Z" } ] } ``` `notice` 既可内联进端点文档(随端点更新一起到达),也可作为独立 `notices.v1.json` 单独发布。两者 schema 相同。 ## 2. 验签规则(客户端必须实现) 客户端接受一份文档**当且仅当**全部通过: 1. **签名有效**:`key_id` 命中内置公钥环中的某个公钥,且签名校验通过。 2. **防回滚**:`version` **严格大于**客户端当前已信任的 `version`(首次为 0)。等于或更小一律拒绝(防降级 / 重放攻击)。 3. **schema 校验**:端点文档 `api_domains≥1`、`mirror_urls≥1`、域名 / URL 合法;公告 `level` 合法、双语标题与 `published_at` 齐备、`id` 唯一。 任一不过 → 丢弃该文档、保留当前已信任版本、记审计。参考实现:`tools/internal/endpoint`(`VerifyDocument`)。 ## 3. 公钥内置与轮换(双公钥过渡) - 客户端内置一个**公钥环** `key_id → 公钥`(可含多把)。 - 轮换时(doc/06 §6 每季):先发版让客户端**同时内置新旧两把公钥**;签名侧切到新 `key_id` 出文档;旧 key 在所有存量客户端都已带新公钥后再退役。过渡期内新旧 `key_id` 的文档都能验签。 - `key_id` 不命中公钥环 → 视为验签失败(拒绝)。 ## 4. 合并策略 验签通过后并入本地端点池: - `api_domains`:与本地池**并集去重**;保留客户端「成功端点置顶记忆」(doc/01 §4)——历史可用端点优先,再追加新域名。 - `mirror_urls`:替换为文档值(镜像列表以最新签名文档为准)。 - `emergency_nodes_hint`:覆盖式更新。 - `notice`:按 `id` 去重入公告位;`critical` 置顶。 - 整体只有 `version` 更大的文档才会触发合并(见 §2.2)。 ## 5. 客户端兜底链顺序(断网弹性,doc/01 §4 + doc/06 §5) 服务层按下面顺序逐级递降,**每一层的信任锚都在安装包内离线可用**: ``` 1. 缓存目录 ——上次成功的节点目录 + 端点池(带 version/时间戳),启动即用 2. 内置端点池 ——安装包内置 api_domains + IP 直连兜底(按渠道分包,§6) 3. DoH 多提供方 ——域名解析优先 DoH 轮询,绕本地 DNS 污染 4. IP 直连兜底 ——DoH 仍失败时用内置 IP 直连 5. 签名端点更新 ——从 mirror_urls(≥3 镜像)拉新文档,验签(§2)后合并(§4),刷新端点池/域名 6. 应急节点 ——emergency_nodes_hint 指向的内置应急参数,低速仅够拉新目录 7. TG 频道 ——最终人工广播渠道,「联系我们」离线可见 ``` 要点: - 上层失败才降到下层;任一层成功即停止下降并回升。 - 第 5 层取文档时按 `mirror_urls` 顺序**故障转移**:单镜像失效仍从其余镜像取(与 `tools/publish-mirrors` 的 `Fetch` 行为一致)。 - 第 1~4、6、7 层都不依赖「正在故障的那个组件」自身恢复。 ## 6. channel 字段(敏感参数按渠道分包轮换) - `channel` 标识分发渠道(如应用商店 / 官网直装 / 某分发包)。空字符串 = 适用所有渠道。 - 用途:敏感内置参数(IP 子集、应急节点)按渠道分包轮换,**泄露一个渠道不烧全部**(doc/06 §3 客户端 / doc/01 §4 注意框)。 - 客户端行为:携带自身 `channel` 标识;只接受 `channel` 为空或与自身匹配的文档(端点合并时按此过滤)。 ## 7. 域名被墙检测(接口需求,本任务不实现) 复用 **#15 探针体系**做域名级 DNS 污染 / TCP 阻断探测(doc/05 §3)。客户端 / 控制面只需消费探测结论触发兜底链与启用流程;本任务只登记接口需求,不实现探针。详见 `domains.md` §4。 ## 8. 评审 本契约需与**客户端任务负责人**评审对齐后冻结。冻结后任何 schema / 验签 / 兜底链顺序变更都要双方签字并升 `version` 语义说明。