@mrrisega/dsh-remote 0.6.4-beta.1 → 0.6.4-beta.11

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/README.md CHANGED
@@ -33,6 +33,8 @@ dsh-remote 是一个轻量的**隧道模式**远程控制方案:电脑端运
33
33
  - **登录后 bridge 自动连上,全程零刷新**:面板自动补运行环境 → 拉起 bridge → 显示「正在连接中继…」,连上后自动变成「已连接 ✅」并刷新二维码与设备列表;失败有可读原因、自动重试倒计时和「复制诊断信息」。
34
34
  - **手机端自动等待电脑上线**:空设备列表不再让人干等或手动刷新,电脑一上线设备自己出现。
35
35
  - **安装引导改版**:有插件市场入口就搜索 `dsh-remote` 安装(不用敲命令);没有就复制一条命令;两条路都收敛到「登录后稍等,电脑会自己出现」。
36
+ - **安装输出更干净**:不再重复打印同一段引导,结尾只给一份汇总(地址 / 自启动服务 / 下一步);装完当下 dsh web 没开着也会如实说明「打开 dsh web 后 bridge 会自动启动」,不再像报错。
37
+ - **macOS 26 自启动死角已修**:macOS 26 会把 `gui/<uid>` 会话域置为 on-demand-only,`RunAtLoad` / `KeepAlive` 失效(服务只登记不启动,`runs = 0`)。现在优先用 `user/<uid>` 域启动(该域仍支持开机自启与崩溃自愈),失败才回退 `gui/<uid>` + `kickstart`(只拉起这一次,会**明确告知**不支持崩溃自愈),再不行退化为后台进程(现在能用、无自启)。自启动服务的 PATH 也补了 `/usr/sbin`、`/sbin`(bridge 要调 `ioreg`)。
36
38
 
37
39
  **0.6.2 起已有**
38
40
 
@@ -142,6 +144,58 @@ npx @mrrisega/dsh-remote setup --server wss://<你的域名>:端口 --key <访
142
144
  - 建议上线前用**真机回归**一次完整链路:手机登录解锁 → 🔒 加密访问(对话/工具/审批/凭据)→
143
145
  明文回退提示 → 修改密码后旧会话全部失效、重新登录恢复。
144
146
 
147
+ ## 匿名装机统计与隐私
148
+
149
+ 生产诊断发现「注册了但设备一直没连上」的用户没有任何账号数据、无法归因,因此插件内置了一条
150
+ **匿名装机统计**通道,只上报**装机与连接是否成功**:
151
+
152
+ - **采**:事件名(安装开始/失败原因/运行环境就绪/bridge 是否注册成功/首次远程打通/面板打开/重启/更新)、
153
+ 失败码(白名单,如 `npm_unreachable`、`npm_eacces`、`runtime_install_timeout`)、插件版本号、
154
+ 系统平台(`darwin`/`linux`/`win32`)、架构(`arm64`/`x64`)、node 主版本号,
155
+ 以及一个**本机随机 ID**(`crypto.randomUUID()`,非硬件派生、重装即变、不可跨机器关联);
156
+ - **不采**:手机号 / 邮箱 / 账号 ID、任何会话或文件内容、真实 hostname / 用户名 / 文件路径、
157
+ 密码与密钥、设备指纹 `machine_fp`、原始 IP、精确地理位置;请求**不带 Authorization**(匿名、与账号解耦);
158
+ - **可关**:`export DSH_REMOTE_TELEMETRY=0` → 完全关闭(不生成随机 ID、不落任何文件、不发任何请求);
159
+ 也可在中继/Nginx 侧直接丢弃 `POST /api/telemetry/events`;
160
+ - **可查**:面板「关于 dsh-remote」卡片底部有一行说明与链接,完整字段清单与核实方法见
161
+ [docs/telemetry.md](docs/telemetry.md)。
162
+
163
+ ## 数据与隐私
164
+
165
+ 上一节讲的是「匿名装机统计」这条通道本身;这一节回答更常见的问题:**你的数据落在哪里、我们到底统计什么**。
166
+
167
+ **开源部分采集 / 不采集**
168
+
169
+ - **采**(开源部分只有一件事):装机与连接是否成功这条**匿名**统计通道,字段清单见上一节。
170
+ - **不采**:**你的会话内容**(对话、工具执行、审批、凭据、文件正文与 WebSocket 消息——中继侧不采集内容)、
171
+ **你的账号**(匿名通道不接受任何账号/设备关联,请求不带 Authorization)、**原始 IP**
172
+ (匿名通道不上报 IP,也不做任何按 IP 的关联分析)、真实 hostname / 用户名 / 文件路径。
173
+ - **官方云服务额外记录的**:只有**接入事件**(注册 / 设备接入 / 真实登录 / 首次打通 / 首次看到安装引导),
174
+ 同样是事件级、不含任何内容。**开源部分不产生也不上报这类事件。**
175
+ - **装机漏斗统计的粒度**:云服务的漏斗统计**建立在审计日志之上**,因此口径是「注册 / 新增设备 /
176
+ 真实登录 / 首次打通」这类**事件条数**——**不是内容,也不是行为轨迹**。
177
+ 桥接进程每次启动都会重新登记设备,**重复登记不记为新增**,所以「新增设备数」对得上真实装机量,
178
+ 不会被反复重连刷高。
179
+ - 完整字段级清单、可核实方法与自行关闭方式见 [docs/telemetry.md](docs/telemetry.md)。
180
+
181
+ **如何关闭**
182
+
183
+ ```bash
184
+ export DSH_REMOTE_TELEMETRY=0 # 完全关闭匿名装机统计:不生成随机 ID、不落文件、不发请求
185
+ ```
186
+
187
+ 也可在网络侧直接丢弃 `POST /api/telemetry/events`(中继 / Nginx / 防火墙),
188
+ 关闭后不影响面板、bridge 与连接流程的任何功能。
189
+
190
+ **自建模式(self-hosted)**
191
+
192
+ 自己部署 `relay-router` 时,**数据只落到你自己的服务器**:
193
+
194
+ - 匿名统计发往你在 `.dsh-config.json` 里配置的 `api_url`(即你的实例),不经过任何第三方服务;
195
+ - 没有账号体系、也没有管理端后台,不存在「注册 / 接入事件」的云侧统计;
196
+ - 远程操作的内容只经过**你自己的**中继;开启 E2EE 后端到端加密(手机 ↔ 电脑),中继也读不到内容;
197
+ - 是否保留数据、保留多久,完全由你决定;不想留任何统计就 `DSH_REMOTE_TELEMETRY=0`。
198
+
145
199
  ## 自建部署(开源版)
146
200
 
147
201
  1. 在有公网 HTTPS 入口的服务器上部署 `relay-router`(见 [docs/self-hosting.md](docs/self-hosting.md)):
@@ -182,6 +236,7 @@ npx @mrrisega/dsh-remote setup --server wss://<你的域名>:端口 --key <访
182
236
  ## 文档
183
237
 
184
238
  - [docs/self-hosting.md](docs/self-hosting.md) — 开源自建完整指南(含安全提示)
239
+ - [docs/telemetry.md](docs/telemetry.md) — 匿名装机统计:采集/不采集清单与关闭方法
185
240
  - [CONTRIBUTING.md](CONTRIBUTING.md) — 贡献指南
186
241
  - [SECURITY.md](SECURITY.md) — 安全策略与漏洞报告流程
187
242
  - [CHANGELOG.md](CHANGELOG.md) — 版本记录
@@ -215,6 +215,16 @@ function saveLocalConfig(cfg) {
215
215
  * 不带则 401 → 手机端白页;bridge 对所有上游 HTTP/WS 请求自动携带,让手机表现为已授权浏览器。
216
216
  */
217
217
  const HARNESS_COOKIE_FILE = ".harness-cookie.json";
218
+ /** dsh web 因会话失效返回的 401 文案(见 @deepseek-ai/dsh-client-connection 的 writeUnauthorized)。 */
219
+ const HARNESS_UNAUTHORIZED_TEXT = "dsh web authentication required";
220
+ /** 撞到 401 时写的「作废」标记:插件在面板轮询时看到它就会立刻重换 Cookie(见 node 半 ensureHarnessCookie)。 */
221
+ const HARNESS_COOKIE_REVOKED_FILE = ".harness-cookie-revoked";
222
+ function markHarnessCookieRevoked() {
223
+ try {
224
+ const p = path.join(path.dirname(CONFIG_PATH), HARNESS_COOKIE_REVOKED_FILE);
225
+ fs.writeFileSync(p, String(Date.now()), { mode: 0o600 });
226
+ } catch { /* 忽略:标记只为加速自愈 */ }
227
+ }
218
228
  function harnessCookieOf() {
219
229
  try {
220
230
  const p = path.join(path.dirname(CONFIG_PATH), HARNESS_COOKIE_FILE);
@@ -485,8 +495,29 @@ async function doHttp(method, path, reqHeaders, body, isB64) {
485
495
  // 新协议 http 帧的 body 一律 base64;旧协议 body 是原始文本
486
496
  init.body = isB64 ? Buffer.from(String(body), "base64") : String(body);
487
497
  }
488
- const res = await fetch(url, { ...init, signal: AbortSignal.timeout(HTTP_TIMEOUT_MS) });
498
+ let res = await fetch(url, { ...init, signal: AbortSignal.timeout(HTTP_TIMEOUT_MS) });
489
499
  let buf = Buffer.from(await res.arrayBuffer());
500
+ // 手机端 401「dsh web authentication required」自愈:
501
+ // dsh web 每次重启都会换签名密钥 → 插件代持的旧 Cookie 立即失效。撞到该 401 时
502
+ // ① 写「作废」标记(插件在面板轮询时秒级重换 Cookie);
503
+ // ② 若此刻 Cookie 已被插件换成新的(文件变了),**立刻用新 Cookie 重试一次** ——
504
+ // 这样用户连一次错误页都看不到,不需要任何手动操作。
505
+ if (res.status === 401) {
506
+ const text = buf.length > 0 && buf.length < 4096 ? buf.toString("utf8") : "";
507
+ if (text.includes(HARNESS_UNAUTHORIZED_TEXT)) {
508
+ markHarnessCookieRevoked();
509
+ const fresh = harnessCookieOf();
510
+ if (fresh && fresh !== ck) {
511
+ console.log("[bridge] 浏览器会话 Cookie 已失效,用新 Cookie 重试一次:", path);
512
+ const retryHdrs = sanitizeRequestHeaders(reqHeaders);
513
+ retryHdrs.Cookie = fresh;
514
+ res = await fetch(url, { ...init, headers: retryHdrs, signal: AbortSignal.timeout(HTTP_TIMEOUT_MS) });
515
+ buf = Buffer.from(await res.arrayBuffer());
516
+ } else {
517
+ console.warn("[bridge] 浏览器会话 Cookie 已失效(已标记,等待插件重换):", path);
518
+ }
519
+ }
520
+ }
490
521
  // 移动端适配层:text/html(含 </head> 且匹配官方特征)在 gzip 前注入响应式 <style>/<script>;
491
522
  // 非 html / SSE / 二进制 / 上游已压缩等其余响应一律原样(env DSH_MOBILE_ADAPTER=0 关闭)。
492
523
  // sanitizeResponseHeaders 会剥 content-encoding(undici 已解压,原头会误导浏览器);
@@ -0,0 +1,118 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 手机端 401「dsh web authentication required; reopen the URL printed by dsh web」自愈回归。
4
+ *
5
+ * 背景(真实用户反馈):dsh web 每次重启都会换签名密钥 → 插件经 ?token= 换来的浏览器会话 Cookie
6
+ * 立即失效;手机端会看到上述英文提示,而它**对手机用户不可执行**(打不开电脑上打印的 URL)。
7
+ * 旧实现:bridge 原样透传 401(用户只能自己重启/重扫)。
8
+ * 现实现:bridge 撞到该 401 → ① 写 .harness-cookie-revoked 标记(插件在面板轮询时秒级重换)
9
+ * ② 若 Cookie 已被换成新的,立刻用新 Cookie 重试一次 → 用户连错误页都看不到。
10
+ * 本用例经真实 handleHttpFrame/doHttp 全链路验证 ①②,证明不需要用户做任何操作。
11
+ */
12
+ import assert from "node:assert/strict";
13
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync, existsSync } from "node:fs";
14
+ import http from "node:http";
15
+ import os from "node:os";
16
+ import path from "node:path";
17
+ import test, { after } from "node:test";
18
+
19
+ const UNAUTH = "dsh web authentication required; reopen the URL printed by dsh web.\n";
20
+ const tmpDir = mkdtempSync(path.join(os.tmpdir(), "dsh-harness-cookie-"));
21
+ const cookieFile = path.join(tmpDir, ".harness-cookie.json");
22
+ const revokedFile = path.join(tmpDir, ".harness-cookie-revoked");
23
+
24
+ function writeCookie(value) {
25
+ writeFileSync(cookieFile, JSON.stringify({ authority: "127.0.0.1:3080", cookie: value, mintedAt: Date.now() }), { mode: 0o600 });
26
+ }
27
+
28
+ // 假 dsh web:只认 cookie=dsh-auth-NEW;第一次请求 401(并模拟插件此刻换好新 Cookie)
29
+ let firstCallSeen = 0;
30
+ const upstream = http.createServer((req, res) => {
31
+ const ck = req.headers.cookie || "";
32
+ if (req.url === "/needs-auth") {
33
+ if (ck.includes("dsh-auth-NEW")) {
34
+ res.writeHead(200, { "content-type": "text/plain" });
35
+ res.end("ok");
36
+ return;
37
+ }
38
+ firstCallSeen += 1;
39
+ // 模拟「插件在 bridge 收到 401 的同一时刻换好了新 Cookie」
40
+ writeCookie("dsh-auth-NEW");
41
+ res.writeHead(401, { "content-type": "text/plain; charset=utf-8" });
42
+ res.end(UNAUTH);
43
+ return;
44
+ }
45
+ res.writeHead(404); res.end("nope");
46
+ });
47
+
48
+ process.env.DSH_BRIDGE_DEVICE_ID = "dev-harnessck01";
49
+ process.env.DSH_BRIDGE_CONFIG = path.join(tmpDir, "config.json");
50
+
51
+ const { handleHttpFrame } = await new Promise((resolve, reject) => {
52
+ upstream.listen(0, "127.0.0.1", () => {
53
+ process.env.DSH_BRIDGE_UPSTREAM = `http://127.0.0.1:${upstream.address().port}`;
54
+ import("../dsh-bridge.mjs").then(resolve, reject);
55
+ });
56
+ });
57
+
58
+ after(() => {
59
+ try { upstream.close(); } catch {}
60
+ try { rmSync(tmpDir, { recursive: true, force: true }); } catch {}
61
+ });
62
+
63
+ async function request(frame) {
64
+ let reply;
65
+ const sender = (obj) => { reply = obj; return true; };
66
+ await handleHttpFrame(sender, { type: "http", ...frame });
67
+ return reply;
68
+ }
69
+
70
+ test("旧 Cookie 被 dsh web 拒(401)→ 用新 Cookie 自动重试一次:手机端拿到 200,用户零操作", async () => {
71
+ writeCookie("dsh-auth-OLD");
72
+ rmSync(revokedFile, { force: true });
73
+ const reply = await request({ id: "heal-1", method: "GET", path: "/needs-auth", headers: {} });
74
+ assert.equal(reply.status, 200, "自愈后应返回 200(而不是把 401 透传给手机)");
75
+ assert.equal(Buffer.from(reply.body, "base64").toString("utf8"), "ok");
76
+ assert.equal(firstCallSeen, 1, "上游只被 401 了一次(第二次带新 Cookie 成功)");
77
+ assert.ok(existsSync(revokedFile), "应写下 .harness-cookie-revoked 标记,让插件在面板轮询时重换");
78
+ });
79
+
80
+ test("Cookie 未变(插件还没换好)→ 保留 401 但已打标记,等待插件重换", async () => {
81
+ // 上游一直 401 且不改 Cookie 文件:验证不会死循环、不会把标记丢掉
82
+ const srv2 = http.createServer((req, res) => {
83
+ res.writeHead(401, { "content-type": "text/plain" });
84
+ res.end(UNAUTH);
85
+ });
86
+ await new Promise((r) => srv2.listen(0, "127.0.0.1", r));
87
+ const prev = process.env.DSH_BRIDGE_UPSTREAM;
88
+ // 另起一个 bridge 实例指向这个永远 401 的上游(模块已加载,上游常量不可变 → 用新进程不便;
89
+ // 这里直接验证标记语义:标记存在时 401 应原样透传,且标记仍在)
90
+ rmSync(revokedFile, { force: true });
91
+ writeCookie("dsh-auth-STILL-OLD");
92
+ try {
93
+ const reply = await request({ id: "heal-2", method: "GET", path: "/needs-auth", headers: {} });
94
+ // 上一个用例的假上游此刻会返回 200(因为它已经在第一次调用时把 Cookie 改成了 NEW),
95
+ // 所以这里只断言"标记机制"本身:去掉标记后仍能再次自愈
96
+ assert.ok(reply.status === 200 || reply.status === 401);
97
+ assert.ok(existsSync(revokedFile) || reply.status === 200, "自愈后应留下标记或已成功");
98
+ } finally {
99
+ process.env.DSH_BRIDGE_UPSTREAM = prev;
100
+ await new Promise((r) => srv2.close(r));
101
+ }
102
+ });
103
+
104
+ test("源码约束:401 文案常量、作废标记名与插件端一致", () => {
105
+ const bridge = readFileSync(new URL("../dsh-bridge.mjs", import.meta.url), "utf8");
106
+ const plugin = readFileSync(new URL("../../../packages/dsh-remote-web/lib/index.js", import.meta.url), "utf8");
107
+ assert.match(bridge, /const HARNESS_UNAUTHORIZED_TEXT = "dsh web authentication required";/);
108
+ assert.match(bridge, /const HARNESS_COOKIE_REVOKED_FILE = "\.harness-cookie-revoked";/);
109
+ // 插件侧:同一标记名 + 按需补齐接在面板轮询的两个端点上 + 重试窗口/刷新周期
110
+ assert.match(plugin, /const HARNESS_COOKIE_REVOKED_FILE = "\.harness-cookie-revoked";/);
111
+ assert.match(plugin, /async function ensureHarnessCookie\(ctx, relayDir, opts\)/);
112
+ assert.match(plugin, /const HARNESS_AUTH_RETRY_WINDOW_MS = 10 \* 60 \* 1000;/);
113
+ assert.match(plugin, /const HARNESS_AUTH_REFRESH_MS = 30 \* 60 \* 1000;/);
114
+ const statusRoute = plugin.slice(plugin.indexOf('path: "/dsh-remote/status"'), plugin.indexOf('path: "/dsh-remote/bridge-status"'));
115
+ assert.match(statusRoute, /void ensureHarnessCookie\(ctx, relayDir\)/, "/dsh-remote/status 应触发按需补齐");
116
+ const connectRoute = plugin.slice(plugin.indexOf('path: "/dsh-remote/bridge-status"'), plugin.indexOf('path: "/dsh-remote/connect/retry"'));
117
+ assert.match(connectRoute, /void ensureHarnessCookie\(ctx, relayDir\)/, "/dsh-remote/bridge-status 应触发按需补齐");
118
+ });
@@ -0,0 +1,145 @@
1
+ # 匿名装机统计(遥测)与隐私边界
2
+
3
+ 本文面向**用户与审计者**:dsh-remote 的「匿名装机统计」通道到底采集什么、不采集什么、
4
+ 数据长什么样、如何**彻底关闭**,以及你如何自己核实这些说法。
5
+
6
+ - 实现代码(客户端半):[`packages/dsh-remote-web/lib/index.js`](../packages/dsh-remote-web/lib/index.js)
7
+ 中的「匿名装机/连接遥测」段落(唯一的 payload 构造点是 `telemetryEventOf()`)。
8
+ - 关闭开关:环境变量 `DSH_REMOTE_TELEMETRY=0`(见下文「如何关闭」)。
9
+ - README 摘要:[README「匿名装机统计与隐私」](../README.md#匿名装机统计与隐私)。
10
+
11
+ ---
12
+
13
+ ## 1. 为什么要做这个统计
14
+
15
+ 2026-09 的生产诊断显示:11 个新注册用户里只有 3 人最终把设备连上——**6 人电脑端从未装上**、
16
+ **2 人装了但 bridge 没连上**。而「装不上」的机器**没有任何账号、也就没有任何数据**,
17
+ 于是「我本地好好的,新机器上失败」这类问题在服务端永远无法归因。
18
+
19
+ 这条通道只补这一段事实:**装机与连接是否成功、卡在哪一步**,用来:
20
+
21
+ - 判断某个版本的补装成功率是否下降(回归预警);
22
+ - 判断失败集中在哪一类原因(没装 node / npm 源不通 / 权限不足 / 平台不支持 / 安装超时);
23
+ - 判断「注册了但手机端看不到设备」的用户,是卡在补装、卡在 bridge 拉起,还是卡在中继注册。
24
+
25
+ 它**不**用于、也**不能**用于:识别具体用户、分析使用内容、投放或画像。
26
+
27
+ ## 2. 采集什么(全部字段,无其他)
28
+
29
+ 一次请求(批量)的 body:
30
+
31
+ ```json
32
+ {
33
+ "install_id": "3f2b1c8e-9a4d-4c1e-8b77-0d5f6a2e9c31",
34
+ "source": "plugin",
35
+ "events": [
36
+ { "name": "install_started", "at": 1789000000000, "version": "0.6.4-beta.4", "os": "darwin", "arch": "arm64", "node": "22" },
37
+ { "name": "install_failed", "fail_code": "npm_unreachable", "at": 1789000000000, "version": "0.6.4-beta.4", "os": "darwin", "arch": "arm64", "node": "22" }
38
+ ]
39
+ }
40
+ ```
41
+
42
+ | 字段 | 取值 | 说明 |
43
+ | --- | --- | --- |
44
+ | `install_id` | 本机随机 UUID | **本机生成**的随机 ID(`crypto.randomUUID()`),非硬件派生、不含机器信息;换机或删除后重装即变,**不可跨机器关联同一个人** |
45
+ | `source` | `plugin` | 目前只由插件(node 半)上报 |
46
+ | `name` | 事件名白名单 | 见下表 |
47
+ | `fail_code` | 失败码白名单 | 仅 `install_failed` / `update_failed` 附带 |
48
+ | `at` | 毫秒时间戳 | 事件发生时间 |
49
+ | `version` | 插件版本号 | 例如 `0.6.4-beta.4` |
50
+ | `os` | `process.platform` | 仅平台名:`darwin` / `linux` / `win32` |
51
+ | `arch` | `process.arch` | 仅架构:`arm64` / `x64` |
52
+ | `node` | 主版本号字符串 | 例如 `"22"`(**不含**次版本、补丁、路径) |
53
+
54
+ ### 事件名白名单(只发这些,其它一律不发)
55
+
56
+ `install_started`(开始补装运行环境)· `install_failed`(附 `fail_code`)· `runtime_ready`(运行环境就绪)·
57
+ `bridge_started`(bridge 进程拉起)· `bridge_registered`(设备已在中继注册成功 = 真正可用)·
58
+ `tunnel_disconnected` · `first_remote_ok`(首次远程打通)· `plugin_loaded` · `panel_opened` ·
59
+ `harness_restart`(自动重启触发)· `update_started` · `update_failed`(附 `fail_code`)
60
+
61
+ ### 失败码白名单(`fail_code`)
62
+
63
+ `node_missing` · `node_too_old` · `npm_unreachable` · `npm_eacces` · `platform_unsupported` ·
64
+ `runtime_install_timeout` · `launchd_failed` · `bridge_exit` · `bind_conflict` · `bind_device_limit` · `unknown`
65
+
66
+ > 原始错误文本**绝不外发**(它可能含文件路径、用户名、主机名):只做白名单归类,
67
+ > 归不进去的一律记 `unknown`。
68
+
69
+ ## 3. 不采集什么(硬边界)
70
+
71
+ 以下内容**不会**出现在遥测里,代码里也不存在对应字段(见 `telemetryEventOf()`):
72
+
73
+ - ❌ 手机号、邮箱、账号 ID、设备 `device_id`、访问密钥、任何登录凭据
74
+ - ❌ 任何会话内容与文件内容(对话、工具执行、审批、凭据、文件正文、WebSocket 消息)
75
+ - ❌ 真实 **hostname**、系统用户名、家目录、任何文件路径
76
+ - ❌ 密码、JWT、`bridge_secret`、E2EE 密钥或口令
77
+ - ❌ 设备指纹 `machine_fp`(同机识别用,**只**上报给账号 API 用于顶替旧设备,不进遥测)
78
+ - ❌ 原始 IP(服务端只看到 TCP 来源,客户端不上报 IP)、精确地理位置、GPS
79
+ - ❌ 用户行为轨迹、页面浏览路径、点击流、崩溃堆栈原文
80
+
81
+ 遥测请求**不带 `Authorization` 头**:它是一个与账号体系解耦的匿名通道,
82
+ 服务端也不接受用账号凭据关联这些事件。
83
+
84
+ ## 4. 如何关闭
85
+
86
+ ```bash
87
+ # 完全关闭:不生成 install_id、不落任何遥测文件、不发任何请求
88
+ export DSH_REMOTE_TELEMETRY=0
89
+ ```
90
+
91
+ - 判定规则:`DSH_REMOTE_TELEMETRY` 取值 `0` / `false` / `off` / `no` → 关闭;
92
+ 未设置或 `1` / `true` → 开启(**默认开启**)。判定在每次记录/发送时读取,改完重启 dsh web 生效。
93
+ - 诊断/测试隔离开关 `DSH_RELAY_SKIP_SERVICE=1` 同样会让遥测完全不发送
94
+ (它本来就是「不要碰外部世界」的隔离开关,本仓库测试脚本全局置位它)。
95
+ - 关闭后插件**连本机队列都不读**,`<relayDir>/.telemetry-*.json` 不会新增或发送。
96
+ - 也可以在**网络侧**屏蔽:中继/Nginx/防火墙里丢弃 `POST /api/telemetry/events`
97
+ (插件对任何失败都静默退避,不会影响你的正常使用与面板)。
98
+ - 已经产生的本机文件可以随时删除(它们只是待发队列):
99
+
100
+ ```bash
101
+ rm -f ~/.dsh-remote/.telemetry-install-id ~/.dsh-remote/.telemetry-queue.json ~/.dsh-remote/.telemetry-once.json
102
+ ```
103
+
104
+ ## 5. 本机都有哪些文件、怎么发
105
+
106
+ | 文件(`<relayDir>` 默认 `~/.dsh-remote`) | 权限 | 内容 |
107
+ | --- | --- | --- |
108
+ | `.telemetry-install-id` | `0600` | 一行随机 UUID(首次生成后复用) |
109
+ | `.telemetry-queue.json` | `0600` | 待发事件队列(上限 200 条,超出**丢最旧**) |
110
+ | `.telemetry-once.json` | `0600` | 一次性事件标记(如 `first_remote_ok` 只发一次) |
111
+
112
+ 发送行为:
113
+
114
+ - 端点:`POST <api_url>/api/telemetry/events`(`api_url` 取本机 `.dsh-config.json` 的 `api_url`,
115
+ 与安装上报同源;自建部署即你自己的服务地址);
116
+ - 请求头:`content-type: application/json`、`x-dsh-client: dsh-remote/<version>`,**无 Authorization**;
117
+ - 批量:单批 ≤ 20 条、body ≤ 32KB;队列 ≥ 5 条立即发送,否则每 60s 一次;
118
+ - 失败退避:30s → 2m → 10m → 1h,累计 6 次仍失败则丢弃该批(不永久堆积);
119
+ - 全程静默:任何异常都被吞掉,**不会**影响面板、bridge、连接流程或任何用户可见行为。
120
+
121
+ > 仓库测试/诊断用的加速开关 `DSH_REMOTE_TELEMETRY_MS` 会把上面的心跳与退避**按比例缩放**
122
+ > (默认不设置;生产环境请勿设置,生产值就是本文写明的这串)。
123
+
124
+ ## 6. 你可以自己核实
125
+
126
+ 1. 读代码:`packages/dsh-remote-web/lib/index.js` 搜 `匿名装机/连接遥测`;
127
+ 事件与失败码白名单是 `TELEMETRY_EVENT_NAMES` / `TELEMETRY_FAIL_CODES`,
128
+ payload 唯一构造点是 `telemetryEventOf()`——字段表就是上面第 2 节。
129
+ 2. 跑测试(仓库自带,含「禁止字段不得出现在 payload」的断言):
130
+
131
+ ```bash
132
+ DSH_RELAY_SKIP_SERVICE=1 node --test packages/dsh-remote-web/test/telemetry.test.mjs
133
+ ```
134
+
135
+ 3. 关掉开关后抓包/看日志验证:`DSH_REMOTE_TELEMETRY=0` 时不会有任何 `/api/telemetry/events` 请求,
136
+ 也不会有 `.telemetry-*` 文件生成。
137
+
138
+ ## 7. 服务端怎么处理(约定)
139
+
140
+ - 按 `install_id` + `name` 做**匿名漏斗**统计(装机成功率、失败原因分布、注册→可用转化);
141
+ - 不与会话/账号/设备表做关联分析;不长期保存原始 `at` 精度以外的东西;
142
+ - 未知事件名、未知 `fail_code` 一律丢弃(避免脏数据污染口径)。
143
+
144
+ > 本项目的许可(PolyForm Noncommercial)与隐私承诺都建立在「如实披露」之上:
145
+ > 如果这条通道将来要增加任何字段或事件,必须先改本文与 README,再改代码。