Skip to content
Open
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
9 changes: 8 additions & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -324,11 +324,18 @@ The same backend publishes a socket snapshot every 5 seconds for the Host tab. T
The tool automatically detects and lists available network interfaces using platform-specific methods:

- **Linux**: Uses `netlink` or falls back to `/sys/class/net/`
- **macOS**: Uses `getifaddrs()` system call
- **macOS**: Uses `getifaddrs()` system call and `SIOCGIFMEDIA` for link state and speed
- **Windows**: Uses IP Helper APIs (`GetAdaptersInfo()` for interface listing and
`GetAdaptersAddresses()` for the parser's complete IPv4/IPv6 local-address set)
- **All platforms**: Falls back to pcap's `pcap_findalldevs()` when native methods fail

The interface-statistics provider also reports optional directional link
capacity. Linux reads `/sys/class/net/<interface>/speed`, macOS combines active
media information with the interface baud rate, FreeBSD uses `ifi_baudrate`,
and Windows reads `ReceiveLinkSpeed` and `TransmitLinkSpeed` from
`MIB_IF_ROW2`. Aggregate graphs use this capacity only when every interface
carrying traffic in that direction has a known value.

Packet endpoint orientation maintains a snapshot of the addresses currently assigned to
the host. Packet-processing workers refresh it every 30 seconds and, when neither unicast
endpoint is recognized as local, perform a rate-limited refresh and retry that packet once.
Expand Down
8 changes: 7 additions & 1 deletion ARCHITECTURE.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -318,11 +318,17 @@ RustNet 使用平台特定的 API 将网络连接与进程关联。每次归属
该工具使用平台特定的方法自动检测和列出可用网络接口:

- **Linux**:使用 `netlink` 或回退到 `/sys/class/net/`
- **macOS**:使用 `getifaddrs()` 系统调用
- **macOS**:使用 `getifaddrs()` 系统调用,并通过 `SIOCGIFMEDIA` 获取链路状态与速率
- **Windows**:使用 IP Helper API(`GetAdaptersInfo()` 用于列出接口,
`GetAdaptersAddresses()` 用于获取解析器所需的完整 IPv4/IPv6 本地地址集合)
- **所有平台**:当原生方法失败时回退到 pcap 的 `pcap_findalldevs()`

接口统计 provider 还会报告可选的方向链路容量。Linux 读取
`/sys/class/net/<interface>/speed`,macOS 将活跃媒体信息与接口 baud rate 结合,
FreeBSD 使用 `ifi_baudrate`,Windows 则读取 `MIB_IF_ROW2` 的
`ReceiveLinkSpeed` 和 `TransmitLinkSpeed`。只有当该方向上所有正在传输流量的接口
都具有已知容量时,汇总图表才会使用这一固定上限。

数据包端点方向判定会维护当前分配给主机的地址快照。数据包处理线程每 30 秒刷新一次;
当两个单播端点都无法识别为本地地址时,还会以限速方式立即刷新,并重新解析该数据包一次。
因此,DHCP 地址变化、VPN 连接、网络漫游以及 IPv6 隐私地址轮换后,流量方向仍能正确判定。
Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
the selected process group

### Changed
- **Capacity-Scaled Traffic Graphs**: aggregate RX and TX graphs use the local
interfaces' reported link capacity as a stable ceiling and show utilization
when every traffic-bearing interface has a known speed. Unsupported and
virtual interfaces retain observed-peak scaling. A fixed capacity-relative
curve keeps low-utilization traffic visible without moving the ceiling
- **Responsive System Sidebar**: Traffic now appears before the static Security
details, which collapse to the sandbox status when terminal height is limited
- **Contextual Help Overlay**: Help now opens above the active tab and only
Expand Down
1 change: 1 addition & 0 deletions README.ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ RustNet は、各接続を所有するプロセス、通信量、状態、アプ
- HTTP、TLS/SNI、DNS、SSH、QUIC などの深層パケット解析
- TCP、QUIC ハンドシェイク、DNS 応答、ICMP エコーの往復時間(RTT)と、TCP の再送・順序入れ替わりをリアルタイム表示
- Host タブに TCP LISTEN ソケット、UDP BOUND エンドポイント、TCP 状態集計、観測 RTT、所有プロセス、インターフェース統計を表示
- リンク容量を取得できる場合、安定した容量基準の曲線で RX / TX と現在の利用率を表示
- `port:`、`process:`、`sni:`、`state:` などのフィルター
- 注釈付き PCAPNG、PCAP と JSONL sidecar、JSON ログの出力
- ローカル GeoIP データベースによる国、ASN、都市情報
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ See [ARCHITECTURE.md](ARCHITECTURE.md) for technical information.
RustNet combines process-level traffic accounting with real-time network interface statistics:

- **Overview Tab**: Shows active interfaces with current rates, errors, and drops
- **Traffic Graphs**: Use a stable capacity-relative curve and show link utilization when available
- **Activity Tab** (press `3`): Ranks processes by Egress (TX) or Ingress (RX), including retained and rolling traffic, rates, shares, connections, and destinations
- **Security Workflow**: Sort by Egress, identify an unexpected uploader, then inspect its top remote peer and retained traffic even after the connection closes
- **Host Tab** (press `5`): Shows TCP LISTEN sockets, UDP BOUND endpoints, aggregated TCP states, observed RTT, and process ownership
Expand Down
1 change: 1 addition & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ cargo build --release --no-default-features
RustNet 将进程级流量计量与实时网络接口统计整合在一起:

- **概览标签页**:展示当前活跃的接口,包含速率、错误数与丢包数
- **流量图表**:链路容量可用时,以稳定的容量相对曲线显示 RX / TX 和利用率
- **活动标签页**(按 `3`):按出站 (TX) 或入站 (RX) 查看进程排名,包括保留流量与滚动流量、速率、占比、连接数和目的地
- **安全工作流**:按出站流量排序,找出异常上传进程,然后检查其流量最大的远端对端;即使连接关闭,仍可查看保留流量
- **主机标签页**(按 `5`):显示 TCP LISTEN 套接字、UDP BOUND 端点、TCP 状态汇总、观测 RTT 和所属进程
Expand Down
9 changes: 9 additions & 0 deletions USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -1095,6 +1095,15 @@ In process grouping mode (`a`), group headers show the historic connection count
**Graph tab:**

The graph tab always shows only active connections, even when historic mode is on.
Its aggregate RX and TX waves use the operating system's reported link capacity
as a stable ceiling when all traffic-bearing interfaces have a known speed. The
header then shows current utilization and, where space permits, the link
capacity. A fixed capacity-relative curve keeps low utilization visible without
moving that ceiling. Tunnels, virtual adapters, and drivers without speed
information fall back automatically to the rolling observed-peak scale. A
`>100%` indicator means the measured interface counters exceeded the reported
capacity; the wave remains pinned to the capacity ceiling so the mismatch is
visible.

**Resetting:**

Expand Down
6 changes: 5 additions & 1 deletion USAGE.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -1072,7 +1072,11 @@ RustNet 根据协议和检测到的应用调整连接超时:

**图表标签页:**

图表标签页始终只显示活跃连接,即使历史模式开启。
图表标签页始终只显示活跃连接,即使历史模式开启。当所有正在传输流量的接口都能提供链路速率时,
汇总 RX 和 TX 波形会使用操作系统报告的链路容量作为稳定上限。标题会显示当前利用率,并在空间足够时
显示链路容量。固定的容量相对曲线会在不移动上限的情况下保持低利用率流量可见。隧道、虚拟适配器或
无法提供速率信息的驱动会自动回退到滚动观测峰值缩放。
`>100%` 表示测得的接口计数器速率超过了报告容量;波形仍固定在容量上限,以便明确显示这种不一致。

**重置:**

Expand Down
155 changes: 154 additions & 1 deletion crates/rustnet-core/src/network/interface_stats/bsd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
//! struct layout differs (Apple's counters are `u32`, FreeBSD's are `u64`),
//! so each platform supplies its own extractor to the shared walker.

use super::{InterfaceStats, InterfaceStatsProvider};
use super::{InterfaceStats, InterfaceStatsProvider, LinkCapacity};
use std::ffi::CStr;
use std::io;
use std::ptr;
Expand Down Expand Up @@ -51,6 +51,138 @@ fn collect_af_link_stats(
#[cfg(target_os = "macos")]
pub struct MacOSStatsProvider;

#[cfg(target_os = "macos")]
const IFM_ETHER: i32 = 0x20;
#[cfg(target_os = "macos")]
const IFM_IEEE80211: i32 = 0x80;
#[cfg(target_os = "macos")]
const IFM_NMASK: i32 = 0xe0;
#[cfg(target_os = "macos")]
const IFM_TMASK_COMPAT: i32 = 0x1f;
#[cfg(target_os = "macos")]
const IFM_TMASK_EXT: i32 = 0x000f_0000;
#[cfg(target_os = "macos")]
const IFM_TMASK_EXT_SHIFT: u32 = 11;
#[cfg(target_os = "macos")]
const IFM_AVALID: i32 = 0x1;
#[cfg(target_os = "macos")]
const IFM_ACTIVE: i32 = 0x2;

/// Apple's public `ifmediareq` is packed to four-byte alignment, including
/// its pointer field. The libc crate exposes the ioctl constants but not this
/// userspace request structure on macOS.
#[cfg(target_os = "macos")]
#[repr(C, packed(4))]
struct MacIfMediaReq {
ifm_name: [libc::c_char; libc::IFNAMSIZ],
ifm_current: libc::c_int,
ifm_mask: libc::c_int,
ifm_status: libc::c_int,
ifm_active: libc::c_int,
ifm_count: libc::c_int,
ifm_ulist: *mut libc::c_int,
}

#[cfg(target_os = "macos")]
fn media_subtype(media: i32) -> i32 {
(media & IFM_TMASK_COMPAT) | ((media & IFM_TMASK_EXT) >> IFM_TMASK_EXT_SHIFT)
}

#[cfg(target_os = "macos")]
fn ethernet_media_speed_bps(subtype: i32) -> Option<u64> {
let mbps = match subtype {
3..=5 | 12 | 13 => 10,
6..=10 | 52 | 62 => 100,
11 | 14..=16 | 24 | 25 | 41 => 1_000,
22 | 32 | 36 | 63 => 2_500,
23 | 64 | 69 | 70 => 5_000,
18..=21 | 26..=29 | 33..=35 | 42 | 59 => 10_000,
30 => 20_000,
40 | 53..=55 | 58 | 60 | 61 | 65..=68 | 71 | 86 => 25_000,
37 | 38 | 43..=45 | 72..=74 => 40_000,
39 | 56 | 57 | 75..=85 | 87 | 88 => 50_000,
51 => 56_000,
47..=50 | 89..=102 => 100_000,
103..=105 => 200_000,
_ => return None,
};
Some(mbps * 1_000_000)
}

#[cfg(target_os = "macos")]
fn wireless_media_speed_bps(subtype: i32) -> Option<u64> {
let bits_per_second = match subtype {
3 | 8 => 1_000_000,
4 | 5 => 2_000_000,
6 => 5_500_000,
7 => 11_000_000,
9 => 22_000_000,
_ => return None,
};
Some(bits_per_second)
}

#[cfg(target_os = "macos")]
fn media_speed_bps(media: i32) -> Option<u64> {
let subtype = media_subtype(media);
match media & IFM_NMASK {
IFM_ETHER => ethernet_media_speed_bps(subtype),
IFM_IEEE80211 => wireless_media_speed_bps(subtype),
_ => None,
}
}

/// Return whether the link is active and the current media word. Extended
/// media is attempted first so links above 20 Gb/s are not collapsed to
/// `IFM_OTHER` by the compatibility ioctl.
#[cfg(target_os = "macos")]
fn query_media(interface: &str) -> Option<(bool, i32)> {
let name = interface.as_bytes();
if name.len() >= libc::IFNAMSIZ {
return None;
}

let socket = unsafe { libc::socket(libc::AF_INET, libc::SOCK_DGRAM, 0) };
if socket < 0 {
return None;
}

let mut request: MacIfMediaReq = unsafe { std::mem::zeroed() };
for (target, source) in request.ifm_name.iter_mut().zip(name) {
*target = *source as libc::c_char;
}

let extended = unsafe { libc::ioctl(socket, libc::SIOCGIFXMEDIA, &mut request) };
let result = if extended == 0 {
extended
} else {
unsafe { libc::ioctl(socket, libc::SIOCGIFMEDIA, &mut request) }
};
unsafe { libc::close(socket) };
if result != 0 {
return None;
}

let status = unsafe { std::ptr::addr_of!(request.ifm_status).read_unaligned() };
let active_media = unsafe { std::ptr::addr_of!(request.ifm_active).read_unaligned() };
let active = status & IFM_AVALID == 0 || status & IFM_ACTIVE != 0;
Some((active, active_media))
}

#[cfg(target_os = "macos")]
fn macos_link_capacity(interface: &str, baudrate: u32) -> LinkCapacity {
let media = match query_media(interface) {
Some((false, _)) => return LinkCapacity::default(),
Some((true, media)) => media_speed_bps(media),
None => None,
};
let baudrate = (baudrate > 0 && baudrate != u32::MAX).then_some(u64::from(baudrate));
media
.or(baudrate)
.map(LinkCapacity::symmetric)
.unwrap_or_default()
}

/// Sanitize counter values that may be uninitialized or invalid on virtual interfaces.
/// On macOS, some virtual interfaces (like vmenet0) report garbage values for certain
/// statistics fields, particularly ifi_iqdrops. We detect these by checking if:
Expand Down Expand Up @@ -83,6 +215,7 @@ impl InterfaceStatsProvider for MacOSStatsProvider {
let total_tx_packets = if_data.ifi_opackets;

InterfaceStats {
link_capacity: macos_link_capacity(&name, if_data.ifi_baudrate),
interface_name: name,
rx_bytes: if_data.ifi_ibytes as u64,
tx_bytes: if_data.ifi_obytes as u64,
Expand Down Expand Up @@ -112,6 +245,9 @@ impl InterfaceStatsProvider for FreeBSDStatsProvider {
// FreeBSD's if_data counters are already u64.
collect_af_link_stats(|if_data, name| InterfaceStats {
interface_name: name,
link_capacity: (if_data.ifi_baudrate > 0)
.then(|| LinkCapacity::symmetric(if_data.ifi_baudrate))
.unwrap_or_default(),
rx_bytes: if_data.ifi_ibytes,
tx_bytes: if_data.ifi_obytes,
rx_packets: if_data.ifi_ipackets,
Expand Down Expand Up @@ -146,6 +282,23 @@ mod tests {
}
}

#[cfg(target_os = "macos")]
#[test]
fn macos_media_request_matches_system_abi() {
assert_eq!(std::mem::size_of::<MacIfMediaReq>(), 44);
}

#[cfg(target_os = "macos")]
#[test]
fn maps_common_macos_media_speeds() {
assert_eq!(ethernet_media_speed_bps(3), Some(10_000_000));
assert_eq!(ethernet_media_speed_bps(16), Some(1_000_000_000));
assert_eq!(ethernet_media_speed_bps(22), Some(2_500_000_000));
assert_eq!(ethernet_media_speed_bps(42), Some(10_000_000_000));
assert_eq!(ethernet_media_speed_bps(97), Some(100_000_000_000));
assert_eq!(ethernet_media_speed_bps(0), None);
}

#[cfg(target_os = "freebsd")]
#[test]
fn test_freebsd_list_interfaces() {
Expand Down
33 changes: 32 additions & 1 deletion crates/rustnet-core/src/network/interface_stats/linux.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// interface_stats/linux.rs - Linux sysfs-based interface stats

use super::{InterfaceStats, InterfaceStatsProvider};
use super::{InterfaceStats, InterfaceStatsProvider, LinkCapacity};
use std::fs;
use std::io;
use std::time::SystemTime;
Expand All @@ -22,6 +22,7 @@ impl LinuxStatsProvider {

Ok(InterfaceStats {
interface_name: interface.to_string(),
link_capacity: read_link_capacity(interface),
rx_bytes: read_stat(&base_path, "rx_bytes")?,
tx_bytes: read_stat(&base_path, "tx_bytes")?,
rx_packets: read_stat(&base_path, "rx_packets")?,
Expand All @@ -36,6 +37,27 @@ impl LinuxStatsProvider {
}
}

/// Read the latest negotiated speed exposed by ethtool-backed drivers.
/// Missing files and sentinel values are normal for loopback, tunnels, and
/// virtual interfaces, so capacity discovery must not fail stat collection.
fn read_link_capacity(interface: &str) -> LinkCapacity {
let path = format!("/sys/class/net/{interface}/speed");
fs::read_to_string(path)
.ok()
.and_then(|value| parse_speed_mbps(&value))
.map(LinkCapacity::symmetric)
.unwrap_or_default()
}

fn parse_speed_mbps(value: &str) -> Option<u64> {
value
.trim()
.parse::<u64>()
.ok()
.filter(|speed| *speed > 0)
.and_then(|speed| speed.checked_mul(1_000_000))
}

impl InterfaceStatsProvider for LinuxStatsProvider {
fn get_all_stats(&self) -> Result<Vec<InterfaceStats>, io::Error> {
let mut stats = Vec::new();
Expand Down Expand Up @@ -147,4 +169,13 @@ mod tests {
assert!(result.is_err());
assert_eq!(result.unwrap_err().kind(), io::ErrorKind::NotFound);
}

#[test]
fn parses_optional_link_speed() {
assert_eq!(parse_speed_mbps("1000\n"), Some(1_000_000_000));
assert_eq!(parse_speed_mbps("-1\n"), None);
assert_eq!(parse_speed_mbps("0"), None);
assert_eq!(parse_speed_mbps("not available"), None);
assert_eq!(parse_speed_mbps(&u64::MAX.to_string()), None);
}
}
Loading