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