338 lines
11 KiB
Rust
338 lines
11 KiB
Rust
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<DoctorCheck>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct DoctorCheck {
|
|
pub code: String,
|
|
pub status: DoctorStatus,
|
|
pub message: String,
|
|
pub hint: Option<String>,
|
|
}
|
|
|
|
#[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<DoctorReport, NodeError> {
|
|
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<DoctorCheck>) {
|
|
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<DoctorCheck>) {
|
|
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<DoctorCheck>) {
|
|
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<DoctorCheck>) {
|
|
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<DoctorCheck>) {
|
|
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::<PeerCard>(&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<DoctorCheck>) {
|
|
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::<AuthOp>(&stored.op_json).ok())
|
|
.collect::<Vec<_>>(),
|
|
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::<Vec<_>>(),
|
|
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<String>, message: impl Into<String>) -> DoctorCheck {
|
|
DoctorCheck {
|
|
code: code.into(),
|
|
status: DoctorStatus::Ok,
|
|
message: message.into(),
|
|
hint: None,
|
|
}
|
|
}
|
|
|
|
fn warn(
|
|
code: impl Into<String>,
|
|
message: impl Into<String>,
|
|
hint: impl Into<String>,
|
|
) -> DoctorCheck {
|
|
DoctorCheck {
|
|
code: code.into(),
|
|
status: DoctorStatus::Warn,
|
|
message: message.into(),
|
|
hint: Some(hint.into()),
|
|
}
|
|
}
|
|
|
|
fn fail(
|
|
code: impl Into<String>,
|
|
message: impl Into<String>,
|
|
hint: impl Into<String>,
|
|
) -> 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)
|
|
}
|
|
}
|