gssh-agent 1.0.0 → 1.0.2
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/daemon +0 -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 +252 -2
- package/internal/portforward/forwarder.go +88 -19
- package/internal/protocol/types.go +23 -0
- package/internal/session/manager.go +203 -20
- package/package.json +21 -6
- package/pkg/rpc/handler.go +28 -0
- package/skill.md +65 -65
- package/gssh-darwin-arm64.tar.gz +0 -0
- package/homebrew/gssh.plist +0 -22
- package/homebrew/install.sh +0 -43
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
|
|
|
@@ -100,7 +103,7 @@ func Connect(user, host string, port int, password, keyPath string) (*SSHClient,
|
|
|
100
103
|
return NewSSHClient(user, host, port, authMethods...)
|
|
101
104
|
}
|
|
102
105
|
|
|
103
|
-
// Exec executes a command on the remote host
|
|
106
|
+
// Exec executes a command on the remote host using an interactive login shell
|
|
104
107
|
func (c *SSHClient) Exec(cmd string) (string, string, int, error) {
|
|
105
108
|
session, err := c.Client.NewSession()
|
|
106
109
|
if err != nil {
|
|
@@ -108,7 +111,29 @@ func (c *SSHClient) Exec(cmd string) (string, string, int, error) {
|
|
|
108
111
|
}
|
|
109
112
|
defer session.Close()
|
|
110
113
|
|
|
111
|
-
|
|
114
|
+
// Get terminal info for PTY
|
|
115
|
+
// Use default term if not available
|
|
116
|
+
termEnv := os.Getenv("TERM")
|
|
117
|
+
if termEnv == "" {
|
|
118
|
+
termEnv = "xterm-256color"
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Request PTY for interactive shell
|
|
122
|
+
termWidth := 80
|
|
123
|
+
termHeight := 24
|
|
124
|
+
if err := session.RequestPty(termEnv, termWidth, termHeight, ssh.TerminalModes{
|
|
125
|
+
ssh.ECHO: 1,
|
|
126
|
+
ssh.TTY_OP_ISPEED: 14400,
|
|
127
|
+
ssh.TTY_OP_OSPEED: 14400,
|
|
128
|
+
}); err != nil {
|
|
129
|
+
return "", "", 0, fmt.Errorf("failed to request PTY: %w", err)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Execute command using login shell to load user config files (.zprofile, .bash_profile, etc.)
|
|
133
|
+
// Using -l for login shell, -c to execute command
|
|
134
|
+
fullCmd := fmt.Sprintf("/bin/zsh -l -c %q", cmd)
|
|
135
|
+
|
|
136
|
+
output, err := session.CombinedOutput(fullCmd)
|
|
112
137
|
if err != nil {
|
|
113
138
|
exitErr, ok := err.(*ssh.ExitError)
|
|
114
139
|
if ok {
|
|
@@ -130,6 +155,231 @@ func (c *SSHClient) LocalForward(localPort, remotePort int) (net.Listener, error
|
|
|
130
155
|
return c.Client.Listen("tcp", fmt.Sprintf("localhost:%d", localPort))
|
|
131
156
|
}
|
|
132
157
|
|
|
158
|
+
// SFTPClient wraps SFTP client for file transfers
|
|
159
|
+
type SFTPClient struct {
|
|
160
|
+
Client *sftp.Client
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// NewSFTPClient creates a new SFTP client from SSH client
|
|
164
|
+
func (c *SSHClient) NewSFTPClient() (*SFTPClient, error) {
|
|
165
|
+
sftpClient, err := sftp.NewClient(c.Client)
|
|
166
|
+
if err != nil {
|
|
167
|
+
return nil, fmt.Errorf("failed to create SFTP client: %w", err)
|
|
168
|
+
}
|
|
169
|
+
return &SFTPClient{Client: sftpClient}, nil
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Upload uploads a local file to remote
|
|
173
|
+
func (s *SFTPClient) Upload(localPath, remotePath string) (int64, error) {
|
|
174
|
+
localFile, err := os.Open(localPath)
|
|
175
|
+
if err != nil {
|
|
176
|
+
return 0, fmt.Errorf("failed to open local file: %w", err)
|
|
177
|
+
}
|
|
178
|
+
defer localFile.Close()
|
|
179
|
+
|
|
180
|
+
localInfo, err := localFile.Stat()
|
|
181
|
+
if err != nil {
|
|
182
|
+
return 0, fmt.Errorf("failed to stat local file: %w", err)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
remoteFile, err := s.Client.Create(remotePath)
|
|
186
|
+
if err != nil {
|
|
187
|
+
return 0, fmt.Errorf("failed to create remote file: %w", err)
|
|
188
|
+
}
|
|
189
|
+
defer remoteFile.Close()
|
|
190
|
+
|
|
191
|
+
written, err := io.Copy(remoteFile, localFile)
|
|
192
|
+
if err != nil {
|
|
193
|
+
return 0, fmt.Errorf("failed to upload file: %w", err)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Set file permissions
|
|
197
|
+
s.Client.Chmod(remotePath, localInfo.Mode())
|
|
198
|
+
|
|
199
|
+
return written, nil
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Download downloads a remote file to local
|
|
203
|
+
func (s *SFTPClient) Download(remotePath, localPath string) (int64, error) {
|
|
204
|
+
remoteFile, err := s.Client.Open(remotePath)
|
|
205
|
+
if err != nil {
|
|
206
|
+
return 0, fmt.Errorf("failed to open remote file: %w", err)
|
|
207
|
+
}
|
|
208
|
+
defer remoteFile.Close()
|
|
209
|
+
|
|
210
|
+
localFile, err := os.Create(localPath)
|
|
211
|
+
if err != nil {
|
|
212
|
+
return 0, fmt.Errorf("failed to create local file: %w", err)
|
|
213
|
+
}
|
|
214
|
+
defer localFile.Close()
|
|
215
|
+
|
|
216
|
+
written, err := io.Copy(localFile, remoteFile)
|
|
217
|
+
if err != nil {
|
|
218
|
+
return 0, fmt.Errorf("failed to download file: %w", err)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Get remote file info to set local permissions
|
|
222
|
+
remoteInfo, err := s.Client.Stat(remotePath)
|
|
223
|
+
if err == nil {
|
|
224
|
+
os.Chmod(localPath, remoteInfo.Mode())
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return written, nil
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// List lists files in a remote directory
|
|
231
|
+
func (s *SFTPClient) List(path string) ([]string, error) {
|
|
232
|
+
entries, err := s.Client.ReadDir(path)
|
|
233
|
+
if err != nil {
|
|
234
|
+
return nil, fmt.Errorf("failed to read directory: %w", err)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
names := make([]string, 0, len(entries))
|
|
238
|
+
for _, entry := range entries {
|
|
239
|
+
names = append(names, entry.Name())
|
|
240
|
+
}
|
|
241
|
+
return names, nil
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Mkdir creates a remote directory
|
|
245
|
+
func (s *SFTPClient) Mkdir(path string) error {
|
|
246
|
+
return s.Client.Mkdir(path)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Remove removes a remote file
|
|
250
|
+
func (s *SFTPClient) Remove(path string) error {
|
|
251
|
+
return s.Client.Remove(path)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Stat gets remote file info
|
|
255
|
+
func (s *SFTPClient) Stat(path string) (os.FileInfo, error) {
|
|
256
|
+
return s.Client.Stat(path)
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Close closes the SFTP client
|
|
260
|
+
func (s *SFTPClient) Close() error {
|
|
261
|
+
return s.Client.Close()
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// TransferResult represents the result of a file transfer
|
|
265
|
+
type TransferResult struct {
|
|
266
|
+
Bytes int64
|
|
267
|
+
Duration time.Duration
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// TransferWithProgress uploads or downloads a file with progress callback
|
|
271
|
+
func (s *SFTPClient) TransferWithProgress(isUpload bool, src, dst string, progress func(int64)) (*TransferResult, error) {
|
|
272
|
+
start := time.Now()
|
|
273
|
+
|
|
274
|
+
if isUpload {
|
|
275
|
+
result, err := s.uploadWithProgress(src, dst, progress)
|
|
276
|
+
if err != nil {
|
|
277
|
+
return nil, err
|
|
278
|
+
}
|
|
279
|
+
return &TransferResult{
|
|
280
|
+
Bytes: result,
|
|
281
|
+
Duration: time.Since(start),
|
|
282
|
+
}, nil
|
|
283
|
+
} else {
|
|
284
|
+
result, err := s.downloadWithProgress(src, dst, progress)
|
|
285
|
+
if err != nil {
|
|
286
|
+
return nil, err
|
|
287
|
+
}
|
|
288
|
+
return &TransferResult{
|
|
289
|
+
Bytes: result,
|
|
290
|
+
Duration: time.Since(start),
|
|
291
|
+
}, nil
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
func (s *SFTPClient) uploadWithProgress(localPath, remotePath string, progress func(int64)) (int64, error) {
|
|
296
|
+
localFile, err := os.Open(localPath)
|
|
297
|
+
if err != nil {
|
|
298
|
+
return 0, fmt.Errorf("failed to open local file: %w", err)
|
|
299
|
+
}
|
|
300
|
+
defer localFile.Close()
|
|
301
|
+
|
|
302
|
+
localInfo, err := localFile.Stat()
|
|
303
|
+
if err != nil {
|
|
304
|
+
return 0, fmt.Errorf("failed to stat local file: %w", err)
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
remoteFile, err := s.Client.Create(remotePath)
|
|
308
|
+
if err != nil {
|
|
309
|
+
return 0, fmt.Errorf("failed to create remote file: %w", err)
|
|
310
|
+
}
|
|
311
|
+
defer remoteFile.Close()
|
|
312
|
+
|
|
313
|
+
// Create a writer with progress tracking
|
|
314
|
+
writer := &progressWriter{
|
|
315
|
+
w: remoteFile,
|
|
316
|
+
progress: progress,
|
|
317
|
+
total: localInfo.Size(),
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
written, err := io.Copy(writer, localFile)
|
|
321
|
+
if err != nil {
|
|
322
|
+
return 0, fmt.Errorf("failed to upload file: %w", err)
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Set file permissions
|
|
326
|
+
s.Client.Chmod(remotePath, localInfo.Mode())
|
|
327
|
+
|
|
328
|
+
return written, nil
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
func (s *SFTPClient) downloadWithProgress(remotePath, localPath string, progress func(int64)) (int64, error) {
|
|
332
|
+
remoteFile, err := s.Client.Open(remotePath)
|
|
333
|
+
if err != nil {
|
|
334
|
+
return 0, fmt.Errorf("failed to open remote file: %w", err)
|
|
335
|
+
}
|
|
336
|
+
defer remoteFile.Close()
|
|
337
|
+
|
|
338
|
+
remoteInfo, err := s.Client.Stat(remotePath)
|
|
339
|
+
if err != nil {
|
|
340
|
+
return 0, fmt.Errorf("failed to stat remote file: %w", err)
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
localFile, err := os.Create(localPath)
|
|
344
|
+
if err != nil {
|
|
345
|
+
return 0, fmt.Errorf("failed to create local file: %w", err)
|
|
346
|
+
}
|
|
347
|
+
defer localFile.Close()
|
|
348
|
+
|
|
349
|
+
// Create a writer with progress tracking
|
|
350
|
+
writer := &progressWriter{
|
|
351
|
+
w: localFile,
|
|
352
|
+
progress: progress,
|
|
353
|
+
total: remoteInfo.Size(),
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
written, err := io.Copy(writer, remoteFile)
|
|
357
|
+
if err != nil {
|
|
358
|
+
return 0, fmt.Errorf("failed to download file: %w", err)
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Set local file permissions
|
|
362
|
+
os.Chmod(localPath, remoteInfo.Mode())
|
|
363
|
+
|
|
364
|
+
return written, nil
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
type progressWriter struct {
|
|
368
|
+
w io.Writer
|
|
369
|
+
progress func(int64)
|
|
370
|
+
total int64
|
|
371
|
+
written int64
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
func (pw *progressWriter) Write(p []byte) (n int, err error) {
|
|
375
|
+
n, err = pw.w.Write(p)
|
|
376
|
+
pw.written += int64(n)
|
|
377
|
+
if pw.progress != nil {
|
|
378
|
+
pw.progress(pw.written)
|
|
379
|
+
}
|
|
380
|
+
return
|
|
381
|
+
}
|
|
382
|
+
|
|
133
383
|
// expandPath expands ~ to home directory
|
|
134
384
|
func expandPath(path string) string {
|
|
135
385
|
if len(path) > 0 && path[0] == '~' {
|
|
@@ -3,6 +3,7 @@ package portforward
|
|
|
3
3
|
import (
|
|
4
4
|
"fmt"
|
|
5
5
|
"io"
|
|
6
|
+
"log"
|
|
6
7
|
"net"
|
|
7
8
|
"sync"
|
|
8
9
|
|
|
@@ -16,12 +17,12 @@ type Forwarder struct {
|
|
|
16
17
|
LocalPort int
|
|
17
18
|
RemotePort int
|
|
18
19
|
|
|
19
|
-
sshClient
|
|
20
|
-
listener
|
|
21
|
-
conns
|
|
22
|
-
mu
|
|
23
|
-
closed
|
|
24
|
-
wg
|
|
20
|
+
sshClient *ssh.Client
|
|
21
|
+
listener net.Listener
|
|
22
|
+
conns map[net.Conn]bool
|
|
23
|
+
mu sync.RWMutex
|
|
24
|
+
closed bool
|
|
25
|
+
wg sync.WaitGroup
|
|
25
26
|
}
|
|
26
27
|
|
|
27
28
|
// NewForwarder creates a new port forwarder
|
|
@@ -42,6 +43,10 @@ func NewForwarder(sshClient *ssh.Client, forwardType string, localPort, remotePo
|
|
|
42
43
|
return nil, fmt.Errorf("failed to listen on %s: %w", addr, err)
|
|
43
44
|
}
|
|
44
45
|
f.listener = listener
|
|
46
|
+
log.Printf("[portforward] Local forward: localhost:%d -> remote:%d", localPort, remotePort)
|
|
47
|
+
} else if forwardType == "remote" {
|
|
48
|
+
// Remote port forward: remote:remotePort -> localhost:localPort
|
|
49
|
+
log.Printf("[portforward] Remote forward: remote:%d -> localhost:%d", remotePort, localPort)
|
|
45
50
|
}
|
|
46
51
|
|
|
47
52
|
return f, nil
|
|
@@ -51,7 +56,7 @@ func NewForwarder(sshClient *ssh.Client, forwardType string, localPort, remotePo
|
|
|
51
56
|
func (f *Forwarder) Start() {
|
|
52
57
|
if f.Type == "local" {
|
|
53
58
|
f.startLocalForward()
|
|
54
|
-
} else {
|
|
59
|
+
} else if f.Type == "remote" {
|
|
55
60
|
f.startRemoteForward()
|
|
56
61
|
}
|
|
57
62
|
}
|
|
@@ -98,13 +103,18 @@ func (f *Forwarder) handleLocalConnection(localConn net.Conn) {
|
|
|
98
103
|
f.mu.Unlock()
|
|
99
104
|
}()
|
|
100
105
|
|
|
106
|
+
log.Printf("[portforward] Connection accepted from %s", localConn.RemoteAddr())
|
|
107
|
+
|
|
101
108
|
remoteAddr := fmt.Sprintf("localhost:%d", f.RemotePort)
|
|
102
109
|
remoteConn, err := f.sshClient.Dial("tcp", remoteAddr)
|
|
103
110
|
if err != nil {
|
|
111
|
+
log.Printf("[portforward] Failed to connect to remote %s: %v", remoteAddr, err)
|
|
104
112
|
return
|
|
105
113
|
}
|
|
106
114
|
defer remoteConn.Close()
|
|
107
115
|
|
|
116
|
+
log.Printf("[portforward] Tunnel established to remote %s", remoteAddr)
|
|
117
|
+
|
|
108
118
|
// Bidirectional copy
|
|
109
119
|
done := make(chan struct{})
|
|
110
120
|
go func() {
|
|
@@ -119,14 +129,34 @@ func (f *Forwarder) handleLocalConnection(localConn net.Conn) {
|
|
|
119
129
|
}
|
|
120
130
|
|
|
121
131
|
// startRemoteForward handles remote port forwarding
|
|
132
|
+
// Remote port forward: remote:remotePort -> localhost:localPort
|
|
122
133
|
func (f *Forwarder) startRemoteForward() {
|
|
123
|
-
// Remote port forwarding: remote:remotePort -> localhost:localPort
|
|
124
134
|
f.wg.Add(1)
|
|
125
135
|
go func() {
|
|
126
136
|
defer f.wg.Done()
|
|
127
137
|
|
|
128
|
-
// Request the server to
|
|
129
|
-
//
|
|
138
|
+
// Request the SSH server to listen on remote:remotePort and forward connections to us
|
|
139
|
+
// Payload format: string (address) + uint32 (port)
|
|
140
|
+
addr := fmt.Sprintf(":%d", f.RemotePort)
|
|
141
|
+
payload := ssh.Marshal(struct {
|
|
142
|
+
Addr string
|
|
143
|
+
Port uint32
|
|
144
|
+
}{Addr: addr, Port: uint32(f.RemotePort)})
|
|
145
|
+
|
|
146
|
+
ok, _, err := f.sshClient.SendRequest("tcpip-forward", true, payload)
|
|
147
|
+
if err != nil {
|
|
148
|
+
log.Printf("[portforward] Failed to send tcpip-forward request: %v", err)
|
|
149
|
+
return
|
|
150
|
+
}
|
|
151
|
+
if !ok {
|
|
152
|
+
log.Printf("[portforward] SSH server rejected tcpip-forward request for port %d", f.RemotePort)
|
|
153
|
+
return
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
log.Printf("[portforward] Remote forward: SSH server listening on port %d", f.RemotePort)
|
|
157
|
+
|
|
158
|
+
// Now we need to accept forwarded connections from the SSH server
|
|
159
|
+
// The SSH server will open a "forwarded-tcpip" channel for each connection
|
|
130
160
|
for {
|
|
131
161
|
f.mu.RLock()
|
|
132
162
|
if f.closed {
|
|
@@ -135,9 +165,46 @@ func (f *Forwarder) startRemoteForward() {
|
|
|
135
165
|
}
|
|
136
166
|
f.mu.RUnlock()
|
|
137
167
|
|
|
138
|
-
// Wait for
|
|
139
|
-
|
|
140
|
-
|
|
168
|
+
// Wait for a forwarded connection
|
|
169
|
+
ch, reqs, err := f.sshClient.OpenChannel("forwarded-tcpip", nil)
|
|
170
|
+
if err != nil {
|
|
171
|
+
f.mu.RLock()
|
|
172
|
+
closed := f.closed
|
|
173
|
+
f.mu.RUnlock()
|
|
174
|
+
if closed {
|
|
175
|
+
return
|
|
176
|
+
}
|
|
177
|
+
log.Printf("[portforward] Error accepting forwarded connection: %v", err)
|
|
178
|
+
continue
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Discard any requests
|
|
182
|
+
go ssh.DiscardRequests(reqs)
|
|
183
|
+
|
|
184
|
+
// Connect to local port
|
|
185
|
+
localAddr := fmt.Sprintf("localhost:%d", f.LocalPort)
|
|
186
|
+
localConn, err := net.Dial("tcp", localAddr)
|
|
187
|
+
if err != nil {
|
|
188
|
+
log.Printf("[portforward] Failed to connect to local port %d: %v", f.LocalPort, err)
|
|
189
|
+
ch.Close()
|
|
190
|
+
continue
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
f.mu.Lock()
|
|
194
|
+
f.conns[localConn] = true
|
|
195
|
+
f.mu.Unlock()
|
|
196
|
+
|
|
197
|
+
log.Printf("[portforward] Remote connection forwarded to localhost:%d", f.LocalPort)
|
|
198
|
+
|
|
199
|
+
// Bidirectional copy
|
|
200
|
+
go func() {
|
|
201
|
+
io.Copy(ch, localConn)
|
|
202
|
+
ch.Close()
|
|
203
|
+
localConn.Close()
|
|
204
|
+
}()
|
|
205
|
+
go func() {
|
|
206
|
+
io.Copy(localConn, ch)
|
|
207
|
+
}()
|
|
141
208
|
}
|
|
142
209
|
}()
|
|
143
210
|
}
|
|
@@ -175,13 +242,15 @@ func (f *Forwarder) Restart(sshClient *ssh.Client) {
|
|
|
175
242
|
f.listener.Close()
|
|
176
243
|
}
|
|
177
244
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
245
|
+
if f.Type == "local" {
|
|
246
|
+
// Create new listener
|
|
247
|
+
addr := fmt.Sprintf("localhost:%d", f.LocalPort)
|
|
248
|
+
listener, err := net.Listen("tcp", addr)
|
|
249
|
+
if err != nil {
|
|
250
|
+
return
|
|
251
|
+
}
|
|
252
|
+
f.listener = listener
|
|
183
253
|
}
|
|
184
|
-
f.listener = listener
|
|
185
254
|
|
|
186
255
|
f.Start()
|
|
187
256
|
}
|
|
@@ -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
|
+
}
|