@yezack/dsh-remote-mobile 1.9.2 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -17,7 +17,10 @@ import { createGlobalAuthGate } from './auth/gate.js';
17
17
  import { createRoutes } from './routes/api.js';
18
18
  import { StyleSnippetStore } from './styles/style-snippets.js';
19
19
  import { getLocalTailscaleIp, getLocalLanIp } from './auth/tailscale.js';
20
- import { patchHttpServerWithVirtualizer, mountIndexInjections, registerRemoteWebUiPairingBridge, takeOverConnectionAuth, } from './bridge/compat.js';
20
+ import { patchHttpServerWithVirtualizer, mountIndexInjections, registerRemoteWebUiPairingBridge, takeOverConnectionAuth, injectTrustedHosts, } from './bridge/compat.js';
21
+ import { detectServerMode } from './gateway/detector.js';
22
+ import { GatewayServer } from './gateway/server.js';
23
+ import { generateGatewaySecret, setGatewaySecret, clearGatewaySecret } from './gateway/secret.js';
21
24
  /** Cordis 插件名称标识 */
22
25
  export const name = 'dsh-remote-mobile';
23
26
  /** DSH 设置命名空间 */
@@ -79,10 +82,15 @@ function bindDshSettings(ctx, store) {
79
82
  * @param config - 插件持久化配置选项 (SessionStoreOptions)
80
83
  */
81
84
  export function apply(ctx, config = {}) {
82
- // 1. 初始化会话与令牌持久化管理器
85
+ // 1. 初始化会话与令牌持久化管理器及内部握手密钥
83
86
  const store = new SessionStore(config);
84
87
  const styleStore = new StyleSnippetStore();
85
88
  const gateMiddleware = createGlobalAuthGate(store);
89
+ // 为本次插件生命周期生成高强度握手 Secret,并绑定到底座 Server
90
+ const gatewaySecret = generateGatewaySecret();
91
+ if (ctx.webServer?.server) {
92
+ setGatewaySecret(ctx.webServer.server, gatewaySecret);
93
+ }
86
94
  // 2. 接入 DSH Settings 服务 (命名空间: dsh-remote-mobile)
87
95
  bindDshSettings(ctx, store);
88
96
  // 3. 挂载底座 WebServer 相关的门禁拦截与路由注册
@@ -90,8 +98,8 @@ export function apply(ctx, config = {}) {
90
98
  if (ctx.webServer.server) {
91
99
  patchHttpServerWithVirtualizer(ctx.webServer.server, gateMiddleware, store, styleStore);
92
100
  }
93
- // 挂载 HTML 模板层的 Crypto Polyfill 注入钩子
94
- mountIndexInjections(ctx);
101
+ // 挂载 HTML 模板层的样式与 Polyfill 注入钩子(在 Gzip 压缩前注入)
102
+ mountIndexInjections(ctx, styleStore);
95
103
  // 注册插件专属的 HTTP & SSE API 路由
96
104
  if (typeof ctx.webServer.register === 'function') {
97
105
  const routes = createRoutes(store, styleStore);
@@ -146,7 +154,70 @@ export function apply(ctx, config = {}) {
146
154
  ctx.logger?.debug?.('[dsh-remote-mobile] 注册 connection 动态注入失败:', e);
147
155
  }
148
156
  }
149
- // 6. 服务就绪时输出提示横幅与接入地址
157
+ // 动态同步受信任主机到所有底层运行时(webRuntime、connection、webStartup)
158
+ const syncTrustedHosts = (target) => {
159
+ try {
160
+ injectTrustedHosts(target || ctx, [store.getActivePort(), 3080, 3081], store.getOptions().trustedHosts);
161
+ }
162
+ catch { }
163
+ };
164
+ syncTrustedHosts();
165
+ if (typeof ctx.inject === 'function') {
166
+ try {
167
+ ctx.inject(['webRuntime'], (runtimeCtx) => {
168
+ syncTrustedHosts(runtimeCtx);
169
+ });
170
+ }
171
+ catch { }
172
+ }
173
+ // 6. 探测 DSH 底座网络暴露姿态,自适应裁决是否拉起独立安全反向代理网关
174
+ // 配置错误(如 mode: 'gateway' + 具体 LAN IP)不能把整个插件 apply 抛崩;
175
+ // 捕获后回退到 direct 模式,让用户仍可通过 3080 本机/局域网访问并修改配置。
176
+ let modeFallbackError = '';
177
+ let detection;
178
+ try {
179
+ detection = detectServerMode(ctx, store.getOptions().mode, ctx.webServer?.port ?? 3080);
180
+ }
181
+ catch (err) {
182
+ modeFallbackError = err?.message || String(err);
183
+ ctx.logger?.error?.('[dsh-remote-mobile] 网关模式不可用,已回退 direct 模式:', err);
184
+ detection = detectServerMode(ctx, 'direct', ctx.webServer?.port ?? 3080);
185
+ }
186
+ // 强制 gateway 且底座已监听通配地址时,输出安全提醒:核心 3080 仍暴露,
187
+ // 网关只是附加入口,不能替代核心侧的 Host/Origin 防护。
188
+ const modeWarning = detection.mode === 'gateway' && !detection.isLoopback
189
+ ? `底座本身已监听 ${detection.rawCoreHost},3080 仍是对外入口;网关 3081 为附加入口,建议优先恢复 127.0.0.1 监听。`
190
+ : '';
191
+ let gatewayServer = null;
192
+ let gatewayReadyPromise = null;
193
+ if (detection.mode === 'gateway') {
194
+ const opts = store.getOptions();
195
+ gatewayServer = new GatewayServer({
196
+ port: opts.gatewayPort ?? 3081,
197
+ host: opts.gatewayHost ?? '0.0.0.0',
198
+ corePort: detection.corePort,
199
+ coreHost: detection.coreHost,
200
+ }, store, gateMiddleware, ctx.logger);
201
+ // 初始标记为正在启动中
202
+ store.setActiveGatewayInfo('gateway', opts.gatewayPort ?? 3081, detection.corePort, false);
203
+ gatewayReadyPromise = gatewayServer.start().then(({ port }) => {
204
+ if (gatewayServer?.getServer()) {
205
+ setGatewaySecret(gatewayServer.getServer(), gatewaySecret);
206
+ }
207
+ store.setActiveGatewayInfo('gateway', port, detection.corePort, true);
208
+ syncTrustedHosts();
209
+ return port;
210
+ }).catch((err) => {
211
+ store.setActiveGatewayInfo('gateway', opts.gatewayPort ?? 3081, detection.corePort, false, err?.message || String(err));
212
+ ctx.logger?.error?.('[dsh-remote-mobile] 独立安全反向代理网关启动失败:', err);
213
+ return opts.gatewayPort ?? 3081;
214
+ });
215
+ }
216
+ else {
217
+ store.setActiveGatewayInfo('direct', detection.corePort, detection.corePort, true);
218
+ gatewayReadyPromise = Promise.resolve(detection.corePort);
219
+ }
220
+ // 7. 服务就绪时输出提示横幅与接入地址
150
221
  let printed = false;
151
222
  let disposed = false;
152
223
  let timer1 = null;
@@ -158,18 +229,42 @@ export function apply(ctx, config = {}) {
158
229
  clearTimeout(timer1);
159
230
  if (timer2)
160
231
  clearTimeout(timer2);
232
+ if (gatewayServer) {
233
+ clearGatewaySecret(gatewayServer.getServer());
234
+ gatewayServer.close().catch(() => { });
235
+ }
236
+ const webServer = (typeof ctx.get === 'function' ? ctx.get('webServer') : undefined) || ctx.webServer;
237
+ if (webServer?.server) {
238
+ clearGatewaySecret(webServer.server);
239
+ }
161
240
  });
162
241
  }
163
242
  catch { }
164
- const printReadyBanner = () => {
243
+ const printReadyBanner = async () => {
165
244
  // 生命周期守卫:若上下文已废弃/进入 dispose,放弃回调
166
245
  if (disposed || printed)
167
246
  return;
168
247
  printed = true;
248
+ // 等待网关就绪以获得准确的对外端口(避免端口自增探测竞态)
249
+ try {
250
+ if (gatewayReadyPromise) {
251
+ await Promise.race([
252
+ gatewayReadyPromise,
253
+ new Promise((r) => setTimeout(r, 2000)),
254
+ ]);
255
+ }
256
+ }
257
+ catch { }
258
+ // 等待期间插件可能已被 dispose / 热重载;此时绝不能再 setGatewaySecret / patch / mount
259
+ if (disposed)
260
+ return;
169
261
  try {
262
+ syncTrustedHosts();
170
263
  const webServer = (typeof ctx.get === 'function' ? ctx.get('webServer') : undefined) || ctx.webServer;
171
264
  if (webServer?.server) {
265
+ setGatewaySecret(webServer.server, gatewaySecret);
172
266
  patchHttpServerWithVirtualizer(webServer.server, gateMiddleware, store, styleStore);
267
+ mountIndexInjections(ctx, styleStore);
173
268
  }
174
269
  try {
175
270
  const conn = typeof ctx.get === 'function' ? ctx.get('connection') : undefined;
@@ -181,18 +276,49 @@ export function apply(ctx, config = {}) {
181
276
  }
182
277
  const tsIp = getLocalTailscaleIp();
183
278
  const lanIp = getLocalLanIp();
184
- const port = webServer?.port ?? 3080;
279
+ const activePort = store.getActivePort();
280
+ const activeMode = store.getActiveMode();
281
+ const corePort = store.getCorePort();
185
282
  const opts = store.getOptions();
283
+ const gwStatus = store.getGatewayStatus();
186
284
  const divider = '─'.repeat(72);
187
285
  console.log(`\n┌${divider}`);
188
286
  console.log(`│ [dsh-remote-mobile] 官方临时 Token 鉴权已接管,PC/移动端访问无需携带官方token`);
189
- console.log(`│ 远程安全网关已就绪,支持手机浏览器访问:`);
190
- console.log(`│ • 本机回环: http://127.0.0.1:${port}`);
191
- if (lanIp && lanIp !== '127.0.0.1') {
192
- console.log(`│ • 局域网: http://${lanIp}:${port} (同 Wi-Fi 局域网直连)`);
287
+ if (modeFallbackError) {
288
+ console.log(`│ ⚠️ 网关配置不可用,已回退 direct 模式: ${modeFallbackError}`);
193
289
  }
194
- if (tsIp) {
195
- console.log(`│ • 虚拟私网: http://${tsIp}:${port} (Tailscale 私网异地直连)`);
290
+ if (modeWarning) {
291
+ console.log(`│ ⚠️ ${modeWarning}`);
292
+ }
293
+ if (activeMode === 'gateway') {
294
+ if (gwStatus.running) {
295
+ console.log(`│ 独立安全反代网关已激活 (0.0.0.0:${activePort} -> 127.0.0.1:${corePort})`);
296
+ console.log(`│ 手机/远程设备请访问安全网关地址:`);
297
+ if (lanIp && lanIp !== '127.0.0.1') {
298
+ console.log(`│ • 局域网: http://${lanIp}:${activePort} (同 Wi-Fi 局域网直连)`);
299
+ }
300
+ if (tsIp) {
301
+ console.log(`│ • 虚拟私网: http://${tsIp}:${activePort} (Tailscale 私网异地直连)`);
302
+ }
303
+ console.log(`│ 电脑本机浏览器访问控制台:`);
304
+ console.log(`│ • 本机回环: http://127.0.0.1:${corePort}`);
305
+ }
306
+ else {
307
+ console.log(`│ ⚠️ 独立安全反代网关未成功启动: ${gwStatus.error || '端口占用'}`);
308
+ console.log(`│ 外部手机设备当前暂时无法连接,请检查 3081 端口占用`);
309
+ console.log(`│ 电脑本机浏览器仍可直接访问:`);
310
+ console.log(`│ • 本机回环: http://127.0.0.1:${corePort}`);
311
+ }
312
+ }
313
+ else {
314
+ console.log(`│ 远程安全网关已就绪,支持手机浏览器访问:`);
315
+ console.log(`│ • 本机回环: http://127.0.0.1:${corePort}`);
316
+ if (lanIp && lanIp !== '127.0.0.1') {
317
+ console.log(`│ • 局域网: http://${lanIp}:${corePort} (同 Wi-Fi 局域网直连)`);
318
+ }
319
+ if (tsIp) {
320
+ console.log(`│ • 虚拟私网: http://${tsIp}:${corePort} (Tailscale 私网异地直连)`);
321
+ }
196
322
  }
197
323
  console.log(`│ 访问与配置说明:`);
198
324
  console.log(`│ - 手机/远程设备打开对应地址即可直接访问`);
@@ -200,9 +326,9 @@ export function apply(ctx, config = {}) {
200
326
  console.log(`│ 可开启免密直连(局域网/Tailscale)、查看 6 位临时配对码或设置长期密码`);
201
327
  console.log(`│ - 项目文档: https://github.com/IceApriler/dsh-remote-mobile`);
202
328
  console.log(`└${divider}\n`);
203
- ctx.logger?.info?.(`[dsh-remote-mobile] 移动端远程控制与安全门禁已激活 (上下文虚拟化桥接就绪,样式片段: ${styleStore.list().length} 个)`);
329
+ ctx.logger?.info?.(`[dsh-remote-mobile] 移动端远程控制与安全门禁已激活 (运行模式: ${activeMode}, 对外端口: ${activePort})`);
204
330
  if (tsIp) {
205
- ctx.logger?.info?.(`[dsh-remote-mobile] Tailscale 地址: http://${tsIp}:${port} (免密直连: ${opts.allowTailscale ? '已开启' : '已关闭'})`);
331
+ ctx.logger?.info?.(`[dsh-remote-mobile] Tailscale 地址: http://${tsIp}:${activePort} (免密直连: ${opts.allowTailscale ? '已开启' : '已关闭'})`);
206
332
  }
207
333
  }
208
334
  catch (e) {
@@ -234,3 +360,5 @@ export * from './auth/gate.js';
234
360
  export * from './routes/api.js';
235
361
  export * from './bridge/compat.js';
236
362
  export * from './styles/style-snippets.js';
363
+ export * from './gateway/detector.js';
364
+ export * from './gateway/server.js';
package/lib/routes/api.js CHANGED
@@ -86,6 +86,12 @@ export function createRoutes(store, styleStore) {
86
86
  pairingBridgeMode: bridgeState.mode,
87
87
  pairingBridgeConflict: bridgeState.conflictWith || '',
88
88
  pairingBridgeConflictId: bridgeState.conflictEntryId || '',
89
+ // 独立安全网关运行状态
90
+ gateway: {
91
+ ...store.getGatewayStatus(),
92
+ activePort: store.getActivePort(),
93
+ gatewayPort: opts.gatewayPort || 3081,
94
+ },
89
95
  });
90
96
  },
91
97
  },
@@ -298,16 +304,16 @@ export function createRoutes(store, styleStore) {
298
304
  const visitCheck = store.recordAuthVisit(clientIp, ua);
299
305
  if (!visitCheck.allowed) {
300
306
  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>
307
+ return res.end(`
308
+ <!DOCTYPE html>
309
+ <html>
310
+ <head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>安全限制 - DSH</title>
311
+ <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;}
312
+ .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);}
313
+ h2{color:#ef4444;margin:0 0 10px 0;font-size:20px;}p{color:#94a3b8;font-size:14px;line-height:1.6;margin:0;}</style>
314
+ </head>
315
+ <body><div class="card"><h2>⚠️ 访问限制 (429)</h2><p>${visitCheck.reason || '当前 IP 请求过于频繁或已被临时锁定,请稍后刷新重试。'}</p></div></body>
316
+ </html>
311
317
  `);
312
318
  }
313
319
  const acceptLang = req.headers['accept-language'] || '';