feat: add local doctor checks

This commit is contained in:
Eric Wendland 2026-07-05 22:39:15 +02:00
commit 12e6815f8d
7 changed files with 409 additions and 7 deletions

View file

@ -0,0 +1,338 @@
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>) {
if !paths.socket_path().exists() {
checks.push(fail(
"daemon-not-running",
format!("daemon socket is absent: {}", paths.socket_path().display()),
"start the daemon with `geth daemon run` or the user service command for your platform",
));
return;
}
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) => checks.push(fail(
"daemon-stale-socket",
format!(
"daemon socket exists but control connection failed: {}",
error
),
"stop any stale daemon, remove the socket if no daemon is running, then start `geth daemon run`",
)),
}
}
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]
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-socket"));
}
fn has_code(report: &DoctorReport, code: &str) -> bool {
report.checks.iter().any(|check| check.code == code)
}
}