@feiyang666/dsh-mobile-remote 1.1.0 → 1.4.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.
@@ -0,0 +1,729 @@
1
+ // dsh-mobile-remote — 外网隧道管理模块(host 侧)。
2
+ //
3
+ // 负责把"外网访问"做成一个开关:
4
+ // - 从中转服务(dsh-update-server)通过绑定码换取 frpc 配置 + 外网链接
5
+ // - 下载/定位 frpc 可执行文件,写入 frpc.toml 并启动子进程
6
+ // - 写 profile 的 connection trustedHosts,放行外网域名(通过 /api 信任围栏)
7
+ // - 心跳上报、状态查询、停止
8
+ //
9
+ // 零运行时依赖:仅 Node 内置模块。frpc 二进制由中转服务提供或用户本地放置。
10
+ //
11
+ // 稳健性说明(针对"开启难/开了又关"问题):
12
+ // - 强制给 frpc.toml 追加 loginFailExit=false:连不上 frps 时持续重试,
13
+ // 而不是立即自杀退出导致开关被前端刷回"关闭"。
14
+ // - 若后端返回的 serverAddr 是回环地址(127.0.0.1/localhost),用环境变量
15
+ // DSH_FRPS_SERVER 覆写为真正的宝塔 frps 公网地址(防止连到本机)。
16
+ // - 启动后等待短暂时间确认子进程存活再判 online,避免"spawn 成功但秒退"误报。
17
+ // - 期间状态标记为 connecting,前端可据此显示"正在连接…"而不是空转。
18
+
19
+ import { homedir } from 'node:os'
20
+ import { join } from 'node:path'
21
+ import {
22
+ existsSync, readFileSync, writeFileSync, mkdirSync, openSync, closeSync, rmSync,
23
+ } from 'node:fs'
24
+ import { spawn } from 'node:child_process'
25
+
26
+ /** 默认中转服务地址(自建 dsh-update-server,Nginx 反代 443→3200,对外无端口)。
27
+ * 可用 DSH_REMOTE_SERVER 覆盖。 */
28
+ const DEFAULT_SERVER_BASE = 'https://api.deepseekharness.desktop.cwj666.top'
29
+
30
+ /** 中转服务基地址:环境变量 > 请求传入 > 默认。 */
31
+ function serverBase(body) {
32
+ return (
33
+ (body && typeof body.serverBase === 'string' && body.serverBase.trim())
34
+ || process.env.DSH_REMOTE_SERVER
35
+ || DEFAULT_SERVER_BASE
36
+ )
37
+ }
38
+
39
+ /** 外网状态本地持久化目录。 */
40
+ function externalDir() {
41
+ const home = process.env.DSH_HOME || join(homedir(), '.dsh')
42
+ return join(home, 'plugins', 'dsh-mobile-remote', 'external')
43
+ }
44
+
45
+ /* ------------------------------------------------------------------ */
46
+ /* 外网状态持久化:记录已启用的绑定信息,供 dsh 重启后自动恢复 */
47
+ /* ------------------------------------------------------------------ */
48
+
49
+ function stateFilePath() {
50
+ return join(externalDir(), 'state.json')
51
+ }
52
+
53
+ /** 读取持久化的外网访问状态;未启用/损坏时返回 null。 */
54
+ function loadPersistedState() {
55
+ try {
56
+ const file = stateFilePath()
57
+ if (!existsSync(file)) return null
58
+ const data = JSON.parse(readFileSync(file, 'utf8'))
59
+ if (!data || data.enabled !== true || !data.bindCode) return null
60
+ return {
61
+ bindCode: String(data.bindCode || ''),
62
+ serverBase: String(data.serverBase || ''),
63
+ localPort: Number(data.localPort) || 0,
64
+ domain: String(data.domain || ''),
65
+ url: String(data.url || ''),
66
+ tunnelPort: Number(data.tunnelPort) || 0,
67
+ }
68
+ } catch {
69
+ return null
70
+ }
71
+ }
72
+
73
+ /** 保存当前启用的外网访问状态。 */
74
+ function savePersistedState(state) {
75
+ try {
76
+ mkdirSync(externalDir(), { recursive: true })
77
+ writeFileSync(stateFilePath(), JSON.stringify({
78
+ enabled: true,
79
+ bindCode: state.bindCode || '',
80
+ serverBase: state.serverBase || '',
81
+ localPort: state.localPort || 0,
82
+ domain: state.domain || '',
83
+ url: state.url || '',
84
+ tunnelPort: state.tunnelPort || 0,
85
+ savedAt: new Date().toISOString(),
86
+ }, null, 2), 'utf8')
87
+ } catch { /* 持久化失败不致命,仅无法自动恢复 */ }
88
+ }
89
+
90
+ /** 清除持久化的外网访问状态(用户主动关闭时调用)。 */
91
+ function clearPersistedState() {
92
+ try { rmSync(stateFilePath(), { force: true }) } catch { /* ignore */ }
93
+ }
94
+
95
+ /** frpc 可执行文件路径。 */
96
+ function frpcBinaryPath() {
97
+ return join(externalDir(), process.platform === 'win32' ? 'frpc.exe' : 'frpc')
98
+ }
99
+
100
+ /** frpc 配置文件路径。 */
101
+ function frpcConfigPath() {
102
+ return join(externalDir(), 'frpc.toml')
103
+ }
104
+
105
+ /** 回环地址判断。 */
106
+ function isLoopback(addr) {
107
+ const a = String(addr || '').trim().toLowerCase()
108
+ return a === '127.0.0.1' || a === 'localhost' || a === '::1'
109
+ }
110
+
111
+ /** 匹配 frpc 配置里的 poolCount 字段(frp v0.59+ 已移除该客户端字段,
112
+ * 残留会导致 "unmarshal ProxyConfig error: json: unknown field poolCount" → frpc 解析失败秒退)。
113
+ * 既匹配顶层 transport.poolCount,也匹配裸 poolCount,统一剥离。 */
114
+ const POOL_COUNT_RE = /^\s*(transport\.)?poolCount\s*=.*(\r?\n|$)/gm
115
+
116
+ /**
117
+ * 修正 frpc 配置文本:
118
+ * 1) 若 serverAddr 是回环地址,用 DSH_FRPS_SERVER 覆写(如需连宝塔 frps);
119
+ * 2) 强制追加 loginFailExit = false,避免连不上就退出;
120
+ * 3) 剥离 frp 客户端已不支持的 poolCount 字段(frp v0.59+ 已移除,改由
121
+ * 连接复用 tcpMux 自动管理;服务端可能仍在下发该字段,本地一律剥离,
122
+ * 否则 frpc 解析配置失败、启动即退出)。
123
+ * @param {string} cfg - 后端返回的原始 frpc 配置
124
+ * @returns {string} 修正后的配置
125
+ */
126
+ function finalizeFrpcConfig(cfg) {
127
+ let text = String(cfg || '')
128
+ // 3) 先剥离 client 端已不支持的 poolCount 字段(放最前,保证后续注入干净)
129
+ text = text.replace(POOL_COUNT_RE, '')
130
+ // 1) serverAddr 回环地址覆写:优先 DSH_FRPS_SERVER,其次回退到提取到的非回环地址
131
+ const override = process.env.DSH_FRPS_SERVER
132
+ const addr = extractServerAddr(text)
133
+ if (isLoopback(addr)) {
134
+ if (!override) {
135
+ // 没有可用的 frps 公网地址:抛出明确错误,避免连本机白耗
136
+ throw new Error('中转配置里的 frps 地址是本机回环(127.0.0.1)。请在宝塔后端 .env 配 FRPS_SERVER=宝塔公网IP,或用环境变量 DSH_FRPS_SERVER 指定。')
137
+ }
138
+ text = text.replace(/^(\s*serverAddr\s*=\s*)[^\n]+/m, `$1"${override.trim()}"`)
139
+ }
140
+ // 2) loginFailExit 必须放顶层(第一个 [[proxies]] 之前),否则 frpc 解析报错
141
+ if (!/loginFailExit\s*=/.test(text)) {
142
+ const injectLine = '# 由 dsh-mobile-remote 注入:连不上 frps 时持续重试不退出\nloginFailExit = false\n'
143
+ const proxiesIdx = text.indexOf('[[proxies]]')
144
+ if (proxiesIdx >= 0) {
145
+ // 在 [[proxies]] 之前插入(顶层全局配置)
146
+ text = text.slice(0, proxiesIdx) + injectLine + '\n' + text.slice(proxiesIdx)
147
+ } else {
148
+ text = text.replace(/\s*$/, '\n') + '\n' + injectLine
149
+ }
150
+ }
151
+ return text
152
+ }
153
+
154
+ /** 从 frpc 配置文本提取 serverAddr 的值。 */
155
+ function extractServerAddr(text) {
156
+ const m = /^\s*serverAddr\s*=\s*["']([^"']+)["']/m.exec(text || '')
157
+ return m ? m[1] : ''
158
+ }
159
+
160
+ /** 调中转服务 /api/tunnel/bind 换取配置。 */
161
+ async function bindToServer(body, localPort) {
162
+ const base = serverBase(body)
163
+ const res = await fetch(`${base.replace(/\/$/, '')}/api/tunnel/bind`, {
164
+ method: 'POST',
165
+ headers: { 'Content-Type': 'application/json' },
166
+ body: JSON.stringify({
167
+ bindCode: body && body.bindCode,
168
+ localPort,
169
+ }),
170
+ })
171
+ if (!res.ok) {
172
+ let msg = `绑定失败 (HTTP ${res.status})`
173
+ try { const j = await res.json(); if (j && j.message) msg = j.message } catch { /* ignore */ }
174
+ throw new Error(msg)
175
+ }
176
+ const j = await res.json()
177
+ if (!j || j.success !== true || !j.data) throw new Error('绑定响应无效')
178
+ return j.data
179
+ }
180
+
181
+ /** 下载 frpc 可执行文件(从中转服务 /api/tunnel/frpc)。 */
182
+ async function downloadFrpc(base) {
183
+ mkdirSync(externalDir(), { recursive: true })
184
+ const bin = frpcBinaryPath()
185
+ const res = await fetch(`${base.replace(/\/$/, '')}/api/tunnel/frpc`)
186
+ if (!res.ok) throw new Error(`frpc 下载失败 (HTTP ${res.status})`)
187
+ const buf = Buffer.from(await res.arrayBuffer())
188
+ writeFileSync(bin, buf, { mode: 0o755 })
189
+ return bin
190
+ }
191
+
192
+ /**
193
+ * 写 profile 的 connection trustedHosts,放行外网域名。
194
+ * @param {string} patchFile - profile 的 cordis.patch.yml 绝对路径
195
+ * @param {string} domain - 外网域名(不带端口)
196
+ * @returns {boolean}
197
+ */
198
+ /**
199
+ * 写 profile 的 connection trustedHosts,放行外网域名。
200
+ * 必须使用 loader 中的正确插件 id `connection`,并用 !!js 表达式保留
201
+ * webRuntime 自动推导的 LAN/CLI trusted hosts,否则 patch 不生效 / 覆盖损坏。
202
+ */
203
+ function ensureTrustedHost(patchFile, domain) {
204
+ let content = existsSync(patchFile) ? readFileSync(patchFile, 'utf8') : ''
205
+ // 1) 清理旧的错误块(id: client-connection 或旧格式的纯数组),避免堆积/语法冲突
206
+ content = content.replace(/\n*(# --- dsh-mobile-remote external trusted-host ---)\n[\s\S]*?(# --- end dsh-mobile-remote external trusted-host ---)\n*/g, '\n')
207
+ content = content.replace(/\n{3,}/g, '\n\n').replace(/\s*$/, '\n')
208
+ // 2) 若已含目标域名的正确配置则直接返回
209
+ if (content.includes(`'${domain}'`)) return true
210
+ // 3) 追加正确块:id=connection + !!js 表达式(保留 webRuntime.trustedHosts)
211
+ // 注意:dsh-app-boot 的 entryListSchema(js-yaml JSON_SCHEMA + JsExpr)中 !!js 为
212
+ // scalar kind,只能修饰标量字符串。若直接写在 flow sequence 前(!!js [...]),
213
+ // js-yaml 会报 `unknown tag !<tag:yaml.org,2002:js>`,导致 profile 无法启动。
214
+ // 因此表达式必须用双引号包成 YAML 标量,loader 求值时再还原为数组。
215
+ const block = [
216
+ '',
217
+ '# --- dsh-mobile-remote external trusted-host ---',
218
+ '- id: connection',
219
+ ' config:',
220
+ ` trustedHosts: !!js "['${domain}', ...ctx.webRuntime.trustedHosts]"`,
221
+ '# --- end dsh-mobile-remote external trusted-host ---',
222
+ '',
223
+ ]
224
+ const next = (content + '\n' + block.join('\n')).replace(/\n{3,}/g, '\n\n').replace(/\s*$/, '\n')
225
+ writeFileSync(patchFile, next, 'utf8')
226
+ return true
227
+ }
228
+
229
+ /** frpc 进程 pid 文件路径(用于清理残留实例,避免多进程抢同一代理名)。 */
230
+ function frpcPidPath() {
231
+ return join(externalDir(), 'frpc.pid')
232
+ }
233
+
234
+ /** 读取 pid 文件里的进程号;缺失 / 损坏返回 0。 */
235
+ function readFrpcPid() {
236
+ try {
237
+ const pid = parseInt(readFileSync(frpcPidPath(), 'utf8').trim(), 10)
238
+ return Number.isFinite(pid) && pid > 0 ? pid : 0
239
+ } catch {
240
+ return 0
241
+ }
242
+ }
243
+
244
+ /** 记录当前 frpc 子进程 pid。 */
245
+ function writeFrpcPid(pid) {
246
+ try { writeFileSync(frpcPidPath(), String(pid), 'utf8') } catch { /* 记录失败不致命 */ }
247
+ }
248
+
249
+ /** 清理残留的旧 frpc 实例(dsh 崩溃 / 重复点击导致的多实例)。
250
+ * 仅处理由本插件 pid 文件记录的进程,避免误杀无关进程。 */
251
+ function killStaleFrpc() {
252
+ const pid = readFrpcPid()
253
+ try { rmSync(frpcPidPath(), { force: true }) } catch { /* ignore */ }
254
+ if (!pid) return
255
+ try {
256
+ // Windows 下 SIGTERM 对子进程同样生效;进程已退出时抛 ESRCH,忽略
257
+ process.kill(pid, 'SIGTERM')
258
+ } catch { /* 进程不存在 */ }
259
+ }
260
+
261
+ /**
262
+ * 启动一个 frpc 子进程并返回 { child, logPath }。stdout/stderr 重定向到日志文件,
263
+ * 避免受限环境下 pipe stdio 被拒(spawn UNKNOWN)。
264
+ * 启动前先截断日志(本次运行从干净日志开始,避免旧错误污染 probe 探测)。
265
+ */
266
+ function spawnFrpc(bin, cfgPath, dir) {
267
+ const logPath = join(dir, 'frpc.log')
268
+ let logFd
269
+ try {
270
+ // 先清空本轮日志,保证 probe 读到的是本次启动的真实事件
271
+ writeFileSync(logPath, '', 'utf8')
272
+ logFd = openSync(logPath, 'a')
273
+ } catch (e) { logFd = undefined }
274
+ const child = spawn(bin, ['-c', cfgPath], {
275
+ cwd: dir,
276
+ stdio: logFd !== undefined ? ['ignore', logFd, logFd] : 'ignore',
277
+ windowsHide: true,
278
+ })
279
+ return { child, logFd }
280
+ }
281
+
282
+ /**
283
+ * 读取 frpc.log 末尾若干字节,用于把 frpc 的真实报错回传给前端 / 用户。
284
+ * frpc 启动即退出的根因(配置解析失败、token 不符、版本不兼容、被系统拦截等)
285
+ * 都写在日志里,不读取就只会看到一句"启动后退出"而无法定位。
286
+ */
287
+ function tryReadFrpcLog(dir, bytes = 3000) {
288
+ try {
289
+ const logPath = join(dir, 'frpc.log')
290
+ if (!existsSync(logPath)) return ''
291
+ const buf = readFileSync(logPath)
292
+ return buf.slice(Math.max(0, buf.length - bytes)).toString('utf8').trim()
293
+ } catch {
294
+ return ''
295
+ }
296
+ }
297
+
298
+ /**
299
+ * 尽力探测 frpc 二进制版本(frpc --version)。
300
+ * 解析失败 / 无法运行均返回 null(非致命,交给后续存活检测兜底)。
301
+ * @returns {Promise<string|null>} 如 "0.62.1"
302
+ */
303
+ function frpcVersion(bin) {
304
+ return new Promise((resolve) => {
305
+ let c
306
+ try {
307
+ c = spawn(bin, ['--version'], { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
308
+ } catch {
309
+ resolve(null)
310
+ return
311
+ }
312
+ let out = ''
313
+ if (c.stdout) c.stdout.on('data', (d) => { out += d })
314
+ if (c.stderr) c.stderr.on('data', (d) => { out += d })
315
+ const done = () => {
316
+ const m = /\d+\.\d+\.\d+/.exec(out)
317
+ resolve(m ? m[0] : null)
318
+ }
319
+ c.on('error', () => resolve(null))
320
+ c.on('close', done)
321
+ setTimeout(done, 3000)
322
+ })
323
+ }
324
+
325
+ /** 判断 frp 版本是否 >= 0.52(TOML 配置自此版本才支持;更早版本只能解析 INI)。 */
326
+ function isFrpcTomlCapable(version) {
327
+ if (!version) return true // 探测不到就乐观放行,交给存活检测
328
+ const m = /(\d+)\.(\d+)\.(\d+)/.exec(version)
329
+ if (!m) return true
330
+ const major = +m[1]
331
+ const minor = +m[2]
332
+ if (major > 0) return true
333
+ return minor >= 52
334
+ }
335
+
336
+ /**
337
+ * 确保 frpc 二进制存在且版本可用(支持 TOML)。
338
+ * - 缺失则下载;
339
+ * - 已存在但版本过旧(<0.52,无法解析本插件生成的 TOML 配置 → 启动即退出)则
340
+ * 删除并重下;若重下后仍过旧,抛出明确错误,避免用户卡在"frpc 启动后退出"。
341
+ * @param {string} base - 中转服务基地址(下载用)
342
+ * @returns {Promise<string>} 可用的二进制路径
343
+ */
344
+ async function ensureFrpcBinary(base) {
345
+ let bin = frpcBinaryPath()
346
+ if (!existsSync(bin)) {
347
+ bin = await downloadFrpc(base)
348
+ } else {
349
+ const v = await frpcVersion(bin)
350
+ if (!isFrpcTomlCapable(v)) {
351
+ try { rmSync(bin, { force: true }) } catch { /* ignore */ }
352
+ bin = await downloadFrpc(base)
353
+ const v2 = await frpcVersion(bin)
354
+ if (!isFrpcTomlCapable(v2)) {
355
+ throw new Error(
356
+ `frpc 版本过旧(${v2 || v || '未知'}),不支持 TOML 配置(需 >= 0.52)。` +
357
+ `请更新中转服务器 public/frpc/ 下的 frpc 二进制。`,
358
+ )
359
+ }
360
+ }
361
+ }
362
+ return bin
363
+ }
364
+
365
+ /**
366
+ * 启动外网隧道。做了稳健性处理,避免"反复点/开了又关"。
367
+ * @returns {Promise<{ok:boolean, url?:string, status?:string, error?:string, message?:string, log?:string}>}
368
+ */
369
+ async function startExternal(state, body, patchFile, defaultLocalPort) {
370
+ // 已运行则直接返回,避免重复点重复启动;
371
+ // 但状态为 error(如鉴权失败)时允许重试:kill 旧进程并重新绑定。
372
+ if (state.proc && state.proc.exitCode === null && state.status !== 'error') {
373
+ if (state.status === 'online') startKeepAlive(state)
374
+ return { ok: true, already: true, url: state.url, status: state.status || 'online' }
375
+ }
376
+ // 清理可能残留的旧进程
377
+ if (state.proc) { try { state.proc.kill() } catch { /* ignore */ } state.proc = null }
378
+
379
+ state.status = 'connecting'
380
+ state.error = null
381
+ try {
382
+ return await startExternalInner(state, body, patchFile, defaultLocalPort)
383
+ } catch (error) {
384
+ // 启动失败必须复位状态,否则前端会一直卡在"连接中"且无法关闭
385
+ state.status = 'error'
386
+ state.error = String((error && error.message) || error)
387
+ throw error
388
+ }
389
+ }
390
+
391
+ async function startExternalInner(state, body, patchFile, defaultLocalPort) {
392
+ const data = await bindToServer(body, Number((body && body.localPort) || defaultLocalPort))
393
+ const base = serverBase(body)
394
+ const domain = data.externalDomain
395
+ || String(data.externalUrl || '').replace(/^https?:\/\//, '').split(':')[0]
396
+ || ''
397
+ const tunnelPort = data.tunnelPort
398
+ const url = data.externalUrl
399
+
400
+ // 1) 确保 frpc 可执行文件存在且版本可用(缺失 / 过旧则下载 / 重下)
401
+ mkdirSync(externalDir(), { recursive: true })
402
+ let bin
403
+ try {
404
+ bin = await ensureFrpcBinary(base)
405
+ } catch (e) {
406
+ throw new Error(`frpc 准备失败:${e.message}`)
407
+ }
408
+ // 记录 frpc 版本,供设置页展示(探测失败不影响启动)
409
+ try {
410
+ const ver = await frpcVersion(bin)
411
+ if (ver) state.frpcVersion = ver
412
+ } catch { /* ignore */ }
413
+
414
+ // 2) 写 frpc.toml(含 loginFailExit=false 与回环地址覆写)
415
+ const cfgPath = frpcConfigPath()
416
+ writeFileSync(cfgPath, finalizeFrpcConfig(data.frpcConfig), 'utf8')
417
+
418
+ // 2.5) 清理残留旧 frpc 实例,避免多个进程抢同一代理名(proxy already exists)
419
+ killStaleFrpc()
420
+
421
+ // 3) 启动 frpc
422
+ const { child, logFd } = spawnFrpc(bin, cfgPath, externalDir())
423
+
424
+ // 记录状态 + pid(供下次启动前清理残留)
425
+ state.proc = child
426
+ state.url = url
427
+ state.domain = domain
428
+ state.tunnelPort = tunnelPort
429
+ state.status = 'connecting'
430
+ state.error = null
431
+ writeFrpcPid(child.pid)
432
+
433
+ child.on('error', (e) => {
434
+ state.error = String(e?.message || e)
435
+ state.status = 'error'
436
+ })
437
+ const onExit = () => {
438
+ state.proc = null
439
+ // 退出不一定要报错:loginFailExit=false 时通常不会立刻退;若退了说明配置/网络有误
440
+ if (state.status !== 'stopped') state.status = 'stopped'
441
+ try { if (logFd !== undefined) closeSync(logFd) } catch { /* ignore */ }
442
+ // 清理 pid 记录(仅当仍是当前进程时;新进程可能已写入新 pid)
443
+ try {
444
+ if (readFrpcPid() === child.pid) rmSync(frpcPidPath(), { force: true })
445
+ } catch { /* ignore */ }
446
+ }
447
+ child.on('exit', onExit)
448
+
449
+ // 4) 放行外网域名(trusted-host)
450
+ if (domain && patchFile) {
451
+ try { ensureTrustedHost(patchFile, domain) } catch (e) { state.lastLog = String(e?.message || e) }
452
+ }
453
+
454
+ // 5) 上报在线(尽力而为)
455
+ try {
456
+ await fetch(`${base.replace(/\/$/, '')}/api/tunnel/heartbeat`, {
457
+ method: 'POST',
458
+ headers: { 'Content-Type': 'application/json' },
459
+ body: JSON.stringify({ bindCode: body && body.bindCode }),
460
+ })
461
+ } catch { /* ignore */ }
462
+
463
+ // 6) 探测 frpc 日志确认隧道真正建立,再判 online(不再只看进程存活——
464
+ // 因为 loginFailExit=false 时进程活着并不代表隧道建立成功)。
465
+ // 检测到 token 不匹配 / 连接被拒等明确失败时,直接报出真实原因。
466
+ return await new Promise((resolve) => {
467
+ // 最多探测 5 次,每次间隔 1.2s(合计约 6s)
468
+ const MAX_TRIES = 5
469
+ let tries = 0
470
+
471
+ const confirmOnline = () => {
472
+ state.status = 'online'
473
+ state.startedAt = Date.now()
474
+ // 记录实际生效的绑定参数并持久化,dsh 重启后由 autoRestoreExternal 自动恢复
475
+ state.bindCode = (body && body.bindCode) || state.bindCode || ''
476
+ state.serverBase = base
477
+ state.localPort = Number((body && body.localPort) || defaultLocalPort)
478
+ savePersistedState(state)
479
+ // 隧道确认建立后启动持续心跳,让后端 last_seen 保持新鲜(管理端在线判定依赖它)
480
+ startKeepAlive(state)
481
+ }
482
+
483
+ const probe = () => {
484
+ tries += 1
485
+ const logTail = tryReadFrpcLog(externalDir())
486
+
487
+ // ① 明确失败:token 与 frps 服务端不一致(隧道不可能建立)
488
+ if (/token in login doesn't match|doesn't match token from configuration/i.test(logTail)) {
489
+ state.status = 'error'
490
+ state.startedAt = null
491
+ state.error = '外网隧道鉴权失败:frpc 配置的 token 与 frps 服务端不一致。请在宝塔服务器上同步 /www/wwwroot/…/.env 的 FRP_TOKEN 与 /www/server/frps/frps.toml 的 auth.token,重启 frps 与中转服务后重新开启外网。'
492
+ resolve({ ok: false, status: 'error', url, message: state.error, log: logTail || null })
493
+ return
494
+ }
495
+
496
+ // ② 明确成功:frps 已接受并建立隧道
497
+ if (/start proxy success|login to server success/i.test(logTail)) {
498
+ confirmOnline()
499
+ resolve({ ok: true, url, status: 'online', note: '隧道已建立' })
500
+ return
501
+ }
502
+
503
+ // ②' 明确连接失败(DNS 解析失败 / 超时 / 拒绝 / 路由不可达):
504
+ // 隧道不可能建立,且 loginFailExit=false 会让 frpc 一直静默重试,
505
+ // 若不识别,探测耗尽后会走"乐观判在线"而误报。这里继续探测到耗尽,
506
+ // 仍连不上就报出真实原因,而不是假在线。
507
+ if (/connect to server error|no such host|i\/o timeout|connection refused|dial tcp/i.test(logTail)) {
508
+ const addrInfo = extractServerAddr(
509
+ existsSync(cfgPath) ? readFileSync(cfgPath, 'utf8') : '',
510
+ )
511
+ if (tries < MAX_TRIES) {
512
+ setTimeout(probe, 1200)
513
+ return
514
+ }
515
+ state.status = 'error'
516
+ state.startedAt = null
517
+ state.error = `无法连接到 frps 中转服务器(${addrInfo || '未知地址'})。请检查:中转服务是否运行、服务器防火墙是否放行 7000 端口、frps 公网地址是否正确。`
518
+ resolve({ ok: false, status: 'error', url, message: state.error, log: logTail || null })
519
+ return
520
+ }
521
+
522
+ // ③ 进程仍存活但暂无结论:继续探测
523
+ if (state.proc && state.proc.exitCode === null && !state.error && tries < MAX_TRIES) {
524
+ setTimeout(probe, 1200)
525
+ return
526
+ }
527
+
528
+ // ④ 探测耗尽但进程存活且无明确结论(日志为空 / 写入延迟):
529
+ // 保守判 connecting(真实状态),不乐观判 online,避免"假在线";
530
+ // 由后续 /status 轮询的 probeTunnelHealth 依据日志判定真实状态。
531
+ if (state.proc && state.proc.exitCode === null && !state.error) {
532
+ state.status = 'connecting'
533
+ resolve({ ok: true, url, status: 'connecting', note: 'frpc 已启动,隧道状态待确认' })
534
+ return
535
+ }
536
+
537
+ // ⑤ 进程已退出:给出明确错误
538
+ const addrInfo = extractServerAddr(
539
+ existsSync(cfgPath) ? readFileSync(cfgPath, 'utf8') : '',
540
+ )
541
+ const exitCode = state.proc ? state.proc.exitCode : null
542
+ resolve({
543
+ ok: false,
544
+ status: 'failed',
545
+ url,
546
+ message: state.error
547
+ ? `frpc 无法启动:${state.error}`
548
+ : `frpc 启动后退出(exitCode=${exitCode === null ? '未知' : exitCode})。请检查:中转服务地址、frps 公网IP(${addrInfo || '未知'}):端口、token 是否一致、防火墙是否放行`,
549
+ exitCode: exitCode,
550
+ spawnError: state.error || null,
551
+ log: logTail || null,
552
+ })
553
+ }
554
+
555
+ probe()
556
+ })
557
+ }
558
+
559
+ /**
560
+ * 停止 frpc 子进程。
561
+ * @param {object} state
562
+ * @param {{keepState?: boolean}} [opts] keepState=true 时保留持久化状态
563
+ * (dsh 进程退出清理用,这样重启后能自动恢复);用户主动关闭则清除。
564
+ */
565
+ function stopExternal(state, opts = {}) {
566
+ stopKeepAlive(state)
567
+ // 停止前上报离线,让管理端/成员端立即反映(后端支持 status 字段才生效)
568
+ if (!opts.keepState) {
569
+ state.status = 'offline'
570
+ void sendHeartbeat(state)
571
+ }
572
+ if (state.proc && state.proc.exitCode === null) {
573
+ try { state.proc.kill() } catch { /* ignore */ }
574
+ }
575
+ state.proc = null
576
+ state.status = 'idle'
577
+ state.error = null
578
+ state.startedAt = null
579
+ if (!opts.keepState) clearPersistedState()
580
+ }
581
+
582
+ /**
583
+ * dsh 重启后自动恢复外网访问:读取本地持久化的绑定信息(bindCode / serverBase),
584
+ * 重新绑定并拉起 frpc,免去每次手动输入验证码与中转服务地址。
585
+ * 失败时把状态置为 error 并保留持久化,供用户手动处理或下次重启重试。
586
+ */
587
+ async function autoRestoreExternal(state, patchFile, defaultLocalPort) {
588
+ const saved = loadPersistedState()
589
+ if (!saved) return
590
+ try {
591
+ const body = {
592
+ bindCode: saved.bindCode,
593
+ serverBase: saved.serverBase || undefined,
594
+ localPort: saved.localPort,
595
+ }
596
+ await startExternal(state, body, patchFile, defaultLocalPort)
597
+ } catch (e) {
598
+ state.status = 'error'
599
+ state.error = `自动恢复外网访问失败:${String((e && e.message) || e)}`
600
+ }
601
+ }
602
+
603
+ /** 读取 frpc 运行日志尾部(供设置页展示,默认 6KB)。 */
604
+ function frpcLogTail(bytes = 6000) {
605
+ return tryReadFrpcLog(externalDir(), bytes)
606
+ }
607
+
608
+ /** 持续心跳间隔(毫秒):后端据此维持 last_seen,并做离线超时判定。 */
609
+ const HEARTBEAT_INTERVAL = 60_000
610
+
611
+ /** 上报一次在线/离线心跳(尽力而为,失败静默)。 */
612
+ async function sendHeartbeat(state) {
613
+ const base = state.serverBase || process.env.DSH_REMOTE_SERVER || DEFAULT_SERVER_BASE
614
+ const bindCode = state.bindCode
615
+ if (!bindCode) return
616
+ try {
617
+ await fetch(`${base.replace(/\/$/, '')}/api/tunnel/heartbeat`, {
618
+ method: 'POST',
619
+ headers: { 'Content-Type': 'application/json' },
620
+ body: JSON.stringify({
621
+ bindCode,
622
+ // offline 表示隧道已停止,让管理端立即标记离线(后端支持时才生效)
623
+ status: state.status === 'online' ? 'online' : 'offline',
624
+ }),
625
+ })
626
+ } catch { /* ignore */ }
627
+ }
628
+
629
+ /** 启动隧道在线保活心跳(幂等:已有定时器则复用)。 */
630
+ function startKeepAlive(state) {
631
+ if (state.keepAliveTimer) return
632
+ state.keepAliveTimer = setInterval(() => { void sendHeartbeat(state) }, HEARTBEAT_INTERVAL)
633
+ if (state.keepAliveTimer.unref) state.keepAliveTimer.unref()
634
+ }
635
+
636
+ /** 停止保活心跳并清空定时器。 */
637
+ function stopKeepAlive(state) {
638
+ if (state.keepAliveTimer) {
639
+ clearInterval(state.keepAliveTimer)
640
+ state.keepAliveTimer = null
641
+ }
642
+ }
643
+
644
+ /** 健康复查缓存:3 秒内不重复读盘,避免每 5s 的 status 轮询频繁读 frpc.log。 */
645
+ let healthProbeCache = { at: 0, value: 'unknown' }
646
+
647
+ /**
648
+ * 探测 frpc 隧道的真实健康状态(供 /status 轮询时复查,避免"一次 online 永久 online")。
649
+ * 读取日志尾部,从后往前找最近一条关键事件:
650
+ * - token 不匹配 → 'error'(隧道不可能建立)
651
+ * - 连接失败(login/connect error)→ 'connecting'(frpc 正在自动重连)
652
+ * - 建连成功(start proxy success)→ 'online'
653
+ * 无日志 / 无法判定时保持 'unknown'(调用方沿用原状态,不误伤正常隧道)。
654
+ */
655
+ function probeTunnelHealth(state) {
656
+ const now = Date.now()
657
+ if (healthProbeCache.at && now - healthProbeCache.at < 3000) return healthProbeCache.value
658
+ let logTail = ''
659
+ try { logTail = tryReadFrpcLog(externalDir(), 20000) } catch { logTail = '' }
660
+ const lines = String(logTail || '').split('\n').filter(Boolean)
661
+ let verdict = 'unknown'
662
+ for (let i = lines.length - 1; i >= 0; i--) {
663
+ const ln = lines[i]
664
+ if (/token in login doesn't match|doesn't match token from configuration/i.test(ln)) {
665
+ verdict = 'error'
666
+ break
667
+ }
668
+ if (/login to server error|connect to server error|start error/i.test(ln)) {
669
+ verdict = 'connecting'
670
+ break
671
+ }
672
+ if (/start proxy success|login to server success/i.test(ln)) {
673
+ verdict = 'online'
674
+ break
675
+ }
676
+ }
677
+ healthProbeCache.at = now
678
+ healthProbeCache.value = verdict
679
+ return verdict
680
+ }
681
+
682
+ /** 当前外网状态摘要(供 /status 使用)。 */
683
+ function externalView(state) {
684
+ const running = !!state.proc && state.proc.exitCode === null
685
+ let status = state.status || (running ? 'online' : 'idle')
686
+ let error = state.error || null
687
+ // 进程存活且宣称在线时,做健康复查:中途断线 / frps 更换 token 等都能及时发现
688
+ if (running && status === 'online') {
689
+ const h = probeTunnelHealth(state)
690
+ if (h === 'error') {
691
+ status = 'error'
692
+ error = error || '外网隧道鉴权失败:frpc 的 token 与 frps 服务端不一致,隧道已断开。请同步服务器 FRP_TOKEN 与 frps.toml 的 auth.token 后,重新开启外网访问。'
693
+ } else if (h === 'connecting') {
694
+ // 隧道中途断开,frpc 正在自动重连:降级为连接中(重连成功后会恢复 online)
695
+ status = 'connecting'
696
+ }
697
+ }
698
+ return {
699
+ enabled: running,
700
+ running,
701
+ // 真实状态:online(隧道已建立)/ connecting(含断线重连)/ error / stopped / idle
702
+ status,
703
+ url: running ? state.url : null,
704
+ domain: state.domain,
705
+ tunnelPort: state.tunnelPort,
706
+ frpcVersion: state.frpcVersion || null,
707
+ pid: running && state.proc ? state.proc.pid : null,
708
+ startedAt: state.startedAt || null,
709
+ lastLog: state.lastLog || null,
710
+ error,
711
+ }
712
+ }
713
+
714
+ export {
715
+ serverBase,
716
+ externalDir,
717
+ frpcBinaryPath,
718
+ frpcConfigPath,
719
+ startExternal,
720
+ stopExternal,
721
+ externalView,
722
+ ensureTrustedHost,
723
+ loadPersistedState,
724
+ savePersistedState,
725
+ clearPersistedState,
726
+ autoRestoreExternal,
727
+ frpcLogTail,
728
+ DEFAULT_SERVER_BASE,
729
+ }