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:
@@ -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
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+44
@@ -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
|
||||
+44
@@ -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
|
||||
+44
@@ -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
|
||||
+44
@@ -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
|
||||
+44
@@ -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
|
||||
+44
@@ -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
|
||||
+44
@@ -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
|
||||
+44
@@ -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
|
||||
+44
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user