gssh-agent 1.0.0

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/idea.md ADDED
@@ -0,0 +1,271 @@
1
+ # gssh 需求文档
2
+
3
+ ## 1. 项目概述
4
+
5
+ gssh 是一个供 Agent 使用的 SSH Session 管理工具。通过 Go 语言实现,支持:
6
+
7
+ - 多个 SSH Session 并发管理
8
+ - 命令执行与结果透传
9
+ - TCP 流量转发
10
+ - 断联自动重连
11
+ - 被连接机器零配置(普通 SSH 即可)
12
+
13
+ ## 2. 整体架构
14
+
15
+ ### 2.1 Client-Server 模式
16
+
17
+ ```
18
+ ┌─────────────────┐ IPC ┌─────────────────┐
19
+ │ gssh CLI │ ───────────▶ │ gssh daemon │
20
+ │ (Agent) │ JSON/RPC │ (后台进程) │
21
+ └─────────────────┘ └────────┬────────┘
22
+
23
+ ┌──────▼──────┐
24
+ │ Session │
25
+ │ Manager │
26
+ └──────┬──────┘
27
+
28
+ ┌────────────┼────────────┐
29
+ ▼ ▼ ▼
30
+ ┌──────────┐ ┌──────────┐ ┌──────────┐
31
+ │ Session 1│ │ Session 2│ │ Session n│
32
+ │ (SSH) │ │ (SSH) │ │ (SSH) │
33
+ └──────────┘ └──────────┘ └──────────┘
34
+ ```
35
+
36
+ ### 2.2 组件职责
37
+
38
+ | 组件 | 职责 |
39
+ |------|------|
40
+ | **gssh daemon** | 后台进程,管理所有 SSH session,处理重连 |
41
+ | **gssh CLI** | Agent 的接口工具,通过 Unix socket 与 daemon 通信 |
42
+ | **Session Manager** | 管理多个 SSH 连接,维护状态,处理断联重连 |
43
+ | **SSH Client** | 封装 SSH 连接逻辑,支持 Key/密码/键盘交互认证 |
44
+ | **Port Forwarder** | 处理 TCP 流量转发 |
45
+
46
+ ## 3. 功能需求
47
+
48
+ ### 3.1 连接管理
49
+
50
+ - [x] 建立 SSH 连接,创建新 session
51
+ - [x] 断开指定 session
52
+ - [x] 手动重连指定 session
53
+ - [x] 列出所有 session(显示 ID、主机、状态)
54
+ - [x] 切换默认 session
55
+
56
+ ### 3.2 命令执行
57
+
58
+ - [x] 在指定 session 执行命令
59
+ - [x] 在默认 session 执行命令
60
+ - [x] 返回 stdout + stderr
61
+ - [x] 返回命令退出码
62
+
63
+ ### 3.3 端口转发
64
+
65
+ - [x] 本地端口转发(-l: local -> remote)
66
+ - [ ] 远程端口转发(-R: remote -> local)
67
+ - [x] 列出所有转发
68
+ - [x] 关闭指定转发
69
+
70
+ ### 3.4 重连机制
71
+
72
+ - [x] 检测连接断开
73
+ - [x] 立即自动重连
74
+ - [ ] 重连成功后恢复端口转发
75
+
76
+ ### 3.5 认证支持
77
+
78
+ - [x] SSH Key 认证
79
+ - [x] 密码认证
80
+ - [x] 键盘交互认证
81
+
82
+ ### 3.6 被连接机器要求
83
+
84
+ - 标准 SSH 服务
85
+ - 可用 SSH 账号
86
+ - **零配置**,无需安装任何 agent
87
+
88
+ ## 4. 命令行接口
89
+
90
+ ### 4.1 连接管理命令
91
+
92
+ ```bash
93
+ # 建立新 session
94
+ gssh connect -u user -h host [-p port] [-i key_path] [-P password]
95
+
96
+ # 断开指定 session
97
+ gssh disconnect [-s session_id]
98
+
99
+ # 重连指定 session
100
+ gssh reconnect [-s session_id]
101
+
102
+ # 列出所有 session
103
+ gssh list
104
+
105
+ # 切换默认 session
106
+ gssh use <session_id>
107
+ ```
108
+
109
+ ### 4.2 命令执行命令
110
+
111
+ ```bash
112
+ # 在默认 session 执行命令
113
+ gssh exec "ls -la"
114
+
115
+ # 在指定 session 执行命令
116
+ gssh exec -s <session_id> "ls"
117
+ ```
118
+
119
+ ### 4.3 端口转发命令
120
+
121
+ ```bash
122
+ # 本地端口转发:本地 8080 -> 远程 80
123
+ gssh forward -l 8080 -r 80
124
+
125
+ # 远程端口转发:远程 9000 -> 本地 9000
126
+ gssh forward -R 9000 -r 9000
127
+
128
+ # 列出所有转发
129
+ gssh forwards
130
+
131
+ # 关闭指定转发
132
+ gssh forward-close <id>
133
+ ```
134
+
135
+ ## 5. 通信协议
136
+
137
+ - **daemon <-> CLI**: 通过 Unix socket (`/tmp/gssh.sock`) 通信
138
+ - **协议格式**: JSON-RPC 2.0
139
+ - **认证**: 使用 Unix socket 权限控制
140
+
141
+ ## 6. Session 状态
142
+
143
+ ```go
144
+ type Session struct {
145
+ ID string // session ID (UUID)
146
+ Host string // 主机地址
147
+ User string // 用户名
148
+ Port int // 端口
149
+ Status string // connected, disconnected, reconnecting
150
+ KeyPath string // SSH 密钥路径
151
+ }
152
+ ```
153
+
154
+ ## 7. 输出格式
155
+
156
+ ### 7.1 list 命令输出示例
157
+
158
+ ```
159
+ ID HOST USER STATUS
160
+ 8d89a0a0-ab03-4a1a-a818-101c6b2f6155 139.196.175.163 admin1 connected
161
+ ```
162
+
163
+ ### 7.2 exec 命令输出格式
164
+
165
+ ```json
166
+ {
167
+ "stdout": "...",
168
+ "stderr": "...",
169
+ "exit_code": 0
170
+ }
171
+ ```
172
+
173
+ ### 7.3 forwards 命令输出示例
174
+
175
+ ```
176
+ ID LOCAL REMOTE TYPE
177
+ 1 8080 80 local
178
+ 2 9000 9000 remote
179
+ ```
180
+
181
+ ## 8. 使用示例
182
+
183
+ ### 8.1 密码认证连接
184
+
185
+ ```bash
186
+ # 启动 daemon
187
+ gssh daemon &
188
+
189
+ # 连接 SSH
190
+ gssh connect -u admin1 -h 139.196.175.163 -p 7080 -P 1234
191
+
192
+ # 执行命令
193
+ gssh exec "ls -la"
194
+ ```
195
+
196
+ ### 8.2 密钥认证连接
197
+
198
+ ```bash
199
+ gssh connect -u admin1 -h 139.196.175.163 -p 7080 -i ~/.ssh/id_rsa
200
+ ```
201
+
202
+ ### 8.3 Session 管理
203
+
204
+ ```bash
205
+ # 列出所有 session
206
+ gssh list
207
+
208
+ # 切换默认 session
209
+ gssh use <session_id>
210
+
211
+ # 断开 session
212
+ gssh disconnect -s <session_id>
213
+
214
+ # 重连 session
215
+ gssh reconnect -s <session_id>
216
+ ```
217
+
218
+ ## 9. 项目结构
219
+
220
+ ```
221
+ gssh/
222
+ ├── cmd/
223
+ │ ├── daemon/main.go # daemon 主程序
224
+ │ └── gssh/main.go # CLI 主程序
225
+ ├── internal/
226
+ │ ├── client/
227
+ │ │ ├── ssh.go # SSH 客户端封装
228
+ │ │ └── ssh_test.go # SSH 客户端测试
229
+ │ ├── portforward/
230
+ │ │ └── forwarder.go # 端口转发实现
231
+ │ ├── protocol/
232
+ │ │ ├── types.go # 协议类型定义
233
+ │ │ └── types_test.go # 协议类型测试
234
+ │ └── session/
235
+ │ ├── manager.go # Session 管理器
236
+ │ └── manager_test.go # Session 管理器测试
237
+ ├── pkg/
238
+ │ └── rpc/
239
+ │ └── handler.go # RPC 处理器
240
+ ├── go.mod
241
+ └── .gitignore
242
+ ```
243
+
244
+ ## 10. 测试结果
245
+
246
+ ### 单元测试
247
+
248
+ ```
249
+ === RUN TestExpandPath PASS
250
+ === RUN TestNewAuthMethodsFromKeyPath PASS
251
+ === RUN TestSessionJSON PASS
252
+ === RUN TestExecResultJSON PASS
253
+ === RUN TestForwardJSON PASS
254
+ === RUN TestNewManager PASS
255
+ === RUN TestManagerList PASS
256
+ === RUN TestManagerUse PASS
257
+ === RUN TestManagerGetDefaultID PASS
258
+ === RUN TestManagerListForwards PASS
259
+ ```
260
+
261
+ ### 功能测试(密码认证)
262
+
263
+ | 测试项 | 结果 |
264
+ |--------|------|
265
+ | 连接 SSH | ✅ 通过 |
266
+ | 列出 session | ✅ 通过 |
267
+ | 执行命令(默认 session) | ✅ 通过 |
268
+ | 执行命令(指定 session ID) | ✅ 通过 |
269
+ | 断开 session | ✅ 通过 |
270
+ | 重连 session | ✅ 通过 |
271
+ | 重连后执行命令 | ✅ 通过 |
@@ -0,0 +1,143 @@
1
+ package client
2
+
3
+ import (
4
+ "fmt"
5
+ "net"
6
+ "os"
7
+ "os/user"
8
+ "path/filepath"
9
+
10
+ "golang.org/x/crypto/ssh"
11
+ )
12
+
13
+ // SSHClient encapsulates SSH connection logic
14
+ type SSHClient struct {
15
+ Client *ssh.Client
16
+ }
17
+
18
+ // KeyboardInteractiveHandler handles keyboard-interactive authentication
19
+ type KeyboardInteractiveHandler struct {
20
+ Password string
21
+ }
22
+
23
+ // Challenge implements ssh.KeyboardInteractiveChallenge
24
+ func (k *KeyboardInteractiveHandler) Challenge(name, instruction string, questions []string, echoes []bool) ([]string, error) {
25
+ answers := make([]string, len(questions))
26
+ for i := range questions {
27
+ answers[i] = k.Password
28
+ }
29
+ return answers, nil
30
+ }
31
+
32
+ // NewSSHClient creates a new SSH client
33
+ func NewSSHClient(user, host string, port int, authMethods ...ssh.AuthMethod) (*SSHClient, error) {
34
+ config := &ssh.ClientConfig{
35
+ User: user,
36
+ Auth: authMethods,
37
+ HostKeyCallback: ssh.InsecureIgnoreHostKey(),
38
+ }
39
+
40
+ addr := fmt.Sprintf("%s:%d", host, port)
41
+ client, err := ssh.Dial("tcp", addr, config)
42
+ if err != nil {
43
+ return nil, fmt.Errorf("failed to connect to %s: %w", addr, err)
44
+ }
45
+
46
+ return &SSHClient{Client: client}, nil
47
+ }
48
+
49
+ // NewAuthMethodsFromKeyPath creates SSH auth methods from a key file
50
+ func NewAuthMethodsFromKeyPath(keyPath string) ([]ssh.AuthMethod, error) {
51
+ keyPath = expandPath(keyPath)
52
+
53
+ key, err := os.ReadFile(keyPath)
54
+ if err != nil {
55
+ return nil, fmt.Errorf("unable to read private key: %w", err)
56
+ }
57
+
58
+ signer, err := ssh.ParsePrivateKey(key)
59
+ if err != nil {
60
+ return nil, fmt.Errorf("unable to parse private key: %w", err)
61
+ }
62
+
63
+ return []ssh.AuthMethod{ssh.PublicKeys(signer)}, nil
64
+ }
65
+
66
+ // NewAuthMethodFromPassword creates SSH auth method from password
67
+ func NewAuthMethodFromPassword(password string) ssh.AuthMethod {
68
+ return ssh.Password(password)
69
+ }
70
+
71
+ // NewAuthMethodFromKeyboardInteractive creates keyboard-interactive auth
72
+ func NewAuthMethodFromKeyboardInteractive(password string) ssh.AuthMethod {
73
+ handler := &KeyboardInteractiveHandler{Password: password}
74
+ return ssh.KeyboardInteractive(handler.Challenge)
75
+ }
76
+
77
+ // Connect establishes an SSH connection
78
+ func Connect(user, host string, port int, password, keyPath string) (*SSHClient, error) {
79
+ var authMethods []ssh.AuthMethod
80
+
81
+ if keyPath != "" {
82
+ methods, err := NewAuthMethodsFromKeyPath(keyPath)
83
+ if err != nil {
84
+ return nil, err
85
+ }
86
+ authMethods = append(authMethods, methods...)
87
+ }
88
+
89
+ if password != "" {
90
+ // Try both password and keyboard-interactive
91
+ authMethods = append(authMethods, NewAuthMethodFromPassword(password))
92
+ authMethods = append(authMethods, NewAuthMethodFromKeyboardInteractive(password))
93
+ }
94
+
95
+ // If no auth methods, return error
96
+ if len(authMethods) == 0 {
97
+ return nil, fmt.Errorf("no authentication method provided")
98
+ }
99
+
100
+ return NewSSHClient(user, host, port, authMethods...)
101
+ }
102
+
103
+ // Exec executes a command on the remote host
104
+ func (c *SSHClient) Exec(cmd string) (string, string, int, error) {
105
+ session, err := c.Client.NewSession()
106
+ if err != nil {
107
+ return "", "", 0, fmt.Errorf("failed to create session: %w", err)
108
+ }
109
+ defer session.Close()
110
+
111
+ output, err := session.CombinedOutput(cmd)
112
+ if err != nil {
113
+ exitErr, ok := err.(*ssh.ExitError)
114
+ if ok {
115
+ return string(output), "", exitErr.ExitStatus(), nil
116
+ }
117
+ return string(output), "", 0, err
118
+ }
119
+
120
+ return string(output), "", 0, nil
121
+ }
122
+
123
+ // Close closes the SSH connection
124
+ func (c *SSHClient) Close() error {
125
+ return c.Client.Close()
126
+ }
127
+
128
+ // LocalForward creates a local port forward (localhost -> remote)
129
+ func (c *SSHClient) LocalForward(localPort, remotePort int) (net.Listener, error) {
130
+ return c.Client.Listen("tcp", fmt.Sprintf("localhost:%d", localPort))
131
+ }
132
+
133
+ // expandPath expands ~ to home directory
134
+ func expandPath(path string) string {
135
+ if len(path) > 0 && path[0] == '~' {
136
+ usr, err := user.Current()
137
+ if err != nil {
138
+ return path
139
+ }
140
+ return filepath.Join(usr.HomeDir, path[1:])
141
+ }
142
+ return path
143
+ }
@@ -0,0 +1,33 @@
1
+ package client
2
+
3
+ import (
4
+ "os"
5
+ "path/filepath"
6
+ "testing"
7
+ )
8
+
9
+ func TestExpandPath(t *testing.T) {
10
+ // Test normal path
11
+ path := "/tmp/test"
12
+ result := expandPath(path)
13
+ if result != path {
14
+ t.Errorf("expected %s, got %s", path, result)
15
+ }
16
+
17
+ // Test with tilde
18
+ home := os.Getenv("HOME")
19
+ path = "~/test"
20
+ result = expandPath(path)
21
+ expected := filepath.Join(home, "test")
22
+ if result != expected {
23
+ t.Errorf("expected %s, got %s", expected, result)
24
+ }
25
+ }
26
+
27
+ func TestNewAuthMethodsFromKeyPath(t *testing.T) {
28
+ // Test with non-existent key
29
+ _, err := NewAuthMethodsFromKeyPath("/nonexistent/key")
30
+ if err == nil {
31
+ t.Error("expected error for non-existent key")
32
+ }
33
+ }
@@ -0,0 +1,187 @@
1
+ package portforward
2
+
3
+ import (
4
+ "fmt"
5
+ "io"
6
+ "net"
7
+ "sync"
8
+
9
+ "golang.org/x/crypto/ssh"
10
+ )
11
+
12
+ // Forwarder handles SSH port forwarding
13
+ type Forwarder struct {
14
+ ID string
15
+ Type string // "local" or "remote"
16
+ LocalPort int
17
+ RemotePort int
18
+
19
+ sshClient *ssh.Client
20
+ listener net.Listener
21
+ conns map[net.Conn]bool
22
+ mu sync.RWMutex
23
+ closed bool
24
+ wg sync.WaitGroup
25
+ }
26
+
27
+ // NewForwarder creates a new port forwarder
28
+ func NewForwarder(sshClient *ssh.Client, forwardType string, localPort, remotePort int) (*Forwarder, error) {
29
+ f := &Forwarder{
30
+ Type: forwardType,
31
+ LocalPort: localPort,
32
+ RemotePort: remotePort,
33
+ sshClient: sshClient,
34
+ conns: make(map[net.Conn]bool),
35
+ }
36
+
37
+ if forwardType == "local" {
38
+ // Local port forward: localhost:localPort -> remote:remotePort
39
+ addr := fmt.Sprintf("localhost:%d", localPort)
40
+ listener, err := net.Listen("tcp", addr)
41
+ if err != nil {
42
+ return nil, fmt.Errorf("failed to listen on %s: %w", addr, err)
43
+ }
44
+ f.listener = listener
45
+ }
46
+
47
+ return f, nil
48
+ }
49
+
50
+ // Start starts the port forwarder
51
+ func (f *Forwarder) Start() {
52
+ if f.Type == "local" {
53
+ f.startLocalForward()
54
+ } else {
55
+ f.startRemoteForward()
56
+ }
57
+ }
58
+
59
+ // startLocalForward handles local port forwarding
60
+ func (f *Forwarder) startLocalForward() {
61
+ f.wg.Add(1)
62
+ go func() {
63
+ defer f.wg.Done()
64
+ for {
65
+ f.mu.RLock()
66
+ if f.closed {
67
+ f.mu.RUnlock()
68
+ return
69
+ }
70
+ f.mu.RUnlock()
71
+
72
+ conn, err := f.listener.Accept()
73
+ if err != nil {
74
+ f.mu.RLock()
75
+ closed := f.closed
76
+ f.mu.RUnlock()
77
+ if closed {
78
+ return
79
+ }
80
+ continue
81
+ }
82
+
83
+ f.mu.Lock()
84
+ f.conns[conn] = true
85
+ f.mu.Unlock()
86
+
87
+ go f.handleLocalConnection(conn)
88
+ }
89
+ }()
90
+ }
91
+
92
+ // handleLocalConnection handles a local connection
93
+ func (f *Forwarder) handleLocalConnection(localConn net.Conn) {
94
+ defer func() {
95
+ localConn.Close()
96
+ f.mu.Lock()
97
+ delete(f.conns, localConn)
98
+ f.mu.Unlock()
99
+ }()
100
+
101
+ remoteAddr := fmt.Sprintf("localhost:%d", f.RemotePort)
102
+ remoteConn, err := f.sshClient.Dial("tcp", remoteAddr)
103
+ if err != nil {
104
+ return
105
+ }
106
+ defer remoteConn.Close()
107
+
108
+ // Bidirectional copy
109
+ done := make(chan struct{})
110
+ go func() {
111
+ io.Copy(remoteConn, localConn)
112
+ remoteConn.Close()
113
+ localConn.Close()
114
+ close(done)
115
+ }()
116
+
117
+ io.Copy(localConn, remoteConn)
118
+ <-done
119
+ }
120
+
121
+ // startRemoteForward handles remote port forwarding
122
+ func (f *Forwarder) startRemoteForward() {
123
+ // Remote port forwarding: remote:remotePort -> localhost:localPort
124
+ f.wg.Add(1)
125
+ go func() {
126
+ defer f.wg.Done()
127
+
128
+ // Request the server to forward remote port
129
+ // This is a simplified implementation
130
+ for {
131
+ f.mu.RLock()
132
+ if f.closed {
133
+ f.mu.RUnlock()
134
+ return
135
+ }
136
+ f.mu.RUnlock()
137
+
138
+ // Wait for connections on remote side
139
+ // In practice, SSH server handles this
140
+ // This would need SSH channel forwarding
141
+ }
142
+ }()
143
+ }
144
+
145
+ // Close closes the port forwarder
146
+ func (f *Forwarder) Close() {
147
+ f.mu.Lock()
148
+ if f.closed {
149
+ f.mu.Unlock()
150
+ return
151
+ }
152
+ f.closed = true
153
+ f.mu.Unlock()
154
+
155
+ if f.listener != nil {
156
+ f.listener.Close()
157
+ }
158
+
159
+ for conn := range f.conns {
160
+ conn.Close()
161
+ }
162
+
163
+ f.wg.Wait()
164
+ }
165
+
166
+ // Restart restarts the forwarder with a new SSH client
167
+ func (f *Forwarder) Restart(sshClient *ssh.Client) {
168
+ f.mu.Lock()
169
+ f.sshClient = sshClient
170
+ f.closed = false
171
+ f.conns = make(map[net.Conn]bool)
172
+ f.mu.Unlock()
173
+
174
+ if f.listener != nil {
175
+ f.listener.Close()
176
+ }
177
+
178
+ // Create new listener
179
+ addr := fmt.Sprintf("localhost:%d", f.LocalPort)
180
+ listener, err := net.Listen("tcp", addr)
181
+ if err != nil {
182
+ return
183
+ }
184
+ f.listener = listener
185
+
186
+ f.Start()
187
+ }
@@ -0,0 +1,90 @@
1
+ package protocol
2
+
3
+ import "encoding/json"
4
+
5
+ // Session represents an SSH session
6
+ type Session struct {
7
+ ID string `json:"id"`
8
+ Host string `json:"host"`
9
+ User string `json:"user"`
10
+ Port int `json:"port"`
11
+ Status string `json:"status"`
12
+ LastCmd string `json:"last_cmd,omitempty"`
13
+ Password string `json:"-"`
14
+ KeyPath string `json:"key_path,omitempty"`
15
+ }
16
+
17
+ // Forward represents a port forward
18
+ type Forward struct {
19
+ ID string `json:"id"`
20
+ Type string `json:"type"` // "local" or "remote"
21
+ Local int `json:"local"`
22
+ Remote int `json:"remote"`
23
+ }
24
+
25
+ // ExecResult represents command execution result
26
+ type ExecResult struct {
27
+ Stdout string `json:"stdout"`
28
+ Stderr string `json:"stderr"`
29
+ ExitCode int `json:"exit_code"`
30
+ }
31
+
32
+ // Request represents a JSON-RPC request
33
+ type Request struct {
34
+ JSONRPC string `json:"jsonrpc"`
35
+ Method string `json:"method"`
36
+ Params json.RawMessage `json:"params,omitempty"`
37
+ ID interface{} `json:"id"`
38
+ }
39
+
40
+ // Response represents a JSON-RPC response
41
+ type Response struct {
42
+ JSONRPC string `json:"jsonrpc"`
43
+ Result interface{} `json:"result,omitempty"`
44
+ Error *Error `json:"error,omitempty"`
45
+ ID interface{} `json:"id"`
46
+ }
47
+
48
+ // Error represents a JSON-RPC error
49
+ type Error struct {
50
+ Code int `json:"code"`
51
+ Message string `json:"message"`
52
+ Data interface{} `json:"data,omitempty"`
53
+ }
54
+
55
+ // Request params types
56
+ type ConnectParams struct {
57
+ User string `json:"user"`
58
+ Host string `json:"host"`
59
+ Port int `json:"port"`
60
+ Password string `json:"password,omitempty"`
61
+ KeyPath string `json:"key_path,omitempty"`
62
+ }
63
+
64
+ type DisconnectParams struct {
65
+ SessionID string `json:"session_id"`
66
+ }
67
+
68
+ type ReconnectParams struct {
69
+ SessionID string `json:"session_id"`
70
+ }
71
+
72
+ type ExecParams struct {
73
+ SessionID string `json:"session_id,omitempty"`
74
+ Command string `json:"command"`
75
+ }
76
+
77
+ type ForwardParams struct {
78
+ SessionID string `json:"session_id,omitempty"`
79
+ Type string `json:"type"` // "local" or "remote"
80
+ Local int `json:"local"`
81
+ Remote int `json:"remote"`
82
+ }
83
+
84
+ type ForwardCloseParams struct {
85
+ ForwardID string `json:"forward_id"`
86
+ }
87
+
88
+ type UseParams struct {
89
+ SessionID string `json:"session_id"`
90
+ }