heyvm 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/LICENSE +21 -0
- package/README.md +621 -0
- package/bin/file-browser +0 -0
- package/bin/heyvm +0 -0
- package/bin/heyvm-core +0 -0
- package/bin/heyvm.js +61 -0
- package/core/README.md +51 -0
- package/core/cmd/heyvm-core/main.go +73 -0
- package/core/go.mod +25 -0
- package/core/go.sum +47 -0
- package/core/internal/auth/errors.go +29 -0
- package/core/internal/auth/password.go +187 -0
- package/core/internal/auth/provider.go +33 -0
- package/core/internal/auth/ssh_key.go +142 -0
- package/core/internal/config/config.go +111 -0
- package/core/internal/ipc/actions.go +738 -0
- package/core/internal/ipc/handler.go +281 -0
- package/core/internal/ipc/protocol.go +126 -0
- package/core/internal/sftp/errors.go +29 -0
- package/core/internal/sftp/manager.go +303 -0
- package/core/internal/sftp/types.go +30 -0
- package/core/internal/ssh/errors.go +23 -0
- package/core/internal/ssh/manager.go +226 -0
- package/core/internal/ssh/session.go +105 -0
- package/core/internal/vm/errors.go +35 -0
- package/core/internal/vm/models.go +84 -0
- package/core/internal/vm/registry.go +240 -0
- package/package.json +59 -0
- package/scripts/install.js +100 -0
- package/ui/README.md +43 -0
- package/ui/dist/App.d.ts +3 -0
- package/ui/dist/App.d.ts.map +1 -0
- package/ui/dist/App.js +142 -0
- package/ui/dist/App.js.map +1 -0
- package/ui/dist/components/ConfirmDialog.d.ts +9 -0
- package/ui/dist/components/ConfirmDialog.d.ts.map +1 -0
- package/ui/dist/components/ConfirmDialog.js +22 -0
- package/ui/dist/components/ConfirmDialog.js.map +1 -0
- package/ui/dist/components/ErrorMessage.d.ts +8 -0
- package/ui/dist/components/ErrorMessage.d.ts.map +1 -0
- package/ui/dist/components/ErrorMessage.js +10 -0
- package/ui/dist/components/ErrorMessage.js.map +1 -0
- package/ui/dist/components/Header.d.ts +8 -0
- package/ui/dist/components/Header.d.ts.map +1 -0
- package/ui/dist/components/Header.js +10 -0
- package/ui/dist/components/Header.js.map +1 -0
- package/ui/dist/components/LoadingSpinner.d.ts +7 -0
- package/ui/dist/components/LoadingSpinner.d.ts.map +1 -0
- package/ui/dist/components/LoadingSpinner.js +7 -0
- package/ui/dist/components/LoadingSpinner.js.map +1 -0
- package/ui/dist/components/StatusBar.d.ts +11 -0
- package/ui/dist/components/StatusBar.d.ts.map +1 -0
- package/ui/dist/components/StatusBar.js +11 -0
- package/ui/dist/components/StatusBar.js.map +1 -0
- package/ui/dist/core/ipc.d.ts +96 -0
- package/ui/dist/core/ipc.d.ts.map +1 -0
- package/ui/dist/core/ipc.js +310 -0
- package/ui/dist/core/ipc.js.map +1 -0
- package/ui/dist/core/types.d.ts +45 -0
- package/ui/dist/core/types.d.ts.map +1 -0
- package/ui/dist/core/types.js +3 -0
- package/ui/dist/core/types.js.map +1 -0
- package/ui/dist/hooks/useFiles.d.ts +14 -0
- package/ui/dist/hooks/useFiles.d.ts.map +1 -0
- package/ui/dist/hooks/useFiles.js +102 -0
- package/ui/dist/hooks/useFiles.js.map +1 -0
- package/ui/dist/hooks/useVM.d.ts +10 -0
- package/ui/dist/hooks/useVM.d.ts.map +1 -0
- package/ui/dist/hooks/useVM.js +54 -0
- package/ui/dist/hooks/useVM.js.map +1 -0
- package/ui/dist/hooks/useVMList.d.ts +10 -0
- package/ui/dist/hooks/useVMList.d.ts.map +1 -0
- package/ui/dist/hooks/useVMList.js +56 -0
- package/ui/dist/hooks/useVMList.js.map +1 -0
- package/ui/dist/index.d.ts +3 -0
- package/ui/dist/index.d.ts.map +1 -0
- package/ui/dist/index.js +7488 -0
- package/ui/dist/index.js.map +1 -0
- package/ui/dist/keybindings.d.ts +146 -0
- package/ui/dist/keybindings.d.ts.map +1 -0
- package/ui/dist/keybindings.js +96 -0
- package/ui/dist/keybindings.js.map +1 -0
- package/ui/dist/screens/AddVMScreen.d.ts +9 -0
- package/ui/dist/screens/AddVMScreen.d.ts.map +1 -0
- package/ui/dist/screens/AddVMScreen.js +163 -0
- package/ui/dist/screens/AddVMScreen.js.map +1 -0
- package/ui/dist/screens/VMDetailScreen.d.ts +12 -0
- package/ui/dist/screens/VMDetailScreen.d.ts.map +1 -0
- package/ui/dist/screens/VMDetailScreen.js +96 -0
- package/ui/dist/screens/VMDetailScreen.js.map +1 -0
- package/ui/dist/screens/VMListScreen.d.ts +12 -0
- package/ui/dist/screens/VMListScreen.d.ts.map +1 -0
- package/ui/dist/screens/VMListScreen.js +158 -0
- package/ui/dist/screens/VMListScreen.js.map +1 -0
- package/ui/dist/screens/tabs/FilesTab.d.ts +9 -0
- package/ui/dist/screens/tabs/FilesTab.d.ts.map +1 -0
- package/ui/dist/screens/tabs/FilesTab.js +374 -0
- package/ui/dist/screens/tabs/FilesTab.js.map +1 -0
- package/ui/dist/screens/tabs/OverviewTab.d.ts +10 -0
- package/ui/dist/screens/tabs/OverviewTab.d.ts.map +1 -0
- package/ui/dist/screens/tabs/OverviewTab.js +110 -0
- package/ui/dist/screens/tabs/OverviewTab.js.map +1 -0
- package/ui/dist/screens/tabs/TerminalTab.d.ts +9 -0
- package/ui/dist/screens/tabs/TerminalTab.d.ts.map +1 -0
- package/ui/dist/screens/tabs/TerminalTab.js +270 -0
- package/ui/dist/screens/tabs/TerminalTab.js.map +1 -0
- package/ui/dist/utils/terminalEmulator.d.ts +49 -0
- package/ui/dist/utils/terminalEmulator.d.ts.map +1 -0
- package/ui/dist/utils/terminalEmulator.js +88 -0
- package/ui/dist/utils/terminalEmulator.js.map +1 -0
- package/ui/package.json +34 -0
- package/ui/scripts/start-with-core.js +81 -0
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
package sftp
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"fmt"
|
|
5
|
+
"io"
|
|
6
|
+
"os"
|
|
7
|
+
"path/filepath"
|
|
8
|
+
|
|
9
|
+
"github.com/pkg/sftp"
|
|
10
|
+
"golang.org/x/crypto/ssh"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
// Manager handles SFTP file operations
|
|
14
|
+
type Manager struct {
|
|
15
|
+
client *sftp.Client
|
|
16
|
+
ssh *ssh.Client
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// NewManager creates a new SFTP manager from an SSH client
|
|
20
|
+
func NewManager(sshClient *ssh.Client) (*Manager, error) {
|
|
21
|
+
if sshClient == nil {
|
|
22
|
+
return nil, fmt.Errorf("SSH client cannot be nil")
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Create SFTP client
|
|
26
|
+
client, err := sftp.NewClient(sshClient)
|
|
27
|
+
if err != nil {
|
|
28
|
+
return nil, fmt.Errorf("failed to create SFTP client: %w", err)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return &Manager{
|
|
32
|
+
client: client,
|
|
33
|
+
ssh: sshClient,
|
|
34
|
+
}, nil
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// List lists files and directories at the specified path
|
|
38
|
+
func (m *Manager) List(path string) ([]FileInfo, error) {
|
|
39
|
+
if m.client == nil {
|
|
40
|
+
return nil, ErrSFTPClientNotInitialized
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Read directory
|
|
44
|
+
entries, err := m.client.ReadDir(path)
|
|
45
|
+
if err != nil {
|
|
46
|
+
return nil, fmt.Errorf("failed to read directory: %w", err)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Convert to FileInfo
|
|
50
|
+
files := make([]FileInfo, 0, len(entries))
|
|
51
|
+
for _, entry := range entries {
|
|
52
|
+
files = append(files, FileInfo{
|
|
53
|
+
Name: entry.Name(),
|
|
54
|
+
Size: entry.Size(),
|
|
55
|
+
Mode: entry.Mode(),
|
|
56
|
+
ModTime: entry.ModTime(),
|
|
57
|
+
IsDir: entry.IsDir(),
|
|
58
|
+
})
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return files, nil
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Upload uploads a local file to a remote path
|
|
65
|
+
func (m *Manager) Upload(localPath, remotePath string, opts *TransferOptions) error {
|
|
66
|
+
if m.client == nil {
|
|
67
|
+
return ErrSFTPClientNotInitialized
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if opts == nil {
|
|
71
|
+
opts = &TransferOptions{Overwrite: true}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Open local file
|
|
75
|
+
localFile, err := os.Open(localPath)
|
|
76
|
+
if err != nil {
|
|
77
|
+
return fmt.Errorf("failed to open local file: %w", err)
|
|
78
|
+
}
|
|
79
|
+
defer localFile.Close()
|
|
80
|
+
|
|
81
|
+
// Get file info for size and permissions
|
|
82
|
+
localInfo, err := localFile.Stat()
|
|
83
|
+
if err != nil {
|
|
84
|
+
return fmt.Errorf("failed to stat local file: %w", err)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Check if remote file exists
|
|
88
|
+
if !opts.Overwrite {
|
|
89
|
+
if _, err := m.client.Stat(remotePath); err == nil {
|
|
90
|
+
return ErrFileAlreadyExists
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Create remote file
|
|
95
|
+
remoteFile, err := m.client.Create(remotePath)
|
|
96
|
+
if err != nil {
|
|
97
|
+
return fmt.Errorf("failed to create remote file: %w", err)
|
|
98
|
+
}
|
|
99
|
+
defer remoteFile.Close()
|
|
100
|
+
|
|
101
|
+
// Copy file with progress tracking
|
|
102
|
+
totalBytes := localInfo.Size()
|
|
103
|
+
var bytesTransferred int64
|
|
104
|
+
|
|
105
|
+
// Use 128KB buffer for better throughput on large files
|
|
106
|
+
buf := make([]byte, 128*1024)
|
|
107
|
+
for {
|
|
108
|
+
n, err := localFile.Read(buf)
|
|
109
|
+
if err != nil && err != io.EOF {
|
|
110
|
+
return fmt.Errorf("%w: %v", ErrTransferFailed, err)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if n > 0 {
|
|
114
|
+
if _, err := remoteFile.Write(buf[:n]); err != nil {
|
|
115
|
+
return fmt.Errorf("%w: %v", ErrTransferFailed, err)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
bytesTransferred += int64(n)
|
|
119
|
+
|
|
120
|
+
// Report progress
|
|
121
|
+
if opts.OnProgress != nil {
|
|
122
|
+
opts.OnProgress(bytesTransferred, totalBytes)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if err == io.EOF {
|
|
127
|
+
break
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Preserve permissions if requested
|
|
132
|
+
if opts.PreservePermissions {
|
|
133
|
+
if err := m.client.Chmod(remotePath, localInfo.Mode()); err != nil {
|
|
134
|
+
// Don't fail the transfer if chmod fails, just log
|
|
135
|
+
fmt.Fprintf(os.Stderr, "Warning: failed to set permissions on %s: %v\n", remotePath, err)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return nil
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Download downloads a remote file to a local path
|
|
143
|
+
func (m *Manager) Download(remotePath, localPath string, opts *TransferOptions) error {
|
|
144
|
+
if m.client == nil {
|
|
145
|
+
return ErrSFTPClientNotInitialized
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if opts == nil {
|
|
149
|
+
opts = &TransferOptions{Overwrite: true}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Open remote file
|
|
153
|
+
remoteFile, err := m.client.Open(remotePath)
|
|
154
|
+
if err != nil {
|
|
155
|
+
return fmt.Errorf("failed to open remote file: %w", err)
|
|
156
|
+
}
|
|
157
|
+
defer remoteFile.Close()
|
|
158
|
+
|
|
159
|
+
// Get file info for size
|
|
160
|
+
remoteInfo, err := remoteFile.Stat()
|
|
161
|
+
if err != nil {
|
|
162
|
+
return fmt.Errorf("failed to stat remote file: %w", err)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if remoteInfo.IsDir() {
|
|
166
|
+
return ErrIsDirectory
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Check if local file exists
|
|
170
|
+
if !opts.Overwrite {
|
|
171
|
+
if _, err := os.Stat(localPath); err == nil {
|
|
172
|
+
return ErrFileAlreadyExists
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Create local file
|
|
177
|
+
localFile, err := os.Create(localPath)
|
|
178
|
+
if err != nil {
|
|
179
|
+
return fmt.Errorf("failed to create local file: %w", err)
|
|
180
|
+
}
|
|
181
|
+
defer localFile.Close()
|
|
182
|
+
|
|
183
|
+
// Copy file with progress tracking
|
|
184
|
+
totalBytes := remoteInfo.Size()
|
|
185
|
+
var bytesTransferred int64
|
|
186
|
+
|
|
187
|
+
// Use 128KB buffer for better throughput on large files
|
|
188
|
+
buf := make([]byte, 128*1024)
|
|
189
|
+
for {
|
|
190
|
+
n, err := remoteFile.Read(buf)
|
|
191
|
+
if err != nil && err != io.EOF {
|
|
192
|
+
return fmt.Errorf("%w: %v", ErrTransferFailed, err)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if n > 0 {
|
|
196
|
+
if _, err := localFile.Write(buf[:n]); err != nil {
|
|
197
|
+
return fmt.Errorf("%w: %v", ErrTransferFailed, err)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
bytesTransferred += int64(n)
|
|
201
|
+
|
|
202
|
+
// Report progress
|
|
203
|
+
if opts.OnProgress != nil {
|
|
204
|
+
opts.OnProgress(bytesTransferred, totalBytes)
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if err == io.EOF {
|
|
209
|
+
break
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Preserve permissions if requested
|
|
214
|
+
if opts.PreservePermissions {
|
|
215
|
+
if err := os.Chmod(localPath, remoteInfo.Mode()); err != nil {
|
|
216
|
+
// Don't fail the transfer if chmod fails, just log
|
|
217
|
+
fmt.Fprintf(os.Stderr, "Warning: failed to set permissions on %s: %v\n", localPath, err)
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return nil
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Delete deletes a file or directory
|
|
225
|
+
func (m *Manager) Delete(path string) error {
|
|
226
|
+
if m.client == nil {
|
|
227
|
+
return ErrSFTPClientNotInitialized
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Check if path is a directory
|
|
231
|
+
info, err := m.client.Stat(path)
|
|
232
|
+
if err != nil {
|
|
233
|
+
return fmt.Errorf("failed to stat path: %w", err)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if info.IsDir() {
|
|
237
|
+
// Remove directory (must be empty)
|
|
238
|
+
if err := m.client.RemoveDirectory(path); err != nil {
|
|
239
|
+
return fmt.Errorf("failed to remove directory: %w", err)
|
|
240
|
+
}
|
|
241
|
+
} else {
|
|
242
|
+
// Remove file
|
|
243
|
+
if err := m.client.Remove(path); err != nil {
|
|
244
|
+
return fmt.Errorf("failed to remove file: %w", err)
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return nil
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Rename renames or moves a file/directory
|
|
252
|
+
func (m *Manager) Rename(oldPath, newPath string) error {
|
|
253
|
+
if m.client == nil {
|
|
254
|
+
return ErrSFTPClientNotInitialized
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if err := m.client.Rename(oldPath, newPath); err != nil {
|
|
258
|
+
return fmt.Errorf("failed to rename: %w", err)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return nil
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// MkdirAll creates a directory and all necessary parent directories
|
|
265
|
+
func (m *Manager) MkdirAll(path string) error {
|
|
266
|
+
if m.client == nil {
|
|
267
|
+
return ErrSFTPClientNotInitialized
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if err := m.client.MkdirAll(path); err != nil {
|
|
271
|
+
return fmt.Errorf("failed to create directory: %w", err)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return nil
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Stat returns file/directory information
|
|
278
|
+
func (m *Manager) Stat(path string) (*FileInfo, error) {
|
|
279
|
+
if m.client == nil {
|
|
280
|
+
return nil, ErrSFTPClientNotInitialized
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
info, err := m.client.Stat(path)
|
|
284
|
+
if err != nil {
|
|
285
|
+
return nil, fmt.Errorf("failed to stat path: %w", err)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return &FileInfo{
|
|
289
|
+
Name: filepath.Base(path),
|
|
290
|
+
Size: info.Size(),
|
|
291
|
+
Mode: info.Mode(),
|
|
292
|
+
ModTime: info.ModTime(),
|
|
293
|
+
IsDir: info.IsDir(),
|
|
294
|
+
}, nil
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Close closes the SFTP client
|
|
298
|
+
func (m *Manager) Close() error {
|
|
299
|
+
if m.client != nil {
|
|
300
|
+
return m.client.Close()
|
|
301
|
+
}
|
|
302
|
+
return nil
|
|
303
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
package sftp
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"os"
|
|
5
|
+
"time"
|
|
6
|
+
)
|
|
7
|
+
|
|
8
|
+
// FileInfo represents information about a file or directory
|
|
9
|
+
type FileInfo struct {
|
|
10
|
+
Name string `json:"name"`
|
|
11
|
+
Size int64 `json:"size"`
|
|
12
|
+
Mode os.FileMode `json:"mode"`
|
|
13
|
+
ModTime time.Time `json:"modTime"`
|
|
14
|
+
IsDir bool `json:"isDir"`
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// ProgressCallback is called during file transfers to report progress
|
|
18
|
+
type ProgressCallback func(bytesTransferred, totalBytes int64)
|
|
19
|
+
|
|
20
|
+
// TransferOptions contains options for file transfers
|
|
21
|
+
type TransferOptions struct {
|
|
22
|
+
// Progress callback (optional)
|
|
23
|
+
OnProgress ProgressCallback
|
|
24
|
+
|
|
25
|
+
// Overwrite existing files
|
|
26
|
+
Overwrite bool
|
|
27
|
+
|
|
28
|
+
// Preserve file permissions
|
|
29
|
+
PreservePermissions bool
|
|
30
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
package ssh
|
|
2
|
+
|
|
3
|
+
import "errors"
|
|
4
|
+
|
|
5
|
+
var (
|
|
6
|
+
// ErrNotConnected is returned when attempting operations on a disconnected client
|
|
7
|
+
ErrNotConnected = errors.New("SSH client not connected")
|
|
8
|
+
|
|
9
|
+
// ErrAlreadyConnected is returned when attempting to connect an already connected client
|
|
10
|
+
ErrAlreadyConnected = errors.New("SSH client already connected")
|
|
11
|
+
|
|
12
|
+
// ErrSessionFailed is returned when SSH session creation fails
|
|
13
|
+
ErrSessionFailed = errors.New("failed to create SSH session")
|
|
14
|
+
|
|
15
|
+
// ErrCommandFailed is returned when command execution fails
|
|
16
|
+
ErrCommandFailed = errors.New("command execution failed")
|
|
17
|
+
|
|
18
|
+
// ErrPTYFailed is returned when PTY allocation fails
|
|
19
|
+
ErrPTYFailed = errors.New("failed to allocate PTY")
|
|
20
|
+
|
|
21
|
+
// ErrSessionClosed is returned when operating on a closed session
|
|
22
|
+
ErrSessionClosed = errors.New("session is closed")
|
|
23
|
+
)
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
package ssh
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"bytes"
|
|
5
|
+
"fmt"
|
|
6
|
+
"io"
|
|
7
|
+
"sync"
|
|
8
|
+
|
|
9
|
+
"github.com/adishm/heyvm/internal/auth"
|
|
10
|
+
"github.com/adishm/heyvm/internal/vm"
|
|
11
|
+
"golang.org/x/crypto/ssh"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
// Manager manages SSH connections and sessions
|
|
15
|
+
type Manager struct {
|
|
16
|
+
vm *vm.VM
|
|
17
|
+
auth auth.Provider
|
|
18
|
+
client *ssh.Client
|
|
19
|
+
sessions map[string]*Session
|
|
20
|
+
mu sync.RWMutex
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// NewManager creates a new SSH manager
|
|
24
|
+
func NewManager(v *vm.VM, authProvider auth.Provider) *Manager {
|
|
25
|
+
return &Manager{
|
|
26
|
+
vm: v,
|
|
27
|
+
auth: authProvider,
|
|
28
|
+
sessions: make(map[string]*Session),
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Connect establishes an SSH connection
|
|
33
|
+
// This method is idempotent - if already connected, it returns nil
|
|
34
|
+
func (m *Manager) Connect() error {
|
|
35
|
+
m.mu.Lock()
|
|
36
|
+
defer m.mu.Unlock()
|
|
37
|
+
|
|
38
|
+
// Idempotent: if already connected, just return success
|
|
39
|
+
if m.client != nil {
|
|
40
|
+
return nil
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
client, err := m.auth.Connect(m.vm)
|
|
44
|
+
if err != nil {
|
|
45
|
+
return fmt.Errorf("connection failed: %w", err)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
m.client = client
|
|
49
|
+
return nil
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Disconnect closes the SSH connection and all sessions
|
|
53
|
+
func (m *Manager) Disconnect() error {
|
|
54
|
+
m.mu.Lock()
|
|
55
|
+
defer m.mu.Unlock()
|
|
56
|
+
|
|
57
|
+
// Close all sessions
|
|
58
|
+
for _, session := range m.sessions {
|
|
59
|
+
session.Close()
|
|
60
|
+
}
|
|
61
|
+
m.sessions = make(map[string]*Session)
|
|
62
|
+
|
|
63
|
+
// Close client
|
|
64
|
+
if m.client != nil {
|
|
65
|
+
if err := m.client.Close(); err != nil {
|
|
66
|
+
return fmt.Errorf("failed to close SSH client: %w", err)
|
|
67
|
+
}
|
|
68
|
+
m.client = nil
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return nil
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// IsConnected returns true if the SSH client is connected
|
|
75
|
+
func (m *Manager) IsConnected() bool {
|
|
76
|
+
m.mu.RLock()
|
|
77
|
+
defer m.mu.RUnlock()
|
|
78
|
+
return m.client != nil
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ExecuteCommand executes a single command and returns the output
|
|
82
|
+
func (m *Manager) ExecuteCommand(cmd string) (string, error) {
|
|
83
|
+
m.mu.RLock()
|
|
84
|
+
client := m.client
|
|
85
|
+
m.mu.RUnlock()
|
|
86
|
+
|
|
87
|
+
if client == nil {
|
|
88
|
+
return "", ErrNotConnected
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Create a new session for this command
|
|
92
|
+
session, err := client.NewSession()
|
|
93
|
+
if err != nil {
|
|
94
|
+
return "", fmt.Errorf("%w: %v", ErrSessionFailed, err)
|
|
95
|
+
}
|
|
96
|
+
defer session.Close()
|
|
97
|
+
|
|
98
|
+
// Capture output
|
|
99
|
+
var stdout, stderr bytes.Buffer
|
|
100
|
+
session.Stdout = &stdout
|
|
101
|
+
session.Stderr = &stderr
|
|
102
|
+
|
|
103
|
+
// Execute command
|
|
104
|
+
if err := session.Run(cmd); err != nil {
|
|
105
|
+
// Include stderr in error if command failed
|
|
106
|
+
if stderr.Len() > 0 {
|
|
107
|
+
return "", fmt.Errorf("%w: %v (stderr: %s)", ErrCommandFailed, err, stderr.String())
|
|
108
|
+
}
|
|
109
|
+
return "", fmt.Errorf("%w: %v", ErrCommandFailed, err)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return stdout.String(), nil
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// StartPTY starts an interactive PTY session
|
|
116
|
+
func (m *Manager) StartPTY(rows, cols int) (*Session, error) {
|
|
117
|
+
m.mu.RLock()
|
|
118
|
+
client := m.client
|
|
119
|
+
m.mu.RUnlock()
|
|
120
|
+
|
|
121
|
+
if client == nil {
|
|
122
|
+
return nil, ErrNotConnected
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Create SSH session
|
|
126
|
+
sshSession, err := client.NewSession()
|
|
127
|
+
if err != nil {
|
|
128
|
+
return nil, fmt.Errorf("%w: %v", ErrSessionFailed, err)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Request PTY
|
|
132
|
+
modes := ssh.TerminalModes{
|
|
133
|
+
ssh.ECHO: 1, // Enable echoing
|
|
134
|
+
ssh.TTY_OP_ISPEED: 14400, // Input speed = 14.4kbaud
|
|
135
|
+
ssh.TTY_OP_OSPEED: 14400, // Output speed = 14.4kbaud
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if err := sshSession.RequestPty("xterm-256color", rows, cols, modes); err != nil {
|
|
139
|
+
sshSession.Close()
|
|
140
|
+
return nil, fmt.Errorf("%w: %v", ErrPTYFailed, err)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Get pipes for stdin/stdout/stderr
|
|
144
|
+
stdin, err := sshSession.StdinPipe()
|
|
145
|
+
if err != nil {
|
|
146
|
+
sshSession.Close()
|
|
147
|
+
return nil, fmt.Errorf("failed to get stdin pipe: %w", err)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
stdout, err := sshSession.StdoutPipe()
|
|
151
|
+
if err != nil {
|
|
152
|
+
sshSession.Close()
|
|
153
|
+
return nil, fmt.Errorf("failed to get stdout pipe: %w", err)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
stderr, err := sshSession.StderrPipe()
|
|
157
|
+
if err != nil {
|
|
158
|
+
sshSession.Close()
|
|
159
|
+
return nil, fmt.Errorf("failed to get stderr pipe: %w", err)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Start shell
|
|
163
|
+
if err := sshSession.Shell(); err != nil {
|
|
164
|
+
sshSession.Close()
|
|
165
|
+
return nil, fmt.Errorf("failed to start shell: %w", err)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Merge stdout and stderr into single stream
|
|
169
|
+
// This prevents SSH deadlocks and ensures all output is captured
|
|
170
|
+
combinedOutput := io.MultiReader(stdout, stderr)
|
|
171
|
+
|
|
172
|
+
// Create session wrapper
|
|
173
|
+
session := &Session{
|
|
174
|
+
session: sshSession,
|
|
175
|
+
stdin: stdin,
|
|
176
|
+
combinedOutput: combinedOutput,
|
|
177
|
+
rows: rows,
|
|
178
|
+
cols: cols,
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Store session
|
|
182
|
+
m.mu.Lock()
|
|
183
|
+
m.sessions[session.ID()] = session
|
|
184
|
+
m.mu.Unlock()
|
|
185
|
+
|
|
186
|
+
return session, nil
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// GetSession retrieves a session by ID
|
|
190
|
+
func (m *Manager) GetSession(id string) (*Session, error) {
|
|
191
|
+
m.mu.RLock()
|
|
192
|
+
defer m.mu.RUnlock()
|
|
193
|
+
|
|
194
|
+
session, exists := m.sessions[id]
|
|
195
|
+
if !exists {
|
|
196
|
+
return nil, fmt.Errorf("session not found: %s", id)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return session, nil
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// CloseSession closes a specific session
|
|
203
|
+
func (m *Manager) CloseSession(id string) error {
|
|
204
|
+
m.mu.Lock()
|
|
205
|
+
defer m.mu.Unlock()
|
|
206
|
+
|
|
207
|
+
session, exists := m.sessions[id]
|
|
208
|
+
if !exists {
|
|
209
|
+
return fmt.Errorf("session not found: %s", id)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if err := session.Close(); err != nil {
|
|
213
|
+
return fmt.Errorf("failed to close session: %w", err)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
delete(m.sessions, id)
|
|
217
|
+
return nil
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// GetClient returns the underlying SSH client
|
|
221
|
+
// This is useful for creating SFTP clients
|
|
222
|
+
func (m *Manager) GetClient() *ssh.Client {
|
|
223
|
+
m.mu.RLock()
|
|
224
|
+
defer m.mu.RUnlock()
|
|
225
|
+
return m.client
|
|
226
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
package ssh
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"crypto/rand"
|
|
5
|
+
"encoding/hex"
|
|
6
|
+
"fmt"
|
|
7
|
+
"io"
|
|
8
|
+
"sync"
|
|
9
|
+
|
|
10
|
+
cryptossh "golang.org/x/crypto/ssh"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
// Session represents an interactive PTY session
|
|
14
|
+
type Session struct {
|
|
15
|
+
id string
|
|
16
|
+
session *cryptossh.Session
|
|
17
|
+
stdin io.WriteCloser
|
|
18
|
+
combinedOutput io.Reader // stdout + stderr merged
|
|
19
|
+
rows int
|
|
20
|
+
cols int
|
|
21
|
+
closed bool
|
|
22
|
+
mu sync.Mutex
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ID returns the session ID
|
|
26
|
+
func (s *Session) ID() string {
|
|
27
|
+
if s.id == "" {
|
|
28
|
+
s.id = generateSessionID()
|
|
29
|
+
}
|
|
30
|
+
return s.id
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Write sends data to the session's stdin
|
|
34
|
+
func (s *Session) Write(data []byte) (int, error) {
|
|
35
|
+
s.mu.Lock()
|
|
36
|
+
defer s.mu.Unlock()
|
|
37
|
+
|
|
38
|
+
if s.closed {
|
|
39
|
+
return 0, ErrSessionClosed
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return s.stdin.Write(data)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// CombinedOutput returns the merged stdout+stderr reader - for SINGLE goroutine use only
|
|
46
|
+
func (s *Session) CombinedOutput() io.Reader {
|
|
47
|
+
return s.combinedOutput
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Resize changes the terminal size
|
|
51
|
+
func (s *Session) Resize(rows, cols int) error {
|
|
52
|
+
s.mu.Lock()
|
|
53
|
+
defer s.mu.Unlock()
|
|
54
|
+
|
|
55
|
+
if s.closed {
|
|
56
|
+
return ErrSessionClosed
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if err := s.session.WindowChange(rows, cols); err != nil {
|
|
60
|
+
return fmt.Errorf("failed to resize window: %w", err)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
s.rows = rows
|
|
64
|
+
s.cols = cols
|
|
65
|
+
return nil
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Close closes the session
|
|
69
|
+
func (s *Session) Close() error {
|
|
70
|
+
s.mu.Lock()
|
|
71
|
+
defer s.mu.Unlock()
|
|
72
|
+
|
|
73
|
+
if s.closed {
|
|
74
|
+
return nil
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
s.closed = true
|
|
78
|
+
|
|
79
|
+
if s.session != nil {
|
|
80
|
+
return s.session.Close()
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return nil
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// IsClosed returns true if the session is closed
|
|
87
|
+
func (s *Session) IsClosed() bool {
|
|
88
|
+
s.mu.Lock()
|
|
89
|
+
defer s.mu.Unlock()
|
|
90
|
+
return s.closed
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// GetSize returns the current terminal size
|
|
94
|
+
func (s *Session) GetSize() (rows, cols int) {
|
|
95
|
+
s.mu.Lock()
|
|
96
|
+
defer s.mu.Unlock()
|
|
97
|
+
return s.rows, s.cols
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// generateSessionID generates a unique session ID
|
|
101
|
+
func generateSessionID() string {
|
|
102
|
+
b := make([]byte, 8)
|
|
103
|
+
rand.Read(b)
|
|
104
|
+
return hex.EncodeToString(b)
|
|
105
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
package vm
|
|
2
|
+
|
|
3
|
+
import "errors"
|
|
4
|
+
|
|
5
|
+
var (
|
|
6
|
+
// ErrVMNotFound is returned when a VM is not found in the registry
|
|
7
|
+
ErrVMNotFound = errors.New("VM not found")
|
|
8
|
+
|
|
9
|
+
// ErrVMAlreadyExists is returned when trying to add a VM with duplicate ID
|
|
10
|
+
ErrVMAlreadyExists = errors.New("VM already exists")
|
|
11
|
+
|
|
12
|
+
// ErrInvalidVMName is returned when VM name is empty
|
|
13
|
+
ErrInvalidVMName = errors.New("VM name cannot be empty")
|
|
14
|
+
|
|
15
|
+
// ErrInvalidVMHost is returned when VM host is empty
|
|
16
|
+
ErrInvalidVMHost = errors.New("VM host cannot be empty")
|
|
17
|
+
|
|
18
|
+
// ErrInvalidVMPort is returned when VM port is invalid
|
|
19
|
+
ErrInvalidVMPort = errors.New("VM port must be between 1 and 65535")
|
|
20
|
+
|
|
21
|
+
// ErrInvalidVMUsername is returned when VM username is empty
|
|
22
|
+
ErrInvalidVMUsername = errors.New("VM username cannot be empty")
|
|
23
|
+
|
|
24
|
+
// ErrInvalidAuthType is returned when auth type is not recognized
|
|
25
|
+
ErrInvalidAuthType = errors.New("invalid authentication type")
|
|
26
|
+
|
|
27
|
+
// ErrMissingKeyPath is returned when SSH key auth is selected but no key path provided
|
|
28
|
+
ErrMissingKeyPath = errors.New("SSH key path is required for key authentication")
|
|
29
|
+
|
|
30
|
+
// ErrConfigNotFound is returned when config file doesn't exist
|
|
31
|
+
ErrConfigNotFound = errors.New("configuration file not found")
|
|
32
|
+
|
|
33
|
+
// ErrConfigCorrupted is returned when config file is malformed
|
|
34
|
+
ErrConfigCorrupted = errors.New("configuration file is corrupted")
|
|
35
|
+
)
|