@feiyang666/dsh-mobile-remote 1.9.0 → 2.0.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/external.js CHANGED
@@ -124,9 +124,10 @@ const POOL_COUNT_RE = /^\s*(transport\.)?poolCount\s*=.*(\r?\n|$)/gm
124
124
  * 连接复用 tcpMux 自动管理;服务端可能仍在下发该字段,本地一律剥离,
125
125
  * 否则 frpc 解析配置失败、启动即退出)。
126
126
  * @param {string} cfg - 后端返回的原始 frpc 配置
127
+ * @param {string} [localHost] - frpc 应当转发到的本地地址(宿主真实监听地址)
127
128
  * @returns {string} 修正后的配置
128
129
  */
129
- function finalizeFrpcConfig(cfg) {
130
+ function finalizeFrpcConfig(cfg, localHost) {
130
131
  let text = String(cfg || '')
131
132
  // 3) 先剥离 client 端已不支持的 poolCount 字段(放最前,保证后续注入干净)
132
133
  text = text.replace(POOL_COUNT_RE, '')
@@ -140,7 +141,19 @@ function finalizeFrpcConfig(cfg) {
140
141
  }
141
142
  text = text.replace(/^(\s*serverAddr\s*=\s*)[^\n]+/m, `$1"${override.trim()}"`)
142
143
  }
143
- // 2) loginFailExit 必须放顶层(第一个 [[proxies]] 之前),否则 frpc 解析报错
144
+ // 2.5) localIP 覆写:宿主如果只绑了某块网卡(官方桌面端/第三方壳的 `--host 192.168.x.x`),
145
+ // 127.0.0.1 上没有任何监听,frpc 转发过去就是 connection refused →
146
+ // 中转服务器/nginx 对外只能是 502 Bad Gateway。这里把代理的 localIP 改成宿主真实地址。
147
+ const target = String(localHost || '127.0.0.1').trim() || '127.0.0.1'
148
+ if (target !== '127.0.0.1') {
149
+ if (/^\s*localIP\s*=/m.test(text)) {
150
+ text = text.replace(/^(\s*localIP\s*=\s*)[^\n]*/gm, `$1"${target}"`)
151
+ } else {
152
+ // 没有 localIP 行时补在每条 localPort 之前(与 frp 配置顺序无关,读者更直观)
153
+ text = text.replace(/^(\s*localPort\s*=\s*)/gm, `localIP = "${target}"\n$1`)
154
+ }
155
+ }
156
+ // 3) loginFailExit 必须放顶层(第一个 [[proxies]] 之前),否则 frpc 解析报错
144
157
  if (!/loginFailExit\s*=/.test(text)) {
145
158
  const injectLine = '# 由 dsh-mobile-remote 注入:连不上 frps 时持续重试不退出\nloginFailExit = false\n'
146
159
  const proxiesIdx = text.indexOf('[[proxies]]')
@@ -351,7 +364,7 @@ async function ensureFrpcBinary(base) {
351
364
  * 启动外网隧道。做了稳健性处理,避免"反复点/开了又关"。
352
365
  * @returns {Promise<{ok:boolean, url?:string, status?:string, error?:string, message?:string, log?:string}>}
353
366
  */
354
- async function startExternal(state, body, patchFile, defaultLocalPort) {
367
+ async function startExternal(state, body, patchFile, defaultLocalPort, localHost) {
355
368
  // 已运行则直接返回,避免重复点重复启动;
356
369
  // 但状态为 error(如鉴权失败)时允许重试:kill 旧进程并重新绑定。
357
370
  if (state.proc && state.proc.exitCode === null && state.status !== 'error') {
@@ -363,8 +376,9 @@ async function startExternal(state, body, patchFile, defaultLocalPort) {
363
376
 
364
377
  state.status = 'connecting'
365
378
  state.error = null
379
+ state.localHost = String(localHost || '127.0.0.1')
366
380
  try {
367
- return await startExternalInner(state, body, patchFile, defaultLocalPort)
381
+ return await startExternalInner(state, body, patchFile, defaultLocalPort, state.localHost)
368
382
  } catch (error) {
369
383
  // 启动失败必须复位状态,否则前端会一直卡在"连接中"且无法关闭
370
384
  state.status = 'error'
@@ -373,7 +387,7 @@ async function startExternal(state, body, patchFile, defaultLocalPort) {
373
387
  }
374
388
  }
375
389
 
376
- async function startExternalInner(state, body, patchFile, defaultLocalPort) {
390
+ async function startExternalInner(state, body, patchFile, defaultLocalPort, localHost) {
377
391
  // 端口优先级:调用方显式指定 > 当前真实监听端口。
378
392
  // 不再让过期的持久化 localPort 覆盖当前端口(否则 frpc 会转发到无人监听的端口)。
379
393
  const localPort = Number((body && body.localPort) || defaultLocalPort || 0)
@@ -401,7 +415,7 @@ async function startExternalInner(state, body, patchFile, defaultLocalPort) {
401
415
 
402
416
  // 2) 写 frpc.toml(含 loginFailExit=false 与回环地址覆写)
403
417
  const cfgPath = frpcConfigPath()
404
- const frpcConfig = finalizeFrpcConfig(data.frpcConfig)
418
+ const frpcConfig = finalizeFrpcConfig(data.frpcConfig, localHost)
405
419
  // 防线:确认代理确实指向当前监听端口。错配时 frpc 会连不上本地服务,
406
420
  // 表现为外网域名 502 Bad Gateway(日志:connect to local service ... refused)。
407
421
  if (localPort) {
@@ -489,6 +503,9 @@ async function startExternalInner(state, body, patchFile, defaultLocalPort) {
489
503
  state.wsClient.connect()
490
504
  // 隧道确认建立后启动持续心跳,让后端 last_seen 保持新鲜(管理端在线判定依赖它)
491
505
  startKeepAlive(state)
506
+ // 同时开始探测外网入口的真实可达性:frpc 进程活着并不代表 nginx/中转侧能拿到
507
+ // 200 —— 本地服务没起来(端口/绑定地址不对)时对外就是 502,必须自己暴露出来。
508
+ startUrlProbe(state)
492
509
  }
493
510
 
494
511
  const probe = () => {
@@ -575,6 +592,7 @@ async function startExternalInner(state, body, patchFile, defaultLocalPort) {
575
592
  */
576
593
  function stopExternal(state, opts = {}) {
577
594
  stopKeepAlive(state)
595
+ stopUrlProbe(state)
578
596
  // 断开 WS 长连接(中转服务端据此判定离线)
579
597
  if (state.wsClient) {
580
598
  try { state.wsClient.disconnect() } catch { /* ignore */ }
@@ -601,7 +619,7 @@ function stopExternal(state, opts = {}) {
601
619
  * 重新绑定并拉起 frpc,免去每次手动输入验证码与中转服务地址。
602
620
  * 失败时把状态置为 error 并保留持久化,供用户手动处理或下次重启重试。
603
621
  */
604
- async function autoRestoreExternal(state, patchFile, defaultLocalPort) {
622
+ async function autoRestoreExternal(state, patchFile, defaultLocalPort, localHost) {
605
623
  const saved = loadPersistedState()
606
624
  if (!saved) return
607
625
  try {
@@ -620,7 +638,7 @@ async function autoRestoreExternal(state, patchFile, defaultLocalPort) {
620
638
  if (stale && live && stale !== live) {
621
639
  state.lastLog = `检测到端口变化(持久化 ${stale} → 当前 ${live}),外网隧道已改用当前端口`
622
640
  }
623
- await startExternal(state, body, patchFile, live || stale)
641
+ await startExternal(state, body, patchFile, live || stale, localHost)
624
642
  } catch (e) {
625
643
  state.status = 'error'
626
644
  state.error = `自动恢复外网访问失败:${String((e && e.message) || e)}`
@@ -709,6 +727,71 @@ function probeTunnelHealth(state) {
709
727
  return verdict
710
728
  }
711
729
 
730
+ /* ------------------------------------------------------------------ */
731
+ /* 外网入口可达性探测 */
732
+ /* ------------------------------------------------------------------ */
733
+
734
+ /** 探测结果缓存:同一 URL 30 秒内不重复请求(status 每 5s 轮询一次)。 */
735
+ let urlProbeCache = { url: '', at: 0, value: null }
736
+
737
+ /**
738
+ * 用 HTTP GET 探测外网入口的真实状态。
739
+ * frpc 进程活着只代表「已经连上 frps」,不代表中转服务器能把请求送到本机:
740
+ * 本机端口没人监听 / 绑定地址不对时,nginx 对外一律回 502 —— 这里把它探测出来。
741
+ * @param {string} url - 外网访问地址(https://xxx)
742
+ * @returns {Promise<{ok: boolean, status: number, at: number, error?: string}|null>}
743
+ */
744
+ async function probeExternalUrl(url) {
745
+ if (!url) return null
746
+ const now = Date.now()
747
+ if (urlProbeCache.url === url && now - urlProbeCache.at < 30_000) return urlProbeCache.value
748
+ let value
749
+ try {
750
+ const res = await fetch(url, {
751
+ method: 'GET',
752
+ redirect: 'follow',
753
+ headers: { 'user-agent': 'dsh-mobile-remote/healthcheck' },
754
+ signal: AbortSignal.timeout(8000),
755
+ })
756
+ // 只用状态码判断可达性,正文直接丢弃(避免把整个前端页面读进内存)
757
+ try { await res.arrayBuffer() } catch { /* ignore */ }
758
+ value = {
759
+ ok: res.status >= 200 && res.status < 400,
760
+ status: res.status,
761
+ at: Date.now(),
762
+ error: res.status === 502
763
+ ? '中转服务器返回 502:frps 已连上,但本机端口没有响应(转发地址/端口不对,或 dsh 未监听该地址)'
764
+ : null,
765
+ }
766
+ } catch (error) {
767
+ value = { ok: false, status: 0, at: Date.now(), error: String((error && error.message) || error) }
768
+ }
769
+ urlProbeCache = { url, at: now, value }
770
+ return value
771
+ }
772
+
773
+ /** 启动外网入口可达性轮询(30s 一次,随隧道停止而停止)。 */
774
+ function startUrlProbe(state) {
775
+ if (state.probeTimer) return
776
+ const tick = async () => {
777
+ if (!state.url) return
778
+ const value = await probeExternalUrl(state.url)
779
+ if (value) state.probe = value
780
+ }
781
+ void tick()
782
+ state.probeTimer = setInterval(() => { void tick() }, 30_000)
783
+ if (state.probeTimer.unref) state.probeTimer.unref()
784
+ }
785
+
786
+ /** 停止外网入口探测并清空缓存结果。 */
787
+ function stopUrlProbe(state) {
788
+ if (state.probeTimer) {
789
+ clearInterval(state.probeTimer)
790
+ state.probeTimer = null
791
+ }
792
+ state.probe = null
793
+ }
794
+
712
795
  /** 当前外网状态摘要(供 /status 使用)。 */
713
796
  function externalView(state) {
714
797
  const running = !!state.proc && state.proc.exitCode === null
@@ -737,6 +820,10 @@ function externalView(state) {
737
820
  pid: running && state.proc ? state.proc.pid : null,
738
821
  startedAt: state.startedAt || null,
739
822
  lastLog: state.lastLog || null,
823
+ // frpc 实际转发的本地地址(非回环时是宿主的网卡 IP)
824
+ localHost: state.localHost || null,
825
+ // 外网入口真实可达性:{ ok, status, at, error }
826
+ probe: state.probe || null,
740
827
  error,
741
828
  }
742
829
  }
@@ -746,6 +833,8 @@ export {
746
833
  externalDir,
747
834
  frpcBinaryPath,
748
835
  frpcConfigPath,
836
+ // 供单元测试直接断言配置改写(localIP / loginFailExit / poolCount)
837
+ finalizeFrpcConfig,
749
838
  startExternal,
750
839
  stopExternal,
751
840
  externalView,