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
package/bin/heyvm.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* heyvm CLI entry point
|
|
5
|
+
* Launches the heyvm TUI with the Go backend
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { spawn } from 'child_process';
|
|
9
|
+
import { fileURLToPath } from 'url';
|
|
10
|
+
import { dirname, join } from 'path';
|
|
11
|
+
import { existsSync } from 'fs';
|
|
12
|
+
|
|
13
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
14
|
+
const __dirname = dirname(__filename);
|
|
15
|
+
|
|
16
|
+
// When installed globally via npm, __dirname is in node_modules/heyvm/bin
|
|
17
|
+
// When run locally, __dirname is in the project's bin directory
|
|
18
|
+
const rootDir = join(__dirname, '..');
|
|
19
|
+
const coreBinaryPath = join(rootDir, 'bin', 'heyvm-core');
|
|
20
|
+
const uiEntryPath = join(rootDir, 'ui', 'scripts', 'start-with-core.js');
|
|
21
|
+
|
|
22
|
+
// Check if installation is complete
|
|
23
|
+
if (!existsSync(coreBinaryPath)) {
|
|
24
|
+
console.error('❌ Error: heyvm-core binary not found.');
|
|
25
|
+
console.error('\nThe Go backend was not built during installation.');
|
|
26
|
+
console.error('Please ensure Go >= 1.21 is installed and run:');
|
|
27
|
+
console.error(' npm install -g heyvm');
|
|
28
|
+
console.error('\nDownload Go from: https://go.dev/dl/');
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (!existsSync(uiEntryPath)) {
|
|
33
|
+
console.error('❌ Error: UI entry point not found.');
|
|
34
|
+
console.error('Installation appears incomplete. Please reinstall:');
|
|
35
|
+
console.error(' npm install -g heyvm');
|
|
36
|
+
process.exit(1);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Launch heyvm via the UI's start script
|
|
40
|
+
const heyvmProcess = spawn('node', [uiEntryPath], {
|
|
41
|
+
stdio: 'inherit',
|
|
42
|
+
cwd: process.cwd(), // Use user's current directory
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
heyvmProcess.on('error', (err) => {
|
|
46
|
+
console.error('❌ Failed to start heyvm:', err.message);
|
|
47
|
+
process.exit(1);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
heyvmProcess.on('exit', (code) => {
|
|
51
|
+
process.exit(code || 0);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// Handle graceful shutdown
|
|
55
|
+
process.on('SIGINT', () => {
|
|
56
|
+
heyvmProcess.kill('SIGINT');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
process.on('SIGTERM', () => {
|
|
60
|
+
heyvmProcess.kill('SIGTERM');
|
|
61
|
+
});
|
package/core/README.md
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# heyvm Core
|
|
2
|
+
|
|
3
|
+
Go backend for heyvm - handles SSH connections, SFTP file transfers, and VM management.
|
|
4
|
+
|
|
5
|
+
## Development
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
# Build
|
|
9
|
+
go build -o ../bin/heyvm-core ./cmd/heyvm-core
|
|
10
|
+
|
|
11
|
+
# Run
|
|
12
|
+
../bin/heyvm-core
|
|
13
|
+
|
|
14
|
+
# Install dependencies
|
|
15
|
+
go mod tidy
|
|
16
|
+
|
|
17
|
+
# Run tests
|
|
18
|
+
go test ./...
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Architecture
|
|
22
|
+
|
|
23
|
+
- **Language**: Go
|
|
24
|
+
- **SSH Library**: golang.org/x/crypto/ssh
|
|
25
|
+
- **SFTP Library**: github.com/pkg/sftp
|
|
26
|
+
- **Config Storage**: YAML files in ~/.heyvm/
|
|
27
|
+
- **Credential Storage**: OS keychain (99designs/keyring)
|
|
28
|
+
- **Communication**: JSON-RPC over stdin/stdout
|
|
29
|
+
|
|
30
|
+
## Directory Structure
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
core/
|
|
34
|
+
├── cmd/
|
|
35
|
+
│ └── heyvm-core/ # Main entry point
|
|
36
|
+
├── internal/
|
|
37
|
+
│ ├── auth/ # Authentication providers (SSH key, password)
|
|
38
|
+
│ ├── config/ # Configuration management
|
|
39
|
+
│ ├── ipc/ # JSON-RPC protocol handler
|
|
40
|
+
│ ├── sftp/ # SFTP file operations
|
|
41
|
+
│ ├── ssh/ # SSH session management
|
|
42
|
+
│ └── vm/ # VM models and registry
|
|
43
|
+
└── go.mod # Go module definition
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Configuration
|
|
47
|
+
|
|
48
|
+
heyvm stores configuration in `~/.heyvm/`:
|
|
49
|
+
- `config.yaml` - VM definitions
|
|
50
|
+
- `state.json` - Runtime state
|
|
51
|
+
- `logs/` - Application logs
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
package main
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"log"
|
|
5
|
+
"os"
|
|
6
|
+
"path/filepath"
|
|
7
|
+
|
|
8
|
+
"github.com/adishm/heyvm/internal/ipc"
|
|
9
|
+
"github.com/adishm/heyvm/internal/vm"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
func main() {
|
|
13
|
+
// Initialize config directory (~/.heyvm/)
|
|
14
|
+
homeDir, err := os.UserHomeDir()
|
|
15
|
+
if err != nil {
|
|
16
|
+
log.Fatal(err)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
configDir := filepath.Join(homeDir, ".heyvm")
|
|
20
|
+
if err := os.MkdirAll(configDir, 0700); err != nil {
|
|
21
|
+
log.Fatal(err)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Create logs directory
|
|
25
|
+
logsDir := filepath.Join(configDir, "logs")
|
|
26
|
+
if err := os.MkdirAll(logsDir, 0700); err != nil {
|
|
27
|
+
log.Fatal(err)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Set up logging to file
|
|
31
|
+
logFile := filepath.Join(logsDir, "heyvm-core.log")
|
|
32
|
+
f, err := os.OpenFile(logFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
|
|
33
|
+
if err != nil {
|
|
34
|
+
log.Fatal(err)
|
|
35
|
+
}
|
|
36
|
+
defer f.Close()
|
|
37
|
+
log.SetOutput(f)
|
|
38
|
+
|
|
39
|
+
log.Println("=====================================")
|
|
40
|
+
log.Println("heyvm-core starting...")
|
|
41
|
+
log.Printf("Config directory: %s", configDir)
|
|
42
|
+
log.Printf("Log file: %s", logFile)
|
|
43
|
+
|
|
44
|
+
// Initialize VM registry
|
|
45
|
+
registry, err := vm.NewRegistry(configDir)
|
|
46
|
+
if err != nil {
|
|
47
|
+
log.Fatalf("Failed to create VM registry: %v", err)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Load existing VMs from config
|
|
51
|
+
if err := registry.Load(); err != nil {
|
|
52
|
+
log.Printf("Warning: Could not load VM registry: %v", err)
|
|
53
|
+
log.Println("Starting with empty registry (this is normal for first run)")
|
|
54
|
+
} else {
|
|
55
|
+
vmCount := registry.Count()
|
|
56
|
+
log.Printf("Loaded %d VM(s) from config", vmCount)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Create IPC handler
|
|
60
|
+
handler := ipc.NewHandler(registry)
|
|
61
|
+
|
|
62
|
+
log.Println("IPC handler initialized")
|
|
63
|
+
log.Println("heyvm-core ready to accept requests")
|
|
64
|
+
log.Println("=====================================")
|
|
65
|
+
|
|
66
|
+
// Start IPC request/response loop
|
|
67
|
+
// This blocks until stdin is closed or an error occurs
|
|
68
|
+
if err := handler.Start(); err != nil {
|
|
69
|
+
log.Fatalf("IPC handler error: %v", err)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
log.Println("heyvm-core shutting down gracefully")
|
|
73
|
+
}
|
package/core/go.mod
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
module github.com/adishm/heyvm
|
|
2
|
+
|
|
3
|
+
go 1.24.0
|
|
4
|
+
|
|
5
|
+
toolchain go1.24.12
|
|
6
|
+
|
|
7
|
+
require (
|
|
8
|
+
github.com/99designs/keyring v1.2.2
|
|
9
|
+
github.com/pkg/sftp v1.13.10
|
|
10
|
+
golang.org/x/crypto v0.47.0
|
|
11
|
+
gopkg.in/yaml.v3 v3.0.1
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
require (
|
|
15
|
+
github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 // indirect
|
|
16
|
+
github.com/danieljoos/wincred v1.1.2 // indirect
|
|
17
|
+
github.com/dvsekhvalnov/jose2go v1.5.0 // indirect
|
|
18
|
+
github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 // indirect
|
|
19
|
+
github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c // indirect
|
|
20
|
+
github.com/kr/fs v0.1.0 // indirect
|
|
21
|
+
github.com/mtibben/percent v0.2.1 // indirect
|
|
22
|
+
github.com/stretchr/objx v0.5.2 // indirect
|
|
23
|
+
golang.org/x/sys v0.40.0 // indirect
|
|
24
|
+
golang.org/x/term v0.39.0 // indirect
|
|
25
|
+
)
|
package/core/go.sum
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 h1:/vQbFIOMbk2FiG/kXiLl8BRyzTWDw7gX/Hz7Dd5eDMs=
|
|
2
|
+
github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4/go.mod h1:hN7oaIRCjzsZ2dE+yG5k+rsdt3qcwykqK6HVGcKwsw4=
|
|
3
|
+
github.com/99designs/keyring v1.2.2 h1:pZd3neh/EmUzWONb35LxQfvuY7kiSXAq3HQd97+XBn0=
|
|
4
|
+
github.com/99designs/keyring v1.2.2/go.mod h1:wes/FrByc8j7lFOAGLGSNEg8f/PaI3cgTBqhFkHUrPk=
|
|
5
|
+
github.com/danieljoos/wincred v1.1.2 h1:QLdCxFs1/Yl4zduvBdcHB8goaYk9RARS2SgLLRuAyr0=
|
|
6
|
+
github.com/danieljoos/wincred v1.1.2/go.mod h1:GijpziifJoIBfYh+S7BbkdUTU4LfM+QnGqR5Vl2tAx0=
|
|
7
|
+
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
|
8
|
+
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
|
9
|
+
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
|
10
|
+
github.com/dvsekhvalnov/jose2go v1.5.0 h1:3j8ya4Z4kMCwT5nXIKFSV84YS+HdqSSO0VsTQxaLAeM=
|
|
11
|
+
github.com/dvsekhvalnov/jose2go v1.5.0/go.mod h1:QsHjhyTlD/lAVqn/NSbVZmSCGeDehTB/mPZadG+mhXU=
|
|
12
|
+
github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 h1:ZpnhV/YsD2/4cESfV5+Hoeu/iUR3ruzNvZ+yQfO03a0=
|
|
13
|
+
github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2/go.mod h1:bBOAhwG1umN6/6ZUMtDFBMQR8jRg9O75tm9K00oMsK4=
|
|
14
|
+
github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c h1:6rhixN/i8ZofjG1Y75iExal34USq5p+wiN1tpie8IrU=
|
|
15
|
+
github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c/go.mod h1:NMPJylDgVpX0MLRlPy15sqSwOFv/U1GZ2m21JhFfek0=
|
|
16
|
+
github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
|
|
17
|
+
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
|
|
18
|
+
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
|
19
|
+
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
|
20
|
+
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
|
21
|
+
github.com/mtibben/percent v0.2.1 h1:5gssi8Nqo8QU/r2pynCm+hBQHpkB/uNK7BJCFogWdzs=
|
|
22
|
+
github.com/mtibben/percent v0.2.1/go.mod h1:KG9uO+SZkUp+VkRHsCdYQV3XSZrrSpR3O9ibNBTZrns=
|
|
23
|
+
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
|
|
24
|
+
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
|
25
|
+
github.com/pkg/sftp v1.13.10 h1:+5FbKNTe5Z9aspU88DPIKJ9z2KZoaGCu6Sr6kKR/5mU=
|
|
26
|
+
github.com/pkg/sftp v1.13.10/go.mod h1:bJ1a7uDhrX/4OII+agvy28lzRvQrmIQuaHrcI1HbeGA=
|
|
27
|
+
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
|
28
|
+
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
|
29
|
+
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
|
30
|
+
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
|
31
|
+
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
|
32
|
+
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
|
33
|
+
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
|
34
|
+
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
|
35
|
+
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
|
36
|
+
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
|
37
|
+
golang.org/x/sys v0.0.0-20210819135213-f52c844e1c1c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
38
|
+
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
|
39
|
+
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
|
40
|
+
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
|
|
41
|
+
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
|
|
42
|
+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
|
43
|
+
gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b h1:QRR6H1YWRnHb4Y/HeNFCTJLFVxaq6wH4YuVdsUOr75U=
|
|
44
|
+
gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
|
45
|
+
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
|
46
|
+
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
|
47
|
+
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
package auth
|
|
2
|
+
|
|
3
|
+
import "errors"
|
|
4
|
+
|
|
5
|
+
var (
|
|
6
|
+
// ErrUnsupportedAuthType is returned when the auth type is not supported
|
|
7
|
+
ErrUnsupportedAuthType = errors.New("unsupported authentication type")
|
|
8
|
+
|
|
9
|
+
// ErrKeyFileNotFound is returned when SSH key file doesn't exist
|
|
10
|
+
ErrKeyFileNotFound = errors.New("SSH key file not found")
|
|
11
|
+
|
|
12
|
+
// ErrInvalidKeyFile is returned when SSH key file is invalid
|
|
13
|
+
ErrInvalidKeyFile = errors.New("invalid SSH key file")
|
|
14
|
+
|
|
15
|
+
// ErrKeyPermissions is returned when SSH key has incorrect permissions
|
|
16
|
+
ErrKeyPermissions = errors.New("SSH key file has incorrect permissions (should be 0600)")
|
|
17
|
+
|
|
18
|
+
// ErrConnectionFailed is returned when SSH connection fails
|
|
19
|
+
ErrConnectionFailed = errors.New("SSH connection failed")
|
|
20
|
+
|
|
21
|
+
// ErrAuthenticationFailed is returned when authentication fails
|
|
22
|
+
ErrAuthenticationFailed = errors.New("authentication failed")
|
|
23
|
+
|
|
24
|
+
// ErrPasswordNotFound is returned when password is not found in keychain
|
|
25
|
+
ErrPasswordNotFound = errors.New("password not found in keychain")
|
|
26
|
+
|
|
27
|
+
// ErrKeychainAccessDenied is returned when keychain access is denied
|
|
28
|
+
ErrKeychainAccessDenied = errors.New("keychain access denied")
|
|
29
|
+
)
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
package auth
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"fmt"
|
|
5
|
+
"time"
|
|
6
|
+
|
|
7
|
+
"github.com/99designs/keyring"
|
|
8
|
+
"github.com/adishm/heyvm/internal/vm"
|
|
9
|
+
"golang.org/x/crypto/ssh"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
const (
|
|
13
|
+
keyringService = "heyvm"
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
// PasswordAuth implements Provider using password authentication
|
|
17
|
+
// Passwords are stored securely in the OS keychain
|
|
18
|
+
type PasswordAuth struct {
|
|
19
|
+
ring keyring.Keyring
|
|
20
|
+
client *ssh.Client
|
|
21
|
+
tempPassword string // Temporary password for testing (not stored)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// NewPasswordAuth creates a new password authentication provider
|
|
25
|
+
func NewPasswordAuth() (*PasswordAuth, error) {
|
|
26
|
+
// Initialize OS keyring
|
|
27
|
+
ring, err := keyring.Open(keyring.Config{
|
|
28
|
+
ServiceName: keyringService,
|
|
29
|
+
KeychainTrustApplication: true,
|
|
30
|
+
// Use file backend as fallback if OS keychain is unavailable
|
|
31
|
+
AllowedBackends: []keyring.BackendType{
|
|
32
|
+
keyring.KeychainBackend, // macOS
|
|
33
|
+
keyring.SecretServiceBackend, // Linux
|
|
34
|
+
keyring.WinCredBackend, // Windows
|
|
35
|
+
keyring.FileBackend, // Fallback
|
|
36
|
+
},
|
|
37
|
+
})
|
|
38
|
+
if err != nil {
|
|
39
|
+
return nil, fmt.Errorf("failed to open keyring: %w", err)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return &PasswordAuth{
|
|
43
|
+
ring: ring,
|
|
44
|
+
}, nil
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// NewPasswordAuthWithTemp creates a provider with a temporary password (for testing)
|
|
48
|
+
func NewPasswordAuthWithTemp(tempPassword string) (*PasswordAuth, error) {
|
|
49
|
+
// Initialize OS keyring (may not be used if temp password is provided)
|
|
50
|
+
ring, err := keyring.Open(keyring.Config{
|
|
51
|
+
ServiceName: keyringService,
|
|
52
|
+
KeychainTrustApplication: true,
|
|
53
|
+
AllowedBackends: []keyring.BackendType{
|
|
54
|
+
keyring.KeychainBackend,
|
|
55
|
+
keyring.SecretServiceBackend,
|
|
56
|
+
keyring.WinCredBackend,
|
|
57
|
+
keyring.FileBackend,
|
|
58
|
+
},
|
|
59
|
+
})
|
|
60
|
+
if err != nil {
|
|
61
|
+
return nil, fmt.Errorf("failed to open keyring: %w", err)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return &PasswordAuth{
|
|
65
|
+
ring: ring,
|
|
66
|
+
tempPassword: tempPassword,
|
|
67
|
+
}, nil
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Connect establishes an SSH connection using password authentication
|
|
71
|
+
func (a *PasswordAuth) Connect(v *vm.VM) (*ssh.Client, error) {
|
|
72
|
+
var password string
|
|
73
|
+
var err error
|
|
74
|
+
|
|
75
|
+
// Use temporary password if provided, otherwise get from keyring
|
|
76
|
+
if a.tempPassword != "" {
|
|
77
|
+
password = a.tempPassword
|
|
78
|
+
} else {
|
|
79
|
+
password, err = a.GetPassword(v.ID)
|
|
80
|
+
if err != nil {
|
|
81
|
+
return nil, fmt.Errorf("failed to get password: %w", err)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Configure SSH client
|
|
86
|
+
config := &ssh.ClientConfig{
|
|
87
|
+
User: v.Username,
|
|
88
|
+
Auth: []ssh.AuthMethod{
|
|
89
|
+
ssh.Password(password),
|
|
90
|
+
},
|
|
91
|
+
HostKeyCallback: ssh.InsecureIgnoreHostKey(), // TODO: Implement proper host key verification
|
|
92
|
+
Timeout: 30 * time.Second, // Increased timeout for large transfers
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Set keep-alive settings to prevent connection drops during large transfers
|
|
96
|
+
config.SetDefaults()
|
|
97
|
+
|
|
98
|
+
// Connect to SSH server
|
|
99
|
+
address := fmt.Sprintf("%s:%d", v.Host, v.Port)
|
|
100
|
+
client, err := ssh.Dial("tcp", address, config)
|
|
101
|
+
if err != nil {
|
|
102
|
+
return nil, fmt.Errorf("%w: %v", ErrConnectionFailed, err)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
a.client = client
|
|
106
|
+
return client, nil
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Disconnect closes the SSH connection
|
|
110
|
+
func (a *PasswordAuth) Disconnect() error {
|
|
111
|
+
if a.client != nil {
|
|
112
|
+
return a.client.Close()
|
|
113
|
+
}
|
|
114
|
+
return nil
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// SupportsSFTP returns true (password auth supports SFTP)
|
|
118
|
+
func (a *PasswordAuth) SupportsSFTP() bool {
|
|
119
|
+
return true
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Name returns the provider name
|
|
123
|
+
func (a *PasswordAuth) Name() string {
|
|
124
|
+
return "Password Authentication"
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// StorePassword stores a password in the OS keychain
|
|
128
|
+
func (a *PasswordAuth) StorePassword(vmID, password string) error {
|
|
129
|
+
if vmID == "" {
|
|
130
|
+
return fmt.Errorf("VM ID cannot be empty")
|
|
131
|
+
}
|
|
132
|
+
if password == "" {
|
|
133
|
+
return fmt.Errorf("password cannot be empty")
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
item := keyring.Item{
|
|
137
|
+
Key: vmID,
|
|
138
|
+
Data: []byte(password),
|
|
139
|
+
Label: fmt.Sprintf("heyvm password for VM %s", vmID),
|
|
140
|
+
Description: "SSH password for heyvm VM",
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if err := a.ring.Set(item); err != nil {
|
|
144
|
+
return fmt.Errorf("failed to store password in keychain: %w", err)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return nil
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// GetPassword retrieves a password from the OS keychain
|
|
151
|
+
func (a *PasswordAuth) GetPassword(vmID string) (string, error) {
|
|
152
|
+
if vmID == "" {
|
|
153
|
+
return "", fmt.Errorf("VM ID cannot be empty")
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
item, err := a.ring.Get(vmID)
|
|
157
|
+
if err != nil {
|
|
158
|
+
if err == keyring.ErrKeyNotFound {
|
|
159
|
+
return "", ErrPasswordNotFound
|
|
160
|
+
}
|
|
161
|
+
return "", fmt.Errorf("failed to retrieve password from keychain: %w", err)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return string(item.Data), nil
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// RemovePassword removes a password from the OS keychain
|
|
168
|
+
func (a *PasswordAuth) RemovePassword(vmID string) error {
|
|
169
|
+
if vmID == "" {
|
|
170
|
+
return fmt.Errorf("VM ID cannot be empty")
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if err := a.ring.Remove(vmID); err != nil {
|
|
174
|
+
if err == keyring.ErrKeyNotFound {
|
|
175
|
+
return ErrPasswordNotFound
|
|
176
|
+
}
|
|
177
|
+
return fmt.Errorf("failed to remove password from keychain: %w", err)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return nil
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// HasPassword checks if a password exists in the keychain for the given VM ID
|
|
184
|
+
func (a *PasswordAuth) HasPassword(vmID string) bool {
|
|
185
|
+
_, err := a.GetPassword(vmID)
|
|
186
|
+
return err == nil
|
|
187
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
package auth
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"github.com/adishm/heyvm/internal/vm"
|
|
5
|
+
"golang.org/x/crypto/ssh"
|
|
6
|
+
)
|
|
7
|
+
|
|
8
|
+
// Provider defines the interface for authentication providers
|
|
9
|
+
type Provider interface {
|
|
10
|
+
// Connect establishes an SSH connection to the VM
|
|
11
|
+
Connect(v *vm.VM) (*ssh.Client, error)
|
|
12
|
+
|
|
13
|
+
// Disconnect closes the connection
|
|
14
|
+
Disconnect() error
|
|
15
|
+
|
|
16
|
+
// SupportsSFTP returns true if this provider supports SFTP
|
|
17
|
+
SupportsSFTP() bool
|
|
18
|
+
|
|
19
|
+
// Name returns the name of the provider
|
|
20
|
+
Name() string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// NewProvider creates an appropriate authentication provider based on VM config
|
|
24
|
+
func NewProvider(v *vm.VM) (Provider, error) {
|
|
25
|
+
switch v.Auth.Type {
|
|
26
|
+
case vm.AuthTypeKey:
|
|
27
|
+
return NewSSHKeyAuth(v.Auth.KeyPath)
|
|
28
|
+
case vm.AuthTypePassword:
|
|
29
|
+
return NewPasswordAuth()
|
|
30
|
+
default:
|
|
31
|
+
return nil, ErrUnsupportedAuthType
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
package auth
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"fmt"
|
|
5
|
+
"os"
|
|
6
|
+
"path/filepath"
|
|
7
|
+
"time"
|
|
8
|
+
|
|
9
|
+
"github.com/adishm/heyvm/internal/vm"
|
|
10
|
+
"golang.org/x/crypto/ssh"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
// SSHKeyAuth implements Provider using SSH key authentication
|
|
14
|
+
type SSHKeyAuth struct {
|
|
15
|
+
keyPath string
|
|
16
|
+
client *ssh.Client
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// NewSSHKeyAuth creates a new SSH key authentication provider
|
|
20
|
+
func NewSSHKeyAuth(keyPath string) (*SSHKeyAuth, error) {
|
|
21
|
+
if keyPath == "" {
|
|
22
|
+
return nil, fmt.Errorf("key path cannot be empty")
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Expand home directory
|
|
26
|
+
expandedPath, err := expandPath(keyPath)
|
|
27
|
+
if err != nil {
|
|
28
|
+
return nil, fmt.Errorf("failed to expand path: %w", err)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Check if key file exists
|
|
32
|
+
info, err := os.Stat(expandedPath)
|
|
33
|
+
if os.IsNotExist(err) {
|
|
34
|
+
return nil, fmt.Errorf("%w: %s (expanded from: %s)", ErrKeyFileNotFound, expandedPath, keyPath)
|
|
35
|
+
}
|
|
36
|
+
if err != nil {
|
|
37
|
+
return nil, fmt.Errorf("failed to access key file: %w", err)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Check key file permissions (should be 0600 or 0400)
|
|
41
|
+
mode := info.Mode().Perm()
|
|
42
|
+
if mode&0077 != 0 {
|
|
43
|
+
// Key is readable/writable by group or others
|
|
44
|
+
return nil, fmt.Errorf("%w: has permissions %o (run: chmod 400 %s)", ErrKeyPermissions, mode, expandedPath)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return &SSHKeyAuth{
|
|
48
|
+
keyPath: expandedPath,
|
|
49
|
+
}, nil
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// expandPath expands ~ to home directory and handles path resolution
|
|
53
|
+
func expandPath(path string) (string, error) {
|
|
54
|
+
if path == "" {
|
|
55
|
+
return path, nil
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Handle ~ at the beginning
|
|
59
|
+
if path[0] == '~' {
|
|
60
|
+
home, err := os.UserHomeDir()
|
|
61
|
+
if err != nil {
|
|
62
|
+
return "", fmt.Errorf("failed to get home directory: %w", err)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Handle ~/path
|
|
66
|
+
if len(path) == 1 {
|
|
67
|
+
return home, nil
|
|
68
|
+
}
|
|
69
|
+
if path[1] == '/' || path[1] == filepath.Separator {
|
|
70
|
+
return filepath.Join(home, path[2:]), nil
|
|
71
|
+
}
|
|
72
|
+
// Handle ~path (shouldn't happen but be safe)
|
|
73
|
+
return filepath.Join(home, path[1:]), nil
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Return absolute path
|
|
77
|
+
return filepath.Abs(path)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Connect establishes an SSH connection using key authentication
|
|
81
|
+
func (a *SSHKeyAuth) Connect(v *vm.VM) (*ssh.Client, error) {
|
|
82
|
+
// Read private key
|
|
83
|
+
key, err := os.ReadFile(a.keyPath)
|
|
84
|
+
if err != nil {
|
|
85
|
+
return nil, fmt.Errorf("failed to read private key from %s: %w", a.keyPath, err)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Parse private key (supports PEM, OpenSSH, RSA, Ed25519, ECDSA)
|
|
89
|
+
signer, err := ssh.ParsePrivateKey(key)
|
|
90
|
+
if err != nil {
|
|
91
|
+
// Check if it's an encrypted key
|
|
92
|
+
if _, ok := err.(*ssh.PassphraseMissingError); ok {
|
|
93
|
+
return nil, fmt.Errorf("SSH key is encrypted (passphrase-protected keys not yet supported in Phase 1)")
|
|
94
|
+
}
|
|
95
|
+
return nil, fmt.Errorf("%w: %v (supported formats: PEM, OpenSSH, RSA, Ed25519, ECDSA)", ErrInvalidKeyFile, err)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Configure SSH client
|
|
99
|
+
config := &ssh.ClientConfig{
|
|
100
|
+
User: v.Username,
|
|
101
|
+
Auth: []ssh.AuthMethod{
|
|
102
|
+
ssh.PublicKeys(signer),
|
|
103
|
+
},
|
|
104
|
+
HostKeyCallback: ssh.InsecureIgnoreHostKey(), // TODO: Implement proper host key verification
|
|
105
|
+
Timeout: 30 * time.Second, // Increased timeout for large transfers
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Set keep-alive settings to prevent connection drops during large transfers
|
|
109
|
+
// This will send keep-alive packets every 15 seconds
|
|
110
|
+
config.SetDefaults()
|
|
111
|
+
// Note: Keep-alive is configured at the session level after connection
|
|
112
|
+
|
|
113
|
+
// Connect to SSH server
|
|
114
|
+
address := fmt.Sprintf("%s:%d", v.Host, v.Port)
|
|
115
|
+
client, err := ssh.Dial("tcp", address, config)
|
|
116
|
+
if err != nil {
|
|
117
|
+
return nil, fmt.Errorf("%w: %v (check host/port/username)", ErrConnectionFailed, err)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
a.client = client
|
|
121
|
+
return client, nil
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Disconnect closes the SSH connection
|
|
125
|
+
func (a *SSHKeyAuth) Disconnect() error {
|
|
126
|
+
if a.client != nil {
|
|
127
|
+
err := a.client.Close()
|
|
128
|
+
a.client = nil // Clear the reference
|
|
129
|
+
return err
|
|
130
|
+
}
|
|
131
|
+
return nil
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// SupportsSFTP returns true (SSH key auth supports SFTP)
|
|
135
|
+
func (a *SSHKeyAuth) SupportsSFTP() bool {
|
|
136
|
+
return true
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Name returns the provider name
|
|
140
|
+
func (a *SSHKeyAuth) Name() string {
|
|
141
|
+
return "SSH Key Authentication"
|
|
142
|
+
}
|