// Package client provides ZFS snapshot backup client functionality. // It handles creating snapshots and uploading them to a remote server. package client import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "os" "os/exec" "strings" "time" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/credentials" "github.com/aws/aws-sdk-go-v2/service/s3" "github.com/mistifyio/go-zfs" "github.com/pierrec/lz4/v4" ) // Client handles snapshot backup operations to a remote server. // It manages creating local ZFS snapshots and transmitting them // to the backup server via HTTP or SSH. type Client struct { config *Config } // New creates a new Client instance with the provided configuration. func New(config *Config) *Client { return &Client{config: config} } // CreateSnapshot creates a local ZFS snapshot of the configured dataset. // The snapshot is named with a timestamp for easy identification. // Returns the created snapshot dataset or an error. func (c *Client) CreateSnapshot() (*zfs.Dataset, error) { // Get the local dataset ds, err := zfs.GetDataset(c.config.LocalDataset) if err != nil { return nil, fmt.Errorf("failed to get dataset: %v", err) } // Generate snapshot name with timestamp timestamp := time.Now().Format("2006-01-02_15:04:05") snapshotName := fmt.Sprintf("backup_%s", timestamp) // Create the snapshot snapshot, err := ds.Snapshot(snapshotName, false) if err != nil { return nil, fmt.Errorf("failed to create snapshot: %v", err) } fmt.Printf("✓ Created local snapshot: %s@%s\n", c.config.LocalDataset, snapshotName) return snapshot, nil } // GetSnapshotSize returns the used size of a snapshot in bytes. func (c *Client) GetSnapshotSize(snapshot *zfs.Dataset) int64 { return int64(snapshot.Used) } // SendSnapshot sends a snapshot to the backup server. // It first requests upload authorization, then streams the snapshot // using the appropriate method (S3 or ZFS receive). func (c *Client) SendSnapshot(snapshot *zfs.Dataset) error { estimatedSize := c.GetSnapshotSize(snapshot) // Request upload authorization from server uploadReq := map[string]interface{}{ "client_id": c.config.ClientID, "api_key": c.config.APIKey, "dataset_name": c.config.LocalDataset, "timestamp": time.Now().Format(time.RFC3339), "compressed": c.config.Compress, "estimated_size": estimatedSize, } reqBody, _ := json.Marshal(uploadReq) resp, err := http.Post(c.config.ServerURL+"/upload", "application/json", bytes.NewBuffer(reqBody)) if err != nil { return fmt.Errorf("failed to request upload: %v", err) } defer resp.Body.Close() // Parse server response var uploadResp struct { Success bool `json:"success"` Message string `json:"message"` UploadURL string `json:"upload_url"` UploadMethod string `json:"upload_method"` StorageKey string `json:"storage_key"` } if err := json.NewDecoder(resp.Body).Decode(&uploadResp); err != nil { return fmt.Errorf("failed to decode response: %v", err) } if !uploadResp.Success { return fmt.Errorf("upload not authorized: %s", uploadResp.Message) } fmt.Printf("→ Upload authorized\n") fmt.Printf(" Method: %s\n", uploadResp.UploadMethod) fmt.Printf(" Storage key: %s\n", uploadResp.StorageKey) // Choose upload method based on server response if uploadResp.UploadMethod == "s3" { return c.streamToS3(snapshot, uploadResp.UploadURL, uploadResp.StorageKey) } return c.sendViaZFS(snapshot, uploadResp.StorageKey) } // streamToS3 streams a ZFS snapshot to S3 storage using AWS SDK. // The snapshot is optionally compressed with LZ4 before transmission. func (c *Client) streamToS3(snapshot *zfs.Dataset, uploadURL, storageKey string) error { fmt.Printf("→ Uploading snapshot to S3...\n") // Ensure endpoint has valid URI scheme endpoint := c.config.S3Endpoint if endpoint != "" && !strings.HasPrefix(endpoint, "http://") && !strings.HasPrefix(endpoint, "https://") { endpoint = "http://" + endpoint } // Create AWS config awsCfg, err := config.LoadDefaultConfig(context.TODO(), config.WithRegion(c.config.S3Region), config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider( c.config.S3AccessKey, c.config.S3SecretKey, "", )), ) if err != nil { return fmt.Errorf("failed to load AWS config: %v", err) } // Determine if using custom endpoint (non-AWS) customEndpoint := endpoint != "" && endpoint != "http://s3.amazonaws.com" && endpoint != "https://s3.amazonaws.com" // Create S3 client s3Client := s3.NewFromConfig(awsCfg, func(o *s3.Options) { if customEndpoint { o.BaseEndpoint = aws.String(endpoint) o.UsePathStyle = true // Required for MinIO compatible storage } }) // Create ZFS send command cmd := exec.Command("zfs", "send", snapshot.Name) zfsOut, err := cmd.StdoutPipe() if err != nil { return fmt.Errorf("failed to create pipe: %v", err) } if err := cmd.Start(); err != nil { return fmt.Errorf("failed to start zfs send: %v", err) } var reader io.Reader = zfsOut // Apply LZ4 compression if enabled if c.config.Compress { fmt.Printf(" Compressing with LZ4...\n") pr, pw := io.Pipe() lz4Writer := lz4.NewWriter(pw) lz4Writer.Apply(lz4.BlockSizeOption(lz4.BlockSize(4 * 1024 * 1024))) // 4MB blocks for better performance go func() { // Copy zfs output to LZ4 writer io.Copy(lz4Writer, zfsOut) // Close LZ4 writer first to flush, then close pipe lz4Writer.Close() pw.Close() }() reader = pr } // Upload to S3 using PutObject _, err = s3Client.PutObject(context.TODO(), &s3.PutObjectInput{ Bucket: aws.String(c.config.S3Bucket), Key: aws.String(storageKey), Body: reader, ContentType: aws.String("application/octet-stream"), }) // Wait for zfs send to complete if err := cmd.Wait(); err != nil { return fmt.Errorf("zfs send failed: %v", err) } if err != nil { return fmt.Errorf("failed to upload to S3: %v", err) } fmt.Printf("✓ Snapshot uploaded to S3 successfully!\n") return nil } // sendViaZFS sends a snapshot via traditional ZFS send/receive over SSH. // This method is used when the server uses local ZFS storage. func (c *Client) sendViaZFS(snapshot *zfs.Dataset, receivePath string) error { fmt.Printf("→ Sending via ZFS send/receive...\n") // Extract server host from URL serverHost := c.config.ServerURL if len(serverHost) > 7 && strings.HasPrefix(serverHost, "http://") { serverHost = serverHost[7:] } else if len(serverHost) > 8 && strings.HasPrefix(serverHost, "https://") { serverHost = serverHost[8:] } // Remove port if present if idx := strings.LastIndex(serverHost, ":"); idx > 0 { serverHost = serverHost[:idx] } // Execute ZFS send over SSH cmd := exec.Command("sh", "-c", fmt.Sprintf("zfs send %s | ssh %s 'zfs recv -F %s'", snapshot.Name, serverHost, receivePath)) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr if err := cmd.Run(); err != nil { return fmt.Errorf("failed to send snapshot: %v", err) } fmt.Printf("✓ Snapshot sent successfully!\n") return nil } // SnapshotResult contains the result of a snapshot creation and send operation. type SnapshotResult struct { FullBackup bool Snapshot *zfs.Dataset } // CreateAndSend creates a snapshot and sends it to the backup server. // It automatically detects if this is a full or incremental backup: // - If no bookmark exists, does a full backup // - If bookmark exists, does an incremental backup from the bookmark func (c *Client) CreateAndSend() (*SnapshotResult, error) { // Check for existing bookmark to determine backup type lastBookmark, err := c.GetLastBookmark() if err != nil { return nil, fmt.Errorf("failed to check bookmarks: %v", err) } // Create new snapshot snapshot, err := c.CreateSnapshot() if err != nil { return nil, fmt.Errorf("failed to create snapshot: %v", err) } isFullBackup := lastBookmark == "" if isFullBackup { fmt.Println("→ No previous backup found, doing FULL backup...") // Send as full (no base) if err := c.SendIncremental(snapshot, ""); err != nil { return nil, fmt.Errorf("failed to send snapshot: %v", err) } } else { fmt.Printf("→ Found previous backup, doing INCREMENTAL from %s...", lastBookmark) // Send as incremental from bookmark if err := c.SendIncremental(snapshot, lastBookmark); err != nil { return nil, fmt.Errorf("failed to send incremental: %v", err) } } // Create bookmark for future incremental backups if err := c.CreateBookmark(snapshot); err != nil { fmt.Printf("Warning: failed to create bookmark: %v\n", err) } return &SnapshotResult{ FullBackup: isFullBackup, Snapshot: snapshot, }, nil } // GetStatus retrieves and displays the client's backup status from the server. // Shows storage usage, quota, and snapshot count. func (c *Client) GetStatus() error { url := fmt.Sprintf("%s/status?client_id=%s&api_key=%s", c.config.ServerURL, c.config.ClientID, c.config.APIKey) resp, err := http.Get(url) if err != nil { return fmt.Errorf("failed to get status: %v", err) } defer resp.Body.Close() var status struct { Success bool `json:"success"` TotalSnapshots int `json:"total_snapshots"` UsedBytes int64 `json:"used_bytes"` MaxBytes int64 `json:"max_bytes"` PercentUsed float64 `json:"percent_used"` StorageType string `json:"storage_type"` } if err := json.NewDecoder(resp.Body).Decode(&status); err != nil { return fmt.Errorf("failed to decode status: %v", err) } if !status.Success { return fmt.Errorf("status check failed") } fmt.Printf("\n=== Server Status ===\n") fmt.Printf("Storage Type: %s\n", status.StorageType) fmt.Printf("Total Snapshots: %d\n", status.TotalSnapshots) fmt.Printf("Used: %.2f GB / %.2f GB (%.1f%%)\n", float64(status.UsedBytes)/(1024*1024*1024), float64(status.MaxBytes)/(1024*1024*1024), status.PercentUsed) return nil }