Skip to content

Commit 638bc9b

Browse files
committed
feat: Cleanup stale keys on VM destroy
1 parent 2c16a8b commit 638bc9b

File tree

3 files changed

+223
-32
lines changed

3 files changed

+223
-32
lines changed

cmd/destroy.go

Lines changed: 11 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -113,23 +113,19 @@ Examples:
113113

114114
fmt.Println()
115115

116-
// Check if we should destroy the VM
117116
// Only destroy if: (1) VM was provisioned by this target AND (2) no other apps on the server
118117
shouldDestroyVM := false
119118
var otherApps []state.DeployedApp
120119

121120
if provisionedID != "" && target.Provider != "" && target.Provider != "byos" {
122-
// Check if this target provisioned the VM
123121
targetProvisionedVM := false
124122
if providerCfg != nil {
125123
targetProvisionedVM = providerCfg.IsProvisioned()
126124
}
127125

128-
// Check if other apps exist on this server
129126
if target.ServerIP != "" {
130127
serverState, err := state.GetServerState(target.ServerIP)
131128
if err == nil {
132-
// Get apps other than the current one
133129
for _, app := range serverState.DeployedApps {
134130
if app.TargetName != destroyTargetFlag {
135131
otherApps = append(otherApps, app)
@@ -138,12 +134,9 @@ Examples:
138134
}
139135
}
140136

141-
// Decide whether to destroy VM
142137
if !targetProvisionedVM {
143-
// This target didn't provision the VM (joined existing server)
144138
shouldDestroyVM = false
145139
} else if len(otherApps) > 0 {
146-
// Other apps exist on this server
147140
shouldDestroyVM = false
148141
fmt.Printf("%s %s\n", destroyWarningStyle.Render("⚠"), destroyWarningStyle.Render("VM will NOT be destroyed - other apps are deployed to this server:"))
149142
for _, app := range otherApps {
@@ -153,7 +146,6 @@ Examples:
153146
fmt.Printf("%s %s\n", destroyMutedStyle.Render("ℹ"), destroyMutedStyle.Render("Only removing this app's configuration and local state"))
154147
fmt.Println()
155148
} else {
156-
// This target provisioned the VM and no other apps exist
157149
shouldDestroyVM = true
158150
}
159151
}
@@ -224,14 +216,12 @@ Examples:
224216
}
225217
}
226218

227-
// Unregister app from server state if applicable
228219
if target.ServerIP != "" {
229220
if err := state.UnregisterApp(target.ServerIP, destroyTargetFlag); err != nil {
230221
fmt.Printf("%s %s\n", destroyWarningStyle.Render("⚠"), destroyMutedStyle.Render(fmt.Sprintf("Failed to unregister app from server: %v", err)))
231222
} else {
232223
fmt.Printf("%s %s\n", destroySuccessStyle.Render("✓"), destroyMutedStyle.Render("Unregistered app from server state"))
233224

234-
// Check if other apps remain on this server
235225
serverState, err := state.GetServerState(target.ServerIP)
236226
if err == nil {
237227
if len(serverState.DeployedApps) > 0 {
@@ -241,17 +231,14 @@ Examples:
241231

242232
// Clean up unused runtimes if keeping the VM (only makes sense for multi-app servers)
243233
if !shouldDestroyVM && len(otherApps) > 0 {
244-
// Get provider config to connect via SSH
245234
providerCfg, err := target.GetAnyProviderConfig()
246235
if err == nil && providerCfg.GetIP() != "" {
247236
fmt.Printf("%s %s\n", destroyMutedStyle.Render("→"), destroyMutedStyle.Render("Analyzing runtime dependencies..."))
248237

249-
// Connect to server via SSH
250238
sshExecutor := sshpkg.NewExecutor(providerCfg.GetIP(), "22", providerCfg.GetUsername(), providerCfg.GetSSHKey())
251239
if err := sshExecutor.Connect(3, 10*time.Second); err == nil {
252240
defer sshExecutor.Disconnect()
253241

254-
// Cleanup unused runtimes
255242
if err := runtime.CleanupUnusedRuntimes(sshExecutor, target.ServerIP, destroyTargetFlag); err != nil {
256243
fmt.Printf("%s %s\n", destroyWarningStyle.Render("⚠"), destroyMutedStyle.Render(fmt.Sprintf("Runtime cleanup warning: %v", err)))
257244
fmt.Printf("%s %s\n", destroyMutedStyle.Render(" "), destroyMutedStyle.Render("You may manually run: apt-get autoremove -y"))
@@ -278,11 +265,21 @@ Examples:
278265
}
279266
fmt.Printf("%s %s\n", destroySuccessStyle.Render("✓"), destroyMutedStyle.Render("Removed target from config"))
280267

268+
fmt.Printf("%s %s\n", destroyMutedStyle.Render("→"), destroyMutedStyle.Render("Checking for unused SSH keys..."))
269+
keysDeleted, err := sshpkg.CleanupUnusedKeys(cfg.Targets)
270+
if err != nil {
271+
fmt.Printf("%s %s\n", destroyWarningStyle.Render("⚠"), destroyMutedStyle.Render(fmt.Sprintf("SSH key cleanup warning: %v", err)))
272+
} else if keysDeleted > 0 {
273+
fmt.Printf("%s %s\n", destroySuccessStyle.Render("✓"), destroyMutedStyle.Render(fmt.Sprintf("Cleaned up %d unused SSH key(s)", keysDeleted)))
274+
} else {
275+
fmt.Printf("%s %s\n", destroyMutedStyle.Render("ℹ"), destroyMutedStyle.Render("No unused SSH keys found"))
276+
}
277+
281278
fmt.Println()
282279

283280
successBox := lipgloss.NewStyle().
284281
Border(lipgloss.RoundedBorder()).
285-
BorderForeground(lipgloss.Color("196")). // Red border for destruction
282+
BorderForeground(lipgloss.Color("196")).
286283
Padding(0, 1).
287284
Render(
288285
lipgloss.JoinVertical(

pkg/ssh/keygen.go

Lines changed: 80 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -24,19 +24,16 @@ type KeyPair struct {
2424

2525
// GenerateKeyPair generates a new Ed25519 SSH key pair
2626
func GenerateKeyPair(keyName string) (*KeyPair, error) {
27-
// Generate Ed25519 key pair
2827
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
2928
if err != nil {
3029
return nil, fmt.Errorf("failed to generate key pair: %w", err)
3130
}
3231

33-
// Create SSH public key
3432
sshPublicKey, err := ssh.NewPublicKey(publicKey)
3533
if err != nil {
3634
return nil, fmt.Errorf("failed to create SSH public key: %w", err)
3735
}
3836

39-
// Convert private key to PEM format
4037
privateKeyBytes, err := x509.MarshalPKCS8PrivateKey(privateKey)
4138
if err != nil {
4239
return nil, fmt.Errorf("failed to marshal private key: %w", err)
@@ -47,29 +44,23 @@ func GenerateKeyPair(keyName string) (*KeyPair, error) {
4744
Bytes: privateKeyBytes,
4845
})
4946

50-
// Format SSH public key
5147
sshPublicKeyBytes := ssh.MarshalAuthorizedKey(sshPublicKey)
5248
sshPublicKeyString := strings.TrimSpace(string(sshPublicKeyBytes))
5349

54-
// Get fingerprint
5550
fingerprint := ssh.FingerprintSHA256(sshPublicKey)
5651

57-
// Get lightfold keys directory
5852
keysDir, err := GetKeysDirectory()
5953
if err != nil {
6054
return nil, fmt.Errorf("failed to get keys directory: %w", err)
6155
}
6256

63-
// Create key file paths
6457
privateKeyPath := filepath.Join(keysDir, keyName)
6558
publicKeyPath := privateKeyPath + ".pub"
6659

67-
// Write private key
6860
if err := os.WriteFile(privateKeyPath, privateKeyPEM, config.PermPrivateKey); err != nil {
6961
return nil, fmt.Errorf("failed to write private key: %w", err)
7062
}
7163

72-
// Write public key
7364
if err := os.WriteFile(publicKeyPath, sshPublicKeyBytes, config.PermPublicKey); err != nil {
7465
return nil, fmt.Errorf("failed to write public key: %w", err)
7566
}
@@ -91,7 +82,6 @@ func GetKeysDirectory() (string, error) {
9182

9283
keysDir := filepath.Join(homeDir, config.LocalConfigDir, config.LocalKeysDir)
9384

94-
// Create directory if it doesn't exist
9585
if err := os.MkdirAll(keysDir, 0700); err != nil {
9686
return "", fmt.Errorf("failed to create keys directory: %w", err)
9787
}
@@ -111,24 +101,19 @@ func LoadPublicKey(publicKeyPath string) (string, error) {
111101

112102
// ValidateSSHKey validates that an SSH key file exists and is valid
113103
func ValidateSSHKey(keyPath string) error {
114-
// Check if file exists
115104
if _, err := os.Stat(keyPath); os.IsNotExist(err) {
116105
return fmt.Errorf("SSH key file does not exist: %s", keyPath)
117106
}
118107

119-
// Try to read and parse the key
120108
data, err := os.ReadFile(keyPath)
121109
if err != nil {
122110
return fmt.Errorf("failed to read SSH key file: %w", err)
123111
}
124112

125-
// Try to parse as private key first
126113
block, _ := pem.Decode(data)
127114
if block != nil {
128-
// It's a PEM-encoded private key
129115
_, err := x509.ParsePKCS8PrivateKey(block.Bytes)
130116
if err != nil {
131-
// Try parsing as RSA private key
132117
_, err = x509.ParsePKCS1PrivateKey(block.Bytes)
133118
if err != nil {
134119
return fmt.Errorf("invalid private key format: %w", err)
@@ -137,7 +122,6 @@ func ValidateSSHKey(keyPath string) error {
137122
return nil
138123
}
139124

140-
// Try to parse as SSH public key
141125
_, _, _, _, err = ssh.ParseAuthorizedKey(data)
142126
if err != nil {
143127
return fmt.Errorf("invalid SSH key format: %w", err)
@@ -148,7 +132,6 @@ func ValidateSSHKey(keyPath string) error {
148132

149133
// GetKeyName generates a key name for a project
150134
func GetKeyName(projectName string) string {
151-
// Sanitize project name for file system
152135
keyName := strings.ReplaceAll(projectName, " ", "_")
153136
keyName = strings.ReplaceAll(keyName, "/", "_")
154137
keyName = strings.ReplaceAll(keyName, "\\", "_")
@@ -167,7 +150,6 @@ func KeyExists(keyName string) (bool, error) {
167150
privateKeyPath := filepath.Join(keysDir, keyName)
168151
publicKeyPath := privateKeyPath + ".pub"
169152

170-
// Check if both files exist
171153
if _, err := os.Stat(privateKeyPath); os.IsNotExist(err) {
172154
return false, nil
173155
}
@@ -177,3 +159,83 @@ func KeyExists(keyName string) (bool, error) {
177159

178160
return true, nil
179161
}
162+
163+
// DeleteKeyPair deletes an SSH key pair
164+
func DeleteKeyPair(keyPath string) error {
165+
if err := os.Remove(keyPath); err != nil && !os.IsNotExist(err) {
166+
return fmt.Errorf("failed to delete private key: %w", err)
167+
}
168+
169+
publicKeyPath := keyPath + ".pub"
170+
if err := os.Remove(publicKeyPath); err != nil && !os.IsNotExist(err) {
171+
return fmt.Errorf("failed to delete public key: %w", err)
172+
}
173+
174+
return nil
175+
}
176+
177+
// GetSSHKeyFromPath extracts the base key name from a full path
178+
// Example: "/home/user/.lightfold/keys/lightfold_my-project_ed25519" -> "lightfold_my-project_ed25519"
179+
func GetSSHKeyFromPath(keyPath string) string {
180+
return filepath.Base(keyPath)
181+
}
182+
183+
// CleanupUnusedKeys removes SSH keys that are not used by any targets.
184+
// Returns the number of keys deleted.
185+
func CleanupUnusedKeys(allTargets map[string]config.TargetConfig) (int, error) {
186+
keysDir, err := GetKeysDirectory()
187+
if err != nil {
188+
return 0, fmt.Errorf("failed to get keys directory: %w", err)
189+
}
190+
191+
keysInUse := make(map[string]bool)
192+
193+
for _, target := range allTargets {
194+
providerCfg, err := target.GetSSHProviderConfig()
195+
if err != nil || providerCfg == nil {
196+
continue
197+
}
198+
199+
sshKey := providerCfg.GetSSHKey()
200+
if sshKey != "" {
201+
keyName := filepath.Base(sshKey)
202+
keysInUse[keyName] = true
203+
}
204+
}
205+
206+
entries, err := os.ReadDir(keysDir)
207+
if err != nil {
208+
return 0, fmt.Errorf("failed to read keys directory: %w", err)
209+
}
210+
211+
var keysToDelete []string
212+
213+
for _, entry := range entries {
214+
if entry.IsDir() {
215+
continue
216+
}
217+
218+
filename := entry.Name()
219+
220+
// Skip public keys - we'll delete them with their private keys
221+
if strings.HasSuffix(filename, ".pub") {
222+
continue
223+
}
224+
225+
if !strings.HasPrefix(filename, "lightfold_") || !strings.HasSuffix(filename, "_ed25519") {
226+
continue
227+
}
228+
229+
if !keysInUse[filename] {
230+
keysToDelete = append(keysToDelete, filepath.Join(keysDir, filename))
231+
}
232+
}
233+
234+
for _, keyPath := range keysToDelete {
235+
if err := DeleteKeyPair(keyPath); err != nil {
236+
return len(keysToDelete), fmt.Errorf("failed to delete key %s: %w", filepath.Base(keyPath), err)
237+
}
238+
}
239+
240+
return len(keysToDelete), nil
241+
}

0 commit comments

Comments
 (0)