Files
Olaf Berberich 29a2886402 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>
2025-12-08 00:31:27 +00:00

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)
}