diff --git a/README.md b/README.md index 0b5157c..6f90cef 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,8 @@ The bootstrap implementation provides: - `geth daemon service install|uninstall|start|stop|status|print` - `geth status` - `geth wait daemon|peer|sync --timeout-ms ` +- `geth backup create --out ` +- `geth backup restore --target-home ` - `geth node id` - `geth node list` - `geth node enroll request --node-name --capability [--out ]` @@ -417,6 +419,21 @@ cargo run -p geth -- cas add /tmp/hello-geth.txt cargo run -p geth -- cas list ``` +## Backup And Restore + +`geth backup create --out ` creates an offline directory backup with a +`manifest.json` plus a `home/` payload. The backup includes `config.toml`, +`geth.sqlite` and SQLite WAL sidecars when present, and local CAS blobs. It +records public geth identity material in the manifest when available. + +The backup intentionally excludes daemon runtime files, private geth identity +keys under `identity/*.ed25519`, and private SSH admin keys. SSH admin keys are +external trust anchors; geth stores public admin material and signatures, not +the private SSH keys. + +`geth backup restore --target-home ` restores into a separate +empty target home for validation. It refuses to overwrite a non-empty target. + ## Owner And Node Management The intended owner setup is SSH-admin-rooted: diff --git a/crates/geth-cli/src/lib.rs b/crates/geth-cli/src/lib.rs index ed78b3a..79eeaf2 100644 --- a/crates/geth-cli/src/lib.rs +++ b/crates/geth-cli/src/lib.rs @@ -368,6 +368,10 @@ pub enum Command { #[command(subcommand)] command: WaitCommand, }, + Backup { + #[command(subcommand)] + command: BackupCommand, + }, Node { #[command(subcommand)] command: NodeCommand, @@ -511,6 +515,19 @@ pub enum WaitCommand { }, } +#[derive(Debug, Subcommand)] +pub enum BackupCommand { + Create { + #[arg(long, value_name = "DIR")] + out: PathBuf, + }, + Restore { + backup_dir: PathBuf, + #[arg(long, value_name = "DIR")] + target_home: PathBuf, + }, +} + #[derive(Debug, Subcommand)] pub enum NodeCommand { Id, @@ -1320,6 +1337,10 @@ async fn run_inner(cli: Cli) -> Result<()> { std::process::exit(1); } } + Command::Backup { command } => { + run_backup_command(&paths, command, cli.json || cli.jsonl) + .context("run backup command")?; + } Command::Ssh { command: SshCommand::Proxy { @@ -2219,6 +2240,7 @@ fn request_for_command(command: Command) -> Result { Command::Guide { .. } | Command::Init { .. } | Command::Daemon { .. } + | Command::Backup { .. } | Command::Wait { .. } => { bail!("command is handled directly") } @@ -2234,6 +2256,66 @@ struct WaitReport { reason: String, } +fn run_backup_command(paths: &GethPaths, command: BackupCommand, json: bool) -> Result<()> { + match command { + BackupCommand::Create { out } => { + let report = geth_node::backup::create_backup(paths, &out)?; + if json { + println!( + "{}", + serde_json::to_string_pretty(&serde_json::json!({ + "type": "backup-created", + "backup_dir": report.backup_dir, + "files_copied": report.files_copied, + "bytes_copied": report.bytes_copied, + "manifest": report.manifest, + }))? + ); + } else { + println!("backup: {}", report.backup_dir.display()); + println!("files_copied: {}", report.files_copied); + println!("bytes_copied: {}", report.bytes_copied); + println!( + "manifest: {}", + report.backup_dir.join("manifest.json").display() + ); + println!( + "note: private geth identity keys and private SSH admin keys are not copied" + ); + } + } + BackupCommand::Restore { + backup_dir, + target_home, + } => { + let report = geth_node::backup::restore_backup(&backup_dir, &target_home)?; + if json { + println!( + "{}", + serde_json::to_string_pretty(&serde_json::json!({ + "type": "backup-restored", + "backup_dir": report.backup_dir, + "target_home": report.target_home, + "files_restored": report.files_restored, + "bytes_restored": report.bytes_restored, + "manifest": report.manifest, + }))? + ); + } else { + println!("restored: {}", report.target_home.display()); + println!("backup: {}", report.backup_dir.display()); + println!("files_restored: {}", report.files_restored); + println!("bytes_restored: {}", report.bytes_restored); + println!( + "note: validate with GETH_HOME={} geth status after starting a daemon for the restored home", + report.target_home.display() + ); + } + } + } + Ok(()) +} + async fn run_wait_command(paths: &GethPaths, command: WaitCommand) -> Result { match command { WaitCommand::Daemon { diff --git a/crates/geth-node/src/backup.rs b/crates/geth-node/src/backup.rs new file mode 100644 index 0000000..4f9b87e --- /dev/null +++ b/crates/geth-node/src/backup.rs @@ -0,0 +1,331 @@ +use crate::NodeError; +use geth_config::GethPaths; +use geth_crypto::AgentKey; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; + +const BACKUP_FORMAT: &str = "geth.backup.v1"; +const MANIFEST_FILE: &str = "manifest.json"; +const HOME_DIR: &str = "home"; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct BackupManifest { + pub format: String, + pub created_at_ms: i64, + pub source_home: PathBuf, + pub includes: Vec, + pub excludes: Vec, + pub identity_public: BackupIdentityPublic, + pub notes: Vec, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct BackupIdentityPublic { + pub agent_id: Option, + pub agent_public_key: Option, + pub iroh_endpoint_id: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct BackupCreateReport { + pub backup_dir: PathBuf, + pub manifest: BackupManifest, + pub files_copied: usize, + pub bytes_copied: u64, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct BackupRestoreReport { + pub backup_dir: PathBuf, + pub target_home: PathBuf, + pub manifest: BackupManifest, + pub files_restored: usize, + pub bytes_restored: u64, +} + +pub fn create_backup( + paths: &GethPaths, + backup_dir: &Path, +) -> Result { + if backup_dir.starts_with(paths.home()) { + return Err(NodeError::Backup( + "backup output must be outside GETH_HOME to avoid recursive self-inclusion".to_owned(), + )); + } + ensure_new_or_empty_dir(backup_dir)?; + + let backup_home = backup_dir.join(HOME_DIR); + std::fs::create_dir_all(&backup_home)?; + let mut copied = CopyStats::default(); + let mut includes = Vec::new(); + let mut excludes = Vec::new(); + + copy_if_file( + paths.config_file(), + &backup_home.join("config.toml"), + &mut copied, + &mut includes, + )?; + copy_if_file( + paths.metadata_db(), + &backup_home.join("geth.sqlite"), + &mut copied, + &mut includes, + )?; + copy_if_file( + paths.home().join("geth.sqlite-wal"), + &backup_home.join("geth.sqlite-wal"), + &mut copied, + &mut includes, + )?; + copy_if_file( + paths.home().join("geth.sqlite-shm"), + &backup_home.join("geth.sqlite-shm"), + &mut copied, + &mut includes, + )?; + copy_dir_filtered( + paths.cas_dir(), + &backup_home.join("cas"), + &mut copied, + &mut includes, + &mut excludes, + )?; + + if paths.identity_dir().exists() { + excludes.push("identity/*.ed25519 private geth node keys".to_owned()); + } + excludes.push("run/* daemon sockets and process-local files".to_owned()); + excludes.push( + "private SSH admin keys; geth records only configured public admin trust anchors" + .to_owned(), + ); + + let manifest = BackupManifest { + format: BACKUP_FORMAT.to_owned(), + created_at_ms: geth_store::now_ms(), + source_home: paths.home().to_path_buf(), + includes, + excludes, + identity_public: identity_public(paths), + notes: vec![ + "Offline backup format for pre-deployment validation.".to_owned(), + "Restore writes to a separate target home and does not restore private geth identity keys.".to_owned(), + ], + }; + std::fs::write( + backup_dir.join(MANIFEST_FILE), + serde_json::to_vec_pretty(&manifest)?, + )?; + + Ok(BackupCreateReport { + backup_dir: backup_dir.to_path_buf(), + manifest, + files_copied: copied.files, + bytes_copied: copied.bytes, + }) +} + +pub fn restore_backup( + backup_dir: &Path, + target_home: &Path, +) -> Result { + let manifest = read_manifest(backup_dir)?; + if manifest.format != BACKUP_FORMAT { + return Err(NodeError::Backup(format!( + "unsupported backup format `{}`", + manifest.format + ))); + } + if target_home.exists() && target_home.read_dir()?.next().is_some() { + return Err(NodeError::Backup(format!( + "restore target `{}` must be empty or not exist", + target_home.display() + ))); + } + std::fs::create_dir_all(target_home)?; + + let mut copied = CopyStats::default(); + copy_dir_all(backup_dir.join(HOME_DIR), target_home, &mut copied)?; + Ok(BackupRestoreReport { + backup_dir: backup_dir.to_path_buf(), + target_home: target_home.to_path_buf(), + manifest, + files_restored: copied.files, + bytes_restored: copied.bytes, + }) +} + +pub fn read_manifest(backup_dir: &Path) -> Result { + let manifest = std::fs::read(backup_dir.join(MANIFEST_FILE))?; + Ok(serde_json::from_slice(&manifest)?) +} + +#[derive(Default)] +struct CopyStats { + files: usize, + bytes: u64, +} + +fn ensure_new_or_empty_dir(path: &Path) -> Result<(), NodeError> { + if path.exists() && path.read_dir()?.next().is_some() { + return Err(NodeError::Backup(format!( + "backup output `{}` must be empty or not exist", + path.display() + ))); + } + std::fs::create_dir_all(path)?; + Ok(()) +} + +fn copy_if_file( + source: PathBuf, + destination: &Path, + copied: &mut CopyStats, + includes: &mut Vec, +) -> Result<(), NodeError> { + if !source.is_file() { + return Ok(()); + } + if let Some(parent) = destination.parent() { + std::fs::create_dir_all(parent)?; + } + let bytes = std::fs::copy(&source, destination)?; + copied.files += 1; + copied.bytes += bytes; + includes.push(destination.display().to_string()); + Ok(()) +} + +fn copy_dir_filtered( + source: PathBuf, + destination: &Path, + copied: &mut CopyStats, + includes: &mut Vec, + excludes: &mut Vec, +) -> Result<(), NodeError> { + if !source.exists() { + return Ok(()); + } + for entry in std::fs::read_dir(&source)? { + let entry = entry?; + let path = entry.path(); + let target = destination.join(entry.file_name()); + if path.is_dir() { + copy_dir_filtered(path, &target, copied, includes, excludes)?; + } else if path.is_file() { + let bytes = copy_regular_file(&path, &target)?; + copied.files += 1; + copied.bytes += bytes; + includes.push(target.display().to_string()); + } else { + excludes.push(format!("non-regular path {}", path.display())); + } + } + Ok(()) +} + +fn copy_dir_all( + source: PathBuf, + destination: &Path, + copied: &mut CopyStats, +) -> Result<(), NodeError> { + if !source.exists() { + return Ok(()); + } + for entry in std::fs::read_dir(source)? { + let entry = entry?; + let path = entry.path(); + let target = destination.join(entry.file_name()); + if path.is_dir() { + std::fs::create_dir_all(&target)?; + copy_dir_all(path, &target, copied)?; + } else if path.is_file() { + let bytes = copy_regular_file(&path, &target)?; + copied.files += 1; + copied.bytes += bytes; + } + } + Ok(()) +} + +fn copy_regular_file(source: &Path, destination: &Path) -> Result { + if let Some(parent) = destination.parent() { + std::fs::create_dir_all(parent)?; + } + Ok(std::fs::copy(source, destination)?) +} + +fn identity_public(paths: &GethPaths) -> BackupIdentityPublic { + let mut identity = BackupIdentityPublic::default(); + if paths.agent_key().is_file() + && let Ok(agent_key) = AgentKey::load(&paths.agent_key()) + { + identity.agent_id = Some(agent_key.agent_id().to_string()); + identity.agent_public_key = Some(agent_key.public_key_hex()); + } + if paths.iroh_key().is_file() + && let Ok(secret_key) = geth_iroh::load_secret_key(&paths.iroh_key()) + { + identity.iroh_endpoint_id = Some(secret_key.public().to_string()); + } + identity +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn backup_create_and_restore_exclude_private_identity_keys() { + let home = tempfile::tempdir().expect("home"); + let backup = tempfile::tempdir().expect("backup"); + let restore = tempfile::tempdir().expect("restore"); + let restore_home = restore.path().join("restored-home"); + let paths = GethPaths::from_home(home.path()); + paths.ensure_base_dirs().expect("dirs"); + std::fs::write(paths.config_file(), "config").expect("config"); + std::fs::write(paths.metadata_db(), "db").expect("db"); + std::fs::write(paths.agent_key(), [1_u8; 32]).expect("agent key"); + std::fs::write(paths.iroh_key(), "00").expect("bad iroh key"); + std::fs::write(paths.cas_dir().join("blobs/blob"), "blob").expect("blob"); + + let report = create_backup(&paths, backup.path()).expect("backup"); + assert_eq!(report.files_copied, 3); + assert!(backup.path().join("manifest.json").exists()); + assert!(backup.path().join("home/config.toml").exists()); + assert!(backup.path().join("home/geth.sqlite").exists()); + assert!(backup.path().join("home/cas/blobs/blob").exists()); + assert!(!backup.path().join("home/identity/agent.ed25519").exists()); + assert!(report.manifest.identity_public.agent_id.is_some()); + assert!( + report + .manifest + .excludes + .iter() + .any(|entry| entry.contains("private SSH admin keys")) + ); + + let restore_report = restore_backup(backup.path(), &restore_home).expect("restore"); + assert_eq!(restore_report.files_restored, 3); + assert!(restore_home.join("config.toml").exists()); + assert!(restore_home.join("geth.sqlite").exists()); + assert!(restore_home.join("cas/blobs/blob").exists()); + assert!(!restore_home.join("identity/agent.ed25519").exists()); + } + + #[test] + fn restore_requires_empty_target_home() { + let home = tempfile::tempdir().expect("home"); + let backup = tempfile::tempdir().expect("backup"); + let target = tempfile::tempdir().expect("target"); + let paths = GethPaths::from_home(home.path()); + paths.ensure_base_dirs().expect("dirs"); + std::fs::write(paths.config_file(), "config").expect("config"); + create_backup(&paths, backup.path()).expect("backup"); + std::fs::write(target.path().join("existing"), "data").expect("existing"); + + let error = restore_backup(backup.path(), target.path()).expect_err("reject target"); + assert!(error.to_string().contains("must be empty")); + } +} diff --git a/crates/geth-node/src/lib.rs b/crates/geth-node/src/lib.rs index 1b8884c..98049f6 100644 --- a/crates/geth-node/src/lib.rs +++ b/crates/geth-node/src/lib.rs @@ -1,3 +1,4 @@ +pub mod backup; mod daemon; mod peer_client; mod runtime; @@ -105,6 +106,8 @@ pub enum NodeError { Json(#[from] serde_json::Error), #[error("io error: {0}")] Io(#[from] std::io::Error), + #[error("backup error: {0}")] + Backup(String), #[error("invalid resource kind: {0}")] InvalidResourceKind(String), #[error("owner init requires --admin-key so the owner trust anchor is recorded")] diff --git a/crates/geth/tests/bootstrap.rs b/crates/geth/tests/bootstrap.rs index bdcb953..3b93e65 100644 --- a/crates/geth/tests/bootstrap.rs +++ b/crates/geth/tests/bootstrap.rs @@ -149,6 +149,64 @@ fn geth_init_in_temp_home() { ); } +#[test] +fn backup_create_and_restore_use_separate_home_without_private_identity_keys() { + let home = tempfile::tempdir().expect("tempdir"); + let backup = tempfile::tempdir().expect("backup"); + let restore = tempfile::tempdir().expect("restore"); + let restore_home = restore.path().join("restored-home"); + + let init = run_geth(home.path(), &["init"]); + assert!( + init.status.success(), + "stderr: {}", + String::from_utf8_lossy(&init.stderr) + ); + let create = run_geth( + home.path(), + &[ + "--json", + "backup", + "create", + "--out", + backup.path().to_str().expect("backup path"), + ], + ); + let create_json = command_json(&create); + assert_eq!(create_json["type"], "backup-created"); + assert!(backup.path().join("manifest.json").exists()); + assert!(backup.path().join("home/config.toml").exists()); + assert!(backup.path().join("home/geth.sqlite").exists()); + assert!(!backup.path().join("home/identity/agent.ed25519").exists()); + assert!( + create_json["manifest"]["excludes"] + .as_array() + .expect("excludes") + .iter() + .any(|entry| entry + .as_str() + .expect("exclude") + .contains("private SSH admin keys")) + ); + + let restore_output = run_geth( + home.path(), + &[ + "--json", + "backup", + "restore", + backup.path().to_str().expect("backup path"), + "--target-home", + restore_home.to_str().expect("restore path"), + ], + ); + let restore_json = command_json(&restore_output); + assert_eq!(restore_json["type"], "backup-restored"); + assert!(restore_home.join("config.toml").exists()); + assert!(restore_home.join("geth.sqlite").exists()); + assert!(!restore_home.join("identity/agent.ed25519").exists()); +} + #[test] fn cli_help_documents_owner_init_keys() { let home = tempfile::tempdir().expect("tempdir"); diff --git a/docs/command-stability.md b/docs/command-stability.md index 03c9b65..3126614 100644 --- a/docs/command-stability.md +++ b/docs/command-stability.md @@ -24,6 +24,7 @@ The following command families are intended to be stable automation surfaces: - `geth daemon service install|uninstall|start|stop|status|print` - `geth status` - `geth node id` +- `geth backup create|restore` - `geth node list|rename|revoke|grant|revoke-grant|endpoint-add|endpoint-revoke` - `geth node enroll request|submit|import|list|approve|sync` - `geth peer export|import|list|ping|auth-check` diff --git a/docs/production-readiness-roadmap.md b/docs/production-readiness-roadmap.md index 19ebeec..1443c0c 100644 --- a/docs/production-readiness-roadmap.md +++ b/docs/production-readiness-roadmap.md @@ -134,13 +134,13 @@ it. error. - `[x]` Tests cover failure injection for at least one multi-table path. -- `[ ]` Add backup and restore workflow. +- `[x]` Add backup and restore workflow. Acceptance criteria: - - `[ ]` `geth backup create` or an equivalent documented command captures + - `[x]` `geth backup create` or an equivalent documented command captures metadata, identity public material, config, and CAS metadata expectations. - - `[ ]` Restore can write to a separate target home for validation. - - `[ ]` Backup output avoids copying private SSH admin keys. - - `[ ]` Docs explain what is and is not included. + - `[x]` Restore can write to a separate target home for validation. + - `[x]` Backup output avoids copying private SSH admin keys. + - `[x]` Docs explain what is and is not included. - `[x]` Document database durability settings. Acceptance criteria: @@ -339,7 +339,7 @@ Goal: prove the system works as an actual base layer before broader use. 1. `[x]` Finish Phase 0. 2. `[~]` Refactor `geth-node` into daemon subsystems. 3. `[x]` Add stable contract and golden JSON tests. -4. `[ ]` Harden store migrations and backup. +4. `[x]` Harden store migrations and backup. 5. `[ ]` Complete security-boundary test coverage. 6. `[ ]` Replace prototype private CAS cryptography. 7. `[ ]` Add fault-injection sync tests.