@qzsy/dsh-bridge 0.0.0-stage → 2.18.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.
Files changed (77) hide show
  1. package/CHANGELOG.md +1107 -0
  2. package/LICENSE +21 -0
  3. package/README.en.md +488 -0
  4. package/README.md +509 -2
  5. package/client/build.mjs +43 -0
  6. package/client/client.js +9864 -0
  7. package/client/host-families.js +442 -0
  8. package/client/index.js +6883 -0
  9. package/client/locales.mjs +242 -0
  10. package/client/mobile-styles.js +1299 -0
  11. package/client/mobile-yield.js +219 -0
  12. package/client/panel-locales.mjs +777 -0
  13. package/client/picker-yield.js +77 -0
  14. package/client/resource-url-compat.js +90 -0
  15. package/client/settings-nav-icon.js +105 -0
  16. package/client/theme-tokens.js +49 -0
  17. package/client/tunnel-guard.js +46 -0
  18. package/client/unlock-manager.js +144 -0
  19. package/cordis.patch.yml +4 -0
  20. package/lib/auth/dsh-native-cookie.js +148 -0
  21. package/lib/auth/login-template.js +516 -0
  22. package/lib/auth/manager.js +596 -0
  23. package/lib/bridge-rpc-constants.js +66 -0
  24. package/lib/bridge-rpc.js +722 -0
  25. package/lib/cloudflared-manager.mjs +1277 -0
  26. package/lib/cloudflared-network-args.js +99 -0
  27. package/lib/cloudflared-process-scan.js +153 -0
  28. package/lib/compat.js +172 -0
  29. package/lib/connection-compat.js +115 -0
  30. package/lib/feishu/comment.js +50 -0
  31. package/lib/feishu/gateway.js +644 -0
  32. package/lib/feishu/index.js +432 -0
  33. package/lib/feishu/lark-bundled.mjs +131929 -0
  34. package/lib/feishu/markdown.js +148 -0
  35. package/lib/feishu/node.js +1152 -0
  36. package/lib/feishu/slash-commands.js +255 -0
  37. package/lib/index.js +3554 -0
  38. package/lib/platform/account-ops.js +248 -0
  39. package/lib/platform/activity.js +169 -0
  40. package/lib/platform/ask-user.js +293 -0
  41. package/lib/platform/base.js +147 -0
  42. package/lib/platform/changes-digest.js +93 -0
  43. package/lib/platform/commands.js +267 -0
  44. package/lib/platform/config-restore.js +78 -0
  45. package/lib/platform/conversation-bridge.js +1544 -0
  46. package/lib/platform/dsh-storage.js +222 -0
  47. package/lib/platform/index.js +10 -0
  48. package/lib/platform/instances.js +458 -0
  49. package/lib/platform/manager.js +130 -0
  50. package/lib/platform/message-split.js +229 -0
  51. package/lib/platform/session-catalog.js +405 -0
  52. package/lib/platform/session-config.js +52 -0
  53. package/lib/platform/stream-slices.js +21 -0
  54. package/lib/qq/gateway.js +1107 -0
  55. package/lib/qq/index.js +561 -0
  56. package/lib/qq/node.js +1153 -0
  57. package/lib/qq/qqbot-bundled.mjs +8273 -0
  58. package/lib/restart-helper.mjs +133 -0
  59. package/lib/saas/device-auth.mjs +366 -0
  60. package/lib/saas/tunnel-client.mjs +695 -0
  61. package/lib/security/log-sanitize.js +50 -0
  62. package/lib/security/path-validator.js +195 -0
  63. package/lib/security/rate-limiter.js +93 -0
  64. package/lib/session-strip.js +100 -0
  65. package/lib/sse-keepalive.js +115 -0
  66. package/lib/telegram/gateway.js +643 -0
  67. package/lib/telegram/index.js +255 -0
  68. package/lib/telegram/node.js +410 -0
  69. package/lib/tunnel-client.mjs +537 -0
  70. package/lib/wechat/gateway.js +1000 -0
  71. package/lib/wechat/index.js +282 -0
  72. package/lib/wechat/media.js +302 -0
  73. package/lib/wechat/node.js +360 -0
  74. package/llms.txt +36 -0
  75. package/locale/en.json +6 -0
  76. package/locale/zh.json +6 -0
  77. package/package.json +135 -4
@@ -0,0 +1,722 @@
1
+ // DSH Bridge - RPC Interface (server side)
2
+ // Loopback-only RPC methods for browser UI
3
+
4
+ import QRCode from 'qrcode';
5
+ import { BRIDGE_RPC_CHANNEL, BRIDGE_ENDPOINTS } from './bridge-rpc-constants.js';
6
+ import { registerRpcChannel } from './connection-compat.js';
7
+ import { RateLimiter } from './security/rate-limiter.js';
8
+
9
+ export { BRIDGE_RPC_CHANNEL, BRIDGE_ENDPOINTS };
10
+
11
+ const rpcRateLimiter = new RateLimiter({ maxRequests: 30, windowMs: 60000 });
12
+
13
+ function ok(value) {
14
+ return { ok: true, value };
15
+ }
16
+
17
+ function fail(code, message, details = {}) {
18
+ const allowedCodes = new Set([
19
+ 'bad-request', 'cancelled', 'internal', 'settings-rejected', 'command-error',
20
+ // 'busy':重启等操作因「有任务在跑」被拦下,需与普通 bad-request 区分,
21
+ // 面板据此弹二次确认(而不是报错)。
22
+ 'busy'
23
+ ]);
24
+ const safeCode = allowedCodes.has(code) ? code : 'bad-request';
25
+ return {
26
+ ok: false,
27
+ error: {
28
+ code: safeCode,
29
+ message,
30
+ details: { issues: [{ message }], ...details },
31
+ },
32
+ };
33
+ }
34
+
35
+ // 把登录态里的二维码载荷渲染成浏览器可展示的 dataURL(带缓存,避免重复生成)
36
+ async function renderQr(loginState) {
37
+ if (!loginState?.qrPayload) return null;
38
+ // issue #78:缓存键包含 instanceId,避免同类型多账号二维码互串
39
+ const cacheKey = `${loginState.instanceId ?? ''}:${loginState.qrKind}:${loginState.qrPayload.slice(0, 80)}`;
40
+ if (renderQr.cache && renderQr.cache.key === cacheKey) return renderQr.cache.url;
41
+ let url;
42
+ const payload = loginState.qrPayload;
43
+ if (loginState.qrKind === 'img') {
44
+ url = /^data:/i.test(payload) ? payload : `data:image/png;base64,${payload}`;
45
+ } else {
46
+ try {
47
+ url = await QRCode.toDataURL(payload, {
48
+ width: 300, margin: 2, color: { dark: '#1F2421', light: '#FFFFFF' },
49
+ });
50
+ } catch { url = null; }
51
+ }
52
+ renderQr.cache = { key: cacheKey, url };
53
+ return url;
54
+ }
55
+
56
+ /** 为某个平台 status 渲染登录二维码(带上 instanceId 参与缓存键,避免多账号互串)。 */
57
+ function renderStatusQr(status) {
58
+ return renderQr({ ...(status?.login ?? {}), instanceId: status?.instanceId }).catch(() => null);
59
+ }
60
+
61
+ function checkAdminAuth(authManager, payload, { requireConfigured = false } = {}) {
62
+ if (!authManager) return null;
63
+ // local_only 最严格:即使关闭管理保护,也绝不远程放行(本机经 loopback-token 天然持有 adminToken)
64
+ // 注意:RPC 层无法区分本机/远程(代理以回环转发),local_only 的防线是 unlockAdmin 拒绝
65
+ // 远程解锁——因此这里管理保护关闭时也不放行 local_only,保持"仅本机可管理"的语义。
66
+ if (authManager.adminPolicy === 'local_only') {
67
+ // local_only 下必须持有有效 adminToken(只有本机 loopback-token / 本机解锁能拿到)
68
+ if (payload?.adminToken && authManager.validateAdminSession(payload.adminToken)) {
69
+ return null;
70
+ }
71
+ return fail('bad-request', '操作已被拦截:当前策略为仅限电脑本机管理');
72
+ }
73
+ // 管理保护独立开关:用户明确关闭后,管理操作免 adminToken(与访问认证 enabled 解耦)
74
+ if (authManager.adminProtection === false) return null;
75
+ if (authManager.adminPolicy === 'open') return null;
76
+ // 若系统尚未设置任何管理密码或访客密码,允许免密管理
77
+ const hasAnyPassword = authManager.hasAdminPassword || authManager.hasPassword;
78
+ // T2.9:高危操作(备份导出/导入、隧道配置与启动、目录浏览、添加工作区、升级、重启)
79
+ // 在系统从未设置任何密码时不再静默放行,强制先完成一次密码设置,
80
+ // 杜绝"未设密码 = 局域网/隧道内任何人都可导出全部凭证"的裸奔状态被直接利用
81
+ if (requireConfigured && !hasAnyPassword) {
82
+ return fail('bad-request', '该操作涉及敏感配置:请先在「安全认证」中设置访问密码或管理密码后再执行');
83
+ }
84
+ if (!hasAnyPassword) {
85
+ return null;
86
+ }
87
+ // 已设置密码时,必须提供经服务端校验有效的 adminToken(绝不依赖客户端自称的 isLocalhost)
88
+ if (payload?.adminToken && authManager.validateAdminSession(payload.adminToken)) {
89
+ return null;
90
+ }
91
+ return fail('bad-request', '操作已被拦截:需要管理员权限,请先在控制台输入管理密码解锁');
92
+ }
93
+
94
+ /** 从平台实例取实例标识(旧实例可能没有 instanceId,回退 id)。 */
95
+ function platformRefOf(platform) {
96
+ const platformId = platform?.id;
97
+ return { platformId, instanceId: platform?.instanceId ?? platformId };
98
+ }
99
+
100
+ /** 给平台 status 补 instanceId/platformId/displayName(issue #78 契约 §4/§7)。 */
101
+ function decoratePlatformStatus(platform) {
102
+ const status = platform?.getStatus?.() ?? {};
103
+ const { instanceId } = platformRefOf(platform);
104
+ return {
105
+ ...status,
106
+ instanceId: status.instanceId ?? instanceId,
107
+ platformId: platform?.id ?? status.platformId ?? status.id ?? instanceId,
108
+ displayName: platform?.displayName ?? status.displayName ?? platform?.name ?? status.name ?? instanceId,
109
+ };
110
+ }
111
+
112
+ /** 平台 RPC 成功响应:附加渲染后的二维码(保留旧字段处理)。 */
113
+ function withRenderedQr(status, qr) {
114
+ return { ...status, login: { ...(status.login ?? {}), qr, qrPayload: undefined, qrKind: undefined } };
115
+ }
116
+
117
+ /**
118
+ * 解析平台定位参数:优先 instanceId,缺省回退 platformId(旧客户端兼容)。
119
+ * 返回 { platform } 或 { error }。
120
+ */
121
+ function resolvePlatformParam(platformManager, payload) {
122
+ const ref = payload?.instanceId ?? payload?.platformId;
123
+ if (!ref) return { error: fail('bad-request', '缺少 instanceId/platformId 参数') };
124
+ const platform = platformManager.resolve ? platformManager.resolve(ref) : platformManager.get(ref);
125
+ if (!platform) return { error: fail('bad-request', `平台未注册: ${ref}`) };
126
+ return { platform };
127
+ }
128
+
129
+ export function installBridgeRpc(ctx, {
130
+ service,
131
+ authManager,
132
+ platformManager,
133
+ logger,
134
+ saveCustomTunnelConfig,
135
+ exportBackup,
136
+ importBackup,
137
+ runtime,
138
+ getUiFlags,
139
+ setUiFlags,
140
+ // issue #78:多账号运行时增删与保存前凭证查重(由 lib/index.js 注入,可缺省)
141
+ addPlatformAccount,
142
+ removePlatformAccount,
143
+ validatePlatformAccountConfig,
144
+ }) {
145
+ if (!ctx?.connection?.rpc?.handle) {
146
+ logger.warn('dsh-bridge: Connection RPC unavailable — UI will not work');
147
+ return () => {};
148
+ }
149
+
150
+ // 运行中任务探测所需的引用(由 apply 注入)。缺失时探测退化为「无任务」,不阻断重启。
151
+ if (runtime) {
152
+ service.runtimeCtx = runtime.ctx ?? null;
153
+ service.platformManager = runtime.platformManager ?? null;
154
+ }
155
+
156
+ // 经 connection-compat 注册:兼容 DSH ≥ 0.1.5-alpha.1 的 webServer 注入回归
157
+ // (上游 register() 里对 connection 自身 ctx 取 webServer 而未加 inject 作用域)
158
+ return registerRpcChannel(
159
+ ctx,
160
+ BRIDGE_RPC_CHANNEL,
161
+ async (endpoint, payload = {}, signal) => {
162
+ if (signal?.aborted) return fail('cancelled', 'Request was cancelled');
163
+
164
+ try {
165
+ // 本通道内所有"管理员专属"分支都已先过 checkAdminAuth;这些分支返回的状态必须显式
166
+ // 声明管理员视图,否则 getStatus 会把仅限管理员可见的字段(外部隧道 URL/二维码、
167
+ // cloudflared token)按访客视图掩码掉,管理员保存后立刻拿到一份"被抹掉"的状态。
168
+ const adminStatus = () => service.getStatus({ adminAuthValid: true });
169
+
170
+ if (endpoint === BRIDGE_ENDPOINTS.getStatus) {
171
+ const isAdmin = checkAdminAuth(authManager, payload) === null;
172
+ const status = await service.getStatus({ adminAuthValid: isAdmin });
173
+ return ok(status);
174
+ }
175
+
176
+ // ---- 访问安全认证 ----
177
+
178
+ if (endpoint === BRIDGE_ENDPOINTS.authGetStatus) {
179
+ if (!authManager) return fail('bad-request', 'AuthManager 未初始化');
180
+ const isAdmin = checkAdminAuth(authManager, payload) === null;
181
+ return ok(authManager.getStatus({ masked: !isAdmin }));
182
+ }
183
+
184
+ if (endpoint === BRIDGE_ENDPOINTS.authUpdateConfig) {
185
+ if (!authManager) return fail('bad-request', 'AuthManager 未初始化');
186
+ const { enabled, mode, scope, adminPolicy, adminProtection, password, adminPassword } = payload;
187
+ // 仅切换 enabled(访问认证开关)不需要管理权限:用户应能自由决定是否开放访问,
188
+ // 否则"关闭访问认证"这个动作本身会被管理保护锁死(死锁:关闭要先解锁,解锁要过认证)。
189
+ // 其余字段(模式/范围/策略/密码/管理保护)均涉及安全配置,仍需管理权限。
190
+ const sensitive = mode !== undefined || scope !== undefined || adminPolicy !== undefined
191
+ || adminProtection !== undefined || password !== undefined || adminPassword !== undefined;
192
+ if (sensitive) {
193
+ const adminErr = checkAdminAuth(authManager, payload);
194
+ if (adminErr) return adminErr;
195
+ }
196
+
197
+ // 防自我锁死守卫(v2.10.5):
198
+ // password_only(仅密码)模式下若从未设置任何密码,开启防护或维持该模式会把
199
+ // 管理员锁在登录墙外(无哈希可校验、密码登录被拒 → 进不去面板设密码 → 死锁)。
200
+ // 因此:无任何密码时禁止单独开启 enabled(除非本次同请求携带 password),
201
+ // 也禁止单独切换到 password_only(除非已设密码或本次带 password)。
202
+ const willHaveNoPassword = !authManager.hasPassword && !authManager.hasAdminPassword
203
+ && (password === undefined || !password);
204
+ const nextMode = mode ?? authManager.mode;
205
+ const nextEnabled = enabled ?? authManager.enabled;
206
+ if (willHaveNoPassword && nextMode === 'password_only') {
207
+ if (nextEnabled) {
208
+ return fail('bad-request', '仅密码模式需要先设置访问密码:请先在下方「设置外部访客访问密码」处设置密码,再开启安全防护');
209
+ }
210
+ if (mode !== undefined) {
211
+ return fail('bad-request', '仅密码模式需要先设置访问密码:请先在下方「设置外部访客访问密码」处设置密码后再切换');
212
+ }
213
+ }
214
+
215
+ if (enabled != null) await authManager.setEnabled(enabled);
216
+ if (mode != null) await authManager.setMode(mode);
217
+ if (scope != null) await authManager.setScope(scope);
218
+ if (adminPolicy != null) await authManager.setAdminPolicy(adminPolicy);
219
+ if (adminProtection != null) await authManager.setAdminProtection(adminProtection);
220
+ if (password !== undefined) await authManager.setPassword(password);
221
+ if (adminPassword !== undefined) await authManager.setAdminPassword(adminPassword);
222
+ return ok(authManager.getStatus({ masked: false }));
223
+ }
224
+
225
+ if (endpoint === BRIDGE_ENDPOINTS.dismissFirstRunGuide) {
226
+ // 无管理权限要求:这是纯本地提示状态,不涉及安全配置变更。
227
+ // 与 authUpdateConfig 中「切换 enabled 不需管理权限」同理,避免提示无法关闭。
228
+ return ok(await service.dismissFirstRunGuide());
229
+ }
230
+
231
+ // ---- 页面改写层用户偏好(设置面板可见开关)----
232
+ // 读开放(仅布尔偏好,无敏感信息);写需管理权限(影响全部被代理页面的展示)。
233
+ if (endpoint === BRIDGE_ENDPOINTS.uiGetFlags) {
234
+ const flags = getUiFlags ? await getUiFlags() : { pageTweaks: true, hostAllows: true, effective: true };
235
+ return ok(flags);
236
+ }
237
+
238
+ if (endpoint === BRIDGE_ENDPOINTS.uiUpdateConfig) {
239
+ const adminErr = checkAdminAuth(authManager, payload);
240
+ if (adminErr) return adminErr;
241
+ if (!setUiFlags) return fail('internal', 'UI 配置持久化不可用');
242
+ const { pageTweaks } = payload ?? {};
243
+ if (pageTweaks !== undefined && typeof pageTweaks !== 'boolean') {
244
+ return fail('bad-request', 'pageTweaks 必须为布尔值');
245
+ }
246
+ return ok(await setUiFlags(pageTweaks === undefined ? {} : { pageTweaks }));
247
+ }
248
+
249
+ if (endpoint === BRIDGE_ENDPOINTS.authRegenerateToken) {
250
+ if (!authManager) return fail('bad-request', 'AuthManager 未初始化');
251
+ const adminErr = checkAdminAuth(authManager, payload);
252
+ if (adminErr) return adminErr;
253
+
254
+ await authManager.regenerateSecretToken();
255
+ return ok(authManager.getStatus({ masked: false }));
256
+ }
257
+
258
+ if (endpoint === BRIDGE_ENDPOINTS.authAdminUnlock) {
259
+ if (!authManager) return fail('bad-request', 'AuthManager 未初始化');
260
+ const { password } = payload;
261
+ const res = await authManager.unlockAdmin(password);
262
+ if (res.ok) return ok({ adminToken: res.adminToken });
263
+ return fail('bad-request', res.error || '管理员密码错误');
264
+ }
265
+
266
+ if (endpoint === BRIDGE_ENDPOINTS.authAdminLock) {
267
+ if (!authManager) return fail('bad-request', 'AuthManager 未初始化');
268
+ if (payload?.adminToken) authManager.revokeAdminSession(payload.adminToken);
269
+ return ok({ locked: true });
270
+ }
271
+
272
+ if (endpoint === BRIDGE_ENDPOINTS.saveCustomTunnelConfig) {
273
+ const adminErr = checkAdminAuth(authManager, payload, { requireConfigured: true });
274
+ if (adminErr) return adminErr;
275
+
276
+ // 未提供的字段保持 undefined 透传:服务端视为"保留现值"。
277
+ // mode/gatewayUrl 为 SaaS 扩展(面板模式切换与网关地址),不影响 self 语义。
278
+ const { serverUrl, accessToken, sseStreaming, mode, gatewayUrl } = payload;
279
+ await saveCustomTunnelConfig(serverUrl, accessToken, sseStreaming, { mode, gatewayUrl });
280
+ const status = await adminStatus();
281
+ return ok(status);
282
+ }
283
+
284
+ if (endpoint === BRIDGE_ENDPOINTS.saveCloudflaredConfig) {
285
+ const adminErr = checkAdminAuth(authManager, payload, { requireConfigured: true });
286
+ if (adminErr) return adminErr;
287
+
288
+ const { token, hostname, protocol, edgeIpVersion, region } = payload;
289
+ await service.saveCloudflaredConfig({ token, hostname, protocol, edgeIpVersion, region });
290
+ const status = await adminStatus();
291
+ return ok(status);
292
+ }
293
+
294
+ if (endpoint === BRIDGE_ENDPOINTS.saveExternalTunnel) {
295
+ const adminErr = checkAdminAuth(authManager, payload, { requireConfigured: true });
296
+ if (adminErr) return adminErr;
297
+
298
+ const { url } = payload;
299
+ await service.saveExternalTunnel({ url });
300
+ const status = await adminStatus();
301
+ return ok(status);
302
+ }
303
+
304
+ if (endpoint === BRIDGE_ENDPOINTS.setTunnelAutoStart) {
305
+ const adminErr = checkAdminAuth(authManager, payload, { requireConfigured: true });
306
+ if (adminErr) return adminErr;
307
+
308
+ const { tunnel, autoStart } = payload;
309
+ await service.setTunnelAutoStart({ tunnel, autoStart });
310
+ const status = await adminStatus();
311
+ return ok(status);
312
+ }
313
+
314
+ if (endpoint === BRIDGE_ENDPOINTS.setLanIp) {
315
+ const adminErr = checkAdminAuth(authManager, payload);
316
+ if (adminErr) return adminErr;
317
+
318
+ const { ip } = payload;
319
+ const status = await service.setLanIp({ ip, adminAuthValid: true });
320
+ return ok(status);
321
+ }
322
+
323
+ if (endpoint === BRIDGE_ENDPOINTS.startCustomTunnel) {
324
+ const adminErr = checkAdminAuth(authManager, payload, { requireConfigured: true });
325
+ if (adminErr) return adminErr;
326
+
327
+ try {
328
+ await service.startCustomTunnel();
329
+ const status = await adminStatus();
330
+ return ok(status);
331
+ } catch (err) {
332
+ logger.error('Failed to start custom tunnel: %s', err.message);
333
+ return fail('bad-request', err.message);
334
+ }
335
+ }
336
+
337
+ if (endpoint === BRIDGE_ENDPOINTS.stopCustomTunnel) {
338
+ const adminErr = checkAdminAuth(authManager, payload);
339
+ if (adminErr) return adminErr;
340
+
341
+ service.stopCustomTunnel();
342
+ const status = await adminStatus();
343
+ return ok(status);
344
+ }
345
+
346
+ // ---- SaaS 隧道(设备码绑定 / 解绑)----
347
+ // 读状态:只要求管理员鉴权(不回传 deviceSecret 明文,仅掩码)
348
+ if (endpoint === BRIDGE_ENDPOINTS.saasGetStatus) {
349
+ const adminErr = checkAdminAuth(authManager, payload);
350
+ if (adminErr) return adminErr;
351
+ return ok(service.getSaasStatus({ adminAuthValid: true }));
352
+ }
353
+
354
+ // 绑定类操作涉及设备凭证落盘,与 saveCustomTunnelConfig/startCustomTunnel 同门槛:
355
+ // 系统从未设置任何密码时拒绝(requireConfigured),避免裸奔设备被他人绑定/解绑。
356
+ if (endpoint === BRIDGE_ENDPOINTS.saasRequestDeviceCode) {
357
+ const adminErr = checkAdminAuth(authManager, payload, { requireConfigured: true });
358
+ if (adminErr) return adminErr;
359
+ try {
360
+ return ok(await service.requestSaasDeviceCode({ gatewayUrl: payload?.gatewayUrl }));
361
+ } catch (err) {
362
+ logger.error('Failed to request SaaS device code: %s', err?.message ?? err);
363
+ return fail('bad-request', err?.message ?? String(err));
364
+ }
365
+ }
366
+
367
+ if (endpoint === BRIDGE_ENDPOINTS.saasBindDevice) {
368
+ const adminErr = checkAdminAuth(authManager, payload, { requireConfigured: true });
369
+ if (adminErr) return adminErr;
370
+ try {
371
+ return ok(await service.bindSaasDevice({ code: payload?.code, gatewayUrl: payload?.gatewayUrl }));
372
+ } catch (err) {
373
+ logger.error('Failed to bind SaaS device: %s', err?.message ?? err);
374
+ return fail('bad-request', err?.message ?? String(err));
375
+ }
376
+ }
377
+
378
+ if (endpoint === BRIDGE_ENDPOINTS.saasUnbindDevice) {
379
+ const adminErr = checkAdminAuth(authManager, payload, { requireConfigured: true });
380
+ if (adminErr) return adminErr;
381
+ try {
382
+ return ok(await service.unbindSaasDevice());
383
+ } catch (err) {
384
+ logger.error('Failed to unbind SaaS device: %s', err?.message ?? err);
385
+ return fail('bad-request', err?.message ?? String(err));
386
+ }
387
+ }
388
+
389
+ if (endpoint === BRIDGE_ENDPOINTS.startCloudflared) {
390
+ const adminErr = checkAdminAuth(authManager, payload, { requireConfigured: true });
391
+ if (adminErr) return adminErr;
392
+
393
+ try {
394
+ await service.startCloudflared();
395
+ const status = await adminStatus();
396
+ return ok(status);
397
+ } catch (err) {
398
+ logger.error('Failed to start cloudflared: %s', err.message);
399
+ return fail('bad-request', err.message);
400
+ }
401
+ }
402
+
403
+ if (endpoint === BRIDGE_ENDPOINTS.stopCloudflared) {
404
+ const adminErr = checkAdminAuth(authManager, payload);
405
+ if (adminErr) return adminErr;
406
+
407
+ service.stopCloudflared();
408
+ const status = await adminStatus();
409
+ return ok(status);
410
+ }
411
+
412
+ if (endpoint === BRIDGE_ENDPOINTS.resetCloudflared) {
413
+ const adminErr = checkAdminAuth(authManager, payload);
414
+ if (adminErr) return adminErr;
415
+
416
+ await service.resetCloudflared();
417
+ const status = await adminStatus();
418
+ return ok(status);
419
+ }
420
+
421
+ if (endpoint === BRIDGE_ENDPOINTS.checkVersion) {
422
+ const result = await service.checkVersion();
423
+ return ok(result);
424
+ }
425
+
426
+ if (endpoint === BRIDGE_ENDPOINTS.upgradePlugin) {
427
+ const adminErr = checkAdminAuth(authManager, payload, { requireConfigured: true });
428
+ if (adminErr) return adminErr;
429
+
430
+ const result = await service.upgradePlugin(payload);
431
+ return ok(result);
432
+ }
433
+
434
+ if (endpoint === BRIDGE_ENDPOINTS.upgradeDsh) {
435
+ const adminErr = checkAdminAuth(authManager, payload, { requireConfigured: true });
436
+ if (adminErr) return adminErr;
437
+
438
+ const result = await service.upgradeDsh(payload);
439
+ return ok(result);
440
+ }
441
+
442
+ if (endpoint === BRIDGE_ENDPOINTS.restartDsh) {
443
+ const adminErr = checkAdminAuth(authManager, payload, { requireConfigured: true });
444
+ if (adminErr) return adminErr;
445
+
446
+ // 打断保护:有任务在跑且调用方未显式确认时,**不重启**,改为把现场回给面板,
447
+ // 由面板弹二次确认。已确认(payload.confirm === true)则照常重启。
448
+ // 探测本身失败会降级为「无任务」,绝不会把重启锁死。
449
+ if (payload?.confirm !== true) {
450
+ let work;
451
+ try {
452
+ work = service.getActiveWork?.() ?? { total: 0, sessions: [], subagentSessions: [], pendingApprovals: [] };
453
+ } catch (err) {
454
+ logger?.debug?.('dsh-bridge: 重启前探测任务失败(按无任务处理):%s', err?.message ?? err);
455
+ work = { total: 0, sessions: [], subagentSessions: [], pendingApprovals: [] };
456
+ }
457
+ if (work.total > 0) {
458
+ return fail('busy', '当前有任务正在运行,重启会打断它们', { activeWork: work });
459
+ }
460
+ }
461
+
462
+ const result = await service.restartDsh();
463
+ return ok(result);
464
+ }
465
+
466
+ if (endpoint === BRIDGE_ENDPOINTS.exportBackup) {
467
+ const adminErr = checkAdminAuth(authManager, payload, { requireConfigured: true });
468
+ if (adminErr) return adminErr;
469
+
470
+ if (!exportBackup) return fail('bad-request', '备份导出服务不可用');
471
+ const backup = await exportBackup();
472
+ return ok(backup);
473
+ }
474
+
475
+ if (endpoint === BRIDGE_ENDPOINTS.importBackup) {
476
+ const adminErr = checkAdminAuth(authManager, payload, { requireConfigured: true });
477
+ if (adminErr) return adminErr;
478
+
479
+ if (!importBackup) return fail('bad-request', '备份导入服务不可用');
480
+ const result = await importBackup(payload?.backup);
481
+ const status = await adminStatus();
482
+ return ok({ result, status });
483
+ }
484
+
485
+ if (endpoint === BRIDGE_ENDPOINTS.diagnoseNetwork) {
486
+ const result = await service.diagnoseNetwork();
487
+ return ok(result);
488
+ }
489
+
490
+ if (endpoint === BRIDGE_ENDPOINTS.getSystemMetrics) {
491
+ const metrics = service.getSystemMetrics();
492
+ return ok(metrics);
493
+ }
494
+
495
+ // ---- 远程工作区管理与目录浏览 ----
496
+
497
+ if (endpoint === BRIDGE_ENDPOINTS.listRemoteDirectories) {
498
+ const adminErr = checkAdminAuth(authManager, payload, { requireConfigured: true });
499
+ if (adminErr) return adminErr;
500
+
501
+ const clientKey = payload?.clientIp || payload?.adminToken || 'default';
502
+ const rateCheck = rpcRateLimiter.check(clientKey, 30);
503
+ if (!rateCheck.allowed) {
504
+ return fail('bad-request', `请求过于频繁,请等待 ${rateCheck.retryAfterSec} 秒后再试`);
505
+ }
506
+
507
+ const result = await service.listRemoteDirectories(payload?.path);
508
+ return ok(result);
509
+ }
510
+
511
+ if (endpoint === BRIDGE_ENDPOINTS.addRemoteWorkspace) {
512
+ const adminErr = checkAdminAuth(authManager, payload, { requireConfigured: true });
513
+ if (adminErr) return adminErr;
514
+
515
+ const clientKey = payload?.clientIp || payload?.adminToken || 'default';
516
+ const rateCheck = rpcRateLimiter.check(clientKey, 20);
517
+ if (!rateCheck.allowed) {
518
+ return fail('bad-request', `添加工作区请求过于频繁,请等待 ${rateCheck.retryAfterSec} 秒后再试`);
519
+ }
520
+
521
+ const result = await service.addWorkspace(payload?.path);
522
+ return ok(result);
523
+ }
524
+
525
+ if (endpoint === BRIDGE_ENDPOINTS.listWorkspaces) {
526
+ const adminErr = checkAdminAuth(authManager, payload);
527
+ if (adminErr) return adminErr;
528
+
529
+ const result = await service.getWorkspaces();
530
+ return ok(result);
531
+ }
532
+
533
+ // ---- 平台管理器(多 IM 平台)----
534
+
535
+ // 本机可用的 DSH agent preset(设置页下拉用)。
536
+ // 只读、不含敏感信息:DSH 未提供 agentPresets 服务(旧版本)或名册读取失败时
537
+ // 返回 available:false,前端退化为手填,不影响会话创建。
538
+ if (endpoint === BRIDGE_ENDPOINTS.listAgentPresets) {
539
+ try {
540
+ const presets = ctx.get?.('agentPresets');
541
+ if (!presets?.list) return ok({ available: false, default: null, presets: [] });
542
+ const rows = await presets.list();
543
+ return ok({
544
+ available: true,
545
+ default: typeof presets.defaultId === 'string' ? presets.defaultId : null,
546
+ presets: (Array.isArray(rows) ? rows : [])
547
+ // 坏掉的预设不列出来(DSH 自己也会在挂载前拒绝)
548
+ .filter((p) => p && typeof p.id === 'string' && p.broken === undefined)
549
+ .map((p) => ({ id: p.id, name: typeof p.name === 'string' ? p.name : p.id })),
550
+ });
551
+ } catch (err) {
552
+ // 服务被卸载 / 名册不可读:这是设置页的辅助信息,不该让整个请求失败
553
+ logger?.warn?.('dsh-bridge: listAgentPresets failed: %s', err?.message ?? err);
554
+ return ok({ available: false, default: null, presets: [] });
555
+ }
556
+ }
557
+
558
+ if (endpoint === BRIDGE_ENDPOINTS.listPlatforms) {
559
+ // #55:缺失时必须显错并记日志——此前 ok({}) 让前端把“查询失败”画成“即将支持”
560
+ if (!platformManager) {
561
+ try { logger?.error?.('dsh-bridge: listPlatforms without platformManager (see issue #55)'); } catch {}
562
+ return fail('internal', 'PlatformManager 未初始化,IM 平台清单不可用');
563
+ }
564
+ // 每个平台的 login.qrPayload 渲染为 dataURL 后返回
565
+ const raw = platformManager.getStatus();
566
+ if (!raw || Object.keys(raw).length === 0) {
567
+ try { logger?.warn?.('dsh-bridge: listPlatforms returned empty platform table'); } catch {}
568
+ }
569
+ const out = {};
570
+ for (const [id, status] of Object.entries(raw)) {
571
+ let qr = null;
572
+ try { qr = await renderStatusQr(status); } catch { /* ignore */ }
573
+ out[id] = { ...status, login: { ...(status.login ?? {}), qr, qrPayload: undefined, qrKind: undefined } };
574
+ }
575
+ return ok(out);
576
+ }
577
+
578
+ // ---- 平台操作(统一接口)----
579
+ // 参数统一支持 instanceId(新客户端)与 platformId(旧客户端,回退到该类型第一个实例)
580
+
581
+ if (endpoint === BRIDGE_ENDPOINTS.platformLogin) {
582
+ if (!platformManager) return fail('bad-request', 'PlatformManager 未初始化');
583
+ const adminErr = checkAdminAuth(authManager, payload);
584
+ if (adminErr) return adminErr;
585
+
586
+ const resolved = resolvePlatformParam(platformManager, payload);
587
+ if (resolved.error) return resolved.error;
588
+ const { platform } = resolved;
589
+ const { qrType, refresh } = payload;
590
+ const result = await platform.login({ qrType, refresh });
591
+ if (!result.ok) return fail('bad-request', result.error ?? '登录启动失败');
592
+ const status = decoratePlatformStatus(platform);
593
+ const qr = await renderStatusQr(status);
594
+ return ok(withRenderedQr(status, qr));
595
+ }
596
+
597
+ if (endpoint === BRIDGE_ENDPOINTS.platformSetAllowFrom) {
598
+ if (!platformManager) return fail('bad-request', 'PlatformManager 未初始化');
599
+ const adminErr = checkAdminAuth(authManager, payload);
600
+ if (adminErr) return adminErr;
601
+
602
+ const resolved = resolvePlatformParam(platformManager, payload);
603
+ if (resolved.error) return resolved.error;
604
+ const { platform } = resolved;
605
+ await platform.setAllowFrom(payload.allowFrom);
606
+ const status = decoratePlatformStatus(platform);
607
+ const qr = await renderStatusQr(status);
608
+ return ok(withRenderedQr(status, qr));
609
+ }
610
+
611
+ if (endpoint === BRIDGE_ENDPOINTS.platformSetConfig) {
612
+ if (!platformManager) return fail('bad-request', 'PlatformManager 未初始化');
613
+ const adminErr = checkAdminAuth(authManager, payload);
614
+ if (adminErr) return adminErr;
615
+
616
+ const resolved = resolvePlatformParam(platformManager, payload);
617
+ if (resolved.error) return resolved.error;
618
+ const { platform } = resolved;
619
+ const config = { ...payload };
620
+ delete config.instanceId;
621
+ delete config.platformId;
622
+ // 同类型重复凭证校验(契约 §5):与其它账号冲突时返回可读中文错误
623
+ if (typeof validatePlatformAccountConfig === 'function') {
624
+ try {
625
+ await validatePlatformAccountConfig(platform, config);
626
+ } catch (err) {
627
+ return fail('bad-request', err?.message ?? String(err));
628
+ }
629
+ }
630
+ await platform.setConfig(config);
631
+ const status = decoratePlatformStatus(platform);
632
+ const qr = await renderStatusQr(status);
633
+ return ok(withRenderedQr(status, qr));
634
+ }
635
+
636
+ if (endpoint === BRIDGE_ENDPOINTS.platformStop) {
637
+ if (!platformManager) return fail('bad-request', 'PlatformManager 未初始化');
638
+ const adminErr = checkAdminAuth(authManager, payload);
639
+ if (adminErr) return adminErr;
640
+
641
+ const resolved = resolvePlatformParam(platformManager, payload);
642
+ if (resolved.error) return resolved.error;
643
+ const { platform } = resolved;
644
+ await platform.stop();
645
+ const status = decoratePlatformStatus(platform);
646
+ const qr = await renderStatusQr(status);
647
+ return ok(withRenderedQr(status, qr));
648
+ }
649
+
650
+ if (endpoint === BRIDGE_ENDPOINTS.platformStart) {
651
+ if (!platformManager) return fail('bad-request', 'PlatformManager 未初始化');
652
+ const adminErr = checkAdminAuth(authManager, payload);
653
+ if (adminErr) return adminErr;
654
+
655
+ const resolved = resolvePlatformParam(platformManager, payload);
656
+ if (resolved.error) return resolved.error;
657
+ const { platform } = resolved;
658
+ await platform.start();
659
+ const status = decoratePlatformStatus(platform);
660
+ const qr = await renderStatusQr(status);
661
+ return ok(withRenderedQr(status, qr));
662
+ }
663
+
664
+ if (endpoint === BRIDGE_ENDPOINTS.platformUnbind) {
665
+ if (!platformManager) return fail('bad-request', 'PlatformManager 未初始化');
666
+ const adminErr = checkAdminAuth(authManager, payload);
667
+ if (adminErr) return adminErr;
668
+
669
+ const resolved = resolvePlatformParam(platformManager, payload);
670
+ if (resolved.error) return resolved.error;
671
+ const { platform } = resolved;
672
+ await platform.unbind();
673
+ const status = decoratePlatformStatus(platform);
674
+ const qr = await renderStatusQr(status);
675
+ return ok(withRenderedQr(status, qr));
676
+ }
677
+
678
+ // ---- 平台账号管理(issue #78 多账号,高危:鉴权同 platformSetConfig)----
679
+
680
+ if (endpoint === BRIDGE_ENDPOINTS.platformAddAccount) {
681
+ if (!platformManager) return fail('bad-request', 'PlatformManager 未初始化');
682
+ const adminErr = checkAdminAuth(authManager, payload);
683
+ if (adminErr) return adminErr;
684
+ if (typeof addPlatformAccount !== 'function') return fail('internal', '多账号能力未接线');
685
+
686
+ try {
687
+ const result = await addPlatformAccount({ platformId: payload.platformId, name: payload.name });
688
+ return ok({ instanceId: result?.instanceId, status: result?.status ?? null });
689
+ } catch (err) {
690
+ logger?.warn?.('dsh-bridge: platformAddAccount failed: %s', err?.message ?? err);
691
+ return fail('bad-request', err?.message ?? String(err));
692
+ }
693
+ }
694
+
695
+ if (endpoint === BRIDGE_ENDPOINTS.platformRemoveAccount) {
696
+ if (!platformManager) return fail('bad-request', 'PlatformManager 未初始化');
697
+ const adminErr = checkAdminAuth(authManager, payload);
698
+ if (adminErr) return adminErr;
699
+ if (typeof removePlatformAccount !== 'function') return fail('internal', '多账号能力未接线');
700
+
701
+ try {
702
+ const result = await removePlatformAccount({ instanceId: payload.instanceId });
703
+ return ok({ ok: true, instanceId: result?.instanceId ?? payload.instanceId });
704
+ } catch (err) {
705
+ logger?.warn?.('dsh-bridge: platformRemoveAccount failed: %s', err?.message ?? err);
706
+ return fail('bad-request', err?.message ?? String(err));
707
+ }
708
+ }
709
+
710
+ return fail('bad-request', `Unknown endpoint: ${endpoint}`);
711
+ } catch (err) {
712
+ logger.error('RPC endpoint %s failed: %s', endpoint, err.message);
713
+ return fail('bad-request', err.message);
714
+ }
715
+ },
716
+ // DSH 0.1.0/0.1.1 支持 authority 选项(仅回环可达);0.1.2+ 忽略之,透传无副作用。
717
+ // 见 lib/connection-compat.js 的 registerRpcChannel 说明。
718
+ { authority: 'loopback' },
719
+ logger
720
+ );
721
+ }
722
+