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:
178
internal/dns/exoscale.go
Normal file
178
internal/dns/exoscale.go
Normal file
@@ -0,0 +1,178 @@
|
||||
package dns
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
|
||||
v3 "github.com/exoscale/egoscale/v3"
|
||||
"github.com/exoscale/egoscale/v3/credentials"
|
||||
)
|
||||
|
||||
// ExoscaleDNS manages DNS records via Exoscale API
|
||||
type ExoscaleDNS struct {
|
||||
client *v3.Client
|
||||
}
|
||||
|
||||
// DNSRecord represents a DNS record
|
||||
type DNSRecord struct {
|
||||
ID string
|
||||
Type string // A, AAAA, CNAME, etc.
|
||||
Name string // subdomain or @ for apex
|
||||
Content string // IP or target
|
||||
TTL int64
|
||||
}
|
||||
|
||||
// NewExoscaleDNS creates a new Exoscale DNS client
|
||||
func NewExoscaleDNS(apiKey, apiSecret string) (*ExoscaleDNS, error) {
|
||||
creds := credentials.NewStaticCredentials(apiKey, apiSecret)
|
||||
client, err := v3.NewClient(creds)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create Exoscale client: %w", err)
|
||||
}
|
||||
|
||||
return &ExoscaleDNS{client: client}, nil
|
||||
}
|
||||
|
||||
// GetRecord gets a specific DNS record
|
||||
func (d *ExoscaleDNS) GetRecord(ctx context.Context, zone, recordType, name string) (*DNSRecord, error) {
|
||||
domains, err := d.client.ListDNSDomains(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list domains: %w", err)
|
||||
}
|
||||
|
||||
var domainID v3.UUID
|
||||
for _, domain := range domains.DNSDomains {
|
||||
if domain.UnicodeName == zone {
|
||||
domainID = domain.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if domainID == "" {
|
||||
return nil, fmt.Errorf("zone %s not found", zone)
|
||||
}
|
||||
|
||||
records, err := d.client.ListDNSDomainRecords(ctx, domainID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list records: %w", err)
|
||||
}
|
||||
|
||||
for _, rec := range records.DNSDomainRecords {
|
||||
if rec.Type == v3.DNSDomainRecordType(recordType) && rec.Name == name {
|
||||
return &DNSRecord{
|
||||
ID: string(rec.ID),
|
||||
Type: string(rec.Type),
|
||||
Name: rec.Name,
|
||||
Content: rec.Content,
|
||||
TTL: rec.Ttl,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("record %s.%s not found", name, zone)
|
||||
}
|
||||
|
||||
// UpdateRecord updates a DNS record
|
||||
func (d *ExoscaleDNS) UpdateRecord(ctx context.Context, zone string, record *DNSRecord) error {
|
||||
domains, err := d.client.ListDNSDomains(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list domains: %w", err)
|
||||
}
|
||||
|
||||
var domainID v3.UUID
|
||||
for _, domain := range domains.DNSDomains {
|
||||
if domain.UnicodeName == zone {
|
||||
domainID = domain.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if domainID == "" {
|
||||
return fmt.Errorf("zone %s not found", zone)
|
||||
}
|
||||
|
||||
op, err := d.client.UpdateDNSDomainRecord(ctx, domainID, v3.UUID(record.ID), v3.UpdateDNSDomainRecordRequest{
|
||||
Content: record.Content,
|
||||
Ttl: record.TTL,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update record: %w", err)
|
||||
}
|
||||
|
||||
_, err = d.client.Wait(ctx, op, v3.OperationStateSuccess)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update operation failed: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListRecords lists all records in a zone
|
||||
func (d *ExoscaleDNS) ListRecords(ctx context.Context, zone string) ([]DNSRecord, error) {
|
||||
domains, err := d.client.ListDNSDomains(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list domains: %w", err)
|
||||
}
|
||||
|
||||
var domainID v3.UUID
|
||||
for _, domain := range domains.DNSDomains {
|
||||
if domain.UnicodeName == zone {
|
||||
domainID = domain.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if domainID == "" {
|
||||
return nil, fmt.Errorf("zone %s not found", zone)
|
||||
}
|
||||
|
||||
records, err := d.client.ListDNSDomainRecords(ctx, domainID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list records: %w", err)
|
||||
}
|
||||
|
||||
var result []DNSRecord
|
||||
for _, rec := range records.DNSDomainRecords {
|
||||
result = append(result, DNSRecord{
|
||||
ID: string(rec.ID),
|
||||
Type: string(rec.Type),
|
||||
Name: rec.Name,
|
||||
Content: rec.Content,
|
||||
TTL: rec.Ttl,
|
||||
})
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ListZones lists all DNS zones managed in Exoscale
|
||||
func (d *ExoscaleDNS) ListZones(ctx context.Context) ([]string, error) {
|
||||
domains, err := d.client.ListDNSDomains(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list domains: %w", err)
|
||||
}
|
||||
|
||||
var zones []string
|
||||
for _, domain := range domains.DNSDomains {
|
||||
zones = append(zones, domain.UnicodeName)
|
||||
}
|
||||
|
||||
return zones, nil
|
||||
}
|
||||
|
||||
// ResolveCurrentIP resolves the current IP for a hostname
|
||||
func ResolveCurrentIP(hostname string) (string, error) {
|
||||
ips, err := net.LookupIP(hostname)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
for _, ip := range ips {
|
||||
if ipv4 := ip.To4(); ipv4 != nil {
|
||||
return ipv4.String(), nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("no IPv4 address found for %s", hostname)
|
||||
}
|
||||
135
internal/dns/health.go
Normal file
135
internal/dns/health.go
Normal file
@@ -0,0 +1,135 @@
|
||||
package dns
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// HealthResult contains the result of health checks
|
||||
type HealthResult struct {
|
||||
SSHReady bool
|
||||
HTTPSReady bool
|
||||
SSHError error
|
||||
HTTPSError error
|
||||
}
|
||||
|
||||
// HealthChecker performs health checks on VMs
|
||||
type HealthChecker struct {
|
||||
SSHTimeout time.Duration
|
||||
HTTPSTimeout time.Duration
|
||||
}
|
||||
|
||||
// NewHealthChecker creates a new health checker
|
||||
func NewHealthChecker() *HealthChecker {
|
||||
return &HealthChecker{
|
||||
SSHTimeout: 10 * time.Second,
|
||||
HTTPSTimeout: 10 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
// CheckSSH checks if SSH is accessible
|
||||
func (h *HealthChecker) CheckSSH(ctx context.Context, ip string, port int) error {
|
||||
if port == 0 {
|
||||
port = 22
|
||||
}
|
||||
|
||||
address := fmt.Sprintf("%s:%d", ip, port)
|
||||
|
||||
dialer := &net.Dialer{
|
||||
Timeout: h.SSHTimeout,
|
||||
}
|
||||
|
||||
conn, err := dialer.DialContext(ctx, "tcp", address)
|
||||
if err != nil {
|
||||
return fmt.Errorf("SSH not accessible: %w", err)
|
||||
}
|
||||
conn.Close()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CheckHTTPS checks if HTTPS is accessible
|
||||
func (h *HealthChecker) CheckHTTPS(ctx context.Context, ip string, port int) error {
|
||||
if port == 0 {
|
||||
port = 443
|
||||
}
|
||||
|
||||
// Use IP directly with insecure TLS (we just want to verify the port is open)
|
||||
client := &http.Client{
|
||||
Timeout: h.HTTPSTimeout,
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{
|
||||
InsecureSkipVerify: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("https://%s:%d/", ip, port)
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
// Connection refused or timeout is a failure
|
||||
// But TLS errors mean the port is open (which is what we want)
|
||||
if _, ok := err.(*tls.CertificateVerificationError); ok {
|
||||
return nil // Port is open, TLS is working
|
||||
}
|
||||
|
||||
// For other TLS errors, the port is still open
|
||||
if netErr, ok := err.(net.Error); ok && !netErr.Timeout() {
|
||||
// Non-timeout network error might still mean port is open
|
||||
// Check if we can at least connect
|
||||
conn, connErr := net.DialTimeout("tcp", fmt.Sprintf("%s:%d", ip, port), h.HTTPSTimeout)
|
||||
if connErr == nil {
|
||||
conn.Close()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("HTTPS not accessible: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CheckAll performs all health checks
|
||||
func (h *HealthChecker) CheckAll(ctx context.Context, ip string) *HealthResult {
|
||||
result := &HealthResult{}
|
||||
|
||||
result.SSHError = h.CheckSSH(ctx, ip, 22)
|
||||
result.SSHReady = result.SSHError == nil
|
||||
|
||||
result.HTTPSError = h.CheckHTTPS(ctx, ip, 443)
|
||||
result.HTTPSReady = result.HTTPSError == nil
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// WaitForReady waits for all health checks to pass
|
||||
func (h *HealthChecker) WaitForReady(ctx context.Context, ip string, timeout time.Duration) error {
|
||||
deadline := time.Now().Add(timeout)
|
||||
|
||||
for time.Now().Before(deadline) {
|
||||
result := h.CheckAll(ctx, ip)
|
||||
if result.SSHReady {
|
||||
return nil // SSH is the minimum requirement
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(5 * time.Second):
|
||||
// Continue checking
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("timeout waiting for VM to be ready")
|
||||
}
|
||||
189
internal/dns/migration.go
Normal file
189
internal/dns/migration.go
Normal file
@@ -0,0 +1,189 @@
|
||||
package dns
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// MigrationRequest contains DNS migration parameters
|
||||
type MigrationRequest struct {
|
||||
Hostname string // Full hostname (e.g., proton.obr.sh)
|
||||
OldIP string // Expected current IP
|
||||
NewIP string // New IP to point to
|
||||
Zone string // DNS zone (e.g., obr.sh)
|
||||
DryRun bool
|
||||
}
|
||||
|
||||
// MigrationResult contains the result of a DNS migration
|
||||
type MigrationResult struct {
|
||||
Success bool
|
||||
Message string
|
||||
OldRecord *DNSRecord
|
||||
NewRecord *DNSRecord
|
||||
RolledBack bool
|
||||
}
|
||||
|
||||
// Migrator handles DNS migration with safety checks
|
||||
type Migrator struct {
|
||||
dns *ExoscaleDNS
|
||||
health *HealthChecker
|
||||
verbose bool
|
||||
}
|
||||
|
||||
// NewMigrator creates a new DNS migrator
|
||||
func NewMigrator(dns *ExoscaleDNS, verbose bool) *Migrator {
|
||||
return &Migrator{
|
||||
dns: dns,
|
||||
health: NewHealthChecker(),
|
||||
verbose: verbose,
|
||||
}
|
||||
}
|
||||
|
||||
// Migrate performs DNS migration with safety checks
|
||||
func (m *Migrator) Migrate(ctx context.Context, req MigrationRequest) (*MigrationResult, error) {
|
||||
result := &MigrationResult{}
|
||||
|
||||
// Parse hostname to get subdomain and zone
|
||||
subdomain, zone := parseHostname(req.Hostname, req.Zone)
|
||||
|
||||
if m.verbose {
|
||||
fmt.Printf("DNS Migration: %s -> %s\n", req.OldIP, req.NewIP)
|
||||
fmt.Printf(" Zone: %s, Subdomain: %s\n", zone, subdomain)
|
||||
}
|
||||
|
||||
// Step 1: Verify new VM is accessible
|
||||
if m.verbose {
|
||||
fmt.Println("\n=== Pre-flight checks ===")
|
||||
}
|
||||
|
||||
healthResult := m.health.CheckAll(ctx, req.NewIP)
|
||||
if !healthResult.SSHReady {
|
||||
return nil, fmt.Errorf("new VM not accessible on SSH (port 22): %s", req.NewIP)
|
||||
}
|
||||
if m.verbose {
|
||||
fmt.Printf(" ✓ SSH accessible on %s\n", req.NewIP)
|
||||
}
|
||||
|
||||
// Step 2: Get current DNS record
|
||||
record, err := m.dns.GetRecord(ctx, zone, "A", subdomain)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get current DNS record: %w", err)
|
||||
}
|
||||
result.OldRecord = record
|
||||
|
||||
// Step 3: Verify current DNS matches expected old IP
|
||||
if record.Content != req.OldIP {
|
||||
return nil, fmt.Errorf("DNS mismatch: expected %s, found %s", req.OldIP, record.Content)
|
||||
}
|
||||
if m.verbose {
|
||||
fmt.Printf(" ✓ Current DNS points to expected IP: %s\n", req.OldIP)
|
||||
}
|
||||
|
||||
// Step 4: Verify via live DNS resolution
|
||||
resolvedIP, err := ResolveCurrentIP(req.Hostname)
|
||||
if err != nil {
|
||||
if m.verbose {
|
||||
fmt.Printf(" ⚠ Could not verify live DNS: %v\n", err)
|
||||
}
|
||||
} else if resolvedIP != req.OldIP {
|
||||
return nil, fmt.Errorf("live DNS mismatch: expected %s, resolved %s", req.OldIP, resolvedIP)
|
||||
} else if m.verbose {
|
||||
fmt.Printf(" ✓ Live DNS resolution verified: %s\n", resolvedIP)
|
||||
}
|
||||
|
||||
// Step 5: Perform migration (or dry-run)
|
||||
if req.DryRun {
|
||||
result.Success = true
|
||||
result.Message = fmt.Sprintf("[DRY RUN] Would update %s.%s: %s -> %s",
|
||||
subdomain, zone, req.OldIP, req.NewIP)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
if m.verbose {
|
||||
fmt.Println("\n=== Updating DNS ===")
|
||||
}
|
||||
|
||||
// Update the record
|
||||
newRecord := &DNSRecord{
|
||||
ID: record.ID,
|
||||
Type: record.Type,
|
||||
Name: record.Name,
|
||||
Content: req.NewIP,
|
||||
TTL: record.TTL,
|
||||
}
|
||||
|
||||
if err := m.dns.UpdateRecord(ctx, zone, newRecord); err != nil {
|
||||
return nil, fmt.Errorf("failed to update DNS: %w", err)
|
||||
}
|
||||
|
||||
result.NewRecord = newRecord
|
||||
|
||||
if m.verbose {
|
||||
fmt.Printf(" ✓ DNS updated: %s -> %s\n", req.OldIP, req.NewIP)
|
||||
}
|
||||
|
||||
// Step 6: Verify the update
|
||||
verifyRecord, err := m.dns.GetRecord(ctx, zone, "A", subdomain)
|
||||
if err != nil || verifyRecord.Content != req.NewIP {
|
||||
// Rollback
|
||||
if m.verbose {
|
||||
fmt.Println(" ✗ Verification failed, rolling back...")
|
||||
}
|
||||
|
||||
rollbackRecord := &DNSRecord{
|
||||
ID: record.ID,
|
||||
Type: record.Type,
|
||||
Name: record.Name,
|
||||
Content: req.OldIP,
|
||||
TTL: record.TTL,
|
||||
}
|
||||
|
||||
if rollbackErr := m.dns.UpdateRecord(ctx, zone, rollbackRecord); rollbackErr != nil {
|
||||
return nil, fmt.Errorf("CRITICAL: rollback failed: %w (original error: %v)", rollbackErr, err)
|
||||
}
|
||||
|
||||
result.RolledBack = true
|
||||
return nil, fmt.Errorf("DNS update verification failed, rolled back: %w", err)
|
||||
}
|
||||
|
||||
result.Success = true
|
||||
result.Message = fmt.Sprintf("DNS migration complete: %s now points to %s", req.Hostname, req.NewIP)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// parseHostname extracts subdomain and zone from hostname
|
||||
func parseHostname(hostname, defaultZone string) (subdomain, zone string) {
|
||||
if defaultZone != "" {
|
||||
if strings.HasSuffix(hostname, "."+defaultZone) {
|
||||
subdomain = strings.TrimSuffix(hostname, "."+defaultZone)
|
||||
return subdomain, defaultZone
|
||||
}
|
||||
}
|
||||
|
||||
// Known zones
|
||||
knownZones := []string{
|
||||
"obr.sh", "obnh.io", "obnh.network", "obnh.org",
|
||||
"obr.digital", "obr.im", "s-n-r.net", "as60284.net", "baumert.cc",
|
||||
}
|
||||
|
||||
for _, z := range knownZones {
|
||||
if strings.HasSuffix(hostname, "."+z) {
|
||||
subdomain = strings.TrimSuffix(hostname, "."+z)
|
||||
return subdomain, z
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: assume last two parts are zone
|
||||
parts := strings.Split(hostname, ".")
|
||||
if len(parts) >= 2 {
|
||||
zone = strings.Join(parts[len(parts)-2:], ".")
|
||||
subdomain = strings.Join(parts[:len(parts)-2], ".")
|
||||
if subdomain == "" {
|
||||
subdomain = "@"
|
||||
}
|
||||
}
|
||||
|
||||
return subdomain, zone
|
||||
}
|
||||
Reference in New Issue
Block a user