Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions keep-cli/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,8 @@ pub(crate) enum WalletCommands {
required = true
)]
recovery: Vec<String>,
#[arg(long, help = "Session timeout in seconds (max 86400)")]
timeout: Option<u64>,
},
}

Expand Down Expand Up @@ -374,6 +376,16 @@ pub(crate) enum FrostNetworkCommands {
)]
count: u32,
},
HealthCheck {
#[arg(short, long)]
group: String,
#[arg(short, long)]
relay: Option<String>,
#[arg(short, long)]
share: Option<u16>,
#[arg(long, default_value = "10", help = "Timeout in seconds")]
timeout: u64,
},
}

#[derive(Subcommand)]
Expand Down
192 changes: 184 additions & 8 deletions keep-cli/src/commands/frost_network/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,8 @@ pub fn cmd_frost_network_serve(
.map_err(|e| KeepError::Frost(e.to_string()))?,
);

let npub = node.pubkey().to_bech32().unwrap_or_default();
let pk = node.pubkey();
let npub = pk.to_bech32().unwrap_or_else(|_| format!("{pk}"));
out.field("Node pubkey", &npub);
out.newline();
out.info("Listening for FROST messages... (Ctrl+C to stop)");
Expand Down Expand Up @@ -123,8 +124,12 @@ pub fn cmd_frost_network_serve(
move || node.derive_account_xpub(&net)
})
.await;
match derived {
Ok(Ok((xpub, fingerprint))) => {
let xpub_result = match derived {
Ok(inner) => inner,
Err(e) => Err(keep_frost_net::FrostNetError::Crypto(e.to_string())),
};
match xpub_result {
Ok((xpub, fingerprint)) => {
if let Err(e) = contribute_node
.contribute_descriptor(
session_id,
Expand All @@ -137,9 +142,6 @@ pub fn cmd_frost_network_serve(
tracing::error!(session, error = %e, "failed to contribute descriptor");
}
}
Ok(Err(e)) => {
tracing::error!(session, error = %e, "failed to derive xpub for contribution");
}
Err(e) => {
tracing::error!(session, error = %e, "failed to derive xpub for contribution");
}
Expand Down Expand Up @@ -172,7 +174,7 @@ pub fn cmd_frost_network_serve(
network,
created_at: now,
};
let guard = keep.lock().unwrap_or_else(|e| e.into_inner());
let guard = keep.lock().expect("keep mutex poisoned");
match guard.store_wallet_descriptor(&descriptor) {
Ok(()) => {
tracing::info!("wallet descriptor stored");
Expand Down Expand Up @@ -405,9 +407,10 @@ pub fn cmd_frost_network_sign(
.map_err(|e| KeepError::Frost(e.to_string()))?;

out.info("Starting FROST coordination node...");
let pk = node.pubkey();
out.field(
"Node pubkey",
&node.pubkey().to_bech32().unwrap_or_default(),
&pk.to_bech32().unwrap_or_else(|_| format!("{pk}")),
);
out.newline();

Expand Down Expand Up @@ -473,3 +476,176 @@ pub fn cmd_frost_network_sign_event(
"FROST network event signing".into(),
))
}

#[tracing::instrument(skip(out), fields(path = %path.display()))]
pub fn cmd_frost_network_health_check(
out: &Output,
path: &Path,
group: &str,
relay: &str,
share_index: Option<u16>,
timeout: u64,
) -> Result<()> {
const MAX_HEALTH_CHECK_TIMEOUT_SECS: u64 = 3600;
if timeout == 0 || timeout > MAX_HEALTH_CHECK_TIMEOUT_SECS {
return Err(KeepError::InvalidInput(format!(
"timeout must be between 1 and {MAX_HEALTH_CHECK_TIMEOUT_SECS} seconds"
)));
}
debug!(group, relay, share = ?share_index, timeout, "health check");

let mut keep = Keep::open(path)?;
let password = get_password("Enter password")?;

let spinner = out.spinner("Unlocking vault...");
keep.unlock(password.expose_secret())?;
spinner.finish();

let group_pubkey = keep_core::keys::npub_to_bytes(group)?;

let share = match share_index {
Some(idx) => keep.frost_get_share_by_index(&group_pubkey, idx)?,
None => keep.frost_get_share(&group_pubkey)?,
};

out.newline();
out.header("Key Health Check");
out.field("Group", group);
out.field("Relay", relay);
out.field("Timeout", &format!("{timeout}s"));
out.newline();

let rt =
tokio::runtime::Runtime::new().map_err(|e| KeepError::Runtime(format!("tokio: {e}")))?;

rt.block_on(async {
let node = std::sync::Arc::new(
keep_frost_net::KfpNode::new(share, vec![relay.to_string()])
.await
.map_err(|e| KeepError::Frost(e.to_string()))?,
);

node.announce()
.await
.map_err(|e| KeepError::Frost(e.to_string()))?;

let node_handle = tokio::spawn({
let node = node.clone();
async move {
if let Err(e) = node.run().await {
tracing::error!(error = %e, "FROST node error");
}
}
});

let spinner = out.spinner("Discovering peers...");
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
spinner.finish();

let online = node.online_peers();
out.info(&format!("{online} peer(s) discovered"));

if online == 0 {
node_handle.abort();
out.newline();
out.warn("No peers discovered. Run 'keep frost network serve' on other devices first.");
return Ok::<_, KeepError>(());
}

let spinner = out.spinner(&format!("Pinging peers (timeout: {timeout}s)..."));
let result = node
.health_check(std::time::Duration::from_secs(timeout))
.await
.map_err(|e| KeepError::Frost(e.to_string()))?;
spinner.finish();
node_handle.abort();

out.newline();
out.header("Results");

if !result.responsive.is_empty() {
let shares: Vec<String> = result.responsive.iter().map(|s| s.to_string()).collect();
out.field("Responsive", &shares.join(", "));
}
if !result.unresponsive.is_empty() {
let shares: Vec<String> = result.unresponsive.iter().map(|s| s.to_string()).collect();
out.field("Unresponsive", &shares.join(", "));
}

let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();

for (&idx, responsive) in result
.responsive
.iter()
.map(|i| (i, true))
.chain(result.unresponsive.iter().map(|i| (i, false)))
{
let existing = keep.get_health_status(&group_pubkey, idx)?;
let created_at = existing.and_then(|s| s.created_at).unwrap_or(now);
let status = keep_core::wallet::KeyHealthStatus {
group_pubkey,
share_index: idx,
last_check_timestamp: now,
responsive,
created_at: Some(created_at),
};
keep.store_health_status(&status)?;
}

out.newline();
out.success(&format!(
"{} responsive, {} unresponsive",
result.responsive.len(),
result.unresponsive.len()
));

let all_statuses = keep.list_health_statuses()?;
let group_statuses: Vec<_> = all_statuses
.iter()
.filter(|s| s.group_pubkey == group_pubkey)
.collect();
if !group_statuses.is_empty() {
out.newline();
out.header("Health History");
for s in &group_statuses {
let age = now.saturating_sub(s.last_check_timestamp);
let status_str = if s.responsive {
"responsive"
} else {
"unresponsive"
};
let staleness = if s.is_critical(now) {
" [CRITICAL]"
} else if s.is_stale(now) {
" [STALE]"
} else {
""
};
let age_display = format_duration_ago(age);
out.field(
&format!("Share {}", s.share_index),
&format!("{status_str} ({age_display}){staleness}"),
);
}
}

Ok::<_, KeepError>(())
})?;

Ok(())
}

fn format_duration_ago(secs: u64) -> String {
if secs < 60 {
format!("{secs}s ago")
} else if secs < 3600 {
format!("{}m ago", secs / 60)
} else if secs < 86400 {
format!("{}h ago", secs / 3600)
} else {
format!("{}d ago", secs / 86400)
}
}
24 changes: 19 additions & 5 deletions keep-cli/src/commands/wallet.rs
Original file line number Diff line number Diff line change
Expand Up @@ -417,8 +417,18 @@ pub fn cmd_wallet_propose(
relay: &str,
share_index: Option<u16>,
recovery: &[String],
timeout_secs: Option<u64>,
) -> Result<()> {
debug!(group, network, relay, share = ?share_index, "wallet propose");
debug!(group, network, relay, share = ?share_index, timeout = ?timeout_secs, "wallet propose");

if let Some(t) = timeout_secs {
if t == 0 || t > keep_frost_net::DESCRIPTOR_SESSION_MAX_TIMEOUT_SECS {
return Err(KeepError::InvalidInput(format!(
"timeout must be between 1 and {} seconds",
keep_frost_net::DESCRIPTOR_SESSION_MAX_TIMEOUT_SECS
)));
}
}

if !keep_frost_net::VALID_NETWORKS.contains(&network) {
return Err(KeepError::InvalidInput(format!(
Expand Down Expand Up @@ -513,7 +523,7 @@ pub fn cmd_wallet_propose(

let spinner = out.spinner("Sending descriptor proposal...");
let session_id = node
.request_descriptor(policy, network, &xpub, &fingerprint)
.request_descriptor_with_timeout(policy, network, &xpub, &fingerprint, timeout_secs)
.await
.map_err(|e| KeepError::Frost(e.to_string()))?;
spinner.finish();
Expand All @@ -537,7 +547,8 @@ pub fn cmd_wallet_propose(
let spinner = out.spinner(&format!(
"Waiting for contributions (0/{remaining_contributions})..."
));
let timeout = Duration::from_secs(keep_frost_net::DESCRIPTOR_SESSION_TIMEOUT_SECS);
let effective_timeout = timeout_secs.unwrap_or(keep_frost_net::DESCRIPTOR_SESSION_TIMEOUT_SECS);
let timeout = Duration::from_secs(effective_timeout);
let deadline = tokio::time::Instant::now() + timeout;

let mut received = 0usize;
Expand Down Expand Up @@ -604,8 +615,11 @@ pub fn cmd_wallet_propose(
spinner.finish();

let spinner = out.spinner("Waiting for ACKs...");
let ack_deadline =
tokio::time::Instant::now() + Duration::from_secs(keep_frost_net::DESCRIPTOR_ACK_TIMEOUT_SECS);
let default_ack = keep_frost_net::DESCRIPTOR_ACK_TIMEOUT_SECS;
let ack_timeout = timeout_secs
.map(|t| t.clamp(default_ack, default_ack * 4))
.unwrap_or(default_ack);
let ack_deadline = tokio::time::Instant::now() + Duration::from_secs(ack_timeout);

let mut external_descriptor = String::new();
let mut internal_descriptor = String::new();
Expand Down
14 changes: 13 additions & 1 deletion keep-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,17 @@ fn dispatch_frost_network(
out, path, &group, relay, &hardware, count,
)
}
FrostNetworkCommands::HealthCheck {
group,
relay,
share,
timeout,
} => {
let relay = relay.as_deref().unwrap_or(default_relay);
commands::frost_network::cmd_frost_network_health_check(
out, path, &group, relay, share, timeout,
)
}
}
}

Expand Down Expand Up @@ -447,10 +458,11 @@ fn dispatch_wallet(
relay,
share,
recovery,
timeout,
} => {
let relay = relay.as_deref().unwrap_or_else(|| cfg.default_relay());
commands::wallet::cmd_wallet_propose(
out, path, &group, &network, relay, share, &recovery,
out, path, &group, &network, relay, share, &recovery, timeout,
)
}
}
Expand Down
5 changes: 5 additions & 0 deletions keep-core/src/backend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ pub const DESCRIPTORS_TABLE: &str = "wallet_descriptors";
pub const RELAY_CONFIGS_TABLE: &str = "relay_configs";
/// Table name for application configuration.
pub const CONFIG_TABLE: &str = "config";
/// Table name for key health status records.
pub const HEALTH_STATUS_TABLE: &str = "key_health_status";

/// Trait for pluggable storage backends.
///
Expand Down Expand Up @@ -83,6 +85,8 @@ const DESCRIPTORS_TABLE_DEF: TableDefinition<&[u8], &[u8]> =
const RELAY_CONFIGS_TABLE_DEF: TableDefinition<&[u8], &[u8]> =
TableDefinition::new("relay_configs");
const CONFIG_TABLE_DEF: TableDefinition<&[u8], &[u8]> = TableDefinition::new("config");
const HEALTH_STATUS_TABLE_DEF: TableDefinition<&[u8], &[u8]> =
TableDefinition::new("key_health_status");

/// Redb-based storage backend (default).
pub struct RedbBackend {
Expand Down Expand Up @@ -148,6 +152,7 @@ impl RedbBackend {
DESCRIPTORS_TABLE => Ok(DESCRIPTORS_TABLE_DEF),
RELAY_CONFIGS_TABLE => Ok(RELAY_CONFIGS_TABLE_DEF),
CONFIG_TABLE => Ok(CONFIG_TABLE_DEF),
HEALTH_STATUS_TABLE => Ok(HEALTH_STATUS_TABLE_DEF),
_ => Err(StorageError::database(format!("unknown table: {name}")).into()),
}
}
Expand Down
Loading