Skip to content

Commit 1eed79c

Browse files
committed
Add peer reachability status
1 parent af737ee commit 1eed79c

7 files changed

Lines changed: 449 additions & 18 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "fabric"
3-
version = "0.1.2"
3+
version = "0.1.3"
44
edition = "2024"
55

66
[dependencies]

README.md

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,14 @@ fabric peers
111111

112112
List trusted peers. The peer list is the daemon's endpoint allow-list.
113113

114+
```sh
115+
fabric status
116+
```
117+
118+
Show the running daemon's local state and echo-ping every trusted peer. Each
119+
peer is reported as reachable or unreachable with round-trip latency and, when
120+
iroh exposes it, the active transport path: `direct`, `relay`, or `mixed`.
121+
114122
```sh
115123
fabric add <nodeid> [name] [--addr-json JSON]
116124
```
@@ -130,7 +138,9 @@ fabric up [--foreground]
130138
```
131139

132140
Start the local fabric daemon. Without `--foreground`, this spawns a background
133-
daemon and logs to `<home>/logs/daemon.log`.
141+
daemon and logs to `<home>/logs/daemon.log`. After the daemon is ready, `fabric
142+
up` runs the same echo-ping reachability check used by `fabric status` and
143+
prints one line per trusted peer.
134144

135145
```sh
136146
fabric down
@@ -165,7 +175,8 @@ fabric ping <peer>
165175

166176
Connectivity and trust test. `fabric ping` dials the peer's built-in
167177
ACL-gated echo protocol, sends a random nonce, verifies the same bytes come
168-
back, and prints the round-trip latency. Use this first when bringing up a new
178+
back, and prints the round-trip latency. When available, it also reports whether
179+
iroh used a direct, relay, or mixed path. Use this first when bringing up a new
169180
machine.
170181

171182
## Declarative Peer Config

src/control.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ use serde::{Deserialize, Serialize};
66
#[serde(tag = "type", rename_all = "snake_case")]
77
pub enum ControlRequest {
88
Status,
9+
ReachabilityStatus,
910
ReloadPeers,
1011
Expose { protocol: String, socket: PathBuf },
1112
Dial { peer: String, protocol: String },
@@ -23,15 +24,34 @@ pub enum ControlResponse {
2324
exposed_protocols: Vec<String>,
2425
dial_sockets: Vec<PathBuf>,
2526
},
27+
ReachabilityStatus {
28+
node_id: String,
29+
endpoint_addr: serde_json::Value,
30+
exposed_protocols: Vec<String>,
31+
dial_sockets: Vec<PathBuf>,
32+
peers: Vec<PeerReachability>,
33+
},
2634
Dial {
2735
socket: PathBuf,
2836
},
2937
Pong {
3038
peer: String,
3139
bytes: usize,
3240
round_trip_micros: u64,
41+
transport: Option<String>,
3342
},
3443
Error {
3544
message: String,
3645
},
3746
}
47+
48+
#[derive(Debug, Clone, Serialize, Deserialize)]
49+
pub struct PeerReachability {
50+
pub id: String,
51+
pub name: Option<String>,
52+
pub reachable: bool,
53+
pub bytes: Option<usize>,
54+
pub round_trip_micros: Option<u64>,
55+
pub transport: Option<String>,
56+
pub error: Option<String>,
57+
}

src/daemon.rs

Lines changed: 156 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@ use std::{
22
collections::{HashMap, HashSet},
33
fs,
44
path::PathBuf,
5-
sync::Arc,
5+
sync::{
6+
Arc,
7+
atomic::{AtomicUsize, Ordering},
8+
},
69
time::Duration,
710
};
811

@@ -11,7 +14,7 @@ use iroh::{
1114
Endpoint, EndpointAddr, EndpointId,
1215
endpoint::{
1316
AfterHandshakeOutcome, Connection, EndpointHooks, Incoming, RecvStream, SendStream, Side,
14-
VarInt, presets,
17+
TransportAddrUsage, VarInt, presets,
1518
},
1619
};
1720
use tokio::{
@@ -23,11 +26,12 @@ use tokio::{
2326
use tokio_util::sync::CancellationToken;
2427

2528
use crate::{
26-
config::{FabricHome, PeerBook, load_or_create_identity, validate_protocol},
27-
control::{ControlRequest, ControlResponse},
29+
config::{FabricHome, Peer, PeerBook, load_or_create_identity, validate_protocol},
30+
control::{ControlRequest, ControlResponse, PeerReachability},
2831
};
2932

3033
const BUILTIN_ECHO_ALPN: &[u8] = b"fabric/echo/0";
34+
const REACHABILITY_TIMEOUT: Duration = Duration::from_secs(3);
3135

3236
#[derive(Debug)]
3337
struct AllowListHook {
@@ -59,6 +63,7 @@ pub struct DaemonState {
5963
allowed: Arc<RwLock<HashSet<EndpointId>>>,
6064
exposures: RwLock<HashMap<Vec<u8>, PathBuf>>,
6165
dial_sockets: Mutex<HashMap<(String, String), PathBuf>>,
66+
builtin_echo_hits: AtomicUsize,
6267
cancel: CancellationToken,
6368
}
6469

@@ -86,6 +91,7 @@ impl DaemonState {
8691
allowed,
8792
exposures: RwLock::new(HashMap::new()),
8893
dial_sockets: Mutex::new(HashMap::new()),
94+
builtin_echo_hits: AtomicUsize::new(0),
8995
cancel,
9096
}))
9197
}
@@ -122,6 +128,10 @@ impl DaemonState {
122128

123129
pub async fn ping(&self, peer: &str) -> Result<PingOutcome> {
124130
let peer_addr = self.peer_book.read().await.resolve(peer)?;
131+
self.ping_addr(peer, peer_addr).await
132+
}
133+
134+
async fn ping_addr(&self, peer: &str, peer_addr: EndpointAddr) -> Result<PingOutcome> {
125135
let nonce = rand::random::<[u8; 32]>();
126136
let started = std::time::Instant::now();
127137
let connection = self
@@ -136,6 +146,12 @@ impl DaemonState {
136146

137147
let response = recv.read_to_end(nonce.len() + 1).await?;
138148
let round_trip = started.elapsed();
149+
let mut transport = classify_connection_transport(&connection);
150+
if transport.is_none()
151+
&& let Some(info) = self.endpoint.remote_info(peer_addr.id).await
152+
{
153+
transport = classify_remote_transport(&info);
154+
}
139155
if response != nonce {
140156
bail!(
141157
"ping nonce mismatch from {peer:?}: sent {} bytes, got {} bytes",
@@ -148,6 +164,7 @@ impl DaemonState {
148164
peer: peer_addr.id.to_string(),
149165
bytes: response.len(),
150166
round_trip,
167+
transport,
151168
})
152169
}
153170

@@ -184,7 +201,9 @@ impl DaemonState {
184201
Ok(socket_path)
185202
}
186203

187-
async fn status_response(&self) -> Result<ControlResponse> {
204+
async fn local_status_fields(
205+
&self,
206+
) -> Result<(String, serde_json::Value, Vec<String>, Vec<PathBuf>)> {
188207
let exposed_protocols = self
189208
.exposures
190209
.read()
@@ -193,20 +212,99 @@ impl DaemonState {
193212
.map(|alpn| String::from_utf8_lossy(alpn).to_string())
194213
.collect();
195214
let dial_sockets = self.dial_sockets.lock().await.values().cloned().collect();
215+
Ok((
216+
self.id().to_string(),
217+
serde_json::to_value(self.addr())?,
218+
exposed_protocols,
219+
dial_sockets,
220+
))
221+
}
222+
223+
async fn status_response(&self) -> Result<ControlResponse> {
224+
let (node_id, endpoint_addr, exposed_protocols, dial_sockets) =
225+
self.local_status_fields().await?;
196226
Ok(ControlResponse::Status {
197-
node_id: self.id().to_string(),
198-
endpoint_addr: serde_json::to_value(self.addr())?,
227+
node_id,
228+
endpoint_addr,
199229
exposed_protocols,
200230
dial_sockets,
201231
})
202232
}
233+
234+
async fn reachability_status_response(&self) -> Result<ControlResponse> {
235+
let (node_id, endpoint_addr, exposed_protocols, dial_sockets) =
236+
self.local_status_fields().await?;
237+
let peers = self.peer_reachability().await;
238+
Ok(ControlResponse::ReachabilityStatus {
239+
node_id,
240+
endpoint_addr,
241+
exposed_protocols,
242+
dial_sockets,
243+
peers,
244+
})
245+
}
246+
247+
pub async fn peer_reachability(&self) -> Vec<PeerReachability> {
248+
let peers = self.peer_book.read().await.peers().to_vec();
249+
let mut statuses = Vec::with_capacity(peers.len());
250+
for peer in peers {
251+
statuses.push(self.check_peer_reachability(peer).await);
252+
}
253+
statuses
254+
}
255+
256+
async fn check_peer_reachability(&self, peer: Peer) -> PeerReachability {
257+
let addr = peer
258+
.addr
259+
.clone()
260+
.unwrap_or_else(|| EndpointAddr::new(peer.id));
261+
let label = peer.name.clone().unwrap_or_else(|| peer.id.to_string());
262+
263+
match tokio::time::timeout(REACHABILITY_TIMEOUT, self.ping_addr(&label, addr)).await {
264+
Ok(Ok(pong)) => PeerReachability {
265+
id: peer.id.to_string(),
266+
name: peer.name,
267+
reachable: true,
268+
bytes: Some(pong.bytes),
269+
round_trip_micros: Some(pong.round_trip.as_micros().try_into().unwrap_or(u64::MAX)),
270+
transport: pong.transport,
271+
error: None,
272+
},
273+
Ok(Err(error)) => PeerReachability {
274+
id: peer.id.to_string(),
275+
name: peer.name,
276+
reachable: false,
277+
bytes: None,
278+
round_trip_micros: None,
279+
transport: None,
280+
error: Some(format!("{error:#}")),
281+
},
282+
Err(_) => PeerReachability {
283+
id: peer.id.to_string(),
284+
name: peer.name,
285+
reachable: false,
286+
bytes: None,
287+
round_trip_micros: None,
288+
transport: None,
289+
error: Some(format!(
290+
"timed out after {:.1}s",
291+
REACHABILITY_TIMEOUT.as_secs_f32()
292+
)),
293+
},
294+
}
295+
}
296+
297+
pub fn builtin_echo_hits(&self) -> usize {
298+
self.builtin_echo_hits.load(Ordering::SeqCst)
299+
}
203300
}
204301

205302
#[derive(Debug, Clone)]
206303
pub struct PingOutcome {
207304
pub peer: String,
208305
pub bytes: usize,
209306
pub round_trip: Duration,
307+
pub transport: Option<String>,
210308
}
211309

212310
pub struct FabricNode {
@@ -344,6 +442,7 @@ async fn process_control_request(
344442
) -> Result<ControlResponse> {
345443
let response = match request {
346444
ControlRequest::Status => state.status_response().await?,
445+
ControlRequest::ReachabilityStatus => state.reachability_status_response().await?,
347446
ControlRequest::ReloadPeers => {
348447
state.reload_peers().await?;
349448
ControlResponse::Ok
@@ -362,6 +461,7 @@ async fn process_control_request(
362461
peer: pong.peer,
363462
bytes: pong.bytes,
364463
round_trip_micros: pong.round_trip.as_micros().try_into().unwrap_or(u64::MAX),
464+
transport: pong.transport,
365465
}
366466
}
367467
ControlRequest::Shutdown => {
@@ -398,7 +498,7 @@ async fn process_incoming_iroh(incoming: Incoming, state: Arc<DaemonState>) -> R
398498
let alpn = accepting.alpn().await?;
399499
if alpn == BUILTIN_ECHO_ALPN {
400500
let connection = accepting.await?;
401-
handle_builtin_echo(connection).await?;
501+
handle_builtin_echo(connection, state).await?;
402502
return Ok(());
403503
}
404504

@@ -419,7 +519,8 @@ async fn process_incoming_iroh(incoming: Incoming, state: Arc<DaemonState>) -> R
419519
Ok(())
420520
}
421521

422-
async fn handle_builtin_echo(connection: Connection) -> Result<()> {
522+
async fn handle_builtin_echo(connection: Connection, state: Arc<DaemonState>) -> Result<()> {
523+
state.builtin_echo_hits.fetch_add(1, Ordering::SeqCst);
423524
let (mut send, mut recv) = connection.accept_bi().await?;
424525
tokio::io::copy(&mut recv, &mut send).await?;
425526
send.finish()?;
@@ -434,6 +535,52 @@ fn accepted_alpns(exposures: &HashMap<Vec<u8>, PathBuf>) -> Vec<Vec<u8>> {
434535
alpns
435536
}
436537

538+
fn classify_connection_transport(connection: &Connection) -> Option<String> {
539+
let paths = connection.paths();
540+
let mut selected_ip = false;
541+
let mut selected_relay = false;
542+
let mut any_ip = false;
543+
let mut any_relay = false;
544+
545+
for path in paths.iter() {
546+
let is_ip = path.is_ip();
547+
let is_relay = path.is_relay();
548+
any_ip |= is_ip;
549+
any_relay |= is_relay;
550+
if path.is_selected() {
551+
selected_ip |= is_ip;
552+
selected_relay |= is_relay;
553+
}
554+
}
555+
556+
classify_transport(selected_ip, selected_relay)
557+
.or_else(|| classify_transport(any_ip, any_relay))
558+
}
559+
560+
fn classify_remote_transport(info: &iroh::endpoint::RemoteInfo) -> Option<String> {
561+
let mut active_ip = false;
562+
let mut active_relay = false;
563+
564+
for addr in info.addrs() {
565+
if !matches!(addr.usage(), TransportAddrUsage::Active) {
566+
continue;
567+
}
568+
active_ip |= addr.addr().is_ip();
569+
active_relay |= addr.addr().is_relay();
570+
}
571+
572+
classify_transport(active_ip, active_relay)
573+
}
574+
575+
fn classify_transport(has_ip: bool, has_relay: bool) -> Option<String> {
576+
match (has_ip, has_relay) {
577+
(true, true) => Some("mixed".to_string()),
578+
(true, false) => Some("direct".to_string()),
579+
(false, true) => Some("relay".to_string()),
580+
(false, false) => None,
581+
}
582+
}
583+
437584
async fn run_dial_socket(
438585
listener: UnixListener,
439586
endpoint: Endpoint,

0 commit comments

Comments
 (0)