@twinsearth/w2m-dsh-plugin 0.0.0-stage → 0.4.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1288 -0
- package/LICENSE +21 -0
- package/PROTOCOL-v0.1.2.md +165 -0
- package/PROTOCOL-v0.3.0.md +454 -0
- package/PROTOCOL-v0.3.9.md +207 -0
- package/PROTOCOL-v0.4.0.md +255 -0
- package/PROTOCOL.md +339 -0
- package/README.md +665 -2
- package/bin/w2m-localside.mjs +303 -0
- package/bin/w2m-rabbit.mjs +396 -0
- package/bin/w2m-stun.mjs +231 -0
- package/cordis.patch.yml +94 -0
- package/deploy/caddy/Caddyfile.example +118 -0
- package/deploy/docker/Dockerfile +144 -0
- package/deploy/docker/README.md +338 -0
- package/deploy/docker/docker-compose.yml +179 -0
- package/deploy/nginx/w2m.conf.example +196 -0
- package/deploy/shared-server/README.md +118 -0
- package/deploy/shared-server/install.sh +255 -0
- package/deploy/systemd/w2m-rabbit.service +150 -0
- package/deploy/systemd/w2m-stun.service +69 -0
- package/deploy/windows/README.md +115 -0
- package/deploy/windows/install-service.ps1 +195 -0
- package/deploy/windows/w2m-agent-supervisor.mjs +205 -0
- package/deploy/windows/winsw/w2m-localside.xml +50 -0
- package/docs/DEPLOY.md +902 -0
- package/docs/LINT-AUDIT.md +492 -0
- package/docs/PIPELINE-DESIGN.md +95 -0
- package/docs/TROUBLESHOOTING.md +560 -0
- package/docs/UI-CARDS.md +81 -0
- package/lib/index.js +13 -0
- package/lib/tools.js +11 -0
- package/package.json +133 -4
- package/src/agent/agent.mjs +3317 -0
- package/src/agent/allowed-commands.mjs +116 -0
- package/src/agent/caps.mjs +437 -0
- package/src/agent/exec.mjs +356 -0
- package/src/agent/git.mjs +616 -0
- package/src/agent/identity.mjs +253 -0
- package/src/agent/p2p-node.mjs +1243 -0
- package/src/agent/p2p.mjs +605 -0
- package/src/agent/resolve.mjs +148 -0
- package/src/agent/spool.mjs +252 -0
- package/src/agent/stun.mjs +397 -0
- package/src/agent/url.mjs +127 -0
- package/src/plugin/auto-update.mjs +574 -0
- package/src/plugin/schedule.mjs +452 -0
- package/src/plugin/shared-config.mjs +264 -0
- package/src/plugin/tools.mjs +3891 -0
- package/src/plugin/update-install.mjs +764 -0
- package/src/plugin/update-source.mjs +686 -0
- package/src/relay/metrics.mjs +284 -0
- package/src/relay/peers.mjs +180 -0
- package/src/relay/persistence.mjs +207 -0
- package/src/relay/report.mjs +591 -0
- package/src/relay/server.mjs +1187 -0
- package/src/relay/state.mjs +2004 -0
- package/src/relay/stun-server.mjs +733 -0
- package/src/signing.mjs +359 -0
- package/src/util/cli.mjs +70 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 DSH: Windows2MacOS contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# W2M 线协议 v0.1.2 增量(跨区域 / 跨网络)
|
|
2
|
+
|
|
3
|
+
> 基线:`PROTOCOL.md`(v1 冻结)。**本文件只写增量**,未提及的部分一律不变。
|
|
4
|
+
> 主题:让中继可以被放在**公网 VPS、Tailscale 内网、或隧道后面**,且三种部署都正确工作。
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## 0. 为什么需要这版(现状的三个真实缺陷)
|
|
9
|
+
|
|
10
|
+
| # | 缺陷 | 后果 |
|
|
11
|
+
|---|---|---|
|
|
12
|
+
| 1 | 客户端用 `new URL('/v1/stream', rabbitUrl)` 拼 URL | **一旦中继挂在子路径后面就全部 404**。`new URL('/a', 'https://h/w2m')` → `https://h/a`,前缀被吃掉。只有挂在根路径才行 |
|
|
13
|
+
| 2 | 中继是纯 `http`,无代理头处理,无 CORS | 反代/隧道后面拿不到真实 scheme,SSE 可能被中间层缓冲;浏览器预检直接失败 |
|
|
14
|
+
| 3 | 中继纯内存、无持久化 | 重启即丢设备表与任务账本,所有机器要重新配对;跨区域后中继多半在 VPS 上会被重启/迁移 |
|
|
15
|
+
| 4 | 任何已配对设备都能下发指令 | 公网暴露后,一台被攻破的机器可以反过来指挥组内其他机器 |
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## 1. 部署形态(三种都要支持)
|
|
20
|
+
|
|
21
|
+
| 形态 | TLS 由谁做 | 中继监听 | 客户端 `rabbitUrl` 长什么样 |
|
|
22
|
+
|---|---|---|---|
|
|
23
|
+
| **A. Tailscale / WireGuard** | 网络层(WireGuard 自带端到端加密) | `100.x.y.z:8787` | `http://100.x.y.z:8787`(明文 HTTP 走加密隧道,**正确且推荐**) |
|
|
24
|
+
| **B. 公网 VPS + 域名 + TLS** | 中继自己(`--tls-cert`/`--tls-key`)或前置反代 | `127.0.0.1:8787` 或 `0.0.0.0:8443` | `https://w2m.example.com` |
|
|
25
|
+
| **C. 隧道(Cloudflare Tunnel / ngrok)** | 隧道服务 | `127.0.0.1:8787` | `https://<随机子域>.example.com` **或带子路径** |
|
|
26
|
+
|
|
27
|
+
**子路径是硬要求**:形态 C 与部分反代会把服务挂在 `https://host/w2m/` 下。因此协议新增「部署基路径」概念。
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## 2. 客户端:`rabbitUrl` 语义(**修正 #1**)
|
|
32
|
+
|
|
33
|
+
`rabbitUrl` 一律解释为「**基地址**」,允许带子路径,**不允许带查询串或片段**。
|
|
34
|
+
|
|
35
|
+
```
|
|
36
|
+
合法:http://127.0.0.1:8787
|
|
37
|
+
http://100.64.0.5:8787
|
|
38
|
+
https://w2m.example.com
|
|
39
|
+
https://w2m.example.com/team-a/w2m ← 子路径
|
|
40
|
+
https://w2m.example.com/w2m/ ← 末尾斜杠可有可无
|
|
41
|
+
非法:https://w2m.example.com?x=1
|
|
42
|
+
https://w2m.example.com#frag
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
**拼接规则(唯一正确实现)**:
|
|
46
|
+
```
|
|
47
|
+
base = 去掉末尾所有 '/' 的 rabbitUrl
|
|
48
|
+
url(p) = base + (p.startsWith('/') ? p : '/' + p)
|
|
49
|
+
```
|
|
50
|
+
**禁止**用 `new URL(path, base)`(会吃掉子路径),**禁止**用 `path.posix.join`(会把 `https://` 的双斜杠压成一个)。
|
|
51
|
+
|
|
52
|
+
`rabbitUrl` 若非法(带 query/fragment、不是 http/https、解析失败)→ **启动即报错**,错误信息点名 `rabbitUrl` 与非法原因,不要静默降级。
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
## 3. 中继:部署参数(新增)
|
|
57
|
+
|
|
58
|
+
| CLI | 环境变量 | 默认 | 语义 |
|
|
59
|
+
|---|---|---|---|
|
|
60
|
+
| `--base-path <p>` | `W2M_BASE_PATH` | `/` | 中继期望自己被挂载的前缀。反代把 `/w2m/v1/task` 转成 `/v1/task` 时**不用设**;反代**原样透传** `/w2m/v1/task` 时**必须设** `--base-path /w2m` |
|
|
61
|
+
| `--trust-proxy` | `W2M_TRUST_PROXY=1` | 关 | 信任 `X-Forwarded-Proto` / `X-Forwarded-For`。**仅在确实位于可信反代之后时开启**,否则客户端可伪造 IP 绕过限流 |
|
|
62
|
+
| `--tls-cert <f>` / `--tls-key <f>` | — | — | 由中继自己终结 TLS(形态 B) |
|
|
63
|
+
| `--operator-token <t>` | `W2M_OPERATOR_TOKEN` | 首次启动自动生成 | 见 §5 |
|
|
64
|
+
| `--pair-rate-limit <n>` | — | `5` | 每 IP 每分钟 `/v1/pair` 尝试上限,`0` = 关闭 |
|
|
65
|
+
|
|
66
|
+
**`--base-path` 行为**:请求路径先剥掉前缀再路由;不在前缀下的请求返回 `404 NOT_FOUND`(不要返回网关式的 502,那是误导)。
|
|
67
|
+
`--base-path /w2m` 时 `/w2m/healthz` 与 `/healthz` **都**应答(健康检查常被反代直接打到根),但 `/v1/*` 只在前缀下应答。
|
|
68
|
+
|
|
69
|
+
**代理头**:仅在 `--trust-proxy` 开启时读取。`X-Forwarded-Proto` 用于 `/healthz` 的 `effective_scheme` 与启动日志里的公开 URL 提示;`X-Forwarded-For` 用于限流取真实客户端 IP。
|
|
70
|
+
|
|
71
|
+
---
|
|
72
|
+
|
|
73
|
+
## 4. `GET /healthz`(扩展,用于跨区域运维)
|
|
74
|
+
|
|
75
|
+
v1 已返回 `{ok, protocol_version, rabbit_time, uptime_ms, ...stats}`。v0.1.2 追加:
|
|
76
|
+
|
|
77
|
+
| 字段 | 语义 |
|
|
78
|
+
|---|---|
|
|
79
|
+
| `relay_id` | 每次进程启动生成的随机 id。**客户端据此发现"中继重启过"** |
|
|
80
|
+
| `started_at` | 进程启动时刻(RFC3339) |
|
|
81
|
+
| `effective_scheme` | `http` 或 `https`(`--trust-proxy` 时取 `X-Forwarded-Proto`,否则取实际监听) |
|
|
82
|
+
| `base_path` | 生效的部署前缀 |
|
|
83
|
+
| `operator_token_required` | bool。跨区域部署时必须为 `true`(见 §5) |
|
|
84
|
+
| `pair_rate_limit` | 每 IP 每分钟上限 |
|
|
85
|
+
| `persistence` | `{ enabled, dir, revived_devices, revived_tasks }` |
|
|
86
|
+
|
|
87
|
+
> 用途:跨区域时你最需要回答的问题是「中继还在吗、重启过吗、我的机器还连着吗」。这三个字段让它一眼可答。
|
|
88
|
+
|
|
89
|
+
---
|
|
90
|
+
|
|
91
|
+
## 5. Operator token(**修正 #4**)
|
|
92
|
+
|
|
93
|
+
区分两种凭据,**不可混用**:
|
|
94
|
+
|
|
95
|
+
| 凭据 | 持有者 | 能做什么 |
|
|
96
|
+
|---|---|---|
|
|
97
|
+
| `device_token` | 每台机器 | 自己领任务、回结果、读设备表/任务状态 |
|
|
98
|
+
| `operator_token` | **只有你** | **下发任务**(`POST /v1/task`) |
|
|
99
|
+
|
|
100
|
+
**规则**:
|
|
101
|
+
1. 中继**总是**生成或接受一个 operator token,并写入 `<state>/operator-token.txt`(权限 0600),启动时打印一次。
|
|
102
|
+
2. `POST /v1/task` 需要 `Authorization: Bearer <operator_token>`:
|
|
103
|
+
- 带头且等于 operator token → 放行;
|
|
104
|
+
- 带的是 device_token(而不是 operator token)→ **401 `OPERATOR_REQUIRED`**,message 要说清"这是设备令牌,下发任务需要操作者令牌";
|
|
105
|
+
- 不带 → 401 `OPERATOR_REQUIRED`。
|
|
106
|
+
3. 其他端点仍用 device_token(不变)。
|
|
107
|
+
4. `--operator-token ''`(空串)显式关闭该要求,并在启动时打印**醒目警告**、`/healthz` 的 `operator_token_required` 为 `false`。这是给纯局域网/调试用的逃生舱,不是默认。
|
|
108
|
+
|
|
109
|
+
**插件侧**:`w2m_run` 必须携带 operator token;配置项新增 `operatorToken`(或读 `W2M_OPERATOR_TOKEN`)。缺失时报错要点名该配置项,**不要**退回用 device token 静默尝试。
|
|
110
|
+
|
|
111
|
+
---
|
|
112
|
+
|
|
113
|
+
## 6. `/v1/pair` 限流(**配合 #4**)
|
|
114
|
+
|
|
115
|
+
- 默认每 IP 每 60s 最多 **5** 次尝试(含失败)。
|
|
116
|
+
- 超限 → `429` + 错误码 **`RATE_LIMITED`**,并带 `retry_after_seconds`。
|
|
117
|
+
- 成功的配对**也计数**(否则可以用成功请求刷掉失败预算)。
|
|
118
|
+
- IP 取 `X-Forwarded-For` 首项**仅当** `--trust-proxy`;否则取 socket 地址。
|
|
119
|
+
- 内存实现即可(滑动窗口),无需持久化。
|
|
120
|
+
|
|
121
|
+
---
|
|
122
|
+
|
|
123
|
+
## 7. 持久化(**修正 #3**)
|
|
124
|
+
|
|
125
|
+
`<state>/` 下:
|
|
126
|
+
|
|
127
|
+
| 文件 | 形式 | 内容 |
|
|
128
|
+
|---|---|---|
|
|
129
|
+
| `devices.json` | 原子快照(写临时文件 + rename) | `{machine_id → {machine_name, platform, caps, user_id, device_token, paired_at, re_paired}}` |
|
|
130
|
+
| `ledger.jsonl` | append-only,每行一个 JSON | 任务创建、结果入库、租约状态变更 |
|
|
131
|
+
| `operator-token.txt` | 单行文本,0600 | operator token |
|
|
132
|
+
|
|
133
|
+
**启动恢复**:读 `devices.json` 重建设备表与 token 索引;重放 `ledger.jsonl` 重建任务、租约、去重键。
|
|
134
|
+
**写入时机**:设备变化立即快照;账本事件同步 append(`fs.appendFileSync` 足够,量小)。
|
|
135
|
+
**损坏处理**:某行 JSON 解析失败 → **跳过该行并计入 `warnings`**,不要让中继启动失败;`devices.json` 损坏 → 从空表启动并在启动日志与 `/healthz` 里**明确报告**,不要静默清空。
|
|
136
|
+
**禁用**:`--state ''` 或 `--no-persist` → 纯内存(v1 行为),`/healthz` 的 `persistence.enabled = false`。
|
|
137
|
+
|
|
138
|
+
---
|
|
139
|
+
|
|
140
|
+
## 8. 跨区域可靠性(小改动,大收益)
|
|
141
|
+
|
|
142
|
+
1. **重连抖动**:现有退避是 `[500,1000,2000,4000,8000,10000]` + 抖动。跨区域后**多台机器同时断线会同时重连**,需要抖动区间足够宽;保持现状即可,但**必须在 `notice` 里暴露"我正在重连,第 N 次"**,否则远程排障只能靠猜。
|
|
143
|
+
2. **RTT 可见**:agent 每次心跳记录到中继的往返时间,滚动保存最近 5 次到 `state.rttMs`,并在信封的 `platform` 旁**不新增字段**,而是通过 `w2m_status` 工具暴露。跨区域时"延迟多少"是第一个要问的问题。
|
|
144
|
+
3. **中继重启检测**:SSE 首帧的 `ready` 带上 `relay_id`;agent 发现 `relay_id` 变化 → 记 `warn` 日志并清空本地 seq 游标(旧 seq 在新进程里无意义)。
|
|
145
|
+
4. **重放窗口**:`notice/REPLAY_TRUNCATED` 已存在;v0.1.2 起该事件必须带 `oldest_available_seq`,让客户端知道该从哪重新对齐,而不是只知道"补不上了"。
|
|
146
|
+
5. **SSE 抗缓冲**:响应头显式带 `X-Accel-Buffering: no` 与 `Cache-Control: no-cache, no-transform`,防止 nginx 类反代把事件流缓冲住(这是隧道部署最常见的"能连上但收不到事件")。
|
|
147
|
+
|
|
148
|
+
---
|
|
149
|
+
|
|
150
|
+
## 9. 版本
|
|
151
|
+
|
|
152
|
+
- 包版本 → **0.1.2**(`package.json`)。
|
|
153
|
+
- `PROTOCOL_VERSION` **保持 1**:以上全部是**向后兼容的追加**(新端点字段、新错误码、新配置项)。
|
|
154
|
+
- 唯一的行为变更:`POST /v1/task` 在**配置了 operator token 时**需要它 —— 这是安全修复,会在 CHANGELOG 的 Breaking 区注明,并给出关闭方法。
|
|
155
|
+
- v1 客户端(0.0.1)连 v0.1.2 中继:能配对、能领任务、能回结果,但**无法下发任务**(缺 operator token)。这是有意的。
|
|
156
|
+
|
|
157
|
+
---
|
|
158
|
+
|
|
159
|
+
## 10. 本版不做(明确边界)
|
|
160
|
+
|
|
161
|
+
- ❌ 不做 WebSocket 传输(SSE 在三种部署形态下都够用,换传输是大改动)
|
|
162
|
+
- ❌ 不做端到端加密(形态 A 由 WireGuard 负责;形态 B/C 由 TLS 负责)
|
|
163
|
+
- ❌ 不做多中继联邦 / 跨中继转发(多区域只支持「各自的机器连同一个中继」)
|
|
164
|
+
- ❌ 不做中继高可用(单点;账本可重建,设备表可快照)
|
|
165
|
+
- ❌ 不做 mTLS 客户端证书(配对码 + 双 token 已覆盖威胁模型)
|
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
# W2M 线协议 v0.3.0 增量(跨机 RTT 走中继)
|
|
2
|
+
|
|
3
|
+
> 基线:`PROTOCOL.md`(v1 冻结)+ `PROTOCOL-v0.1.2.md` 增量。**本文件只写增量**,未提及的部分一律不变。
|
|
4
|
+
> `PROTOCOL_VERSION` **保持 1**:全部是向后兼容的**追加**(新可选字段、新响应字段、新端点)。
|
|
5
|
+
> 主题:让**延迟**这个诊断量跨机器可见。
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## 0. 为什么需要这版
|
|
10
|
+
|
|
11
|
+
v0.2.3 的 RTT 走**本机文件**(agent 写 `<stateDir>/agent-state.json`,插件读同一个文件)。同机可用,但:
|
|
12
|
+
|
|
13
|
+
- 跨区域部署下**插件在 A 机、agent 在 B 机**,文件通道完全无效;
|
|
14
|
+
- 中继才是唯一同时看得见所有机器的地方。
|
|
15
|
+
|
|
16
|
+
因此 v0.3.0 起,**RTT 由 agent 通过心跳上报给中继,由中继存储并暴露**。本机文件通道不再承担跨机职责。
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## 1. 设计原则(本版新增条款)
|
|
21
|
+
|
|
22
|
+
| 原则 | 含义 |
|
|
23
|
+
|---|---|
|
|
24
|
+
| **`null` ≠ `0`** | `null` 是"从未测到",`0` 是"极快"。任何把二者混同的实现都是缺陷 |
|
|
25
|
+
| **陌生字段不拒绝** | 心跳里出现本版不认识的字段必须被忽略并正常处理,不得 400 |
|
|
26
|
+
| **诊断量不得影响主流程** | `rtt_ms` 缺失或非法**绝不**导致心跳失败;租约续期永远优先 |
|
|
27
|
+
| **重启不撒谎** | 易失量(RTT)不持久化、不恢复;重启后一律回到"不知道" |
|
|
28
|
+
| **只增字段** | 不删字段、不改字段语义、不改已有端点行为 |
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## 2. `POST /v1/heartbeat`:两种模式 + 新增可选字段 `rtt_ms`
|
|
33
|
+
|
|
34
|
+
v0.3.0 起心跳有**两种模式**,由是否携带 `task_id` 决定:
|
|
35
|
+
|
|
36
|
+
| 模式 | 触发条件 | 作用 | 是否触碰租约 |
|
|
37
|
+
|---|---|---|---|
|
|
38
|
+
| **租约心跳** | 携带 `task_id` | §4.3 原语义:续租、`phase`/`progress`、`cancel` 反馈 | **是** |
|
|
39
|
+
| **空闲(诊断)心跳** | 不带 `task_id`,且 `diagnostic: true` | 仅刷新该机的 `last_heartbeat_at` / `rtt_ms` | **否,完全不触碰** |
|
|
40
|
+
| 都不是 | 无 `task_id` 且无 `diagnostic` | `400 BAD_REQUEST` | — |
|
|
41
|
+
|
|
42
|
+
```json
|
|
43
|
+
// 空闲(诊断)心跳 —— 机器没有任务时也要能被看见
|
|
44
|
+
{ "diagnostic": true, "rtt_ms": 42.5 }
|
|
45
|
+
|
|
46
|
+
// 租约心跳 —— 与 v0.2.3 完全一致
|
|
47
|
+
{ "task_id": "01J…", "machine_id": "3f2a…", "attempt": 1, "phase": "running", "progress": 0.4, "rtt_ms": 42.5 }
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
> **§4.3 语义变更**:心跳**不再隐含租约续期**。只有携带 `task_id` 的心跳才续租;不携带 `task_id` 的心跳是纯诊断流量。
|
|
51
|
+
|
|
52
|
+
### 2.1 为什么需要空闲心跳
|
|
53
|
+
跨机状态视图若只在持租约时刷新,**一台空闲机器恰恰是最需要被观察的对象**(没活干的时候才去看它)。缺了它,机器一闲下来 RTT 就冻在最后一次任务的值上,并在窗口过后变成 `rtt_stale: true` —— **比"未知"更糟**,因为它看起来像"机器在,但很慢"。
|
|
54
|
+
|
|
55
|
+
推荐节奏:**每 60s 一次**(中继常量 `DEFAULT_IDLE_HEARTBEAT_MS`)。租约心跳仍是 10s,且只在有任务时发送。
|
|
56
|
+
|
|
57
|
+
### 2.2 ⚠️ 安全不变式:空闲心跳绝不允许触碰租约
|
|
58
|
+
空闲心跳的处理路径**不查询任何 task**,这是**结构性**保证而非"记得别写错":
|
|
59
|
+
|
|
60
|
+
> 若空闲心跳能够续租,一台租约已 `expired` 的机器发一次空闲 ping 就会把 `expired` 洗回 `running`,**凭空制造出"任务仍在执行"的假象** —— 这是本中继能说出的最危险的谎言。
|
|
61
|
+
|
|
62
|
+
因此空闲心跳与任务账本完全解耦;`expired` 的归属权始终只属于 sweep/接管路径。此不变式有专门用例(`THE INVARIANT: an idle heartbeat never resurrects an expired lease`)。
|
|
63
|
+
|
|
64
|
+
### 2.3 为什么"缺 `task_id` 且无 `diagnostic`"必须报错
|
|
65
|
+
若把"缺 `task_id`"直接当作纯诊断心跳,那么一个**掉了 `task_id` 字段的 agent** 会静默地不再续租,却以为自己一切正常,最终导致**任务被误判 `expired`**。这与 §0「绝不静默降级」相冲突,故显式报 `400`。
|
|
66
|
+
|
|
67
|
+
### 2.4 `rtt_ms` 取值规则
|
|
68
|
+
|
|
69
|
+
| 字段 | 类型 | 语义 |
|
|
70
|
+
|---|---|---|
|
|
71
|
+
| `rtt_ms` | `number` | agent 测得的**到中继**的往返时间,单位毫秒。可选,两种模式都可用 |
|
|
72
|
+
|
|
73
|
+
**取值规则(中继侧实现,逐条有测试)**:
|
|
74
|
+
|
|
75
|
+
| 输入 | 行为 |
|
|
76
|
+
|---|---|
|
|
77
|
+
| 缺失 | 忽略;**保持上一次的值**;心跳照常成功 |
|
|
78
|
+
| 合法:有限数字且 `0 ≤ v ≤ 86400000` | 记录该值,并把 `rtt_at` 置为本次心跳时刻 |
|
|
79
|
+
| 非法:非 number / `NaN` / `±Infinity` / `< 0` / `> 86400000`(24h) | **忽略**,保持旧值;心跳**照常成功** |
|
|
80
|
+
| `null` | 视为非法(等同缺失)→ 保持旧值 |
|
|
81
|
+
| 任何未知字段(`future_field`、`rtt_ms_v2` …) | 完全忽略,不拒绝、不存入状态 |
|
|
82
|
+
|
|
83
|
+
**为什么非法值不报 `400`**:RTT 是诊断量。若非法值能拒绝心跳,一个有 bug 的 agent 就会因为"延迟上报写错"而**丢掉自己的租约**,任务被误判 `expired` —— 用一个诊断字段的 bug 制造一次虚假的任务失败,代价远大于收益。协议在"未知字段"上的宽容原则同样适用于"已知字段的坏值"。
|
|
84
|
+
|
|
85
|
+
> 注意这里的**有意不对称**:`phase`/`progress` 非法仍然报 `400`(它们是**控制**字段,会改变执行语义),而 `rtt_ms` 非法只忽略(它是**诊断**字段)。
|
|
86
|
+
|
|
87
|
+
**24h 上限的理由**:单个垃圾值(例如某 agent 把纳秒当毫秒)会污染 `/healthz` 的舰队聚合。上限取 24h 是因为任何物理上可能的 RTT 都远小于它。
|
|
88
|
+
|
|
89
|
+
**刻意不提供"清除"操作**:没有 `rtt_ms: null` → 清空的语义。值只会被**新的有效测量**覆盖;"这个数还可不可信"由 §7 的新鲜度字段表达,而不是靠清空。
|
|
90
|
+
|
|
91
|
+
### 2.5 空闲心跳的响应
|
|
92
|
+
|
|
93
|
+
```json
|
|
94
|
+
{ "lease_until": null, "cancel": false, "diagnostic": true, "machine_id": "3f2a…", "rtt_ms": 42.5 }
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
`lease_until: null` 与 `diagnostic: true` 是明确的"**本次没有续任何租约**"信号:客户端**不得**把它当成一次成功的续租。`cancel` 恒为 `false`(无任务可取消)。
|
|
98
|
+
|
|
99
|
+
---
|
|
100
|
+
|
|
101
|
+
## 3. 中继状态新增字段(设备维度)
|
|
102
|
+
|
|
103
|
+
`GET /v1/devices` 的**每个设备条目**追加:
|
|
104
|
+
|
|
105
|
+
| 字段 | 类型 | 语义 |
|
|
106
|
+
|---|---|---|
|
|
107
|
+
| `rtt_ms` | `number \| null` | 最近一次测得的往返时间。**`null` = 从未测到** |
|
|
108
|
+
| `rtt_at` | `RFC3339 \| null` | 该数值的**测量时刻**(不是最后一次心跳时刻) |
|
|
109
|
+
| `rtt_age_ms` | `int \| null` | `now - rtt_at`;无值时 `null` |
|
|
110
|
+
| `rtt_stale` | `bool` | 见 §7 |
|
|
111
|
+
| `last_heartbeat_at` | `RFC3339 \| null` | 本进程内最后一次收到该机心跳的时刻;重启后为 `null` |
|
|
112
|
+
| `reconnect_attempts` | `int` | **中继观测到**的重连次数 =(SSE 成功 attach 次数 − 1) |
|
|
113
|
+
|
|
114
|
+
> `reconnect_attempts` 是中继**自己数的**连接次数,不是 agent 上报的数字。理由是它必须在 agent 不配合时也成立;代价是**进程内计数**,重启归零(`relay_id` 变化会告诉读者这一点)。
|
|
115
|
+
>
|
|
116
|
+
> `last_heartbeat_at` 与 `rtt_at` 回答**两个不同问题**,不可合并:前者是"这台机器还活着吗",后者是"这个延迟数字是什么时候量的"。
|
|
117
|
+
|
|
118
|
+
---
|
|
119
|
+
|
|
120
|
+
## 4. `GET /healthz` 新增 `rtt` 聚合
|
|
121
|
+
|
|
122
|
+
```json
|
|
123
|
+
"rtt": {
|
|
124
|
+
"machines_reporting": 2,
|
|
125
|
+
"min_ms": 12,
|
|
126
|
+
"max_ms": 340,
|
|
127
|
+
"avg_ms": 176,
|
|
128
|
+
"machines_stale": 1,
|
|
129
|
+
"machines_unknown": 3
|
|
130
|
+
}
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
| 字段 | 语义 |
|
|
134
|
+
|---|---|
|
|
135
|
+
| `machines_reporting` | **新鲜**(`rtt_stale === false`)测量的机器数 |
|
|
136
|
+
| `min_ms` / `max_ms` / `avg_ms` | 仅对**新鲜**测量取值;**无新鲜数据时一律 `null`,绝不是 `0`** |
|
|
137
|
+
| `machines_stale` | 有值但已过期的机器数(附加字段) |
|
|
138
|
+
| `machines_unknown` | 从未上报过的机器数(附加字段) |
|
|
139
|
+
|
|
140
|
+
`avg_ms` 保留 1 位小数。**陈旧值不计入聚合**:一台三天前就不在的机器不该继续拉偏舰队延迟。
|
|
141
|
+
|
|
142
|
+
---
|
|
143
|
+
|
|
144
|
+
## 5. 新端点 `GET /v1/agents/{machine_id}/status`
|
|
145
|
+
|
|
146
|
+
**鉴权**:`Authorization: Bearer <device_token>`(与其它设备端点一致;operator token 不适用于此端点)。
|
|
147
|
+
|
|
148
|
+
```json
|
|
149
|
+
{
|
|
150
|
+
"protocol_version": 1,
|
|
151
|
+
"rabbit_time": "2026-10-08T00:00:00Z",
|
|
152
|
+
"machine_id": "3f2a…",
|
|
153
|
+
"machine_name": "win-desktop",
|
|
154
|
+
"relay_id": "6d3a46616f106399",
|
|
155
|
+
"connected": true,
|
|
156
|
+
"streams": 1,
|
|
157
|
+
"online": true,
|
|
158
|
+
"rtt_ms": 42.5,
|
|
159
|
+
"rtt_at": "2026-10-08T00:00:00Z",
|
|
160
|
+
"rtt_age_ms": 1200,
|
|
161
|
+
"rtt_stale": false,
|
|
162
|
+
"last_heartbeat_at": "2026-10-08T00:00:00Z",
|
|
163
|
+
"last_seen_at": "2026-10-08T00:00:00Z",
|
|
164
|
+
"reconnect_attempts": 2,
|
|
165
|
+
"stream_connects": 3,
|
|
166
|
+
"paired_at": "2026-10-07T10:00:00Z",
|
|
167
|
+
"platform": { "os": "windows" },
|
|
168
|
+
"caps": { "node": "v24.21.0" },
|
|
169
|
+
"user_id": null,
|
|
170
|
+
"now": "2026-10-08T00:00:01Z"
|
|
171
|
+
}
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
- `connected` = 当前是否有 SSE 流挂在这台机器上(`streams > 0`)。
|
|
175
|
+
- 未知 `machine_id` → `404 NOT_FOUND`;无 token → `401 UNAUTHORIZED`。
|
|
176
|
+
- 响应**不含任何凭据**(无 `device_token`)。
|
|
177
|
+
|
|
178
|
+
---
|
|
179
|
+
|
|
180
|
+
## 6. 重启语义(**裁定**)
|
|
181
|
+
|
|
182
|
+
**裁定:`rtt_ms` 不持久化;重启后为 `null`,不做"恢复 + stale 标记"。**
|
|
183
|
+
|
|
184
|
+
理由:
|
|
185
|
+
|
|
186
|
+
1. **`stale` 标记无法表达"跨进程"这件事**。`rtt_stale` 是按"测量时刻距今多久"算的,而重启后这个数字**同样无法验证** —— 恢复出来的 `rtt_at` 与真实值之间隔着一次进程死亡,谁也不知道那段时间里网络变成了什么样。给一个不可验证的时间戳打标记,只是把谎言包装得更精致。
|
|
187
|
+
2. **进程死亡与网络中断无法区分**。SIGKILL 之后中继没有机会记录"我要走了"。恢复出来的值既可能是 5 秒前的,也可能是 5 天前的,而中继**无法知道**是哪种。
|
|
188
|
+
3. **代价不对称**。丢失 RTT 的代价是"重启后几秒到几十秒内看不到延迟"(下一次心跳就恢复);而谎报的代价是运维**根据错误数据做跨区域排障决策**。
|
|
189
|
+
4. **实现上不可能"意外说真话"**。`restore()` **显式**把 `rtt_ms`/`rtt_at`/`last_heartbeat_at` 置 `null`:即使将来有人往 `devices.json` 里写 RTT,重启也会丢弃它。
|
|
190
|
+
|
|
191
|
+
同一个理由适用于 `ledger`:账本重放**不**恢复 RTT。
|
|
192
|
+
|
|
193
|
+
---
|
|
194
|
+
|
|
195
|
+
## 7. 陈旧性:读者如何区分"真的这么慢"与"这台机器早就不在了"(**裁定**)
|
|
196
|
+
|
|
197
|
+
**`rtt_stale` 的定义**:
|
|
198
|
+
|
|
199
|
+
```
|
|
200
|
+
rtt_stale = (rtt_ms === null) 或 (now - rtt_at > rttStaleMs)
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
**默认 `rttStaleMs = 180000`(180s = 3 × 空闲心跳间隔)**。边界取"**严格大于**":恰好等于窗口仍算新鲜。
|
|
204
|
+
|
|
205
|
+
### 7.1 为什么窗口必须是空闲心跳间隔的 3 倍,而不是 1 倍
|
|
206
|
+
(这是一个会导致功能悄悄失效的坑)
|
|
207
|
+
|
|
208
|
+
若窗口取 60s 而空闲心跳也是 60s,那么每次心跳**恰好**在窗口到期时到达:任何一点网络抖动都会让值在刷新前**瞬间翻成 stale**,下一次心跳又翻回 fresh。结果:
|
|
209
|
+
|
|
210
|
+
- `rtt_stale` 开始**抖动**,`rtt_stale === false` 于是不再是"可信"的可靠谓词 —— 读者要么误判,要么被迫自己加重试/去抖逻辑;
|
|
211
|
+
- 而这正是 §7 存在的理由(把判断压缩成一次比较)。抖动的布尔值比没有布尔值更坏,因为它看起来能用。
|
|
212
|
+
|
|
213
|
+
取 3 倍意味着**连续丢两次空闲心跳**仍不翻 stale,第三次才翻 —— 这与"陈旧"这个词的直觉一致,也让 `rtt_stale === false` 保持其核心性质。
|
|
214
|
+
|
|
215
|
+
**关键性质:`rtt_stale === false` 是一个可以无条件信任的谓词** —— 它保证"有一个值,且值是新鲜的"。因此读者的正确用法是:
|
|
216
|
+
|
|
217
|
+
```
|
|
218
|
+
只有当 rtt_stale === false 时,才把 rtt_ms 当作可信的延迟数字。
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
**四元组判读表**(`/v1/agents/{id}/status`):
|
|
222
|
+
|
|
223
|
+
| 场景 | `rtt_ms` | `rtt_age_ms` | `rtt_stale` | `connected` | 读者应理解为 |
|
|
224
|
+
|---|---|---|---|---|---|
|
|
225
|
+
| 正常 | `42` | `1200` | `false` | `true` | **真的 42ms** |
|
|
226
|
+
| 真的慢 | `800` | `3000` | `false` | `true` | **真的 800ms** |
|
|
227
|
+
| 空闲但活着(有空闲心跳) | `42` | `45_000` | `false` | `false` | **真的 42ms**(无流但有心跳) |
|
|
228
|
+
| 机器早就不在了 | `42` | `600_000` | `true` | `false` | **历史值,别用** |
|
|
229
|
+
| 老客户端(v0.2.3) | `null` | `null` | `true` | `true` | 机器活着,但**延迟未知** |
|
|
230
|
+
| 从未测过 | `null` | `null` | `true` | `false` | 未知 |
|
|
231
|
+
|
|
232
|
+
### 7.2 两个正交信号,不要互相污染
|
|
233
|
+
- **`connected`** = **瞬时**链路状态(此刻是否有 SSE 流):机器"现在是不是断的"用它,秒级准确。
|
|
234
|
+
- **`rtt_stale`** = **测量新鲜度**(那个数字还值不值得信):分钟级。
|
|
235
|
+
|
|
236
|
+
`connected` **不参与** `rtt_stale` 的计算,这是刻意的:流断开 1 秒但 5 秒前刚心跳过的机器,其 RTT 依然新鲜有效;把连接状态混进新鲜度,会让一次瞬时抖动把好数据标成不可信。反过来,空闲机器 `connected: false` 但 `rtt_stale: false` 正是"没有流、但刚刚报过平安"的正确表达。
|
|
237
|
+
|
|
238
|
+
**为什么这足够**:区分"慢"和"不在"需要两个正交信息 —— ①**数值本身**(`rtt_ms`)②**数值的新鲜度**(`rtt_age_ms`/`rtt_stale`)。只看 `rtt_ms` 必然混淆这两者,而 `rtt_stale` 把②压缩成一个布尔,使读者的判断退化为**一次比较**,不需要自己拿 `rtt_at` 和当前时间做减法(那正是各客户端实现容易写错、且容易因时钟漂移出错的地方)。同时保留 `rtt_at`/`last_heartbeat_at` 原始时间戳,供需要精确诊断的读者使用。
|
|
239
|
+
|
|
240
|
+
**`connected` 不参与 `rtt_stale` 的计算**,这是刻意的:流断开 1 秒但 5 秒前刚心跳过的机器,其 RTT 依然新鲜有效;把连接状态混进新鲜度会让一个瞬时的网络抖动把好数据标成不可信。
|
|
241
|
+
|
|
242
|
+
---
|
|
243
|
+
|
|
244
|
+
## 8. 向后兼容矩阵
|
|
245
|
+
|
|
246
|
+
### 8.1 v0.2.3 客户端 → v0.3.0 中继:**完全可用,零改动**
|
|
247
|
+
|
|
248
|
+
| 交互 | 结果 |
|
|
249
|
+
|---|---|
|
|
250
|
+
| `POST /v1/pair` | 不变 |
|
|
251
|
+
| `GET /v1/stream` | 不变;`ready` 帧的 `relay_id` 是 v0.1.2 就有的追加字段 |
|
|
252
|
+
| `POST /v1/heartbeat`(**不带** `rtt_ms`) | `200`,租约照常续期;`last_heartbeat_at` 正常推进 |
|
|
253
|
+
| `POST /v1/heartbeat`(不带 `task_id`、不带 `diagnostic`) | **`400`** —— 老 agent 永远带着 `task_id`,因此不受影响;这是新增的显式拒绝分支 |
|
|
254
|
+
| 空闲心跳 | 老 agent **不会**发送;中继支持它纯属追加能力,对老客户端零影响 |
|
|
255
|
+
| `POST /v1/result` / `GET /v1/tasks*` | 不变 |
|
|
256
|
+
| `GET /v1/devices` | 响应**多了 6 个字段**;按"未知字段必须被忽略"约定,老客户端不受影响 |
|
|
257
|
+
| `GET /healthz` | 多了 `rtt` 对象;同上 |
|
|
258
|
+
| 该机的 RTT 视图 | `rtt_ms: null`、`rtt_stale: true` —— **正确表达了"未知",而不是伪造一个 0** |
|
|
259
|
+
|
|
260
|
+
### 8.2 v0.3.0 客户端 → v0.2.3 中继:**agent 可用;插件必须优雅降级**
|
|
261
|
+
|
|
262
|
+
| 交互 | 结果 | 客户端要求 |
|
|
263
|
+
|---|---|---|
|
|
264
|
+
| `POST /v1/heartbeat` **带** `rtt_ms` | v0.2.3 中继只读取自己认识的字段 → `200`,`rtt_ms` 被忽略 | 无 |
|
|
265
|
+
| `POST /v1/heartbeat` **空闲模式**(`diagnostic: true`,无 `task_id`) | v0.2.3 中继走 `requireTask(undefined)` → **`404 NOT_FOUND`** | agent **必须容忍该 404**:空闲心跳是尽力而为的诊断流量,失败只记 debug 日志,**不得重试风暴、不得影响任务执行** |
|
|
266
|
+
| `GET /v1/agents/{id}/status` | **`404 NOT_FOUND`**(老中继没有该路由) | 插件**必须捕获 404** 并显示"中继版本过旧,无法提供跨机 RTT",**不得崩溃、不得重试风暴** |
|
|
267
|
+
| `GET /healthz` 的 `rtt` | **字段不存在**(不是 `null`) | 插件必须把"字段缺失"与"值为 `null`"**同等**处理为"不可用",**绝不能当成 0** |
|
|
268
|
+
| `GET /v1/devices` 条目的 `rtt_ms` | **字段不存在** | 同上,按 `null` 处理 |
|
|
269
|
+
|
|
270
|
+
> 结论:**新客户端连老中继的唯一要求是"把缺失字段当未知"**。这也是本节存在的意义 —— 兼容性矩阵必须写清降级方向,否则跨版本部署时插件会在一个 `undefined.toFixed()` 上崩掉。
|
|
271
|
+
|
|
272
|
+
---
|
|
273
|
+
|
|
274
|
+
## 9. 请求签名(HMAC)接入 HTTP 层(**裁定**)
|
|
275
|
+
|
|
276
|
+
> 线格式与密码学细节由 `src/signing.mjs` 定义并自带 33 条用例。本节定义**中继如何接线**:哪些端点校验、配置项、错误映射、以及迁移纪律。
|
|
277
|
+
|
|
278
|
+
### 9.1 配置项(中继)
|
|
279
|
+
|
|
280
|
+
| `createRelayServer` 选项 | 建议 CLI 参数 | 默认 | 语义 |
|
|
281
|
+
|---|---|---|---|
|
|
282
|
+
| `signingSecret` | `--signing-secret <s>` | 无 | 当前签名密钥。**未配置 = 完全不校验**,行为与 v0.2.3 逐字节一致 |
|
|
283
|
+
| `signingSecretPrevious` | `--signing-secret-previous <s>` | 无 | 轮换期间的旧密钥(仍被接受)。列表顺序 = `[当前, 旧]`,`keyIndex` 0/1 |
|
|
284
|
+
| `requireSignature` | `--require-signature` | `false` | **唯一的强制开关**。开启后未签名 = `401 SIGNATURE_REQUIRED` |
|
|
285
|
+
| `signatureSkewSeconds` | `--signature-skew <n>` | `120` | 允许的时钟偏移(秒),由 `signing.mjs` 的 `DEFAULT_SKEW_SECONDS` 定义 |
|
|
286
|
+
|
|
287
|
+
> `signingSecret` 若**存在但不是非空字符串**(例如数字、对象)→ 中继**构造时即报错**。绝不静默降级成"没配签名" —— 那正是 `signing.mjs` 开头点名要避免的失败模式。
|
|
288
|
+
|
|
289
|
+
### 9.2 端点覆盖(本版裁定)
|
|
290
|
+
|
|
291
|
+
| 端点 | 未配置密钥 | 配置了密钥(默认) | 配置了 + `--require-signature` |
|
|
292
|
+
|---|---|---|---|
|
|
293
|
+
| `GET /healthz` | 公开 | 公开 | **仍然公开**(永久豁免) |
|
|
294
|
+
| `POST /v1/pair` | 公开 | 公开 | **仍然公开**(永久豁免) |
|
|
295
|
+
| 写端点:`POST /v1/task`、`/v1/heartbeat`、`/v1/result` | 不校验 | **有签名则校验;未签名仍接受** | **必须签名** |
|
|
296
|
+
| 读端点:`GET /v1/devices`、`/v1/tasks`、`/v1/tasks/{id}`、`/v1/tasks/{id}/report`、`/v1/agents/{id}/status`、`/v1/stream` | 不校验 | **不校验**(签名头被忽略) | **必须签名** |
|
|
297
|
+
|
|
298
|
+
**两个永久豁免的理由**:
|
|
299
|
+
- `/healthz` 是运维探针(systemd / Docker healthcheck / 负载均衡 / uptime 监控)。要求它带凭据会把这些系统的密钥暴露面乘以它们的数量,而且是本版唯一能让**监控整体失联**的改动。
|
|
300
|
+
- `/v1/pair` 是**引导步骤**:此刻双方还没有共享密钥。要求签名等于要求在配对前分发签名密钥,会让 `signing.mjs` 那句"不需要额外分发密钥"的立论失效。
|
|
301
|
+
|
|
302
|
+
### 9.3 读端点裁定:默认不校验(与 lead 的倾向一致,理由如下)
|
|
303
|
+
|
|
304
|
+
**写端点默认校验;读端点默认不校验,只有 `--require-signature` 才校验。**
|
|
305
|
+
|
|
306
|
+
1. **未签名的读本来就被接受,所以"有签名才校验"是安全剧场。** 攻击者只需**不发送**那三个头就能降级——除非同时也拒绝未签名,否则校验不产生任何实际防护。
|
|
307
|
+
2. **读泄露的是拓扑,不是可执行能力。** `/v1/devices`、`/v1/tasks` 已经要求 `device_token`。签名要防的是**重放**与**中间盒篡改**;重放一次 GET 是无害的(幂等、返回同样的数据),篡改一个 GET 请求只能改 `limit` 这类由调用方自己控制的参数。
|
|
308
|
+
3. **代价不对称。** 写端点被伪造/重放会产生**状态变更**;读端点加校验只增加 CPU 与一种新的失败模式——而 `/v1/stream` 恰好是我们刚花两个任务修好的重连路径,不该再给它加新的失败方式。
|
|
309
|
+
4. **硬模式必须名副其实。** 名字叫 `--require-signature` 却在读端点上不要求,是对操作者的误导。开启它就要求**除两个引导端点外的全部 `/v1/*`**,规则单一、无需按方法记忆。
|
|
310
|
+
|
|
311
|
+
### 9.4 签名覆盖的"路径"是**路由后的路径**(⚠️ 客户端必读)
|
|
312
|
+
|
|
313
|
+
签名的 `path` 是**剥掉部署前缀之后的路由路径 + 查询串**,**不是**原始 URL。
|
|
314
|
+
|
|
315
|
+
```
|
|
316
|
+
客户端对 https://host/w2m/v1/result 发起请求时,签名的 path 是: /v1/result
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
理由:v0.1.2 §3 的三种部署形态下,同一个客户端必须产生**同一个**签名 —— 反代可能把 `/w2m/v1/result` 原样透传,也可能剥成 `/v1/result`。**路由后的路径是双方在三种形态下唯一都能达成一致的值**。若签原始 URL 路径,则"加一层反代"就会让全部签名失效,而人们遇到这种情况的做法通常是关掉签名。
|
|
320
|
+
|
|
321
|
+
**因此:客户端永远签 `/v1/result`,而不是 `https://host/w2m/v1/result`。** 中继**只接受**路由路径一种形式(不接受两种),因为"两者都收"会让同一个签名在两个不同端点上有效,削弱绑定;而且一旦接受两种形式,就**没有任何用例能证明客户端签对了**——错的也会通过。
|
|
322
|
+
|
|
323
|
+
### 9.4.1 确定性测试向量(**双方必须逐字节一致**)
|
|
324
|
+
|
|
325
|
+
> 用途:发送方与验证方各自算一遍再比对,比任何单边自证都强。这两条向量**已被 `test/relay.test.mjs` 的 `signing test vectors (§9.4.1)` 用例钉死**:canonical string、签名值、以及"真实中继接受该签名"三步。任何一侧改算法都会立刻变红。
|
|
326
|
+
> 密钥:`test-secret-abc`(**仅供测试向量使用,切勿用于任何真实部署**)
|
|
327
|
+
|
|
328
|
+
**向量 1 —— `POST /v1/heartbeat`**
|
|
329
|
+
|
|
330
|
+
| 输入 | 值 |
|
|
331
|
+
|---|---|
|
|
332
|
+
| `method` | `POST` |
|
|
333
|
+
| `path` | `/v1/heartbeat` |
|
|
334
|
+
| `timestamp` | `1780000000` |
|
|
335
|
+
| `nonce` | `abcdef0123456789` |
|
|
336
|
+
| `body` | `{"task_id":"T1","machine_id":"m1"}` |
|
|
337
|
+
|
|
338
|
+
```
|
|
339
|
+
canonical string(6 段,以 \n 连接):
|
|
340
|
+
v1
|
|
341
|
+
POST
|
|
342
|
+
/v1/heartbeat
|
|
343
|
+
1780000000
|
|
344
|
+
abcdef0123456789
|
|
345
|
+
c132705f2342284320b7e59ef2f32f9d580f6ecf1b83116ae22b71ad6fa09d28
|
|
346
|
+
|
|
347
|
+
X-W2M-Timestamp: 1780000000
|
|
348
|
+
X-W2M-Nonce: abcdef0123456789
|
|
349
|
+
X-W2M-Signature: v1=a8de86289154861c7289b87e3bb4f39121c5ca0f59d50f819285efe3265778db
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
**向量 2 —— `GET /v1/stream`(含 query)**
|
|
353
|
+
|
|
354
|
+
| 输入 | 值 |
|
|
355
|
+
|---|---|
|
|
356
|
+
| `method` | `GET` |
|
|
357
|
+
| `path` | `/v1/stream?machine_id=m1&seq=4` |
|
|
358
|
+
| `timestamp` | `1780000001` |
|
|
359
|
+
| `nonce` | `0123456789abcdef` |
|
|
360
|
+
| `body` | *(空)* |
|
|
361
|
+
|
|
362
|
+
```
|
|
363
|
+
canonical string:
|
|
364
|
+
v1
|
|
365
|
+
GET
|
|
366
|
+
/v1/stream?machine_id=m1&seq=4
|
|
367
|
+
1780000001
|
|
368
|
+
0123456789abcdef
|
|
369
|
+
b613679a0814d9ec772f95d778c35fc5ff1697c493715653c6c712144292c5ad
|
|
370
|
+
|
|
371
|
+
X-W2M-Signature: v1=a9c899e05b70be0c4fce37ef7cbedacd8bdf36984faeafc8499f13a135776ce6
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
> 第 6 段是 **body 的 HMAC-SHA256(以空密钥)十六进制**,不是 body 本身。空 body 时它是 `b613679a…c5ad`(即 `HMAC-SHA256(key="", msg="")`)。
|
|
375
|
+
|
|
376
|
+
**前缀敏感性对照**(相同输入与密钥,仅 `path` 不同)——必须不同,这就是"签错 path 必然验不过"的可验证证据:
|
|
377
|
+
|
|
378
|
+
| path | signature |
|
|
379
|
+
|---|---|
|
|
380
|
+
| `/v1/heartbeat` | `v1=a8de86289154861c7289b87e3bb4f39121c5ca0f59d50f819285efe3265778db` |
|
|
381
|
+
| `/w2m/v1/heartbeat` | `v1=20c0f143fd27a61c86d876fdd802d143645d5238a301b7d91269205e37a6300d` |
|
|
382
|
+
|
|
383
|
+
**复算方式**(两侧都应能独立复现):
|
|
384
|
+
|
|
385
|
+
```js
|
|
386
|
+
import { canonicalString, signRequest } from './src/signing.mjs';
|
|
387
|
+
const input = { method: 'POST', path: '/v1/heartbeat', timestamp: 1780000000,
|
|
388
|
+
nonce: 'abcdef0123456789', body: '{"task_id":"T1","machine_id":"m1"}' };
|
|
389
|
+
canonicalString(input); // 应与上面的 6 行逐字节相同
|
|
390
|
+
signRequest({ secret: 'test-secret-abc', ...input }); // v1=a8de86…
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
### 9.5 错误码 → HTTP 状态映射
|
|
394
|
+
|
|
395
|
+
| `verifyRequest` code | HTTP | `error.detail` | 说明 |
|
|
396
|
+
|---|---|---|---|
|
|
397
|
+
| `SIGNATURE_REQUIRED` | **401** | `code`/`reason`/`hint` | 开了强制但请求未签名 |
|
|
398
|
+
| `SIGNATURE_INCOMPLETE` | **401** | 同上 | 三个头只带了一部分(半个签名永远是坏代理或探测,**不**降级为"未签名") |
|
|
399
|
+
| `SIGNATURE_MISMATCH` | **401** | 同上 | 与所有已配置密钥都不匹配(含密钥轮换没跟上) |
|
|
400
|
+
| `SIGNATURE_BAD_TIMESTAMP` | **401** | 同上 | `X-W2M-Timestamp` 不是数字 |
|
|
401
|
+
| `SIGNATURE_BAD_NONCE` | **401** | 同上 | nonce 短于 8 字符 |
|
|
402
|
+
| `SIGNATURE_EXPIRED` | **401** | 同上 | 时间戳超出 ±120s 窗口 → hint 指向**发送方时钟** |
|
|
403
|
+
| `SIGNATURE_REPLAY` | **401** | 同上 | nonce 已用过 → hint 指向**每次请求换 nonce** |
|
|
404
|
+
| `SIGNING_NOT_CONFIGURED` | **500** | 同上 | ⚠️ **中继自己配错了**:开了 `--require-signature` 却没有密钥 |
|
|
405
|
+
|
|
406
|
+
**为什么 `SIGNING_NOT_CONFIGURED` 必须是 5xx 而不是 401**:那是**中继的配置错误**。报成 401 会让每一个操作者去排查客户端 —— **唯一没有坏的那一侧**。选 **500 而非 503**:503 意味着"稍后重试",而重试永远修不好一个缺失的配置,不应诱导任何重试循环。启动横幅与 `/healthz.signing` 都会同时暴露这个矛盾。
|
|
407
|
+
|
|
408
|
+
### 9.6 `/healthz` 新增 `signing`
|
|
409
|
+
|
|
410
|
+
```json
|
|
411
|
+
"signing": { "configured": true, "required": true, "previous_secret_accepted": true, "skew_seconds": 120 }
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
**绝不暴露密钥本身,连前缀都不行**(有用例对原始响应文本做 `includes(secret)` 与 `includes(secret.slice(0,8))` 断言)。
|
|
415
|
+
|
|
416
|
+
### 9.7 迁移纪律(三步,每步都有用例)
|
|
417
|
+
|
|
418
|
+
| 步骤 | 配置 | 效果 |
|
|
419
|
+
|---|---|---|
|
|
420
|
+
| 0. 现状 | 无 | 不校验,v0.2.3 客户端零影响(**硬门禁**) |
|
|
421
|
+
| 1. 灰度 | `--signing-secret S` | 校验**存在**的签名,未签名仍放行 → 可以安全地逐台升级 agent;不匹配的签名**已经会 401**,所以能提前发现密钥分发错误 |
|
|
422
|
+
| 2. 收紧 | 再 `--require-signature` | 未签名 = 401。此时舰队应已全部会签名 |
|
|
423
|
+
|
|
424
|
+
轮换:`--signing-secret NEW --signing-secret-previous OLD` → 两者都接受;`keyIndex`(0=新,1=旧)可用来判断哪台机器还没拿到新密钥;确认全部切换后再去掉 `--signing-secret-previous`。
|
|
425
|
+
|
|
426
|
+
### 9.8 兼容矩阵(签名部分)
|
|
427
|
+
|
|
428
|
+
| 场景 | 结果 |
|
|
429
|
+
|---|---|
|
|
430
|
+
| v0.2.3 agent(不签名)→ v0.3.0 中继(未配置密钥) | 完全一致 |
|
|
431
|
+
| v0.2.3 agent(不签名)→ v0.3.0 中继(配置了密钥、未强制) | **照常工作**(步骤 1 的存在意义) |
|
|
432
|
+
| v0.2.3 agent(不签名)→ v0.3.0 中继(强制) | `401 SIGNATURE_REQUIRED` —— **这是操作者显式选择的结果**,不是意外 |
|
|
433
|
+
| v0.3.0 agent(签名)→ v0.2.3 中继 | 三个签名头是**未知请求头**,老中继忽略 → 照常工作 |
|
|
434
|
+
| 中继开了强制但忘配密钥 | `500 SIGNING_NOT_CONFIGURED`(不是 401) |
|
|
435
|
+
|
|
436
|
+
---
|
|
437
|
+
|
|
438
|
+
## 10. 不当错误码
|
|
439
|
+
|
|
440
|
+
本版的 `rtt_ms` 非法**不产生任何错误**(§2)。签名相关的新错误码见 §9.5,全部复用 §7 的错误体形状。
|
|
441
|
+
|
|
442
|
+
---
|
|
443
|
+
|
|
444
|
+
## 11. 本版不做
|
|
445
|
+
|
|
446
|
+
- ❌ **不做 RTT 历史/时序**(只保留最近一次测量;跨区域排障要看趋势时再单开一版)
|
|
447
|
+
- ❌ **不做 RTT 的持久化或账本重放**(§6)
|
|
448
|
+
- ❌ **不做按 RTT 的调度/选机**:本版只做"可见",不做"据此决策"
|
|
449
|
+
- ❌ **不做空闲心跳的频率协商**:60s 是协议推荐值,中继不校验、不限流;一台机器发得太频只会浪费它自己的带宽
|
|
450
|
+
- ❌ **不做请求体加密 / 端到端加密**:签名证明"谁发的、有没有被改",**不隐藏内容**。隐藏由 TLS 或 WireGuard 负责(v0.1.2 §1)
|
|
451
|
+
- ❌ **不做非对称签名**:见 `signing.mjs` 开头"为什么用共享密钥"——复用配对时已下发的 `device_token` 派生,零额外密钥分发
|
|
452
|
+
- ❌ **不做签名的时间戳缓存/时钟同步**:窗口固定 ±120s,超出即 401 并提示查时钟
|
|
453
|
+
|
|
454
|
+
> ✅ **已做**(原计划不做,后因用户缺陷范围而纳入):**空闲心跳**。理由见 §2.1 —— 跨机视图若在机器空闲时冻结,就等于在最需要它的时刻失效。
|