cli: avoid data-dir initialization for version; create db parent dirs; redact generated passwords in CLI output
- Prevent 'nx9-wg version' from creating data directories by avoiding database initialization. - Create parent directories when an explicit --database path is provided. - Redact printed generated administrator passwords; announce file path or redact instead. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
commit
2ac6c81dfe
140 files changed
+31342
No files matched your search
@@ -0,0 +1,204 @@
|
||||
//! Backup and Restore engine for consistent SQLite snapshots and manifests.
|
||||
|
||||
use crate::error::{ApiError, ApiResult};
|
||||
use chrono::Utc;
|
||||
use nx9_wg_core::types::audit::AuditEventType;
|
||||
use nx9_wg_core::types::backup::{BackupFileEntry, BackupManifest, BackupMeta};
|
||||
use nx9_wg_db::Store;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::path::{Path, PathBuf};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Backup and restore management service.
|
||||
pub struct BackupService;
|
||||
|
||||
impl BackupService {
|
||||
/// Create a consistent, atomic SQLite snapshot backup and manifest.
|
||||
pub async fn create_backup(
|
||||
store: &Store,
|
||||
backup_dir: &Path,
|
||||
description: Option<&str>,
|
||||
actor: &str,
|
||||
ip_address: Option<&str>,
|
||||
) -> ApiResult<(BackupMeta, PathBuf)> {
|
||||
if !backup_dir.exists() {
|
||||
std::fs::create_dir_all(backup_dir).map_err(|e| {
|
||||
ApiError::Internal(format!("Failed to create backup directory: {e}"))
|
||||
})?;
|
||||
}
|
||||
|
||||
let timestamp = Utc::now().format("%Y%m%d-%H%M%S").to_string();
|
||||
let filename = format!("nx9-backup-{timestamp}.db");
|
||||
let backup_path = backup_dir.join(&filename);
|
||||
let backup_path_str = backup_path.to_string_lossy().to_string();
|
||||
|
||||
// 1. Perform atomic SQLite VACUUM INTO
|
||||
store.vacuum_into(&backup_path_str).await?;
|
||||
|
||||
// 2. Read bytes to compute checksum and size
|
||||
let bytes = std::fs::read(&backup_path)
|
||||
.map_err(|e| ApiError::Internal(format!("Failed to read created backup file: {e}")))?;
|
||||
let size_bytes = bytes.len() as i64;
|
||||
let hash_bytes = Sha256::digest(&bytes);
|
||||
let checksum = hash_bytes
|
||||
.iter()
|
||||
.map(|b| format!("{b:02x}"))
|
||||
.collect::<String>();
|
||||
|
||||
let now = Utc::now().naive_utc();
|
||||
let backup_id = Uuid::new_v4();
|
||||
|
||||
let meta = BackupMeta {
|
||||
id: backup_id,
|
||||
filename: filename.clone(),
|
||||
size_bytes,
|
||||
checksum: checksum.clone(),
|
||||
schema_version: "1".to_string(),
|
||||
encrypted: false,
|
||||
description: description.map(|s| s.to_string()),
|
||||
created_at: now,
|
||||
};
|
||||
|
||||
// 3. Write manifest file
|
||||
let manifest = BackupManifest {
|
||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
schema_version: "1".to_string(),
|
||||
created_at: now,
|
||||
checksum: checksum.clone(),
|
||||
encrypted: false,
|
||||
files: vec![BackupFileEntry {
|
||||
path: filename.clone(),
|
||||
size_bytes: size_bytes as u64,
|
||||
checksum: checksum.clone(),
|
||||
}],
|
||||
notes: description.map(|s| s.to_string()),
|
||||
};
|
||||
|
||||
let manifest_path = backup_dir.join(format!("nx9-backup-{timestamp}.manifest.json"));
|
||||
let manifest_json = serde_json::to_string_pretty(&manifest)
|
||||
.map_err(|e| ApiError::Internal(format!("Failed to serialize backup manifest: {e}")))?;
|
||||
std::fs::write(&manifest_path, manifest_json)
|
||||
.map_err(|e| ApiError::Internal(format!("Failed to write backup manifest: {e}")))?;
|
||||
|
||||
// 4. Save metadata in SQLite
|
||||
store.create_backup_meta(&meta).await?;
|
||||
|
||||
// 5. Audit event
|
||||
let _ = store
|
||||
.record_audit(
|
||||
AuditEventType::BackupCreate,
|
||||
actor,
|
||||
Some("backup"),
|
||||
Some(&backup_id.to_string()),
|
||||
Some(&format!("Created backup '{filename}' ({size_bytes} bytes)")),
|
||||
None,
|
||||
ip_address,
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok((meta, backup_path))
|
||||
}
|
||||
|
||||
/// Verify the integrity and SQLite magic header of a backup file.
|
||||
pub fn verify_backup(backup_file: &Path, expected_checksum: Option<&str>) -> ApiResult<bool> {
|
||||
if !backup_file.exists() {
|
||||
return Err(ApiError::NotFound(format!(
|
||||
"Backup file '{}' not found",
|
||||
backup_file.display()
|
||||
)));
|
||||
}
|
||||
|
||||
let bytes = std::fs::read(backup_file).map_err(|e| {
|
||||
ApiError::Internal(format!("Failed to read backup file for verification: {e}"))
|
||||
})?;
|
||||
|
||||
if bytes.len() < 100 {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// Verify SQLite 3 header magic
|
||||
if &bytes[0..16] != b"SQLite format 3\0" {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// Verify checksum if supplied
|
||||
if let Some(expected) = expected_checksum {
|
||||
let hash_bytes = Sha256::digest(&bytes);
|
||||
let calculated = hash_bytes
|
||||
.iter()
|
||||
.map(|b| format!("{b:02x}"))
|
||||
.collect::<String>();
|
||||
if calculated.to_lowercase() != expected.to_lowercase() {
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Restore database from a verified backup file with safety pre-restore backup snapshot.
|
||||
pub async fn restore_backup(
|
||||
store: &Store,
|
||||
backup_file: &Path,
|
||||
active_db_path: &Path,
|
||||
safety_dir: &Path,
|
||||
actor: &str,
|
||||
ip_address: Option<&str>,
|
||||
) -> ApiResult<()> {
|
||||
// 1. Verify backup file before touching active DB
|
||||
let is_valid = Self::verify_backup(backup_file, None)?;
|
||||
if !is_valid {
|
||||
return Err(ApiError::Validation(
|
||||
"Backup file failed verification: invalid SQLite format or corrupted data"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// 2. Create pre-restore safety snapshot of the active database
|
||||
if active_db_path.exists() {
|
||||
if !safety_dir.exists() {
|
||||
let _ = std::fs::create_dir_all(safety_dir);
|
||||
}
|
||||
let safety_name = format!(
|
||||
"pre-restore-safety-{}.bak",
|
||||
Utc::now().format("%Y%m%d-%H%M%S")
|
||||
);
|
||||
let safety_path = safety_dir.join(safety_name);
|
||||
let _ = store.vacuum_into(&safety_path.to_string_lossy()).await;
|
||||
}
|
||||
|
||||
// 3. Record audit event before closing pool
|
||||
let _ = store
|
||||
.record_audit(
|
||||
AuditEventType::BackupRestore,
|
||||
actor,
|
||||
Some("backup"),
|
||||
None,
|
||||
Some(&format!(
|
||||
"Database restore initiated from '{}'",
|
||||
backup_file.display()
|
||||
)),
|
||||
None,
|
||||
ip_address,
|
||||
)
|
||||
.await;
|
||||
|
||||
// 4. Close store pool to release file locks
|
||||
store.close().await;
|
||||
|
||||
// 5. Clean up existing active database and WAL, SHM, and journal files
|
||||
let wal_path = PathBuf::from(format!("{}-wal", active_db_path.display()));
|
||||
let shm_path = PathBuf::from(format!("{}-shm", active_db_path.display()));
|
||||
let journal_path = PathBuf::from(format!("{}-journal", active_db_path.display()));
|
||||
let _ = std::fs::remove_file(wal_path);
|
||||
let _ = std::fs::remove_file(shm_path);
|
||||
let _ = std::fs::remove_file(journal_path);
|
||||
let _ = std::fs::remove_file(active_db_path);
|
||||
|
||||
// 6. Copy backup file to active database location
|
||||
std::fs::copy(backup_file, active_db_path)
|
||||
.map_err(|e| ApiError::Internal(format!("Failed to restore database file: {e}")))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user