@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 +20 -0
- package/bin/index.js +138 -0
- package/package.json +2 -2
- package/scripts/postinstall.js +14 -0
- package/src/SshCommands.js +275 -0
- package/src/index.js +1 -0
- package/src/init.js +2 -26
- package/src/sshkey.js +43 -0
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.
|
|
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.
|
|
40
|
+
"@wwkit/shared": "1.0.3"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
|
43
43
|
"jest": "^29.7.0"
|
package/scripts/postinstall.js
CHANGED
|
@@ -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
|
+
}
|