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,410 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 前置检查:把"能上网但用不了"的失败提前变成一句人话。
|
|
3
|
+
*
|
|
4
|
+
* 每一项只返回**原因码 + 参数**(`{ ok, code, params, hintCode, hintParams }`),
|
|
5
|
+
* 由调用方按自己的语言渲染(`renderCheck` / `runPreflight`)。
|
|
6
|
+
* 这样面板、CLI、测试看到的是同一份结构化结果,文案集中在 core/messages.js。
|
|
7
|
+
*
|
|
8
|
+
* @module dsh-plugin-remote-connect-beta/core/preflight
|
|
9
|
+
*/
|
|
10
|
+
import dns from 'node:dns/promises'
|
|
11
|
+
import tls from 'node:tls'
|
|
12
|
+
import https from 'node:https'
|
|
13
|
+
import { execFile, spawn } from 'node:child_process'
|
|
14
|
+
import { classifyFunnelError, funnelStatusArgv, runOnce } from './tailscale.js'
|
|
15
|
+
import { translator } from './messages.js'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* DNS:域名是否解析,以及是否指向预期 IP。
|
|
19
|
+
* @param {string} domain
|
|
20
|
+
* @param {string} [expectIp] 你的服务器公网 IP
|
|
21
|
+
*/
|
|
22
|
+
export async function checkDns(domain, expectIp) {
|
|
23
|
+
try {
|
|
24
|
+
const records = await dns.lookup(domain, { all: true })
|
|
25
|
+
const addresses = records.map((item) => item.address)
|
|
26
|
+
if (addresses.length === 0) {
|
|
27
|
+
return { ok: false, code: 'preflight.dns.none', params: { domain }, hintCode: 'preflight.dns.none.hint' }
|
|
28
|
+
}
|
|
29
|
+
if (expectIp && !addresses.includes(expectIp)) {
|
|
30
|
+
return {
|
|
31
|
+
ok: false,
|
|
32
|
+
code: 'preflight.dns.mismatch',
|
|
33
|
+
params: { domain, addresses: addresses.join(', '), expectIp },
|
|
34
|
+
hintCode: 'preflight.dns.mismatch.hint',
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return { ok: true, code: 'preflight.dns.ok', params: { domain, addresses: addresses.join(', ') } }
|
|
38
|
+
} catch (error) {
|
|
39
|
+
return {
|
|
40
|
+
ok: false,
|
|
41
|
+
code: 'preflight.dns.failed',
|
|
42
|
+
params: { domain, code: String(error?.code ?? error) },
|
|
43
|
+
hintCode: 'preflight.dns.failed.hint',
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* TLS:能否握手成功,证书是否覆盖该域名、是否过期。
|
|
50
|
+
* @param {string} domain
|
|
51
|
+
* @param {number} [port=443]
|
|
52
|
+
*/
|
|
53
|
+
export async function checkTls(domain, port = 443) {
|
|
54
|
+
return await new Promise((resolve) => {
|
|
55
|
+
const socket = tls.connect(
|
|
56
|
+
{ host: domain, port, servername: domain, rejectUnauthorized: false, timeout: 8000 },
|
|
57
|
+
() => {
|
|
58
|
+
const cert = socket.getPeerCertificate()
|
|
59
|
+
const authorized = socket.authorized
|
|
60
|
+
const validTo = cert?.valid_to ? new Date(cert.valid_to) : null
|
|
61
|
+
socket.end()
|
|
62
|
+
if (!cert || Object.keys(cert).length === 0) {
|
|
63
|
+
resolve({ ok: false, code: 'preflight.tls.nocert', hintCode: 'preflight.tls.nocert.hint' })
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
const names = String(cert.subjectaltname ?? '')
|
|
67
|
+
.split(',')
|
|
68
|
+
.map((item) => item.trim().replace(/^DNS:/, ''))
|
|
69
|
+
.filter(Boolean)
|
|
70
|
+
const covered =
|
|
71
|
+
names.includes(domain) ||
|
|
72
|
+
names.some((name) => name.startsWith('*.') && domain.endsWith(name.slice(1)))
|
|
73
|
+
const expiring = validTo !== null && validTo.getTime() - Date.now() < 7 * 24 * 3600 * 1000
|
|
74
|
+
if (!covered) {
|
|
75
|
+
resolve({
|
|
76
|
+
ok: false,
|
|
77
|
+
code: 'preflight.tls.uncovered',
|
|
78
|
+
params: { domain, names: names.join(', ') },
|
|
79
|
+
hintCode: 'preflight.tls.uncovered.hint',
|
|
80
|
+
})
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
if (!authorized) {
|
|
84
|
+
resolve({ ok: false, code: 'preflight.tls.chain', hintCode: 'preflight.tls.chain.hint' })
|
|
85
|
+
return
|
|
86
|
+
}
|
|
87
|
+
resolve({
|
|
88
|
+
ok: !expiring,
|
|
89
|
+
code: 'preflight.tls.ok',
|
|
90
|
+
params: { date: validTo ? validTo.toISOString().slice(0, 10) : 'unknown' },
|
|
91
|
+
hintCode: expiring ? 'preflight.tls.expiring.hint' : undefined,
|
|
92
|
+
})
|
|
93
|
+
},
|
|
94
|
+
)
|
|
95
|
+
socket.on('timeout', () => {
|
|
96
|
+
socket.destroy()
|
|
97
|
+
resolve({
|
|
98
|
+
ok: false,
|
|
99
|
+
code: 'preflight.tls.timeout',
|
|
100
|
+
params: { domain, port: String(port) },
|
|
101
|
+
hintCode: 'preflight.tls.timeout.hint',
|
|
102
|
+
})
|
|
103
|
+
})
|
|
104
|
+
socket.on('error', (error) => {
|
|
105
|
+
resolve({
|
|
106
|
+
ok: false,
|
|
107
|
+
code: 'preflight.tls.failed',
|
|
108
|
+
params: { code: String(error?.code ?? error?.message ?? error) },
|
|
109
|
+
hintCode: 'preflight.tls.failed.hint',
|
|
110
|
+
})
|
|
111
|
+
})
|
|
112
|
+
})
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* HTTPS 可达性:不带凭据应 401,带凭据应非 401。
|
|
117
|
+
* @param {string} domain
|
|
118
|
+
* @param {{ user?: string, password?: string }} [auth]
|
|
119
|
+
*/
|
|
120
|
+
export async function checkHttps(domain, auth = {}) {
|
|
121
|
+
const request = (headers) =>
|
|
122
|
+
new Promise((resolve) => {
|
|
123
|
+
const req = https.request(
|
|
124
|
+
{ host: domain, port: 443, path: '/', method: 'GET', headers, timeout: 10000, rejectUnauthorized: false },
|
|
125
|
+
(res) => {
|
|
126
|
+
res.resume()
|
|
127
|
+
resolve({ status: res.statusCode ?? 0 })
|
|
128
|
+
},
|
|
129
|
+
)
|
|
130
|
+
req.on('timeout', () => {
|
|
131
|
+
req.destroy()
|
|
132
|
+
resolve({ status: 0, error: 'ETIMEDOUT' })
|
|
133
|
+
})
|
|
134
|
+
req.on('error', (error) => resolve({ status: 0, error: String(error?.code ?? error) }))
|
|
135
|
+
req.end()
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
const anonymous = await request({})
|
|
139
|
+
if (anonymous.status === 0) {
|
|
140
|
+
return {
|
|
141
|
+
ok: false,
|
|
142
|
+
code: 'preflight.https.requestFailed',
|
|
143
|
+
params: { error: String(anonymous.error) },
|
|
144
|
+
hintCode: 'preflight.https.requestFailed.hint',
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (anonymous.status !== 401) {
|
|
148
|
+
return {
|
|
149
|
+
ok: false,
|
|
150
|
+
code: 'preflight.https.noAuth',
|
|
151
|
+
params: { status: String(anonymous.status) },
|
|
152
|
+
hintCode: 'preflight.https.noAuth.hint',
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (!auth.user || !auth.password) {
|
|
156
|
+
return { ok: true, code: 'preflight.https.skipped' }
|
|
157
|
+
}
|
|
158
|
+
const token = Buffer.from(auth.user + ':' + auth.password).toString('base64')
|
|
159
|
+
const authorized = await request({ authorization: 'Basic ' + token })
|
|
160
|
+
if (authorized.status === 401) {
|
|
161
|
+
return { ok: false, code: 'preflight.https.still401', hintCode: 'preflight.https.still401.hint' }
|
|
162
|
+
}
|
|
163
|
+
return { ok: true, code: 'preflight.https.ok', params: { status: String(authorized.status) } }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* 本机是不是已经有一条 ssh 隧道占着这个远端口了?
|
|
168
|
+
*
|
|
169
|
+
* 为什么必须查:`remote port forwarding failed for listen port 8788` 这个报错
|
|
170
|
+
* **两种原因长得一模一样** —— 服务器没放行该端口,或者端口已经被占用。
|
|
171
|
+
* 而"已经被占用"最常见的情况恰恰是:你自己的隧道正在工作(这是好事)。
|
|
172
|
+
* 把这种情况报成"服务器拒绝"会把人带去查 permitlisten,白折腾。
|
|
173
|
+
*
|
|
174
|
+
* @param {number} remotePort
|
|
175
|
+
* @param {(file: string, args: string[]) => Promise<{ stdout: string }>} [exec]
|
|
176
|
+
* @returns {Promise<{ pid: string, command: string } | null>}
|
|
177
|
+
*/
|
|
178
|
+
export async function findExistingTunnel(remotePort, exec = null) {
|
|
179
|
+
const runner =
|
|
180
|
+
exec ??
|
|
181
|
+
((file, args) =>
|
|
182
|
+
new Promise((resolve) => {
|
|
183
|
+
execFile(file, args, { timeout: 5000 }, (error, stdout) => {
|
|
184
|
+
if (error) {
|
|
185
|
+
resolve({ stdout: '' })
|
|
186
|
+
return
|
|
187
|
+
}
|
|
188
|
+
resolve({ stdout: String(stdout) })
|
|
189
|
+
})
|
|
190
|
+
}))
|
|
191
|
+
const pattern = 'ssh .*-R 127[.:]0[.:]0[.:]1:' + String(remotePort)
|
|
192
|
+
try {
|
|
193
|
+
const { stdout } = await runner('pgrep', ['-fl', pattern])
|
|
194
|
+
const line = String(stdout)
|
|
195
|
+
.split('\n')
|
|
196
|
+
.map((item) => item.trim())
|
|
197
|
+
.filter((item) => item !== '' && item.includes('-R'))
|
|
198
|
+
.find((item) => !item.includes('pgrep'))
|
|
199
|
+
if (line === undefined) return null
|
|
200
|
+
const [pid, ...rest] = line.split(' ')
|
|
201
|
+
return { pid, command: rest.join(' ') }
|
|
202
|
+
} catch {
|
|
203
|
+
return null
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* ssh 反向隧道实连测试:能不能建立,以及远端口是否被允许。
|
|
209
|
+
* @param {object} options
|
|
210
|
+
* @param {string} options.user
|
|
211
|
+
* @param {string} options.host
|
|
212
|
+
* @param {string} [options.keyPath]
|
|
213
|
+
* @param {number} [options.localPort=8788]
|
|
214
|
+
* @param {number} [options.remotePort=8788]
|
|
215
|
+
* @param {number} [options.port=22] 服务器 sshd 端口
|
|
216
|
+
* @param {number} [options.timeoutMs=12000]
|
|
217
|
+
*/
|
|
218
|
+
export async function checkSshTunnel(options) {
|
|
219
|
+
const localPort = options.localPort ?? 8788
|
|
220
|
+
const remotePort = options.remotePort ?? 8788
|
|
221
|
+
const port = options.port ?? 22
|
|
222
|
+
|
|
223
|
+
// 先看本机是不是已经有一条隧道占着这个远端口:那样"实连测试"必然失败,
|
|
224
|
+
// 但结论不是"服务器拒绝",而是"已经在工作"。
|
|
225
|
+
const detect = typeof options.detectTunnel === 'function' ? options.detectTunnel : findExistingTunnel
|
|
226
|
+
const existing = await detect(remotePort)
|
|
227
|
+
if (existing !== null) {
|
|
228
|
+
return {
|
|
229
|
+
ok: true,
|
|
230
|
+
code: 'preflight.tunnel.existing',
|
|
231
|
+
params: { remotePort: String(remotePort), pid: existing.pid },
|
|
232
|
+
hintCode: 'preflight.tunnel.existing.hint',
|
|
233
|
+
hintParams: { remotePort: String(remotePort) },
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
const argv = [
|
|
237
|
+
'-N',
|
|
238
|
+
'-T',
|
|
239
|
+
...(port === 22 ? [] : ['-p', String(port)]),
|
|
240
|
+
'-o',
|
|
241
|
+
'ExitOnForwardFailure=yes',
|
|
242
|
+
'-o',
|
|
243
|
+
'BatchMode=yes',
|
|
244
|
+
'-o',
|
|
245
|
+
'StrictHostKeyChecking=accept-new',
|
|
246
|
+
'-o',
|
|
247
|
+
'ConnectTimeout=8',
|
|
248
|
+
]
|
|
249
|
+
if (options.keyPath) argv.push('-i', options.keyPath)
|
|
250
|
+
argv.push('-R', '127.0.0.1:' + String(remotePort) + ':127.0.0.1:' + String(localPort))
|
|
251
|
+
argv.push(options.user + '@' + options.host)
|
|
252
|
+
|
|
253
|
+
return await new Promise((resolve) => {
|
|
254
|
+
const child = spawn('ssh', argv, { stdio: ['ignore', 'pipe', 'pipe'] })
|
|
255
|
+
let stderr = ''
|
|
256
|
+
let settled = false
|
|
257
|
+
const finish = (result) => {
|
|
258
|
+
if (settled) return
|
|
259
|
+
settled = true
|
|
260
|
+
try {
|
|
261
|
+
child.kill('SIGTERM')
|
|
262
|
+
} catch {
|
|
263
|
+
/* 已退出 */
|
|
264
|
+
}
|
|
265
|
+
resolve(result)
|
|
266
|
+
}
|
|
267
|
+
child.stderr.on('data', (chunk) => {
|
|
268
|
+
stderr += chunk.toString('utf8')
|
|
269
|
+
})
|
|
270
|
+
child.on('error', (error) =>
|
|
271
|
+
finish({
|
|
272
|
+
ok: false,
|
|
273
|
+
code: 'preflight.tunnel.spawnFailed',
|
|
274
|
+
params: { message: String(error?.message ?? error) },
|
|
275
|
+
hintCode: 'preflight.tunnel.spawnFailed.hint',
|
|
276
|
+
}),
|
|
277
|
+
)
|
|
278
|
+
child.on('exit', (code) => {
|
|
279
|
+
const text = stderr.trim().split('\n').slice(-3).join(' | ')
|
|
280
|
+
if (code === 0) return
|
|
281
|
+
const hintCode = text.includes('Permission denied')
|
|
282
|
+
? 'preflight.tunnel.notKept.hint.denied'
|
|
283
|
+
: text.includes('remote port forwarding failed')
|
|
284
|
+
? 'preflight.tunnel.notKept.hint.listen'
|
|
285
|
+
: text.includes('AllowTcpForwarding')
|
|
286
|
+
? 'preflight.tunnel.notKept.hint.forwarding'
|
|
287
|
+
: 'preflight.tunnel.notKept.hint.generic'
|
|
288
|
+
finish({
|
|
289
|
+
ok: false,
|
|
290
|
+
code: 'preflight.tunnel.notKept',
|
|
291
|
+
params: { code: String(code), output: text || '-' },
|
|
292
|
+
hintCode,
|
|
293
|
+
hintParams: { remotePort: String(remotePort) },
|
|
294
|
+
})
|
|
295
|
+
})
|
|
296
|
+
const holdMs = Math.min(options.timeoutMs ?? 3000, 15000)
|
|
297
|
+
setTimeout(() => {
|
|
298
|
+
if (child.exitCode === null) {
|
|
299
|
+
finish({
|
|
300
|
+
ok: true,
|
|
301
|
+
code: 'preflight.tunnel.ok',
|
|
302
|
+
params: { seconds: String(Math.round(holdMs / 1000)), remotePort: String(remotePort) },
|
|
303
|
+
})
|
|
304
|
+
}
|
|
305
|
+
}, holdMs)
|
|
306
|
+
})
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* tailscale funnel 实连测试:本机有没有 tailscale、登录没有、funnel 是否已指向本端口。
|
|
311
|
+
* @param {object} [options]
|
|
312
|
+
* @param {string} [options.path]
|
|
313
|
+
* @param {number} [options.localPort]
|
|
314
|
+
* @param {typeof spawn} [options.spawnImpl]
|
|
315
|
+
* @param {number} [options.timeoutMs]
|
|
316
|
+
*/
|
|
317
|
+
export async function checkTailscaleFunnel(options = {}) {
|
|
318
|
+
const argv = funnelStatusArgv({ path: options.path })
|
|
319
|
+
const result = await runOnce(argv, { spawnImpl: options.spawnImpl, timeoutMs: options.timeoutMs ?? 10000 })
|
|
320
|
+
if (result.error !== undefined) {
|
|
321
|
+
return {
|
|
322
|
+
ok: false,
|
|
323
|
+
code: 'preflight.tailscale.failed',
|
|
324
|
+
params: { output: result.error },
|
|
325
|
+
hintCode: 'preflight.tailscale.failed.hint.' + classifyFunnelError(result.error),
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
const text = (result.stderr || result.stdout).trim()
|
|
329
|
+
if (result.code !== 0) {
|
|
330
|
+
return {
|
|
331
|
+
ok: false,
|
|
332
|
+
code: 'preflight.tailscale.failed',
|
|
333
|
+
params: { output: text.split('\n').slice(-3).join(' | ') || '-' },
|
|
334
|
+
hintCode: 'preflight.tailscale.failed.hint.' + classifyFunnelError(text),
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
if (options.localPort !== undefined && !text.includes(String(options.localPort))) {
|
|
338
|
+
return {
|
|
339
|
+
ok: false,
|
|
340
|
+
code: 'preflight.tailscale.failed',
|
|
341
|
+
params: { output: text.split('\n').slice(0, 3).join(' | ') || '-' },
|
|
342
|
+
hintCode: 'preflight.tailscale.failed.hint.generic',
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
const url = /https:\/\/[A-Za-z0-9.-]+/.exec(text)
|
|
346
|
+
return { ok: true, code: 'preflight.tailscale.ok', params: { url: url === null ? '-' : url[0] + '/' } }
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* 把结构化结果渲染成可展示的行。
|
|
351
|
+
* @param {object} result check* 的返回值
|
|
352
|
+
* @param {string} id 条目 id(面板按它取自己的语言)
|
|
353
|
+
* @param {(key: string, params?: object) => string} t
|
|
354
|
+
*/
|
|
355
|
+
export function renderCheck(result, id, t) {
|
|
356
|
+
const rendered = {
|
|
357
|
+
id,
|
|
358
|
+
name: t('check.' + id + '.name'),
|
|
359
|
+
ok: result.ok,
|
|
360
|
+
code: result.code,
|
|
361
|
+
params: result.params ?? {},
|
|
362
|
+
detail: t(result.code, result.params),
|
|
363
|
+
}
|
|
364
|
+
if (result.hintCode !== undefined) rendered.hint = t(result.hintCode, result.hintParams)
|
|
365
|
+
return rendered
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* 一次跑完所有检查。
|
|
370
|
+
* @param {object} options
|
|
371
|
+
* @param {string} options.domain
|
|
372
|
+
* @param {'en'|'zh'} [options.locale] 渲染语言;缺省时按英文渲染(CLI 会显式传入)
|
|
373
|
+
* @returns {Promise<Array<{ id: string, name: string, ok: boolean, detail: string, hint?: string }>>}
|
|
374
|
+
*/
|
|
375
|
+
export async function runPreflight(options) {
|
|
376
|
+
const t = translator(options.locale)
|
|
377
|
+
const results = []
|
|
378
|
+
|
|
379
|
+
// 域名三项只在有域名时才有意义:tailscale 用节点自带的 ts.net 域名,配置里没有 domain
|
|
380
|
+
if (options.domain !== undefined && options.domain !== '') {
|
|
381
|
+
const dnsResult = await checkDns(options.domain, options.expectIp)
|
|
382
|
+
results.push(renderCheck(dnsResult, 'dns', t))
|
|
383
|
+
|
|
384
|
+
const tlsResult = await checkTls(options.domain, options.httpsPort ?? 443)
|
|
385
|
+
results.push(renderCheck(tlsResult, 'tls', t))
|
|
386
|
+
|
|
387
|
+
const httpsResult = await checkHttps(options.domain, { user: options.user, password: options.password })
|
|
388
|
+
results.push(renderCheck(httpsResult, 'https', t))
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if (options.tunnelMode === 'tailscale') {
|
|
392
|
+
const funnelResult = await checkTailscaleFunnel({
|
|
393
|
+
path: options.tailscalePath,
|
|
394
|
+
localPort: options.localPort,
|
|
395
|
+
spawnImpl: options.spawnImpl,
|
|
396
|
+
})
|
|
397
|
+
results.push(renderCheck(funnelResult, 'tailscale', t))
|
|
398
|
+
} else if (options.sshUser && options.sshHost) {
|
|
399
|
+
const sshResult = await checkSshTunnel({
|
|
400
|
+
user: options.sshUser,
|
|
401
|
+
host: options.sshHost,
|
|
402
|
+
keyPath: options.sshKeyPath,
|
|
403
|
+
port: options.sshPort,
|
|
404
|
+
localPort: options.localPort ?? 8788,
|
|
405
|
+
remotePort: options.remotePort ?? 8788,
|
|
406
|
+
})
|
|
407
|
+
results.push(renderCheck(sshResult, 'tunnel', t))
|
|
408
|
+
}
|
|
409
|
+
return results
|
|
410
|
+
}
|