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.
@@ -0,0 +1,87 @@
1
+ package protocol
2
+
3
+ import (
4
+ "encoding/json"
5
+ "testing"
6
+ )
7
+
8
+ func TestSessionJSON(t *testing.T) {
9
+ session := Session{
10
+ ID: "test-id",
11
+ Host: "192.168.1.1",
12
+ User: "admin",
13
+ Port: 22,
14
+ Status: "connected",
15
+ }
16
+
17
+ data, err := json.Marshal(session)
18
+ if err != nil {
19
+ t.Fatalf("failed to marshal: %v", err)
20
+ }
21
+
22
+ var parsed Session
23
+ if err := json.Unmarshal(data, &parsed); err != nil {
24
+ t.Fatalf("failed to unmarshal: %v", err)
25
+ }
26
+
27
+ if parsed.ID != session.ID {
28
+ t.Errorf("expected ID %s, got %s", session.ID, parsed.ID)
29
+ }
30
+ if parsed.Host != session.Host {
31
+ t.Errorf("expected Host %s, got %s", session.Host, parsed.Host)
32
+ }
33
+ if parsed.User != session.User {
34
+ t.Errorf("expected User %s, got %s", session.User, parsed.User)
35
+ }
36
+ }
37
+
38
+ func TestExecResultJSON(t *testing.T) {
39
+ result := ExecResult{
40
+ Stdout: "hello\n",
41
+ Stderr: "error\n",
42
+ ExitCode: 0,
43
+ }
44
+
45
+ data, err := json.Marshal(result)
46
+ if err != nil {
47
+ t.Fatalf("failed to marshal: %v", err)
48
+ }
49
+
50
+ var parsed ExecResult
51
+ if err := json.Unmarshal(data, &parsed); err != nil {
52
+ t.Fatalf("failed to unmarshal: %v", err)
53
+ }
54
+
55
+ if parsed.Stdout != result.Stdout {
56
+ t.Errorf("expected stdout %s, got %s", result.Stdout, parsed.Stdout)
57
+ }
58
+ if parsed.ExitCode != result.ExitCode {
59
+ t.Errorf("expected exit code %d, got %d", result.ExitCode, parsed.ExitCode)
60
+ }
61
+ }
62
+
63
+ func TestForwardJSON(t *testing.T) {
64
+ forward := Forward{
65
+ ID: "fwd-1",
66
+ Type: "local",
67
+ Local: 8080,
68
+ Remote: 80,
69
+ }
70
+
71
+ data, err := json.Marshal(forward)
72
+ if err != nil {
73
+ t.Fatalf("failed to marshal: %v", err)
74
+ }
75
+
76
+ var parsed Forward
77
+ if err := json.Unmarshal(data, &parsed); err != nil {
78
+ t.Fatalf("failed to unmarshal: %v", err)
79
+ }
80
+
81
+ if parsed.Type != forward.Type {
82
+ t.Errorf("expected type %s, got %s", forward.Type, parsed.Type)
83
+ }
84
+ if parsed.Local != forward.Local {
85
+ t.Errorf("expected local port %d, got %d", forward.Local, parsed.Local)
86
+ }
87
+ }
@@ -0,0 +1,380 @@
1
+ package session
2
+
3
+ import (
4
+ "fmt"
5
+ "sync"
6
+ "time"
7
+
8
+ "gssh/internal/client"
9
+ "gssh/internal/portforward"
10
+ "gssh/internal/protocol"
11
+
12
+ "github.com/google/uuid"
13
+ )
14
+
15
+ // Manager manages SSH sessions
16
+ type Manager struct {
17
+ sessions map[string]*ManagedSession
18
+ defaultID string
19
+ mu sync.RWMutex
20
+ forwards map[string]*portforward.Forwarder
21
+ forwardMu sync.RWMutex
22
+ }
23
+
24
+ // ManagedSession wraps a session with additional state
25
+ type ManagedSession struct {
26
+ ID string
27
+ Host string
28
+ User string
29
+ Port int
30
+ Status string
31
+ Password string
32
+ KeyPath string
33
+ SSHClient *client.SSHClient
34
+ LastCmd string
35
+ Forwards map[string]*portforward.Forwarder
36
+ mu sync.RWMutex
37
+ }
38
+
39
+ // NewManager creates a new session manager
40
+ func NewManager() *Manager {
41
+ return &Manager{
42
+ sessions: make(map[string]*ManagedSession),
43
+ forwards: make(map[string]*portforward.Forwarder),
44
+ }
45
+ }
46
+
47
+ // Connect creates a new SSH session
48
+ func (m *Manager) Connect(user, host string, port int, password, keyPath string) (*protocol.Session, error) {
49
+ m.mu.Lock()
50
+ defer m.mu.Unlock()
51
+
52
+ // Check if session already exists
53
+ for _, s := range m.sessions {
54
+ if s.Host == host && s.User == user && s.Port == port {
55
+ if s.Status == "connected" {
56
+ return toProtocolSession(s), fmt.Errorf("session already exists")
57
+ }
58
+ // Try to reconnect
59
+ sshClient, err := client.Connect(user, host, port, password, keyPath)
60
+ if err != nil {
61
+ return nil, err
62
+ }
63
+ s.SSHClient = sshClient
64
+ s.Status = "connected"
65
+ return toProtocolSession(s), nil
66
+ }
67
+ }
68
+
69
+ // Create new session
70
+ sshClient, err := client.Connect(user, host, port, password, keyPath)
71
+ if err != nil {
72
+ return nil, err
73
+ }
74
+
75
+ id := uuid.New().String()
76
+ ms := &ManagedSession{
77
+ ID: id,
78
+ Host: host,
79
+ User: user,
80
+ Port: port,
81
+ Status: "connected",
82
+ Password: password,
83
+ KeyPath: keyPath,
84
+ SSHClient: sshClient,
85
+ Forwards: make(map[string]*portforward.Forwarder),
86
+ }
87
+
88
+ m.sessions[id] = ms
89
+
90
+ // Set as default if first session
91
+ if m.defaultID == "" {
92
+ m.defaultID = id
93
+ }
94
+
95
+ // Start reconnect monitor
96
+ go m.monitorReconnect(ms)
97
+
98
+ return toProtocolSession(ms), nil
99
+ }
100
+
101
+ // Disconnect closes a session
102
+ func (m *Manager) Disconnect(sessionID string) error {
103
+ m.mu.Lock()
104
+ defer m.mu.Unlock()
105
+
106
+ ms, ok := m.sessions[sessionID]
107
+ if !ok {
108
+ return fmt.Errorf("session not found")
109
+ }
110
+
111
+ if ms.SSHClient != nil {
112
+ ms.SSHClient.Close()
113
+ }
114
+
115
+ ms.Status = "disconnected"
116
+
117
+ // Clear default ID when disconnecting
118
+ if m.defaultID == sessionID {
119
+ m.defaultID = ""
120
+ }
121
+
122
+ return nil
123
+ }
124
+
125
+ // Reconnect reconnects a session
126
+ func (m *Manager) Reconnect(sessionID string) (*protocol.Session, error) {
127
+ m.mu.Lock()
128
+ ms, ok := m.sessions[sessionID]
129
+ m.mu.Unlock()
130
+
131
+ if !ok {
132
+ return nil, fmt.Errorf("session not found")
133
+ }
134
+
135
+ // Close existing connection
136
+ if ms.SSHClient != nil {
137
+ ms.SSHClient.Close()
138
+ }
139
+
140
+ // Create new connection
141
+ sshClient, err := client.Connect(ms.User, ms.Host, ms.Port, ms.Password, ms.KeyPath)
142
+ if err != nil {
143
+ ms.mu.Lock()
144
+ ms.Status = "disconnected"
145
+ ms.mu.Unlock()
146
+ return nil, err
147
+ }
148
+
149
+ ms.mu.Lock()
150
+ ms.SSHClient = sshClient
151
+ ms.Status = "connected"
152
+ ms.mu.Unlock()
153
+
154
+ // Restore default ID if it was cleared
155
+ m.mu.Lock()
156
+ if m.defaultID == "" {
157
+ m.defaultID = sessionID
158
+ }
159
+ m.mu.Unlock()
160
+
161
+ // Restore forwards
162
+ m.restoreForwards(ms)
163
+
164
+ return toProtocolSession(ms), nil
165
+ }
166
+
167
+ // Exec executes a command on a session
168
+ func (m *Manager) Exec(sessionID, command string) (*protocol.ExecResult, error) {
169
+ m.mu.RLock()
170
+ var ms *ManagedSession
171
+ if sessionID != "" {
172
+ ms = m.sessions[sessionID]
173
+ } else if m.defaultID != "" {
174
+ ms = m.sessions[m.defaultID]
175
+ }
176
+ m.mu.RUnlock()
177
+
178
+ if ms == nil {
179
+ return nil, fmt.Errorf("session not found")
180
+ }
181
+
182
+ ms.mu.RLock()
183
+ defer ms.mu.RUnlock()
184
+
185
+ if ms.SSHClient == nil {
186
+ return nil, fmt.Errorf("session not connected")
187
+ }
188
+
189
+ stdout, stderr, exitCode, err := ms.SSHClient.Exec(command)
190
+ if err != nil && exitCode == 0 {
191
+ return nil, err
192
+ }
193
+
194
+ ms.LastCmd = command
195
+
196
+ return &protocol.ExecResult{
197
+ Stdout: stdout,
198
+ Stderr: stderr,
199
+ ExitCode: exitCode,
200
+ }, nil
201
+ }
202
+
203
+ // List returns all sessions
204
+ func (m *Manager) List() []*protocol.Session {
205
+ m.mu.RLock()
206
+ defer m.mu.RUnlock()
207
+
208
+ result := make([]*protocol.Session, 0, len(m.sessions))
209
+ for _, s := range m.sessions {
210
+ result = append(result, toProtocolSession(s))
211
+ }
212
+ return result
213
+ }
214
+
215
+ // Use sets the default session
216
+ func (m *Manager) Use(sessionID string) error {
217
+ m.mu.Lock()
218
+ defer m.mu.Unlock()
219
+
220
+ if _, ok := m.sessions[sessionID]; !ok {
221
+ return fmt.Errorf("session not found")
222
+ }
223
+
224
+ m.defaultID = sessionID
225
+ return nil
226
+ }
227
+
228
+ // GetDefaultID returns the default session ID
229
+ func (m *Manager) GetDefaultID() string {
230
+ m.mu.RLock()
231
+ defer m.mu.RUnlock()
232
+ return m.defaultID
233
+ }
234
+
235
+ // AddForward adds a port forward
236
+ func (m *Manager) AddForward(sessionID, forwardType string, localPort, remotePort int) (*protocol.Forward, error) {
237
+ m.mu.RLock()
238
+ var ms *ManagedSession
239
+ if sessionID != "" {
240
+ ms = m.sessions[sessionID]
241
+ } else if m.defaultID != "" {
242
+ ms = m.sessions[m.defaultID]
243
+ }
244
+ m.mu.RUnlock()
245
+
246
+ if ms == nil {
247
+ return nil, fmt.Errorf("session not found")
248
+ }
249
+
250
+ ms.mu.RLock()
251
+ sshClient := ms.SSHClient
252
+ ms.mu.RUnlock()
253
+
254
+ if sshClient == nil {
255
+ return nil, fmt.Errorf("session not connected")
256
+ }
257
+
258
+ forwarder, err := portforward.NewForwarder(sshClient.Client, forwardType, localPort, remotePort)
259
+ if err != nil {
260
+ return nil, err
261
+ }
262
+
263
+ id := uuid.New().String()
264
+ forwarder.ID = id
265
+
266
+ m.forwardMu.Lock()
267
+ m.forwards[id] = forwarder
268
+
269
+ ms.mu.Lock()
270
+ ms.Forwards[id] = forwarder
271
+ ms.mu.Unlock()
272
+ m.forwardMu.Unlock()
273
+
274
+ go forwarder.Start()
275
+
276
+ return &protocol.Forward{
277
+ ID: id,
278
+ Type: forwardType,
279
+ Local: localPort,
280
+ Remote: remotePort,
281
+ }, nil
282
+ }
283
+
284
+ // ListForwards lists all forwards
285
+ func (m *Manager) ListForwards() []*protocol.Forward {
286
+ m.forwardMu.RLock()
287
+ defer m.forwardMu.RUnlock()
288
+
289
+ result := make([]*protocol.Forward, 0, len(m.forwards))
290
+ for _, f := range m.forwards {
291
+ result = append(result, &protocol.Forward{
292
+ ID: f.ID,
293
+ Type: f.Type,
294
+ Local: f.LocalPort,
295
+ Remote: f.RemotePort,
296
+ })
297
+ }
298
+ return result
299
+ }
300
+
301
+ // CloseForward closes a forward
302
+ func (m *Manager) CloseForward(forwardID string) error {
303
+ m.forwardMu.Lock()
304
+ defer m.forwardMu.Unlock()
305
+
306
+ forwarder, ok := m.forwards[forwardID]
307
+ if !ok {
308
+ return fmt.Errorf("forward not found")
309
+ }
310
+
311
+ forwarder.Close()
312
+ delete(m.forwards, forwardID)
313
+
314
+ return nil
315
+ }
316
+
317
+ // monitorReconnect monitors connection and auto-reconnects
318
+ func (m *Manager) monitorReconnect(ms *ManagedSession) {
319
+ ticker := time.NewTicker(5 * time.Second)
320
+ defer ticker.Stop()
321
+
322
+ for range ticker.C {
323
+ ms.mu.RLock()
324
+ status := ms.Status
325
+ sshClient := ms.SSHClient
326
+ ms.mu.RUnlock()
327
+
328
+ if status == "disconnected" {
329
+ return
330
+ }
331
+
332
+ if sshClient == nil || sshClient.Client == nil {
333
+ ms.mu.Lock()
334
+ ms.Status = "reconnecting"
335
+ ms.mu.Unlock()
336
+
337
+ // Try to reconnect
338
+ newClient, err := client.Connect(ms.User, ms.Host, ms.Port, ms.Password, ms.KeyPath)
339
+ if err != nil {
340
+ continue
341
+ }
342
+
343
+ ms.mu.Lock()
344
+ ms.SSHClient = newClient
345
+ ms.Status = "connected"
346
+ ms.mu.Unlock()
347
+
348
+ // Restore forwards
349
+ m.restoreForwards(ms)
350
+ }
351
+ }
352
+ }
353
+
354
+ // restoreForwards restores all forwards after reconnection
355
+ func (m *Manager) restoreForwards(ms *ManagedSession) {
356
+ ms.mu.RLock()
357
+ forwards := make(map[string]*portforward.Forwarder)
358
+ for k, v := range ms.Forwards {
359
+ forwards[k] = v
360
+ }
361
+ ms.mu.RUnlock()
362
+
363
+ for _, f := range forwards {
364
+ f.Restart(ms.SSHClient.Client)
365
+ }
366
+ }
367
+
368
+ func toProtocolSession(ms *ManagedSession) *protocol.Session {
369
+ ms.mu.RLock()
370
+ defer ms.mu.RUnlock()
371
+
372
+ return &protocol.Session{
373
+ ID: ms.ID,
374
+ Host: ms.Host,
375
+ User: ms.User,
376
+ Port: ms.Port,
377
+ Status: ms.Status,
378
+ KeyPath: ms.KeyPath,
379
+ }
380
+ }
@@ -0,0 +1,52 @@
1
+ package session
2
+
3
+ import (
4
+ "testing"
5
+ )
6
+
7
+ func TestNewManager(t *testing.T) {
8
+ manager := NewManager()
9
+ if manager == nil {
10
+ t.Error("NewManager returned nil")
11
+ }
12
+ if manager.sessions == nil {
13
+ t.Error("sessions map is nil")
14
+ }
15
+ if manager.forwards == nil {
16
+ t.Error("forwards map is nil")
17
+ }
18
+ }
19
+
20
+ func TestManagerList(t *testing.T) {
21
+ manager := NewManager()
22
+ sessions := manager.List()
23
+ if len(sessions) != 0 {
24
+ t.Errorf("expected 0 sessions, got %d", len(sessions))
25
+ }
26
+ }
27
+
28
+ func TestManagerUse(t *testing.T) {
29
+ manager := NewManager()
30
+
31
+ // Test using non-existent session
32
+ err := manager.Use("non-existent")
33
+ if err == nil {
34
+ t.Error("expected error for non-existent session")
35
+ }
36
+ }
37
+
38
+ func TestManagerGetDefaultID(t *testing.T) {
39
+ manager := NewManager()
40
+ id := manager.GetDefaultID()
41
+ if id != "" {
42
+ t.Errorf("expected empty default ID, got %s", id)
43
+ }
44
+ }
45
+
46
+ func TestManagerListForwards(t *testing.T) {
47
+ manager := NewManager()
48
+ forwards := manager.ListForwards()
49
+ if len(forwards) != 0 {
50
+ t.Errorf("expected 0 forwards, got %d", len(forwards))
51
+ }
52
+ }
package/package.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "gssh-agent",
3
+ "version": "1.0.0",
4
+ "description": "SSH Session Manager for Agents",
5
+ "bin": {
6
+ "gssh": "bin/gssh",
7
+ "gssh-daemon": "bin/gssh-daemon"
8
+ },
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/forechoandlook/gssh.git"
12
+ },
13
+ "keywords": [
14
+ "ssh",
15
+ "agent",
16
+ "session-manager",
17
+ "port-forwarding"
18
+ ],
19
+ "author": "",
20
+ "license": "MIT",
21
+ "bugs": {
22
+ "url": "https://github.com/forechoandlook/gssh/issues"
23
+ },
24
+ "homepage": "https://github.com/forechoandlook/gssh#readme"
25
+ }