@wwkit/sshproxy 1.0.1 → 1.0.3

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/README.md CHANGED
@@ -79,6 +79,26 @@ sshproxy debug # 全链路检查(验证代理是否可用)
79
79
  sshproxy help # 显示帮助
80
80
  ```
81
81
 
82
+ ### SSH 远程管理(ssh)
83
+
84
+ 代理之外,`sshproxy ssh ...` 提供基于同一连接配置的远程管理命令(`src/SshCommands.js`):
85
+
86
+ ```bash
87
+ sshproxy ssh connect # 测试 SSH 连接
88
+ sshproxy ssh exec <命令> # 执行远程命令
89
+ sshproxy ssh upload <本地> <远程> [-x] [--rm] # 上传(-x 自动解压 zip/rar,--rm 解压后删除压缩包)
90
+ sshproxy ssh download <远程> <本地> [-x] [--rm] # 下载(-x 自动解压)
91
+ sshproxy ssh rsync <本地目录> [-r 远程目录] [-d] [-n] # rsync 同步到远程
92
+ sshproxy ssh root # 显示远程 $HOME
93
+ sshproxy ssh login [-f] # 安装本地 SSH 公钥到远程(免密登录;-f 覆盖 authorized_keys)
94
+ sshproxy ssh open # 打开终端窗口并建立 SSH 连接
95
+ sshproxy ssh services [-a] [-f 名称] # 列出远程服务
96
+ sshproxy ssh port <端口> # 查看远程端口监听进程
97
+ sshproxy ssh config # 显示 SSH 配置
98
+ ```
99
+
100
+ 公钥探测/安装逻辑与初始化流程共用 `src/sshkey.js`。
101
+
82
102
  ## 本地开发调用示例
83
103
 
84
104
  在 monorepo 根目录或 `packages/sshproxy` 内均可运行。若未全局安装 `sshproxy`,用以下方式调用:
package/bin/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { ProxyManager } from '../src/ProxyManager.js'
4
+ import { SshCommands } from '../src/SshCommands.js'
4
5
  import { clientAddr, loadConfig } from '../src/config.js'
5
6
 
6
7
  const args = process.argv.slice(2)
@@ -15,6 +16,8 @@ async function main() {
15
16
  return handleProxy(sub)
16
17
  case 'chrome':
17
18
  return handleChrome(sub)
19
+ case 'ssh':
20
+ return handleSsh(args.slice(1))
18
21
  case 'start':
19
22
  return handleStart()
20
23
  case 'stop':
@@ -37,6 +40,140 @@ function printResult(result) {
37
40
  if (result.ok === false) process.exitCode = 1
38
41
  }
39
42
 
43
+ function parseOptions(tokens, flagDefs) {
44
+ const options = {}
45
+ const positional = []
46
+ for (let i = 0; i < tokens.length; i++) {
47
+ const t = tokens[i]
48
+ const flag = flagDefs.find((f) => f.flag === t || (f.short && f.short === t))
49
+ if (flag) {
50
+ if (flag.boolean) {
51
+ options[flag.name] = true
52
+ } else {
53
+ options[flag.name] = tokens[i + 1]
54
+ i++
55
+ }
56
+ } else {
57
+ positional.push(t)
58
+ }
59
+ }
60
+ return { options, positional }
61
+ }
62
+
63
+ async function handleSsh(rest) {
64
+ const sub = rest[0] ?? 'help'
65
+ const commands = new SshCommands()
66
+ switch (sub) {
67
+ case 'connect':
68
+ await commands.connect()
69
+ break
70
+ case 'exec':
71
+ await commands.exec(rest.slice(1))
72
+ break
73
+ case 'upload': {
74
+ const { options, positional } = parseOptions(rest.slice(1), [
75
+ { flag: '-x', name: 'extract', boolean: true },
76
+ { flag: '--rm', name: 'rm', boolean: true },
77
+ ])
78
+ await commands.upload(positional[0], positional[1], options)
79
+ break
80
+ }
81
+ case 'download': {
82
+ const { options, positional } = parseOptions(rest.slice(1), [
83
+ { flag: '-x', name: 'extract', boolean: true },
84
+ { flag: '--rm', name: 'rm', boolean: true },
85
+ ])
86
+ await commands.download(positional[0], positional[1], options)
87
+ break
88
+ }
89
+ case 'rsync': {
90
+ const { options, positional } = parseOptions(rest.slice(1), [
91
+ { flag: '-r', name: 'remote' },
92
+ { flag: '-d', name: 'delete', boolean: true },
93
+ { flag: '-n', name: 'dryRun', boolean: true },
94
+ { flag: '-t', name: 'timeout' },
95
+ ])
96
+ await commands.rsync(positional[0], options)
97
+ break
98
+ }
99
+ case 'root':
100
+ await commands.root()
101
+ break
102
+ case 'login': {
103
+ const { options } = parseOptions(rest.slice(1), [
104
+ { flag: '-f', name: 'force', boolean: true },
105
+ ])
106
+ await commands.login(options)
107
+ break
108
+ }
109
+ case 'open':
110
+ await commands.open()
111
+ break
112
+ case 'services': {
113
+ const { options } = parseOptions(rest.slice(1), [
114
+ { flag: '-a', name: 'all', boolean: true },
115
+ { flag: '-f', name: 'find' },
116
+ ])
117
+ await commands.services(options)
118
+ break
119
+ }
120
+ case 'port':
121
+ await commands.port(rest[1])
122
+ break
123
+ case 'config':
124
+ commands.config()
125
+ break
126
+ case 'help':
127
+ default:
128
+ printSshHelp()
129
+ }
130
+ }
131
+
132
+ function printSshHelp() {
133
+ console.log(`sshproxy ssh - SSH 命令
134
+
135
+ 用法:
136
+ sshproxy ssh connect 测试 SSH 连接
137
+ sshproxy ssh exec <命令> 执行远程命令
138
+ sshproxy ssh upload <本地> <远程> [选项] 上传文件到远程
139
+ -x, --extract 上传后自动解压(zip/rar)
140
+ --rm 解压后删除压缩包
141
+ sshproxy ssh download <远程> <本地> [选项] 从远程下载文件
142
+ -x, --extract 下载后自动解压(zip/rar)
143
+ --rm 解压后删除压缩包
144
+ sshproxy ssh rsync <本地目录> [选项] 通过 rsync 同步目录到远程
145
+ -r, --remote <目录> 远程目标目录(默认 /htdocs)
146
+ -d, --delete 镜像同步(删除远程多余文件)
147
+ -n, --dry-run 试运行(只显示将传输的文件)
148
+ -t, --timeout <秒> 连接超时(默认 30)
149
+ sshproxy ssh root 显示远程主目录($HOME)
150
+ sshproxy ssh login [选项] 安装本地公钥到远程实现免密登录
151
+ -f, --force 强制覆盖 authorized_keys
152
+ sshproxy ssh open 打开终端窗口并建立 SSH 连接
153
+ sshproxy ssh services [选项] 列出远程服务器运行中的服务
154
+ -a, --all 列出所有服务(不仅运行中)
155
+ -f, --find <名称> 按名称筛选服务
156
+ sshproxy ssh port <端口> 查看指定端口上的监听进程
157
+ sshproxy ssh config 显示 SSH 配置
158
+ sshproxy ssh help 显示本帮助
159
+
160
+ 示例:
161
+ sshproxy ssh exec ls -la /root
162
+ sshproxy ssh upload ./config.py /root/config.py
163
+ sshproxy ssh download /root/log/app.log ./app.log
164
+ sshproxy ssh upload ./app.zip /root/app.zip -x --rm
165
+ sshproxy ssh download /root/backup.zip ./backup.zip -x
166
+ sshproxy ssh rsync ./webwork/
167
+ sshproxy ssh rsync ./webwork/ -r /htdocs -d
168
+ sshproxy ssh rsync ./webwork/ -r /htdocs -n
169
+ sshproxy ssh services
170
+ sshproxy ssh services -f nginx
171
+ sshproxy ssh port 80
172
+ sshproxy ssh login
173
+ sshproxy ssh open
174
+ `)
175
+ }
176
+
40
177
  async function handleTunnel(sub) {
41
178
  const manager = new ProxyManager()
42
179
  if (sub === 'off') return printResult(await manager.tunnelStop())
@@ -159,6 +296,7 @@ function printHelp() {
159
296
  状态与工具:
160
297
  sshproxy status 查看全部状态
161
298
  sshproxy debug 全链路检查(验证代理是否可用)
299
+ sshproxy ssh SSH 远程管理命令(connect/exec/upload/...)
162
300
  sshproxy web 启动 Web 管理页(后台,默认 http://127.0.0.1:1081)
163
301
  sshproxy web on 启动 Web 管理页
164
302
  sshproxy web off 关闭 Web 管理页
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wwkit/sshproxy",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "author": "bluesliu <langcai163@163.com>",
5
5
  "description": "SOCKS5 proxy manager (client SSH tunnel / server remote), CLI + web UI",
6
6
  "type": "module",
@@ -37,7 +37,7 @@
37
37
  "dotenv": "^16.4.5",
38
38
  "json5": "^2.2.3",
39
39
  "ssh2": "^1.16.0",
40
- "@wwkit/shared": "1.0.1"
40
+ "@wwkit/shared": "1.0.3"
41
41
  },
42
42
  "devDependencies": {
43
43
  "jest": "^29.7.0"
@@ -1,11 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ import { spawnSync } from 'node:child_process'
3
4
  import path from 'node:path'
4
5
  import { fileURLToPath } from 'node:url'
5
6
  import { createConfigLoader, getXdgConfigDir } from '@wwkit/shared'
6
7
 
7
8
  const __dirname = path.dirname(fileURLToPath(import.meta.url))
8
9
  const src = path.join(__dirname, '..', 'src', 'config.json5')
10
+ const bin = path.join(__dirname, '..', 'bin', 'index.js')
9
11
 
10
12
  const loader = createConfigLoader({
11
13
  builtinConfigPath: src,
@@ -15,4 +17,16 @@ const loader = createConfigLoader({
15
17
 
16
18
  if (loader.copyBuiltinConfig()) {
17
19
  console.log(`sshproxy: wrote default config to ${loader.userConfigPath}`)
20
+ }
21
+
22
+ const tunnel = spawnSync('node', [bin, 'tunnel', 'on'], { stdio: 'inherit' })
23
+ if (tunnel.status !== 0) {
24
+ console.error('sshproxy: postinstall (tunnel on) failed')
25
+ }
26
+
27
+ spawnSync('node', [bin, 'web', 'off'], { stdio: 'inherit' })
28
+
29
+ const web = spawnSync('node', [bin, 'web', 'on'], { stdio: 'inherit' })
30
+ if (web.status !== 0) {
31
+ console.error('sshproxy: postinstall (web on) failed')
18
32
  }
@@ -0,0 +1,275 @@
1
+ import fs from 'node:fs'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+ import { spawnSync } from 'node:child_process'
5
+
6
+ import { loadConfig } from './config.js'
7
+ import { SshClient } from './SshClient.js'
8
+ import { which } from './OSystem.js'
9
+ import { ensureLocalKey, installPubkeyCommand } from './sshkey.js'
10
+
11
+ function requireConnection(client) {
12
+ if (client.checkConnection()) return true
13
+ console.log('连接失败,请检查 SSH 配置或服务器状态')
14
+ return false
15
+ }
16
+
17
+ function extractTool(p) {
18
+ const lower = String(p).toLowerCase()
19
+ if (lower.endsWith('.zip')) return { name: 'unzip', command: 'unzip -o' }
20
+ if (lower.endsWith('.rar')) return { name: 'unrar', command: 'unrar x -o+' }
21
+ return null
22
+ }
23
+
24
+ function remoteHasTool(client, name) {
25
+ const [code, out] = client.execute(`command -v ${name}`)
26
+ return code === 0 && Boolean(out.trim())
27
+ }
28
+
29
+ function printMissingTool(name, remote) {
30
+ const tips = {
31
+ unzip: 'brew install unzip(macOS)或 apt-get install -y unzip(Linux)',
32
+ unrar: 'brew install unrar(macOS)或 apt-get install -y unrar(Linux)',
33
+ }
34
+ const where = remote ? '远程服务器' : '本地'
35
+ console.log(`${where}缺少解压命令: ${name}`)
36
+ console.log(`请先安装: ${tips[name] || `请安装 ${name}`}`)
37
+ console.log('已停止解压操作')
38
+ }
39
+
40
+ export class SshCommands {
41
+ constructor() {
42
+ this.client = new SshClient()
43
+ }
44
+
45
+ config() {
46
+ const cfg = loadConfig()
47
+ const { host, port, user, timeout } = cfg
48
+ console.log(JSON.stringify({ host, port, user, timeout }, null, 2))
49
+ }
50
+
51
+ async connect() {
52
+ const client = this.client
53
+ if (requireConnection(client)) console.log('连接成功')
54
+ }
55
+
56
+ async exec(cmdParts) {
57
+ const client = this.client
58
+ if (!requireConnection(client)) return
59
+ const command = cmdParts.join(' ')
60
+ const [code, out, err] = await client.execute(command)
61
+ if (out) console.log(out.endsWith('\n') ? out : out + '\n')
62
+ if (err) console.log(err.endsWith('\n') ? err : err + '\n')
63
+ if (code !== 0) console.log(`命令执行失败 (exit=${code})`)
64
+ else console.log(`OK (exit=${code})`)
65
+ }
66
+
67
+ async root() {
68
+ const client = this.client
69
+ if (!requireConnection(client)) return
70
+ const [code, out, err] = await client.execute('echo $HOME')
71
+ const home = out.trim()
72
+ if (code === 0 && home) console.log(home)
73
+ else console.log(`获取失败: ${err.trim()}`)
74
+ }
75
+
76
+ async upload(local, remote, { extract = false, rm = false } = {}) {
77
+ const client = this.client
78
+ if (!requireConnection(client)) return
79
+ const ok = await client.upload(local, remote)
80
+ if (!ok) {
81
+ console.log(`上传失败:${local} -> ${remote}`)
82
+ return
83
+ }
84
+ console.log(`上传成功:${local} -> ${remote}`)
85
+ if (!extract && !rm) return
86
+ const tool = extractTool(remote)
87
+ if (!tool) {
88
+ console.log('不是 zip/rar 压缩文件,忽略 -x/--rm')
89
+ return
90
+ }
91
+ if (!remoteHasTool(client, tool.name)) {
92
+ printMissingTool(tool.name, true)
93
+ return
94
+ }
95
+ const remoteDir = path.posix.dirname(remote) || '.'
96
+ const filename = path.posix.basename(remote)
97
+ const [code, , err] = await client.execute(`cd ${remoteDir} && ${tool.command} ${filename}`)
98
+ if (code !== 0) {
99
+ console.log(`解压失败: ${(err || '').trim()}`)
100
+ return
101
+ }
102
+ if (rm) await client.execute(`rm -f ${remote}`)
103
+ console.log(`解压完成,结果目录: ${remoteDir}`)
104
+ }
105
+
106
+ async download(remote, local, { extract = false, rm = false } = {}) {
107
+ const client = this.client
108
+ if (!requireConnection(client)) return
109
+ const ok = await client.download(remote, local)
110
+ if (!ok) {
111
+ console.log(`下载失败:${remote} -> ${local}`)
112
+ return
113
+ }
114
+ console.log(`下载成功:${remote} -> ${local}`)
115
+ if (!extract && !rm) return
116
+ const tool = extractTool(local)
117
+ if (!tool) {
118
+ console.log('不是 zip/rar 压缩文件,忽略 -x/--rm')
119
+ return
120
+ }
121
+ if (!which(tool.name)) {
122
+ printMissingTool(tool.name, false)
123
+ return
124
+ }
125
+ const localDir = path.dirname(path.resolve(local))
126
+ const result = spawnSync(tool.command.split(' ')[0], [...tool.command.split(' ').slice(1), local, '-d', localDir], { encoding: 'utf-8' })
127
+ if (result.status !== 0) {
128
+ console.log(`解压失败: ${(result.stderr || result.stdout || '').trim()}`)
129
+ return
130
+ }
131
+ if (rm) fs.unlinkSync(local)
132
+ console.log(`解压完成,结果目录: ${localDir}`)
133
+ }
134
+
135
+ async rsync(local, { remote = '/htdocs', delete: del = false, dryRun = false, timeout = 30 } = {}) {
136
+ if (!which('rsync')) {
137
+ console.log('未检测到本地 rsync 命令')
138
+ const system = os.type()
139
+ if (system === 'Darwin') console.log('请先安装 rsync: brew install rsync')
140
+ else console.log('请先安装 rsync: apt-get install -y rsync 或 yum install -y rsync')
141
+ return
142
+ }
143
+ if (!local) {
144
+ console.log('缺少本地目录参数')
145
+ return
146
+ }
147
+ const cfg = loadConfig()
148
+ const host = cfg.host || ''
149
+ if (!host) {
150
+ console.log('SSH config incomplete (host required).')
151
+ return
152
+ }
153
+ const user = cfg.user || 'root'
154
+ const port = cfg.port || 22
155
+
156
+ if (!fs.existsSync(local) || !fs.statSync(local).isDirectory()) {
157
+ console.log(`本地目录不存在: ${local}`)
158
+ return
159
+ }
160
+ let localDir = local
161
+ if (!localDir.endsWith('/')) localDir += '/'
162
+
163
+ const [code, out] = await this.client.execute('command -v rsync')
164
+ if (code !== 0 || !out.trim()) {
165
+ console.log('远程服务器缺少 rsync 命令')
166
+ console.log('请先在远程服务器安装 rsync: apt-get install -y rsync 或 yum install -y rsync')
167
+ console.log('已停止同步操作')
168
+ return
169
+ }
170
+
171
+ const sshCmd = `ssh -p ${port} -o ConnectTimeout=${timeout}`
172
+ const command = ['rsync', '-avz', '--progress', `--rsh=${sshCmd}`]
173
+ if (del) command.push('--delete')
174
+ if (dryRun) command.push('--dry-run')
175
+ command.push(localDir)
176
+ command.push(`${user}@${host}:${remote}/`)
177
+
178
+ console.log(`rsync: ${command.join(' ')}`)
179
+ if (dryRun) console.log('[dry-run] 仅显示将传输的文件,不实际执行')
180
+
181
+ const result = spawnSync(command[0], command.slice(1), { encoding: 'utf-8', stdio: 'inherit' })
182
+ if (result.status !== 0) console.log(`rsync 失败 (exit=${result.status})`)
183
+ else console.log(`同步完成: ${localDir} -> ${user}@${host}:${remote}/`)
184
+ }
185
+
186
+ async login({ force = false } = {}) {
187
+ const pubKey = ensureLocalKey()
188
+ if (!pubKey) {
189
+ console.log('生成密钥失败,请手动执行: ssh-keygen -t ed25519')
190
+ return
191
+ }
192
+ const client = this.client
193
+ if (!requireConnection(client)) return
194
+ const command = installPubkeyCommand(pubKey, { force })
195
+ const [code, , err] = await client.execute(command)
196
+ if (code !== 0) {
197
+ console.log(`安装公钥失败: ${(err || '').trim()}`)
198
+ return
199
+ }
200
+ const [vCode, vOut] = await client.execute('echo ok')
201
+ if (vCode === 0 && vOut.trim() === 'ok') {
202
+ const cfg = loadConfig()
203
+ console.log(`免密登录配置成功,可直接执行 ssh ${cfg.user || 'root'}@${cfg.host || '<host>'}`)
204
+ } else {
205
+ console.log('公钥已安装,但免密验证未通过')
206
+ }
207
+ }
208
+
209
+ async open() {
210
+ const cfg = loadConfig()
211
+ const host = cfg.host || ''
212
+ if (!host) {
213
+ console.log('SSH config incomplete (host required).')
214
+ return
215
+ }
216
+ const user = cfg.user || 'root'
217
+ const port = cfg.port || 22
218
+ const command = `ssh ${user}@${host} -p ${port}`
219
+ const system = os.type()
220
+ try {
221
+ if (system === 'Darwin') {
222
+ spawnSync('osascript', ['-e', `tell application "Terminal" to do script "${command}"`], { stdio: 'ignore' })
223
+ } else if (system === 'Windows_NT') {
224
+ spawnSync('cmd', ['/c', 'start', 'cmd', '/k', command], { stdio: 'ignore' })
225
+ } else {
226
+ spawnSync('gnome-terminal', ['--', 'bash', '-c', `${command}; exec bash`], { stdio: 'ignore' })
227
+ }
228
+ console.log(`已在终端打开 SSH 连接: ${command}`)
229
+ } catch (e) {
230
+ console.log(`打开终端失败: ${e.message}`)
231
+ }
232
+ }
233
+
234
+ async services({ all = false, find = '' } = {}) {
235
+ const client = this.client
236
+ if (!requireConnection(client)) return
237
+ const state = all ? '--all' : '--state=running'
238
+ let command = `systemctl list-units --type=service ${state} --no-pager --no-legend`
239
+ if (find) command += ` | grep ${find} || true`
240
+ const [code, out, err] = await client.execute(command)
241
+ if (code !== 0) {
242
+ console.log(`获取服务列表失败: ${(err || out || '').trim()}`)
243
+ return
244
+ }
245
+ const lines = out.split('\n').map((l) => l.trim()).filter(Boolean)
246
+ if (lines.length === 0) {
247
+ console.log('未找到匹配的服务')
248
+ return
249
+ }
250
+ console.log('UNIT\tLOAD\tACTIVE\tSUB\tDESCRIPTION')
251
+ for (const line of lines) {
252
+ const parts = line.split(/\s+/, 5)
253
+ if (parts.length >= 5) console.log(`${parts[0]}\t${parts[1]}\t${parts[2]}\t${parts[3]}\t${parts[4]}`)
254
+ else console.log(line)
255
+ }
256
+ }
257
+
258
+ async port(number) {
259
+ const client = this.client
260
+ if (!requireConnection(client)) return
261
+ const command = `ss -tlnp | grep ":${number} " || true`
262
+ const [code, out, err] = await client.execute(command)
263
+ if (code !== 0) {
264
+ console.log(`获取端口信息失败: ${(err || out || '').trim()}`)
265
+ return
266
+ }
267
+ const lines = out.split('\n').map((l) => l.trim()).filter(Boolean)
268
+ if (lines.length === 0) {
269
+ console.log(`未发现监听 ${number} 端口的进程`)
270
+ return
271
+ }
272
+ console.log(`端口 ${number} 的监听进程:`)
273
+ for (const line of lines) console.log(line)
274
+ }
275
+ }
package/src/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export { ProxyManager } from './ProxyManager.js'
2
2
  export { SshClient } from './SshClient.js'
3
+ export { SshCommands } from './SshCommands.js'
3
4
  export { loadConfig, defaultConfig, getConfigPath, getConfigDir, getProxyConfig, clientAddr, sshAlias } from './config.js'
4
5
  export { initClient } from './init.js'
package/src/init.js CHANGED
@@ -1,30 +1,10 @@
1
1
  import fs from 'node:fs'
2
- import path from 'node:path'
3
2
  import { spawnSync } from 'node:child_process'
4
3
 
5
4
  import { clientAddr, getResolvedStaticConfig, loadConfig, sshAlias } from './config.js'
6
5
  import { getOsType, which } from './OSystem.js'
7
6
  import { SshClient } from './SshClient.js'
8
-
9
- function ensureLocalKey() {
10
- const s = getResolvedStaticConfig()
11
- const sshDir = s.paths.sshDir
12
- for (const name of s.ssh.keyCandidates) {
13
- const pubPath = path.join(sshDir, `${name}.pub`)
14
- if (fs.existsSync(pubPath)) {
15
- return fs.readFileSync(pubPath, 'utf-8').trim()
16
- }
17
- }
18
- const keyPath = path.join(sshDir, s.ssh.defaultKeyName)
19
- fs.mkdirSync(sshDir, { recursive: true })
20
- const result = spawnSync('ssh-keygen', ['-t', 'ed25519', '-C', s.ssh.keygenComment, '-N', '', '-f', keyPath], { encoding: 'utf-8' })
21
- if (result.status !== 0) return null
22
- const pubPath = `${keyPath}.pub`
23
- if (fs.existsSync(pubPath)) {
24
- return fs.readFileSync(pubPath, 'utf-8').trim()
25
- }
26
- return null
27
- }
7
+ import { ensureLocalKey, installPubkeyCommand } from './sshkey.js'
28
8
 
29
9
  function writeSshConfig(host, user, port, alias, localPort) {
30
10
  const s = getResolvedStaticConfig()
@@ -110,11 +90,7 @@ export async function initClient() {
110
90
  lines.push(` ✓ SSH 连接正常 (${host})`)
111
91
 
112
92
  lines.push('[3/5] 安装公钥到远程服务器...')
113
- const command = (
114
- `mkdir -p ~/.ssh && chmod 700 ~/.ssh && `
115
- + `grep -qF '${pubKey}' ~/.ssh/authorized_keys 2>/dev/null || `
116
- + `echo '${pubKey}' >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys`
117
- )
93
+ const command = installPubkeyCommand(pubKey)
118
94
  const [code, , err] = await client.execute(command)
119
95
  if (code !== 0) {
120
96
  lines.push(` ✗ 安装公钥失败: ${(err ?? '').trim()}`)
package/src/sshkey.js ADDED
@@ -0,0 +1,43 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { spawnSync } from 'node:child_process'
4
+
5
+ import { getResolvedStaticConfig } from './config.js'
6
+
7
+ export function ensureLocalKey() {
8
+ const s = getResolvedStaticConfig()
9
+ const sshDir = s.paths.sshDir
10
+ for (const name of s.ssh.keyCandidates) {
11
+ const pubPath = path.join(sshDir, `${name}.pub`)
12
+ if (fs.existsSync(pubPath)) {
13
+ return fs.readFileSync(pubPath, 'utf-8').trim()
14
+ }
15
+ }
16
+ const keyPath = path.join(sshDir, s.ssh.defaultKeyName)
17
+ fs.mkdirSync(sshDir, { recursive: true })
18
+ const result = spawnSync(
19
+ 'ssh-keygen',
20
+ ['-t', 'ed25519', '-C', s.ssh.keygenComment, '-N', '', '-f', keyPath],
21
+ { encoding: 'utf-8' }
22
+ )
23
+ if (result.status !== 0) return null
24
+ const pubPath = `${keyPath}.pub`
25
+ if (fs.existsSync(pubPath)) {
26
+ return fs.readFileSync(pubPath, 'utf-8').trim()
27
+ }
28
+ return null
29
+ }
30
+
31
+ export function installPubkeyCommand(pubKey, { force = false } = {}) {
32
+ if (force) {
33
+ return (
34
+ `mkdir -p ~/.ssh && chmod 700 ~/.ssh && `
35
+ + `echo '${pubKey}' > ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys`
36
+ )
37
+ }
38
+ return (
39
+ `mkdir -p ~/.ssh && chmod 700 ~/.ssh && `
40
+ + `grep -qF '${pubKey}' ~/.ssh/authorized_keys 2>/dev/null || `
41
+ + `echo '${pubKey}' >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys`
42
+ )
43
+ }