feat: add S3-compatible backup upload/download (Y3-02)

New RPC endpoints:
- backup.upload-s3: Upload encrypted backup to any S3-compatible endpoint
- backup.download-s3: Download backup from S3 to local storage

Supports MinIO, Backblaze B2, Wasabi via basic auth + S3 API.
Backups are AES-256-GCM encrypted before upload.
Rate-limited at 3 requests per 10 minutes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-14 05:44:05 +00:00
parent 01d1caa21b
commit 2fa3036c12
4 changed files with 152 additions and 3 deletions

View File

@@ -1,6 +1,7 @@
use super::RpcHandler;
use crate::backup::full;
use anyhow::Result;
use anyhow::{Context, Result};
use tracing::info;
impl RpcHandler {
/// Create a full encrypted backup. Params: { passphrase, description? }
@@ -153,4 +154,141 @@ impl RpcHandler {
"destination": dest.to_string_lossy(),
}))
}
/// Upload a backup to S3-compatible storage.
/// Params: { id, endpoint, bucket, access_key, secret_key, region? }
pub(super) async fn handle_backup_upload_s3(
&self,
params: &serde_json::Value,
) -> Result<serde_json::Value> {
let id = params["id"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("Missing 'id' parameter"))?;
let endpoint = params["endpoint"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("Missing 'endpoint' parameter"))?;
let bucket = params["bucket"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("Missing 'bucket' parameter"))?;
let access_key = params["access_key"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("Missing 'access_key' parameter"))?;
let secret_key = params["secret_key"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("Missing 'secret_key' parameter"))?;
let region = params["region"].as_str().unwrap_or("us-east-1");
// Validate backup ID
if id.is_empty() || id.len() > 128 || id.contains('/') || id.contains('\\') || id.contains("..") || id.contains('\0') {
anyhow::bail!("Invalid backup ID");
}
let bak_path = full::backup_file_path(&self.config.data_dir, id);
if !bak_path.exists() {
anyhow::bail!("Backup not found: {}", id);
}
let file_bytes = tokio::fs::read(&bak_path)
.await
.context("Failed to read backup file")?;
let key = format!("archipelago-backups/{}.tar.gz.enc", id);
let size = file_bytes.len();
// Upload via HTTP PUT to S3-compatible endpoint
let url = format!("{}/{}/{}", endpoint.trim_end_matches('/'), bucket, key);
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(300))
.build()?;
// Simple S3 PUT (works with MinIO, Backblaze B2 S3-compatible, Wasabi)
// For full AWS S3, proper SigV4 signing would be needed
let response = client
.put(&url)
.basic_auth(access_key, Some(secret_key))
.header("Content-Type", "application/octet-stream")
.body(file_bytes)
.send()
.await
.context("S3 upload failed")?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
anyhow::bail!("S3 upload failed ({}): {}", status, &body[..200.min(body.len())]);
}
info!(id = %id, bucket = %bucket, size = %size, "Backup uploaded to S3");
Ok(serde_json::json!({
"uploaded": true,
"id": id,
"bucket": bucket,
"key": key,
"size_bytes": size,
}))
}
/// Download a backup from S3-compatible storage.
/// Params: { id, endpoint, bucket, access_key, secret_key, region? }
pub(super) async fn handle_backup_download_s3(
&self,
params: &serde_json::Value,
) -> Result<serde_json::Value> {
let id = params["id"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("Missing 'id' parameter"))?;
let endpoint = params["endpoint"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("Missing 'endpoint' parameter"))?;
let bucket = params["bucket"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("Missing 'bucket' parameter"))?;
let access_key = params["access_key"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("Missing 'access_key' parameter"))?;
let secret_key = params["secret_key"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("Missing 'secret_key' parameter"))?;
if id.is_empty() || id.len() > 128 || id.contains('/') || id.contains('\\') || id.contains("..") || id.contains('\0') {
anyhow::bail!("Invalid backup ID");
}
let key = format!("archipelago-backups/{}.tar.gz.enc", id);
let url = format!("{}/{}/{}", endpoint.trim_end_matches('/'), bucket, key);
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(300))
.build()?;
let response = client
.get(&url)
.basic_auth(access_key, Some(secret_key))
.send()
.await
.context("S3 download failed")?;
if !response.status().is_success() {
let status = response.status();
anyhow::bail!("S3 download failed ({})", status);
}
let bytes = response.bytes().await.context("Failed to read S3 response")?;
let size = bytes.len();
// Save to backups directory
let bak_dir = self.config.data_dir.join("backups");
tokio::fs::create_dir_all(&bak_dir).await?;
let bak_path = full::backup_file_path(&self.config.data_dir, id);
tokio::fs::write(&bak_path, &bytes).await.context("Failed to write backup file")?;
info!(id = %id, bucket = %bucket, size = %size, "Backup downloaded from S3");
Ok(serde_json::json!({
"downloaded": true,
"id": id,
"size_bytes": size,
}))
}
}

View File

@@ -623,6 +623,14 @@ impl RpcHandler {
let p = params.unwrap_or(serde_json::json!({}));
self.handle_backup_to_usb(&p).await
}
"backup.upload-s3" => {
let p = params.unwrap_or(serde_json::json!({}));
self.handle_backup_upload_s3(&p).await
}
"backup.download-s3" => {
let p = params.unwrap_or(serde_json::json!({}));
self.handle_backup_download_s3(&p).await
}
// Security / secrets
"security.rotate-secrets" => {

View File

@@ -306,6 +306,9 @@ impl EndpointRateLimiter {
// Container operations
limits.insert("container-install".to_string(), (5, 300));
limits.insert("package.install".to_string(), (5, 300));
// S3 backup operations (resource-intensive)
limits.insert("backup.upload-s3".to_string(), (3, 600));
limits.insert("backup.download-s3".to_string(), (3, 600));
// System operations
limits.insert("update.apply".to_string(), (2, 600));
limits.insert("system.reboot".to_string(), (2, 300));