dsh-plugin-remote-connect-beta 0.1.0-beta.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,320 @@
1
+ /**
2
+ * 隧道看护:把本机的公网入口端口送到服务器(ssh -R)、送上一个临时公网地址
3
+ * (cloudflared),或直接用 tailscale funnel 发布(本机即出口)。
4
+ *
5
+ * 三种后端的看护模型不同,这里刻意分开:
6
+ * - ssh / cloudflared:长驻子进程,退出即指数退避重连。
7
+ * - tailscale funnel:`tailscale funnel --bg` 是一次性配置命令(配完由 tailscaled 常驻),
8
+ * 所以启动后改为定时探测状态,停止时撤销该端口上的 funnel 映射。
9
+ *
10
+ * 状态只发 `code` + `params`(外加一份英文兜底 `detail`),由调用方按自己的语言渲染。
11
+ *
12
+ * @module dsh-plugin-remote-connect-beta/core/tunnel
13
+ */
14
+ import { spawn } from 'node:child_process'
15
+ import {
16
+ classifyFunnelError,
17
+ funnelOffArgv,
18
+ funnelStatusArgv,
19
+ funnelUpArgv,
20
+ readSelfUrl,
21
+ runOnce,
22
+ } from './tailscale.js'
23
+ import { translator } from './messages.js'
24
+
25
+ /** 英文兜底渲染:CLI / 日志在没有请求语言时用它。 */
26
+ const tEn = translator('en')
27
+
28
+ /**
29
+ * 下一次重连要等多久。
30
+ *
31
+ * 服务器侧对 22022 有**新连接频率限制**(每 IP 60 秒最多 20 个新连接)。固定 2 秒重试
32
+ * 一分钟就是 30 次,会直接撞上限流,而限流表现出来只是"ssh 超时",日志里看不出原因 ——
33
+ * 所以这里必须**指数退避 + 抖动**:5s 起、60s 封顶、±25% 抖动,避免所有客户端同时重连。
34
+ *
35
+ * @param {number} attempts 连续失败次数(从 1 开始)
36
+ * @param {object} [options]
37
+ * @param {number} [options.baseMs=5000]
38
+ * @param {number} [options.maxMs=60000]
39
+ * @param {number} [options.jitter=0.25]
40
+ * @param {() => number} [options.random=Math.random]
41
+ */
42
+ export function nextBackoffDelay(attempts, options = {}) {
43
+ const baseMs = options.baseMs ?? 5000
44
+ const maxMs = options.maxMs ?? 60000
45
+ const jitter = Math.max(0, Math.min(options.jitter ?? 0.25, 0.9))
46
+ const random = options.random ?? Math.random
47
+ const raw = Math.min(baseMs * 2 ** Math.max(0, attempts - 1), maxMs)
48
+ const factor = 1 - jitter + random() * jitter * 2
49
+ return Math.max(1000, Math.round(raw * factor))
50
+ }
51
+
52
+ /**
53
+ * @param {object} options
54
+ * @param {'ssh'|'cloudflared'|'tailscale'} options.mode
55
+ * @param {number} options.localPort 本机监听端口(代理的公网口)
56
+ * @param {string} [options.user] ssh 账号(ssh 模式必填)
57
+ * @param {string} [options.host] 服务器地址(ssh 模式必填)
58
+ * @param {string} [options.keyPath] 私钥
59
+ * @param {number} [options.remotePort] 服务器回环端口,默认与 localPort 相同
60
+ * @param {number} [options.port=22] 服务器 sshd 端口
61
+ * @param {string} [options.cloudflaredPath='cloudflared']
62
+ * @param {string} [options.tailscalePath='tailscale']
63
+ * @param {number} [options.tailscaleHttpsPort=443] funnel 对外端口
64
+ * @param {number} [options.tailscaleProbeMs=60000] funnel 状态探测间隔
65
+ * @param {(state: object) => void} [options.onState] 状态回调
66
+ * @param {(line: string) => void} [options.log]
67
+ */
68
+ export function createTunnel(options) {
69
+ const localPort = options.localPort
70
+ const remotePort = options.remotePort ?? localPort
71
+ const sshPort = options.port ?? 22
72
+ const tailscalePath = options.tailscalePath ?? 'tailscale'
73
+ const tailscaleHttpsPort = options.tailscaleHttpsPort ?? 443
74
+ const tailscaleProbeMs = options.tailscaleProbeMs ?? 60000
75
+ // 重连退避:服务器侧对 22022 有限流(20 次新连接/60s),所以必须指数退避 + 抖动
76
+ const backoffBaseMs = options.backoffBaseMs ?? 5000
77
+ const backoffMaxMs = options.backoffMaxMs ?? 60000
78
+ const backoffJitter = options.backoffJitter ?? 0.25
79
+ /** 稳定多久才算"这次真的好了"(之后才清零退避计数)。 */
80
+ const stableMs = options.stableMs ?? 120000
81
+ const log = options.log ?? (() => {})
82
+ const onState = options.onState ?? (() => {})
83
+
84
+ let child = null
85
+ let stopped = false
86
+ let attempts = 0
87
+ let restartTimer = null
88
+ let healthTimer = null
89
+ let state = { phase: 'idle', code: 'tunnel.idle', params: {}, detail: '', publicUrl: null, restarts: 0 }
90
+
91
+ function setState(patch) {
92
+ const next = { ...state, ...patch }
93
+ // 兜底 detail:调用方没给语言时至少有一句英文,而不是空字符串
94
+ if (patch.code !== undefined && patch.detail === undefined) {
95
+ next.detail = tEn(patch.code, patch.params)
96
+ }
97
+ state = next
98
+ onState(state)
99
+ }
100
+
101
+ function buildSshArgv() {
102
+ const argv = [
103
+ '-N',
104
+ '-T',
105
+ ...(sshPort === 22 ? [] : ['-p', String(sshPort)]),
106
+ '-o',
107
+ 'ExitOnForwardFailure=yes',
108
+ '-o',
109
+ 'ServerAliveInterval=30',
110
+ '-o',
111
+ 'ServerAliveCountMax=3',
112
+ '-o',
113
+ 'StrictHostKeyChecking=yes',
114
+ ]
115
+ if (options.keyPath) argv.push('-i', options.keyPath)
116
+ argv.push('-R', '127.0.0.1:' + String(remotePort) + ':127.0.0.1:' + String(localPort))
117
+ argv.push(options.user + '@' + options.host)
118
+ return argv
119
+ }
120
+
121
+ function buildCloudflaredArgv() {
122
+ return [
123
+ options.cloudflaredPath ?? 'cloudflared',
124
+ 'tunnel',
125
+ '--no-autoupdate',
126
+ '--url',
127
+ 'http://127.0.0.1:' + String(localPort),
128
+ ]
129
+ }
130
+
131
+ function scheduleRestart(reason) {
132
+ if (stopped) return
133
+ attempts += 1
134
+ const delay = nextBackoffDelay(attempts, { baseMs: backoffBaseMs, maxMs: backoffMaxMs, jitter: backoffJitter })
135
+ setState({
136
+ phase: 'reconnecting',
137
+ code: 'tunnel.reconnecting',
138
+ // seconds 给人看;delayMs 给测试与排障用(含抖动,便于确认确实是退避而不是固定间隔)
139
+ params: { reason, seconds: String(Math.round(delay / 1000)), delayMs: String(delay) },
140
+ restarts: state.restarts + 1,
141
+ })
142
+ restartTimer = setTimeout(() => {
143
+ restartTimer = null
144
+ launch()
145
+ }, delay)
146
+ }
147
+
148
+ function capturePublicUrl(text) {
149
+ const match = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/i.exec(text)
150
+ if (match === null || state.publicUrl === match[0]) return
151
+ setState({ publicUrl: match[0] })
152
+ log(tEn('tunnel.urlDiscovered', { url: match[0] }))
153
+ }
154
+
155
+ function launchProcess() {
156
+ if (stopped) return
157
+ const argv = options.mode === 'cloudflared' ? buildCloudflaredArgv() : ['ssh', ...buildSshArgv()]
158
+ setState({ phase: 'connecting', code: 'tunnel.connecting', params: { command: argv[0] } })
159
+ let spawned
160
+ try {
161
+ spawned = spawn(argv[0], argv.slice(1), { stdio: ['ignore', 'pipe', 'pipe'] })
162
+ } catch (error) {
163
+ scheduleRestart(tEn('tunnel.spawnFailed', { command: argv[0], message: String(error?.message ?? error) }))
164
+ return
165
+ }
166
+ child = spawned
167
+ let stderr = ''
168
+ spawned.stdout.on('data', (chunk) => {
169
+ if (options.mode === 'cloudflared') capturePublicUrl(chunk.toString('utf8'))
170
+ })
171
+ spawned.stderr.on('data', (chunk) => {
172
+ stderr = (stderr + chunk.toString('utf8')).slice(-4000)
173
+ if (options.mode === 'cloudflared') capturePublicUrl(stderr)
174
+ })
175
+ spawned.on('error', (error) => {
176
+ scheduleRestart(tEn('tunnel.spawnFailed', { command: argv[0], message: String(error?.message ?? error) }))
177
+ })
178
+ spawned.on('exit', (code, signal) => {
179
+ if (child !== spawned) return
180
+ child = null
181
+ if (stopped) {
182
+ setState({ phase: 'stopped', code: 'tunnel.stopped' })
183
+ return
184
+ }
185
+ const tail = stderr.trim().split('\n').slice(-2).join(' | ')
186
+ // ⚠️ 这里**不能**清零 attempts:清了之后每次都是第一档延迟,
187
+ // 变成"固定间隔猛重试",正好撞上服务器 22022 的新连接限流。
188
+ // 只有在稳定运行 stableMs 之后才由下面的定时器清零。
189
+ scheduleRestart('exit ' + (signal ?? String(code)) + (tail ? ': ' + tail : ''))
190
+ })
191
+ // 连上 10 秒就认为"这次通了"(面板显示 up);但要稳定 stableMs 才清零退避计数
192
+ setTimeout(() => {
193
+ if (child === spawned) setState({ phase: 'up', code: 'tunnel.up' })
194
+ }, 10000)
195
+ const stableTimer = setTimeout(() => {
196
+ if (child === spawned) attempts = 0
197
+ }, stableMs)
198
+ spawned.once('exit', () => clearTimeout(stableTimer))
199
+ }
200
+
201
+ /** funnel 状态探测:tailscaled 掉线 / funnel 被撤销时要能发现。 */
202
+ function startHealthProbe() {
203
+ stopHealthProbe()
204
+ healthTimer = setInterval(() => {
205
+ void (async () => {
206
+ if (stopped) return
207
+ const status = await runOnce(funnelStatusArgv({ path: tailscalePath }), { timeoutMs: 15000 })
208
+ if (stopped) return
209
+ const text = (status.stderr || status.stdout).trim()
210
+ if (status.error === undefined && status.code === 0) return
211
+ stopHealthProbe()
212
+ setState({
213
+ phase: 'error',
214
+ code: 'tunnel.funnelFailed',
215
+ params: { output: (text || status.error || '-').split('\n').slice(-3).join(' | ') },
216
+ hintCode: 'preflight.tailscale.failed.hint.' + classifyFunnelError(text || status.error),
217
+ })
218
+ })()
219
+ }, tailscaleProbeMs)
220
+ if (typeof healthTimer.unref === 'function') healthTimer.unref()
221
+ }
222
+
223
+ function stopHealthProbe() {
224
+ if (healthTimer === null) return
225
+ clearInterval(healthTimer)
226
+ healthTimer = null
227
+ }
228
+
229
+ async function launchFunnel() {
230
+ if (stopped) return
231
+ const argv = funnelUpArgv({ path: tailscalePath, localPort, httpsPort: tailscaleHttpsPort })
232
+ setState({ phase: 'connecting', code: 'tunnel.connecting', params: { command: argv[0] + ' funnel' } })
233
+ const up = await runOnce(argv, { timeoutMs: 30000 })
234
+ if (stopped) return
235
+ if (up.error !== undefined || up.code !== 0) {
236
+ const text = (up.stderr || up.stdout || up.error || '').trim()
237
+ // 配置类失败(没装客户端 / 没登录 / 后台没开 Funnel)重试也不会好,交给用户
238
+ setState({
239
+ phase: 'error',
240
+ code: 'tunnel.funnelFailed',
241
+ params: { output: text.split('\n').slice(-3).join(' | ') || '-' },
242
+ hintCode: 'preflight.tailscale.failed.hint.' + classifyFunnelError(text || up.error),
243
+ })
244
+ return
245
+ }
246
+ const self = await readSelfUrl({ path: tailscalePath })
247
+ if (stopped) return
248
+ setState({
249
+ phase: 'up',
250
+ code: 'tunnel.funnelUp',
251
+ params: { url: self.ok ? self.url : '-' },
252
+ publicUrl: self.ok ? self.url : null,
253
+ ...(self.ok ? {} : { hintCode: 'preflight.tailscale.failed.hint.' + self.reason }),
254
+ })
255
+ startHealthProbe()
256
+ }
257
+
258
+ async function takeFunnelDown() {
259
+ stopHealthProbe()
260
+ const result = await runOnce(funnelOffArgv({ path: tailscalePath, httpsPort: tailscaleHttpsPort }), {
261
+ timeoutMs: 20000,
262
+ })
263
+ if (result.code === 0) log(tEn('tunnel.funnelOff'))
264
+ }
265
+
266
+ function launch() {
267
+ if (options.mode === 'tailscale') {
268
+ void launchFunnel()
269
+ return
270
+ }
271
+ launchProcess()
272
+ }
273
+
274
+ return {
275
+ /** 启动隧道(失败会自动重连,不抛异常)。 */
276
+ start() {
277
+ stopped = false
278
+ attempts = 0
279
+ launch()
280
+ return state
281
+ },
282
+ /** 停止并等待子进程退出(tailscale 模式会撤销 funnel 映射)。 */
283
+ async stop() {
284
+ stopped = true
285
+ if (restartTimer !== null) {
286
+ clearTimeout(restartTimer)
287
+ restartTimer = null
288
+ }
289
+ if (options.mode === 'tailscale') {
290
+ if (state.phase === 'up') await takeFunnelDown()
291
+ else stopHealthProbe()
292
+ }
293
+ const current = child
294
+ child = null
295
+ if (current !== null) {
296
+ current.kill('SIGTERM')
297
+ await new Promise((resolve) => {
298
+ const timer = setTimeout(() => {
299
+ try {
300
+ current.kill('SIGKILL')
301
+ } catch {
302
+ /* 已退出 */
303
+ }
304
+ resolve()
305
+ }, 2000)
306
+ current.once('exit', () => {
307
+ clearTimeout(timer)
308
+ resolve()
309
+ })
310
+ })
311
+ }
312
+ setState({ phase: 'stopped', code: 'tunnel.stopped' })
313
+ return state
314
+ },
315
+ /** 当前状态快照。 */
316
+ state() {
317
+ return state
318
+ },
319
+ }
320
+ }