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.
Files changed (112) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +621 -0
  3. package/bin/file-browser +0 -0
  4. package/bin/heyvm +0 -0
  5. package/bin/heyvm-core +0 -0
  6. package/bin/heyvm.js +61 -0
  7. package/core/README.md +51 -0
  8. package/core/cmd/heyvm-core/main.go +73 -0
  9. package/core/go.mod +25 -0
  10. package/core/go.sum +47 -0
  11. package/core/internal/auth/errors.go +29 -0
  12. package/core/internal/auth/password.go +187 -0
  13. package/core/internal/auth/provider.go +33 -0
  14. package/core/internal/auth/ssh_key.go +142 -0
  15. package/core/internal/config/config.go +111 -0
  16. package/core/internal/ipc/actions.go +738 -0
  17. package/core/internal/ipc/handler.go +281 -0
  18. package/core/internal/ipc/protocol.go +126 -0
  19. package/core/internal/sftp/errors.go +29 -0
  20. package/core/internal/sftp/manager.go +303 -0
  21. package/core/internal/sftp/types.go +30 -0
  22. package/core/internal/ssh/errors.go +23 -0
  23. package/core/internal/ssh/manager.go +226 -0
  24. package/core/internal/ssh/session.go +105 -0
  25. package/core/internal/vm/errors.go +35 -0
  26. package/core/internal/vm/models.go +84 -0
  27. package/core/internal/vm/registry.go +240 -0
  28. package/package.json +59 -0
  29. package/scripts/install.js +100 -0
  30. package/ui/README.md +43 -0
  31. package/ui/dist/App.d.ts +3 -0
  32. package/ui/dist/App.d.ts.map +1 -0
  33. package/ui/dist/App.js +142 -0
  34. package/ui/dist/App.js.map +1 -0
  35. package/ui/dist/components/ConfirmDialog.d.ts +9 -0
  36. package/ui/dist/components/ConfirmDialog.d.ts.map +1 -0
  37. package/ui/dist/components/ConfirmDialog.js +22 -0
  38. package/ui/dist/components/ConfirmDialog.js.map +1 -0
  39. package/ui/dist/components/ErrorMessage.d.ts +8 -0
  40. package/ui/dist/components/ErrorMessage.d.ts.map +1 -0
  41. package/ui/dist/components/ErrorMessage.js +10 -0
  42. package/ui/dist/components/ErrorMessage.js.map +1 -0
  43. package/ui/dist/components/Header.d.ts +8 -0
  44. package/ui/dist/components/Header.d.ts.map +1 -0
  45. package/ui/dist/components/Header.js +10 -0
  46. package/ui/dist/components/Header.js.map +1 -0
  47. package/ui/dist/components/LoadingSpinner.d.ts +7 -0
  48. package/ui/dist/components/LoadingSpinner.d.ts.map +1 -0
  49. package/ui/dist/components/LoadingSpinner.js +7 -0
  50. package/ui/dist/components/LoadingSpinner.js.map +1 -0
  51. package/ui/dist/components/StatusBar.d.ts +11 -0
  52. package/ui/dist/components/StatusBar.d.ts.map +1 -0
  53. package/ui/dist/components/StatusBar.js +11 -0
  54. package/ui/dist/components/StatusBar.js.map +1 -0
  55. package/ui/dist/core/ipc.d.ts +96 -0
  56. package/ui/dist/core/ipc.d.ts.map +1 -0
  57. package/ui/dist/core/ipc.js +310 -0
  58. package/ui/dist/core/ipc.js.map +1 -0
  59. package/ui/dist/core/types.d.ts +45 -0
  60. package/ui/dist/core/types.d.ts.map +1 -0
  61. package/ui/dist/core/types.js +3 -0
  62. package/ui/dist/core/types.js.map +1 -0
  63. package/ui/dist/hooks/useFiles.d.ts +14 -0
  64. package/ui/dist/hooks/useFiles.d.ts.map +1 -0
  65. package/ui/dist/hooks/useFiles.js +102 -0
  66. package/ui/dist/hooks/useFiles.js.map +1 -0
  67. package/ui/dist/hooks/useVM.d.ts +10 -0
  68. package/ui/dist/hooks/useVM.d.ts.map +1 -0
  69. package/ui/dist/hooks/useVM.js +54 -0
  70. package/ui/dist/hooks/useVM.js.map +1 -0
  71. package/ui/dist/hooks/useVMList.d.ts +10 -0
  72. package/ui/dist/hooks/useVMList.d.ts.map +1 -0
  73. package/ui/dist/hooks/useVMList.js +56 -0
  74. package/ui/dist/hooks/useVMList.js.map +1 -0
  75. package/ui/dist/index.d.ts +3 -0
  76. package/ui/dist/index.d.ts.map +1 -0
  77. package/ui/dist/index.js +7488 -0
  78. package/ui/dist/index.js.map +1 -0
  79. package/ui/dist/keybindings.d.ts +146 -0
  80. package/ui/dist/keybindings.d.ts.map +1 -0
  81. package/ui/dist/keybindings.js +96 -0
  82. package/ui/dist/keybindings.js.map +1 -0
  83. package/ui/dist/screens/AddVMScreen.d.ts +9 -0
  84. package/ui/dist/screens/AddVMScreen.d.ts.map +1 -0
  85. package/ui/dist/screens/AddVMScreen.js +163 -0
  86. package/ui/dist/screens/AddVMScreen.js.map +1 -0
  87. package/ui/dist/screens/VMDetailScreen.d.ts +12 -0
  88. package/ui/dist/screens/VMDetailScreen.d.ts.map +1 -0
  89. package/ui/dist/screens/VMDetailScreen.js +96 -0
  90. package/ui/dist/screens/VMDetailScreen.js.map +1 -0
  91. package/ui/dist/screens/VMListScreen.d.ts +12 -0
  92. package/ui/dist/screens/VMListScreen.d.ts.map +1 -0
  93. package/ui/dist/screens/VMListScreen.js +158 -0
  94. package/ui/dist/screens/VMListScreen.js.map +1 -0
  95. package/ui/dist/screens/tabs/FilesTab.d.ts +9 -0
  96. package/ui/dist/screens/tabs/FilesTab.d.ts.map +1 -0
  97. package/ui/dist/screens/tabs/FilesTab.js +374 -0
  98. package/ui/dist/screens/tabs/FilesTab.js.map +1 -0
  99. package/ui/dist/screens/tabs/OverviewTab.d.ts +10 -0
  100. package/ui/dist/screens/tabs/OverviewTab.d.ts.map +1 -0
  101. package/ui/dist/screens/tabs/OverviewTab.js +110 -0
  102. package/ui/dist/screens/tabs/OverviewTab.js.map +1 -0
  103. package/ui/dist/screens/tabs/TerminalTab.d.ts +9 -0
  104. package/ui/dist/screens/tabs/TerminalTab.d.ts.map +1 -0
  105. package/ui/dist/screens/tabs/TerminalTab.js +270 -0
  106. package/ui/dist/screens/tabs/TerminalTab.js.map +1 -0
  107. package/ui/dist/utils/terminalEmulator.d.ts +49 -0
  108. package/ui/dist/utils/terminalEmulator.d.ts.map +1 -0
  109. package/ui/dist/utils/terminalEmulator.js +88 -0
  110. package/ui/dist/utils/terminalEmulator.js.map +1 -0
  111. package/ui/package.json +34 -0
  112. 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
+ }