Initial commit: Disaster recovery CLI tool
A Go-based CLI tool for recovering servers from backups to new cloud VMs. Features: - Multi-cloud support: Exoscale, Cloudscale, Hetzner Cloud - Backup sources: Local filesystem, Hetzner Storage Box - 6-stage restore pipeline with /etc whitelist protection - DNS migration with safety checks and auto-rollback - Dry-run by default, requires --yes to execute - Cloud-init for SSH key injection Packages: - cmd/recover-server: CLI commands (recover, migrate-dns, list, cleanup) - internal/providers: Cloud provider implementations - internal/backup: Backup source implementations - internal/restore: 6-stage restore pipeline - internal/dns: Exoscale DNS management - internal/ui: Prompts, progress, dry-run display - internal/config: Environment and host configuration 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
110
internal/restore/ssh.go
Normal file
110
internal/restore/ssh.go
Normal file
@@ -0,0 +1,110 @@
|
||||
package restore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
// SSHKeyPair holds an ephemeral SSH key pair
|
||||
type SSHKeyPair struct {
|
||||
PrivateKeyPath string
|
||||
PublicKey string
|
||||
}
|
||||
|
||||
// GenerateEphemeralKey creates a temporary ED25519 SSH key pair
|
||||
func GenerateEphemeralKey() (*SSHKeyPair, error) {
|
||||
// Generate ED25519 key pair
|
||||
pubKey, privKey, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate key: %w", err)
|
||||
}
|
||||
|
||||
// Convert to SSH format
|
||||
sshPubKey, err := ssh.NewPublicKey(pubKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create SSH public key: %w", err)
|
||||
}
|
||||
|
||||
// Marshal public key
|
||||
pubKeyStr := strings.TrimSpace(string(ssh.MarshalAuthorizedKey(sshPubKey)))
|
||||
|
||||
// Create temp directory for key
|
||||
tmpDir, err := os.MkdirTemp("", "recover-ssh-")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create temp dir: %w", err)
|
||||
}
|
||||
|
||||
// Write private key in OpenSSH format
|
||||
privKeyPath := filepath.Join(tmpDir, "id_ed25519")
|
||||
|
||||
// Marshal private key to OpenSSH format
|
||||
pemBlock, err := ssh.MarshalPrivateKey(privKey, "")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal private key: %w", err)
|
||||
}
|
||||
|
||||
privKeyPEM := pem.EncodeToMemory(pemBlock)
|
||||
if err := os.WriteFile(privKeyPath, privKeyPEM, 0600); err != nil {
|
||||
return nil, fmt.Errorf("failed to write private key: %w", err)
|
||||
}
|
||||
|
||||
return &SSHKeyPair{
|
||||
PrivateKeyPath: privKeyPath,
|
||||
PublicKey: pubKeyStr,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Cleanup removes the ephemeral key files
|
||||
func (k *SSHKeyPair) Cleanup() {
|
||||
if k.PrivateKeyPath != "" {
|
||||
os.RemoveAll(filepath.Dir(k.PrivateKeyPath))
|
||||
}
|
||||
}
|
||||
|
||||
// runSSHKeyMerge merges original authorized_keys with ephemeral key
|
||||
func (p *Pipeline) runSSHKeyMerge(ctx context.Context) error {
|
||||
// First, backup current authorized_keys
|
||||
backupCmd := "cp /root/.ssh/authorized_keys /root/.ssh/authorized_keys.ephemeral 2>/dev/null || true"
|
||||
p.remoteCmd(ctx, backupCmd)
|
||||
|
||||
// Check if we have original keys in the restored /root
|
||||
checkCmd := "cat /root/.ssh/authorized_keys.original 2>/dev/null || cat /srv/restore/root/.ssh/authorized_keys 2>/dev/null || echo ''"
|
||||
originalKeys, _ := p.remoteCmdOutput(ctx, checkCmd)
|
||||
|
||||
// Get current (ephemeral) keys
|
||||
currentKeys, _ := p.remoteCmdOutput(ctx, "cat /root/.ssh/authorized_keys 2>/dev/null || echo ''")
|
||||
|
||||
// Merge keys (unique)
|
||||
allKeys := make(map[string]bool)
|
||||
for _, key := range strings.Split(currentKeys, "\n") {
|
||||
key = strings.TrimSpace(key)
|
||||
if key != "" && !strings.HasPrefix(key, "#") {
|
||||
allKeys[key] = true
|
||||
}
|
||||
}
|
||||
for _, key := range strings.Split(originalKeys, "\n") {
|
||||
key = strings.TrimSpace(key)
|
||||
if key != "" && !strings.HasPrefix(key, "#") {
|
||||
allKeys[key] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Write merged keys
|
||||
var mergedKeys []string
|
||||
for key := range allKeys {
|
||||
mergedKeys = append(mergedKeys, key)
|
||||
}
|
||||
|
||||
mergeCmd := fmt.Sprintf("mkdir -p /root/.ssh && echo '%s' > /root/.ssh/authorized_keys && chmod 600 /root/.ssh/authorized_keys",
|
||||
strings.Join(mergedKeys, "\n"))
|
||||
|
||||
return p.remoteCmd(ctx, mergeCmd)
|
||||
}
|
||||
Reference in New Issue
Block a user