gssh-agent 1.0.7 → 1.0.8
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 +31 -61
- package/bin/gssh +0 -0
- package/cmd/gssh/main.go +4 -1
- package/internal/client/ssh.go +186 -3
- package/internal/client/ssh_test.go +43 -0
- package/internal/portforward/forwarder.go +82 -55
- package/internal/session/manager.go +131 -41
- package/internal/session/manager_test.go +324 -0
- package/package.json +2 -2
- package/pkg/rpc/handler_test.go +36 -0
- package/plan.md +4 -0
- package/skill.md +30 -10
|
@@ -96,42 +96,65 @@ func needsShell(cmd string) bool {
|
|
|
96
96
|
// Connect creates a new SSH session
|
|
97
97
|
func (m *Manager) Connect(user, host string, port int, password, keyPath string) (*protocol.Session, error) {
|
|
98
98
|
m.mu.Lock()
|
|
99
|
-
defer m.mu.Unlock()
|
|
100
99
|
|
|
101
100
|
// Check if session already exists
|
|
102
101
|
for _, s := range m.sessions {
|
|
103
102
|
if s.Host == host && s.User == user && s.Port == port {
|
|
104
|
-
|
|
105
|
-
|
|
103
|
+
s.mu.RLock()
|
|
104
|
+
status := s.Status
|
|
105
|
+
s.mu.RUnlock()
|
|
106
|
+
|
|
107
|
+
if status == "connected" || status == "connecting" || status == "reconnecting" {
|
|
108
|
+
m.defaultID = s.ID
|
|
109
|
+
m.mu.Unlock()
|
|
110
|
+
return toProtocolSession(s), nil
|
|
106
111
|
}
|
|
107
|
-
|
|
112
|
+
|
|
113
|
+
// Mark as connecting to prevent concurrent connect attempts
|
|
114
|
+
s.mu.Lock()
|
|
115
|
+
s.Status = "connecting"
|
|
116
|
+
s.mu.Unlock()
|
|
117
|
+
|
|
118
|
+
// Switch default session
|
|
119
|
+
m.defaultID = s.ID
|
|
120
|
+
|
|
121
|
+
m.mu.Unlock()
|
|
122
|
+
|
|
123
|
+
// Perform network connect outside of Manager lock
|
|
108
124
|
sshClient, err := client.Connect(user, host, port, password, keyPath)
|
|
109
125
|
if err != nil {
|
|
126
|
+
s.mu.Lock()
|
|
127
|
+
s.Status = "offline"
|
|
128
|
+
s.mu.Unlock()
|
|
110
129
|
return nil, err
|
|
111
130
|
}
|
|
131
|
+
|
|
132
|
+
s.mu.Lock()
|
|
133
|
+
// Check if a disconnect was requested while we were connecting
|
|
134
|
+
if s.Status != "connecting" {
|
|
135
|
+
s.mu.Unlock()
|
|
136
|
+
sshClient.Close()
|
|
137
|
+
return nil, fmt.Errorf("session was disconnected while connecting")
|
|
138
|
+
}
|
|
112
139
|
s.SSHClient = sshClient
|
|
113
140
|
s.Status = "connected"
|
|
141
|
+
s.mu.Unlock()
|
|
142
|
+
|
|
114
143
|
return toProtocolSession(s), nil
|
|
115
144
|
}
|
|
116
145
|
}
|
|
117
146
|
|
|
118
|
-
// Create new session
|
|
119
|
-
|
|
120
|
-
if err != nil {
|
|
121
|
-
return nil, err
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
id := uuid.New().String()
|
|
147
|
+
// Create new session placeholder
|
|
148
|
+
id := uuid.New().String()[:8]
|
|
125
149
|
ms := &ManagedSession{
|
|
126
|
-
ID:
|
|
127
|
-
Host:
|
|
128
|
-
User:
|
|
129
|
-
Port:
|
|
130
|
-
Status:
|
|
131
|
-
Password:
|
|
132
|
-
KeyPath:
|
|
133
|
-
|
|
134
|
-
Forwards: make(map[string]*portforward.Forwarder),
|
|
150
|
+
ID: id,
|
|
151
|
+
Host: host,
|
|
152
|
+
User: user,
|
|
153
|
+
Port: port,
|
|
154
|
+
Status: "connecting",
|
|
155
|
+
Password: password,
|
|
156
|
+
KeyPath: keyPath,
|
|
157
|
+
Forwards: make(map[string]*portforward.Forwarder),
|
|
135
158
|
}
|
|
136
159
|
|
|
137
160
|
m.sessions[id] = ms
|
|
@@ -140,6 +163,31 @@ func (m *Manager) Connect(user, host string, port int, password, keyPath string)
|
|
|
140
163
|
if m.defaultID == "" {
|
|
141
164
|
m.defaultID = id
|
|
142
165
|
}
|
|
166
|
+
m.mu.Unlock()
|
|
167
|
+
|
|
168
|
+
// Perform network connect outside of Manager lock
|
|
169
|
+
sshClient, err := client.Connect(user, host, port, password, keyPath)
|
|
170
|
+
if err != nil {
|
|
171
|
+
// Clean up the placeholder since initialization failed completely
|
|
172
|
+
m.mu.Lock()
|
|
173
|
+
delete(m.sessions, id)
|
|
174
|
+
if m.defaultID == id {
|
|
175
|
+
m.defaultID = ""
|
|
176
|
+
}
|
|
177
|
+
m.mu.Unlock()
|
|
178
|
+
return nil, err
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
ms.mu.Lock()
|
|
182
|
+
// Check if a disconnect was requested while we were connecting
|
|
183
|
+
if ms.Status != "connecting" {
|
|
184
|
+
ms.mu.Unlock()
|
|
185
|
+
sshClient.Close()
|
|
186
|
+
return nil, fmt.Errorf("session was disconnected while connecting")
|
|
187
|
+
}
|
|
188
|
+
ms.SSHClient = sshClient
|
|
189
|
+
ms.Status = "connected"
|
|
190
|
+
ms.mu.Unlock()
|
|
143
191
|
|
|
144
192
|
// Start reconnect monitor
|
|
145
193
|
go m.monitorReconnect(ms)
|
|
@@ -149,28 +197,32 @@ func (m *Manager) Connect(user, host string, port int, password, keyPath string)
|
|
|
149
197
|
|
|
150
198
|
// Disconnect closes a session
|
|
151
199
|
func (m *Manager) Disconnect(sessionID string) error {
|
|
200
|
+
m.mu.Lock()
|
|
201
|
+
|
|
152
202
|
// Use default session if not specified
|
|
153
203
|
if sessionID == "" {
|
|
154
204
|
sessionID = m.defaultID
|
|
155
205
|
}
|
|
156
206
|
|
|
157
|
-
m.mu.Lock()
|
|
158
|
-
defer m.mu.Unlock()
|
|
159
|
-
|
|
160
207
|
ms, ok := m.sessions[sessionID]
|
|
161
208
|
if !ok {
|
|
209
|
+
m.mu.Unlock()
|
|
162
210
|
return fmt.Errorf("session not found")
|
|
163
211
|
}
|
|
164
212
|
|
|
165
|
-
|
|
166
|
-
|
|
213
|
+
// Clear default ID when disconnecting
|
|
214
|
+
if m.defaultID == sessionID {
|
|
215
|
+
m.defaultID = ""
|
|
167
216
|
}
|
|
217
|
+
m.mu.Unlock()
|
|
168
218
|
|
|
219
|
+
ms.mu.Lock()
|
|
220
|
+
sshClient := ms.SSHClient
|
|
169
221
|
ms.Status = "disconnected"
|
|
222
|
+
ms.mu.Unlock()
|
|
170
223
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
m.defaultID = ""
|
|
224
|
+
if sshClient != nil {
|
|
225
|
+
sshClient.Close()
|
|
174
226
|
}
|
|
175
227
|
|
|
176
228
|
return nil
|
|
@@ -191,21 +243,36 @@ func (m *Manager) Reconnect(sessionID string) (*protocol.Session, error) {
|
|
|
191
243
|
return nil, fmt.Errorf("session not found")
|
|
192
244
|
}
|
|
193
245
|
|
|
246
|
+
ms.mu.Lock()
|
|
247
|
+
if ms.Status == "connecting" || ms.Status == "reconnecting" {
|
|
248
|
+
ms.mu.Unlock()
|
|
249
|
+
return nil, fmt.Errorf("session is currently connecting")
|
|
250
|
+
}
|
|
251
|
+
existingClient := ms.SSHClient
|
|
252
|
+
ms.Status = "reconnecting"
|
|
253
|
+
ms.mu.Unlock()
|
|
254
|
+
|
|
194
255
|
// Close existing connection
|
|
195
|
-
if
|
|
196
|
-
|
|
256
|
+
if existingClient != nil {
|
|
257
|
+
existingClient.Close()
|
|
197
258
|
}
|
|
198
259
|
|
|
199
260
|
// Create new connection
|
|
200
261
|
sshClient, err := client.Connect(ms.User, ms.Host, ms.Port, ms.Password, ms.KeyPath)
|
|
201
262
|
if err != nil {
|
|
202
263
|
ms.mu.Lock()
|
|
203
|
-
ms.Status = "
|
|
264
|
+
ms.Status = "offline"
|
|
204
265
|
ms.mu.Unlock()
|
|
205
266
|
return nil, err
|
|
206
267
|
}
|
|
207
268
|
|
|
208
269
|
ms.mu.Lock()
|
|
270
|
+
// Check if a disconnect was requested while we were reconnecting
|
|
271
|
+
if ms.Status != "reconnecting" {
|
|
272
|
+
ms.mu.Unlock()
|
|
273
|
+
sshClient.Close()
|
|
274
|
+
return nil, fmt.Errorf("session was disconnected while reconnecting")
|
|
275
|
+
}
|
|
209
276
|
ms.SSHClient = sshClient
|
|
210
277
|
ms.Status = "connected"
|
|
211
278
|
ms.mu.Unlock()
|
|
@@ -265,18 +332,24 @@ func (m *Manager) Exec(sessionID, command string, timeout int) (*protocol.ExecRe
|
|
|
265
332
|
var ok bool
|
|
266
333
|
|
|
267
334
|
if timeout > 0 {
|
|
268
|
-
|
|
335
|
+
type result struct {
|
|
336
|
+
out []byte
|
|
337
|
+
err error
|
|
338
|
+
}
|
|
339
|
+
done := make(chan result, 1)
|
|
269
340
|
go func() {
|
|
270
|
-
|
|
271
|
-
|
|
341
|
+
out, err := session.CombinedOutput(fullCmd)
|
|
342
|
+
done <- result{out, err}
|
|
272
343
|
}()
|
|
273
344
|
select {
|
|
274
|
-
case <-done:
|
|
345
|
+
case res := <-done:
|
|
346
|
+
output = res.out
|
|
347
|
+
err = res.err
|
|
275
348
|
// 命令执行完成
|
|
276
349
|
case <-time.After(time.Duration(timeout) * time.Second):
|
|
277
350
|
session.Signal(ssh.SIGKILL)
|
|
278
351
|
return &protocol.ExecResult{
|
|
279
|
-
Stdout:
|
|
352
|
+
Stdout: "",
|
|
280
353
|
Stderr: "",
|
|
281
354
|
ExitCode: -1,
|
|
282
355
|
}, fmt.Errorf("command timed out after %d seconds", timeout)
|
|
@@ -285,17 +358,19 @@ func (m *Manager) Exec(sessionID, command string, timeout int) (*protocol.ExecRe
|
|
|
285
358
|
output, err = session.CombinedOutput(fullCmd)
|
|
286
359
|
}
|
|
287
360
|
|
|
361
|
+
ms.mu.Lock()
|
|
362
|
+
ms.LastCmd = command
|
|
363
|
+
ms.mu.Unlock()
|
|
364
|
+
|
|
288
365
|
if err != nil {
|
|
289
366
|
exitErr, ok = err.(*ssh.ExitError)
|
|
290
367
|
if ok {
|
|
291
|
-
ms.LastCmd = command
|
|
292
368
|
return &protocol.ExecResult{
|
|
293
369
|
Stdout: string(output),
|
|
294
370
|
Stderr: "",
|
|
295
371
|
ExitCode: exitErr.ExitStatus(),
|
|
296
372
|
}, nil
|
|
297
373
|
}
|
|
298
|
-
ms.LastCmd = command
|
|
299
374
|
return &protocol.ExecResult{
|
|
300
375
|
Stdout: string(output),
|
|
301
376
|
Stderr: "",
|
|
@@ -303,7 +378,6 @@ func (m *Manager) Exec(sessionID, command string, timeout int) (*protocol.ExecRe
|
|
|
303
378
|
}, nil
|
|
304
379
|
}
|
|
305
380
|
|
|
306
|
-
ms.LastCmd = command
|
|
307
381
|
return &protocol.ExecResult{
|
|
308
382
|
Stdout: string(output),
|
|
309
383
|
Stderr: "",
|
|
@@ -371,7 +445,7 @@ func (m *Manager) AddForward(sessionID, forwardType string, localPort, remotePor
|
|
|
371
445
|
return nil, err
|
|
372
446
|
}
|
|
373
447
|
|
|
374
|
-
id := uuid.New().String()
|
|
448
|
+
id := uuid.New().String()[:8]
|
|
375
449
|
forwarder.ID = id
|
|
376
450
|
|
|
377
451
|
m.forwardMu.Lock()
|
|
@@ -598,14 +672,30 @@ func (m *Manager) monitorReconnect(ms *ManagedSession) {
|
|
|
598
672
|
return
|
|
599
673
|
}
|
|
600
674
|
|
|
601
|
-
|
|
675
|
+
isAlive := false
|
|
676
|
+
if sshClient != nil && sshClient.Client != nil {
|
|
677
|
+
// Try to send a keepalive request
|
|
678
|
+
_, _, err := sshClient.Client.SendRequest("keepalive@gssh", true, nil)
|
|
679
|
+
if err == nil {
|
|
680
|
+
isAlive = true
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
if !isAlive {
|
|
602
685
|
ms.mu.Lock()
|
|
603
686
|
ms.Status = "reconnecting"
|
|
687
|
+
if sshClient != nil {
|
|
688
|
+
sshClient.Close()
|
|
689
|
+
ms.SSHClient = nil
|
|
690
|
+
}
|
|
604
691
|
ms.mu.Unlock()
|
|
605
692
|
|
|
606
693
|
// Try to reconnect
|
|
607
694
|
newClient, err := client.Connect(ms.User, ms.Host, ms.Port, ms.Password, ms.KeyPath)
|
|
608
695
|
if err != nil {
|
|
696
|
+
ms.mu.Lock()
|
|
697
|
+
ms.Status = "offline"
|
|
698
|
+
ms.mu.Unlock()
|
|
609
699
|
continue
|
|
610
700
|
}
|
|
611
701
|
|
|
@@ -1,9 +1,126 @@
|
|
|
1
1
|
package session
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
|
+
"crypto/rand"
|
|
5
|
+
"crypto/rsa"
|
|
6
|
+
"net"
|
|
7
|
+
"strconv"
|
|
4
8
|
"testing"
|
|
9
|
+
"time"
|
|
10
|
+
|
|
11
|
+
"golang.org/x/crypto/ssh"
|
|
5
12
|
)
|
|
6
13
|
|
|
14
|
+
// generatePrivateKey generates a new RSA private key for testing
|
|
15
|
+
func generatePrivateKey() (ssh.Signer, error) {
|
|
16
|
+
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
|
17
|
+
if err != nil {
|
|
18
|
+
return nil, err
|
|
19
|
+
}
|
|
20
|
+
return ssh.NewSignerFromKey(key)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// startDummySSHServer starts a basic SSH server for testing
|
|
24
|
+
func startDummySSHServer(t *testing.T) (string, int, func()) {
|
|
25
|
+
t.Helper()
|
|
26
|
+
|
|
27
|
+
config := &ssh.ServerConfig{
|
|
28
|
+
PasswordCallback: func(c ssh.ConnMetadata, pass []byte) (*ssh.Permissions, error) {
|
|
29
|
+
if c.User() == "testuser" && string(pass) == "testpass" {
|
|
30
|
+
return nil, nil
|
|
31
|
+
}
|
|
32
|
+
return nil, ssh.ErrNoAuth
|
|
33
|
+
},
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
signer, err := generatePrivateKey()
|
|
37
|
+
if err != nil {
|
|
38
|
+
t.Fatalf("failed to generate private key: %v", err)
|
|
39
|
+
}
|
|
40
|
+
config.AddHostKey(signer)
|
|
41
|
+
|
|
42
|
+
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
|
43
|
+
if err != nil {
|
|
44
|
+
t.Fatalf("failed to listen on port: %v", err)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
host, portStr, err := net.SplitHostPort(listener.Addr().String())
|
|
48
|
+
if err != nil {
|
|
49
|
+
t.Fatalf("failed to split host port: %v", err)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
port, err := strconv.Atoi(portStr)
|
|
53
|
+
if err != nil {
|
|
54
|
+
t.Fatalf("failed to parse port: %v", err)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
go func() {
|
|
58
|
+
for {
|
|
59
|
+
nConn, err := listener.Accept()
|
|
60
|
+
if err != nil {
|
|
61
|
+
return
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
go func() {
|
|
65
|
+
// Perform SSH handshake
|
|
66
|
+
serverConn, chans, reqs, err := ssh.NewServerConn(nConn, config)
|
|
67
|
+
if err != nil {
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
go ssh.DiscardRequests(reqs)
|
|
72
|
+
|
|
73
|
+
for newChannel := range chans {
|
|
74
|
+
if newChannel.ChannelType() != "session" {
|
|
75
|
+
newChannel.Reject(ssh.UnknownChannelType, "unknown channel type")
|
|
76
|
+
continue
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
channel, requests, err := newChannel.Accept()
|
|
80
|
+
if err != nil {
|
|
81
|
+
continue
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
go func() {
|
|
85
|
+
defer channel.Close()
|
|
86
|
+
for req := range requests {
|
|
87
|
+
if req.Type == "exec" {
|
|
88
|
+
// Mock command execution
|
|
89
|
+
if req.WantReply {
|
|
90
|
+
req.Reply(true, nil)
|
|
91
|
+
}
|
|
92
|
+
// Simply write something back and exit
|
|
93
|
+
channel.Write([]byte("mock output"))
|
|
94
|
+
|
|
95
|
+
// Wait a little bit to simulate some work or timeout testing
|
|
96
|
+
cmdLen := len(req.Payload)
|
|
97
|
+
if cmdLen > 4 && string(req.Payload[4:]) == "/bin/sh -c \"sleep 2\"" {
|
|
98
|
+
time.Sleep(2 * time.Second)
|
|
99
|
+
}
|
|
100
|
+
if cmdLen > 4 && string(req.Payload[4:]) == "sleep 2" {
|
|
101
|
+
time.Sleep(2 * time.Second)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
channel.SendRequest("exit-status", false, ssh.Marshal(struct{ uint32 }{0}))
|
|
105
|
+
return
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}()
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Handle client disconnect gracefully if needed
|
|
112
|
+
_ = serverConn
|
|
113
|
+
}()
|
|
114
|
+
}
|
|
115
|
+
}()
|
|
116
|
+
|
|
117
|
+
cleanup := func() {
|
|
118
|
+
listener.Close()
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return host, port, cleanup
|
|
122
|
+
}
|
|
123
|
+
|
|
7
124
|
func TestNewManager(t *testing.T) {
|
|
8
125
|
manager := NewManager()
|
|
9
126
|
if manager == nil {
|
|
@@ -50,3 +167,210 @@ func TestManagerListForwards(t *testing.T) {
|
|
|
50
167
|
t.Errorf("expected 0 forwards, got %d", len(forwards))
|
|
51
168
|
}
|
|
52
169
|
}
|
|
170
|
+
|
|
171
|
+
func TestNeedsShell(t *testing.T) {
|
|
172
|
+
tests := []struct {
|
|
173
|
+
cmd string
|
|
174
|
+
expected bool
|
|
175
|
+
}{
|
|
176
|
+
{"ls -la", false},
|
|
177
|
+
{"echo hello", false},
|
|
178
|
+
{"cat file.txt | grep test", true},
|
|
179
|
+
{"echo hello > file.txt", true},
|
|
180
|
+
{"cat < file.txt", true},
|
|
181
|
+
{"cat << EOF\nhello\nEOF", true},
|
|
182
|
+
{"echo hello && echo world", true},
|
|
183
|
+
{"echo hello || echo world", true},
|
|
184
|
+
{"sleep 10 &", true},
|
|
185
|
+
{"echo $(whoami)", true},
|
|
186
|
+
{"echo `whoami`", true},
|
|
187
|
+
{"echo ${HOME}", true},
|
|
188
|
+
{"echo $HOME", true},
|
|
189
|
+
{"echo $$", false},
|
|
190
|
+
{"echo hello\nworld", true},
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
for _, tt := range tests {
|
|
194
|
+
t.Run(tt.cmd, func(t *testing.T) {
|
|
195
|
+
result := needsShell(tt.cmd)
|
|
196
|
+
if result != tt.expected {
|
|
197
|
+
t.Errorf("needsShell(%q) = %v; want %v", tt.cmd, result, tt.expected)
|
|
198
|
+
}
|
|
199
|
+
})
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
func TestManagerConnect(t *testing.T) {
|
|
204
|
+
host, port, cleanup := startDummySSHServer(t)
|
|
205
|
+
defer cleanup()
|
|
206
|
+
|
|
207
|
+
manager := NewManager()
|
|
208
|
+
|
|
209
|
+
// Test successful connection
|
|
210
|
+
sess, err := manager.Connect("testuser", host, port, "testpass", "")
|
|
211
|
+
if err != nil {
|
|
212
|
+
t.Fatalf("expected successful connection, got error: %v", err)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if sess.Status != "connected" {
|
|
216
|
+
t.Errorf("expected session status 'connected', got %s", sess.Status)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Test duplicate connection (should reuse and return nil error)
|
|
220
|
+
sess2, err := manager.Connect("testuser", host, port, "testpass", "")
|
|
221
|
+
if err != nil {
|
|
222
|
+
t.Errorf("expected successful reuse for duplicate connection, got error: %v", err)
|
|
223
|
+
}
|
|
224
|
+
if sess2.ID != sess.ID {
|
|
225
|
+
t.Errorf("expected reused session ID %s, got %s", sess.ID, sess2.ID)
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
func TestManagerDisconnect(t *testing.T) {
|
|
230
|
+
host, port, cleanup := startDummySSHServer(t)
|
|
231
|
+
defer cleanup()
|
|
232
|
+
|
|
233
|
+
manager := NewManager()
|
|
234
|
+
|
|
235
|
+
// Connect first
|
|
236
|
+
sess, err := manager.Connect("testuser", host, port, "testpass", "")
|
|
237
|
+
if err != nil {
|
|
238
|
+
t.Fatalf("expected successful connection, got error: %v", err)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Disconnect
|
|
242
|
+
err = manager.Disconnect(sess.ID)
|
|
243
|
+
if err != nil {
|
|
244
|
+
t.Errorf("expected successful disconnect, got error: %v", err)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Disconnect non-existent
|
|
248
|
+
err = manager.Disconnect("invalid-id")
|
|
249
|
+
if err == nil {
|
|
250
|
+
t.Errorf("expected error for non-existent session, got nil")
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
func TestManagerReconnect(t *testing.T) {
|
|
255
|
+
host, port, cleanup := startDummySSHServer(t)
|
|
256
|
+
defer cleanup()
|
|
257
|
+
|
|
258
|
+
manager := NewManager()
|
|
259
|
+
|
|
260
|
+
// Connect first
|
|
261
|
+
sess, err := manager.Connect("testuser", host, port, "testpass", "")
|
|
262
|
+
if err != nil {
|
|
263
|
+
t.Fatalf("expected successful connection, got error: %v", err)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Reconnect
|
|
267
|
+
reconnectedSess, err := manager.Reconnect(sess.ID)
|
|
268
|
+
if err != nil {
|
|
269
|
+
t.Errorf("expected successful reconnect, got error: %v", err)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if reconnectedSess.Status != "connected" {
|
|
273
|
+
t.Errorf("expected reconnected session status 'connected', got %s", reconnectedSess.Status)
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
func TestManagerConcurrentStress(t *testing.T) {
|
|
278
|
+
host, port, cleanup := startDummySSHServer(t)
|
|
279
|
+
defer cleanup()
|
|
280
|
+
|
|
281
|
+
manager := NewManager()
|
|
282
|
+
|
|
283
|
+
// Connect first
|
|
284
|
+
sess, err := manager.Connect("testuser", host, port, "testpass", "")
|
|
285
|
+
if err != nil {
|
|
286
|
+
t.Fatalf("expected successful connection, got error: %v", err)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
numGoroutines := 20
|
|
290
|
+
done := make(chan bool)
|
|
291
|
+
|
|
292
|
+
for i := 0; i < numGoroutines; i++ {
|
|
293
|
+
go func(id int) {
|
|
294
|
+
// Randomly perform operations
|
|
295
|
+
if id%3 == 0 {
|
|
296
|
+
manager.Exec(sess.ID, "echo hello", 5)
|
|
297
|
+
} else if id%3 == 1 {
|
|
298
|
+
manager.List()
|
|
299
|
+
} else {
|
|
300
|
+
manager.GetDefaultID()
|
|
301
|
+
}
|
|
302
|
+
done <- true
|
|
303
|
+
}(i)
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
for i := 0; i < numGoroutines; i++ {
|
|
307
|
+
<-done
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Make sure we can still disconnect
|
|
311
|
+
err = manager.Disconnect(sess.ID)
|
|
312
|
+
if err != nil {
|
|
313
|
+
t.Errorf("expected successful disconnect after stress, got error: %v", err)
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
func TestManagerConcurrentConnect(t *testing.T) {
|
|
318
|
+
host, port, cleanup := startDummySSHServer(t)
|
|
319
|
+
defer cleanup()
|
|
320
|
+
|
|
321
|
+
manager := NewManager()
|
|
322
|
+
|
|
323
|
+
numGoroutines := 10
|
|
324
|
+
done := make(chan bool)
|
|
325
|
+
|
|
326
|
+
// Since they all try to connect with the same user/host/port, the first one succeeds
|
|
327
|
+
// and subsequent ones should get "session already exists".
|
|
328
|
+
// The key is that no deadlocks happen here.
|
|
329
|
+
for i := 0; i < numGoroutines; i++ {
|
|
330
|
+
go func() {
|
|
331
|
+
manager.Connect("testuser", host, port, "testpass", "")
|
|
332
|
+
done <- true
|
|
333
|
+
}()
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
for i := 0; i < numGoroutines; i++ {
|
|
337
|
+
<-done
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
sessions := manager.List()
|
|
341
|
+
if len(sessions) != 1 {
|
|
342
|
+
t.Errorf("expected exactly 1 session, got %d", len(sessions))
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
func TestManagerExecTimeout(t *testing.T) {
|
|
347
|
+
host, port, cleanup := startDummySSHServer(t)
|
|
348
|
+
defer cleanup()
|
|
349
|
+
|
|
350
|
+
manager := NewManager()
|
|
351
|
+
|
|
352
|
+
sess, err := manager.Connect("testuser", host, port, "testpass", "")
|
|
353
|
+
if err != nil {
|
|
354
|
+
t.Fatalf("expected successful connection, got error: %v", err)
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// This command takes 2 seconds on our dummy server
|
|
358
|
+
// Set timeout to 1 second
|
|
359
|
+
start := time.Now()
|
|
360
|
+
res, err := manager.Exec(sess.ID, "sleep 2", 1)
|
|
361
|
+
|
|
362
|
+
elapsed := time.Since(start)
|
|
363
|
+
|
|
364
|
+
// Exec returns err != nil when it times out
|
|
365
|
+
if err == nil {
|
|
366
|
+
t.Errorf("expected timeout error, got nil")
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
if res != nil && res.ExitCode != -1 {
|
|
370
|
+
t.Errorf("expected exit code -1 for timeout, got %d", res.ExitCode)
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
if elapsed >= 2*time.Second {
|
|
374
|
+
t.Errorf("expected to timeout before 2 seconds, elapsed: %v", elapsed)
|
|
375
|
+
}
|
|
376
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gssh-agent",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.8",
|
|
4
4
|
"description": "SSH Session Manager for Agents - Stateless SSH client with SFTP support",
|
|
5
5
|
"bin": {
|
|
6
6
|
"gssh": "./bin/gssh"
|
|
@@ -36,4 +36,4 @@
|
|
|
36
36
|
"engines": {
|
|
37
37
|
"node": ">=14.0.0"
|
|
38
38
|
}
|
|
39
|
-
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
package rpc
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"encoding/json"
|
|
5
|
+
"testing"
|
|
6
|
+
|
|
7
|
+
"gssh/internal/protocol"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
func TestHandle_InvalidJSON(t *testing.T) {
|
|
11
|
+
handler := NewHandler(nil)
|
|
12
|
+
|
|
13
|
+
invalidJSON := []byte(`{invalid json`)
|
|
14
|
+
|
|
15
|
+
respBytes, err := handler.Handle(invalidJSON)
|
|
16
|
+
if err != nil {
|
|
17
|
+
t.Fatalf("Handle returned unexpected error: %v", err)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
var resp protocol.Response
|
|
21
|
+
if err := json.Unmarshal(respBytes, &resp); err != nil {
|
|
22
|
+
t.Fatalf("Failed to unmarshal response: %v", err)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if resp.Error == nil {
|
|
26
|
+
t.Fatal("Expected error in response, got nil")
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if resp.Error.Code != -32700 {
|
|
30
|
+
t.Errorf("Expected error code -32700, got %d", resp.Error.Code)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if resp.Error.Message != "Parse error" {
|
|
34
|
+
t.Errorf("Expected error message 'Parse error', got '%s'", resp.Error.Message)
|
|
35
|
+
}
|
|
36
|
+
}
|
package/plan.md
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
1. **Create `pkg/rpc/handler_test.go`:** I will create a test file in the `pkg/rpc` directory.
|
|
2
|
+
2. **Add test for invalid JSON:** I will add a test `TestHandle_InvalidJSON` that initializes a new `Handler` and calls `Handle` with an invalid JSON byte slice. It will assert that the returned error JSON structure matches the JSON-RPC error response for parse errors (`code: -32700`).
|
|
3
|
+
3. **Run pre-commit steps:** Complete pre commit steps to ensure proper testing, verifications, reviews and reflections are done.
|
|
4
|
+
4. **Submit change:** Once everything works and tests pass, I will submit the change.
|