@mrrisega/dsh-remote 0.6.0-beta.8 → 0.6.0

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.
@@ -4,7 +4,8 @@
4
4
  // - 读写配置目录下 .dsh-config.json(0600)
5
5
  // - 查询/启停 bridge(launchctl,plist 缺失时自动生成,逻辑与 dsh-setup.mjs 一致)
6
6
  // - 代理 relay API(captcha / register / login / public-config),直连、不走系统代理;
7
- // 另代理企业端一次性访问密钥 / 授权设备(/api/auth-key、/api/mobile-sessions、…/revoke,Bearer)供面板「📱 远程访问」卡使用
7
+ // 另代理企业端一次性访问密钥 / 授权设备(/api/auth-key、/api/mobile-sessions、…/revoke、
8
+ // DELETE …/:id、POST …/purge,Bearer)供面板「📱 远程访问」卡使用
8
9
  // - 自管理 self*(版本可见 / 新版检测 / 一键在线更新 / 彻底卸载):插件市场没有更新卸载按钮,
9
10
  // 面板内即官方管理入口;更新=后台 npx 按 dist-tag(默认 latest,DSH_UPDATE_TAG 可切 beta/alpha)(幂等补齐运行环境并重启 bridge);
10
11
  // 彻底卸载=profile 插件清理(uninstallSelf)+ 运行时清理(uninstallRuntime:停 bridge 自启动 /
@@ -16,7 +17,7 @@
16
17
  import { readFileSync, writeFileSync, mkdirSync, existsSync, realpathSync, accessSync, chmodSync, openSync, closeSync, rmSync, constants as fsConstants } from "node:fs";
17
18
  import { join, dirname, sep } from "node:path";
18
19
  import { execSync, spawn } from "node:child_process";
19
- import { homedir, hostname, platform } from "node:os";
20
+ import { homedir, platform } from "node:os";
20
21
  import { fileURLToPath } from "node:url";
21
22
 
22
23
  /** 本插件在 host 侧的服务依赖。 */
@@ -652,7 +653,7 @@ async function relayAccount(relayDir) {
652
653
  if (!r.ok || !r.body || !r.body.user) return null;
653
654
  const u = r.body.user;
654
655
  return {
655
- phone: u.phone || "",
656
+ phone: maskPhone(u.phone || ""),
656
657
  plan: u.plan || "free",
657
658
  plan_source: u.plan_source || "plan",
658
659
  plan_ends_at: u.plan_ends_at ?? null,
@@ -802,6 +803,51 @@ async function proxyRevokeMobileSession(relayDir, req, res) {
802
803
  return sendJson(res, 200, { ok: true, relayStatus: (r && r.status) || 200 });
803
804
  }
804
805
 
806
+ /**
807
+ * DELETE /dsh-remote/mobile-sessions/delete(body {id})→ 删除本机该设备的授权记录
808
+ * (企业端 DELETE /api/mobile-sessions/:id,Bearer:本人整行删除并拉黑 jti)。
809
+ */
810
+ async function proxyDeleteMobileSession(relayDir, req, res) {
811
+ const body = await readJsonBody(req);
812
+ if (body.__parseError) return sendJson(res, 400, { ok: false, error: "JSON 解析失败" });
813
+ const id = String(body.id ?? "").trim();
814
+ if (!id) return sendJson(res, 400, { ok: false, error: "缺少参数 id(会话 ID)" });
815
+ const token = await relayToken(relayDir).catch(() => "");
816
+ if (!token) return sendJson(res, 401, notLoggedInJson());
817
+ const r = await relayFetch(relayDir, `/api/mobile-sessions/${encodeURIComponent(id)}`, {
818
+ method: "DELETE",
819
+ headers: { authorization: `Bearer ${token}` },
820
+ });
821
+ const d = flattenRelayBody(r);
822
+ const ok = !!(r.ok && d.ok !== false);
823
+ if (!ok) {
824
+ return sendJson(res, (r && r.status) || 502, { ok: false, error: relayErrorMessage(r), relayStatus: (r && r.status) || 0 });
825
+ }
826
+ return sendJson(res, 200, { ok: true, relayStatus: (r && r.status) || 200 });
827
+ }
828
+
829
+ /**
830
+ * POST /dsh-remote/mobile-sessions/purge → 清理本人所有已解绑(revoked)记录
831
+ * (企业端 POST /api/mobile-sessions/purge,Bearer)。
832
+ */
833
+ async function proxyPurgeMobileSessions(relayDir, res) {
834
+ const token = await relayToken(relayDir).catch(() => "");
835
+ if (!token) return sendJson(res, 401, notLoggedInJson());
836
+ const r = await relayFetch(relayDir, "/api/mobile-sessions/purge", {
837
+ method: "POST",
838
+ headers: { authorization: `Bearer ${token}` },
839
+ });
840
+ const d = flattenRelayBody(r);
841
+ const ok = !!(r.ok && d.ok !== false);
842
+ if (!ok) {
843
+ return sendJson(res, (r && r.status) || 502, { ok: false, error: relayErrorMessage(r), relayStatus: (r && r.status) || 0 });
844
+ }
845
+ const removed = Number.isInteger(d.removed) ? d.removed : null;
846
+ const payload = { ok: true, relayStatus: (r && r.status) || 200 };
847
+ if (removed !== null) payload.removed = removed;
848
+ return sendJson(res, 200, payload);
849
+ }
850
+
805
851
  // ---------- 综合状态 ----------
806
852
 
807
853
  /**
@@ -829,6 +875,15 @@ export function readE2eeStateFile(relayDir) {
829
875
  }
830
876
  }
831
877
 
878
+ /**
879
+ * 手机号脱敏(隐私审计 2026-09):面板/镜像 UI 一律不下发明文手机号。
880
+ * 明文只在 bridge 本机 config / 服务端账号体系内流转,浏览器侧仅见掩码。
881
+ */
882
+ function maskPhone(p) {
883
+ const s = String(p || "");
884
+ return s.length >= 7 ? s.slice(0, 3) + "****" + s.slice(-4) : (s ? s.slice(0, 1) + "****" : "");
885
+ }
886
+
832
887
  async function composeStatus(relayDir) {
833
888
  const cfg = loadConfig(relayDir);
834
889
  const launchd = launchdStatus();
@@ -847,7 +902,8 @@ async function composeStatus(relayDir) {
847
902
  return {
848
903
  ok: true,
849
904
  config: {
850
- phone: cfg.phone || "",
905
+ phone: maskPhone(cfg.phone || ""),
906
+ hasPhone: Boolean(cfg.phone),
851
907
  hasPassword: Boolean(cfg.password),
852
908
  deviceId: cfg.device_id || "",
853
909
  apiUrl,
@@ -867,7 +923,7 @@ async function composeStatus(relayDir) {
867
923
  // {enabled, reason, profile, epoch, caps},供面板「📱 远程访问」卡展示加密状态。
868
924
  e2ee: readE2eeStateFile(relayDir),
869
925
  },
870
- host: hostname(),
926
+ // 隐私审计(2026-09):不再下发真实 hostname(移除 host 字段)——设备标识统一走 deviceId/服务端登记名
871
927
  };
872
928
  }
873
929
 
@@ -974,7 +1030,7 @@ const PLUGIN_ID = "dsh-remote-web";
974
1030
  const PLUGIN_LEGACY_IDS = ["dsh-remote-ui"];
975
1031
  const PLUGIN_ALL_IDS = [PLUGIN_ID, ...PLUGIN_LEGACY_IDS];
976
1032
  /** 插件自身发布版本(与 dsh-remote 根包同步递增)。 */
977
- const PLUGIN_VERSION = "0.6.0-beta.8";
1033
+ const PLUGIN_VERSION = "0.6.0";
978
1034
  const UPDATE_LOG = ".dsh-update.log";
979
1035
  const UPDATE_MARKER = ".dsh-update-running";
980
1036
 
@@ -1250,6 +1306,22 @@ function registerRoutes(ctx, relayDir) {
1250
1306
  await proxyRevokeMobileSession(relayDir, req, res);
1251
1307
  },
1252
1308
  },
1309
+ // 删除已授权设备记录(整行删除并拉黑 jti;企业端 DELETE /api/mobile-sessions/:id,Bearer)
1310
+ {
1311
+ method: "DELETE",
1312
+ path: "/dsh-remote/mobile-sessions/delete",
1313
+ handler: async (req, res) => {
1314
+ await proxyDeleteMobileSession(relayDir, req, res);
1315
+ },
1316
+ },
1317
+ // 清理本人全部已解绑(revoked)记录(企业端 POST /api/mobile-sessions/purge,Bearer)
1318
+ {
1319
+ method: "POST",
1320
+ path: "/dsh-remote/mobile-sessions/purge",
1321
+ handler: async (_req, res) => {
1322
+ await proxyPurgeMobileSessions(relayDir, res);
1323
+ },
1324
+ },
1253
1325
  {
1254
1326
  method: "POST",
1255
1327
  path: "/dsh-remote/config",
@@ -1320,7 +1392,11 @@ function registerRoutes(ctx, relayDir) {
1320
1392
  handler: async (req, res) => {
1321
1393
  const body = await readJsonBody(req);
1322
1394
  if (body.__parseError) return sendJson(res, 400, { ok: false, error: "JSON 解析失败" });
1323
- const phone = String(body.phone ?? "").trim();
1395
+ let phone = String(body.phone ?? "").trim();
1396
+ if (!phone) {
1397
+ // 隐私审计(2026-09):账号卡修改密码不再下发明文手机号——服务端以本机账号为准
1398
+ phone = String((loadConfig(relayDir).phone) || "").trim();
1399
+ }
1324
1400
  if (!phone) return sendJson(res, 400, { ok: false, error: "手机号必填" });
1325
1401
  const r = await relayFetch(relayDir, "/api/sms-code", {
1326
1402
  method: "POST", headers: { "content-type": "application/json" },
@@ -1329,6 +1405,31 @@ function registerRoutes(ctx, relayDir) {
1329
1405
  sendJson(res, r.status || 502, { ok: r.ok, status: r.status, body: r.body });
1330
1406
  },
1331
1407
  },
1408
+ {
1409
+ method: "POST",
1410
+ path: "/dsh-remote/password/reset",
1411
+ handler: async (req, res) => {
1412
+ // 公开 POST /api/password/reset(无需 Bearer):短信验证码重置密码。
1413
+ // 成功 → 企业端使该账号全部授权设备/会话失效(含 E2EE 派生口令)。
1414
+ const body = await readJsonBody(req);
1415
+ if (body.__parseError) return sendJson(res, 400, { ok: false, error: "JSON 解析失败" });
1416
+ let phone = String(body.phone ?? "").trim();
1417
+ if (!phone) {
1418
+ // 隐私审计(2026-09):同上,账号卡改密不传明文手机号,服务端回填本机账号
1419
+ phone = String((loadConfig(relayDir).phone) || "").trim();
1420
+ }
1421
+ const smsCode = String(body.sms_code ?? "").trim();
1422
+ const newPassword = String(body.new_password ?? body.password ?? "");
1423
+ if (!phone || !smsCode || !newPassword) return sendJson(res, 400, { ok: false, error: "手机号、短信验证码与新密码必填" });
1424
+ const r = await relayFetch(relayDir, "/api/password/reset", {
1425
+ method: "POST",
1426
+ headers: { "content-type": "application/json" },
1427
+ body: JSON.stringify({ phone, sms_code: smsCode, new_password: newPassword }),
1428
+ });
1429
+ // 透传企业端 ok/error(成功 200 {ok:true};失败保留 status 与错误体)
1430
+ sendJson(res, r.status || 502, { ok: r.ok, status: r.status, body: r.body });
1431
+ },
1432
+ },
1332
1433
  {
1333
1434
  method: "GET",
1334
1435
  path: "/dsh-remote/captcha",
@@ -1421,7 +1522,7 @@ function registerRoutes(ctx, relayDir) {
1421
1522
  feedbackUrl: api,
1422
1523
  reachable,
1423
1524
  deviceId: cfg.device_id || "",
1424
- phone: cfg.phone || "",
1525
+ phone: maskPhone(cfg.phone || ""), // 隐私:浏览器端只见掩码(代理提交时服务端另附真号)
1425
1526
  // 登录态(已配置账号或自建密钥)→ 节点半自动附加 JWT,免图形验证码
1426
1527
  auth: cfg.local_key || (cfg.phone && cfg.password) ? "account" : "anonymous"
1427
1528
  });
@@ -1,5 +1,7 @@
1
1
  // 插件 node 半新增代理回归:/dsh-remote/access-key | mobile-sessions | mobile-sessions/revoke
2
- // (企业端 E1 契约:POST /api/auth-key、POST /api/mobile-sessions、POST /api/mobile-sessions/:id/revoke,Bearer device-login JWT)
2
+ // | mobile-sessions/delete | mobile-sessions/purge
3
+ // (企业端 E1 契约:POST /api/auth-key、GET /api/mobile-sessions、POST /api/mobile-sessions/:id/revoke、
4
+ // DELETE /api/mobile-sessions/:id、POST /api/mobile-sessions/purge,Bearer device-login JWT)
3
5
  // 覆盖:Bearer 透传、字段扁平化、未登录 401、qr 缺失容错、上游错误透传。
4
6
  import assert from "node:assert/strict";
5
7
  import http from "node:http";
@@ -41,6 +43,9 @@ function startFakeRelay(opts = {}) {
41
43
  }
42
44
  if (req.method === "POST" && url.pathname === "/api/mobile-sessions/ms_1/revoke") return send(200, { ok: true });
43
45
  if (req.method === "POST" && url.pathname === "/api/mobile-sessions/ghost/revoke") return send(404, { error: { message: "not_found" } });
46
+ if (req.method === "DELETE" && url.pathname === "/api/mobile-sessions/ms_1") return send(200, { ok: true });
47
+ if (req.method === "DELETE" && url.pathname === "/api/mobile-sessions/ghost") return send(404, { error: { message: "not_found" } });
48
+ if (req.method === "POST" && url.pathname === "/api/mobile-sessions/purge") return send(200, { ok: true, removed: 2 });
44
49
  send(404, { error: { code: "not_found" } });
45
50
  });
46
51
  return new Promise((resolve) => srv.listen(0, "127.0.0.1", () => resolve({ srv, seen, port: srv.address().port })));
@@ -155,7 +160,66 @@ test("mobile-sessions/revoke 路由:body {id} 转发到 /api/mobile-sessions/:
155
160
  }
156
161
  });
157
162
 
158
- test("未登录(无账号配置)→ 三条路由统一 401 提示登录,不请求企业端", async () => {
163
+ test("mobile-sessions/delete 路由:DELETE body {id} 转发到企业端 DELETE /api/mobile-sessions/:id(拉黑 jti)", async () => {
164
+ const relay = await startFakeRelay();
165
+ const { host, base, tempDir } = await bootRelay(relay.port);
166
+ try {
167
+ const r = await (await fetch(`${base}/dsh-remote/mobile-sessions/delete`, {
168
+ method: "DELETE",
169
+ headers: { "content-type": "application/json" },
170
+ body: JSON.stringify({ id: "ms_1" }),
171
+ })).json();
172
+ assert.equal(r.ok, true);
173
+ const up = relay.seen.find((s) => s.path === "/api/mobile-sessions/ms_1" && s.method === "DELETE");
174
+ assert.ok(up, "应转发到企业端 DELETE /api/mobile-sessions/ms_1");
175
+ assert.equal(up.authorization, "Bearer jwt-abc");
176
+
177
+ // 缺 id → 400;不存在的会话 → 透传 404 文案
178
+ const bad = await (await fetch(`${base}/dsh-remote/mobile-sessions/delete`, {
179
+ method: "DELETE",
180
+ headers: { "content-type": "application/json" },
181
+ body: JSON.stringify({}),
182
+ })).json();
183
+ assert.equal(bad.ok, false);
184
+ assert.match(String(bad.error), /id/);
185
+
186
+ const ghost = await (await fetch(`${base}/dsh-remote/mobile-sessions/delete`, {
187
+ method: "DELETE",
188
+ headers: { "content-type": "application/json" },
189
+ body: JSON.stringify({ id: "ghost" }),
190
+ })).json();
191
+ assert.equal(ghost.ok, false);
192
+ assert.match(String(ghost.error), /not_found/);
193
+ } finally {
194
+ host.close();
195
+ relay.srv.close();
196
+ await rm(tempDir, { recursive: true, force: true });
197
+ }
198
+ });
199
+
200
+ test("mobile-sessions/purge 路由:POST → 企业端 POST /api/mobile-sessions/purge,removed 透传", async () => {
201
+ const relay = await startFakeRelay();
202
+ const { host, base, tempDir } = await bootRelay(relay.port);
203
+ try {
204
+ const r = await (await fetch(`${base}/dsh-remote/mobile-sessions/purge`, {
205
+ method: "POST",
206
+ headers: { "content-type": "application/json" },
207
+ body: JSON.stringify({}),
208
+ })).json();
209
+ assert.equal(r.ok, true);
210
+ assert.equal(r.removed, 2, "企业端返回的 removed 清理条数应透传");
211
+ const up = relay.seen.find((s) => s.path === "/api/mobile-sessions/purge");
212
+ assert.ok(up, "应转发到 /api/mobile-sessions/purge");
213
+ assert.equal(up.method, "POST");
214
+ assert.equal(up.authorization, "Bearer jwt-abc");
215
+ } finally {
216
+ host.close();
217
+ relay.srv.close();
218
+ await rm(tempDir, { recursive: true, force: true });
219
+ }
220
+ });
221
+
222
+ test("未登录(无账号配置)→ 五条路由统一 401 提示登录,不请求企业端", async () => {
159
223
  const relay = await startFakeRelay();
160
224
  const tempDir = await mkdtemp(path.join(os.tmpdir(), "dsh-aks-401-"));
161
225
  await writeFile(path.join(tempDir, ".dsh-config.json"), JSON.stringify({ api_url: `http://127.0.0.1:${relay.port}` }));
@@ -179,13 +243,19 @@ test("未登录(无账号配置)→ 三条路由统一 401 提示登录,
179
243
  assert.equal((await (await fetch(`${base}${p}`, { method }))).status, 401, `${p} 应返回 401`);
180
244
  assert.match(String(r.error), /尚未登录/);
181
245
  }
182
- const rev = await (await fetch(`${base}/dsh-remote/mobile-sessions/revoke`, {
183
- method: "POST",
184
- headers: { "content-type": "application/json" },
185
- body: JSON.stringify({ id: "ms_1" }),
186
- })).json();
187
- assert.equal(rev.ok, false);
188
- assert.match(String(rev.error), /尚未登录/);
246
+ for (const [method, p] of [
247
+ ["POST", "/dsh-remote/mobile-sessions/revoke"],
248
+ ["DELETE", "/dsh-remote/mobile-sessions/delete"],
249
+ ["POST", "/dsh-remote/mobile-sessions/purge"],
250
+ ]) {
251
+ const r = await (await fetch(`${base}${p}`, {
252
+ method,
253
+ headers: { "content-type": "application/json" },
254
+ body: JSON.stringify({ id: "ms_1" }),
255
+ })).json();
256
+ assert.equal(r.ok, false);
257
+ assert.match(String(r.error), /尚未登录/);
258
+ }
189
259
  assert.ok(!relay.seen.some((s) => s.path.startsWith("/api/auth-key") || s.path.startsWith("/api/mobile-sessions")), "未登录不应请求企业端");
190
260
  } finally {
191
261
  host.close();
@@ -122,7 +122,7 @@ test("composeStatus:.e2ee-state.json 启用 → /dsh-remote/status 的 service
122
122
  enabled: true, reason: "ok", profile: "pbkdf2-sha256-600k", epoch: 2, caps: ["e2ee-v2"],
123
123
  });
124
124
 
125
- // 2) bridge 未启用(服务端灰度关)→ 原样上报 reason,供面板映射可读文案
125
+ // 2) bridge 未启用(服务端关闭/不支持)→ 原样上报 reason,供面板映射可读文案
126
126
  await writeFile(stateFile, JSON.stringify({ enabled: false, reason: "server_disabled", profile: "", epoch: 0, caps: [] }));
127
127
  status = await (await fetch(`${boot.base}/dsh-remote/status`)).json();
128
128
  assert.equal(status.service.e2ee.enabled, false);
@@ -104,7 +104,8 @@ test("反馈配置:默认走 api_url(生产 relay-api 同源反馈端点)
104
104
  assert.equal(body.ok, true);
105
105
  assert.equal(body.reachable, true);
106
106
  assert.equal(body.deviceId, "dev-cfgcheck");
107
- assert.equal(body.phone, "13900000000");
107
+ // 隐私契约(2026-09):反馈配置不下发明文手机号,只给掩码(提交时服务端另附 x-dsh-phone 真号)
108
+ assert.equal(body.phone, "139****0000");
108
109
  // 代理把 /dsh-remote/feedback/api/feedback/captcha 映射到 {api_url}/api/feedback/captcha
109
110
  const cap = await fetch(`http://127.0.0.1:${host.address().port}/dsh-remote/feedback/api/feedback/captcha`);
110
111
  assert.equal(cap.status, 201);