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.
- package/CHANGELOG.md +115 -0
- package/CONTRIBUTING.md +50 -0
- package/LICENSE +21 -0
- package/README.md +279 -0
- package/README.zh.md +298 -0
- package/SECURITY.md +41 -0
- package/bin/dsh-remote.js +768 -0
- package/docs/client-preview.png +0 -0
- package/docs/market-submission.md +82 -0
- package/docs/multi-tenant.md +136 -0
- package/docs/multi-tenant.zh.md +127 -0
- package/docs/self-host.md +398 -0
- package/docs/self-host.zh.md +369 -0
- package/docs/tenants-preview.png +0 -0
- package/lib/client.js +1120 -0
- package/lib/core/assets/server-setup.sh.tpl +344 -0
- package/lib/core/credential.js +123 -0
- package/lib/core/instance.js +360 -0
- package/lib/core/messages.js +549 -0
- package/lib/core/paths.js +59 -0
- package/lib/core/preflight.js +410 -0
- package/lib/core/proxy.js +924 -0
- package/lib/core/serversetup.js +133 -0
- package/lib/core/snippets.js +193 -0
- package/lib/core/tailscale.js +158 -0
- package/lib/core/tenancy.js +267 -0
- package/lib/core/tenant.js +272 -0
- package/lib/core/tunnel.js +320 -0
- package/lib/index.js +1097 -0
- package/package.json +76 -0
|
@@ -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
|
+
}
|