Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]

### Added
- **Inline Connection Health**: connection rows now show compact TCP
retransmit/out-of-order, QUIC Retry/version, and transactional UDP
retry/timeout badges, with a severity-first Health sort. The Details
Transport Health card marks the counters behind the badge with their
letters, e.g. `TCP Retransmits (R)` and `Out-of-Order (O)` (#583)
- **VPN Traffic Detection**: identify WireGuard and OpenVPN connections through
packet signatures, including OpenVPN over UDP and TCP
packet signatures, including OpenVPN over UDP and TCP (#581)
- **Host Socket Inventory**: the new Host tab shows TCP LISTEN sockets, UDP
BOUND endpoints, TCP state totals, observed RTT, process owners, and the
detailed interface table on Linux, macOS, FreeBSD, and Windows
Expand Down
2 changes: 1 addition & 1 deletion README.ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ RustNet は、各接続を所有するプロセス、通信量、状態、アプ
- TCP、UDP、QUIC 接続とプロセスの対応付け。詳細には PID、実行ファイル、ユーザー/グループ名、照合の信頼度、全プラットフォーム共通の親プロセスチェーン(上限あり)を表示
- Linux 5.11 以降では、起動時の BPF task-file イテレーターにより、ファイル capabilities で実行した場合でも root や他ユーザーが所有する既存 socket を識別
- HTTP、TLS/SNI、DNS、SSH、QUIC、WireGuard、OpenVPN などの深層パケット解析
- TCP、QUIC ハンドシェイク、DNS 応答、ICMP エコーの往復時間(RTT)と、TCP の再送・順序入れ替わりをリアルタイム表示
- TCP、QUIC ハンドシェイク、DNS 応答、ICMP エコーの往復時間(RTT)と、TCP の再送・順序入れ替わりをリアルタイム表示。Overview テーブルではプロトコル別のヘルスバッジにより、TCP の問題、明示的な QUIC Retry/バージョンネゴシエーション、トランザクション型 UDP の再試行/タイムアウトを表示し、重大度順に並べ替え可能
- Host タブに TCP LISTEN ソケット、UDP BOUND エンドポイント、TCP 状態集計、観測 RTT、所有プロセス、インターフェース統計を表示
- `port:`、`process:`、`sni:`、`state:` などのフィルター
- 注釈付き PCAPNG、PCAP と JSONL sidecar、JSON ログの出力
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
- **Deep packet inspection**: Identify HTTP, HTTPS/TLS with SNI, DNS, SSH, FTP, QUIC, MQTT, BitTorrent, WireGuard, OpenVPN, STUN, NTP, mDNS, LLMNR, DHCP, SNMP, SSDP, and NetBIOS, without external dissectors.
- **Annotated PCAPNG export**: `--pcapng-export` writes a Wireshark-ready capture with process, PID, direction, DPI/SNI, and GeoIP embedded as per-packet comments. Open it in Wireshark and every packet already names its owning process, with no post-processing. Classic `--pcap-export` with a JSONL sidecar for offline correlation is also available.
- **Security sandboxing**: Landlock (Linux 5.13+), Seatbelt (macOS), token privilege drop + job-object child-process block (Windows). Drops privileges immediately after libpcap initializes. See [SECURITY.md](SECURITY.md).
- **Network analytics**: Real-time round-trip times for TCP, QUIC handshakes, DNS responses, and ICMP echo, plus TCP retransmission, out-of-order, and fast-retransmit detection.
- **Network analytics**: Real-time round-trip times for TCP, QUIC handshakes, DNS responses, and ICMP echo, plus TCP retransmission, out-of-order, and fast-retransmit detection. Protocol-aware health badges surface TCP issues, explicit QUIC Retry/version events, and retries/timeouts for transaction-based UDP, with severity-first sorting in the Overview table.
- **Smart connection lifecycle**: Protocol-aware timeouts with white → yellow → red staleness indicators. Toggle `t` to keep historic (closed) connections visible for forensics.
- **Vim/fzf-style filtering**: `port:`, `src:`, `dst:`, `sni:`, `process:`, `state:`, `proto:`, plus regex via `/(?i)pattern/`.
- **GeoIP enrichment**: Country lookups via local MaxMind GeoLite2. No network calls.
Expand Down
2 changes: 1 addition & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
- **深度包检测**:无需外部解析器即可识别 HTTP、带 SNI 的 HTTPS/TLS、DNS、SSH、FTP、QUIC、MQTT、BitTorrent、WireGuard、OpenVPN、STUN、NTP、mDNS、LLMNR、DHCP、SNMP、SSDP 及 NetBIOS。
- **带注释的 PCAPNG 导出**:`--pcapng-export` 可写出能直接用 Wireshark 打开的捕获文件,并将进程、PID、方向、DPI/SNI 和 GeoIP 作为逐包注释嵌入。每个数据包都会直接标明所属进程,无需后处理。也可使用经典的 `--pcap-export` 配合 JSONL sidecar 进行离线关联。
- **安全沙箱**:Linux 5.13+ 使用 Landlock,macOS 使用 Seatbelt,Windows 通过 token 降权 + job-object 阻止子进程创建。libpcap 初始化完成后立即丢弃特权。详见 [SECURITY.zh-CN.md](SECURITY.zh-CN.md)。
- **网络分析**:实时统计 TCP、QUIC 握手、DNS 响应及 ICMP 回显的往返时延,并检测 TCP 重传、乱序包和快重传。
- **网络分析**:实时统计 TCP、QUIC 握手、DNS 响应及 ICMP 回显的往返时延,并检测 TCP 重传、乱序包和快重传。概览表格通过按协议显示的健康徽标,呈现 TCP 问题、明确可见的 QUIC Retry/版本协商事件,以及事务型 UDP 的重试/超时,并按严重程度排序。
- **智能连接生命周期**:按协议设置超时,以白 → 黄 → 红的颜色指示过期程度。按 `t` 可保留历史(已关闭)连接以便事后追溯。
- **Vim / fzf 风格过滤**:支持 `port:`、`src:`、`dst:`、`sni:`、`process:`、`state:`、`proto:`,以及 `/(?i)pattern/` 形式的正则。
- **GeoIP 增强**:基于本地 MaxMind GeoLite2 数据库查询国家信息,不发起任何网络请求。
Expand Down
23 changes: 22 additions & 1 deletion USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -567,6 +567,7 @@ Press `s` to cycle through columns in left-to-right order:
| **Application** | ↑ Ascending | Sort by detected application protocol (HTTP, DNS, etc.), with TCP/UDP as tie-break |
| **State** | ↑ Ascending | Sort by connection state (ESTABLISHED, etc.) |
| **RTT** | ↓ Descending | Sort by round-trip time (slowest connections first by default) |
| **Health** | ↓ Descending | Sort protocol-aware health signals by severity, then event count |
| **Bandwidth (Rx/Tx)** | ↓ Descending | Sort by **combined up+down** bandwidth (highest first by default) |

Columns hidden at narrow terminal widths stay in the cycle — the active sort is always named in the table's section title.
Expand Down Expand Up @@ -594,7 +595,7 @@ Section title shows current sort:
**Press `s` (lowercase) - Cycle Columns:**
- Moves to the next column in left-to-right visual order
- **Resets to default direction** for that column
- Bandwidth column defaults to descending (↓) to show highest values first
- Bandwidth, RTT, and Health default to descending (↓) to show the most significant values first
- Text columns default to ascending (↑) for alphabetical order

**Press `S` (Shift+s) - Toggle Direction:**
Expand Down Expand Up @@ -768,6 +769,26 @@ Active TCP Flows: 18

### Per-Connection Statistics

The Overview table shows observable connection quality in the **Health**
column. The badge adapts to the protocol:

- `R3/O1` for TCP means three retransmits and one out-of-order packet.
- `R1/V0` for QUIC means one explicit Retry and no Version Negotiation packet.
- `R2/T1` for outgoing DNS, LLMNR, NetBIOS, STUN, or NTP transactions means
two repeated request IDs and one request that expired unanswered. NTP polls
carry a fresh transmit timestamp each time, so NTP surfaces timeouts rather
than retries.

Clean, gradable connections show `ok`. Generic UDP, unsupported protocols, and
transaction rows where no outgoing request was observed show `-`. Double-digit
counts are displayed as `+`, while Details retains the exact counters. The
Details Transport Health card marks the two counters behind the badge with
their letters (`TCP Retransmits (R)`, `Out-of-Order (O)`), so the compact badge
maps back to exact numbers. Health
sorting is severity-first: TCP retransmits and request timeouts rank above
warning-only out-of-order, retry, and version events, then higher counts rank
first.

When viewing connection details (press `Enter` on a connection), TCP analytics are shown for that specific connection:

```
Expand Down
18 changes: 17 additions & 1 deletion USAGE.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -557,6 +557,7 @@ RustNet 提供强大的表格排序功能来帮助你分析网络连接。按 `s
| **Application** | ↑ 升序 | 按检测到的应用协议排序(HTTP、DNS 等),以 TCP/UDP 作为同序比较 |
| **State** | ↑ 升序 | 按连接状态排序(ESTABLISHED 等) |
| **RTT** | ↓ 降序 | 按往返时延排序(默认最慢的连接优先) |
| **Health** | ↓ 降序 | 按协议相关健康信号的严重程度排序,再按事件数排序 |
| **Bandwidth (Rx/Tx)** | ↓ 降序 | 按**上下行合计**带宽排序(默认最高优先) |

在窄终端下被隐藏的列仍留在循环中 —— 当前排序列始终显示在表格的区段标题中。
Expand Down Expand Up @@ -584,7 +585,7 @@ Process │ Remote ↑ │ Local │ Service │ App │ ...
**按 `s`(小写)—— 循环列:**
- 移动到从左到右视觉顺序的下一列
- **重置为该列的默认方向**
- 带宽列默认降序(↓)以优先显示最高值
- 带宽、RTT 和 Health 默认降序(↓),优先显示最显著的值
- 文本列默认升序(↑)以按字母顺序排列

**按 `S`(Shift+s)—— 切换方向:**
Expand Down Expand Up @@ -757,6 +758,21 @@ Active TCP Flows: 18

### 逐连接统计<a id="per-connection-statistics"></a>

概览表格的 **Health** 列会显示可观测的连接质量,徽标会随协议变化:

- TCP 的 `R3/O1` 表示三次重传和一个乱序包。
- QUIC 的 `R1/V0` 表示一个明确的 Retry 包,没有版本协商包。
- 对于出站 DNS、LLMNR、NetBIOS、STUN 或 NTP 事务,`R2/T1` 表示
两次使用相同请求 ID 的重试和一个未收到应答而过期的请求。NTP 每次
轮询都携带新的发送时间戳,因此 NTP 主要报告超时而非重试。

可评估且无异常的连接显示 `ok`。普通 UDP、不支持的协议,以及未观察到
出站请求的事务连接显示 `-`。两位数及以上的计数显示为 `+`,详情页保留
精确计数。详情页的 Transport Health 卡片会在徽标对应的两个计数标签后
标注字母(`TCP Retransmits (R)`、`Out-of-Order (O)`),便于把紧凑徽标
对应回精确数字。Health 排序优先按严重程度:TCP 重传和请求超时高于仅告警的
乱序、重试及版本事件,同级再按事件数降序排列。

查看连接详情时(在连接上按 `Enter`),显示该特定连接的 TCP 分析:

```
Expand Down
106 changes: 104 additions & 2 deletions crates/rustnet-core/src/network/tracker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ use crate::network::neighbors::{NeighborCache, NeighborEntry};
use crate::network::parser::ParsedPacket;
use crate::network::types::{
ApplicationProtocol, AttributionSource, Connection, ConnectionKey, Protocol, ProtocolState,
QuicPacketType, RttTracker,
QuicPacketType, RequestHealthEvents, RttTracker,
};
use dashmap::DashMap;
use rustc_hash::FxBuildHasher;
Expand Down Expand Up @@ -282,6 +282,49 @@ fn apply_timings(conn: &mut Connection, timings: &PacketTimings) {
}
}

/// Count explicit QUIC health signals and mark outgoing timed requests.
fn apply_observed_protocol_health(conn: &mut Connection, parsed: &ParsedPacket) {
let Some(dpi) = parsed.dpi_result.as_ref() else {
return;
};
match &dpi.application {
ApplicationProtocol::Quic(quic) => match quic.packet_type {
QuicPacketType::Retry => {
conn.protocol_health.quic_retry_count =
conn.protocol_health.quic_retry_count.saturating_add(1);
}
QuicPacketType::VersionNegotiation => {
conn.protocol_health.quic_version_negotiation_count = conn
.protocol_health
.quic_version_negotiation_count
.saturating_add(1);
}
_ => {}
},
ApplicationProtocol::Dns(info) if parsed.is_outgoing && !info.is_response => {
conn.protocol_health.request_observed = true;
}
ApplicationProtocol::Llmnr(info) if parsed.is_outgoing && !info.is_response => {
conn.protocol_health.request_observed = true;
}
ApplicationProtocol::NetBios(info) if parsed.is_outgoing && info.is_request() => {
conn.protocol_health.request_observed = true;
}
ApplicationProtocol::Stun(info)
if parsed.is_outgoing
&& info.message_class == crate::network::types::StunMessageClass::Request =>
{
conn.protocol_health.request_observed = true;
}
ApplicationProtocol::Ntp(info)
if parsed.is_outgoing && info.mode == crate::network::types::NtpMode::Client =>
{
conn.protocol_health.request_observed = true;
}
_ => {}
}
}

/// A live, lifecycle-managed table of network connections built from parsed
/// packets. See the [module docs](self) for the intended usage.
pub struct ConnectionTracker {
Expand Down Expand Up @@ -439,6 +482,7 @@ impl ConnectionTracker {
let mut netbios_response_time: Option<Duration> = None;
let mut stun_rtt: Option<Duration> = None;
let mut ntp_rtt: Option<Duration> = None;
let mut request_health_events = RequestHealthEvents::default();
let base_key = parsed.connection_key();
if parsed.protocol == Protocol::Tcp
&& let Some(tcp_header) = &parsed.tcp_header
Expand Down Expand Up @@ -523,8 +567,11 @@ impl ConnectionTracker {
}
_ => {}
}
request_health_events = tracker.take_request_health_events();
}

self.apply_request_health_events(request_health_events);

// ICMP echo requests reuse one identifier for the life of a ping
// process, so sequence number is part of the key. That allows several
// subsecond requests to be pending at once and replies to arrive out of
Expand Down Expand Up @@ -586,6 +633,21 @@ impl ConnectionTracker {
}
}

fn apply_request_health_events(&self, events: RequestHealthEvents) {
for key in events.retries {
if let Some(mut conn) = self.connections.get_mut(&key) {
conn.protocol_health.request_retry_count =
conn.protocol_health.request_retry_count.saturating_add(1);
}
}
for key in events.timeouts {
if let Some(mut conn) = self.connections.get_mut(&key) {
conn.protocol_health.request_timeout_count =
conn.protocol_health.request_timeout_count.saturating_add(1);
}
}
}

fn ingest_into_active(
&self,
parsed: &ParsedPacket,
Expand Down Expand Up @@ -615,11 +677,13 @@ impl ConnectionTracker {
.and_modify(|conn| {
deltas = merge_packet_into_connection(conn, parsed, now);
apply_timings(conn, &timings);
apply_observed_protocol_health(conn, parsed);
})
.or_insert_with(|| {
created = true;
let mut conn = create_connection_from_packet(parsed, now);
apply_timings(&mut conn, &timings);
apply_observed_protocol_health(&mut conn, parsed);
// Attribute a hostname (or enroll for a later DNS response)
// at creation. The cache only touches its own maps, so this
// is safe under the entry's shard lock.
Expand Down Expand Up @@ -885,6 +949,12 @@ impl ConnectionTracker {
.lifecycle
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let request_health_events = self
.rtt
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.expire_requests(now);
self.apply_request_health_events(request_health_events);
let mut removed: Vec<Connection> = Vec::new();
let mut removed_keys: Vec<ConnectionKey> = Vec::new();
let mut to_archive: Vec<(HistoricKey, Connection)> = Vec::new();
Expand Down Expand Up @@ -1460,6 +1530,20 @@ mod tests {
);
}

#[test]
fn quic_retry_and_version_negotiation_packets_are_counted() {
let tracker = ConnectionTracker::new();
let retry = tracker.ingest_at(&quic_packet(QuicPacketType::Retry, false), capture_time(0));
tracker.ingest_at(
&quic_packet(QuicPacketType::VersionNegotiation, false),
capture_time(1),
);

let conn = tracker.connections().get(&retry.key).unwrap().clone();
assert_eq!(conn.protocol_health.quic_retry_count, 1);
assert_eq!(conn.protocol_health.quic_version_negotiation_count, 1);
}

/// rustnet watches from an endpoint, so an arriving packet followed by this
/// host's own answer spans no network at all — it times the local stack's
/// turnaround. Only an outbound packet may start the clock.
Expand Down Expand Up @@ -1602,10 +1686,28 @@ mod tests {
#[test]
fn retransmitted_dns_query_measures_from_the_last_send() {
let tracker = ConnectionTracker::new();
tracker.ingest_at(&dns_packet(0x1234, true, false, 0), capture_time(0));
let query = tracker.ingest_at(&dns_packet(0x1234, true, false, 0), capture_time(0));
tracker.ingest_at(&dns_packet(0x1234, true, false, 0), capture_time(1_000));
let response = tracker.ingest_at(&dns_packet(0x1234, false, true, 0), capture_time(1_018));
assert_eq!(response.dns_response_time, Some(Duration::from_millis(18)));

let conn = tracker.connections().get(&query.key).unwrap().clone();
assert!(conn.protocol_health.request_observed);
assert_eq!(conn.protocol_health.request_retry_count, 1);
assert_eq!(conn.protocol_health.request_timeout_count, 0);
}

#[test]
fn unanswered_dns_query_is_counted_as_a_timeout() {
let tracker = ConnectionTracker::new();
let query = tracker.ingest_at(&dns_packet(0x1234, true, false, 0), capture_time(0));

tracker.cleanup(capture_time(10_001));

let conn = tracker.connections().get(&query.key).unwrap().clone();
assert!(conn.protocol_health.request_observed);
assert_eq!(conn.protocol_health.request_retry_count, 0);
assert_eq!(conn.protocol_health.request_timeout_count, 1);
}

/// SERVFAIL is still an answer: the round trip completed, so it is a valid
Expand Down
8 changes: 7 additions & 1 deletion crates/rustnet-core/src/network/types/connection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ use super::identity::{
use super::protocol_info::{
ApplicationProtocol, DpiInfo, QuicConnectionState, QuicInfo, QuicPacketType,
};
use super::rates::{RateTracker, TERMINAL_ARCHIVE_GRACE, TcpAnalytics, smooth_rate};
use super::rates::{
ProtocolHealth, RateTracker, TERMINAL_ARCHIVE_GRACE, TcpAnalytics, smooth_rate,
};

/// Distribution of connections by application protocol (from DPI)
#[derive(Debug, Clone, Default)]
Expand Down Expand Up @@ -191,6 +193,9 @@ pub struct Connection {
// TCP analytics (only for TCP connections)
pub tcp_analytics: Option<TcpAnalytics>,

// Health signals observable without TCP sequence analysis.
pub protocol_health: ProtocolHealth,

// Initial RTT measurement: TCP SYN/SYN-ACK timing, or the QUIC long-header
// handshake exchange. Set once, from the first round trip observed.
pub initial_rtt: Option<std::time::Duration>,
Expand Down Expand Up @@ -299,6 +304,7 @@ impl Connection {
current_incoming_rate_bps: 0.0,
current_outgoing_rate_bps: 0.0,
tcp_analytics,
protocol_health: ProtocolHealth::default(),
initial_rtt: None,
dns_response_time: None,
llmnr_response_time: None,
Expand Down
Loading