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/README.md +175 -0
- package/bin/daemon +0 -0
- package/bin/gssh +0 -0
- package/bin/gssh-daemon +0 -0
- package/cmd/daemon/main.go +45 -0
- package/cmd/gssh/main.go +394 -0
- package/go.mod +10 -0
- package/go.sum +8 -0
- package/gssh-darwin-arm64.tar.gz +0 -0
- package/homebrew/gssh.plist +22 -0
- package/homebrew/install.sh +43 -0
- package/idea.md +271 -0
- package/internal/client/ssh.go +143 -0
- package/internal/client/ssh_test.go +33 -0
- package/internal/portforward/forwarder.go +187 -0
- package/internal/protocol/types.go +90 -0
- package/internal/protocol/types_test.go +87 -0
- package/internal/session/manager.go +380 -0
- package/internal/session/manager_test.go +52 -0
- package/package.json +25 -0
- package/pkg/rpc/handler.go +206 -0
- package/skill.md +96 -0
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
|
+
}
|