Files
fips/src/bin/fipstop/ui/transports.rs
oleksky 4e43cb81e9 Add Nym mixnet transport and single-container demo
Add an outbound-only Nym mixnet transport that tunnels FMP peer links
through a local nym-socks5-client SOCKS5 proxy into the Nym mixnet. It
structurally mirrors the Tor SOCKS5 transport (connection pool,
connect-on-send background promotion, FMP-v0 framing reused from TCP)
with the onion, inbound-listener, and control-port machinery removed.

Wires the transport through the full TransportHandle dispatch, NymConfig
(standard transport-instance pattern), and node instantiation, and
surfaces its counters in fipstop. Includes a mock SOCKS5 harness and unit
coverage for the address-parsing paths.

Also adds an isolated single-container example
(examples/sidecar-nostr-mixnet-relay/) demonstrating FIPS peering across
the mixnet end to end. No new crate dependencies: tokio_socks, socket2,
and futures are already pulled in by the Tor transport.
2026-06-13 19:38:01 +00:00

734 lines
26 KiB
Rust

use ratatui::Frame;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{
Block, Borders, Cell, Paragraph, Row, Scrollbar, ScrollbarOrientation, ScrollbarState, Table,
};
use crate::app::{App, SelectedTreeItem, Tab};
use super::helpers;
/// A single visible row in the tree view — either a transport or a nested link.
enum TreeRow {
Transport {
index: usize,
transport_id: u64,
link_count: usize,
},
Link {
index: usize,
is_last: bool,
},
}
pub fn draw(frame: &mut Frame, app: &mut App, area: Rect) {
let transports = get_transports(app);
let links = get_links(app);
let tree_rows = build_tree_rows(&transports, &links, app);
// Update app state for navigation
app.tree_row_count = tree_rows.len();
update_selected_tree_item(app, &tree_rows);
if app.detail_view.is_some() {
let chunks = Layout::horizontal([Constraint::Percentage(40), Constraint::Percentage(60)])
.split(area);
draw_table(frame, app, chunks[0], &transports, &links, &tree_rows);
draw_detail(frame, app, chunks[1], &transports, &links, &tree_rows);
} else {
draw_table(frame, app, area, &transports, &links, &tree_rows);
}
}
fn get_transports(app: &App) -> Vec<serde_json::Value> {
app.data
.get(&Tab::Transports)
.and_then(|v| v.get("transports"))
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default()
}
fn get_links(app: &App) -> Vec<serde_json::Value> {
app.data
.get(&Tab::Links)
.and_then(|v| v.get("links"))
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default()
}
fn build_tree_rows(
transports: &[serde_json::Value],
links: &[serde_json::Value],
app: &App,
) -> Vec<TreeRow> {
let mut rows = Vec::new();
for (t_idx, transport) in transports.iter().enumerate() {
let tid = transport
.get("transport_id")
.and_then(|v| v.as_u64())
.unwrap_or(0);
// Gather links belonging to this transport
let transport_links: Vec<(usize, &serde_json::Value)> = links
.iter()
.enumerate()
.filter(|(_, l)| l.get("transport_id").and_then(|v| v.as_u64()) == Some(tid))
.collect();
rows.push(TreeRow::Transport {
index: t_idx,
transport_id: tid,
link_count: transport_links.len(),
});
if app.expanded_transports.contains(&tid) {
let last_idx = transport_links.len().saturating_sub(1);
for (pos, (l_idx, _)) in transport_links.iter().enumerate() {
rows.push(TreeRow::Link {
index: *l_idx,
is_last: pos == last_idx,
});
}
}
}
rows
}
fn update_selected_tree_item(app: &mut App, tree_rows: &[TreeRow]) {
let selected = app
.table_states
.get(&Tab::Transports)
.and_then(|s| s.selected())
.unwrap_or(0);
app.selected_tree_item = match tree_rows.get(selected) {
Some(TreeRow::Transport { transport_id, .. }) => SelectedTreeItem::Transport(*transport_id),
Some(TreeRow::Link { .. }) => SelectedTreeItem::Link,
None => SelectedTreeItem::None,
};
}
fn draw_table(
frame: &mut Frame,
app: &mut App,
area: Rect,
transports: &[serde_json::Value],
links: &[serde_json::Value],
tree_rows: &[TreeRow],
) {
let header = Row::new(vec![
Cell::from("Transport / Link"),
Cell::from("State"),
Cell::from("Peer"),
Cell::from("Tx"),
Cell::from("Rx"),
])
.style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
);
let rows: Vec<Row> = tree_rows
.iter()
.map(|tree_row| match tree_row {
TreeRow::Transport {
index,
transport_id,
link_count,
} => {
let t = &transports[*index];
let indicator = if *link_count == 0 {
" "
} else if app.expanded_transports.contains(transport_id) {
"\u{25BC} " // ▼
} else {
"\u{25B6} " // ▶
};
let typ = helpers::str_field(t, "type");
let name = t.get("name").and_then(|v| v.as_str()).unwrap_or("");
let addr = t.get("local_addr").and_then(|v| v.as_str()).unwrap_or("");
let label = if !name.is_empty() {
format!("{indicator}{typ} {name}")
} else if typ == "tor" {
let mode = t
.get("tor_mode")
.and_then(|v| v.as_str())
.unwrap_or("socks5");
let onion_hint = t
.get("onion_address")
.and_then(|v| v.as_str())
.map(|a| {
let short = if a.len() > 16 { &a[..16] } else { a };
format!(" {short}..")
})
.unwrap_or_default();
format!("{indicator}tor({mode}){onion_hint}")
} else if !addr.is_empty() {
format!("{indicator}{typ} {addr}")
} else {
format!("{indicator}{typ} #{transport_id}")
};
let state = helpers::str_field(t, "state");
let tx = t
.get("stats")
.and_then(|s| s.get("packets_sent").or_else(|| s.get("frames_sent")))
.and_then(|v| v.as_u64())
.map(|n| n.to_string())
.unwrap_or_else(|| "-".into());
let rx = t
.get("stats")
.and_then(|s| s.get("packets_recv").or_else(|| s.get("frames_recv")))
.and_then(|v| v.as_u64())
.map(|n| n.to_string())
.unwrap_or_else(|| "-".into());
Row::new(vec![
Cell::from(label),
Cell::from(state.to_string()),
Cell::from(""),
Cell::from(tx),
Cell::from(rx),
])
.style(Style::default().fg(Color::White))
}
TreeRow::Link { index, is_last } => {
let link = &links[*index];
let tree_char = if *is_last {
"\u{2514}\u{2500}"
} else {
"\u{251C}\u{2500}"
}; // └─ or ├─
let dir = helpers::str_field(link, "direction");
let dir_short = match dir {
"Outbound" => "Out",
"Inbound" => "In",
other => other,
};
// Wide enough to render a full MAC (~17) or `hci0/MAC`
// (~22) without chopping mid-octet; the link detail view
// shows the untruncated address.
let addr = helpers::truncate_hex(helpers::str_field(link, "remote_addr"), 24);
let label = format!(" {tree_char} {dir_short} {addr}");
let state = helpers::str_field(link, "state");
let peer_name = lookup_peer_for_link(app, link)
.map(|p| helpers::str_field(&p, "display_name").to_string())
.unwrap_or_default();
Row::new(vec![
Cell::from(Span::styled(
label,
Style::default().fg(if dir == "Outbound" {
Color::Cyan
} else {
Color::Green
}),
)),
Cell::from(state.to_string()),
Cell::from(peer_name),
Cell::from(""),
Cell::from(""),
])
}
})
.collect();
let transport_count = transports.len();
let link_count: usize = tree_rows
.iter()
.filter(|r| matches!(r, TreeRow::Link { .. }))
.count();
let title = if link_count > 0 {
format!(" Transports ({transport_count}) Links ({link_count}) ")
} else {
format!(" Transports ({transport_count}) ")
};
let widths = [
Constraint::Min(28),
Constraint::Length(12),
Constraint::Length(14),
Constraint::Length(9),
Constraint::Length(9),
];
let table = Table::new(rows, widths)
.header(header)
.block(Block::default().borders(Borders::ALL).title(title))
.row_highlight_style(
Style::default()
.bg(Color::DarkGray)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol("\u{25B8} "); // ▸
let row_count = tree_rows.len();
let state = app.table_states.entry(Tab::Transports).or_default();
frame.render_stateful_widget(table, area, state);
if row_count > 0 {
let selected = state.selected().unwrap_or(0);
let mut scrollbar_state = ScrollbarState::new(row_count).position(selected);
frame.render_stateful_widget(
Scrollbar::new(ScrollbarOrientation::VerticalRight)
.begin_symbol(None)
.end_symbol(None),
area,
&mut scrollbar_state,
);
}
}
fn draw_detail(
frame: &mut Frame,
app: &App,
area: Rect,
transports: &[serde_json::Value],
links: &[serde_json::Value],
tree_rows: &[TreeRow],
) {
let selected = app
.table_states
.get(&Tab::Transports)
.and_then(|s| s.selected())
.unwrap_or(0);
let Some(tree_row) = tree_rows.get(selected) else {
let block = Block::default().borders(Borders::ALL).title(" Detail ");
let inner = block.inner(area);
frame.render_widget(block, area);
let msg = Paragraph::new(" No item selected").style(Style::default().fg(Color::DarkGray));
frame.render_widget(msg, inner);
return;
};
match tree_row {
TreeRow::Transport { index, .. } => {
draw_transport_detail(frame, app, area, &transports[*index]);
}
TreeRow::Link { index, .. } => {
draw_link_detail(frame, app, area, &links[*index]);
}
}
}
fn draw_transport_detail(frame: &mut Frame, app: &App, area: Rect, t: &serde_json::Value) {
let block = Block::default()
.borders(Borders::ALL)
.title(" Transport Detail ");
let inner = block.inner(area);
frame.render_widget(block, area);
let mut lines: Vec<Line> = vec![
helpers::section_header("Transport Info"),
helpers::kv_line("Transport ID", &helpers::u64_field(t, "transport_id")),
helpers::kv_line("Type", helpers::str_field(t, "type")),
helpers::kv_line("State", helpers::str_field(t, "state")),
helpers::kv_line("MTU", &helpers::u64_field(t, "mtu")),
];
if let Some(name) = t.get("name").and_then(|v| v.as_str()) {
lines.push(helpers::kv_line("Name", name));
}
if let Some(addr) = t.get("local_addr").and_then(|v| v.as_str()) {
lines.push(helpers::kv_line("Local Addr", addr));
}
// Tor-specific info
if let Some(mode) = t.get("tor_mode").and_then(|v| v.as_str()) {
lines.push(helpers::kv_line("Tor Mode", mode));
}
if let Some(onion) = t.get("onion_address").and_then(|v| v.as_str()) {
lines.push(helpers::kv_line("Onion Address", onion));
}
// Transport stats
if let Some(stats) = t.get("stats") {
let typ = helpers::str_field(t, "type");
lines.push(Line::from(""));
lines.push(helpers::section_header("Traffic"));
match typ {
"ethernet" => {
lines.push(helpers::kv_line(
"Frames Sent",
&helpers::nested_u64(t, "stats", "frames_sent"),
));
lines.push(helpers::kv_line(
"Frames Recv",
&helpers::nested_u64(t, "stats", "frames_recv"),
));
}
_ => {
lines.push(helpers::kv_line(
"Pkts Sent",
&helpers::nested_u64(t, "stats", "packets_sent"),
));
lines.push(helpers::kv_line(
"Pkts Recv",
&helpers::nested_u64(t, "stats", "packets_recv"),
));
}
}
let bytes_sent = stats
.get("bytes_sent")
.and_then(|v| v.as_u64())
.unwrap_or(0);
let bytes_recv = stats
.get("bytes_recv")
.and_then(|v| v.as_u64())
.unwrap_or(0);
lines.push(helpers::kv_line(
"Bytes Sent",
&helpers::format_bytes(bytes_sent),
));
lines.push(helpers::kv_line(
"Bytes Recv",
&helpers::format_bytes(bytes_recv),
));
lines.push(Line::from(""));
lines.push(helpers::section_header("Errors"));
lines.push(helpers::kv_line(
"Send Errors",
&helpers::nested_u64(t, "stats", "send_errors"),
));
lines.push(helpers::kv_line(
"Recv Errors",
&helpers::nested_u64(t, "stats", "recv_errors"),
));
match typ {
"udp" => {
lines.push(helpers::kv_line(
"MTU Exceeded",
&helpers::nested_u64(t, "stats", "mtu_exceeded"),
));
lines.push(helpers::kv_line(
"Kernel Drops",
&helpers::nested_u64(t, "stats", "kernel_drops"),
));
}
"tcp" => {
lines.push(helpers::kv_line(
"MTU Exceeded",
&helpers::nested_u64(t, "stats", "mtu_exceeded"),
));
lines.push(Line::from(""));
lines.push(helpers::section_header("Connections"));
lines.push(helpers::kv_line(
"Established",
&helpers::nested_u64(t, "stats", "connections_established"),
));
lines.push(helpers::kv_line(
"Accepted",
&helpers::nested_u64(t, "stats", "connections_accepted"),
));
lines.push(helpers::kv_line(
"Rejected",
&helpers::nested_u64(t, "stats", "connections_rejected"),
));
lines.push(helpers::kv_line(
"Timeouts",
&helpers::nested_u64(t, "stats", "connect_timeouts"),
));
lines.push(helpers::kv_line(
"Refused",
&helpers::nested_u64(t, "stats", "connect_refused"),
));
}
"tor" => {
lines.push(helpers::kv_line(
"MTU Exceeded",
&helpers::nested_u64(t, "stats", "mtu_exceeded"),
));
lines.push(helpers::kv_line(
"SOCKS5 Errors",
&helpers::nested_u64(t, "stats", "socks5_errors"),
));
lines.push(helpers::kv_line(
"Control Errors",
&helpers::nested_u64(t, "stats", "control_errors"),
));
lines.push(Line::from(""));
lines.push(helpers::section_header("Connections"));
lines.push(helpers::kv_line(
"Established",
&helpers::nested_u64(t, "stats", "connections_established"),
));
lines.push(helpers::kv_line(
"Accepted",
&helpers::nested_u64(t, "stats", "connections_accepted"),
));
lines.push(helpers::kv_line(
"Rejected",
&helpers::nested_u64(t, "stats", "connections_rejected"),
));
lines.push(helpers::kv_line(
"Timeouts",
&helpers::nested_u64(t, "stats", "connect_timeouts"),
));
lines.push(helpers::kv_line(
"Refused",
&helpers::nested_u64(t, "stats", "connect_refused"),
));
}
"nym" => {
lines.push(helpers::kv_line(
"MTU Exceeded",
&helpers::nested_u64(t, "stats", "mtu_exceeded"),
));
lines.push(helpers::kv_line(
"SOCKS5 Errors",
&helpers::nested_u64(t, "stats", "socks5_errors"),
));
lines.push(Line::from(""));
lines.push(helpers::section_header("Connections"));
lines.push(helpers::kv_line(
"Established",
&helpers::nested_u64(t, "stats", "connections_established"),
));
lines.push(helpers::kv_line(
"Timeouts",
&helpers::nested_u64(t, "stats", "connect_timeouts"),
));
}
"ethernet" => {
lines.push(Line::from(""));
lines.push(helpers::section_header("Beacons"));
lines.push(helpers::kv_line(
"Beacons Sent",
&helpers::nested_u64(t, "stats", "beacons_sent"),
));
lines.push(helpers::kv_line(
"Beacons Recv",
&helpers::nested_u64(t, "stats", "beacons_recv"),
));
lines.push(Line::from(""));
lines.push(helpers::section_header("Frame Errors"));
lines.push(helpers::kv_line(
"Too Short",
&helpers::nested_u64(t, "stats", "frames_too_short"),
));
lines.push(helpers::kv_line(
"Too Long",
&helpers::nested_u64(t, "stats", "frames_too_long"),
));
}
_ => {}
}
// Tor daemon monitoring (when control port data is available)
if let Some(mon) = t.get("tor_monitoring") {
lines.push(Line::from(""));
lines.push(helpers::section_header("Tor Daemon"));
lines.push(helpers::kv_line(
"Bootstrap",
&format!("{}%", helpers::nested_u64(t, "tor_monitoring", "bootstrap")),
));
lines.push(helpers::kv_line(
"Circuit",
if mon
.get("circuit_established")
.and_then(|v| v.as_bool())
.unwrap_or(false)
{
"established"
} else {
"none"
},
));
lines.push(helpers::kv_line(
"Version",
&helpers::nested_str(t, "tor_monitoring", "version"),
));
lines.push(helpers::kv_line(
"Network",
&helpers::nested_str(t, "tor_monitoring", "network_liveness"),
));
lines.push(helpers::kv_line(
"Dormant",
helpers::bool_field(mon, "dormant"),
));
let tor_read = mon
.get("traffic_read")
.and_then(|v| v.as_u64())
.unwrap_or(0);
let tor_written = mon
.get("traffic_written")
.and_then(|v| v.as_u64())
.unwrap_or(0);
lines.push(helpers::kv_line(
"Tor Read",
&helpers::format_bytes(tor_read),
));
lines.push(helpers::kv_line(
"Tor Written",
&helpers::format_bytes(tor_written),
));
}
}
let detail_scroll = app.detail_view.as_ref().map(|d| d.scroll).unwrap_or(0);
let paragraph = Paragraph::new(lines).scroll((detail_scroll, 0));
frame.render_widget(paragraph, inner);
}
fn draw_link_detail(frame: &mut Frame, app: &App, area: Rect, link: &serde_json::Value) {
let block = Block::default()
.borders(Borders::ALL)
.title(" Link Detail ");
let inner = block.inner(area);
frame.render_widget(block, area);
let mut lines: Vec<Line> = vec![
helpers::section_header("Link Info"),
helpers::kv_line("Link ID", &helpers::u64_field(link, "link_id")),
helpers::kv_line("Direction", helpers::str_field(link, "direction")),
helpers::kv_line("State", helpers::str_field(link, "state")),
helpers::kv_line("Remote Addr", helpers::str_field(link, "remote_addr")),
helpers::kv_line(
"Created",
&helpers::format_elapsed_ms(
link.get("created_at_ms")
.and_then(|v| v.as_u64())
.unwrap_or(0),
),
),
Line::from(""),
];
// Transport cross-reference
if let Some(transport) = lookup_transport(app, link) {
lines.push(helpers::section_header("Transport"));
lines.push(helpers::kv_line(
"Transport ID",
&helpers::u64_field(link, "transport_id"),
));
lines.push(helpers::kv_line(
"Type",
helpers::str_field(&transport, "type"),
));
if let Some(name) = transport.get("name").and_then(|v| v.as_str()) {
lines.push(helpers::kv_line("Name", name));
}
lines.push(helpers::kv_line(
"MTU",
&helpers::u64_field(&transport, "mtu"),
));
if let Some(addr) = transport.get("local_addr").and_then(|v| v.as_str()) {
lines.push(helpers::kv_line("Local Addr", addr));
}
lines.push(helpers::kv_line(
"State",
helpers::str_field(&transport, "state"),
));
lines.push(Line::from(""));
}
// Peer cross-reference
if let Some(peer) = lookup_peer_for_link(app, link) {
lines.push(helpers::section_header("Peer"));
lines.push(helpers::kv_line(
"Name",
helpers::str_field(&peer, "display_name"),
));
lines.push(helpers::kv_line(
"Connectivity",
helpers::str_field(&peer, "connectivity"),
));
lines.push(helpers::kv_line(
"Last Seen",
&helpers::format_elapsed_ms(
peer.get("last_seen_ms")
.and_then(|v| v.as_u64())
.unwrap_or(0),
),
));
lines.push(Line::from(""));
lines.push(helpers::section_header("Peer Stats"));
lines.push(helpers::kv_line(
"Pkts Sent",
&helpers::nested_u64(&peer, "stats", "packets_sent"),
));
lines.push(helpers::kv_line(
"Pkts Recv",
&helpers::nested_u64(&peer, "stats", "packets_recv"),
));
lines.push(helpers::kv_line(
"Bytes Sent",
&helpers::format_bytes(
peer.get("stats")
.and_then(|s| s.get("bytes_sent"))
.and_then(|v| v.as_u64())
.unwrap_or(0),
),
));
lines.push(helpers::kv_line(
"Bytes Recv",
&helpers::format_bytes(
peer.get("stats")
.and_then(|s| s.get("bytes_recv"))
.and_then(|v| v.as_u64())
.unwrap_or(0),
),
));
if peer.get("mmp").is_some() {
lines.push(Line::from(""));
lines.push(helpers::section_header("MMP Metrics"));
lines.push(helpers::kv_line(
"SRTT",
&format!("{}ms", helpers::nested_f64(&peer, "mmp", "srtt_ms", 1)),
));
lines.push(helpers::kv_line(
"Loss Rate",
&helpers::nested_f64_prefer(&peer, "mmp", "smoothed_loss", "loss_rate", 4),
));
lines.push(helpers::kv_line(
"ETX",
&helpers::nested_f64_prefer(&peer, "mmp", "smoothed_etx", "etx", 2),
));
lines.push(helpers::kv_line(
"LQI",
&helpers::nested_f64(&peer, "mmp", "lqi", 2),
));
}
}
let detail_scroll = app.detail_view.as_ref().map(|d| d.scroll).unwrap_or(0);
let paragraph = Paragraph::new(lines).scroll((detail_scroll, 0));
frame.render_widget(paragraph, inner);
}
/// Look up the peer associated with a link by matching link_id.
fn lookup_peer_for_link(app: &App, link: &serde_json::Value) -> Option<serde_json::Value> {
let link_id = link.get("link_id").and_then(|v| v.as_u64())?;
let peers = app.data.get(&Tab::Peers)?;
peers
.get("peers")?
.as_array()?
.iter()
.find(|p| p.get("link_id").and_then(|v| v.as_u64()) == Some(link_id))
.cloned()
}
fn lookup_transport(app: &App, link: &serde_json::Value) -> Option<serde_json::Value> {
let transport_id = link.get("transport_id").and_then(|v| v.as_u64())?;
let transports = app.data.get(&Tab::Transports)?;
transports
.get("transports")?
.as_array()?
.iter()
.find(|t| t.get("transport_id").and_then(|v| v.as_u64()) == Some(transport_id))
.cloned()
}