Skip to main content

bee_tui/components/
network.rs

1//! S7 — Network / NAT screen (`docs/PLAN.md` § 8.S7).
2//!
3//! Answers "I have peers but I'm unreachable" (bee#4194) by surfacing
4//! what the AutoNAT subsystem and `/addresses` already report:
5//!
6//! - **Public addresses** advertised by the node — every underlay
7//!   multiaddr from `/addresses`. Loopback and private RFC1918
8//!   ranges are dimmed so it's obvious which entries are routable
9//!   from outside.
10//! - **Inbound vs outbound** connection counts derived from each
11//!   peer's `MetricSnapshotView::session_connection_direction`.
12//!   This is the numbers operators want to see — a node that's
13//!   only making outbound connections is reachable in name only.
14//! - **Reachability + network availability** read off the
15//!   `/topology` stream's AutoNAT strings, with a *stability
16//!   window* ("stable for 9m") computed in the component. The
17//!   tooltip captures the truth that `isReachable` flickers under
18//!   symmetric NAT, so operators don't chase phantom flap.
19//!
20//! The truth-telling extras from PLAN (external port-check, relay
21//! candidates) require either a separate observer service or fields
22//! Bee doesn't expose today; the screen footer documents that gap.
23//!
24//! Render path delegates to the pure [`Network::view_for`] so the
25//! snapshot tests in `tests/s7_network_view.rs` pin the
26//! reachability ladder and the public/private underlay
27//! classification without launching a TUI.
28
29use std::time::{Duration, Instant};
30
31use color_eyre::Result;
32use ratatui::{
33    Frame,
34    layout::{Constraint, Layout, Rect},
35    style::{Color, Modifier, Style},
36    text::{Line, Span},
37    widgets::{Block, Borders, Paragraph},
38};
39use tokio::sync::watch;
40
41use super::Component;
42use crate::action::Action;
43use crate::watch::{NetworkSnapshot, TopologySnapshot};
44
45use bee::debug::{Addresses, Topology};
46
47/// Tri-state for the AutoNAT-reported reachability string.
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub enum ReachabilityStatus {
50    /// Bee has not reported a reachability string yet (older builds
51    /// or topology not loaded).
52    NotLoaded,
53    /// AutoNAT says the node is publicly reachable.
54    Public,
55    /// AutoNAT says the node is behind NAT and not directly reachable.
56    Private,
57    /// Some other string Bee surfaced — kept verbatim so we don't
58    /// silently misclassify future API additions.
59    Other(String),
60}
61
62impl ReachabilityStatus {
63    fn from_api(s: &str) -> Self {
64        match s {
65            "" => Self::NotLoaded,
66            "Public" => Self::Public,
67            "Private" => Self::Private,
68            other => Self::Other(other.into()),
69        }
70    }
71    fn color(&self) -> Color {
72        match self {
73            Self::NotLoaded => Color::DarkGray,
74            Self::Public => Color::Green,
75            Self::Private => Color::Yellow,
76            Self::Other(_) => Color::DarkGray,
77        }
78    }
79    fn label(&self) -> String {
80        match self {
81            Self::NotLoaded => "(unknown)".into(),
82            Self::Public => "Public".into(),
83            Self::Private => "Private".into(),
84            Self::Other(s) => s.clone(),
85        }
86    }
87}
88
89/// Tri-state for `networkAvailability`. Less ambiguous than
90/// reachability — Bee only reports `Available` / `Unavailable` /
91/// empty.
92#[derive(Debug, Clone, PartialEq, Eq)]
93pub enum AvailabilityStatus {
94    NotLoaded,
95    Available,
96    Unavailable,
97    Other(String),
98}
99
100impl AvailabilityStatus {
101    fn from_api(s: &str) -> Self {
102        match s {
103            "" => Self::NotLoaded,
104            "Available" => Self::Available,
105            "Unavailable" => Self::Unavailable,
106            other => Self::Other(other.into()),
107        }
108    }
109    fn color(&self) -> Color {
110        match self {
111            Self::NotLoaded => Color::DarkGray,
112            Self::Available => Color::Green,
113            Self::Unavailable => Color::Red,
114            Self::Other(_) => Color::DarkGray,
115        }
116    }
117    fn label(&self) -> String {
118        match self {
119            Self::NotLoaded => "(unknown)".into(),
120            Self::Available => "Available".into(),
121            Self::Unavailable => "Unavailable".into(),
122            Self::Other(s) => s.clone(),
123        }
124    }
125}
126
127/// Per-underlay public/private classification — used to dim non-
128/// routable rows so operators see which addresses are actually
129/// advertised to the network.
130#[derive(Debug, Clone, Copy, PartialEq, Eq)]
131pub enum UnderlayKind {
132    /// Routable IPv4/IPv6 — what the node advertises as a peer-dial
133    /// address.
134    Public,
135    /// RFC 1918 / link-local / loopback — visible inside the host
136    /// but not directly dialable from the open internet.
137    Private,
138    /// Couldn't classify (hostname, DNS multiaddr, exotic transport).
139    Unknown,
140}
141
142#[derive(Debug, Clone, PartialEq, Eq)]
143pub struct UnderlayRow {
144    pub multiaddr: String,
145    pub kind: UnderlayKind,
146}
147
148/// Aggregated view fed to renderer and snapshot tests.
149#[derive(Debug, Clone, PartialEq, Eq)]
150pub struct NetworkView {
151    pub overlay_short: String,
152    pub ethereum_short: String,
153    pub underlays: Vec<UnderlayRow>,
154    pub inbound: u64,
155    pub outbound: u64,
156    pub reachability: ReachabilityStatus,
157    pub network_availability: AvailabilityStatus,
158}
159
160pub struct Network {
161    network_rx: watch::Receiver<NetworkSnapshot>,
162    topology_rx: watch::Receiver<TopologySnapshot>,
163    network: NetworkSnapshot,
164    topology: TopologySnapshot,
165    /// Stability tracking. `last_seen_reachability` is the value we
166    /// most recently observed; `reachability_changed_at` is when we
167    /// first saw it. `Instant::now() - reachability_changed_at` is
168    /// the "stable for X" window the tooltip displays.
169    last_seen_reachability: Option<String>,
170    reachability_changed_at: Option<Instant>,
171}
172
173impl Network {
174    pub fn new(
175        network_rx: watch::Receiver<NetworkSnapshot>,
176        topology_rx: watch::Receiver<TopologySnapshot>,
177    ) -> Self {
178        let network = network_rx.borrow().clone();
179        let topology = topology_rx.borrow().clone();
180        Self {
181            network_rx,
182            topology_rx,
183            network,
184            topology,
185            last_seen_reachability: None,
186            reachability_changed_at: None,
187        }
188    }
189
190    fn pull_latest(&mut self) {
191        self.network = self.network_rx.borrow().clone();
192        self.topology = self.topology_rx.borrow().clone();
193        // Stability bookkeeping: if the reachability string changed
194        // since last tick, reset the changed-at timestamp.
195        let current = self
196            .topology
197            .topology
198            .as_ref()
199            .map(|t| t.reachability.clone());
200        if current != self.last_seen_reachability {
201            self.last_seen_reachability = current;
202            self.reachability_changed_at = Some(Instant::now());
203        }
204    }
205
206    /// Pure, snapshot-driven view computation. Exposed for snapshot
207    /// tests.
208    pub fn view_for(network: &NetworkSnapshot, topology: &TopologySnapshot) -> NetworkView {
209        let addresses = network.addresses.as_ref();
210        let topo = topology.topology.as_ref();
211        let underlays = addresses.map(underlay_rows).unwrap_or_default();
212        let (inbound, outbound) = topo.map(peer_direction_counts).unwrap_or((0, 0));
213        NetworkView {
214            overlay_short: addresses
215                .map(|a| short_hex(&a.overlay))
216                .unwrap_or_else(|| "—".into()),
217            ethereum_short: addresses
218                .map(|a| short_hex(&a.ethereum))
219                .unwrap_or_else(|| "—".into()),
220            underlays,
221            inbound,
222            outbound,
223            reachability: topo
224                .map(|t| ReachabilityStatus::from_api(&t.reachability))
225                .unwrap_or(ReachabilityStatus::NotLoaded),
226            network_availability: topo
227                .map(|t| AvailabilityStatus::from_api(&t.network_availability))
228                .unwrap_or(AvailabilityStatus::NotLoaded),
229        }
230    }
231}
232
233fn underlay_rows(a: &Addresses) -> Vec<UnderlayRow> {
234    a.underlay
235        .iter()
236        .map(|m| UnderlayRow {
237            multiaddr: m.clone(),
238            kind: classify_multiaddr(m),
239        })
240        .collect()
241}
242
243/// Classify a multiaddr as Public / Private / Unknown by parsing the
244/// `/ip4/x.x.x.x` or `/ip6/...` segment. RFC 1918, link-local, and
245/// loopback ranges count as Private; DNS/hostname multiaddrs we leave
246/// as Unknown because we can't tell without resolving.
247pub fn classify_multiaddr(m: &str) -> UnderlayKind {
248    let parts: Vec<&str> = m.split('/').collect();
249    // Look for /ip4/x.x.x.x or /ip6/x... in the protocol stack.
250    let mut i = 0;
251    while i + 1 < parts.len() {
252        match parts[i] {
253            "ip4" => return classify_ipv4(parts[i + 1]),
254            "ip6" => return classify_ipv6(parts[i + 1]),
255            "dns" | "dns4" | "dns6" | "dnsaddr" => return UnderlayKind::Unknown,
256            _ => {}
257        }
258        i += 1;
259    }
260    UnderlayKind::Unknown
261}
262
263fn classify_ipv4(addr: &str) -> UnderlayKind {
264    let octets: Vec<u8> = match addr
265        .split('.')
266        .map(|s| s.parse::<u8>())
267        .collect::<Result<Vec<_>, _>>()
268    {
269        Ok(o) if o.len() == 4 => o,
270        _ => return UnderlayKind::Unknown,
271    };
272    // RFC 1918, loopback, link-local, CGNAT.
273    let is_private = matches!(octets[0], 10)
274        || (octets[0] == 172 && (16..=31).contains(&octets[1]))
275        || (octets[0] == 192 && octets[1] == 168)
276        || octets[0] == 127
277        || (octets[0] == 169 && octets[1] == 254)
278        || (octets[0] == 100 && (64..=127).contains(&octets[1]))
279        || octets[0] == 0;
280    if is_private {
281        UnderlayKind::Private
282    } else {
283        UnderlayKind::Public
284    }
285}
286
287fn classify_ipv6(addr: &str) -> UnderlayKind {
288    let lower = addr.to_lowercase();
289    // Loopback ::1.
290    if lower == "::1" {
291        return UnderlayKind::Private;
292    }
293    // Unique-local fc00::/7 (any first hextet starting with fc/fd).
294    if lower.starts_with("fc") || lower.starts_with("fd") {
295        return UnderlayKind::Private;
296    }
297    // Link-local fe80::/10.
298    if lower.starts_with("fe8")
299        || lower.starts_with("fe9")
300        || lower.starts_with("fea")
301        || lower.starts_with("feb")
302    {
303        return UnderlayKind::Private;
304    }
305    UnderlayKind::Public
306}
307
308fn peer_direction_counts(t: &Topology) -> (u64, u64) {
309    let mut inbound = 0u64;
310    let mut outbound = 0u64;
311    for b in &t.bins {
312        for p in &b.connected_peers {
313            if let Some(m) = &p.metrics {
314                match m.session_connection_direction.as_str() {
315                    "inbound" => inbound += 1,
316                    "outbound" => outbound += 1,
317                    _ => {}
318                }
319            }
320        }
321    }
322    (inbound, outbound)
323}
324
325fn short_hex(s: &str) -> String {
326    let trimmed = s.trim_start_matches("0x");
327    if trimmed.len() > 12 {
328        format!("{}…{}", &trimmed[..6], &trimmed[trimmed.len() - 4..])
329    } else {
330        trimmed.to_string()
331    }
332}
333
334/// Format a stability duration as `Xh Ym`, `Xm Ys`, or `Xs`. Used by
335/// the renderer; not part of [`NetworkView`] because it depends on
336/// wall-clock state the snapshot tests can't pin.
337pub fn format_stability(d: Duration) -> String {
338    let secs = d.as_secs();
339    if secs >= 3_600 {
340        let h = secs / 3_600;
341        let m = (secs % 3_600) / 60;
342        format!("{h}h {m:>2}m")
343    } else if secs >= 60 {
344        let m = secs / 60;
345        let s = secs % 60;
346        format!("{m}m {s:>2}s")
347    } else {
348        format!("{secs}s")
349    }
350}
351
352impl Component for Network {
353    fn update(&mut self, action: Action) -> Result<Option<Action>> {
354        if matches!(action, Action::Tick) {
355            self.pull_latest();
356        }
357        Ok(None)
358    }
359
360    fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> {
361        let chunks = Layout::vertical([
362            Constraint::Length(3), // header
363            Constraint::Length(4), // identity
364            Constraint::Length(4), // connections + reachability
365            Constraint::Min(0),    // public addresses
366            Constraint::Length(1), // footer
367        ])
368        .split(area);
369
370        // Header
371        let header_l1 = Line::from(vec![Span::styled(
372            "NETWORK / NAT",
373            Style::default().add_modifier(Modifier::BOLD),
374        )]);
375        let mut header_l2 = Vec::new();
376        if let Some(err) = &self.network.last_error {
377            header_l2.push(Span::styled(
378                format!("addresses error: {err}"),
379                Style::default().fg(Color::Red),
380            ));
381        } else if !self.network.is_loaded() {
382            header_l2.push(Span::styled(
383                "loading…",
384                Style::default().fg(Color::DarkGray),
385            ));
386        }
387        frame.render_widget(
388            Paragraph::new(vec![header_l1, Line::from(header_l2)])
389                .block(Block::default().borders(Borders::BOTTOM)),
390            chunks[0],
391        );
392
393        let view = Self::view_for(&self.network, &self.topology);
394
395        // Identity: overlay + ethereum
396        let identity = vec![
397            Line::from(vec![
398                Span::styled("  overlay   ", Style::default().add_modifier(Modifier::BOLD)),
399                Span::styled(view.overlay_short.clone(), Style::default().fg(Color::Cyan)),
400            ]),
401            Line::from(vec![
402                Span::styled("  ethereum  ", Style::default().add_modifier(Modifier::BOLD)),
403                Span::styled(
404                    view.ethereum_short.clone(),
405                    Style::default().fg(Color::Cyan),
406                ),
407            ]),
408        ];
409        frame.render_widget(
410            Paragraph::new(identity).block(Block::default().borders(Borders::BOTTOM)),
411            chunks[1],
412        );
413
414        // Connections + reachability
415        let stability = self
416            .reachability_changed_at
417            .map(|t| format_stability(Instant::now().saturating_duration_since(t)))
418            .unwrap_or_else(|| "—".into());
419        let conns = vec![
420            Line::from(vec![
421                Span::styled("  inbound   ", Style::default().add_modifier(Modifier::BOLD)),
422                Span::raw(format!("{:<6}", view.inbound)),
423                Span::styled("outbound  ", Style::default().add_modifier(Modifier::BOLD)),
424                Span::raw(format!("{}", view.outbound)),
425            ]),
426            Line::from(vec![
427                Span::styled(
428                    "  reachable ",
429                    Style::default().add_modifier(Modifier::BOLD),
430                ),
431                Span::styled(
432                    view.reachability.label(),
433                    Style::default()
434                        .fg(view.reachability.color())
435                        .add_modifier(Modifier::BOLD),
436                ),
437                Span::styled(
438                    format!("  (stable for {stability})"),
439                    Style::default().fg(Color::DarkGray),
440                ),
441            ]),
442            Line::from(vec![
443                Span::styled("  network   ", Style::default().add_modifier(Modifier::BOLD)),
444                Span::styled(
445                    view.network_availability.label(),
446                    Style::default()
447                        .fg(view.network_availability.color())
448                        .add_modifier(Modifier::BOLD),
449                ),
450            ]),
451        ];
452        frame.render_widget(
453            Paragraph::new(conns).block(Block::default().borders(Borders::BOTTOM)),
454            chunks[2],
455        );
456
457        // Public addresses
458        let mut addr_lines: Vec<Line> = vec![Line::from(Span::styled(
459            "  PUBLIC ADDRESSES",
460            Style::default()
461                .fg(Color::DarkGray)
462                .add_modifier(Modifier::BOLD),
463        ))];
464        if view.underlays.is_empty() {
465            addr_lines.push(Line::from(Span::styled(
466                "  (no addresses reported)",
467                Style::default()
468                    .fg(Color::DarkGray)
469                    .add_modifier(Modifier::ITALIC),
470            )));
471        } else {
472            for u in &view.underlays {
473                let (style, badge) = match u.kind {
474                    UnderlayKind::Public => (Style::default().fg(Color::Green), " PUB "),
475                    UnderlayKind::Private => (Style::default().fg(Color::DarkGray), " PRIV"),
476                    UnderlayKind::Unknown => (Style::default().fg(Color::Yellow), " ??? "),
477                };
478                addr_lines.push(Line::from(vec![
479                    Span::raw("  "),
480                    Span::styled(format!("[{badge}] "), style),
481                    Span::styled(u.multiaddr.clone(), style),
482                ]));
483            }
484            addr_lines.push(Line::from(""));
485            addr_lines.push(Line::from(Span::styled(
486                "  External port-check + relay candidates require services Bee doesn't expose;",
487                Style::default()
488                    .fg(Color::DarkGray)
489                    .add_modifier(Modifier::ITALIC),
490            )));
491            addr_lines.push(Line::from(Span::styled(
492                "  use `nmap -p 1634 <ip>` from a separate machine to confirm public reachability.",
493                Style::default()
494                    .fg(Color::DarkGray)
495                    .add_modifier(Modifier::ITALIC),
496            )));
497        }
498        frame.render_widget(Paragraph::new(addr_lines), chunks[3]);
499
500        // Footer
501        frame.render_widget(
502            Paragraph::new(Line::from(vec![
503                Span::styled(" Tab ", Style::default().fg(Color::Black).bg(Color::White)),
504                Span::raw(" switch screen  "),
505                Span::styled(" q ", Style::default().fg(Color::Black).bg(Color::White)),
506                Span::raw(" quit  "),
507                Span::styled(
508                    "isReachable flickers under symmetric NAT — watch the stability window",
509                    Style::default().fg(Color::DarkGray),
510                ),
511            ])),
512            chunks[4],
513        );
514
515        Ok(())
516    }
517}
518
519#[cfg(test)]
520mod tests {
521    use super::*;
522
523    #[test]
524    fn classify_ipv4_public_and_private() {
525        assert_eq!(classify_ipv4("8.8.8.8"), UnderlayKind::Public);
526        assert_eq!(classify_ipv4("77.123.45.6"), UnderlayKind::Public);
527        assert_eq!(classify_ipv4("10.0.0.1"), UnderlayKind::Private);
528        assert_eq!(classify_ipv4("172.16.0.1"), UnderlayKind::Private);
529        assert_eq!(classify_ipv4("172.31.0.1"), UnderlayKind::Private);
530        assert_eq!(classify_ipv4("192.168.1.1"), UnderlayKind::Private);
531        assert_eq!(classify_ipv4("127.0.0.1"), UnderlayKind::Private);
532        assert_eq!(classify_ipv4("169.254.1.1"), UnderlayKind::Private);
533        assert_eq!(classify_ipv4("100.64.0.1"), UnderlayKind::Private);
534    }
535
536    #[test]
537    fn classify_ipv4_invalid_is_unknown() {
538        assert_eq!(classify_ipv4("not-an-ip"), UnderlayKind::Unknown);
539        assert_eq!(classify_ipv4("1.2.3"), UnderlayKind::Unknown);
540    }
541
542    #[test]
543    fn classify_ipv6_public_and_private() {
544        assert_eq!(classify_ipv6("2a01:4f8:1:2::1"), UnderlayKind::Public);
545        assert_eq!(classify_ipv6("::1"), UnderlayKind::Private);
546        assert_eq!(classify_ipv6("fc00::1"), UnderlayKind::Private);
547        assert_eq!(classify_ipv6("fd12::1"), UnderlayKind::Private);
548        assert_eq!(classify_ipv6("fe80::1"), UnderlayKind::Private);
549    }
550
551    #[test]
552    fn classify_multiaddr_picks_protocol() {
553        assert_eq!(
554            classify_multiaddr("/ip4/77.123.45.6/tcp/1634/p2p/16Uiu..."),
555            UnderlayKind::Public
556        );
557        assert_eq!(
558            classify_multiaddr("/ip4/192.168.1.5/tcp/1634/p2p/16Uiu..."),
559            UnderlayKind::Private
560        );
561        assert_eq!(
562            classify_multiaddr("/dns4/bee.example.com/tcp/1634"),
563            UnderlayKind::Unknown
564        );
565        // No /ip4 or /ip6 segment at all.
566        assert_eq!(classify_multiaddr("/p2p/16Uiu..."), UnderlayKind::Unknown);
567    }
568
569    #[test]
570    fn reachability_from_api_known_strings() {
571        assert_eq!(ReachabilityStatus::from_api("Public"), ReachabilityStatus::Public);
572        assert_eq!(
573            ReachabilityStatus::from_api("Private"),
574            ReachabilityStatus::Private
575        );
576        assert_eq!(
577            ReachabilityStatus::from_api(""),
578            ReachabilityStatus::NotLoaded
579        );
580        assert_eq!(
581            ReachabilityStatus::from_api("Symmetric"),
582            ReachabilityStatus::Other("Symmetric".into())
583        );
584    }
585
586    #[test]
587    fn format_stability_unit_thresholds() {
588        assert_eq!(format_stability(Duration::from_secs(5)), "5s");
589        assert_eq!(format_stability(Duration::from_secs(125)), "2m  5s");
590        assert_eq!(format_stability(Duration::from_secs(3_725)), "1h  2m");
591    }
592}