diff --git a/core/Cargo.lock b/core/Cargo.lock index f2323973..694de8f8 100644 --- a/core/Cargo.lock +++ b/core/Cargo.lock @@ -80,7 +80,7 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" [[package]] name = "archipelago" -version = "1.4.0" +version = "1.5.0" dependencies = [ "anyhow", "archipelago-container", diff --git a/core/archipelago/Cargo.toml b/core/archipelago/Cargo.toml index bc38b694..ef7faef5 100644 --- a/core/archipelago/Cargo.toml +++ b/core/archipelago/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "archipelago" -version = "1.4.0" +version = "1.5.0" edition = "2021" description = "Archipelago Bitcoin Node OS - Native backend" authors = ["Archipelago Team"] diff --git a/core/archipelago/src/api/rpc/federation/handlers.rs b/core/archipelago/src/api/rpc/federation/handlers.rs index d7a0c751..27cd89d8 100644 --- a/core/archipelago/src/api/rpc/federation/handlers.rs +++ b/core/archipelago/src/api/rpc/federation/handlers.rs @@ -485,6 +485,8 @@ impl RpcHandler { last_seen: None, last_state: None, fips_npub, + last_transport: None, + last_transport_at: None, }; federation::add_node(&self.config.data_dir, node).await?; diff --git a/core/archipelago/src/federation/invites.rs b/core/archipelago/src/federation/invites.rs index 1d96206f..692c1f90 100644 --- a/core/archipelago/src/federation/invites.rs +++ b/core/archipelago/src/federation/invites.rs @@ -163,6 +163,8 @@ pub async fn accept_invite( last_seen: None, last_state: None, fips_npub: fips_npub.clone(), + last_transport: None, + last_transport_at: None, }; add_node(data_dir, node.clone()).await?; diff --git a/core/archipelago/src/federation/mod.rs b/core/archipelago/src/federation/mod.rs index dae72565..278ab66e 100644 --- a/core/archipelago/src/federation/mod.rs +++ b/core/archipelago/src/federation/mod.rs @@ -12,9 +12,10 @@ mod types; // Re-export all public items so `crate::federation::*` continues to work. pub use invites::{accept_invite, create_invite}; +#[allow(unused_imports)] pub use storage::{ - add_node, fips_npub_for_onion, load_nodes, remove_node, save_nodes, set_trust_level, - update_node, + add_node, fips_npub_for_onion, load_nodes, record_peer_transport, remove_node, save_nodes, + set_trust_level, update_node, }; pub use sync::{build_local_state, deploy_to_peer, sync_with_peer}; pub use types::{AppStatus, FederatedNode, NodeStateSnapshot, TrustLevel}; diff --git a/core/archipelago/src/federation/storage.rs b/core/archipelago/src/federation/storage.rs index dc0eded3..c643302a 100644 --- a/core/archipelago/src/federation/storage.rs +++ b/core/archipelago/src/federation/storage.rs @@ -60,6 +60,43 @@ pub async fn fips_npub_for_onion(data_dir: &Path, onion: &str) -> Option .and_then(|n| n.fips_npub.clone()) } +/// Record the transport used on the most recent successful peer reach. +/// Used for the "FIPS"/"Tor" badge on each node card in the UI — we write +/// what we actually used, not what was predicted. +/// +/// Matches by DID first (precise) and falls back to onion (when the +/// caller didn't carry the DID through). No-op if the peer isn't in +/// our federation list. +pub async fn record_peer_transport( + data_dir: &Path, + did: Option<&str>, + onion: Option<&str>, + transport: &str, +) -> Result<()> { + let mut nodes = load_nodes(data_dir).await?; + let now = chrono::Utc::now().to_rfc3339(); + let onion_target = onion.map(|o| o.trim_end_matches(".onion")); + + let mut modified = false; + for node in nodes.iter_mut() { + let did_match = did.is_some_and(|d| d == node.did); + let onion_match = onion_target + .is_some_and(|t| node.onion.trim_end_matches(".onion") == t); + if did_match || onion_match { + node.last_transport = Some(transport.to_string()); + node.last_transport_at = Some(now.clone()); + node.last_seen = Some(now.clone()); + modified = true; + break; + } + } + + if modified { + save_nodes(data_dir, &nodes).await?; + } + Ok(()) +} + pub async fn save_nodes(data_dir: &Path, nodes: &[FederatedNode]) -> Result<()> { let dir = ensure_dir(data_dir).await?; let file = NodesFile { @@ -186,6 +223,8 @@ mod tests { last_seen: None, last_state: None, fips_npub: None, + last_transport: None, + last_transport_at: None, } } diff --git a/core/archipelago/src/federation/sync.rs b/core/archipelago/src/federation/sync.rs index cdcf8972..18dec72a 100644 --- a/core/archipelago/src/federation/sync.rs +++ b/core/archipelago/src/federation/sync.rs @@ -43,6 +43,16 @@ pub async fn sync_with_peer( anyhow::bail!("Peer returned {} (via {})", resp.status(), transport); } + // Record transport used so the UI badge on this peer's card reflects + // the transport that actually carried the call, not a prediction. + let _ = super::storage::record_peer_transport( + data_dir, + Some(&peer.did), + Some(&peer.onion), + &transport.to_string(), + ) + .await; + let result: serde_json::Value = resp.json().await.context("Invalid response from peer")?; let state_val = result .get("result") @@ -113,6 +123,8 @@ async fn merge_transitive_peers( last_seen: None, last_state: None, fips_npub: hint.fips_npub.clone(), + last_transport: None, + last_transport_at: None, }); added += 1; } @@ -284,6 +296,8 @@ mod tests { last_seen: None, last_state: None, fips_npub: Some("npub1a".into()), + last_transport: None, + last_transport_at: None, }, FederatedNode { did: "did:key:zObserver".into(), @@ -295,6 +309,8 @@ mod tests { last_seen: None, last_state: None, fips_npub: Some("npub1b".into()), + last_transport: None, + last_transport_at: None, }, FederatedNode { did: "did:key:zUntrusted".into(), @@ -306,6 +322,8 @@ mod tests { last_seen: None, last_state: None, fips_npub: None, + last_transport: None, + last_transport_at: None, }, ]; let state = build_local_state( diff --git a/core/archipelago/src/federation/types.rs b/core/archipelago/src/federation/types.rs index 5aef877f..8a3cc9ee 100644 --- a/core/archipelago/src/federation/types.rs +++ b/core/archipelago/src/federation/types.rs @@ -39,6 +39,16 @@ pub struct FederatedNode { /// Lets the transport router prefer FIPS over Tor for peer traffic. #[serde(default)] pub fips_npub: Option, + /// Transport kind used on the most recent successful reach + /// ("fips" | "tor" | "mesh" | "lan"). Written after each successful + /// PeerRequest so the UI can show a ground-truth badge ("this peer + /// is currently being reached over FIPS") instead of a prediction + /// based on available addresses. + #[serde(default)] + pub last_transport: Option, + /// RFC 3339 timestamp of the last_transport value. + #[serde(default)] + pub last_transport_at: Option, } /// State snapshot received from a federated peer during sync. @@ -140,6 +150,8 @@ mod tests { last_seen: None, last_state: None, fips_npub: None, + last_transport: None, + last_transport_at: None, }; let json = serde_json::to_string(&node).unwrap(); let parsed: FederatedNode = serde_json::from_str(&json).unwrap(); diff --git a/neode-ui/src/views/federation/NodeList.vue b/neode-ui/src/views/federation/NodeList.vue index 6f26a1b6..b35fd1d7 100644 --- a/neode-ui/src/views/federation/NodeList.vue +++ b/neode-ui/src/views/federation/NodeList.vue @@ -53,10 +53,11 @@
{{ nodeName(node) }} {{ nodeTransportIcon(node.did).icon }} + v-if="transportBadge(node)" + class="text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded shrink-0" + :class="transportBadge(node)!.cls" + :title="transportBadge(node)!.title" + >{{ transportBadge(node)!.label }}
{{ nodeName(node) }} + {{ transportBadge(node)!.label }} {{ node.trust_level }} @@ -130,7 +137,6 @@ diff --git a/neode-ui/src/views/federation/types.ts b/neode-ui/src/views/federation/types.ts index 743ac65b..b6aa24c6 100644 --- a/neode-ui/src/views/federation/types.ts +++ b/neode-ui/src/views/federation/types.ts @@ -25,6 +25,12 @@ export interface FederatedNode { name?: string last_seen?: string last_state?: NodeState + /** bech32 FIPS npub this peer advertised (when known). */ + fips_npub?: string + /** Transport used on the most recent successful reach: 'fips' | 'tor' | 'mesh' | 'lan'. */ + last_transport?: 'fips' | 'tor' | 'mesh' | 'lan' + /** RFC 3339 timestamp of last_transport. */ + last_transport_at?: string } export interface DwnStatus {