@yezack/dsh-remote-mobile 1.9.2 → 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 +86 -12
- package/LICENSE +21 -21
- package/README.md +273 -56
- package/README_EN.md +281 -58
- package/lib/auth/crypto.js +3 -1
- package/lib/auth/gate.d.ts +39 -9
- package/lib/auth/gate.js +231 -33
- package/lib/auth/tailscale.d.ts +24 -2
- package/lib/auth/tailscale.js +38 -3
- package/lib/auth/token.d.ts +22 -0
- package/lib/auth/token.js +61 -2
- package/lib/bridge/compat.d.ts +22 -2
- package/lib/bridge/compat.js +319 -76
- package/lib/bridge/directory-picker.d.ts +118 -0
- package/lib/bridge/directory-picker.js +617 -0
- package/lib/client.js +1051 -166
- package/lib/gateway/detector.d.ts +40 -0
- package/lib/gateway/detector.js +75 -0
- package/lib/gateway/secret.d.ts +23 -0
- package/lib/gateway/secret.js +65 -0
- package/lib/gateway/server.d.ts +108 -0
- package/lib/gateway/server.js +568 -0
- package/lib/index.d.ts +3 -0
- package/lib/index.js +201 -22
- package/lib/routes/api.d.ts +15 -12
- package/lib/routes/api.js +126 -11
- package/lib/routes/login-page.js +402 -399
- 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/package.json +4 -4
package/lib/index.js
CHANGED
|
@@ -12,14 +12,30 @@
|
|
|
12
12
|
* 7. 配置持久化于插件自有文件 ~/.dsh/remote-mobile/settings.json(DSH 0.1.7+ 起官方已弃用 settings.yaml),
|
|
13
13
|
* 启动时一次性兼容迁移旧 settings.yaml / settings.yaml.imported 中的配置。
|
|
14
14
|
*/
|
|
15
|
+
import { createRequire } from 'node:module';
|
|
16
|
+
import { installStartupFailureHint } from './startup-hint.js';
|
|
15
17
|
import { SessionStore } from './auth/token.js';
|
|
16
18
|
import { createGlobalAuthGate } from './auth/gate.js';
|
|
17
19
|
import { createRoutes } from './routes/api.js';
|
|
20
|
+
import { registerWebRoutes } from './routes/register.js';
|
|
18
21
|
import { StyleSnippetStore } from './styles/style-snippets.js';
|
|
19
22
|
import { getLocalTailscaleIp, getLocalLanIp } from './auth/tailscale.js';
|
|
20
|
-
import { patchHttpServerWithVirtualizer, mountIndexInjections, registerRemoteWebUiPairingBridge, takeOverConnectionAuth, } from './bridge/compat.js';
|
|
23
|
+
import { patchHttpServerWithVirtualizer, mountIndexInjections, registerRemoteWebUiPairingBridge, takeOverConnectionAuth, injectTrustedHosts, } from './bridge/compat.js';
|
|
24
|
+
import { bindDirectoryPickerRuntime, getDirectoryPickerStatus } from './bridge/directory-picker.js';
|
|
25
|
+
import { detectServerMode } from './gateway/detector.js';
|
|
26
|
+
import { GatewayServer } from './gateway/server.js';
|
|
27
|
+
import { generateGatewaySecret, setGatewaySecret, clearGatewaySecret } from './gateway/secret.js';
|
|
28
|
+
// 该 hook 必须在模块顶层注册,才能捕获 webserver 在插件 apply 前的启动失败。
|
|
29
|
+
installStartupFailureHint();
|
|
21
30
|
/** Cordis 插件名称标识 */
|
|
22
31
|
export const name = 'dsh-remote-mobile';
|
|
32
|
+
/** 服务端 Banner 使用的插件版本(从安装包 package.json 读取,避免与客户端版本常量重复维护) */
|
|
33
|
+
let pluginVersion = '';
|
|
34
|
+
try {
|
|
35
|
+
const require = createRequire(import.meta.url);
|
|
36
|
+
pluginVersion = String(require('../package.json').version || '');
|
|
37
|
+
}
|
|
38
|
+
catch { }
|
|
23
39
|
/** DSH 设置命名空间 */
|
|
24
40
|
export const REMOTE_MOBILE_SETTINGS_NAMESPACE = 'dsh-remote-mobile';
|
|
25
41
|
/** Cordis 依赖注入声明:声明注入底座内置的 webServer 服务 */
|
|
@@ -79,10 +95,15 @@ function bindDshSettings(ctx, store) {
|
|
|
79
95
|
* @param config - 插件持久化配置选项 (SessionStoreOptions)
|
|
80
96
|
*/
|
|
81
97
|
export function apply(ctx, config = {}) {
|
|
82
|
-
// 1.
|
|
98
|
+
// 1. 初始化会话与令牌持久化管理器及内部握手密钥
|
|
83
99
|
const store = new SessionStore(config);
|
|
84
100
|
const styleStore = new StyleSnippetStore();
|
|
85
101
|
const gateMiddleware = createGlobalAuthGate(store);
|
|
102
|
+
// 为本次插件生命周期生成高强度握手 Secret,并绑定到底座 Server
|
|
103
|
+
const gatewaySecret = generateGatewaySecret();
|
|
104
|
+
if (ctx.webServer?.server) {
|
|
105
|
+
setGatewaySecret(ctx.webServer.server, gatewaySecret);
|
|
106
|
+
}
|
|
86
107
|
// 2. 接入 DSH Settings 服务 (命名空间: dsh-remote-mobile)
|
|
87
108
|
bindDshSettings(ctx, store);
|
|
88
109
|
// 3. 挂载底座 WebServer 相关的门禁拦截与路由注册
|
|
@@ -90,14 +111,12 @@ export function apply(ctx, config = {}) {
|
|
|
90
111
|
if (ctx.webServer.server) {
|
|
91
112
|
patchHttpServerWithVirtualizer(ctx.webServer.server, gateMiddleware, store, styleStore);
|
|
92
113
|
}
|
|
93
|
-
// 挂载 HTML
|
|
94
|
-
mountIndexInjections(ctx);
|
|
95
|
-
// 注册插件专属的 HTTP & SSE API
|
|
114
|
+
// 挂载 HTML 模板层的样式与 Polyfill 注入钩子(在 Gzip 压缩前注入)
|
|
115
|
+
mountIndexInjections(ctx, styleStore);
|
|
116
|
+
// 注册插件专属的 HTTP & SSE API 路由。registerWebRoutes 会通过 ctx.effect
|
|
117
|
+
// 收集 DSH WebServer 返回的撤销函数,保证插件重新激活/热重载时不残留路由。
|
|
96
118
|
if (typeof ctx.webServer.register === 'function') {
|
|
97
|
-
|
|
98
|
-
for (const route of routes) {
|
|
99
|
-
ctx.webServer.register(route);
|
|
100
|
-
}
|
|
119
|
+
registerWebRoutes(ctx, createRoutes(store, styleStore));
|
|
101
120
|
}
|
|
102
121
|
}
|
|
103
122
|
// 4. 向 Cordis 容器桥接注册 remoteWebUiPairing 服务(延迟裁决:与其他远程接入
|
|
@@ -113,10 +132,18 @@ export function apply(ctx, config = {}) {
|
|
|
113
132
|
* takeOverConnectionAuth 内部通过 __dsh_rm_auth_takeover__ 标记保证幂等,
|
|
114
133
|
* 同一 connection 实例不会被重复接管。
|
|
115
134
|
*/
|
|
135
|
+
let connectionAuthAttached = false;
|
|
116
136
|
const attachConnectionAuth = (conn) => {
|
|
117
|
-
if (conn)
|
|
137
|
+
if (!conn)
|
|
138
|
+
return false;
|
|
139
|
+
try {
|
|
118
140
|
takeOverConnectionAuth(conn, store);
|
|
141
|
+
connectionAuthAttached = Boolean(conn.__dsh_rm_auth_takeover__);
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
connectionAuthAttached = false;
|
|
119
145
|
}
|
|
146
|
+
return connectionAuthAttached;
|
|
120
147
|
};
|
|
121
148
|
// 1) 优先通过 ctx.get 安全获取(未注入时不抛错返回 undefined)
|
|
122
149
|
try {
|
|
@@ -146,7 +173,76 @@ export function apply(ctx, config = {}) {
|
|
|
146
173
|
ctx.logger?.debug?.('[dsh-remote-mobile] 注册 connection 动态注入失败:', e);
|
|
147
174
|
}
|
|
148
175
|
}
|
|
149
|
-
//
|
|
176
|
+
// 动态同步受信任主机到所有底层运行时(webRuntime、connection、webStartup)
|
|
177
|
+
const syncTrustedHosts = (target) => {
|
|
178
|
+
try {
|
|
179
|
+
injectTrustedHosts(target || ctx, [store.getActivePort(), 3080, 3081], store.getOptions().trustedHosts);
|
|
180
|
+
}
|
|
181
|
+
catch { }
|
|
182
|
+
};
|
|
183
|
+
syncTrustedHosts();
|
|
184
|
+
if (typeof ctx.inject === 'function') {
|
|
185
|
+
try {
|
|
186
|
+
ctx.inject(['webRuntime'], (runtimeCtx) => {
|
|
187
|
+
syncTrustedHosts(runtimeCtx);
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
catch { }
|
|
191
|
+
}
|
|
192
|
+
// 6. 探测 DSH 底座网络暴露姿态:默认 proxy;核心已对外监听时回退 direct。
|
|
193
|
+
// 检测逻辑本身不抛错,异常时仍保留兜底,避免插件 apply 崩溃。
|
|
194
|
+
let modeFallbackError = '';
|
|
195
|
+
let detection;
|
|
196
|
+
try {
|
|
197
|
+
detection = detectServerMode(ctx, store.getOptions().mode, ctx.webServer?.port ?? 3080);
|
|
198
|
+
}
|
|
199
|
+
catch (err) {
|
|
200
|
+
modeFallbackError = err?.message || String(err);
|
|
201
|
+
ctx.logger?.error?.('[dsh-remote-mobile] 安全接入代理配置不可用,已回退 direct 模式:', err);
|
|
202
|
+
detection = detectServerMode(ctx, 'direct', ctx.webServer?.port ?? 3080);
|
|
203
|
+
}
|
|
204
|
+
// proxy 是默认模式,但核心已对外监听时会回退 direct:
|
|
205
|
+
// - 旧版 webserver 配置(0.0.0.0 / 具体网卡 IP)继续可用;
|
|
206
|
+
// - direct + 回环核心时,外部手机无法直连,仅适合同机反向代理。
|
|
207
|
+
bindDirectoryPickerRuntime(ctx, () => detection.isLoopback);
|
|
208
|
+
const requestedMode = store.getOptions().mode ?? 'proxy';
|
|
209
|
+
const legacyDirectFallback = requestedMode === 'proxy' && detection.mode === 'direct' && !detection.isLoopback;
|
|
210
|
+
const directLoopbackWarning = detection.mode === 'direct' && detection.isLoopback;
|
|
211
|
+
const modeWarning = legacyDirectFallback
|
|
212
|
+
? `检测到底座监听 ${detection.rawCoreHost},已自动使用 direct 模式;3080 直接对外,如需 proxy 请恢复 127.0.0.1 监听。`
|
|
213
|
+
: directLoopbackWarning
|
|
214
|
+
? 'direct 模式 + 回环核心:外部手机无法直连,仅适合同机反向代理。'
|
|
215
|
+
: '';
|
|
216
|
+
let gatewayServer = null;
|
|
217
|
+
let gatewayReadyPromise = null;
|
|
218
|
+
if (detection.mode === 'proxy') {
|
|
219
|
+
const opts = store.getOptions();
|
|
220
|
+
gatewayServer = new GatewayServer({
|
|
221
|
+
port: opts.proxyPort ?? 3081,
|
|
222
|
+
host: opts.proxyHost ?? '0.0.0.0',
|
|
223
|
+
corePort: detection.corePort,
|
|
224
|
+
coreHost: detection.coreHost,
|
|
225
|
+
}, store, gateMiddleware, ctx.logger);
|
|
226
|
+
// 初始标记为正在启动中
|
|
227
|
+
store.setActiveGatewayInfo('proxy', opts.proxyPort ?? 3081, detection.corePort, false);
|
|
228
|
+
gatewayReadyPromise = gatewayServer.start().then(({ port }) => {
|
|
229
|
+
if (gatewayServer?.getServer()) {
|
|
230
|
+
setGatewaySecret(gatewayServer.getServer(), gatewaySecret);
|
|
231
|
+
}
|
|
232
|
+
store.setActiveGatewayInfo('proxy', port, detection.corePort, true);
|
|
233
|
+
syncTrustedHosts();
|
|
234
|
+
return port;
|
|
235
|
+
}).catch((err) => {
|
|
236
|
+
store.setActiveGatewayInfo('proxy', opts.proxyPort ?? 3081, detection.corePort, false, err?.message || String(err));
|
|
237
|
+
ctx.logger?.error?.('[dsh-remote-mobile] 安全接入代理启动失败:', err);
|
|
238
|
+
return opts.proxyPort ?? 3081;
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
else {
|
|
242
|
+
store.setActiveGatewayInfo('direct', detection.corePort, detection.corePort, true);
|
|
243
|
+
gatewayReadyPromise = Promise.resolve(detection.corePort);
|
|
244
|
+
}
|
|
245
|
+
// 7. 服务就绪时输出提示横幅与接入地址
|
|
150
246
|
let printed = false;
|
|
151
247
|
let disposed = false;
|
|
152
248
|
let timer1 = null;
|
|
@@ -158,18 +254,42 @@ export function apply(ctx, config = {}) {
|
|
|
158
254
|
clearTimeout(timer1);
|
|
159
255
|
if (timer2)
|
|
160
256
|
clearTimeout(timer2);
|
|
257
|
+
if (gatewayServer) {
|
|
258
|
+
clearGatewaySecret(gatewayServer.getServer());
|
|
259
|
+
gatewayServer.close().catch(() => { });
|
|
260
|
+
}
|
|
261
|
+
const webServer = (typeof ctx.get === 'function' ? ctx.get('webServer') : undefined) || ctx.webServer;
|
|
262
|
+
if (webServer?.server) {
|
|
263
|
+
clearGatewaySecret(webServer.server);
|
|
264
|
+
}
|
|
161
265
|
});
|
|
162
266
|
}
|
|
163
267
|
catch { }
|
|
164
|
-
const printReadyBanner = () => {
|
|
268
|
+
const printReadyBanner = async () => {
|
|
165
269
|
// 生命周期守卫:若上下文已废弃/进入 dispose,放弃回调
|
|
166
270
|
if (disposed || printed)
|
|
167
271
|
return;
|
|
168
272
|
printed = true;
|
|
273
|
+
// 等待安全接入代理就绪以获得准确的对外端口(避免端口自增探测竞态)
|
|
274
|
+
try {
|
|
275
|
+
if (gatewayReadyPromise) {
|
|
276
|
+
await Promise.race([
|
|
277
|
+
gatewayReadyPromise,
|
|
278
|
+
new Promise((r) => setTimeout(r, 2000)),
|
|
279
|
+
]);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
catch { }
|
|
283
|
+
// 等待期间插件可能已被 dispose / 热重载;此时绝不能再 setGatewaySecret / patch / mount
|
|
284
|
+
if (disposed)
|
|
285
|
+
return;
|
|
169
286
|
try {
|
|
287
|
+
syncTrustedHosts();
|
|
170
288
|
const webServer = (typeof ctx.get === 'function' ? ctx.get('webServer') : undefined) || ctx.webServer;
|
|
171
289
|
if (webServer?.server) {
|
|
290
|
+
setGatewaySecret(webServer.server, gatewaySecret);
|
|
172
291
|
patchHttpServerWithVirtualizer(webServer.server, gateMiddleware, store, styleStore);
|
|
292
|
+
mountIndexInjections(ctx, styleStore);
|
|
173
293
|
}
|
|
174
294
|
try {
|
|
175
295
|
const conn = typeof ctx.get === 'function' ? ctx.get('connection') : undefined;
|
|
@@ -181,28 +301,84 @@ export function apply(ctx, config = {}) {
|
|
|
181
301
|
}
|
|
182
302
|
const tsIp = getLocalTailscaleIp();
|
|
183
303
|
const lanIp = getLocalLanIp();
|
|
184
|
-
const
|
|
304
|
+
const activePort = store.getActivePort();
|
|
305
|
+
const activeMode = store.getActiveMode();
|
|
306
|
+
const corePort = store.getCorePort();
|
|
307
|
+
const coreHost = detection.coreHost;
|
|
185
308
|
const opts = store.getOptions();
|
|
309
|
+
const proxyHost = opts.proxyHost ?? '0.0.0.0';
|
|
310
|
+
const proxyStatus = store.getGatewayStatus();
|
|
311
|
+
const versionLabel = pluginVersion ? `v${pluginVersion}` : 'unknown';
|
|
312
|
+
const coreLabel = detection.isLoopback ? '回环' : '对外监听';
|
|
313
|
+
const modeLabel = activeMode === 'proxy'
|
|
314
|
+
? 'proxy(默认)'
|
|
315
|
+
: legacyDirectFallback
|
|
316
|
+
? 'direct(旧配置回退)'
|
|
317
|
+
: 'direct(显式配置)';
|
|
186
318
|
const divider = '─'.repeat(72);
|
|
187
319
|
console.log(`\n┌${divider}`);
|
|
188
|
-
console.log(
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
320
|
+
console.log(connectionAuthAttached
|
|
321
|
+
? `│ [dsh-remote-mobile] ${versionLabel} 官方临时 Token 鉴权已接管,PC/移动端访问无需携带官方token`
|
|
322
|
+
: `│ [dsh-remote-mobile] ${versionLabel} ⚠️ 官方临时 Token 鉴权接管未完成,请检查 DSH 版本或启动日志`);
|
|
323
|
+
console.log(`│ DSH 核心: ${detection.rawCoreHost}:${corePort} (${coreLabel})`);
|
|
324
|
+
console.log(`│ 运行模式: ${modeLabel}`);
|
|
325
|
+
if (modeFallbackError) {
|
|
326
|
+
console.log(`│ ⚠️ 安全接入代理配置不可用,已回退 direct 模式: ${modeFallbackError}`);
|
|
193
327
|
}
|
|
194
|
-
if (
|
|
195
|
-
console.log(`│
|
|
328
|
+
if (modeWarning) {
|
|
329
|
+
console.log(`│ ⚠️ ${modeWarning}`);
|
|
330
|
+
}
|
|
331
|
+
if (activeMode === 'proxy') {
|
|
332
|
+
if (proxyStatus.running) {
|
|
333
|
+
console.log(`│ 安全接入代理已激活 (${proxyHost}:${activePort} -> ${coreHost}:${corePort})`);
|
|
334
|
+
console.log(`│ 手机/远程设备请访问安全接入代理地址:`);
|
|
335
|
+
if (lanIp && lanIp !== '127.0.0.1') {
|
|
336
|
+
console.log(`│ • 局域网: http://${lanIp}:${activePort} (同 Wi-Fi 局域网直连)`);
|
|
337
|
+
}
|
|
338
|
+
if (tsIp) {
|
|
339
|
+
console.log(`│ • 虚拟私网: http://${tsIp}:${activePort} (Tailscale 私网异地直连)`);
|
|
340
|
+
}
|
|
341
|
+
console.log(`│ 电脑本机浏览器访问控制台:`);
|
|
342
|
+
console.log(`│ • 核心直连: http://127.0.0.1:${corePort}`);
|
|
343
|
+
if (proxyHost === '0.0.0.0' || proxyHost === '::' || proxyHost === '127.0.0.1' || proxyHost === 'localhost') {
|
|
344
|
+
const localProxyHost = proxyHost === '::' ? '[::1]' : '127.0.0.1';
|
|
345
|
+
console.log(`│ • 代理入口: http://${localProxyHost}:${activePort}`);
|
|
346
|
+
console.log(`│ 本机直连不校验配对码/长期密码`);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
else {
|
|
350
|
+
console.log(`│ ⚠️ 安全接入代理未成功启动: ${proxyStatus.error || '端口占用'}`);
|
|
351
|
+
console.log(`│ 外部手机设备当前暂时无法连接,请检查 ${opts.proxyPort ?? 3081} 端口占用`);
|
|
352
|
+
console.log(`│ 电脑本机浏览器仍可直接访问:`);
|
|
353
|
+
console.log(`│ • 本机回环: http://127.0.0.1:${corePort}`);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
else {
|
|
357
|
+
console.log(`│ 直连模式已启用(无独立安全接入代理),支持手机浏览器访问:`);
|
|
358
|
+
console.log(`│ • 本机回环: http://127.0.0.1:${corePort}`);
|
|
359
|
+
if (lanIp && lanIp !== '127.0.0.1') {
|
|
360
|
+
console.log(`│ • 局域网: http://${lanIp}:${corePort} (同 Wi-Fi 局域网直连)`);
|
|
361
|
+
}
|
|
362
|
+
if (tsIp) {
|
|
363
|
+
console.log(`│ • 虚拟私网: http://${tsIp}:${corePort} (Tailscale 私网异地直连)`);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
const dirPickerStatus = getDirectoryPickerStatus();
|
|
367
|
+
if (dirPickerStatus.needsWarning) {
|
|
368
|
+
console.log(`│ ⚠️ 移动端选目录提示: 当前未开启网页内目录选择器,手机点「选择文件夹」会在电脑弹窗`);
|
|
369
|
+
console.log(`│ 可在 Web「设置」->「远程与移动端」顶部一键开启网页目录选择器`);
|
|
196
370
|
}
|
|
197
371
|
console.log(`│ 访问与配置说明:`);
|
|
198
372
|
console.log(`│ - 手机/远程设备打开对应地址即可直接访问`);
|
|
199
373
|
console.log(`│ - 在本机打开 Web 界面 ->「设置」->「远程与移动端」进行安全管理:`);
|
|
200
374
|
console.log(`│ 可开启免密直连(局域网/Tailscale)、查看 6 位临时配对码或设置长期密码`);
|
|
375
|
+
console.log(`│ - 高级接入参数: settings.json 中的 mode / proxyPort / proxyHost / trustedHosts`);
|
|
376
|
+
console.log(`│ 修改后需要重启 DSH`);
|
|
201
377
|
console.log(`│ - 项目文档: https://github.com/IceApriler/dsh-remote-mobile`);
|
|
202
378
|
console.log(`└${divider}\n`);
|
|
203
|
-
ctx.logger?.info?.(`[dsh-remote-mobile] 移动端远程控制与安全门禁已激活 (
|
|
379
|
+
ctx.logger?.info?.(`[dsh-remote-mobile] 移动端远程控制与安全门禁已激活 (运行模式: ${activeMode}, 对外端口: ${activePort})`);
|
|
204
380
|
if (tsIp) {
|
|
205
|
-
ctx.logger?.info?.(`[dsh-remote-mobile] Tailscale 地址: http://${tsIp}:${
|
|
381
|
+
ctx.logger?.info?.(`[dsh-remote-mobile] Tailscale 地址: http://${tsIp}:${activePort} (免密直连: ${opts.allowTailscale ? '已开启' : '已关闭'})`);
|
|
206
382
|
}
|
|
207
383
|
}
|
|
208
384
|
catch (e) {
|
|
@@ -233,4 +409,7 @@ export * from './auth/token.js';
|
|
|
233
409
|
export * from './auth/gate.js';
|
|
234
410
|
export * from './routes/api.js';
|
|
235
411
|
export * from './bridge/compat.js';
|
|
412
|
+
export * from './bridge/directory-picker.js';
|
|
236
413
|
export * from './styles/style-snippets.js';
|
|
414
|
+
export * from './gateway/detector.js';
|
|
415
|
+
export * from './gateway/server.js';
|
package/lib/routes/api.d.ts
CHANGED
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
2
|
import { type SessionStore } from '../auth/token.js';
|
|
3
3
|
import { type StyleSnippetStore } from '../styles/style-snippets.js';
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
/**
|
|
5
|
+
* DSH 0.2.1+ WebServer 的路由契约:`kind` 决定注册到 exact 还是 prefixes 表。
|
|
6
|
+
* 当前所有接口都是字面路径,因此统一使用 exact;`method` 保留给旧宿主兼容。
|
|
7
|
+
*/
|
|
8
|
+
export interface WebRoute {
|
|
9
|
+
kind: 'exact' | 'prefix';
|
|
10
|
+
/** 旧版 DSH 使用;DSH 0.2.1+ 的 WebServer 不按 method 分派。 */
|
|
11
|
+
method?: 'GET' | 'POST';
|
|
6
12
|
path: string;
|
|
7
|
-
handler: (
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
path: string;
|
|
15
|
-
handler: (req: IncomingMessage, res: ServerResponse) => Promise<void>;
|
|
16
|
-
})[];
|
|
13
|
+
handler: (req: IncomingMessage, res: ServerResponse) => unknown;
|
|
14
|
+
}
|
|
15
|
+
export interface CreateRoutesOptions {
|
|
16
|
+
/** 测试环境注入的自定义 cordis.patch.yml 路径(避免污染宿主配置) */
|
|
17
|
+
patchFilePath?: string;
|
|
18
|
+
}
|
|
19
|
+
export declare function createRoutes(store: SessionStore, styleStore?: StyleSnippetStore, routeOptions?: CreateRoutesOptions): WebRoute[];
|
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,11 +90,23 @@ export function createRoutes(store, styleStore) {
|
|
|
86
90
|
pairingBridgeMode: bridgeState.mode,
|
|
87
91
|
pairingBridgeConflict: bridgeState.conflictWith || '',
|
|
88
92
|
pairingBridgeConflictId: bridgeState.conflictEntryId || '',
|
|
93
|
+
// 目录选择器(移动端添加工作区/选择文件夹)状态与补丁信息
|
|
94
|
+
directoryPicker: getDirectoryPickerStatus(routeOptions?.patchFilePath),
|
|
95
|
+
// 安全接入代理运行状态
|
|
96
|
+
proxy: {
|
|
97
|
+
...store.getGatewayStatus(),
|
|
98
|
+
activePort: store.getActivePort(),
|
|
99
|
+
configuredMode: opts.mode,
|
|
100
|
+
proxyPort: opts.proxyPort || 3081,
|
|
101
|
+
proxyHost: opts.proxyHost || '0.0.0.0',
|
|
102
|
+
trustedHosts: opts.trustedHosts,
|
|
103
|
+
},
|
|
89
104
|
});
|
|
90
105
|
},
|
|
91
106
|
},
|
|
92
107
|
// 2. 生成 6 位短期配对码
|
|
93
108
|
{
|
|
109
|
+
kind: 'exact',
|
|
94
110
|
method: 'POST',
|
|
95
111
|
path: '/api/remote-mobile/generate-code',
|
|
96
112
|
handler: async (_req, res) => {
|
|
@@ -104,6 +120,7 @@ export function createRoutes(store, styleStore) {
|
|
|
104
120
|
},
|
|
105
121
|
// 3. 手机端验证与换取长效 Cookie (支持 RSA-OAEP 密文解密 + 防暴力破解 429 拦截)
|
|
106
122
|
{
|
|
123
|
+
kind: 'exact',
|
|
107
124
|
method: 'POST',
|
|
108
125
|
path: '/api/remote-mobile/verify',
|
|
109
126
|
handler: async (req, res) => {
|
|
@@ -143,6 +160,7 @@ export function createRoutes(store, styleStore) {
|
|
|
143
160
|
},
|
|
144
161
|
// 3.1 手动解除某个 IP 的防暴破锁定
|
|
145
162
|
{
|
|
163
|
+
kind: 'exact',
|
|
146
164
|
method: 'POST',
|
|
147
165
|
path: '/api/remote-mobile/unlock-ip',
|
|
148
166
|
handler: async (req, res) => {
|
|
@@ -154,6 +172,7 @@ export function createRoutes(store, styleStore) {
|
|
|
154
172
|
},
|
|
155
173
|
// 3.2 一键清空所有 IP 访问与安全审计日志
|
|
156
174
|
{
|
|
175
|
+
kind: 'exact',
|
|
157
176
|
method: 'POST',
|
|
158
177
|
path: '/api/remote-mobile/clear-ip-stats',
|
|
159
178
|
handler: async (_req, res) => {
|
|
@@ -163,6 +182,7 @@ export function createRoutes(store, styleStore) {
|
|
|
163
182
|
},
|
|
164
183
|
// 4. 切换 Tailscale / 局域网免密直连开关
|
|
165
184
|
{
|
|
185
|
+
kind: 'exact',
|
|
166
186
|
method: 'POST',
|
|
167
187
|
path: '/api/remote-mobile/toggle-bypass',
|
|
168
188
|
handler: async (req, res) => {
|
|
@@ -187,11 +207,46 @@ export function createRoutes(store, styleStore) {
|
|
|
187
207
|
},
|
|
188
208
|
},
|
|
189
209
|
{
|
|
210
|
+
kind: 'exact',
|
|
190
211
|
method: 'POST',
|
|
191
212
|
path: '/api/remote-mobile/update-options',
|
|
192
213
|
handler: async (req, res) => {
|
|
193
214
|
const body = await parseJsonBody(req);
|
|
194
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
|
+
}
|
|
195
250
|
if (typeof body.allowTailscale === 'boolean') {
|
|
196
251
|
updates.allowTailscale = body.allowTailscale;
|
|
197
252
|
}
|
|
@@ -210,6 +265,10 @@ export function createRoutes(store, styleStore) {
|
|
|
210
265
|
if (typeof body.lockDurationMs === 'number' && body.lockDurationMs > 0) {
|
|
211
266
|
updates.lockDurationMs = Math.floor(body.lockDurationMs);
|
|
212
267
|
}
|
|
268
|
+
const restartRequired = updates.mode !== undefined ||
|
|
269
|
+
updates.proxyPort !== undefined ||
|
|
270
|
+
updates.proxyHost !== undefined ||
|
|
271
|
+
updates.trustedHosts !== undefined;
|
|
213
272
|
store.updateOptions(updates);
|
|
214
273
|
const opts = store.getOptions();
|
|
215
274
|
jsonResponse(res, 200, {
|
|
@@ -219,11 +278,19 @@ export function createRoutes(store, styleStore) {
|
|
|
219
278
|
maxVisitsPerMinute: opts.maxVisitsPerMinute,
|
|
220
279
|
maxFailedAttempts: opts.maxFailedAttempts,
|
|
221
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,
|
|
222
288
|
});
|
|
223
289
|
},
|
|
224
290
|
},
|
|
225
291
|
// 5. 设置并持久化长期访问密码 (支持 RSA 密文 + 6位以上字母数字复杂度校验)
|
|
226
292
|
{
|
|
293
|
+
kind: 'exact',
|
|
227
294
|
method: 'POST',
|
|
228
295
|
path: '/api/remote-mobile/set-secret',
|
|
229
296
|
handler: async (req, res) => {
|
|
@@ -249,6 +316,7 @@ export function createRoutes(store, styleStore) {
|
|
|
249
316
|
},
|
|
250
317
|
// 5.1 清除长期访问密码
|
|
251
318
|
{
|
|
319
|
+
kind: 'exact',
|
|
252
320
|
method: 'POST',
|
|
253
321
|
path: '/api/remote-mobile/clear-secret',
|
|
254
322
|
handler: async (_req, res) => {
|
|
@@ -258,6 +326,7 @@ export function createRoutes(store, styleStore) {
|
|
|
258
326
|
},
|
|
259
327
|
// 6. 撤销单个设备授权
|
|
260
328
|
{
|
|
329
|
+
kind: 'exact',
|
|
261
330
|
method: 'POST',
|
|
262
331
|
path: '/api/remote-mobile/revoke-device',
|
|
263
332
|
handler: async (req, res) => {
|
|
@@ -269,6 +338,7 @@ export function createRoutes(store, styleStore) {
|
|
|
269
338
|
},
|
|
270
339
|
// 7. 一键撤销所有设备
|
|
271
340
|
{
|
|
341
|
+
kind: 'exact',
|
|
272
342
|
method: 'POST',
|
|
273
343
|
path: '/api/remote-mobile/revoke-all',
|
|
274
344
|
handler: async (_req, res) => {
|
|
@@ -278,6 +348,7 @@ export function createRoutes(store, styleStore) {
|
|
|
278
348
|
},
|
|
279
349
|
// 8. 独立手机登录引导页面(已授权或 Tailscale/局域网免密设备访问时自动 302 跳转回工作区,支持 IP 访问计数与限频)
|
|
280
350
|
{
|
|
351
|
+
kind: 'exact',
|
|
281
352
|
method: 'GET',
|
|
282
353
|
path: '/auth',
|
|
283
354
|
handler: async (req, res) => {
|
|
@@ -298,16 +369,16 @@ export function createRoutes(store, styleStore) {
|
|
|
298
369
|
const visitCheck = store.recordAuthVisit(clientIp, ua);
|
|
299
370
|
if (!visitCheck.allowed) {
|
|
300
371
|
res.writeHead(429, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
301
|
-
return res.end(`
|
|
302
|
-
<!DOCTYPE html>
|
|
303
|
-
<html>
|
|
304
|
-
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>安全限制 - DSH</title>
|
|
305
|
-
<style>body{background:#0f172a;color:#fff;font-family:-apple-system,sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;padding:20px;box-sizing:border-box;text-align:center;}
|
|
306
|
-
.card{background:rgba(255,255,255,0.06);border:1px solid rgba(239,68,68,0.4);border-radius:16px;padding:28px 20px;max-width:380px;box-shadow:0 8px 32px rgba(0,0,0,0.4);}
|
|
307
|
-
h2{color:#ef4444;margin:0 0 10px 0;font-size:20px;}p{color:#94a3b8;font-size:14px;line-height:1.6;margin:0;}</style>
|
|
308
|
-
</head>
|
|
309
|
-
<body><div class="card"><h2>⚠️ 访问限制 (429)</h2><p>${visitCheck.reason || '当前 IP 请求过于频繁或已被临时锁定,请稍后刷新重试。'}</p></div></body>
|
|
310
|
-
</html>
|
|
372
|
+
return res.end(`
|
|
373
|
+
<!DOCTYPE html>
|
|
374
|
+
<html>
|
|
375
|
+
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>安全限制 - DSH</title>
|
|
376
|
+
<style>body{background:#0f172a;color:#fff;font-family:-apple-system,sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;padding:20px;box-sizing:border-box;text-align:center;}
|
|
377
|
+
.card{background:rgba(255,255,255,0.06);border:1px solid rgba(239,68,68,0.4);border-radius:16px;padding:28px 20px;max-width:380px;box-shadow:0 8px 32px rgba(0,0,0,0.4);}
|
|
378
|
+
h2{color:#ef4444;margin:0 0 10px 0;font-size:20px;}p{color:#94a3b8;font-size:14px;line-height:1.6;margin:0;}</style>
|
|
379
|
+
</head>
|
|
380
|
+
<body><div class="card"><h2>⚠️ 访问限制 (429)</h2><p>${visitCheck.reason || '当前 IP 请求过于频繁或已被临时锁定,请稍后刷新重试。'}</p></div></body>
|
|
381
|
+
</html>
|
|
311
382
|
`);
|
|
312
383
|
}
|
|
313
384
|
const acceptLang = req.headers['accept-language'] || '';
|
|
@@ -322,6 +393,7 @@ export function createRoutes(store, styleStore) {
|
|
|
322
393
|
},
|
|
323
394
|
// 9. SSE 实时事件监听通道 (Server-Sent Events 0延时推送,彻底告别轮询)
|
|
324
395
|
{
|
|
396
|
+
kind: 'exact',
|
|
325
397
|
method: 'GET',
|
|
326
398
|
path: '/api/remote-mobile/events',
|
|
327
399
|
handler: async (req, res) => {
|
|
@@ -379,6 +451,7 @@ export function createRoutes(store, styleStore) {
|
|
|
379
451
|
},
|
|
380
452
|
// 10. 移动端样式片段(样式小插件)管理 API(合并 GET 与 POST 处理,避免 webserver.register 重复 path 冲突)
|
|
381
453
|
{
|
|
454
|
+
kind: 'exact',
|
|
382
455
|
path: '/api/remote-mobile/styles',
|
|
383
456
|
handler: async (req, res) => {
|
|
384
457
|
if (!styleStore) {
|
|
@@ -416,6 +489,7 @@ export function createRoutes(store, styleStore) {
|
|
|
416
489
|
},
|
|
417
490
|
},
|
|
418
491
|
{
|
|
492
|
+
kind: 'exact',
|
|
419
493
|
method: 'POST',
|
|
420
494
|
path: '/api/remote-mobile/styles/toggle',
|
|
421
495
|
handler: async (req, res) => {
|
|
@@ -429,6 +503,7 @@ export function createRoutes(store, styleStore) {
|
|
|
429
503
|
},
|
|
430
504
|
},
|
|
431
505
|
{
|
|
506
|
+
kind: 'exact',
|
|
432
507
|
method: 'POST',
|
|
433
508
|
path: '/api/remote-mobile/styles/delete',
|
|
434
509
|
handler: async (req, res) => {
|
|
@@ -441,6 +516,7 @@ export function createRoutes(store, styleStore) {
|
|
|
441
516
|
},
|
|
442
517
|
},
|
|
443
518
|
{
|
|
519
|
+
kind: 'exact',
|
|
444
520
|
method: 'POST',
|
|
445
521
|
path: '/api/remote-mobile/styles/reset',
|
|
446
522
|
handler: async (_req, res) => {
|
|
@@ -451,5 +527,44 @@ export function createRoutes(store, styleStore) {
|
|
|
451
527
|
jsonResponse(res, 200, { success: true, snippets: styleStore.list() });
|
|
452
528
|
},
|
|
453
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
|
+
},
|
|
454
569
|
];
|
|
455
570
|
}
|