gssh-agent 1.0.4 → 1.0.5
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/.github/workflows/ci.yml +27 -0
- package/.github/workflows/publish.yml +104 -0
- package/README.md +31 -61
- package/bin/gssh +0 -0
- package/cmd/gssh/main.go +6 -1
- package/fix_manager.patch +79 -0
- package/internal/client/ssh.go +186 -3
- package/internal/client/ssh_test.go +43 -0
- package/internal/portforward/forwarder.go +94 -40
- package/internal/protocol/types.go +3 -1
- package/internal/session/manager.go +154 -39
- package/internal/session/manager_test.go +324 -0
- package/package.json +3 -4
- package/pkg/rpc/handler.go +1 -1
- package/pkg/rpc/handler_test.go +36 -0
- package/plan.md +4 -0
- package/skill.md +34 -8
- package/bin/daemon +0 -0
- package/bin/gssh-daemon +0 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches:
|
|
6
|
+
- '**'
|
|
7
|
+
pull_request:
|
|
8
|
+
branches:
|
|
9
|
+
- main
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
test:
|
|
13
|
+
runs-on: ubuntu-latest
|
|
14
|
+
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v4
|
|
17
|
+
|
|
18
|
+
- name: Set up Go
|
|
19
|
+
uses: actions/setup-go@v5
|
|
20
|
+
with:
|
|
21
|
+
go-version: '1.21'
|
|
22
|
+
|
|
23
|
+
- name: Build
|
|
24
|
+
run: go build ./...
|
|
25
|
+
|
|
26
|
+
- name: Test
|
|
27
|
+
run: go test ./...
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
name: Publish
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches:
|
|
6
|
+
- main
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
# npm 发布
|
|
10
|
+
publish-npm:
|
|
11
|
+
if: "contains(github.event.head_commit.message, 'publish')"
|
|
12
|
+
runs-on: ubuntu-latest
|
|
13
|
+
permissions:
|
|
14
|
+
contents: read
|
|
15
|
+
id-token: write
|
|
16
|
+
steps:
|
|
17
|
+
- uses: actions/checkout@v4
|
|
18
|
+
|
|
19
|
+
- name: Set up Go
|
|
20
|
+
uses: actions/setup-go@v5
|
|
21
|
+
with:
|
|
22
|
+
go-version: '1.21'
|
|
23
|
+
|
|
24
|
+
- name: Get version
|
|
25
|
+
id: version
|
|
26
|
+
run: |
|
|
27
|
+
VERSION=$(echo "${{ github.event.head_commit.message }}" | sed -n 's/.*publish[: ]*\(.*\)/\1/p')
|
|
28
|
+
echo "VERSION=$VERSION" >> $GITHUB_OUTPUT
|
|
29
|
+
echo "Publishing version: $VERSION"
|
|
30
|
+
|
|
31
|
+
- name: Build
|
|
32
|
+
run: |
|
|
33
|
+
go build -ldflags="-s -w" -o bin/gssh ./cmd/gssh
|
|
34
|
+
|
|
35
|
+
- name: Update package.json version
|
|
36
|
+
run: |
|
|
37
|
+
VERSION="${{ steps.version.outputs.VERSION }}"
|
|
38
|
+
npm version $VERSION --no-git-tag-version
|
|
39
|
+
|
|
40
|
+
- name: Build npm package
|
|
41
|
+
run: npm pack
|
|
42
|
+
|
|
43
|
+
- name: Setup Node
|
|
44
|
+
uses: actions/setup-node@v4
|
|
45
|
+
with:
|
|
46
|
+
node-version: '20'
|
|
47
|
+
|
|
48
|
+
- name: Configure npm
|
|
49
|
+
run: |
|
|
50
|
+
echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" > .npmrc
|
|
51
|
+
cat .npmrc
|
|
52
|
+
|
|
53
|
+
- name: Publish to npm
|
|
54
|
+
run: npm publish --access public
|
|
55
|
+
|
|
56
|
+
# GitHub Releases 发布
|
|
57
|
+
publish-github:
|
|
58
|
+
if: "startsWith(github.ref, 'refs/tags/v')"
|
|
59
|
+
runs-on: ubuntu-latest
|
|
60
|
+
permissions:
|
|
61
|
+
contents: write
|
|
62
|
+
|
|
63
|
+
strategy:
|
|
64
|
+
matrix:
|
|
65
|
+
include:
|
|
66
|
+
- goos: linux
|
|
67
|
+
goarch: amd64
|
|
68
|
+
- goos: linux
|
|
69
|
+
goarch: arm64
|
|
70
|
+
- goos: darwin
|
|
71
|
+
goarch: amd64
|
|
72
|
+
- goos: darwin
|
|
73
|
+
goarch: arm64
|
|
74
|
+
- goos: windows
|
|
75
|
+
goarch: amd64
|
|
76
|
+
|
|
77
|
+
steps:
|
|
78
|
+
- uses: actions/checkout@v4
|
|
79
|
+
|
|
80
|
+
- name: Set up Go
|
|
81
|
+
uses: actions/setup-go@v5
|
|
82
|
+
with:
|
|
83
|
+
go-version: '1.21'
|
|
84
|
+
|
|
85
|
+
- name: Build
|
|
86
|
+
env:
|
|
87
|
+
GOOS: ${{ matrix.goos }}
|
|
88
|
+
GOARCH: ${{ matrix.goarch }}
|
|
89
|
+
run: |
|
|
90
|
+
EXT=""
|
|
91
|
+
if [ "$GOOS" = "windows" ]; then
|
|
92
|
+
EXT=".exe"
|
|
93
|
+
fi
|
|
94
|
+
FILENAME="gssh-${GOOS}-${GOARCH}${EXT}"
|
|
95
|
+
go build -ldflags="-s -w" -o "$FILENAME" ./cmd/gssh
|
|
96
|
+
echo "$FILENAME" >> artifacts.txt
|
|
97
|
+
|
|
98
|
+
- name: Upload Release Asset
|
|
99
|
+
uses: softprops/action-gh-release@v2
|
|
100
|
+
with:
|
|
101
|
+
tag_name: ${{ github.ref }}
|
|
102
|
+
files: ${{ matrix.goos }}-${{ matrix.goarch }}*
|
|
103
|
+
env:
|
|
104
|
+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
package/README.md
CHANGED
|
@@ -14,22 +14,13 @@ gssh 是一个供 Agent 使用的 SSH Session 管理工具。通过 Go 语言实
|
|
|
14
14
|
|
|
15
15
|
## 安装
|
|
16
16
|
|
|
17
|
-
###
|
|
17
|
+
### 使用 npm(推荐)
|
|
18
18
|
|
|
19
19
|
```bash
|
|
20
|
-
|
|
21
|
-
# brew tap forechoandlook/gssh
|
|
22
|
-
# brew install gssh
|
|
23
|
-
|
|
24
|
-
# 暂时使用手动安装方式
|
|
25
|
-
git clone https://github.com/forechoandlook/gssh.git
|
|
26
|
-
cd gssh
|
|
27
|
-
go build -o bin/daemon cmd/daemon/main.go
|
|
28
|
-
go build -o bin/gssh cmd/gssh/main.go
|
|
29
|
-
./homebrew/install.sh
|
|
20
|
+
npm install -g gssh-agent
|
|
30
21
|
```
|
|
31
22
|
|
|
32
|
-
###
|
|
23
|
+
### 手动安装
|
|
33
24
|
|
|
34
25
|
```bash
|
|
35
26
|
# 克隆项目
|
|
@@ -45,53 +36,11 @@ cp bin/daemon /usr/local/bin/gssh-daemon
|
|
|
45
36
|
cp bin/gssh /usr/local/bin/gssh
|
|
46
37
|
```
|
|
47
38
|
|
|
48
|
-
##
|
|
49
|
-
|
|
50
|
-
### 使用 launchctl
|
|
39
|
+
## 启动服务
|
|
51
40
|
|
|
52
41
|
```bash
|
|
53
|
-
#
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
# 停止服务
|
|
57
|
-
launchctl stop com.gssh.daemon
|
|
58
|
-
|
|
59
|
-
# 查看状态
|
|
60
|
-
launchctl list | grep gssh
|
|
61
|
-
|
|
62
|
-
# 卸载服务
|
|
63
|
-
launchctl unload ~/Library/LaunchAgents/gssh.plist
|
|
64
|
-
rm ~/Library/LaunchAgents/gssh.plist
|
|
65
|
-
```
|
|
66
|
-
|
|
67
|
-
### 使用 Homebrew services
|
|
68
|
-
|
|
69
|
-
```bash
|
|
70
|
-
# 启动(首次安装后自动启动)
|
|
71
|
-
brew services start gssh
|
|
72
|
-
|
|
73
|
-
# 停止
|
|
74
|
-
brew services stop gssh
|
|
75
|
-
|
|
76
|
-
# 查看状态
|
|
77
|
-
brew services list
|
|
78
|
-
|
|
79
|
-
# 重启
|
|
80
|
-
brew services restart gssh
|
|
81
|
-
```
|
|
82
|
-
|
|
83
|
-
### 使用 PM2(跨平台)
|
|
84
|
-
|
|
85
|
-
```bash
|
|
86
|
-
# 安装 PM2
|
|
87
|
-
npm install -g pm2
|
|
88
|
-
|
|
89
|
-
# 启动 daemon
|
|
90
|
-
pm2 start gssh-daemon.sh --name gssh
|
|
91
|
-
|
|
92
|
-
# 保存并设置开机自启
|
|
93
|
-
pm2 save
|
|
94
|
-
pm2 startup
|
|
42
|
+
# 启动 gssh daemon
|
|
43
|
+
gssh-daemon
|
|
95
44
|
```
|
|
96
45
|
|
|
97
46
|
## 使用方法
|
|
@@ -99,7 +48,7 @@ pm2 startup
|
|
|
99
48
|
### 连接 SSH(密码认证)
|
|
100
49
|
|
|
101
50
|
```bash
|
|
102
|
-
gssh connect -u admin1 -h
|
|
51
|
+
gssh connect -u admin1 -h xxxx -p xxxx -P xxxxx
|
|
103
52
|
```
|
|
104
53
|
|
|
105
54
|
### 连接 SSH(密钥认证)
|
|
@@ -116,6 +65,12 @@ gssh exec "ls -la"
|
|
|
116
65
|
|
|
117
66
|
# 指定 session
|
|
118
67
|
gssh exec -s <session_id> "pwd"
|
|
68
|
+
|
|
69
|
+
# 带超时命令
|
|
70
|
+
gssh exec -t 10 "ls -la"
|
|
71
|
+
|
|
72
|
+
# sudo 命令
|
|
73
|
+
gssh exec -S password "sudo systemctl restart nginx"
|
|
119
74
|
```
|
|
120
75
|
|
|
121
76
|
### 端口转发
|
|
@@ -123,16 +78,27 @@ gssh exec -s <session_id> "pwd"
|
|
|
123
78
|
```bash
|
|
124
79
|
# 本地端口转发:本地 8080 -> 远程 80
|
|
125
80
|
gssh forward -l 8080 -r 80
|
|
81
|
+
|
|
82
|
+
# 远程端口转发:远程 9000 -> 本地 3000
|
|
83
|
+
gssh forward -R -l 9000 -r 3000
|
|
84
|
+
|
|
85
|
+
# 列出所有端口转发
|
|
86
|
+
gssh forwards
|
|
87
|
+
|
|
88
|
+
# 关闭端口转发
|
|
89
|
+
gssh forward-close <forward_id>
|
|
126
90
|
```
|
|
127
91
|
|
|
128
|
-
###
|
|
92
|
+
### 文件传输与同步(SFTP/SCP/SYNC)
|
|
129
93
|
|
|
130
94
|
```bash
|
|
131
|
-
#
|
|
95
|
+
# 上传文件或文件夹(本地 -> 远程)
|
|
132
96
|
gssh scp -put /path/to/local/file.txt /path/to/remote/file.txt
|
|
97
|
+
gssh sync -put /path/to/local/dir /path/to/remote/dir
|
|
133
98
|
|
|
134
|
-
#
|
|
99
|
+
# 下载文件或文件夹(远程 -> 本地)
|
|
135
100
|
gssh scp -get /path/to/remote/file.txt /path/to/local/file.txt
|
|
101
|
+
gssh sync -get /path/to/remote/dir /path/to/local/dir
|
|
136
102
|
|
|
137
103
|
# 列出远程目录
|
|
138
104
|
gssh sftp -c ls -p /path/to/remote/dir
|
|
@@ -178,6 +144,10 @@ gssh reconnect -s <session_id>
|
|
|
178
144
|
| `-get` | 下载模式(远程 -> 本地) |
|
|
179
145
|
| `-c command` | SFTP 命令(ls/mkdir/rm) |
|
|
180
146
|
| `-p path` | SFTP 路径 |
|
|
147
|
+
| `-t timeout` | 命令超时时间(秒) |
|
|
148
|
+
| `-S password` | sudo 密码 |
|
|
149
|
+
| `--ask-pass` | 交互输入 SSH 密码 |
|
|
150
|
+
| `--ask-sudo-pass` | 交互输入 sudo 密码 |
|
|
181
151
|
|
|
182
152
|
## 开发
|
|
183
153
|
|
package/bin/gssh
CHANGED
|
Binary file
|
package/cmd/gssh/main.go
CHANGED
|
@@ -73,7 +73,7 @@ func main() {
|
|
|
73
73
|
err = handleForwards(socketPath)
|
|
74
74
|
case "forward-close":
|
|
75
75
|
err = handleForwardClose(subArgs, socketPath)
|
|
76
|
-
case "scp":
|
|
76
|
+
case "scp", "sync":
|
|
77
77
|
err = handleSCP(subArgs, socketPath)
|
|
78
78
|
case "sftp":
|
|
79
79
|
err = handleSFTP(subArgs, socketPath)
|
|
@@ -309,6 +309,7 @@ func handleExec(args []string, socketPath string) error {
|
|
|
309
309
|
fs.SetOutput(os.Stderr)
|
|
310
310
|
|
|
311
311
|
sessionID := fs.String("s", "", "Session ID")
|
|
312
|
+
timeout := fs.Int("t", 0, "Timeout in seconds (0 = no timeout)")
|
|
312
313
|
sudoPassword := fs.String("S", "", "sudo password")
|
|
313
314
|
askSudoPassword := fs.Bool("ask-sudo-pass", false, "Interactively ask for sudo password")
|
|
314
315
|
|
|
@@ -334,6 +335,7 @@ func handleExec(args []string, socketPath string) error {
|
|
|
334
335
|
params := protocol.ExecParams{
|
|
335
336
|
SessionID: *sessionID,
|
|
336
337
|
Command: command,
|
|
338
|
+
Timeout: *timeout,
|
|
337
339
|
}
|
|
338
340
|
|
|
339
341
|
result, err := sendRequest(socketPath, "exec", params)
|
|
@@ -623,6 +625,8 @@ Usage:
|
|
|
623
625
|
gssh forward-close <forward_id>
|
|
624
626
|
gssh scp [-s session_id] -put <local> <remote>
|
|
625
627
|
gssh scp [-s session_id] -get <remote> <local>
|
|
628
|
+
gssh sync [-s session_id] -put <local> <remote>
|
|
629
|
+
gssh sync [-s session_id] -get <remote> <local>
|
|
626
630
|
gssh sftp [-s session_id] -c <ls|mkdir|rm> -p <path>
|
|
627
631
|
gssh -v, --version
|
|
628
632
|
|
|
@@ -652,6 +656,7 @@ Examples:
|
|
|
652
656
|
gssh reconnect 549b6eff-f62c-4dae-a7e9-298815233cf4
|
|
653
657
|
gssh forward -l 8080 -r 80
|
|
654
658
|
gssh scp -put local.txt /home/user/remote.txt
|
|
659
|
+
gssh sync -put local_dir /home/user/remote_dir
|
|
655
660
|
`, version)
|
|
656
661
|
}
|
|
657
662
|
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
--- internal/session/manager.go
|
|
2
|
+
+++ internal/session/manager.go
|
|
3
|
+
@@ -100,16 +100,24 @@
|
|
4
|
+
// Check if session already exists
|
|
5
|
+
for _, s := range m.sessions {
|
|
6
|
+
if s.Host == host && s.User == user && s.Port == port {
|
|
7
|
+
- if s.Status == "connected" {
|
|
8
|
+
+ s.mu.RLock()
|
|
9
|
+
+ status := s.Status
|
|
10
|
+
+ s.mu.RUnlock()
|
|
11
|
+
+
|
|
12
|
+
+ if status == "connected" {
|
|
13
|
+
return toProtocolSession(s), fmt.Errorf("session already exists")
|
|
14
|
+
}
|
|
15
|
+
// Try to reconnect
|
|
16
|
+
sshClient, err := client.Connect(user, host, port, password, keyPath)
|
|
17
|
+
if err != nil {
|
|
18
|
+
return nil, err
|
|
19
|
+
}
|
|
20
|
+
+
|
|
21
|
+
+ s.mu.Lock()
|
|
22
|
+
s.SSHClient = sshClient
|
|
23
|
+
s.Status = "connected"
|
|
24
|
+
+ s.mu.Unlock()
|
|
25
|
+
+
|
|
26
|
+
return toProtocolSession(s), nil
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
@@ -159,10 +167,12 @@
|
|
30
|
+
return fmt.Errorf("session not found")
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
+ ms.mu.Lock()
|
|
34
|
+
if ms.SSHClient != nil {
|
|
35
|
+
ms.SSHClient.Close()
|
|
36
|
+
}
|
|
37
|
+
-
|
|
38
|
+
ms.Status = "disconnected"
|
|
39
|
+
+ ms.mu.Unlock()
|
|
40
|
+
|
|
41
|
+
// Clear default ID when disconnecting
|
|
42
|
+
if m.defaultID == sessionID {
|
|
43
|
+
@@ -188,10 +198,14 @@
|
|
44
|
+
return nil, fmt.Errorf("session not found")
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
+ ms.mu.RLock()
|
|
48
|
+
+ existingClient := ms.SSHClient
|
|
49
|
+
+ ms.mu.RUnlock()
|
|
50
|
+
+
|
|
51
|
+
// Close existing connection
|
|
52
|
+
- if ms.SSHClient != nil {
|
|
53
|
+
- ms.SSHClient.Close()
|
|
54
|
+
+ if existingClient != nil {
|
|
55
|
+
+ existingClient.Close()
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Create new connection
|
|
59
|
+
sshClient, err := client.Connect(ms.User, ms.Host, ms.Port, ms.Password, ms.KeyPath)
|
|
60
|
+
@@ -239,15 +253,16 @@
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
ms.mu.RLock()
|
|
64
|
+
- if ms.SSHClient == nil {
|
|
65
|
+
+ sshClient := ms.SSHClient
|
|
66
|
+
+ ms.mu.RUnlock()
|
|
67
|
+
+
|
|
68
|
+
+ if sshClient == nil {
|
|
69
|
+
- ms.mu.RUnlock()
|
|
70
|
+
return nil, fmt.Errorf("session not connected")
|
|
71
|
+
}
|
|
72
|
+
- ms.mu.RUnlock()
|
|
73
|
+
|
|
74
|
+
// 复用 SSH 连接,创建新的 session 执行命令
|
|
75
|
+
- session, err := ms.SSHClient.Client.NewSession()
|
|
76
|
+
+ session, err := sshClient.Client.NewSession()
|
|
77
|
+
if err != nil {
|
|
78
|
+
return nil, fmt.Errorf("failed to create session: %w", err)
|
|
79
|
+
}
|
package/internal/client/ssh.go
CHANGED
|
@@ -11,6 +11,7 @@ import (
|
|
|
11
11
|
|
|
12
12
|
"github.com/pkg/sftp"
|
|
13
13
|
"golang.org/x/crypto/ssh"
|
|
14
|
+
"golang.org/x/crypto/ssh/knownhosts"
|
|
14
15
|
)
|
|
15
16
|
|
|
16
17
|
// SSHClient encapsulates SSH connection logic
|
|
@@ -32,12 +33,84 @@ func (k *KeyboardInteractiveHandler) Challenge(name, instruction string, questio
|
|
|
32
33
|
return answers, nil
|
|
33
34
|
}
|
|
34
35
|
|
|
36
|
+
// getHostKeyCallback returns a host key callback that verifies the host key against the user's known_hosts file.
|
|
37
|
+
// If the host is unknown, it implements Trust-On-First-Use (TOFU) by adding the new key to the known_hosts file.
|
|
38
|
+
func getHostKeyCallback() (ssh.HostKeyCallback, error) {
|
|
39
|
+
homeDir, err := os.UserHomeDir()
|
|
40
|
+
if err != nil {
|
|
41
|
+
return nil, fmt.Errorf("could not get user home dir: %w", err)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
knownHostsPath := filepath.Join(homeDir, ".ssh", "known_hosts")
|
|
45
|
+
|
|
46
|
+
// Ensure the .ssh directory exists
|
|
47
|
+
if err := os.MkdirAll(filepath.Dir(knownHostsPath), 0700); err != nil {
|
|
48
|
+
return nil, fmt.Errorf("failed to create .ssh directory: %w", err)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Create the known_hosts file if it doesn't exist
|
|
52
|
+
if _, err := os.Stat(knownHostsPath); os.IsNotExist(err) {
|
|
53
|
+
f, err := os.OpenFile(knownHostsPath, os.O_CREATE|os.O_RDWR, 0600)
|
|
54
|
+
if err != nil {
|
|
55
|
+
return nil, fmt.Errorf("failed to create known_hosts file: %w", err)
|
|
56
|
+
}
|
|
57
|
+
f.Close()
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
cb, err := knownhosts.New(knownHostsPath)
|
|
61
|
+
if err != nil {
|
|
62
|
+
return nil, fmt.Errorf("failed to create knownhosts callback: %w", err)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return func(hostname string, remote net.Addr, key ssh.PublicKey) error {
|
|
66
|
+
err := cb(hostname, remote, key)
|
|
67
|
+
if err == nil {
|
|
68
|
+
return nil
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
keyErr, ok := err.(*knownhosts.KeyError)
|
|
72
|
+
if !ok {
|
|
73
|
+
return err
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// If len(keyErr.Want) is 0, it means the key is completely unknown (not a mismatch).
|
|
77
|
+
// We implement Trust-On-First-Use (TOFU) by adding the new key to known_hosts.
|
|
78
|
+
if len(keyErr.Want) == 0 {
|
|
79
|
+
f, fErr := os.OpenFile(knownHostsPath, os.O_APPEND|os.O_WRONLY, 0600)
|
|
80
|
+
if fErr != nil {
|
|
81
|
+
return fmt.Errorf("failed to open known_hosts for appending: %w", fErr)
|
|
82
|
+
}
|
|
83
|
+
defer f.Close()
|
|
84
|
+
|
|
85
|
+
addresses := []string{knownhosts.Normalize(hostname)}
|
|
86
|
+
if remoteString := remote.String(); remoteString != hostname {
|
|
87
|
+
addresses = append(addresses, knownhosts.Normalize(remoteString))
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
line := knownhosts.Line(addresses, key)
|
|
91
|
+
if _, wErr := f.WriteString(line + "\n"); wErr != nil {
|
|
92
|
+
return fmt.Errorf("failed to append key to known_hosts: %w", wErr)
|
|
93
|
+
}
|
|
94
|
+
return nil
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// It's a key mismatch (security risk: MITM or host key changed). Reject the connection.
|
|
98
|
+
return err
|
|
99
|
+
}, nil
|
|
100
|
+
}
|
|
101
|
+
|
|
35
102
|
// NewSSHClient creates a new SSH client
|
|
36
103
|
func NewSSHClient(user, host string, port int, authMethods ...ssh.AuthMethod) (*SSHClient, error) {
|
|
104
|
+
hostKeyCallback, err := getHostKeyCallback()
|
|
105
|
+
if err != nil {
|
|
106
|
+
return nil, fmt.Errorf("failed to get host key callback: %w", err)
|
|
107
|
+
}
|
|
108
|
+
|
|
37
109
|
config := &ssh.ClientConfig{
|
|
38
110
|
User: user,
|
|
39
111
|
Auth: authMethods,
|
|
40
|
-
HostKeyCallback:
|
|
112
|
+
HostKeyCallback: hostKeyCallback,
|
|
113
|
+
Timeout: 10 * time.Second,
|
|
41
114
|
}
|
|
42
115
|
|
|
43
116
|
addr := fmt.Sprintf("%s:%d", host, port)
|
|
@@ -169,8 +242,56 @@ func (c *SSHClient) NewSFTPClient() (*SFTPClient, error) {
|
|
|
169
242
|
return &SFTPClient{Client: sftpClient}, nil
|
|
170
243
|
}
|
|
171
244
|
|
|
172
|
-
// Upload uploads a local file to remote
|
|
245
|
+
// Upload uploads a local file or directory to remote
|
|
173
246
|
func (s *SFTPClient) Upload(localPath, remotePath string) (int64, error) {
|
|
247
|
+
localInfo, err := os.Stat(localPath)
|
|
248
|
+
if err != nil {
|
|
249
|
+
return 0, fmt.Errorf("failed to stat local path: %w", err)
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if !localInfo.IsDir() {
|
|
253
|
+
return s.uploadFile(localPath, remotePath)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
var totalWritten int64
|
|
257
|
+
err = filepath.Walk(localPath, func(path string, info os.FileInfo, err error) error {
|
|
258
|
+
if err != nil {
|
|
259
|
+
return err
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
relPath, err := filepath.Rel(localPath, path)
|
|
263
|
+
if err != nil {
|
|
264
|
+
return err
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
targetPath := filepath.ToSlash(filepath.Join(remotePath, relPath))
|
|
268
|
+
|
|
269
|
+
if info.IsDir() {
|
|
270
|
+
s.Client.MkdirAll(targetPath)
|
|
271
|
+
return nil
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Sync logic
|
|
275
|
+
remoteInfo, err := s.Client.Stat(targetPath)
|
|
276
|
+
if err == nil && remoteInfo.Size() == info.Size() && remoteInfo.ModTime().Unix() == info.ModTime().Unix() {
|
|
277
|
+
return nil
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
written, err := s.uploadFile(path, targetPath)
|
|
281
|
+
if err != nil {
|
|
282
|
+
return err
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
totalWritten += written
|
|
286
|
+
s.Client.Chtimes(targetPath, info.ModTime(), info.ModTime())
|
|
287
|
+
|
|
288
|
+
return nil
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
return totalWritten, err
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
func (s *SFTPClient) uploadFile(localPath, remotePath string) (int64, error) {
|
|
174
295
|
localFile, err := os.Open(localPath)
|
|
175
296
|
if err != nil {
|
|
176
297
|
return 0, fmt.Errorf("failed to open local file: %w", err)
|
|
@@ -199,8 +320,62 @@ func (s *SFTPClient) Upload(localPath, remotePath string) (int64, error) {
|
|
|
199
320
|
return written, nil
|
|
200
321
|
}
|
|
201
322
|
|
|
202
|
-
// Download downloads a remote file to local
|
|
323
|
+
// Download downloads a remote file or directory to local
|
|
203
324
|
func (s *SFTPClient) Download(remotePath, localPath string) (int64, error) {
|
|
325
|
+
remoteInfo, err := s.Client.Stat(remotePath)
|
|
326
|
+
if err != nil {
|
|
327
|
+
return 0, fmt.Errorf("failed to stat remote path: %w", err)
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if !remoteInfo.IsDir() {
|
|
331
|
+
return s.downloadFile(remotePath, localPath)
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
var totalWritten int64
|
|
335
|
+
walker := s.Client.Walk(remotePath)
|
|
336
|
+
for walker.Step() {
|
|
337
|
+
if walker.Err() != nil {
|
|
338
|
+
return totalWritten, walker.Err()
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
path := walker.Path()
|
|
342
|
+
info := walker.Stat()
|
|
343
|
+
|
|
344
|
+
relPath, err := filepath.Rel(remotePath, path)
|
|
345
|
+
if err != nil {
|
|
346
|
+
return totalWritten, err
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if err := checkPathTraversal(relPath); err != nil {
|
|
350
|
+
return totalWritten, err
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
targetPath := filepath.Join(localPath, relPath)
|
|
354
|
+
|
|
355
|
+
if info.IsDir() {
|
|
356
|
+
os.MkdirAll(targetPath, info.Mode())
|
|
357
|
+
continue
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Sync logic
|
|
361
|
+
localInfo, err := os.Stat(targetPath)
|
|
362
|
+
if err == nil && localInfo.Size() == info.Size() && localInfo.ModTime().Unix() == info.ModTime().Unix() {
|
|
363
|
+
continue
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
written, err := s.downloadFile(path, targetPath)
|
|
367
|
+
if err != nil {
|
|
368
|
+
return totalWritten, err
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
totalWritten += written
|
|
372
|
+
os.Chtimes(targetPath, info.ModTime(), info.ModTime())
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
return totalWritten, nil
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
func (s *SFTPClient) downloadFile(remotePath, localPath string) (int64, error) {
|
|
204
379
|
remoteFile, err := s.Client.Open(remotePath)
|
|
205
380
|
if err != nil {
|
|
206
381
|
return 0, fmt.Errorf("failed to open remote file: %w", err)
|
|
@@ -380,6 +555,14 @@ func (pw *progressWriter) Write(p []byte) (n int, err error) {
|
|
|
380
555
|
return
|
|
381
556
|
}
|
|
382
557
|
|
|
558
|
+
// checkPathTraversal ensures that relPath does not escape the destination directory
|
|
559
|
+
func checkPathTraversal(relPath string) error {
|
|
560
|
+
if !filepath.IsLocal(relPath) {
|
|
561
|
+
return fmt.Errorf("path traversal detected: %s escapes destination", relPath)
|
|
562
|
+
}
|
|
563
|
+
return nil
|
|
564
|
+
}
|
|
565
|
+
|
|
383
566
|
// expandPath expands ~ to home directory
|
|
384
567
|
func expandPath(path string) string {
|
|
385
568
|
if len(path) > 0 && path[0] == '~' {
|
|
@@ -31,3 +31,46 @@ func TestNewAuthMethodsFromKeyPath(t *testing.T) {
|
|
|
31
31
|
t.Error("expected error for non-existent key")
|
|
32
32
|
}
|
|
33
33
|
}
|
|
34
|
+
|
|
35
|
+
func TestCheckPathTraversal(t *testing.T) {
|
|
36
|
+
tests := []struct {
|
|
37
|
+
name string
|
|
38
|
+
relPath string
|
|
39
|
+
wantErr bool
|
|
40
|
+
}{
|
|
41
|
+
{
|
|
42
|
+
name: "safe path within directory",
|
|
43
|
+
relPath: "safe_file.txt",
|
|
44
|
+
wantErr: false,
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
name: "safe path same as directory",
|
|
48
|
+
relPath: ".",
|
|
49
|
+
wantErr: false,
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
name: "path traversal attempt with ../",
|
|
53
|
+
relPath: "../etc/passwd",
|
|
54
|
+
wantErr: true,
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
name: "absolute path traversal",
|
|
58
|
+
relPath: "/etc/passwd",
|
|
59
|
+
wantErr: true,
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
name: "deep nested safe path",
|
|
63
|
+
relPath: "a/b/c/d.txt",
|
|
64
|
+
wantErr: false,
|
|
65
|
+
},
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
for _, tt := range tests {
|
|
69
|
+
t.Run(tt.name, func(t *testing.T) {
|
|
70
|
+
err := checkPathTraversal(tt.relPath)
|
|
71
|
+
if (err != nil) != tt.wantErr {
|
|
72
|
+
t.Errorf("checkPathTraversal() error = %v, wantErr %v", err, tt.wantErr)
|
|
73
|
+
}
|
|
74
|
+
})
|
|
75
|
+
}
|
|
76
|
+
}
|