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>
111 lines
3.0 KiB
Go
111 lines
3.0 KiB
Go
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)
|
|
}
|