feat(ui): align the Details Application card across protocols (#557)

* feat(ui): align the Details Application card across protocols
* feat(ui): show DNS, LLMNR, and NetBIOS transaction IDs in Details
This commit is contained in:
Marco Cadetg
2026-08-16 16:58:43 +02:00
committed by GitHub
parent 632d3b8fae
commit a65dbb055f
14 changed files with 1472 additions and 214 deletions
+13
View File
@@ -189,6 +189,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
each socket-table refresh (#513)
### Changed
- **Details Tab Application Card Alignment**: every protocol's Application
card now renders a fixed row set with `-` placeholders instead of rows that
appear and disappear with data availability; HTTPS shows its four rows even
before the TLS handshake is parsed, and QUIC's SNI/ALPN rows are no longer
hidden behind it. ICMP/ICMPv6 and IGMP gain their own cards (message name,
echo ID/sequence, NDP neighbor, group address), HTTP gains Version, Host,
and User-Agent rows, and ARP gains an Operation row plus the same
protocol-colored heading as DPI protocols. SSH version/state and DNS, mDNS,
and LLMNR response IPs render human-readable instead of Rust debug output,
and FTP's response code and message merge into one row. DNS, LLMNR, and
NetBIOS expose their transaction IDs like STUN already did. Transport Health
drops its duplicate NTP Stratum and STUN Last Message rows; those now live
only in the Application card (#557)
- **Library Internals Deduplicated and Narrowed**: Removed remaining dead code
and test-only public API from the workspace crates, narrowed public items
with no external consumers to crate or module visibility (including
@@ -216,6 +216,61 @@ pub enum ArpOperation {
Reply,
}
/// Human-readable name for an ICMP (or ICMPv6) message type, for display in
/// the Details tab's Application card. Unknown types render as `Type N`.
///
/// This is a different display register from [`Connection::state`]'s compact
/// codes (`ECHO_REQ(id)`, `DEST_UNREACH`); a unit test keeps the two
/// special-cased type sets from drifting apart.
///
/// [`Connection::state`]: crate::network::types::Connection::state
pub fn icmp_message_name(icmp_type: u8, is_ipv6: bool) -> std::borrow::Cow<'static, str> {
let name = if is_ipv6 {
match icmp_type {
128 => Some("Echo Request"),
129 => Some("Echo Reply"),
133 => Some("Router Solicitation"),
134 => Some("Router Advertisement"),
135 => Some("Neighbor Solicitation"),
136 => Some("Neighbor Advertisement"),
137 => Some("Redirect"),
_ => None,
}
} else {
match icmp_type {
0 => Some("Echo Reply"),
3 => Some("Destination Unreachable"),
5 => Some("Redirect"),
8 => Some("Echo Request"),
11 => Some("Time Exceeded"),
_ => None,
}
};
match name {
Some(name) => name.into(),
None => format!("Type {}", icmp_type).into(),
}
}
/// Human-readable name for an IGMP message type, for display in the Details
/// tab's Application card. Unknown types render as `Type 0xNN`.
///
/// Same display-register note as [`icmp_message_name`]: the compact codes in
/// [`Connection::state`] stay as they are, and a unit test keeps the two
/// special-cased type sets in sync.
///
/// [`Connection::state`]: crate::network::types::Connection::state
pub fn igmp_message_name(igmp_type: u8) -> std::borrow::Cow<'static, str> {
match igmp_type {
0x11 => "Membership Query".into(),
0x12 => "Membership Report v1".into(),
0x16 => "Membership Report v2".into(),
0x22 => "Membership Report v3".into(),
0x17 => "Leave Group".into(),
other => format!("Type 0x{:02x}", other).into(),
}
}
/// One IP-to-MAC mapping extracted from an NDP (IPv6 Neighbor Discovery,
/// RFC 4861) message's link-layer address option — the IPv6 analogue of what
/// [`ArpInfo`] carries for IPv4.
@@ -339,6 +394,88 @@ mod tests {
assert!(!MatchQuality::Unspecified.is_exact());
}
#[test]
fn icmp_message_names_are_family_aware() {
assert_eq!(icmp_message_name(0, false), "Echo Reply");
assert_eq!(icmp_message_name(3, false), "Destination Unreachable");
assert_eq!(icmp_message_name(5, false), "Redirect");
assert_eq!(icmp_message_name(8, false), "Echo Request");
assert_eq!(icmp_message_name(11, false), "Time Exceeded");
assert_eq!(icmp_message_name(128, false), "Type 128");
assert_eq!(icmp_message_name(128, true), "Echo Request");
assert_eq!(icmp_message_name(129, true), "Echo Reply");
assert_eq!(icmp_message_name(133, true), "Router Solicitation");
assert_eq!(icmp_message_name(134, true), "Router Advertisement");
assert_eq!(icmp_message_name(135, true), "Neighbor Solicitation");
assert_eq!(icmp_message_name(136, true), "Neighbor Advertisement");
assert_eq!(icmp_message_name(137, true), "Redirect");
assert_eq!(icmp_message_name(8, true), "Type 8");
}
#[test]
fn igmp_message_names_cover_the_known_types() {
assert_eq!(igmp_message_name(0x11), "Membership Query");
assert_eq!(igmp_message_name(0x12), "Membership Report v1");
assert_eq!(igmp_message_name(0x16), "Membership Report v2");
assert_eq!(igmp_message_name(0x22), "Membership Report v3");
assert_eq!(igmp_message_name(0x17), "Leave Group");
assert_eq!(igmp_message_name(0x42), "Type 0x42");
}
/// `Connection::state()` renders compact codes (`ECHO_REQ`, `QUERY`) while
/// the `*_message_name` helpers render prose for the Details Application
/// card. The two must not drift: every type `state()` recognizes must
/// also get a friendly name from the helper, and for IGMP the two sets
/// are identical.
#[test]
fn message_name_helpers_cover_the_state_special_cases() {
use crate::network::types::Connection;
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
let local = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 10)), 0);
let remote = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20)), 0);
for icmp_type in 0..=u8::MAX {
let conn = Connection::new(
Protocol::Icmp,
local,
remote,
ProtocolState::Icmp {
icmp_type,
icmp_id: None,
icmp_sequence: None,
ndp_neighbor: None,
},
);
let state_names_it = conn.state() != "ICMP_OTHER";
let helper_names_it = !icmp_message_name(icmp_type, false).starts_with("Type ")
|| !icmp_message_name(icmp_type, true).starts_with("Type ");
assert!(
!state_names_it || helper_names_it,
"state() names ICMP type {icmp_type} but icmp_message_name does not"
);
}
for igmp_type in 0..=u8::MAX {
let conn = Connection::new(
Protocol::Igmp,
local,
remote,
ProtocolState::Igmp {
igmp_type,
group_addr: None,
},
);
let state_names_it = conn.state() != "IGMP_OTHER";
let helper_names_it = !igmp_message_name(igmp_type).starts_with("Type ");
assert_eq!(
state_names_it, helper_names_it,
"state() and igmp_message_name disagree on IGMP type 0x{igmp_type:02x}"
);
}
}
#[test]
fn test_tcp_state_display() {
assert_eq!(TcpState::SynSent.to_string(), "SYN_SENT");
@@ -11,6 +11,18 @@ pub enum SshConnectionState {
Established,
}
impl fmt::Display for SshConnectionState {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let name = match self {
SshConnectionState::Banner => "Banner",
SshConnectionState::KeyExchange => "Key Exchange",
SshConnectionState::Authentication => "Authentication",
SshConnectionState::Established => "Established",
};
f.write_str(name)
}
}
#[derive(Debug, Clone)]
pub struct SshInfo {
pub version: Option<SshVersion>,
@@ -27,6 +39,16 @@ pub enum SshVersion {
V2,
}
impl fmt::Display for SshVersion {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let name = match self {
SshVersion::V1 => "SSH-1",
SshVersion::V2 => "SSH-2",
};
f.write_str(name)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BitTorrentType {
Peer,
@@ -397,6 +419,17 @@ pub enum HttpVersion {
Http2,
}
impl fmt::Display for HttpVersion {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let name = match self {
HttpVersion::Http10 => "HTTP/1.0",
HttpVersion::Http11 => "HTTP/1.1",
HttpVersion::Http2 => "HTTP/2",
};
f.write_str(name)
}
}
#[derive(Debug, Clone)]
pub struct HttpsInfo {
pub tls_info: Option<TlsInfo>,
@@ -1273,6 +1306,27 @@ pub struct DpiInfo {
mod tests {
use super::*;
/// The Details tab renders these values directly; pinning them here keeps
/// the debug-formatting leaks (`V2`, `KeyExchange`, `Http11`) from coming
/// back.
#[test]
fn ssh_and_http_display_forms_are_human_readable() {
assert_eq!(SshVersion::V1.to_string(), "SSH-1");
assert_eq!(SshVersion::V2.to_string(), "SSH-2");
assert_eq!(SshConnectionState::Banner.to_string(), "Banner");
assert_eq!(SshConnectionState::KeyExchange.to_string(), "Key Exchange");
assert_eq!(
SshConnectionState::Authentication.to_string(),
"Authentication"
);
assert_eq!(SshConnectionState::Established.to_string(), "Established");
assert_eq!(HttpVersion::Http10.to_string(), "HTTP/1.0");
assert_eq!(HttpVersion::Http11.to_string(), "HTTP/1.1");
assert_eq!(HttpVersion::Http2.to_string(), "HTTP/2");
}
#[test]
fn quic_version_string_known_versions_are_borrowed() {
use std::borrow::Cow;
+577 -2
View File
@@ -200,6 +200,19 @@ pub(crate) fn dpi_color(app: &crate::network::types::ApplicationProtocol) -> Col
}
}
/// Color for the Details Application heading of the non-DPI protocol classes
/// (ARP, ICMP, IGMP), which have no `ApplicationProtocol` value to feed
/// [`dpi_color`]. Mirrors its theme fallback: the classic preset colors the
/// heading like any other detected application, the muted preset renders it
/// as plain content.
pub(crate) fn non_dpi_app_color() -> Color {
if theme::is_classic() {
theme::field_application()
} else {
Color::Reset
}
}
/// Draw the UI
pub fn draw(
f: &mut Frame,
@@ -1537,9 +1550,12 @@ mod snapshot_tests {
let output = render_details(&app, &connections, 0);
assert!(output.contains("STUN RTT") && output.contains("23.4ms"));
assert!(output.contains("Last Message") && output.contains("Binding Success"));
assert!(output.contains("Paired by 96-bit transaction ID"));
assert!(!output.contains("No transport metrics for this protocol"));
// Method and class moved to the Application card; Transport Health
// must not repeat them as a Last Message row.
assert!(output.contains("Binding") && output.contains("Success"));
assert!(!output.contains("Last Message"));
}
/// An NTP poll is timeable through the originate timestamp echo, so its
@@ -1568,9 +1584,15 @@ mod snapshot_tests {
let output = render_details(&app, &connections, 0);
assert!(output.contains("NTP RTT") && output.contains("6.5ms"));
assert!(output.contains("Stratum"));
assert!(output.contains("Paired by originate timestamp echo"));
assert!(!output.contains("No transport metrics for this protocol"));
// Stratum's only home is the Application card now; the old Transport
// Health duplicate is gone.
assert_eq!(
output.matches("Stratum").count(),
1,
"Stratum must render exactly once:\n{output}"
);
}
/// The Attribution section repeats PID beside the richer process fields so
@@ -2116,4 +2138,557 @@ mod snapshot_tests {
insta::assert_snapshot!(output);
}
// --- Application card: fixed per-protocol row sets ---
/// One fully populated instance per `ApplicationProtocol` variant. The
/// match at the bottom is deliberately exhaustive so a new variant fails
/// compilation here until the fixture (and the fixed row-set spec in
/// details.rs) covers it.
fn dpi_variants_full() -> Vec<crate::network::types::ApplicationProtocol> {
use crate::network::types::{
ApplicationProtocol, BitTorrentInfo, BitTorrentType, DhcpInfo, DhcpMessageType,
DnsInfo, DnsQueryType, FtpInfo, FtpMessageType, HttpInfo, HttpVersion, HttpsInfo,
LlmnrInfo, MdnsInfo, MqttInfo, MqttPacketType, MqttVersion, NetBiosInfo, NetBiosOpcode,
NetBiosResponseStatus, NetBiosService, NtpInfo, NtpMode, QuicConnectionState, QuicInfo,
QuicPacketType, SnmpInfo, SnmpPduType, SnmpVersion, SsdpInfo, SsdpMethod,
SshConnectionState, SshInfo, SshVersion, StunInfo, StunMessageClass, StunMethod,
TlsInfo, TlsVersion,
};
let tls_info = TlsInfo {
version: Some(TlsVersion::Tls13),
sni: Some("github.com".to_string()),
alpn: vec!["h2".to_string(), "http/1.1".to_string()],
cipher_suite: Some(0x1301),
};
let mut quic = QuicInfo::new(0x0000_0001);
quic.packet_type = QuicPacketType::OneRtt;
quic.connection_state = QuicConnectionState::Connected;
quic.connection_id_hex = Some("deadbeefcafe".to_string());
quic.tls_info = Some(tls_info.clone());
let variants = vec![
ApplicationProtocol::Http(HttpInfo {
version: HttpVersion::Http11,
method: Some("GET".to_string()),
host: Some("example.com".to_string()),
path: Some("/index.html".to_string()),
status_code: Some(200),
user_agent: Some("curl/8.9.0".to_string()),
}),
ApplicationProtocol::Https(HttpsInfo {
tls_info: Some(tls_info),
}),
ApplicationProtocol::Dns(DnsInfo {
query_name: Some("example.com".to_string()),
query_type: Some(DnsQueryType::A),
response_ips: vec![
IpAddr::V4(Ipv4Addr::new(93, 184, 216, 34)),
IpAddr::V4(Ipv4Addr::new(93, 184, 216, 35)),
],
is_response: true,
txid: 0x1234,
rcode: Some(0),
nodata: Some(false),
}),
ApplicationProtocol::Ssh(SshInfo {
version: Some(SshVersion::V2),
client_software: Some("OpenSSH_9.8".to_string()),
server_software: Some("OpenSSH_9.6p1".to_string()),
connection_state: SshConnectionState::Established,
algorithms: vec!["curve25519-sha256".to_string(), "ssh-ed25519".to_string()],
auth_method: Some("publickey".to_string()),
}),
ApplicationProtocol::Quic(Box::new(quic)),
ApplicationProtocol::Ntp(NtpInfo {
version: 4,
mode: NtpMode::Server,
stratum: 2,
origin_timestamp: 0xAABB,
transmit_timestamp: 0xCCDD,
}),
ApplicationProtocol::Mdns(MdnsInfo {
query_name: Some("printer.local".to_string()),
query_type: Some(DnsQueryType::A),
is_response: true,
response_ips: vec![IpAddr::V4(Ipv4Addr::new(192, 168, 1, 42))],
}),
ApplicationProtocol::Llmnr(LlmnrInfo {
query_name: Some("fileserver".to_string()),
query_type: Some(DnsQueryType::A),
is_response: true,
response_ips: vec![IpAddr::V4(Ipv4Addr::new(192, 168, 1, 43))],
txid: 0x77,
}),
ApplicationProtocol::Dhcp(DhcpInfo {
message_type: DhcpMessageType::Ack,
hostname: Some("laptop".to_string()),
client_mac: Some("aa:bb:cc:dd:ee:ff".to_string()),
}),
ApplicationProtocol::Snmp(SnmpInfo {
version: SnmpVersion::V2c,
community: Some("public".to_string()),
pdu_type: SnmpPduType::GetRequest,
}),
ApplicationProtocol::Ssdp(SsdpInfo {
method: SsdpMethod::MSearch,
service_type: Some("upnp:rootdevice".to_string()),
}),
ApplicationProtocol::NetBios(NetBiosInfo {
service: NetBiosService::NameService,
opcode: NetBiosOpcode::Response,
name: Some("FILESERVER".to_string()),
transaction_id: 0x1234,
is_response: true,
response_status: Some(NetBiosResponseStatus::NameService(0)),
}),
ApplicationProtocol::BitTorrent(BitTorrentInfo {
protocol_type: BitTorrentType::Peer,
info_hash: Some("aabbccddeeff00112233445566778899aabbccdd".to_string()),
client: Some("qBittorrent 4.6".to_string()),
dht_method: Some("get_peers".to_string()),
supports_dht: true,
supports_extension: true,
supports_fast: true,
}),
ApplicationProtocol::Stun(StunInfo {
message_class: StunMessageClass::SuccessResponse,
method: StunMethod::Binding,
transaction_id: [7u8; 12],
software: Some("coturn".to_string()),
}),
ApplicationProtocol::Mqtt(MqttInfo {
version: Some(MqttVersion::V311),
packet_type: MqttPacketType::Publish,
client_id: Some("sensor-1".to_string()),
topic: Some("home/temp".to_string()),
qos: Some(1),
}),
ApplicationProtocol::Ftp(FtpInfo {
message_type: FtpMessageType::Response,
command: Some("USER".to_string()),
args: Some("marco".to_string()),
response_code: Some(230),
response_message: Some("Login successful".to_string()),
username: Some("marco".to_string()),
server_software: Some("vsftpd 3.0.5".to_string()),
system_type: Some("UNIX".to_string()),
}),
];
let mut seen = std::collections::HashSet::new();
for variant in &variants {
assert!(
seen.insert(std::mem::discriminant(variant)),
"duplicate fixture for {}",
variant.sort_key()
);
// Exhaustive on purpose: extend the fixture list above (and the
// Details row-set table) when this match stops compiling.
match variant {
ApplicationProtocol::Http(_) => {}
ApplicationProtocol::Https(_) => {}
ApplicationProtocol::Dns(_) => {}
ApplicationProtocol::Ssh(_) => {}
ApplicationProtocol::Quic(_) => {}
ApplicationProtocol::Ntp(_) => {}
ApplicationProtocol::Mdns(_) => {}
ApplicationProtocol::Llmnr(_) => {}
ApplicationProtocol::Dhcp(_) => {}
ApplicationProtocol::Snmp(_) => {}
ApplicationProtocol::Ssdp(_) => {}
ApplicationProtocol::NetBios(_) => {}
ApplicationProtocol::BitTorrent(_) => {}
ApplicationProtocol::Stun(_) => {}
ApplicationProtocol::Mqtt(_) => {}
ApplicationProtocol::Ftp(_) => {}
}
}
assert_eq!(
seen.len(),
16,
"fixture list out of sync with ApplicationProtocol: update the \
variants vec (and this count) alongside the match above"
);
variants
}
/// A Details-ready connection carrying `app` as its DPI classification,
/// on the transport that protocol actually rides on.
fn dpi_details_connection(app: crate::network::types::ApplicationProtocol) -> Connection {
use crate::network::types::{ApplicationProtocol, DpiInfo};
let tcp_based = matches!(
app,
ApplicationProtocol::Http(_)
| ApplicationProtocol::Https(_)
| ApplicationProtocol::Ssh(_)
| ApplicationProtocol::BitTorrent(_)
| ApplicationProtocol::Mqtt(_)
| ApplicationProtocol::Ftp(_)
);
let (protocol, state) = if tcp_based {
(Protocol::Tcp, ProtocolState::Tcp(TcpState::Established))
} else {
(Protocol::Udp, ProtocolState::Udp)
};
let mut conn = Connection::new(
protocol,
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 10)), 50_000),
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 7)), 4433),
state,
);
conn.process_name = Some("proc".to_string());
conn.pid = Some(4242);
conn.dpi_info = Some(DpiInfo { application: app });
conn
}
fn arp_details_connection(with_vendors: bool) -> Connection {
use crate::network::types::{ArpInfo, ArpOperation};
let gateway = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1));
let host = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 10));
Connection::new(
Protocol::Arp,
SocketAddr::new(host, 0),
SocketAddr::new(gateway, 0),
ProtocolState::Arp(ArpInfo {
operation: ArpOperation::Request,
sender_mac: "68:5e:dd:09:15:5e".to_string(),
sender_ip: host,
target_mac: "00:00:00:00:00:00".to_string(),
target_ip: gateway,
sender_vendor: with_vendors.then(|| "Apple, Inc.".to_string()),
target_vendor: with_vendors.then(|| "ASUSTek COMPUTER INC.".to_string()),
}),
)
}
fn icmp_echo_details_connection() -> Connection {
let mut conn = Connection::new(
Protocol::Icmp,
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 10)), 0),
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8)), 0),
ProtocolState::Icmp {
icmp_type: 8,
icmp_id: Some(0x1234),
icmp_sequence: Some(42),
ndp_neighbor: None,
},
);
conn.process_name = Some("ping".to_string());
conn.icmp_echo_rtt = Some(Duration::from_micros(8_700));
conn
}
fn icmpv6_ndp_details_connection() -> Connection {
use crate::network::types::NdpNeighbor;
use std::net::Ipv6Addr;
let local = IpAddr::V6(Ipv6Addr::new(0xfe80, 0, 0, 0, 0, 0, 0, 1));
let remote = IpAddr::V6(Ipv6Addr::new(0xfe80, 0, 0, 0, 0, 0, 0, 2));
Connection::new(
Protocol::Icmp,
SocketAddr::new(local, 0),
SocketAddr::new(remote, 0),
ProtocolState::Icmp {
icmp_type: 136,
icmp_id: None,
icmp_sequence: None,
ndp_neighbor: Some(NdpNeighbor {
ip: remote,
mac: "b8:27:eb:12:34:56".to_string(),
vendor: Some("Raspberry Pi Foundation".to_string()),
}),
},
)
}
fn igmp_details_connection() -> Connection {
Connection::new(
Protocol::Igmp,
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 10)), 0),
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(224, 0, 0, 251)), 0),
ProtocolState::Igmp {
igmp_type: 0x16,
group_addr: Some(Ipv4Addr::new(224, 0, 0, 251)),
},
)
}
/// Rows between the Application heading and the Transport Health heading
/// in a Details render: the release-mode guard for the Application card's
/// row budget.
fn application_card_height(app: &App, conn: Connection) -> usize {
let connections = vec![conn];
app.set_connections_snapshot_for_test(connections.clone());
let output = render_details(app, &connections, 0);
heading_row(&output, "Transport Health") - heading_row(&output, "Application")
}
/// The Application heading and the Transport Health heading must sit the
/// same distance apart for every protocol class, so the dashboard cards
/// never move while flipping through a mixed connection list. The
/// baseline is measured from an unclassified TCP record, not hardcoded.
#[test]
fn application_card_geometry_is_fixed_for_all_protocols() {
let app = test_app();
let baseline = {
let mut sample = sample_connections();
application_card_height(&app, sample.remove(0))
};
assert!(baseline > 0, "baseline render must show both headings");
for variant in dpi_variants_full() {
let name = variant.sort_key();
assert_eq!(
application_card_height(&app, dpi_details_connection(variant)),
baseline,
"Application card height for {name} deviates from the TCP baseline"
);
}
for (name, conn) in [
("ARP", arp_details_connection(true)),
("ICMP echo", icmp_echo_details_connection()),
("ICMPv6 NDP", icmpv6_ndp_details_connection()),
("IGMP", igmp_details_connection()),
] {
assert_eq!(
application_card_height(&app, conn),
baseline,
"Application card height for {name} deviates from the TCP baseline"
);
}
}
/// Label column of the Application card (heading row through the row
/// before Transport Health), sliced at the card's own x offset since the
/// card lives in the right pane of the split layout.
fn application_card_labels(render: &str) -> Vec<String> {
let header_row = heading_row(render, "Application");
let header_line = render.lines().nth(header_row).expect("header line");
let byte_index = header_line.find("Application").expect("Application x");
let x = header_line[..byte_index].chars().count();
let end_row = heading_row(render, "Transport Health");
render
.lines()
.skip(header_row)
.take(end_row - header_row)
.map(|line| {
line.chars()
.skip(x)
.take(tabs::details::DETAIL_LABEL_WIDTH)
.collect::<String>()
.trim_end()
.to_string()
})
.collect()
}
/// The card's label column is a function of the protocol class alone:
/// a fully populated record and an empty one of the same class must
/// render identical labels, with `-` filling the gaps.
#[test]
fn application_card_rows_do_not_depend_on_data() {
use crate::network::types::{
ApplicationProtocol, DnsInfo, DnsQueryType, FtpInfo, FtpMessageType, HttpsInfo,
QuicInfo, SshConnectionState, SshInfo,
};
let app = test_app();
let full = |matcher: fn(&ApplicationProtocol) -> bool| {
dpi_variants_full()
.into_iter()
.find(matcher)
.expect("fixture variant")
};
let pairs: Vec<(&str, Connection, Connection)> = vec![
(
"HTTPS",
dpi_details_connection(full(|v| matches!(v, ApplicationProtocol::Https(_)))),
dpi_details_connection(ApplicationProtocol::Https(HttpsInfo { tls_info: None })),
),
(
"QUIC",
dpi_details_connection(full(|v| matches!(v, ApplicationProtocol::Quic(_)))),
dpi_details_connection(ApplicationProtocol::Quic(Box::new(QuicInfo::new(
0xdead_beef,
)))),
),
(
"SSH",
dpi_details_connection(full(|v| matches!(v, ApplicationProtocol::Ssh(_)))),
dpi_details_connection(ApplicationProtocol::Ssh(SshInfo {
version: None,
client_software: None,
server_software: None,
connection_state: SshConnectionState::Banner,
algorithms: Vec::new(),
auth_method: None,
})),
),
(
"FTP",
dpi_details_connection(full(|v| matches!(v, ApplicationProtocol::Ftp(_)))),
dpi_details_connection(ApplicationProtocol::Ftp(FtpInfo {
message_type: FtpMessageType::Request,
command: None,
args: None,
response_code: None,
response_message: None,
username: None,
server_software: None,
system_type: None,
})),
),
(
"DNS",
dpi_details_connection(full(|v| matches!(v, ApplicationProtocol::Dns(_)))),
dpi_details_connection(ApplicationProtocol::Dns(DnsInfo {
query_name: Some("example.com".to_string()),
query_type: Some(DnsQueryType::A),
response_ips: Vec::new(),
is_response: false,
txid: 0x0001,
rcode: None,
nodata: None,
})),
),
(
"ARP",
arp_details_connection(true),
arp_details_connection(false),
),
];
for (name, full_conn, empty_conn) in pairs {
let render_one = |conn: Connection| {
let connections = vec![conn];
app.set_connections_snapshot_for_test(connections.clone());
render_details(&app, &connections, 0)
};
let full_labels = application_card_labels(&render_one(full_conn));
let empty_labels = application_card_labels(&render_one(empty_conn));
assert!(
full_labels.len() > 1,
"{name}: the card must render labeled rows"
);
assert_eq!(
full_labels, empty_labels,
"{name}: the Application card labels must not depend on data availability"
);
}
}
/// Snapshot of one Details render per reworked Application card, so the
/// exact row sets (placeholders included) are pinned and reviewable.
/// The name is explicit because the assertion runs inside this shared
/// helper, where insta cannot derive a per-test name.
fn assert_details_snapshot(name: &str, conn: Connection) {
let app = test_app();
let connections = vec![conn];
app.set_connections_snapshot_for_test(connections.clone());
let output = render_details(&app, &connections, 0);
insta::with_settings!({
filters => time_filters(),
}, {
insta::assert_snapshot!(name, output);
});
}
#[test]
fn details_tab_http_application_card() {
use crate::network::types::ApplicationProtocol;
let http = dpi_variants_full()
.into_iter()
.find(|v| matches!(v, ApplicationProtocol::Http(_)))
.expect("HTTP fixture");
assert_details_snapshot(
"details_tab_http_application_card",
dpi_details_connection(http),
);
}
#[test]
fn details_tab_https_without_tls_info() {
use crate::network::types::{ApplicationProtocol, HttpsInfo};
assert_details_snapshot(
"details_tab_https_without_tls_info",
dpi_details_connection(ApplicationProtocol::Https(HttpsInfo { tls_info: None })),
);
}
#[test]
fn details_tab_ssh_application_card() {
use crate::network::types::ApplicationProtocol;
let ssh = dpi_variants_full()
.into_iter()
.find(|v| matches!(v, ApplicationProtocol::Ssh(_)))
.expect("SSH fixture");
assert_details_snapshot(
"details_tab_ssh_application_card",
dpi_details_connection(ssh),
);
}
#[test]
fn details_tab_dns_response_application_card() {
use crate::network::types::ApplicationProtocol;
let dns = dpi_variants_full()
.into_iter()
.find(|v| matches!(v, ApplicationProtocol::Dns(_)))
.expect("DNS fixture");
assert_details_snapshot(
"details_tab_dns_response_application_card",
dpi_details_connection(dns),
);
}
#[test]
fn details_tab_ntp_application_card() {
use crate::network::types::ApplicationProtocol;
let ntp = dpi_variants_full()
.into_iter()
.find(|v| matches!(v, ApplicationProtocol::Ntp(_)))
.expect("NTP fixture");
assert_details_snapshot(
"details_tab_ntp_application_card",
dpi_details_connection(ntp),
);
}
#[test]
fn details_tab_icmp_echo_application_card() {
assert_details_snapshot(
"details_tab_icmp_echo_application_card",
icmp_echo_details_connection(),
);
}
#[test]
fn details_tab_icmpv6_ndp_application_card() {
assert_details_snapshot(
"details_tab_icmpv6_ndp_application_card",
icmpv6_ndp_details_connection(),
);
}
#[test]
fn details_tab_igmp_application_card() {
assert_details_snapshot(
"details_tab_igmp_application_card",
igmp_details_connection(),
);
}
#[test]
fn details_tab_arp_application_card() {
assert_details_snapshot(
"details_tab_arp_application_card",
arp_details_connection(true),
);
}
}
@@ -0,0 +1,44 @@
---
source: src/ui/mod.rs
expression: output
---
rustnet 1 Overview 2 Details 3 Activity 4 Graph 5 Help
─────────────────────────━━━━━━━━━──────────────────────────────────────────────────────────────────────────────────────────────────────────
Process Remote Local Service App State RTT Rx↓/Tx↑
> - 192.168.1.1:0 192.168.1.10:0 - ARP ARP_WHO_HAS… - -/-
▎ ? → 192.168.1.1:0 · click a field to copy
Connection Application: ARP █
Protocol ARP Operation Request █
Status Active (last seen <T> ago) Sender MAC 68:5e:dd:09:15:5e █
Local Address 192.168.1.10:0 Sender Vendor Apple, Inc. █
Remote Address 192.168.1.1:0 Sender IP 192.168.1.10 █
Scope PRIVATE Target MAC 00:00:00:00:00:00 █
State ARP_WHO_HAS 192.168.1.1 (Apple, Inc.) Target Vendor ASUSTek COMPUTER INC. █
Process - Target IP 192.168.1.1 █
PID - █
Service - █
Network Context Transport Health █
Local Hostname - No transport metrics for this protocol █
Local MAC - █
Remote Hostname - █
Attributed Name - █
Attributed Via - █
Remote MAC - ║
Country - ║
City - ║
ASN - ║
▎ Traffic Statistics
↓ RX - → peak - ↑ TX - → peak -
Total 0 B · 0 packets Total 0 B · 0 packets
'h' help | 1-5 jump | Tab/[/] cycle | j/k prev/next | Ctrl-d/u scroll | 'c' copy remote addr | Esc back
@@ -0,0 +1,44 @@
---
source: src/ui/mod.rs
expression: output
---
rustnet 1 Overview 2 Details 3 Activity 4 Graph 5 Help
─────────────────────────━━━━━━━━━──────────────────────────────────────────────────────────────────────────────────────────────────────────
Process Remote Local Service App State RTT Rx↓/Tx↑
> proc (4242) 203.0.113.7:4433 192.168.1.10:50000 - UDP·DNS (example.com) DNS_RESPONSE - -/-
▎ proc → 203.0.113.7:4433 · click a field to copy
Connection Application: DNS █
Protocol UDP DNS Query example.com █
Status Active (last seen <T> ago) DNS Type A █
Local Address 192.168.1.10:50000 DNS Response IPs 93.184.216.34, 93.184.216.35 █
Remote Address 203.0.113.7:4433 DNS Answer - █
Scope DOCUMENTATION Transaction ID 0x1234 █
State DNS_RESPONSE █
Process proc █
PID 4242 █
Service - █
Network Context Transport Health █
Local Hostname - DNS Response Time - █
Local MAC - Last Response Code NOERROR █
Remote Hostname - █
Attributed Name - Timed by pairing query and response IDs █
Attributed Via - █
Remote MAC - ║
Country - ║
City - ║
ASN - ║
▎ Traffic Statistics
↓ RX - → peak - ↑ TX - → peak -
Total 0 B · 0 packets Total 0 B · 0 packets
'h' help | 1-5 jump | Tab/[/] cycle | j/k prev/next | Ctrl-d/u scroll | 'c' copy remote addr | Esc back
@@ -0,0 +1,44 @@
---
source: src/ui/mod.rs
expression: output
---
rustnet 1 Overview 2 Details 3 Activity 4 Graph 5 Help
─────────────────────────━━━━━━━━━──────────────────────────────────────────────────────────────────────────────────────────────────────────
Process Remote Local Service App State RTT Rx↓/Tx↑
> proc (4242) 203.0.113.7:4433 192.168.1.10:50000 - TCP·HTTP (example.com) ESTABLISHED - -/-
▎ proc → 203.0.113.7:4433 · click a field to copy
Connection Application: HTTP █
Protocol TCP HTTP Version HTTP/1.1 █
Status Active (last seen <T> ago) HTTP Method GET █
Local Address 192.168.1.10:50000 HTTP Host example.com █
Remote Address 203.0.113.7:4433 HTTP Path /index.html █
Scope DOCUMENTATION HTTP Status 200 █
State ESTABLISHED User-Agent curl/8.9.0 █
Process proc █
PID 4242 █
Service - █
Network Context Transport Health █
Local Hostname - Initial RTT - █
Local MAC - Live RTT - █
Remote Hostname - TCP Retransmits 0 █
Attributed Name - Out-of-Order Packets 0 █
Attributed Via - Duplicate ACKs 0 █
Remote MAC - Fast Retransmits 0 ║
Country - Window Size 0 ║
City - ║
ASN - ║
▎ Traffic Statistics
↓ RX - → peak - ↑ TX - → peak -
Total 0 B · 0 packets Total 0 B · 0 packets
'h' help | 1-5 jump | Tab/[/] cycle | j/k prev/next | Ctrl-d/u scroll | 'c' copy remote addr | Esc back
@@ -0,0 +1,44 @@
---
source: src/ui/mod.rs
expression: output
---
rustnet 1 Overview 2 Details 3 Activity 4 Graph 5 Help
─────────────────────────━━━━━━━━━──────────────────────────────────────────────────────────────────────────────────────────────────────────
Process Remote Local Service App State RTT Rx↓/Tx↑
> proc (4242) 203.0.113.7:4433 192.168.1.10:50000 - TCP·HTTPS ESTABLISHED - -/-
▎ proc → 203.0.113.7:4433 · click a field to copy
Connection Application: HTTPS █
Protocol TCP SNI - █
Status Active (last seen <T> ago) ALPN - █
Local Address 192.168.1.10:50000 TLS Version - █
Remote Address 203.0.113.7:4433 Cipher Suite - █
Scope DOCUMENTATION █
State ESTABLISHED █
Process proc █
PID 4242 █
Service - █
Network Context Transport Health █
Local Hostname - Initial RTT - █
Local MAC - Live RTT - █
Remote Hostname - TCP Retransmits 0 █
Attributed Name - Out-of-Order Packets 0 █
Attributed Via - Duplicate ACKs 0 █
Remote MAC - Fast Retransmits 0 ║
Country - Window Size 0 ║
City - ║
ASN - ║
▎ Traffic Statistics
↓ RX - → peak - ↑ TX - → peak -
Total 0 B · 0 packets Total 0 B · 0 packets
'h' help | 1-5 jump | Tab/[/] cycle | j/k prev/next | Ctrl-d/u scroll | 'c' copy remote addr | Esc back
@@ -0,0 +1,44 @@
---
source: src/ui/mod.rs
expression: output
---
rustnet 1 Overview 2 Details 3 Activity 4 Graph 5 Help
─────────────────────────━━━━━━━━━──────────────────────────────────────────────────────────────────────────────────────────────────────────
Process Remote Local Service App State RTT Rx↓/Tx↑
> ping 8.8.8.8:0 192.168.1.10:0 - ICMP ECHO_REQ(46… 8.7ms -/-
▎ ping → 8.8.8.8:0 · click a field to copy
Connection Application: ICMP █
Protocol ICMP Message Echo Request █
Status Active (last seen <T> ago) Echo ID 4660 █
Local Address 192.168.1.10:0 Sequence 42 █
Remote Address 8.8.8.8:0 NDP Neighbor - █
Scope PUBLIC █
State ECHO_REQ(4660) █
Process ping █
PID - █
Service - █
Network Context Transport Health █
Local Hostname - Ping RTT 8.7ms █
Local MAC - Last Sequence 42 █
Remote Hostname - █
Attributed Name - Paired by echo ID and sequence █
Attributed Via - █
Remote MAC - ║
Country - ║
City - ║
ASN - ║
▎ Traffic Statistics
↓ RX - → peak - ↑ TX - → peak -
Total 0 B · 0 packets Total 0 B · 0 packets
'h' help | 1-5 jump | Tab/[/] cycle | j/k prev/next | Ctrl-d/u scroll | 'c' copy remote addr | Esc back
@@ -0,0 +1,44 @@
---
source: src/ui/mod.rs
expression: output
---
rustnet 1 Overview 2 Details 3 Activity 4 Graph 5 Help
─────────────────────────━━━━━━━━━──────────────────────────────────────────────────────────────────────────────────────────────────────────
Process Remote Local Service App State RTT Rx↓/Tx↑
> - [fe80::2]:0 [fe80::1]:0 - ICMP ICMP_OTHER - -/-
▎ ? → [fe80::2]:0 · click a field to copy
Connection Application: ICMPv6 █
Protocol ICMP Message Neighbor Advertisement █
Status Active (last seen <T> ago) Echo ID - █
Local Address [fe80::1]:0 Sequence - █
Remote Address [fe80::2]:0 NDP Neighbor fe80::2 at b8:27:eb:12:34:56 (Raspberry Pi Fou █
Scope LINK-LOCAL █
State ICMP_OTHER █
Process - █
PID - █
Service - █
Network Context Transport Health █
Local Hostname - No transport metrics for this protocol █
Local MAC - █
Remote Hostname - █
Attributed Name - █
Attributed Via - █
Remote MAC - ║
Country - ║
City - ║
ASN - ║
▎ Traffic Statistics
↓ RX - → peak - ↑ TX - → peak -
Total 0 B · 0 packets Total 0 B · 0 packets
'h' help | 1-5 jump | Tab/[/] cycle | j/k prev/next | Ctrl-d/u scroll | 'c' copy remote addr | Esc back
@@ -0,0 +1,44 @@
---
source: src/ui/mod.rs
expression: output
---
rustnet 1 Overview 2 Details 3 Activity 4 Graph 5 Help
─────────────────────────━━━━━━━━━──────────────────────────────────────────────────────────────────────────────────────────────────────────
Process Remote Local Service App State RTT Rx↓/Tx↑
> - 224.0.0.251:0 192.168.1.10:0 - IGMP REPORT_V2(2… - -/-
▎ ? → 224.0.0.251:0 · click a field to copy
Connection Application: IGMP █
Protocol IGMP Message Membership Report v2 █
Status Active (last seen <T> ago) Group Address 224.0.0.251 █
Local Address 192.168.1.10:0 █
Remote Address 224.0.0.251:0 █
Scope MULTICAST █
State REPORT_V2(224.0.0.251) █
Process - █
PID - █
Service - █
Network Context Transport Health █
Local Hostname - No transport metrics for this protocol █
Local MAC - █
Remote Hostname - █
Attributed Name - █
Attributed Via - █
Remote MAC - ║
Country - ║
City - ║
ASN - ║
▎ Traffic Statistics
↓ RX - → peak - ↑ TX - → peak -
Total 0 B · 0 packets Total 0 B · 0 packets
'h' help | 1-5 jump | Tab/[/] cycle | j/k prev/next | Ctrl-d/u scroll | 'c' copy remote addr | Esc back
@@ -0,0 +1,44 @@
---
source: src/ui/mod.rs
expression: output
---
rustnet 1 Overview 2 Details 3 Activity 4 Graph 5 Help
─────────────────────────━━━━━━━━━──────────────────────────────────────────────────────────────────────────────────────────────────────────
Process Remote Local Service App State RTT Rx↓/Tx↑
> proc (4242) 203.0.113.7:4433 192.168.1.10:50000 - UDP·NTP (v4 Server) NTP - -/-
▎ proc → 203.0.113.7:4433 · click a field to copy
Connection Application: NTP █
Protocol UDP NTP Version 4 █
Status Active (last seen <T> ago) NTP Mode Server █
Local Address 192.168.1.10:50000 Stratum 2 █
Remote Address 203.0.113.7:4433 █
Scope DOCUMENTATION █
State NTP █
Process proc █
PID 4242 █
Service - █
Network Context Transport Health █
Local Hostname - NTP RTT - █
Local MAC - █
Remote Hostname - Paired by originate timestamp echo █
Attributed Name - █
Attributed Via - █
Remote MAC - ║
Country - ║
City - ║
ASN - ║
▎ Traffic Statistics
↓ RX - → peak - ↑ TX - → peak -
Total 0 B · 0 packets Total 0 B · 0 packets
'h' help | 1-5 jump | Tab/[/] cycle | j/k prev/next | Ctrl-d/u scroll | 'c' copy remote addr | Esc back
@@ -0,0 +1,44 @@
---
source: src/ui/mod.rs
expression: output
---
rustnet 1 Overview 2 Details 3 Activity 4 Graph 5 Help
─────────────────────────━━━━━━━━━──────────────────────────────────────────────────────────────────────────────────────────────────────────
Process Remote Local Service App State RTT Rx↓/Tx↑
> proc (4242) 203.0.113.7:4433 192.168.1.10:50000 - TCP·SSH (OpenSSH) ESTABLISHED - -/-
▎ proc → 203.0.113.7:4433 · click a field to copy
Connection Application: SSH █
Protocol TCP SSH Version SSH-2 █
Status Active (last seen <T> ago) Connection State Established █
Local Address 192.168.1.10:50000 Server Software OpenSSH_9.6p1 █
Remote Address 203.0.113.7:4433 Client Software OpenSSH_9.8 █
Scope DOCUMENTATION Algorithms curve25519-sha256, ssh-ed25519 █
State ESTABLISHED Auth Method publickey █
Process proc █
PID 4242 █
Service - █
Network Context Transport Health █
Local Hostname - Initial RTT - █
Local MAC - Live RTT - █
Remote Hostname - TCP Retransmits 0 █
Attributed Name - Out-of-Order Packets 0 █
Attributed Via - Duplicate ACKs 0 █
Remote MAC - Fast Retransmits 0 ║
Country - Window Size 0 ║
City - ║
ASN - ║
▎ Traffic Statistics
↓ RX - → peak - ↑ TX - → peak -
Total 0 B · 0 packets Total 0 B · 0 packets
'h' help | 1-5 jump | Tab/[/] cycle | j/k prev/next | Ctrl-d/u scroll | 'c' copy remote addr | Esc back
+295 -212
View File
@@ -33,7 +33,8 @@ use crate::ui::{
connection_table::{build_header, column_constraints, connection_row, select_columns},
dpi_color,
format::{format_bytes, format_rate},
section_header, state_color, theme, try_handle_connection_nav, try_handle_pane_wheel,
non_dpi_app_color, section_header, state_color, theme, try_handle_connection_nav,
try_handle_pane_wheel,
widgets::braille_graph,
widgets::scrollbar::draw_scrollbar,
};
@@ -62,7 +63,7 @@ const DETAILS_SCROLL_STEP: u16 = 5;
const DETAILS_MAX_CONTENT_WIDTH: u16 = 140;
/// Rows reserved for the Application card before the Transport Health card.
/// The current protocol decoders expose at most eight application fields. The
/// The current protocol decoders expose at most seven application fields. The
/// right pane trims the first separator, so eleven buffered rows leave ten
/// visible rows and align Transport Health with Network Context on the left.
const APPLICATION_CARD_ROWS: usize = 11;
@@ -184,6 +185,21 @@ impl<'a> DetailsBuilder<'a> {
self.fields.push(Some((label.to_string(), copy)));
}
/// Push a fixed set of Application-card rows. Every row renders, absent
/// data as [`NONE_PLACEHOLDER`], so the card's row set is a function of
/// the protocol class alone and never grows or shrinks with data
/// availability.
fn app_rows(&mut self, rows: &[(&str, Option<String>)]) {
for (label, value) in rows {
self.field(
label,
value
.clone()
.unwrap_or_else(|| NONE_PLACEHOLDER.to_string()),
);
}
}
/// Push an RTT field with the value colored by latency (green < 50ms,
/// yellow < 150ms, red above), or the "-" placeholder when unmeasured.
fn rtt_field(&mut self, label: &str, rtt: Option<std::time::Duration>) {
@@ -528,6 +544,33 @@ fn format_quic_close(close: &crate::network::types::QuicCloseInfo) -> String {
format!("{} 0x{:x}", origin, close.error_code)
}
/// Comma-joined display form of a response-IP list, `None` when empty so the
/// Application card renders its placeholder instead of `[]`-style debug
/// output.
fn join_ips(ips: &[std::net::IpAddr]) -> Option<String> {
(!ips.is_empty()).then(|| {
ips.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(", ")
})
}
/// Shared Application-card row set for the name-service protocols whose info
/// structs carry identical fields (mDNS and LLMNR are distinct types with the
/// same query/response shape).
fn name_service_rows(
query_name: Option<&String>,
query_type: Option<&crate::network::types::DnsQueryType>,
response_ips: &[std::net::IpAddr],
) -> [(&'static str, Option<String>); 3] {
[
("Query Name", query_name.cloned()),
("Query Type", query_type.map(|t| t.to_string())),
("Response IPs", join_ips(response_ips)),
]
}
/// Endpoint address with a broadcast/multicast annotation when the address
/// is a group or broadcast destination rather than an actual host.
fn annotated_addr(addr: std::net::SocketAddr, kind: AddrKind) -> String {
@@ -1169,243 +1212,285 @@ pub(in crate::ui) fn draw_connection_details(
theme::bold_fg(dpi_color(&dpi.application)),
);
// Add protocol-specific details
// Protocol-specific details. Each protocol class renders a fixed row
// set: absent data shows the placeholder instead of dropping the row,
// so labels never move while navigating between connections of the
// same class (and the card's height never depends on the data).
match &dpi.application {
crate::network::types::ApplicationProtocol::Http(info) => {
if let Some(method) = &info.method {
details.field("HTTP Method", method.clone());
}
if let Some(path) = &info.path {
details.field("HTTP Path", path.clone());
}
if let Some(status) = info.status_code {
details.field("HTTP Status", status.to_string());
}
details.app_rows(&[
("HTTP Version", Some(info.version.to_string())),
("HTTP Method", info.method.clone()),
("HTTP Host", info.host.clone()),
("HTTP Path", info.path.clone()),
("HTTP Status", info.status_code.map(|s| s.to_string())),
("User-Agent", info.user_agent.clone()),
]);
}
crate::network::types::ApplicationProtocol::Https(info) => {
if let Some(tls_info) = &info.tls_info {
if let Some(sni) = &tls_info.sni {
details.field("SNI", sni.clone());
}
if !tls_info.alpn.is_empty() {
details.field("ALPN", tls_info.alpn.join(", "));
}
if let Some(version) = &tls_info.version {
details.field("TLS Version", version.to_string());
}
if let Some(formatted_cipher) = tls_info.format_cipher_suite() {
let cipher_color = if tls_info.is_cipher_suite_secure().unwrap_or(false) {
// The full row set renders even before the handshake is
// parsed (tls_info still None): a heading-only card would
// read as a rendering glitch rather than as pending data.
let tls = info.tls_info.as_ref();
details.app_rows(&[
("SNI", tls.and_then(|t| t.sni.clone())),
(
"ALPN",
tls.and_then(|t| (!t.alpn.is_empty()).then(|| t.alpn.join(", "))),
),
(
"TLS Version",
tls.and_then(|t| t.version.map(|v| v.to_string())),
),
]);
// Escape hatch from app_rows: the cipher keeps its ok/warn
// color so a weak suite still stands out.
match tls.and_then(|t| t.format_cipher_suite()) {
Some(cipher) => {
let cipher_color = if tls
.and_then(|t| t.is_cipher_suite_secure())
.unwrap_or(false)
{
theme::ok()
} else {
theme::warn()
};
details.field_styled(
"Cipher Suite",
formatted_cipher,
theme::fg(cipher_color),
);
details.field_styled("Cipher Suite", cipher, theme::fg(cipher_color));
}
None => details.field("Cipher Suite", NONE_PLACEHOLDER.to_string()),
}
}
crate::network::types::ApplicationProtocol::Dns(info) => {
if let Some(query_name) = &info.query_name {
details.field("DNS Query", query_name.clone());
}
if let Some(query_type) = &info.query_type {
details.field("DNS Type", format!("{}", query_type));
}
if !info.response_ips.is_empty() {
details.field("DNS Response IPs", format!("{:?}", info.response_ips));
}
// Disambiguate "record doesn't exist" from "answer not
// parsed": a NOERROR response whose answer section held no
// record of the queried type is a deliberate empty answer.
if info.nodata == Some(true) {
details.field(
// The Answer row disambiguates "record doesn't exist" from
// "answer not parsed": a NOERROR response whose answer
// section held no record of the queried type is a deliberate
// empty answer (NODATA).
details.app_rows(&[
("DNS Query", info.query_name.clone()),
("DNS Type", info.query_type.map(|t| t.to_string())),
("DNS Response IPs", join_ips(&info.response_ips)),
(
"DNS Answer",
"no data (name exists, no record of this type)".to_string(),
);
}
(info.nodata == Some(true))
.then(|| "no data (name exists, no record of this type)".to_string()),
),
("Transaction ID", Some(format!("0x{:04x}", info.txid))),
]);
}
crate::network::types::ApplicationProtocol::Quic(info) => {
if let Some(tls_info) = &info.tls_info {
let sni = tls_info
.sni
.clone()
.unwrap_or_else(|| NONE_PLACEHOLDER.to_string());
details.field("QUIC SNI", sni);
let alpn = tls_info.alpn.join(", ");
details.field("QUIC ALPN", alpn);
}
if let Some(version) = info.version_string.as_deref() {
details.field("QUIC Version", version.to_owned());
}
if let Some(connection_id) = &info.connection_id_hex {
details.field("Connection ID", connection_id.clone());
}
details.field("Packet Type", info.packet_type.to_string());
details.field("Connection State", info.connection_state.to_string());
let tls = info.tls_info.as_ref();
details.app_rows(&[
("QUIC SNI", tls.and_then(|t| t.sni.clone())),
(
"QUIC ALPN",
tls.and_then(|t| (!t.alpn.is_empty()).then(|| t.alpn.join(", "))),
),
(
"QUIC Version",
info.version_string.as_deref().map(str::to_owned),
),
("Connection ID", info.connection_id_hex.clone()),
("Packet Type", Some(info.packet_type.to_string())),
("Connection State", Some(info.connection_state.to_string())),
]);
}
crate::network::types::ApplicationProtocol::Ssh(info) => {
if let Some(version) = &info.version {
details.field("SSH Version", format!("{:?}", version));
}
if let Some(server_software) = &info.server_software {
details.field("Server Software", server_software.clone());
}
if let Some(client_software) = &info.client_software {
details.field("Client Software", client_software.clone());
}
details.field("Connection State", format!("{:?}", info.connection_state));
if !info.algorithms.is_empty() {
details.field("Algorithms", info.algorithms.join(", "));
}
if let Some(auth_method) = &info.auth_method {
details.field("Auth Method", auth_method.clone());
}
details.app_rows(&[
("SSH Version", info.version.as_ref().map(|v| v.to_string())),
("Connection State", Some(info.connection_state.to_string())),
("Server Software", info.server_software.clone()),
("Client Software", info.client_software.clone()),
(
"Algorithms",
(!info.algorithms.is_empty()).then(|| info.algorithms.join(", ")),
),
("Auth Method", info.auth_method.clone()),
]);
}
crate::network::types::ApplicationProtocol::Ntp(info) => {
details.field("NTP Version", format!("{}", info.version));
details.field("NTP Mode", info.mode.to_string());
details.field("Stratum", format!("{}", info.stratum));
// Stratum 0 marks an unspecified/invalid stratum (RFC 5905),
// so it renders as the placeholder rather than a value.
details.app_rows(&[
("NTP Version", Some(info.version.to_string())),
("NTP Mode", Some(info.mode.to_string())),
(
"Stratum",
(info.stratum != 0).then(|| info.stratum.to_string()),
),
]);
}
crate::network::types::ApplicationProtocol::Mdns(info) => {
if let Some(query_name) = &info.query_name {
details.field("Query Name", query_name.clone());
}
if let Some(query_type) = &info.query_type {
details.field("Query Type", format!("{}", query_type));
}
if !info.response_ips.is_empty() {
details.field("Response IPs", format!("{:?}", info.response_ips));
}
details.app_rows(&name_service_rows(
info.query_name.as_ref(),
info.query_type.as_ref(),
&info.response_ips,
));
}
crate::network::types::ApplicationProtocol::Llmnr(info) => {
if let Some(query_name) = &info.query_name {
details.field("Query Name", query_name.clone());
}
if let Some(query_type) = &info.query_type {
details.field("Query Type", format!("{}", query_type));
}
if !info.response_ips.is_empty() {
details.field("Response IPs", format!("{:?}", info.response_ips));
}
// Unlike mDNS, LLMNR keeps its transaction ID (used for
// response timing), so its card carries one extra row.
let [name, query_type, ips] = name_service_rows(
info.query_name.as_ref(),
info.query_type.as_ref(),
&info.response_ips,
);
details.app_rows(&[
name,
query_type,
ips,
("Transaction ID", Some(format!("0x{:04x}", info.txid))),
]);
}
crate::network::types::ApplicationProtocol::Dhcp(info) => {
details.field("Message Type", info.message_type.to_string());
if let Some(hostname) = &info.hostname {
details.field("Hostname", hostname.clone());
}
if let Some(client_mac) = &info.client_mac {
details.field("Client MAC", client_mac.clone());
}
details.app_rows(&[
("Message Type", Some(info.message_type.to_string())),
("Hostname", info.hostname.clone()),
("Client MAC", info.client_mac.clone()),
]);
}
crate::network::types::ApplicationProtocol::Snmp(info) => {
details.field("SNMP Version", info.version.to_string());
details.field("PDU Type", info.pdu_type.to_string());
if let Some(community) = &info.community {
details.field("Community", community.clone());
}
details.app_rows(&[
("SNMP Version", Some(info.version.to_string())),
("PDU Type", Some(info.pdu_type.to_string())),
("Community", info.community.clone()),
]);
}
crate::network::types::ApplicationProtocol::Ssdp(info) => {
details.field("Method", info.method.to_string());
if let Some(service_type) = &info.service_type {
details.field("Service Type", service_type.clone());
}
details.app_rows(&[
("Method", Some(info.method.to_string())),
("Service Type", info.service_type.clone()),
]);
}
crate::network::types::ApplicationProtocol::NetBios(info) => {
details.field("Service", info.service.to_string());
details.field("Opcode", info.opcode.to_string());
if let Some(name) = &info.name {
details.field("Name", name.clone());
}
details.app_rows(&[
("Service", Some(info.service.to_string())),
("Opcode", Some(info.opcode.to_string())),
("Name", info.name.clone()),
(
"Transaction ID",
Some(format!("0x{:04x}", info.transaction_id)),
),
]);
}
crate::network::types::ApplicationProtocol::BitTorrent(info) => {
details.field("Type", info.protocol_type.to_string());
if let Some(client) = &info.client {
details.field("Client", client.clone());
}
if let Some(info_hash) = &info.info_hash {
details.field("Info Hash", info_hash.clone());
}
if let Some(method) = &info.dht_method {
details.field("DHT Method", method.clone());
}
let mut extensions = Vec::new();
if info.supports_dht {
extensions.push("DHT");
}
if info.supports_extension {
extensions.push("Extension Protocol");
}
if info.supports_fast {
extensions.push("Fast");
}
if !extensions.is_empty() {
details.field("Extensions", extensions.join(", "));
}
let extensions: Vec<&str> = [
(info.supports_dht, "DHT"),
(info.supports_extension, "Extension Protocol"),
(info.supports_fast, "Fast"),
]
.into_iter()
.filter_map(|(supported, name)| supported.then_some(name))
.collect();
details.app_rows(&[
("Type", Some(info.protocol_type.to_string())),
("Client", info.client.clone()),
("Info Hash", info.info_hash.clone()),
("DHT Method", info.dht_method.clone()),
(
"Extensions",
(!extensions.is_empty()).then(|| extensions.join(", ")),
),
]);
}
crate::network::types::ApplicationProtocol::Stun(info) => {
details.field("Method", info.method.to_string());
details.field("Class", info.message_class.to_string());
let txn_id = crate::network::util::hex_encode(&info.transaction_id, "");
details.field("Transaction ID", txn_id);
if let Some(software) = &info.software {
details.field("Software", software.clone());
}
details.app_rows(&[
("Method", Some(info.method.to_string())),
("Class", Some(info.message_class.to_string())),
(
"Transaction ID",
Some(crate::network::util::hex_encode(&info.transaction_id, "")),
),
("Software", info.software.clone()),
]);
}
crate::network::types::ApplicationProtocol::Ftp(info) => {
details.field("Message Type", info.message_type.to_string());
if let Some(cmd) = &info.command {
details.field("Command", cmd.clone());
}
if let Some(args) = &info.args {
details.field("Arguments", args.clone());
}
if let Some(code) = info.response_code {
details.field("Response Code", code.to_string());
}
if let Some(message) = &info.response_message {
details.field("Response", message.clone());
}
if let Some(user) = &info.username {
details.field("Username", user.clone());
}
if let Some(sw) = &info.server_software {
details.field("Server Software", sw.clone());
}
if let Some(sys) = &info.system_type {
details.field("System Type", sys.clone());
}
// Code and message describe one server reply; a merged row
// keeps the seven-row FTP card inside the shared budget.
let response = match (info.response_code, &info.response_message) {
(Some(code), Some(message)) => Some(format!("{} {}", code, message)),
(Some(code), None) => Some(code.to_string()),
(None, Some(message)) => Some(message.clone()),
(None, None) => None,
};
details.app_rows(&[
("Message Type", Some(info.message_type.to_string())),
("Command", info.command.clone()),
("Arguments", info.args.clone()),
("Response", response),
("Username", info.username.clone()),
("Server Software", info.server_software.clone()),
("System Type", info.system_type.clone()),
]);
}
crate::network::types::ApplicationProtocol::Mqtt(info) => {
details.field("Packet Type", info.packet_type.to_string());
if let Some(version) = &info.version {
details.field("Version", version.to_string());
}
if let Some(client_id) = &info.client_id {
details.field("Client ID", client_id.clone());
}
if let Some(topic) = &info.topic {
details.field("Topic", topic.clone());
}
if let Some(qos) = info.qos {
details.field("QoS", qos.to_string());
}
details.app_rows(&[
("Packet Type", Some(info.packet_type.to_string())),
("Version", info.version.map(|v| v.to_string())),
("Client ID", info.client_id.clone()),
("Topic", info.topic.clone()),
("QoS", info.qos.map(|q| q.to_string())),
]);
}
}
} else if let ProtocolState::Arp(arp_info) = &conn.protocol_state {
details.section("Application: ARP");
details.field("Sender MAC", arp_info.sender_mac.clone());
if let Some(ref vendor) = arp_info.sender_vendor {
details.field("Sender Vendor", vendor.clone());
}
details.field("Sender IP", arp_info.sender_ip.to_string());
details.field("Target MAC", arp_info.target_mac.clone());
if let Some(ref vendor) = arp_info.target_vendor {
details.field("Target Vendor", vendor.clone());
}
details.field("Target IP", arp_info.target_ip.to_string());
details.section_styled("Application: ARP", theme::bold_fg(non_dpi_app_color()));
let operation = match arp_info.operation {
crate::network::types::ArpOperation::Request => "Request",
crate::network::types::ArpOperation::Reply => "Reply",
};
details.app_rows(&[
("Operation", Some(operation.to_string())),
("Sender MAC", Some(arp_info.sender_mac.clone())),
("Sender Vendor", arp_info.sender_vendor.clone()),
("Sender IP", Some(arp_info.sender_ip.to_string())),
("Target MAC", Some(arp_info.target_mac.clone())),
("Target Vendor", arp_info.target_vendor.clone()),
("Target IP", Some(arp_info.target_ip.to_string())),
]);
} else if let ProtocolState::Icmp {
icmp_type,
icmp_id,
icmp_sequence,
ndp_neighbor,
} = &conn.protocol_state
{
let is_ipv6 = conn.local_addr.is_ipv6();
details.section_styled(
if is_ipv6 {
"Application: ICMPv6"
} else {
"Application: ICMP"
},
theme::bold_fg(non_dpi_app_color()),
);
// "ip at mac (vendor)" mirrors what the ARP card spells out over
// separate rows: NDP messages carry a single IP-to-MAC mapping.
let neighbor = ndp_neighbor.as_ref().map(|n| match &n.vendor {
Some(vendor) => format!("{} at {} ({})", n.ip, n.mac, vendor),
None => format!("{} at {}", n.ip, n.mac),
});
details.app_rows(&[
(
"Message",
Some(crate::network::types::icmp_message_name(*icmp_type, is_ipv6).into_owned()),
),
("Echo ID", icmp_id.map(|id| id.to_string())),
("Sequence", icmp_sequence.map(|seq| seq.to_string())),
("NDP Neighbor", neighbor),
]);
} else if let ProtocolState::Igmp {
igmp_type,
group_addr,
} = &conn.protocol_state
{
details.section_styled("Application: IGMP", theme::bold_fg(non_dpi_app_color()));
details.app_rows(&[
(
"Message",
Some(crate::network::types::igmp_message_name(*igmp_type).into_owned()),
),
("Group Address", group_addr.map(|addr| addr.to_string())),
]);
} else {
details.section("Application");
details.field("Detected", NONE_PLACEHOLDER.to_string());
@@ -1413,7 +1498,13 @@ pub(in crate::ui) fn draw_connection_details(
// Short application records keep their whitespace inside the card instead
// of pulling Transport Health and Traffic Statistics upward. All current
// decoders fit within this budget, including FTP's eight detail fields.
// row sets fit within this budget, including FTP's and ARP's seven rows.
debug_assert!(
details.rows() - application_start <= APPLICATION_CARD_ROWS,
"Application card overflowed its {APPLICATION_CARD_ROWS}-row budget \
({} rows): grow the budget or trim the row set",
details.rows() - application_start,
);
details.pad_section(application_start, APPLICATION_CARD_ROWS);
right_ranges.push(application_start..details.rows());
@@ -1538,24 +1629,16 @@ pub(in crate::ui) fn draw_connection_details(
}
details.plain_line(Line::from(""));
details.note("Timed by pairing request and response IDs");
} else if let Some(stun) = stun_info {
} else if stun_info.is_some() {
// Method and class live in the Application card; this card keeps
// only the measured outcome.
details.rtt_field("STUN RTT", conn.stun_rtt);
details.field(
"Last Message",
format!("{} {}", stun.method, stun.message_class),
);
details.plain_line(Line::from(""));
details.note("Paired by 96-bit transaction ID");
} else if let Some(ntp) = ntp_info {
} else if ntp_info.is_some() {
// Stratum lives in the Application card; this card keeps only the
// measured outcome.
details.rtt_field("NTP RTT", conn.ntp_rtt);
details.field(
"Stratum",
if ntp.stratum == 0 {
NONE_PLACEHOLDER.to_string()
} else {
ntp.stratum.to_string()
},
);
details.plain_line(Line::from(""));
details.note("Paired by originate timestamp echo");
} else if let Some(sequence) = icmp_echo_sequence {