gssh-agent 1.0.0 → 1.0.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/README.md +24 -0
- package/bin/gssh +0 -0
- package/bin/gssh-daemon +0 -0
- package/cmd/gssh/main.go +235 -12
- package/go.mod +6 -1
- package/go.sum +46 -0
- package/internal/client/ssh.go +228 -0
- package/internal/protocol/types.go +23 -0
- package/internal/session/manager.go +158 -0
- package/package.json +21 -6
- package/pkg/rpc/handler.go +28 -0
- package/skill.md +31 -67
- package/bin/daemon +0 -0
- package/gssh-darwin-arm64.tar.gz +0 -0
- package/homebrew/gssh.plist +0 -22
- package/homebrew/install.sh +0 -43
package/README.md
CHANGED
|
@@ -10,6 +10,7 @@ gssh 是一个供 Agent 使用的 SSH Session 管理工具。通过 Go 语言实
|
|
|
10
10
|
- 断线自动重连
|
|
11
11
|
- 支持密码认证和 SSH 密钥认证
|
|
12
12
|
- 被连接机器零配置(普通 SSH 即可)
|
|
13
|
+
- SFTP 文件传输支持(上传/下载)
|
|
13
14
|
|
|
14
15
|
## 安装
|
|
15
16
|
|
|
@@ -124,6 +125,25 @@ gssh exec -s <session_id> "pwd"
|
|
|
124
125
|
gssh forward -l 8080 -r 80
|
|
125
126
|
```
|
|
126
127
|
|
|
128
|
+
### 文件传输(SFTP/SCP)
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
# 上传文件(本地 -> 远程)
|
|
132
|
+
gssh scp -put /path/to/local/file.txt /path/to/remote/file.txt
|
|
133
|
+
|
|
134
|
+
# 下载文件(远程 -> 本地)
|
|
135
|
+
gssh scp -get /path/to/remote/file.txt /path/to/local/file.txt
|
|
136
|
+
|
|
137
|
+
# 列出远程目录
|
|
138
|
+
gssh sftp -c ls -p /path/to/remote/dir
|
|
139
|
+
|
|
140
|
+
# 创建远程目录
|
|
141
|
+
gssh sftp -c mkdir -p /path/to/remote/newdir
|
|
142
|
+
|
|
143
|
+
# 删除远程文件
|
|
144
|
+
gssh sftp -c rm -p /path/to/remote/file.txt
|
|
145
|
+
```
|
|
146
|
+
|
|
127
147
|
### Session 管理
|
|
128
148
|
|
|
129
149
|
```bash
|
|
@@ -154,6 +174,10 @@ gssh reconnect -s <session_id>
|
|
|
154
174
|
| `-l local` | 本地端口 |
|
|
155
175
|
| `-r remote` | 远程端口 |
|
|
156
176
|
| `-R` | 远程端口转发 |
|
|
177
|
+
| `-put` | 上传模式(本地 -> 远程) |
|
|
178
|
+
| `-get` | 下载模式(远程 -> 本地) |
|
|
179
|
+
| `-c command` | SFTP 命令(ls/mkdir/rm) |
|
|
180
|
+
| `-p path` | SFTP 路径 |
|
|
157
181
|
|
|
158
182
|
## 开发
|
|
159
183
|
|
package/bin/gssh
CHANGED
|
Binary file
|
package/bin/gssh-daemon
CHANGED
|
Binary file
|
package/cmd/gssh/main.go
CHANGED
|
@@ -7,9 +7,13 @@ import (
|
|
|
7
7
|
"fmt"
|
|
8
8
|
"net"
|
|
9
9
|
"os"
|
|
10
|
+
"os/signal"
|
|
10
11
|
"strings"
|
|
12
|
+
"syscall"
|
|
11
13
|
|
|
12
14
|
"gssh/internal/protocol"
|
|
15
|
+
|
|
16
|
+
"golang.org/x/term"
|
|
13
17
|
)
|
|
14
18
|
|
|
15
19
|
const (
|
|
@@ -50,6 +54,10 @@ func main() {
|
|
|
50
54
|
err = handleForwards()
|
|
51
55
|
case "forward-close":
|
|
52
56
|
err = handleForwardClose()
|
|
57
|
+
case "scp":
|
|
58
|
+
err = handleSCP()
|
|
59
|
+
case "sftp":
|
|
60
|
+
err = handleSFTP()
|
|
53
61
|
case "help", "-h", "--help":
|
|
54
62
|
printUsage()
|
|
55
63
|
default:
|
|
@@ -64,6 +72,50 @@ func main() {
|
|
|
64
72
|
}
|
|
65
73
|
}
|
|
66
74
|
|
|
75
|
+
// readPassword reads password from terminal without echo
|
|
76
|
+
func readPassword() (string, error) {
|
|
77
|
+
// Check if stdin is a terminal
|
|
78
|
+
if !term.IsTerminal(int(os.Stdin.Fd())) {
|
|
79
|
+
// Try to read from stdin
|
|
80
|
+
reader := bufio.NewReader(os.Stdin)
|
|
81
|
+
fmt.Print("Password: ")
|
|
82
|
+
password, err := reader.ReadString('\n')
|
|
83
|
+
if err != nil {
|
|
84
|
+
return "", err
|
|
85
|
+
}
|
|
86
|
+
return strings.TrimSuffix(password, "\n"), nil
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
fmt.Print("Password: ")
|
|
90
|
+
bytePassword, err := term.ReadPassword(int(os.Stdin.Fd()))
|
|
91
|
+
fmt.Println()
|
|
92
|
+
if err != nil {
|
|
93
|
+
return "", err
|
|
94
|
+
}
|
|
95
|
+
return string(bytePassword), nil
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// readPassphrase reads passphrase from terminal without echo
|
|
99
|
+
func readPassphrase() (string, error) {
|
|
100
|
+
if !term.IsTerminal(int(os.Stdin.Fd())) {
|
|
101
|
+
reader := bufio.NewReader(os.Stdin)
|
|
102
|
+
fmt.Print("Key passphrase: ")
|
|
103
|
+
passphrase, err := reader.ReadString('\n')
|
|
104
|
+
if err != nil {
|
|
105
|
+
return "", err
|
|
106
|
+
}
|
|
107
|
+
return strings.TrimSuffix(passphrase, "\n"), nil
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
fmt.Print("Key passphrase: ")
|
|
111
|
+
bytePassphrase, err := term.ReadPassword(int(os.Stdin.Fd()))
|
|
112
|
+
fmt.Println()
|
|
113
|
+
if err != nil {
|
|
114
|
+
return "", err
|
|
115
|
+
}
|
|
116
|
+
return string(bytePassphrase), nil
|
|
117
|
+
}
|
|
118
|
+
|
|
67
119
|
func sendRequest(method string, params interface{}) ([]byte, error) {
|
|
68
120
|
conn, err := net.Dial("unix", *socketPath)
|
|
69
121
|
if err != nil {
|
|
@@ -129,6 +181,8 @@ func handleConnect() error {
|
|
|
129
181
|
port := flag.Int("p", 22, "Port")
|
|
130
182
|
password := flag.String("P", "", "Password")
|
|
131
183
|
keyPath := flag.String("i", "", "SSH key path")
|
|
184
|
+
askPassword := flag.Bool("ask-pass", false, "Ask for password interactively")
|
|
185
|
+
askPassphrase := flag.Bool("ask-passphrase", false, "Ask for key passphrase interactively")
|
|
132
186
|
|
|
133
187
|
flag.CommandLine.Parse(flag.Args()[1:])
|
|
134
188
|
|
|
@@ -136,6 +190,28 @@ func handleConnect() error {
|
|
|
136
190
|
return fmt.Errorf("user and host are required")
|
|
137
191
|
}
|
|
138
192
|
|
|
193
|
+
// If no password provided via flag and -ask-pass is set, read interactively
|
|
194
|
+
if *password == "" && *askPassword {
|
|
195
|
+
p, err := readPassword()
|
|
196
|
+
if err != nil {
|
|
197
|
+
return fmt.Errorf("failed to read password: %w", err)
|
|
198
|
+
}
|
|
199
|
+
*password = p
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// If key path provided and -ask-passphrase is set, read interactively
|
|
203
|
+
if *keyPath != "" && *askPassphrase {
|
|
204
|
+
passphrase, err := readPassphrase()
|
|
205
|
+
if err != nil {
|
|
206
|
+
return fmt.Errorf("failed to read passphrase: %w", err)
|
|
207
|
+
}
|
|
208
|
+
// Note: Passphrase support would require modifying the SSH client
|
|
209
|
+
// For now, we'll just warn that it's not supported
|
|
210
|
+
if passphrase != "" {
|
|
211
|
+
fmt.Println("Note: Passphrase for keys is not yet supported, ignoring")
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
139
215
|
params := protocol.ConnectParams{
|
|
140
216
|
User: *user,
|
|
141
217
|
Host: *host,
|
|
@@ -202,6 +278,9 @@ func handleExec() error {
|
|
|
202
278
|
args := flag.Args()[1:]
|
|
203
279
|
|
|
204
280
|
sessionID := flag.String("s", "", "Session ID")
|
|
281
|
+
sudoPassword := flag.String("S", "", "sudo password (will prompt if empty and -s flag used)")
|
|
282
|
+
askSudoPassword := flag.Bool("ask-sudo-pass", false, "Interactively ask for sudo password")
|
|
283
|
+
|
|
205
284
|
flag.CommandLine.Parse(args)
|
|
206
285
|
|
|
207
286
|
if flag.NArg() < 1 {
|
|
@@ -209,6 +288,21 @@ func handleExec() error {
|
|
|
209
288
|
}
|
|
210
289
|
command := strings.Join(flag.Args(), " ")
|
|
211
290
|
|
|
291
|
+
// If sudo password needed but not provided, prompt for it
|
|
292
|
+
if *askSudoPassword && *sudoPassword == "" {
|
|
293
|
+
p, err := readPassword()
|
|
294
|
+
if err != nil {
|
|
295
|
+
return fmt.Errorf("failed to read sudo password: %w", err)
|
|
296
|
+
}
|
|
297
|
+
*sudoPassword = p
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// If command contains sudo and password provided, wrap the command
|
|
301
|
+
if *sudoPassword != "" && strings.Contains(command, "sudo") {
|
|
302
|
+
// Use printf to pipe password to sudo -S
|
|
303
|
+
command = fmt.Sprintf("printf '%%s\\n' '%s' | %s -S", *sudoPassword, command)
|
|
304
|
+
}
|
|
305
|
+
|
|
212
306
|
params := protocol.ExecParams{
|
|
213
307
|
SessionID: *sessionID,
|
|
214
308
|
Command: command,
|
|
@@ -364,31 +458,160 @@ func handleForwardClose() error {
|
|
|
364
458
|
return nil
|
|
365
459
|
}
|
|
366
460
|
|
|
461
|
+
func handleSCP() error {
|
|
462
|
+
sessionID := flag.String("s", "", "Session ID")
|
|
463
|
+
isUpload := flag.Bool("put", false, "Upload mode (local->remote)")
|
|
464
|
+
isDownload := flag.Bool("get", false, "Download mode (remote->local)")
|
|
465
|
+
|
|
466
|
+
flag.CommandLine.Parse(flag.Args()[1:])
|
|
467
|
+
|
|
468
|
+
if flag.NArg() < 2 {
|
|
469
|
+
return fmt.Errorf("source and destination paths required")
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
source := flag.Arg(0)
|
|
473
|
+
dest := flag.Arg(1)
|
|
474
|
+
|
|
475
|
+
if !*isUpload && !*isDownload {
|
|
476
|
+
return fmt.Errorf("must specify -put or -get")
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
params := protocol.SCPParams{
|
|
480
|
+
SessionID: *sessionID,
|
|
481
|
+
Source: source,
|
|
482
|
+
Dest: dest,
|
|
483
|
+
IsUpload: *isUpload,
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
result, err := sendRequest("scp", params)
|
|
487
|
+
if err != nil {
|
|
488
|
+
return err
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
var scpResult protocol.SCPResult
|
|
492
|
+
if err := json.Unmarshal(result, &scpResult); err != nil {
|
|
493
|
+
return fmt.Errorf("failed to parse result: %w", err)
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
if scpResult.Success {
|
|
497
|
+
fmt.Printf("Success: %s\n", scpResult.Message)
|
|
498
|
+
} else {
|
|
499
|
+
return fmt.Errorf("failed: %s", scpResult.Message)
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
return nil
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
func handleSFTP() error {
|
|
506
|
+
sessionID := flag.String("s", "", "Session ID")
|
|
507
|
+
command := flag.String("c", "", "SFTP command (ls, mkdir, rm)")
|
|
508
|
+
path := flag.String("p", ".", "Path")
|
|
509
|
+
|
|
510
|
+
flag.CommandLine.Parse(flag.Args()[1:])
|
|
511
|
+
|
|
512
|
+
if *command == "" {
|
|
513
|
+
return fmt.Errorf("SFTP command required (-c ls|mkdir|rm)")
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
switch *command {
|
|
517
|
+
case "ls":
|
|
518
|
+
params := protocol.SFTPParams{
|
|
519
|
+
SessionID: *sessionID,
|
|
520
|
+
Command: "ls",
|
|
521
|
+
Path: *path,
|
|
522
|
+
}
|
|
523
|
+
result, err := sendRequest("sftp_list", params)
|
|
524
|
+
if err != nil {
|
|
525
|
+
return err
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
var files []string
|
|
529
|
+
if err := json.Unmarshal(result, &files); err != nil {
|
|
530
|
+
return fmt.Errorf("failed to parse result: %w", err)
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
for _, f := range files {
|
|
534
|
+
fmt.Println(f)
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
case "mkdir":
|
|
538
|
+
params := protocol.SFTPParams{
|
|
539
|
+
SessionID: *sessionID,
|
|
540
|
+
Command: "mkdir",
|
|
541
|
+
Path: *path,
|
|
542
|
+
}
|
|
543
|
+
_, err := sendRequest("sftp_mkdir", params)
|
|
544
|
+
if err != nil {
|
|
545
|
+
return err
|
|
546
|
+
}
|
|
547
|
+
fmt.Printf("Directory created: %s\n", *path)
|
|
548
|
+
|
|
549
|
+
case "rm":
|
|
550
|
+
params := protocol.SFTPParams{
|
|
551
|
+
SessionID: *sessionID,
|
|
552
|
+
Command: "rm",
|
|
553
|
+
Path: *path,
|
|
554
|
+
}
|
|
555
|
+
_, err := sendRequest("sftp_remove", params)
|
|
556
|
+
if err != nil {
|
|
557
|
+
return err
|
|
558
|
+
}
|
|
559
|
+
fmt.Printf("File removed: %s\n", *path)
|
|
560
|
+
|
|
561
|
+
default:
|
|
562
|
+
return fmt.Errorf("unknown SFTP command: %s", *command)
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
return nil
|
|
566
|
+
}
|
|
567
|
+
|
|
367
568
|
func printUsage() {
|
|
368
569
|
fmt.Println(`gssh - SSH Session Manager for Agents (Stateless)
|
|
369
570
|
|
|
370
571
|
Usage:
|
|
371
|
-
gssh connect -u user -h host [-p port] [-i key_path] [-P password]
|
|
572
|
+
gssh connect -u user -h host [-p port] [-i key_path] [-P password] [--ask-pass]
|
|
372
573
|
gssh disconnect [-s session_id]
|
|
373
574
|
gssh reconnect [-s session_id]
|
|
374
|
-
gssh exec [-s session_id] "command"
|
|
575
|
+
gssh exec [-s session_id] [-S password | --ask-sudo-pass] "sudo command"
|
|
375
576
|
gssh list
|
|
376
577
|
gssh use <session_id>
|
|
377
578
|
gssh forward [-s session_id] -l local_port -r remote_port
|
|
378
579
|
gssh forwards
|
|
379
580
|
gssh forward-close <forward_id>
|
|
581
|
+
gssh scp [-s session_id] [-put|-get] <source> <dest>
|
|
582
|
+
gssh sftp [-s session_id] -c <command> -p <path>
|
|
380
583
|
|
|
381
584
|
Note: For sudo commands, use key-based authentication or configure passwordless sudo.
|
|
382
585
|
|
|
383
586
|
Options:
|
|
384
|
-
-socket path
|
|
385
|
-
-s session_id
|
|
386
|
-
-u user
|
|
387
|
-
-h host
|
|
388
|
-
-p port
|
|
389
|
-
-P password
|
|
390
|
-
-i key_path
|
|
391
|
-
-
|
|
392
|
-
-
|
|
393
|
-
-
|
|
587
|
+
-socket path Unix socket path (default: /tmp/gssh.sock)
|
|
588
|
+
-s session_id Session ID
|
|
589
|
+
-u user Username
|
|
590
|
+
-h host Host
|
|
591
|
+
-p port Port (default: 22)
|
|
592
|
+
-P password Password (or use --ask-pass for interactive input)
|
|
593
|
+
-i key_path SSH key path
|
|
594
|
+
-S password sudo password (for executing sudo commands)
|
|
595
|
+
--ask-sudo-pass Interactively ask for sudo password
|
|
596
|
+
-l local Local port
|
|
597
|
+
-r remote Remote port
|
|
598
|
+
-R Remote port forward
|
|
599
|
+
-put Upload mode (local -> remote)
|
|
600
|
+
-get Download mode (remote -> local)
|
|
601
|
+
-c command SFTP command (ls, mkdir, rm)
|
|
602
|
+
-p path Path for SFTP command
|
|
603
|
+
--ask-pass Interactively ask for SSH password (secure)
|
|
604
|
+
--ask-passphrase Interactively ask for key passphrase (secure)`)
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// Restore terminal on exit
|
|
608
|
+
func init() {
|
|
609
|
+
// Setup cleanup to restore terminal state on unexpected exit
|
|
610
|
+
c := make(chan os.Signal, 1)
|
|
611
|
+
signal.Notify(c, syscall.SIGINT, syscall.SIGTERM)
|
|
612
|
+
go func() {
|
|
613
|
+
<-c
|
|
614
|
+
term.Restore(int(os.Stdin.Fd()), &term.State{})
|
|
615
|
+
os.Exit(1)
|
|
616
|
+
}()
|
|
394
617
|
}
|
package/go.mod
CHANGED
|
@@ -4,7 +4,12 @@ go 1.21
|
|
|
4
4
|
|
|
5
5
|
require (
|
|
6
6
|
github.com/google/uuid v1.5.0
|
|
7
|
+
github.com/pkg/sftp v1.13.6
|
|
7
8
|
golang.org/x/crypto v0.18.0
|
|
9
|
+
golang.org/x/term v0.16.0
|
|
8
10
|
)
|
|
9
11
|
|
|
10
|
-
require
|
|
12
|
+
require (
|
|
13
|
+
github.com/kr/fs v0.1.0 // indirect
|
|
14
|
+
golang.org/x/sys v0.16.0 // indirect
|
|
15
|
+
)
|
package/go.sum
CHANGED
|
@@ -1,8 +1,54 @@
|
|
|
1
|
+
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
|
2
|
+
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
|
3
|
+
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
|
1
4
|
github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU=
|
|
2
5
|
github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
|
6
|
+
github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
|
|
7
|
+
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
|
|
8
|
+
github.com/pkg/sftp v1.13.6 h1:JFZT4XbOU7l77xGSpOdW+pwIMqP044IyjXX6FGyEKFo=
|
|
9
|
+
github.com/pkg/sftp v1.13.6/go.mod h1:tz1ryNURKu77RL+GuCzmoJYxQczL3wLNNpPWagdg4Qk=
|
|
10
|
+
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
|
11
|
+
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
|
12
|
+
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
|
13
|
+
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
|
14
|
+
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
|
15
|
+
github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk=
|
|
16
|
+
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
|
17
|
+
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
|
18
|
+
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
|
19
|
+
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
|
20
|
+
golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw=
|
|
3
21
|
golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc=
|
|
4
22
|
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
|
|
23
|
+
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
|
24
|
+
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
|
25
|
+
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
|
26
|
+
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
|
27
|
+
golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco=
|
|
28
|
+
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
|
29
|
+
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
|
30
|
+
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
|
31
|
+
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
32
|
+
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
33
|
+
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
34
|
+
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
35
|
+
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
5
36
|
golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU=
|
|
6
37
|
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
|
38
|
+
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
|
39
|
+
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
|
40
|
+
golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
|
7
41
|
golang.org/x/term v0.16.0 h1:m+B6fahuftsE9qjo0VWp2FW0mB3MTJvR0BaMQrq0pmE=
|
|
8
42
|
golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY=
|
|
43
|
+
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
|
44
|
+
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
|
45
|
+
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
|
46
|
+
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
|
47
|
+
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
|
48
|
+
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
|
49
|
+
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
|
50
|
+
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
|
51
|
+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
|
52
|
+
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
|
53
|
+
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
|
54
|
+
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
package/internal/client/ssh.go
CHANGED
|
@@ -2,11 +2,14 @@ package client
|
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
4
|
"fmt"
|
|
5
|
+
"io"
|
|
5
6
|
"net"
|
|
6
7
|
"os"
|
|
7
8
|
"os/user"
|
|
8
9
|
"path/filepath"
|
|
10
|
+
"time"
|
|
9
11
|
|
|
12
|
+
"github.com/pkg/sftp"
|
|
10
13
|
"golang.org/x/crypto/ssh"
|
|
11
14
|
)
|
|
12
15
|
|
|
@@ -130,6 +133,231 @@ func (c *SSHClient) LocalForward(localPort, remotePort int) (net.Listener, error
|
|
|
130
133
|
return c.Client.Listen("tcp", fmt.Sprintf("localhost:%d", localPort))
|
|
131
134
|
}
|
|
132
135
|
|
|
136
|
+
// SFTPClient wraps SFTP client for file transfers
|
|
137
|
+
type SFTPClient struct {
|
|
138
|
+
Client *sftp.Client
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// NewSFTPClient creates a new SFTP client from SSH client
|
|
142
|
+
func (c *SSHClient) NewSFTPClient() (*SFTPClient, error) {
|
|
143
|
+
sftpClient, err := sftp.NewClient(c.Client)
|
|
144
|
+
if err != nil {
|
|
145
|
+
return nil, fmt.Errorf("failed to create SFTP client: %w", err)
|
|
146
|
+
}
|
|
147
|
+
return &SFTPClient{Client: sftpClient}, nil
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Upload uploads a local file to remote
|
|
151
|
+
func (s *SFTPClient) Upload(localPath, remotePath string) (int64, error) {
|
|
152
|
+
localFile, err := os.Open(localPath)
|
|
153
|
+
if err != nil {
|
|
154
|
+
return 0, fmt.Errorf("failed to open local file: %w", err)
|
|
155
|
+
}
|
|
156
|
+
defer localFile.Close()
|
|
157
|
+
|
|
158
|
+
localInfo, err := localFile.Stat()
|
|
159
|
+
if err != nil {
|
|
160
|
+
return 0, fmt.Errorf("failed to stat local file: %w", err)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
remoteFile, err := s.Client.Create(remotePath)
|
|
164
|
+
if err != nil {
|
|
165
|
+
return 0, fmt.Errorf("failed to create remote file: %w", err)
|
|
166
|
+
}
|
|
167
|
+
defer remoteFile.Close()
|
|
168
|
+
|
|
169
|
+
written, err := io.Copy(remoteFile, localFile)
|
|
170
|
+
if err != nil {
|
|
171
|
+
return 0, fmt.Errorf("failed to upload file: %w", err)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Set file permissions
|
|
175
|
+
s.Client.Chmod(remotePath, localInfo.Mode())
|
|
176
|
+
|
|
177
|
+
return written, nil
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Download downloads a remote file to local
|
|
181
|
+
func (s *SFTPClient) Download(remotePath, localPath string) (int64, error) {
|
|
182
|
+
remoteFile, err := s.Client.Open(remotePath)
|
|
183
|
+
if err != nil {
|
|
184
|
+
return 0, fmt.Errorf("failed to open remote file: %w", err)
|
|
185
|
+
}
|
|
186
|
+
defer remoteFile.Close()
|
|
187
|
+
|
|
188
|
+
localFile, err := os.Create(localPath)
|
|
189
|
+
if err != nil {
|
|
190
|
+
return 0, fmt.Errorf("failed to create local file: %w", err)
|
|
191
|
+
}
|
|
192
|
+
defer localFile.Close()
|
|
193
|
+
|
|
194
|
+
written, err := io.Copy(localFile, remoteFile)
|
|
195
|
+
if err != nil {
|
|
196
|
+
return 0, fmt.Errorf("failed to download file: %w", err)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Get remote file info to set local permissions
|
|
200
|
+
remoteInfo, err := s.Client.Stat(remotePath)
|
|
201
|
+
if err == nil {
|
|
202
|
+
os.Chmod(localPath, remoteInfo.Mode())
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return written, nil
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// List lists files in a remote directory
|
|
209
|
+
func (s *SFTPClient) List(path string) ([]string, error) {
|
|
210
|
+
entries, err := s.Client.ReadDir(path)
|
|
211
|
+
if err != nil {
|
|
212
|
+
return nil, fmt.Errorf("failed to read directory: %w", err)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
names := make([]string, 0, len(entries))
|
|
216
|
+
for _, entry := range entries {
|
|
217
|
+
names = append(names, entry.Name())
|
|
218
|
+
}
|
|
219
|
+
return names, nil
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Mkdir creates a remote directory
|
|
223
|
+
func (s *SFTPClient) Mkdir(path string) error {
|
|
224
|
+
return s.Client.Mkdir(path)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Remove removes a remote file
|
|
228
|
+
func (s *SFTPClient) Remove(path string) error {
|
|
229
|
+
return s.Client.Remove(path)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Stat gets remote file info
|
|
233
|
+
func (s *SFTPClient) Stat(path string) (os.FileInfo, error) {
|
|
234
|
+
return s.Client.Stat(path)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Close closes the SFTP client
|
|
238
|
+
func (s *SFTPClient) Close() error {
|
|
239
|
+
return s.Client.Close()
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// TransferResult represents the result of a file transfer
|
|
243
|
+
type TransferResult struct {
|
|
244
|
+
Bytes int64
|
|
245
|
+
Duration time.Duration
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// TransferWithProgress uploads or downloads a file with progress callback
|
|
249
|
+
func (s *SFTPClient) TransferWithProgress(isUpload bool, src, dst string, progress func(int64)) (*TransferResult, error) {
|
|
250
|
+
start := time.Now()
|
|
251
|
+
|
|
252
|
+
if isUpload {
|
|
253
|
+
result, err := s.uploadWithProgress(src, dst, progress)
|
|
254
|
+
if err != nil {
|
|
255
|
+
return nil, err
|
|
256
|
+
}
|
|
257
|
+
return &TransferResult{
|
|
258
|
+
Bytes: result,
|
|
259
|
+
Duration: time.Since(start),
|
|
260
|
+
}, nil
|
|
261
|
+
} else {
|
|
262
|
+
result, err := s.downloadWithProgress(src, dst, progress)
|
|
263
|
+
if err != nil {
|
|
264
|
+
return nil, err
|
|
265
|
+
}
|
|
266
|
+
return &TransferResult{
|
|
267
|
+
Bytes: result,
|
|
268
|
+
Duration: time.Since(start),
|
|
269
|
+
}, nil
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
func (s *SFTPClient) uploadWithProgress(localPath, remotePath string, progress func(int64)) (int64, error) {
|
|
274
|
+
localFile, err := os.Open(localPath)
|
|
275
|
+
if err != nil {
|
|
276
|
+
return 0, fmt.Errorf("failed to open local file: %w", err)
|
|
277
|
+
}
|
|
278
|
+
defer localFile.Close()
|
|
279
|
+
|
|
280
|
+
localInfo, err := localFile.Stat()
|
|
281
|
+
if err != nil {
|
|
282
|
+
return 0, fmt.Errorf("failed to stat local file: %w", err)
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
remoteFile, err := s.Client.Create(remotePath)
|
|
286
|
+
if err != nil {
|
|
287
|
+
return 0, fmt.Errorf("failed to create remote file: %w", err)
|
|
288
|
+
}
|
|
289
|
+
defer remoteFile.Close()
|
|
290
|
+
|
|
291
|
+
// Create a writer with progress tracking
|
|
292
|
+
writer := &progressWriter{
|
|
293
|
+
w: remoteFile,
|
|
294
|
+
progress: progress,
|
|
295
|
+
total: localInfo.Size(),
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
written, err := io.Copy(writer, localFile)
|
|
299
|
+
if err != nil {
|
|
300
|
+
return 0, fmt.Errorf("failed to upload file: %w", err)
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Set file permissions
|
|
304
|
+
s.Client.Chmod(remotePath, localInfo.Mode())
|
|
305
|
+
|
|
306
|
+
return written, nil
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
func (s *SFTPClient) downloadWithProgress(remotePath, localPath string, progress func(int64)) (int64, error) {
|
|
310
|
+
remoteFile, err := s.Client.Open(remotePath)
|
|
311
|
+
if err != nil {
|
|
312
|
+
return 0, fmt.Errorf("failed to open remote file: %w", err)
|
|
313
|
+
}
|
|
314
|
+
defer remoteFile.Close()
|
|
315
|
+
|
|
316
|
+
remoteInfo, err := s.Client.Stat(remotePath)
|
|
317
|
+
if err != nil {
|
|
318
|
+
return 0, fmt.Errorf("failed to stat remote file: %w", err)
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
localFile, err := os.Create(localPath)
|
|
322
|
+
if err != nil {
|
|
323
|
+
return 0, fmt.Errorf("failed to create local file: %w", err)
|
|
324
|
+
}
|
|
325
|
+
defer localFile.Close()
|
|
326
|
+
|
|
327
|
+
// Create a writer with progress tracking
|
|
328
|
+
writer := &progressWriter{
|
|
329
|
+
w: localFile,
|
|
330
|
+
progress: progress,
|
|
331
|
+
total: remoteInfo.Size(),
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
written, err := io.Copy(writer, remoteFile)
|
|
335
|
+
if err != nil {
|
|
336
|
+
return 0, fmt.Errorf("failed to download file: %w", err)
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Set local file permissions
|
|
340
|
+
os.Chmod(localPath, remoteInfo.Mode())
|
|
341
|
+
|
|
342
|
+
return written, nil
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
type progressWriter struct {
|
|
346
|
+
w io.Writer
|
|
347
|
+
progress func(int64)
|
|
348
|
+
total int64
|
|
349
|
+
written int64
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
func (pw *progressWriter) Write(p []byte) (n int, err error) {
|
|
353
|
+
n, err = pw.w.Write(p)
|
|
354
|
+
pw.written += int64(n)
|
|
355
|
+
if pw.progress != nil {
|
|
356
|
+
pw.progress(pw.written)
|
|
357
|
+
}
|
|
358
|
+
return
|
|
359
|
+
}
|
|
360
|
+
|
|
133
361
|
// expandPath expands ~ to home directory
|
|
134
362
|
func expandPath(path string) string {
|
|
135
363
|
if len(path) > 0 && path[0] == '~' {
|
|
@@ -88,3 +88,26 @@ type ForwardCloseParams struct {
|
|
|
88
88
|
type UseParams struct {
|
|
89
89
|
SessionID string `json:"session_id"`
|
|
90
90
|
}
|
|
91
|
+
|
|
92
|
+
// SCPParams represents SCP transfer parameters
|
|
93
|
+
type SCPParams struct {
|
|
94
|
+
SessionID string `json:"session_id,omitempty"`
|
|
95
|
+
Source string `json:"source"`
|
|
96
|
+
Dest string `json:"dest"`
|
|
97
|
+
IsUpload bool `json:"is_upload"` // true = upload (local->remote), false = download (remote->local)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// SCPResult represents SCP transfer result
|
|
101
|
+
type SCPResult struct {
|
|
102
|
+
Success bool `json:"success"`
|
|
103
|
+
Message string `json:"message"`
|
|
104
|
+
Bytes int64 `json:"bytes"`
|
|
105
|
+
Duration int64 `json:"duration_ms"`
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// SFTPParams represents SFTP command parameters
|
|
109
|
+
type SFTPParams struct {
|
|
110
|
+
SessionID string `json:"session_id,omitempty"`
|
|
111
|
+
Command string `json:"command"` // "ls", "cd", "pwd", "mkdir", "rm", "rmdir"
|
|
112
|
+
Path string `json:"path"`
|
|
113
|
+
}
|
|
@@ -314,6 +314,164 @@ func (m *Manager) CloseForward(forwardID string) error {
|
|
|
314
314
|
return nil
|
|
315
315
|
}
|
|
316
316
|
|
|
317
|
+
// SCP uploads or downloads files via SFTP
|
|
318
|
+
func (m *Manager) SCP(sessionID, source, dest string, isUpload bool) (*protocol.SCPResult, error) {
|
|
319
|
+
m.mu.RLock()
|
|
320
|
+
var ms *ManagedSession
|
|
321
|
+
if sessionID != "" {
|
|
322
|
+
ms = m.sessions[sessionID]
|
|
323
|
+
} else if m.defaultID != "" {
|
|
324
|
+
ms = m.sessions[m.defaultID]
|
|
325
|
+
}
|
|
326
|
+
m.mu.RUnlock()
|
|
327
|
+
|
|
328
|
+
if ms == nil {
|
|
329
|
+
return nil, fmt.Errorf("session not found")
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
ms.mu.RLock()
|
|
333
|
+
sshClient := ms.SSHClient
|
|
334
|
+
ms.mu.RUnlock()
|
|
335
|
+
|
|
336
|
+
if sshClient == nil {
|
|
337
|
+
return nil, fmt.Errorf("session not connected")
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Create SFTP client
|
|
341
|
+
sftpClient, err := sshClient.NewSFTPClient()
|
|
342
|
+
if err != nil {
|
|
343
|
+
return nil, fmt.Errorf("failed to create SFTP client: %w", err)
|
|
344
|
+
}
|
|
345
|
+
defer sftpClient.Close()
|
|
346
|
+
|
|
347
|
+
start := time.Now()
|
|
348
|
+
|
|
349
|
+
var bytes int64
|
|
350
|
+
var transferErr error
|
|
351
|
+
|
|
352
|
+
if isUpload {
|
|
353
|
+
// Upload: source is local, dest is remote
|
|
354
|
+
bytes, transferErr = sftpClient.Upload(source, dest)
|
|
355
|
+
} else {
|
|
356
|
+
// Download: source is remote, dest is local
|
|
357
|
+
bytes, transferErr = sftpClient.Download(source, dest)
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
duration := time.Since(start).Milliseconds()
|
|
361
|
+
|
|
362
|
+
if transferErr != nil {
|
|
363
|
+
return &protocol.SCPResult{
|
|
364
|
+
Success: false,
|
|
365
|
+
Message: transferErr.Error(),
|
|
366
|
+
Bytes: bytes,
|
|
367
|
+
Duration: duration,
|
|
368
|
+
}, nil
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
return &protocol.SCPResult{
|
|
372
|
+
Success: true,
|
|
373
|
+
Message: fmt.Sprintf("Transferred %d bytes in %dms", bytes, duration),
|
|
374
|
+
Bytes: bytes,
|
|
375
|
+
Duration: duration,
|
|
376
|
+
}, nil
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// SFTPList lists files in a remote directory
|
|
380
|
+
func (m *Manager) SFTPList(sessionID, path string) ([]string, error) {
|
|
381
|
+
m.mu.RLock()
|
|
382
|
+
var ms *ManagedSession
|
|
383
|
+
if sessionID != "" {
|
|
384
|
+
ms = m.sessions[sessionID]
|
|
385
|
+
} else if m.defaultID != "" {
|
|
386
|
+
ms = m.sessions[m.defaultID]
|
|
387
|
+
}
|
|
388
|
+
m.mu.RUnlock()
|
|
389
|
+
|
|
390
|
+
if ms == nil {
|
|
391
|
+
return nil, fmt.Errorf("session not found")
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
ms.mu.RLock()
|
|
395
|
+
sshClient := ms.SSHClient
|
|
396
|
+
ms.mu.RUnlock()
|
|
397
|
+
|
|
398
|
+
if sshClient == nil {
|
|
399
|
+
return nil, fmt.Errorf("session not connected")
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
sftpClient, err := sshClient.NewSFTPClient()
|
|
403
|
+
if err != nil {
|
|
404
|
+
return nil, fmt.Errorf("failed to create SFTP client: %w", err)
|
|
405
|
+
}
|
|
406
|
+
defer sftpClient.Close()
|
|
407
|
+
|
|
408
|
+
return sftpClient.List(path)
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// SFTPMkdir creates a remote directory
|
|
412
|
+
func (m *Manager) SFTPMkdir(sessionID, path string) error {
|
|
413
|
+
m.mu.RLock()
|
|
414
|
+
var ms *ManagedSession
|
|
415
|
+
if sessionID != "" {
|
|
416
|
+
ms = m.sessions[sessionID]
|
|
417
|
+
} else if m.defaultID != "" {
|
|
418
|
+
ms = m.sessions[m.defaultID]
|
|
419
|
+
}
|
|
420
|
+
m.mu.RUnlock()
|
|
421
|
+
|
|
422
|
+
if ms == nil {
|
|
423
|
+
return fmt.Errorf("session not found")
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
ms.mu.RLock()
|
|
427
|
+
sshClient := ms.SSHClient
|
|
428
|
+
ms.mu.RUnlock()
|
|
429
|
+
|
|
430
|
+
if sshClient == nil {
|
|
431
|
+
return fmt.Errorf("session not connected")
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
sftpClient, err := sshClient.NewSFTPClient()
|
|
435
|
+
if err != nil {
|
|
436
|
+
return fmt.Errorf("failed to create SFTP client: %w", err)
|
|
437
|
+
}
|
|
438
|
+
defer sftpClient.Close()
|
|
439
|
+
|
|
440
|
+
return sftpClient.Mkdir(path)
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// SFTPRemove removes a remote file
|
|
444
|
+
func (m *Manager) SFTPRemove(sessionID, path string) error {
|
|
445
|
+
m.mu.RLock()
|
|
446
|
+
var ms *ManagedSession
|
|
447
|
+
if sessionID != "" {
|
|
448
|
+
ms = m.sessions[sessionID]
|
|
449
|
+
} else if m.defaultID != "" {
|
|
450
|
+
ms = m.sessions[m.defaultID]
|
|
451
|
+
}
|
|
452
|
+
m.mu.RUnlock()
|
|
453
|
+
|
|
454
|
+
if ms == nil {
|
|
455
|
+
return fmt.Errorf("session not found")
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
ms.mu.RLock()
|
|
459
|
+
sshClient := ms.SSHClient
|
|
460
|
+
ms.mu.RUnlock()
|
|
461
|
+
|
|
462
|
+
if sshClient == nil {
|
|
463
|
+
return fmt.Errorf("session not connected")
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
sftpClient, err := sshClient.NewSFTPClient()
|
|
467
|
+
if err != nil {
|
|
468
|
+
return fmt.Errorf("failed to create SFTP client: %w", err)
|
|
469
|
+
}
|
|
470
|
+
defer sftpClient.Close()
|
|
471
|
+
|
|
472
|
+
return sftpClient.Remove(path)
|
|
473
|
+
}
|
|
474
|
+
|
|
317
475
|
// monitorReconnect monitors connection and auto-reconnects
|
|
318
476
|
func (m *Manager) monitorReconnect(ms *ManagedSession) {
|
|
319
477
|
ticker := time.NewTicker(5 * time.Second)
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gssh-agent",
|
|
3
|
-
"version": "1.0.
|
|
4
|
-
"description": "SSH Session Manager for Agents",
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"description": "SSH Session Manager for Agents - Stateless SSH client with SFTP support",
|
|
5
5
|
"bin": {
|
|
6
|
-
"gssh": "bin/gssh",
|
|
7
|
-
"gssh-daemon": "bin/gssh-daemon"
|
|
6
|
+
"gssh": "./bin/gssh",
|
|
7
|
+
"gssh-daemon": "./bin/gssh-daemon"
|
|
8
8
|
},
|
|
9
9
|
"repository": {
|
|
10
10
|
"type": "git",
|
|
@@ -12,14 +12,29 @@
|
|
|
12
12
|
},
|
|
13
13
|
"keywords": [
|
|
14
14
|
"ssh",
|
|
15
|
+
"sftp",
|
|
16
|
+
"scp",
|
|
15
17
|
"agent",
|
|
16
18
|
"session-manager",
|
|
17
|
-
"port-forwarding"
|
|
19
|
+
"port-forwarding",
|
|
20
|
+
"remote-execution"
|
|
18
21
|
],
|
|
19
22
|
"author": "",
|
|
20
23
|
"license": "MIT",
|
|
21
24
|
"bugs": {
|
|
22
25
|
"url": "https://github.com/forechoandlook/gssh/issues"
|
|
23
26
|
},
|
|
24
|
-
"homepage": "https://github.com/forechoandlook/gssh#readme"
|
|
27
|
+
"homepage": "https://github.com/forechoandlook/gssh#readme",
|
|
28
|
+
"preferGlobal": true,
|
|
29
|
+
"os": [
|
|
30
|
+
"darwin",
|
|
31
|
+
"linux"
|
|
32
|
+
],
|
|
33
|
+
"cpu": [
|
|
34
|
+
"x64",
|
|
35
|
+
"arm64"
|
|
36
|
+
],
|
|
37
|
+
"engines": {
|
|
38
|
+
"node": ">=14.0.0"
|
|
39
|
+
}
|
|
25
40
|
}
|
package/pkg/rpc/handler.go
CHANGED
|
@@ -89,6 +89,34 @@ func (h *Handler) Handle(data []byte) ([]byte, error) {
|
|
|
89
89
|
}
|
|
90
90
|
err = h.manager.CloseForward(params.ForwardID)
|
|
91
91
|
|
|
92
|
+
case "scp":
|
|
93
|
+
var params protocol.SCPParams
|
|
94
|
+
if err := json.Unmarshal(req.Params, ¶ms); err != nil {
|
|
95
|
+
return h.errorResponse(req.ID, -32602, "Invalid params")
|
|
96
|
+
}
|
|
97
|
+
result, err = h.manager.SCP(params.SessionID, params.Source, params.Dest, params.IsUpload)
|
|
98
|
+
|
|
99
|
+
case "sftp_list":
|
|
100
|
+
var params protocol.SFTPParams
|
|
101
|
+
if err := json.Unmarshal(req.Params, ¶ms); err != nil {
|
|
102
|
+
return h.errorResponse(req.ID, -32602, "Invalid params")
|
|
103
|
+
}
|
|
104
|
+
result, err = h.manager.SFTPList(params.SessionID, params.Path)
|
|
105
|
+
|
|
106
|
+
case "sftp_mkdir":
|
|
107
|
+
var params protocol.SFTPParams
|
|
108
|
+
if err := json.Unmarshal(req.Params, ¶ms); err != nil {
|
|
109
|
+
return h.errorResponse(req.ID, -32602, "Invalid params")
|
|
110
|
+
}
|
|
111
|
+
err = h.manager.SFTPMkdir(params.SessionID, params.Path)
|
|
112
|
+
|
|
113
|
+
case "sftp_remove":
|
|
114
|
+
var params protocol.SFTPParams
|
|
115
|
+
if err := json.Unmarshal(req.Params, ¶ms); err != nil {
|
|
116
|
+
return h.errorResponse(req.ID, -32602, "Invalid params")
|
|
117
|
+
}
|
|
118
|
+
err = h.manager.SFTPRemove(params.SessionID, params.Path)
|
|
119
|
+
|
|
92
120
|
default:
|
|
93
121
|
return h.errorResponse(req.ID, -32601, fmt.Sprintf("Method not found: %s", req.Method))
|
|
94
122
|
}
|
package/skill.md
CHANGED
|
@@ -1,96 +1,60 @@
|
|
|
1
|
-
|
|
1
|
+
---
|
|
2
|
+
name: use-gssh
|
|
3
|
+
description: "Manage SSH sessions with gssh CLI for agents"
|
|
4
|
+
allowed-tools: Read, Write, Glob, Grep, Bash
|
|
5
|
+
triggers:
|
|
6
|
+
- gssh
|
|
7
|
+
- ssh session
|
|
8
|
+
- ssh 连接
|
|
9
|
+
- ssh 管理
|
|
10
|
+
---
|
|
2
11
|
|
|
3
12
|
## 快速开始
|
|
4
13
|
|
|
5
|
-
### 1. 安装
|
|
6
|
-
|
|
7
|
-
```bash
|
|
8
|
-
git clone <repository-url>
|
|
9
|
-
cd gssh
|
|
10
|
-
go build -o bin/daemon cmd/daemon/main.go
|
|
11
|
-
go build -o bin/gssh cmd/gssh/main.go
|
|
12
|
-
./homebrew/install.sh
|
|
13
|
-
```
|
|
14
|
-
|
|
15
|
-
### 2. 基本使用
|
|
16
|
-
|
|
17
14
|
```bash
|
|
18
|
-
# 连接 SSH
|
|
19
|
-
gssh connect -u
|
|
15
|
+
# 连接 SSH(交互式输入密码)
|
|
16
|
+
gssh connect -u admin -h 192.168.1.100 --ask-pass
|
|
20
17
|
|
|
21
18
|
# 执行命令
|
|
22
19
|
gssh exec "ls -la"
|
|
23
20
|
|
|
24
|
-
#
|
|
25
|
-
gssh
|
|
21
|
+
# 断开连接
|
|
22
|
+
gssh disconnect
|
|
26
23
|
```
|
|
27
24
|
|
|
28
|
-
##
|
|
29
|
-
|
|
30
|
-
- **daemon**: 后台服务,管理 SSH 连接
|
|
31
|
-
- **session**: 一个 SSH 连接会话
|
|
32
|
-
- **默认 session**: 不指定 session ID 时使用的会话
|
|
33
|
-
|
|
34
|
-
## 命令列表
|
|
25
|
+
## 核心命令
|
|
35
26
|
|
|
36
27
|
| 命令 | 说明 |
|
|
37
28
|
|------|------|
|
|
38
|
-
| `connect` |
|
|
29
|
+
| `connect` | 建立 SSH 连接 |
|
|
39
30
|
| `exec` | 执行命令 |
|
|
40
|
-
| `
|
|
41
|
-
| `
|
|
42
|
-
| `disconnect` | 断开 session |
|
|
43
|
-
| `reconnect` | 重连 session |
|
|
31
|
+
| `scp` | 文件传输(上/下载) |
|
|
32
|
+
| `sftp` | SFTP 操作 |
|
|
44
33
|
| `forward` | 端口转发 |
|
|
45
|
-
| `
|
|
46
|
-
| `forward-close` | 关闭转发 |
|
|
34
|
+
| `list` | 列出 session |
|
|
47
35
|
|
|
48
|
-
##
|
|
36
|
+
## 认证
|
|
49
37
|
|
|
50
38
|
```bash
|
|
51
|
-
#
|
|
52
|
-
gssh connect -u
|
|
53
|
-
|
|
54
|
-
# 2. 执行命令(使用默认 session)
|
|
55
|
-
gssh exec "pwd"
|
|
56
|
-
gssh exec "uname -a"
|
|
39
|
+
# 交互式密码(推荐)
|
|
40
|
+
gssh connect -u user -h host --ask-pass
|
|
57
41
|
|
|
58
|
-
#
|
|
59
|
-
gssh
|
|
60
|
-
|
|
61
|
-
# 4. 重连
|
|
62
|
-
gssh reconnect -s <session-id>
|
|
42
|
+
# SSH 密钥
|
|
43
|
+
gssh connect -u user -h host -i ~/.ssh/id_rsa
|
|
63
44
|
```
|
|
64
45
|
|
|
65
|
-
##
|
|
46
|
+
## sudo 命令
|
|
66
47
|
|
|
67
48
|
```bash
|
|
68
|
-
|
|
69
|
-
gssh connect -u admin -h 192.168.1.100 -i ~/.ssh/id_rsa
|
|
49
|
+
gssh exec --ask-sudo-pass "sudo apt update"
|
|
70
50
|
```
|
|
71
51
|
|
|
72
|
-
##
|
|
52
|
+
## 文件传输
|
|
73
53
|
|
|
74
54
|
```bash
|
|
75
|
-
#
|
|
76
|
-
gssh
|
|
77
|
-
```
|
|
55
|
+
# 上传
|
|
56
|
+
gssh scp -put local.txt /remote/path/
|
|
78
57
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
```bash
|
|
82
|
-
# 启动
|
|
83
|
-
brew services start gssh
|
|
84
|
-
|
|
85
|
-
# 停止
|
|
86
|
-
brew services stop gssh
|
|
87
|
-
|
|
88
|
-
# 状态
|
|
89
|
-
brew services list
|
|
58
|
+
# 下载
|
|
59
|
+
gssh scp -get /remote/file.txt ./
|
|
90
60
|
```
|
|
91
|
-
|
|
92
|
-
## 注意事项
|
|
93
|
-
|
|
94
|
-
- Agent 使用场景:无状态,每次请求独立完成
|
|
95
|
-
- sudo 命令:需要配置 passwordless sudo 或使用密钥认证
|
|
96
|
-
- Session 状态由 daemon 维护,CLI 只负责发送请求
|
package/bin/daemon
DELETED
|
Binary file
|
package/gssh-darwin-arm64.tar.gz
DELETED
|
Binary file
|
package/homebrew/gssh.plist
DELETED
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
-
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
3
|
-
<plist version="1.0">
|
|
4
|
-
<dict>
|
|
5
|
-
<key>Label</key>
|
|
6
|
-
<string>com.gssh.daemon</string>
|
|
7
|
-
<key>ProgramArguments</key>
|
|
8
|
-
<array>
|
|
9
|
-
<string>/usr/local/bin/gssh-daemon</string>
|
|
10
|
-
<string>-socket</string>
|
|
11
|
-
<string>/tmp/gssh.sock</string>
|
|
12
|
-
</array>
|
|
13
|
-
<key>RunAtLoad</key>
|
|
14
|
-
<true/>
|
|
15
|
-
<key>KeepAlive</key>
|
|
16
|
-
<true/>
|
|
17
|
-
<key>StandardOutPath</key>
|
|
18
|
-
<string>/var/log/gssh.log</string>
|
|
19
|
-
<key>StandardErrorPath</key>
|
|
20
|
-
<string>/var/log/gssh.error.log</string>
|
|
21
|
-
</dict>
|
|
22
|
-
</plist>
|
package/homebrew/install.sh
DELETED
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
#!/bin/bash
|
|
2
|
-
# gssh service installation script
|
|
3
|
-
|
|
4
|
-
set -e
|
|
5
|
-
|
|
6
|
-
# Detect architecture
|
|
7
|
-
ARCH=$(uname -m)
|
|
8
|
-
if [ "$ARCH" = "arm64" ]; then
|
|
9
|
-
PREFIX="/opt/homebrew"
|
|
10
|
-
else
|
|
11
|
-
PREFIX="/usr/local"
|
|
12
|
-
fi
|
|
13
|
-
|
|
14
|
-
DAEMON_BIN="$PREFIX/bin/gssh-daemon"
|
|
15
|
-
CLI_BIN="$PREFIX/bin/gssh"
|
|
16
|
-
|
|
17
|
-
echo "Installing gssh to $PREFIX/bin/..."
|
|
18
|
-
|
|
19
|
-
# Copy binaries
|
|
20
|
-
cp bin/daemon "$DAEMON_BIN"
|
|
21
|
-
cp bin/gssh "$CLI_BIN"
|
|
22
|
-
|
|
23
|
-
chmod +x "$DAEMON_BIN" "$CLI_BIN"
|
|
24
|
-
|
|
25
|
-
# Install launchd plist
|
|
26
|
-
PLIST_DIR="$HOME/Library/LaunchAgents"
|
|
27
|
-
mkdir -p "$PLIST_DIR"
|
|
28
|
-
cp homebrew/gssh.plist "$PLIST_DIR/"
|
|
29
|
-
|
|
30
|
-
echo "Loading gssh service..."
|
|
31
|
-
launchctl load "$PLIST_DIR/gssh.plist"
|
|
32
|
-
|
|
33
|
-
echo "gssh installed successfully!"
|
|
34
|
-
echo ""
|
|
35
|
-
echo "Usage:"
|
|
36
|
-
echo " Start: launchctl start com.gssh.daemon"
|
|
37
|
-
echo " Stop: launchctl stop com.gssh.daemon"
|
|
38
|
-
echo " Status: launchctl list | grep gssh"
|
|
39
|
-
echo ""
|
|
40
|
-
echo "Or use Homebrew services:"
|
|
41
|
-
echo " brew services start gssh"
|
|
42
|
-
echo " brew services stop gssh"
|
|
43
|
-
echo " brew services list"
|