fix: add health RPC handler, Nostr connect timeouts, atomic backup restore, nginx rate limits

- R1: Add health RPC endpoint with crash recovery status, uptime, and version
- R2: Wrap all 5 Nostr client.connect() calls in 10s timeout
- R3: Make backup restore atomic with staging dir and rollback on failure
- I1: Add rate limiting, body size, and proxy timeouts to unauthenticated nginx endpoints

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-21 01:02:16 +00:00
co-authored by Claude Opus 4.6
parent 9fc13f3079
commit b57ca4f171
7 changed files with 204 additions and 15 deletions
+118 -8
View File
@@ -120,6 +120,9 @@ pub async fn create_full_backup(
}
/// Restore a full backup from an encrypted archive.
///
/// Uses atomic staging: extracts to a temporary directory first, validates,
/// then swaps into place with rollback on failure.
pub async fn restore_full_backup(
data_dir: &Path,
backup_id: &str,
@@ -134,20 +137,127 @@ pub async fn restore_full_backup(
.await
.context("Failed to read backup file")?;
// Check disk space: need at least 2x backup size free
let backup_size = encrypted.len() as u64;
if let Ok(output) = tokio::process::Command::new("df")
.args(["--output=avail", "-B1"])
.arg(data_dir)
.output()
.await
{
if let Ok(stdout) = String::from_utf8(output.stdout) {
if let Some(avail) = stdout.lines().nth(1).and_then(|l| l.trim().parse::<u64>().ok()) {
if avail < backup_size * 2 {
anyhow::bail!(
"Insufficient disk space for restore: need {}MB, have {}MB",
backup_size * 2 / (1024 * 1024),
avail / (1024 * 1024),
);
}
}
}
}
let tar_gz_data = decrypt_data(&encrypted, passphrase)?;
// Extract to data_dir
tokio::task::spawn_blocking({
let data_dir = data_dir.to_path_buf();
move || extract_tar_gz(&data_dir, &tar_gz_data)
})
.await?
.context("Failed to extract backup")?;
let staging_dir = data_dir.join(".restore-staging");
let rollback_dir = data_dir.join(".restore-backup");
info!(id = %backup_id, "Backup restored");
// Clean up any previous failed restore
let _ = fs::remove_dir_all(&staging_dir).await;
let _ = fs::remove_dir_all(&rollback_dir).await;
// Extract to staging directory
fs::create_dir_all(&staging_dir)
.await
.context("Failed to create staging directory")?;
let staging_clone = staging_dir.clone();
if let Err(e) = tokio::task::spawn_blocking(move || extract_tar_gz(&staging_clone, &tar_gz_data))
.await?
{
let _ = fs::remove_dir_all(&staging_dir).await;
return Err(e).context("Failed to extract backup to staging");
}
// Validate staging has required files
let has_identity = staging_dir.join("identity").exists();
if !has_identity {
let _ = fs::remove_dir_all(&staging_dir).await;
anyhow::bail!("Invalid backup: missing identity directory");
}
// Move current data to rollback directory
fs::create_dir_all(&rollback_dir)
.await
.context("Failed to create rollback directory")?;
for dir_name in BACKUP_DIRS {
let src = data_dir.join(dir_name);
if src.exists() {
let dst = rollback_dir.join(dir_name);
if let Err(e) = fs::rename(&src, &dst).await {
// Rollback: restore what we already moved
info!("Restore failed during move, rolling back: {}", e);
restore_from_rollback(data_dir, &rollback_dir).await;
let _ = fs::remove_dir_all(&staging_dir).await;
let _ = fs::remove_dir_all(&rollback_dir).await;
return Err(e).context("Failed to move current data to rollback");
}
}
}
for file_name in BACKUP_FILES {
let src = data_dir.join(file_name);
if src.exists() {
let dst = rollback_dir.join(file_name);
let _ = fs::rename(&src, &dst).await;
}
}
// Move staging contents to data_dir
if let Err(e) = move_staging_to_data(data_dir, &staging_dir).await {
info!("Restore failed during staging swap, rolling back: {}", e);
restore_from_rollback(data_dir, &rollback_dir).await;
let _ = fs::remove_dir_all(&staging_dir).await;
let _ = fs::remove_dir_all(&rollback_dir).await;
return Err(e).context("Failed to move staging data to data_dir");
}
// Clean up
let _ = fs::remove_dir_all(&staging_dir).await;
let _ = fs::remove_dir_all(&rollback_dir).await;
info!(id = %backup_id, "Backup restored atomically");
Ok(())
}
/// Move staging directory contents into data_dir.
async fn move_staging_to_data(data_dir: &Path, staging_dir: &Path) -> Result<()> {
let mut entries = fs::read_dir(staging_dir)
.await
.context("Failed to read staging dir")?;
while let Some(entry) = entries.next_entry().await? {
let src = entry.path();
let name = entry.file_name();
let dst = data_dir.join(&name);
fs::rename(&src, &dst)
.await
.with_context(|| format!("Failed to move {:?} from staging", name))?;
}
Ok(())
}
/// Restore data from rollback directory back to data_dir.
async fn restore_from_rollback(data_dir: &Path, rollback_dir: &Path) {
if let Ok(mut entries) = fs::read_dir(rollback_dir).await {
while let Ok(Some(entry)) = entries.next_entry().await {
let src = entry.path();
let dst = data_dir.join(entry.file_name());
let _ = fs::rename(&src, &dst).await;
}
}
}
/// List available backups by reading metadata files.
pub async fn list_backups(data_dir: &Path) -> Result<Vec<BackupMetadata>> {
let backups_dir = data_dir.join("backups");