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,768 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* dsh-remote —— 给 DSH Harness 开一条远程入口。
|
|
4
|
+
*
|
|
5
|
+
* dsh-remote serve --public --key <密钥> --domain dsh.example.com --tunnel ssh --ssh-user dshtunnel --ssh-host dsh.example.com
|
|
6
|
+
* dsh-remote check --domain dsh.example.com --user dsh --password *** --ssh-user dshtunnel --ssh-host dsh.example.com
|
|
7
|
+
* dsh-remote snippets --domain dsh.example.com --kind nginx
|
|
8
|
+
* dsh-remote keygen
|
|
9
|
+
*
|
|
10
|
+
* 设计原则:不引入运行时依赖(qrcode 为可选),所有失败都给人话提示。
|
|
11
|
+
*/
|
|
12
|
+
import os from 'node:os'
|
|
13
|
+
import path from 'node:path'
|
|
14
|
+
import fs from 'node:fs'
|
|
15
|
+
import { spawnSync } from 'node:child_process'
|
|
16
|
+
import { createProxy, qrRows, defaultLogCandidates, lanAddresses } from '../lib/core/proxy.js'
|
|
17
|
+
import { createTunnel } from '../lib/core/tunnel.js'
|
|
18
|
+
import { runPreflight } from '../lib/core/preflight.js'
|
|
19
|
+
import { normalizeLocale, resolveLocale, translator } from '../lib/core/messages.js'
|
|
20
|
+
import { buildServerSetupScript, buildServerUninstallScript } from '../lib/core/serversetup.js'
|
|
21
|
+
import { createTenancy } from '../lib/core/tenancy.js'
|
|
22
|
+
import { defaultGateSecretPath, defaultRegistryPath, loadOrCreateGateSecret, pluginStateDir } from '../lib/core/paths.js'
|
|
23
|
+
import { createRegistry, defaultTenantBaseDir, generateAccessKey, slugifyId } from '../lib/core/tenant.js'
|
|
24
|
+
import { generatePassphrase, generateRandomPassword, setupCommands } from '../lib/core/credential.js'
|
|
25
|
+
import { checkAccessKeyStrength } from '../lib/index.js'
|
|
26
|
+
import { discoverHarnessBin, discoverRuntime } from '../lib/core/instance.js'
|
|
27
|
+
import {
|
|
28
|
+
NGINX_UPGRADE_MAP,
|
|
29
|
+
nginxServerBlock,
|
|
30
|
+
caddySite,
|
|
31
|
+
authorizedKeysLine,
|
|
32
|
+
sshTunnelCommand,
|
|
33
|
+
serverSetupSteps,
|
|
34
|
+
} from '../lib/core/snippets.js'
|
|
35
|
+
|
|
36
|
+
/** CLI 语言:`--lang` 覆盖环境变量(DSH_REMOTE_LANG / LC_ALL / LANG),兜底英文。 */
|
|
37
|
+
let lang = resolveLocale()
|
|
38
|
+
let t = translator(lang)
|
|
39
|
+
|
|
40
|
+
/** 当前语言标记:传给 runPreflight / doctor 渲染结果。 */
|
|
41
|
+
function currentLocale() {
|
|
42
|
+
return lang
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** 供 `--help` 与用法错误使用。 */
|
|
46
|
+
function helpText() {
|
|
47
|
+
return t('cli.help')
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 极简参数解析:支持 --flag、--flag value、--flag=value,位置参数进 `_`。
|
|
52
|
+
* @param {string[]} argv
|
|
53
|
+
*/
|
|
54
|
+
function parseArgs(argv) {
|
|
55
|
+
const flags = {}
|
|
56
|
+
const rest = []
|
|
57
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
58
|
+
const item = argv[index]
|
|
59
|
+
if (!item.startsWith('--')) {
|
|
60
|
+
rest.push(item)
|
|
61
|
+
continue
|
|
62
|
+
}
|
|
63
|
+
const body = item.slice(2)
|
|
64
|
+
const eq = body.indexOf('=')
|
|
65
|
+
if (eq !== -1) {
|
|
66
|
+
const key = body.slice(0, eq)
|
|
67
|
+
const value = body.slice(eq + 1)
|
|
68
|
+
if (key === 'domain') {
|
|
69
|
+
flags.domain = [].concat(flags.domain ?? [], value)
|
|
70
|
+
} else {
|
|
71
|
+
flags[key] = value
|
|
72
|
+
}
|
|
73
|
+
continue
|
|
74
|
+
}
|
|
75
|
+
const next = argv[index + 1]
|
|
76
|
+
if (next === undefined || next.startsWith('--')) {
|
|
77
|
+
flags[body] = true
|
|
78
|
+
continue
|
|
79
|
+
}
|
|
80
|
+
index += 1
|
|
81
|
+
if (body === 'domain') {
|
|
82
|
+
flags.domain = [].concat(flags.domain ?? [], next)
|
|
83
|
+
} else {
|
|
84
|
+
flags[body] = next
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return { flags, rest }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function fail(message) {
|
|
91
|
+
process.stderr.write('✖ ' + message + '\n')
|
|
92
|
+
process.exit(1)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function printResult(result) {
|
|
96
|
+
process.stdout.write(
|
|
97
|
+
t('cli.result.line', { mark: result.ok ? '✔' : '✖', name: result.name, detail: result.detail }) + '\n',
|
|
98
|
+
)
|
|
99
|
+
if (!result.ok && result.hint) process.stdout.write(t('cli.result.hint', { hint: result.hint }) + '\n')
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** 未翻译部分的提示:英文环境下明确告知,而不是给一段看不懂的中文。 */
|
|
103
|
+
function noticeZhOnly() {
|
|
104
|
+
const notice = t('cli.notice.zhOnly')
|
|
105
|
+
if (notice !== '') process.stderr.write(notice + '\n')
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function printQr(url) {
|
|
109
|
+
const rows = qrRows(url)
|
|
110
|
+
if (rows === null) return
|
|
111
|
+
const size = rows.length
|
|
112
|
+
const quiet = 2
|
|
113
|
+
const lines = []
|
|
114
|
+
const pad = ' '.repeat((size + quiet * 2) * 2)
|
|
115
|
+
for (let index = 0; index < quiet; index += 1) lines.push(pad)
|
|
116
|
+
for (let y = 0; y < size; y += 1) {
|
|
117
|
+
let line = ' '.repeat(quiet)
|
|
118
|
+
for (let x = 0; x < size; x += 1) line += rows[y][x] === '1' ? '██' : ' '
|
|
119
|
+
line += ' '.repeat(quiet)
|
|
120
|
+
lines.push(line)
|
|
121
|
+
}
|
|
122
|
+
for (let index = 0; index < quiet; index += 1) lines.push(pad)
|
|
123
|
+
process.stdout.write(lines.join('\n') + '\n')
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** 多租户模式下某个人的入口链接(局域网优先)。 */
|
|
127
|
+
function tenantEntryFor(tenant, info, flags) {
|
|
128
|
+
const lanIp = info.addresses.length > 0 ? info.addresses[0].address : null
|
|
129
|
+
if (lanIp !== null) return 'http://' + lanIp + ':' + String(info.port) + '/?k=' + tenant.accessKey
|
|
130
|
+
const domain = typeof flags.domain === 'string' ? flags.domain : ''
|
|
131
|
+
return domain === '' ? null : 'https://' + domain + '/?k=' + tenant.accessKey
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function commandServe(flags) {
|
|
135
|
+
const isPublic = flags.public === true
|
|
136
|
+
const multi = flags.multi === true
|
|
137
|
+
const accessKey = typeof flags.key === 'string' ? flags.key : ''
|
|
138
|
+
// 多租户下没有"全局密钥":每个人都有自己那把,所以不要求 --key
|
|
139
|
+
if (isPublic && !multi && accessKey === '' && flags['allow-no-key'] !== true) {
|
|
140
|
+
fail(t('cli.error.needKey'))
|
|
141
|
+
}
|
|
142
|
+
if (isPublic && accessKey === '' && flags['allow-no-key'] === true) {
|
|
143
|
+
process.stderr.write(t('cli.serve.noKeyWarning') + '\n')
|
|
144
|
+
}
|
|
145
|
+
const port = Number(flags.port ?? (isPublic ? 8788 : 8787))
|
|
146
|
+
const upstreamPort = flags.upstream !== undefined ? Number(flags.upstream) : undefined
|
|
147
|
+
const domains = [].concat(flags.domain ?? [])
|
|
148
|
+
|
|
149
|
+
// 多租户:本进程既当网关又当实例看护(退出时会一起收掉)
|
|
150
|
+
let tenancy = null
|
|
151
|
+
let tenantRouter = null
|
|
152
|
+
let gateSecret = ''
|
|
153
|
+
if (multi) {
|
|
154
|
+
const { registry: registryFile, baseDir } = tenantRegistryOptions(flags)
|
|
155
|
+
gateSecret = loadOrCreateGateSecret(defaultGateSecretPath()).secret
|
|
156
|
+
tenancy = createTenancy({
|
|
157
|
+
config: {
|
|
158
|
+
enabled: true,
|
|
159
|
+
registry: registryFile,
|
|
160
|
+
baseDir,
|
|
161
|
+
autostart: true,
|
|
162
|
+
harness: {
|
|
163
|
+
bin: typeof flags['harness-bin'] === 'string' ? flags['harness-bin'] : '',
|
|
164
|
+
node: typeof flags.node === 'string' ? flags.node : '',
|
|
165
|
+
extraArgs: [],
|
|
166
|
+
},
|
|
167
|
+
list: [],
|
|
168
|
+
},
|
|
169
|
+
log: (line) => process.stderr.write('· ' + line + '\n'),
|
|
170
|
+
})
|
|
171
|
+
const loaded = tenancy.load()
|
|
172
|
+
if (tenancy.harness.problem !== null) fail(tenancy.harness.problem)
|
|
173
|
+
process.stderr.write('· ' + t('cli.multi.registry', { file: registryFile, count: String(loaded.total) }) + '\n')
|
|
174
|
+
tenantRouter = { findById: (id) => tenancy.handle(id), findByKey: (key) => tenancy.findByKey(key) }
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const proxy = createProxy({
|
|
178
|
+
port,
|
|
179
|
+
upstreamPort,
|
|
180
|
+
listenHost: isPublic ? '127.0.0.1' : '0.0.0.0',
|
|
181
|
+
token: typeof flags.token === 'string' ? flags.token : '',
|
|
182
|
+
logPaths: defaultLogCandidates(),
|
|
183
|
+
accessKey: multi ? '' : accessKey,
|
|
184
|
+
gateSecret: multi ? gateSecret : undefined,
|
|
185
|
+
tenants: tenantRouter,
|
|
186
|
+
allowedHosts: domains,
|
|
187
|
+
mobileAdaptation: flags['no-mobile'] !== true,
|
|
188
|
+
log: (line) => process.stderr.write('· ' + line + '\n'),
|
|
189
|
+
})
|
|
190
|
+
const info = await proxy.start()
|
|
191
|
+
|
|
192
|
+
if (multi) {
|
|
193
|
+
const started = tenancy.startAutostart()
|
|
194
|
+
process.stderr.write('· ' + t('cli.multi.started', { count: String(started.length) }) + '\n')
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
let tunnel = null
|
|
198
|
+
if (typeof flags.tunnel === 'string') {
|
|
199
|
+
if (!['ssh', 'cloudflared', 'tailscale', 'none'].includes(flags.tunnel)) {
|
|
200
|
+
fail(t('cli.error.unknownTunnel', { mode: flags.tunnel }))
|
|
201
|
+
}
|
|
202
|
+
if (flags.tunnel === 'ssh' && (typeof flags['ssh-user'] !== 'string' || typeof flags['ssh-host'] !== 'string')) {
|
|
203
|
+
fail(t('cli.error.needSshHost'))
|
|
204
|
+
}
|
|
205
|
+
tunnel = flags.tunnel === 'none' ? null : createTunnel({
|
|
206
|
+
mode: flags.tunnel,
|
|
207
|
+
localPort: info.port,
|
|
208
|
+
remotePort: flags['remote-port'] !== undefined ? Number(flags['remote-port']) : undefined,
|
|
209
|
+
user: flags['ssh-user'],
|
|
210
|
+
host: flags['ssh-host'],
|
|
211
|
+
keyPath: typeof flags['ssh-key'] === 'string' ? flags['ssh-key'] : undefined,
|
|
212
|
+
port: flags['ssh-port'] !== undefined ? Number(flags['ssh-port']) : undefined,
|
|
213
|
+
tailscalePath: typeof flags['tailscale'] === 'string' ? flags['tailscale'] : undefined,
|
|
214
|
+
tailscaleHttpsPort: flags['tailscale-https-port'] !== undefined ? Number(flags['tailscale-https-port']) : undefined,
|
|
215
|
+
log: (line) => process.stdout.write('· ' + line + '\n'),
|
|
216
|
+
onState: (state) => {
|
|
217
|
+
process.stderr.write('· ' + t(state.code, state.params) + '\n')
|
|
218
|
+
},
|
|
219
|
+
})
|
|
220
|
+
tunnel.start()
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const publicBase =
|
|
224
|
+
tunnel !== null && tunnel.state().publicUrl !== null
|
|
225
|
+
? tunnel.state().publicUrl
|
|
226
|
+
: isPublic && domains.length > 0
|
|
227
|
+
? 'https://' + domains[0] + '/'
|
|
228
|
+
: null
|
|
229
|
+
const payload = {
|
|
230
|
+
...info,
|
|
231
|
+
tenants: tenancy === null ? null : tenancy.list(),
|
|
232
|
+
tunnel: tunnel === null ? null : tunnel.state(),
|
|
233
|
+
entry: publicBase ?? info.lanUrl,
|
|
234
|
+
publicEntry: publicBase === null ? null : publicBase + (accessKey === '' ? '' : '?k=' + accessKey),
|
|
235
|
+
accessKey: accessKey === '' ? null : accessKey,
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (flags.json === true) {
|
|
239
|
+
process.stdout.write(JSON.stringify(payload, null, 2) + '\n')
|
|
240
|
+
} else {
|
|
241
|
+
process.stdout.write('\n' + t('cli.serve.started') + '\n')
|
|
242
|
+
process.stdout.write(
|
|
243
|
+
t('cli.serve.listen', { listenHost: info.listenHost, port: String(info.port), upstream: String(info.upstream) }) + '\n',
|
|
244
|
+
)
|
|
245
|
+
if (!info.loopbackOnly && info.lanUrl !== null) {
|
|
246
|
+
process.stdout.write(t('cli.serve.lan', { url: info.lanUrl }) + '\n')
|
|
247
|
+
printQr(info.lanUrl)
|
|
248
|
+
}
|
|
249
|
+
if (publicBase !== null) {
|
|
250
|
+
const entryUrl = publicBase + (accessKey === '' ? '' : '?k=' + accessKey)
|
|
251
|
+
process.stdout.write(t('cli.serve.public', { url: entryUrl }) + '\n')
|
|
252
|
+
printQr(entryUrl)
|
|
253
|
+
}
|
|
254
|
+
if (multi) {
|
|
255
|
+
for (const tenant of tenancy.list()) {
|
|
256
|
+
const link = tenantEntryFor(tenant, info, flags)
|
|
257
|
+
process.stdout.write('\n' + t('cli.multi.tenant', { name: tenant.name, id: tenant.id }) + '\n')
|
|
258
|
+
if (link !== null) {
|
|
259
|
+
process.stdout.write(' ' + link + '\n')
|
|
260
|
+
printQr(link)
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
process.stdout.write('\n' + t('cli.multi.note') + '\n')
|
|
264
|
+
}
|
|
265
|
+
if (tunnel !== null) {
|
|
266
|
+
process.stdout.write(t('cli.serve.tunnel', { phase: t('phase.' + tunnel.state().phase) }) + '\n')
|
|
267
|
+
}
|
|
268
|
+
process.stdout.write(t('cli.serve.ctrlC') + '\n\n')
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const shutdown = async () => {
|
|
272
|
+
process.stdout.write('\n' + t('cli.serve.stopping') + '\n')
|
|
273
|
+
if (tenancy !== null) await tenancy.stopAll()
|
|
274
|
+
if (tunnel !== null) await tunnel.stop()
|
|
275
|
+
await proxy.stop()
|
|
276
|
+
process.exit(0)
|
|
277
|
+
}
|
|
278
|
+
process.on('SIGINT', () => void shutdown())
|
|
279
|
+
process.on('SIGTERM', () => void shutdown())
|
|
280
|
+
await new Promise(() => {})
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async function commandCheck(flags) {
|
|
284
|
+
const domains = [].concat(flags.domain ?? [])
|
|
285
|
+
if (domains.length === 0) fail(t('cli.error.needDomain', { command: 'check' }))
|
|
286
|
+
const results = await runPreflight({
|
|
287
|
+
domain: domains[0],
|
|
288
|
+
locale: currentLocale(),
|
|
289
|
+
expectIp: typeof flags['expect-ip'] === 'string' ? flags['expect-ip'] : undefined,
|
|
290
|
+
user: typeof flags.user === 'string' ? flags.user : undefined,
|
|
291
|
+
password: typeof flags.password === 'string' ? flags.password : undefined,
|
|
292
|
+
sshUser: typeof flags['ssh-user'] === 'string' ? flags['ssh-user'] : undefined,
|
|
293
|
+
sshHost: typeof flags['ssh-host'] === 'string' ? flags['ssh-host'] : undefined,
|
|
294
|
+
sshKeyPath: typeof flags['ssh-key'] === 'string' ? flags['ssh-key'] : undefined,
|
|
295
|
+
sshPort: flags['ssh-port'] !== undefined ? Number(flags['ssh-port']) : undefined,
|
|
296
|
+
remotePort: flags['remote-port'] !== undefined ? Number(flags['remote-port']) : undefined,
|
|
297
|
+
localPort: flags['local-port'] !== undefined ? Number(flags['local-port']) : undefined,
|
|
298
|
+
})
|
|
299
|
+
for (const result of results) printResult(result)
|
|
300
|
+
const failed = results.filter((item) => !item.ok)
|
|
301
|
+
process.stdout.write(
|
|
302
|
+
'\n' + t('cli.result.summary', { passed: String(results.length - failed.length), total: String(results.length) }) + '\n',
|
|
303
|
+
)
|
|
304
|
+
if (failed.length > 0) process.exit(1)
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function commandSnippets(flags) {
|
|
308
|
+
const domains = [].concat(flags.domain ?? [])
|
|
309
|
+
if (domains.length === 0) fail(t('cli.error.needDomain', { command: 'snippets' }))
|
|
310
|
+
const domain = domains[0]
|
|
311
|
+
const targetPort = flags['target-port'] !== undefined ? Number(flags['target-port']) : 8788
|
|
312
|
+
const kind = typeof flags.kind === 'string' ? flags.kind : 'nginx'
|
|
313
|
+
process.stdout.write('=== 1) 服务器一次性准备 ===\n\n')
|
|
314
|
+
process.stdout.write(serverSetupSteps({ targetPort, user: typeof flags['ssh-user'] === 'string' ? flags['ssh-user'] : undefined }) + '\n\n')
|
|
315
|
+
if (typeof flags['public-key'] === 'string') {
|
|
316
|
+
process.stdout.write('=== 2) authorized_keys 限制行(贴进专用账号的 ~/.ssh/authorized_keys)===\n\n')
|
|
317
|
+
process.stdout.write(authorizedKeysLine(flags['public-key'], targetPort) + '\n\n')
|
|
318
|
+
}
|
|
319
|
+
process.stdout.write('=== 3) 反向代理配置(' + kind + ')===\n\n')
|
|
320
|
+
if (kind === 'caddy') {
|
|
321
|
+
process.stdout.write(caddySite({ domain, targetPort }) + '\n\n')
|
|
322
|
+
} else {
|
|
323
|
+
process.stdout.write('# /etc/nginx/conf.d/upgrade-map.conf\n' + NGINX_UPGRADE_MAP + '\n\n')
|
|
324
|
+
process.stdout.write(nginxServerBlock({ domain, targetPort }) + '\n\n')
|
|
325
|
+
}
|
|
326
|
+
process.stdout.write('=== 4) 本机要跑的隧道命令 ===\n\n')
|
|
327
|
+
process.stdout.write(
|
|
328
|
+
sshTunnelCommand({
|
|
329
|
+
user: typeof flags['ssh-user'] === 'string' ? flags['ssh-user'] : 'dshtunnel',
|
|
330
|
+
host: domain,
|
|
331
|
+
keyPath: typeof flags['ssh-key'] === 'string' ? flags['ssh-key'] : '~/.ssh/dsh_remote_tunnel',
|
|
332
|
+
port: flags['ssh-port'] !== undefined ? Number(flags['ssh-port']) : 22,
|
|
333
|
+
localPort: targetPort,
|
|
334
|
+
remotePort: targetPort,
|
|
335
|
+
}) + '\n\n',
|
|
336
|
+
)
|
|
337
|
+
process.stdout.write('提示:`dsh-remote serve --public --key <密钥> --domain ' + domain + ' --tunnel ssh --ssh-user <账号> --ssh-host ' + domain + '`\n')
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function commandSetupServer(flags) {
|
|
341
|
+
if (currentLocale() !== 'zh') noticeZhOnly()
|
|
342
|
+
const domains = [].concat(flags.domain ?? [])
|
|
343
|
+
if (domains.length === 0) fail('setup-server 需要 --domain <域名>')
|
|
344
|
+
// 规范《账密设置规范》:用户名默认 dsh 且可改;口令没有默认值,
|
|
345
|
+
// 只能 auto(生成并显示一次)/ prompt(交给安装脚本交互输入)/ 显式给定(要过强度校验)
|
|
346
|
+
const edgeUser = typeof flags['edge-user'] === 'string' ? flags['edge-user'] : flags['auth-user']
|
|
347
|
+
const edgeMode = typeof flags['edge-password'] === 'string' ? flags['edge-password'] : 'auto'
|
|
348
|
+
const render = (builder) =>
|
|
349
|
+
builder({
|
|
350
|
+
domain: domains[0],
|
|
351
|
+
remotePort: flags['remote-port'] !== undefined ? Number(flags['remote-port']) : 8788,
|
|
352
|
+
tunnelUser: typeof flags['ssh-user'] === 'string' ? flags['ssh-user'] : 'dshtunnel',
|
|
353
|
+
authUser: typeof edgeUser === 'string' ? edgeUser : 'dsh',
|
|
354
|
+
authFile: typeof flags['auth-file'] === 'string' ? flags['auth-file'] : undefined,
|
|
355
|
+
nginxConf: typeof flags['nginx-conf'] === 'string' ? flags['nginx-conf'] : undefined,
|
|
356
|
+
})
|
|
357
|
+
let text
|
|
358
|
+
try {
|
|
359
|
+
text = render(flags.uninstall === true ? buildServerUninstallScript : buildServerSetupScript)
|
|
360
|
+
} catch (error) {
|
|
361
|
+
fail(String(error && error.message ? error.message : error))
|
|
362
|
+
}
|
|
363
|
+
// 边缘口令:默认由插件生成一次并打印(用户自己想的 90% 是弱口令),
|
|
364
|
+
// 口令不写进脚本;用 `printf %s '<口令>' | bash <脚本> install --auth-password-stdin` 应用。
|
|
365
|
+
if (flags.uninstall !== true && edgeMode !== 'prompt') {
|
|
366
|
+
const explicit = edgeMode !== 'auto' ? edgeMode : ''
|
|
367
|
+
if (explicit !== '') {
|
|
368
|
+
const problems = checkAccessKeyStrength(explicit)
|
|
369
|
+
if (problems.length > 0) fail(t('host.error.keyWeak', { reasons: problems.join(';') }))
|
|
370
|
+
}
|
|
371
|
+
const edge = explicit !== '' ? { password: explicit, bits: '—' } : generatePassphrase()
|
|
372
|
+
process.stderr.write(
|
|
373
|
+
t('cli.setup.edgePassword', { user: typeof edgeUser === 'string' ? edgeUser : 'dsh' }) +
|
|
374
|
+
'\n ' + edge.password + '\n' +
|
|
375
|
+
t('cli.setup.edgeApply', { bits: String(edge.bits) }) + '\n' +
|
|
376
|
+
' printf %s ' + JSON.stringify(edge.password) + ' | sudo bash <脚本> install --auth-password-stdin\n' +
|
|
377
|
+
t('cli.setup.edgeNote') + '\n\n',
|
|
378
|
+
)
|
|
379
|
+
}
|
|
380
|
+
if (flags.uninstall !== true && edgeMode === 'prompt') {
|
|
381
|
+
process.stderr.write(t('cli.setup.edgePrompt') + '\n\n')
|
|
382
|
+
}
|
|
383
|
+
const out = typeof flags.out === 'string' ? flags.out : null
|
|
384
|
+
if (out === null) {
|
|
385
|
+
process.stdout.write(text)
|
|
386
|
+
process.stderr.write(
|
|
387
|
+
'\n· 这份脚本默认不会执行任何东西。用法:拷到服务器上先 `bash <脚本> probe` 看环境,' +
|
|
388
|
+
'再 `bash <脚本> install --dry-run` 看计划,最后 `bash <脚本> install`。\n' +
|
|
389
|
+
(flags.uninstall === true ? '' : '· 撤销:`bash <脚本> uninstall`(或 dsh-remote uninstall-server 产出的独立脚本)。\n'),
|
|
390
|
+
)
|
|
391
|
+
return
|
|
392
|
+
}
|
|
393
|
+
fs.writeFileSync(out, text, { mode: 0o755 })
|
|
394
|
+
process.stdout.write('已写入 ' + out + '(' + String(text.length) + ' 字节,权限 755)\n')
|
|
395
|
+
process.stdout.write(
|
|
396
|
+
flags.uninstall === true
|
|
397
|
+
? '下一步:scp ' + out + ' <服务器>:/tmp/ && ssh <服务器> "sudo bash /tmp/' + path.basename(out) + ' [--purge-user]"\n'
|
|
398
|
+
: '下一步:scp ' + out + ' <服务器>:/tmp/ && ssh <服务器> "sudo bash /tmp/' + path.basename(out) + ' probe"\n',
|
|
399
|
+
)
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** 租户注册表的位置与 home 基准(与插件默认值一致,避免两边算出不同目录)。 */
|
|
403
|
+
function tenantRegistryOptions(flags, env = process.env) {
|
|
404
|
+
return {
|
|
405
|
+
registry: typeof flags.registry === 'string' ? flags.registry : defaultRegistryPath(env),
|
|
406
|
+
baseDir: typeof flags['base-dir'] === 'string' ? flags['base-dir'] : defaultTenantBaseDir(),
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** 某个租户现在能用的入口链接(局域网优先;公网要等隧道与域名都就绪)。 */
|
|
411
|
+
function tenantLinks(tenant, flags) {
|
|
412
|
+
const lanPort = flags['lan-port'] !== undefined ? Number(flags['lan-port']) : 8787
|
|
413
|
+
const addresses = lanAddresses()
|
|
414
|
+
const lanIp = addresses.length > 0 ? addresses[0].address : null
|
|
415
|
+
const lan = lanIp === null ? null : 'http://' + lanIp + ':' + String(lanPort) + '/?k=' + tenant.accessKey
|
|
416
|
+
const domain = typeof flags.domain === 'string' ? flags.domain : ''
|
|
417
|
+
const publicEntry = domain === '' ? null : 'https://' + domain + '/?k=' + tenant.accessKey
|
|
418
|
+
return { lan, public: publicEntry }
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* `dsh-remote tenant …` —— 租户注册表的命令行入口。
|
|
423
|
+
*
|
|
424
|
+
* 刻意**不**在这里拉起/停止实例:实例属于"跑网关的那个进程"(DSH 插件或 `serve --multi`),
|
|
425
|
+
* CLI 起了也会随命令退出而变成孤儿进程。启停请在宿主窗口的面板里做。
|
|
426
|
+
*/
|
|
427
|
+
/**
|
|
428
|
+
* `dsh-remote credential` —— 生成并给出"怎么在服务器上设置边缘口令"。
|
|
429
|
+
*
|
|
430
|
+
* 这是公网入口唯一一道门,所以默认给的是**能用手输的短语**(46 bit 在线强度),
|
|
431
|
+
* 并且把设置命令连同 `--auth-password-stdin` 的用法一次给全,用户不用自己想口令。
|
|
432
|
+
*/
|
|
433
|
+
function commandCredential(flags) {
|
|
434
|
+
const user = typeof flags.user === 'string' ? flags.user : 'dsh'
|
|
435
|
+
const authFile = typeof flags['auth-file'] === 'string' ? flags['auth-file'] : '/etc/nginx/.htpasswd-dsh'
|
|
436
|
+
const random = flags.random === true
|
|
437
|
+
const generated = random
|
|
438
|
+
? generateRandomPassword({ length: flags.length !== undefined ? Number(flags.length) : 24 })
|
|
439
|
+
: generatePassphrase({
|
|
440
|
+
words: flags.words !== undefined ? Number(flags.words) : undefined,
|
|
441
|
+
digits: flags.digits !== undefined ? Number(flags.digits) : undefined,
|
|
442
|
+
})
|
|
443
|
+
const commands = setupCommands({ user, authFile, password: generated.password })
|
|
444
|
+
|
|
445
|
+
if (flags.json === true) {
|
|
446
|
+
process.stdout.write(
|
|
447
|
+
JSON.stringify({ user, authFile, password: generated.password, bits: generated.bits, commands }, null, 2) + '\n',
|
|
448
|
+
)
|
|
449
|
+
return
|
|
450
|
+
}
|
|
451
|
+
process.stdout.write(t('cli.cred.title') + '\n\n')
|
|
452
|
+
process.stdout.write(t('cli.cred.user') + ' ' + user + '\n')
|
|
453
|
+
process.stdout.write(t('cli.cred.password') + ' ' + generated.password + '\n')
|
|
454
|
+
process.stdout.write(t('cli.cred.strength', { bits: String(generated.bits) }) + '\n\n')
|
|
455
|
+
process.stdout.write(t('cli.cred.step1') + '\n ' + commands.htpasswd + '\n')
|
|
456
|
+
process.stdout.write(t('cli.cred.step1b') + '\n ' + commands.openssl + '\n\n')
|
|
457
|
+
process.stdout.write(t('cli.cred.step2') + '\n ' + commands.verify + '\n\n')
|
|
458
|
+
process.stdout.write(t('cli.cred.warnSave') + '\n')
|
|
459
|
+
process.stdout.write(t('cli.cred.warnIndependent') + '\n')
|
|
460
|
+
process.stdout.write(t('cli.cred.warnNoUrl') + '\n')
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function commandTenant(flags, rest) {
|
|
464
|
+
const action = rest[0] ?? 'list'
|
|
465
|
+
const { registry: file, baseDir } = tenantRegistryOptions(flags)
|
|
466
|
+
const registry = createRegistry({ file, baseDir, log: (line) => process.stderr.write('· ' + line + '\n') })
|
|
467
|
+
registry.load()
|
|
468
|
+
const find = (value) => {
|
|
469
|
+
if (typeof value !== 'string' || value === '') return null
|
|
470
|
+
const byId = registry.get(value)
|
|
471
|
+
if (byId !== null) return byId
|
|
472
|
+
return registry.list().find((item) => item.name === value) ?? null
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
if (action === 'list') {
|
|
476
|
+
const list = registry.list()
|
|
477
|
+
process.stdout.write('注册表:' + file + '\n')
|
|
478
|
+
if (list.length === 0) {
|
|
479
|
+
process.stdout.write(t('cli.tenant.empty') + '\n')
|
|
480
|
+
return
|
|
481
|
+
}
|
|
482
|
+
for (const tenant of list) {
|
|
483
|
+
const links = tenantLinks(tenant, flags)
|
|
484
|
+
process.stdout.write(
|
|
485
|
+
'· ' + tenant.name + ' (' + tenant.id + ')' + (tenant.autostart ? '' : t('cli.tenant.noAutostart')) + '\n',
|
|
486
|
+
)
|
|
487
|
+
process.stdout.write(' home: ' + tenant.home + '\n')
|
|
488
|
+
if (links.lan !== null) process.stdout.write(' ' + t('cli.tenant.lan') + ' ' + links.lan + '\n')
|
|
489
|
+
if (links.public !== null) process.stdout.write(' ' + t('cli.tenant.public') + ' ' + links.public + '\n')
|
|
490
|
+
}
|
|
491
|
+
process.stdout.write('\n' + t('cli.tenant.instancesNote') + '\n')
|
|
492
|
+
return
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
if (action === 'add') {
|
|
496
|
+
const name = typeof flags.name === 'string' ? flags.name : rest.slice(1).join(' ')
|
|
497
|
+
if (name.trim() === '' && typeof flags.id !== 'string') {
|
|
498
|
+
fail(t('cli.error.tenantNameRequired'))
|
|
499
|
+
}
|
|
500
|
+
// id 由显示名推导(中文名推不出可读 id 时会得到 u-xxxxxx,用 --id 可指定)
|
|
501
|
+
const id = typeof flags.id === 'string' && flags.id !== '' ? flags.id : slugifyId(name, 'u')
|
|
502
|
+
const created = registry.add({
|
|
503
|
+
id,
|
|
504
|
+
name: name.trim() === '' ? id : name.trim(),
|
|
505
|
+
note: typeof flags.note === 'string' ? flags.note : '',
|
|
506
|
+
})
|
|
507
|
+
const links = tenantLinks(created, flags)
|
|
508
|
+
process.stdout.write(t('cli.tenant.added', { id: created.id, home: created.home }) + '\n')
|
|
509
|
+
process.stdout.write(t('cli.tenant.key') + ' ' + created.accessKey + '\n')
|
|
510
|
+
if (links.lan !== null) {
|
|
511
|
+
process.stdout.write(t('cli.tenant.lan') + ' ' + links.lan + '\n')
|
|
512
|
+
printQr(links.lan)
|
|
513
|
+
}
|
|
514
|
+
if (links.public !== null) process.stdout.write(t('cli.tenant.public') + ' ' + links.public + '\n')
|
|
515
|
+
process.stdout.write('\n' + t('cli.tenant.instancesNote') + '\n')
|
|
516
|
+
return
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
if (!['rm', 'remove', 'rotate', 'key'].includes(action)) {
|
|
520
|
+
fail(t('cli.error.tenantUnknownAction', { action }))
|
|
521
|
+
}
|
|
522
|
+
const target = find(typeof flags.id === 'string' ? flags.id : rest[1])
|
|
523
|
+
if (target === null) fail(t('cli.tenant.notFound', { id: String(rest[1] ?? flags.id ?? '') }))
|
|
524
|
+
|
|
525
|
+
if (action === 'rm' || action === 'remove') {
|
|
526
|
+
registry.remove(target.id)
|
|
527
|
+
process.stdout.write(t('cli.tenant.removed', { id: target.id, home: target.home }) + '\n')
|
|
528
|
+
return
|
|
529
|
+
}
|
|
530
|
+
if (action === 'rotate') {
|
|
531
|
+
const rotated = registry.rotateKey(target.id)
|
|
532
|
+
const links = tenantLinks(rotated, flags)
|
|
533
|
+
process.stdout.write(t('cli.tenant.rotated', { id: rotated.id }) + '\n')
|
|
534
|
+
process.stdout.write(t('cli.tenant.key') + ' ' + rotated.accessKey + '\n')
|
|
535
|
+
if (links.lan !== null) process.stdout.write(t('cli.tenant.lan') + ' ' + links.lan + '\n')
|
|
536
|
+
if (links.public !== null) process.stdout.write(t('cli.tenant.public') + ' ' + links.public + '\n')
|
|
537
|
+
return
|
|
538
|
+
}
|
|
539
|
+
if (action === 'key') {
|
|
540
|
+
process.stdout.write(target.accessKey + '\n')
|
|
541
|
+
return
|
|
542
|
+
}
|
|
543
|
+
fail(t('cli.error.tenantUnknownAction', { action }))
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
async function commandDoctor(flags) {
|
|
547
|
+
const domains = [].concat(flags.domain ?? [])
|
|
548
|
+
if (domains.length === 0) fail(t('cli.error.needDomain', { command: 'doctor' }))
|
|
549
|
+
// doctor 的详细报告暂时只有中文(见 CHANGELOG):英文用户至少知道这件事
|
|
550
|
+
if (currentLocale() !== 'zh') noticeZhOnly()
|
|
551
|
+
const domain = domains[0]
|
|
552
|
+
const results = []
|
|
553
|
+
|
|
554
|
+
// 1) 上游与令牌来源:只构造不监听,避免打扰正在跑的服务
|
|
555
|
+
const probe = createProxy({
|
|
556
|
+
port: 0,
|
|
557
|
+
listenHost: '127.0.0.1',
|
|
558
|
+
upstreamPort: flags.upstream !== undefined ? Number(flags.upstream) : undefined,
|
|
559
|
+
token: typeof flags.token === 'string' ? flags.token : '',
|
|
560
|
+
mobileAdaptation: false,
|
|
561
|
+
})
|
|
562
|
+
const probeInfo = probe.info()
|
|
563
|
+
results.push({
|
|
564
|
+
name: '上游与令牌来源',
|
|
565
|
+
ok: probeInfo.hasToken,
|
|
566
|
+
detail:
|
|
567
|
+
probeInfo.upstream + '(端口来源 ' + probeInfo.upstreamSource + ',令牌来源 ' + probeInfo.tokenSource + ')',
|
|
568
|
+
hint: probeInfo.hasToken ? undefined : '没拿到令牌:Harness 可能没在跑;或显式给 --token / --upstream',
|
|
569
|
+
})
|
|
570
|
+
|
|
571
|
+
// 2) 局域网入口自测:绑 0 端口 → 请求 → 释放
|
|
572
|
+
try {
|
|
573
|
+
const info = await probe.start()
|
|
574
|
+
const status = await fetch('http://127.0.0.1:' + String(info.port) + '/', { redirect: 'manual' }).then(
|
|
575
|
+
(response) => response.status,
|
|
576
|
+
() => 0,
|
|
577
|
+
)
|
|
578
|
+
await probe.stop()
|
|
579
|
+
results.push({
|
|
580
|
+
name: '代理自测(临时回环端口)',
|
|
581
|
+
ok: status === 303 || status === 200,
|
|
582
|
+
detail: '绑定 :' + String(info.port) + ' 并请求 / → HTTP ' + String(status),
|
|
583
|
+
hint: status === 0 || status === 401 ? '上游拒绝或不可达:确认 Harness 在跑且令牌有效' : undefined,
|
|
584
|
+
})
|
|
585
|
+
} catch (error) {
|
|
586
|
+
results.push({ name: '代理自测(临时回环端口)', ok: false, detail: String(error?.message ?? error) })
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// 3) 公网四项
|
|
590
|
+
const preflight = await runPreflight({
|
|
591
|
+
domain,
|
|
592
|
+
locale: currentLocale(),
|
|
593
|
+
expectIp: typeof flags['expect-ip'] === 'string' ? flags['expect-ip'] : undefined,
|
|
594
|
+
user: typeof flags.user === 'string' ? flags.user : undefined,
|
|
595
|
+
password: typeof flags.password === 'string' ? flags.password : undefined,
|
|
596
|
+
sshUser: typeof flags['ssh-user'] === 'string' ? flags['ssh-user'] : undefined,
|
|
597
|
+
sshHost: typeof flags['ssh-host'] === 'string' ? flags['ssh-host'] : domains[0],
|
|
598
|
+
sshKeyPath: typeof flags['ssh-key'] === 'string' ? flags['ssh-key'] : undefined,
|
|
599
|
+
sshPort: flags['ssh-port'] !== undefined ? Number(flags['ssh-port']) : undefined,
|
|
600
|
+
remotePort: flags['remote-port'] !== undefined ? Number(flags['remote-port']) : undefined,
|
|
601
|
+
localPort: flags['remote-port'] !== undefined ? Number(flags['remote-port']) : undefined,
|
|
602
|
+
})
|
|
603
|
+
for (const item of preflight) results.push(item)
|
|
604
|
+
|
|
605
|
+
// 4) 证书线上生效性(本机只能看到"线上发的那张")
|
|
606
|
+
const served = await servedCertificate(domain).catch(() => null)
|
|
607
|
+
if (served !== null) {
|
|
608
|
+
const days = Math.floor((served.expiresAt - Date.now()) / 86400000)
|
|
609
|
+
results.push({
|
|
610
|
+
name: '线上实际发出的证书',
|
|
611
|
+
ok: days > 7,
|
|
612
|
+
detail: 'notAfter=' + served.notAfter.toISOString().slice(0, 10) + '(剩 ' + String(days) + ' 天,SAN: ' + served.names + ')',
|
|
613
|
+
hint:
|
|
614
|
+
days > 7
|
|
615
|
+
? undefined
|
|
616
|
+
: '快到期/已过期:在服务器上对比磁盘证书与线上证书,并确认 renew 后 reload(deploy hook)',
|
|
617
|
+
})
|
|
618
|
+
if (typeof flags['expect-cert-sha256'] === 'string') {
|
|
619
|
+
const expected = flags['expect-cert-sha256'].replaceAll(':', '').toLowerCase()
|
|
620
|
+
const matches = served.sha256 !== '' && served.sha256 === expected
|
|
621
|
+
results.push({
|
|
622
|
+
name: '证书生效性(线上指纹 == 服务器磁盘指纹)',
|
|
623
|
+
ok: matches,
|
|
624
|
+
detail: matches
|
|
625
|
+
? '线上发出的正是服务器上那张证书(sha256 ' + served.sha256.slice(0, 16) + '…)'
|
|
626
|
+
: '线上 sha256 ' + (served.sha256 || '未知').slice(0, 16) + '… ≠ 期望 ' + expected.slice(0, 16) + '…',
|
|
627
|
+
hint: matches
|
|
628
|
+
? undefined
|
|
629
|
+
: 'nginx 仍在用内存里的旧证书:服务器上执行 nginx -t && systemctl reload nginx,并确认 /etc/letsencrypt/renewal-hooks/deploy/ 里装了 reload 钩子(安装脚本已默认装)',
|
|
630
|
+
})
|
|
631
|
+
}
|
|
632
|
+
if (typeof flags['expect-expiry'] === 'string') {
|
|
633
|
+
const expected = new Date(flags['expect-expiry'] + 'T00:00:00Z')
|
|
634
|
+
const matches = Math.abs(expected.getTime() - served.notAfter.getTime()) < 86400000
|
|
635
|
+
results.push({
|
|
636
|
+
name: '证书生效性(磁盘 vs 线上)',
|
|
637
|
+
ok: matches,
|
|
638
|
+
detail: matches ? '线上证书与预期到期日一致(' + flags['expect-expiry'] + ')' : '线上到期日 ' + served.notAfter.toISOString().slice(0, 10) + ' ≠ 预期 ' + flags['expect-expiry'],
|
|
639
|
+
hint: matches ? undefined : 'nginx 很可能还在用内存里的旧证书:服务器上执行 nginx -t && systemctl reload nginx,并补 /etc/letsencrypt/renewal-hooks/deploy/ 钩子',
|
|
640
|
+
})
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// 5) 服务器侧需要人跑的检查(Mac 读不到对端日志)
|
|
645
|
+
process.stdout.write('\n以下检查只能在服务器上跑(本机读不到对端的 /etc 与日志):\n')
|
|
646
|
+
process.stdout.write(' 证书生效性:echo | openssl s_client -connect 127.0.0.1:443 -servername ' + domain + ' 2>/dev/null | openssl x509 -noout -enddate \\\n')
|
|
647
|
+
process.stdout.write(' openssl x509 -in /etc/letsencrypt/live/<lineage>/fullchain.pem -noout -enddate # 两者必须一致\n')
|
|
648
|
+
process.stdout.write(' deploy 钩子:ls -l /etc/letsencrypt/renewal-hooks/deploy/ # 空 = 续签后不会 reload,线上会继续发旧证书\n')
|
|
649
|
+
process.stdout.write(' 日志泄漏: grep -c "k=" /var/log/nginx/dsh-remote.access.log # 期望 0\n')
|
|
650
|
+
process.stdout.write(' 本机泄漏: dsh-remote doctor --key <你的密钥> # 检查本机候选日志里有没有 ?k= 的值\n')
|
|
651
|
+
|
|
652
|
+
// 6) 本地侧:访问密钥不得出现在本机日志里
|
|
653
|
+
if (typeof flags.key === 'string' && flags.key !== '') {
|
|
654
|
+
const hits = defaultLogCandidates().filter((file) => {
|
|
655
|
+
try {
|
|
656
|
+
return fs.readFileSync(file, 'utf8').includes(flags.key)
|
|
657
|
+
} catch {
|
|
658
|
+
return false
|
|
659
|
+
}
|
|
660
|
+
})
|
|
661
|
+
results.push({
|
|
662
|
+
name: '本机日志未泄漏访问密钥',
|
|
663
|
+
ok: hits.length === 0,
|
|
664
|
+
detail: hits.length === 0 ? '本地候选日志中未出现 ?k= 的值' : '命中:' + hits.join(', '),
|
|
665
|
+
hint: hits.length === 0 ? undefined : '换一个新密钥(面板可一键重新生成),并检查是谁把密钥写进了日志',
|
|
666
|
+
})
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
if (flags.json === true) {
|
|
670
|
+
process.stdout.write(JSON.stringify({ ok: results.every((item) => item.ok), results }, null, 2) + '\n')
|
|
671
|
+
} else {
|
|
672
|
+
for (const result of results) printResult(result)
|
|
673
|
+
const failed = results.filter((item) => !item.ok)
|
|
674
|
+
process.stdout.write('\n' + String(results.length - failed.length) + '/' + String(results.length) + ' 项通过\n')
|
|
675
|
+
}
|
|
676
|
+
if (results.some((item) => !item.ok)) process.exit(1)
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/** 读取线上(443)实际发出的证书。 */
|
|
680
|
+
async function servedCertificate(domain) {
|
|
681
|
+
const tls = await import('node:tls')
|
|
682
|
+
return await new Promise((resolve, reject) => {
|
|
683
|
+
const socket = tls.connect({ host: domain, port: 443, servername: domain, rejectUnauthorized: false, timeout: 8000 }, () => {
|
|
684
|
+
const cert = socket.getPeerCertificate()
|
|
685
|
+
socket.end()
|
|
686
|
+
if (cert === null || Object.keys(cert).length === 0) {
|
|
687
|
+
reject(new Error('未拿到证书'))
|
|
688
|
+
return
|
|
689
|
+
}
|
|
690
|
+
resolve({
|
|
691
|
+
notAfter: new Date(cert.valid_to),
|
|
692
|
+
expiresAt: new Date(cert.valid_to).getTime(),
|
|
693
|
+
names: String(cert.subjectaltname ?? '').split(',').map((item) => item.trim()).join(' '),
|
|
694
|
+
sha256: String(cert.fingerprint256 ?? '').replaceAll(':', '').toLowerCase(),
|
|
695
|
+
})
|
|
696
|
+
})
|
|
697
|
+
socket.on('timeout', () => { socket.destroy(); reject(new Error('超时')) })
|
|
698
|
+
socket.on('error', reject)
|
|
699
|
+
})
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
function commandKeygen(flags) {
|
|
703
|
+
if (currentLocale() !== 'zh') noticeZhOnly()
|
|
704
|
+
const outPath =
|
|
705
|
+
typeof flags.out === 'string' ? flags.out : path.join(os.homedir(), '.ssh', 'dsh_remote_tunnel')
|
|
706
|
+
if (fs.existsSync(outPath) && flags.force !== true) {
|
|
707
|
+
fail(outPath + ' 已存在;确认要覆盖请加 --force')
|
|
708
|
+
}
|
|
709
|
+
fs.mkdirSync(path.dirname(outPath), { recursive: true })
|
|
710
|
+
const result = spawnSync('ssh-keygen', ['-t', 'ed25519', '-N', '', '-C', 'dsh-mac-tunnel', '-f', outPath], {
|
|
711
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
712
|
+
})
|
|
713
|
+
if (result.status !== 0) {
|
|
714
|
+
fail('ssh-keygen 失败:' + result.stderr.toString('utf8').trim())
|
|
715
|
+
}
|
|
716
|
+
const publicKey = fs.readFileSync(outPath + '.pub', 'utf8').trim()
|
|
717
|
+
const targetPort = flags['target-port'] !== undefined ? Number(flags['target-port']) : 8788
|
|
718
|
+
process.stdout.write('私钥:' + outPath + '\n公钥:' + outPath + '.pub\n\n')
|
|
719
|
+
process.stdout.write('把这一行贴到服务器专用账号的 ~/.ssh/authorized_keys:\n\n')
|
|
720
|
+
process.stdout.write(authorizedKeysLine(publicKey, targetPort) + '\n')
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
async function main() {
|
|
724
|
+
const raw = process.argv.slice(2)
|
|
725
|
+
// 命令词前面允许放全局选项(`dsh-remote --lang zh tenant list`):
|
|
726
|
+
// 逐个吃掉前导 `--flag [value]`,剩下的第一个词才是命令。
|
|
727
|
+
const leading = []
|
|
728
|
+
let argv = raw
|
|
729
|
+
while (argv.length > 0 && argv[0].startsWith('--')) {
|
|
730
|
+
const key = argv[0]
|
|
731
|
+
if (key === '--help' || key === '-h') {
|
|
732
|
+
leading.push(key)
|
|
733
|
+
argv = argv.slice(1)
|
|
734
|
+
continue
|
|
735
|
+
}
|
|
736
|
+
const next = argv[1]
|
|
737
|
+
const hasValue = next !== undefined && !next.startsWith('--')
|
|
738
|
+
leading.push(key, ...(hasValue ? [next] : []))
|
|
739
|
+
argv = argv.slice(hasValue ? 2 : 1)
|
|
740
|
+
}
|
|
741
|
+
const helpRequested = leading.includes('--help') || leading.includes('-h')
|
|
742
|
+
const command = argv[0] === undefined || helpRequested ? 'help' : argv[0]
|
|
743
|
+
const { flags, rest } = parseArgs(command === 'help' ? [...leading, ...argv] : [...leading, ...argv.slice(1)])
|
|
744
|
+
// --lang 优先于环境变量;未知取值退回环境推断(不因为写错语言就让命令失败)
|
|
745
|
+
const requested = normalizeLocale(flags.lang)
|
|
746
|
+
if (requested !== undefined) {
|
|
747
|
+
lang = requested
|
|
748
|
+
t = translator(lang)
|
|
749
|
+
}
|
|
750
|
+
if (command === 'help' || flags.help === true) {
|
|
751
|
+
process.stdout.write(helpText() + '\n')
|
|
752
|
+
return
|
|
753
|
+
}
|
|
754
|
+
if (command === 'serve') return await commandServe(flags)
|
|
755
|
+
if (command === 'check') return await commandCheck(flags)
|
|
756
|
+
if (command === 'snippets') return commandSnippets(flags)
|
|
757
|
+
if (command === 'keygen') return commandKeygen(flags)
|
|
758
|
+
if (command === 'setup-server') return commandSetupServer(flags)
|
|
759
|
+
if (command === 'uninstall-server') return commandSetupServer({ ...flags, uninstall: true })
|
|
760
|
+
if (command === 'doctor') return await commandDoctor(flags)
|
|
761
|
+
if (command === 'tenant' || command === 'tenants') return commandTenant(flags, rest)
|
|
762
|
+
if (command === 'credential' || command === 'creds') return commandCredential(flags)
|
|
763
|
+
fail(t('cli.error.unknownCommand', { command }) + '\n\n' + helpText())
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
main().catch((error) => {
|
|
767
|
+
fail(String(error?.stack ?? error))
|
|
768
|
+
})
|