@yezack/dsh-remote-mobile 1.10.0 → 1.10.2
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 +54 -42
- package/LICENSE +21 -21
- package/README.md +203 -61
- package/README_EN.md +205 -57
- package/lib/auth/gate.js +1 -1
- package/lib/auth/token.d.ts +10 -10
- package/lib/auth/token.js +18 -15
- package/lib/bridge/compat.js +260 -260
- package/lib/bridge/directory-picker.d.ts +118 -0
- package/lib/bridge/directory-picker.js +617 -0
- package/lib/client.js +1010 -178
- package/lib/gateway/detector.d.ts +10 -12
- package/lib/gateway/detector.js +12 -25
- package/lib/gateway/server.d.ts +9 -9
- package/lib/gateway/server.js +10 -10
- package/lib/index.d.ts +1 -0
- package/lib/index.js +86 -35
- package/lib/routes/api.d.ts +15 -12
- package/lib/routes/api.js +113 -4
- package/lib/routes/login-page.js +9 -7
- package/lib/routes/register.d.ts +9 -0
- package/lib/routes/register.js +53 -0
- package/lib/startup-hint.d.ts +5 -0
- package/lib/startup-hint.js +72 -0
- package/lib/styles/style-snippets.js +639 -639
- package/package.json +3 -3
package/lib/routes/api.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
+
import { isIP } from 'node:net';
|
|
1
2
|
import { AUTH_COOKIE_NAME, readGlobalLocale } from '../auth/token.js';
|
|
2
3
|
import { getLocalTailscaleIp, getLocalLanIp, getAllNetworkIps, getClientIp, isTailscaleIp, isLanIp } from '../auth/tailscale.js';
|
|
3
4
|
import { getPublicKeyPem, decryptWithPrivateKey, validateSecretStrength, RSA_KEY_FILE } from '../auth/crypto.js';
|
|
4
5
|
import { getLoginPageHtml } from './login-page.js';
|
|
5
6
|
import { DEFAULT_STYLE_FILE } from '../styles/style-snippets.js';
|
|
6
7
|
import { getPairingBridgeState } from '../bridge/compat.js';
|
|
8
|
+
import { getDirectoryPickerStatus, enableDirectoryPickerBrowse, disableDirectoryPickerBrowse, } from '../bridge/directory-picker.js';
|
|
7
9
|
import { resolveRedirectPath } from '../auth/prefix.js';
|
|
8
10
|
function jsonResponse(res, status, data) {
|
|
9
11
|
res.writeHead(status, {
|
|
@@ -37,10 +39,11 @@ function parseJsonBody(req) {
|
|
|
37
39
|
req.on('error', () => resolve({}));
|
|
38
40
|
});
|
|
39
41
|
}
|
|
40
|
-
export function createRoutes(store, styleStore) {
|
|
42
|
+
export function createRoutes(store, styleStore, routeOptions) {
|
|
41
43
|
return [
|
|
42
44
|
// 0. RSA 公钥获取接口 (RSA-OAEP-SHA256)
|
|
43
45
|
{
|
|
46
|
+
kind: 'exact',
|
|
44
47
|
method: 'GET',
|
|
45
48
|
path: '/api/remote-mobile/public-key',
|
|
46
49
|
handler: async (_req, res) => {
|
|
@@ -53,6 +56,7 @@ export function createRoutes(store, styleStore) {
|
|
|
53
56
|
},
|
|
54
57
|
// 1. 状态查询接口 (返回 Tailscale IP、局域网 LAN IP 与 IP 防暴破安全统计)
|
|
55
58
|
{
|
|
59
|
+
kind: 'exact',
|
|
56
60
|
method: 'GET',
|
|
57
61
|
path: '/api/remote-mobile/status',
|
|
58
62
|
handler: async (_req, res) => {
|
|
@@ -86,17 +90,23 @@ export function createRoutes(store, styleStore) {
|
|
|
86
90
|
pairingBridgeMode: bridgeState.mode,
|
|
87
91
|
pairingBridgeConflict: bridgeState.conflictWith || '',
|
|
88
92
|
pairingBridgeConflictId: bridgeState.conflictEntryId || '',
|
|
89
|
-
//
|
|
90
|
-
|
|
93
|
+
// 目录选择器(移动端添加工作区/选择文件夹)状态与补丁信息
|
|
94
|
+
directoryPicker: getDirectoryPickerStatus(routeOptions?.patchFilePath),
|
|
95
|
+
// 安全接入代理运行状态
|
|
96
|
+
proxy: {
|
|
91
97
|
...store.getGatewayStatus(),
|
|
92
98
|
activePort: store.getActivePort(),
|
|
93
|
-
|
|
99
|
+
configuredMode: opts.mode,
|
|
100
|
+
proxyPort: opts.proxyPort || 3081,
|
|
101
|
+
proxyHost: opts.proxyHost || '0.0.0.0',
|
|
102
|
+
trustedHosts: opts.trustedHosts,
|
|
94
103
|
},
|
|
95
104
|
});
|
|
96
105
|
},
|
|
97
106
|
},
|
|
98
107
|
// 2. 生成 6 位短期配对码
|
|
99
108
|
{
|
|
109
|
+
kind: 'exact',
|
|
100
110
|
method: 'POST',
|
|
101
111
|
path: '/api/remote-mobile/generate-code',
|
|
102
112
|
handler: async (_req, res) => {
|
|
@@ -110,6 +120,7 @@ export function createRoutes(store, styleStore) {
|
|
|
110
120
|
},
|
|
111
121
|
// 3. 手机端验证与换取长效 Cookie (支持 RSA-OAEP 密文解密 + 防暴力破解 429 拦截)
|
|
112
122
|
{
|
|
123
|
+
kind: 'exact',
|
|
113
124
|
method: 'POST',
|
|
114
125
|
path: '/api/remote-mobile/verify',
|
|
115
126
|
handler: async (req, res) => {
|
|
@@ -149,6 +160,7 @@ export function createRoutes(store, styleStore) {
|
|
|
149
160
|
},
|
|
150
161
|
// 3.1 手动解除某个 IP 的防暴破锁定
|
|
151
162
|
{
|
|
163
|
+
kind: 'exact',
|
|
152
164
|
method: 'POST',
|
|
153
165
|
path: '/api/remote-mobile/unlock-ip',
|
|
154
166
|
handler: async (req, res) => {
|
|
@@ -160,6 +172,7 @@ export function createRoutes(store, styleStore) {
|
|
|
160
172
|
},
|
|
161
173
|
// 3.2 一键清空所有 IP 访问与安全审计日志
|
|
162
174
|
{
|
|
175
|
+
kind: 'exact',
|
|
163
176
|
method: 'POST',
|
|
164
177
|
path: '/api/remote-mobile/clear-ip-stats',
|
|
165
178
|
handler: async (_req, res) => {
|
|
@@ -169,6 +182,7 @@ export function createRoutes(store, styleStore) {
|
|
|
169
182
|
},
|
|
170
183
|
// 4. 切换 Tailscale / 局域网免密直连开关
|
|
171
184
|
{
|
|
185
|
+
kind: 'exact',
|
|
172
186
|
method: 'POST',
|
|
173
187
|
path: '/api/remote-mobile/toggle-bypass',
|
|
174
188
|
handler: async (req, res) => {
|
|
@@ -193,11 +207,46 @@ export function createRoutes(store, styleStore) {
|
|
|
193
207
|
},
|
|
194
208
|
},
|
|
195
209
|
{
|
|
210
|
+
kind: 'exact',
|
|
196
211
|
method: 'POST',
|
|
197
212
|
path: '/api/remote-mobile/update-options',
|
|
198
213
|
handler: async (req, res) => {
|
|
199
214
|
const body = await parseJsonBody(req);
|
|
200
215
|
const updates = {};
|
|
216
|
+
if (body.mode !== undefined) {
|
|
217
|
+
if (body.mode !== 'proxy' && body.mode !== 'direct') {
|
|
218
|
+
return jsonResponse(res, 400, { success: false, reason: 'mode 必须为 proxy 或 direct' });
|
|
219
|
+
}
|
|
220
|
+
updates.mode = body.mode;
|
|
221
|
+
}
|
|
222
|
+
if (body.proxyPort !== undefined) {
|
|
223
|
+
const port = Number(body.proxyPort);
|
|
224
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
225
|
+
return jsonResponse(res, 400, { success: false, reason: 'proxyPort 必须是 1-65535 的整数' });
|
|
226
|
+
}
|
|
227
|
+
updates.proxyPort = port;
|
|
228
|
+
}
|
|
229
|
+
if (body.proxyHost !== undefined) {
|
|
230
|
+
const host = String(body.proxyHost || '').trim();
|
|
231
|
+
const bareHost = host.replace(/^\[/, '').replace(/\]$/, '');
|
|
232
|
+
if (!host || host.length > 255 || (bareHost !== 'localhost' && isIP(bareHost) === 0)) {
|
|
233
|
+
return jsonResponse(res, 400, { success: false, reason: 'proxyHost 必须是有效 IP 地址或 localhost' });
|
|
234
|
+
}
|
|
235
|
+
updates.proxyHost = host;
|
|
236
|
+
}
|
|
237
|
+
if (body.trustedHosts !== undefined) {
|
|
238
|
+
if (!Array.isArray(body.trustedHosts)) {
|
|
239
|
+
return jsonResponse(res, 400, { success: false, reason: 'trustedHosts 必须是字符串数组' });
|
|
240
|
+
}
|
|
241
|
+
const hosts = body.trustedHosts
|
|
242
|
+
.filter((h) => typeof h === 'string')
|
|
243
|
+
.map((h) => h.trim())
|
|
244
|
+
.filter(Boolean);
|
|
245
|
+
if (hosts.some((h) => h.length > 255 || /[\s]/.test(h))) {
|
|
246
|
+
return jsonResponse(res, 400, { success: false, reason: 'trustedHosts 中包含无效域名' });
|
|
247
|
+
}
|
|
248
|
+
updates.trustedHosts = Array.from(new Set(hosts));
|
|
249
|
+
}
|
|
201
250
|
if (typeof body.allowTailscale === 'boolean') {
|
|
202
251
|
updates.allowTailscale = body.allowTailscale;
|
|
203
252
|
}
|
|
@@ -216,6 +265,10 @@ export function createRoutes(store, styleStore) {
|
|
|
216
265
|
if (typeof body.lockDurationMs === 'number' && body.lockDurationMs > 0) {
|
|
217
266
|
updates.lockDurationMs = Math.floor(body.lockDurationMs);
|
|
218
267
|
}
|
|
268
|
+
const restartRequired = updates.mode !== undefined ||
|
|
269
|
+
updates.proxyPort !== undefined ||
|
|
270
|
+
updates.proxyHost !== undefined ||
|
|
271
|
+
updates.trustedHosts !== undefined;
|
|
219
272
|
store.updateOptions(updates);
|
|
220
273
|
const opts = store.getOptions();
|
|
221
274
|
jsonResponse(res, 200, {
|
|
@@ -225,11 +278,19 @@ export function createRoutes(store, styleStore) {
|
|
|
225
278
|
maxVisitsPerMinute: opts.maxVisitsPerMinute,
|
|
226
279
|
maxFailedAttempts: opts.maxFailedAttempts,
|
|
227
280
|
lockDurationMs: opts.lockDurationMs,
|
|
281
|
+
mode: opts.mode,
|
|
282
|
+
proxyPort: opts.proxyPort,
|
|
283
|
+
proxyHost: opts.proxyHost,
|
|
284
|
+
trustedHosts: opts.trustedHosts,
|
|
285
|
+
activeMode: store.getActiveMode(),
|
|
286
|
+
activePort: store.getActivePort(),
|
|
287
|
+
restartRequired,
|
|
228
288
|
});
|
|
229
289
|
},
|
|
230
290
|
},
|
|
231
291
|
// 5. 设置并持久化长期访问密码 (支持 RSA 密文 + 6位以上字母数字复杂度校验)
|
|
232
292
|
{
|
|
293
|
+
kind: 'exact',
|
|
233
294
|
method: 'POST',
|
|
234
295
|
path: '/api/remote-mobile/set-secret',
|
|
235
296
|
handler: async (req, res) => {
|
|
@@ -255,6 +316,7 @@ export function createRoutes(store, styleStore) {
|
|
|
255
316
|
},
|
|
256
317
|
// 5.1 清除长期访问密码
|
|
257
318
|
{
|
|
319
|
+
kind: 'exact',
|
|
258
320
|
method: 'POST',
|
|
259
321
|
path: '/api/remote-mobile/clear-secret',
|
|
260
322
|
handler: async (_req, res) => {
|
|
@@ -264,6 +326,7 @@ export function createRoutes(store, styleStore) {
|
|
|
264
326
|
},
|
|
265
327
|
// 6. 撤销单个设备授权
|
|
266
328
|
{
|
|
329
|
+
kind: 'exact',
|
|
267
330
|
method: 'POST',
|
|
268
331
|
path: '/api/remote-mobile/revoke-device',
|
|
269
332
|
handler: async (req, res) => {
|
|
@@ -275,6 +338,7 @@ export function createRoutes(store, styleStore) {
|
|
|
275
338
|
},
|
|
276
339
|
// 7. 一键撤销所有设备
|
|
277
340
|
{
|
|
341
|
+
kind: 'exact',
|
|
278
342
|
method: 'POST',
|
|
279
343
|
path: '/api/remote-mobile/revoke-all',
|
|
280
344
|
handler: async (_req, res) => {
|
|
@@ -284,6 +348,7 @@ export function createRoutes(store, styleStore) {
|
|
|
284
348
|
},
|
|
285
349
|
// 8. 独立手机登录引导页面(已授权或 Tailscale/局域网免密设备访问时自动 302 跳转回工作区,支持 IP 访问计数与限频)
|
|
286
350
|
{
|
|
351
|
+
kind: 'exact',
|
|
287
352
|
method: 'GET',
|
|
288
353
|
path: '/auth',
|
|
289
354
|
handler: async (req, res) => {
|
|
@@ -328,6 +393,7 @@ export function createRoutes(store, styleStore) {
|
|
|
328
393
|
},
|
|
329
394
|
// 9. SSE 实时事件监听通道 (Server-Sent Events 0延时推送,彻底告别轮询)
|
|
330
395
|
{
|
|
396
|
+
kind: 'exact',
|
|
331
397
|
method: 'GET',
|
|
332
398
|
path: '/api/remote-mobile/events',
|
|
333
399
|
handler: async (req, res) => {
|
|
@@ -385,6 +451,7 @@ export function createRoutes(store, styleStore) {
|
|
|
385
451
|
},
|
|
386
452
|
// 10. 移动端样式片段(样式小插件)管理 API(合并 GET 与 POST 处理,避免 webserver.register 重复 path 冲突)
|
|
387
453
|
{
|
|
454
|
+
kind: 'exact',
|
|
388
455
|
path: '/api/remote-mobile/styles',
|
|
389
456
|
handler: async (req, res) => {
|
|
390
457
|
if (!styleStore) {
|
|
@@ -422,6 +489,7 @@ export function createRoutes(store, styleStore) {
|
|
|
422
489
|
},
|
|
423
490
|
},
|
|
424
491
|
{
|
|
492
|
+
kind: 'exact',
|
|
425
493
|
method: 'POST',
|
|
426
494
|
path: '/api/remote-mobile/styles/toggle',
|
|
427
495
|
handler: async (req, res) => {
|
|
@@ -435,6 +503,7 @@ export function createRoutes(store, styleStore) {
|
|
|
435
503
|
},
|
|
436
504
|
},
|
|
437
505
|
{
|
|
506
|
+
kind: 'exact',
|
|
438
507
|
method: 'POST',
|
|
439
508
|
path: '/api/remote-mobile/styles/delete',
|
|
440
509
|
handler: async (req, res) => {
|
|
@@ -447,6 +516,7 @@ export function createRoutes(store, styleStore) {
|
|
|
447
516
|
},
|
|
448
517
|
},
|
|
449
518
|
{
|
|
519
|
+
kind: 'exact',
|
|
450
520
|
method: 'POST',
|
|
451
521
|
path: '/api/remote-mobile/styles/reset',
|
|
452
522
|
handler: async (_req, res) => {
|
|
@@ -457,5 +527,44 @@ export function createRoutes(store, styleStore) {
|
|
|
457
527
|
jsonResponse(res, 200, { success: true, snippets: styleStore.list() });
|
|
458
528
|
},
|
|
459
529
|
},
|
|
530
|
+
// 11. 移动端网页目录选择器(browse 模式)一键开启与还原(严格幂等去重写入 cordis.patch.yml)
|
|
531
|
+
{
|
|
532
|
+
kind: 'exact',
|
|
533
|
+
method: 'POST',
|
|
534
|
+
path: '/api/remote-mobile/directory-picker',
|
|
535
|
+
handler: async (req, res) => {
|
|
536
|
+
const body = await parseJsonBody(req);
|
|
537
|
+
if (typeof body.enabled !== 'boolean') {
|
|
538
|
+
return jsonResponse(res, 400, {
|
|
539
|
+
success: false,
|
|
540
|
+
reason: 'enabled 必须为布尔值 (true 开启网页目录选择器 / false 恢复原生目录选择器)',
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
try {
|
|
544
|
+
const result = body.enabled
|
|
545
|
+
? enableDirectoryPickerBrowse(routeOptions?.patchFilePath)
|
|
546
|
+
: disableDirectoryPickerBrowse(routeOptions?.patchFilePath);
|
|
547
|
+
return jsonResponse(res, 200, {
|
|
548
|
+
success: true,
|
|
549
|
+
changed: result.changed,
|
|
550
|
+
directoryPicker: {
|
|
551
|
+
patchEnabled: result.patchEnabled,
|
|
552
|
+
patchPartial: result.patchPartial,
|
|
553
|
+
runtimeActive: result.runtimeActive,
|
|
554
|
+
restartRequired: result.restartRequired,
|
|
555
|
+
needsWarning: result.needsWarning,
|
|
556
|
+
patchFilePath: result.patchFilePath,
|
|
557
|
+
yamlSnippet: result.yamlSnippet,
|
|
558
|
+
},
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
catch (err) {
|
|
562
|
+
return jsonResponse(res, 500, {
|
|
563
|
+
success: false,
|
|
564
|
+
reason: err?.message || '写入 cordis.patch.yml 失败',
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
},
|
|
568
|
+
},
|
|
460
569
|
];
|
|
461
570
|
}
|
package/lib/routes/login-page.js
CHANGED
|
@@ -11,10 +11,13 @@ export function getLoginPageHtml(publicKeyPem, lang = 'zh') {
|
|
|
11
11
|
headerDesc: isEn ? 'Enter 6-digit dynamic pairing code or persistent password' : '请输入 6 位动态配对码或长期访问密码',
|
|
12
12
|
placeholder: isEn ? '6-digit code or password' : '6 位临时配对码 / 长期密码',
|
|
13
13
|
submitBtn: isEn ? 'Authorize & Connect' : '立即授权连接',
|
|
14
|
-
footerSecurity: isEn ? '🔒
|
|
14
|
+
footerSecurity: isEn ? '🔒 Login credentials encrypted with 2048-bit RSA' : '🔒 登录凭证使用 2048 位 RSA 加密',
|
|
15
15
|
verifying: isEn ? '🔐 Encrypting & verifying...' : '🔐 正在 RSA 加密与验证...',
|
|
16
16
|
success: isEn ? 'Authorization successful! Entering workspace...' : '授权成功!正在进入工作区...',
|
|
17
17
|
networkErr: isEn ? 'Network connection error, please try again' : '网络连接异常,请检查网络',
|
|
18
|
+
cryptoErr: isEn
|
|
19
|
+
? 'Secure encryption is unavailable. Refresh the page or use another browser.'
|
|
20
|
+
: '当前环境无法安全加密,请刷新页面或更换浏览器。',
|
|
18
21
|
};
|
|
19
22
|
return `<!DOCTYPE html>
|
|
20
23
|
<html lang="${isEn ? 'en' : 'zh-CN'}">
|
|
@@ -321,7 +324,8 @@ export function getLoginPageHtml(publicKeyPem, lang = 'zh') {
|
|
|
321
324
|
return { encryptedCredential: pureB64 };
|
|
322
325
|
} catch (err) {
|
|
323
326
|
console.error('RSA encrypt error:', err);
|
|
324
|
-
|
|
327
|
+
// 加密失败时绝不回退明文;调用方会提示用户更换浏览器或刷新页面。
|
|
328
|
+
throw new Error('ENCRYPTION_UNAVAILABLE');
|
|
325
329
|
}
|
|
326
330
|
}
|
|
327
331
|
|
|
@@ -338,10 +342,6 @@ export function getLoginPageHtml(publicKeyPem, lang = 'zh') {
|
|
|
338
342
|
if (document.baseURI && document.querySelector('base')) {
|
|
339
343
|
return new URL('api/remote-mobile/verify', document.baseURI).toString();
|
|
340
344
|
}
|
|
341
|
-
// 用 new RegExp 显式构造正则:字符串里不含反斜杠,杜绝外层模板字符串
|
|
342
|
-
// 把转义吃掉后拼出「两条斜杠开头的行注释」,进而使整段内联脚本
|
|
343
|
-
// SyntaxError(历史上曾因此完全无法登录,见 CHANGELOG 1.9.1 / 1.10.0)。
|
|
344
|
-
// 下两处同理。
|
|
345
345
|
const pathname = window.location.pathname.replace(new RegExp('/auth/?$'), '/');
|
|
346
346
|
return new URL('api/remote-mobile/verify', window.location.origin + pathname).toString();
|
|
347
347
|
} catch (e) {
|
|
@@ -407,7 +407,9 @@ export function getLoginPageHtml(publicKeyPem, lang = 'zh') {
|
|
|
407
407
|
}
|
|
408
408
|
} catch (err) {
|
|
409
409
|
statusMsg.className = 'status-msg error';
|
|
410
|
-
statusMsg.innerText =
|
|
410
|
+
statusMsg.innerText = err && err.message === 'ENCRYPTION_UNAVAILABLE'
|
|
411
|
+
? ${JSON.stringify(i18n.cryptoErr)}
|
|
412
|
+
: ${JSON.stringify(i18n.networkErr)};
|
|
411
413
|
statusMsg.style.display = 'block';
|
|
412
414
|
submitBtn.disabled = false;
|
|
413
415
|
submitBtn.innerText = ${JSON.stringify(i18n.submitBtn)};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { WebRoute } from './api.js';
|
|
2
|
+
/**
|
|
3
|
+
* 将插件路由注册到 DSH WebServer。
|
|
4
|
+
*
|
|
5
|
+
* DSH 0.2.1+ 的 `register(route)` 返回一个撤销函数。这里统一收集这些函数,
|
|
6
|
+
* 并通过 Cordis `ctx.effect` 绑定到插件生命周期,避免插件重复激活时旧路由
|
|
7
|
+
* 残留导致 `duplicate undefined route`。
|
|
8
|
+
*/
|
|
9
|
+
export declare function registerWebRoutes(ctx: any, routes: WebRoute[]): void;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 将插件路由注册到 DSH WebServer。
|
|
3
|
+
*
|
|
4
|
+
* DSH 0.2.1+ 的 `register(route)` 返回一个撤销函数。这里统一收集这些函数,
|
|
5
|
+
* 并通过 Cordis `ctx.effect` 绑定到插件生命周期,避免插件重复激活时旧路由
|
|
6
|
+
* 残留导致 `duplicate undefined route`。
|
|
7
|
+
*/
|
|
8
|
+
export function registerWebRoutes(ctx, routes) {
|
|
9
|
+
const webServer = ctx?.webServer;
|
|
10
|
+
if (!webServer || typeof webServer.register !== 'function')
|
|
11
|
+
return;
|
|
12
|
+
const registerAll = () => {
|
|
13
|
+
const disposers = [];
|
|
14
|
+
try {
|
|
15
|
+
for (const route of routes) {
|
|
16
|
+
const dispose = webServer.register({
|
|
17
|
+
...route,
|
|
18
|
+
kind: route.kind ?? 'exact',
|
|
19
|
+
});
|
|
20
|
+
if (typeof dispose === 'function') {
|
|
21
|
+
disposers.push(dispose);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
// 中途失败时撤销本轮已经注册的路由,避免留下半套 API。
|
|
27
|
+
for (const dispose of disposers.reverse()) {
|
|
28
|
+
try {
|
|
29
|
+
dispose();
|
|
30
|
+
}
|
|
31
|
+
catch { }
|
|
32
|
+
}
|
|
33
|
+
throw error;
|
|
34
|
+
}
|
|
35
|
+
return () => {
|
|
36
|
+
for (const dispose of disposers.reverse()) {
|
|
37
|
+
try {
|
|
38
|
+
dispose();
|
|
39
|
+
}
|
|
40
|
+
catch { }
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
};
|
|
44
|
+
if (typeof ctx.effect === 'function') {
|
|
45
|
+
ctx.effect(registerAll, 'dsh-remote-mobile:web-routes');
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
// 兼容没有 ctx.effect 的旧宿主:至少尝试在 dispose 事件中回收路由。
|
|
49
|
+
const cleanup = registerAll();
|
|
50
|
+
if (typeof ctx.on === 'function') {
|
|
51
|
+
ctx.on('dispose', cleanup);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { DSH_HOME } from './auth/token.js';
|
|
4
|
+
const WILDCARD_ERROR_RE = /host\s+["']?(?:0\.0\.0\.0|::)["']?\s+is an unspecified \(wildcard\) address|binding every interface would expose remote code execution/i;
|
|
5
|
+
let installed = false;
|
|
6
|
+
let sawWildcardError = false;
|
|
7
|
+
/** 查找配置中 webserver 使用通配地址的 profile patch 文件 */
|
|
8
|
+
function findWildcardConfigFile() {
|
|
9
|
+
const files = [join(DSH_HOME, 'cordis.patch.yml')];
|
|
10
|
+
try {
|
|
11
|
+
for (const entry of readdirSync(join(DSH_HOME, 'profiles'), { withFileTypes: true })) {
|
|
12
|
+
if (!entry.isDirectory())
|
|
13
|
+
continue;
|
|
14
|
+
files.push(join(DSH_HOME, 'profiles', entry.name, 'cordis.patch.yml'));
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
catch { }
|
|
18
|
+
for (const file of files) {
|
|
19
|
+
if (!existsSync(file))
|
|
20
|
+
continue;
|
|
21
|
+
let content = '';
|
|
22
|
+
try {
|
|
23
|
+
content = readFileSync(file, 'utf8');
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
const lines = content.split(/\r?\n/);
|
|
29
|
+
for (let i = 0; i < lines.length; i++) {
|
|
30
|
+
if (!/^\s*-\s*id:\s*['"]?webserver['"]?\s*$/.test(lines[i]))
|
|
31
|
+
continue;
|
|
32
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
33
|
+
if (/^\s*-\s*id:/.test(lines[j]))
|
|
34
|
+
break;
|
|
35
|
+
if (/^\s*host:\s*['"]?(?:0\.0\.0\.0|::)['"]?\s*$/.test(lines[j]))
|
|
36
|
+
return file;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* 在 DSH webserver 校验失败退出后追加一条针对通配地址错误的友好提示。
|
|
44
|
+
* 只在 stderr 命中 DSH 的 wildcard 报错时触发,其他启动错误不输出。
|
|
45
|
+
*/
|
|
46
|
+
export function installStartupFailureHint() {
|
|
47
|
+
if (installed)
|
|
48
|
+
return;
|
|
49
|
+
installed = true;
|
|
50
|
+
const originalWrite = process.stderr.write.bind(process.stderr);
|
|
51
|
+
process.stderr.write = ((chunk, encoding, callback) => {
|
|
52
|
+
try {
|
|
53
|
+
if (WILDCARD_ERROR_RE.test(String(chunk)))
|
|
54
|
+
sawWildcardError = true;
|
|
55
|
+
}
|
|
56
|
+
catch { }
|
|
57
|
+
return originalWrite(chunk, encoding, callback);
|
|
58
|
+
});
|
|
59
|
+
process.once('exit', () => {
|
|
60
|
+
if (!sawWildcardError)
|
|
61
|
+
return;
|
|
62
|
+
const file = findWildcardConfigFile();
|
|
63
|
+
console.error(`
|
|
64
|
+
[dsh-remote-mobile] 启动失败提示:
|
|
65
|
+
检测到 DSH webserver 使用了通配地址 0.0.0.0 / ::。
|
|
66
|
+
DSH 0.2.1+ 不允许这种监听方式,插件无法在 webserver 校验前接管。
|
|
67
|
+
${file ? `请删除或注释 ${file} 中的 webserver 配置块;` : '请删除或注释 profile 的 cordis.patch.yml 中的 webserver 配置块;'}
|
|
68
|
+
保持默认 127.0.0.1:3080,然后重启 dsh web。
|
|
69
|
+
重启后 dsh-remote-mobile 会自动使用 3081 安全接入代理。
|
|
70
|
+
`);
|
|
71
|
+
});
|
|
72
|
+
}
|