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    pub overlay_short: String,
153    pub ethereum_short: String,
154    pub underlays: Vec<UnderlayRow>,
155    pub inbound: u64,
156    pub outbound: u64,
157    pub reachability: ReachabilityStatus,
158    pub network_availability: AvailabilityStatus,
159}
160
161pub struct Network {
162    network_rx: watch::Receiver<NetworkSnapshot>,
163    topology_rx: watch::Receiver<TopologySnapshot>,
164    network: NetworkSnapshot,
165    topology: TopologySnapshot,
166    /// Stability tracking. `last_seen_reachability` is the value we
167    /// most recently observed; `reachability_changed_at` is when we
168    /// first saw it. `Instant::now() - reachability_changed_at` is
169    /// the "stable for X" window the tooltip displays.
170    last_seen_reachability: Option<String>,
171    reachability_changed_at: Option<Instant>,
172}
173
174impl Network {
175    pub fn new(
176        network_rx: watch::Receiver<NetworkSnapshot>,
177        topology_rx: watch::Receiver<TopologySnapshot>,
178    ) -> Self {
179        let network = network_rx.borrow().clone();
180        let topology = topology_rx.borrow().clone();
181        Self {
182            network_rx,
183            topology_rx,
184            network,
185            topology,
186            last_seen_reachability: None,
187            reachability_changed_at: None,
188        }
189    }
190
191    fn pull_latest(&mut self) {
192        self.network = self.network_rx.borrow().clone();
193        self.topology = self.topology_rx.borrow().clone();
194        // Stability bookkeeping: if the reachability string changed
195        // since last tick, reset the changed-at timestamp.
196        let current = self
197            .topology
198            .topology
199            .as_ref()
200            .map(|t| t.reachability.clone());
201        if current != self.last_seen_reachability {
202            self.last_seen_reachability = current;
203            self.reachability_changed_at = Some(Instant::now());
204        }
205    }
206
207    /// Pure, snapshot-driven view computation. Exposed for snapshot
208    /// tests.
209    pub fn view_for(network: &NetworkSnapshot, topology: &TopologySnapshot) -> NetworkView {
210        let addresses = network.addresses.as_ref();
211        let topo = topology.topology.as_ref();
212        let underlays = addresses.map(underlay_rows).unwrap_or_default();
213        let (inbound, outbound) = topo.map(peer_direction_counts).unwrap_or((0, 0));
214        NetworkView {
215            overlay_short: addresses
216                .map(|a| short_hex(&a.overlay))
217                .unwrap_or_else(|| "—".into()),
218            ethereum_short: addresses
219                .map(|a| short_hex(&a.ethereum))
220                .unwrap_or_else(|| "—".into()),
221            underlays,
222            inbound,
223            outbound,
224            reachability: topo
225                .map(|t| ReachabilityStatus::from_api(&t.reachability))
226                .unwrap_or(ReachabilityStatus::NotLoaded),
227            network_availability: topo
228                .map(|t| AvailabilityStatus::from_api(&t.network_availability))
229                .unwrap_or(AvailabilityStatus::NotLoaded),
230        }
231    }
232}
233
234fn underlay_rows(a: &Addresses) -> Vec<UnderlayRow> {
235    a.underlay
236        .iter()
237        .map(|m| UnderlayRow {
238            multiaddr: m.clone(),
239            kind: classify_multiaddr(m),
240        })
241        .collect()
242}
243
244/// Classify a multiaddr as Public / Private / Unknown by parsing the
245/// `/ip4/x.x.x.x` or `/ip6/...` segment. RFC 1918, link-local, and
246/// loopback ranges count as Private; DNS/hostname multiaddrs we leave
247/// as Unknown because we can't tell without resolving.
248pub fn classify_multiaddr(m: &str) -> UnderlayKind {
249    let parts: Vec<&str> = m.split('/').collect();
250    // Look for /ip4/x.x.x.x or /ip6/x... in the protocol stack.
251    let mut i = 0;
252    while i + 1 < parts.len() {
253        match parts[i] {
254            "ip4" => return classify_ipv4(parts[i + 1]),
255            "ip6" => return classify_ipv6(parts[i + 1]),
256            "dns" | "dns4" | "dns6" | "dnsaddr" => return UnderlayKind::Unknown,
257            _ => {}
258        }
259        i += 1;
260    }
261    UnderlayKind::Unknown
262}
263
264fn classify_ipv4(addr: &str) -> UnderlayKind {
265    let octets: Vec<u8> = match addr
266        .split('.')
267        .map(|s| s.parse::<u8>())
268        .collect::<Result<Vec<_>, _>>()
269    {
270        Ok(o) if o.len() == 4 => o,
271        _ => return UnderlayKind::Unknown,
272    };
273    // RFC 1918, loopback, link-local, CGNAT.
274    let is_private = matches!(octets[0], 10)
275        || (octets[0] == 172 && (16..=31).contains(&octets[1]))
276        || (octets[0] == 192 && octets[1] == 168)
277        || octets[0] == 127
278        || (octets[0] == 169 && octets[1] == 254)
279        || (octets[0] == 100 && (64..=127).contains(&octets[1]))
280        || octets[0] == 0;
281    if is_private {
282        UnderlayKind::Private
283    } else {
284        UnderlayKind::Public
285    }
286}
287
288fn classify_ipv6(addr: &str) -> UnderlayKind {
289    let lower = addr.to_lowercase();
290    // Loopback ::1.
291    if lower == "::1" {
292        return UnderlayKind::Private;
293    }
294    // Unique-local fc00::/7 (any first hextet starting with fc/fd).
295    if lower.starts_with("fc") || lower.starts_with("fd") {
296        return UnderlayKind::Private;
297    }
298    // Link-local fe80::/10.
299    if lower.starts_with("fe8")
300        || lower.starts_with("fe9")
301        || lower.starts_with("fea")
302        || lower.starts_with("feb")
303    {
304        return UnderlayKind::Private;
305    }
306    UnderlayKind::Public
307}
308
309fn peer_direction_counts(t: &Topology) -> (u64, u64) {
310    let mut inbound = 0u64;
311    let mut outbound = 0u64;
312    for b in &t.bins {
313        for p in &b.connected_peers {
314            if let Some(m) = &p.metrics {
315                match m.session_connection_direction.as_str() {
316                    "inbound" => inbound += 1,
317                    "outbound" => outbound += 1,
318                    _ => {}
319                }
320            }
321        }
322    }
323    (inbound, outbound)
324}
325
326fn short_hex(s: &str) -> String {
327    let trimmed = s.trim_start_matches("0x");
328    if trimmed.len() > 12 {
329        format!("{}…{}", &trimmed[..6], &trimmed[trimmed.len() - 4..])
330    } else {
331        trimmed.to_string()
332    }
333}
334
335/// Format a stability duration as `Xh Ym`, `Xm Ys`, or `Xs`. Used by
336/// the renderer; not part of [`NetworkView`] because it depends on
337/// wall-clock state the snapshot tests can't pin.
338pub fn format_stability(d: Duration) -> String {
339    let secs = d.as_secs();
340    if secs >= 3_600 {
341        let h = secs / 3_600;
342        let m = (secs % 3_600) / 60;
343        format!("{h}h {m:>2}m")
344    } else if secs >= 60 {
345        let m = secs / 60;
346        let s = secs % 60;
347        format!("{m}m {s:>2}s")
348    } else {
349        format!("{secs}s")
350    }
351}
352
353impl Component for Network {
354    fn update(&mut self, action: Action) -> Result<Option<Action>> {
355        if matches!(action, Action::Tick) {
356            self.pull_latest();
357        }
358        Ok(None)
359    }
360
361    fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> {
362        let chunks = Layout::vertical([
363            Constraint::Length(3), // header
364            Constraint::Length(4), // identity
365            Constraint::Length(4), // connections + reachability
366            Constraint::Min(0),    // public addresses
367            Constraint::Length(1), // footer
368        ])
369        .split(area);
370
371        // Header
372        let header_l1 = Line::from(vec![Span::styled(
373            "NETWORK / NAT",
374            Style::default().add_modifier(Modifier::BOLD),
375        )]);
376        let mut header_l2 = Vec::new();
377        let t = theme::active();
378        if let Some(err) = &self.network.last_error {
379            let (color, msg) = theme::classify_header_error(err);
380            header_l2.push(Span::styled(msg, Style::default().fg(color)));
381        } else if !self.network.is_loaded() {
382            header_l2.push(Span::styled(
383                format!("{} loading…", theme::spinner_glyph()),
384                Style::default().fg(t.dim),
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(
399                    "  overlay   ",
400                    Style::default().add_modifier(Modifier::BOLD),
401                ),
402                Span::styled(view.overlay_short.clone(), Style::default().fg(t.info)),
403            ]),
404            Line::from(vec![
405                Span::styled(
406                    "  ethereum  ",
407                    Style::default().add_modifier(Modifier::BOLD),
408                ),
409                Span::styled(view.ethereum_short.clone(), Style::default().fg(t.info)),
410            ]),
411        ];
412        frame.render_widget(
413            Paragraph::new(identity).block(Block::default().borders(Borders::BOTTOM)),
414            chunks[1],
415        );
416
417        // Connections + reachability
418        let stability = self
419            .reachability_changed_at
420            .map(|t| format_stability(Instant::now().saturating_duration_since(t)))
421            .unwrap_or_else(|| "—".into());
422        let conns = vec![
423            Line::from(vec![
424                Span::styled(
425                    "  inbound   ",
426                    Style::default().add_modifier(Modifier::BOLD),
427                ),
428                Span::raw(format!("{:<6}", view.inbound)),
429                Span::styled("outbound  ", Style::default().add_modifier(Modifier::BOLD)),
430                Span::raw(format!("{}", view.outbound)),
431            ]),
432            Line::from(vec![
433                Span::styled(
434                    "  reachable ",
435                    Style::default().add_modifier(Modifier::BOLD),
436                ),
437                Span::styled(
438                    view.reachability.label(),
439                    Style::default()
440                        .fg(view.reachability.color())
441                        .add_modifier(Modifier::BOLD),
442                ),
443                Span::styled(
444                    format!("  (stable for {stability})"),
445                    Style::default().fg(t.dim),
446                ),
447            ]),
448            Line::from(vec![
449                Span::styled(
450                    "  network   ",
451                    Style::default().add_modifier(Modifier::BOLD),
452                ),
453                Span::styled(
454                    view.network_availability.label(),
455                    Style::default()
456                        .fg(view.network_availability.color())
457                        .add_modifier(Modifier::BOLD),
458                ),
459            ]),
460        ];
461        frame.render_widget(
462            Paragraph::new(conns).block(Block::default().borders(Borders::BOTTOM)),
463            chunks[2],
464        );
465
466        // Public addresses
467        let mut addr_lines: Vec<Line> = vec![Line::from(Span::styled(
468            "  PUBLIC ADDRESSES",
469            Style::default().fg(t.dim).add_modifier(Modifier::BOLD),
470        ))];
471        if view.underlays.is_empty() {
472            addr_lines.push(Line::from(Span::styled(
473                "  (no addresses reported)",
474                Style::default().fg(t.dim).add_modifier(Modifier::ITALIC),
475            )));
476        } else {
477            for u in &view.underlays {
478                let (style, badge) = match u.kind {
479                    UnderlayKind::Public => (Style::default().fg(t.pass), " PUB "),
480                    UnderlayKind::Private => (Style::default().fg(t.dim), " PRIV"),
481                    UnderlayKind::Unknown => (Style::default().fg(t.warn), " ??? "),
482                };
483                addr_lines.push(Line::from(vec![
484                    Span::raw("  "),
485                    Span::styled(format!("[{badge}] "), style),
486                    Span::styled(u.multiaddr.clone(), style),
487                ]));
488            }
489            addr_lines.push(Line::from(""));
490            addr_lines.push(Line::from(Span::styled(
491                "  External port-check + relay candidates require services Bee doesn't expose;",
492                Style::default().fg(t.dim).add_modifier(Modifier::ITALIC),
493            )));
494            addr_lines.push(Line::from(Span::styled(
495                "  use `nmap -p 1634 <ip>` from a separate machine to confirm public reachability.",
496                Style::default().fg(t.dim).add_modifier(Modifier::ITALIC),
497            )));
498        }
499        frame.render_widget(Paragraph::new(addr_lines), chunks[3]);
500
501        // Footer
502        frame.render_widget(
503            Paragraph::new(Line::from(vec![
504                Span::styled(" Tab ", Style::default().fg(Color::Black).bg(Color::White)),
505                Span::raw(" switch screen  "),
506                Span::styled(" ? ", Style::default().fg(Color::Black).bg(Color::White)),
507                Span::raw(" help  "),
508                Span::styled(" q ", Style::default().fg(Color::Black).bg(Color::White)),
509                Span::raw(" quit  "),
510                Span::styled(
511                    "isReachable flickers under symmetric NAT — watch the stability window",
512                    Style::default().fg(t.dim),
513                ),
514            ])),
515            chunks[4],
516        );
517
518        Ok(())
519    }
520}
521
522#[cfg(test)]
523mod tests {
524    use super::*;
525
526    #[test]
527    fn classify_ipv4_public_and_private() {
528        assert_eq!(classify_ipv4("8.8.8.8"), UnderlayKind::Public);
529        assert_eq!(classify_ipv4("77.123.45.6"), UnderlayKind::Public);
530        assert_eq!(classify_ipv4("10.0.0.1"), UnderlayKind::Private);
531        assert_eq!(classify_ipv4("172.16.0.1"), UnderlayKind::Private);
532        assert_eq!(classify_ipv4("172.31.0.1"), UnderlayKind::Private);
533        assert_eq!(classify_ipv4("192.168.1.1"), UnderlayKind::Private);
534        assert_eq!(classify_ipv4("127.0.0.1"), UnderlayKind::Private);
535        assert_eq!(classify_ipv4("169.254.1.1"), UnderlayKind::Private);
536        assert_eq!(classify_ipv4("100.64.0.1"), UnderlayKind::Private);
537    }
538
539    #[test]
540    fn classify_ipv4_invalid_is_unknown() {
541        assert_eq!(classify_ipv4("not-an-ip"), UnderlayKind::Unknown);
542        assert_eq!(classify_ipv4("1.2.3"), UnderlayKind::Unknown);
543    }
544
545    #[test]
546    fn classify_ipv6_public_and_private() {
547        assert_eq!(classify_ipv6("2a01:4f8:1:2::1"), UnderlayKind::Public);
548        assert_eq!(classify_ipv6("::1"), UnderlayKind::Private);
549        assert_eq!(classify_ipv6("fc00::1"), UnderlayKind::Private);
550        assert_eq!(classify_ipv6("fd12::1"), UnderlayKind::Private);
551        assert_eq!(classify_ipv6("fe80::1"), UnderlayKind::Private);
552    }
553
554    #[test]
555    fn classify_multiaddr_picks_protocol() {
556        assert_eq!(
557            classify_multiaddr("/ip4/77.123.45.6/tcp/1634/p2p/16Uiu..."),
558            UnderlayKind::Public
559        );
560        assert_eq!(
561            classify_multiaddr("/ip4/192.168.1.5/tcp/1634/p2p/16Uiu..."),
562            UnderlayKind::Private
563        );
564        assert_eq!(
565            classify_multiaddr("/dns4/bee.example.com/tcp/1634"),
566            UnderlayKind::Unknown
567        );
568        // No /ip4 or /ip6 segment at all.
569        assert_eq!(classify_multiaddr("/p2p/16Uiu..."), UnderlayKind::Unknown);
570    }
571
572    #[test]
573    fn reachability_from_api_known_strings() {
574        assert_eq!(
575            ReachabilityStatus::from_api("Public"),
576            ReachabilityStatus::Public
577        );
578        assert_eq!(
579            ReachabilityStatus::from_api("Private"),
580            ReachabilityStatus::Private
581        );
582        assert_eq!(
583            ReachabilityStatus::from_api(""),
584            ReachabilityStatus::NotLoaded
585        );
586        assert_eq!(
587            ReachabilityStatus::from_api("Symmetric"),
588            ReachabilityStatus::Other("Symmetric".into())
589        );
590    }
591
592    #[test]
593    fn format_stability_unit_thresholds() {
594        assert_eq!(format_stability(Duration::from_secs(5)), "5s");
595        assert_eq!(format_stability(Duration::from_secs(125)), "2m  5s");
596        assert_eq!(format_stability(Duration::from_secs(3_725)), "1h  2m");
597    }
598}