use crate::{NodeError, send_control}; use geth_auth::AuthOp; use geth_config::{GethConfig, GethPaths}; use geth_control::{ControlRequest, ControlResponse}; use geth_discovery::PeerCard; use geth_store::Store; use geth_types::PrincipalId; use serde::{Deserialize, Serialize}; #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct DoctorReport { pub ok: bool, pub checks: Vec, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct DoctorCheck { pub code: String, pub status: DoctorStatus, pub message: String, pub hint: Option, } #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum DoctorStatus { Ok, Warn, Fail, } pub async fn run_doctor(paths: &GethPaths) -> Result { let mut checks = Vec::new(); check_config(paths, &mut checks); check_daemon(paths, &mut checks).await; check_ssh_keygen(&mut checks); check_store_metadata(paths, &mut checks); let ok = checks .iter() .all(|check| check.status != DoctorStatus::Fail); Ok(DoctorReport { ok, checks }) } fn check_config(paths: &GethPaths, checks: &mut Vec) { match GethConfig::load(&paths.config_file()) { Ok(_) => checks.push(ok( "config-ok", format!("config is readable: {}", paths.config_file().display()), )), Err(error) => checks.push(fail( "config-invalid", format!("config is invalid: {error}"), "fix config.toml or move it aside and rerun `geth init` to regenerate defaults", )), } } async fn check_daemon(paths: &GethPaths, checks: &mut Vec) { match send_control(paths, ControlRequest::Status).await { Ok(ControlResponse::Status(status)) => checks.push(ok( "daemon-ok", format!( "daemon responded as {} with endpoint {}", status.node_id, status.endpoint_id.as_deref().unwrap_or("not-started") ), )), Ok(other) => checks.push(fail( "daemon-unexpected-response", format!("daemon returned unexpected response: {other:?}"), "restart the daemon and rerun `geth doctor`", )), Err(error) if crate::local_transport::endpoint_file_exists(paths) => checks.push(fail( "daemon-stale-control-endpoint", format!( "daemon control endpoint exists but the connection failed: {}", error ), "restart the user service or foreground daemon; on Unix, remove the stale socket only after confirming no daemon is running", )), Err(error) => checks.push(fail( "daemon-not-running", format!( "daemon control endpoint did not answer at {}: {error}", paths.control_endpoint() ), "start the daemon with `geth daemon run` or `geth daemon install`", )), } } fn check_ssh_keygen(checks: &mut Vec) { if std::process::Command::new("ssh-keygen") .arg("-?") .output() .is_ok() { checks.push(ok( "ssh-keygen-ok", "ssh-keygen is available for SSH signature and certificate workflows", )); } else { checks.push(fail( "ssh-keygen-missing", "ssh-keygen is not available on PATH", "install OpenSSH client tools before using admin signing, cert approval, or KRL export workflows", )); } } fn check_store_metadata(paths: &GethPaths, checks: &mut Vec) { if !paths.metadata_db().exists() { checks.push(fail( "store-missing", format!( "metadata store is absent: {}", paths.metadata_db().display() ), "run `geth init` before starting the daemon or creating resources", )); return; } let store = match Store::open(&paths.metadata_db()) { Ok(store) => store, Err(error) => { checks.push(fail( "store-open-failed", format!("metadata store could not be opened: {error}"), "restore from backup or inspect the SQLite file before continuing", )); return; } }; checks.push(ok( "store-ok", format!( "metadata store is readable: {}", paths.metadata_db().display() ), )); check_peer_cards(&store, checks); check_peer_grants(&store, checks); } fn check_peer_cards(store: &Store, checks: &mut Vec) { let peers = match store.list_peer_cards() { Ok(peers) => peers, Err(error) => { checks.push(fail( "peer-card-read-failed", format!("could not read peer cards: {error}"), "inspect the metadata store or restore from backup", )); return; } }; if peers.is_empty() { checks.push(warn( "peer-card-none", "no imported peer cards are available", "exchange peer cards with `geth peer export` and `geth peer import` before remote operations", )); return; } let mut invalid = Vec::new(); for peer in peers { match serde_json::from_str::(&peer.card_json) .map_err(NodeError::from) .and_then(|card| card.validate_candidate().map_err(NodeError::from)) { Ok(()) => {} Err(error) => invalid.push(format!("{}: {error}", peer.peer_id)), } } if invalid.is_empty() { checks.push(ok( "peer-cards-ok", "imported peer cards are valid candidates", )); } else { checks.push(warn( "peer-card-invalid", format!( "some imported peer cards are invalid: {}", invalid.join("; ") ), "re-import a fresh signed peer card from the affected peer", )); } } fn check_peer_grants(store: &Store, checks: &mut Vec) { let peers = match store.list_peer_cards() { Ok(peers) if !peers.is_empty() => peers, _ => return, }; let resources = match store.list_resources() { Ok(resources) if !resources.is_empty() => resources, _ => return, }; let auth_ops = match store.list_auth_ops() { Ok(ops) => ops .into_iter() .filter_map(|stored| serde_json::from_str::(&stored.op_json).ok()) .collect::>(), Err(error) => { checks.push(fail( "auth-op-read-failed", format!("could not read auth operations: {error}"), "inspect the metadata store or restore from backup", )); return; } }; let mut missing = Vec::new(); for peer in peers.iter().take(4) { for resource in resources.iter().take(6) { let Some(capability) = representative_capability(&resource.kind) else { continue; }; let explanation = geth_auth::explain_auth_ops( &auth_ops .iter() .filter(|op| op.resource.as_str() == resource.resource_id) .cloned() .collect::>(), PrincipalId::new(peer.peer_id.clone()), resource.resource_id.clone().into(), capability.into(), ); if !explanation.allowed { missing.push(format!( "{} lacks {} on {}", peer.peer_id, capability, resource.resource_id )); } } } if missing.is_empty() { checks.push(ok( "peer-grants-ok", "imported peers have representative grants for known resources", )); } else { checks.push(warn( "peer-grants-missing", format!("missing representative grants: {}", missing.join("; ")), "use `geth node grant` or `geth auth grant` for the resource/capability needed by the peer", )); } } fn representative_capability(kind: &str) -> Option<&'static str> { match kind { "cas" => Some("cas.fetch"), "kv" => Some("kv.sync"), "db" => Some("db.sync"), "document" => Some("document.sync"), "pubsub" => Some("pubsub.subscribe"), "pipe" => Some("pipe.connect"), "ssh-proxy" => Some("ssh_proxy.connect"), "overlay" => Some("overlay.route"), _ => None, } } fn ok(code: impl Into, message: impl Into) -> DoctorCheck { DoctorCheck { code: code.into(), status: DoctorStatus::Ok, message: message.into(), hint: None, } } fn warn( code: impl Into, message: impl Into, hint: impl Into, ) -> DoctorCheck { DoctorCheck { code: code.into(), status: DoctorStatus::Warn, message: message.into(), hint: Some(hint.into()), } } fn fail( code: impl Into, message: impl Into, hint: impl Into, ) -> DoctorCheck { DoctorCheck { code: code.into(), status: DoctorStatus::Fail, message: message.into(), hint: Some(hint.into()), } } #[cfg(test)] mod tests { use super::*; #[tokio::test] async fn doctor_reports_missing_daemon_and_store_before_init() { let home = tempfile::tempdir().expect("home"); let paths = GethPaths::from_home(home.path()); let report = run_doctor(&paths).await.expect("doctor"); assert!(!report.ok); assert!(has_code(&report, "daemon-not-running")); assert!(has_code(&report, "store-missing")); } #[tokio::test] #[cfg(unix)] async fn doctor_reports_bad_config_and_stale_socket() { let home = tempfile::tempdir().expect("home"); let paths = GethPaths::from_home(home.path()); paths.ensure_base_dirs().expect("dirs"); std::fs::write(paths.config_file(), "[iroh]\nrelay_mode = 1").expect("bad config"); std::fs::write(paths.socket_path(), "stale").expect("socket placeholder"); let report = run_doctor(&paths).await.expect("doctor"); assert!(!report.ok); assert!(has_code(&report, "config-invalid")); assert!(has_code(&report, "daemon-stale-control-endpoint")); } fn has_code(report: &DoctorReport, code: &str) -> bool { report.checks.iter().any(|check| check.code == code) } }