diff --git a/CHANGELOG.md b/CHANGELOG.md index ed06cb66..0bd4d5e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 finite and at least 1.0, `switch_dwell_secs`, `min_samples`, `active_heartbeat_ms`, `standby_heartbeat_ms`). +- Views, for multi-path: `show_peers` lists every path under its peer + (`paths[]`; `path_show` is the same rows plus the now-relative liveness + ages and `acked_once`), and `fipstop`'s Peers tab draws a peer with more + than one path as a tree — the peer row keeps the peer-level columns, one + child row per path carries transport, address, + `active`/`live`/`probing`/`suspect`/`dead` with `pinned` / `backup` + markers, RTT and score, the active path in green, an unproven one in + yellow — with a Paths section in the detail pane. A single-path peer + stays one line. + - An authentic frame arriving on a transport the peer has no path on no longer re-pins the peer's send side to that transport, and a decrypt failure on such a transport is not counted toward force-removal. Both diff --git a/docs/reference/cli-fipstop.md b/docs/reference/cli-fipstop.md index 8befc96e..81cff52f 100644 --- a/docs/reference/cli-fipstop.md +++ b/docs/reference/cli-fipstop.md @@ -119,6 +119,21 @@ the keys the current context accepts. | --- | ------ | | `Del` | Disconnect the selected peer. Opens a `Y`/`N` confirmation modal first; this is the only state-mutating action in `fipstop`. | +A peer reachable over more than one transport is drawn as a tree: its own +row keeps the peer-level columns (name, npub, direction, effective depth, +goodput, packet counts) and one child row per path, under it, carries the +path's transport (instance name, or type), its state in the Npub column — +`active` for the path this node sends on, otherwise `live`, `probing`, +`suspect` or `dead`, with `,pinned`, `,remote` (the peer sends here) and +`,backup` markers — the transport type and address, the last probe round +trip under SRTT and the path score under LQI. The active path is green, a +live standby dim, a `probing` path (never acknowledged: an older peer, or +a medium it cannot hear us on) yellow, a `suspect` or `dead` one red. The +cursor lands on peer rows only. A peer with one path stays one line with +its transport inline. The detail view (`Enter`) adds a **Paths** section +with every path's transport, address, state, round trips, sample count, +ETX and score; `fipsctl path show` gives the same with the liveness ages. + ### Transports tab (extra) | Key | Action | diff --git a/docs/reference/control-socket.md b/docs/reference/control-socket.md index 2fe2f7fe..0510b385 100644 --- a/docs/reference/control-socket.md +++ b/docs/reference/control-socket.md @@ -116,7 +116,7 @@ table below lists every command currently registered. | ------- | ------ | ----------------------------- | | `show_status` | — | `version`, `npub`, `node_addr`, `ipv6_addr`, `state`, `is_leaf_only`, `is_root` (bool — this node is the spanning-tree root), `root` (hex node-addr of the current tree root), `persistent` (bool — identity is persisted, i.e. `persistent` set or an `nsec` configured), `peer_count`, `session_count`, `link_count`, `transport_count`, `connection_count`, `transport_peer_counts` (object mapping transport-type name to its connected-peer count; configured transports appear with `0`), `tun_state`, `tun_name`, `effective_ipv6_mtu`, `control_socket`, `pid`, `exe_path`, `uptime_secs`, `estimated_mesh_size`, `forwarding`, `sparklines`. | | `show_acl` | — | `allow_file`, `deny_file`, `enforcement_active`, `effective_mode`, `default_decision`, `allow_all`, `deny_all`, `allow_file_entries`, `deny_file_entries`, `allow_entries`, `deny_entries`. | -| `show_peers` | — | `peers[]` — per-peer object: `node_addr`, `npub`, `display_name`, `ipv6_addr`, `connectivity`, `link_id`, `direction`, `transport_addr`, `transport_type`, `is_parent`, `is_child`, `tree_depth`, `effective_depth` (`tree_depth + link_cost` — the metric `evaluate_parent` ranks on; `null` when the peer has no coords, or is unmeasured while another peer has an SRTT sample, per the cold-start gate), `stats`, `noise`, `current_k_bit`, `mmp`, plus optional `nostr_traversal`, `rekey_in_progress`, `rekey_draining`. | +| `show_peers` | — | `peers[]` — per-peer object: `node_addr`, `npub`, `display_name`, `ipv6_addr`, `connectivity`, `link_id`, `direction`, `transport_addr`, `transport_type`, `is_parent`, `is_child`, `tree_depth`, `effective_depth` (`tree_depth + link_cost` — the metric `evaluate_parent` ranks on; `null` when the peer has no coords, or is unmeasured while another peer has an SRTT sample, per the cold-start gate), `stats`, `noise`, `current_k_bit`, `mmp`, `paths[]` (every path to the peer: `transport_id`, `transport`, `transport_type`, `addr`, `state`, `active`, `remote_active`, `role`, `pinned`, `last_rtt_ms`, `min_rtt_ms`, `rtt_samples`, `etx`, `score` — the `path_show` fields minus the now-relative ages), plus optional `nostr_traversal`, `rekey_in_progress`, `rekey_draining`. | | `show_links` | — | `links[]` — `link_id`, `transport_id`, `remote_addr`, `direction`, `state`, `created_at_ms`, `stats`. | | `show_tree` | — | `my_node_addr`, `root`, `root_npub` (bech32 npub of the current tree root), `is_root`, `depth`, `my_coords[]`, `parent`, `parent_display_name`, `declaration_sequence`, `declaration_signed`, `peer_tree_count`, `peers[]`, `stats`. | | `show_sessions` | — | `sessions[]` — `remote_addr`, `npub`, `display_name`, `state` (`established`, `initiating`, `awaiting_msg3`, `unknown`), `is_initiator`, `last_activity_ms`, `stats`, optional `mmp`, `current_k_bit`, `is_draining`. | diff --git a/src/bin/fipstop/ui/peers.rs b/src/bin/fipstop/ui/peers.rs index c6b28868..dbeab045 100644 --- a/src/bin/fipstop/ui/peers.rs +++ b/src/bin/fipstop/ui/peers.rs @@ -92,8 +92,12 @@ fn draw_table( // Build the grouped display: a styled label row before each non-empty // group, the group's peer rows, and a blank separator before the next - // group. `peer_display_idx[p]` is the display-row index of sorted peer `p`, - // so the stored peer-index selection (used by detail + navigation) can be + // group. A peer with more than one path is a tree: its row keeps the + // peer-level columns (Dir, EffD, Goodput, packets) and one child row per + // path carries the path-specific ones (transport, RTT, score), the + // active path in green. A single-path peer stays one line, as before. + // `peer_display_idx[p]` is the display-row index of sorted peer `p`, so + // the stored peer-index selection (used by detail + navigation) can be // translated to the display row to highlight, and the cursor only ever // lands on peer rows. let mut rows: Vec = Vec::new(); @@ -170,6 +174,15 @@ fn draw_table( Style::default() }; + // Multi-path: the path-specific columns move to the child rows. + let paths = peer_paths(peer); + let multi = paths.len() > 1; + let (transport, srtt, loss, lqi) = if multi { + (String::new(), String::new(), String::new(), String::new()) + } else { + (transport, srtt, loss, lqi) + }; + peer_display_idx.push(rows.len()); rows.push( Row::new(vec![ @@ -187,6 +200,21 @@ fn draw_table( ]) .style(row_style), ); + + // One child row per path, tree-drawn under the peer: + // peer + // ├─ cable active udp/10.0.0.2:2121 + // └─ wifi live udp/10.0.1.2:2121 + if multi { + for (i, path) in paths.iter().enumerate() { + let branch = if i + 1 == paths.len() { + "└─" + } else { + "├─" + }; + rows.push(path_row(path, branch)); + } + } } let widths = [ @@ -243,6 +271,108 @@ fn draw_table( } } +/// The `paths` array of a `show_peers` row: every transport the peer is +/// reachable over. Empty on daemons that predate multi-path. +fn peer_paths(peer: &serde_json::Value) -> Vec { + peer.get("paths") + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default() +} + +/// The transport a path runs over, by instance name when it has one and +/// type otherwise (`cable`, `udp`). +fn path_transport_label(path: &serde_json::Value) -> String { + let name = path.get("transport").and_then(|v| v.as_str()); + let t_type = path.get("transport_type").and_then(|v| v.as_str()); + match (name, t_type) { + (Some(n), _) if !n.is_empty() => n.to_string(), + (_, Some(t)) if !t.is_empty() => t.to_string(), + _ => format!("#{}", helpers::u64_field(path, "transport_id")), + } +} + +/// Path state as shown in the table: `active` for the path we send on, +/// otherwise its lifecycle state, with `pinned` / `remote` markers. +fn path_state_label(path: &serde_json::Value) -> String { + let active = path + .get("active") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let mut label = if active { + "active".to_string() + } else { + helpers::str_field(path, "state").to_string() + }; + if path + .get("pinned") + .and_then(|v| v.as_bool()) + .unwrap_or(false) + { + label.push_str(",pinned"); + } + if path + .get("remote_active") + .and_then(|v| v.as_bool()) + .unwrap_or(false) + && !active + { + label.push_str(",remote"); + } + if path.get("role").and_then(|v| v.as_str()) == Some("backup") { + label.push_str(",backup"); + } + label +} + +/// A path's own RTT / score fill the peer table's SRTT / LQI columns; the +/// rest (Dir, Loss, EffD, Goodput, packets) are link-level and stay on the +/// peer row. The active path is green, a live standby dim, an unproven one +/// (probing: never acked, as with a peer that predates multi-path) yellow, +/// a failing one red. +fn path_row(path: &serde_json::Value, branch: &str) -> Row<'static> { + let active = path + .get("active") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let state = helpers::str_field(path, "state"); + let style = if active { + Style::default().fg(Color::Green) + } else if state == "probing" { + Style::default().fg(Color::Yellow) + } else if state == "suspect" || state == "dead" { + Style::default().fg(Color::Red) + } else { + Style::default().fg(Color::DarkGray) + }; + let t_type = path + .get("transport_type") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let addr = path.get("addr").and_then(|v| v.as_str()).unwrap_or(""); + let transport = if t_type.is_empty() { + addr.to_string() + } else { + format!("{t_type}/{addr}") + }; + let rtt = path + .get("last_rtt_ms") + .and_then(|v| v.as_u64()) + .map(|ms| ms.to_string()) + .unwrap_or_else(|| "-".to_string()); + let score = helpers::opt_f64_field(path, "score", 2); + Row::new(vec![ + Cell::from(format!(" {branch} {}", path_transport_label(path))), + Cell::from(path_state_label(path)), + Cell::from(transport), + Cell::from(""), + Cell::from(rtt), + Cell::from(""), + Cell::from(score), + ]) + .style(style) +} + fn draw_detail(frame: &mut Frame, app: &App, area: Rect, peers: &[serde_json::Value]) { let state = app.table_states.get(&Tab::Peers); let selected = state.and_then(|s| s.selected()).unwrap_or(0); @@ -363,6 +493,35 @@ fn draw_detail(frame: &mut Frame, app: &App, area: Rect, peers: &[serde_json::Va lines.push(Line::from("")); } + // Every path to the peer, active first. + let paths = peer_paths(peer); + if !paths.is_empty() { + lines.push(helpers::section_header("Paths")); + for path in &paths { + let addr = helpers::str_field(path, "addr"); + let t_type = helpers::str_field(path, "transport_type"); + lines.push(helpers::kv_line( + &path_transport_label(path), + &format!("{t_type}/{addr} {}", path_state_label(path)), + )); + let rtt = |key: &str| { + path.get(key) + .and_then(|v| v.as_u64()) + .map(|ms| format!("{ms}ms")) + .unwrap_or_else(|| "-".to_string()) + }; + lines.push(Line::from(format!( + " rtt {} min {} n={} etx {} score {}", + rtt("last_rtt_ms"), + rtt("min_rtt_ms"), + helpers::u64_field(path, "rtt_samples"), + helpers::opt_f64_field(path, "etx", 2), + helpers::opt_f64_field(path, "score", 2), + ))); + } + lines.push(Line::from("")); + } + lines.extend([ // Tree & Bloom helpers::section_header("Tree / Bloom"), diff --git a/src/bin/fipstop/ui/snapshots.rs b/src/bin/fipstop/ui/snapshots.rs index b5d7c635..2b24b7dc 100644 --- a/src/bin/fipstop/ui/snapshots.rs +++ b/src/bin/fipstop/ui/snapshots.rs @@ -112,6 +112,161 @@ fn bloom_peer_filters_alignment() { assert_eq!(cols[0], cols[1], "fill: columns align across rows"); } +/// Peers multi-path: a peer with two paths renders each as a tree-drawn +/// child row carrying the path-specific columns (the peer row's own +/// Transport / SRTT / LQI go blank), the active path green and a standby +/// dimmed; a single-path peer stays one line with its transport inline. +/// Selection stays on peer rows: the highlight of peer 1 lands on its own +/// row, not on a child of peer 0. +#[test] +fn peers_paths_render_as_child_rows() { + let data = json!({ + "peers": [ + { + "display_name": "alice", "npub": "npub1alice", + "is_parent": false, "is_child": false, + "transport_type": "udp", "transport_addr": "10.0.0.2:2121", + "mmp": { "srtt_ms": 12.0, "lqi": 1.10 }, + "paths": [ + { "transport_id": 1, "transport": "cable", "transport_type": "udp", + "addr": "10.0.0.2:2121", "state": "live", "active": true, + "remote_active": true, "role": "normal", "pinned": false, + "last_rtt_ms": 12, "min_rtt_ms": 10, "rtt_samples": 4, + "etx": 1.0, "score": 1.10 }, + { "transport_id": 2, "transport": "wifi", "transport_type": "udp", + "addr": "10.0.1.2:2121", "state": "live", "active": false, + "remote_active": false, "role": "backup", "pinned": false, + "last_rtt_ms": 30, "min_rtt_ms": 28, "rtt_samples": 4, + "etx": 1.0, "score": 1.28 } + ] + }, + { + "display_name": "bob", "npub": "npub1bob", + "is_parent": false, "is_child": false, + "transport_type": "udp", "transport_addr": "10.0.2.2:2121", + "mmp": { "srtt_ms": 7.0, "lqi": 2.07 }, + "paths": [ + { "transport_id": 1, "transport": "cable", "transport_type": "udp", + "addr": "10.0.2.2:2121", "state": "live", "active": true, + "remote_active": true, "role": "normal", "pinned": false, + "last_rtt_ms": 7, "min_rtt_ms": 7, "rtt_samples": 4, + "etx": 1.0, "score": 1.07 } + ] + } + ] + }); + let mut app = app_with(Tab::Peers, data); + app.table_states + .entry(Tab::Peers) + .or_default() + .select(Some(1)); + let buf = testkit::render(140, 20, |frame, area| { + super::peers::draw(frame, &mut app, area); + }); + + let y_alice = testkit::find(&buf, "alice").map(|(_, y)| y).unwrap(); + let (_, y_cable) = testkit::find(&buf, "├─ cable").expect("first path row"); + let (_, y_wifi) = testkit::find(&buf, "└─ wifi").expect("last path row"); + let y_bob = testkit::find(&buf, "bob").map(|(_, y)| y).unwrap(); + assert_eq!(y_cable, y_alice + 1, "active path directly under its peer"); + assert_eq!(y_wifi, y_alice + 2, "standby path after the active one"); + assert_eq!(y_bob, y_alice + 3, "next peer follows the last path row"); + + assert!( + testkit::contains_row(&buf, "active"), + "active path labelled" + ); + assert!( + testkit::contains_row(&buf, "live,backup"), + "standby carries state and role" + ); + assert!( + testkit::contains_row(&buf, "udp/10.0.1.2:2121"), + "path address shown" + ); + assert_eq!( + testkit::fg_at(&buf, "├─ cable"), + Some(ratatui::style::Color::Green), + "active path green" + ); + assert_eq!( + testkit::fg_at(&buf, "└─ wifi"), + Some(ratatui::style::Color::DarkGray), + "standby path dimmed" + ); + + // alice's own row carries no transport: that moved to the child rows. + let alice_row = &testkit::lines(&buf)[y_alice as usize]; + assert!( + !alice_row.contains("udp/10.0.0.2:2121") && !alice_row.contains("12.0"), + "multi-path peer row leaves Transport and SRTT to its child rows: {alice_row}" + ); + + // bob has one path: a single line with the transport inline, no children. + let bob_row = &testkit::lines(&buf)[y_bob as usize]; + assert!( + bob_row.contains("udp/10.0.2.2:2121") && bob_row.contains("7.0"), + "single-path peer keeps transport and SRTT on its own row: {bob_row}" + ); + assert!( + !testkit::contains_row(&buf, "└─ cable"), + "single-path peer draws no child row" + ); + + // bob (peer index 1) is selected: the highlight symbol sits on bob's + // display row, not on alice's child rows. + let (_, y_sel) = testkit::find(&buf, "▶ ").expect("selection marker"); + assert_eq!( + y_sel, y_bob, + "selection lands on the peer row, skipping path rows" + ); +} + +/// A peer that predates multi-path never acks a probe: its second path sits +/// in `probing` forever with no RTT or score. It still renders as a tree — +/// the operator sees the transport is there and unproven — in yellow. +#[test] +fn peers_probing_path_renders_unproven() { + let data = json!({ + "peers": [ + { + "display_name": "oldpeer", "npub": "npub1old", + "is_parent": false, "is_child": false, + "transport_type": "udp", "transport_addr": "10.0.0.2:2121", + "paths": [ + { "transport_id": 1, "transport": "cable", "transport_type": "udp", + "addr": "10.0.0.2:2121", "state": "live", "active": true, + "remote_active": false, "role": "normal", "pinned": false, + "last_rtt_ms": null, "min_rtt_ms": null, "rtt_samples": 0, + "etx": 1.0, "score": null }, + { "transport_id": 2, "transport": "wifi", "transport_type": "udp", + "addr": "10.0.1.2:2121", "state": "probing", "active": false, + "remote_active": false, "role": "normal", "pinned": false, + "last_rtt_ms": null, "min_rtt_ms": null, "rtt_samples": 0, + "etx": 1.0, "score": null } + ] + } + ] + }); + let mut app = app_with(Tab::Peers, data); + let buf = testkit::render(140, 20, |frame, area| { + super::peers::draw(frame, &mut app, area); + }); + + let (_, y_wifi) = testkit::find(&buf, "└─ wifi").expect("probing path row"); + let row = &testkit::lines(&buf)[y_wifi as usize]; + assert!(row.contains("probing"), "unproven path labelled: {row}"); + assert!( + row.contains("udp/10.0.1.2:2121"), + "its transport shown: {row}" + ); + assert_eq!( + testkit::fg_at(&buf, "└─ wifi"), + Some(ratatui::style::Color::Yellow), + "probing path yellow" + ); +} + /// Peers group-sort: the comparator orders parent before STP children /// before other peers, regardless of LQI, while preserving within-group /// LQI order. diff --git a/src/control/queries.rs b/src/control/queries.rs index 98eeb828..1af67fe7 100644 --- a/src/control/queries.rs +++ b/src/control/queries.rs @@ -294,6 +294,8 @@ pub fn show_peers(node: &Node) -> Value { } } + peer_json["paths"] = render_peer_paths(&node.project_peer_paths(peer)); + // Add tree depth if available if let Some(coords) = peer.coords() { peer_json["tree_depth"] = json!(coords.depth()); @@ -411,6 +413,33 @@ pub fn show_peers(node: &Node) -> Value { /// peers, `sqi` for sessions). Reproduces the on-loop key insertion order /// exactly. `path_mtu` is emitted (inside the leading literal) only when /// present (session-layer); for peers it is `None` and omitted. +/// Render a peer's path rows as the `paths` array of `show_peers`. +fn render_peer_paths(paths: &[super::snapshot::PeerPathRow]) -> Value { + Value::Array( + paths + .iter() + .map(|p| { + json!({ + "transport_id": p.transport_id, + "transport": p.transport, + "transport_type": p.transport_type, + "addr": p.addr, + "state": p.state, + "active": p.active, + "remote_active": p.remote_active, + "role": p.role, + "pinned": p.pinned, + "last_rtt_ms": p.last_rtt_ms, + "min_rtt_ms": p.min_rtt_ms, + "rtt_samples": p.rtt_samples, + "etx": p.etx, + "score": p.score, + }) + }) + .collect(), + ) +} + fn render_entity_mmp(mmp: &super::snapshot::EntityMmp, quality_key: &str) -> Value { // The on-loop `show_sessions` block places loss_rate/etx/goodput_bps/ // delivery ratios/path_mtu in the leading json! literal, while `show_peers` @@ -504,6 +533,8 @@ pub(crate) fn show_peers_from_handle(handle: &super::read_handle::ControlReadHan } } + peer_json["paths"] = render_peer_paths(&peer.paths); + if let Some(depth) = peer.tree_depth { peer_json["tree_depth"] = json!(depth); } diff --git a/src/control/snapshot.rs b/src/control/snapshot.rs index a5e7dec6..8ca61ccf 100644 --- a/src/control/snapshot.rs +++ b/src/control/snapshot.rs @@ -603,6 +603,34 @@ pub(crate) struct EntityMmp { pub path_mtu: Option, } +/// One path to a peer in `show_peers`: the transport binding it runs over +/// and the selection state that decides whether it carries traffic. The +/// per-peer `fipsctl path show` emits the same fields plus the +/// now-relative liveness ages, which a tick-published snapshot cannot carry. +#[derive(Clone, PartialEq)] +pub(crate) struct PeerPathRow { + pub transport_id: u32, + /// Transport instance name (`cable`, `main`), when it has one. + pub transport: Option, + /// Transport type name, present only when the transport handle is found. + pub transport_type: Option, + pub addr: String, + /// `probing` / `live` / `suspect` / `dead`. + pub state: String, + /// Whether this is the path we currently send on. + pub active: bool, + /// Whether the peer last said it sends on this path. + pub remote_active: bool, + /// `normal` / `backup`. + pub role: String, + pub pinned: bool, + pub last_rtt_ms: Option, + pub min_rtt_ms: Option, + pub rtt_samples: u32, + pub etx: f64, + pub score: Option, +} + /// Link-layer stat counters for a peer in `show_peers`. #[derive(Clone, PartialEq)] pub(crate) struct PeerLinkStats { @@ -668,6 +696,9 @@ pub(crate) struct PeerRow { /// the kernel chooses on both sides. pub probe_bind: Option, pub link_info: Option, + /// Every path to the peer, in the peer's own order (the active path + /// first is not guaranteed; `active` marks it). + pub paths: Vec, pub tree_depth: Option, /// `effective_depth = tree_depth + link_cost` — the same quantity /// `evaluate_parent` ranks parent candidates on. `None` when the peer is diff --git a/src/node/mod.rs b/src/node/mod.rs index ae2ce3dd..bda9a946 100644 --- a/src/node/mod.rs +++ b/src/node/mod.rs @@ -2367,6 +2367,7 @@ impl Node { .map(|sa| sa.ip()) .filter(|ip| !ip.is_unspecified()), link_info, + paths: self.project_peer_paths(peer), tree_depth: peer.coords().map(|c| c.depth()), effective_depth, stats: snap::PeerLinkStats { @@ -2680,6 +2681,37 @@ impl Node { id } + /// Project every path to `peer` into the `show_peers` rows shared by the + /// on-loop query and the tick-published snapshot. + pub(crate) fn project_peer_paths( + &self, + peer: &ActivePeer, + ) -> Vec { + let active = peer.transport_id(); + peer.paths() + .iter() + .map(|path| { + let handle = self.transports.get(&path.transport_id()); + crate::control::snapshot::PeerPathRow { + transport_id: path.transport_id().as_u32(), + transport: handle.and_then(|t| t.name().map(str::to_string)), + transport_type: handle.map(|t| t.transport_type().name.to_string()), + addr: path.addr().to_string(), + state: format!("{:?}", path.state()).to_lowercase(), + active: Some(path.transport_id()) == active, + remote_active: path.remote_active(), + role: format!("{:?}", path.role()).to_lowercase(), + pinned: path.pinned(), + last_rtt_ms: path.last_rtt_ms(), + min_rtt_ms: path.min_rtt_ms(), + rtt_samples: path.rtt_samples(), + etx: path.etx(), + score: path.score(), + } + }) + .collect() + } + /// Get a transport by ID. pub fn get_transport(&self, id: &TransportId) -> Option<&TransportHandle> { self.transports.get(id) diff --git a/src/node/tests/multi_path.rs b/src/node/tests/multi_path.rs index c4a27b98..aca9b4f2 100644 --- a/src/node/tests/multi_path.rs +++ b/src/node/tests/multi_path.rs @@ -1438,6 +1438,55 @@ async fn fipsctl_path_show_pin_and_unpin_go_through_the_control_api() { ); } +/// `show_peers` carries every path under its peer, from both the on-loop +/// query and the tick-published snapshot fipstop reads, so the Peers tab +/// can draw the transports a peer is reachable over without a per-peer +/// `path show` round trip. +#[tokio::test] +async fn show_peers_lists_every_path_on_and_off_loop() { + let (mut nodes, _wifi_0, _wifi_1) = pair_with_wifi_live().await; + let addr_0 = *nodes[0].node.node_addr(); + + let check = |peers: &serde_json::Value| { + let peer = peers["peers"] + .as_array() + .unwrap() + .iter() + .find(|p| p["node_addr"] == hex::encode(addr_0.as_bytes())) + .expect("peer 0 listed"); + let paths = peer["paths"].as_array().expect("paths array"); + assert_eq!(paths.len(), 2, "both transports listed"); + assert_eq!( + paths.iter().filter(|p| p["active"] == true).count(), + 1, + "exactly one active path" + ); + assert!(paths.iter().all(|p| p["state"] == "live")); + assert!(paths.iter().all(|p| p["transport_type"] == "loopback")); + assert!(paths.iter().all(|p| p["addr"].is_string())); + let ids: std::collections::HashSet = paths + .iter() + .map(|p| p["transport_id"].as_u64().unwrap()) + .collect(); + assert!( + ids.contains(&u64::from(wifi().as_u32())), + "wifi path listed" + ); + }; + + check(&crate::control::queries::show_peers(&nodes[1].node)); + + nodes[1].node.record_stats_history(); + let handle = nodes[1].node.control_read_handle(); + let off_loop = crate::control::queries::show_peers_from_handle(&handle); + check(&off_loop); + assert_eq!( + serde_json::to_string(&crate::control::queries::show_peers(&nodes[1].node)).unwrap(), + serde_json::to_string(&off_loop).unwrap(), + "off-loop show_peers matches on-loop, paths included" + ); +} + #[test] fn udp_interface_config_parses() { let cfg: crate::config::UdpConfig = serde_yaml::from_str("interface: en0\n").unwrap();