feat: add local doctor checks
This commit is contained in:
parent
619e8eee62
commit
12e6815f8d
7 changed files with 409 additions and 7 deletions
338
crates/geth-node/src/doctor.rs
Normal file
338
crates/geth-node/src/doctor.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue