@qzsy/dsh-bridge 2.18.0 → 3.0.1
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 +15 -1094
- package/LICENSE +21 -21
- package/README.md +73 -476
- package/client/build.mjs +1 -3
- package/client/client.js +381 -9747
- package/client/index.js +242 -6828
- package/client/locales.mjs +99 -233
- package/cordis.patch.yml +5 -3
- package/lib/config.js +93 -0
- package/lib/data-dir.js +16 -0
- package/lib/dsh-cookie.js +111 -0
- package/lib/gate.js +414 -0
- package/lib/identity.js +23 -0
- package/lib/index.js +303 -3496
- package/lib/saas/tunnel-client.mjs +529 -467
- package/locale/en.json +3 -3
- package/locale/zh.json +3 -3
- package/package.json +15 -70
- package/README.en.md +0 -488
- package/client/host-families.js +0 -442
- package/client/mobile-styles.js +0 -1299
- package/client/mobile-yield.js +0 -219
- package/client/panel-locales.mjs +0 -777
- package/client/picker-yield.js +0 -77
- package/client/resource-url-compat.js +0 -90
- package/client/settings-nav-icon.js +0 -105
- package/client/theme-tokens.js +0 -49
- package/client/tunnel-guard.js +0 -46
- package/client/unlock-manager.js +0 -144
- package/lib/auth/dsh-native-cookie.js +0 -148
- package/lib/auth/login-template.js +0 -516
- package/lib/auth/manager.js +0 -596
- package/lib/bridge-rpc-constants.js +0 -66
- package/lib/bridge-rpc.js +0 -722
- package/lib/cloudflared-manager.mjs +0 -1277
- package/lib/cloudflared-network-args.js +0 -99
- package/lib/cloudflared-process-scan.js +0 -153
- package/lib/compat.js +0 -172
- package/lib/connection-compat.js +0 -115
- package/lib/feishu/comment.js +0 -50
- package/lib/feishu/gateway.js +0 -644
- package/lib/feishu/index.js +0 -432
- package/lib/feishu/lark-bundled.mjs +0 -131929
- package/lib/feishu/markdown.js +0 -148
- package/lib/feishu/node.js +0 -1152
- package/lib/feishu/slash-commands.js +0 -255
- package/lib/platform/account-ops.js +0 -248
- package/lib/platform/activity.js +0 -169
- package/lib/platform/ask-user.js +0 -293
- package/lib/platform/base.js +0 -147
- package/lib/platform/changes-digest.js +0 -93
- package/lib/platform/commands.js +0 -267
- package/lib/platform/config-restore.js +0 -78
- package/lib/platform/conversation-bridge.js +0 -1544
- package/lib/platform/dsh-storage.js +0 -222
- package/lib/platform/index.js +0 -10
- package/lib/platform/instances.js +0 -458
- package/lib/platform/manager.js +0 -130
- package/lib/platform/message-split.js +0 -229
- package/lib/platform/session-catalog.js +0 -405
- package/lib/platform/session-config.js +0 -52
- package/lib/platform/stream-slices.js +0 -21
- package/lib/qq/gateway.js +0 -1107
- package/lib/qq/index.js +0 -561
- package/lib/qq/node.js +0 -1153
- package/lib/qq/qqbot-bundled.mjs +0 -8273
- package/lib/restart-helper.mjs +0 -133
- package/lib/security/log-sanitize.js +0 -50
- package/lib/security/path-validator.js +0 -195
- package/lib/security/rate-limiter.js +0 -93
- package/lib/session-strip.js +0 -100
- package/lib/sse-keepalive.js +0 -115
- package/lib/telegram/gateway.js +0 -643
- package/lib/telegram/index.js +0 -255
- package/lib/telegram/node.js +0 -410
- package/lib/tunnel-client.mjs +0 -537
- package/lib/wechat/gateway.js +0 -1000
- package/lib/wechat/index.js +0 -282
- package/lib/wechat/media.js +0 -302
- package/lib/wechat/node.js +0 -360
- package/llms.txt +0 -36
package/lib/index.js
CHANGED
|
@@ -1,38 +1,29 @@
|
|
|
1
|
-
//
|
|
1
|
+
// DSH 远程隧道云 · 官方客户端插件(Host 侧)
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
// 1.
|
|
5
|
-
// 2.
|
|
6
|
-
// 3.
|
|
7
|
-
|
|
8
|
-
import {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
* 去掉末尾斜杠的线性实现(不用正则)。
|
|
12
|
-
*
|
|
13
|
-
* 等价于 `text.replace(/\/+$/, '')`,但严格 O(n):`req.url` 来自外部请求,
|
|
14
|
-
* 正则版本在超长斜杠串上会触发多项式回溯(CodeQL js/polynomial-redos)。
|
|
15
|
-
* @param {string} value
|
|
16
|
-
* @returns {string}
|
|
17
|
-
*/
|
|
18
|
-
function stripTrailingSlashesLinear(value) {
|
|
19
|
-
const text = String(value ?? '');
|
|
20
|
-
let end = text.length;
|
|
21
|
-
while (end > 0 && text.charCodeAt(end - 1) === 0x2f) end -= 1;
|
|
22
|
-
return text.slice(0, end);
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
import { get as httpsGet } from 'node:https';
|
|
26
|
-
import { networkInterfaces, homedir, totalmem, freemem, cpus, loadavg, platform, arch, release, hostname, uptime } from 'node:os';
|
|
27
|
-
import { join, dirname, basename, resolve, normalize } from 'node:path';
|
|
3
|
+
// 职责(保持精简,全部为独立实现):
|
|
4
|
+
// 1. 本地门禁代理:监听本机端口 → 转发 DSH 本体,注入 DSH 原生 loopback cookie;
|
|
5
|
+
// 2. SaaS 隧道客户端:设备码绑定 → 短期令牌 → v2 控制通道(转发 HTTP / SSE / WS);
|
|
6
|
+
// 3. 设置页面的 RPC 控制面:状态、绑定、解绑、启停、访问链接。
|
|
7
|
+
import { randomBytes } from 'node:crypto';
|
|
8
|
+
import { readFileSync } from 'node:fs';
|
|
9
|
+
import { hostname, platform as osPlatform, arch as osArch } from 'node:os';
|
|
10
|
+
import { join, dirname } from 'node:path';
|
|
28
11
|
import { fileURLToPath } from 'node:url';
|
|
29
|
-
import { readFileSync, appendFileSync, existsSync, realpathSync, mkdirSync } from 'node:fs';
|
|
30
|
-
import { readFile, writeFile, mkdir, unlink, readdir, stat, access, chmod } from 'node:fs/promises';
|
|
31
|
-
import { spawn } from 'node:child_process';
|
|
32
12
|
import QRCode from 'qrcode';
|
|
33
|
-
|
|
34
|
-
import {
|
|
35
|
-
import {
|
|
13
|
+
|
|
14
|
+
import { PLUGIN_ID, PACKAGE_NAME, DEFAULT_PORT, RPC_CHANNEL, RPC_ENDPOINTS, GATEWAY_PATH } from './identity.js';
|
|
15
|
+
import { resolveConfigPath } from './data-dir.js';
|
|
16
|
+
import {
|
|
17
|
+
ensureConfigSecrets,
|
|
18
|
+
loadConfig,
|
|
19
|
+
maskSecret,
|
|
20
|
+
normalizeConfig,
|
|
21
|
+
saveConfig,
|
|
22
|
+
createSecret,
|
|
23
|
+
hashPassword,
|
|
24
|
+
} from './config.js';
|
|
25
|
+
import { GateProxy } from './gate.js';
|
|
26
|
+
import { SaasTunnelClient } from './saas/tunnel-client.mjs';
|
|
36
27
|
import {
|
|
37
28
|
claimDeviceCode,
|
|
38
29
|
fetchDeviceCodeStatus,
|
|
@@ -40,3515 +31,331 @@ import {
|
|
|
40
31
|
normalizeSaasBaseUrl,
|
|
41
32
|
refreshDeviceToken,
|
|
42
33
|
requestDeviceCode,
|
|
43
|
-
|
|
34
|
+
buildDeviceInfo,
|
|
44
35
|
} from './saas/device-auth.mjs';
|
|
45
|
-
import { CloudflaredManager, CLOUDFLARED_LOG_NAME } from './cloudflared-manager.mjs';
|
|
46
|
-
import { PlatformManager } from './platform/manager.js';
|
|
47
|
-
import { describeOpenTurn } from './platform/conversation-bridge.js';
|
|
48
|
-
import { applyRestoredPlatformConfig, RESTORED_STRING_FIELDS, PLATFORM_TIMING_FIELDS } from './platform/config-restore.js';
|
|
49
|
-
import {
|
|
50
|
-
normalizePlatformConfig,
|
|
51
|
-
normalizePlatformSection,
|
|
52
|
-
patchAccount,
|
|
53
|
-
displayNameFor,
|
|
54
|
-
PLATFORM_TYPE_LABELS,
|
|
55
|
-
} from './platform/instances.js';
|
|
56
|
-
import { createPlatformAccountOps, assertNoCreateErrors } from './platform/account-ops.js';
|
|
57
|
-
import { WechatService } from './wechat/index.js';
|
|
58
|
-
import { QqService } from './qq/index.js';
|
|
59
|
-
import { FeishuService } from './feishu/index.js';
|
|
60
|
-
import { TelegramService } from './telegram/index.js';
|
|
61
|
-
import { AuthManager } from './auth/manager.js';
|
|
62
|
-
import { getDshLoopbackCookie as getDshLoopbackCookieImpl } from './auth/dsh-native-cookie.js';
|
|
63
|
-
import { renderLoginPage, localizeAuthError, pickLoginLang } from './auth/login-template.js';
|
|
64
|
-
import { isSafeWorkspacePath, isSensitiveFolderName } from './security/path-validator.js';
|
|
65
|
-
import { stripSessionProjections, isSessionProjectionPath } from './session-strip.js';
|
|
66
|
-
import { isSseKeepaliveCandidate, createSseKeepalive, SSE_KEEPALIVE_INTERVAL_MS } from './sse-keepalive.js';
|
|
67
|
-
import { validateCloudflaredNetworkOptions, CLOUDFLARED_NETWORK_FIELDS } from './cloudflared-network-args.js';
|
|
68
|
-
import { scanSameTokenRunner } from './cloudflared-process-scan.js';
|
|
69
|
-
import { installAbortSignalCompat, BROWSER_ABORT_SIGNAL_POLYFILL, BROWSER_PROMISE_ITERATOR_POLYFILL } from './compat.js';
|
|
70
|
-
|
|
71
|
-
const name = 'dsh-bridge';
|
|
72
|
-
// 微信 Bot 会话桥依赖 DSH 提供的会话/agent/审批/工作区/持久化服务,需显式 inject
|
|
73
|
-
const inject = ['connection', 'webServer', 'sessions', 'agents', 'approval', 'workspaceRegistry', 'sessionPersistence'];
|
|
74
36
|
|
|
75
|
-
// 从 package.json 动态读取版本号,发版只需改 package.json 一处
|
|
76
37
|
const PACKAGE_JSON = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf8'));
|
|
77
38
|
const VERSION = PACKAGE_JSON.version ?? '0.0.0';
|
|
39
|
+
const PAIRING_POLL_MS = 2000;
|
|
40
|
+
const PAIRING_MAX_MS = 10 * 60 * 1000;
|
|
78
41
|
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
'tailscale', 'zerotier', 'tap', 'tun', 'utun', 'wireguard', 'loopback', 'bridge',
|
|
82
|
-
];
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* 列出所有可用的局域网 IPv4 网卡与 IP 地址(按推荐优先级排序)
|
|
86
|
-
*/
|
|
87
|
-
function listAllLanIPv4() {
|
|
88
|
-
const interfaces = networkInterfaces();
|
|
89
|
-
const list = [];
|
|
90
|
-
|
|
91
|
-
for (const [ifname, addrs] of Object.entries(interfaces)) {
|
|
92
|
-
if (!addrs) continue;
|
|
93
|
-
const lower = ifname.toLowerCase();
|
|
94
|
-
const isVirtual = VIRTUAL_KEYWORDS.some((kw) => lower.includes(kw));
|
|
95
|
-
|
|
96
|
-
for (const addr of addrs) {
|
|
97
|
-
if (addr.family !== 'IPv4' || addr.internal) continue;
|
|
98
|
-
|
|
99
|
-
let score = 0;
|
|
100
|
-
// 1. IP 网段优先(家庭/企业物理局域网最常用网段)
|
|
101
|
-
if (addr.address.startsWith('192.168.')) score += 100;
|
|
102
|
-
else if (addr.address.startsWith('10.')) score += 90;
|
|
103
|
-
else if (addr.address.match(/^172\.(1[6-9]|2[0-9]|3[0-1])\./)) score += 70;
|
|
104
|
-
else score += 10;
|
|
105
|
-
|
|
106
|
-
// 2. 物理网卡与名称特征优先
|
|
107
|
-
if (isVirtual) {
|
|
108
|
-
score -= 200; // 虚拟网卡大幅降权
|
|
109
|
-
} else {
|
|
110
|
-
score += 100;
|
|
111
|
-
if (lower.includes('wi-fi') || lower.includes('wlan') || lower.includes('wireless')) score += 50;
|
|
112
|
-
else if (lower.includes('ethernet') || lower.includes('以太网') || lower.includes('eth') || lower.includes('en')) score += 40;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
let label = ifname;
|
|
116
|
-
if (lower.includes('wi-fi') || lower.includes('wlan') || lower.includes('wireless')) label += ' (Wi-Fi 无线网卡)';
|
|
117
|
-
else if (lower.includes('ethernet') || lower.includes('以太网') || lower.includes('eth') || lower.includes('en')) label += ' (有线网卡)';
|
|
118
|
-
else if (isVirtual) label += ' (虚拟网卡 / WSL / 虚拟机)';
|
|
119
|
-
|
|
120
|
-
list.push({
|
|
121
|
-
name: ifname,
|
|
122
|
-
label,
|
|
123
|
-
address: addr.address,
|
|
124
|
-
netmask: addr.netmask,
|
|
125
|
-
isVirtual,
|
|
126
|
-
score,
|
|
127
|
-
});
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
return list.sort((a, b) => b.score - a.score);
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/**
|
|
135
|
-
* 选择最佳默认局域网 IP
|
|
136
|
-
*/
|
|
137
|
-
function selectLanIPv4() {
|
|
138
|
-
const list = listAllLanIPv4();
|
|
139
|
-
return list[0]?.address || null;
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
/**
|
|
143
|
-
* 二维码缓存(带 TTL + LRU)
|
|
144
|
-
*/
|
|
145
|
-
class QrCache {
|
|
146
|
-
constructor(ttl = 30 * 60 * 1000, maxSize = 8) {
|
|
147
|
-
this.cache = new Map();
|
|
148
|
-
this.ttl = ttl;
|
|
149
|
-
this.maxSize = maxSize;
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
async get(text) {
|
|
153
|
-
const cached = this.cache.get(text);
|
|
154
|
-
if (cached && Date.now() - cached.time < this.ttl) {
|
|
155
|
-
return cached.data;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
const qr = await QRCode.toDataURL(text, {
|
|
159
|
-
width: 300,
|
|
160
|
-
margin: 2,
|
|
161
|
-
color: { dark: '#1F2421', light: '#FFFFFF' },
|
|
162
|
-
});
|
|
163
|
-
|
|
164
|
-
this.cache.set(text, { data: qr, time: Date.now() });
|
|
165
|
-
|
|
166
|
-
if (this.cache.size > this.maxSize) {
|
|
167
|
-
const oldest = Array.from(this.cache.entries())
|
|
168
|
-
.sort((a, b) => a[1].time - b[1].time)[0];
|
|
169
|
-
if (oldest) this.cache.delete(oldest[0]);
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
return qr;
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
clear() {
|
|
176
|
-
this.cache.clear();
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
/**
|
|
181
|
-
* PWA Web App Manifest 与 App 启动图标
|
|
182
|
-
*/
|
|
183
|
-
const PWA_MANIFEST = JSON.stringify({
|
|
184
|
-
name: 'DeepSeek Harness',
|
|
185
|
-
short_name: 'DSH',
|
|
186
|
-
description: 'DeepSeek Harness Remote & Mobile Workspace',
|
|
187
|
-
start_url: '/',
|
|
188
|
-
display: 'standalone',
|
|
189
|
-
background_color: '#181825',
|
|
190
|
-
theme_color: '#1e1e2e',
|
|
191
|
-
orientation: 'any',
|
|
192
|
-
icons: [
|
|
193
|
-
{
|
|
194
|
-
src: '/__dsh_bridge__/pwa-icon.svg',
|
|
195
|
-
sizes: 'any',
|
|
196
|
-
type: 'image/svg+xml',
|
|
197
|
-
purpose: 'any maskable'
|
|
198
|
-
}
|
|
199
|
-
]
|
|
200
|
-
}, null, 2);
|
|
201
|
-
|
|
202
|
-
const PWA_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
|
203
|
-
<defs>
|
|
204
|
-
<linearGradient id="g" x1="0%" y1="0%" x2="100%" y2="100%">
|
|
205
|
-
<stop offset="0%" stop-color="#4f6ef7"/>
|
|
206
|
-
<stop offset="100%" stop-color="#24388a"/>
|
|
207
|
-
</linearGradient>
|
|
208
|
-
</defs>
|
|
209
|
-
<rect width="512" height="512" rx="128" fill="url(#g)"/>
|
|
210
|
-
<path d="M150 170 C150 140, 362 140, 362 170 L362 330 C362 360, 150 360, 150 330 Z" fill="#ffffff" fill-opacity="0.12"/>
|
|
211
|
-
<circle cx="206" cy="220" r="28" fill="#ffffff"/>
|
|
212
|
-
<circle cx="306" cy="220" r="28" fill="#ffffff"/>
|
|
213
|
-
<path d="M200 290 Q256 340 312 290" stroke="#ffffff" stroke-width="24" stroke-linecap="round" fill="none"/>
|
|
214
|
-
<rect x="236" y="90" width="40" height="60" rx="10" fill="#ffffff"/>
|
|
215
|
-
<circle cx="256" cy="80" r="16" fill="#4f6ef7"/>
|
|
216
|
-
</svg>`;
|
|
217
|
-
|
|
218
|
-
const HTML_HEAD_ASSETS = `<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
|
219
|
-
<meta name="apple-mobile-web-app-capable" content="yes">
|
|
220
|
-
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
|
221
|
-
<meta name="apple-mobile-web-app-title" content="DSH">
|
|
222
|
-
<meta name="theme-color" content="#1e1e2e">
|
|
223
|
-
<link rel="manifest" href="/manifest.webmanifest">
|
|
224
|
-
<link rel="icon" type="image/svg+xml" href="/__dsh_bridge__/pwa-icon.svg">
|
|
225
|
-
<link rel="apple-touch-icon" href="/__dsh_bridge__/pwa-icon.svg">
|
|
226
|
-
<style data-dsh-bridge-overscroll="1">html,body{overscroll-behavior-y:none;-webkit-overflow-scrolling:touch}</style>
|
|
227
|
-
<script data-dsh-bridge-polyfill="1">!function(){try{if(self.crypto&&!self.crypto.randomUUID){self.crypto.randomUUID=function(){var b=new Uint8Array(16);self.crypto.getRandomValues(b);b[6]=b[6]&15|64;b[8]=b[8]&63|128;var h="";for(var i=0;i<16;i++){var x=b[i].toString(16);h+=(x.length<2?"0":"")+x;if(i===3||i===5||i===7||i===9)h+="-";}return h;}}}catch(e){}}();</script>
|
|
228
|
-
`;
|
|
229
|
-
/** 可分别判重注入的三段:各自的 data 标记即去重键(旧版只按 marker 1 判重,
|
|
230
|
-
* 两层桥串联时会出现"上游有 1 没有 2/3 → 整段跳过"的漏注入)。 */
|
|
231
|
-
const HTML_INJECTION_PARTS = [
|
|
232
|
-
{ mark: 'data-dsh-bridge-polyfill="1"', html: HTML_HEAD_ASSETS },
|
|
233
|
-
{ mark: 'data-dsh-bridge-polyfill="2"', html: BROWSER_ABORT_SIGNAL_POLYFILL },
|
|
234
|
-
{ mark: 'data-dsh-bridge-polyfill="3"', html: BROWSER_PROMISE_ITERATOR_POLYFILL },
|
|
235
|
-
];
|
|
236
|
-
|
|
237
|
-
function isCompressed(headers) {
|
|
238
|
-
return /(^|,\s*)(gzip|br|deflate)(\s*,|$)/i.test(String(headers['content-encoding'] ?? ''));
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
/**
|
|
242
|
-
* 读取 DSH 本地凭证并生成 loopback dsh-auth 认证签名 Cookie (适配 DSH 新版原生认证)
|
|
243
|
-
*
|
|
244
|
-
* 实现见 lib/auth/dsh-native-cookie.js(含 cookie 名称/载荷/签名的格式说明与
|
|
245
|
-
* 凭据记录定位策略)。此处仅做 thin wrapper,保持既有调用点不变。
|
|
246
|
-
*/
|
|
247
|
-
function getDshLoopbackCookie(targetPort) {
|
|
248
|
-
return getDshLoopbackCookieImpl(targetPort);
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
/** 把请求头中的 Host 和 Origin 改写成 loopback,让 DSH 的安全栅栏放行 */
|
|
252
|
-
function loopbackHeaders(headers, targetPort) {
|
|
253
|
-
const authority = `127.0.0.1:${targetPort}`;
|
|
254
|
-
const out = { ...headers };
|
|
255
|
-
out['host'] = authority;
|
|
256
|
-
if (out['origin']) out['origin'] = `http://${authority}`;
|
|
257
|
-
if (out['Origin']) out['Origin'] = `http://${authority}`;
|
|
258
|
-
|
|
259
|
-
// 1. 注入 DSH 本地认证签名(若有)
|
|
260
|
-
const dshCookie = getDshLoopbackCookie(targetPort);
|
|
261
|
-
if (dshCookie) {
|
|
262
|
-
const existing = out['cookie'] || out['Cookie'] || '';
|
|
263
|
-
out['cookie'] = existing ? `${existing}; ${dshCookie}` : dshCookie;
|
|
264
|
-
delete out['Cookie'];
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
// 2. 禁用内部代理流量压缩,确保代理层拿到未压缩 HTML 以稳定注入 ownsHost 和 Polyfill
|
|
268
|
-
delete out['accept-encoding'];
|
|
269
|
-
delete out['Accept-Encoding'];
|
|
270
|
-
|
|
271
|
-
return out;
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
// hop-by-hop 响应头黑名单:这些头描述的是"当前一跳"的传输语义,反向代理
|
|
275
|
-
// 透传会干扰客户端对响应体的解析(transfer-encoding/connection 并存、
|
|
276
|
-
// keep-alive 连接复用错乱等)。Node http 层会自动生成正确的传输头,故统一剥离。
|
|
277
|
-
const HOP_BY_HOP_HEADERS = new Set([
|
|
278
|
-
'connection',
|
|
279
|
-
'keep-alive',
|
|
280
|
-
'proxy-authenticate',
|
|
281
|
-
'proxy-authorization',
|
|
282
|
-
'te',
|
|
283
|
-
'trailer',
|
|
284
|
-
'transfer-encoding',
|
|
285
|
-
'upgrade',
|
|
286
|
-
]);
|
|
287
|
-
|
|
288
|
-
/**
|
|
289
|
-
* 净化上游响应头:剔除 hop-by-hop 头,返回可安全 writeHead 的干净头对象。
|
|
290
|
-
* 保留原始键大小写(Node writeHead 会归一化处理)。
|
|
291
|
-
* @param {object} headers 上游响应头(IncomingMessage.headers 形式)
|
|
292
|
-
* @param {Set<string>} [extra] 额外需剔除的头(小写)
|
|
293
|
-
*/
|
|
294
|
-
function sanitizeProxyHeaders(headers, extra) {
|
|
295
|
-
const out = {};
|
|
296
|
-
for (const [k, v] of Object.entries(headers)) {
|
|
297
|
-
if (HOP_BY_HOP_HEADERS.has(k.toLowerCase())) continue;
|
|
298
|
-
if (extra && extra.has(k.toLowerCase())) continue;
|
|
299
|
-
out[k] = v;
|
|
300
|
-
}
|
|
301
|
-
return out;
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
/**
|
|
305
|
-
* HTTP + WebSocket 代理服务器(带安全认证守门)
|
|
306
|
-
* 关键:改写 Host + Origin,注入 crypto.randomUUID polyfill
|
|
307
|
-
* 并在未授权时拦截并展示 DSH 风格登录页,阻止未授权 WebSocket 与 API 调用
|
|
308
|
-
*/
|
|
309
|
-
/**
|
|
310
|
-
* 从 `/proc/self/cgroup` 内容里取出最内层(最后一段)的 systemd 单元名。
|
|
311
|
-
*
|
|
312
|
-
* 形如 `0::/user.slice/user-1000.slice/user@1000.service/app.slice/dsh-web.service`
|
|
313
|
-
* 时必须取 `dsh-web.service`(祖先里的 `user@1000.service` 是用户管理器,不是本服务)。
|
|
314
|
-
*
|
|
315
|
-
* @param {string} cgroup /proc/self/cgroup 的原始内容
|
|
316
|
-
* @returns {string} 单元名,取不到返回空串
|
|
317
|
-
*/
|
|
318
|
-
export function parseSystemdUnit(cgroup) {
|
|
319
|
-
const lines = String(cgroup || '').split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
|
320
|
-
for (const line of lines) {
|
|
321
|
-
const parts = line.split('/').map((p) => p.trim()).filter(Boolean);
|
|
322
|
-
// 只认**最内层**组件:若最内层不是 .service(例如 .scope、或 docker/k8s 的容器目录),
|
|
323
|
-
// 说明本进程不是被某个 service 直接托管,绝不能向上游误取祖先单元
|
|
324
|
-
// (否则会把 docker.service / cron.service 之类重启掉)。
|
|
325
|
-
const innermost = parts[parts.length - 1] ?? '';
|
|
326
|
-
if (innermost.endsWith('.service')) {
|
|
327
|
-
return innermost;
|
|
328
|
-
}
|
|
329
|
-
}
|
|
330
|
-
return '';
|
|
331
|
-
}
|
|
42
|
+
export const name = PLUGIN_ID;
|
|
43
|
+
export const inject = ['connection', 'webServer'];
|
|
332
44
|
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
*
|
|
336
|
-
* @param {object} [opts]
|
|
337
|
-
* @param {object} [opts.env] 环境变量(默认 process.env)
|
|
338
|
-
* @param {string} [opts.cgroup] /proc/self/cgroup 内容
|
|
339
|
-
* @returns {{kind:'daemon'|'systemd'|'self', scope?:'user'|'system', unit?:string, reason:string}}
|
|
340
|
-
*/
|
|
341
|
-
export function detectSupervisor({ env = process.env, cgroup = '' } = {}) {
|
|
342
|
-
if (env.DSH_DAEMON || env.PM2_HOME) {
|
|
343
|
-
return { kind: 'daemon', reason: env.PM2_HOME ? 'PM2_HOME' : 'DSH_DAEMON' };
|
|
344
|
-
}
|
|
345
|
-
const text = String(cgroup || '');
|
|
346
|
-
// 只在确实处于 systemd 环境时才认(INVOCATION_ID/JOURNAL_STREAM 是 systemd 注入的标记),
|
|
347
|
-
// 避免在容器里存在同名 cgroup 文本时误判。
|
|
348
|
-
const systemdMarked = Boolean(env.INVOCATION_ID || env.JOURNAL_STREAM) || text.includes('.slice');
|
|
349
|
-
const unit = systemdMarked ? parseSystemdUnit(text) : '';
|
|
350
|
-
if (unit) {
|
|
351
|
-
const isUser = text.includes('user.slice') || text.includes('/user@');
|
|
352
|
-
return { kind: 'systemd', scope: isUser ? 'user' : 'system', unit, reason: 'cgroup 检出 systemd 单元' };
|
|
353
|
-
}
|
|
354
|
-
return { kind: 'self', reason: '未检出托管器(无 DSH_DAEMON/PM2_HOME,也不在 systemd 单元内)' };
|
|
45
|
+
function ok(value) {
|
|
46
|
+
return { ok: true, value };
|
|
355
47
|
}
|
|
356
48
|
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
*
|
|
360
|
-
* 背景:桌面端宿主是 Electron 以 RunAsNode 模式拉起的同一套 @deepseek-ai/dsh,
|
|
361
|
-
* 进程生死由 Electron 壳经 IPC 管理(shutdown/quit-inspection),没有 systemd、
|
|
362
|
-
* DSH_DAEMON/PM2 这类托管器。插件若按 Web/CLI 版的假设自行重启/升级宿主,
|
|
363
|
-
* 会拉起脱离壳管理的孤儿进程(抢占 19387 端口、触发壳的恢复对话框)或把包
|
|
364
|
-
* 装进错误的 profile(桌面端 profile 名为 `desktop`,不是 `web`)。
|
|
365
|
-
*
|
|
366
|
-
* 判据(按可靠性排序,任一命中即判为桌面端):
|
|
367
|
-
* 1. `process.versions.electron` 存在 —— Electron(含 RunAsNode 模式)的官方
|
|
368
|
-
* 行为保证该字段存在,跨平台最可靠;
|
|
369
|
-
* 2. 主模块路径含 `desktop-host` —— 桌面宿主入口固定为 desktop-host 包。
|
|
370
|
-
*
|
|
371
|
-
* fail-open:判不准时一律按 Web/CLI 版走,绝不能把正常用户的重启/升级拦掉。
|
|
372
|
-
*
|
|
373
|
-
* @param {object} [opts]
|
|
374
|
-
* @param {object} [opts.versions] process.versions(默认 process.versions)
|
|
375
|
-
* @param {string} [opts.argv1] process.argv[1](默认 process.argv[1])
|
|
376
|
-
* @returns {boolean}
|
|
377
|
-
*/
|
|
378
|
-
export function isDesktopHost({ versions = process.versions, argv1 = process.argv[1] } = {}) {
|
|
379
|
-
try {
|
|
380
|
-
if (versions && typeof versions.electron === 'string' && versions.electron.length > 0) return true;
|
|
381
|
-
} catch { /* 忽略,继续下一个判据 */ }
|
|
382
|
-
try {
|
|
383
|
-
// 必须按路径分段匹配(前后是分隔符或串 ends):避免用户目录名恰好包含
|
|
384
|
-
// desktop-host 子串(如本仓库的 test/desktop-host-compat.test.mjs)时误判。
|
|
385
|
-
// 生产路径形如 .../dsh-desktop-host/lib/index.js。
|
|
386
|
-
if (typeof argv1 === 'string' && /(?:^|[/\\])(?:dsh-)?desktop-host(?:[/\\]|$)/i.test(argv1)) return true;
|
|
387
|
-
} catch { /* 忽略,fail-open */ }
|
|
388
|
-
return false;
|
|
49
|
+
function fail(code, message) {
|
|
50
|
+
return { ok: false, error: { code: String(code || 'bad-request'), message: String(message ?? '') } };
|
|
389
51
|
}
|
|
390
52
|
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
* 桌面端为 `desktop`,其余(Web/CLI)为 `web`。供 `dsh plugin --profile <name> add`
|
|
394
|
-
* 类命令使用,避免把包装进宿主不加载的 profile。
|
|
395
|
-
*/
|
|
396
|
-
export function currentProfileName(opts = {}) {
|
|
397
|
-
return isDesktopHost(opts) ? 'desktop' : 'web';
|
|
53
|
+
function errorPayload(err) {
|
|
54
|
+
return fail(err?.code || 'internal', err?.message ?? String(err));
|
|
398
55
|
}
|
|
399
56
|
|
|
400
|
-
|
|
401
|
-
const
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
*
|
|
406
|
-
* 桌面窗口的页面来源是自定义协议 `dsh-app://app`,它向 `127.0.0.1:3082` 取
|
|
407
|
-
* `adminToken` 属于跨源请求;此前白名单只收 http(s),于是响应没有 ACAO、fetch 被
|
|
408
|
-
* 浏览器拒绝 → 管理类操作静默失败。
|
|
409
|
-
*
|
|
410
|
-
* 为什么可以放行这个**字面量**(而不是重开 #62 的口子):
|
|
411
|
-
* - `new URL('dsh-app://app').origin` 是字符串 `"null"`(opaque),所以它绝不能被
|
|
412
|
-
* `toHttpOrigin()` 处理或写进白名单;这里显式写死字面量,`"null"` 依旧永不授权;
|
|
413
|
-
* - Origin 由浏览器生成、网页无法伪造:普通网页(http(s) 页面、sandboxed iframe)
|
|
414
|
-
* 拿不到 `dsh-app://app` 这个 Origin,因此放行它不等于放行任意来源;
|
|
415
|
-
* - 该来源仍受 loopback-token 的物理回环校验(`isLoopback`)与隧道来源判定约束。
|
|
416
|
-
*
|
|
417
|
-
* 维护动作:桌面壳若更换自定义协议名(如 `dsh-app://shell`),必须同步更新这里的字面量,
|
|
418
|
-
* 否则桌面窗口会重新退化为"拿不到 adminToken"(表现为管理类操作无反应,客户端会显示
|
|
419
|
-
* 「请改用电脑浏览器打开控制台」的兜底提示)。协议名可从桌面壳注册 `registerSchemesAsPrivileged`
|
|
420
|
-
* 的位置确认。
|
|
421
|
-
*/
|
|
422
|
-
export const DESKTOP_APP_ORIGIN = 'dsh-app://app';
|
|
423
|
-
|
|
424
|
-
/**
|
|
425
|
-
* 把候选面板来源 URL 收敛为 http(s) 的 origin;非 http(s) 一律丢弃。
|
|
426
|
-
*
|
|
427
|
-
* 必要性:`new URL('dsh-app://app').origin` 返回的是字符串 `"null"`(opaque origin),
|
|
428
|
-
* 一旦这种值进入 loopback-token 的 CORS 白名单,就等于放行**所有** opaque origin——
|
|
429
|
-
* 任意网页只要用 sandboxed iframe 发起请求,就能跨域读取回环领取的 adminToken 响应。
|
|
430
|
-
* 隧道地址来自外部(隧道服务器下发的 `publicUrl`),因此必须在进入白名单前按协议收敛。
|
|
431
|
-
*
|
|
432
|
-
* @param {unknown} value 候选来源(URL 字符串)
|
|
433
|
-
* @returns {string|undefined} http(s) origin;非法或非 http(s) 返回 undefined
|
|
434
|
-
*/
|
|
435
|
-
export function toHttpOrigin(value) {
|
|
436
|
-
if (value === undefined || value === null || value === '') return undefined;
|
|
437
|
-
try {
|
|
438
|
-
const url = new URL(String(value));
|
|
439
|
-
if (url.protocol !== 'http:' && url.protocol !== 'https:') return undefined;
|
|
440
|
-
return url.origin;
|
|
441
|
-
} catch {
|
|
442
|
-
return undefined;
|
|
57
|
+
function registerRpc(ctx, handler, logger) {
|
|
58
|
+
const rpc = ctx?.connection?.rpc;
|
|
59
|
+
if (!rpc?.handle) {
|
|
60
|
+
logger?.warn?.('connection RPC unavailable — panel will stay disabled');
|
|
61
|
+
return () => {};
|
|
443
62
|
}
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
/**
|
|
447
|
-
* 归一化外部隧道地址:仅接受 http(s),去掉末尾斜杠。
|
|
448
|
-
*
|
|
449
|
-
* 保存(`saveExternalTunnel`)与启动恢复(`loadConfig()` 回填 `externalTunnelConfig`)
|
|
450
|
-
* 两条路径共用同一收敛逻辑,消除"保存时校验、恢复时不校验"的不对称。
|
|
451
|
-
*
|
|
452
|
-
* 定位说明(勿夸大):**这不是 CORS 防线**——进入白名单前还有 `toHttpOrigin()` 闸门,
|
|
453
|
-
* 因此非 http(s) 值即使回填也不会产生 `"null"` origin。本函数的价值是纵深防御 +
|
|
454
|
-
* 输入归一化:不让非法值进入配置对象(否则会被面板当公网地址展示并生成二维码),
|
|
455
|
-
* 并且不再依赖"每个新调用点都记得加闸门"。
|
|
456
|
-
*
|
|
457
|
-
* @param {unknown} value 候选地址
|
|
458
|
-
* @returns {string|null} 归一化后的 http(s) 地址;非法或非 http(s) 返回 null
|
|
459
|
-
*/
|
|
460
|
-
export function normalizeExternalTunnelUrl(value) {
|
|
461
|
-
const trimmed = value ? String(value).trim() : '';
|
|
462
|
-
if (!trimmed) return null;
|
|
463
63
|
try {
|
|
464
|
-
const
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
64
|
+
const dispose = rpc.handle(RPC_CHANNEL, handler, { authority: 'loopback' });
|
|
65
|
+
return typeof dispose === 'function' ? dispose : () => {};
|
|
66
|
+
} catch (err) {
|
|
67
|
+
logger?.warn?.(`RPC register with options failed, retrying without: ${err?.message ?? err}`);
|
|
68
|
+
try {
|
|
69
|
+
const dispose = rpc.handle(RPC_CHANNEL, handler);
|
|
70
|
+
return typeof dispose === 'function' ? dispose : () => {};
|
|
71
|
+
} catch (retryErr) {
|
|
72
|
+
logger?.error?.(`RPC register failed: ${retryErr?.message ?? retryErr}`);
|
|
73
|
+
return () => {};
|
|
74
|
+
}
|
|
474
75
|
}
|
|
475
76
|
}
|
|
476
77
|
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
const
|
|
488
|
-
const
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
78
|
+
export async function apply(ctx, pluginConfig = {}) {
|
|
79
|
+
const logger = ctx?.logger?.(name) ?? console;
|
|
80
|
+
const dshPort = Number(ctx?.webServer?.port) || Number(pluginConfig.targetPort) || 3080;
|
|
81
|
+
const configPath = resolveConfigPath();
|
|
82
|
+
|
|
83
|
+
let config = loadConfig(configPath);
|
|
84
|
+
if (pluginConfig.port) config.port = Number(pluginConfig.port) || config.port;
|
|
85
|
+
if (pluginConfig.gatewayUrl) config.gatewayUrl = String(pluginConfig.gatewayUrl);
|
|
86
|
+
if (ensureConfigSecrets(config)) config = saveConfig(config, configPath);
|
|
87
|
+
|
|
88
|
+
const internalSecret = createSecret(24);
|
|
89
|
+
const gate = new GateProxy({
|
|
90
|
+
targetPort: dshPort,
|
|
91
|
+
port: config.port || DEFAULT_PORT,
|
|
92
|
+
config,
|
|
93
|
+
internalSecret,
|
|
94
|
+
logger,
|
|
95
|
+
dshHome: process.env.DSH_HOME,
|
|
96
|
+
});
|
|
97
|
+
const gatePort = await gate.start();
|
|
98
|
+
|
|
99
|
+
let tunnel = null;
|
|
100
|
+
let pairing = null;
|
|
101
|
+
let pairingTimer = null;
|
|
102
|
+
let pairingDeadline = 0;
|
|
103
|
+
let lastError = null;
|
|
104
|
+
|
|
105
|
+
const persist = (patch) => {
|
|
106
|
+
config = saveConfig({ ...config, ...patch, access: { ...config.access, ...(patch?.access ?? {}) } }, configPath);
|
|
107
|
+
gate.config = config;
|
|
108
|
+
return config;
|
|
109
|
+
};
|
|
492
110
|
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
* `restoreExternalTunnelConfig`)。自动拉起沿用原有的 fire-and-forget + 错误日志语义。
|
|
499
|
-
*
|
|
500
|
-
* @param {object} args
|
|
501
|
-
* @param {object} args.service 目标 BridgeService 实例
|
|
502
|
-
* @param {object} [args.stored] 落盘配置
|
|
503
|
-
* @param {object} [args.logger]
|
|
504
|
-
* @param {boolean} [args.authEnabled] 访问认证是否已开启(决定首次启用引导提示)
|
|
505
|
-
*/
|
|
506
|
-
export function restorePersistedServiceState({ service, stored, logger, authEnabled } = {}) {
|
|
507
|
-
// 首次启用引导判据(保守):仅当「访问认证未开启」且「从未展示过引导」时才提示。
|
|
508
|
-
// 已开启认证的用户、以及已经看过引导的用户一律不再打扰。
|
|
509
|
-
service.firstRunGuidePending = authEnabled !== true && stored?.wizard?.guideShown !== true;
|
|
111
|
+
const clearPairing = () => {
|
|
112
|
+
if (pairingTimer) clearInterval(pairingTimer);
|
|
113
|
+
pairingTimer = null;
|
|
114
|
+
pairingDeadline = 0;
|
|
115
|
+
};
|
|
510
116
|
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
const restored = restoreExternalTunnelConfig(stored.externalTunnel);
|
|
516
|
-
if (restored.invalid) {
|
|
517
|
-
logger?.warn?.('dsh-bridge: 已忽略非法的外部隧道地址配置(仅接受 http/https)');
|
|
117
|
+
const stopTunnel = async ({ persistAutoStart = false } = {}) => {
|
|
118
|
+
if (tunnel) {
|
|
119
|
+
try { await tunnel.disconnect(); } catch {}
|
|
120
|
+
tunnel = null;
|
|
518
121
|
}
|
|
519
|
-
|
|
520
|
-
|
|
122
|
+
if (persistAutoStart) persist({ autoStart: false });
|
|
123
|
+
lastError = null;
|
|
124
|
+
};
|
|
521
125
|
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
126
|
+
const gatewayBase = () => normalizeSaasBaseUrl(config.gatewayUrl);
|
|
127
|
+
|
|
128
|
+
const createTunnel = () => new SaasTunnelClient({
|
|
129
|
+
gatewayUrl: gatewayBase(),
|
|
130
|
+
gatewayPath: GATEWAY_PATH,
|
|
131
|
+
localPort: gatePort,
|
|
132
|
+
internalTunnelSecret: internalSecret,
|
|
133
|
+
protocol: 'v2',
|
|
134
|
+
deviceInfo: buildDeviceInfo({
|
|
135
|
+
name: hostname(),
|
|
136
|
+
platform: osPlatform(),
|
|
137
|
+
hostname: hostname(),
|
|
138
|
+
arch: osArch(),
|
|
139
|
+
pluginVersion: VERSION,
|
|
140
|
+
}),
|
|
141
|
+
tokenProvider: () => refreshDeviceToken({
|
|
142
|
+
gatewayUrl: gatewayBase(),
|
|
143
|
+
deviceId: config.deviceId,
|
|
144
|
+
secret: config.deviceSecret,
|
|
145
|
+
}),
|
|
146
|
+
onHelloAck: (ack) => {
|
|
147
|
+
lastError = null;
|
|
148
|
+
logger?.info?.(`SaaS tunnel ready (publicId=%s)`, ack?.tunnel?.publicId ?? '');
|
|
149
|
+
},
|
|
150
|
+
onRouteUpdate: ({ routes, rev }) => {
|
|
151
|
+
persist({ routesVersion: rev });
|
|
152
|
+
logger?.info?.(`SaaS tunnel routes updated (rev=%s, count=%d)`, rev, Array.isArray(routes) ? routes.length : 0);
|
|
153
|
+
},
|
|
154
|
+
onDrain: () => logger?.warn?.('SaaS tunnel draining by gateway'),
|
|
155
|
+
onDrainComplete: () => { lastError = 'SaaS 网关已下线(drain)'; },
|
|
156
|
+
onStateChange: ({ state, detail }) => {
|
|
157
|
+
if (state === 'error') lastError = detail;
|
|
158
|
+
},
|
|
159
|
+
logger,
|
|
160
|
+
});
|
|
526
161
|
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
if (service.cloudflaredConfig.autoStart) {
|
|
531
|
-
logger?.info?.('dsh-bridge: auto-starting cloudflared tunnel...');
|
|
532
|
-
service.startCloudflared({ autoStart: true }).catch((err) => {
|
|
533
|
-
logger?.error?.('dsh-bridge: cloudflared auto-start failed: %s', err?.message ?? err);
|
|
534
|
-
});
|
|
162
|
+
const ensureConnected = async () => {
|
|
163
|
+
if (!config.deviceId || !config.deviceSecret) {
|
|
164
|
+
throw new Error('尚未绑定设备:请先获取设备码并在控制台确认');
|
|
535
165
|
}
|
|
536
|
-
|
|
166
|
+
if (tunnel?.connected) return tunnel;
|
|
167
|
+
if (!tunnel) tunnel = createTunnel();
|
|
168
|
+
await tunnel.connect();
|
|
169
|
+
return tunnel;
|
|
170
|
+
};
|
|
537
171
|
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
logger?.info?.('dsh-bridge: loaded saved custom tunnel config (mode=%s, autoStart=%s)', mode, Boolean(config.autoStart));
|
|
544
|
-
const canAutoStart = mode === 'saas'
|
|
545
|
-
? Boolean(config.gatewayUrl && config.deviceId && config.deviceSecret)
|
|
546
|
-
: Boolean(config.serverUrl);
|
|
547
|
-
if (config.autoStart && canAutoStart) {
|
|
548
|
-
logger?.info?.('dsh-bridge: auto-starting custom tunnel (mode=%s)...', mode);
|
|
549
|
-
service.startCustomTunnel({ autoStart: true }).catch((err) => {
|
|
550
|
-
logger?.error?.('dsh-bridge: custom tunnel auto-start failed: %s', err?.message ?? err);
|
|
551
|
-
});
|
|
172
|
+
const pairStatus = async () => {
|
|
173
|
+
if (!pairing) return null;
|
|
174
|
+
if (pairing.status === 'pending' && pairingDeadline && Date.now() > pairingDeadline) {
|
|
175
|
+
pairing.status = 'expired';
|
|
176
|
+
clearPairing();
|
|
552
177
|
}
|
|
553
|
-
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
/** 自建隧道服务地址允许的协议:ws 库接受 ws/wss/http/https(websocket.js 会把 http(s) 归一为 ws(s))。 */
|
|
557
|
-
const CUSTOM_TUNNEL_URL_SCHEMES = new Set(['ws:', 'wss:', 'http:', 'https:']);
|
|
558
|
-
|
|
559
|
-
/**
|
|
560
|
-
* 校验自建隧道服务地址(保存前调用,避免把永远连不上的值静默存进配置)。
|
|
561
|
-
*
|
|
562
|
-
* **不剥离 URL 里的 userinfo**:`ws` 会把 `https://user:pass@host` 的 userinfo 转成
|
|
563
|
-
* `Authorization: Basic ...`(node_modules/ws/lib/websocket.js:803、865),这是隧道服务端
|
|
564
|
-
* Basic Auth 的可用配置,剥离等于砍掉一个真实用法。凭据保护改由"仅管理员视图返回"承担
|
|
565
|
-
* (见 getStatus 的 customTunnel.serverUrl)。
|
|
566
|
-
*
|
|
567
|
-
* @param {unknown} value 候选地址(非空)
|
|
568
|
-
* @throws {Error} 协议不是 ws/wss/http/https 或无法解析时
|
|
569
|
-
*/
|
|
570
|
-
export function assertCustomTunnelServerUrl(value) {
|
|
571
|
-
let protocol = null;
|
|
572
|
-
try {
|
|
573
|
-
protocol = new URL(String(value)).protocol;
|
|
574
|
-
} catch { /* 无法解析:按非法处理 */ }
|
|
575
|
-
if (!protocol || !CUSTOM_TUNNEL_URL_SCHEMES.has(protocol)) {
|
|
576
|
-
throw new Error('隧道服务地址必须是 ws:// 或 wss://(也接受 http/https)');
|
|
577
|
-
}
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
/**
|
|
581
|
-
* 保存前的自建隧道地址归一化(纯函数,便于单测):
|
|
582
|
-
* `undefined` 保留现值、空串表示清除、非空则 trim 并校验协议;userinfo 原样保留。
|
|
583
|
-
*
|
|
584
|
-
* @param {unknown} value
|
|
585
|
-
* @returns {string|undefined}
|
|
586
|
-
* @throws {Error} 非空且协议非法时
|
|
587
|
-
*/
|
|
588
|
-
export function normalizeCustomTunnelServerUrl(value) {
|
|
589
|
-
if (value === undefined) return undefined;
|
|
590
|
-
const trimmed = String(value).trim();
|
|
591
|
-
if (trimmed) assertCustomTunnelServerUrl(trimmed);
|
|
592
|
-
return trimmed;
|
|
593
|
-
}
|
|
594
|
-
|
|
595
|
-
/** 隧道模式:缺省 self(旧配置无 mode 即 self),未知值同样回落 self。 */
|
|
596
|
-
export const CUSTOM_TUNNEL_MODES = ['self', 'saas'];
|
|
597
|
-
|
|
598
|
-
/**
|
|
599
|
-
* 归一化隧道模式(纯函数,便于单测)。
|
|
600
|
-
* @param {unknown} value
|
|
601
|
-
* @returns {'self'|'saas'}
|
|
602
|
-
*/
|
|
603
|
-
export function normalizeCustomTunnelMode(value) {
|
|
604
|
-
return value === 'saas' ? 'saas' : 'self';
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
/**
|
|
608
|
-
* 归一化 customTunnel 配置:补齐 mode='self'(旧配置迁移),其余字段原样保留。
|
|
609
|
-
* @template T
|
|
610
|
-
* @param {T} config
|
|
611
|
-
* @returns {T}
|
|
612
|
-
*/
|
|
613
|
-
export function normalizeCustomTunnelConfig(config) {
|
|
614
|
-
if (!config || typeof config !== 'object') return config;
|
|
615
|
-
const mode = normalizeCustomTunnelMode(config.mode);
|
|
616
|
-
if (config.mode === mode) return config;
|
|
617
|
-
return { ...config, mode };
|
|
618
|
-
}
|
|
619
|
-
|
|
620
|
-
/** v2 协议模式:auto(缺省,兼容旧网关 ready 降级)/ v2(严格 hello-ack)。 */
|
|
621
|
-
export function normalizeCustomTunnelProtocol(value) {
|
|
622
|
-
return value === 'v2' ? 'v2' : 'auto';
|
|
623
|
-
}
|
|
624
|
-
|
|
625
|
-
/**
|
|
626
|
-
* 校验/归一化 SaaS 网关地址(保存前调用):undefined = 保留现值,空串 = 清除。
|
|
627
|
-
* 只允许 http(s)/ws(s);ws(s) 归一为 http(s) 以调用 /api/saas/*。
|
|
628
|
-
* @param {unknown} value
|
|
629
|
-
* @returns {string|undefined}
|
|
630
|
-
* @throws {Error} 非空且非法时
|
|
631
|
-
*/
|
|
632
|
-
export function normalizeSaasGatewayUrl(value) {
|
|
633
|
-
if (value === undefined) return undefined;
|
|
634
|
-
const trimmed = String(value).trim();
|
|
635
|
-
if (!trimmed) return '';
|
|
636
|
-
return normalizeSaasBaseUrl(trimmed);
|
|
637
|
-
}
|
|
638
|
-
|
|
639
|
-
/**
|
|
640
|
-
* 在线心跳间隔(仅 v2 生效):缺省 30s,允许 number 或 { intervalMs };
|
|
641
|
-
* 生产收敛到 5s..300s,避免把网关卡死或让半开连接拖太久。
|
|
642
|
-
* @param {unknown} value
|
|
643
|
-
* @returns {number}
|
|
644
|
-
*/
|
|
645
|
-
export function resolveSaasHeartbeatMs(value) {
|
|
646
|
-
const raw = typeof value === 'object' && value !== null ? value.intervalMs : value;
|
|
647
|
-
const numeric = Number(raw);
|
|
648
|
-
if (!Number.isFinite(numeric) || numeric <= 0) return 30000;
|
|
649
|
-
return Math.min(300000, Math.max(5000, Math.round(numeric)));
|
|
650
|
-
}
|
|
651
|
-
|
|
652
|
-
/**
|
|
653
|
-
* 由控制面基址推导数据面连接地址(token 接口未返回 gateway 时的兜底)。
|
|
654
|
-
* 单端口/反代部署下控制面与网关同源;http→ws、https→wss。
|
|
655
|
-
* @param {string} gatewayUrl
|
|
656
|
-
* @returns {string}
|
|
657
|
-
*/
|
|
658
|
-
function deriveSaasGatewayConnectUrl(gatewayUrl) {
|
|
659
|
-
try {
|
|
660
|
-
const url = new URL(String(gatewayUrl));
|
|
661
|
-
if (url.protocol === 'https:') url.protocol = 'wss:';
|
|
662
|
-
else if (url.protocol === 'http:') url.protocol = 'ws:';
|
|
663
|
-
return `${url.protocol}//${url.host}`;
|
|
664
|
-
} catch {
|
|
665
|
-
return String(gatewayUrl ?? '');
|
|
666
|
-
}
|
|
667
|
-
}
|
|
668
|
-
|
|
669
|
-
class ProxyServer {
|
|
670
|
-
constructor({ localPort, targetPort, authManager, logger, allowedOrigins, sseKeepalive = true, sseKeepaliveIntervalMs = SSE_KEEPALIVE_INTERVAL_MS }) {
|
|
671
|
-
this.localPort = localPort;
|
|
672
|
-
this.targetPort = targetPort;
|
|
673
|
-
this.authManager = authManager;
|
|
674
|
-
this.logger = logger;
|
|
675
|
-
// 返回 loopback-token 端点允许跨域读取的 Origin 列表(本插件自身生成的面板地址)
|
|
676
|
-
this.allowedOrigins = allowedOrigins ?? (() => []);
|
|
677
|
-
// issue #71:SSE 空闲保活(默认开;包级配置 sseKeepalive:false 可整体关闭)
|
|
678
|
-
this.sseKeepalive = sseKeepalive !== false;
|
|
679
|
-
this.sseKeepaliveIntervalMs = sseKeepaliveIntervalMs;
|
|
680
|
-
this.server = null;
|
|
681
|
-
this.clientSockets = new Set();
|
|
682
|
-
}
|
|
683
|
-
|
|
684
|
-
async start() {
|
|
685
|
-
if (this.server) return;
|
|
686
|
-
|
|
687
|
-
this.server = createServer((req, res) => {
|
|
688
|
-
const pathname = stripTrailingSlashesLinear((req.url || '/').split('?')[0]) || '/';
|
|
689
|
-
|
|
690
|
-
// 0. PWA Web App Manifest 与 App 图标支持
|
|
691
|
-
if (pathname === '/manifest.webmanifest' || pathname === '/manifest.json') {
|
|
692
|
-
res.writeHead(200, { 'Content-Type': 'application/manifest+json; charset=utf-8', 'Access-Control-Allow-Origin': '*' });
|
|
693
|
-
res.end(PWA_MANIFEST);
|
|
694
|
-
return;
|
|
695
|
-
}
|
|
696
|
-
if (pathname === '/__dsh_bridge__/pwa-icon.svg' || pathname === '/apple-touch-icon.png') {
|
|
697
|
-
res.writeHead(200, { 'Content-Type': 'image/svg+xml; charset=utf-8', 'Access-Control-Allow-Origin': '*' });
|
|
698
|
-
res.end(PWA_ICON_SVG);
|
|
699
|
-
return;
|
|
700
|
-
}
|
|
701
|
-
|
|
702
|
-
// 1. 处理登录 API: POST /__dsh_bridge__/login
|
|
703
|
-
if (pathname === '/__dsh_bridge__/login' && req.method === 'POST') {
|
|
704
|
-
const chunks = [];
|
|
705
|
-
req.on('data', (c) => chunks.push(c));
|
|
706
|
-
req.on('end', async () => {
|
|
707
|
-
try {
|
|
708
|
-
const body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
|
|
709
|
-
const clientIp = req.socket?.remoteAddress || '';
|
|
710
|
-
const verify = await this.authManager?.verifyPassword(body.password, clientIp);
|
|
711
|
-
// 登录失败文案:按请求语言本地化(与登录页语言同一判定)
|
|
712
|
-
const loginLang = pickLoginLang(req.headers['accept-language']);
|
|
713
|
-
if (verify?.success) {
|
|
714
|
-
const sessionToken = this.authManager.createSession();
|
|
715
|
-
res.writeHead(200, {
|
|
716
|
-
'Content-Type': 'application/json; charset=utf-8',
|
|
717
|
-
'Set-Cookie': `dsh_bridge_auth=${sessionToken}; Path=/; HttpOnly; SameSite=Lax; Max-Age=2592000`,
|
|
718
|
-
});
|
|
719
|
-
res.end(JSON.stringify({ ok: true }));
|
|
720
|
-
} else {
|
|
721
|
-
res.writeHead(401, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
722
|
-
res.end(JSON.stringify({ ok: false, error: localizeAuthError(verify?.error || '访问密码错误', loginLang) }));
|
|
723
|
-
}
|
|
724
|
-
} catch (e) {
|
|
725
|
-
res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
726
|
-
res.end(JSON.stringify({ ok: false, error: '无效请求' }));
|
|
727
|
-
}
|
|
728
|
-
});
|
|
729
|
-
return;
|
|
730
|
-
}
|
|
731
|
-
|
|
732
|
-
// 2. 处理登出 API: POST /__dsh_bridge__/logout
|
|
733
|
-
if (pathname === '/__dsh_bridge__/logout' && req.method === 'POST') {
|
|
734
|
-
res.writeHead(200, {
|
|
735
|
-
'Content-Type': 'application/json; charset=utf-8',
|
|
736
|
-
'Set-Cookie': 'dsh_bridge_auth=; Path=/; HttpOnly; Max-Age=0',
|
|
737
|
-
});
|
|
738
|
-
res.end(JSON.stringify({ ok: true }));
|
|
739
|
-
return;
|
|
740
|
-
}
|
|
741
|
-
|
|
742
|
-
// 3. 处理鉴权状态 API: GET /__dsh_bridge__/auth-status (严格脱敏,不暴露 secretToken)
|
|
743
|
-
if (pathname === '/__dsh_bridge__/auth-status' && req.method === 'GET') {
|
|
744
|
-
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
745
|
-
res.end(JSON.stringify(this.authManager?.getPublicStatus() ?? { enabled: false }));
|
|
746
|
-
return;
|
|
747
|
-
}
|
|
178
|
+
return { ...pairing };
|
|
179
|
+
};
|
|
748
180
|
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
{
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
}
|
|
765
|
-
}
|
|
766
|
-
// 纵深防御(issue #76 复核建议):请求**带** Origin 但未获授权时,直接拒绝且不签发
|
|
767
|
-
// 令牌——不再依赖"浏览器会因缺少 ACAO 而拦下响应"这一 CORS 实现细节。
|
|
768
|
-
// 无 Origin 的客户端(curl / Electron 主进程等)不受影响。
|
|
769
|
-
// 注意用「头是否存在」而不是真值判断:空 Origin(`Origin: `)在语义上同样是
|
|
770
|
-
// 「浏览器给了来源但未获授权」,必须 fail-closed;只有**完全没有** Origin 头
|
|
771
|
-
// (curl / Electron 主进程等非浏览器客户端)才走免 CORS 的签发路径。
|
|
772
|
-
if (Object.hasOwn(req.headers ?? {}, 'origin') && !corsOrigin) {
|
|
773
|
-
res.writeHead(403, {
|
|
774
|
-
'Content-Type': 'application/json; charset=utf-8',
|
|
775
|
-
Vary: 'Origin',
|
|
776
|
-
'Cache-Control': 'no-store',
|
|
181
|
+
const startPairingPoll = () => {
|
|
182
|
+
clearPairing();
|
|
183
|
+
pairingDeadline = Date.now() + PAIRING_MAX_MS;
|
|
184
|
+
pairingTimer = setInterval(async () => {
|
|
185
|
+
if (!pairing || pairing.status !== 'pending') return;
|
|
186
|
+
try {
|
|
187
|
+
const info = await fetchDeviceCodeStatus({ gatewayUrl: gatewayBase(), code: pairing.code });
|
|
188
|
+
if (info.status === 'approved') {
|
|
189
|
+
const claimed = await claimDeviceCode({ gatewayUrl: gatewayBase(), code: pairing.code });
|
|
190
|
+
pairing.status = 'bound';
|
|
191
|
+
persist({
|
|
192
|
+
deviceId: String(claimed?.deviceId ?? ''),
|
|
193
|
+
deviceSecret: String(claimed?.secret ?? ''),
|
|
194
|
+
tunnel: claimed?.tunnel ?? null,
|
|
195
|
+
routesVersion: 0,
|
|
777
196
|
});
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
}
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
|
784
|
-
'Access-Control-Allow-Headers': 'Content-Type',
|
|
785
|
-
// 该端点响应体含 adminToken:禁止任何缓存留存。Vary: Origin 只影响缓存键,
|
|
786
|
-
// 不禁缓存——缺 no-store 时中间缓存/浏览器磁盘缓存可能留存令牌。
|
|
787
|
-
'Cache-Control': 'no-store',
|
|
788
|
-
};
|
|
789
|
-
if (req.method === 'OPTIONS') {
|
|
790
|
-
res.writeHead(204, corsHeaders);
|
|
791
|
-
res.end();
|
|
792
|
-
return;
|
|
793
|
-
}
|
|
794
|
-
|
|
795
|
-
const remote = req.socket?.remoteAddress || '';
|
|
796
|
-
const isLoopback = (remote === '127.0.0.1' || remote === '::1' || remote === '::ffff:127.0.0.1');
|
|
797
|
-
const internalTunnelHeader = req.headers?.['x-dsh-internal-tunnel'];
|
|
798
|
-
const isCustomTunnel = Boolean(isLoopback && internalTunnelHeader && internalTunnelHeader === this.authManager?.internalTunnelSecret);
|
|
799
|
-
const isCloudflare = Boolean(isLoopback && (req.headers?.['cf-ray'] || req.headers?.['cf-connecting-ip']));
|
|
800
|
-
const isPublicTunnel = isCustomTunnel || isCloudflare;
|
|
801
|
-
|
|
802
|
-
if (isLoopback && !isPublicTunnel && this.authManager) {
|
|
803
|
-
const adminToken = this.authManager.createAdminSession();
|
|
804
|
-
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', ...corsHeaders });
|
|
805
|
-
res.end(JSON.stringify({ ok: true, adminToken }));
|
|
806
|
-
return;
|
|
197
|
+
clearPairing();
|
|
198
|
+
if (config.autoStart) await ensureConnected().catch((err) => { lastError = err?.message ?? String(err); });
|
|
199
|
+
} else if (info.status === 'expired' || info.status === 'consumed') {
|
|
200
|
+
pairing.status = info.status;
|
|
201
|
+
clearPairing();
|
|
807
202
|
}
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
res.end(JSON.stringify({ ok: false, error: 'Forbidden: loopback only' }));
|
|
811
|
-
return;
|
|
203
|
+
} catch (err) {
|
|
204
|
+
lastError = err?.message ?? String(err);
|
|
812
205
|
}
|
|
206
|
+
}, PAIRING_POLL_MS);
|
|
207
|
+
pairingTimer.unref?.();
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
const status = async () => {
|
|
211
|
+
const bound = Boolean(config.deviceId && config.deviceSecret);
|
|
212
|
+
const publicUrl = tunnel?.publicUrl || '';
|
|
213
|
+
return {
|
|
214
|
+
plugin: PACKAGE_NAME,
|
|
215
|
+
version: VERSION,
|
|
216
|
+
phase: tunnel?.connected ? 'ready' : (tunnel?.state ?? 'idle'),
|
|
217
|
+
phaseDetail: tunnel?.stateDetail ?? '',
|
|
218
|
+
connected: Boolean(tunnel?.connected),
|
|
219
|
+
gatewayUrl: config.gatewayUrl,
|
|
220
|
+
device: {
|
|
221
|
+
bound,
|
|
222
|
+
id: config.deviceId || '',
|
|
223
|
+
idMasked: config.deviceId ? maskSecret(config.deviceId) : '',
|
|
224
|
+
secretMasked: maskDeviceSecret(config.deviceSecret),
|
|
225
|
+
tunnel: config.tunnel ?? null,
|
|
226
|
+
},
|
|
227
|
+
publicUrl,
|
|
228
|
+
routes: Array.isArray(tunnel?.routes) ? tunnel.routes : [],
|
|
229
|
+
lastError: lastError || tunnel?.lastError || '',
|
|
230
|
+
proxy: { port: gatePort, host: '127.0.0.1', url: `http://127.0.0.1:${gatePort}` },
|
|
231
|
+
pairing: await pairStatus(),
|
|
232
|
+
protocol: tunnel?.protocolVersion ?? 2,
|
|
233
|
+
access: { passwordSet: gate.passwordConfigured() },
|
|
234
|
+
};
|
|
235
|
+
};
|
|
813
236
|
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
: (this.authManager?.verifyRequest(req) ?? { authenticated: true });
|
|
237
|
+
const requireAdmin = (payload) => {
|
|
238
|
+
if (!gate.verifyAdminToken(payload?.adminToken)) {
|
|
239
|
+
const error = new Error('需要管理员权限:请在本机打开 DSH 设置页「远程访问」操作');
|
|
240
|
+
error.code = 'unauthorized';
|
|
241
|
+
throw error;
|
|
242
|
+
}
|
|
243
|
+
};
|
|
822
244
|
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
});
|
|
835
|
-
res.end();
|
|
836
|
-
return;
|
|
837
|
-
} catch {
|
|
838
|
-
res.writeHead(302, {
|
|
839
|
-
'Location': '/',
|
|
840
|
-
'Set-Cookie': `dsh_bridge_auth=${sessionToken}; Path=/; HttpOnly; SameSite=Lax; Max-Age=2592000`,
|
|
841
|
-
});
|
|
842
|
-
res.end();
|
|
843
|
-
return;
|
|
245
|
+
const rpcHandler = async (endpoint, payload = {}) => {
|
|
246
|
+
try {
|
|
247
|
+
// getStatus 是面板启动引导(读取代理端口以领取 adminToken),只暴露脱敏状态;
|
|
248
|
+
// 其余端点一律要求物理回环签发的 adminToken。
|
|
249
|
+
if (endpoint === RPC_ENDPOINTS.getStatus) return ok(await status());
|
|
250
|
+
requireAdmin(payload);
|
|
251
|
+
switch (endpoint) {
|
|
252
|
+
case RPC_ENDPOINTS.saveGateway: {
|
|
253
|
+
const baseUrl = normalizeSaasBaseUrl(payload?.gatewayUrl);
|
|
254
|
+
persist({ gatewayUrl: baseUrl });
|
|
255
|
+
return ok({ gatewayUrl: baseUrl });
|
|
844
256
|
}
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
// 浏览器 Accept-Language 判断(判定逻辑与 POST /login 的错误文案共用
|
|
856
|
-
// pickLoginLang,避免出现「英文页面 + 中文报错」)。
|
|
857
|
-
const html = renderLoginPage({
|
|
858
|
-
hasPassword: this.authManager?.hasPassword,
|
|
859
|
-
// 管理员是否从未设置过任何密码(含独立管理密码):登录页需如实提示,
|
|
860
|
-
// 避免出现"输入任意密码都能进"的假门禁
|
|
861
|
-
noPasswordConfigured: !this.authManager?.hasPassword && !this.authManager?.hasAdminPassword,
|
|
862
|
-
mode: this.authManager?.mode,
|
|
863
|
-
locked: isLocked,
|
|
864
|
-
error: isLocked ? '尝试次数过多,请 60 秒后再试' : '',
|
|
865
|
-
lang: pickLoginLang(req.headers['accept-language']),
|
|
257
|
+
case RPC_ENDPOINTS.requestDeviceCode: {
|
|
258
|
+
const created = await requestDeviceCode({
|
|
259
|
+
gatewayUrl: gatewayBase(),
|
|
260
|
+
deviceInfo: buildDeviceInfo({
|
|
261
|
+
name: hostname(),
|
|
262
|
+
platform: osPlatform(),
|
|
263
|
+
hostname: hostname(),
|
|
264
|
+
arch: osArch(),
|
|
265
|
+
pluginVersion: VERSION,
|
|
266
|
+
}),
|
|
866
267
|
});
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
268
|
+
pairing = {
|
|
269
|
+
code: created.code,
|
|
270
|
+
expiresAt: created.expiresAt ?? null,
|
|
271
|
+
expiresAtMs: created.expiresAtMs ?? null,
|
|
272
|
+
verificationUrl: created.verificationUrl ?? '',
|
|
273
|
+
status: 'pending',
|
|
274
|
+
};
|
|
275
|
+
lastError = null;
|
|
276
|
+
startPairingPoll();
|
|
277
|
+
return ok({ ...pairing });
|
|
874
278
|
}
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
// session 投影剥离须覆盖压缩(gzip)响应:v2.10.4 起 stripSessionProjections 支持
|
|
891
|
-
// gunzip 解压,因此压缩的 session 响应也应进入缓冲分支进行剥离(此前 !isCompressed
|
|
892
|
-
// 门控会跳过压缩响应,导致 DSH 启用 gzip 后局域网/CF/外部隧道入口剥离失效——只有
|
|
893
|
-
// 自建隧道入口正确剥离)。HTML 注入仍只在未压缩时进行(注入逻辑处理明文)。
|
|
894
|
-
const sessionProjectionBuffered = isSessionProjection && proxyRes.statusCode === 200;
|
|
895
|
-
const shouldBuffer = (contentType.includes('text/html') && !isCompressed(proxyRes.headers))
|
|
896
|
-
|| sessionProjectionBuffered;
|
|
897
|
-
if (shouldBuffer) {
|
|
898
|
-
const chunks = [];
|
|
899
|
-
proxyRes.on('data', (c) => chunks.push(c));
|
|
900
|
-
proxyRes.on('end', () => {
|
|
901
|
-
let out = Buffer.concat(chunks);
|
|
902
|
-
const outHeaders = { ...proxyRes.headers };
|
|
903
|
-
if (contentType.includes('text/html') && !isCompressed(proxyRes.headers)) {
|
|
904
|
-
let html = out.toString('utf8');
|
|
905
|
-
const missingParts = HTML_INJECTION_PARTS.filter((part) => !html.includes(part.mark));
|
|
906
|
-
if (missingParts.length > 0) {
|
|
907
|
-
const payload = missingParts.map((part) => part.html).join('\n');
|
|
908
|
-
html = html.replace(/<head[^>]*>/i, (m) => `${m}${payload}`);
|
|
909
|
-
}
|
|
910
|
-
out = Buffer.from(html, 'utf8');
|
|
911
|
-
} else if (isSessionProjection) {
|
|
912
|
-
const { body, stripped } = stripSessionProjections(pathname, out, proxyRes.headers['content-encoding']);
|
|
913
|
-
if (stripped) {
|
|
914
|
-
out = body;
|
|
915
|
-
// 已修改 body,清除原始压缩编码标记
|
|
916
|
-
delete outHeaders['content-encoding'];
|
|
917
|
-
}
|
|
918
|
-
}
|
|
919
|
-
// 缓冲改写过 body:必须去掉原始传输头,避免 content-length 与
|
|
920
|
-
// transfer-encoding 并存导致客户端解析错误(HPE_INVALID_CONTENT_LENGTH)
|
|
921
|
-
delete outHeaders['content-length'];
|
|
922
|
-
delete outHeaders['transfer-encoding'];
|
|
923
|
-
outHeaders['content-length'] = String(out.length);
|
|
924
|
-
res.writeHead(proxyRes.statusCode ?? 200, sanitizeProxyHeaders(outHeaders));
|
|
925
|
-
res.end(out);
|
|
926
|
-
});
|
|
927
|
-
proxyRes.on('error', () => res.destroy());
|
|
928
|
-
return;
|
|
279
|
+
case RPC_ENDPOINTS.bindDevice: {
|
|
280
|
+
const code = String(payload?.code ?? '').trim();
|
|
281
|
+
if (!code) return fail('bad-request', '设备码不能为空');
|
|
282
|
+
const claimed = await claimDeviceCode({ gatewayUrl: gatewayBase(), code });
|
|
283
|
+
pairing = { code, status: 'bound', expiresAt: null, expiresAtMs: null, verificationUrl: '' };
|
|
284
|
+
clearPairing();
|
|
285
|
+
persist({
|
|
286
|
+
deviceId: String(claimed?.deviceId ?? ''),
|
|
287
|
+
deviceSecret: String(claimed?.secret ?? ''),
|
|
288
|
+
tunnel: claimed?.tunnel ?? null,
|
|
289
|
+
routesVersion: 0,
|
|
290
|
+
});
|
|
291
|
+
if (payload?.autoStart === true) {
|
|
292
|
+
persist({ autoStart: true });
|
|
293
|
+
await ensureConnected();
|
|
929
294
|
}
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
295
|
+
return ok({ deviceId: config.deviceId, tunnel: config.tunnel });
|
|
296
|
+
}
|
|
297
|
+
case RPC_ENDPOINTS.unbindDevice:
|
|
298
|
+
await stopTunnel();
|
|
299
|
+
clearPairing();
|
|
300
|
+
pairing = null;
|
|
301
|
+
persist({ deviceId: '', deviceSecret: '', tunnel: null, routesVersion: 0, autoStart: false });
|
|
302
|
+
return ok({ unbound: true });
|
|
303
|
+
case RPC_ENDPOINTS.startTunnel:
|
|
304
|
+
if (!gate.passwordConfigured()) return fail('password_not_set', '请先设置访问密码:远程访客需要凭此口令访问');
|
|
305
|
+
persist({ autoStart: payload?.autoStart !== false });
|
|
306
|
+
await ensureConnected();
|
|
307
|
+
return ok(await status());
|
|
308
|
+
case RPC_ENDPOINTS.stopTunnel:
|
|
309
|
+
await stopTunnel({ persistAutoStart: true });
|
|
310
|
+
return ok(await status());
|
|
311
|
+
case RPC_ENDPOINTS.getAccessUrl: {
|
|
312
|
+
const publicUrl = tunnel?.publicUrl || '';
|
|
313
|
+
const url = publicUrl ? gate.accessUrl(publicUrl) : '';
|
|
314
|
+
let qrDataUrl = null;
|
|
315
|
+
if (url) {
|
|
316
|
+
try {
|
|
317
|
+
qrDataUrl = await QRCode.toDataURL(url, { width: 240, margin: 2, color: { dark: '#1F2421', light: '#FFFFFF' } });
|
|
318
|
+
} catch { qrDataUrl = null; }
|
|
948
319
|
}
|
|
949
|
-
|
|
950
|
-
res.on('close', () => proxyRes.destroy());
|
|
951
|
-
proxyRes.on('error', () => res.destroy());
|
|
952
|
-
proxyRes.on('close', () => { if (!res.writableEnded) res.destroy(); });
|
|
953
|
-
},
|
|
954
|
-
);
|
|
955
|
-
proxyReq.on('error', (err) => {
|
|
956
|
-
this.logger.error('代理请求失败: %s', err.message);
|
|
957
|
-
if (!res.headersSent) res.writeHead(502, { 'content-type': 'text/plain; charset=utf-8' });
|
|
958
|
-
res.end(`dsh-bridge: 无法连接 dsh web (127.0.0.1:${this.targetPort}) — ${err.message}`);
|
|
959
|
-
});
|
|
960
|
-
req.pipe(proxyReq);
|
|
961
|
-
});
|
|
962
|
-
|
|
963
|
-
// WebSocket upgrade 鉴权与代理
|
|
964
|
-
this.server.on('upgrade', (req, socket, head) => {
|
|
965
|
-
const auth = this.authManager?.verifyRequest(req) ?? { authenticated: true };
|
|
966
|
-
if (!auth.authenticated) {
|
|
967
|
-
socket.write('HTTP/1.1 401 Unauthorized\r\nContent-Type: text/plain\r\n\r\nUnauthorized\r\n');
|
|
968
|
-
socket.destroy();
|
|
969
|
-
return;
|
|
970
|
-
}
|
|
971
|
-
|
|
972
|
-
// 纵深防御:浏览器发起的升级请求若带 Origin 且不在已知面板来源内,拒绝握手,
|
|
973
|
-
// 防止恶意页面借用户浏览器已有的 Cookie 建立 WebSocket(CSWSH)。
|
|
974
|
-
// 认证已在上方完成,此处只做额外收紧,不构成绕过认证的路径。
|
|
975
|
-
// 关键兼容性约束:Origin 缺失一律放行——非浏览器客户端(IM 机器人、
|
|
976
|
-
// ws 库、命令行工具、隧道转发)通常不发 Origin,若按缺失拒绝会直接
|
|
977
|
-
// 打断现有用户的机器人通道。
|
|
978
|
-
const origin = req.headers?.origin;
|
|
979
|
-
if (origin) {
|
|
980
|
-
let allowed;
|
|
981
|
-
try {
|
|
982
|
-
allowed = this.allowedOrigins?.() ?? [];
|
|
983
|
-
} catch { allowed = []; }
|
|
984
|
-
// opaque origin(字面量 "null")永不放行:它无法归属到任何具体面板来源。
|
|
985
|
-
if (origin === OPAQUE_ORIGIN || !allowed.includes(origin)) {
|
|
986
|
-
// 直接内联 origin,避免依赖 logger 的 printf 插值(该 logger 在部分调用形态下不做替换)
|
|
987
|
-
this.logger?.warn?.(`dsh-bridge: 拒绝来源不明的 WebSocket 升级请求 (Origin=${origin})`);
|
|
988
|
-
socket.write('HTTP/1.1 403 Forbidden\r\nContent-Type: text/plain\r\n\r\nForbidden\r\n');
|
|
989
|
-
socket.destroy();
|
|
990
|
-
return;
|
|
320
|
+
return ok({ url, token: config.access.token, qrDataUrl });
|
|
991
321
|
}
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
socket.write('HTTP/1.1 101 Switching Protocols\r\n');
|
|
1000
|
-
// WS 升级成功:手写原始 socket 响应,只放行必要的 101 头(白名单),
|
|
1001
|
-
// 避免上游注入任意头(如 set-cookie / 自定义敏感头)透传给浏览器
|
|
1002
|
-
const WS_101_ALLOW = new Set([
|
|
1003
|
-
'connection', 'upgrade', 'sec-websocket-accept',
|
|
1004
|
-
'sec-websocket-extensions', 'sec-websocket-protocol',
|
|
1005
|
-
]);
|
|
1006
|
-
const raw = [];
|
|
1007
|
-
for (const [k, v] of Object.entries(proxyRes.headers)) {
|
|
1008
|
-
if (!WS_101_ALLOW.has(k.toLowerCase())) continue;
|
|
1009
|
-
raw.push(`${k}: ${Array.isArray(v) ? v.join(', ') : v}`);
|
|
322
|
+
case RPC_ENDPOINTS.setAccessPassword: {
|
|
323
|
+
const password = String(payload?.password ?? '');
|
|
324
|
+
if (password.length < 6) return fail('bad-request', '访问密码至少 6 位');
|
|
325
|
+
const digest = hashPassword(password);
|
|
326
|
+
// 轮换会话签名密钥:改口令后所有已登录的远程访客立即失效
|
|
327
|
+
persist({ access: { ...config.access, ...digest, sessionSecret: createSecret(32) } });
|
|
328
|
+
return ok({ passwordSet: true });
|
|
1010
329
|
}
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
socket.pipe(proxySocket);
|
|
1015
|
-
const teardown = () => {
|
|
1016
|
-
try { proxySocket.destroy(); } catch {}
|
|
1017
|
-
try { socket.destroy(); } catch {}
|
|
1018
|
-
};
|
|
1019
|
-
// issue #81:上游 socket 必须有 'error' 监听者——否则上游 RST(如
|
|
1020
|
-
// ECONNRESET)在 Node 中会抛出为未捕获异常,直接打死整个 dsh 进程。
|
|
1021
|
-
// teardown 幂等,重复 destroy 无副作用。
|
|
1022
|
-
proxySocket.on('error', teardown);
|
|
1023
|
-
proxySocket.on('close', teardown);
|
|
1024
|
-
socket.on('close', teardown);
|
|
1025
|
-
});
|
|
1026
|
-
proxyReq.on('response', (proxyRes) => {
|
|
1027
|
-
if (proxyRes.statusCode === 101) return;
|
|
1028
|
-
try {
|
|
1029
|
-
const raw = [`HTTP/1.1 ${proxyRes.statusCode} ${proxyRes.statusMessage ?? ''}`.trim()];
|
|
1030
|
-
// 非 101(如后端拒绝升级):剔除 hop-by-hop 头后再回写
|
|
1031
|
-
for (const [k, v] of Object.entries(sanitizeProxyHeaders(proxyRes.headers))) {
|
|
1032
|
-
raw.push(`${k}: ${Array.isArray(v) ? v.join(', ') : v}`);
|
|
1033
|
-
}
|
|
1034
|
-
socket.end(raw.join('\r\n') + '\r\n\r\n');
|
|
1035
|
-
proxyRes.resume();
|
|
1036
|
-
} catch { socket.destroy(); }
|
|
1037
|
-
});
|
|
1038
|
-
proxyReq.on('error', () => socket.destroy());
|
|
1039
|
-
if (head?.length) proxyReq.write(head);
|
|
1040
|
-
proxyReq.end();
|
|
1041
|
-
socket.on('error', () => socket.destroy());
|
|
1042
|
-
});
|
|
1043
|
-
|
|
1044
|
-
// 跟踪所有连接以便 stop() 时强制关闭
|
|
1045
|
-
this.server.on('connection', (sock) => {
|
|
1046
|
-
this.clientSockets.add(sock);
|
|
1047
|
-
sock.on('close', () => this.clientSockets.delete(sock));
|
|
1048
|
-
sock.on('error', () => {});
|
|
1049
|
-
});
|
|
1050
|
-
|
|
1051
|
-
await new Promise((resolve, reject) => {
|
|
1052
|
-
this.server.once('error', reject);
|
|
1053
|
-
this.server.listen(this.localPort, '0.0.0.0', () => {
|
|
1054
|
-
this.logger.info('dsh-bridge: 代理已启动 0.0.0.0:%d -> 127.0.0.1:%d', this.localPort, this.targetPort);
|
|
1055
|
-
resolve();
|
|
1056
|
-
});
|
|
1057
|
-
});
|
|
1058
|
-
}
|
|
1059
|
-
|
|
1060
|
-
async stop() {
|
|
1061
|
-
if (!this.server) return;
|
|
1062
|
-
for (const s of this.clientSockets) { try { s.destroy(); } catch {} }
|
|
1063
|
-
await new Promise((resolve) => this.server.close(() => resolve()));
|
|
1064
|
-
this.server = null;
|
|
1065
|
-
this.clientSockets.clear();
|
|
1066
|
-
}
|
|
1067
|
-
|
|
1068
|
-
get activeConnections() {
|
|
1069
|
-
return this.clientSockets.size;
|
|
1070
|
-
}
|
|
1071
|
-
|
|
1072
|
-
get port() {
|
|
1073
|
-
return this.localPort;
|
|
1074
|
-
}
|
|
1075
|
-
}
|
|
1076
|
-
|
|
1077
|
-
/**
|
|
1078
|
-
* Bridge Service
|
|
1079
|
-
*/
|
|
1080
|
-
class BridgeService {
|
|
1081
|
-
constructor({ dshPort, proxyPort, home, cloudflaredConfig, customTunnelConfig, lanConfig, authManager, onPersist, logger, mobileUiEnabled, sseKeepalive }) {
|
|
1082
|
-
this.dshPort = dshPort;
|
|
1083
|
-
this.proxyPort = proxyPort;
|
|
1084
|
-
// 包级别页面改写层开关(issue #56 §4),默认 true;仅影响客户端页面改写,不影响隧道/IM
|
|
1085
|
-
this.mobileUiEnabled = mobileUiEnabled ?? true;
|
|
1086
|
-
// issue #71:SSE 空闲保活(包级配置 sseKeepalive:false 关闭,缺省开),透传给 ProxyServer
|
|
1087
|
-
this.sseKeepalive = sseKeepalive !== false;
|
|
1088
|
-
// issue #71 第4条:同 token 的另一个 cloudflared 探测结果(启动时异步填充)
|
|
1089
|
-
this.foreignCloudflared = null;
|
|
1090
|
-
this.home = home;
|
|
1091
|
-
this.cloudflaredConfig = cloudflaredConfig ?? { token: '', hostname: '', autoStart: false };
|
|
1092
|
-
// 缺省 mode='self':旧配置文件没有该键,迁移后行为与改造前一致
|
|
1093
|
-
this.customTunnelConfig = normalizeCustomTunnelConfig(customTunnelConfig) ?? null;
|
|
1094
|
-
this.externalTunnelConfig = null; // 用户自行部署的外部隧道(Docker cloudflared 等),仅登记公网地址展示
|
|
1095
|
-
this.selectedLanIp = lanConfig?.selectedIp ?? null;
|
|
1096
|
-
this.authManager = authManager ?? null;
|
|
1097
|
-
this.onPersist = onPersist ?? null;
|
|
1098
|
-
this.logger = logger;
|
|
1099
|
-
|
|
1100
|
-
// 运行中任务探测所需的运行时引用(由 apply 在构造后注入):
|
|
1101
|
-
// ctx.sessions —— 枚举 live 会话及其事件流,判断是否有 turn 在跑
|
|
1102
|
-
// platformManager —— 遍历各 IM 平台 bridge,取其 pending 审批
|
|
1103
|
-
// 二者缺失时探测退化为「未检测到任务」,重启照常执行(不得因探测失败而阻断重启)。
|
|
1104
|
-
this.runtimeCtx = null;
|
|
1105
|
-
this.platformManager = null;
|
|
1106
|
-
|
|
1107
|
-
this.qrCache = new QrCache();
|
|
1108
|
-
this.proxy = null;
|
|
1109
|
-
|
|
1110
|
-
// 首次启用引导:是否仍需要向用户展示「开门禁」提示(由 apply 的启动链按保守判据置位)
|
|
1111
|
-
this.firstRunGuidePending = false;
|
|
1112
|
-
|
|
1113
|
-
this.customTunnel = null;
|
|
1114
|
-
this.customTunnelState = { phase: 'idle', detail: '' };
|
|
1115
|
-
// SaaS 模式运行态:短期令牌缓存、设备码等待器、最近错误(面板/状态用)
|
|
1116
|
-
this._saasTokenCache = null;
|
|
1117
|
-
this._saasBindWaiter = null;
|
|
1118
|
-
this.saasPendingCode = null;
|
|
1119
|
-
this.saasLastError = null;
|
|
1120
|
-
|
|
1121
|
-
this.cloudflared = null;
|
|
1122
|
-
this.cloudflaredState = { phase: 'idle', detail: '' };
|
|
1123
|
-
|
|
1124
|
-
// DSH 宿主版本:惰性探测一次并缓存(进程生命周期内不变,避免频繁 spawn 子进程)
|
|
1125
|
-
this._dshVersion = null;
|
|
1126
|
-
this._dshVersionLoaded = false;
|
|
1127
|
-
// 版本检查结果缓存(10 分钟 TTL):客户端轮询/多处面板重复调用时不反复打 npm registry
|
|
1128
|
-
this._versionCheckCache = null;
|
|
1129
|
-
this._versionCheckCachedAt = 0;
|
|
1130
|
-
this._versionCheckTtlMs = 10 * 60 * 1000;
|
|
1131
|
-
}
|
|
1132
|
-
|
|
1133
|
-
/**
|
|
1134
|
-
* 探测 DSH 宿主版本(`dsh --version`,与升级功能同一套增强 PATH 的 spawn 模式)。
|
|
1135
|
-
* 惰性执行 + 结果缓存;失败静默返回 null(不阻塞面板)。
|
|
1136
|
-
* @returns {Promise<string|null>}
|
|
1137
|
-
*/
|
|
1138
|
-
async getDshVersion() {
|
|
1139
|
-
if (this._dshVersionLoaded) return this._dshVersion;
|
|
1140
|
-
// 上次探测失败后的退避期未到:不 spawn,直接返回 null(面板仍可正常加载)
|
|
1141
|
-
if (this._dshVersionRetryAt && Date.now() < this._dshVersionRetryAt) return this._dshVersion;
|
|
1142
|
-
this._dshVersionLoaded = true; // 尝试期间置位;成功保持,失败在 catch 里复位并设退避
|
|
1143
|
-
|
|
1144
|
-
const isWin = process.platform === 'win32';
|
|
1145
|
-
const nodeDir = dirname(process.execPath);
|
|
1146
|
-
const existingPath = process.env.PATH || process.env.Path || '';
|
|
1147
|
-
const extraPaths = isWin ? [nodeDir] : [nodeDir, '/opt/homebrew/bin', '/opt/homebrew/sbin', '/usr/local/bin', '/usr/bin', '/bin'];
|
|
1148
|
-
const separator = isWin ? ';' : ':';
|
|
1149
|
-
const augmentedEnv = {
|
|
1150
|
-
...process.env,
|
|
1151
|
-
PATH: [...extraPaths, existingPath].filter(Boolean).join(separator),
|
|
1152
|
-
};
|
|
1153
|
-
if (isWin) augmentedEnv.Path = augmentedEnv.PATH;
|
|
1154
|
-
|
|
1155
|
-
try {
|
|
1156
|
-
const version = await new Promise((resolve, reject) => {
|
|
1157
|
-
let cp;
|
|
1158
|
-
try {
|
|
1159
|
-
// 命令为固定字面量(无用户输入),shell 拼接安全;带 args 的 shell:true 在 Node 24 有弃用警告
|
|
1160
|
-
cp = spawn('dsh --version', {
|
|
1161
|
-
windowsHide: true,
|
|
1162
|
-
shell: true,
|
|
1163
|
-
env: augmentedEnv,
|
|
1164
|
-
timeout: 5000,
|
|
1165
|
-
});
|
|
1166
|
-
} catch (e) {
|
|
1167
|
-
return reject(e);
|
|
330
|
+
case RPC_ENDPOINTS.clearAccessPassword: {
|
|
331
|
+
persist({ access: { ...config.access, passwordHash: '', passwordSalt: '', sessionSecret: createSecret(32) } });
|
|
332
|
+
return ok({ passwordSet: false });
|
|
1168
333
|
}
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
cp.stdout?.on('data', (d) => { stdout += d.toString(); });
|
|
1172
|
-
cp.stderr?.on('data', (d) => { stderr += d.toString(); });
|
|
1173
|
-
cp.on('error', reject);
|
|
1174
|
-
cp.on('close', (code) => {
|
|
1175
|
-
if (code === 0 && stdout.trim()) resolve(stdout.trim());
|
|
1176
|
-
else reject(new Error(stderr || stdout || `退出码 ${code}`));
|
|
1177
|
-
});
|
|
1178
|
-
});
|
|
1179
|
-
this._dshVersion = version;
|
|
1180
|
-
} catch (e) {
|
|
1181
|
-
this.logger?.debug?.('dsh-bridge: 探测 DSH 版本失败: %s', e.message);
|
|
1182
|
-
this._dshVersion = null;
|
|
1183
|
-
// 失败:复位已加载标记并设 10 分钟退避期。退避期内 getStatus 不再 spawn,
|
|
1184
|
-
// 到期后允许重试一次(用户修复 PATH 后无需重启即可恢复版本显示)。
|
|
1185
|
-
this._dshVersionLoaded = false;
|
|
1186
|
-
this._dshVersionRetryAt = Date.now() + 10 * 60 * 1000;
|
|
1187
|
-
}
|
|
1188
|
-
return this._dshVersion;
|
|
1189
|
-
}
|
|
1190
|
-
|
|
1191
|
-
/**
|
|
1192
|
-
* 标记「首次启用引导」已展示/已确认,持久化后不再打扰用户。
|
|
1193
|
-
* 与访问认证状态解耦:即便用户仍未开启门禁,确认过后也不再重复弹窗。
|
|
1194
|
-
*/
|
|
1195
|
-
async dismissFirstRunGuide() {
|
|
1196
|
-
this.firstRunGuidePending = false;
|
|
1197
|
-
await this.onPersist?.({ wizard: { guideShown: true } });
|
|
1198
|
-
return { ok: true };
|
|
1199
|
-
}
|
|
1200
|
-
|
|
1201
|
-
async setLanIp({ ip, adminAuthValid = false } = {}) { const trimmed = ip ? String(ip).trim() : null;
|
|
1202
|
-
this.selectedLanIp = trimmed || null;
|
|
1203
|
-
await this.onPersist?.({ lan: { selectedIp: this.selectedLanIp } });
|
|
1204
|
-
this.logger?.info('局域网选定 IP 更新为: %s', this.selectedLanIp || '自动推荐');
|
|
1205
|
-
return this.getStatus({ adminAuthValid });
|
|
1206
|
-
}
|
|
1207
|
-
|
|
1208
|
-
async startProxy() {
|
|
1209
|
-
if (this.proxy) return this.proxy;
|
|
1210
|
-
|
|
1211
|
-
this.proxy = new ProxyServer({
|
|
1212
|
-
localPort: this.proxyPort,
|
|
1213
|
-
targetPort: this.dshPort,
|
|
1214
|
-
authManager: this.authManager,
|
|
1215
|
-
logger: this.logger,
|
|
1216
|
-
sseKeepalive: this.sseKeepalive,
|
|
1217
|
-
// loopback-token 允许跨域的面板来源:DSH 原生端口(直连 3080 场景)、代理端口、
|
|
1218
|
-
// 当前局域网 IP、隧道公网地址。直连原生端口时页面相对路径拿不到 token,
|
|
1219
|
-
// 必须允许它跨域回读 3082 代理端口的 loopback-token 端点(见 issue #28)。
|
|
1220
|
-
allowedOrigins: () => {
|
|
1221
|
-
const origins = [
|
|
1222
|
-
`http://127.0.0.1:${this.proxyPort}`, `http://localhost:${this.proxyPort}`,
|
|
1223
|
-
`http://127.0.0.1:${this.dshPort}`, `http://localhost:${this.dshPort}`,
|
|
1224
|
-
// 官方桌面版固定来源(issue #76):字面量,绝不放行 opaque 的 "null"
|
|
1225
|
-
DESKTOP_APP_ORIGIN,
|
|
1226
|
-
];
|
|
1227
|
-
try {
|
|
1228
|
-
// LAN 面板与回环同理需要同时覆盖 3082(代理)与 dshPort(DSH 原生端口直连):
|
|
1229
|
-
// 浏览器从 LAN IP 直连原生端口时 Origin 是 http://<lanIp>:<dshPort>,
|
|
1230
|
-
// 只配 proxyPort 会导致页面能打开但 WebSocket 升级被拒(实时通道静默失效)。
|
|
1231
|
-
for (const iface of listAllLanIPv4()) {
|
|
1232
|
-
origins.push(`http://${iface.address}:${this.proxyPort}`);
|
|
1233
|
-
origins.push(`http://${iface.address}:${this.dshPort}`);
|
|
1234
|
-
}
|
|
1235
|
-
if (this.selectedLanIp) {
|
|
1236
|
-
origins.push(`http://${this.selectedLanIp}:${this.proxyPort}`);
|
|
1237
|
-
origins.push(`http://${this.selectedLanIp}:${this.dshPort}`);
|
|
1238
|
-
}
|
|
1239
|
-
// 隧道地址由外部提供,必须先按协议收敛为 http(s) origin:非 http(s) 的
|
|
1240
|
-
// `.origin` 是字符串 "null",会放行全部 opaque origin(见 toHttpOrigin)。
|
|
1241
|
-
const cloudflaredOrigin = toHttpOrigin(this.cloudflared?.url);
|
|
1242
|
-
if (cloudflaredOrigin) origins.push(cloudflaredOrigin);
|
|
1243
|
-
const customTunnelOrigin = toHttpOrigin(this.customTunnel?.publicUrl);
|
|
1244
|
-
if (customTunnelOrigin) origins.push(customTunnelOrigin);
|
|
1245
|
-
const externalTunnelOrigin = toHttpOrigin(this.externalTunnelConfig?.url);
|
|
1246
|
-
if (externalTunnelOrigin) origins.push(externalTunnelOrigin);
|
|
1247
|
-
} catch { /* 单项来源解析失败不影响其余 */ }
|
|
1248
|
-
return origins;
|
|
1249
|
-
},
|
|
1250
|
-
});
|
|
1251
|
-
|
|
1252
|
-
await this.proxy.start();
|
|
1253
|
-
return this.proxy;
|
|
1254
|
-
}
|
|
1255
|
-
|
|
1256
|
-
async getStatus({ adminAuthValid = false } = {}) {
|
|
1257
|
-
const allInterfaces = listAllLanIPv4();
|
|
1258
|
-
const isSelectedValid = Boolean(this.selectedLanIp && allInterfaces.some(i => i.address === this.selectedLanIp));
|
|
1259
|
-
const lanIp = isSelectedValid ? this.selectedLanIp : selectLanIPv4();
|
|
1260
|
-
const token = adminAuthValid ? this.authManager?.secretToken : null;
|
|
1261
|
-
const isAuthEnabled = Boolean(this.authManager?.enabled && this.authManager?.mode !== 'password_only' && token);
|
|
1262
|
-
|
|
1263
|
-
const isLanProtected = isAuthEnabled && this.authManager?.scope !== 'public_only';
|
|
1264
|
-
const isPublicProtected = isAuthEnabled && this.authManager?.scope !== 'lan_only';
|
|
1265
|
-
|
|
1266
|
-
const appendToken = (url, shouldAppend) => {
|
|
1267
|
-
if (!url || !shouldAppend || !token) return url;
|
|
1268
|
-
try {
|
|
1269
|
-
const u = new URL(url);
|
|
1270
|
-
u.searchParams.set('auth', token);
|
|
1271
|
-
return u.toString();
|
|
1272
|
-
} catch {
|
|
1273
|
-
const sep = url.includes('?') ? '&' : '?';
|
|
1274
|
-
return `${url}${sep}auth=${encodeURIComponent(token)}`;
|
|
1275
|
-
}
|
|
1276
|
-
};
|
|
1277
|
-
|
|
1278
|
-
// 隧道可观测性(issue #71 P1-A/P2-B):数据面状态 + 实际二进制画像。
|
|
1279
|
-
// binary 在 manager 存续期间读取并缓存最近一次值——显式关闭隧道后仍能展示
|
|
1280
|
-
// "最后实际使用的是哪个二进制",不随 manager 引用置空而从面板蒸发。
|
|
1281
|
-
if (this.cloudflared) this._lastCloudflaredBinary = this.cloudflared.getBinaryInfo();
|
|
1282
|
-
const cfDataPlane = this.cloudflared?.dataPlaneState()
|
|
1283
|
-
?? { state: 'idle', url: null, checkedAt: null, resolvedIp: null, consecutiveFailures: 0, lastError: '隧道未运行' };
|
|
1284
|
-
const cfBinary = this._lastCloudflaredBinary ?? null;
|
|
1285
|
-
|
|
1286
|
-
const baseLanUrl = lanIp ? `http://${lanIp}:${this.proxyPort}` : null;
|
|
1287
|
-
const lanUrl = appendToken(baseLanUrl, isLanProtected);
|
|
1288
|
-
|
|
1289
|
-
const baseCloudflaredUrl = this.cloudflared?.url || null;
|
|
1290
|
-
const cloudflaredUrl = appendToken(baseCloudflaredUrl, isPublicProtected);
|
|
1291
|
-
|
|
1292
|
-
const baseCustomUrl = this.customTunnel?.publicUrl || null;
|
|
1293
|
-
const customUrl = appendToken(baseCustomUrl, isPublicProtected);
|
|
1294
|
-
const tunnelMode = normalizeCustomTunnelMode(this.customTunnelConfig?.mode);
|
|
1295
|
-
const saasStatus = this.getSaasStatus({ adminAuthValid });
|
|
1296
|
-
|
|
1297
|
-
return {
|
|
1298
|
-
version: VERSION,
|
|
1299
|
-
dshVersion: await this.getDshVersion(),
|
|
1300
|
-
|
|
1301
|
-
// 页面改写层:mobileUi=实际生效(宿主开关 && 用户偏好),另附分项供面板展示
|
|
1302
|
-
mobileUi: (this.mobileUiEnabled ?? true) && (this.uiPageTweaks ?? true),
|
|
1303
|
-
mobileUiHost: this.mobileUiEnabled ?? true,
|
|
1304
|
-
uiPageTweaks: this.uiPageTweaks ?? true,
|
|
1305
|
-
|
|
1306
|
-
auth: this.authManager?.getStatus({ masked: !adminAuthValid }) ?? { enabled: false },
|
|
1307
|
-
|
|
1308
|
-
// 首次启用引导:为 true 时面板应提示用户开启访问门禁(默认 0.0.0.0 且认证默认关闭)
|
|
1309
|
-
firstRunGuide: {
|
|
1310
|
-
pending: Boolean(this.firstRunGuidePending),
|
|
1311
|
-
// 当前是否处于「监听 0.0.0.0 但认证未开启」的开箱敞开状态
|
|
1312
|
-
exposedWithoutAuth: this.authManager?.enabled !== true,
|
|
1313
|
-
},
|
|
1314
|
-
|
|
1315
|
-
proxy: {
|
|
1316
|
-
running: !!this.proxy,
|
|
1317
|
-
port: this.proxyPort,
|
|
1318
|
-
activeConnections: this.proxy?.activeConnections ?? 0,
|
|
1319
|
-
},
|
|
1320
|
-
|
|
1321
|
-
lan: {
|
|
1322
|
-
ip: lanIp,
|
|
1323
|
-
selectedIp: this.selectedLanIp || '',
|
|
1324
|
-
interfaces: allInterfaces,
|
|
1325
|
-
url: lanUrl,
|
|
1326
|
-
rawUrl: baseLanUrl,
|
|
1327
|
-
qr: lanUrl ? await this.qrCache.get(lanUrl) : null,
|
|
1328
|
-
},
|
|
1329
|
-
|
|
1330
|
-
cloudflared: {
|
|
1331
|
-
running: this.isCloudflaredActive(),
|
|
1332
|
-
url: cloudflaredUrl,
|
|
1333
|
-
rawUrl: baseCloudflaredUrl,
|
|
1334
|
-
qr: cloudflaredUrl
|
|
1335
|
-
? await this.qrCache.get(cloudflaredUrl)
|
|
1336
|
-
: null,
|
|
1337
|
-
state: this.cloudflaredState,
|
|
1338
|
-
tokenConfigured: !!this.cloudflaredConfig?.token,
|
|
1339
|
-
token: adminAuthValid ? (this.cloudflaredConfig?.token || '') : (this.cloudflaredConfig?.token ? '******' : ''),
|
|
1340
|
-
hostname: this.cloudflaredConfig?.hostname || '',
|
|
1341
|
-
// issue #71 P1-B:网络参数白名单(空串 = 未设置 = cloudflared 默认行为)
|
|
1342
|
-
protocol: this.cloudflaredConfig?.protocol || '',
|
|
1343
|
-
edgeIpVersion: this.cloudflaredConfig?.edgeIpVersion || '',
|
|
1344
|
-
region: this.cloudflaredConfig?.region || '',
|
|
1345
|
-
autoStart: Boolean(this.cloudflaredConfig?.autoStart),
|
|
1346
|
-
// issue #71:公网入口端到端探测(DNS/TLS/边缘/回源)、实际二进制画像、
|
|
1347
|
-
// 同 token 的外部 cloudflared 命中结果
|
|
1348
|
-
dataPlane: cfDataPlane,
|
|
1349
|
-
binary: cfBinary,
|
|
1350
|
-
foreignRunner: this.foreignCloudflared,
|
|
1351
|
-
},
|
|
1352
|
-
|
|
1353
|
-
customTunnel: {
|
|
1354
|
-
// self:serverUrl+accessToken;saas:网关地址+设备凭证。两者都表示"配置齐全可连接"
|
|
1355
|
-
configured: tunnelMode === 'saas'
|
|
1356
|
-
? Boolean(
|
|
1357
|
-
this.customTunnelConfig?.gatewayUrl
|
|
1358
|
-
&& this.customTunnelConfig?.deviceId
|
|
1359
|
-
&& this.customTunnelConfig?.deviceSecret,
|
|
1360
|
-
)
|
|
1361
|
-
: Boolean(this.customTunnelConfig?.serverUrl && this.customTunnelConfig?.accessToken),
|
|
1362
|
-
mode: tunnelMode,
|
|
1363
|
-
// serverUrl 是管理员配置的**出站连接目标**,URL 里可能内嵌 Basic Auth(ws 会把
|
|
1364
|
-
// userinfo 转成 Authorization 头,见 node_modules/ws/lib/websocket.js:803),
|
|
1365
|
-
// 与 externalTunnel 同策略:仅管理员视图返回,访客只保留 configured/running 等布尔位。
|
|
1366
|
-
serverUrl: adminAuthValid ? (this.customTunnelConfig?.serverUrl ?? '') : '',
|
|
1367
|
-
running: !!this.customTunnel?.connected,
|
|
1368
|
-
sseStreaming: Boolean(this.customTunnelConfig?.sseStreaming),
|
|
1369
|
-
url: customUrl,
|
|
1370
|
-
rawUrl: baseCustomUrl,
|
|
1371
|
-
qr: customUrl
|
|
1372
|
-
? await this.qrCache.get(customUrl)
|
|
1373
|
-
: null,
|
|
1374
|
-
state: this.customTunnelState,
|
|
1375
|
-
autoStart: Boolean(this.customTunnelConfig?.autoStart),
|
|
1376
|
-
},
|
|
1377
|
-
|
|
1378
|
-
// SaaS 隧道(契约 docs/architecture/saas-tunnel.md §4/§6):
|
|
1379
|
-
// bound/online 是面板与外部集成可直接消费的布尔位;tunnel 永远是对象,
|
|
1380
|
-
// deviceSecret 只回显掩码,原文仅留在 config.json(0600)。
|
|
1381
|
-
saas: saasStatus,
|
|
1382
|
-
|
|
1383
|
-
// 外部已部署隧道(用户自行 Docker/二进制部署,插件仅登记展示)
|
|
1384
|
-
// 该 URL 由用户自由填写,可能内嵌 Basic Auth 凭据(https://user:pass@host),
|
|
1385
|
-
// 二维码等价于同一串凭据。因此与 cloudflared.token 采用同一策略:仅管理员可见;
|
|
1386
|
-
// 非管理员只保留"是否已登记",不回传 URL 与二维码(UI 在锁屏时本就看不到)。
|
|
1387
|
-
externalTunnel: {
|
|
1388
|
-
configured: !!this.externalTunnelConfig?.url,
|
|
1389
|
-
url: adminAuthValid ? (this.externalTunnelConfig?.url ?? '') : '',
|
|
1390
|
-
qr: adminAuthValid && this.externalTunnelConfig?.url
|
|
1391
|
-
? await this.qrCache.get(this.externalTunnelConfig.url)
|
|
1392
|
-
: null,
|
|
1393
|
-
},
|
|
1394
|
-
|
|
1395
|
-
// 宿主系统运行监控指标
|
|
1396
|
-
system: this.getSystemMetrics(),
|
|
1397
|
-
};
|
|
1398
|
-
}
|
|
1399
|
-
|
|
1400
|
-
/**
|
|
1401
|
-
* SaaS 隧道状态(供 getStatus().saas 与 saasGetStatus RPC 共用)。
|
|
1402
|
-
* @param {{adminAuthValid?: boolean}} [options]
|
|
1403
|
-
* @returns {{mode: string, configured: boolean, gatewayUrl: string, bound: boolean, deviceId: string,
|
|
1404
|
-
* deviceSecret: string, online: boolean, tunnel: {publicId: string, name: string, url: string},
|
|
1405
|
-
* lastError: string|null, pendingCode: object|null, state: object, protocol: string, autoStart: boolean}}
|
|
1406
|
-
*/
|
|
1407
|
-
getSaasStatus({ adminAuthValid = false } = {}) {
|
|
1408
|
-
const config = this.customTunnelConfig ?? {};
|
|
1409
|
-
const mode = normalizeCustomTunnelMode(config.mode);
|
|
1410
|
-
const deviceId = String(config.deviceId ?? '');
|
|
1411
|
-
const secret = String(config.deviceSecret ?? '');
|
|
1412
|
-
const liveTunnel = this.customTunnel?.tunnel ?? null;
|
|
1413
|
-
const cachedTunnel = config.tunnel ?? {};
|
|
1414
|
-
const cachedRoutes = Array.isArray(config.routes) ? config.routes : [];
|
|
1415
|
-
const liveUrl = this.customTunnel?.publicUrl || null;
|
|
1416
|
-
const cachedUrl = pickRoutePublicUrl(cachedRoutes);
|
|
1417
|
-
const tunnel = {
|
|
1418
|
-
publicId: String(liveTunnel?.publicId ?? cachedTunnel.publicId ?? ''),
|
|
1419
|
-
name: String(liveTunnel?.name ?? cachedTunnel.name ?? ''),
|
|
1420
|
-
url: liveUrl || cachedUrl || '',
|
|
1421
|
-
};
|
|
1422
|
-
const pending = this.saasPendingCode
|
|
1423
|
-
? {
|
|
1424
|
-
code: this.saasPendingCode.code,
|
|
1425
|
-
expiresAt: this.saasPendingCode.expiresAt ?? null,
|
|
1426
|
-
verificationUrl: this.saasPendingCode.verificationUrl ?? '',
|
|
1427
|
-
status: this.saasPendingCode.status ?? 'pending',
|
|
334
|
+
default:
|
|
335
|
+
return fail('bad-request', `未知 RPC 端点: ${endpoint}`);
|
|
1428
336
|
}
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
configured: Boolean(config.gatewayUrl),
|
|
1433
|
-
gatewayUrl: adminAuthValid ? String(config.gatewayUrl ?? '') : '',
|
|
1434
|
-
bound: Boolean(deviceId && secret),
|
|
1435
|
-
// 设备 id 不是秘密,但访客视图同样收敛(与 serverUrl 同策略)
|
|
1436
|
-
deviceId: adminAuthValid ? deviceId : (deviceId ? `${deviceId.slice(0, 6)}…` : ''),
|
|
1437
|
-
deviceSecret: secret ? maskDeviceSecret(secret) : '',
|
|
1438
|
-
online: mode === 'saas' && Boolean(this.customTunnel?.connected),
|
|
1439
|
-
tunnel,
|
|
1440
|
-
lastError: this.saasLastError || null,
|
|
1441
|
-
pendingCode: pending,
|
|
1442
|
-
state: this.customTunnelState,
|
|
1443
|
-
protocol: normalizeCustomTunnelProtocol(config.protocol),
|
|
1444
|
-
autoStart: Boolean(config.autoStart),
|
|
1445
|
-
};
|
|
1446
|
-
}
|
|
1447
|
-
|
|
1448
|
-
async saveCloudflaredConfig({ token, hostname, protocol, edgeIpVersion, region } = {}) {
|
|
1449
|
-
const prev = this.cloudflaredConfig ?? {};
|
|
1450
|
-
const next = { ...prev };
|
|
1451
|
-
// undefined = 客户端未修改不上传;'******' = 非管理员视图的掩码回显。
|
|
1452
|
-
// 两者都保留现值,防止真实 Token 被掩码覆盖;仅显式字符串(含空串=清除)才变更。
|
|
1453
|
-
if (token !== undefined) next.token = token === '******' ? (prev.token ?? '') : String(token).trim();
|
|
1454
|
-
if (hostname !== undefined) next.hostname = String(hostname).trim();
|
|
1455
|
-
// 网络参数(issue #71 P1-B):undefined = 不修改;空串 = 清除;非法值直接拒绝。
|
|
1456
|
-
const netResult = validateCloudflaredNetworkOptions({ protocol, edgeIpVersion, region });
|
|
1457
|
-
if (!netResult.ok) throw new Error(Object.values(netResult.errors).join(';'));
|
|
1458
|
-
const netInput = { protocol, edgeIpVersion, region };
|
|
1459
|
-
for (const field of CLOUDFLARED_NETWORK_FIELDS) {
|
|
1460
|
-
if (netInput[field] !== undefined) next[field] = netResult.values[field];
|
|
1461
|
-
}
|
|
1462
|
-
this.cloudflaredConfig = next;
|
|
1463
|
-
// 数据面探针跟随新域名(不重启隧道,下一次探测即按新入口)
|
|
1464
|
-
if (this.cloudflared && next.hostname !== undefined) this.cloudflared.setHostname(next.hostname);
|
|
1465
|
-
await this.onPersist?.({ cloudflared: this.cloudflaredConfig });
|
|
1466
|
-
}
|
|
1467
|
-
|
|
1468
|
-
// 登记用户自行部署的外部隧道(Docker cloudflared 等)。插件不下载/不管理该隧道,
|
|
1469
|
-
// 仅保存公网地址用于面板展示二维码/URL 并放行 CORS。url 传空串则清除登记。
|
|
1470
|
-
async saveExternalTunnel({ url } = {}) {
|
|
1471
|
-
const trimmed = url ? String(url).trim() : '';
|
|
1472
|
-
if (trimmed) {
|
|
1473
|
-
// 仅接受 http/https 公网地址,防止注入任意协议或本地路径
|
|
1474
|
-
const normalized = normalizeExternalTunnelUrl(trimmed);
|
|
1475
|
-
if (!normalized) throw new Error('请输入合法的公网隧道地址(https://...)');
|
|
1476
|
-
this.externalTunnelConfig = { url: normalized };
|
|
1477
|
-
} else {
|
|
1478
|
-
this.externalTunnelConfig = null;
|
|
1479
|
-
}
|
|
1480
|
-
await this.onPersist?.({ externalTunnel: this.externalTunnelConfig });
|
|
1481
|
-
}
|
|
1482
|
-
|
|
1483
|
-
async setTunnelAutoStart({ tunnel, autoStart }) {
|
|
1484
|
-
const isAuto = Boolean(autoStart);
|
|
1485
|
-
if (tunnel === 'cloudflared') {
|
|
1486
|
-
this.cloudflaredConfig = {
|
|
1487
|
-
...(this.cloudflaredConfig ?? {}),
|
|
1488
|
-
autoStart: isAuto,
|
|
1489
|
-
};
|
|
1490
|
-
await this.onPersist?.({ cloudflared: this.cloudflaredConfig });
|
|
1491
|
-
} else if (tunnel === 'customTunnel' || tunnel === 'custom') {
|
|
1492
|
-
this.customTunnelConfig = {
|
|
1493
|
-
...(this.customTunnelConfig ?? {}),
|
|
1494
|
-
autoStart: isAuto,
|
|
1495
|
-
};
|
|
1496
|
-
await this.onPersist?.({ customTunnel: this.customTunnelConfig });
|
|
337
|
+
} catch (err) {
|
|
338
|
+
lastError = err?.message ?? String(err);
|
|
339
|
+
return errorPayload(err);
|
|
1497
340
|
}
|
|
1498
|
-
}
|
|
341
|
+
};
|
|
1499
342
|
|
|
1500
|
-
|
|
1501
|
-
// 运行中的隧道不允许重复开启。error/closed 态残留的旧引用先清理:
|
|
1502
|
-
// 否则用户点「开启」只会拿到"已在运行",且无法从终态自愈。
|
|
1503
|
-
if (this.customTunnel) {
|
|
1504
|
-
if (this.customTunnel.connected) {
|
|
1505
|
-
throw new Error('自建隧道已在运行');
|
|
1506
|
-
}
|
|
1507
|
-
this.customTunnel.disconnect();
|
|
1508
|
-
this.customTunnel = null;
|
|
1509
|
-
this.customTunnelState = { phase: 'idle', detail: '' };
|
|
1510
|
-
}
|
|
343
|
+
const disposeRpc = registerRpc(ctx, rpcHandler, logger);
|
|
1511
344
|
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
return this._startSelfTunnel({ autoStart });
|
|
345
|
+
if (config.deviceSecret && config.autoStart && gate.passwordConfigured()) {
|
|
346
|
+
ensureConnected().catch((err) => { lastError = err?.message ?? String(err); });
|
|
1515
347
|
}
|
|
1516
348
|
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
if (!serverUrl || !accessToken) {
|
|
1526
|
-
throw new Error('缺少配置:请在控制台配置 customTunnel.serverUrl 和 customTunnel.accessToken');
|
|
1527
|
-
}
|
|
1528
|
-
|
|
1529
|
-
this.customTunnelConfig = {
|
|
1530
|
-
...(this.customTunnelConfig ?? {}),
|
|
1531
|
-
mode: 'self',
|
|
1532
|
-
autoStart: Boolean(autoStart),
|
|
1533
|
-
};
|
|
1534
|
-
await this.onPersist?.({ customTunnel: this.customTunnelConfig });
|
|
349
|
+
const dispose = async () => {
|
|
350
|
+
clearPairing();
|
|
351
|
+
disposeRpc();
|
|
352
|
+
await stopTunnel();
|
|
353
|
+
await gate.stop();
|
|
354
|
+
};
|
|
355
|
+
try { ctx.effect?.(() => dispose, `${PLUGIN_ID}: lifecycle`); } catch {}
|
|
1535
356
|
|
|
1536
|
-
|
|
1537
|
-
serverUrl,
|
|
1538
|
-
accessToken,
|
|
1539
|
-
localPort: this.proxyPort,
|
|
1540
|
-
internalTunnelSecret: this.authManager?.internalTunnelSecret,
|
|
1541
|
-
sseStreaming: Boolean(this.customTunnelConfig?.sseStreaming),
|
|
1542
|
-
onStateChange: (state) => {
|
|
1543
|
-
this.customTunnelState = state;
|
|
1544
|
-
},
|
|
1545
|
-
logger: this.logger,
|
|
1546
|
-
});
|
|
1547
|
-
|
|
1548
|
-
try {
|
|
1549
|
-
await this.customTunnel.connect();
|
|
1550
|
-
} catch (err) {
|
|
1551
|
-
// 启动失败不留僵尸:断开(阻止其后台重连计时器)并清空引用,用户可立即重试
|
|
1552
|
-
this.customTunnel.disconnect();
|
|
1553
|
-
this.customTunnel = null;
|
|
1554
|
-
this.customTunnelState = { phase: 'error', detail: err.message };
|
|
1555
|
-
throw err;
|
|
1556
|
-
}
|
|
1557
|
-
}
|
|
1558
|
-
|
|
1559
|
-
/**
|
|
1560
|
-
* SaaS 启动路径:先用 deviceSecret 换短期 token,再建 v2 控制通道。
|
|
1561
|
-
* @param {{autoStart?: boolean}} [options]
|
|
1562
|
-
*/
|
|
1563
|
-
async _startSaasTunnel({ autoStart = true } = {}) {
|
|
1564
|
-
const config = this.customTunnelConfig ?? {};
|
|
1565
|
-
const gatewayUrl = config.gatewayUrl ? normalizeSaasGatewayUrl(config.gatewayUrl) : '';
|
|
1566
|
-
if (!gatewayUrl) {
|
|
1567
|
-
throw new Error('缺少配置:请先在「公网隧道 → SaaS 账号」中填写 SaaS 网关地址');
|
|
1568
|
-
}
|
|
1569
|
-
if (!config.deviceId || !config.deviceSecret) {
|
|
1570
|
-
throw new Error('设备尚未绑定:请先在「公网隧道 → SaaS 账号」中完成设备码绑定');
|
|
1571
|
-
}
|
|
1572
|
-
|
|
1573
|
-
this.customTunnelConfig = {
|
|
1574
|
-
...config,
|
|
1575
|
-
mode: 'saas',
|
|
1576
|
-
gatewayUrl,
|
|
1577
|
-
autoStart: Boolean(autoStart),
|
|
1578
|
-
};
|
|
1579
|
-
await this.onPersist?.({ customTunnel: this.customTunnelConfig });
|
|
1580
|
-
|
|
1581
|
-
this.saasLastError = null;
|
|
1582
|
-
this._saasTokenCache = null; // 启动强制换新令牌,避免使用过期缓存
|
|
1583
|
-
let tokenInfo;
|
|
1584
|
-
try {
|
|
1585
|
-
tokenInfo = await this._ensureSaasToken({ force: true });
|
|
1586
|
-
} catch (err) {
|
|
1587
|
-
const detail = err?.message ?? String(err);
|
|
1588
|
-
this.customTunnelState = { phase: 'error', detail };
|
|
1589
|
-
this.saasLastError = detail;
|
|
1590
|
-
throw err;
|
|
1591
|
-
}
|
|
1592
|
-
|
|
1593
|
-
const deviceInfo = await this._resolveSaasDeviceInfo();
|
|
1594
|
-
const client = new SaasTunnelClient({
|
|
1595
|
-
// token 接口会下发数据面 gateway(url+path);缺省时由控制面地址推导(单端口部署)
|
|
1596
|
-
gatewayUrl: tokenInfo.gateway?.url || deriveSaasGatewayConnectUrl(gatewayUrl),
|
|
1597
|
-
gatewayPath: tokenInfo.gateway?.path || '/_gw/connect',
|
|
1598
|
-
tokenProvider: async () => {
|
|
1599
|
-
const info = await this._ensureSaasToken();
|
|
1600
|
-
return { token: info.token, gateway: info.gateway, tunnel: info.tunnel };
|
|
1601
|
-
},
|
|
1602
|
-
deviceInfo,
|
|
1603
|
-
protocol: normalizeCustomTunnelProtocol(this.customTunnelConfig?.protocol),
|
|
1604
|
-
heartbeatIntervalMs: resolveSaasHeartbeatMs(this.customTunnelConfig?.onlineHeartbeat),
|
|
1605
|
-
localPort: this.proxyPort,
|
|
1606
|
-
internalTunnelSecret: this.authManager?.internalTunnelSecret,
|
|
1607
|
-
sseStreaming: Boolean(this.customTunnelConfig?.sseStreaming),
|
|
1608
|
-
onStateChange: (state) => {
|
|
1609
|
-
this.customTunnelState = state;
|
|
1610
|
-
if (state?.phase === 'ready') {
|
|
1611
|
-
this.saasLastError = null;
|
|
1612
|
-
} else if (state?.phase === 'error' && state.detail) {
|
|
1613
|
-
this.saasLastError = state.detail;
|
|
1614
|
-
}
|
|
1615
|
-
},
|
|
1616
|
-
// hello-ack / route-update 下发的隧道身份与路由是只读缓存,落盘供离线展示
|
|
1617
|
-
onHelloAck: (ack) => {
|
|
1618
|
-
const patch = {};
|
|
1619
|
-
if (ack?.tunnel) {
|
|
1620
|
-
patch.tunnel = {
|
|
1621
|
-
publicId: String(ack.tunnel.publicId ?? ''),
|
|
1622
|
-
name: String(ack.tunnel.name ?? ''),
|
|
1623
|
-
};
|
|
1624
|
-
}
|
|
1625
|
-
if (Array.isArray(ack?.routes)) patch.routes = ack.routes;
|
|
1626
|
-
if (Object.keys(patch).length > 0) void this._persistSaasTunnelCache(patch);
|
|
1627
|
-
},
|
|
1628
|
-
onRouteUpdate: ({ routes }) => {
|
|
1629
|
-
void this._persistSaasTunnelCache({ routes: Array.isArray(routes) ? routes : [] });
|
|
1630
|
-
},
|
|
1631
|
-
logger: this.logger,
|
|
1632
|
-
});
|
|
1633
|
-
this.customTunnel = client;
|
|
1634
|
-
|
|
1635
|
-
try {
|
|
1636
|
-
await client.connect();
|
|
1637
|
-
} catch (err) {
|
|
1638
|
-
client.disconnect();
|
|
1639
|
-
this.customTunnel = null;
|
|
1640
|
-
const detail = err?.message ?? String(err);
|
|
1641
|
-
this.customTunnelState = { phase: 'error', detail };
|
|
1642
|
-
this.saasLastError = detail;
|
|
1643
|
-
throw err;
|
|
1644
|
-
}
|
|
1645
|
-
}
|
|
1646
|
-
|
|
1647
|
-
/** SaaS 设备信息(hello.device):惰性探测 DSH 版本并缓存。 */
|
|
1648
|
-
async _resolveSaasDeviceInfo() {
|
|
1649
|
-
if (this._saasDeviceInfo) return this._saasDeviceInfo;
|
|
1650
|
-
let dshVersion;
|
|
1651
|
-
try {
|
|
1652
|
-
dshVersion = await this.getDshVersion();
|
|
1653
|
-
} catch {
|
|
1654
|
-
dshVersion = undefined;
|
|
1655
|
-
}
|
|
1656
|
-
this._saasDeviceInfo = {
|
|
1657
|
-
name: hostname() || 'DSH Bridge',
|
|
1658
|
-
platform: process.platform,
|
|
1659
|
-
hostname: hostname(),
|
|
1660
|
-
arch: process.arch,
|
|
1661
|
-
pluginVersion: VERSION,
|
|
1662
|
-
...(dshVersion ? { dshVersion } : {}),
|
|
1663
|
-
};
|
|
1664
|
-
return this._saasDeviceInfo;
|
|
1665
|
-
}
|
|
1666
|
-
|
|
1667
|
-
/** 持久化 hello-ack / route-update 的路由缓存(失败只告警,不影响连接)。 */
|
|
1668
|
-
async _persistSaasTunnelCache(patch) {
|
|
1669
|
-
if (!patch || typeof patch !== 'object') return;
|
|
1670
|
-
this.customTunnelConfig = { ...(this.customTunnelConfig ?? {}), ...patch };
|
|
1671
|
-
try {
|
|
1672
|
-
await this.onPersist?.({ customTunnel: this.customTunnelConfig });
|
|
1673
|
-
} catch (err) {
|
|
1674
|
-
this.logger?.warn?.('dsh-bridge: 持久化 SaaS 隧道缓存失败: %s', err?.message ?? err);
|
|
1675
|
-
}
|
|
1676
|
-
}
|
|
1677
|
-
|
|
1678
|
-
/**
|
|
1679
|
-
* 取短期 deviceToken(缓存至过期前 60s)。token 接口同时下发 tunnel/gateway,
|
|
1680
|
-
* 是契约里的"只读缓存,以 hello-ack 为准"。
|
|
1681
|
-
* @param {{force?: boolean}} [options]
|
|
1682
|
-
*/
|
|
1683
|
-
async _ensureSaasToken({ force = false } = {}) {
|
|
1684
|
-
const cached = this._saasTokenCache;
|
|
1685
|
-
if (!force && cached?.token && cached.expiresAtMs && cached.expiresAtMs - Date.now() > 60_000) {
|
|
1686
|
-
return cached;
|
|
1687
|
-
}
|
|
1688
|
-
const config = this.customTunnelConfig ?? {};
|
|
1689
|
-
const info = await refreshDeviceToken({
|
|
1690
|
-
gatewayUrl: config.gatewayUrl,
|
|
1691
|
-
deviceId: config.deviceId,
|
|
1692
|
-
secret: config.deviceSecret,
|
|
1693
|
-
});
|
|
1694
|
-
const next = {
|
|
1695
|
-
token: info.token,
|
|
1696
|
-
expiresAt: info.expiresAt ?? null,
|
|
1697
|
-
expiresAtMs: info.expiresAtMs ?? (Date.now() + 9 * 60_000),
|
|
1698
|
-
tunnel: info.tunnel ?? config.tunnel ?? null,
|
|
1699
|
-
gateway: info.gateway ?? config.gateway ?? null,
|
|
1700
|
-
fetchedAt: Date.now(),
|
|
1701
|
-
};
|
|
1702
|
-
this._saasTokenCache = next;
|
|
1703
|
-
const patch = {};
|
|
1704
|
-
if (next.tunnel) patch.tunnel = next.tunnel;
|
|
1705
|
-
if (next.gateway) patch.gateway = next.gateway;
|
|
1706
|
-
if (Object.keys(patch).length > 0) await this._persistSaasTunnelCache(patch);
|
|
1707
|
-
return next;
|
|
1708
|
-
}
|
|
1709
|
-
|
|
1710
|
-
/**
|
|
1711
|
-
* RPC:申请设备码并后台轮询(POST /api/saas/device/code → 轮询 → claim)。
|
|
1712
|
-
* 立即返回 pending 状态;用户控制台确认后由后台等待器自动落盘 deviceSecret 并可选自动连接。
|
|
1713
|
-
* @param {{gatewayUrl?: string}} [options]
|
|
1714
|
-
*/
|
|
1715
|
-
async requestSaasDeviceCode({ gatewayUrl } = {}) {
|
|
1716
|
-
if (gatewayUrl !== undefined) await this._saveSaasGatewayUrl(gatewayUrl);
|
|
1717
|
-
const config = this.customTunnelConfig ?? {};
|
|
1718
|
-
if (!config.gatewayUrl) {
|
|
1719
|
-
throw new Error('缺少配置:请先填写 SaaS 网关地址');
|
|
1720
|
-
}
|
|
1721
|
-
this._cancelSaasBindWaiter();
|
|
1722
|
-
const deviceInfo = await this._resolveSaasDeviceInfo();
|
|
1723
|
-
const created = await requestDeviceCode({ gatewayUrl: config.gatewayUrl, deviceInfo });
|
|
1724
|
-
this.saasLastError = null;
|
|
1725
|
-
this.saasPendingCode = {
|
|
1726
|
-
code: created.code,
|
|
1727
|
-
expiresAt: created.expiresAt,
|
|
1728
|
-
expiresAtMs: created.expiresAtMs,
|
|
1729
|
-
verificationUrl: created.verificationUrl,
|
|
1730
|
-
status: 'pending',
|
|
1731
|
-
createdAt: Date.now(),
|
|
1732
|
-
};
|
|
1733
|
-
const timeoutMs = created.expiresAtMs
|
|
1734
|
-
? Math.max(1_000, created.expiresAtMs - Date.now())
|
|
1735
|
-
: undefined;
|
|
1736
|
-
this._startSaasBindPolling({ code: created.code, timeoutMs });
|
|
1737
|
-
return this.getSaasStatus({ adminAuthValid: true });
|
|
1738
|
-
}
|
|
1739
|
-
|
|
1740
|
-
/**
|
|
1741
|
-
* RPC:单次检查设备码并绑定。approved → 立即 claim;pending → 继续后台轮询。
|
|
1742
|
-
* @param {{code?: string, gatewayUrl?: string}} [options]
|
|
1743
|
-
*/
|
|
1744
|
-
async bindSaasDevice({ code, gatewayUrl } = {}) {
|
|
1745
|
-
if (gatewayUrl !== undefined) await this._saveSaasGatewayUrl(gatewayUrl);
|
|
1746
|
-
const config = this.customTunnelConfig ?? {};
|
|
1747
|
-
if (!config.gatewayUrl) throw new Error('缺少配置:请先填写 SaaS 网关地址');
|
|
1748
|
-
const pendingCode = String(code ?? this.saasPendingCode?.code ?? '').trim();
|
|
1749
|
-
if (!pendingCode) throw new Error('请先生成设备码,或输入控制台显示的设备码');
|
|
1750
|
-
|
|
1751
|
-
const status = await fetchDeviceCodeStatus({ gatewayUrl: config.gatewayUrl, code: pendingCode });
|
|
1752
|
-
if (status.status === 'approved') {
|
|
1753
|
-
this._cancelSaasBindWaiter();
|
|
1754
|
-
const claim = await claimDeviceCode({ gatewayUrl: config.gatewayUrl, code: pendingCode });
|
|
1755
|
-
await this._applySaasClaim(claim);
|
|
1756
|
-
return this.getSaasStatus({ adminAuthValid: true });
|
|
1757
|
-
}
|
|
1758
|
-
if (status.status === 'pending') {
|
|
1759
|
-
const sameCode = this.saasPendingCode?.code === pendingCode;
|
|
1760
|
-
this.saasPendingCode = {
|
|
1761
|
-
code: pendingCode,
|
|
1762
|
-
expiresAt: status.expiresAt ?? this.saasPendingCode?.expiresAt ?? null,
|
|
1763
|
-
expiresAtMs: status.expiresAtMs ?? this.saasPendingCode?.expiresAtMs ?? null,
|
|
1764
|
-
verificationUrl: sameCode ? (this.saasPendingCode?.verificationUrl ?? '') : '',
|
|
1765
|
-
status: 'pending',
|
|
1766
|
-
createdAt: sameCode ? (this.saasPendingCode?.createdAt ?? Date.now()) : Date.now(),
|
|
1767
|
-
};
|
|
1768
|
-
const timeoutMs = status.expiresAtMs ? Math.max(1_000, status.expiresAtMs - Date.now()) : undefined;
|
|
1769
|
-
this._startSaasBindPolling({ code: pendingCode, timeoutMs });
|
|
1770
|
-
return this.getSaasStatus({ adminAuthValid: true });
|
|
1771
|
-
}
|
|
1772
|
-
if (status.status === 'expired') throw new Error('设备码已过期,请重新生成');
|
|
1773
|
-
if (status.status === 'consumed') throw new Error('设备码已被使用,请重新生成');
|
|
1774
|
-
throw new Error(`设备码状态异常: ${status.status}`);
|
|
1775
|
-
}
|
|
1776
|
-
|
|
1777
|
-
/**
|
|
1778
|
-
* RPC:解绑设备。停止隧道并清除 deviceId/deviceSecret/路由缓存;保留网关地址与 mode。
|
|
1779
|
-
*/
|
|
1780
|
-
async unbindSaasDevice() {
|
|
1781
|
-
this._cancelSaasBindWaiter();
|
|
1782
|
-
this.saasPendingCode = null;
|
|
1783
|
-
this.saasLastError = null;
|
|
1784
|
-
this._saasTokenCache = null;
|
|
1785
|
-
await this._haltCustomTunnel();
|
|
1786
|
-
// 保留用户当前的模式选择(在 self 模式解绑也不应被强制切到 saas)
|
|
1787
|
-
const config = { ...(this.customTunnelConfig ?? {}) };
|
|
1788
|
-
for (const key of ['deviceId', 'deviceSecret', 'tunnel', 'routes', 'gateway', 'deviceBoundAt']) {
|
|
1789
|
-
delete config[key];
|
|
1790
|
-
}
|
|
1791
|
-
this.customTunnelConfig = config;
|
|
1792
|
-
await this.onPersist?.({ customTunnel: this.customTunnelConfig });
|
|
1793
|
-
return this.getSaasStatus({ adminAuthValid: true });
|
|
1794
|
-
}
|
|
1795
|
-
|
|
1796
|
-
/** 保存并归一化 SaaS 网关地址(设备码/令牌接口的基址)。 */
|
|
1797
|
-
async _saveSaasGatewayUrl(value) {
|
|
1798
|
-
const normalized = normalizeSaasGatewayUrl(value);
|
|
1799
|
-
if (normalized === undefined) return;
|
|
1800
|
-
this.customTunnelConfig = {
|
|
1801
|
-
...(this.customTunnelConfig ?? {}),
|
|
1802
|
-
gatewayUrl: normalized,
|
|
1803
|
-
};
|
|
1804
|
-
this._saasTokenCache = null;
|
|
1805
|
-
await this.onPersist?.({ customTunnel: this.customTunnelConfig });
|
|
1806
|
-
}
|
|
1807
|
-
|
|
1808
|
-
/** 后台等待设备码被控制台确认 → claim → 落盘(不阻塞 RPC)。 */
|
|
1809
|
-
_startSaasBindPolling({ code, timeoutMs } = {}) {
|
|
1810
|
-
this._cancelSaasBindWaiter();
|
|
1811
|
-
const gatewayUrl = this.customTunnelConfig?.gatewayUrl;
|
|
1812
|
-
if (!gatewayUrl || !code) return;
|
|
1813
|
-
const controller = new AbortController();
|
|
1814
|
-
const waiter = { controller, code, finished: false };
|
|
1815
|
-
this._saasBindWaiter = waiter;
|
|
1816
|
-
const run = async () => {
|
|
1817
|
-
try {
|
|
1818
|
-
const claim = await waitForDeviceClaim({
|
|
1819
|
-
gatewayUrl,
|
|
1820
|
-
code,
|
|
1821
|
-
signal: controller.signal,
|
|
1822
|
-
...(timeoutMs ? { timeoutMs } : {}),
|
|
1823
|
-
onStatus: (status) => {
|
|
1824
|
-
if (this.saasPendingCode?.code === code) this.saasPendingCode.status = status;
|
|
1825
|
-
},
|
|
1826
|
-
});
|
|
1827
|
-
if (waiter.finished) return;
|
|
1828
|
-
waiter.finished = true;
|
|
1829
|
-
await this._applySaasClaim(claim);
|
|
1830
|
-
} catch (err) {
|
|
1831
|
-
if (waiter.finished || controller.signal.aborted) return;
|
|
1832
|
-
waiter.finished = true;
|
|
1833
|
-
const detail = err?.message ?? String(err);
|
|
1834
|
-
this.saasLastError = detail;
|
|
1835
|
-
if (this.saasPendingCode?.code === code) this.saasPendingCode.status = 'error';
|
|
1836
|
-
this.logger?.warn?.('dsh-bridge: SaaS 设备码绑定失败: %s', detail);
|
|
1837
|
-
} finally {
|
|
1838
|
-
if (this._saasBindWaiter === waiter) this._saasBindWaiter = null;
|
|
1839
|
-
}
|
|
1840
|
-
};
|
|
1841
|
-
run().catch(() => {});
|
|
1842
|
-
}
|
|
1843
|
-
|
|
1844
|
-
/** 领取成功:把 deviceSecret 写入 config.json(updateConfig → writeConfig 0600)。 */
|
|
1845
|
-
async _applySaasClaim(claim) {
|
|
1846
|
-
this._cancelSaasBindWaiter();
|
|
1847
|
-
// 重新绑定沿用旧 autoStart 意图;清理上一台设备的路由缓存
|
|
1848
|
-
const config = { ...(this.customTunnelConfig ?? {}), mode: 'saas' };
|
|
1849
|
-
delete config.routes;
|
|
1850
|
-
delete config.gateway;
|
|
1851
|
-
config.deviceId = claim.deviceId;
|
|
1852
|
-
config.deviceSecret = claim.secret;
|
|
1853
|
-
config.tunnel = claim.tunnel ?? null;
|
|
1854
|
-
config.deviceBoundAt = new Date().toISOString();
|
|
1855
|
-
this.customTunnelConfig = config;
|
|
1856
|
-
this._saasTokenCache = null;
|
|
1857
|
-
this.saasPendingCode = null;
|
|
1858
|
-
this.saasLastError = null;
|
|
1859
|
-
await this.onPersist?.({ customTunnel: this.customTunnelConfig });
|
|
1860
|
-
// 旧客户端持有的是上一台设备的令牌,必须先停掉(无论 autoStart 是否开启)
|
|
1861
|
-
if (this.customTunnel) {
|
|
1862
|
-
this.customTunnel.disconnect();
|
|
1863
|
-
this.customTunnel = null;
|
|
1864
|
-
this.customTunnelState = { phase: 'idle', detail: '' };
|
|
1865
|
-
}
|
|
1866
|
-
if (this.customTunnelConfig.autoStart) {
|
|
1867
|
-
try {
|
|
1868
|
-
await this.startCustomTunnel({ autoStart: true });
|
|
1869
|
-
} catch (err) {
|
|
1870
|
-
this.saasLastError = err?.message ?? String(err);
|
|
1871
|
-
this.logger?.warn?.('dsh-bridge: SaaS 绑定后自动启动失败: %s', this.saasLastError);
|
|
1872
|
-
}
|
|
1873
|
-
}
|
|
1874
|
-
return this.getSaasStatus({ adminAuthValid: true });
|
|
1875
|
-
}
|
|
1876
|
-
|
|
1877
|
-
/** 取消后台设备码等待(不改变已保存的绑定状态)。 */
|
|
1878
|
-
_cancelSaasBindWaiter() {
|
|
1879
|
-
const waiter = this._saasBindWaiter;
|
|
1880
|
-
this._saasBindWaiter = null;
|
|
1881
|
-
if (!waiter) return;
|
|
1882
|
-
waiter.finished = true;
|
|
1883
|
-
try { waiter.controller.abort(); } catch {}
|
|
1884
|
-
}
|
|
1885
|
-
|
|
1886
|
-
async stopCustomTunnel() {
|
|
1887
|
-
if (this.customTunnel) {
|
|
1888
|
-
this.customTunnel.disconnect();
|
|
1889
|
-
this.customTunnel = null;
|
|
1890
|
-
this.customTunnelState = { phase: 'idle', detail: '' };
|
|
1891
|
-
}
|
|
1892
|
-
this.customTunnelConfig = {
|
|
1893
|
-
...(this.customTunnelConfig ?? {}),
|
|
1894
|
-
autoStart: false,
|
|
1895
|
-
};
|
|
1896
|
-
await this.onPersist?.({ customTunnel: this.customTunnelConfig });
|
|
1897
|
-
}
|
|
1898
|
-
|
|
1899
|
-
// 仅断开与状态,不动持久化配置(dispose 路径用)
|
|
1900
|
-
async _haltCustomTunnel() {
|
|
1901
|
-
// 设备码等待器属于运行态:插件卸载/切模式/解绑时必须一并取消,避免后台永久轮询
|
|
1902
|
-
this._cancelSaasBindWaiter();
|
|
1903
|
-
if (this.customTunnel) {
|
|
1904
|
-
this.customTunnel.disconnect();
|
|
1905
|
-
this.customTunnel = null;
|
|
1906
|
-
this.customTunnelState = { phase: 'idle', detail: '' };
|
|
1907
|
-
}
|
|
1908
|
-
}
|
|
1909
|
-
|
|
1910
|
-
async startCloudflared({ autoStart = true } = {}) {
|
|
1911
|
-
// 活动中的隧道不允许重复开启;error / idle(引用保留但已停止)允许重建。
|
|
1912
|
-
if (this.cloudflared && this.isCloudflaredActive()) {
|
|
1913
|
-
throw new Error('Cloudflare 隧道已在运行');
|
|
1914
|
-
}
|
|
1915
|
-
|
|
1916
|
-
this.cloudflaredConfig = {
|
|
1917
|
-
...(this.cloudflaredConfig ?? {}),
|
|
1918
|
-
autoStart: Boolean(autoStart),
|
|
1919
|
-
};
|
|
1920
|
-
await this.onPersist?.({ cloudflared: this.cloudflaredConfig });
|
|
1921
|
-
|
|
1922
|
-
// 若存在旧 manager(error/自愈中断留的引用),先彻底停止再重建,杜绝双跑
|
|
1923
|
-
if (this.cloudflared) {
|
|
1924
|
-
this.cloudflared.stop();
|
|
1925
|
-
this.cloudflared = null;
|
|
1926
|
-
}
|
|
1927
|
-
|
|
1928
|
-
this.cloudflaredState = { phase: 'connecting', detail: '正在初始化...' };
|
|
1929
|
-
this.cloudflared = new CloudflaredManager({
|
|
1930
|
-
port: this.proxyPort,
|
|
1931
|
-
home: this.home,
|
|
1932
|
-
token: this.cloudflaredConfig?.token,
|
|
1933
|
-
hostname: this.cloudflaredConfig?.hostname,
|
|
1934
|
-
// 网络参数(issue #71 P1-B):经白名单校验的 protocol/edgeIpVersion/region
|
|
1935
|
-
network: {
|
|
1936
|
-
protocol: this.cloudflaredConfig?.protocol ?? '',
|
|
1937
|
-
edgeIpVersion: this.cloudflaredConfig?.edgeIpVersion ?? '',
|
|
1938
|
-
region: this.cloudflaredConfig?.region ?? '',
|
|
1939
|
-
},
|
|
1940
|
-
// cloudflared 自身的 stdout/stderr 落盘:事故时 journal 里没有任何连接器日志,
|
|
1941
|
-
// 导致断连时间点与原因完全无法定位(内含 Token 脱敏与 5MB 轮转)。
|
|
1942
|
-
logFilePath: join(this.home ?? join(homedir(), '.dsh-bridge'), CLOUDFLARED_LOG_NAME),
|
|
1943
|
-
onStateChange: (state) => {
|
|
1944
|
-
this.cloudflaredState = state;
|
|
1945
|
-
// 注意:不在 error 时清空 this.cloudflared 引用。
|
|
1946
|
-
// manager 在 error/意外退出时会自行退避重试(reconnecting),引用必须保留到
|
|
1947
|
-
// 用户显式 stop / dispose——否则自愈后 UI 失去控制句柄("关闭"杀不掉、
|
|
1948
|
-
// "开启"叠新 manager 双跑)。
|
|
1949
|
-
},
|
|
1950
|
-
logger: this.logger,
|
|
1951
|
-
});
|
|
1952
|
-
|
|
1953
|
-
// 同 token 的另一个 cloudflared 探测(issue #71 第4条):异步一次、fail-open,
|
|
1954
|
-
// 绝不阻塞启动;每次启动重扫(外部进程可能已变化)。命中时面板黄条提示"半张图"。
|
|
1955
|
-
scanSameTokenRunner({
|
|
1956
|
-
myToken: this.cloudflaredConfig?.token,
|
|
1957
|
-
getExcludePids: () => [this.cloudflared?.process?.pid].filter((x) => typeof x === 'number'),
|
|
1958
|
-
}).then((match) => {
|
|
1959
|
-
this.foreignCloudflared = match;
|
|
1960
|
-
if (match) {
|
|
1961
|
-
this.logger.warn('检测到另一个 cloudflared(PID %s,凭据来源 --%s)正在使用相同的 tunnel token:插件面板可能只反映半张图,请停用其中一侧', match.pid, match.via);
|
|
1962
|
-
}
|
|
1963
|
-
}).catch(() => {});
|
|
1964
|
-
|
|
1965
|
-
// 非阻塞启动,立即返回——下载/连接进度通过 onStateChange 推送
|
|
1966
|
-
this.cloudflared.start();
|
|
1967
|
-
}
|
|
1968
|
-
|
|
1969
|
-
// cloudflared 是否处于"活动"(进程存在或有意图运行)状态。
|
|
1970
|
-
// 引用保留期间(自愈退避中)引用仍在,但不算 running——UI 据此显示"重连中"而非"运行中"。
|
|
1971
|
-
isCloudflaredActive() {
|
|
1972
|
-
const phase = this.cloudflaredState?.phase;
|
|
1973
|
-
return phase === 'ready' || phase === 'connecting' || phase === 'downloading' || phase === 'reconnecting';
|
|
1974
|
-
}
|
|
1975
|
-
|
|
1976
|
-
// 用户显式关闭:停止进程并持久化 autoStart=false(与 UI"关闭"语义一致:关了就不该再自启)。
|
|
1977
|
-
// 内部 dispose 请走 _haltCloudflared(),避免误改用户配置。
|
|
1978
|
-
async stopCloudflared() {
|
|
1979
|
-
if (this.cloudflared) {
|
|
1980
|
-
this.cloudflared.stop();
|
|
1981
|
-
this.cloudflared = null;
|
|
1982
|
-
this.cloudflaredState = { phase: 'idle', detail: '' };
|
|
1983
|
-
}
|
|
1984
|
-
this.cloudflaredConfig = {
|
|
1985
|
-
...(this.cloudflaredConfig ?? {}),
|
|
1986
|
-
autoStart: false,
|
|
1987
|
-
};
|
|
1988
|
-
await this.onPersist?.({ cloudflared: this.cloudflaredConfig });
|
|
1989
|
-
}
|
|
1990
|
-
|
|
1991
|
-
// 仅停止进程与状态,不动持久化配置(插件卸载/重启路径用——用户的 autoStart 意图要保留)
|
|
1992
|
-
async _haltCloudflared() {
|
|
1993
|
-
if (this.cloudflared) {
|
|
1994
|
-
this.cloudflared.stop();
|
|
1995
|
-
this.cloudflared = null;
|
|
1996
|
-
this.cloudflaredState = { phase: 'idle', detail: '' };
|
|
1997
|
-
}
|
|
1998
|
-
}
|
|
1999
|
-
|
|
2000
|
-
// 重置 Cloudflare 隧道:关闭隧道 + 删除已下载的 cloudflared 二进制
|
|
2001
|
-
async resetCloudflared() {
|
|
2002
|
-
await this.stopCloudflared();
|
|
2003
|
-
const binDir = join(this.home ?? join(homedir(), '.dsh-bridge'), 'bin');
|
|
2004
|
-
const candidates = ['cloudflared.exe', 'cloudflared'];
|
|
2005
|
-
for (const name of candidates) {
|
|
2006
|
-
const p = join(binDir, name);
|
|
2007
|
-
try { await unlink(p); } catch {}
|
|
2008
|
-
}
|
|
2009
|
-
this.cloudflaredState = { phase: 'idle', detail: '' };
|
|
2010
|
-
}
|
|
2011
|
-
|
|
2012
|
-
// 检查 npm 上是否有新版本(优先国内高速镜像 npmmirror,降级 npmjs 官方源)
|
|
2013
|
-
// 探测 DSH 宿主 CLI 的安装形态,判断能否通过"npm 全局升级"自动更新。
|
|
2014
|
-
// 仅当 dsh 命令 resolve 到当前 node 同 prefix 的 node_modules/@deepseek-ai/dsh(标准 npm 全局安装)
|
|
2015
|
-
// 时才允许一键升级;pnpm / 源码 / Electron 加壳等形态无法用 npm -g 升级,仅返回提示。
|
|
2016
|
-
// @returns {Promise<{upgradable:boolean, rootGlobal?:string, dshRealPath?:string, reason?:string}>}
|
|
2017
|
-
async _probeDshUpgrade() {
|
|
2018
|
-
const isWin = process.platform === 'win32';
|
|
2019
|
-
const nodeDir = dirname(process.execPath);
|
|
2020
|
-
const existingPath = process.env.PATH || process.env.Path || '';
|
|
2021
|
-
const extraPaths = isWin ? [nodeDir] : [nodeDir, '/opt/homebrew/bin', '/opt/homebrew/sbin', '/usr/local/bin', '/usr/bin', '/bin', join(homedir(), '.local/bin'), join(homedir(), '.npm-global/bin')];
|
|
2022
|
-
const separator = isWin ? ';' : ':';
|
|
2023
|
-
const augmentedEnv = { ...process.env, PATH: [...extraPaths, existingPath].filter(Boolean).join(separator) };
|
|
2024
|
-
if (isWin) augmentedEnv.Path = augmentedEnv.PATH;
|
|
2025
|
-
|
|
2026
|
-
const runCmd = (cmd, args) => new Promise((resolve) => {
|
|
2027
|
-
let cp;
|
|
2028
|
-
try {
|
|
2029
|
-
cp = spawn(cmd, args, { windowsHide: true, shell: true, env: augmentedEnv, timeout: 8000 });
|
|
2030
|
-
} catch {
|
|
2031
|
-
return resolve(null);
|
|
2032
|
-
}
|
|
2033
|
-
let stdout = ''; let stderr = '';
|
|
2034
|
-
cp.stdout?.on('data', (d) => { stdout += d.toString(); });
|
|
2035
|
-
cp.stderr?.on('data', (d) => { stderr += d.toString(); });
|
|
2036
|
-
cp.on('error', () => resolve(null));
|
|
2037
|
-
cp.on('close', (code) => {
|
|
2038
|
-
if (code === 0 && stdout.trim()) resolve(stdout.trim().split(/\r?\n/)[0]);
|
|
2039
|
-
else resolve(null);
|
|
2040
|
-
});
|
|
2041
|
-
});
|
|
2042
|
-
|
|
2043
|
-
try {
|
|
2044
|
-
// 1. 找 dsh bin 的真实路径(Windows which 不存在,用 where)
|
|
2045
|
-
const dshBin = await runCmd(isWin ? 'where' : 'which', [isWin ? 'dsh.cmd' : 'dsh']);
|
|
2046
|
-
if (!dshBin) {
|
|
2047
|
-
return { upgradable: false, reason: '未找到 dsh 命令(可能通过源码/打包方式运行,非 npm 全局安装)' };
|
|
2048
|
-
}
|
|
2049
|
-
// realpath 在主进程内解析(子进程 node -e + shell 引号嵌套易出错)
|
|
2050
|
-
let dshRealPath = dshBin;
|
|
2051
|
-
try {
|
|
2052
|
-
dshRealPath = realpathSync(dshBin) || dshBin;
|
|
2053
|
-
} catch { /* 保留原始路径继续判断 */ }
|
|
2054
|
-
// npm root -g 返回的是 prefix 下的 node_modules(如 /prefix/lib/node_modules),
|
|
2055
|
-
// 不能直接 dirname 后再作为 --prefix,否则会变成 /prefix/lib,实际安装到
|
|
2056
|
-
// /prefix/lib/lib/node_modules,dsh 命令仍会加载旧的 /prefix/lib/node_modules。
|
|
2057
|
-
// npm prefix -g 才是可传给 --prefix 的真实全局安装前缀。
|
|
2058
|
-
const globalPrefix = await runCmd('npm', ['prefix', '-g']);
|
|
2059
|
-
const globalNodeModules = await runCmd('npm', ['root', '-g']);
|
|
2060
|
-
const rootGlobal = globalPrefix || (globalNodeModules ? dirname(dirname(globalNodeModules)) : dirname(nodeDir));
|
|
2061
|
-
const marker = join('node_modules', '@deepseek-ai', 'dsh');
|
|
2062
|
-
if (dshRealPath.includes(marker) && (dshRealPath.startsWith(rootGlobal) || dshRealPath.includes('node-v') && dshRealPath.includes('lib'))) {
|
|
2063
|
-
return { upgradable: true, rootGlobal, dshRealPath };
|
|
2064
|
-
}
|
|
2065
|
-
return {
|
|
2066
|
-
upgradable: false,
|
|
2067
|
-
// 桌面端 dsh 装在 App 包内:走桌面应用内更新,不要用 npm 动它。
|
|
2068
|
-
// 桌面检测是 fail-open 的,判不准时仍是这条通用文案,不影响 Web/CLI 用户。
|
|
2069
|
-
reason: isDesktopHost()
|
|
2070
|
-
? '桌面版 DSH 由应用内更新统一管理(一键升级仅支持 npm 全局安装的 CLI 版);请用桌面应用菜单的"检查更新"升级,勿用 npm 改动应用包内文件'
|
|
2071
|
-
: 'dsh 非标准 npm 全局安装(Electron/打包/源码/pnpm 等),无法自动升级;请按官方渠道手动更新',
|
|
2072
|
-
};
|
|
2073
|
-
} catch {
|
|
2074
|
-
return { upgradable: false, reason: '探测 dsh 安装形态失败,请按官方渠道手动更新' };
|
|
2075
|
-
}
|
|
2076
|
-
}
|
|
2077
|
-
|
|
2078
|
-
async checkVersion() {
|
|
2079
|
-
// TTL 缓存:窗口内直接返回上次结果,避免重复请求外网 registry
|
|
2080
|
-
if (this._versionCheckCache && Date.now() - this._versionCheckCachedAt < this._versionCheckTtlMs) {
|
|
2081
|
-
return this._versionCheckCache;
|
|
2082
|
-
}
|
|
2083
|
-
const fetchRegistry = (url, timeoutMs = 4000) => new Promise((resolve, reject) => {
|
|
2084
|
-
const req = httpsGet(url, { timeout: timeoutMs, headers: { 'User-Agent': 'dsh-bridge' } }, (res) => {
|
|
2085
|
-
if (res.statusCode !== 200) return reject(new Error(`HTTP ${res.statusCode}`));
|
|
2086
|
-
const chunks = [];
|
|
2087
|
-
res.on('data', (c) => chunks.push(c));
|
|
2088
|
-
res.on('end', () => {
|
|
2089
|
-
try {
|
|
2090
|
-
const data = JSON.parse(Buffer.concat(chunks).toString());
|
|
2091
|
-
resolve({
|
|
2092
|
-
version: data.version ?? null,
|
|
2093
|
-
releaseNotes: data.releaseNotes ?? data.description ?? null,
|
|
2094
|
-
});
|
|
2095
|
-
} catch (e) {
|
|
2096
|
-
reject(e);
|
|
2097
|
-
}
|
|
2098
|
-
});
|
|
2099
|
-
});
|
|
2100
|
-
req.on('error', reject);
|
|
2101
|
-
req.on('timeout', () => { req.destroy(); reject(new Error('请求超时')); });
|
|
2102
|
-
});
|
|
2103
|
-
|
|
2104
|
-
try {
|
|
2105
|
-
const latestData = await fetchRegistry('https://registry.npmmirror.com/@wenbin_wb/dsh-bridge/latest', 3500)
|
|
2106
|
-
.catch(() => fetchRegistry('https://registry.npmjs.org/@wenbin_wb/dsh-bridge/latest', 5000));
|
|
2107
|
-
// DSH 宿主 CLI 最新版本(npm 全局包 @deepseek-ai/dsh;与插件同源镜像,失败不阻断面板)
|
|
2108
|
-
const dshLatestData = await fetchRegistry('https://registry.npmmirror.com/@deepseek-ai/dsh/latest', 3500)
|
|
2109
|
-
.catch(() => fetchRegistry('https://registry.npmjs.org/@deepseek-ai/dsh/latest', 5000))
|
|
2110
|
-
.catch(() => null);
|
|
2111
|
-
// 安装形态探测(决定是否给"一键升级 DSH"按钮):npm 全局可升级,加壳/源码仅提醒
|
|
2112
|
-
const dshProbe = await this._probeDshUpgrade();
|
|
2113
|
-
const result = {
|
|
2114
|
-
current: VERSION,
|
|
2115
|
-
latest: latestData?.version ?? null,
|
|
2116
|
-
releaseNotes: latestData?.releaseNotes ?? null,
|
|
2117
|
-
dshVersion: await this.getDshVersion(),
|
|
2118
|
-
dshLatest: dshLatestData?.version ?? null,
|
|
2119
|
-
dshUpgradable: dshProbe.upgradable,
|
|
2120
|
-
dshUpgradeReason: dshProbe.reason || '',
|
|
2121
|
-
};
|
|
2122
|
-
this._versionCheckCache = result;
|
|
2123
|
-
this._versionCheckCachedAt = Date.now();
|
|
2124
|
-
return result;
|
|
2125
|
-
} catch (e) {
|
|
2126
|
-
// 失败不缓存(让下次调用可重试),但记录便于诊断
|
|
2127
|
-
return { current: VERSION, latest: null, error: e.message ?? '检查失败', dshVersion: await this.getDshVersion(), dshLatest: null, dshUpgradable: false, dshUpgradeReason: '' };
|
|
2128
|
-
}
|
|
2129
|
-
}
|
|
2130
|
-
|
|
2131
|
-
// 一键直接升级插件(执行 dsh / npx / npm 自动升级,使用安全的参数数组彻底杜绝 shell 注入)
|
|
2132
|
-
// opts.profile 供测试注入;默认按当前宿主推导(桌面端为 `desktop`,其余为 `web`),
|
|
2133
|
-
// 绝不能写死 --profile web——桌面端会把包装进宿主不加载的 profile,造成"显示成功实际没升"。
|
|
2134
|
-
async upgradePlugin({ version, spawnImpl = spawn, profile } = {}) {
|
|
2135
|
-
const targetVersion = version ? String(version).trim() : 'latest';
|
|
2136
|
-
// 严格 SemVer 白名单正则校验
|
|
2137
|
-
if (!/^(latest|\d+\.\d+\.\d+(-[a-zA-Z0-9.]+)?)$/.test(targetVersion)) {
|
|
2138
|
-
return { ok: false, error: `非法的版本号格式: ${targetVersion}`, version: targetVersion };
|
|
2139
|
-
}
|
|
2140
|
-
const pkgSpec = `@wenbin_wb/dsh-bridge@${targetVersion}`;
|
|
2141
|
-
const isWin = process.platform === 'win32';
|
|
2142
|
-
|
|
2143
|
-
// 自动构建包含 Homebrew / NVM / Node 兄弟目录的全量 PATH 环境变量
|
|
2144
|
-
const nodeDir = dirname(process.execPath);
|
|
2145
|
-
const home = homedir();
|
|
2146
|
-
const extraPaths = isWin ? [
|
|
2147
|
-
nodeDir,
|
|
2148
|
-
] : [
|
|
2149
|
-
nodeDir,
|
|
2150
|
-
'/opt/homebrew/bin',
|
|
2151
|
-
'/opt/homebrew/sbin',
|
|
2152
|
-
'/usr/local/bin',
|
|
2153
|
-
'/usr/bin',
|
|
2154
|
-
'/bin',
|
|
2155
|
-
join(home, '.nvm/current/bin'),
|
|
2156
|
-
join(home, '.fnm/current/bin'),
|
|
2157
|
-
join(home, '.local/bin'),
|
|
2158
|
-
join(home, '.cargo/bin'),
|
|
2159
|
-
];
|
|
2160
|
-
|
|
2161
|
-
const separator = isWin ? ';' : ':';
|
|
2162
|
-
const existingPath = process.env.PATH || process.env.Path || '';
|
|
2163
|
-
const augmentedEnv = {
|
|
2164
|
-
...process.env,
|
|
2165
|
-
PATH: [...extraPaths, existingPath].filter(Boolean).join(separator),
|
|
2166
|
-
};
|
|
2167
|
-
if (isWin) augmentedEnv.Path = augmentedEnv.PATH;
|
|
2168
|
-
|
|
2169
|
-
// 寻找与当前 node 配对的 npm/npx 绝对路径
|
|
2170
|
-
const siblingNpm = join(nodeDir, isWin ? 'npm.cmd' : 'npm');
|
|
2171
|
-
const siblingNpx = join(nodeDir, isWin ? 'npx.cmd' : 'npx');
|
|
2172
|
-
|
|
2173
|
-
// 目标 profile:显式传入优先,否则按宿主形态推导(桌面端 `desktop`,其余 `web`)。
|
|
2174
|
-
// 桌面端 profile 名固定,见官方 desktop-host(runProfile profile: 'desktop')。
|
|
2175
|
-
// 安全:payload 里的 profile 不可信,白名单只认 desktop/web,其余一律按推导值处理,
|
|
2176
|
-
// 避免未校验字符串进入 shell:true 的 spawn 参数(参数拼接有注入风险)。
|
|
2177
|
-
const targetProfile = (profile === 'desktop' || profile === 'web') ? profile : currentProfileName();
|
|
2178
|
-
|
|
2179
|
-
const tasks = [
|
|
2180
|
-
{ cmd: 'dsh', args: ['plugin', '--profile', targetProfile, 'add', pkgSpec] },
|
|
2181
|
-
{ cmd: existsSync(siblingNpx) ? siblingNpx : 'npx', args: ['--yes', '@deepseek-ai/dsh', 'plugin', '--profile', targetProfile, 'add', pkgSpec] },
|
|
2182
|
-
{ cmd: existsSync(siblingNpm) ? siblingNpm : 'npm', args: ['install', pkgSpec] },
|
|
2183
|
-
];
|
|
2184
|
-
|
|
2185
|
-
let lastError = null;
|
|
2186
|
-
|
|
2187
|
-
for (const task of tasks) {
|
|
2188
|
-
try {
|
|
2189
|
-
const res = await new Promise((resolve, reject) => {
|
|
2190
|
-
let cp;
|
|
2191
|
-
try {
|
|
2192
|
-
cp = spawnImpl(task.cmd, task.args, {
|
|
2193
|
-
windowsHide: true,
|
|
2194
|
-
shell: true,
|
|
2195
|
-
env: augmentedEnv,
|
|
2196
|
-
timeout: 120000,
|
|
2197
|
-
});
|
|
2198
|
-
} catch (spawnErr) {
|
|
2199
|
-
return reject(spawnErr);
|
|
2200
|
-
}
|
|
2201
|
-
let stdout = '';
|
|
2202
|
-
let stderr = '';
|
|
2203
|
-
cp.stdout?.on('data', (d) => { stdout += d.toString(); });
|
|
2204
|
-
cp.stderr?.on('data', (d) => { stderr += d.toString(); });
|
|
2205
|
-
cp.on('error', (err) => {
|
|
2206
|
-
reject(err);
|
|
2207
|
-
});
|
|
2208
|
-
cp.on('close', (code) => {
|
|
2209
|
-
if (code === 0) {
|
|
2210
|
-
resolve({ stdout, stderr });
|
|
2211
|
-
} else {
|
|
2212
|
-
reject(new Error(stderr || stdout || `进程退出码 ${code}`));
|
|
2213
|
-
}
|
|
2214
|
-
});
|
|
2215
|
-
});
|
|
2216
|
-
|
|
2217
|
-
const output = res.stdout || res.stderr || '升级成功';
|
|
2218
|
-
return { ok: true, command: `${task.cmd} ${task.args.join(' ')}`, output, version: targetVersion };
|
|
2219
|
-
} catch (err) {
|
|
2220
|
-
lastError = err;
|
|
2221
|
-
}
|
|
2222
|
-
}
|
|
2223
|
-
|
|
2224
|
-
return { ok: false, error: lastError?.message ?? '升级命令执行失败', version: targetVersion };
|
|
2225
|
-
}
|
|
2226
|
-
|
|
2227
|
-
// 一键升级 DSH 宿主 CLI(npm 全局包 @deepseek-ai/dsh)。
|
|
2228
|
-
// 升级的是"与当前 node 配对"的全局 prefix(dsh 命令所在目录),完成后需重启 DSH 生效。
|
|
2229
|
-
// opts.spawnImpl 供测试注入,生产环境用 node:child_process 的 spawn。
|
|
2230
|
-
async upgradeDsh({ version, spawnImpl = spawn } = {}) {
|
|
2231
|
-
const targetVersion = version ? String(version).trim() : 'latest';
|
|
2232
|
-
// 严格 SemVer 白名单校验(dsh 用 rc 版本号,如 0.1.2-rc.1)
|
|
2233
|
-
if (!/^(latest|\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?)$/.test(targetVersion)) {
|
|
2234
|
-
return { ok: false, error: `非法的版本号格式: ${targetVersion}`, version: targetVersion };
|
|
2235
|
-
}
|
|
2236
|
-
|
|
2237
|
-
// 安装形态守卫:非标准 npm 全局安装(Electron/源码/pnpm 等)拒绝自动升级,
|
|
2238
|
-
// 避免"装一份不生效的新副本、误导用户以为升级成功"
|
|
2239
|
-
const probe = await this._probeDshUpgrade();
|
|
2240
|
-
if (!probe.upgradable) {
|
|
2241
|
-
return {
|
|
2242
|
-
ok: false,
|
|
2243
|
-
error: probe.reason || '当前 DSH 为非 npm 全局安装,无法自动升级;请按官方渠道手动更新',
|
|
2244
|
-
version: targetVersion,
|
|
2245
|
-
};
|
|
2246
|
-
}
|
|
2247
|
-
|
|
2248
|
-
const pkgSpec = `@deepseek-ai/dsh@${targetVersion}`;
|
|
2249
|
-
const isWin = process.platform === 'win32';
|
|
2250
|
-
const nodeDir = dirname(process.execPath);
|
|
2251
|
-
const home = homedir();
|
|
2252
|
-
const extraPaths = isWin ? [nodeDir] : [
|
|
2253
|
-
nodeDir,
|
|
2254
|
-
'/opt/homebrew/bin',
|
|
2255
|
-
'/opt/homebrew/sbin',
|
|
2256
|
-
'/usr/local/bin',
|
|
2257
|
-
'/usr/bin',
|
|
2258
|
-
'/bin',
|
|
2259
|
-
join(home, '.nvm/current/bin'),
|
|
2260
|
-
join(home, '.fnm/current/bin'),
|
|
2261
|
-
join(home, '.local/bin'),
|
|
2262
|
-
join(home, '.cargo/bin'),
|
|
2263
|
-
];
|
|
2264
|
-
const separator = isWin ? ';' : ':';
|
|
2265
|
-
const existingPath = process.env.PATH || process.env.Path || '';
|
|
2266
|
-
const augmentedEnv = {
|
|
2267
|
-
...process.env,
|
|
2268
|
-
PATH: [...extraPaths, existingPath].filter(Boolean).join(separator),
|
|
2269
|
-
};
|
|
2270
|
-
if (isWin) augmentedEnv.Path = augmentedEnv.PATH;
|
|
2271
|
-
|
|
2272
|
-
// 与当前 node 配对的 npm 绝对路径(保证装进 dsh 所在 prefix,而非 PATH 里其它 npm)
|
|
2273
|
-
const siblingNpm = join(nodeDir, isWin ? 'npm.cmd' : 'npm');
|
|
2274
|
-
const npmBin = existsSync(siblingNpm) ? siblingNpm : 'npm';
|
|
2275
|
-
// 优先用探测到的全局 root(dsh 实际所在 prefix),其次 nodeDir 父目录
|
|
2276
|
-
const globalRoot = probe.rootGlobal || dirname(nodeDir);
|
|
2277
|
-
|
|
2278
|
-
const tasks = [
|
|
2279
|
-
{ cmd: npmBin, args: ['install', '-g', '--prefix', globalRoot, pkgSpec] },
|
|
2280
|
-
{ cmd: npmBin, args: ['install', '-g', pkgSpec] },
|
|
2281
|
-
];
|
|
2282
|
-
|
|
2283
|
-
let lastError = null;
|
|
2284
|
-
for (const task of tasks) {
|
|
2285
|
-
try {
|
|
2286
|
-
const res = await new Promise((resolve, reject) => {
|
|
2287
|
-
let cp;
|
|
2288
|
-
try {
|
|
2289
|
-
cp = spawnImpl(task.cmd, task.args, {
|
|
2290
|
-
windowsHide: true,
|
|
2291
|
-
shell: true,
|
|
2292
|
-
env: augmentedEnv,
|
|
2293
|
-
timeout: 180000,
|
|
2294
|
-
});
|
|
2295
|
-
} catch (spawnErr) {
|
|
2296
|
-
return reject(spawnErr);
|
|
2297
|
-
}
|
|
2298
|
-
let stdout = '';
|
|
2299
|
-
let stderr = '';
|
|
2300
|
-
cp.stdout?.on('data', (d) => { stdout += d.toString(); });
|
|
2301
|
-
cp.stderr?.on('data', (d) => { stderr += d.toString(); });
|
|
2302
|
-
cp.on('error', reject);
|
|
2303
|
-
cp.on('close', (code) => {
|
|
2304
|
-
if (code === 0) resolve({ stdout, stderr });
|
|
2305
|
-
else reject(new Error(stderr || stdout || `进程退出码 ${code}`));
|
|
2306
|
-
});
|
|
2307
|
-
});
|
|
2308
|
-
const output = (res.stdout || res.stderr || '升级成功').trim().slice(-500);
|
|
2309
|
-
this.logger?.info('dsh-bridge: DSH 升级命令成功: %s %s', task.cmd, task.args.join(' '));
|
|
2310
|
-
|
|
2311
|
-
// 升级成功后清理版本缓存,让 getDshVersion/checkVersion 下次重新探测
|
|
2312
|
-
this._dshVersion = null;
|
|
2313
|
-
this._dshVersionLoaded = false;
|
|
2314
|
-
this._versionCheckCache = null;
|
|
2315
|
-
|
|
2316
|
-
// 核验实际 dsh 版本:npm 命令成功不等于正在使用的 dsh 已经变新。
|
|
2317
|
-
// 如果探测到的版本号仍然是旧版(或探测失败),则安装可能装到了错误位置。
|
|
2318
|
-
const actualVersion = await this.getDshVersion();
|
|
2319
|
-
if (actualVersion && targetVersion !== 'latest') {
|
|
2320
|
-
// 用户指定了具体版本号(如 0.2.0-rc.2):安装后 dsh --version 必须精确匹配
|
|
2321
|
-
if (actualVersion !== targetVersion) {
|
|
2322
|
-
return {
|
|
2323
|
-
ok: false,
|
|
2324
|
-
error: `npm 安装成功但 dsh 命令实际版本仍为 ${actualVersion}(预期 ${targetVersion})。可能安装到了错误位置,请手动执行 \`npm install -g @deepseek-ai/dsh@${targetVersion}\` 并重启 DSH。`,
|
|
2325
|
-
version: targetVersion,
|
|
2326
|
-
installedButNotActive: true,
|
|
2327
|
-
};
|
|
2328
|
-
}
|
|
2329
|
-
}
|
|
2330
|
-
|
|
2331
|
-
return { ok: true, command: `${task.cmd} ${task.args.join(' ')}`, output, version: targetVersion };
|
|
2332
|
-
} catch (err) {
|
|
2333
|
-
lastError = err;
|
|
2334
|
-
}
|
|
2335
|
-
}
|
|
2336
|
-
return { ok: false, error: lastError?.message ?? '升级命令执行失败', version: targetVersion };
|
|
2337
|
-
}
|
|
2338
|
-
|
|
2339
|
-
// 优雅重启 DSH 服务。
|
|
2340
|
-
//
|
|
2341
|
-
// 关键教训(真实事故):DSH 常以 systemd 用户单元(如 dsh-web.service,Restart=on-failure)托管,
|
|
2342
|
-
// 而"自己派生一个子进程再 process.exit(0)"在这种环境下必然失败:
|
|
2343
|
-
// - 干净退出(code 0)不满足 Restart=on-failure,systemd 不会拉起;
|
|
2344
|
-
// - 默认 KillMode=control-group,主进程退出时 systemd 会把 cgroup 里刚派生的子进程一起杀掉。
|
|
2345
|
-
// 结果就是"点了重启,dsh 再也没起来",而且没有任何日志(旧实现 stdio: 'ignore')。
|
|
2346
|
-
// 因此这里先识别托管方式:
|
|
2347
|
-
// 探测「重启会打断哪些正在跑的任务」。
|
|
2348
|
-
//
|
|
2349
|
-
// 判据(均为可从宿主运行时直接读到的信号,不依赖任何新增宿主 API):
|
|
2350
|
-
// 1. 主会话进行中的 turn:事件流里存在未配对的 turn/start(无对应 turn/end)。
|
|
2351
|
-
// 这是 Web/IM 两端「正在思考/工具调用中」的同一依据
|
|
2352
|
-
// (见 lib/platform/conversation-bridge.js 的 digestLine)。
|
|
2353
|
-
// 2. 子代理/agent 会话:会话 header.origin === 'subagent',同样按未闭合 turn 判断在跑。
|
|
2354
|
-
// 嵌套委托最容易被遗忘,重启损失也最大。
|
|
2355
|
-
// 3. 待审批请求:各 IM 平台 bridge 的 pending(用户尚未在机器人里点「同意/拒绝」)。
|
|
2356
|
-
// 宿主 ctx.approval 只提供 request(),无查询 API,故以插件自身登记的 pending 为准。
|
|
2357
|
-
//
|
|
2358
|
-
// 任何一步探测失败都必须降级为「该项无任务」并继续,绝不因此阻断重启:
|
|
2359
|
-
// 重启是救急能力,不能被探测逻辑锁死。
|
|
2360
|
-
getActiveWork() {
|
|
2361
|
-
const work = { sessions: [], subagentSessions: [], pendingApprovals: [], total: 0 };
|
|
2362
|
-
|
|
2363
|
-
// 1 + 2:遍历 live 会话,按未闭合 turn 判断是否在跑
|
|
2364
|
-
try {
|
|
2365
|
-
const list = this.runtimeCtx?.sessions?.list?.() ?? [];
|
|
2366
|
-
for (const s of list) {
|
|
2367
|
-
if (!s || !s.id) continue;
|
|
2368
|
-
const isSubagent = s.origin === 'subagent' || s.header?.origin === 'subagent';
|
|
2369
|
-
const turn = describeOpenTurn(s.events);
|
|
2370
|
-
if (!turn) continue;
|
|
2371
|
-
const entry = {
|
|
2372
|
-
id: s.id,
|
|
2373
|
-
title: s.title || (s.header?.cwd ? basename(s.header.cwd) : '') || '未命名会话',
|
|
2374
|
-
turn: turn.turn,
|
|
2375
|
-
tools: turn.tools,
|
|
2376
|
-
lastTool: turn.lastTool,
|
|
2377
|
-
};
|
|
2378
|
-
if (isSubagent) work.subagentSessions.push(entry);
|
|
2379
|
-
else work.sessions.push(entry);
|
|
2380
|
-
}
|
|
2381
|
-
} catch (err) {
|
|
2382
|
-
this.logger?.debug?.('dsh-bridge: 探测进行中会话失败(视为无任务):%s', err?.message ?? err);
|
|
2383
|
-
}
|
|
2384
|
-
|
|
2385
|
-
// 3:各 IM 平台的待审批请求
|
|
2386
|
-
try {
|
|
2387
|
-
for (const platform of this.platformManager?.list?.() ?? []) {
|
|
2388
|
-
const pending = platform?.bridge?.pending;
|
|
2389
|
-
if (!pending || typeof pending.forEach !== 'function') continue;
|
|
2390
|
-
pending.forEach((entry, number) => {
|
|
2391
|
-
work.pendingApprovals.push({
|
|
2392
|
-
platform: platform?.id ?? 'unknown',
|
|
2393
|
-
number,
|
|
2394
|
-
summary: entry?.summary || entry?.title || entry?.tool || '待审批请求',
|
|
2395
|
-
});
|
|
2396
|
-
});
|
|
2397
|
-
}
|
|
2398
|
-
} catch (err) {
|
|
2399
|
-
this.logger?.debug?.('dsh-bridge: 探测待审批请求失败(视为无)):%s', err?.message ?? err);
|
|
2400
|
-
}
|
|
2401
|
-
|
|
2402
|
-
work.total = work.sessions.length + work.subagentSessions.length + work.pendingApprovals.length;
|
|
2403
|
-
return work;
|
|
2404
|
-
}
|
|
2405
|
-
|
|
2406
|
-
// - systemd(从 /proc/self/cgroup 取单元名)→ 交给 `systemctl [--user] restart --no-block <unit>`,
|
|
2407
|
-
// 由 systemd 负责 stop+start,新进程仍在正确的 cgroup 里;
|
|
2408
|
-
// - DSH_DAEMON / PM2 → 直接退出,交给守护进程拉起;
|
|
2409
|
-
// - 无托管器 → 派生独立助手(lib/restart-helper.mjs),等旧进程退出 + 端口释放后才拉起新进程,
|
|
2410
|
-
// 并把全过程写入 ~/.dsh/dsh-bridge/restart.log(失败不再无声)。
|
|
2411
|
-
// opts 仅供单测注入(spawnImpl / readCgroup / env / scheduleExit)。
|
|
2412
|
-
async restartDsh(opts = {}) {
|
|
2413
|
-
// 桌面端拦截(P0):桌面宿主由 Electron 壳经 IPC 管理生死,插件绝不能自行
|
|
2414
|
-
// 派生 helper 重拉进程——那会产生壳外孤儿(抢占 19387 端口、触发壳的恢复
|
|
2415
|
-
// 对话框)。直接拒绝,并指引用户走应用菜单重启。
|
|
2416
|
-
// isDesktopHost 是 fail-open 的:判不准时按 Web/CLI 版走,不拦正常用户。
|
|
2417
|
-
if (isDesktopHost({ versions: opts.processVersions, argv1: opts.argv1 })) {
|
|
2418
|
-
return {
|
|
2419
|
-
ok: false,
|
|
2420
|
-
error: '当前运行在 DSH 桌面版内,宿主由应用统一管理,插件不能自行重启。请用桌面应用菜单的重启/退出重进(有关闭确认保护正在跑的任务),不要用面板重启。',
|
|
2421
|
-
desktopManaged: true,
|
|
2422
|
-
};
|
|
2423
|
-
}
|
|
2424
|
-
const env = opts.env ?? process.env;
|
|
2425
|
-
const spawnImpl = opts.spawnImpl ?? spawn;
|
|
2426
|
-
const readCgroup = opts.readCgroup
|
|
2427
|
-
?? (() => { try { return readFileSync('/proc/self/cgroup', 'utf8'); } catch { return ''; } });
|
|
2428
|
-
const cgroup = opts.cgroup ?? readCgroup();
|
|
2429
|
-
const scheduleExit = opts.scheduleExit ?? ((ms) => setTimeout(() => process.exit(0), ms));
|
|
2430
|
-
|
|
2431
|
-
const dshHome = env.DSH_HOME ?? join(homedir(), '.dsh');
|
|
2432
|
-
const logFile = join(dshHome, 'dsh-bridge', 'restart.log');
|
|
2433
|
-
const childLog = join(dshHome, 'dsh-bridge', 'restart-child.log');
|
|
2434
|
-
try { mkdirSync(dirname(logFile), { recursive: true }); } catch { /* 日志目录不可写时忽略 */ }
|
|
2435
|
-
const writeLog = (message) => {
|
|
2436
|
-
try { appendFileSync(logFile, `[${new Date().toISOString()}] ${message}\n`); } catch { /* 日志不可写时忽略 */ }
|
|
2437
|
-
};
|
|
2438
|
-
|
|
2439
|
-
const plan = detectSupervisor({ env, cgroup });
|
|
2440
|
-
writeLog(`重启请求:plan=${plan.kind}${plan.unit ? ` unit=${plan.unit}(${plan.scope})` : ''}(${plan.reason})pid=${process.pid} cwd=${process.cwd()} argv=${JSON.stringify(process.argv.slice(1))}`);
|
|
2441
|
-
this.logger?.info('收到 DSH 重启请求(%s)', plan.kind === 'systemd' ? `systemd:${plan.unit}` : plan.kind);
|
|
2442
|
-
|
|
2443
|
-
if (plan.kind === 'systemd') {
|
|
2444
|
-
const args = [...(plan.scope === 'user' ? ['--user'] : []), 'restart', '--no-block', plan.unit];
|
|
2445
|
-
const systemctlEnv = { ...env };
|
|
2446
|
-
if (process.platform === 'linux') {
|
|
2447
|
-
const defaultPaths = ['/usr/local/bin', '/usr/bin', '/bin', '/usr/local/sbin', '/usr/sbin', '/sbin'];
|
|
2448
|
-
const existingPath = systemctlEnv.PATH || '';
|
|
2449
|
-
const missingPaths = defaultPaths.filter((p) => !existingPath.split(':').includes(p));
|
|
2450
|
-
if (missingPaths.length > 0) {
|
|
2451
|
-
systemctlEnv.PATH = existingPath ? `${existingPath}:${missingPaths.join(':')}` : missingPaths.join(':');
|
|
2452
|
-
}
|
|
2453
|
-
if (plan.scope === 'user' && !systemctlEnv.XDG_RUNTIME_DIR && typeof process.getuid === 'function') {
|
|
2454
|
-
systemctlEnv.XDG_RUNTIME_DIR = `/run/user/${process.getuid()}`;
|
|
2455
|
-
}
|
|
2456
|
-
}
|
|
2457
|
-
// 关键:必须以 **systemctl 的退出码** 判定成败,而不是"派生成功"。
|
|
2458
|
-
// `--no-block` 下 systemctl 只发请求就返回(实测往返 ~6ms),但单元名不存在会 rc=5、
|
|
2459
|
-
// 权限不足会 rc=1;旧写法只看 'spawn' 事件 → 必然失败也返回 ok:true 再 exit(0),
|
|
2460
|
-
// 而干净退出不满足 Restart=on-failure → DSH 直接下线且不自动拉起。
|
|
2461
|
-
const outcome = await new Promise((resolve) => {
|
|
2462
|
-
let settled = false;
|
|
2463
|
-
const timer = setTimeout(() => settle({ error: new Error('systemctl 执行超时(5s)') }), 5000);
|
|
2464
|
-
const settle = (value) => {
|
|
2465
|
-
if (!settled) {
|
|
2466
|
-
settled = true;
|
|
2467
|
-
clearTimeout(timer);
|
|
2468
|
-
resolve(value);
|
|
2469
|
-
}
|
|
2470
|
-
};
|
|
2471
|
-
try {
|
|
2472
|
-
const child = spawnImpl('systemctl', args, { detached: true, stdio: 'ignore', env: systemctlEnv });
|
|
2473
|
-
child.once?.('error', (err) => settle({ error: err }));
|
|
2474
|
-
child.once?.('close', (code) => settle({ code }));
|
|
2475
|
-
} catch (err) {
|
|
2476
|
-
settle({ error: err });
|
|
2477
|
-
}
|
|
2478
|
-
});
|
|
2479
|
-
|
|
2480
|
-
if (!outcome.error && (outcome.code === 0 || outcome.code === null || outcome.code === undefined)) {
|
|
2481
|
-
writeLog(`已请求 systemd 重启(systemctl 退出码 ${outcome.code ?? 'n/a'}):systemctl ${args.join(' ')}`);
|
|
2482
|
-
scheduleExit(1500);
|
|
2483
|
-
const scopeText = plan.scope === 'user' ? '用户会话' : '系统';
|
|
2484
|
-
return { ok: true, message: `已请求 systemd 重启 ${plan.unit}(${scopeText}),由 systemd 负责停止与拉起…` };
|
|
2485
|
-
}
|
|
2486
|
-
|
|
2487
|
-
// 失败时**不退化为自派生**:systemd 下主进程干净退出既不会被拉起,
|
|
2488
|
-
// 默认 KillMode=control-group 还会把 detached 助手一起杀掉 —— 那等于"报着重启却永久下线"。
|
|
2489
|
-
// 因此保持现状(DSH 继续运行)并如实报错,由用户手动处理。
|
|
2490
|
-
const reason = outcome.error ? outcome.error.message : `systemctl 退出码 ${outcome.code}`;
|
|
2491
|
-
writeLog(`systemctl 重启失败(${reason}),为不让 DSH 掉线未做任何退出`);
|
|
2492
|
-
return {
|
|
2493
|
-
ok: false,
|
|
2494
|
-
error: `systemctl ${args.join(' ')} 失败:${reason}。DSH 仍在运行;请手动执行「systemctl ${plan.scope === 'user' ? '--user ' : ''}restart ${plan.unit}」或检查单元。`,
|
|
2495
|
-
};
|
|
2496
|
-
} else if (plan.kind === 'daemon') {
|
|
2497
|
-
writeLog(`交由守护进程拉起(${plan.reason})`);
|
|
2498
|
-
scheduleExit(600);
|
|
2499
|
-
return { ok: true, message: `DSH 正在重启(由 ${plan.reason} 守护进程拉起)…` };
|
|
2500
|
-
}
|
|
2501
|
-
|
|
2502
|
-
// 无托管器(或 systemd 调用失败)→ 独立助手:等旧进程退出、端口释放后再拉起新进程,全程留日志
|
|
2503
|
-
const helper = join(dirname(fileURLToPath(import.meta.url)), 'restart-helper.mjs');
|
|
2504
|
-
const payload = JSON.stringify({
|
|
2505
|
-
pid: process.pid,
|
|
2506
|
-
port: this.dshPort ?? 3080,
|
|
2507
|
-
argv: process.argv.slice(1),
|
|
2508
|
-
cwd: process.cwd(),
|
|
2509
|
-
execPath: process.execPath,
|
|
2510
|
-
logFile,
|
|
2511
|
-
childLog,
|
|
2512
|
-
});
|
|
2513
|
-
// spawn 的失败是异步事件('error'),只 try/catch 抓不到——必须挂监听,否则会变成未捕获异常
|
|
2514
|
-
const helperError = await new Promise((resolve) => {
|
|
2515
|
-
let settled = false;
|
|
2516
|
-
const settle = (value) => { if (!settled) { settled = true; resolve(value); } };
|
|
2517
|
-
try {
|
|
2518
|
-
const child = spawnImpl(process.execPath, [helper, payload], {
|
|
2519
|
-
cwd: process.cwd(),
|
|
2520
|
-
env,
|
|
2521
|
-
detached: true,
|
|
2522
|
-
stdio: 'ignore',
|
|
2523
|
-
});
|
|
2524
|
-
child.once?.('error', (err) => settle(err));
|
|
2525
|
-
child.once?.('spawn', () => { writeLog(`已派生重启助手:helperPid=${child.pid ?? '(未知)'},日志:${logFile}`); settle(null); });
|
|
2526
|
-
child.unref?.();
|
|
2527
|
-
} catch (err) {
|
|
2528
|
-
settle(err);
|
|
2529
|
-
}
|
|
2530
|
-
});
|
|
2531
|
-
|
|
2532
|
-
if (helperError) {
|
|
2533
|
-
writeLog(`派生重启助手失败:${helperError.message}`);
|
|
2534
|
-
return { ok: false, error: `重启失败:${helperError.message}(DSH 仍在运行,未做任何退出)` };
|
|
2535
|
-
}
|
|
2536
|
-
scheduleExit(600);
|
|
2537
|
-
return { ok: true, message: '正在重启:独立助手会在旧进程退出、端口释放后拉起新的 DSH(若失败会记录在 ~/.dsh/dsh-bridge/restart.log)…' };
|
|
2538
|
-
}
|
|
2539
|
-
|
|
2540
|
-
getSystemMetrics() {
|
|
2541
|
-
try {
|
|
2542
|
-
const totalMem = totalmem();
|
|
2543
|
-
const freeMem = freemem();
|
|
2544
|
-
const usedMem = totalMem - freeMem;
|
|
2545
|
-
const memUsage = process.memoryUsage();
|
|
2546
|
-
const cpusList = cpus() || [];
|
|
2547
|
-
const cpuCount = cpusList.length;
|
|
2548
|
-
const cpuModel = cpusList[0]?.model || 'Generic CPU';
|
|
2549
|
-
|
|
2550
|
-
return {
|
|
2551
|
-
os: {
|
|
2552
|
-
platform: platform(),
|
|
2553
|
-
arch: arch(),
|
|
2554
|
-
release: release(),
|
|
2555
|
-
hostname: hostname(),
|
|
2556
|
-
nodeVersion: process.version,
|
|
2557
|
-
},
|
|
2558
|
-
uptime: {
|
|
2559
|
-
processSec: Math.floor(process.uptime()),
|
|
2560
|
-
systemSec: Math.floor(uptime()),
|
|
2561
|
-
},
|
|
2562
|
-
cpu: {
|
|
2563
|
-
model: cpuModel,
|
|
2564
|
-
cores: cpuCount,
|
|
2565
|
-
loadAvg: typeof loadavg === 'function' ? loadavg() : [0, 0, 0],
|
|
2566
|
-
},
|
|
2567
|
-
memory: {
|
|
2568
|
-
totalBytes: totalMem,
|
|
2569
|
-
freeBytes: freeMem,
|
|
2570
|
-
usedBytes: usedMem,
|
|
2571
|
-
usedPercent: Math.round((usedMem / totalMem) * 100),
|
|
2572
|
-
processHeapUsed: memUsage.heapUsed,
|
|
2573
|
-
processRss: memUsage.rss,
|
|
2574
|
-
},
|
|
2575
|
-
};
|
|
2576
|
-
} catch {
|
|
2577
|
-
return null;
|
|
2578
|
-
}
|
|
2579
|
-
}
|
|
2580
|
-
|
|
2581
|
-
// 获取当前所有已注册的工作区
|
|
2582
|
-
async getWorkspaces() {
|
|
2583
|
-
try {
|
|
2584
|
-
const list = await this.ctx?.workspaceRegistry?.list?.() ?? [];
|
|
2585
|
-
const out = [];
|
|
2586
|
-
for (const ws of list) {
|
|
2587
|
-
if (ws && ws.path) {
|
|
2588
|
-
out.push({
|
|
2589
|
-
id: ws.id,
|
|
2590
|
-
title: ws.title ?? basename(ws.path),
|
|
2591
|
-
path: ws.path,
|
|
2592
|
-
});
|
|
2593
|
-
}
|
|
2594
|
-
}
|
|
2595
|
-
return out.sort((a, b) => String(a.path).localeCompare(String(b.path)));
|
|
2596
|
-
} catch {
|
|
2597
|
-
return [];
|
|
2598
|
-
}
|
|
2599
|
-
}
|
|
2600
|
-
|
|
2601
|
-
// 远程添加工作区目录到 DSH 体系
|
|
2602
|
-
async addWorkspace(workspacePath) {
|
|
2603
|
-
if (!workspacePath || typeof workspacePath !== 'string') {
|
|
2604
|
-
return { ok: false, error: '缺少工作区目录路径' };
|
|
2605
|
-
}
|
|
2606
|
-
const safetyCheck = await isSafeWorkspacePath(workspacePath);
|
|
2607
|
-
if (!safetyCheck.valid) {
|
|
2608
|
-
return { ok: false, error: safetyCheck.error || '路径安全校验未通过' };
|
|
2609
|
-
}
|
|
2610
|
-
const resolved = safetyCheck.path;
|
|
2611
|
-
|
|
2612
|
-
const title = basename(resolved) || resolved;
|
|
2613
|
-
let added = false;
|
|
2614
|
-
let workspaceId = null;
|
|
2615
|
-
|
|
2616
|
-
if (this.ctx?.workspaceRegistry) {
|
|
2617
|
-
if (typeof this.ctx.workspaceRegistry.create === 'function') {
|
|
2618
|
-
try {
|
|
2619
|
-
const entity = await this.ctx.workspaceRegistry.create(resolved, title);
|
|
2620
|
-
added = true;
|
|
2621
|
-
workspaceId = entity?.id ?? null;
|
|
2622
|
-
} catch (e) {
|
|
2623
|
-
this.logger?.warn?.('workspaceRegistry.create 失败: %s', e.message);
|
|
2624
|
-
}
|
|
2625
|
-
} else if (typeof this.ctx.workspaceRegistry.add === 'function') {
|
|
2626
|
-
try {
|
|
2627
|
-
const res = await this.ctx.workspaceRegistry.add({ path: resolved, title });
|
|
2628
|
-
added = true;
|
|
2629
|
-
workspaceId = res?.id ?? null;
|
|
2630
|
-
} catch (e) {
|
|
2631
|
-
this.logger?.warn?.('workspaceRegistry.add 失败: %s', e.message);
|
|
2632
|
-
}
|
|
2633
|
-
} else if (typeof this.ctx.workspaceRegistry.register === 'function') {
|
|
2634
|
-
try {
|
|
2635
|
-
const res = await this.ctx.workspaceRegistry.register({ path: resolved, title });
|
|
2636
|
-
added = true;
|
|
2637
|
-
workspaceId = res?.id ?? null;
|
|
2638
|
-
} catch (e) {
|
|
2639
|
-
this.logger?.warn?.('workspaceRegistry.register 失败: %s', e.message);
|
|
2640
|
-
}
|
|
2641
|
-
}
|
|
2642
|
-
}
|
|
2643
|
-
|
|
2644
|
-
const list = await this.getWorkspaces();
|
|
2645
|
-
if (!workspaceId) {
|
|
2646
|
-
const match = list.find(w => w.path === resolved || (w.path && w.path.toLowerCase() === resolved.toLowerCase()));
|
|
2647
|
-
if (match) workspaceId = match.id;
|
|
2648
|
-
}
|
|
2649
|
-
|
|
2650
|
-
let sessionId = null;
|
|
2651
|
-
if (this.ctx?.sessions && typeof this.ctx.sessions.create === 'function') {
|
|
2652
|
-
try {
|
|
2653
|
-
const session = this.ctx.sessions.create(undefined, { meta: { cwd: resolved } });
|
|
2654
|
-
if (session?.id) {
|
|
2655
|
-
sessionId = session.id;
|
|
2656
|
-
if (workspaceId && this.ctx?.workspaceRegistry?.get) {
|
|
2657
|
-
const entity = this.ctx.workspaceRegistry.get(workspaceId);
|
|
2658
|
-
if (entity && typeof entity.attachSession === 'function') {
|
|
2659
|
-
await entity.attachSession(session.id).catch(() => {});
|
|
2660
|
-
}
|
|
2661
|
-
}
|
|
2662
|
-
}
|
|
2663
|
-
} catch (e) {
|
|
2664
|
-
this.logger?.debug?.('sessions.create 初始化 session 提示: %s', e.message);
|
|
2665
|
-
}
|
|
2666
|
-
}
|
|
2667
|
-
|
|
2668
|
-
return {
|
|
2669
|
-
ok: true,
|
|
2670
|
-
path: resolved,
|
|
2671
|
-
title,
|
|
2672
|
-
workspaceId,
|
|
2673
|
-
sessionId,
|
|
2674
|
-
workspaces: list,
|
|
2675
|
-
registered: added
|
|
2676
|
-
};
|
|
2677
|
-
}
|
|
2678
|
-
|
|
2679
|
-
// 远程目录列表浏览与常用路径推荐
|
|
2680
|
-
async listRemoteDirectories(targetPath) {
|
|
2681
|
-
const isWin = process.platform === 'win32';
|
|
2682
|
-
const home = homedir();
|
|
2683
|
-
|
|
2684
|
-
// 1. 获取快速访问常用根目录
|
|
2685
|
-
const roots = [
|
|
2686
|
-
{ name: '🏠 用户主目录', path: home },
|
|
2687
|
-
];
|
|
2688
|
-
const commonSubdirs = [
|
|
2689
|
-
{ name: '💻 桌面', sub: 'Desktop' },
|
|
2690
|
-
{ name: '📁 文档', sub: 'Documents' },
|
|
2691
|
-
{ name: '📥 下载', sub: 'Downloads' },
|
|
2692
|
-
{ name: '💡 IdeaProjects', sub: 'IdeaProjects' },
|
|
2693
|
-
{ name: '🔨 Projects', sub: 'Projects' },
|
|
2694
|
-
{ name: '📦 workspace', sub: 'workspace' },
|
|
2695
|
-
{ name: '💻 code', sub: 'code' },
|
|
2696
|
-
{ name: '💻 src', sub: 'src' },
|
|
2697
|
-
];
|
|
2698
|
-
for (const item of commonSubdirs) {
|
|
2699
|
-
const fullPath = join(home, item.sub);
|
|
2700
|
-
try {
|
|
2701
|
-
const s = await stat(fullPath);
|
|
2702
|
-
if (s.isDirectory()) {
|
|
2703
|
-
roots.push({ name: item.name, path: fullPath });
|
|
2704
|
-
}
|
|
2705
|
-
} catch {}
|
|
2706
|
-
}
|
|
2707
|
-
|
|
2708
|
-
// 2. Windows 盘符探测
|
|
2709
|
-
const drives = [];
|
|
2710
|
-
if (isWin) {
|
|
2711
|
-
const letters = 'CDEFGHIJKLMNOPQRSTUVWXYZ'.split('');
|
|
2712
|
-
for (const letter of letters) {
|
|
2713
|
-
const driveRoot = `${letter}:\\`;
|
|
2714
|
-
try {
|
|
2715
|
-
await access(driveRoot);
|
|
2716
|
-
drives.push({ name: `${letter}: 盘`, path: driveRoot });
|
|
2717
|
-
} catch {}
|
|
2718
|
-
}
|
|
2719
|
-
if (drives.length === 0) drives.push({ name: 'C: 盘', path: 'C:\\' });
|
|
2720
|
-
} else {
|
|
2721
|
-
drives.push({ name: '根目录 /', path: '/' });
|
|
2722
|
-
}
|
|
2723
|
-
|
|
2724
|
-
// 3. 解析当前请求路径并进行安全校验
|
|
2725
|
-
let rawTarget = targetPath && typeof targetPath === 'string' ? targetPath.trim() : '';
|
|
2726
|
-
if (isWin && /^[A-Za-z]:$/.test(rawTarget)) {
|
|
2727
|
-
rawTarget = `${rawTarget}\\`;
|
|
2728
|
-
}
|
|
2729
|
-
let candidatePath = rawTarget ? resolve(rawTarget) : home;
|
|
2730
|
-
|
|
2731
|
-
// 安全校验:遇非法或黑名单目录时安全回退至用户主目录
|
|
2732
|
-
let currentPath = home;
|
|
2733
|
-
const pathCheck = await isSafeWorkspacePath(candidatePath);
|
|
2734
|
-
if (pathCheck.valid && pathCheck.path) {
|
|
2735
|
-
currentPath = pathCheck.path;
|
|
2736
|
-
}
|
|
2737
|
-
|
|
2738
|
-
// 4. 读取子文件夹列表(过滤敏感目录与不安全软链接)
|
|
2739
|
-
const entries = [];
|
|
2740
|
-
let readError = null;
|
|
2741
|
-
try {
|
|
2742
|
-
const dirents = await readdir(currentPath, { withFileTypes: true });
|
|
2743
|
-
for (const d of dirents) {
|
|
2744
|
-
if (isSensitiveFolderName(d.name)) continue;
|
|
2745
|
-
|
|
2746
|
-
let isDir = d.isDirectory();
|
|
2747
|
-
const targetEntryPath = join(currentPath, d.name);
|
|
2748
|
-
|
|
2749
|
-
// 如果是符号链接,安全探测其真实目标
|
|
2750
|
-
if (d.isSymbolicLink()) {
|
|
2751
|
-
try {
|
|
2752
|
-
const symCheck = await isSafeWorkspacePath(targetEntryPath);
|
|
2753
|
-
if (!symCheck.valid) continue;
|
|
2754
|
-
isDir = true;
|
|
2755
|
-
} catch {
|
|
2756
|
-
continue;
|
|
2757
|
-
}
|
|
2758
|
-
}
|
|
2759
|
-
|
|
2760
|
-
if (isDir) {
|
|
2761
|
-
entries.push({
|
|
2762
|
-
name: d.name,
|
|
2763
|
-
path: targetEntryPath,
|
|
2764
|
-
isDirectory: true,
|
|
2765
|
-
});
|
|
2766
|
-
}
|
|
2767
|
-
}
|
|
2768
|
-
} catch (err) {
|
|
2769
|
-
readError = err.message;
|
|
2770
|
-
}
|
|
2771
|
-
|
|
2772
|
-
entries.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }));
|
|
2773
|
-
|
|
2774
|
-
const parentPath = dirname(currentPath) !== currentPath ? dirname(currentPath) : null;
|
|
2775
|
-
|
|
2776
|
-
// 5. 生成结构化面包屑导航路径
|
|
2777
|
-
const breadcrumbs = [];
|
|
2778
|
-
if (isWin) {
|
|
2779
|
-
const match = currentPath.match(/^([A-Za-z]:)(?:\\(.*))?$/);
|
|
2780
|
-
if (match) {
|
|
2781
|
-
const driveLetter = match[1];
|
|
2782
|
-
const rest = match[2] || '';
|
|
2783
|
-
breadcrumbs.push({ name: `${driveLetter}`, path: `${driveLetter}\\` });
|
|
2784
|
-
if (rest) {
|
|
2785
|
-
const parts = rest.split('\\').filter(Boolean);
|
|
2786
|
-
let curr = `${driveLetter}\\`;
|
|
2787
|
-
for (const p of parts) {
|
|
2788
|
-
curr = join(curr, p);
|
|
2789
|
-
breadcrumbs.push({ name: p, path: curr });
|
|
2790
|
-
}
|
|
2791
|
-
}
|
|
2792
|
-
} else {
|
|
2793
|
-
breadcrumbs.push({ name: currentPath, path: currentPath });
|
|
2794
|
-
}
|
|
2795
|
-
} else {
|
|
2796
|
-
breadcrumbs.push({ name: '根目录 /', path: '/' });
|
|
2797
|
-
const parts = currentPath.split('/').filter(Boolean);
|
|
2798
|
-
let curr = '/';
|
|
2799
|
-
for (const p of parts) {
|
|
2800
|
-
curr = join(curr, p);
|
|
2801
|
-
breadcrumbs.push({ name: p, path: curr });
|
|
2802
|
-
}
|
|
2803
|
-
}
|
|
2804
|
-
|
|
2805
|
-
// 6. 获取当前已注册的工作区作为快捷参考
|
|
2806
|
-
const currentWorkspaces = await this.getWorkspaces();
|
|
2807
|
-
|
|
2808
|
-
return {
|
|
2809
|
-
ok: !readError,
|
|
2810
|
-
error: readError ? `读取文件夹失败: ${readError}` : undefined,
|
|
2811
|
-
currentPath,
|
|
2812
|
-
parentPath,
|
|
2813
|
-
breadcrumbs,
|
|
2814
|
-
entries: entries.slice(0, 150),
|
|
2815
|
-
totalEntries: entries.length,
|
|
2816
|
-
roots,
|
|
2817
|
-
drives,
|
|
2818
|
-
workspaces: currentWorkspaces,
|
|
2819
|
-
};
|
|
2820
|
-
}
|
|
2821
|
-
|
|
2822
|
-
async diagnoseNetwork() {
|
|
2823
|
-
const results = [];
|
|
2824
|
-
|
|
2825
|
-
// 1. 本地代理端口检测
|
|
2826
|
-
results.push({
|
|
2827
|
-
item: 'local_proxy',
|
|
2828
|
-
name: `本地反向代理端口 (${this.proxyPort})`,
|
|
2829
|
-
status: this.proxy ? 'pass' : 'fail',
|
|
2830
|
-
detail: this.proxy ? `正常运行中 (代理目标端口: ${this.dshPort})` : '代理未启动',
|
|
2831
|
-
});
|
|
2832
|
-
|
|
2833
|
-
// 2. 局域网网卡检测
|
|
2834
|
-
const lanIp = selectLanIPv4();
|
|
2835
|
-
results.push({
|
|
2836
|
-
item: 'lan_interface',
|
|
2837
|
-
name: '局域网 IP 分配与可用性',
|
|
2838
|
-
status: lanIp ? 'pass' : 'warn',
|
|
2839
|
-
detail: lanIp ? `检测到有效局域网 IPv4: ${lanIp}` : '未检测到活跃局域网 IPv4 地址 (可能未连接 Wi-Fi/以太网)',
|
|
2840
|
-
});
|
|
2841
|
-
|
|
2842
|
-
// 3. Cloudflare 边缘连通性测试
|
|
2843
|
-
const cfStart = Date.now();
|
|
2844
|
-
try {
|
|
2845
|
-
await new Promise((resolve, reject) => {
|
|
2846
|
-
const req = httpsGet('https://1.1.1.1', { timeout: 3500 }, (res) => {
|
|
2847
|
-
res.resume();
|
|
2848
|
-
resolve();
|
|
2849
|
-
});
|
|
2850
|
-
req.on('error', reject);
|
|
2851
|
-
req.on('timeout', () => { req.destroy(); reject(new Error('连接超时 (3.5s)')); });
|
|
2852
|
-
});
|
|
2853
|
-
const cfLatency = Date.now() - cfStart;
|
|
2854
|
-
results.push({
|
|
2855
|
-
item: 'cloudflare_edge',
|
|
2856
|
-
name: 'Cloudflare Anycast 边缘网络',
|
|
2857
|
-
status: 'pass',
|
|
2858
|
-
latencyMs: cfLatency,
|
|
2859
|
-
detail: `连接畅通 (延迟 ${cfLatency}ms)`,
|
|
2860
|
-
});
|
|
2861
|
-
} catch (err) {
|
|
2862
|
-
results.push({
|
|
2863
|
-
item: 'cloudflare_edge',
|
|
2864
|
-
name: 'Cloudflare Anycast 边缘网络',
|
|
2865
|
-
status: 'warn',
|
|
2866
|
-
detail: `连接异常: ${err.message} (临时公网隧道可能受阻)`,
|
|
2867
|
-
});
|
|
2868
|
-
}
|
|
2869
|
-
|
|
2870
|
-
// 4. 国内 npm 高速镜像源 (npmmirror)
|
|
2871
|
-
const npmStart = Date.now();
|
|
2872
|
-
try {
|
|
2873
|
-
await new Promise((resolve, reject) => {
|
|
2874
|
-
const req = httpsGet('https://registry.npmmirror.com', { timeout: 3500 }, (res) => {
|
|
2875
|
-
res.resume();
|
|
2876
|
-
resolve();
|
|
2877
|
-
});
|
|
2878
|
-
req.on('error', reject);
|
|
2879
|
-
req.on('timeout', () => { req.destroy(); reject(new Error('连接超时 (3.5s)')); });
|
|
2880
|
-
});
|
|
2881
|
-
const npmLatency = Date.now() - npmStart;
|
|
2882
|
-
results.push({
|
|
2883
|
-
item: 'npmmirror',
|
|
2884
|
-
name: '国内 npm 高速镜像源 (npmmirror)',
|
|
2885
|
-
status: 'pass',
|
|
2886
|
-
latencyMs: npmLatency,
|
|
2887
|
-
detail: `连接畅通 (延迟 ${npmLatency}ms)`,
|
|
2888
|
-
});
|
|
2889
|
-
} catch (err) {
|
|
2890
|
-
results.push({
|
|
2891
|
-
item: 'npmmirror',
|
|
2892
|
-
name: '国内 npm 高速镜像源 (npmmirror)',
|
|
2893
|
-
status: 'warn',
|
|
2894
|
-
detail: `连接超时或异常: ${err.message}`,
|
|
2895
|
-
});
|
|
2896
|
-
}
|
|
2897
|
-
|
|
2898
|
-
// 5. 自建隧道部署服务器连通性检测
|
|
2899
|
-
const customServerUrl = this.customTunnelConfig?.serverUrl?.trim();
|
|
2900
|
-
if (customServerUrl) {
|
|
2901
|
-
const isRunning = Boolean(this.customTunnel?.connected);
|
|
2902
|
-
const ctStart = Date.now();
|
|
2903
|
-
try {
|
|
2904
|
-
const parsedUrl = new URL(customServerUrl);
|
|
2905
|
-
const isSecure = parsedUrl.protocol === 'https:' || parsedUrl.protocol === 'wss:';
|
|
2906
|
-
const getter = isSecure ? httpsGet : httpGet;
|
|
2907
|
-
const probeUrl = new URL(customServerUrl);
|
|
2908
|
-
probeUrl.protocol = isSecure ? 'https:' : 'http:';
|
|
2909
|
-
|
|
2910
|
-
await new Promise((resolve, reject) => {
|
|
2911
|
-
const req = getter(probeUrl.toString(), { timeout: 4000 }, (res) => {
|
|
2912
|
-
res.resume();
|
|
2913
|
-
resolve();
|
|
2914
|
-
});
|
|
2915
|
-
req.on('error', reject);
|
|
2916
|
-
req.on('timeout', () => { req.destroy(); reject(new Error('连接超时 (4.0s)')); });
|
|
2917
|
-
});
|
|
2918
|
-
const ctLatency = Date.now() - ctStart;
|
|
2919
|
-
results.push({
|
|
2920
|
-
item: 'custom_tunnel_server',
|
|
2921
|
-
name: `自建隧道部署服务器 (${parsedUrl.hostname}${parsedUrl.port ? `:${parsedUrl.port}` : ''})`,
|
|
2922
|
-
status: 'pass',
|
|
2923
|
-
latencyMs: ctLatency,
|
|
2924
|
-
detail: `服务器连通良好 (延迟 ${ctLatency}ms · 状态: ${isRunning ? '客户端在线运行中' : '待连接/就绪'})`,
|
|
2925
|
-
});
|
|
2926
|
-
} catch (err) {
|
|
2927
|
-
if (isRunning) {
|
|
2928
|
-
results.push({
|
|
2929
|
-
item: 'custom_tunnel_server',
|
|
2930
|
-
name: `自建隧道部署服务器 (${customServerUrl})`,
|
|
2931
|
-
status: 'pass',
|
|
2932
|
-
detail: '客户端在线运行中 (WebSocket 通道已建立)',
|
|
2933
|
-
});
|
|
2934
|
-
} else {
|
|
2935
|
-
results.push({
|
|
2936
|
-
item: 'custom_tunnel_server',
|
|
2937
|
-
name: `自建隧道部署服务器 (${customServerUrl})`,
|
|
2938
|
-
status: 'warn',
|
|
2939
|
-
detail: `无法连通自建服务器: ${err.message}`,
|
|
2940
|
-
});
|
|
2941
|
-
}
|
|
2942
|
-
}
|
|
2943
|
-
} else {
|
|
2944
|
-
results.push({
|
|
2945
|
-
item: 'custom_tunnel_server',
|
|
2946
|
-
name: '自建隧道部署服务器',
|
|
2947
|
-
status: 'pass',
|
|
2948
|
-
detail: '未配置自建服务器(若已部署自建隧道可在「公网隧道」中配置)',
|
|
2949
|
-
});
|
|
2950
|
-
}
|
|
2951
|
-
|
|
2952
|
-
const allPassed = results.every(r => r.status === 'pass');
|
|
2953
|
-
return {
|
|
2954
|
-
ok: true,
|
|
2955
|
-
timestamp: new Date().toISOString(),
|
|
2956
|
-
overall: allPassed ? 'healthy' : 'warning',
|
|
2957
|
-
results,
|
|
2958
|
-
};
|
|
2959
|
-
}
|
|
2960
|
-
|
|
2961
|
-
async dispose() {
|
|
2962
|
-
await this._haltCustomTunnel();
|
|
2963
|
-
// 只停进程与状态,不改持久化:插件卸载/重载不应悄悄抹掉用户的 autoStart 意图
|
|
2964
|
-
await this._haltCloudflared();
|
|
2965
|
-
if (this.proxy) {
|
|
2966
|
-
await this.proxy.stop();
|
|
2967
|
-
this.proxy = null;
|
|
2968
|
-
}
|
|
2969
|
-
this.qrCache.clear();
|
|
2970
|
-
}
|
|
2971
|
-
}
|
|
2972
|
-
|
|
2973
|
-
/**
|
|
2974
|
-
* 插件入口
|
|
2975
|
-
*/
|
|
2976
|
-
/**
|
|
2977
|
-
* 同步读取用户页面改写偏好(设置面板可见开关)。文件缺失/损坏一律默认开启。
|
|
2978
|
-
* @param {string} configFile config.json 绝对路径
|
|
2979
|
-
* @returns {boolean}
|
|
2980
|
-
*/
|
|
2981
|
-
export function readUiPageTweaksPref(configFile) {
|
|
2982
|
-
try {
|
|
2983
|
-
const stored = JSON.parse(readFileSync(configFile, 'utf8'));
|
|
2984
|
-
if (stored?.ui && typeof stored.ui.pageTweaks === 'boolean') return stored.ui.pageTweaks;
|
|
2985
|
-
} catch {}
|
|
2986
|
-
return true;
|
|
2987
|
-
}
|
|
2988
|
-
|
|
2989
|
-
/**
|
|
2990
|
-
* 解析移动端页面改写层总开关(issue #56 §4 包级别 opt-in)。
|
|
2991
|
-
* 关闭条件(任一命中即关):宿主插件配置 `mobileUi: false`,或环境变量
|
|
2992
|
-
* `DSH_BRIDGE_MOBILE_UI=0/false/off/no`。默认开启,不影响远程访问/隧道/IM。
|
|
2993
|
-
* @param {object} [pluginConfig] 宿主传给 apply 的插件配置
|
|
2994
|
-
* @returns {boolean} true=启用页面改写层,false=整层跳过
|
|
2995
|
-
*/
|
|
2996
|
-
export function resolveMobileUiEnabled(pluginConfig = {}) {
|
|
2997
|
-
const offValues = new Set(['0', 'false', 'off', 'no', 'disable', 'disabled']);
|
|
2998
|
-
const isOff = (v) => v === false || (typeof v === 'string' && offValues.has(v.trim().toLowerCase()));
|
|
2999
|
-
if (isOff(pluginConfig?.mobileUi)) return false;
|
|
3000
|
-
try {
|
|
3001
|
-
if (isOff(process.env.DSH_BRIDGE_MOBILE_UI)) return false;
|
|
3002
|
-
} catch {}
|
|
3003
|
-
return true;
|
|
3004
|
-
}
|
|
3005
|
-
|
|
3006
|
-
// 服务端 mobileUi 关闭时向被代理页面注入的配置合并脚本(去重键):让客户端总开关
|
|
3007
|
-
// 在 apply() 之前即可见。注意:仅经桥接代理的页面生效;桌面宿主请自行声明
|
|
3008
|
-
// window.__DSH_BRIDGE_CONFIG__(见 mobile-yield.js 注释与 #56)。
|
|
3009
|
-
const BRIDGE_CLIENT_CONFIG_MARK = 'data-dsh-bridge-config="1"';
|
|
3010
|
-
const BRIDGE_CLIENT_CONFIG_OFF_HTML = `<script ${BRIDGE_CLIENT_CONFIG_MARK}>try{window.__DSH_BRIDGE_CONFIG__=Object.assign({},window.__DSH_BRIDGE_CONFIG__,{disablePageTweaks:true,mobileUi:false});}catch(e){}</script>`;
|
|
3011
|
-
|
|
3012
|
-
function apply(ctx, config = {}) {
|
|
3013
|
-
const logger = ctx.logger(name);
|
|
3014
|
-
const dshPort = ctx.webServer?.port ?? config.targetPort ?? 3080;
|
|
3015
|
-
|
|
3016
|
-
// 低版本 Node(<20.3)缺少 AbortSignal.any,DSH 核心链路(dsh-timeout ← dsh-llm)
|
|
3017
|
-
// 每次 agent 请求都会调用它——缺失时通过桥接发送消息直接报 "(internal)"。
|
|
3018
|
-
// 插件加载即安装兼容垫片,并在缺失时告警引导升级。
|
|
3019
|
-
if (installAbortSignalCompat()) {
|
|
3020
|
-
logger.warn('dsh-bridge: 当前 Node %s 缺少 AbortSignal.any/timeout,已安装兼容垫片。建议升级 Node 至 22.19+ 或 24+(见 package.json engines)。', process.version);
|
|
3021
|
-
}
|
|
3022
|
-
|
|
3023
|
-
if (!dshPort) {
|
|
3024
|
-
logger.error('webServer port unavailable');
|
|
3025
|
-
return;
|
|
3026
|
-
}
|
|
3027
|
-
|
|
3028
|
-
const proxyPort = config.port ?? 3082;
|
|
3029
|
-
const dshHome = process.env.DSH_HOME ?? join(homedir(), '.dsh');
|
|
3030
|
-
const configFile = join(dshHome, 'dsh-bridge', 'config.json');
|
|
3031
|
-
const emergencyResetFile = join(dshHome, 'dsh-bridge', 'reset-auth');
|
|
3032
|
-
|
|
3033
|
-
// 包级别 opt-in(issue #56 §4):宿主在加载阶段决定不要移动页面改写层。
|
|
3034
|
-
// 注意:必须放在 configFile 定义之后(TDZ:提前引用会抛 ReferenceError,见 #55/#56 验收)。
|
|
3035
|
-
const mobileUiEnabled = resolveMobileUiEnabled(config);
|
|
3036
|
-
// 用户偏好(设置面板可见开关,持久化于 config.json.ui.pageTweaks,默认开)
|
|
3037
|
-
const uiPageTweaks = readUiPageTweaksPref(configFile);
|
|
3038
|
-
const pageTweaksEffective = mobileUiEnabled && uiPageTweaks;
|
|
3039
|
-
if (!pageTweaksEffective) {
|
|
3040
|
-
logger.info('dsh-bridge: page-tweak layer disabled (hostAllows=%s userPref=%s)', mobileUiEnabled, uiPageTweaks);
|
|
3041
|
-
if (!HTML_INJECTION_PARTS.some((p) => p.mark === BRIDGE_CLIENT_CONFIG_MARK)) {
|
|
3042
|
-
HTML_INJECTION_PARTS.push({ mark: BRIDGE_CLIENT_CONFIG_MARK, html: BRIDGE_CLIENT_CONFIG_OFF_HTML });
|
|
3043
|
-
}
|
|
3044
|
-
}
|
|
3045
|
-
|
|
3046
|
-
// 配置持久化互斥队列:读-改-写事务整体入队,杜绝多平台并发持久化时
|
|
3047
|
-
// "读到同一份旧配置 → 各自合并 → 后写覆盖先写"的丢失更新问题
|
|
3048
|
-
let configQueue = Promise.resolve();
|
|
3049
|
-
|
|
3050
|
-
// 从 JSON 文件读取持久化配置(只读快照;启动恢复等场景使用)
|
|
3051
|
-
async function loadConfig() {
|
|
3052
|
-
try {
|
|
3053
|
-
const raw = await readFile(configFile, 'utf8');
|
|
3054
|
-
const parsed = JSON.parse(raw);
|
|
3055
|
-
// 配置迁移:customTunnel 无 mode 的旧配置一律视为 self(不额外写盘,改动时自然落盘)
|
|
3056
|
-
if (parsed?.customTunnel) parsed.customTunnel = normalizeCustomTunnelConfig(parsed.customTunnel);
|
|
3057
|
-
return parsed;
|
|
3058
|
-
} catch {
|
|
3059
|
-
return {};
|
|
3060
|
-
}
|
|
3061
|
-
}
|
|
3062
|
-
|
|
3063
|
-
// config.json 含平台 Token / 隧道凭据 / 访问密码哈希,属同机敏感文件:
|
|
3064
|
-
// 落盘固定 0600,与 sessions.json(lib/auth/manager.js)保持一致。
|
|
3065
|
-
const CONFIG_FILE_MODE = 0o600;
|
|
3066
|
-
|
|
3067
|
-
async function writeConfig(data) {
|
|
3068
|
-
await mkdir(join(dshHome, 'dsh-bridge'), { recursive: true });
|
|
3069
|
-
// mode 仅对新建文件生效;已存在的旧文件(历史版本落在 0644)由
|
|
3070
|
-
// ensureConfigFileMode 在启动时收敛,两者配合覆盖新建与存量两种情形。
|
|
3071
|
-
await writeFile(configFile, JSON.stringify(data, null, 2), { encoding: 'utf8', mode: CONFIG_FILE_MODE });
|
|
3072
|
-
}
|
|
3073
|
-
|
|
3074
|
-
// 存量收敛:writeFile 的 mode 不会修改已存在文件的权限位,历史版本已把
|
|
3075
|
-
// config.json 写成 0644(同机其他用户可读)。此处幂等收紧到 0600。
|
|
3076
|
-
// 失败仅告警、不阻断启动(Windows 等平台 chmod 语义不同,且权限收紧
|
|
3077
|
-
// 不应成为插件不可用的原因)。
|
|
3078
|
-
async function ensureConfigFileMode() {
|
|
3079
|
-
try {
|
|
3080
|
-
await access(configFile);
|
|
3081
|
-
} catch (err) {
|
|
3082
|
-
// 仅「文件不存在」属预期(尚未写入,写入时会带上正确 mode)。
|
|
3083
|
-
// 其余错误(EACCES/EIO 等)意味着我们无法确认权限状态,必须出声,
|
|
3084
|
-
// 否则旧版遗留的宽松权限会被静默放过。
|
|
3085
|
-
if (err?.code !== 'ENOENT') {
|
|
3086
|
-
logger.warn('dsh-bridge: 无法检查 config.json 权限状态,跳过收紧:%s', err?.message ?? err);
|
|
3087
|
-
}
|
|
3088
|
-
return;
|
|
3089
|
-
}
|
|
3090
|
-
try {
|
|
3091
|
-
const current = await stat(configFile);
|
|
3092
|
-
const tightened = current.mode & 0o777;
|
|
3093
|
-
if (tightened === CONFIG_FILE_MODE) return;
|
|
3094
|
-
await chmod(configFile, CONFIG_FILE_MODE);
|
|
3095
|
-
logger.info('dsh-bridge: 已将 config.json 权限从 %s 收敛为 0600', tightened.toString(8).padStart(4, '0'));
|
|
3096
|
-
} catch (err) {
|
|
3097
|
-
logger.warn('dsh-bridge: 收紧 config.json 权限失败(不影响运行):%s', err?.message ?? err);
|
|
3098
|
-
}
|
|
3099
|
-
}
|
|
3100
|
-
|
|
3101
|
-
// 整对象写入(同样入队,避免与进行中的事务交错)
|
|
3102
|
-
async function saveConfig(data) {
|
|
3103
|
-
const task = configQueue.then(() => writeConfig(data));
|
|
3104
|
-
configQueue = task.catch((err) => {
|
|
3105
|
-
logger.error('saveConfig failed: %s', err.message);
|
|
3106
|
-
});
|
|
3107
|
-
return task;
|
|
3108
|
-
}
|
|
3109
|
-
|
|
3110
|
-
// 读-改-写事务:mutate(current) 在队列内执行并返回新配置对象,与其他持久化调用严格串行
|
|
3111
|
-
async function updateConfig(mutate) {
|
|
3112
|
-
const task = configQueue.then(async () => {
|
|
3113
|
-
const current = await loadConfig();
|
|
3114
|
-
const next = (await mutate(current)) ?? current;
|
|
3115
|
-
await writeConfig(next);
|
|
3116
|
-
return next;
|
|
3117
|
-
});
|
|
3118
|
-
configQueue = task.catch((err) => {
|
|
3119
|
-
logger.error('updateConfig failed: %s', err.message);
|
|
3120
|
-
});
|
|
3121
|
-
return task;
|
|
3122
|
-
}
|
|
3123
|
-
|
|
3124
|
-
// 访问安全认证管理器
|
|
3125
|
-
const authManager = new AuthManager({
|
|
3126
|
-
config: config.auth ?? {},
|
|
3127
|
-
logger,
|
|
3128
|
-
onPersist: (patch) => updateConfig((stored) => {
|
|
3129
|
-
stored.auth = { ...(stored.auth ?? {}), ...patch };
|
|
3130
|
-
return stored;
|
|
3131
|
-
}),
|
|
3132
|
-
});
|
|
3133
|
-
|
|
3134
|
-
// 保命救急检查:检测到 reset-auth 文件时自动重置全量密码与安全策略
|
|
3135
|
-
async function checkEmergencyReset() {
|
|
3136
|
-
try {
|
|
3137
|
-
await unlink(emergencyResetFile);
|
|
3138
|
-
authManager.enabled = false;
|
|
3139
|
-
authManager.passwordHash = '';
|
|
3140
|
-
authManager.passwordSalt = '';
|
|
3141
|
-
authManager.adminPasswordHash = '';
|
|
3142
|
-
authManager.adminPasswordSalt = '';
|
|
3143
|
-
authManager.adminPolicy = 'password_unlock';
|
|
3144
|
-
authManager.mode = 'token_and_password';
|
|
3145
|
-
authManager.sessions.clear();
|
|
3146
|
-
authManager.adminSessions.clear();
|
|
3147
|
-
await updateConfig((stored) => {
|
|
3148
|
-
delete stored.auth;
|
|
3149
|
-
return stored;
|
|
3150
|
-
});
|
|
3151
|
-
logger.warn('dsh-bridge: [保命救急] 检测到 reset-auth 标记文件,已成功重置所有访问密码与安全策略!');
|
|
3152
|
-
} catch {}
|
|
3153
|
-
}
|
|
3154
|
-
|
|
3155
|
-
// 启动时读取已保存的 auth 配置并执行保命标记检查
|
|
3156
|
-
checkEmergencyReset().then(() => ensureConfigFileMode()).then(() => loadConfig()).then((stored) => {
|
|
3157
|
-
if (stored?.auth) {
|
|
3158
|
-
if (stored.auth.enabled != null) authManager.enabled = Boolean(stored.auth.enabled);
|
|
3159
|
-
if (stored.auth.mode) authManager.mode = stored.auth.mode;
|
|
3160
|
-
if (stored.auth.scope) authManager.scope = stored.auth.scope;
|
|
3161
|
-
if (stored.auth.adminPolicy) authManager.adminPolicy = stored.auth.adminPolicy;
|
|
3162
|
-
if (stored.auth.adminProtection != null) authManager.adminProtection = stored.auth.adminProtection !== false;
|
|
3163
|
-
if (stored.auth.passwordHash) authManager.passwordHash = stored.auth.passwordHash;
|
|
3164
|
-
if (stored.auth.passwordSalt) authManager.passwordSalt = stored.auth.passwordSalt;
|
|
3165
|
-
if (stored.auth.adminPasswordHash) authManager.adminPasswordHash = stored.auth.adminPasswordHash;
|
|
3166
|
-
if (stored.auth.adminPasswordSalt) authManager.adminPasswordSalt = stored.auth.adminPasswordSalt;
|
|
3167
|
-
if (stored.auth.secretToken) authManager.secretToken = stored.auth.secretToken;
|
|
3168
|
-
if (stored.auth.allowLoopback != null) authManager.allowLoopback = Boolean(stored.auth.allowLoopback);
|
|
3169
|
-
logger.info('dsh-bridge: loaded saved auth config (enabled=%s, mode=%s, adminPolicy=%s, adminProtection=%s)', authManager.enabled, authManager.mode, authManager.adminPolicy, authManager.adminProtection);
|
|
3170
|
-
}
|
|
3171
|
-
}).catch(() => {});
|
|
3172
|
-
|
|
3173
|
-
const service = new BridgeService({
|
|
3174
|
-
mobileUiEnabled,
|
|
3175
|
-
// SSE 空闲保活(issue #71):仅显式 false 关闭,缺省/其余取值均为开启
|
|
3176
|
-
sseKeepalive: config.sseKeepalive !== false,
|
|
3177
|
-
dshPort,
|
|
3178
|
-
proxyPort,
|
|
3179
|
-
home: config.home,
|
|
3180
|
-
customTunnelConfig: config.customTunnel ?? null,
|
|
3181
|
-
cloudflaredConfig: config.cloudflared ?? null,
|
|
3182
|
-
lanConfig: config.lan ?? null,
|
|
3183
|
-
authManager,
|
|
3184
|
-
onPersist: (patch) => updateConfig((stored) => Object.assign(stored, patch)),
|
|
3185
|
-
logger,
|
|
3186
|
-
});
|
|
3187
|
-
service.uiPageTweaks = uiPageTweaks;
|
|
3188
|
-
|
|
3189
|
-
// 启动时读取已保存的局域网网卡配置与公网隧道配置并按需自动拉起
|
|
3190
|
-
loadConfig().then(async (stored) => {
|
|
3191
|
-
restorePersistedServiceState({ service, stored, logger, authEnabled: authManager.enabled });
|
|
3192
|
-
}).catch(() => {});
|
|
3193
|
-
|
|
3194
|
-
// 平台管理器:注册/协调所有 IM 平台适配器
|
|
3195
|
-
const platformManager = new PlatformManager({ logger });
|
|
3196
|
-
|
|
3197
|
-
// ---- 平台多账号装配(issue #78:同平台多实例,替代四段静态构造 + 逐平台恢复)----
|
|
3198
|
-
// 新增平台只需在 platformCtors / platformSpecs 各加一行。
|
|
3199
|
-
const platformCtors = {
|
|
3200
|
-
wechat: WechatService, // 微信 Bot(ClawBot/iLink)
|
|
3201
|
-
qq: QqService, // QQ Bot(OpenAPI v2)
|
|
3202
|
-
feishu: FeishuService, // 飞书 Bot(官方 OpenAPI / WebSocket 长连接)
|
|
3203
|
-
telegram: TelegramService, // Telegram Bot(Long Polling + 代理)
|
|
3204
|
-
};
|
|
3205
|
-
const platformSpecs = {
|
|
3206
|
-
wechat: {
|
|
3207
|
-
numericFields: PLATFORM_TIMING_FIELDS,
|
|
3208
|
-
defaultMaxMessageChars: 2000,
|
|
3209
|
-
hasCredentials: (cfg) => Boolean(cfg.token && cfg.accountId),
|
|
3210
|
-
credentialState: (service) => ({
|
|
3211
|
-
token: service.gateway.c?.token,
|
|
3212
|
-
accountId: service.gateway.c?.accountId,
|
|
3213
|
-
}),
|
|
3214
|
-
applyCredentials: (service, cfg) => service.gateway.setCredentials({
|
|
3215
|
-
token: cfg.token,
|
|
3216
|
-
accountId: cfg.accountId,
|
|
3217
|
-
baseUrl: cfg.baseUrl,
|
|
3218
|
-
}),
|
|
3219
|
-
},
|
|
3220
|
-
qq: {
|
|
3221
|
-
numericFields: PLATFORM_TIMING_FIELDS,
|
|
3222
|
-
defaultMaxMessageChars: 2000,
|
|
3223
|
-
// 凭证字段名随官方 SDK 统一为 appSecret(旧配置里的 clientSecret 由
|
|
3224
|
-
// normalizePlatformSection / 读路径兼容,见 migrateLegacyQqSecret)
|
|
3225
|
-
hasCredentials: (cfg) => Boolean(cfg.appId && (cfg.appSecret || cfg.clientSecret)),
|
|
3226
|
-
credentialState: (service) => ({
|
|
3227
|
-
appId: service.gateway.config?.appId,
|
|
3228
|
-
appSecret: service.gateway.config?.appSecret,
|
|
3229
|
-
}),
|
|
3230
|
-
applyCredentials: (service, cfg) => service.gateway.setCredentials({
|
|
3231
|
-
appId: cfg.appId,
|
|
3232
|
-
appSecret: cfg.appSecret || cfg.clientSecret,
|
|
3233
|
-
markdownSupport: cfg.markdownSupport,
|
|
3234
|
-
requireMentionInGroup: cfg.requireMentionInGroup,
|
|
3235
|
-
}),
|
|
3236
|
-
},
|
|
3237
|
-
feishu: {
|
|
3238
|
-
numericFields: PLATFORM_TIMING_FIELDS,
|
|
3239
|
-
defaultMaxMessageChars: 2000,
|
|
3240
|
-
hasCredentials: (cfg) => Boolean(cfg.appId && cfg.appSecret),
|
|
3241
|
-
credentialState: (service) => ({
|
|
3242
|
-
appId: service.gateway.config?.appId,
|
|
3243
|
-
appSecret: service.gateway.config?.appSecret,
|
|
3244
|
-
domain: service.gateway.config?.domain,
|
|
3245
|
-
}),
|
|
3246
|
-
applyCredentials: (service, cfg) => service.gateway.updateConfig({
|
|
3247
|
-
appId: cfg.appId,
|
|
3248
|
-
appSecret: cfg.appSecret,
|
|
3249
|
-
domain: cfg.domain || 'feishu',
|
|
3250
|
-
slashCommandIds: cfg.slashCommandIds ?? {},
|
|
3251
|
-
}),
|
|
3252
|
-
},
|
|
3253
|
-
telegram: {
|
|
3254
|
-
numericFields: PLATFORM_TIMING_FIELDS,
|
|
3255
|
-
defaultMaxMessageChars: 4096,
|
|
3256
|
-
hasCredentials: (cfg) => Boolean(cfg.botToken),
|
|
3257
|
-
credentialState: (service) => ({
|
|
3258
|
-
botToken: service.gateway.config?.botToken,
|
|
3259
|
-
proxy: service.gateway.config?.proxy,
|
|
3260
|
-
}),
|
|
3261
|
-
applyCredentials: (service, cfg) => service.gateway.setCredentials({
|
|
3262
|
-
botToken: cfg.botToken,
|
|
3263
|
-
proxy: cfg.proxy || '',
|
|
3264
|
-
}),
|
|
3265
|
-
},
|
|
3266
|
-
};
|
|
3267
|
-
|
|
3268
|
-
// 兼容旧引用:platforms[type] 指向该类型第一个实例(可能不存在 = 该类型 0 账号)
|
|
3269
|
-
const platforms = {};
|
|
3270
|
-
|
|
3271
|
-
const instanceIdOf = (platform) => platform?.instanceId ?? platform?.id;
|
|
3272
|
-
|
|
3273
|
-
/** 把 account 的实例元信息(展示名 / 备注名)同步到服务实例(供 getStatus 回显)。 */
|
|
3274
|
-
function applyAccountMeta(service, type, account, index) {
|
|
3275
|
-
service.displayName = displayNameFor({
|
|
3276
|
-
type,
|
|
3277
|
-
// 平台中文名:飞书服务类的 this.name 是英文 'Feishu',这里按契约用中文名
|
|
3278
|
-
label: PLATFORM_TYPE_LABELS[type] ?? service.name,
|
|
3279
|
-
account,
|
|
3280
|
-
index,
|
|
3281
|
-
});
|
|
3282
|
-
service.config = {
|
|
3283
|
-
...(service.config ?? {}),
|
|
3284
|
-
name: typeof account?.name === 'string' ? account.name : '',
|
|
3285
|
-
};
|
|
3286
|
-
}
|
|
3287
|
-
|
|
3288
|
-
/** 按 account 构造一个平台实例;onPersist 只 read-modify-write 目标 account。 */
|
|
3289
|
-
function createPlatformInstance(type, account, index, serviceName) {
|
|
3290
|
-
const Ctor = platformCtors[type];
|
|
3291
|
-
const instanceId = account.instanceId;
|
|
3292
|
-
let service = null;
|
|
3293
|
-
service = new Ctor({
|
|
3294
|
-
ctx,
|
|
3295
|
-
logger,
|
|
3296
|
-
instanceId,
|
|
3297
|
-
// cordis 服务名不复用(D2):首次 instanceId,重复创建 instanceId#n
|
|
3298
|
-
serviceName,
|
|
3299
|
-
config: { ...account },
|
|
3300
|
-
// QQ 的官方 SDK 会把 RESUME 会话落盘到该目录(跨重启优先 RESUME);
|
|
3301
|
-
// 其它平台忽略此参数。
|
|
3302
|
-
dataDir: join(dshHome, 'dsh-bridge'),
|
|
3303
|
-
onPersist: async (patch) => {
|
|
3304
|
-
const stored = await updateConfig((current) => {
|
|
3305
|
-
current[type] = patchAccount(current[type], instanceId, patch);
|
|
3306
|
-
return current;
|
|
3307
|
-
});
|
|
3308
|
-
// 备注名变化后立即刷新展示名,避免 listPlatforms 要等下次同步才更新
|
|
3309
|
-
if (service && patch && Object.prototype.hasOwnProperty.call(patch, 'name')) {
|
|
3310
|
-
const { section } = normalizePlatformSection(type, stored?.[type]);
|
|
3311
|
-
const idx = section.accounts.findIndex((item) => item.instanceId === instanceId);
|
|
3312
|
-
if (idx >= 0) applyAccountMeta(service, type, section.accounts[idx], idx + 1);
|
|
3313
|
-
}
|
|
3314
|
-
return stored;
|
|
3315
|
-
},
|
|
3316
|
-
});
|
|
3317
|
-
applyAccountMeta(service, type, account, index);
|
|
3318
|
-
return service;
|
|
3319
|
-
}
|
|
3320
|
-
|
|
3321
|
-
/**
|
|
3322
|
-
* 把一个 account 的持久化配置回写到实例:
|
|
3323
|
-
* 白名单/数值字段/会话字符串字段 → 活动会话恢复(_restoringConfig 屏障)→ 凭证注入。
|
|
3324
|
-
* 与旧 restorePlatform 语义一致,但按账号执行。
|
|
3325
|
-
*/
|
|
3326
|
-
async function restorePlatformAccount(service, type, account, { created = false } = {}) {
|
|
3327
|
-
const spec = platformSpecs[type];
|
|
3328
|
-
const node = service.node;
|
|
3329
|
-
if (!node || !spec) return;
|
|
3330
|
-
applyRestoredPlatformConfig(node.config, account, {
|
|
3331
|
-
numericFields: spec.numericFields,
|
|
3332
|
-
stringFields: RESTORED_STRING_FIELDS,
|
|
3333
|
-
defaultMaxMessageChars: spec.defaultMaxMessageChars,
|
|
3334
|
-
});
|
|
3335
|
-
|
|
3336
|
-
node._restoringConfig = (async () => {
|
|
3337
|
-
if (account.activeSessionId) {
|
|
3338
|
-
node.activeSessionId = account.activeSessionId;
|
|
3339
|
-
logger.info('dsh-bridge: restored %s active session: %s', account.instanceId, account.activeSessionId);
|
|
3340
|
-
} else if (created) {
|
|
3341
|
-
// 仅新建实例回落到默认会话;已在运行的实例不因一次同步而被切走当前会话
|
|
3342
|
-
await node._pickDefaultSession().catch(() => {});
|
|
3343
|
-
}
|
|
3344
|
-
})();
|
|
3345
|
-
await node._restoringConfig;
|
|
3346
|
-
|
|
3347
|
-
// 凭证注入:新建实例必做;已有实例仅在凭证确实变化时注入(wechat 的
|
|
3348
|
-
// setCredentials 会触发重连,不能在每次同步时无谓重启长连接)
|
|
3349
|
-
if (spec.hasCredentials(account) && (created || !credentialsMatch(spec, service, account))) {
|
|
3350
|
-
spec.applyCredentials(service, account);
|
|
3351
|
-
}
|
|
3352
|
-
}
|
|
3353
|
-
|
|
3354
|
-
/** 实例内已有凭证是否与持久化 account 一致(避免重复注入触发无谓重连)。 */
|
|
3355
|
-
function credentialsMatch(spec, service, account) {
|
|
3356
|
-
if (typeof spec?.credentialState !== 'function') return false;
|
|
3357
|
-
const current = spec.credentialState(service) ?? {};
|
|
3358
|
-
return Object.entries(current).every(([key, value]) => String(value ?? '') === String(account?.[key] ?? ''));
|
|
3359
|
-
}
|
|
3360
|
-
|
|
3361
|
-
/** 读取 config 并做四类型规范化;仅在发生迁移时写回一次。 */
|
|
3362
|
-
async function loadNormalizedPlatformConfig() {
|
|
3363
|
-
const raw = await loadConfig();
|
|
3364
|
-
const { config: normalized, changed } = normalizePlatformConfig(raw);
|
|
3365
|
-
if (!changed) return normalized;
|
|
3366
|
-
// 迁移写回放在写队列内二次判定,避免与其他持久化事务交错覆盖
|
|
3367
|
-
return updateConfig((stored) => {
|
|
3368
|
-
const result = normalizePlatformConfig(stored);
|
|
3369
|
-
return result.changed ? result.config : stored;
|
|
3370
|
-
});
|
|
3371
|
-
}
|
|
3372
|
-
|
|
3373
|
-
/** RPC 响应用的实例 status(含 instanceId/platformId/displayName)。 */
|
|
3374
|
-
function platformStatusOf(service) {
|
|
3375
|
-
const instanceId = instanceIdOf(service);
|
|
3376
|
-
const status = platformManager.getStatus()[instanceId];
|
|
3377
|
-
if (status) return status;
|
|
3378
|
-
try { return service.getStatus(); } catch (err) {
|
|
3379
|
-
return { id: service?.id, instanceId, status: 'error', error: err?.message ?? String(err) };
|
|
3380
|
-
}
|
|
3381
|
-
}
|
|
3382
|
-
|
|
3383
|
-
// 账号运行时编排(issue #78 / D2、D4):add / remove / 查重 / 同步共用同一个串行队列,
|
|
3384
|
-
// 编排细节在 lib/platform/account-ops.js,这里只做依赖接线
|
|
3385
|
-
const accountOps = createPlatformAccountOps({
|
|
3386
|
-
logger,
|
|
3387
|
-
ctx,
|
|
3388
|
-
loadNormalizedConfig: loadNormalizedPlatformConfig,
|
|
3389
|
-
updateConfig,
|
|
3390
|
-
platformManager,
|
|
3391
|
-
createInstance: (type, account, index, { serviceName }) => createPlatformInstance(type, account, index, serviceName),
|
|
3392
|
-
restoreInstance: (service, type, account, created) => restorePlatformAccount(service, type, account, { created }),
|
|
3393
|
-
startInstance: async (service, type, account) => {
|
|
3394
|
-
// 无完整凭证:保持 idle,不拉起长连接
|
|
3395
|
-
if (!platformSpecs[type]?.hasCredentials(account)) return false;
|
|
3396
|
-
await service.start();
|
|
3397
|
-
return true;
|
|
3398
|
-
},
|
|
3399
|
-
statusOf: platformStatusOf,
|
|
3400
|
-
// 兼容旧引用:platforms[type] 指向该类型第一个实例(0 账号时删除)
|
|
3401
|
-
onInstancesChanged: (type, first) => {
|
|
3402
|
-
if (first) platforms[type] = first;
|
|
3403
|
-
else delete platforms[type];
|
|
3404
|
-
},
|
|
3405
|
-
});
|
|
3406
|
-
|
|
3407
|
-
// 启动时读取配置 → 迁移(只写一次)→ 按账号恢复/启动;失败只记日志,不阻断插件启动
|
|
3408
|
-
void accountOps.syncAccounts({ start: true }).then((report) => {
|
|
3409
|
-
if (report?.errors?.length) {
|
|
3410
|
-
logger.warn('dsh-bridge: 平台账号初始化存在 %d 个失败项(详见日志)', report.errors.length);
|
|
3411
|
-
}
|
|
3412
|
-
}).catch((err) => {
|
|
3413
|
-
logger.error('dsh-bridge: 平台账号初始化失败:%s', err?.stack ?? err);
|
|
3414
|
-
});
|
|
3415
|
-
|
|
3416
|
-
const disposeRpc = installBridgeRpc(ctx, {
|
|
3417
|
-
service,
|
|
3418
|
-
authManager,
|
|
3419
|
-
platformManager,
|
|
3420
|
-
logger,
|
|
3421
|
-
// 运行中任务探测所需的引用:探测失败一律降级为「无任务」,不阻断重启
|
|
3422
|
-
runtime: { ctx, platformManager },
|
|
3423
|
-
// 多账号管理(issue #78):缺失时 RPC 返回「多账号能力未接线」
|
|
3424
|
-
addPlatformAccount: accountOps.addAccount,
|
|
3425
|
-
removePlatformAccount: accountOps.removeAccount,
|
|
3426
|
-
validatePlatformAccountConfig: accountOps.validateAccountConfig,
|
|
3427
|
-
// 页面改写层用户偏好(设置面板可见开关):读返回三态,写持久化 + 同步内存
|
|
3428
|
-
getUiFlags: async () => ({
|
|
3429
|
-
pageTweaks: service.uiPageTweaks ?? true,
|
|
3430
|
-
hostAllows: service.mobileUiEnabled ?? true,
|
|
3431
|
-
effective: (service.mobileUiEnabled ?? true) && (service.uiPageTweaks ?? true),
|
|
3432
|
-
}),
|
|
3433
|
-
setUiFlags: async (patch) => {
|
|
3434
|
-
if (patch.pageTweaks !== undefined) {
|
|
3435
|
-
service.uiPageTweaks = patch.pageTweaks === true;
|
|
3436
|
-
await updateConfig((current) => {
|
|
3437
|
-
current.ui = { ...(current.ui ?? {}), pageTweaks: service.uiPageTweaks };
|
|
3438
|
-
return current;
|
|
3439
|
-
});
|
|
3440
|
-
}
|
|
3441
|
-
return {
|
|
3442
|
-
pageTweaks: service.uiPageTweaks ?? true,
|
|
3443
|
-
hostAllows: service.mobileUiEnabled ?? true,
|
|
3444
|
-
effective: (service.mobileUiEnabled ?? true) && (service.uiPageTweaks ?? true),
|
|
3445
|
-
};
|
|
3446
|
-
},
|
|
3447
|
-
saveCustomTunnelConfig: async (serverUrl, accessToken, sseStreaming, extra = {}) => {
|
|
3448
|
-
// 保存前先归一化并校验协议(undefined 保留现值、空串清除):
|
|
3449
|
-
// 避免把永远连不上的地址静默存进配置,直到连接时才在 WebSocket 构造里报错。
|
|
3450
|
-
// userinfo 有意保留(见 assertCustomTunnelServerUrl)。
|
|
3451
|
-
const trimmedServerUrl = normalizeCustomTunnelServerUrl(serverUrl);
|
|
3452
|
-
// SaaS 扩展字段(面板模式切换 / 网关地址):未提供 = 保留现值
|
|
3453
|
-
let nextMode;
|
|
3454
|
-
if (extra.mode !== undefined) {
|
|
3455
|
-
if (extra.mode !== 'self' && extra.mode !== 'saas') {
|
|
3456
|
-
throw new Error('隧道模式只能是 self 或 saas');
|
|
3457
|
-
}
|
|
3458
|
-
nextMode = extra.mode;
|
|
3459
|
-
}
|
|
3460
|
-
const normalizedGatewayUrl = normalizeSaasGatewayUrl(extra.gatewayUrl);
|
|
3461
|
-
const prevMode = normalizeCustomTunnelMode(service.customTunnelConfig?.mode);
|
|
3462
|
-
const prevGatewayUrl = String(service.customTunnelConfig?.gatewayUrl ?? '');
|
|
3463
|
-
const stored = await updateConfig((current) => {
|
|
3464
|
-
const prev = normalizeCustomTunnelConfig(service.customTunnelConfig ?? current.customTunnel ?? {}) ?? {};
|
|
3465
|
-
const next = { ...prev, mode: normalizeCustomTunnelMode(prev.mode) };
|
|
3466
|
-
// 与 saveCloudflaredConfig 同契约:undefined/掩码保留现值,空串清除
|
|
3467
|
-
if (serverUrl !== undefined) next.serverUrl = trimmedServerUrl;
|
|
3468
|
-
if (accessToken !== undefined) next.accessToken = accessToken === '******' ? (prev.accessToken ?? '') : accessToken;
|
|
3469
|
-
if (sseStreaming !== undefined) next.sseStreaming = Boolean(sseStreaming);
|
|
3470
|
-
if (nextMode !== undefined) next.mode = nextMode;
|
|
3471
|
-
if (normalizedGatewayUrl !== undefined) next.gatewayUrl = normalizedGatewayUrl;
|
|
3472
|
-
current.customTunnel = next;
|
|
3473
|
-
return current;
|
|
3474
|
-
});
|
|
3475
|
-
service.customTunnelConfig = normalizeCustomTunnelConfig(stored.customTunnel);
|
|
3476
|
-
// 模式/网关变化后旧连接不再代表当前配置:停掉并清缓存,避免面板显示与配置不一致
|
|
3477
|
-
const modeChanged = nextMode !== undefined && nextMode !== prevMode;
|
|
3478
|
-
const gatewayChanged = normalizedGatewayUrl !== undefined && normalizedGatewayUrl !== prevGatewayUrl;
|
|
3479
|
-
if (modeChanged || (gatewayChanged && prevMode === 'saas')) {
|
|
3480
|
-
service._saasTokenCache = null;
|
|
3481
|
-
if (modeChanged) {
|
|
3482
|
-
service.saasPendingCode = null;
|
|
3483
|
-
service.saasLastError = null;
|
|
3484
|
-
}
|
|
3485
|
-
if (service.customTunnel) await service._haltCustomTunnel();
|
|
3486
|
-
}
|
|
3487
|
-
},
|
|
3488
|
-
exportBackup: async () => {
|
|
3489
|
-
const stored = await loadConfig();
|
|
3490
|
-
return {
|
|
3491
|
-
version: VERSION,
|
|
3492
|
-
exportedAt: new Date().toISOString(),
|
|
3493
|
-
config: stored,
|
|
3494
|
-
};
|
|
3495
|
-
},
|
|
3496
|
-
importBackup: async (backup) => {
|
|
3497
|
-
if (!backup || typeof backup !== 'object' || !backup.config || typeof backup.config !== 'object') {
|
|
3498
|
-
throw new Error('无效的备份数据结构:缺少 config 节点');
|
|
3499
|
-
}
|
|
3500
|
-
const incoming = backup.config;
|
|
3501
|
-
await saveConfig(incoming);
|
|
3502
|
-
|
|
3503
|
-
// 重新载入 Auth
|
|
3504
|
-
if (incoming.auth) {
|
|
3505
|
-
if (incoming.auth.enabled != null) authManager.enabled = Boolean(incoming.auth.enabled);
|
|
3506
|
-
if (incoming.auth.mode) authManager.mode = incoming.auth.mode;
|
|
3507
|
-
if (incoming.auth.scope) authManager.scope = incoming.auth.scope;
|
|
3508
|
-
if (incoming.auth.adminPolicy) authManager.adminPolicy = incoming.auth.adminPolicy;
|
|
3509
|
-
if (incoming.auth.adminProtection != null) authManager.adminProtection = incoming.auth.adminProtection !== false;
|
|
3510
|
-
if (incoming.auth.passwordHash) authManager.passwordHash = incoming.auth.passwordHash;
|
|
3511
|
-
if (incoming.auth.passwordSalt) authManager.passwordSalt = incoming.auth.passwordSalt;
|
|
3512
|
-
if (incoming.auth.adminPasswordHash) authManager.adminPasswordHash = incoming.auth.adminPasswordHash;
|
|
3513
|
-
if (incoming.auth.adminPasswordSalt) authManager.adminPasswordSalt = incoming.auth.adminPasswordSalt;
|
|
3514
|
-
if (incoming.auth.secretToken) authManager.secretToken = incoming.auth.secretToken;
|
|
3515
|
-
}
|
|
3516
|
-
// 重新载入 Tunnels
|
|
3517
|
-
if (incoming.cloudflared) {
|
|
3518
|
-
service.cloudflaredConfig = incoming.cloudflared;
|
|
3519
|
-
}
|
|
3520
|
-
if (incoming.customTunnel) {
|
|
3521
|
-
service.customTunnelConfig = normalizeCustomTunnelConfig(incoming.customTunnel);
|
|
3522
|
-
}
|
|
3523
|
-
// 重新载入 IM 平台:按导入配置重建实例(含旧单对象格式迁移)
|
|
3524
|
-
// create 失败不静默:抛可读错误交给 RPC 渲染(否则账号只在日志里丢)
|
|
3525
|
-
const platformReport = await accountOps.syncAccounts({ start: true });
|
|
3526
|
-
assertNoCreateErrors(platformReport, { action: '备份导入后的平台账号恢复' });
|
|
3527
|
-
|
|
3528
|
-
return { ok: true, message: '配置已成功导入并刷新生效!' };
|
|
3529
|
-
},
|
|
3530
|
-
});
|
|
3531
|
-
|
|
3532
|
-
// 代理随插件自动启动
|
|
3533
|
-
void service.startProxy().catch((err) => {
|
|
3534
|
-
logger.error('dsh-bridge: proxy start failed: %s', err?.message ?? err);
|
|
3535
|
-
});
|
|
3536
|
-
|
|
3537
|
-
ctx.effect(() => async () => {
|
|
3538
|
-
try { disposeRpc(); } catch {}
|
|
3539
|
-
// platforms[type] 是 platformManager 中的同引用对象,Set 去重后统一收尾
|
|
3540
|
-
const instances = new Set([...platformManager.list(), ...Object.values(platforms).filter(Boolean)]);
|
|
3541
|
-
for (const platform of instances) {
|
|
3542
|
-
try {
|
|
3543
|
-
await platform.destroy?.();
|
|
3544
|
-
} catch (err) {
|
|
3545
|
-
logger.warn('dsh-bridge: 停用平台实例失败:%s', err?.message ?? err);
|
|
3546
|
-
}
|
|
3547
|
-
}
|
|
3548
|
-
platformManager.dispose();
|
|
3549
|
-
authManager.dispose();
|
|
3550
|
-
await service.dispose();
|
|
3551
|
-
}, 'dsh-bridge: stop wechat, qq, feishu, telegram, proxy, auth and tunnels');
|
|
357
|
+
return { gate, dispose };
|
|
3552
358
|
}
|
|
3553
359
|
|
|
3554
|
-
export {
|
|
360
|
+
export { GateProxy } from './gate.js';
|
|
361
|
+
export { SaasTunnelClient } from './saas/tunnel-client.mjs';
|