chore: import upstream snapshot with attribution
Rust / build (push) Failing after 1s
Rust / docker (push) Failing after 0s

This commit is contained in:
wehub-resource-sync
2026-07-13 12:29:44 +08:00
commit 19dc5d82a0
236 changed files with 224662 additions and 0 deletions
+3070
View File
File diff suppressed because it is too large Load Diff
+227
View File
@@ -0,0 +1,227 @@
use clap::{Arg, Command};
#[cfg(target_os = "linux")]
const INTERFACE_HELP: &str = "Network interface to monitor (use \"any\" to capture all interfaces)";
#[cfg(not(target_os = "linux"))]
const INTERFACE_HELP: &str = "Network interface to monitor";
#[cfg(target_os = "macos")]
const BPF_HELP: &str = "BPF filter expression for packet capture (e.g., \"tcp port 443\"). Note: Using a BPF filter disables PKTAP (process info falls back to lsof)";
#[cfg(not(target_os = "macos"))]
const BPF_HELP: &str =
"BPF filter expression for packet capture (e.g., \"tcp port 443\", \"dst port 80\")";
pub fn build_cli() -> Command {
let cmd = Command::new("rustnet")
.version(env!("CARGO_PKG_VERSION"))
.author("Network Monitor")
.about("Cross-platform network monitoring tool")
.arg(
Arg::new("interface")
.short('i')
.long("interface")
.value_name("INTERFACE")
.help(INTERFACE_HELP)
.required(false),
)
.arg(
Arg::new("no-localhost")
.long("no-localhost")
.help("Filter out localhost connections")
.action(clap::ArgAction::SetTrue),
)
.arg(
Arg::new("show-localhost")
.long("show-localhost")
.help("Show localhost connections (overrides default filtering)")
.action(clap::ArgAction::SetTrue),
)
.arg(
Arg::new("refresh-interval")
.short('r')
.long("refresh-interval")
.value_name("MILLISECONDS")
.help("UI refresh interval in milliseconds")
.value_parser(clap::value_parser!(u64))
.default_value("1000")
.required(false),
)
.arg(
Arg::new("no-dpi")
.long("no-dpi")
.help("Disable deep packet inspection")
.action(clap::ArgAction::SetTrue),
)
.arg(
Arg::new("log-level")
.short('l')
.long("log-level")
.value_name("LEVEL")
.help("Set the log level (if not provided, no logging will be enabled)")
.required(false),
)
.arg(
Arg::new("json-log")
.long("json-log")
.value_name("FILE")
.help("Enable JSON logging of connection events to specified file")
.required(false),
)
.arg(
Arg::new("pcap-export")
.long("pcap-export")
.value_name("FILE")
.help("Export captured packets to PCAP file for Wireshark analysis")
.required(false),
)
.arg(
Arg::new("pcapng-export")
.long("pcapng-export")
.value_name("FILE")
.help("Export captured packets to annotated PCAPNG file for Wireshark analysis")
.required(false),
)
.arg(
Arg::new("bpf-filter")
.short('f')
.long("bpf-filter")
.value_name("FILTER")
.help(BPF_HELP)
.required(false),
)
.arg(
Arg::new("no-resolve-dns")
.long("no-resolve-dns")
.help("Disable reverse DNS resolution for IP addresses (enabled by default; shows hostnames instead of IPs)")
.action(clap::ArgAction::SetTrue),
)
.arg(
Arg::new("show-ptr-lookups")
.long("show-ptr-lookups")
.help("Show PTR lookup connections in UI (hidden by default when DNS resolution is enabled)")
.action(clap::ArgAction::SetTrue),
)
.arg(
Arg::new("no-color")
.long("no-color")
.help("Disable all colors in the UI (also respects NO_COLOR env var)")
.action(clap::ArgAction::SetTrue),
)
.arg(
Arg::new("theme")
.long("theme")
.value_name("PRESET")
.help("Color theme preset: \"muted\" (single accent, color reserved for signals) or \"classic\" (original full-color palette)")
.value_parser(["muted", "classic"])
.default_value("muted")
.required(false),
)
.arg(
Arg::new("geoip-country")
.long("geoip-country")
.value_name("PATH")
.help(
"Path to GeoLite2-Country.mmdb database. \
Auto-discovered from: ./resources/geoip2, $XDG_DATA_HOME/rustnet/geoip, \
~/.local/share/rustnet/geoip, /usr/share/GeoIP, /usr/local/share/GeoIP, \
/opt/homebrew/share/GeoIP, /var/lib/GeoIP",
)
.required(false),
)
.arg(
Arg::new("geoip-asn")
.long("geoip-asn")
.value_name("PATH")
.help("Path to GeoLite2-ASN.mmdb database (same search paths as --geoip-country)")
.required(false),
)
.arg(
Arg::new("geoip-city")
.long("geoip-city")
.value_name("PATH")
.help(
"Path to GeoLite2-City.mmdb database (same search paths as --geoip-country; \
superset of Country — provides city name and postal code in addition to country)",
)
.required(false),
)
.arg(
Arg::new("no-geoip")
.long("no-geoip")
.help("Disable GeoIP lookups entirely")
.action(clap::ArgAction::SetTrue),
);
#[cfg(feature = "kubernetes")]
let cmd = cmd.arg(
Arg::new("kubernetes")
.long("kubernetes")
.value_name("MODE")
.help(
"Kubernetes pod/container attribution: \"auto\" (enable only when running inside a pod), \"on\" (always), or \"off\"",
)
.value_parser(["auto", "on", "off"])
.default_value("auto")
.required(false),
);
#[cfg(any(
target_os = "linux",
target_os = "windows",
all(target_os = "macos", feature = "macos-sandbox")
))]
let cmd = cmd
.arg(
Arg::new("no-sandbox")
.long("no-sandbox")
.help("Disable sandboxing (on Linux, PR_SET_NO_NEW_PRIVS is still set)")
.action(clap::ArgAction::SetTrue),
)
.arg(
Arg::new("sandbox-strict")
.long("sandbox-strict")
.help("Require full sandbox enforcement or exit")
.action(clap::ArgAction::SetTrue)
.conflicts_with("no-sandbox"),
);
#[cfg(target_os = "linux")]
let cmd = cmd.arg(
Arg::new("no-uid-drop")
.long("no-uid-drop")
.help(
"Keep running as root instead of dropping to SUDO_UID/SUDO_GID (or nobody) \
after initialization. Keeping root lets the procfs fallback attribute \
other users' processes when eBPF is unavailable",
)
.action(clap::ArgAction::SetTrue),
);
#[cfg(target_os = "macos")]
let cmd = cmd.arg(
Arg::new("no-uid-drop")
.long("no-uid-drop")
.help(
"Keep running as root instead of dropping to SUDO_UID/SUDO_GID (or nobody) \
after initialization. Keeping root lets the lsof fallback attribute other \
users' processes when PKTAP is unavailable",
)
.action(clap::ArgAction::SetTrue),
);
#[cfg(target_os = "freebsd")]
let cmd = cmd.arg(
Arg::new("no-uid-drop")
.long("no-uid-drop")
.help(
"Keep running as root instead of dropping to SUDO_UID/SUDO_GID (or nobody) \
after initialization. Keeping root lets sockstat attribute other users' \
processes",
)
.action(clap::ArgAction::SetTrue),
);
cmd
}
+190
View File
@@ -0,0 +1,190 @@
//! Runtime configuration: CLI flags, GeoIP database discovery, refresh
//! interval, DNS resolution toggle, and pcap export settings.
use anyhow::{Result, anyhow};
use std::fs;
use std::path::PathBuf;
/// Application configuration
#[derive(Debug, Clone)]
pub struct Config {
/// Network interface to monitor
pub interface: Option<String>,
/// Interface language (ISO code)
pub language: String,
/// Path to MaxMind GeoIP database
pub geoip_db_path: Option<PathBuf>,
/// Refresh interval in milliseconds
pub refresh_interval: u64,
/// Show IP locations (requires MaxMind DB)
pub show_locations: bool,
/// Filter out localhost (loopback) traffic
pub filter_localhost: bool,
/// Interval in milliseconds for the packet processing loop's sleep. 0 means minimal sleep for continuous processing.
pub packet_processing_interval_ms: u64,
/// Custom configuration file path
pub config_path: Option<PathBuf>,
}
impl Default for Config {
fn default() -> Self {
Self {
interface: None,
language: "en".to_string(),
geoip_db_path: None,
refresh_interval: 1000,
show_locations: true,
filter_localhost: true,
packet_processing_interval_ms: 0, // Default to continuous processing (minimal sleep)
config_path: None,
}
}
}
impl Config {
/// Load configuration from file
pub fn load(path: Option<&str>) -> Result<Self> {
let config_path = if let Some(path) = path {
PathBuf::from(path)
} else {
Self::find_config_file()?
};
let mut config = Config::default();
if config_path.exists() {
config.config_path = Some(config_path.clone());
// Read config file
let content = fs::read_to_string(&config_path)?;
// Parse YAML
for line in content.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some(pos) = line.find(':') {
let key = line[..pos].trim();
let value = line[pos + 1..].trim();
match key {
"interface" => {
config.interface = Some(value.to_string());
}
"language" => {
config.language = value.to_string();
}
"geoip_db_path" => {
config.geoip_db_path = Some(PathBuf::from(value));
}
"refresh_interval" => {
if let Ok(interval) = value.parse::<u64>() {
config.refresh_interval = interval;
}
}
"show_locations" => {
if value == "true" {
config.show_locations = true;
} else if value == "false" {
config.show_locations = false;
}
}
"filter_localhost" => {
if value == "true" {
config.filter_localhost = true;
} else if value == "false" {
config.filter_localhost = false;
}
}
"packet_processing_interval_ms" => {
if let Ok(interval) = value.parse::<u64>() {
config.packet_processing_interval_ms = interval;
}
}
_ => {
// Ignore unknown keys
}
}
}
}
}
// Try to find GeoIP database if not specified in config
if config.geoip_db_path.is_none() {
for path in Self::possible_geoip_paths() {
if path.exists() {
config.geoip_db_path = Some(path);
break;
}
}
}
Ok(config)
}
/// Find configuration file
fn find_config_file() -> Result<PathBuf> {
// Try XDG config directory first
if let Ok(xdg_config) = std::env::var("XDG_CONFIG_HOME") {
let xdg_path = PathBuf::from(xdg_config).join("rustnet/config.yml");
if xdg_path.exists() {
return Ok(xdg_path);
}
}
// Try ~/.config/rustnet
let home = Self::get_home_dir()?;
let home_config = home.join(".config/rustnet/config.yml");
if home_config.exists() {
return Ok(home_config);
}
// Try current directory
let current_config = PathBuf::from("config.yml");
if current_config.exists() {
return Ok(current_config);
}
// Default to home config path
Ok(home_config)
}
/// Get home directory
fn get_home_dir() -> Result<PathBuf> {
if let Ok(home) = std::env::var("HOME") {
return Ok(PathBuf::from(home));
}
if let Ok(userprofile) = std::env::var("USERPROFILE") {
return Ok(PathBuf::from(userprofile));
}
Err(anyhow!("Could not determine home directory"))
}
/// Get possible GeoIP database paths
fn possible_geoip_paths() -> Vec<PathBuf> {
let mut paths = Vec::new();
// Current directory
paths.push(PathBuf::from("GeoLite2-City.mmdb"));
// Try XDG data directory
if let Ok(xdg_data) = std::env::var("XDG_DATA_HOME") {
paths.push(PathBuf::from(xdg_data).join("rustnet/GeoLite2-City.mmdb"));
}
// Try home directory
if let Ok(home) = Self::get_home_dir() {
paths.push(home.join(".local/share/rustnet/GeoLite2-City.mmdb"));
}
// System paths
paths.push(PathBuf::from("/usr/share/GeoIP/GeoLite2-City.mmdb"));
paths.push(PathBuf::from("/usr/local/share/GeoIP/GeoLite2-City.mmdb"));
paths
}
}
+1
View File
@@ -0,0 +1 @@
pub mod pcapng;
+258
View File
@@ -0,0 +1,258 @@
use std::io::{self, Write};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
const SHB_TYPE: u32 = 0x0A0D_0D0A;
const IDB_TYPE: u32 = 0x0000_0001;
const EPB_TYPE: u32 = 0x0000_0006;
const BYTE_ORDER_MAGIC: u32 = 0x1A2B_3C4D;
const SHB_MAJOR: u16 = 1;
const SHB_MINOR: u16 = 0;
const SECTION_LENGTH_UNSPECIFIED: i64 = -1;
const OPT_ENDOFOPT: u16 = 0;
const OPT_COMMENT: u16 = 1;
const SHB_USERAPPL: u16 = 4;
const IF_NAME: u16 = 2;
const IF_TSRESOL: u16 = 9;
const TSRESOL_MICROS: u8 = 6;
/// Minimal PCAPNG writer for RustNet's annotated packet export.
///
/// It writes one little-endian section, one interface, and Enhanced Packet
/// Blocks with optional packet comments. This intentionally supports only the
/// block and option shapes RustNet needs.
pub struct PcapngWriter<W: Write> {
writer: W,
}
impl<W: Write> PcapngWriter<W> {
pub fn new(
mut writer: W,
linktype: u16,
snaplen: u32,
if_name: Option<&str>,
) -> io::Result<Self> {
write_section_header(&mut writer)?;
write_interface_description(&mut writer, linktype, snaplen, if_name)?;
Ok(Self { writer })
}
pub fn write_packet(
&mut self,
timestamp: SystemTime,
data: &[u8],
original_len: u32,
comment: Option<&str>,
) -> io::Result<()> {
write_enhanced_packet(&mut self.writer, timestamp, data, original_len, comment)
}
pub fn flush(&mut self) -> io::Result<()> {
self.writer.flush()
}
}
pub fn timestamp_micros_since_epoch(timestamp: SystemTime) -> u64 {
timestamp
.duration_since(UNIX_EPOCH)
.unwrap_or(Duration::ZERO)
.as_micros()
.min(u128::from(u64::MAX)) as u64
}
pub fn linktype_to_u16(linktype: i32) -> u16 {
u16::try_from(linktype).unwrap_or(1)
}
fn write_section_header<W: Write>(writer: &mut W) -> io::Result<()> {
let mut body = Vec::with_capacity(32);
write_u32(&mut body, BYTE_ORDER_MAGIC)?;
write_u16(&mut body, SHB_MAJOR)?;
write_u16(&mut body, SHB_MINOR)?;
write_i64(&mut body, SECTION_LENGTH_UNSPECIFIED)?;
write_option_string(
&mut body,
SHB_USERAPPL,
concat!("rustnet ", env!("CARGO_PKG_VERSION")),
)?;
write_option_end(&mut body)?;
write_block(writer, SHB_TYPE, &body)
}
fn write_interface_description<W: Write>(
writer: &mut W,
linktype: u16,
snaplen: u32,
if_name: Option<&str>,
) -> io::Result<()> {
let mut body = Vec::with_capacity(32);
write_u16(&mut body, linktype)?;
write_u16(&mut body, 0)?;
write_u32(&mut body, snaplen)?;
if let Some(name) = if_name.filter(|s| !s.is_empty()) {
write_option_string(&mut body, IF_NAME, name)?;
}
write_option_bytes(&mut body, IF_TSRESOL, &[TSRESOL_MICROS])?;
write_option_end(&mut body)?;
write_block(writer, IDB_TYPE, &body)
}
fn write_enhanced_packet<W: Write>(
writer: &mut W,
timestamp: SystemTime,
data: &[u8],
original_len: u32,
comment: Option<&str>,
) -> io::Result<()> {
let mut body = Vec::with_capacity(20 + padded_len(data.len()) + 128);
let micros = timestamp_micros_since_epoch(timestamp);
let captured_len = u32::try_from(data.len())
.map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "packet too large"))?;
write_u32(&mut body, 0)?;
write_u32(&mut body, (micros >> 32) as u32)?;
write_u32(&mut body, (micros & 0xffff_ffff) as u32)?;
write_u32(&mut body, captured_len)?;
write_u32(&mut body, original_len.max(captured_len))?;
body.write_all(data)?;
write_padding(&mut body, data.len())?;
if let Some(comment) = comment.filter(|s| !s.is_empty()) {
write_option_string(&mut body, OPT_COMMENT, comment)?;
}
write_option_end(&mut body)?;
write_block(writer, EPB_TYPE, &body)
}
fn write_block<W: Write>(writer: &mut W, block_type: u32, body: &[u8]) -> io::Result<()> {
let total_len = u32::try_from(12 + body.len())
.map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "pcapng block too large"))?;
write_u32(writer, block_type)?;
write_u32(writer, total_len)?;
writer.write_all(body)?;
write_u32(writer, total_len)
}
fn write_option_string<W: Write>(writer: &mut W, code: u16, value: &str) -> io::Result<()> {
write_option_bytes(writer, code, value.as_bytes())
}
fn write_option_bytes<W: Write>(writer: &mut W, code: u16, value: &[u8]) -> io::Result<()> {
let len = u16::try_from(value.len())
.map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "pcapng option too large"))?;
write_u16(writer, code)?;
write_u16(writer, len)?;
writer.write_all(value)?;
write_padding(writer, value.len())
}
fn write_option_end<W: Write>(writer: &mut W) -> io::Result<()> {
write_u16(writer, OPT_ENDOFOPT)?;
write_u16(writer, 0)
}
fn write_padding<W: Write>(writer: &mut W, len: usize) -> io::Result<()> {
const ZERO_PAD: [u8; 3] = [0; 3];
writer.write_all(&ZERO_PAD[..padding_len(len)])
}
fn padding_len(len: usize) -> usize {
(4 - (len % 4)) % 4
}
fn padded_len(len: usize) -> usize {
len + padding_len(len)
}
fn write_u16<W: Write>(writer: &mut W, value: u16) -> io::Result<()> {
writer.write_all(&value.to_le_bytes())
}
fn write_u32<W: Write>(writer: &mut W, value: u32) -> io::Result<()> {
writer.write_all(&value.to_le_bytes())
}
fn write_i64<W: Write>(writer: &mut W, value: i64) -> io::Result<()> {
writer.write_all(&value.to_le_bytes())
}
#[cfg(test)]
mod tests {
use super::*;
fn read_u32(buf: &[u8], offset: usize) -> u32 {
u32::from_le_bytes(buf[offset..offset + 4].try_into().unwrap())
}
fn find_epb(buf: &[u8]) -> &[u8] {
let mut offset = 0usize;
while offset + 12 <= buf.len() {
let block_type = read_u32(buf, offset);
let block_len = read_u32(buf, offset + 4) as usize;
if block_type == EPB_TYPE {
return &buf[offset..offset + block_len];
}
offset += block_len;
}
panic!("EPB not found");
}
#[test]
fn writes_valid_block_lengths_and_comment() {
let mut out = Vec::new();
let mut writer = PcapngWriter::new(&mut out, 1, 1514, Some("eth0")).unwrap();
writer
.write_packet(
UNIX_EPOCH + Duration::from_micros(42),
&[1, 2, 3],
3,
Some("rustnet pid=1"),
)
.unwrap();
writer.flush().unwrap();
let mut offset = 0usize;
while offset + 12 <= out.len() {
let block_len = read_u32(&out, offset + 4) as usize;
assert!(block_len >= 12);
assert_eq!(read_u32(&out, offset + block_len - 4), block_len as u32);
offset += block_len;
}
assert_eq!(offset, out.len());
let epb = find_epb(&out);
let captured_len = read_u32(epb, 20) as usize;
let original_len = read_u32(epb, 24) as usize;
assert_eq!(captured_len, 3);
assert_eq!(original_len, 3);
let options_start = 28 + padded_len(captured_len);
assert_eq!(
u16::from_le_bytes(epb[options_start..options_start + 2].try_into().unwrap()),
OPT_COMMENT
);
let comment_len = u16::from_le_bytes(
epb[options_start + 2..options_start + 4]
.try_into()
.unwrap(),
) as usize;
let comment =
std::str::from_utf8(&epb[options_start + 4..options_start + 4 + comment_len]).unwrap();
assert_eq!(comment, "rustnet pid=1");
}
#[test]
fn timestamp_uses_microseconds() {
let ts = UNIX_EPOCH + Duration::from_secs(2) + Duration::from_micros(7);
assert_eq!(timestamp_micros_since_epoch(ts), 2_000_007);
}
#[test]
fn packet_can_report_original_length_larger_than_capture() {
let mut out = Vec::new();
let mut writer = PcapngWriter::new(&mut out, 1, 3, None).unwrap();
writer
.write_packet(UNIX_EPOCH, &[1, 2, 3], 42, None)
.unwrap();
let epb = find_epb(&out);
assert_eq!(read_u32(epb, 20), 3);
assert_eq!(read_u32(epb, 24), 42);
}
}
+879
View File
@@ -0,0 +1,879 @@
//! Vim/fzf-style connection filter: parses `port:`, `src:`, `dst:`,
//! `sni:`, `process:`, `state:`, `proto:`, (and `pod:`, `ns:`,
//! `container:` when the `kubernetes` feature is enabled) keyword
//! expressions (with
//! optional `(?i)…` regex literals via `regex-lite`) and matches them
//! against live `Connection` records.
use crate::network::types::{ApplicationProtocol, Connection, ProtocolState};
use regex_lite::Regex;
/// How to match a text field (case-insensitive for literals; regex handles its own flags)
#[derive(Debug, Clone)]
pub enum FilterValue {
/// Case-insensitive substring match (existing default)
Literal(String),
/// Pre-compiled regex (compiled with (?i) prefix for case-insensitive matching)
Regex(Regex),
}
/// How to match a port number
#[derive(Debug, Clone)]
pub enum PortMatch {
/// Exact equality — default when the filter value is all digits
Exact(u16),
/// Substring match — fallback for non-numeric, non-regex values
Partial(String),
/// Pre-compiled regex
Regex(Regex),
}
#[derive(Debug, Clone)]
pub enum FilterCriteria {
/// Match any field containing this text
General(FilterValue),
/// Match port number (local or remote)
Port(PortMatch),
/// Match source port
SourcePort(PortMatch),
/// Match destination port
DestinationPort(PortMatch),
/// Match source IP address
SourceIp(FilterValue),
/// Match destination IP address
DestinationIp(FilterValue),
/// Match protocol (TCP, UDP, etc.)
Protocol(FilterValue),
/// Match process name
Process(FilterValue),
/// Match service name
Service(FilterValue),
/// Match SNI hostname from TLS/QUIC
Sni(FilterValue),
/// Match DPI application protocol
Application(FilterValue),
/// Match connection state (e.g., ESTABLISHED, SYN_RECV)
State(FilterValue),
/// Match Kubernetes pod name or UID
#[cfg(feature = "kubernetes")]
Pod(FilterValue),
/// Match Kubernetes pod namespace
#[cfg(feature = "kubernetes")]
Namespace(FilterValue),
/// Match Kubernetes container name or ID
#[cfg(feature = "kubernetes")]
Container(FilterValue),
}
pub struct ConnectionFilter {
pub criteria: Vec<FilterCriteria>,
}
/// Parse a filter value string into a `PortMatch`.
/// - `/pattern/` → regex
/// - all-digit → exact u16 equality
/// - anything else → substring contains
fn parse_port_match(value: &str) -> PortMatch {
if value.starts_with('/') && value.ends_with('/') && value.len() > 2 {
let pattern = &value[1..value.len() - 1];
match Regex::new(pattern) {
Ok(re) => PortMatch::Regex(re),
Err(_) => PortMatch::Partial(value.to_string()),
}
} else if value.chars().all(|c| c.is_ascii_digit()) {
value
.parse::<u16>()
.map(PortMatch::Exact)
.unwrap_or_else(|_| PortMatch::Partial(value.to_string()))
} else {
PortMatch::Partial(value.to_string())
}
}
/// Parse a filter value string into a `FilterValue`.
/// - `/pattern/` → case-insensitive regex
/// - anything else → case-insensitive literal contains
fn parse_filter_value(value: &str) -> FilterValue {
if value.starts_with('/') && value.ends_with('/') && value.len() > 2 {
let pattern = &value[1..value.len() - 1];
match Regex::new(&format!("(?i){pattern}")) {
Ok(re) => FilterValue::Regex(re),
Err(_) => FilterValue::Literal(value.to_string()),
}
} else {
FilterValue::Literal(value.to_string())
}
}
/// Match a port number against a `PortMatch`.
fn match_port(port: u16, m: &PortMatch) -> bool {
match m {
PortMatch::Exact(n) => port == *n,
PortMatch::Partial(s) => port.to_string().contains(s.as_str()),
PortMatch::Regex(re) => re.is_match(&port.to_string()),
}
}
/// Match a haystack string against a `FilterValue`.
fn match_text(haystack: &str, fv: &FilterValue) -> bool {
match fv {
FilterValue::Literal(s) => haystack.to_lowercase().contains(s.as_str()),
FilterValue::Regex(re) => re.is_match(haystack),
}
}
impl ConnectionFilter {
/// Parse filter query string into filter criteria
pub fn parse(query: &str) -> Self {
let mut criteria = Vec::new();
if query.trim().is_empty() {
return Self { criteria };
}
// Split by whitespace and process each part
let parts: Vec<&str> = query.split_whitespace().collect();
for part in parts {
if let Some((keyword, value)) = part.split_once(':') {
let value = value.to_lowercase();
match keyword.to_lowercase().as_str() {
"port" => {
criteria.push(FilterCriteria::Port(parse_port_match(&value)));
}
"sport" | "srcport" | "source-port" => {
criteria.push(FilterCriteria::SourcePort(parse_port_match(&value)));
}
"dport" | "dstport" | "dest-port" | "destination-port" => {
criteria.push(FilterCriteria::DestinationPort(parse_port_match(&value)));
}
"src" | "source" => {
criteria.push(FilterCriteria::SourceIp(parse_filter_value(&value)));
}
"dst" | "dest" | "destination" => {
criteria.push(FilterCriteria::DestinationIp(parse_filter_value(&value)));
}
"proto" | "protocol" => {
criteria.push(FilterCriteria::Protocol(parse_filter_value(&value)));
}
"process" | "proc" => {
criteria.push(FilterCriteria::Process(parse_filter_value(&value)));
}
"service" | "svc" => {
criteria.push(FilterCriteria::Service(parse_filter_value(&value)));
}
"sni" | "host" | "hostname" => {
criteria.push(FilterCriteria::Sni(parse_filter_value(&value)));
}
"app" | "application" => {
criteria.push(FilterCriteria::Application(parse_filter_value(&value)));
}
"state" => {
criteria.push(FilterCriteria::State(parse_filter_value(&value)));
}
#[cfg(feature = "kubernetes")]
"pod" => {
criteria.push(FilterCriteria::Pod(parse_filter_value(&value)));
}
#[cfg(feature = "kubernetes")]
"ns" | "namespace" => {
criteria.push(FilterCriteria::Namespace(parse_filter_value(&value)));
}
#[cfg(feature = "kubernetes")]
"container" | "cont" => {
criteria.push(FilterCriteria::Container(parse_filter_value(&value)));
}
_ => {
// Unknown keyword, treat as general search
criteria.push(FilterCriteria::General(parse_filter_value(
&part.to_lowercase(),
)));
}
}
} else {
// General text search
criteria.push(FilterCriteria::General(parse_filter_value(
&part.to_lowercase(),
)));
}
}
Self { criteria }
}
/// Check if a connection matches all filter criteria
pub fn matches(&self, connection: &Connection) -> bool {
if self.criteria.is_empty() {
return true;
}
// All criteria must match (AND operation)
self.criteria.iter().all(|criterion| match criterion {
FilterCriteria::General(fv) => self.matches_general(connection, fv),
FilterCriteria::Port(pm) => {
match_port(connection.local_addr.port(), pm)
|| match_port(connection.remote_addr.port(), pm)
}
FilterCriteria::SourcePort(pm) => match_port(connection.local_addr.port(), pm),
FilterCriteria::DestinationPort(pm) => match_port(connection.remote_addr.port(), pm),
FilterCriteria::SourceIp(fv) => match_text(&connection.local_addr.ip().to_string(), fv),
FilterCriteria::DestinationIp(fv) => {
match_text(&connection.remote_addr.ip().to_string(), fv)
}
FilterCriteria::Protocol(fv) => match_text(connection.protocol.as_str(), fv),
FilterCriteria::Process(fv) => {
if let Some(ref process_name) = connection.process_name {
match_text(process_name, fv)
} else {
false
}
}
FilterCriteria::Service(fv) => {
if let Some(ref service_name) = connection.service_name {
match_text(service_name, fv)
} else {
false
}
}
FilterCriteria::Sni(fv) => self.matches_sni(connection, fv),
FilterCriteria::Application(fv) => self.matches_application(connection, fv),
FilterCriteria::State(fv) => match_text(&connection.state(), fv),
#[cfg(feature = "kubernetes")]
FilterCriteria::Pod(fv) => connection.k8s_info.as_ref().is_some_and(|k| {
k.pod_name.as_deref().is_some_and(|n| match_text(n, fv))
|| k.pod_uid.as_deref().is_some_and(|u| match_text(u, fv))
}),
#[cfg(feature = "kubernetes")]
FilterCriteria::Namespace(fv) => connection.k8s_info.as_ref().is_some_and(|k| {
k.pod_namespace
.as_deref()
.is_some_and(|ns| match_text(ns, fv))
}),
#[cfg(feature = "kubernetes")]
FilterCriteria::Container(fv) => connection.k8s_info.as_ref().is_some_and(|k| {
k.container_name
.as_deref()
.is_some_and(|n| match_text(n, fv))
|| k.container_id.as_deref().is_some_and(|c| match_text(c, fv))
}),
})
}
/// Check if connection matches general text search across all fields
fn matches_general(&self, connection: &Connection, fv: &FilterValue) -> bool {
// Check basic connection info
if match_text(connection.protocol.as_str(), fv)
|| match_text(&connection.local_addr.to_string(), fv)
|| match_text(&connection.remote_addr.to_string(), fv)
{
return true;
}
// Check process info
if let Some(ref process_name) = connection.process_name
&& match_text(process_name, fv)
{
return true;
}
// Check service info
if let Some(ref service_name) = connection.service_name
&& match_text(service_name, fv)
{
return true;
}
// Check DPI info
if let Some(ref dpi_info) = connection.dpi_info
&& self.matches_dpi_general(&dpi_info.application, fv)
{
return true;
}
// Check ARP vendor names
if let ProtocolState::Arp(ref arp_info) = connection.protocol_state {
if let Some(ref vendor) = arp_info.sender_vendor
&& match_text(vendor, fv)
{
return true;
}
if let Some(ref vendor) = arp_info.target_vendor
&& match_text(vendor, fv)
{
return true;
}
}
false
}
/// Check if SNI matches the filter value
fn matches_sni(&self, connection: &Connection, fv: &FilterValue) -> bool {
if let Some(ref dpi_info) = connection.dpi_info {
match &dpi_info.application {
ApplicationProtocol::Https(info) => {
if let Some(ref tls_info) = info.tls_info
&& let Some(ref sni) = tls_info.sni
{
return match_text(sni, fv);
}
}
ApplicationProtocol::Quic(info) => {
if let Some(ref tls_info) = info.tls_info
&& let Some(ref sni) = tls_info.sni
{
return match_text(sni, fv);
}
}
ApplicationProtocol::Http(info) => {
if let Some(ref host) = info.host {
return match_text(host, fv);
}
}
_ => {}
}
}
false
}
/// Check if application protocol matches the filter value
fn matches_application(&self, connection: &Connection, fv: &FilterValue) -> bool {
if let Some(ref dpi_info) = connection.dpi_info {
match_text(&dpi_info.application.to_string(), fv)
} else {
false
}
}
/// Check if DPI info matches general search
fn matches_dpi_general(&self, application: &ApplicationProtocol, fv: &FilterValue) -> bool {
// Check the application type display
if match_text(&application.to_string(), fv) {
return true;
}
// Check specific protocol details
match application {
ApplicationProtocol::Http(info) => {
if let Some(ref host) = info.host
&& match_text(host, fv)
{
return true;
}
if let Some(ref path) = info.path
&& match_text(path, fv)
{
return true;
}
if let Some(ref method) = info.method
&& match_text(method, fv)
{
return true;
}
}
ApplicationProtocol::Https(info) => {
if let Some(ref tls_info) = info.tls_info {
if let Some(ref sni) = tls_info.sni
&& match_text(sni, fv)
{
return true;
}
// Check ALPN protocols
for alpn in &tls_info.alpn {
if match_text(alpn, fv) {
return true;
}
}
}
}
ApplicationProtocol::Dns(info) => {
if let Some(ref query_name) = info.query_name
&& match_text(query_name, fv)
{
return true;
}
}
ApplicationProtocol::Quic(info) => {
if let Some(ref tls_info) = info.tls_info {
if let Some(ref sni) = tls_info.sni
&& match_text(sni, fv)
{
return true;
}
// Check ALPN protocols
for alpn in &tls_info.alpn {
if match_text(alpn, fv) {
return true;
}
}
}
}
ApplicationProtocol::Ssh(info) => {
if match_text("ssh", fv) {
return true;
}
// Check software names
if let Some(ref software) = info.server_software
&& match_text(software, fv)
{
return true;
}
if let Some(ref software) = info.client_software
&& match_text(software, fv)
{
return true;
}
// Check connection state
let state_str = format!("{:?}", info.connection_state).to_lowercase();
if match_text(&state_str, fv) {
return true;
}
// Check algorithms
for algo in &info.algorithms {
if match_text(algo, fv) {
return true;
}
}
}
ApplicationProtocol::Ntp(_) => {
if match_text("ntp", fv) {
return true;
}
}
ApplicationProtocol::Ftp(info) => {
if match_text("ftp", fv) {
return true;
}
if let Some(ref cmd) = info.command
&& match_text(cmd, fv)
{
return true;
}
if let Some(ref user) = info.username
&& match_text(user, fv)
{
return true;
}
if let Some(ref sw) = info.server_software
&& match_text(sw, fv)
{
return true;
}
if let Some(ref sys) = info.system_type
&& match_text(sys, fv)
{
return true;
}
if let Some(code) = info.response_code
&& match_text(&code.to_string(), fv)
{
return true;
}
}
ApplicationProtocol::Mdns(info) => {
if let Some(ref query_name) = info.query_name
&& match_text(query_name, fv)
{
return true;
}
}
ApplicationProtocol::Llmnr(info) => {
if let Some(ref query_name) = info.query_name
&& match_text(query_name, fv)
{
return true;
}
}
ApplicationProtocol::Dhcp(info) => {
if let Some(ref hostname) = info.hostname
&& match_text(hostname, fv)
{
return true;
}
}
ApplicationProtocol::Snmp(info) => {
if let Some(ref community) = info.community
&& match_text(community, fv)
{
return true;
}
}
ApplicationProtocol::Ssdp(info) => {
if let Some(ref service_type) = info.service_type
&& match_text(service_type, fv)
{
return true;
}
}
ApplicationProtocol::NetBios(info) => {
if let Some(ref name) = info.name
&& match_text(name, fv)
{
return true;
}
}
ApplicationProtocol::BitTorrent(info) => {
if match_text("bittorrent", fv) {
return true;
}
if let Some(ref client) = info.client
&& match_text(client, fv)
{
return true;
}
}
ApplicationProtocol::Stun(info) => {
if match_text("stun", fv) {
return true;
}
if let Some(ref software) = info.software
&& match_text(software, fv)
{
return true;
}
}
ApplicationProtocol::Mqtt(info) => {
if match_text("mqtt", fv) {
return true;
}
if let Some(ref client_id) = info.client_id
&& match_text(client_id, fv)
{
return true;
}
if let Some(ref topic) = info.topic
&& match_text(topic, fv)
{
return true;
}
}
}
false
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_general_filter() {
let filter = ConnectionFilter::parse("google");
assert_eq!(filter.criteria.len(), 1);
matches!(filter.criteria[0], FilterCriteria::General(_));
}
#[test]
fn test_parse_port_filter() {
let filter = ConnectionFilter::parse("port:443");
assert_eq!(filter.criteria.len(), 1);
match &filter.criteria[0] {
FilterCriteria::Port(PortMatch::Exact(n)) => assert_eq!(*n, 443),
_ => panic!("Expected Port(Exact(443))"),
}
}
#[test]
fn test_parse_multiple_filters() {
let filter = ConnectionFilter::parse("port:443 src:192.168");
assert_eq!(filter.criteria.len(), 2);
}
#[test]
fn test_parse_port_exact_match() {
// port:44 should now be an exact match for port 44, not partial
let filter = ConnectionFilter::parse("port:44");
match &filter.criteria[0] {
FilterCriteria::Port(PortMatch::Exact(n)) => assert_eq!(*n, 44),
_ => panic!("Expected Port(Exact(44))"),
}
}
#[test]
fn test_parse_port_regex() {
let filter = ConnectionFilter::parse("port:/22/");
match &filter.criteria[0] {
FilterCriteria::Port(PortMatch::Regex(_)) => {}
_ => panic!("Expected Port(Regex)"),
}
}
#[test]
fn test_parse_sport_dport_filters() {
let filter = ConnectionFilter::parse("sport:80 dport:443");
assert_eq!(filter.criteria.len(), 2);
match &filter.criteria[0] {
FilterCriteria::SourcePort(PortMatch::Exact(n)) => assert_eq!(*n, 80),
_ => panic!("Expected SourcePort(Exact(80))"),
}
match &filter.criteria[1] {
FilterCriteria::DestinationPort(PortMatch::Exact(n)) => assert_eq!(*n, 443),
_ => panic!("Expected DestinationPort(Exact(443))"),
}
}
#[test]
fn test_parse_state_filter() {
let filter = ConnectionFilter::parse("state:established");
assert_eq!(filter.criteria.len(), 1);
match &filter.criteria[0] {
FilterCriteria::State(_) => {}
_ => panic!("Expected State filter"),
}
}
#[test]
fn test_port_exact_no_partial_match() {
use crate::network::types::*;
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
// port:22 should NOT match port 2223 or 5522
let conn_2223 = Connection::new(
Protocol::Tcp,
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 2223),
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)), 80),
ProtocolState::Tcp(TcpState::Established),
);
let conn_5522 = Connection::new(
Protocol::Tcp,
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 5522),
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)), 80),
ProtocolState::Tcp(TcpState::Established),
);
let conn_22 = Connection::new(
Protocol::Tcp,
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 22),
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)), 80),
ProtocolState::Tcp(TcpState::Established),
);
let filter = ConnectionFilter::parse("port:22");
assert!(
!filter.matches(&conn_2223),
"port:22 must not match port 2223"
);
assert!(
!filter.matches(&conn_5522),
"port:22 must not match port 5522"
);
assert!(filter.matches(&conn_22), "port:22 must match port 22");
}
#[test]
fn test_port_regex_partial_match() {
use crate::network::types::*;
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
// port:/22/ should match 22, 220, 2200, 5522
let make_conn = |local_port: u16| {
Connection::new(
Protocol::Tcp,
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), local_port),
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)), 80),
ProtocolState::Tcp(TcpState::Established),
)
};
let filter = ConnectionFilter::parse("port:/22/");
assert!(filter.matches(&make_conn(22)));
assert!(filter.matches(&make_conn(220)));
assert!(filter.matches(&make_conn(2200)));
assert!(filter.matches(&make_conn(5522)));
assert!(!filter.matches(&make_conn(80)));
}
#[test]
fn test_state_filter_tcp_states() {
use crate::network::types::*;
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
let mut conn = Connection::new(
Protocol::Tcp,
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 12345),
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)), 80),
ProtocolState::Tcp(TcpState::Established),
);
let established_filter = ConnectionFilter::parse("state:established");
assert!(established_filter.matches(&conn));
let est_filter = ConnectionFilter::parse("state:est");
assert!(est_filter.matches(&conn));
let upper_filter = ConnectionFilter::parse("state:ESTABLISHED");
assert!(upper_filter.matches(&conn));
let syn_filter = ConnectionFilter::parse("state:syn_recv");
assert!(!syn_filter.matches(&conn));
conn.protocol_state = ProtocolState::Tcp(TcpState::SynReceived);
assert!(syn_filter.matches(&conn));
assert!(!established_filter.matches(&conn));
}
#[test]
fn test_state_filter_udp_states() {
use crate::network::types::*;
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
let conn = Connection::new(
Protocol::Udp,
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 12345),
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8)), 53),
ProtocolState::Udp,
);
let active_filter = ConnectionFilter::parse("state:udp_active");
assert!(active_filter.matches(&conn));
let udp_filter = ConnectionFilter::parse("state:udp");
assert!(udp_filter.matches(&conn));
}
#[test]
fn test_combined_state_and_port_filter() {
use crate::network::types::*;
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
let conn = Connection::new(
Protocol::Tcp,
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)), 443),
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 100)), 54321),
ProtocolState::Tcp(TcpState::SynReceived),
);
let combined_filter = ConnectionFilter::parse("sport:443 state:syn_recv");
assert!(combined_filter.matches(&conn));
let wrong_port_filter = ConnectionFilter::parse("sport:80 state:syn_recv");
assert!(!wrong_port_filter.matches(&conn));
let wrong_state_filter = ConnectionFilter::parse("sport:443 state:established");
assert!(!wrong_state_filter.matches(&conn));
}
#[test]
fn test_state_filter_case_insensitive() {
use crate::network::types::*;
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
let conn = Connection::new(
Protocol::Tcp,
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 12345),
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)), 80),
ProtocolState::Tcp(TcpState::Established),
);
let filters = vec![
"state:established",
"state:ESTABLISHED",
"state:Established",
"state:EstAbLiShEd",
];
for filter_str in filters {
let filter = ConnectionFilter::parse(filter_str);
assert!(
filter.matches(&conn),
"Filter '{}' should match ESTABLISHED state",
filter_str
);
}
}
#[test]
fn test_regex_general_search() {
use crate::network::types::*;
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
let conn = Connection::new(
Protocol::Tcp,
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 100)), 12345),
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)), 443),
ProtocolState::Tcp(TcpState::Established),
);
// Regex matching IP pattern
let filter = ConnectionFilter::parse("/192\\.168\\.[0-9]+/");
assert!(filter.matches(&conn));
// Should not match unrelated connection
let conn2 = Connection::new(
Protocol::Tcp,
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)), 12345),
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8)), 53),
ProtocolState::Tcp(TcpState::Established),
);
assert!(!filter.matches(&conn2));
}
#[test]
fn test_invalid_regex_falls_back_to_literal() {
// An invalid regex pattern should fall back to literal match without panicking
let filter = ConnectionFilter::parse("port:/[invalid/");
// Should have created a PortMatch::Partial (fallback)
match &filter.criteria[0] {
FilterCriteria::Port(PortMatch::Partial(_)) => {}
_ => panic!("Expected fallback to Partial for invalid regex"),
}
}
#[cfg(feature = "kubernetes")]
#[test]
fn test_kubernetes_filter_keywords() {
use crate::network::types::*;
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
let mut conn = Connection::new(
Protocol::Tcp,
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 12345),
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)), 80),
ProtocolState::Tcp(TcpState::Established),
);
conn.k8s_info = Some(K8sInfo {
pod_uid: Some("c3b4d893-473e-43c2-8013-8ee2955a4630".to_string()),
pod_name: Some("nginx-86644db9cc-mf5lx".to_string()),
pod_namespace: Some("demo-traffic".to_string()),
container_id: Some(
"c16c7605305c854d8582a1db3d5bb3c4b6c89a08e914223e9d500682b3fb0b1b".to_string(),
),
container_name: Some("nginx".to_string()),
cgroup_path: None,
});
// pod: matches by name (substring)
assert!(ConnectionFilter::parse("pod:nginx").matches(&conn));
// pod: matches by UID prefix
assert!(ConnectionFilter::parse("pod:c3b4d893").matches(&conn));
// ns: matches namespace
assert!(ConnectionFilter::parse("ns:demo-traffic").matches(&conn));
assert!(ConnectionFilter::parse("namespace:demo").matches(&conn));
// container: matches by name and by ID prefix
assert!(ConnectionFilter::parse("container:nginx").matches(&conn));
assert!(ConnectionFilter::parse("container:c16c7605").matches(&conn));
// Negative cases
assert!(!ConnectionFilter::parse("pod:redis").matches(&conn));
assert!(!ConnectionFilter::parse("ns:kube-system").matches(&conn));
assert!(!ConnectionFilter::parse("container:redis").matches(&conn));
// Combined filter
assert!(ConnectionFilter::parse("ns:demo-traffic pod:nginx").matches(&conn));
// Filter on connections without K8s info returns no match for any pod filter.
let bare = Connection::new(
Protocol::Tcp,
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 12345),
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)), 80),
ProtocolState::Tcp(TcpState::Established),
);
assert!(!ConnectionFilter::parse("pod:nginx").matches(&bare));
assert!(!ConnectionFilter::parse("ns:demo").matches(&bare));
assert!(!ConnectionFilter::parse("container:nginx").matches(&bare));
}
}
+104
View File
@@ -0,0 +1,104 @@
//! # RustNet Monitor
//!
//! A cross-platform real-time network monitoring tool with a terminal user
//! interface (TUI), deep packet inspection (DPI), per-connection process
//! attribution, and protocol-aware connection lifecycle tracking. RustNet
//! sits between simple connection listers (`netstat`, `ss`) and full packet
//! analyzers (`Wireshark`, `tcpdump`): it shows which process owns each
//! connection, with live bandwidth and protocol state, and runs over SSH.
//!
//! ## Capabilities
//!
//! - **Live connection table** for TCP, UDP, ICMP, and ARP, with detailed
//! state tracking (TCP `ESTABLISHED`/`SYN_SENT`/`TIME_WAIT`, QUIC
//! `INITIAL`/`HANDSHAKE`/`CONNECTED`, DNS, SSH, and activity-based UDP).
//! - **Deep packet inspection** for HTTP, HTTPS/TLS with SNI extraction,
//! DNS, SSH, QUIC, NTP, mDNS, LLMNR, DHCP, SNMP, SSDP, and NetBIOS.
//! - **TCP analytics**: retransmissions, out-of-order packets, and fast
//! retransmit detection, both per-connection and aggregate.
//! - **Process attribution** via procfs on Linux, native APIs on macOS,
//! Windows, and FreeBSD, and an optional eBPF fast path on Linux.
//! - **GeoIP** lookups against MaxMind GeoLite2 databases.
//! - **Reverse DNS** with background async resolution and caching.
//! - **Vim/fzf-style filtering** (`port:`, `src:`, `dst:`, `sni:`,
//! `process:`, `state:`, `proto:`, regex via `(?i)…`).
//! - **Security sandboxing** with Linux Landlock (5.13+) and macOS
//! Seatbelt to restrict filesystem and network access at runtime.
//! - **PCAP export** with process metadata for offline analysis.
//!
//! ## Technology stack
//!
//! - `ratatui` + `crossterm` for the terminal user interface.
//! - `pcap` (libpcap / Npcap) for cross-platform packet capture.
//! - `libbpf-rs` for the optional Linux eBPF process-attribution fast path.
//! - `dashmap` and `crossbeam` channels for lock-free, multi-threaded
//! connection state and packet pipelines.
//! - `ring` and `aes` for TLS SNI parsing and QUIC Initial decryption.
//! - `maxminddb` for GeoLite2 country/city lookups.
//! - `landlock` and `caps` for Linux capability-based sandboxing;
//! macOS Seatbelt is invoked via `sandbox_init` directly.
//!
//! ## Modules
//!
//! - [`app`] — application orchestration, packet pipeline, and shared
//! state.
//! - [`config`] — command-line and runtime configuration.
//! - [`filter`] — vim/fzf-style connection filter parser and matcher.
//! - [`network`] — packet capture, parsers, DPI, DNS, GeoIP, interface
//! stats, and platform-specific process lookup.
//! - [`ui`] — ratatui rendering, tabs, tables, and keyboard handling.
//!
//! ## Binary vs. library
//!
//! `rustnet-monitor` is primarily distributed as a binary (`rustnet`).
//! The library surface exposed here is unstable and intended for internal
//! use; install via `cargo install rustnet-monitor` or one of the system
//! package managers listed in the README.
pub mod app;
pub mod cli;
pub mod config;
pub mod export;
pub mod filter;
pub mod network;
pub mod ui;
/// Check if the current process is running with Administrator privileges (Windows only)
#[cfg(target_os = "windows")]
pub fn is_admin() -> bool {
use windows::Win32::Foundation::HANDLE;
use windows::Win32::Security::{
GetTokenInformation, TOKEN_ELEVATION, TOKEN_QUERY, TokenElevation,
};
use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
unsafe {
let mut token_handle = HANDLE::default();
// Open the process token
if OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token_handle).is_err() {
return false;
}
let mut elevation = TOKEN_ELEVATION::default();
let mut return_length = 0u32;
// Get the elevation information
let result = GetTokenInformation(
token_handle,
TokenElevation,
Some(&mut elevation as *mut _ as *mut _),
std::mem::size_of::<TOKEN_ELEVATION>() as u32,
&mut return_length,
);
// Close the token handle
let _ = windows::Win32::Foundation::CloseHandle(token_handle);
if result.is_err() {
return false;
}
elevation.TokenIsElevated != 0
}
}
+1303
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+33
View File
@@ -0,0 +1,33 @@
//! Networking layer for the `rustnet` binary.
//!
//! The platform-independent analysis core — parsers, DPI, protocol/connection
//! types, connection merging, GeoIP/DNS/OUI lookups, and interface-stats
//! traits — lives in the [`rustnet_core`] library crate and is re-exported here
//! so existing `crate::network::*` paths keep resolving unchanged.
//!
//! The modules that remain in the binary are the ones tied to the running host:
//! platform-specific process attribution and sandboxing ([`platform`]) and the
//! [`privileges`] preflight check. libpcap-based packet capture now lives in the
//! [`rustnet_capture`] crate and is re-exported here as [`capture`] so existing
//! `crate::network::capture::*` paths keep resolving.
#[cfg(feature = "kubernetes")]
pub mod kubernetes;
pub mod platform;
pub mod privileges;
// pcap-based capture moved to the `rustnet-capture` crate; re-export it under
// the historical path so the app, tests, and platform code are unchanged.
pub use rustnet_capture as capture;
// Re-export the analysis core. Keeps `crate::network::types`, `::parser`,
// `::dpi`, `::link_layer`, etc. working for the rest of the binary, the
// integration tests, and the benches without touching their imports.
// `allow(unused_imports)`: the `rustnet` bin doesn't touch every module
// directly, but the `rustnet_monitor` lib re-exports the full facade for
// tests, benches, and external consumers.
#[allow(unused_imports)]
pub use rustnet_core::network::{
bogon, dns, dpi, geoip, interface_stats, link_layer, merge, oui, parser, protocol, services,
tracker, types,
};
@@ -0,0 +1,98 @@
// network/platform/freebsd/interface_stats.rs - FreeBSD getifaddrs-based interface stats
use crate::network::interface_stats::{InterfaceStats, InterfaceStatsProvider};
use std::ffi::CStr;
use std::io;
use std::ptr;
use std::time::SystemTime;
/// FreeBSD-specific implementation using getifaddrs
pub struct FreeBSDStatsProvider;
impl InterfaceStatsProvider for FreeBSDStatsProvider {
fn get_all_stats(&self) -> Result<Vec<InterfaceStats>, io::Error> {
unsafe {
let mut ifap: *mut libc::ifaddrs = ptr::null_mut();
if libc::getifaddrs(&mut ifap) != 0 {
return Err(io::Error::last_os_error());
}
let mut stats = Vec::new();
let mut current = ifap;
while let Some(ifa) = current.as_ref() {
// Only process AF_LINK entries (data link layer)
if let Some(addr) = ifa.ifa_addr.as_ref()
&& addr.sa_family as i32 == libc::AF_LINK
{
let name = CStr::from_ptr(ifa.ifa_name).to_string_lossy().to_string();
// Get if_data from ifa_data
#[cfg(target_os = "freebsd")]
{
if let Some(if_data) = (ifa.ifa_data as *const libc::if_data).as_ref() {
stats.push(InterfaceStats {
interface_name: name,
rx_bytes: if_data.ifi_ibytes,
tx_bytes: if_data.ifi_obytes,
rx_packets: if_data.ifi_ipackets,
tx_packets: if_data.ifi_opackets,
rx_errors: if_data.ifi_ierrors,
tx_errors: if_data.ifi_oerrors,
rx_dropped: if_data.ifi_iqdrops,
tx_dropped: 0, // Not typically available on FreeBSD
collisions: if_data.ifi_collisions,
timestamp: SystemTime::now(),
});
}
}
}
current = ifa.ifa_next;
}
libc::freeifaddrs(ifap);
Ok(stats)
}
}
}
#[cfg(test)]
#[cfg(target_os = "freebsd")]
mod tests {
use super::*;
#[test]
fn test_freebsd_list_interfaces() {
let provider = FreeBSDStatsProvider;
let result = provider.get_all_stats();
match result {
Ok(stats) => {
assert!(!stats.is_empty(), "Expected at least one interface");
}
Err(e) => {
panic!("Failed to list interfaces: {:?}", e);
}
}
}
#[test]
fn test_freebsd_get_all_stats() {
let provider = FreeBSDStatsProvider;
let result = provider.get_all_stats();
match result {
Ok(stats) => {
assert!(!stats.is_empty(), "Expected at least one interface");
for stat in stats {
assert!(!stat.interface_name.is_empty());
}
}
Err(e) => {
panic!("Failed to get stats: {:?}", e);
}
}
}
}
+7
View File
@@ -0,0 +1,7 @@
// network/platform/freebsd/mod.rs - FreeBSD interface stats.
// Process attribution (sockstat) lives in the rustnet-host crate.
mod interface_stats;
pub mod privdrop;
pub use interface_stats::FreeBSDStatsProvider;
+186
View File
@@ -0,0 +1,186 @@
//! Root privilege drop (setuid) for FreeBSD
//!
//! `sudo rustnet` keeps euid 0 for its whole lifetime, even though root is
//! only needed during initialization to open the BPF capture devices. FreeBSD
//! has no sandbox layer yet (a Capsicum sandbox is planned, see ROADMAP.md),
//! so until then this drop is the primary containment: a compromise of the
//! packet-parsing code no longer runs as root.
//!
//! The process drops to the invoking sudo user (`SUDO_UID`/`SUDO_GID`), or to
//! `nobody` (65534) when started as plain root. `doas` does not set these
//! variables, so doas users get the `nobody` fallback. Already-open file
//! descriptors (capture devices, log and export files) remain valid across
//! the drop.
//!
//! # Trust model
//!
//! `SUDO_UID`/`SUDO_GID` are environment variables and thus caller-controlled,
//! but they can only ever select which unprivileged identity we become:
//! uid 0 and unparseable values are rejected and fall back to `nobody`, so a
//! forged value cannot retain or regain privilege.
//!
//! # Trade-offs
//!
//! Process attribution on FreeBSD execs `sockstat`, which as a non-root user
//! only sees the target user's sockets, so attribution for other users'
//! processes is lost after the drop. `--no-uid-drop` keeps the old keep-root
//! behavior.
use anyhow::{Result, anyhow};
/// The uid/gid pair to drop to.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DropTarget {
pub uid: libc::uid_t,
pub gid: libc::gid_t,
}
/// FreeBSD `nobody` user and group, both 65534. Used when rustnet runs as
/// plain root (no sudo, or doas) or SUDO_UID/SUDO_GID are unusable.
const NOBODY: DropTarget = DropTarget {
uid: 65534,
gid: 65534,
};
/// Resolve the identity to drop to, or `None` when not running as root
/// (nothing to drop; the bpf-group path never has euid 0).
pub fn resolve_drop_target() -> Option<DropTarget> {
// SAFETY: geteuid() has no failure mode and takes no pointers.
if unsafe { libc::geteuid() } != 0 {
return None;
}
Some(target_from_env(
std::env::var("SUDO_UID").ok().as_deref(),
std::env::var("SUDO_GID").ok().as_deref(),
))
}
/// Pick the target from SUDO_UID/SUDO_GID; both must parse to a nonzero id,
/// otherwise fall back to `nobody`. Split out from `resolve_drop_target` for
/// testability (no process-global env manipulation in tests).
fn target_from_env(sudo_uid: Option<&str>, sudo_gid: Option<&str>) -> DropTarget {
let parse = |v: Option<&str>| v.and_then(|s| s.parse::<u32>().ok()).filter(|&id| id != 0);
match (parse(sudo_uid), parse(sudo_gid)) {
(Some(uid), Some(gid)) => DropTarget { uid, gid },
_ => NOBODY,
}
}
/// Change the file's owner to the drop target.
///
/// Used on pre-created export files (0600, root-owned) so they can still be
/// reopened by name after the uid drop, e.g. libpcap's `pcap_dump_open` and
/// the per-event sidecar JSONL appends. Operates on the already-open fd
/// (`fchown`), never on the path, so it cannot be redirected by a
/// symlink/rename swap after the O_NOFOLLOW create.
pub fn chown_to_target(file: &std::fs::File, target: DropTarget) -> std::io::Result<()> {
use std::os::fd::AsRawFd;
// SAFETY: fchown on a valid owned fd; no pointers involved.
let rc = unsafe { libc::fchown(file.as_raw_fd(), target.uid, target.gid) };
if rc == 0 {
Ok(())
} else {
Err(std::io::Error::last_os_error())
}
}
/// Irreversibly drop root to `target`: supplementary groups first, then gid,
/// then uid (all three of real/effective/saved so root cannot be regained).
///
/// Must be called while euid is 0. Process credentials are shared by all
/// threads on FreeBSD, so threads spawned before the drop (capture,
/// enrichment) are covered too.
pub fn drop_to(target: DropTarget) -> Result<()> {
if target.uid == 0 || target.gid == 0 {
return Err(anyhow!("refusing to 'drop' to uid 0 / gid 0"));
}
// SAFETY: setgroups reads `ngroups` gid_t values from the pointer; we pass
// exactly one element and its valid address.
let gids = [target.gid];
if unsafe { libc::setgroups(1, gids.as_ptr()) } != 0 {
return Err(anyhow!(
"setgroups failed: {}",
std::io::Error::last_os_error()
));
}
// gid before uid: once uid 0 is gone, setresgid would fail.
// SAFETY: setresgid/setresuid take plain integers.
if unsafe { libc::setresgid(target.gid, target.gid, target.gid) } != 0 {
return Err(anyhow!(
"setresgid({}) failed: {}",
target.gid,
std::io::Error::last_os_error()
));
}
if unsafe { libc::setresuid(target.uid, target.uid, target.uid) } != 0 {
return Err(anyhow!(
"setresuid({}) failed: {}",
target.uid,
std::io::Error::last_os_error()
));
}
// Verify the drop stuck and root cannot be regained.
let (mut ruid, mut euid, mut suid) = (0, 0, 0);
let (mut rgid, mut egid, mut sgid) = (0, 0, 0);
// SAFETY: getresuid/getresgid write to the three provided out-pointers.
unsafe {
libc::getresuid(&mut ruid, &mut euid, &mut suid);
libc::getresgid(&mut rgid, &mut egid, &mut sgid);
}
if [ruid, euid, suid] != [target.uid; 3] || [rgid, egid, sgid] != [target.gid; 3] {
return Err(anyhow!(
"uid/gid drop verification failed (uids {ruid}/{euid}/{suid}, gids {rgid}/{egid}/{sgid})"
));
}
// SAFETY: setuid takes a plain integer. With ruid/euid/suid all nonzero
// this must fail; success means the drop is unsound.
if unsafe { libc::setuid(0) } == 0 {
return Err(anyhow!("uid drop verification failed: setuid(0) succeeded"));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_target_from_env_uses_sudo_ids() {
assert_eq!(
target_from_env(Some("1001"), Some("1001")),
DropTarget {
uid: 1001,
gid: 1001
}
);
}
#[test]
fn test_target_from_env_rejects_root_and_garbage() {
// uid 0 must never be a drop target
assert_eq!(target_from_env(Some("0"), Some("0")), NOBODY);
// unparseable values fall back to nobody
assert_eq!(target_from_env(Some("abc"), Some("1001")), NOBODY);
assert_eq!(target_from_env(Some("-1"), Some("1001")), NOBODY);
// both must be present (doas sets neither)
assert_eq!(target_from_env(Some("1001"), None), NOBODY);
assert_eq!(target_from_env(None, None), NOBODY);
}
#[test]
fn test_drop_to_rejects_root_target() {
assert!(drop_to(DropTarget { uid: 0, gid: 0 }).is_err());
}
#[test]
fn test_resolve_drop_target_none_when_not_root() {
// Tests don't run as root in CI; as non-root there is nothing to drop.
if unsafe { libc::geteuid() } != 0 {
assert!(resolve_drop_target().is_none());
}
}
}
@@ -0,0 +1,150 @@
// network/platform/linux/interface_stats.rs - Linux sysfs-based interface stats
use crate::network::interface_stats::{InterfaceStats, InterfaceStatsProvider};
use std::fs;
use std::io;
use std::time::SystemTime;
/// Linux-specific implementation using sysfs
pub struct LinuxStatsProvider;
impl LinuxStatsProvider {
pub fn get_stats(&self, interface: &str) -> Result<InterfaceStats, io::Error> {
let base_path = format!("/sys/class/net/{}/statistics", interface);
// Check if interface exists
if !std::path::Path::new(&base_path).exists() {
return Err(io::Error::new(
io::ErrorKind::NotFound,
format!("Interface {} not found", interface),
));
}
Ok(InterfaceStats {
interface_name: interface.to_string(),
rx_bytes: read_stat(&base_path, "rx_bytes")?,
tx_bytes: read_stat(&base_path, "tx_bytes")?,
rx_packets: read_stat(&base_path, "rx_packets")?,
tx_packets: read_stat(&base_path, "tx_packets")?,
rx_errors: read_stat(&base_path, "rx_errors")?,
tx_errors: read_stat(&base_path, "tx_errors")?,
rx_dropped: read_stat(&base_path, "rx_dropped")?,
tx_dropped: read_stat(&base_path, "tx_dropped")?,
collisions: read_stat(&base_path, "collisions")?,
timestamp: SystemTime::now(),
})
}
}
impl InterfaceStatsProvider for LinuxStatsProvider {
fn get_all_stats(&self) -> Result<Vec<InterfaceStats>, io::Error> {
let mut stats = Vec::new();
for entry in fs::read_dir("/sys/class/net")? {
let entry = entry?;
let interface = entry.file_name().to_string_lossy().to_string();
// Skip if we can't read stats (some virtual interfaces may not have all stats)
if let Ok(stat) = self.get_stats(&interface) {
stats.push(stat);
}
}
Ok(stats)
}
}
/// Read a single statistic from sysfs
fn read_stat(base_path: &str, stat_name: &str) -> Result<u64, io::Error> {
let path = format!("{}/{}", base_path, stat_name);
let content = fs::read_to_string(&path)
.map_err(|e| io::Error::new(e.kind(), format!("Failed to read {}: {}", path, e)))?;
content
.trim()
.parse::<u64>()
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
}
#[cfg(test)]
#[cfg(target_os = "linux")]
mod tests {
use super::*;
#[test]
fn test_linux_stats_loopback() {
let provider = LinuxStatsProvider;
let result = provider.get_stats("lo");
match result {
Ok(stats) => {
assert_eq!(stats.interface_name, "lo");
// Stats are u64, so they're always >= 0 by definition
// Just verify the struct is properly populated
}
Err(e) => {
// Acceptable errors: NotFound or PermissionDenied
assert!(
e.kind() == io::ErrorKind::NotFound
|| e.kind() == io::ErrorKind::PermissionDenied,
"Unexpected error: {:?}",
e
);
}
}
}
#[test]
fn test_list_interfaces() {
let provider = LinuxStatsProvider;
let result = provider.get_all_stats();
match result {
Ok(stats) => {
// Should have at least loopback
assert!(!stats.is_empty(), "Expected at least one interface (lo)");
let interface_names: Vec<String> =
stats.iter().map(|s| s.interface_name.clone()).collect();
assert!(
interface_names.iter().any(|name| name == "lo"),
"Expected loopback interface"
);
}
Err(e) => {
// PermissionDenied is acceptable
assert_eq!(e.kind(), io::ErrorKind::PermissionDenied);
}
}
}
#[test]
fn test_get_all_stats() {
let provider = LinuxStatsProvider;
let result = provider.get_all_stats();
match result {
Ok(stats) => {
// Should have at least one interface
assert!(!stats.is_empty(), "Expected at least one interface");
// Check that all interfaces have valid names
for stat in stats {
assert!(!stat.interface_name.is_empty());
}
}
Err(e) => {
// PermissionDenied is acceptable
assert_eq!(e.kind(), io::ErrorKind::PermissionDenied);
}
}
}
#[test]
fn test_nonexistent_interface() {
let provider = LinuxStatsProvider;
let result = provider.get_stats("nonexistent_interface_12345");
assert!(result.is_err());
assert_eq!(result.unwrap_err().kind(), io::ErrorKind::NotFound);
}
}
+10
View File
@@ -0,0 +1,10 @@
// network/platform/linux/mod.rs - Linux interface stats + sandbox.
// Process attribution (procfs/eBPF) lives in the rustnet-host crate.
mod interface_stats;
// Always compiled: without the `landlock` feature the module still provides
// the stub apply_sandbox() that sets PR_SET_NO_NEW_PRIVS.
pub mod sandbox;
pub use interface_stats::LinuxStatsProvider;
@@ -0,0 +1,139 @@
//! Linux capability management
//!
//! Handles dropping capabilities after they are no longer needed.
//! This follows the principle of least privilege - capabilities are
//! only held while necessary for initialization.
//!
//! # CAP_NET_RAW
//!
//! CAP_NET_RAW is required to create raw sockets for packet capture.
//! However, once the pcap handle is opened, the capability is no longer
//! needed and can be safely dropped. This prevents an attacker from
//! creating new raw sockets if they gain code execution.
//!
//! This is the same pattern used by `ping` and other network utilities.
use anyhow::{Context, Result};
use caps::{CapSet, Capability};
/// Drop CAP_NET_RAW from the current process
///
/// This removes CAP_NET_RAW from both the effective and permitted
/// capability sets. The existing pcap socket file descriptor remains
/// valid since the capability was only needed to create it.
///
/// # Returns
///
/// - `Ok(true)` if CAP_NET_RAW was dropped
/// - `Ok(false)` if CAP_NET_RAW was not held (nothing to drop)
/// - `Err` if dropping failed
pub fn drop_cap_net_raw() -> Result<bool> {
// Check if we have CAP_NET_RAW in the effective set
let has_cap = caps::has_cap(None, CapSet::Effective, Capability::CAP_NET_RAW)
.context("Failed to check CAP_NET_RAW in effective set")?;
if !has_cap {
log::debug!("CAP_NET_RAW not in effective set, nothing to drop");
return Ok(false);
}
// Drop from effective set first
caps::drop(None, CapSet::Effective, Capability::CAP_NET_RAW)
.context("Failed to drop CAP_NET_RAW from effective set")?;
log::debug!("Dropped CAP_NET_RAW from effective set");
// Also drop from permitted set to prevent re-acquiring
// This is optional but provides stronger security
if caps::has_cap(None, CapSet::Permitted, Capability::CAP_NET_RAW).unwrap_or(false) {
if let Err(e) = caps::drop(None, CapSet::Permitted, Capability::CAP_NET_RAW) {
// Not fatal - we already dropped from effective
log::warn!("Could not drop CAP_NET_RAW from permitted set: {}", e);
} else {
log::debug!("Dropped CAP_NET_RAW from permitted set");
}
}
Ok(true)
}
/// Check if CAP_NET_RAW is currently held in the effective set
pub fn has_cap_net_raw() -> bool {
caps::has_cap(None, CapSet::Effective, Capability::CAP_NET_RAW).unwrap_or(false)
}
/// Drop CAP_BPF and CAP_PERFMON from the current process
///
/// These capabilities are required for loading eBPF programs but are no
/// longer needed once the programs are loaded. Dropping them limits the
/// blast radius if the process is compromised.
///
/// # Returns
///
/// - `Ok(count)` where count is how many capabilities were dropped (0-2)
/// - `Err` if dropping failed
pub fn drop_ebpf_caps() -> Result<u32> {
let mut dropped = 0;
for cap in [Capability::CAP_BPF, Capability::CAP_PERFMON] {
let has_effective = caps::has_cap(None, CapSet::Effective, cap).unwrap_or(false);
if !has_effective {
continue;
}
caps::drop(None, CapSet::Effective, cap)
.with_context(|| format!("Failed to drop {:?} from effective set", cap))?;
// Also drop from permitted set to prevent re-acquiring
if caps::has_cap(None, CapSet::Permitted, cap).unwrap_or(false)
&& let Err(e) = caps::drop(None, CapSet::Permitted, cap)
{
log::warn!("Could not drop {:?} from permitted set: {}", cap, e);
}
log::debug!("Dropped {:?}", cap);
dropped += 1;
}
Ok(dropped)
}
/// Clear all ambient capabilities
///
/// Ambient capabilities survive `execve()` of non-privileged programs.
/// Clearing them prevents child processes from inheriting any capabilities
/// that were held by the parent. This is standard practice in container
/// runtimes (Docker, systemd) and security-sensitive daemons.
pub fn clear_ambient_caps() -> Result<()> {
caps::clear(None, CapSet::Ambient).context("Failed to clear ambient capability set")?;
log::debug!("Cleared ambient capability set");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_has_cap_net_raw_does_not_panic() {
// This should not panic regardless of capabilities
let _ = has_cap_net_raw();
}
#[test]
fn test_drop_cap_net_raw_without_capability() {
// If we don't have CAP_NET_RAW, drop should return Ok(false)
// This test may behave differently depending on test environment
let result = drop_cap_net_raw();
// Should not error, just return whether it was dropped
assert!(result.is_ok());
}
#[test]
fn test_drop_ebpf_caps_does_not_panic() {
// Should not panic regardless of capabilities held
let result = drop_ebpf_caps();
assert!(result.is_ok());
}
}
@@ -0,0 +1,365 @@
//! Landlock sandboxing implementation
//!
//! Landlock is a Linux Security Module (LSM) that allows unprivileged
//! processes to restrict their own ambient rights (filesystem access,
//! network access).
//!
//! # Kernel Requirements
//!
//! - Linux 5.13+: Filesystem access control (ABI v1)
//! - Linux 5.19+: File referring/REFER (ABI v2)
//! - Linux 6.2+: Truncate control (ABI v3)
//! - Linux 6.7+: Network TCP bind/connect (ABI v4)
//! - Linux 6.12+: Scoping of abstract UNIX sockets and signals (ABI v6)
//!
//! We rely on the `landlock` crate's default best-effort compatibility: on
//! older kernels any unsupported rights are silently dropped rather than
//! causing an error. Filesystem rights are deliberately capped at ABI v4
//! ([`ABI_FS`]) to avoid ABI v5's `IoctlDev` (which would break the TUI's
//! terminal ioctls), while scoping uses ABI v6 ([`ABI_SCOPE`]).
//!
//! # What We Restrict
//!
//! - Filesystem: Only allow read access to `/proc` (needed for process lookup)
//! - Filesystem: Only allow read access to specified paths (e.g., GeoIP databases)
//! - Filesystem: Only allow write access to specified paths (logs)
//! - Network: Block TCP bind and connect (RustNet is passive)
//! - Scope: Deny connecting to abstract UNIX sockets created outside our domain
//! and deny sending signals to processes outside our domain (limits a
//! compromised process from reaching local IPC like D-Bus/X11 or signalling
//! other processes)
use anyhow::{Context, Result};
use landlock::{
ABI, Access, AccessFs, AccessNet, BitFlags, LandlockStatus, PathBeneath, PathFd, Ruleset,
RulesetAttr, RulesetCreatedAttr, RulesetStatus, Scope,
};
use std::path::Path;
use super::{SandboxConfig, SandboxMode};
/// Highest ABI whose *filesystem* rights we handle.
///
/// Capped at V4 on purpose: ABI v5 adds `AccessFs::IoctlDev`, and
/// `AccessFs::from_all(V5+)` would handle it. Since we add no allow-rule for
/// ioctls on the controlling terminal (a character device), handling IoctlDev
/// would deny the ratatui/crossterm ioctls (e.g. `TIOCGWINSZ`, `TCSETS`) and
/// break the TUI. V4's `from_all` covers all filesystem rights we want
/// (read/write/truncate) without IoctlDev.
const ABI_FS: ABI = ABI::V4;
/// ABI used for scoping (abstract UNIX sockets + signals).
///
/// V6 (Linux 6.12+) is the first ABI with scoping. We stop at V6 rather than V7
/// because V7 only adds audit-logging controls, which we don't use. The crate
/// downgrades automatically (best effort) on kernels that support less.
const ABI_SCOPE: ABI = ABI::V6;
/// Result of Landlock application
pub struct LandlockResult {
/// Whether filesystem restrictions were applied
pub fs_applied: bool,
/// Whether network restrictions were applied
pub net_applied: bool,
/// Whether scoping restrictions (abstract UNIX sockets + signals) were applied
pub scope_applied: bool,
/// Effective Landlock ABI the kernel negotiated (e.g. `Some(6)`), or `None`
/// when Landlock is unavailable / not enforced. This is the tier that is
/// actually in effect, which may be lower than what we requested.
pub effective_abi: Option<u8>,
/// Human-readable message
pub message: String,
}
/// Map a `landlock::ABI` to its numeric version for display/reporting.
///
/// The `landlock` 0.4.5 crate knows up to `V7`; `restrict_self` never reports an
/// effective ABI above the crate's compiled-in maximum, so the catch-all is only
/// future-proofing and not expected to trigger.
fn abi_version(abi: ABI) -> u8 {
match abi {
ABI::Unsupported => 0,
ABI::V1 => 1,
ABI::V2 => 2,
ABI::V3 => 3,
ABI::V4 => 4,
ABI::V5 => 5,
ABI::V6 => 6,
ABI::V7 => 7,
_ => 0,
}
}
/// Check if Landlock is available by attempting a test restriction
/// Note: This actually attempts to create a minimal ruleset to check
pub fn is_available() -> bool {
// Try to create a minimal ruleset - this will fail if Landlock isn't available
Ruleset::default()
.handle_access(AccessFs::Execute)
.and_then(|r| r.create())
.is_ok()
}
/// Apply Landlock restrictions based on configuration
pub fn apply_landlock(config: &SandboxConfig) -> Result<LandlockResult> {
// Check if disabled
if config.mode == SandboxMode::Disabled {
return Ok(LandlockResult {
fs_applied: false,
net_applied: false,
scope_applied: false,
effective_abi: None,
message: "Sandbox disabled".to_string(),
});
}
// Filesystem rights are capped at ABI v4 (see ABI_FS); the crate's default
// best-effort compatibility downgrades automatically on older kernels.
let abi = ABI_FS;
// Build filesystem access rights for reading
// We need read access to /proc for process identification
let read_access = AccessFs::from_read(abi);
// Build filesystem access rights for writing (principle of least privilege)
// RustNet only needs to create regular files, write/append to them, and traverse dirs.
let write_access = AccessFs::WriteFile
| AccessFs::ReadFile
| AccessFs::ReadDir
| AccessFs::MakeReg
| AccessFs::Truncate;
// Start building the ruleset. `Ruleset::default()` uses the crate's
// best-effort compatibility, so requesting rights newer than the running
// kernel supports silently drops them instead of erroring.
let mut ruleset = Ruleset::default()
.handle_access(AccessFs::from_all(abi))
.context("Failed to handle filesystem access")?;
// Add network + scope restrictions if requested (the passive-monitor profile).
//
// Network: handling BindTcp/ConnectTcp without adding any allow-rule denies
// all TCP bind/connect (kernel 6.7+, ABI v4). On older kernels best-effort
// drops these silently.
//
// Scope: denying abstract UNIX socket connects and signal sending to
// processes outside our domain (kernel 6.12+, ABI v6). This closes a local
// exfiltration / lateral-movement channel (D-Bus session bus, X11's
// `@/tmp/.X11-unix`, etc.) that RustNet never legitimately uses. Pathname
// UNIX sockets (nss/nscd/systemd-resolved, the reverse-DNS IPC path) are NOT
// abstract sockets and are unaffected.
//
// TODO(udp landlock): UDP restrictions (LANDLOCK_ACCESS_NET_CONNECT_UDP /
// SENDTO_UDP) are an RFC kernel patch series as of 2026-05 and not yet
// exposed by the `landlock` crate. When `AccessNet::ConnectUdp` / `SendtoUdp`
// land, add them to the chain below (they degrade via best effort too). Note
// the tension: a blanket UDP block breaks reverse DNS (glibc resolver,
// UDP/53) and the `UdpSocket::connect("8.8.8.8:53")` routing heuristic in
// capture.rs, so a UDP/53 allow-rule or reliance on `--no-resolve-dns` would
// be needed.
if config.block_network {
ruleset = ruleset
.handle_access(AccessNet::BindTcp)
.and_then(|r| r.handle_access(AccessNet::ConnectTcp))
.context("Failed to handle TCP network access")?
.scope(Scope::from_all(ABI_SCOPE))
.context("Failed to handle scope restrictions")?;
}
// Create the ruleset
let mut ruleset_created = ruleset
.create()
.context("Failed to create Landlock ruleset")?;
// Add rule for /proc (read-only)
// This is required for process identification via procfs. We grant read
// access to all of /proc because Landlock PathBeneath rules apply to
// entire subtrees, and we need to enumerate PIDs via read_dir("/proc")
// and then access per-PID files (/proc/<pid>/comm, /proc/<pid>/fd/).
// Landlock's ptrace domain restrictions provide automatic protection
// against reading sensitive /proc files of processes outside our domain.
if let Err(e) = add_path_rule(&mut ruleset_created, "/proc", read_access) {
log::warn!("Could not add /proc rule: {}", e);
}
// Add rules for sysfs (read-only). The interface-stats poller enumerates
// interfaces via read_dir("/sys/class/net") and then reads each
// /sys/class/net/<iface>/statistics/* counter. Those per-interface entries
// are symlinks into /sys/devices/.../net/<iface>, and Landlock evaluates the
// *resolved* path, so both subtrees need an allow-rule — without them the
// reads fail with EACCES and the Interfaces panel shows
// "No interface stats available". sysfs is not process-sensitive the way
// /proc is, and this is read-only, so granting the two subtrees is fine.
for sysfs_path in ["/sys/class/net", "/sys/devices"] {
if let Err(e) = add_path_rule(&mut ruleset_created, sysfs_path, read_access) {
log::warn!("Could not add {} rule: {}", sysfs_path, e);
}
}
// Add rules for read-only paths (e.g., GeoIP databases)
for path in &config.read_paths {
if path.exists()
&& let Err(e) = add_path_rule(&mut ruleset_created, path, read_access)
{
log::warn!("Could not add read rule for {:?}: {}", path, e);
}
}
// Add rules for write paths (logs, etc.)
for path in &config.write_paths {
if path.exists() {
if let Err(e) = add_path_rule(&mut ruleset_created, path, write_access) {
log::warn!("Could not add write rule for {:?}: {}", path, e);
}
} else {
// For paths that don't exist yet, fall back to the parent directory.
// Landlock requires an open FD (PathFd) to create rules, so non-existent
// paths can't be directly referenced. This grants write access to the
// entire parent directory, which is broader than ideal — callers should
// pre-create output files before applying the sandbox when possible.
if let Some(parent) = path.parent()
&& parent.exists()
{
log::warn!(
"Write path {:?} does not exist; granting write to parent {:?}",
path,
parent
);
if let Err(e) = add_path_rule(&mut ruleset_created, parent, write_access) {
log::warn!("Could not add write rule for parent {:?}: {}", parent, e);
}
}
}
}
// If network blocking is enabled, we DON'T add any NetPort rules and no
// scope allow-rules. Handling the access/scope types without allowing any
// port (or any abstract socket) blocks all TCP bind/connect and all
// cross-domain abstract-socket connects / signals by default.
// Apply the restrictions
let status = ruleset_created
.restrict_self()
.context("Failed to apply Landlock restrictions")?;
// Determine what was actually applied based on the returned status
let fs_applied = matches!(
status.ruleset,
RulesetStatus::FullyEnforced | RulesetStatus::PartiallyEnforced
);
// Check if network restrictions were actually applied. TCP net needs ABI v4
// (Linux 6.7+); scoping needs ABI v6 (Linux 6.12+). We read the effective ABI
// the kernel negotiated rather than what we requested.
let net_applied = config.block_network
&& fs_applied
&& matches!(
status.landlock,
LandlockStatus::Available { effective_abi, .. } if effective_abi >= ABI::V4
);
let scope_applied = config.block_network
&& fs_applied
&& matches!(
status.landlock,
LandlockStatus::Available { effective_abi, .. } if effective_abi >= ABI_SCOPE
);
// The ABI tier actually negotiated with the running kernel (only meaningful
// when Landlock is available and something was enforced).
let effective_abi = match status.landlock {
LandlockStatus::Available { effective_abi, .. } if fs_applied => {
Some(abi_version(effective_abi))
}
_ => None,
};
let message = match (&status.ruleset, &status.landlock) {
(RulesetStatus::FullyEnforced, _) => "Landlock fully enforced".to_string(),
(RulesetStatus::PartiallyEnforced, _) => "Landlock partially enforced".to_string(),
(RulesetStatus::NotEnforced, LandlockStatus::NotEnabled) => {
"Landlock disabled in kernel".to_string()
}
(RulesetStatus::NotEnforced, LandlockStatus::NotImplemented) => {
"Landlock not implemented in kernel".to_string()
}
(RulesetStatus::NotEnforced, _) => "Landlock not enforced".to_string(),
};
log::info!("Landlock: {}", message);
log::info!(
"Landlock: filesystem={}, network={}, scope={}, landlock_status={:?}",
fs_applied,
net_applied,
scope_applied,
status.landlock
);
Ok(LandlockResult {
fs_applied,
net_applied,
scope_applied,
effective_abi,
message,
})
}
/// Add a rule for a path with the specified access rights
fn add_path_rule(
ruleset: &mut landlock::RulesetCreated,
path: impl AsRef<Path>,
access: BitFlags<AccessFs>,
) -> Result<()> {
let path = path.as_ref();
let fd = PathFd::new(path).with_context(|| format!("Failed to open {:?}", path))?;
ruleset
.add_rule(PathBeneath::new(fd, access))
.with_context(|| format!("Failed to add rule for {:?}", path))?;
log::debug!("Landlock: Added rule for {:?}", path);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_available_does_not_panic() {
// Should not panic regardless of kernel support
let _ = is_available();
}
#[test]
fn test_disabled_mode() {
let config = SandboxConfig {
mode: SandboxMode::Disabled,
block_network: true,
read_paths: vec![],
write_paths: vec![],
drop_uid: None,
};
let result = apply_landlock(&config).unwrap();
assert!(!result.fs_applied);
assert!(!result.net_applied);
assert!(!result.scope_applied);
assert_eq!(result.effective_abi, None);
}
#[test]
fn test_best_effort_does_not_panic() {
// Requesting the highest ABI plus network/scope must not error or panic
// regardless of the running kernel: best-effort silently drops any
// unsupported rights. On kernels without Landlock this returns a result
// with nothing applied; on modern kernels it enforces.
let config = SandboxConfig {
mode: SandboxMode::BestEffort,
block_network: true,
read_paths: vec![],
write_paths: vec![],
drop_uid: None,
};
let result = apply_landlock(&config).expect("best-effort must not error");
// scope can only be reported applied when fs was applied too
assert!(result.fs_applied || !result.scope_applied);
}
}
+477
View File
@@ -0,0 +1,477 @@
//! Linux sandboxing support
//!
//! Provides multi-layered sandboxing for restricting process capabilities
//! after initialization is complete. This is a defense-in-depth measure
//! that limits damage if the application (processing untrusted network data)
//! is compromised.
//!
//! # Security Model
//!
//! After sandboxing is applied:
//! - Filesystem: Only `/proc` and specified read paths (e.g., GeoIP databases) readable
//! - Filesystem: Only specified write paths writable (e.g., logs, exports)
//! - Network: TCP bind/connect blocked (kernel 6.7+, ABI v4)
//! - Scope: abstract UNIX socket connects + signals to outside processes blocked
//! (kernel 6.12+, ABI v6)
//! - Capabilities: CAP_NET_RAW, CAP_BPF, CAP_PERFMON dropped
//! - Identity: when started as root (e.g. `sudo rustnet`), euid/egid drop to
//! the invoking user (SUDO_UID/SUDO_GID) or `nobody`, see [`privdrop`].
//! Disable with `--no-uid-drop`.
//! - Privileges: PR_SET_NO_NEW_PRIVS set by rustnet itself (no privilege
//! escalation via execve). This is applied unconditionally — even with
//! `--no-sandbox` or when Landlock is unavailable — since it is privilege
//! hygiene rather than sandboxing and rustnet never execs on Linux.
//!
//! # Application Order
//!
//! 1. Set PR_SET_NO_NEW_PRIVS
//! 2. Drop capabilities (CAP_NET_RAW, CAP_BPF, CAP_PERFMON)
//! 3. Drop root uid/gid (setresuid to SUDO_UID/SUDO_GID or nobody)
//! 4. Apply Landlock (filesystem + network restrictions; needs no privileges
//! since PR_SET_NO_NEW_PRIVS is already set)
//!
//! # Compatibility
//!
//! - Kernel 5.13+: Filesystem sandboxing
//! - Kernel 6.7+: Network sandboxing (TCP bind/connect, ABI v4)
//! - Kernel 6.12+: Scope sandboxing (abstract UNIX sockets + signals, ABI v6)
//! - Older kernels: Graceful degradation (unsupported restrictions dropped)
#[cfg(feature = "landlock")]
pub mod capabilities;
#[cfg(feature = "landlock")]
mod landlock;
// Not gated on `landlock`: the uid drop only needs libc, and matters most on
// exactly the kernels/builds where Landlock is unavailable.
pub mod privdrop;
use std::path::PathBuf;
/// Sandbox enforcement mode
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SandboxMode {
/// Apply sandbox with best-effort (graceful degradation on older kernels)
#[default]
BestEffort,
/// Require full sandbox enforcement or fail
Strict,
/// Disable sandboxing entirely
Disabled,
}
/// Configuration for the sandbox
// Without the landlock feature only the stub apply_sandbox() exists, which
// reads just `mode` and `drop_uid`; the remaining fields are part of the
// shared API.
#[cfg_attr(not(feature = "landlock"), allow(dead_code))]
#[derive(Debug, Clone, Default)]
pub struct SandboxConfig {
/// Sandbox enforcement mode
pub mode: SandboxMode,
/// Block TCP bind/connect (recommended for passive monitors)
pub block_network: bool,
/// Paths that need read access (e.g., GeoIP databases)
pub read_paths: Vec<PathBuf>,
/// Paths that need write access (e.g., log files)
pub write_paths: Vec<PathBuf>,
/// When running as root: the identity to drop to after initialization
/// (`None` = not root, or drop disabled via `--no-uid-drop`)
pub drop_uid: Option<privdrop::DropTarget>,
}
/// Result of sandbox application
#[cfg_attr(not(feature = "landlock"), allow(dead_code))]
#[derive(Debug, Clone)]
pub struct SandboxResult {
/// Overall status
pub status: SandboxStatus,
/// Human-readable message
pub message: String,
/// Whether CAP_NET_RAW was dropped
pub cap_net_raw_dropped: bool,
/// Whether CAP_BPF/CAP_PERFMON were dropped
pub ebpf_caps_dropped: bool,
/// Whether the root uid/gid were dropped (setresuid to the sudo user or nobody)
pub uid_dropped: bool,
/// Whether Landlock is available on this kernel
pub landlock_available: bool,
/// Whether Landlock filesystem restrictions were applied
pub landlock_fs_applied: bool,
/// Whether Landlock network restrictions were applied
pub landlock_net_applied: bool,
/// Whether Landlock scope restrictions (abstract UNIX sockets + signals) were applied
pub landlock_scope_applied: bool,
/// Effective Landlock ABI negotiated with the kernel (e.g. `Some(6)`), or
/// `None` when Landlock is unavailable / not enforced
pub landlock_effective_abi: Option<u8>,
/// Whether PR_SET_NO_NEW_PRIVS is set (applied even when the sandbox is
/// disabled)
pub no_new_privs: bool,
}
/// Status of sandbox application
#[cfg_attr(not(feature = "landlock"), allow(dead_code))]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SandboxStatus {
/// Sandbox fully enforced (all requested restrictions applied)
FullyEnforced,
/// Sandbox partially enforced (some features unavailable on this kernel)
PartiallyEnforced,
/// Sandbox not applied (disabled, or kernel doesn't support)
NotApplied,
}
/// Set PR_SET_NO_NEW_PRIVS: execve() can never grant new privileges
/// (setuid/setgid bits, file capabilities). Irreversible and inherited by
/// children/threads. The landlock crate sets this again in `restrict_self()`;
/// setting it twice is a no-op.
fn set_no_new_privs() -> std::io::Result<()> {
// SAFETY: prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) takes no pointers.
let rc = unsafe { libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) };
if rc == 0 {
Ok(())
} else {
Err(std::io::Error::last_os_error())
}
}
/// Apply the sandbox with the given configuration
///
/// This should be called AFTER:
/// - eBPF programs are loaded
/// - Packet capture handles are opened
/// - Log files are created
///
/// # Returns
///
/// Returns `Ok(SandboxResult)` with details about what was applied.
/// In `Strict` mode, returns `Err` if full sandboxing cannot be achieved.
#[cfg(feature = "landlock")]
pub fn apply_sandbox(config: &SandboxConfig) -> anyhow::Result<SandboxResult> {
use anyhow::Context;
// Set PR_SET_NO_NEW_PRIVS first, regardless of sandbox mode. This is
// privilege hygiene (blocks setuid/file-caps escalation via execve), not
// sandboxing, and rustnet never execs on Linux — so it applies even with
// --no-sandbox.
let no_new_privs = match set_no_new_privs() {
Ok(()) => {
log::info!("PR_SET_NO_NEW_PRIVS set");
true
}
Err(e) => {
log::warn!("Failed to set PR_SET_NO_NEW_PRIVS: {}", e);
false
}
};
if !no_new_privs && config.mode == SandboxMode::Strict {
return Err(anyhow::anyhow!(
"Strict mode requires PR_SET_NO_NEW_PRIVS to be settable"
));
}
// Check Landlock availability upfront
let landlock_available = landlock::is_available();
// Handle disabled mode
if config.mode == SandboxMode::Disabled {
log::info!("Sandbox disabled by configuration");
let message = if no_new_privs {
"Sandbox disabled by configuration (no-new-privs still set)"
} else {
"Sandbox disabled by configuration"
};
return Ok(SandboxResult {
status: SandboxStatus::NotApplied,
message: message.to_string(),
cap_net_raw_dropped: false,
ebpf_caps_dropped: false,
uid_dropped: false,
landlock_available,
landlock_fs_applied: false,
landlock_net_applied: false,
landlock_scope_applied: false,
landlock_effective_abi: None,
no_new_privs,
});
}
let mut result = SandboxResult {
status: SandboxStatus::FullyEnforced,
message: String::new(),
cap_net_raw_dropped: false,
ebpf_caps_dropped: false,
uid_dropped: false,
landlock_available,
landlock_fs_applied: false,
landlock_net_applied: false,
landlock_scope_applied: false,
landlock_effective_abi: None,
no_new_privs,
};
let mut messages = Vec::new();
if no_new_privs {
messages.push("no-new-privs set".to_string());
} else {
messages.push("no-new-privs could not be set".to_string());
}
// Step 0: Clear ambient capability set
// Ambient caps survive execve() — clearing prevents child processes
// from inheriting any capabilities if fork/exec somehow succeeds.
if let Err(e) = capabilities::clear_ambient_caps() {
log::debug!("Could not clear ambient capabilities: {}", e);
}
// Step 1: Drop CAP_NET_RAW capability
// This prevents creating new raw sockets for exfiltration
match capabilities::drop_cap_net_raw() {
Ok(dropped) => {
if dropped {
// Verify the drop actually worked
if capabilities::has_cap_net_raw() {
log::error!("CAP_NET_RAW drop reported success but capability still present!");
result.cap_net_raw_dropped = false;
messages.push("CAP_NET_RAW drop verification failed".to_string());
} else {
result.cap_net_raw_dropped = true;
messages.push("CAP_NET_RAW dropped".to_string());
log::info!("Dropped CAP_NET_RAW capability (verified)");
}
} else {
messages.push("CAP_NET_RAW was not held".to_string());
log::debug!("CAP_NET_RAW was not in effective set");
}
}
Err(e) => {
let msg = format!("Failed to drop CAP_NET_RAW: {}", e);
log::warn!("{}", msg);
messages.push(msg);
if config.mode == SandboxMode::Strict {
return Err(e).context("Strict mode requires CAP_NET_RAW to be droppable");
}
result.status = SandboxStatus::PartiallyEnforced;
}
}
// Step 2: Drop CAP_BPF and CAP_PERFMON
// These are only needed for loading eBPF programs (already done)
match capabilities::drop_ebpf_caps() {
Ok(count) => {
if count > 0 {
result.ebpf_caps_dropped = true;
messages.push(format!("eBPF capabilities dropped ({})", count));
log::info!("Dropped {} eBPF capabilities", count);
} else {
log::debug!("No eBPF capabilities were held");
}
}
Err(e) => {
let msg = format!("Failed to drop eBPF capabilities: {}", e);
log::warn!("{}", msg);
messages.push(msg);
if config.mode == SandboxMode::Strict {
return Err(e).context("Strict mode requires eBPF capabilities to be droppable");
}
}
}
// Step 3: Drop the root uid/gid (sudo case)
// Capabilities alone leave euid 0; on kernels without Landlock this drop
// is the main containment. Runs after the capability drops purely for
// reporting; transitioning all uids away from 0 clears the capability
// sets anyway. Runs before Landlock, which needs no privileges since
// PR_SET_NO_NEW_PRIVS is already set. The libc set*id wrappers apply the
// change to every thread, including those spawned before this point.
if let Some(target) = config.drop_uid {
match privdrop::drop_to(target) {
Ok(()) => {
result.uid_dropped = true;
messages.push(format!(
"root dropped to uid {} gid {}",
target.uid, target.gid
));
log::info!(
"Dropped root privileges to uid {} gid {} (verified); \
procfs process attribution is now limited to that user's \
processes (eBPF attribution unaffected)",
target.uid,
target.gid
);
}
Err(e) => {
let msg = format!("Failed to drop root uid/gid: {}", e);
log::warn!("{}", msg);
messages.push(msg);
if config.mode == SandboxMode::Strict {
return Err(e).context("Strict mode requires the root uid drop to succeed");
}
result.status = SandboxStatus::PartiallyEnforced;
}
}
}
// Step 4: Apply Landlock restrictions
match landlock::apply_landlock(config) {
Ok(ll_result) => {
result.landlock_fs_applied = ll_result.fs_applied;
result.landlock_net_applied = ll_result.net_applied;
result.landlock_scope_applied = ll_result.scope_applied;
result.landlock_effective_abi = ll_result.effective_abi;
if ll_result.fs_applied {
messages.push("Landlock filesystem restrictions applied".to_string());
}
if ll_result.net_applied {
messages.push("Landlock network restrictions applied".to_string());
}
if ll_result.scope_applied {
messages.push("Landlock scope restrictions applied".to_string());
}
if !ll_result.fs_applied && !ll_result.net_applied {
messages.push(format!("Landlock not applied: {}", ll_result.message));
if config.mode == SandboxMode::Strict {
return Err(anyhow::anyhow!(
"Strict mode requires Landlock support: {}",
ll_result.message
));
}
if result.status == SandboxStatus::FullyEnforced {
result.status = SandboxStatus::PartiallyEnforced;
}
} else if !ll_result.net_applied && config.block_network {
// Filesystem applied but network not available
if result.status == SandboxStatus::FullyEnforced {
result.status = SandboxStatus::PartiallyEnforced;
}
}
}
Err(e) => {
let msg = format!("Landlock application failed: {}", e);
log::warn!("{}", msg);
messages.push(msg);
if config.mode == SandboxMode::Strict {
return Err(e).context("Strict mode requires Landlock");
}
result.status = SandboxStatus::PartiallyEnforced;
}
}
// Determine final status
if !result.cap_net_raw_dropped
&& !result.uid_dropped
&& !result.landlock_fs_applied
&& !result.landlock_net_applied
{
result.status = SandboxStatus::NotApplied;
}
// Use appropriate log level based on status
match result.status {
SandboxStatus::FullyEnforced => {
log::info!("Sandbox fully enforced: {}", messages.join("; "));
}
SandboxStatus::PartiallyEnforced => {
log::warn!("Sandbox partially enforced: {}", messages.join("; "));
}
SandboxStatus::NotApplied => {
log::warn!("Sandbox not applied: {}", messages.join("; "));
}
}
result.message = messages.join("; ");
Ok(result)
}
/// Stub implementation when Landlock feature is disabled
#[cfg(not(feature = "landlock"))]
pub fn apply_sandbox(config: &SandboxConfig) -> anyhow::Result<SandboxResult> {
// PR_SET_NO_NEW_PRIVS is independent of Landlock support, so non-landlock
// builds still get the execve privilege lock.
let no_new_privs = match set_no_new_privs() {
Ok(()) => {
log::info!("PR_SET_NO_NEW_PRIVS set");
true
}
Err(e) => {
log::warn!("Failed to set PR_SET_NO_NEW_PRIVS: {}", e);
false
}
};
if !no_new_privs && config.mode == SandboxMode::Strict {
return Err(anyhow::anyhow!(
"Strict mode requires PR_SET_NO_NEW_PRIVS to be settable"
));
}
// The uid drop needs only libc, so non-landlock builds still shed root
// after initialization (unless the sandbox is disabled entirely).
let mut uid_dropped = false;
let mut drop_message = String::new();
if config.mode != SandboxMode::Disabled
&& let Some(target) = config.drop_uid
{
match privdrop::drop_to(target) {
Ok(()) => {
uid_dropped = true;
drop_message = format!("; root dropped to uid {} gid {}", target.uid, target.gid);
log::info!(
"Dropped root privileges to uid {} gid {} (verified); \
procfs process attribution is now limited to that user's \
processes",
target.uid,
target.gid
);
}
Err(e) => {
log::warn!("Failed to drop root uid/gid: {}", e);
if config.mode == SandboxMode::Strict {
return Err(e.context("Strict mode requires the root uid drop to succeed"));
}
drop_message = format!("; root uid drop failed: {}", e);
}
}
}
log::warn!("Landlock feature not compiled in");
let message = if no_new_privs {
"Landlock feature not compiled in (no-new-privs still set)"
} else {
"Landlock feature not compiled in"
};
Ok(SandboxResult {
status: if uid_dropped {
SandboxStatus::PartiallyEnforced
} else {
SandboxStatus::NotApplied
},
message: format!("{}{}", message, drop_message),
cap_net_raw_dropped: false,
ebpf_caps_dropped: false,
uid_dropped,
landlock_available: false,
landlock_fs_applied: false,
landlock_net_applied: false,
landlock_scope_applied: false,
landlock_effective_abi: None,
no_new_privs,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_set_no_new_privs_is_idempotent_and_sticks() {
// NNP is per-task and irreversible, but nothing in the test suite
// execs setuid binaries, so restricting the test process is safe
// (same precedent as the landlock restrict_self test).
set_no_new_privs().expect("first set_no_new_privs call");
set_no_new_privs().expect("second set_no_new_privs call (idempotent)");
// SAFETY: prctl(PR_GET_NO_NEW_PRIVS, 0, 0, 0, 0) takes no pointers.
let value = unsafe { libc::prctl(libc::PR_GET_NO_NEW_PRIVS, 0, 0, 0, 0) };
assert_eq!(value, 1, "NoNewPrivs should be set after set_no_new_privs");
}
}
@@ -0,0 +1,192 @@
//! Root privilege drop (setuid) for Linux
//!
//! When rustnet is started via `sudo` it keeps euid 0 for its whole lifetime,
//! even though root is only needed during initialization: opening the raw
//! capture socket (CAP_NET_RAW) and loading eBPF programs (CAP_BPF +
//! CAP_PERFMON). Dropping capabilities alone still leaves euid 0, and on
//! kernels without Landlock (< 5.13) that means a compromise of the DPI code
//! runs as unconstrained root.
//!
//! This module drops the process to the invoking sudo user (`SUDO_UID` /
//! `SUDO_GID`), or to `nobody` (65534) when started as plain root, after all
//! privileged initialization is complete. Already-open file descriptors (the
//! capture socket, eBPF map/program fds, log and export files) remain valid
//! across the drop.
//!
//! # Trust model
//!
//! `SUDO_UID`/`SUDO_GID` are environment variables and thus caller-controlled,
//! but they can only ever select which *unprivileged* identity we become:
//! uid 0 and unparseable values are rejected and fall back to `nobody`, so a
//! forged value cannot retain or regain privilege.
//!
//! # Trade-offs
//!
//! After the drop, the procfs fallback for process attribution can only scan
//! `/proc/<pid>/fd` of processes owned by the target user; the eBPF fast path
//! is unaffected (it reads already-open map fds). Kubernetes log-directory
//! metadata under `/var/log/pods` may also become unreadable. `--no-uid-drop`
//! keeps the old keep-root behavior.
use anyhow::{Result, anyhow};
/// The uid/gid pair to drop to.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DropTarget {
pub uid: libc::uid_t,
pub gid: libc::gid_t,
}
/// Overflow uid/gid, `nobody`/`nogroup` on all mainstream distros. Used when
/// rustnet runs as plain root (no sudo) or SUDO_UID/SUDO_GID are unusable.
const NOBODY: DropTarget = DropTarget {
uid: 65534,
gid: 65534,
};
/// Resolve the identity to drop to, or `None` when not running as root
/// (nothing to drop; the setcap path never has euid 0).
pub fn resolve_drop_target() -> Option<DropTarget> {
// SAFETY: geteuid() has no failure mode and takes no pointers.
if unsafe { libc::geteuid() } != 0 {
return None;
}
Some(target_from_env(
std::env::var("SUDO_UID").ok().as_deref(),
std::env::var("SUDO_GID").ok().as_deref(),
))
}
/// Pick the target from SUDO_UID/SUDO_GID; both must parse to a nonzero id,
/// otherwise fall back to `nobody`. Split out from `resolve_drop_target` for
/// testability (no process-global env manipulation in tests).
fn target_from_env(sudo_uid: Option<&str>, sudo_gid: Option<&str>) -> DropTarget {
let parse = |v: Option<&str>| v.and_then(|s| s.parse::<u32>().ok()).filter(|&id| id != 0);
match (parse(sudo_uid), parse(sudo_gid)) {
(Some(uid), Some(gid)) => DropTarget { uid, gid },
_ => NOBODY,
}
}
/// Change the file's owner to the drop target.
///
/// Used on pre-created export files (0600, root-owned) so they can still be
/// reopened by name after the uid drop, e.g. libpcap's `pcap_dump_open` and
/// the per-event sidecar JSONL appends. Operates on the already-open fd
/// (`fchown`), never on the path, so it cannot be redirected by a
/// symlink/rename swap after the O_NOFOLLOW create.
pub fn chown_to_target(file: &std::fs::File, target: DropTarget) -> std::io::Result<()> {
use std::os::fd::AsRawFd;
// SAFETY: fchown on a valid owned fd; no pointers involved.
let rc = unsafe { libc::fchown(file.as_raw_fd(), target.uid, target.gid) };
if rc == 0 {
Ok(())
} else {
Err(std::io::Error::last_os_error())
}
}
/// Irreversibly drop root to `target`: supplementary groups first, then gid,
/// then uid (all three of real/effective/saved so root cannot be regained).
///
/// Must be called while euid is 0. Uses the libc wrappers (not raw syscalls):
/// glibc and musl broadcast set*id() to every thread of the process, so
/// threads spawned before the drop (capture, enrichment) are covered too.
///
/// Side effect: transitioning all three uids away from 0 clears the effective
/// and permitted capability sets, which subsumes the explicit capability
/// drops that run before this.
pub fn drop_to(target: DropTarget) -> Result<()> {
if target.uid == 0 || target.gid == 0 {
return Err(anyhow!("refusing to 'drop' to uid 0 / gid 0"));
}
// SAFETY: setgroups reads `ngroups` gid_t values from the pointer; we pass
// exactly one element and its valid address.
let gids = [target.gid];
if unsafe { libc::setgroups(1, gids.as_ptr()) } != 0 {
return Err(anyhow!(
"setgroups failed: {}",
std::io::Error::last_os_error()
));
}
// gid before uid: once uid 0 is gone, setresgid would fail.
// SAFETY: setresgid/setresuid take plain integers.
if unsafe { libc::setresgid(target.gid, target.gid, target.gid) } != 0 {
return Err(anyhow!(
"setresgid({}) failed: {}",
target.gid,
std::io::Error::last_os_error()
));
}
if unsafe { libc::setresuid(target.uid, target.uid, target.uid) } != 0 {
return Err(anyhow!(
"setresuid({}) failed: {}",
target.uid,
std::io::Error::last_os_error()
));
}
// Verify the drop stuck and root cannot be regained.
let (mut ruid, mut euid, mut suid) = (0, 0, 0);
let (mut rgid, mut egid, mut sgid) = (0, 0, 0);
// SAFETY: getresuid/getresgid write to the three provided out-pointers.
unsafe {
libc::getresuid(&mut ruid, &mut euid, &mut suid);
libc::getresgid(&mut rgid, &mut egid, &mut sgid);
}
if [ruid, euid, suid] != [target.uid; 3] || [rgid, egid, sgid] != [target.gid; 3] {
return Err(anyhow!(
"uid/gid drop verification failed (uids {ruid}/{euid}/{suid}, gids {rgid}/{egid}/{sgid})"
));
}
// SAFETY: setuid takes a plain integer. With ruid/euid/suid all nonzero
// and no CAP_SETUID this must fail; success means the drop is unsound.
if unsafe { libc::setuid(0) } == 0 {
return Err(anyhow!("uid drop verification failed: setuid(0) succeeded"));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_target_from_env_uses_sudo_ids() {
assert_eq!(
target_from_env(Some("1000"), Some("1000")),
DropTarget {
uid: 1000,
gid: 1000
}
);
}
#[test]
fn test_target_from_env_rejects_root_and_garbage() {
// uid 0 must never be a drop target
assert_eq!(target_from_env(Some("0"), Some("0")), NOBODY);
// unparseable values fall back to nobody
assert_eq!(target_from_env(Some("abc"), Some("1000")), NOBODY);
assert_eq!(target_from_env(Some("-1"), Some("1000")), NOBODY);
// both must be present
assert_eq!(target_from_env(Some("1000"), None), NOBODY);
assert_eq!(target_from_env(None, None), NOBODY);
}
#[test]
fn test_drop_to_rejects_root_target() {
assert!(drop_to(DropTarget { uid: 0, gid: 0 }).is_err());
}
#[test]
fn test_resolve_drop_target_none_when_not_root() {
// Tests don't run as root in CI; as non-root there is nothing to drop.
if unsafe { libc::geteuid() } != 0 {
assert!(resolve_drop_target().is_none());
}
}
}
@@ -0,0 +1,131 @@
// network/platform/macos/interface_stats.rs - macOS getifaddrs-based interface stats
use crate::network::interface_stats::{InterfaceStats, InterfaceStatsProvider};
use std::ffi::CStr;
use std::io;
use std::ptr;
use std::time::SystemTime;
/// macOS-specific implementation using getifaddrs
pub struct MacOSStatsProvider;
/// 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:
/// 1. The value is suspiciously large (> 2^31, suggesting signed overflow or garbage)
/// 2. The value is larger than total packets (logically impossible for drops/errors)
fn sanitize_counter(value: u32, total_packets: u32) -> u64 {
const MAX_REASONABLE_U32: u32 = 0x7FFF_FFFF; // 2^31 - 1
// If the value is very large (> 2^31), it's likely garbage or overflow
if value > MAX_REASONABLE_U32 {
return 0;
}
// If drops/errors exceed total packets, the data is invalid
if total_packets > 0 && value > total_packets {
return 0;
}
value as u64
}
impl InterfaceStatsProvider for MacOSStatsProvider {
fn get_all_stats(&self) -> Result<Vec<InterfaceStats>, io::Error> {
unsafe {
let mut ifap: *mut libc::ifaddrs = ptr::null_mut();
if libc::getifaddrs(&mut ifap) != 0 {
return Err(io::Error::last_os_error());
}
let mut stats = Vec::new();
let mut current = ifap;
while let Some(ifa) = current.as_ref() {
// Only process AF_LINK entries (data link layer)
if let Some(addr) = ifa.ifa_addr.as_ref()
&& addr.sa_family as i32 == libc::AF_LINK
{
let name = CStr::from_ptr(ifa.ifa_name).to_string_lossy().to_string();
// Get if_data from ifa_data
if let Some(if_data) = (ifa.ifa_data as *const libc::if_data).as_ref() {
// Calculate total packets for validation
let total_rx_packets = if_data.ifi_ipackets;
let total_tx_packets = if_data.ifi_opackets;
stats.push(InterfaceStats {
interface_name: name,
rx_bytes: if_data.ifi_ibytes as u64,
tx_bytes: if_data.ifi_obytes as u64,
rx_packets: total_rx_packets as u64,
tx_packets: total_tx_packets as u64,
// Sanitize error and drop counters (may contain garbage on virtual interfaces)
rx_errors: sanitize_counter(if_data.ifi_ierrors, total_rx_packets),
tx_errors: sanitize_counter(if_data.ifi_oerrors, total_tx_packets),
rx_dropped: sanitize_counter(if_data.ifi_iqdrops, total_rx_packets),
tx_dropped: 0, // Limited on macOS
collisions: sanitize_counter(
if_data.ifi_collisions,
total_rx_packets + total_tx_packets,
),
timestamp: SystemTime::now(),
});
}
}
current = ifa.ifa_next;
}
libc::freeifaddrs(ifap);
Ok(stats)
}
}
}
#[cfg(test)]
#[cfg(target_os = "macos")]
mod tests {
use super::*;
#[test]
fn test_macos_list_interfaces() {
let provider = MacOSStatsProvider;
let result = provider.get_all_stats();
match result {
Ok(stats) => {
assert!(!stats.is_empty(), "Expected at least one interface");
let interface_names: Vec<String> =
stats.iter().map(|s| s.interface_name.clone()).collect();
// macOS should have at least loopback (lo0)
assert!(
interface_names.iter().any(|i| i.starts_with("lo")),
"Expected loopback interface"
);
}
Err(e) => {
panic!("Failed to list interfaces: {:?}", e);
}
}
}
#[test]
fn test_macos_get_all_stats() {
let provider = MacOSStatsProvider;
let result = provider.get_all_stats();
match result {
Ok(stats) => {
assert!(!stats.is_empty(), "Expected at least one interface");
for stat in stats {
assert!(!stat.interface_name.is_empty());
}
}
Err(e) => {
panic!("Failed to get stats: {:?}", e);
}
}
}
}
+11
View File
@@ -0,0 +1,11 @@
// network/platform/macos/mod.rs - macOS interface stats + sandbox.
// Process attribution (PKTAP/lsof) lives in the rustnet-host crate.
mod interface_stats;
// Not gated on `macos-sandbox`: the uid drop only needs libc and applies even
// in builds without Seatbelt support.
pub mod privdrop;
#[cfg(feature = "macos-sandbox")]
pub mod sandbox;
pub use interface_stats::MacOSStatsProvider;
+179
View File
@@ -0,0 +1,179 @@
//! Root privilege drop (setuid) for macOS
//!
//! `sudo rustnet` keeps euid 0 for its whole lifetime, even though root is
//! only needed during initialization to open the BPF/PKTAP capture devices.
//! This module drops the process to the invoking sudo user (`SUDO_UID` /
//! `SUDO_GID`), or to `nobody` when started as plain root, after privileged
//! initialization is complete. Already-open file descriptors (capture
//! devices, log and export files) remain valid across the drop.
//!
//! The drop runs BEFORE the Seatbelt sandbox is applied, so the profile does
//! not need to allow the setuid/setgid syscalls.
//!
//! # Trust model
//!
//! `SUDO_UID`/`SUDO_GID` are environment variables and thus caller-controlled,
//! but they can only ever select which unprivileged identity we become:
//! uid 0 and unparseable values are rejected and fall back to `nobody`, so a
//! forged value cannot retain or regain privilege.
//!
//! # Trade-offs
//!
//! The default PKTAP attribution path is unaffected: process metadata arrives
//! in-band on the already-open capture fd. The lsof fallback (active when
//! PKTAP is unavailable, e.g. with an explicit `--interface`) then only sees
//! the target user's processes. `--no-uid-drop` keeps the old keep-root
//! behavior.
use anyhow::{Result, anyhow};
/// The uid/gid pair to drop to.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DropTarget {
pub uid: libc::uid_t,
pub gid: libc::gid_t,
}
/// macOS `nobody` user and group, both uid/gid -2 in Directory Services
/// (4294967294 as unsigned). Used when rustnet runs as plain root (no sudo)
/// or SUDO_UID/SUDO_GID are unusable.
const NOBODY: DropTarget = DropTarget {
uid: 0xFFFF_FFFE,
gid: 0xFFFF_FFFE,
};
/// Resolve the identity to drop to, or `None` when not running as root
/// (nothing to drop; the ChmodBPF/wireshark path never has euid 0).
pub fn resolve_drop_target() -> Option<DropTarget> {
// SAFETY: geteuid() has no failure mode and takes no pointers.
if unsafe { libc::geteuid() } != 0 {
return None;
}
Some(target_from_env(
std::env::var("SUDO_UID").ok().as_deref(),
std::env::var("SUDO_GID").ok().as_deref(),
))
}
/// Pick the target from SUDO_UID/SUDO_GID; both must parse to a nonzero id,
/// otherwise fall back to `nobody`. Split out from `resolve_drop_target` for
/// testability (no process-global env manipulation in tests).
fn target_from_env(sudo_uid: Option<&str>, sudo_gid: Option<&str>) -> DropTarget {
let parse = |v: Option<&str>| v.and_then(|s| s.parse::<u32>().ok()).filter(|&id| id != 0);
match (parse(sudo_uid), parse(sudo_gid)) {
(Some(uid), Some(gid)) => DropTarget { uid, gid },
_ => NOBODY,
}
}
/// Change the file's owner to the drop target.
///
/// Used on pre-created export files (0600, root-owned) so they can still be
/// reopened by name after the uid drop, e.g. libpcap's `pcap_dump_open` and
/// the per-event sidecar JSONL appends. Operates on the already-open fd
/// (`fchown`), never on the path, so it cannot be redirected by a
/// symlink/rename swap after the O_NOFOLLOW create.
pub fn chown_to_target(file: &std::fs::File, target: DropTarget) -> std::io::Result<()> {
use std::os::fd::AsRawFd;
// SAFETY: fchown on a valid owned fd; no pointers involved.
let rc = unsafe { libc::fchown(file.as_raw_fd(), target.uid, target.gid) };
if rc == 0 {
Ok(())
} else {
Err(std::io::Error::last_os_error())
}
}
/// Irreversibly drop root to `target`: supplementary groups first, then gid,
/// then uid.
///
/// Must be called while euid is 0. With euid 0, `setgid`/`setuid` set the
/// real, effective, and saved ids (POSIX saved-ID semantics, which macOS
/// follows), so root cannot be regained. Process credentials are shared by
/// all threads on macOS, so threads spawned before the drop are covered too.
pub fn drop_to(target: DropTarget) -> Result<()> {
if target.uid == 0 || target.gid == 0 {
return Err(anyhow!("refusing to 'drop' to uid 0 / gid 0"));
}
// SAFETY: setgroups reads `ngroups` gid_t values from the pointer; we pass
// exactly one element and its valid address.
let gids = [target.gid];
if unsafe { libc::setgroups(1, gids.as_ptr()) } != 0 {
return Err(anyhow!(
"setgroups failed: {}",
std::io::Error::last_os_error()
));
}
// gid before uid: once uid 0 is gone, setgid would fail.
// SAFETY: setgid/setuid take plain integers.
if unsafe { libc::setgid(target.gid) } != 0 {
return Err(anyhow!(
"setgid({}) failed: {}",
target.gid,
std::io::Error::last_os_error()
));
}
if unsafe { libc::setuid(target.uid) } != 0 {
return Err(anyhow!(
"setuid({}) failed: {}",
target.uid,
std::io::Error::last_os_error()
));
}
// Verify the drop stuck and root cannot be regained. macOS has no
// getresuid; check real+effective and confirm setuid(0) now fails.
// SAFETY: get*id() have no failure modes; setuid takes a plain integer.
let (ruid, euid) = unsafe { (libc::getuid(), libc::geteuid()) };
let (rgid, egid) = unsafe { (libc::getgid(), libc::getegid()) };
if [ruid, euid] != [target.uid; 2] || [rgid, egid] != [target.gid; 2] {
return Err(anyhow!(
"uid/gid drop verification failed (uids {ruid}/{euid}, gids {rgid}/{egid})"
));
}
if unsafe { libc::setuid(0) } == 0 {
return Err(anyhow!("uid drop verification failed: setuid(0) succeeded"));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_target_from_env_uses_sudo_ids() {
assert_eq!(
target_from_env(Some("501"), Some("20")),
DropTarget { uid: 501, gid: 20 }
);
}
#[test]
fn test_target_from_env_rejects_root_and_garbage() {
// uid 0 must never be a drop target
assert_eq!(target_from_env(Some("0"), Some("0")), NOBODY);
// unparseable values fall back to nobody
assert_eq!(target_from_env(Some("abc"), Some("20")), NOBODY);
assert_eq!(target_from_env(Some("-1"), Some("20")), NOBODY);
// both must be present
assert_eq!(target_from_env(Some("501"), None), NOBODY);
assert_eq!(target_from_env(None, None), NOBODY);
}
#[test]
fn test_drop_to_rejects_root_target() {
assert!(drop_to(DropTarget { uid: 0, gid: 0 }).is_err());
}
#[test]
fn test_resolve_drop_target_none_when_not_root() {
// Tests don't run as root in CI; as non-root there is nothing to drop.
if unsafe { libc::geteuid() } != 0 {
assert!(resolve_drop_target().is_none());
}
}
}
+140
View File
@@ -0,0 +1,140 @@
//! macOS sandboxing support
//!
//! Provides Seatbelt-based sandboxing for restricting process capabilities
//! after initialization is complete. This is a defense-in-depth measure
//! that limits damage if the application (processing untrusted network data)
//! is compromised.
//!
//! # Security Model
//!
//! After sandboxing is applied:
//! - Network: Outbound TCP/UDP connections blocked (RustNet is passive)
//! - Filesystem writes: Credential directories blocked (~/.ssh, ~/.aws, etc.)
//! - Filesystem writes: Only configured log and PCAP export paths writable
//!
//! # Compatibility
//!
//! - macOS 10.5+: Full support (Seatbelt has been present since Leopard)
//! - All Intel and Apple Silicon hardware supported
mod seatbelt;
/// Sandbox enforcement mode
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SandboxMode {
/// Apply sandbox with best-effort (graceful degradation on failure)
#[default]
BestEffort,
/// Require full sandbox enforcement or fail
Strict,
/// Disable sandboxing entirely
Disabled,
}
/// Configuration for the sandbox
#[derive(Debug, Clone, Default)]
pub struct SandboxConfig {
/// Sandbox enforcement mode
pub mode: SandboxMode,
/// Block outbound TCP/UDP connections (recommended for passive monitors)
pub block_network: bool,
/// Log directory path that needs write access
pub log_dir: Option<String>,
/// JSON log file path that needs write access
pub json_log_path: Option<String>,
/// PCAP export file path that needs write access
pub pcap_export_path: Option<String>,
/// PCAPNG export file path that needs write access
pub pcapng_export_path: Option<String>,
/// GeoIP database paths that need read access (may be under /Users)
pub geoip_paths: Vec<String>,
}
/// Result of sandbox application
#[derive(Debug, Clone)]
pub struct SandboxResult {
/// Overall status
pub status: SandboxStatus,
/// Human-readable message
pub message: String,
/// Whether Seatbelt was successfully applied
pub seatbelt_applied: bool,
/// Whether filesystem write restrictions were applied
pub fs_restricted: bool,
/// Whether outbound network connections were blocked
pub net_blocked: bool,
}
/// Status of sandbox application
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SandboxStatus {
/// Sandbox fully enforced
FullyEnforced,
/// Sandbox not applied (disabled or failed in BestEffort mode)
NotApplied,
}
/// Apply the sandbox with the given configuration
///
/// This should be called AFTER:
/// - Packet capture handles are opened (BPF/PKTAP fds survive the sandbox)
/// - Log files are created
///
/// # Returns
///
/// Returns `Ok(SandboxResult)` with details about what was applied.
/// In `Strict` mode, returns `Err` if sandboxing cannot be applied.
pub fn apply_sandbox(config: &SandboxConfig) -> anyhow::Result<SandboxResult> {
use anyhow::Context;
if config.mode == SandboxMode::Disabled {
log::info!("Sandbox disabled by configuration");
return Ok(SandboxResult {
status: SandboxStatus::NotApplied,
message: "Sandbox disabled by configuration".to_string(),
seatbelt_applied: false,
fs_restricted: false,
net_blocked: false,
});
}
match seatbelt::apply_seatbelt(config) {
Ok(result) => {
let status = if result.applied {
SandboxStatus::FullyEnforced
} else {
SandboxStatus::NotApplied
};
if result.applied {
log::info!("Seatbelt: {}", result.message);
} else {
log::warn!("Seatbelt sandbox not applied: {}", result.message);
}
Ok(SandboxResult {
status,
message: result.message,
seatbelt_applied: result.applied,
fs_restricted: result.fs_restricted,
net_blocked: result.net_blocked,
})
}
Err(e) => {
let msg = format!("Seatbelt application failed: {}", e);
log::warn!("{}", msg);
if config.mode == SandboxMode::Strict {
return Err(e).context("Strict mode requires Seatbelt sandboxing");
}
Ok(SandboxResult {
status: SandboxStatus::NotApplied,
message: msg,
seatbelt_applied: false,
fs_restricted: false,
net_blocked: false,
})
}
}
}
@@ -0,0 +1,589 @@
//! macOS Seatbelt sandboxing implementation
//!
//! Uses the macOS `sandbox_init_with_parameters` private API (Seatbelt) to
//! restrict the process after initialization. This is analogous to Linux
//! Landlock: existing file descriptors (BPF devices) remain usable, and only
//! future operations are restricted.
//!
//! # What We Restrict
//!
//! - Network: Outbound TCP/UDP connections (RustNet is passive)
//! - Filesystem writes: Only allowed to configured log and PCAP paths
//! - Filesystem writes: All user home directories blocked (/Users, /var/root)
//! - Filesystem reads: User home directories and system credential stores
//! (keychains, dslocal account database, SSH host keys) blocked
//!
//! # Profile Strategy
//!
//! Uses allow-default with targeted denies. A deny-default profile would
//! require whitelisting all system libraries, Mach ports, locale data, etc.
//! Allow-default provides meaningful security against the primary threats
//! (outbound exfiltration, credential theft) without operational risk.
//!
//! Note: `home-subpath` is not available outside the App Sandbox context.
//! We use hardcoded paths for macOS user home directories instead.
//! SBPL specificity rules ensure that the explicit allow rules for log/PCAP
//! paths override the broader deny rules for /Users and /var/root.
use anyhow::{Context, Result, bail};
use std::ffi::{CStr, CString};
use std::os::raw::{c_char, c_int};
use std::path::PathBuf;
use super::SandboxConfig;
/// Result of Seatbelt application
pub struct SeatbeltResult {
/// Whether the sandbox was applied
pub applied: bool,
/// Human-readable message
pub message: String,
/// Whether filesystem write restrictions were applied
pub fs_restricted: bool,
/// Whether outbound network connections were blocked
pub net_blocked: bool,
}
// macOS Seatbelt private API — stable since macOS 10.5, present through macOS 15+
// Part of libSystem.B.dylib which is linked by default on all macOS targets.
// flags = 0 means an inline SBPL profile string (not a named built-in profile).
unsafe extern "C" {
fn sandbox_init_with_parameters(
profile: *const c_char,
flags: u64,
parameters: *const *const c_char,
errorbuf: *mut *mut c_char,
) -> c_int;
fn sandbox_free_error(errorbuf: *mut c_char);
}
/// Parameter keys used in the SBPL profile via `(param "KEY")` substitution
const PARAM_LOG_DIR: &str = "LOG_DIR";
const PARAM_JSON_LOG_PATH: &str = "JSON_LOG_PATH";
const PARAM_PCAP_PATH: &str = "PCAP_PATH";
const PARAM_PCAP_JSONL_PATH: &str = "PCAP_JSONL_PATH";
const PARAM_PCAPNG_PATH: &str = "PCAPNG_PATH";
const PARAM_GEOIP_PATH_1: &str = "GEOIP_PATH_1";
const PARAM_GEOIP_PATH_2: &str = "GEOIP_PATH_2";
const PARAM_GEOIP_PATH_3: &str = "GEOIP_PATH_3";
/// Base SBPL profile: allow-default with filesystem and process restrictions.
///
/// Note: `home-subpath` is not used because it is only valid inside the
/// App Sandbox context and fails with `sandbox_init_with_parameters`.
/// Instead we hardcode the macOS-specific home directory prefixes.
const SBPL_PROFILE_BASE: &str = r#"(version 1)
;; Allow-default: everything permitted unless explicitly denied
(allow default)
;; Block reads from user home directories
;; Prevents reading SSH keys, AWS credentials, browser profiles, cookies, etc.
;; if a vulnerability in DPI/packet parsing is exploited.
;; SBPL specificity: more specific allow rules for GeoIP paths below
;; will take precedence over these broader deny rules.
(deny file-read-data
(subpath "/Users")
(subpath "/var/root"))
;; Block reads of system credential stores outside the user homes above.
;; RustNet never needs these, so denying them limits credential theft if a
;; DPI/packet-parsing vulnerability is exploited. This matters in particular
;; when running as root: Seatbelt is enforced regardless of uid, so it covers
;; secrets that DAC file permissions would otherwise expose to a root process.
;; Both the symlink and resolved forms are listed because macOS resolves
;; /etc -> /private/etc and /var -> /private/var.
(deny file-read*
(subpath "/Library/Keychains")
(subpath "/private/var/db/dslocal")
(subpath "/var/db/dslocal")
(subpath "/private/etc/ssh")
(subpath "/etc/ssh"))
;; Block writes to user home directories
;; Regular user homes on macOS are under /Users; root's home is /var/root.
;; Protects SSH keys, AWS credentials, GPG keys, browser profiles, etc.
;; SBPL specificity: more specific allow rules for log/PCAP paths below
;; will take precedence over these broader deny rules.
(deny file-write*
(subpath "/Users")
(subpath "/var/root"))
;; Allow reads from configured GeoIP database paths
;; These may reside under /Users (e.g., ~/.local/share/GeoIP)
(allow file-read-data
(literal (param "GEOIP_PATH_1"))
(subpath (param "GEOIP_PATH_1"))
(literal (param "GEOIP_PATH_2"))
(subpath (param "GEOIP_PATH_2"))
(literal (param "GEOIP_PATH_3"))
(subpath (param "GEOIP_PATH_3")))
;; Allow writes to configured output paths (log files, PCAP exports)
;; These use exact file paths, plus the configured log directory when present,
;; that take precedence over the deny rules above when outputs happen to be
;; inside a user home directory.
(allow file-write*
(literal (param "LOG_DIR"))
(subpath (param "LOG_DIR"))
(literal (param "JSON_LOG_PATH"))
(literal (param "PCAP_PATH"))
(literal (param "PCAP_JSONL_PATH"))
(literal (param "PCAPNG_PATH")))
;; Block execution of all binaries except lsof
;; Prevents shell escapes (/bin/sh, /usr/bin/curl, etc.) if code execution
;; is achieved through a DPI parsing vulnerability.
(deny process-exec)
(allow process-exec
(literal "/usr/sbin/lsof"))
"#;
/// Network deny SBPL section, appended when `block_network` is true.
///
/// Blocks outbound TCP/UDP connections. Unix domain sockets are explicitly
/// allowed for Mach IPC. Already-open BPF/PKTAP file descriptors are unaffected.
const SBPL_NETWORK_DENY: &str = r#"
;; Block outbound TCP and UDP connections
;; RustNet only reads from BPF/PKTAP — already-open fds are unaffected
(deny network-outbound
(remote tcp)
(remote udp))
;; Allow Unix domain socket IPC (required for threading, Mach port bridge)
(allow network-outbound
(remote unix-socket))
"#;
/// Build the complete SBPL profile string based on configuration.
fn build_sbpl_profile(block_network: bool) -> String {
if block_network {
format!("{}{}", SBPL_PROFILE_BASE, SBPL_NETWORK_DENY)
} else {
SBPL_PROFILE_BASE.to_string()
}
}
/// Apply Seatbelt restrictions based on configuration.
///
/// The caller (`apply_sandbox` in mod.rs) handles the `Disabled` mode check,
/// so this function assumes sandboxing is requested.
pub fn apply_seatbelt(config: &SandboxConfig) -> Result<SeatbeltResult> {
let profile = build_sbpl_profile(config.block_network);
let profile_cstr = CString::new(profile).context("Profile contains null byte")?;
let params = build_parameters(config).context("Failed to build sandbox parameters")?;
// Build null-terminated array of *const c_char for the FFI call.
// params must outlive ptrs.
let ptrs: Vec<*const c_char> = params.iter().map(|s| s.as_ptr()).collect();
let mut ptrs_with_null = ptrs;
ptrs_with_null.push(std::ptr::null());
let mut errorbuf: *mut c_char = std::ptr::null_mut();
// SAFETY:
// - profile_cstr is a valid null-terminated C string held on the stack
// - ptrs_with_null is a valid null-terminated array of valid C strings
// (params lives at least as long as ptrs_with_null)
// - errorbuf is a valid out-pointer; we free it with sandbox_free_error
let ret = unsafe {
sandbox_init_with_parameters(
profile_cstr.as_ptr(),
0,
ptrs_with_null.as_ptr(),
&mut errorbuf,
)
};
// Always free the error buffer if non-null (may contain a warning on success)
let error_message = if !errorbuf.is_null() {
let msg = unsafe { CStr::from_ptr(errorbuf) }
.to_string_lossy()
.into_owned();
unsafe { sandbox_free_error(errorbuf) };
Some(msg)
} else {
None
};
if ret != 0 {
let detail = error_message.unwrap_or_else(|| "unknown error".to_string());
bail!("sandbox_init_with_parameters failed ({}): {}", ret, detail);
}
if let Some(warn) = &error_message {
log::warn!("Seatbelt: warning from sandbox_init: {}", warn);
}
log::info!(
"Seatbelt sandbox applied (fs_restricted=true, net_blocked={})",
config.block_network
);
Ok(SeatbeltResult {
applied: true,
message: format!(
"Seatbelt applied (fs restricted, net {})",
if config.block_network {
"blocked"
} else {
"allowed"
}
),
fs_restricted: true,
net_blocked: config.block_network,
})
}
/// Resolve a path to a canonical absolute path for use in SBPL rules.
///
/// Seatbelt evaluates paths against their canonical (symlink-resolved) form.
/// On macOS, `/tmp` is a symlink to `/private/tmp`, so non-canonical paths
/// would silently fail to match. We use `std::fs::canonicalize()` when possible,
/// falling back to simple absolute resolution if the path doesn't exist yet.
fn resolve_to_absolute(path: &str) -> String {
let p = PathBuf::from(path);
// Try full canonicalization first (resolves symlinks)
if let Ok(canonical) = std::fs::canonicalize(&p) {
return canonical.to_string_lossy().into_owned();
}
// Path doesn't exist yet — make it absolute without symlink resolution
if p.is_absolute() {
path.to_string()
} else {
std::env::current_dir()
.map(|cwd| cwd.join(&p).to_string_lossy().into_owned())
.unwrap_or_else(|_| path.to_string())
}
}
/// Escape a path string for safe embedding in an SBPL profile.
///
/// SBPL uses S-expression syntax where `"` and `\` have special meaning.
/// While rare in filesystem paths, a crafted path could break SBPL parsing.
fn escape_sbpl_path(path: &str) -> String {
path.replace('\\', "\\\\").replace('"', "\\\"")
}
/// Build the flat key/value parameter array for `sandbox_init_with_parameters`.
///
/// Unused paths default to `/dev/null` (a safe sentinel that avoids empty-string
/// parse issues and is always present on macOS).
/// Relative paths are resolved to absolute paths before being passed.
fn build_parameters(config: &SandboxConfig) -> Result<Vec<CString>> {
let devnull = CString::new("/dev/null").unwrap();
// Helper: resolve path and escape for SBPL safety
let resolve = |p: &str| escape_sbpl_path(&resolve_to_absolute(p));
let log_dir = config
.log_dir
.as_deref()
.map(resolve)
.map(CString::new)
.transpose()
.context("log_dir contains null byte")?
.unwrap_or_else(|| devnull.clone());
let json_log = config
.json_log_path
.as_deref()
.map(resolve)
.map(CString::new)
.transpose()
.context("json_log_path contains null byte")?
.unwrap_or_else(|| devnull.clone());
let pcap = config
.pcap_export_path
.as_deref()
.map(resolve)
.map(CString::new)
.transpose()
.context("pcap_export_path contains null byte")?
.unwrap_or_else(|| devnull.clone());
let pcap_jsonl_str = config
.pcap_export_path
.as_deref()
.map(|p| format!("{}.connections.jsonl", resolve(p)));
let pcap_jsonl = pcap_jsonl_str
.as_deref()
.map(CString::new)
.transpose()
.context("pcap_jsonl path contains null byte")?
.unwrap_or_else(|| devnull.clone());
let pcapng = config
.pcapng_export_path
.as_deref()
.map(resolve)
.map(CString::new)
.transpose()
.context("pcapng_export_path contains null byte")?
.unwrap_or_else(|| devnull.clone());
// GeoIP database paths (up to 3 user-specified or auto-discovered paths)
let geoip_paths: Vec<CString> = config
.geoip_paths
.iter()
.take(3)
.map(|p| resolve_to_absolute(p))
.map(CString::new)
.collect::<std::result::Result<Vec<_>, _>>()
.context("geoip path contains null byte")?;
let geoip_1 = geoip_paths
.first()
.cloned()
.unwrap_or_else(|| devnull.clone());
let geoip_2 = geoip_paths
.get(1)
.cloned()
.unwrap_or_else(|| devnull.clone());
let geoip_3 = geoip_paths
.get(2)
.cloned()
.unwrap_or_else(|| devnull.clone());
// Flat key/value pairs: [KEY0, VAL0, KEY1, VAL1, ...]
// The null terminator is added by the caller just before the FFI call.
Ok(vec![
CString::new(PARAM_LOG_DIR).unwrap(),
log_dir,
CString::new(PARAM_JSON_LOG_PATH).unwrap(),
json_log,
CString::new(PARAM_PCAP_PATH).unwrap(),
pcap,
CString::new(PARAM_PCAP_JSONL_PATH).unwrap(),
pcap_jsonl,
CString::new(PARAM_PCAPNG_PATH).unwrap(),
pcapng,
CString::new(PARAM_GEOIP_PATH_1).unwrap(),
geoip_1,
CString::new(PARAM_GEOIP_PATH_2).unwrap(),
geoip_2,
CString::new(PARAM_GEOIP_PATH_3).unwrap(),
geoip_3,
])
}
#[cfg(test)]
mod tests {
use super::*;
use crate::network::platform::sandbox::SandboxMode;
#[test]
fn test_build_parameters_uses_devnull_for_none() {
let config = SandboxConfig {
mode: SandboxMode::BestEffort,
block_network: true,
log_dir: None,
json_log_path: None,
pcap_export_path: None,
pcapng_export_path: None,
geoip_paths: vec![],
};
let params = build_parameters(&config).unwrap();
// Values at odd indices should all be /dev/null
assert_eq!(params[1].to_str().unwrap(), "/dev/null");
assert_eq!(params[3].to_str().unwrap(), "/dev/null");
assert_eq!(params[5].to_str().unwrap(), "/dev/null");
assert_eq!(params[7].to_str().unwrap(), "/dev/null");
assert_eq!(params[9].to_str().unwrap(), "/dev/null");
// GeoIP paths should also be /dev/null
assert_eq!(params[11].to_str().unwrap(), "/dev/null");
assert_eq!(params[13].to_str().unwrap(), "/dev/null");
assert_eq!(params[15].to_str().unwrap(), "/dev/null");
}
#[test]
fn test_build_parameters_with_absolute_paths() {
let config = SandboxConfig {
mode: SandboxMode::BestEffort,
block_network: true,
log_dir: Some("/tmp/rustnet/logs".to_string()),
json_log_path: Some("/tmp/rustnet/events.jsonl".to_string()),
pcap_export_path: Some("/tmp/rustnet/capture.pcap".to_string()),
pcapng_export_path: Some("/tmp/rustnet/capture.pcapng".to_string()),
geoip_paths: vec![],
};
let params = build_parameters(&config).unwrap();
// Keys
assert_eq!(params[0].to_str().unwrap(), PARAM_LOG_DIR);
assert_eq!(params[2].to_str().unwrap(), PARAM_JSON_LOG_PATH);
assert_eq!(params[4].to_str().unwrap(), PARAM_PCAP_PATH);
assert_eq!(params[6].to_str().unwrap(), PARAM_PCAP_JSONL_PATH);
assert_eq!(params[8].to_str().unwrap(), PARAM_PCAPNG_PATH);
// Values
assert_eq!(params[1].to_str().unwrap(), "/tmp/rustnet/logs");
assert_eq!(params[3].to_str().unwrap(), "/tmp/rustnet/events.jsonl");
assert_eq!(params[5].to_str().unwrap(), "/tmp/rustnet/capture.pcap");
assert_eq!(
params[7].to_str().unwrap(),
"/tmp/rustnet/capture.pcap.connections.jsonl"
);
assert_eq!(params[9].to_str().unwrap(), "/tmp/rustnet/capture.pcapng");
}
#[test]
fn test_build_parameters_with_geoip_paths() {
let config = SandboxConfig {
mode: SandboxMode::BestEffort,
block_network: true,
log_dir: None,
json_log_path: None,
pcap_export_path: None,
pcapng_export_path: None,
geoip_paths: vec![
"/usr/share/GeoIP".to_string(),
"/opt/homebrew/share/GeoIP".to_string(),
],
};
let params = build_parameters(&config).unwrap();
assert_eq!(params[10].to_str().unwrap(), PARAM_GEOIP_PATH_1);
assert_eq!(params[11].to_str().unwrap(), "/usr/share/GeoIP");
assert_eq!(params[12].to_str().unwrap(), PARAM_GEOIP_PATH_2);
assert_eq!(params[13].to_str().unwrap(), "/opt/homebrew/share/GeoIP");
// Third slot defaults to /dev/null
assert_eq!(params[14].to_str().unwrap(), PARAM_GEOIP_PATH_3);
assert_eq!(params[15].to_str().unwrap(), "/dev/null");
}
#[test]
fn test_relative_path_is_resolved_to_absolute() {
let abs = resolve_to_absolute("logs");
assert!(abs.starts_with('/'), "Expected absolute path, got: {}", abs);
assert!(
abs.ends_with("/logs"),
"Expected path ending with /logs, got: {}",
abs
);
}
#[test]
fn test_absolute_nonexistent_path_is_unchanged() {
// /tmp/foo doesn't exist, so canonicalize fails and we fall back to returning it as-is
assert_eq!(resolve_to_absolute("/tmp/foo"), "/tmp/foo");
}
#[test]
fn test_symlink_resolved_by_canonicalize() {
// On macOS, /tmp is a symlink to /private/tmp.
// resolve_to_absolute should canonicalize existing paths,
// ensuring Seatbelt rules match the real filesystem location.
let resolved = resolve_to_absolute("/tmp");
assert_eq!(
resolved, "/private/tmp",
"Expected /tmp to resolve to /private/tmp via canonicalize, got: {}",
resolved
);
}
#[test]
fn test_escape_sbpl_path_no_special_chars() {
assert_eq!(escape_sbpl_path("/tmp/rustnet/logs"), "/tmp/rustnet/logs");
}
#[test]
fn test_escape_sbpl_path_with_quotes_and_backslashes() {
assert_eq!(
escape_sbpl_path(r#"/tmp/path"with\special"#),
r#"/tmp/path\"with\\special"#
);
}
#[test]
fn test_profile_variants_are_valid_cstrings() {
CString::new(SBPL_PROFILE_BASE).expect("SBPL_PROFILE_BASE must not contain null bytes");
CString::new(build_sbpl_profile(true)).expect("full profile must not contain null bytes");
CString::new(build_sbpl_profile(false))
.expect("base-only profile must not contain null bytes");
}
#[test]
fn test_profile_denies_system_credential_stores() {
// Both with and without network blocking, the base profile must deny
// reads of the system credential stores rustnet never needs.
for block_network in [true, false] {
let profile = build_sbpl_profile(block_network);
for store in ["/Library/Keychains", "/private/var/db/dslocal", "/etc/ssh"] {
assert!(
profile.contains(store),
"Expected credential-store deny for {store} (block_network={block_network})"
);
}
}
}
#[test]
fn test_profile_includes_network_deny_when_block_network_true() {
let profile = build_sbpl_profile(true);
assert!(
profile.contains("deny network-outbound"),
"Expected network deny in profile when block_network=true"
);
}
#[test]
fn test_profile_excludes_network_deny_when_block_network_false() {
let profile = build_sbpl_profile(false);
assert!(
!profile.contains("deny network-outbound"),
"Expected no network deny in profile when block_network=false"
);
}
#[test]
fn test_profile_includes_file_read_deny() {
let profile = build_sbpl_profile(false);
assert!(
profile.contains("deny file-read-data"),
"Expected file-read-data deny in profile"
);
assert!(
profile.contains(r#"(subpath "/Users")"#),
"Expected /Users in file-read-data deny"
);
assert!(
profile.contains(r#"(subpath "/var/root")"#),
"Expected /var/root in file-read-data deny"
);
}
#[test]
fn test_profile_includes_process_exec_deny() {
let profile = build_sbpl_profile(false);
assert!(
profile.contains("(deny process-exec)"),
"Expected process-exec deny in profile"
);
assert!(
profile.contains(r#"(allow process-exec"#),
"Expected process-exec allow for lsof"
);
assert!(
profile.contains(r#"(literal "/usr/sbin/lsof")"#),
"Expected lsof in process-exec allow"
);
}
#[test]
fn test_profile_includes_geoip_read_allow() {
let profile = build_sbpl_profile(false);
assert!(
profile.contains(r#"(param "GEOIP_PATH_1")"#),
"Expected GEOIP_PATH_1 parameter in profile"
);
assert!(
profile.contains(r#"(param "GEOIP_PATH_2")"#),
"Expected GEOIP_PATH_2 parameter in profile"
);
assert!(
profile.contains(r#"(param "GEOIP_PATH_3")"#),
"Expected GEOIP_PATH_3 parameter in profile"
);
}
}
+56
View File
@@ -0,0 +1,56 @@
// network/platform/mod.rs - Platform-specific interface stats and sandboxing.
//
// Per-connection process attribution moved to the `rustnet-host` crate; its
// public API (`ProcessLookup`, `DegradationReason`, `ConnectionKey`, and the
// `create_process_lookup` factory) is re-exported here so the rest of the
// binary keeps using `crate::network::platform::*` unchanged. What remains in
// the binary is the per-platform interface-statistics providers and the
// privilege-dropping sandbox (Landlock / Seatbelt / restricted token).
// Process attribution lives in the rustnet-host crate. Re-export the bits the
// binary uses; the full API (ProcessLookup, ConnectionKey, ...) is available
// directly from `rustnet_host` for other consumers.
pub use rustnet_host::{DegradationReason, create_process_lookup};
// macOS: the app injects the PKTAP-unavailable reason into rustnet-host so the
// host crate need not depend on rustnet-capture.
#[cfg(target_os = "macos")]
pub use rustnet_host::report_pktap_degradation;
// Platform-specific modules (interface stats + sandbox)
#[cfg(target_os = "freebsd")]
mod freebsd;
#[cfg(target_os = "linux")]
mod linux;
#[cfg(target_os = "macos")]
mod macos;
#[cfg(target_os = "windows")]
mod windows;
// Re-export interface-stats providers and the sandbox entry points.
#[cfg(target_os = "freebsd")]
pub use freebsd::FreeBSDStatsProvider;
// FreeBSD has no sandbox module yet (Capsicum planned); the uid drop is the
// containment layer in the meantime.
#[cfg(target_os = "freebsd")]
pub use freebsd::privdrop;
#[cfg(target_os = "linux")]
pub use linux::LinuxStatsProvider;
// Not gated on the `landlock` feature: the sandbox module always compiles on
// Linux (a non-landlock build still sets PR_SET_NO_NEW_PRIVS via the stub).
#[cfg(target_os = "linux")]
pub use linux::sandbox;
// Linux keeps privdrop inside the sandbox module; re-export it at the platform
// level so callers can use `platform::privdrop` uniformly across platforms.
#[cfg(target_os = "linux")]
pub use linux::sandbox::privdrop;
#[cfg(target_os = "macos")]
pub use macos::MacOSStatsProvider;
// Not gated on the `macos-sandbox` feature: the uid drop works without Seatbelt.
#[cfg(target_os = "macos")]
pub use macos::privdrop;
#[cfg(all(target_os = "macos", feature = "macos-sandbox"))]
pub use macos::sandbox;
#[cfg(target_os = "windows")]
pub use windows::WindowsStatsProvider;
#[cfg(target_os = "windows")]
pub use windows::sandbox;
@@ -0,0 +1,167 @@
// network/platform/windows/interface_stats.rs - Windows IP Helper API interface stats
use crate::network::interface_stats::{InterfaceStats, InterfaceStatsProvider};
use std::collections::HashMap;
use std::io;
use std::time::SystemTime;
#[cfg(target_os = "windows")]
use windows::Win32::NetworkManagement::IpHelper::{FreeMibTable, GetIfTable2, MIB_IF_TABLE2};
#[cfg(target_os = "windows")]
use windows::Win32::NetworkManagement::Ndis::IfOperStatusUp;
/// Windows-specific implementation using IP Helper API
pub struct WindowsStatsProvider;
impl InterfaceStatsProvider for WindowsStatsProvider {
#[cfg(target_os = "windows")]
fn get_all_stats(&self) -> Result<Vec<InterfaceStats>, io::Error> {
unsafe {
let mut table: *mut MIB_IF_TABLE2 = std::ptr::null_mut();
let result = GetIfTable2(&mut table);
if result.is_err() {
return Err(io::Error::other(format!(
"GetIfTable2 failed with error code: {:?}",
result
)));
}
let table_ref = match table.as_ref() {
Some(t) => t,
None => return Err(io::Error::other("Failed to get interface table")),
};
let num_entries = table_ref.NumEntries as usize;
// Use LUID as key for deduplication since it's unique per interface
let mut stats_map: HashMap<u64, InterfaceStats> = HashMap::new();
for i in 0..num_entries {
let row = &*table_ref.Table.as_ptr().add(i);
// Convert interface alias (friendly name) to string
let name = String::from_utf16_lossy(&row.Alias)
.trim_end_matches('\0')
.to_string();
if name.is_empty() {
continue;
}
// Skip virtual/filter interfaces by name patterns
// These are NDIS filter drivers, WFP filters, and virtual adapters
// Always skip these as they just mirror the physical interface
let name_lower = name.to_lowercase();
if name_lower.contains("-npcap")
|| name_lower.contains("-wfp")
|| name_lower.contains("-qos")
|| name_lower.contains("-native")
|| name_lower.contains("-virtual")
|| name_lower.contains("-packet")
|| name_lower.contains("lightweight filter")
|| name_lower.contains("mac layer")
{
continue;
}
// Skip "Local Area Con" with zero traffic (these are usually disconnected adapters)
let total_traffic =
row.InOctets + row.OutOctets + row.InUcastPkts + row.OutUcastPkts;
if name_lower.starts_with("local area con") && total_traffic == 0 {
continue;
}
// Skip interfaces that are not operationally up
// But allow them if they have any traffic statistics
let has_traffic = row.InOctets > 0 || row.OutOctets > 0;
if row.OperStatus != IfOperStatusUp && !has_traffic {
continue;
}
let stat = InterfaceStats {
interface_name: name.clone(),
rx_bytes: row.InOctets,
tx_bytes: row.OutOctets,
rx_packets: row.InUcastPkts + row.InNUcastPkts,
tx_packets: row.OutUcastPkts + row.OutNUcastPkts,
rx_errors: row.InErrors,
tx_errors: row.OutErrors,
rx_dropped: row.InDiscards,
tx_dropped: row.OutDiscards,
collisions: 0, // Not available on modern Windows interfaces
timestamp: SystemTime::now(),
};
// Use InterfaceLuid.Value as unique key to prevent duplicates
// This ensures each physical interface appears only once
let luid_value = row.InterfaceLuid.Value;
stats_map.insert(luid_value, stat);
}
FreeMibTable(table.cast());
let stats_vec: Vec<InterfaceStats> = stats_map.into_values().collect();
log::debug!(
"Windows interface stats collected: {} interfaces",
stats_vec.len()
);
for stat in &stats_vec {
log::debug!(
" {} - RX: {} bytes, TX: {} bytes",
stat.interface_name,
stat.rx_bytes,
stat.tx_bytes
);
}
Ok(stats_vec)
}
}
#[cfg(not(target_os = "windows"))]
fn get_all_stats(&self) -> Result<Vec<InterfaceStats>, io::Error> {
Err(io::Error::new(
io::ErrorKind::Unsupported,
"Windows interface stats not available on this platform",
))
}
}
#[cfg(test)]
#[cfg(target_os = "windows")]
mod tests {
use super::*;
#[test]
fn test_windows_list_interfaces() {
let provider = WindowsStatsProvider;
let result = provider.get_all_stats();
match result {
Ok(stats) => {
assert!(!stats.is_empty(), "Expected at least one interface");
}
Err(e) => {
panic!("Failed to list interfaces: {:?}", e);
}
}
}
#[test]
fn test_windows_get_all_stats() {
let provider = WindowsStatsProvider;
let result = provider.get_all_stats();
match result {
Ok(stats) => {
assert!(!stats.is_empty(), "Expected at least one interface");
for stat in stats {
assert!(!stat.interface_name.is_empty());
}
}
Err(e) => {
panic!("Failed to get stats: {:?}", e);
}
}
}
}
+7
View File
@@ -0,0 +1,7 @@
// network/platform/windows/mod.rs - Windows interface stats + sandbox.
// Process attribution (IP Helper API) lives in the rustnet-host crate.
mod interface_stats;
pub mod sandbox;
pub use interface_stats::WindowsStatsProvider;
+144
View File
@@ -0,0 +1,144 @@
//! Windows sandboxing support
//!
//! Provides privilege removal and job object restrictions to reduce blast
//! radius if the application (processing untrusted network data) is compromised.
//!
//! # Security Model
//!
//! After sandboxing is applied:
//! - Dangerous privileges removed (SeDebugPrivilege, SeTakeOwnershipPrivilege, etc.)
//! - Child process creation blocked via Job Object
//!
//! # Limitations
//!
//! Windows sandboxing is weaker than Linux/macOS/FreeBSD:
//! - No filesystem restriction (Windows ACLs are per-object, not process-wide)
//! - No network restriction (would break Npcap packet capture)
//! - Privilege removal only affects privileges the process held
mod restricted;
/// Sandbox enforcement mode
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SandboxMode {
/// Apply sandbox with best-effort (graceful degradation)
#[default]
BestEffort,
/// Require full sandbox enforcement or fail
Strict,
/// Disable sandboxing entirely
Disabled,
}
/// Configuration for the sandbox
#[derive(Debug, Clone, Default)]
pub struct SandboxConfig {
/// Sandbox enforcement mode
pub mode: SandboxMode,
}
/// Result of sandbox application
#[derive(Debug, Clone)]
pub struct SandboxResult {
/// Overall status
pub status: SandboxStatus,
/// Human-readable message
pub message: String,
/// Whether dangerous privileges were removed
pub privileges_removed: bool,
/// Number of privileges removed
pub privileges_removed_count: u32,
/// Whether job object was applied
pub job_object_applied: bool,
}
/// Status of sandbox application
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SandboxStatus {
/// Sandbox fully enforced (privileges removed + job object)
FullyEnforced,
/// Sandbox partially enforced (some components failed)
PartiallyEnforced,
/// Sandbox not applied (disabled or all components failed)
NotApplied,
}
/// Apply the sandbox with the given configuration
///
/// This should be called AFTER:
/// - Npcap handles are opened
/// - Log files are created
pub fn apply_sandbox(config: &SandboxConfig) -> anyhow::Result<SandboxResult> {
if config.mode == SandboxMode::Disabled {
log::info!("Sandbox disabled by configuration");
return Ok(SandboxResult {
status: SandboxStatus::NotApplied,
message: "Sandbox disabled by configuration".to_string(),
privileges_removed: false,
privileges_removed_count: 0,
job_object_applied: false,
});
}
let mut messages = Vec::new();
let mut privileges_removed = false;
let mut privileges_removed_count = 0u32;
let mut privileges_succeeded = false;
let mut job_object_applied = false;
// Step 1: Remove dangerous privileges
match restricted::remove_dangerous_privileges() {
Ok(result) => {
privileges_removed = result.privileges_removed;
privileges_removed_count = result.privileges_removed_count;
privileges_succeeded = result.succeeded;
log::info!("Privilege restriction: {}", result.message);
messages.push(result.message);
}
Err(e) => {
let msg = format!("Privilege restriction failed: {}", e);
log::warn!("{}", msg);
messages.push(msg);
}
}
// Step 2: Apply job object to prevent child process creation
match restricted::apply_job_object() {
Ok(result) => {
job_object_applied = result.applied;
log::info!("Job object: {}", result.message);
messages.push(result.message);
}
Err(e) => {
let msg = format!("Job object failed: {}", e);
log::warn!("{}", msg);
messages.push(msg);
}
}
// Status reflects whether each step *succeeded*, not whether anything
// was actually removed. A standard (non-elevated) user never held the
// dangerous privileges, so a successful no-op is the desired end state.
let status = if privileges_succeeded && job_object_applied {
SandboxStatus::FullyEnforced
} else if privileges_succeeded || job_object_applied {
SandboxStatus::PartiallyEnforced
} else {
SandboxStatus::NotApplied
};
if config.mode == SandboxMode::Strict && status != SandboxStatus::FullyEnforced {
return Err(anyhow::anyhow!(
"Strict mode requires full sandbox enforcement: {}",
messages.join("; ")
));
}
Ok(SandboxResult {
status,
message: messages.join("; "),
privileges_removed,
privileges_removed_count,
job_object_applied,
})
}
@@ -0,0 +1,221 @@
//! Windows restricted token and job object sandboxing
//!
//! After initialization, we:
//! 1. Create a Job Object that prevents child process creation
//! 2. Remove dangerous privileges from the process token
//!
//! This reduces blast radius if a vulnerability in packet parsing is exploited:
//! - Cannot spawn child processes (reverse shell, data exfiltration via curl, etc.)
//! - Cannot debug other processes (SeDebugPrivilege removed)
//! - Cannot take ownership of files (SeTakeOwnershipPrivilege removed)
//! - Cannot back up/restore files (SeBackupPrivilege, SeRestorePrivilege removed)
use anyhow::{Context, Result};
use std::ffi::c_void;
use windows::Win32::Foundation::{CloseHandle, HANDLE, LUID};
use windows::Win32::Security::{
AdjustTokenPrivileges, LUID_AND_ATTRIBUTES, LookupPrivilegeValueW, SE_PRIVILEGE_REMOVED,
TOKEN_ADJUST_PRIVILEGES, TOKEN_PRIVILEGES, TOKEN_QUERY,
};
use windows::Win32::System::JobObjects::{
AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_ACTIVE_PROCESS,
JOBOBJECT_BASIC_LIMIT_INFORMATION, JOBOBJECT_EXTENDED_LIMIT_INFORMATION,
JobObjectExtendedLimitInformation, SetInformationJobObject,
};
use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
/// Win32 error code returned by `AdjustTokenPrivileges` when the token
/// did not hold the requested privilege (success, but nothing was changed).
const ERROR_NOT_ALL_ASSIGNED: u32 = 1300;
/// Privileges to remove from the process token after initialization.
///
/// These are the most dangerous privileges an attacker could abuse:
const PRIVILEGES_TO_REMOVE: &[&str] = &[
"SeDebugPrivilege", // Debug arbitrary processes
"SeTakeOwnershipPrivilege", // Take ownership of any securable object
"SeBackupPrivilege", // Read any file regardless of ACL
"SeRestorePrivilege", // Write any file regardless of ACL
"SeCreateTokenPrivilege", // Create primary tokens
"SeAssignPrimaryTokenPrivilege", // Replace process-level token
"SeLoadDriverPrivilege", // Load/unload device drivers
"SeTcbPrivilege", // Act as part of the OS
"SeRemoteShutdownPrivilege", // Shut down remote systems
"SeImpersonatePrivilege", // Impersonate other users
];
/// Result of restricted token application
pub struct RestrictedTokenResult {
/// Whether at least one privilege was removed (count > 0).
/// False for non-elevated processes that never held the privileges.
pub privileges_removed: bool,
/// Number of privileges removed
pub privileges_removed_count: u32,
/// Whether the privilege restriction step completed without errors.
/// True even when 0 privileges were removed because the token never
/// held them — that is the desired end state, not a failure.
pub succeeded: bool,
/// Human-readable message
pub message: String,
}
/// Result of job object application
pub struct JobObjectResult {
/// Whether the job object was applied
pub applied: bool,
/// Human-readable message
pub message: String,
}
/// Remove dangerous privileges from the current process token.
///
/// Uses SE_PRIVILEGE_REMOVED which permanently removes privileges —
/// they cannot be re-enabled, even by the process itself.
pub fn remove_dangerous_privileges() -> Result<RestrictedTokenResult> {
unsafe {
let mut token_handle = HANDLE::default();
OpenProcessToken(
GetCurrentProcess(),
TOKEN_ADJUST_PRIVILEGES | TOKEN_QUERY,
&mut token_handle,
)
.context("Failed to open process token")?;
let mut removed_count = 0u32;
let mut messages = Vec::new();
for priv_name in PRIVILEGES_TO_REMOVE {
match remove_single_privilege(token_handle, priv_name) {
Ok(true) => {
removed_count += 1;
log::debug!("Removed privilege: {}", priv_name);
}
Ok(false) => {
// Privilege not held — not an error
log::debug!("Privilege not present: {}", priv_name);
}
Err(e) => {
let msg = format!("Failed to remove {}: {}", priv_name, e);
log::warn!("{}", msg);
messages.push(msg);
}
}
}
let _ = CloseHandle(token_handle);
let message = if removed_count > 0 {
format!(
"{} privilege(s) removed{}",
removed_count,
if messages.is_empty() {
String::new()
} else {
format!("; {}", messages.join("; "))
}
)
} else if messages.is_empty() {
"No dangerous privileges were held".to_string()
} else {
messages.join("; ")
};
Ok(RestrictedTokenResult {
privileges_removed: removed_count > 0,
privileges_removed_count: removed_count,
succeeded: messages.is_empty(),
message,
})
}
}
/// Remove a single privilege from the token.
/// Returns Ok(true) if removed, Ok(false) if not held, Err on failure.
unsafe fn remove_single_privilege(token: HANDLE, privilege_name: &str) -> Result<bool> {
unsafe {
let wide_name: Vec<u16> = privilege_name
.encode_utf16()
.chain(std::iter::once(0))
.collect();
let mut luid = LUID::default();
if LookupPrivilegeValueW(None, windows::core::PCWSTR(wide_name.as_ptr()), &mut luid)
.is_err()
{
// Privilege name not recognized on this system — skip
return Ok(false);
}
let tp = TOKEN_PRIVILEGES {
PrivilegeCount: 1,
Privileges: [LUID_AND_ATTRIBUTES {
Luid: luid,
Attributes: SE_PRIVILEGE_REMOVED,
}],
};
if AdjustTokenPrivileges(token, false, Some(&tp), 0, None, None).is_err() {
return Err(anyhow::anyhow!(
"AdjustTokenPrivileges failed for {}",
privilege_name
));
}
// Check GetLastError — AdjustTokenPrivileges returns success even if
// the privilege wasn't held (ERROR_NOT_ALL_ASSIGNED = 1300)
let last_error = windows::Win32::Foundation::GetLastError();
if last_error.0 == ERROR_NOT_ALL_ASSIGNED {
return Ok(false);
}
Ok(true)
}
}
/// Apply a Job Object to the current process that prevents child process creation.
///
/// After this call, any attempt to create a child process will fail.
/// This blocks reverse shells, data exfiltration via exec, etc.
pub fn apply_job_object() -> Result<JobObjectResult> {
unsafe {
// Create an unnamed job object
let job = CreateJobObjectW(None, None).context("Failed to create job object")?;
// Configure: limit to 1 active process (prevents child spawning)
let info = JOBOBJECT_EXTENDED_LIMIT_INFORMATION {
BasicLimitInformation: JOBOBJECT_BASIC_LIMIT_INFORMATION {
LimitFlags: JOB_OBJECT_LIMIT_ACTIVE_PROCESS,
ActiveProcessLimit: 1,
..Default::default()
},
..Default::default()
};
SetInformationJobObject(
job,
JobObjectExtendedLimitInformation,
&info as *const _ as *const c_void,
std::mem::size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
)
.context("Failed to set job object limits")?;
// Assign current process to the job.
// On Windows 8+ nested jobs are supported, so this succeeds even if the
// process is already in a job (e.g., launched from Task Scheduler or a
// container). On Windows 7 (unsupported) it would fail with ACCESS_DENIED.
AssignProcessToJobObject(job, GetCurrentProcess())
.context("Failed to assign process to job object")?;
// Don't close the job handle — it must remain open for the lifetime
// of the process, otherwise the restrictions are lifted.
// Intentionally leak it.
log::debug!("Job object applied: child process creation blocked");
Ok(JobObjectResult {
applied: true,
message: "Job object applied: child process creation blocked".to_string(),
})
}
}
+400
View File
@@ -0,0 +1,400 @@
//! Network privilege detection for packet capture
//!
//! This module checks if the application has sufficient privileges to capture
//! network packets on different platforms (Linux, macOS, Windows).
use anyhow::Result;
#[cfg(target_os = "linux")]
use anyhow::anyhow;
#[cfg(any(
not(any(
target_os = "linux",
target_os = "macos",
target_os = "windows",
target_os = "freebsd"
)),
target_os = "windows"
))]
use log::warn;
use log::{debug, info};
/// Privilege check result with detailed information
#[derive(Debug, Clone)]
pub struct PrivilegeStatus {
/// Whether sufficient privileges are available
pub has_privileges: bool,
/// Missing capabilities or permissions
pub missing: Vec<String>,
/// Platform-specific instructions to gain privileges
pub instructions: Vec<String>,
}
impl PrivilegeStatus {
/// Create a status indicating sufficient privileges
pub fn sufficient() -> Self {
Self {
has_privileges: true,
missing: Vec::new(),
instructions: Vec::new(),
}
}
/// Create a status indicating insufficient privileges
#[cfg(any(
target_os = "linux",
target_os = "macos",
target_os = "windows",
target_os = "freebsd",
test
))]
pub fn insufficient(missing: Vec<String>, instructions: Vec<String>) -> Self {
Self {
has_privileges: false,
missing,
instructions,
}
}
/// Get a human-readable error message
pub fn error_message(&self) -> String {
if self.has_privileges {
return String::new();
}
let mut msg = String::from("Insufficient privileges for network packet capture.\n\n");
if !self.missing.is_empty() {
msg.push_str("Missing:\n");
for item in &self.missing {
msg.push_str(&format!("{}\n", item));
}
msg.push('\n');
}
if !self.instructions.is_empty() {
msg.push_str("How to fix:\n");
for (i, instruction) in self.instructions.iter().enumerate() {
msg.push_str(&format!(" {}. {}\n", i + 1, instruction));
}
}
msg
}
}
/// Check if the current process has sufficient privileges for packet capture
pub fn check_packet_capture_privileges() -> Result<PrivilegeStatus> {
#[cfg(target_os = "linux")]
{
check_linux_privileges()
}
#[cfg(target_os = "macos")]
{
check_macos_privileges()
}
#[cfg(target_os = "windows")]
{
check_windows_privileges()
}
#[cfg(target_os = "freebsd")]
{
check_freebsd_privileges()
}
#[cfg(not(any(
target_os = "linux",
target_os = "macos",
target_os = "windows",
target_os = "freebsd"
)))]
{
// Unknown platform - return optimistic result
warn!("Privilege check not implemented for this platform");
Ok(PrivilegeStatus::sufficient())
}
}
#[cfg(target_os = "linux")]
fn check_linux_privileges() -> Result<PrivilegeStatus> {
use std::fs;
// Check if running as root by reading /proc/self/status
let is_root = is_root_user();
if is_root {
info!("Running as root - all privileges available");
return Ok(PrivilegeStatus::sufficient());
}
debug!("Not running as root, checking capabilities");
// Check for required capabilities via /proc/self/status
let status = fs::read_to_string("/proc/self/status")
.map_err(|e| anyhow!("Failed to read /proc/self/status: {}", e))?;
// Parse CapEff (effective capabilities) line
let cap_value = status
.lines()
.find(|line| line.starts_with("CapEff:"))
.and_then(|line| line.split_whitespace().nth(1))
.and_then(|cap_hex| u64::from_str_radix(cap_hex, 16).ok())
.ok_or_else(|| anyhow!("Failed to parse effective capabilities"))?;
debug!("Current effective capabilities: 0x{:x}", cap_value);
// Required capability for read-only packet capture (no promiscuous mode)
const CAP_NET_RAW: u64 = 13; // For packet capture
let mut missing = Vec::new();
// Check CAP_NET_RAW
if (cap_value & (1u64 << CAP_NET_RAW)) != 0 {
debug!("CAP_NET_RAW: present");
return Ok(PrivilegeStatus::sufficient());
} else {
debug!("CAP_NET_RAW: missing");
missing.push("CAP_NET_RAW capability (required for packet capture)".to_string());
}
// Build instructions for gaining privileges
let mut instructions = vec![
"Run with sudo: sudo rustnet".to_string(),
"Set capabilities (modern Linux 5.8+, with eBPF): sudo setcap 'cap_net_raw,cap_bpf,cap_perfmon+eip' $(which rustnet)".to_string(),
"Set capabilities (packet capture only, no eBPF): sudo setcap 'cap_net_raw+eip' $(which rustnet)".to_string(),
];
// Add Docker-specific instructions if it looks like we're in a container
if is_running_in_container() {
instructions.push(
"If running in Docker, add these flags:\n \
--cap-add=NET_RAW --cap-add=BPF --cap-add=PERFMON \
--net=host --pid=host"
.to_string(),
);
}
Ok(PrivilegeStatus::insufficient(missing, instructions))
}
/// Detect if running inside a container
#[cfg(target_os = "linux")]
fn is_running_in_container() -> bool {
use std::fs;
// Check for .dockerenv file
if fs::metadata("/.dockerenv").is_ok() {
return true;
}
// Check cgroup
if let Ok(cgroup) = fs::read_to_string("/proc/self/cgroup")
&& (cgroup.contains("docker") || cgroup.contains("kubepods") || cgroup.contains("lxc"))
{
return true;
}
false
}
#[cfg(target_os = "macos")]
fn check_macos_privileges() -> Result<PrivilegeStatus> {
use std::fs;
// Check if running as root by reading effective UID from process
let is_root = is_root_user();
if is_root {
info!("Running as root - all privileges available");
return Ok(PrivilegeStatus::sufficient());
}
debug!("Not running as root, checking BPF device permissions");
// On macOS, packet capture requires access to BPF devices
// Try to open a BPF device to check permissions
let bpf_devices = (0..10)
.map(|i| format!("/dev/bpf{}", i))
.collect::<Vec<_>>();
let mut can_access_bpf = false;
for bpf_device in &bpf_devices {
if fs::metadata(bpf_device).is_ok() {
debug!("Checking BPF device: {}", bpf_device);
// Try to actually open it (this is the real test)
if std::fs::OpenOptions::new()
.read(true)
.write(true)
.open(bpf_device)
.is_ok()
{
can_access_bpf = true;
debug!("Successfully opened BPF device: {}", bpf_device);
break;
}
}
}
if can_access_bpf {
return Ok(PrivilegeStatus::sufficient());
}
// No BPF access - build error message
let missing = vec!["Access to BPF devices (/dev/bpf*)".to_string()];
let instructions = vec![
"Run with sudo: sudo rustnet".to_string(),
"Change BPF device permissions (temporary):\n \
sudo chmod o+rw /dev/bpf*"
.to_string(),
"Install BPF permission helper (persistent):\n \
brew install wireshark && sudo /usr/local/bin/install-bpf"
.to_string(),
];
Ok(PrivilegeStatus::insufficient(missing, instructions))
}
#[cfg(target_os = "windows")]
fn check_windows_privileges() -> Result<PrivilegeStatus> {
use pcap::Device;
debug!("Checking Windows privileges by attempting to list network interfaces");
// Try to list network devices - this will fail if we don't have sufficient privileges
match Device::list() {
Ok(devices) => {
info!(
"Successfully listed {} network devices - privileges sufficient",
devices.len()
);
Ok(PrivilegeStatus::sufficient())
}
Err(e) => {
debug!("Failed to list network devices: {}", e);
// Check if the error indicates a permissions issue
let error_str = e.to_string().to_lowercase();
if error_str.contains("access")
|| error_str.contains("denied")
|| error_str.contains("permission")
{
let missing = vec!["Administrator privileges".to_string()];
let instructions = vec![
"Run as Administrator: Right-click the terminal and select 'Run as Administrator'".to_string(),
"If using Npcap: Ensure it was installed with 'WinPcap API-compatible Mode' enabled".to_string(),
];
Ok(PrivilegeStatus::insufficient(missing, instructions))
} else {
// Some other error - assume it's not a privilege issue
warn!(
"Network device enumeration failed but error doesn't indicate privilege issue: {}",
e
);
Ok(PrivilegeStatus::sufficient())
}
}
}
}
#[cfg(target_os = "freebsd")]
fn check_freebsd_privileges() -> Result<PrivilegeStatus> {
use std::fs;
// Check if running as root by reading effective UID from process
let is_root = is_root_user();
if is_root {
info!("Running as root - all privileges available");
return Ok(PrivilegeStatus::sufficient());
}
debug!("Not running as root, checking BPF device permissions");
// On FreeBSD, packet capture requires access to BPF devices
// Try to open a BPF device to check permissions
let bpf_devices = (0..10)
.map(|i| format!("/dev/bpf{}", i))
.collect::<Vec<_>>();
let mut can_access_bpf = false;
for bpf_device in &bpf_devices {
if fs::metadata(bpf_device).is_ok() {
debug!("Checking BPF device: {}", bpf_device);
// Try to actually open it (this is the real test)
if std::fs::OpenOptions::new()
.read(true)
.write(true)
.open(bpf_device)
.is_ok()
{
can_access_bpf = true;
debug!("Successfully opened BPF device: {}", bpf_device);
break;
}
}
}
if can_access_bpf {
return Ok(PrivilegeStatus::sufficient());
}
// No BPF access - build error message
let missing = vec!["Access to BPF devices (/dev/bpf*)".to_string()];
let instructions = vec![
"Run with sudo: sudo rustnet".to_string(),
"Add your user to the bpf group:\n \
sudo pw groupmod bpf -m $(whoami)\n \
Then logout and login again"
.to_string(),
"Change BPF device permissions (temporary):\n \
sudo chmod o+rw /dev/bpf*"
.to_string(),
];
Ok(PrivilegeStatus::insufficient(missing, instructions))
}
/// Check if running as root user on Unix systems
#[cfg(unix)]
fn is_root_user() -> bool {
effective_uid() == 0
}
/// Return the effective UID of the current process on Unix systems
#[cfg(unix)]
pub fn effective_uid() -> u32 {
unsafe { libc::geteuid() }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_privilege_status_error_message() {
let status = PrivilegeStatus::insufficient(
vec!["CAP_NET_RAW".to_string()],
vec!["Run with sudo".to_string()],
);
let msg = status.error_message();
assert!(msg.contains("Insufficient privileges"));
assert!(msg.contains("CAP_NET_RAW"));
assert!(msg.contains("Run with sudo"));
}
#[test]
fn test_sufficient_privileges() {
let status = PrivilegeStatus::sufficient();
assert!(status.has_privileges);
assert!(status.error_message().is_empty());
}
}
+159
View File
@@ -0,0 +1,159 @@
//! Shared input actions that mutate `UIState` and trigger side
//! effects on `App`. These live here (not on `UIState` directly)
//! because they touch both. Used by `OverviewTab::handle_key` for
//! the Overview-active case and by main.rs's fallback match for
//! the cross-tab case — keeping a single source of truth so both
//! callers stay in lockstep when the action's semantics evolve.
use std::time::Instant;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind};
use log::info;
use crate::app::App;
use crate::ui::{Effect, HandlerContext, PaneScroll, UIState};
/// Connection-list navigation + copy that's meaningful on both
/// Overview and Details. Navigation flips which connection has
/// focus (`selected_connection_key`); on Details that's also what
/// drives the displayed record, so keys like `j` / `k` let users
/// flip through connections without bouncing back to Overview.
/// Returns `None` for keys this helper doesn't claim so the caller
/// can fall through to its own match.
pub fn try_handle_connection_nav(
key: KeyEvent,
ctx: &mut HandlerContext<'_>,
) -> Option<Vec<Effect>> {
match (key.code, key.modifiers) {
(KeyCode::Up, _) | (KeyCode::Char('k'), _) => {
if ctx.ui_state.grouping_enabled
&& let Some(rows) = ctx.grouped_rows
{
ctx.ui_state.move_selection_up_grouped(rows);
} else {
ctx.ui_state.move_selection_up(ctx.connections);
}
Some(Vec::new())
}
(KeyCode::Down, _) | (KeyCode::Char('j'), _) => {
if ctx.ui_state.grouping_enabled
&& let Some(rows) = ctx.grouped_rows
{
ctx.ui_state.move_selection_down_grouped(rows);
} else {
ctx.ui_state.move_selection_down(ctx.connections);
}
Some(Vec::new())
}
(KeyCode::PageUp, _) | (KeyCode::Char('b'), KeyModifiers::CONTROL) => {
let page_size = ctx.ui_state.visible_rows.max(1);
if ctx.ui_state.grouping_enabled
&& let Some(rows) = ctx.grouped_rows
{
ctx.ui_state.move_selection_page_up_grouped(rows, page_size);
} else {
ctx.ui_state
.move_selection_page_up(ctx.connections, page_size);
}
Some(Vec::new())
}
(KeyCode::PageDown, _) | (KeyCode::Char('f'), KeyModifiers::CONTROL) => {
let page_size = ctx.ui_state.visible_rows.max(1);
if ctx.ui_state.grouping_enabled
&& let Some(rows) = ctx.grouped_rows
{
ctx.ui_state
.move_selection_page_down_grouped(rows, page_size);
} else {
ctx.ui_state
.move_selection_page_down(ctx.connections, page_size);
}
Some(Vec::new())
}
(KeyCode::Char('g'), KeyModifiers::NONE) => {
ctx.ui_state.move_selection_to_first(ctx.connections);
Some(Vec::new())
}
(KeyCode::Char('G'), _) | (KeyCode::Char('g'), KeyModifiers::SHIFT) => {
ctx.ui_state.move_selection_to_last(ctx.connections);
Some(Vec::new())
}
// Copy selected connection's remote address — works wherever
// there's a selection (Overview list focus or Details record).
(KeyCode::Char('c'), KeyModifiers::NONE) => {
if let Some(idx) = ctx.ui_state.get_selected_index(ctx.connections)
&& let Some(conn) = ctx.connections.get(idx)
{
let addr = conn.remote_addr.to_string();
Some(vec![Effect::Copy {
label: addr.clone(),
value: addr,
}])
} else {
Some(Vec::new())
}
}
_ => None,
}
}
/// Shared key handling for read-only scrollable panes (Help,
/// Interfaces): line, page, and top/bottom movement on the usual
/// vim-style keys. Claims only those keys; everything else falls
/// through to the caller's global handling.
pub fn try_handle_pane_scroll(
key: KeyEvent,
page_size: usize,
scroll: &mut PaneScroll,
) -> Option<Vec<Effect>> {
let page = page_size.max(1) as u16;
match (key.code, key.modifiers) {
(KeyCode::Up, _) | (KeyCode::Char('k'), _) => scroll.scroll_up(1),
(KeyCode::Down, _) | (KeyCode::Char('j'), _) => scroll.scroll_down(1),
(KeyCode::PageUp, _) | (KeyCode::Char('b'), KeyModifiers::CONTROL) => {
scroll.scroll_up(page)
}
(KeyCode::PageDown, _) | (KeyCode::Char('f'), KeyModifiers::CONTROL) => {
scroll.scroll_down(page)
}
(KeyCode::Char('g'), KeyModifiers::NONE) | (KeyCode::Home, _) => scroll.scroll_to_top(),
(KeyCode::Char('G'), _) | (KeyCode::Char('g'), KeyModifiers::SHIFT) | (KeyCode::End, _) => {
scroll.scroll_to_bottom()
}
_ => return None,
}
Some(Vec::new())
}
/// Shared wheel handling for scrollable panes (Details info panes,
/// Help, Interfaces).
pub fn try_handle_pane_wheel(mouse: MouseEvent, scroll: &mut PaneScroll) -> Option<Vec<Effect>> {
match mouse.kind {
MouseEventKind::ScrollUp => scroll.scroll_up(1),
MouseEventKind::ScrollDown => scroll.scroll_down(1),
_ => return None,
}
Some(Vec::new())
}
/// Handle the 'x' (clear all connections) key with two-press
/// confirmation. First press flips `clear_confirmation` on; the
/// second press (while it's on) actually clears.
///
/// Returns `true` when the clear happened — caller should treat
/// this as a data-refresh signal.
pub fn clear_all_with_confirmation(ui_state: &mut UIState, app: &App) -> bool {
if ui_state.clear_confirmation {
info!("User confirmed clear all connections");
app.clear_all_connections();
ui_state.clear_confirmation = false;
ui_state.show_historic = false;
ui_state.set_connection_key(None);
ui_state.clipboard_message = Some(("All connections cleared".to_string(), Instant::now()));
true
} else {
info!("User requested clear - showing confirmation");
ui_state.clear_confirmation = true;
false
}
}
+62
View File
@@ -0,0 +1,62 @@
//! Cross-platform clipboard helper that updates `UIState` with
//! user-visible feedback. Tries `arboard` first; on Linux/FreeBSD
//! falls back to `wl-copy` for Wayland environments where arboard
//! can't reach the clipboard daemon. Sandbox-aware on Linux —
//! reports a more useful error when Landlock has blocked the path.
use std::time::Instant;
use arboard::Clipboard;
use log::{error, info};
use crate::app::App;
use crate::ui::UIState;
/// Copy `text` to the system clipboard. On success, sets a
/// "Copied: …" banner in the status bar; on failure, sets an error
/// banner instead. `display_msg` is what's shown to the user
/// (typically "label: value"), while `text` is the literal payload.
pub fn copy_to_clipboard(text: &str, display_msg: &str, ui_state: &mut UIState, app: &App) {
// Used conditionally on Linux/FreeBSD for sandbox-aware error messages
let _ = app;
let result = Clipboard::new().and_then(|mut cb| cb.set_text(text));
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
let result = result.or_else(|_| {
std::process::Command::new("wl-copy")
.arg(text)
.status()
.map_err(|e| arboard::Error::Unknown {
description: e.to_string(),
})
.and_then(|s| {
if s.success() {
Ok(())
} else {
Err(arboard::Error::Unknown {
description: "wl-copy failed".to_string(),
})
}
})
});
match result {
Ok(()) => {
info!("Copied to clipboard: {}", display_msg);
ui_state.clipboard_message = Some((format!("Copied: {}", display_msg), Instant::now()));
}
Err(e) => {
#[cfg(target_os = "linux")]
let msg = if app.get_sandbox_info().fs_restricted {
"Clipboard unavailable (sandbox active). Use --no-sandbox to enable.".to_string()
} else {
format!("Clipboard error: {}", e)
};
#[cfg(not(target_os = "linux"))]
let msg = format!("Clipboard error: {}", e);
error!("{}", msg);
ui_state.clipboard_message = Some((msg, Instant::now()));
}
}
}
+97
View File
@@ -0,0 +1,97 @@
//! Per-tab Component pattern adapted for rustnet's synchronous,
//! crossbeam-threaded UI loop.
//!
//! Differences from ratatui's official component template:
//! - No `tokio::sync::mpsc::UnboundedSender<Action>` — the loop is
//! synchronous; components return `Vec<Effect>` from event
//! handlers instead of pushing through a channel.
//! - No `register_action_handler` / `register_config_handler` /
//! `init` — shared state (`App`, `UIState`) is passed through
//! context structs on each call.
use anyhow::Result;
use crossterm::event::{KeyEvent, MouseEvent};
use ratatui::{Frame, layout::Rect};
use crate::app::{App, AppStats};
use crate::network::types::Connection;
use crate::ui::{ClickableRegions, GroupedRow, UIState};
/// Read-only bundle passed to every component's `draw`. Lifetime
/// matches the borrow scope inside the main loop's `terminal.draw`
/// closure.
pub struct DrawContext<'a> {
pub app: &'a App,
pub connections: &'a [Connection],
pub ui_state: &'a UIState,
pub grouped_rows: Option<&'a [GroupedRow<'a>]>,
pub stats: &'a AppStats,
}
/// Mutable bundle for event handlers. The component owns the
/// mutation of `ui_state`; cross-cutting work (refresh, clipboard)
/// goes back via the returned `Vec<Effect>`. `click_regions` is
/// read-only and lets `handle_mouse` consult the current frame's
/// hit-test table (e.g. scroll-area bounds).
pub struct HandlerContext<'a> {
pub app: &'a App,
pub ui_state: &'a mut UIState,
pub connections: &'a [Connection],
pub grouped_rows: Option<&'a [GroupedRow<'a>]>,
pub click_regions: &'a ClickableRegions,
}
/// Cross-cutting effects a component can request from the main
/// loop. Anything the component can't or shouldn't apply directly
/// (data refresh flag, clipboard write, quit) gets enumerated here
/// so `apply_effects` is the single place that touches the loop.
#[derive(Debug, Clone)]
pub enum Effect {
/// Connection data needs to be re-pulled from the snapshot
/// provider before the next render.
RefreshData,
/// Grouped rows need to be rebuilt from the existing connection
/// list (cheaper than full RefreshData when only expand/collapse
/// changed).
Regroup,
/// Copy `value` to the system clipboard. `label` is the
/// human-readable name shown in the status-bar banner.
Copy { label: String, value: String },
}
/// Implemented by every tab. `draw` must be cheap (called every
/// render tick). `handle_key` translates raw keystrokes into
/// `Effect`s; UIState mutations happen in-place through the
/// handler context.
///
/// `handle_key` returns:
/// - `None` — the component did not claim this key; the loop falls
/// through to its global / fallback handling.
/// - `Some(vec)` — the component handled the key (vec may be empty
/// if no cross-cutting effect was needed). The loop skips its
/// fallback match.
pub trait Component {
fn draw(
&mut self,
f: &mut Frame,
area: Rect,
ctx: &DrawContext<'_>,
click_regions: &mut ClickableRegions,
) -> Result<()>;
fn handle_key(&mut self, _key: KeyEvent, _ctx: &mut HandlerContext<'_>) -> Option<Vec<Effect>> {
None
}
/// Same Some/None contract as `handle_key`. Click events are
/// dispatched through the global `ClickableRegions` hit-test
/// in main.rs; tabs use this hook for raw-position needs
/// (typically scroll-wheel handling).
fn handle_mouse(
&mut self,
_mouse: MouseEvent,
_ctx: &mut HandlerContext<'_>,
) -> Option<Vec<Effect>> {
None
}
}
+704
View File
@@ -0,0 +1,704 @@
//! Shared column model for the connection tables: one source of truth
//! for column order, headers, widths, responsive visibility, and row
//! construction. Used by the flat Overview list, the grouped view, and
//! the Details continuity strip so all three render the same grid.
//!
//! Column order puts identifying info (process, addresses) on the left
//! and status info (state, bandwidth) on the right.
//!
//! Widths are a pure function of the available table width — never of
//! row content — so the layout is stable while scrolling and only
//! changes when the terminal is resized (or the sidebar is toggled).
//! When the table is too narrow, whole columns are hidden in a fixed
//! priority order; when there is width to spare, it is distributed to
//! the flexible columns by weight so the grid spans the full width and
//! the Bandwidth column sits flush against the right edge. Cell-level
//! ellipsis only happens as a last resort at very narrow widths, after
//! column hiding has already done its job.
use std::borrow::Cow;
use ratatui::layout::Constraint;
use ratatui::style::{Color, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Cell, Row};
use crate::network::dns::DnsResolver;
use crate::network::types::{Connection, Protocol};
use crate::ui::{
NONE_PLACEHOLDER, SortColumn, UIState, dpi_color, format::format_rate_compact, state_color,
theme,
};
// --- Column floors (cells). Flexible columns grow beyond their floor
// --- when surplus width is distributed; fixed columns never do.
const PROCESS_WIDTH: u16 = 22;
/// Floor for the Local column; "192.168.1.10:51234" fits in 18.
const LOCAL_MIN_WIDTH: u16 = 18;
const LOCATION_WIDTH: u16 = 4;
const SERVICE_WIDTH: u16 = 10; // most IANA service names ("netbios-ns") fit
const APP_WIDTH_FULL: u16 = 24;
const APP_WIDTH_COMPACT: u16 = 14;
const STATE_WIDTH: u16 = 12; // longest TCP state: "ESTABLISHED" (11)
const BANDWIDTH_WIDTH: u16 = 11;
/// Floor for the Remote column; bare "ip:port" for IPv4 fits in 21.
const REMOTE_MIN_WIDTH: u16 = 21;
/// One of the connection-table columns. Headers use short labels and
/// single-cell glyphs (↓ ↑ ·) only — multi-width emoji are deliberately
/// avoided because double-width glyphs break ratatui column alignment
/// in many terminals.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(in crate::ui) enum ColumnId {
Process,
Remote,
Local,
Location,
Service,
/// Merged protocol + application column: renders "TCP·HTTPS (sni)"
/// (full), "TCP·HTTPS" (compact), or bare "TCP" when DPI has nothing.
Application,
State,
Bandwidth,
}
/// A column resolved for the current frame: its identity, the width it
/// was granted, and the sort key its header maps to.
#[derive(Debug, Clone, Copy)]
pub(in crate::ui) struct Column {
pub id: ColumnId,
pub width: u16,
pub sort: Option<SortColumn>,
}
impl Column {
fn new(id: ColumnId, width: u16) -> Self {
let sort = match id {
ColumnId::Process => Some(SortColumn::Process),
ColumnId::Remote => Some(SortColumn::RemoteAddress),
ColumnId::Local => Some(SortColumn::LocalAddress),
ColumnId::Location => Some(SortColumn::Location),
ColumnId::Service => Some(SortColumn::Service),
ColumnId::Application => Some(SortColumn::Application),
ColumnId::State => Some(SortColumn::State),
ColumnId::Bandwidth => Some(SortColumn::BandwidthTotal),
};
Self { id, width, sort }
}
}
/// Fixed chrome the table adds around the column widths: the row
/// highlight symbol "> " (2) plus the inter-column spacing.
fn table_chrome(column_count: usize) -> u16 {
let spacing = column_count.saturating_sub(1) as u16; // default column_spacing(1)
2 + spacing
}
/// Pick the visible column set for `available_width` (the table area's
/// width, borders excluded). A pure function of the width — row content
/// never affects the layout, so columns stay put while scrolling.
///
/// Too narrow: whole columns are hidden in a fixed degradation order
/// (Location → Service → Local → Application shrinks to compact →
/// State) rather than truncating cells. The floor is Process · Remote ·
/// App · Bandwidth; below that ratatui clips columns from the right.
///
/// Width to spare: the surplus is distributed to the flexible columns
/// proportionally to their weight (Remote 4 · App 3 · Process 2 ·
/// Local 1), so the grid spans the full width — Bandwidth lands flush
/// against the right edge and the spare space reads as even breathing
/// room between columns instead of one big gap.
pub(in crate::ui) fn select_columns(available_width: u16, has_location: bool) -> Vec<Column> {
let mut columns = vec![
Column::new(ColumnId::Process, PROCESS_WIDTH),
Column::new(ColumnId::Remote, REMOTE_MIN_WIDTH),
Column::new(ColumnId::Local, LOCAL_MIN_WIDTH),
];
if has_location {
columns.push(Column::new(ColumnId::Location, LOCATION_WIDTH));
}
columns.extend([
Column::new(ColumnId::Service, SERVICE_WIDTH),
Column::new(ColumnId::Application, APP_WIDTH_FULL),
Column::new(ColumnId::State, STATE_WIDTH),
Column::new(ColumnId::Bandwidth, BANDWIDTH_WIDTH),
]);
let used = |cols: &[Column]| -> u16 {
cols.iter().map(|c| c.width).sum::<u16>() + table_chrome(cols.len())
};
let fits = |cols: &[Column]| used(cols) <= available_width;
for id in [ColumnId::Location, ColumnId::Service, ColumnId::Local] {
if !fits(&columns) {
columns.retain(|c| c.id != id);
}
}
if !fits(&columns) {
for c in columns.iter_mut() {
if c.id == ColumnId::Application {
c.width = APP_WIDTH_COMPACT;
}
}
}
if !fits(&columns) {
columns.retain(|c| c.id != ColumnId::State);
}
// Distribute the surplus by weight. A compacted Application column
// stays compact (re-growing it would undo the degradation step).
let weight = |c: &Column| -> u32 {
match c.id {
ColumnId::Remote => 4,
ColumnId::Application if c.width >= APP_WIDTH_FULL => 3,
ColumnId::Process => 2,
ColumnId::Local => 1,
_ => 0,
}
};
let surplus = available_width.saturating_sub(used(&columns)) as u32;
let total: u32 = columns.iter().map(weight).sum();
if surplus > 0 && total > 0 {
let mut handed = 0;
for c in columns.iter_mut() {
let grant = surplus * weight(c) / total;
c.width += grant as u16;
handed += grant;
}
// Integer-division remainder goes to Remote (always visible) so
// the columns sum to the full width exactly.
if let Some(c) = columns.iter_mut().find(|c| c.id == ColumnId::Remote) {
c.width += (surplus - handed) as u16;
}
}
columns
}
/// Map resolved columns to ratatui layout constraints. Every column is
/// `Length` — the widths already account for the full table width via
/// the weighted distribution in [`select_columns`].
pub(in crate::ui) fn column_constraints(columns: &[Column]) -> Vec<Constraint> {
columns
.iter()
.map(|c| Constraint::Length(c.width))
.collect()
}
/// Untruncated Process cell text: "name (pid)".
fn process_text(conn: &Connection) -> String {
// Borrow the name; `format!` in the Some-pid arm (the common case)
// allocates its own String, so cloning out of the Option first just
// throws away a heap allocation per row per frame. Only the None-pid
// arm needs to materialize an owned String.
let name = conn.process_name.as_deref().unwrap_or(NONE_PLACEHOLDER);
let text = match conn.pid {
Some(pid) => format!("{name} ({pid})"),
None => name.to_string(),
};
// Kubernetes attribution: when the resolver mapped this connection to
// a pod, prefix the cell with "namespace/pod" so the owning workload
// is visible at a glance. The process name/PID follow it.
#[cfg(feature = "kubernetes")]
if let Some(pod) = conn.k8s_info.as_ref().and_then(|k| k.pod_name.as_deref()) {
return match conn
.k8s_info
.as_ref()
.and_then(|k| k.pod_namespace.as_deref())
{
Some(ns) => format!("{ns}/{pod} {text}"),
None => format!("{pod} {text}"),
};
}
text
}
/// Untruncated Service cell text: service name or port number.
fn service_text<'a>(conn: &'a Connection, ui_state: &UIState) -> Cow<'a, str> {
if ui_state.show_port_numbers {
Cow::Owned(conn.remote_addr.port().to_string())
} else {
match conn.service_name.as_deref() {
Some(name) => Cow::Borrowed(name),
None => Cow::Borrowed(NONE_PLACEHOLDER),
}
}
}
/// Remote address (or resolved hostname) with port, fitted to
/// `max_width` cells. Hostnames keep their port visible when cut
/// ("host…:443"); raw addresses only ellipsize as a last resort at
/// very narrow widths.
fn remote_display(
conn: &Connection,
ui_state: &UIState,
dns_resolver: Option<&DnsResolver>,
max_width: usize,
) -> String {
if ui_state.show_hostnames
&& conn.protocol != Protocol::Arp
&& let Some(resolver) = dns_resolver
&& let Some(hostname) = resolver.get_hostname(&conn.remote_addr.ip())
{
let port = conn.remote_addr.port();
let full = format!("{hostname}:{port}");
if full.chars().count() > max_width {
let port_str = format!(":{port}");
let budget = max_width.saturating_sub(port_str.chars().count());
format!("{}{}", truncate_with_ellipsis(&hostname, budget), port_str)
} else {
full
}
} else {
truncate_with_ellipsis(&conn.remote_addr.to_string(), max_width)
}
}
/// Char-safe truncation to `max_chars` cells, ending in "…" when cut.
fn truncate_with_ellipsis(s: &str, max_chars: usize) -> String {
if s.chars().count() <= max_chars {
return s.to_string();
}
let keep = max_chars.saturating_sub(1);
let mut out: String = s.chars().take(keep).collect();
out.push('…');
out
}
/// Header label for a column. Short on purpose — no " Address" suffixes.
fn header_label(id: ColumnId, ui_state: &UIState) -> &'static str {
match id {
ColumnId::Process => "Process",
ColumnId::Remote => "Remote",
ColumnId::Local => "Local",
ColumnId::Location => "Loc",
ColumnId::Service => {
if ui_state.show_port_numbers {
"Port"
} else {
"Service"
}
}
ColumnId::Application => "App",
ColumnId::State => "State",
ColumnId::Bandwidth => "", // built as spans in build_header
}
}
/// Build the shared header row. The active sort column is bold,
/// underlined, and accent-colored with an ↑/↓ arrow appended; the
/// Bandwidth header carries the rx/tx arrows ("Rx↓/Tx↑") so the data
/// rows don't have to repeat them on every line.
pub(in crate::ui) fn build_header<'a>(columns: &[Column], ui_state: &UIState) -> Row<'a> {
let sorting = ui_state.sort_column != SortColumn::CreatedAt;
let sort_arrow = if ui_state.sort_ascending {
""
} else {
""
};
let cells = columns.iter().map(|col| {
let active = sorting && col.sort == Some(ui_state.sort_column);
let style = if active {
theme::bold_underline_fg(theme::accent())
} else {
theme::fg(theme::heading())
};
if col.id == ColumnId::Bandwidth {
let line = if active {
Line::from(Span::styled(format!("Rx↓/Tx↑ {sort_arrow}"), style))
} else {
Line::from(vec![
Span::styled("Rx", style),
Span::styled("", theme::fg(theme::rx())),
Span::styled("/Tx", style),
Span::styled("", theme::fg(theme::tx())),
])
};
return Cell::from(line.right_aligned());
}
let label = header_label(col.id, ui_state);
let text = if active {
format!("{label} {sort_arrow}")
} else {
label.to_string()
};
Cell::from(text).style(style)
});
Row::new(cells).height(1).bottom_margin(1)
}
/// Row-level staleness styling shared by every connection row:
/// fresh rows keep per-cell colors; historic and aging/critical rows
/// drop cell colors so a whole-row override carries the signal
/// (gray for historic, yellow/red for aging).
fn staleness_style(conn: &Connection) -> (Option<Style>, bool) {
let staleness = conn.staleness_ratio();
if conn.is_historic {
(Some(theme::historic_row()), false)
} else if staleness >= 0.90 {
(Some(theme::fg(theme::err())), false)
} else if staleness >= 0.75 {
(Some(theme::fg(theme::warn())), false)
} else {
(None, true)
}
}
/// Build one connection row for the given visible `columns`.
///
/// `process_override` replaces the Process cell content (the grouped
/// view passes the tree connector + PID since the group header above
/// already names the process).
pub(in crate::ui) fn connection_row<'a>(
conn: &'a Connection,
columns: &[Column],
ui_state: &UIState,
dns_resolver: Option<&DnsResolver>,
process_override: Option<Line<'a>>,
) -> Row<'a> {
let (row_override, color_cells) = staleness_style(conn);
let style_if_colored = |c: Color| {
if color_cells {
theme::fg(c)
} else {
Style::default()
}
};
let mut process_override = process_override;
let cells: Vec<Cell<'a>> = columns
.iter()
.map(|col| match col.id {
ColumnId::Process => {
if let Some(line) = process_override.take() {
return Cell::from(line);
}
let full = process_text(conn);
Cell::from(truncate_with_ellipsis(&full, col.width as usize))
.style(style_if_colored(theme::field_process()))
}
ColumnId::Remote => Cell::from(remote_display(
conn,
ui_state,
dns_resolver,
col.width as usize,
))
.style(style_if_colored(theme::field_remote_addr())),
ColumnId::Local => Cell::from(truncate_with_ellipsis(
&conn.local_addr.to_string(),
col.width as usize,
))
.style(style_if_colored(theme::field_local_addr())),
ColumnId::Location => {
let location = conn
.geoip_info
.as_ref()
.map(|g| g.country_display())
.unwrap_or(NONE_PLACEHOLDER);
Cell::from(location).style(style_if_colored(theme::field_location()))
}
ColumnId::Service => {
let service =
truncate_with_ellipsis(&service_text(conn, ui_state), col.width as usize);
Cell::from(service).style(style_if_colored(theme::field_service()))
}
ColumnId::Application => application_cell(conn, col.width, color_cells),
ColumnId::State => {
// Historic connections show "closed" instead of their last
// TCP state — together with the DIM row style this is the
// NO_COLOR-safe replacement for the old hollow status dot.
if conn.is_historic {
Cell::from("closed").style(style_if_colored(theme::tcp_closed()))
} else {
// Most states fit the fixed width; the odd long one
// (e.g. "ECHO_REP(12345)") ellipsizes instead of
// hard-clipping.
let state = truncate_with_ellipsis(&conn.state(), col.width as usize);
Cell::from(state).style(style_if_colored(state_color(conn)))
}
}
ColumnId::Bandwidth => bandwidth_cell(
conn.current_incoming_rate_bps,
conn.current_outgoing_rate_bps,
color_cells,
),
})
.collect();
let row = Row::new(cells);
match row_override {
Some(style) => row.style(style),
None => row,
}
}
/// Merged protocol + application cell: "TCP·HTTPS (sni)" at full width,
/// "TCP·HTTPS" compact, bare "TCP" without DPI info. The protocol half
/// is muted so the detected application reads as the content.
fn application_cell<'a>(conn: &Connection, width: u16, color_cells: bool) -> Cell<'a> {
let proto = conn.protocol.as_str();
let Some(dpi) = conn.dpi_info.as_ref() else {
let style = if color_cells {
theme::fg(theme::muted())
} else {
Style::default()
};
return Cell::from(proto).style(style);
};
let budget = (width as usize).saturating_sub(proto.chars().count() + 1);
let app = if width >= APP_WIDTH_FULL {
truncate_with_ellipsis(&dpi.application.to_string(), budget)
} else {
truncate_with_ellipsis(dpi.application.sort_key(), budget)
};
if color_cells {
Cell::from(Line::from(vec![
Span::styled(format!("{proto}·"), theme::fg(theme::muted())),
Span::styled(app, theme::fg(dpi_color(&dpi.application))),
]))
} else {
Cell::from(format!("{proto}·{app}"))
}
}
/// Bandwidth cell: "{rx}/{tx}" right-aligned, rx/tx halves colored when
/// there's live traffic, whole cell muted when idle (muted preset). The
/// ↓/↑ arrows live in the column header, not on every row. Takes raw
/// rates so the grouped view can feed per-group aggregates through the
/// same formatting.
pub(in crate::ui) fn bandwidth_cell<'a>(rx_bps: f64, tx_bps: f64, color_cells: bool) -> Cell<'a> {
let rx = format_rate_compact(rx_bps);
let tx = format_rate_compact(tx_bps);
let active = rx_bps > 0.0 || tx_bps > 0.0;
let line = if !color_cells {
Line::from(format!("{rx}/{tx}"))
} else if !active && !theme::is_classic() {
Line::from(Span::styled(
format!("{rx}/{tx}"),
theme::fg(theme::muted()),
))
} else {
Line::from(vec![
Span::styled(rx, theme::fg(theme::rx())),
Span::raw("/"),
Span::styled(tx, theme::fg(theme::tx())),
])
};
Cell::from(line.right_aligned())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::network::types::{Connection, Protocol, ProtocolState, TcpState};
use std::net::{IpAddr, Ipv6Addr, SocketAddr};
fn ids(columns: &[Column]) -> Vec<ColumnId> {
columns.iter().map(|c| c.id).collect()
}
fn width_of(columns: &[Column], id: ColumnId) -> u16 {
columns.iter().find(|c| c.id == id).expect("column").width
}
fn used(columns: &[Column]) -> u16 {
columns.iter().map(|c| c.width).sum::<u16>() + table_chrome(columns.len())
}
// Width math for the full set with Location at floor widths:
// 22+21+18+4+10+24+12+11 = 122 content + chrome(8 cols) = 9 -> 131.
const FULL_WIDTH: u16 = 131;
#[test]
fn select_columns_shows_everything_when_wide() {
let cols = select_columns(FULL_WIDTH, true);
assert_eq!(
ids(&cols),
vec![
ColumnId::Process,
ColumnId::Remote,
ColumnId::Local,
ColumnId::Location,
ColumnId::Service,
ColumnId::Application,
ColumnId::State,
ColumnId::Bandwidth,
]
);
assert_eq!(width_of(&cols, ColumnId::Application), APP_WIDTH_FULL);
}
#[test]
fn select_columns_degrades_in_priority_order() {
// One cell short of the full set -> Location goes first.
let cols = select_columns(FULL_WIDTH - 1, true);
assert!(!ids(&cols).contains(&ColumnId::Location));
assert!(ids(&cols).contains(&ColumnId::Service));
// 22+21+18+10+24+12+11 = 118 + chrome(7) = 126 -> below that Service goes.
let cols = select_columns(125, true);
assert!(!ids(&cols).contains(&ColumnId::Service));
assert!(ids(&cols).contains(&ColumnId::Local));
// 22+21+18+24+12+11 = 108 + chrome(6) = 115 -> below that Local goes.
let cols = select_columns(115, true);
assert!(ids(&cols).contains(&ColumnId::Local));
assert_eq!(width_of(&cols, ColumnId::Application), APP_WIDTH_FULL);
let cols = select_columns(114, true);
assert!(!ids(&cols).contains(&ColumnId::Local));
// 22+21+24+12+11 = 90 + chrome(5) = 96 -> below that App compacts.
let cols = select_columns(95, true);
assert_eq!(width_of(&cols, ColumnId::Application), APP_WIDTH_COMPACT);
assert!(ids(&cols).contains(&ColumnId::State));
// 22+21+14+12+11 = 80 + chrome(5) = 86 -> below that State goes.
let cols = select_columns(85, true);
assert_eq!(
ids(&cols),
vec![
ColumnId::Process,
ColumnId::Remote,
ColumnId::Application,
ColumnId::Bandwidth,
]
);
// The floor never shrinks further, even at absurd widths.
let cols = select_columns(10, true);
assert_eq!(ids(&cols).len(), 4);
}
#[test]
fn select_columns_without_location_never_contains_it() {
let cols = select_columns(FULL_WIDTH, false);
assert!(!ids(&cols).contains(&ColumnId::Location));
}
#[test]
fn surplus_is_distributed_by_weight_and_spans_the_full_width() {
// 100 spare cells split 4:3:2:1 across Remote/App/Process/Local.
let width = FULL_WIDTH + 100;
let cols = select_columns(width, true);
assert_eq!(width_of(&cols, ColumnId::Remote), REMOTE_MIN_WIDTH + 40);
assert_eq!(width_of(&cols, ColumnId::Application), APP_WIDTH_FULL + 30);
assert_eq!(width_of(&cols, ColumnId::Process), PROCESS_WIDTH + 20);
assert_eq!(width_of(&cols, ColumnId::Local), LOCAL_MIN_WIDTH + 10);
// Fixed columns never grow.
assert_eq!(width_of(&cols, ColumnId::State), STATE_WIDTH);
assert_eq!(width_of(&cols, ColumnId::Bandwidth), BANDWIDTH_WIDTH);
// The grid spans the full width exactly, so the Bandwidth
// column sits flush against the right edge.
assert_eq!(used(&cols), width);
// Division remainders land on Remote so spanning stays exact.
// 103 spare: grants are 41/30/20/10 (101 handed), remainder 2.
let width = FULL_WIDTH + 103;
let cols = select_columns(width, true);
assert_eq!(used(&cols), width);
assert_eq!(width_of(&cols, ColumnId::Remote), REMOTE_MIN_WIDTH + 41 + 2);
}
#[test]
fn widths_depend_only_on_available_width() {
for width in [60u16, 96, 131, 200, 320] {
assert_eq!(
ids(&select_columns(width, true)),
ids(&select_columns(width, true))
);
let a: Vec<u16> = select_columns(width, true)
.iter()
.map(|c| c.width)
.collect();
let b: Vec<u16> = select_columns(width, true)
.iter()
.map(|c| c.width)
.collect();
assert_eq!(a, b);
}
}
#[test]
fn remote_display_keeps_raw_addresses_until_width_forces_ellipsis() {
let remote = SocketAddr::new(
IpAddr::V6(Ipv6Addr::new(
0x2001, 0x0db8, 0x85a3, 0x1111, 0x2222, 0x8a2e, 0x0370, 0x7334,
)),
65535,
);
let local = "[::1]:8080".parse().unwrap();
let conn = Connection::new(
Protocol::Tcp,
local,
remote,
ProtocolState::Tcp(TcpState::Established),
);
let ui_state = UIState::default();
let full = conn.remote_addr.to_string();
let full_len = full.chars().count();
// Enough width: the raw address is shown verbatim.
assert_eq!(remote_display(&conn, &ui_state, None, full_len), full);
// On a wide terminal the weighted Remote share covers a full
// IPv6 address (40 spare cells at FULL_WIDTH+100 -> width 61).
let cols = select_columns(FULL_WIDTH + 100, true);
assert!(width_of(&cols, ColumnId::Remote) as usize >= full_len);
// Last resort at narrow widths: ellipsized, never wider than asked.
let narrow = remote_display(&conn, &ui_state, None, REMOTE_MIN_WIDTH as usize);
assert_eq!(narrow.chars().count(), REMOTE_MIN_WIDTH as usize);
assert!(narrow.ends_with('\u{2026}'));
}
#[test]
fn truncate_with_ellipsis_is_char_safe() {
assert_eq!(truncate_with_ellipsis("short", 10), "short");
assert_eq!(truncate_with_ellipsis("exactly-10", 10), "exactly-10");
assert_eq!(
truncate_with_ellipsis("0123456789ab", 10),
"012345678\u{2026}"
);
// Multi-byte chars must not split.
assert_eq!(
truncate_with_ellipsis("h\u{e9}ll\u{f6} w\u{f6}rld!", 6),
"h\u{e9}ll\u{f6}\u{2026}"
);
}
#[test]
fn process_text_formats_name_pid_and_placeholder() {
let mut conn = Connection::new(
Protocol::Tcp,
"[::1]:8080".parse().unwrap(),
"[::1]:443".parse().unwrap(),
ProtocolState::Tcp(TcpState::Established),
);
// name + pid -> "name (pid)"
conn.process_name = Some("firefox".to_string());
conn.pid = Some(1234);
assert_eq!(process_text(&conn), "firefox (1234)");
// name, no pid -> bare name
conn.pid = None;
assert_eq!(process_text(&conn), "firefox");
// no name -> placeholder (bare when pid absent)
conn.process_name = None;
assert_eq!(process_text(&conn), NONE_PLACEHOLDER);
// no name, with pid -> "placeholder (pid)"
conn.pid = Some(42);
assert_eq!(process_text(&conn), format!("{NONE_PLACEHOLDER} (42)"));
}
}
+33
View File
@@ -0,0 +1,33 @@
//! Effect application — the one place the main loop reaches into
//! `Effect`s produced by `Component::handle_key` / `handle_mouse`
//! and translates them into the loop's local state mutations
//! (`needs_data_refresh`, `needs_regroup`) plus side effects
//! (clipboard write).
use crate::app::App;
use crate::ui::{Effect, UIState, copy_to_clipboard};
/// Result of applying a batch of effects. The loop folds this into
/// its own bookkeeping (refresh flags).
#[derive(Default, Debug, Clone, Copy)]
pub struct EffectOutcome {
pub needs_data_refresh: bool,
pub needs_regroup: bool,
}
/// Apply a vector of effects and return what the caller needs to
/// know. Clipboard writes happen inline; UIState mutations for
/// the clipboard banner land through `copy_to_clipboard`.
pub fn apply_effects(effects: Vec<Effect>, ui_state: &mut UIState, app: &App) -> EffectOutcome {
let mut out = EffectOutcome::default();
for effect in effects {
match effect {
Effect::RefreshData => out.needs_data_refresh = true,
Effect::Regroup => out.needs_regroup = true,
Effect::Copy { label, value } => {
copy_to_clipboard(&value, &label, ui_state, app);
}
}
}
out
}
+59
View File
@@ -0,0 +1,59 @@
//! Human-readable formatters for byte counts and per-second rates,
//! shared across the connection list, stats panel, interface table,
//! and graph tab. Returns the parent module's `NONE_PLACEHOLDER`
//! ("-") for zero/absent rates so the UI reads consistently.
/// Format rate to human readable form
pub(super) fn format_rate(bytes_per_second: f64) -> String {
const KB_PER_SEC: f64 = 1024.0;
const MB_PER_SEC: f64 = KB_PER_SEC * 1024.0;
const GB_PER_SEC: f64 = MB_PER_SEC * 1024.0;
if bytes_per_second >= GB_PER_SEC {
format!("{:.2} GB/s", bytes_per_second / GB_PER_SEC)
} else if bytes_per_second >= MB_PER_SEC {
format!("{:.2} MB/s", bytes_per_second / MB_PER_SEC)
} else if bytes_per_second >= KB_PER_SEC {
format!("{:.2} KB/s", bytes_per_second / KB_PER_SEC)
} else if bytes_per_second > 0.0 {
format!("{:.0} B/s", bytes_per_second)
} else {
super::NONE_PLACEHOLDER.to_string()
}
}
/// Format rate to compact form for tight spaces
pub(super) fn format_rate_compact(bytes_per_second: f64) -> String {
const KB_PER_SEC: f64 = 1024.0;
const MB_PER_SEC: f64 = KB_PER_SEC * 1024.0;
const GB_PER_SEC: f64 = MB_PER_SEC * 1024.0;
if bytes_per_second >= GB_PER_SEC {
format!("{:.1}G", bytes_per_second / GB_PER_SEC)
} else if bytes_per_second >= MB_PER_SEC {
format!("{:.1}M", bytes_per_second / MB_PER_SEC)
} else if bytes_per_second >= KB_PER_SEC {
format!("{:.0}K", bytes_per_second / KB_PER_SEC)
} else if bytes_per_second > 0.0 {
format!("{:.0}B", bytes_per_second)
} else {
super::NONE_PLACEHOLDER.to_string()
}
}
/// Format bytes to human readable form
pub(super) fn format_bytes(bytes: u64) -> String {
const KB: u64 = 1024;
const MB: u64 = KB * 1024;
const GB: u64 = MB * 1024;
if bytes >= GB {
format!("{:.2} GB", bytes as f64 / GB as f64)
} else if bytes >= MB {
format!("{:.2} MB", bytes as f64 / MB as f64)
} else if bytes >= KB {
format!("{:.2} KB", bytes as f64 / KB as f64)
} else {
format!("{} B", bytes)
}
}
+1146
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,44 @@
---
source: src/ui/mod.rs
expression: output
---
rustnet 1 Overview 2 Details 3 Interfaces 4 Graph 5 Help
─────────────────────────━━━━━━━━━──────────────────────────────────────────────────────────────────────────────────────────────────────────
Process Remote Local Service App State Rx↓/Tx↑
> firefox (2001) 140.82.121.4:443 192.168.1.10:51234 https TCP ESTABLISHED -/-
systemd-resolved (820) 1.1.1.1:53 192.168.1.10:53 dns UDP UDP_ACTIVE -/-
sshd (1500) 10.0.0.5:51022 192.168.1.10:22 ssh TCP ESTABLISHED -/-
▎ firefox → 140.82.121.4:443 · click a field to copy
Connection Application
Protocol TCP Detected -
Status Active (last seen <T> ago)
Local Address 192.168.1.10:51234
Remote Address 140.82.121.4:443
Scope PUBLIC
State ESTABLISHED
Process firefox
PID 2001
Service https
Network Context Transport Health
Local Hostname - Initial RTT -
Remote Hostname - TCP Retransmits 0
Country - Out-of-Order Packets 0
City - Duplicate ACKs 0
ASN - Fast Retransmits 0
Window Size 0
▎ Traffic Statistics
↓ RX - → peak - ↑ TX - → peak -
Total 234.38 KB · 234 packets Total 12.21 KB · 12 packets
'h' help | 1-5 jump | Tab/[/] cycle | j/k prev/next | Ctrl-d/u scroll | 'c' copy remote addr | Esc back
@@ -0,0 +1,6 @@
---
source: src/ui/mod.rs
assertion_line: 652
expression: output
---
/ | ↑↓ navigate · Enter confirm · Esc cancel
@@ -0,0 +1,6 @@
---
source: src/ui/mod.rs
assertion_line: 664
expression: output
---
/ port:443| ↑↓ navigate · Enter confirm · Esc cancel
@@ -0,0 +1,6 @@
---
source: src/ui/mod.rs
assertion_line: 676
expression: output
---
/ tcp port:443 filter active · Esc clears
@@ -0,0 +1,45 @@
---
source: src/ui/mod.rs
assertion_line: 1002
expression: output
---
rustnet 1 Overview 2 Details 3 Interfaces 4 Graph 5 Help
────────────────────────────────────────────────────━━━━━━━─────────────────────────────────────────────────────────────────────────────────
▎ Traffic Over Time (60s) ▎ Connections
Collecting data... Collecting...
▎ Network Health ▎ TCP Counters ▎ TCP States
Collecting data... Retransmits 0 ESTAB █████████████████████████ 2
Out of Order 0 TIME_WAIT ████████████ 1
Fast Retrans 0
▎ Application Distribution ▎ Top Processes
Other ██████████████████████████████████████████████████████ 100.0% No active processes
'h' help | 1-5 jump | Tab/[/] cycle | Esc back to Overview
@@ -0,0 +1,54 @@
---
source: src/ui/mod.rs
expression: output
---
╭Help · ↑/↓ scroll─────────────────────────────────────────────────────────────────────────────────╮
│RustNet Monitor - Network Connection Monitor █│
│ █│
│q Quit application (press twice to confirm) █│
│Ctrl+C Quit immediately █│
│x Clear all connections (press twice to confirm) █│
│Tab, ] Next tab █│
│Shift+Tab, [ Previous tab █│
│1-5 Jump directly to a tab (1=Overview, 2=Details, 3=Interfaces, 4=Graph, 5=Help) █│
│↑/k, ↓/j Navigate connections (wraps around) █│
│g, G Jump to first/last connection (vim-style) █│
│Page Up/Down, Ctrl+B/F Navigate connections by page █│
│Ctrl+D/U Scroll the Details info panes █│
│c Copy remote address to clipboard █│
│p Toggle between service names and port numbers █│
│d Toggle between hostnames and IP addresses (when --resolve-dns) █│
│s Cycle through sort columns (Bandwidth, Process, etc.) █│
│S Toggle sort direction (ascending/descending) █│
│a Toggle process grouping (aggregate by process) █│
│Space Expand/collapse group (when grouping enabled) █│
│←/→ or h/l Collapse/expand group █│
│t Toggle display of historic (closed) connections █│
│i Toggle the System info sidebar █│
│r Reset view (grouping, sort, filter) █│
│Enter View connection details █│
│Esc Return to overview █│
│h Toggle this help screen █│
│/ Enter filter mode on Overview (use ↑/↓ to navigate while typing) █│
│ █│
│Tabs: █│
│Overview Connection list with mini traffic graph █│
│Details Full details for selected connection █│
│Interfaces Network interface statistics █│
│Graph Traffic charts and protocol distribution █│
│Help This help screen █│
│ █│
│Mouse Controls: █│
│Click tab Switch between tabs █│
│Click row Select connection █│
│Scroll wheel Navigate connection list / scroll Details, Interfaces, Help █│
│Double-click row Open connection details █│
│Double-click group Expand/collapse process group ║│
│Click field (Details) Copy field value to clipboard ║│
│ ║│
│Connection Colors: ║│
│White Active connection (< 75% of timeout) ║│
│Yellow Stale connection (75-90% of timeout) ║│
│Red Critical - will be removed soon (> 90% of timeout) ║│
│ ║│
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
@@ -0,0 +1,35 @@
---
source: src/ui/mod.rs
assertion_line: 969
expression: output
---
rustnet 1 Overview 2 Details 3 Interfaces 4 Graph 5 Help
─────────────────────────────────────━━━━━━━━━━━━───────────────────────────────────────────────────────────────────────────────────────────
▎ Interface Statistics
Interface RX Rate TX Rate RX Packets TX Packets RX Err TX Err RX Drop TX Drop Collisions
eth0 512.00 KB/s 128.00 KB/s 1200000 800000 0 0 12 0 0
'h' help | 1-5 jump | Tab/[/] cycle | j/k scroll | Esc back to Overview
@@ -0,0 +1,22 @@
---
source: src/ui/mod.rs
assertion_line: 650
expression: output
---
╭RustNet Monitor───────────────────────────────────────────────────────────────╮
│ │
│ ⣾ Loading network connections... │
│ │
│ This may take a few seconds │
│ │
│ ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣠⣶⣿⣿⣿⣿⣷⣤⡀⠀⠀⠀⠀⠀⢀⣠⣤⣤⣤⣄⡀⠀⠀⠀⠀⠀ │
│ ⣀⣠⣴⣾⣿⣿⣿⣷⣦⣄⣀⣀⣠⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣦⣀⣀⣠⣴⣿⣿⣿⣿⣿⣿⣿⣶⣤⣀⣀⠀ │
╰──────────────────────────────────────────────────────────────────────────────╯
@@ -0,0 +1,22 @@
---
source: src/ui/mod.rs
assertion_line: 1080
expression: output
---
╭RustNet Monitor───────────────────────────────────────────────────────────────╮
│ │
│ ⣾ Loading network connections... │
│ │
│ This may take a few seconds │
│ │
│ ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣠⣶⣿⣿⣿⣿⣷⣤⡀⠀⠀⠀⠀⠀⢀⣠⣤⣤⣤⣄⡀⠀⠀⠀⠀⠀ │
│ ⣀⣠⣴⣾⣿⣿⣿⣷⣦⣄⣀⣀⣠⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣦⣀⣀⣠⣴⣿⣿⣿⣿⣿⣿⣿⣶⣤⣀⣀⠀ │
╰──────────────────────────────────────────────────────────────────────────────╯
@@ -0,0 +1,6 @@
---
source: src/ui.rs
assertion_line: 5275
expression: output
---
Press 'x' again to clear all connections or any other key to cancel
@@ -0,0 +1,6 @@
---
source: src/ui/mod.rs
assertion_line: 697
expression: output
---
'h' help | 1-5 jump | Tab/[/] cycle | j/k prev/next | Ctrl-d/u scroll | 'c' copy remote addr | Esc back
@@ -0,0 +1,6 @@
---
source: src/ui/mod.rs
assertion_line: 703
expression: output
---
'h' help | 1-5 jump | Tab/[/] cycle | Showing 7 filtered connections (Esc to clear)
@@ -0,0 +1,6 @@
---
source: src/ui/mod.rs
assertion_line: 707
expression: output
---
'h' help | 1-5 jump | Tab/[/] cycle | j/k scroll | Esc back to Overview
@@ -0,0 +1,6 @@
---
source: src/ui/mod.rs
assertion_line: 648
expression: output
---
'h' help | 1-5 jump | Tab/[/] cycle | '/' filter | 'a' group | 't' history | 'i' info | 'c' copy
@@ -0,0 +1,6 @@
---
source: src/ui.rs
assertion_line: 5265
expression: output
---
Press 'q' again to quit or any other key to cancel
@@ -0,0 +1,7 @@
---
source: src/ui/mod.rs
assertion_line: 629
expression: output
---
rustnet 1 Overview 2 Details 3 Interfaces 4 Graph 5 Help
─────────────────────────━━━━━━━━━──────────────────────────────────────────────
@@ -0,0 +1,7 @@
---
source: src/ui/mod.rs
assertion_line: 640
expression: output
---
rustnet 1 Overview 2 Details 3 Interfaces 4 Graph 5 Help
──────────────────────────────────────────────────────────────━━━━━━────────────
@@ -0,0 +1,7 @@
---
source: src/ui/mod.rs
assertion_line: 618
expression: output
---
rustnet 1 Overview 2 Details 3 Interfaces 4 Graph 5 Help
────────────━━━━━━━━━━──────────────────────────────────────────────────────────
+80
View File
@@ -0,0 +1,80 @@
//! Connection-list sort comparators, one per `SortColumn`. Pure
//! function: no UI state, no I/O — just reorders the slice in
//! place.
use crate::network::types::Connection;
use crate::ui::SortColumn;
/// Sort `connections` in place by the chosen column. `ascending`
/// flips the comparator's ordering after the column-specific cmp.
pub fn sort_connections(connections: &mut [Connection], sort_column: SortColumn, ascending: bool) {
connections.sort_by(|a, b| {
let ordering = match sort_column {
SortColumn::CreatedAt => a.created_at.cmp(&b.created_at),
SortColumn::BandwidthTotal => {
// Compare combined up+down bandwidth, handle NaN cases
let a_total = a.current_incoming_rate_bps + a.current_outgoing_rate_bps;
let b_total = b.current_incoming_rate_bps + b.current_outgoing_rate_bps;
a_total
.partial_cmp(&b_total)
.unwrap_or(std::cmp::Ordering::Equal)
}
SortColumn::Process => {
let a_process = a.process_name.as_deref().unwrap_or("");
let b_process = b.process_name.as_deref().unwrap_or("");
a_process.cmp(b_process)
}
SortColumn::LocalAddress => a
.local_addr
.ip()
.cmp(&b.local_addr.ip())
.then_with(|| a.local_addr.port().cmp(&b.local_addr.port())),
SortColumn::RemoteAddress => a
.remote_addr
.ip()
.cmp(&b.remote_addr.ip())
.then_with(|| a.remote_addr.port().cmp(&b.remote_addr.port())),
SortColumn::Application => {
// The App column shows "{proto}·{application}", so rows
// without DPI info (None sorts first) still order
// meaningfully by the protocol tie-break.
let a_app = a.dpi_info.as_ref().map(|dpi| dpi.application.sort_key());
let b_app = b.dpi_info.as_ref().map(|dpi| dpi.application.sort_key());
a_app.cmp(&b_app).then_with(|| a.protocol.cmp(&b.protocol))
}
SortColumn::Service => {
let a_service = a.service_name.as_deref().unwrap_or("");
let b_service = b.service_name.as_deref().unwrap_or("");
a_service.cmp(b_service)
}
SortColumn::State => Ord::cmp(&a.state(), &b.state()),
SortColumn::Location => {
let a_loc = a
.geoip_info
.as_ref()
.and_then(|g| g.country_code.as_deref())
.unwrap_or("");
let b_loc = b
.geoip_info
.as_ref()
.and_then(|g| g.country_code.as_deref())
.unwrap_or("");
a_loc.cmp(b_loc)
}
};
if ascending {
ordering
} else {
ordering.reverse()
}
});
}
+1050
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+712
View File
@@ -0,0 +1,712 @@
//! Graph tab — traffic chart, connections sparkline, network
//! health, TCP counters, TCP state distribution, application
//! protocol distribution, and top processes by bandwidth.
use std::collections::HashMap;
use anyhow::Result;
use ratatui::{
Frame,
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Cell, Paragraph, Row, Table},
};
use crate::app::App;
use crate::network::types::{
AppProtocolDistribution, Connection, Protocol, ProtocolState, TcpState, TrafficHistory,
};
use crate::ui::{
ClickableRegions, Component, ComponentContext, format::format_rate, section_header, theme,
widgets::braille_graph,
};
const TCP_STATE_NAMES: [&str; 11] = [
"ESTAB",
"SYN_SENT",
"SYN_RECV",
"FIN_WAIT1",
"FIN_WAIT2",
"TIME_WAIT",
"CLOSE_WAIT",
"LAST_ACK",
"CLOSING",
"CLOSED",
"UNKNOWN",
];
/// Data used by the connection-derived graph panels. Building it once keeps
/// the render cost to one pass over active connections.
struct GraphAnalytics<'a> {
app_distribution: AppProtocolDistribution,
process_traffic: HashMap<&'a str, f64>,
tcp_state_counts: [usize; TCP_STATE_NAMES.len()],
}
impl<'a> GraphAnalytics<'a> {
fn from_connections(connections: &'a [Connection]) -> Self {
let mut analytics = Self {
app_distribution: AppProtocolDistribution::default(),
process_traffic: HashMap::new(),
tcp_state_counts: [0; TCP_STATE_NAMES.len()],
};
for conn in connections.iter().filter(|conn| !conn.is_historic) {
analytics.app_distribution.record_connection(conn);
let name = conn.process_name.as_deref().unwrap_or("Unknown");
let traffic = conn.current_incoming_rate_bps + conn.current_outgoing_rate_bps;
*analytics.process_traffic.entry(name).or_insert(0.0) += traffic;
if conn.protocol == Protocol::Tcp
&& let ProtocolState::Tcp(state) = &conn.protocol_state
{
analytics.tcp_state_counts[tcp_state_index(state)] += 1;
}
}
analytics
}
}
fn tcp_state_index(state: &TcpState) -> usize {
match state {
TcpState::Established => 0,
TcpState::SynSent => 1,
TcpState::SynReceived => 2,
TcpState::FinWait1 => 3,
TcpState::FinWait2 => 4,
TcpState::TimeWait => 5,
TcpState::CloseWait => 6,
TcpState::LastAck => 7,
TcpState::Closing => 8,
TcpState::Closed => 9,
TcpState::Unknown => 10,
}
}
/// Bold default-foreground title span for a graph section header.
fn graph_title(text: &str) -> Span<'_> {
Span::styled(text, Style::default().add_modifier(Modifier::BOLD))
}
/// Read-only graph tab. Aggregates traffic history, protocol mix,
/// and TCP analytics every render — no per-tab state today.
pub(in crate::ui) struct GraphTab;
impl Component for GraphTab {
fn draw(
&mut self,
f: &mut Frame,
area: Rect,
ctx: &ComponentContext<'_>,
_click_regions: &mut ClickableRegions,
) -> Result<()> {
draw_graph_tab(f, ctx.app, ctx.connections, area)
}
}
pub(in crate::ui) fn draw_graph_tab(
f: &mut Frame,
app: &App,
connections: &[Connection],
area: Rect,
) -> Result<()> {
let analytics = GraphAnalytics::from_connections(connections);
let traffic_history = app.get_traffic_history();
// Each panel is a borderless section_header region; layout spacing
// provides the breathing room the old borders used to. The health
// and distribution rows hold a handful of lines each, so they get
// fixed heights and the wave panels absorb the rest — percentage
// sizing left a large hole between sections on tall terminals.
let main_chunks = Layout::default()
.direction(Direction::Vertical)
.spacing(1)
.constraints([
Constraint::Min(8), // Traffic + connections waves
Constraint::Length(10), // Network health + TCP counters/states
Constraint::Length(12), // App distribution + top processes
])
.split(area);
let top_chunks = Layout::default()
.direction(Direction::Horizontal)
.spacing(2)
.constraints([Constraint::Percentage(70), Constraint::Percentage(30)])
.split(main_chunks[0]);
let health_chunks = Layout::default()
.direction(Direction::Horizontal)
.spacing(2)
.constraints([
Constraint::Percentage(35),
Constraint::Percentage(35),
Constraint::Percentage(30),
])
.split(main_chunks[1]);
let bottom_chunks = Layout::default()
.direction(Direction::Horizontal)
.spacing(2)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(main_chunks[2]);
draw_traffic_chart(f, &traffic_history, top_chunks[0]);
draw_connections_sparkline(f, &traffic_history, top_chunks[1]);
draw_health_chart(f, &traffic_history, health_chunks[0]);
draw_tcp_counters(f, app, health_chunks[1]);
draw_tcp_states(f, &analytics.tcp_state_counts, health_chunks[2]);
draw_app_distribution(f, &analytics.app_distribution, bottom_chunks[0]);
draw_top_processes(f, &analytics.process_traffic, bottom_chunks[1]);
Ok(())
}
/// Draw the RX/TX traffic waves: two stacked braille area graphs with
/// a vertical gradient (bright crest, saturated base), each header
/// showing the current rate, a trend arrow, and the 60s peak.
fn draw_traffic_chart(f: &mut Frame, history: &TrafficHistory, area: Rect) {
let inner = section_header(f, area, graph_title(" Traffic Over Time (60s)"));
if !history.has_enough_data() {
let placeholder = Paragraph::new("Collecting data...").style(theme::fg(theme::muted()));
f.render_widget(placeholder, inner);
return;
}
// Blank row between the halves so the TX header doesn't sit
// directly on the RX wave's baseline.
let halves = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage(50),
Constraint::Length(1),
Constraint::Percentage(50),
])
.split(inner);
let frac = history.scroll_fraction();
let window = history.capacity();
let rx = history.get_rx_sparkline_data(usize::MAX);
let tx = history.get_tx_sparkline_data(usize::MAX);
braille_graph::wave_panel(
f,
halves[0],
&rx,
"↓ RX",
braille_graph::WavePanelOptions::new(frac, window),
theme::rx_wave,
);
braille_graph::wave_panel(
f,
halves[2],
&tx,
"↑ TX",
braille_graph::WavePanelOptions::new(frac, window),
theme::tx_wave,
);
}
/// Horizontal bar with the same dark→bright glow as the waves: each
/// filled cell walks the gradient from deep hue at the origin to the
/// bright crest at the tip; the remainder renders as muted `░` track.
fn gradient_bar(filled: usize, width: usize, ramp: fn(f64) -> Color) -> Vec<Span<'static>> {
let filled = filled.min(width);
let mut spans: Vec<Span> = (0..filled)
.map(|i| {
let t = if filled > 1 {
i as f64 / (filled - 1) as f64
} else {
1.0
};
Span::styled("", theme::fg(ramp(0.15 + 0.85 * t)))
})
.collect();
if width > filled {
spans.push(Span::styled(
"".repeat(width - filled),
theme::fg(theme::muted()),
));
}
spans
}
/// Draw the connection-count wave: same gradient braille style as the
/// traffic panels, in the accent (cyan) hue.
fn draw_connections_sparkline(f: &mut Frame, history: &TrafficHistory, area: Rect) {
let inner = section_header(f, area, graph_title(" Connections"));
if !history.has_enough_data() {
let placeholder = Paragraph::new("Collecting...").style(theme::fg(theme::muted()));
f.render_widget(placeholder, inner);
return;
}
let conn_data = history.get_connection_sparkline_data(usize::MAX);
if inner.height < 2 || conn_data.is_empty() {
return;
}
let current = *conn_data.last().unwrap();
let peak = conn_data.iter().copied().max().unwrap_or(0).max(1);
let ratio = current as f64 / peak as f64;
let left = vec![
Span::styled(
format!("{current} active"),
theme::bold_fg(theme::accent_wave(0.35 + 0.65 * ratio)),
),
Span::styled(
format!(" {}", braille_graph::trend_glyph(&conn_data)),
theme::fg(theme::muted()),
),
];
let right = Span::styled(format!("peak {peak}"), theme::fg(theme::muted()));
f.render_widget(
Paragraph::new(braille_graph::spread_line(left, right, inner.width)),
Rect::new(inner.x, inner.y, inner.width, 1),
);
let graph_area = Rect::new(
inner.x,
inner.y + 1,
inner.width,
inner.height.saturating_sub(1),
);
let lines = braille_graph::render(
&conn_data,
graph_area.width as usize,
graph_area.height as usize,
peak as f64,
history.scroll_fraction(),
history.capacity(),
|intensity| theme::accent_wave((0.6 + 0.4 * ratio) * intensity),
);
f.render_widget(Paragraph::new(lines), graph_area);
}
/// Draw application protocol distribution
fn draw_app_distribution(f: &mut Frame, dist: &AppProtocolDistribution, area: Rect) {
let inner = section_header(f, area, graph_title(" Application Distribution"));
let percentages = dist.as_percentages();
// Filter out zero-count protocols and create bars.
// Layout per row: "{label:6} {bar} {pct:5.1}%" — 6 + 1 + bar + 1 + 6 = 14 + bar.
// Reserve those 14 cells plus 1 for right padding so bars don't touch
// the panel edge.
const LABEL_WIDTH: usize = 6;
const PCT_WIDTH: usize = 6; // " 99.9%"
const SPACERS_AND_PAD: usize = 3; // " bar " + 1 right pad
let bar_width = (inner.width as usize)
.saturating_sub(LABEL_WIDTH + PCT_WIDTH + SPACERS_AND_PAD)
.max(1);
let mut lines: Vec<Line> = Vec::new();
for (label, count, pct) in percentages {
if count == 0 {
continue;
}
let filled = ((pct / 100.0) * bar_width as f64) as usize;
let (color, ramp): (Color, fn(f64) -> Color) = match label {
"HTTPS" => (theme::proto_https(), theme::ok_wave),
"QUIC" => (theme::proto_quic(), theme::accent_wave),
"HTTP" => (theme::proto_http(), theme::warn_wave),
"DNS" => (theme::proto_dns(), theme::special_wave),
"SSH" => (theme::proto_ssh(), theme::tx_wave),
_ => (theme::proto_other(), theme::muted_wave),
};
let mut spans = vec![
Span::styled(
format!("{:<width$}", label, width = LABEL_WIDTH),
theme::fg(color),
),
Span::raw(" "),
];
spans.extend(gradient_bar(filled, bar_width, ramp));
spans.push(Span::raw(format!(" {:>5.1}%", pct)));
lines.push(Line::from(spans));
}
if lines.is_empty() {
lines.push(Line::from(Span::styled(
"No connections",
theme::fg(theme::muted()),
)));
}
let paragraph = Paragraph::new(lines);
f.render_widget(paragraph, inner);
}
/// Draw top processes by bandwidth
fn draw_top_processes(f: &mut Frame, process_traffic: &HashMap<&str, f64>, area: Rect) {
use std::borrow::Cow;
let inner = section_header(f, area, graph_title(" Top Processes"));
let top_processes = select_top_processes(process_traffic, 5);
// Create rows for top 5 processes. Process name absorbs whatever width is
// left after the fixed-width Rate column, and Rate is right-aligned so the
// numbers form a clean right edge.
let rows: Vec<Row> = top_processes
.into_iter()
.map(|(name, rate)| {
let display_name: Cow<str> = if name.len() > 20 {
Cow::Owned(format!("{}...", &name[..17]))
} else {
Cow::Borrowed(name)
};
Row::new(vec![
Cell::from(display_name),
Cell::from(Line::from(format_rate(rate)).right_aligned())
.style(theme::fg(theme::accent())),
])
})
.collect();
if rows.is_empty() {
let placeholder = Paragraph::new("No active processes").style(theme::fg(theme::muted()));
f.render_widget(placeholder, inner);
return;
}
let table = Table::new(rows, [Constraint::Min(0), Constraint::Length(12)]).header(
Row::new(vec![
Cell::from("Process"),
Cell::from(Line::from("Rate").right_aligned()),
])
.style(theme::fg(theme::heading())),
);
f.render_widget(table, inner);
}
fn select_top_processes<'a>(
process_traffic: &HashMap<&'a str, f64>,
limit: usize,
) -> Vec<(&'a str, f64)> {
let mut processes: Vec<_> = process_traffic
.iter()
.filter_map(|(&name, &rate)| (rate > 0.0).then_some((name, rate)))
.collect();
let compare = |a: &(&str, f64), b: &(&str, f64)| b.1.total_cmp(&a.1).then_with(|| a.0.cmp(b.0));
if processes.len() > limit {
processes.select_nth_unstable_by(limit, compare);
processes.truncate(limit);
}
processes.sort_unstable_by(compare);
processes
}
/// Draw the network health gauges with RTT and packet loss bars
fn draw_health_chart(f: &mut Frame, history: &TrafficHistory, area: Rect) {
let inner = section_header(f, area, graph_title(" Network Health"));
if !history.has_enough_data() {
let placeholder = Paragraph::new("Collecting data...").style(theme::fg(theme::muted()));
f.render_widget(placeholder, inner);
return;
}
// Get current values from history
let (loss_data, rtt_data) = history.get_health_chart_data();
// Get most recent values (last data point)
let current_loss = loss_data.last().map(|(_, v)| *v).unwrap_or(0.0);
let current_rtt = rtt_data.last().map(|(_, v)| *v);
// Calculate averages
let avg_loss = if !loss_data.is_empty() {
loss_data.iter().map(|(_, v)| v).sum::<f64>() / loss_data.len() as f64
} else {
0.0
};
let avg_rtt = if !rtt_data.is_empty() {
Some(rtt_data.iter().map(|(_, v)| v).sum::<f64>() / rtt_data.len() as f64)
} else {
None
};
// Thresholds for gauges
const RTT_MAX: f64 = 200.0; // 200ms max scale
const LOSS_MAX: f64 = 10.0; // 10% max scale
// Layout per row: " {label:5}{bar} {value:>9}" with 1 cell of right pad.
// Reserve 2 (lead) + 5 (label) + 1 (gap) + 9 (value) + 1 (pad) = 18.
let bar_width = (inner.width as usize).saturating_sub(18).max(1);
// Build RTT gauge
let rtt_line = if let Some(rtt) = current_rtt {
let rtt_pct = (rtt / RTT_MAX).min(1.0);
let filled = (rtt_pct * bar_width as f64) as usize;
let (color, ramp): (Color, fn(f64) -> Color) = if rtt < 50.0 {
(theme::ok(), theme::ok_wave)
} else if rtt < 150.0 {
(theme::warn(), theme::warn_wave)
} else {
(theme::err(), theme::err_wave)
};
let mut spans = vec![Span::styled(
" RTT ",
Style::default().add_modifier(Modifier::BOLD),
)];
spans.extend(gradient_bar(filled, bar_width, ramp));
spans.push(Span::styled(format!(" {:>6.1}ms", rtt), theme::fg(color)));
Line::from(spans)
} else {
Line::from(vec![
Span::styled(" RTT ", Style::default().add_modifier(Modifier::BOLD)),
Span::styled("".repeat(bar_width), theme::fg(theme::muted())),
Span::styled(" -- ", theme::fg(theme::muted())),
])
};
// Build Loss gauge
let loss_pct = (current_loss / LOSS_MAX).min(1.0);
let filled = (loss_pct * bar_width as f64) as usize;
let (loss_color, loss_ramp): (Color, fn(f64) -> Color) = if current_loss < 1.0 {
(theme::ok(), theme::ok_wave)
} else if current_loss < 5.0 {
(theme::warn(), theme::warn_wave)
} else {
(theme::err(), theme::err_wave)
};
let mut loss_spans = vec![Span::styled(
" Loss ",
Style::default().add_modifier(Modifier::BOLD),
)];
loss_spans.extend(gradient_bar(
filled.max(if current_loss > 0.0 { 1 } else { 0 }),
bar_width,
loss_ramp,
));
loss_spans.push(Span::styled(
format!(" {:>6.2}%", current_loss),
theme::fg(loss_color),
));
let loss_line = Line::from(loss_spans);
// Build averages line
let avg_line = Line::from(vec![
Span::styled(" avg: ", theme::fg(theme::muted())),
Span::styled(
avg_rtt
.map(|r| format!("{:.0}ms", r))
.unwrap_or_else(|| "--".to_string()),
theme::fg(theme::muted()),
),
Span::styled(" / ", theme::fg(theme::muted())),
Span::styled(format!("{:.2}%", avg_loss), theme::fg(theme::muted())),
]);
let paragraph = Paragraph::new(vec![rtt_line, loss_line, avg_line]);
f.render_widget(paragraph, inner);
}
/// Draw TCP counters (retransmits, out of order, fast retransmits)
fn draw_tcp_counters(f: &mut Frame, app: &App, area: Rect) {
use std::sync::atomic::Ordering;
let stats = app.get_stats();
let retransmits = stats.total_tcp_retransmits.load(Ordering::Relaxed);
let out_of_order = stats.total_tcp_out_of_order.load(Ordering::Relaxed);
let fast_retransmits = stats.total_tcp_fast_retransmits.load(Ordering::Relaxed);
let inner = section_header(f, area, graph_title(" TCP Counters"));
// Color based on counts (higher = more concerning)
let retrans_color = if retransmits == 0 {
theme::ok()
} else if retransmits < 100 {
theme::warn()
} else {
theme::err()
};
let ooo_color = if out_of_order == 0 {
theme::ok()
} else if out_of_order < 50 {
theme::warn()
} else {
theme::err()
};
let fast_color = if fast_retransmits == 0 {
theme::ok()
} else if fast_retransmits < 50 {
theme::warn()
} else {
theme::err()
};
let lines = vec![
Line::from(vec![
Span::styled(
" Retransmits ",
Style::default().add_modifier(Modifier::BOLD),
),
Span::styled(format!("{:>8}", retransmits), theme::fg(retrans_color)),
]),
Line::from(vec![
Span::styled(
" Out of Order ",
Style::default().add_modifier(Modifier::BOLD),
),
Span::styled(format!("{:>8}", out_of_order), theme::fg(ooo_color)),
]),
Line::from(vec![
Span::styled(
" Fast Retrans ",
Style::default().add_modifier(Modifier::BOLD),
),
Span::styled(format!("{:>8}", fast_retransmits), theme::fg(fast_color)),
]),
];
let paragraph = Paragraph::new(lines);
f.render_widget(paragraph, inner);
}
/// Draw TCP connection states breakdown
fn draw_tcp_states(f: &mut Frame, state_counts: &[usize; TCP_STATE_NAMES.len()], area: Rect) {
// Build ordered list with only non-zero counts
let states: Vec<_> = TCP_STATE_NAMES
.iter()
.zip(state_counts)
.filter_map(|(&name, &count)| (count > 0).then_some((name, count)))
.collect();
let inner = section_header(f, area, graph_title(" TCP States"));
if states.is_empty() {
let text = Paragraph::new("No TCP connections").style(theme::fg(theme::muted()));
f.render_widget(text, inner);
return;
}
// Find max count for bar scaling.
// Layout per row: "{name:>10} {bar} {count:>4}" with 1 cell of right pad.
// Reserve 10 (name) + 1 + 1 (count gap) + 4 (count) + 1 (right pad) = 17.
let max_count = states.iter().map(|(_, c)| *c).max().unwrap_or(1);
const RESERVED: usize = 17;
let bar_width = (inner.width as usize).saturating_sub(RESERVED).max(1);
// Build lines for each state (limit to available height)
let max_rows = inner.height as usize;
let lines: Vec<Line> = states
.iter()
.take(max_rows)
.map(|(name, count)| {
let bar_len = (*count * bar_width).checked_div(max_count).unwrap_or(0);
// Label color keeps the semantic state alias; the bar
// itself glows in the matching gradient family.
let (color, ramp): (Color, fn(f64) -> Color) = match *name {
"ESTAB" => (theme::tcp_established(), theme::ok_wave),
"SYN_SENT" | "SYN_RECV" => (theme::tcp_opening(), theme::warn_wave),
"TIME_WAIT" | "FIN_WAIT1" | "FIN_WAIT2" => {
(theme::tcp_closing(), theme::muted_wave)
}
"CLOSE_WAIT" | "LAST_ACK" | "CLOSING" => (theme::tcp_waiting(), theme::muted_wave),
"CLOSED" => (theme::tcp_closed(), theme::muted_wave),
_ => (Color::Reset, theme::muted_wave),
};
let mut spans = vec![Span::styled(format!("{:>10} ", name), theme::fg(color))];
// No empty track here: rows are scaled to the max count,
// so a full-width track would just add noise.
let filled = bar_len.max(1).min(bar_width);
spans.extend(gradient_bar(filled, filled, ramp));
spans.push(Span::raw(format!(" {:>4}", count)));
Line::from(spans)
})
.collect();
let paragraph = Paragraph::new(lines);
f.render_widget(paragraph, inner);
}
#[cfg(test)]
mod tests {
use super::*;
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
fn test_connection(
port: u16,
process: &str,
rate: f64,
state: TcpState,
historic: bool,
) -> Connection {
let mut connection = Connection::new(
Protocol::Tcp,
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port),
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(192, 0, 2, 1)), 443),
ProtocolState::Tcp(state),
);
connection.process_name = Some(process.to_string());
connection.current_incoming_rate_bps = rate;
connection.is_historic = historic;
connection
}
#[test]
fn analytics_aggregate_active_connections_once() {
let connections = vec![
test_connection(1000, "alpha", 10.0, TcpState::Established, false),
test_connection(1001, "alpha", 20.0, TcpState::TimeWait, false),
test_connection(1002, "beta", 5.0, TcpState::Established, false),
test_connection(1003, "old", 100.0, TcpState::Closed, true),
];
let analytics = GraphAnalytics::from_connections(&connections);
assert_eq!(analytics.app_distribution.total(), 3);
assert_eq!(analytics.process_traffic.get("alpha"), Some(&30.0));
assert_eq!(analytics.process_traffic.get("beta"), Some(&5.0));
assert!(!analytics.process_traffic.contains_key("old"));
assert_eq!(
analytics.tcp_state_counts[tcp_state_index(&TcpState::Established)],
2
);
assert_eq!(
analytics.tcp_state_counts[tcp_state_index(&TcpState::TimeWait)],
1
);
assert_eq!(
analytics.tcp_state_counts[tcp_state_index(&TcpState::Closed)],
0
);
}
#[test]
fn top_process_selection_only_sorts_the_requested_prefix() {
let traffic = HashMap::from([
("one", 1.0),
("two", 2.0),
("three", 3.0),
("four", 4.0),
("five", 5.0),
("six", 6.0),
("seven", 7.0),
("eight", 8.0),
]);
let top = select_top_processes(&traffic, 3);
assert_eq!(top, vec![("eight", 8.0), ("seven", 7.0), ("six", 6.0)]);
assert!(select_top_processes(&traffic, 0).is_empty());
}
}
+321
View File
@@ -0,0 +1,321 @@
//! Help/legend tab — a scrollable paragraph of keybinds, mouse
//! controls, colors, and filter examples. Scroll position lives in
//! `UIState::help_scroll`.
use anyhow::Result;
use crossterm::event::{KeyEvent, MouseEvent, MouseEventKind};
use ratatui::{
Frame,
layout::Rect,
style::Style,
text::{Line, Span},
widgets::{Padding, Paragraph, Wrap},
};
use crate::ui::{
ClickableRegions, Component, ComponentContext, Effect, HandlerContext, UIState, panel_block,
theme, try_handle_pane_scroll, widgets::scrollbar::draw_scrollbar,
};
/// Help tab. Zero-sized — the scroll offset it responds to lives in
/// `UIState`, not here.
pub(in crate::ui) struct HelpTab;
impl Component for HelpTab {
fn draw(
&mut self,
f: &mut Frame,
area: Rect,
ctx: &ComponentContext<'_>,
_click_regions: &mut ClickableRegions,
) -> Result<()> {
draw_help(f, ctx.ui_state, area)
}
fn handle_key(&mut self, key: KeyEvent, ctx: &mut HandlerContext<'_>) -> Option<Vec<Effect>> {
try_handle_pane_scroll(
key,
ctx.ui_state.visible_rows,
&mut ctx.ui_state.help_scroll,
)
}
fn handle_mouse(
&mut self,
mouse: MouseEvent,
ctx: &mut HandlerContext<'_>,
) -> Option<Vec<Effect>> {
// Three lines per wheel tick: the Help text is a long static
// page, so the single-line step shared by the data panes feels
// sluggish here.
const WHEEL_STEP: u16 = 3;
let scroll = &mut ctx.ui_state.help_scroll;
match mouse.kind {
MouseEventKind::ScrollUp => scroll.scroll_up(WHEEL_STEP),
MouseEventKind::ScrollDown => scroll.scroll_down(WHEEL_STEP),
_ => return None,
}
Some(Vec::new())
}
}
pub(in crate::ui) fn draw_help(f: &mut Frame, ui_state: &UIState, area: Rect) -> Result<()> {
let help_text: Vec<Line> = vec![
Line::from(vec![
Span::styled("RustNet Monitor ", theme::bold_fg(theme::ok())),
Span::raw("- Network Connection Monitor"),
]),
Line::from(""),
Line::from(vec![
Span::styled("q ", theme::fg(theme::key())),
Span::raw("Quit application (press twice to confirm)"),
]),
Line::from(vec![
Span::styled("Ctrl+C ", theme::fg(theme::key())),
Span::raw("Quit immediately"),
]),
Line::from(vec![
Span::styled("x ", theme::fg(theme::key())),
Span::raw("Clear all connections (press twice to confirm)"),
]),
Line::from(vec![
Span::styled("Tab, ] ", theme::fg(theme::key())),
Span::raw("Next tab"),
]),
Line::from(vec![
Span::styled("Shift+Tab, [ ", theme::fg(theme::key())),
Span::raw("Previous tab"),
]),
Line::from(vec![
Span::styled("1-5 ", theme::fg(theme::key())),
Span::raw(
"Jump directly to a tab (1=Overview, 2=Details, 3=Interfaces, 4=Graph, 5=Help)",
),
]),
Line::from(vec![
Span::styled("↑/k, ↓/j ", theme::fg(theme::key())),
Span::raw("Navigate connections (wraps around)"),
]),
Line::from(vec![
Span::styled("g, G ", theme::fg(theme::key())),
Span::raw("Jump to first/last connection (vim-style)"),
]),
Line::from(vec![
Span::styled("Page Up/Down, Ctrl+B/F ", theme::fg(theme::key())),
Span::raw("Navigate connections by page"),
]),
Line::from(vec![
Span::styled("Ctrl+D/U ", theme::fg(theme::key())),
Span::raw("Scroll the Details info panes"),
]),
Line::from(vec![
Span::styled("c ", theme::fg(theme::key())),
Span::raw("Copy remote address to clipboard"),
]),
Line::from(vec![
Span::styled("p ", theme::fg(theme::key())),
Span::raw("Toggle between service names and port numbers"),
]),
Line::from(vec![
Span::styled("d ", theme::fg(theme::key())),
Span::raw("Toggle between hostnames and IP addresses (when --resolve-dns)"),
]),
Line::from(vec![
Span::styled("s ", theme::fg(theme::key())),
Span::raw("Cycle through sort columns (Bandwidth, Process, etc.)"),
]),
Line::from(vec![
Span::styled("S ", theme::fg(theme::key())),
Span::raw("Toggle sort direction (ascending/descending)"),
]),
Line::from(vec![
Span::styled("a ", theme::fg(theme::key())),
Span::raw("Toggle process grouping (aggregate by process)"),
]),
Line::from(vec![
Span::styled("Space ", theme::fg(theme::key())),
Span::raw("Expand/collapse group (when grouping enabled)"),
]),
Line::from(vec![
Span::styled("←/→ or h/l ", theme::fg(theme::key())),
Span::raw("Collapse/expand group"),
]),
Line::from(vec![
Span::styled("t ", theme::fg(theme::key())),
Span::raw("Toggle display of historic (closed) connections"),
]),
Line::from(vec![
Span::styled("i ", theme::fg(theme::key())),
Span::raw("Toggle the System info sidebar"),
]),
Line::from(vec![
Span::styled("r ", theme::fg(theme::key())),
Span::raw("Reset view (grouping, sort, filter)"),
]),
Line::from(vec![
Span::styled("Enter ", theme::fg(theme::key())),
Span::raw("View connection details"),
]),
Line::from(vec![
Span::styled("Esc ", theme::fg(theme::key())),
Span::raw("Return to overview"),
]),
Line::from(vec![
Span::styled("h ", theme::fg(theme::key())),
Span::raw("Toggle this help screen"),
]),
Line::from(vec![
Span::styled("/ ", theme::fg(theme::key())),
Span::raw(
"Enter filter mode on Overview (use \u{2191}/\u{2193} to navigate while typing)",
),
]),
Line::from(""),
Line::from(vec![Span::styled("Tabs:", theme::bold_fg(theme::accent()))]),
Line::from(vec![
Span::styled(" Overview ", theme::fg(theme::ok())),
Span::raw("Connection list with mini traffic graph"),
]),
Line::from(vec![
Span::styled(" Details ", theme::fg(theme::ok())),
Span::raw("Full details for selected connection"),
]),
Line::from(vec![
Span::styled(" Interfaces ", theme::fg(theme::ok())),
Span::raw("Network interface statistics"),
]),
Line::from(vec![
Span::styled(" Graph ", theme::fg(theme::ok())),
Span::raw("Traffic charts and protocol distribution"),
]),
Line::from(vec![
Span::styled(" Help ", theme::fg(theme::ok())),
Span::raw("This help screen"),
]),
Line::from(""),
Line::from(vec![Span::styled(
"Mouse Controls:",
theme::bold_fg(theme::accent()),
)]),
Line::from(vec![
Span::styled(" Click tab ", theme::fg(theme::key())),
Span::raw("Switch between tabs"),
]),
Line::from(vec![
Span::styled(" Click row ", theme::fg(theme::key())),
Span::raw("Select connection"),
]),
Line::from(vec![
Span::styled(" Scroll wheel ", theme::fg(theme::key())),
Span::raw("Navigate connection list / scroll Details, Interfaces, Help"),
]),
Line::from(vec![
Span::styled(" Double-click row ", theme::fg(theme::key())),
Span::raw("Open connection details"),
]),
Line::from(vec![
Span::styled(" Double-click group ", theme::fg(theme::key())),
Span::raw("Expand/collapse process group"),
]),
Line::from(vec![
Span::styled(" Click field (Details) ", theme::fg(theme::key())),
Span::raw("Copy field value to clipboard"),
]),
Line::from(""),
Line::from(vec![Span::styled(
"Connection Colors:",
theme::bold_fg(theme::accent()),
)]),
Line::from(vec![
Span::styled(" White ", Style::default()),
Span::raw("Active connection (< 75% of timeout)"),
]),
Line::from(vec![
Span::styled(" Yellow ", theme::fg(theme::key())),
Span::raw("Stale connection (75-90% of timeout)"),
]),
Line::from(vec![
Span::styled(" Red ", theme::fg(theme::err())),
Span::raw("Critical - will be removed soon (> 90% of timeout)"),
]),
Line::from(""),
Line::from(vec![Span::styled(
"Filter Examples:",
theme::bold_fg(theme::accent()),
)]),
Line::from(vec![
Span::styled(" /google ", theme::fg(theme::ok())),
Span::raw("Search for 'google' in all fields"),
]),
Line::from(vec![
Span::styled(" /port:22 ", theme::fg(theme::ok())),
Span::raw("Exact port match (only port 22, not 2223 or 5522)"),
]),
Line::from(vec![
Span::styled(" /port:/22/ ", theme::fg(theme::ok())),
Span::raw("Regex port match (22, 220, 5522, etc.)"),
]),
Line::from(vec![
Span::styled(" /src:192.168 ", theme::fg(theme::ok())),
Span::raw("Filter by source IP prefix"),
]),
Line::from(vec![
Span::styled(" /dst:github.com ", theme::fg(theme::ok())),
Span::raw("Filter by destination"),
]),
Line::from(vec![
Span::styled(" /sni:/.*github.*/ ", theme::fg(theme::ok())),
Span::raw("Regex SNI match (wrap value in /…/ for regex)"),
]),
Line::from(vec![
Span::styled(" /process:firefox ", theme::fg(theme::ok())),
Span::raw("Filter by process name"),
]),
Line::from(""),
];
// Scroll against the unwrapped line count. A handful of lines can
// wrap on very narrow terminals, making the true maximum slightly
// larger, but staying off the unstable rendered-line-info APIs is
// worth the last row or two of scroll range.
let total_lines = help_text.len();
let inner_height = area.height.saturating_sub(2); // panel borders
let max_scroll = (total_lines as u16).saturating_sub(inner_height);
let scroll = ui_state.help_scroll.clamp_for_render(max_scroll);
let title = if max_scroll > 0 {
"Help · ↑/↓ scroll"
} else {
"Help"
};
// Right padding keeps the text clear of the two rightmost inner
// columns: a blank gap and the scrollbar, same arrangement as the
// Overview table.
let help = Paragraph::new(help_text)
.block(panel_block(title).padding(Padding::right(2)))
.style(Style::default())
.wrap(Wrap { trim: true })
.scroll((scroll, 0))
.alignment(ratatui::layout::Alignment::Left);
f.render_widget(help, area);
// Scrollbar one column inside the panel border so the border line
// stays intact, inset one row top and bottom to clear the title
// row and rounded corners.
let track = Rect::new(
area.x,
area.y + 1,
area.width.saturating_sub(1),
area.height.saturating_sub(2),
);
draw_scrollbar(
f,
track,
total_lines,
scroll as usize,
inner_height as usize,
);
Ok(())
}
+203
View File
@@ -0,0 +1,203 @@
//! Interfaces tab — full table of per-NIC counters (RX/TX rate,
//! packets, errors, drops, collisions) sorted with the active
//! capture interface first. Read-only apart from scrolling, which
//! matters on Docker/VM hosts with dozens of bridge interfaces.
use anyhow::Result;
use crossterm::event::{KeyEvent, MouseEvent};
use ratatui::{
Frame,
layout::{Constraint, Rect},
style::Style,
text::{Line, Span},
widgets::{Cell, Row, Table},
};
use crate::app::App;
use crate::ui::{
ClickableRegions, Component, ComponentContext, Effect, HandlerContext, UIState,
format::format_bytes, section_header, theme, try_handle_pane_scroll, try_handle_pane_wheel,
widgets::scrollbar::draw_scrollbar,
};
/// Interfaces tab. The table is rebuilt from the App's
/// interface-stats DashMap every render; the scroll offset lives in
/// `UIState::interfaces_scroll`.
pub(in crate::ui) struct InterfacesTab;
impl Component for InterfacesTab {
fn draw(
&mut self,
f: &mut Frame,
area: Rect,
ctx: &ComponentContext<'_>,
_click_regions: &mut ClickableRegions,
) -> Result<()> {
draw_interface_stats(f, ctx.app, ctx.ui_state, area)
}
fn handle_key(&mut self, key: KeyEvent, ctx: &mut HandlerContext<'_>) -> Option<Vec<Effect>> {
try_handle_pane_scroll(
key,
ctx.ui_state.visible_rows,
&mut ctx.ui_state.interfaces_scroll,
)
}
fn handle_mouse(
&mut self,
mouse: MouseEvent,
ctx: &mut HandlerContext<'_>,
) -> Option<Vec<Effect>> {
try_handle_pane_wheel(mouse, &mut ctx.ui_state.interfaces_scroll)
}
}
pub(in crate::ui) fn draw_interface_stats(
f: &mut Frame,
app: &App,
ui_state: &UIState,
area: Rect,
) -> Result<()> {
let mut stats = app.get_interface_stats();
let rates = app.get_interface_rates();
// Sort interfaces to show the captured interface first
let captured_interface = app.get_current_interface();
if let Some(ref captured) = captured_interface {
stats.sort_by(|a, b| {
let a_is_captured = &a.interface_name == captured;
let b_is_captured = &b.interface_name == captured;
match (a_is_captured, b_is_captured) {
(true, false) => std::cmp::Ordering::Less,
(false, true) => std::cmp::Ordering::Greater,
_ => a.interface_name.cmp(&b.interface_name),
}
});
}
if stats.is_empty() {
return Ok(());
}
// Create table rows
let mut rows = Vec::new();
for stat in &stats {
// Determine error style
let error_style = if stat.rx_errors > 0 || stat.tx_errors > 0 {
theme::fg(theme::err())
} else {
theme::fg(theme::ok())
};
// Determine drop style
let drop_style = if stat.rx_dropped > 0 || stat.tx_dropped > 0 {
theme::fg(theme::warn())
} else {
theme::fg(theme::ok())
};
// Get rate for this interface
let rx_rate_str = if let Some(rate) = rates.get(&stat.interface_name) {
format!("{}/s", format_bytes(rate.rx_bytes_per_sec))
} else {
"---".to_string()
};
let tx_rate_str = if let Some(rate) = rates.get(&stat.interface_name) {
format!("{}/s", format_bytes(rate.tx_bytes_per_sec))
} else {
"---".to_string()
};
let right = |s: String| Cell::from(Line::from(s).right_aligned());
let right_styled = |s: String, style: Style| {
Cell::from(Line::from(Span::styled(s, style)).right_aligned())
};
rows.push(Row::new(vec![
Cell::from(stat.interface_name.clone()),
right(rx_rate_str),
right(tx_rate_str),
right(format!("{}", stat.rx_packets)),
right(format!("{}", stat.tx_packets)),
right_styled(format!("{}", stat.rx_errors), error_style),
right_styled(format!("{}", stat.tx_errors), error_style),
right_styled(format!("{}", stat.rx_dropped), drop_style),
right_styled(format!("{}", stat.tx_dropped), drop_style),
right(format!("{}", stat.collisions)),
]));
}
let inner = section_header(
f,
area,
ratatui::text::Span::styled(
" Interface Statistics",
Style::default().add_modifier(ratatui::style::Modifier::BOLD),
),
);
// Window the rows against the scroll offset so hosts with dozens of
// bridge/veth interfaces can reach them all. The viewport excludes
// the table's header row.
let total_rows = rows.len();
let viewport = inner.height.saturating_sub(1) as usize;
let max_scroll = (total_rows as u16).saturating_sub(viewport as u16);
let scroll = ui_state.interfaces_scroll.clamp_for_render(max_scroll) as usize;
let windowed: Vec<Row> = rows.into_iter().skip(scroll).collect();
// Create table; reserve the two rightmost columns for a blank gap
// plus the scrollbar, mirroring the Overview table.
let table = Table::new(
windowed,
[
Constraint::Length(14), // Interface
Constraint::Length(12), // RX Bytes
Constraint::Length(12), // TX Bytes
Constraint::Length(10), // RX Packets
Constraint::Length(10), // TX Packets
Constraint::Length(9), // RX Err
Constraint::Length(9), // TX Err
Constraint::Length(10), // RX Drop
Constraint::Length(10), // TX Drop
Constraint::Length(10), // Collis
],
)
.header({
let right = |s: &str| Cell::from(Line::from(s.to_string()).right_aligned());
Row::new(vec![
Cell::from("Interface"),
right("RX Rate"),
right("TX Rate"),
right("RX Packets"),
right("TX Packets"),
right("RX Err"),
right("TX Err"),
right("RX Drop"),
right("TX Drop"),
right("Collisions"),
])
.style(theme::fg(theme::heading()))
})
.style(Style::default());
let table_area = Rect::new(
inner.x,
inner.y,
inner.width.saturating_sub(2),
inner.height,
);
f.render_widget(table, table_area);
// Scrollbar tracks the row region (below the header row).
let rows_area = Rect::new(
inner.x,
inner.y + 1,
inner.width,
inner.height.saturating_sub(1),
);
draw_scrollbar(f, rows_area, total_rows, scroll, viewport);
Ok(())
}
+9
View File
@@ -0,0 +1,9 @@
//! Per-tab renderers. Each submodule owns the rendering for one
//! tab (Overview, Details, Interfaces, Graph, Help) and is invoked
//! from the top-level `draw()` dispatcher in `ui::mod`.
pub(super) mod details;
pub(super) mod graph;
pub(super) mod help;
pub(super) mod interfaces;
pub(super) mod overview;
File diff suppressed because it is too large Load Diff
+77
View File
@@ -0,0 +1,77 @@
//! Terminal lifecycle: alternate-screen + raw-mode setup and teardown,
//! plus the `Terminal` type alias the rest of the crate uses.
use anyhow::Result;
use ratatui::Terminal as RatatuiTerminal;
pub type Terminal<B> = RatatuiTerminal<B>;
/// Set up the terminal for the TUI application.
///
/// Also installs a panic hook (chained ahead of the previous one) that
/// restores the terminal before the panic message is printed. Without
/// it, a panic anywhere in the app leaves the terminal in raw mode on
/// the alternate screen with mouse capture on — a garbled shell that
/// needs `tput reset`. `ratatui::init()` installs an equivalent hook,
/// but it does not enable mouse capture, which rustnet relies on for
/// its clickable hit-test regions, so we keep the manual setup and add
/// the hook ourselves.
pub fn setup_terminal<B: ratatui::backend::Backend>(backend: B) -> Result<Terminal<B>>
where
<B as ratatui::backend::Backend>::Error: Send + Sync + 'static,
{
crossterm::terminal::enable_raw_mode()?;
crossterm::execute!(
std::io::stdout(),
crossterm::terminal::EnterAlternateScreen,
crossterm::event::EnableMouseCapture
)?;
install_panic_hook();
let mut terminal = RatatuiTerminal::new(backend)?;
// Clear via the backend, not `Terminal::clear()`: since ratatui 0.30 the
// latter queries the cursor position (ESC[6n) and errors out after a 2s
// timeout on terminals that never reply (e.g. the FreeBSD vt console),
// aborting startup. The backend clear is a plain clear-screen write. It
// also runs after entering the alternate screen, so the user's primary
// screen is left untouched.
terminal.backend_mut().clear()?;
terminal.hide_cursor()?;
Ok(terminal)
}
/// Restore the terminal to its original state
pub fn restore_terminal<B: ratatui::backend::Backend>(terminal: &mut Terminal<B>) -> Result<()>
where
<B as ratatui::backend::Backend>::Error: Send + Sync + 'static,
{
restore_terminal_raw()?;
terminal.show_cursor()?;
Ok(())
}
/// Crossterm-level teardown that needs no `Terminal` handle: disable raw
/// mode, leave the alternate screen, disable mouse capture, and show the
/// cursor. Shared by the normal teardown path and the panic hook (which
/// cannot borrow the `Terminal`). Best-effort — errors are ignored when
/// called from the panic hook since we are already unwinding.
fn restore_terminal_raw() -> Result<()> {
crossterm::terminal::disable_raw_mode()?;
crossterm::execute!(
std::io::stdout(),
crossterm::terminal::LeaveAlternateScreen,
crossterm::event::DisableMouseCapture,
crossterm::cursor::Show
)?;
Ok(())
}
/// Chain a panic hook ahead of the existing one that restores the
/// terminal first, so the panic message lands on a usable screen.
fn install_panic_hook() {
let previous = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
// Best-effort: we are already panicking, so ignore restore errors.
let _ = restore_terminal_raw();
previous(info);
}));
}
+366
View File
@@ -0,0 +1,366 @@
//! Centralized color palette for cross-terminal consistency.
//! All semantic colors derive from these 7 base constants.
//!
//! Two presets share this module: the default `Muted` preset keeps one
//! accent color (cyan) and reserves the rest of the palette for semantic
//! signals (state health, staleness, traffic activity), while `Classic`
//! restores the original per-field rainbow. Every alias below branches on
//! the active preset so call sites stay preset-agnostic.
use std::sync::atomic::{AtomicBool, Ordering};
use ratatui::style::{Color, Modifier, Style};
// --- 7-slot base palette ---
const OK: Color = Color::Green; // Healthy/success
const WARN: Color = Color::Yellow; // Caution/attention
const ERR: Color = Color::Red; // Error/critical
const ACCENT: Color = Color::Cyan; // Informational highlight
const MUTED: Color = Color::Gray; // Secondary/inactive
const INFO: Color = Color::Blue; // Neutral info
const SPECIAL: Color = Color::Magenta; // Distinct/special
// --- Theme presets ---
/// Selectable palette presets (`--theme` CLI flag).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ThemePreset {
/// Restrained default: one cyan accent, color only for semantic signals.
Muted,
/// The original full-color palette with per-field colors.
Classic,
}
/// Stored as a bool ("is classic") so reads stay a single relaxed atomic
/// load, mirroring the NO_COLOR flag in `ui::mod`.
static CLASSIC: AtomicBool = AtomicBool::new(false);
/// Select the active palette preset. Called once at startup.
pub fn set_preset(preset: ThemePreset) {
CLASSIC.store(preset == ThemePreset::Classic, Ordering::Relaxed);
}
/// Whether the Classic (full-color) preset is active.
pub fn is_classic() -> bool {
CLASSIC.load(Ordering::Relaxed)
}
// --- Base color accessors ---
pub fn ok() -> Color {
OK
}
pub fn warn() -> Color {
WARN
}
pub fn err() -> Color {
ERR
}
pub fn accent() -> Color {
ACCENT
}
pub fn muted() -> Color {
MUTED
}
pub fn info() -> Color {
INFO
}
pub fn special() -> Color {
SPECIAL
}
// --- UI element aliases ---
//
// Three-tier hierarchy so the showcase can pick out a clear winner:
// * primary() — what the user is acting on right now (active tab,
// selected row's focus column, sorted column header)
// * heading() — structural anchors (table column headers, section titles)
// * label() — supporting context (field labels, units, separators)
//
// `primary()` returns a full Style because it always pairs with BOLD;
// the others return raw Colors so callers can compose with `fg()` /
// `bold_fg()` as needed.
pub fn primary() -> Style {
bold_fg(accent())
}
pub fn label() -> Color {
muted()
}
pub fn heading() -> Color {
if is_classic() { warn() } else { muted() }
}
pub fn key() -> Color {
if is_classic() { warn() } else { accent() }
}
// --- Network aliases ---
pub fn rx() -> Color {
ok()
}
pub fn tx() -> Color {
info()
}
// --- Traffic wave gradients (Graph tab) ---
//
// Truecolor 5-stop ramps for the braille traffic waves: deep hue at
// the base of the wave, brighter at the crest (style borrowed from
// github.com/programmersd21/flow). Each ramp stays inside a single
// hue — RX is unmistakably green, TX unmistakably blue — and the
// bright end stops well short of white so crests stay visible on
// light backgrounds. Callers must wrap the result in `fg()` so
// NO_COLOR still strips these.
const RX_WAVE_STOPS: [(u8, u8, u8); 5] = [
(0x16, 0x65, 0x34), // deep green
(0x15, 0x80, 0x3D),
(0x16, 0xA3, 0x4A),
(0x22, 0xC5, 0x5E),
(0x4A, 0xDE, 0x80), // bright green crest
];
const TX_WAVE_STOPS: [(u8, u8, u8); 5] = [
(0x1E, 0x40, 0xAF), // deep blue
(0x1D, 0x4E, 0xD8),
(0x25, 0x63, 0xEB),
(0x3B, 0x82, 0xF6),
(0x60, 0xA5, 0xFA), // bright blue crest
];
const ACCENT_WAVE_STOPS: [(u8, u8, u8); 5] = [
(0x15, 0x5E, 0x75), // deep cyan
(0x0E, 0x74, 0x90),
(0x08, 0x91, 0xB2),
(0x06, 0xB6, 0xD4),
(0x22, 0xD3, 0xEE), // bright cyan crest
];
const WARN_WAVE_STOPS: [(u8, u8, u8); 5] = [
(0x92, 0x40, 0x0E), // deep amber
(0xB4, 0x53, 0x09),
(0xD9, 0x77, 0x06),
(0xF5, 0x9E, 0x0B),
(0xFB, 0xBF, 0x24), // bright amber
];
const ERR_WAVE_STOPS: [(u8, u8, u8); 5] = [
(0x99, 0x1B, 0x1B), // deep red
(0xB9, 0x1C, 0x1C),
(0xDC, 0x26, 0x26),
(0xEF, 0x44, 0x44),
(0xF8, 0x71, 0x71), // bright red
];
const SPECIAL_WAVE_STOPS: [(u8, u8, u8); 5] = [
(0x86, 0x19, 0x8F), // deep fuchsia
(0xA2, 0x1C, 0xAF),
(0xC0, 0x26, 0xD3),
(0xD9, 0x46, 0xEF),
(0xE8, 0x79, 0xF9), // bright fuchsia
];
const MUTED_WAVE_STOPS: [(u8, u8, u8); 5] = [
(0x37, 0x41, 0x51), // deep gray
(0x4B, 0x55, 0x63),
(0x6B, 0x72, 0x80),
(0x84, 0x8D, 0x9C),
(0x9C, 0xA3, 0xAF), // light gray
];
fn lerp_channel(a: u8, b: u8, t: f64) -> u8 {
(a as f64 + (b as f64 - a as f64) * t).round() as u8
}
/// Walk a 5-stop color ramp at `t` ∈ [0, 1] (4 linear segments).
fn five_stop(stops: &[(u8, u8, u8); 5], t: f64) -> Color {
let seg = t.clamp(0.0, 1.0) * 4.0;
let i = (seg as usize).min(3);
let local = seg - i as f64;
let (a, b) = (stops[i], stops[i + 1]);
Color::Rgb(
lerp_channel(a.0, b.0, local),
lerp_channel(a.1, b.1, local),
lerp_channel(a.2, b.2, local),
)
}
/// RX wave gradient color at intensity `t` (0 = dim base, 1 = crest).
pub fn rx_wave(t: f64) -> Color {
five_stop(&RX_WAVE_STOPS, t)
}
/// TX wave gradient color at intensity `t` (0 = dim base, 1 = crest).
pub fn tx_wave(t: f64) -> Color {
five_stop(&TX_WAVE_STOPS, t)
}
/// Accent (cyan) wave gradient for non-directional graphs like the
/// connection count, at intensity `t` (0 = dim base, 1 = crest).
pub fn accent_wave(t: f64) -> Color {
five_stop(&ACCENT_WAVE_STOPS, t)
}
/// Green gradient for healthy/success bars (same ramp as RX).
pub fn ok_wave(t: f64) -> Color {
five_stop(&RX_WAVE_STOPS, t)
}
/// Amber gradient for caution bars.
pub fn warn_wave(t: f64) -> Color {
five_stop(&WARN_WAVE_STOPS, t)
}
/// Red gradient for critical bars.
pub fn err_wave(t: f64) -> Color {
five_stop(&ERR_WAVE_STOPS, t)
}
/// Fuchsia gradient for special/distinct bars (DNS).
pub fn special_wave(t: f64) -> Color {
five_stop(&SPECIAL_WAVE_STOPS, t)
}
/// Gray gradient for secondary/inactive bars.
pub fn muted_wave(t: f64) -> Color {
five_stop(&MUTED_WAVE_STOPS, t)
}
// --- Protocol aliases ---
pub fn proto_https() -> Color {
ok()
}
pub fn proto_quic() -> Color {
accent()
}
pub fn proto_http() -> Color {
warn()
}
pub fn proto_dns() -> Color {
special()
}
pub fn proto_ssh() -> Color {
info()
}
pub fn proto_other() -> Color {
muted()
}
// --- TCP state aliases ---
// Muted preset: ESTABLISHED is the common case and reads as plain text;
// only transitional states (a genuine signal) keep an attention color.
pub fn tcp_established() -> Color {
if is_classic() { ok() } else { Color::Reset }
}
pub fn tcp_opening() -> Color {
warn()
}
pub fn tcp_closing() -> Color {
if is_classic() { accent() } else { muted() }
}
pub fn tcp_waiting() -> Color {
if is_classic() { special() } else { muted() }
}
pub fn tcp_closed() -> Color {
muted()
}
// --- Field-level aliases (same color used everywhere a field appears) ---
// Muted preset: addresses keep a calm color (they're the data being
// monitored), the other identifying fields render in the terminal's
// default foreground (`Color::Reset`), supporting context fades to
// gray. Same address colors in both presets.
pub fn field_local_addr() -> Color {
accent()
}
pub fn field_remote_addr() -> Color {
info()
}
pub fn field_state() -> Color {
if is_classic() { ok() } else { Color::Reset }
}
pub fn field_service() -> Color {
if is_classic() { warn() } else { muted() }
}
pub fn field_location() -> Color {
if is_classic() { special() } else { muted() }
}
pub fn field_process() -> Color {
if is_classic() { ok() } else { Color::Reset }
}
pub fn field_application() -> Color {
if is_classic() { warn() } else { muted() }
}
// --- Historic (closed) connection rows ---
// Whole-row override; per-cell colors are dropped so the uniform gray
// carries the signal. DarkGray reads as muted-but-present on both
// light and dark backgrounds. DIM is deliberately NOT used when colors
// are available: terminals disagree wildly on it (invisible on light
// themes, barely-there in WezTerm dark). Under NO_COLOR it returns as
// the only row-level cue, alongside the "closed" state text.
pub fn historic_row() -> Style {
if super::NO_COLOR.load(super::Ordering::Relaxed) {
Style::default().add_modifier(Modifier::DIM)
} else {
Style::default().fg(Color::DarkGray)
}
}
// --- Panel border ---
pub fn border() -> Color {
if is_classic() {
special()
} else {
Color::DarkGray
}
}
// --- Status bar styles ---
// Uses REVERSED modifier instead of fg(Black).bg(Color) which breaks on dark terminals
pub fn status_bar_confirm() -> Style {
if super::NO_COLOR.load(super::Ordering::Relaxed) {
return Style::default().add_modifier(Modifier::REVERSED);
}
Style::default()
.fg(warn())
.add_modifier(Modifier::BOLD | Modifier::REVERSED)
}
pub fn status_bar_success() -> Style {
if super::NO_COLOR.load(super::Ordering::Relaxed) {
return Style::default().add_modifier(Modifier::REVERSED);
}
Style::default()
.fg(ok())
.add_modifier(Modifier::BOLD | Modifier::REVERSED)
}
pub fn status_bar_default() -> Style {
if super::NO_COLOR.load(super::Ordering::Relaxed) || !is_classic() {
return Style::default().add_modifier(Modifier::REVERSED);
}
Style::default().fg(info()).add_modifier(Modifier::REVERSED)
}
pub fn row_highlight() -> Style {
// No fg override: the highlight inherits the row's existing fg, so
// when REVERSED swaps fg ↔ bg, a red staleness row gets a red
// selection bar, a yellow row gets a yellow bar, and a default row
// gets a default-fg bar. The staleness signal survives the
// selection highlight.
Style::default().add_modifier(Modifier::BOLD | Modifier::REVERSED)
}
// --- Style builders (NO_COLOR-aware) ---
/// Apply a foreground color, respecting NO_COLOR.
pub fn fg(color: Color) -> Style {
if super::NO_COLOR.load(super::Ordering::Relaxed) {
Style::default()
} else {
Style::default().fg(color)
}
}
/// Apply a foreground color with BOLD, respecting NO_COLOR.
pub fn bold_fg(color: Color) -> Style {
if super::NO_COLOR.load(super::Ordering::Relaxed) {
Style::default().add_modifier(Modifier::BOLD)
} else {
Style::default().fg(color).add_modifier(Modifier::BOLD)
}
}
/// Apply a foreground color with BOLD + UNDERLINED, respecting NO_COLOR.
pub fn bold_underline_fg(color: Color) -> Style {
if super::NO_COLOR.load(super::Ordering::Relaxed) {
Style::default().add_modifier(Modifier::BOLD | Modifier::UNDERLINED)
} else {
Style::default()
.fg(color)
.add_modifier(Modifier::BOLD | Modifier::UNDERLINED)
}
}
+404
View File
@@ -0,0 +1,404 @@
//! Braille area graph: renders a rate history as a filled wave on a
//! 2×4 dots-per-cell braille canvas with a vertical color gradient
//! (bright crest, saturated base). Visual style inspired by
//! <https://github.com/programmersd21/flow>, reimplemented for ratatui.
//!
//! The renderer is pure: samples in, styled `Line`s out. Callers pick
//! the gradient via a color callback so the widget stays theme-agnostic.
use ratatui::{
Frame,
layout::Rect,
style::Color,
text::{Line, Span},
widgets::Paragraph,
};
use crate::ui::{format::format_rate, theme};
/// Width of rate values in wave-panel headers. This fits values through
/// `999.99 GB/s` and keeps both the trend glyph and `peak` label anchored as
/// formatted values cross digit and unit boundaries.
const HEADER_RATE_WIDTH: usize = 11;
pub(in crate::ui) struct WavePanelOptions {
summary: Option<Line<'static>>,
frac: f64,
window: usize,
}
impl WavePanelOptions {
pub(in crate::ui) fn new(frac: f64, window: usize) -> Self {
Self {
summary: None,
frac,
window,
}
}
pub(in crate::ui) fn with_summary(mut self, summary: Line<'static>) -> Self {
self.summary = Some(summary);
self
}
}
/// Unicode braille bit for a dot at (dx, dy) inside one cell.
/// dx: 0 = left column, 1 = right column; dy: 0 = top … 3 = bottom.
/// Dots 7/8 (the bottom row) live in the high bits — this is the
/// standard braille encoding, not a linear layout.
const fn dot_mask(dx: usize, dy: usize) -> u8 {
match (dx, dy) {
(0, 0) => 0x01,
(0, 1) => 0x02,
(0, 2) => 0x04,
(0, 3) => 0x40,
(1, 0) => 0x08,
(1, 1) => 0x10,
(1, 2) => 0x20,
_ => 0x80,
}
}
/// Soft peak shaping: lifts small ratios so idle traffic still draws
/// a visible curve instead of a flat line.
fn ease_out_quad(t: f64) -> f64 {
t * (2.0 - t)
}
/// Value at fractional position `pos` (in sample units), linearly
/// interpolated between neighbors.
fn sample_at(samples: &[u64], pos: f64) -> f64 {
let last = samples.len() - 1;
let pos = pos.clamp(0.0, last as f64);
let i = pos as usize;
let f = pos - i as f64;
if i < last {
samples[i] as f64 * (1.0 - f) + samples[i + 1] as f64 * f
} else {
samples[last] as f64
}
}
/// 5-tap weighted moving average (1-2-3-2-1) over dot columns, for a
/// water-like curve instead of hard per-sample steps.
fn smooth_columns(cols: &[f64]) -> Vec<f64> {
const W: [f64; 5] = [1.0, 2.0, 3.0, 2.0, 1.0];
let n = cols.len();
(0..n)
.map(|i| {
let mut sum = 0.0;
let mut total = 0.0;
for (k, w) in W.iter().enumerate() {
if let Some(j) = (i + k).checked_sub(2)
&& j < n
{
sum += cols[j] * w;
total += w;
}
}
sum / total
})
.collect()
}
/// Render `samples` (oldest→newest) as a filled braille wave of
/// `width`×`height` cells, normalized against `max_val`.
///
/// `window` is the number of samples the panel spans horizontally
/// (the history buffer's capacity, not the current sample count).
/// Anchoring the newest sample to the right edge at a fixed
/// samples-per-dot density keeps the scroll continuous: while the
/// buffer is still filling, the wave grows in from the right instead
/// of stretching (which made every new sample snap the wave back).
///
/// `frac` ∈ [0, 1] is how far we are into the current sampling
/// interval; it shifts the wave left by a sub-cell amount each frame
/// so the graph scrolls smoothly instead of stepping once per sample.
///
/// Each row is one solid-colored `Line`; `row_color` maps `intensity`
/// (1.0 at the crest row, →0 at the base) to a gradient color.
pub(in crate::ui) fn render(
samples: &[u64],
width: usize,
height: usize,
max_val: f64,
frac: f64,
window: usize,
row_color: impl Fn(f64) -> ratatui::style::Color,
) -> Vec<Line<'static>> {
if width == 0 || height == 0 || samples.is_empty() {
return Vec::new();
}
let dots_x = width * 2;
let dots_y = height * 4;
// Newest sample pinned to the right edge; `frac` advances every
// lookup by a sub-sample amount so the wave flows left between
// samples. Positions before the first sample render as zero.
let per_dot = if dots_x > 1 {
(window.max(2) - 1) as f64 / (dots_x - 1) as f64
} else {
0.0
};
let right = (samples.len() - 1) as f64 + frac.clamp(0.0, 1.0);
let cols: Vec<f64> = (0..dots_x)
.map(|x| {
let pos = right - (dots_x - 1 - x) as f64 * per_dot;
if pos < 0.0 {
0.0
} else {
sample_at(samples, pos)
}
})
.collect();
let cols = smooth_columns(&cols);
// Fill each dot column bottom-up to its eased height.
let mut grid = vec![vec![0u8; width]; height];
for (x, col) in cols.iter().enumerate() {
let ratio = if max_val > 0.0 {
ease_out_quad((col / max_val).clamp(0.0, 1.0))
} else {
0.0
};
let h_dots = ratio * dots_y as f64;
for y_dot in 0..dots_y {
if (y_dot as f64) < h_dots {
let row = height - 1 - y_dot / 4;
grid[row][x / 2] |= dot_mask(x % 2, 3 - y_dot % 4);
}
}
}
grid.into_iter()
.enumerate()
.map(|(i, row)| {
let text: String = row
.into_iter()
.map(|bits| char::from_u32(0x2800 + bits as u32).unwrap_or(' '))
.collect();
let intensity = 1.0 - i as f64 / height as f64;
Line::from(Span::styled(text, theme::fg(row_color(intensity))))
})
.collect()
}
/// Header line for a wave panel: left spans, then the right span
/// pushed to the panel's right edge (all glyphs used here are
/// single-width, so char count == display width).
pub(in crate::ui) fn spread_line(
mut left: Vec<Span<'static>>,
right: Span<'static>,
width: u16,
) -> Line<'static> {
let used: usize = left
.iter()
.map(|s| s.content.chars().count())
.sum::<usize>()
+ right.content.chars().count();
let gap = (width as usize).saturating_sub(used + 1);
left.push(Span::raw(" ".repeat(gap)));
left.push(right);
Line::from(left)
}
fn format_header_rate(rate: f64) -> String {
let rate = format_rate(rate);
format!("{rate:<HEADER_RATE_WIDTH$}")
}
fn format_peak_rate(rate: f64) -> String {
let rate = format_rate(rate);
format!("peak {rate:>HEADER_RATE_WIDTH$}")
}
/// One rate direction as a complete panel: a header line (label, the
/// current rate, a trend arrow, and the window peak), an optional summary
/// line, then a gradient braille wave. The wave is normalized to the window
/// peak; row brightness also scales with how close the current rate is to
/// that peak, so the panel glows under load and dims when idle.
pub(in crate::ui) fn wave_panel(
f: &mut Frame,
area: Rect,
samples: &[u64],
label: &str,
options: WavePanelOptions,
wave: fn(f64) -> Color,
) {
if area.height < 2 || samples.is_empty() {
return;
}
let current = *samples.last().unwrap() as f64;
let peak = samples.iter().copied().max().unwrap_or(0) as f64;
let max_val = peak.max(1024.0); // minimum 1 KB/s scale
let speed_ratio = (current / max_val).clamp(0.0, 1.0);
let value_color = wave(0.35 + 0.65 * speed_ratio);
let left = vec![
Span::styled(format!("{label} "), theme::bold_fg(wave(0.4))),
Span::styled(format_header_rate(current), theme::bold_fg(value_color)),
Span::styled(
format!(" {}", trend_glyph(samples)),
theme::fg(theme::muted()),
),
];
let right = Span::styled(format_peak_rate(peak), theme::fg(theme::muted()));
f.render_widget(
Paragraph::new(spread_line(left, right, area.width)),
Rect::new(area.x, area.y, area.width, 1),
);
let summary_height = u16::from(options.summary.is_some() && area.height >= 3);
if let Some(summary) = options.summary.filter(|_| summary_height == 1) {
f.render_widget(
Paragraph::new(summary),
Rect::new(area.x, area.y + 1, area.width, 1),
);
}
let graph_area = Rect::new(
area.x,
area.y + 1 + summary_height,
area.width,
area.height.saturating_sub(1 + summary_height),
);
let lines = render(
samples,
graph_area.width as usize,
graph_area.height as usize,
max_val,
options.frac,
options.window,
|intensity| wave((0.6 + 0.4 * speed_ratio) * intensity),
);
f.render_widget(Paragraph::new(lines), graph_area);
}
/// Least-squares slope over the last `n` samples — feeds the ↗/→/↘
/// trend glyph next to the current rate.
fn slope(samples: &[u64], n: usize) -> f64 {
let tail = &samples[samples.len().saturating_sub(n)..];
let len = tail.len();
if len < 2 {
return 0.0;
}
let mean_x = (len - 1) as f64 / 2.0;
let mean_y = tail.iter().map(|&v| v as f64).sum::<f64>() / len as f64;
let (mut num, mut den) = (0.0, 0.0);
for (i, &v) in tail.iter().enumerate() {
let dx = i as f64 - mean_x;
num += dx * (v as f64 - mean_y);
den += dx * dx;
}
if den == 0.0 { 0.0 } else { num / den }
}
/// Trend arrow for the recent samples: rising, falling, or steady
/// (relative to 5% of the current value, so noise reads as steady).
pub(in crate::ui) fn trend_glyph(samples: &[u64]) -> &'static str {
let current = samples.last().copied().unwrap_or(0) as f64;
let s = slope(samples, 6);
let threshold = current * 0.05;
if s > threshold {
""
} else if s < -threshold {
""
} else {
""
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dot_mask_covers_all_braille_bits() {
let mut all = 0u8;
for dx in 0..2 {
for dy in 0..4 {
all |= dot_mask(dx, dy);
}
}
assert_eq!(all, 0xFF);
}
#[test]
fn render_fills_bottom_row_under_load() {
let samples = vec![100u64; 30];
let lines = render(&samples, 10, 3, 100.0, 0.0, 30, |_| {
ratatui::style::Color::Reset
});
assert_eq!(lines.len(), 3);
// Constant max-value input must fully fill the bottom row.
let bottom: String = lines[2].spans.iter().map(|s| s.content.clone()).collect();
assert!(bottom.chars().all(|c| c == '⣿'), "got {bottom:?}");
}
#[test]
fn render_empty_when_no_samples() {
assert!(render(&[], 10, 3, 1.0, 0.0, 60, |_| ratatui::style::Color::Reset).is_empty());
}
#[test]
fn partial_buffer_grows_from_right() {
// 5 samples in a 60-sample window: the wave hugs the right
// edge and the left stays blank (no stretch during warmup).
let samples = vec![100u64; 5];
let lines = render(&samples, 30, 2, 100.0, 0.0, 60, |_| {
ratatui::style::Color::Reset
});
let bottom: String = lines[1].spans.iter().map(|s| s.content.clone()).collect();
let cells: Vec<char> = bottom.chars().collect();
assert_eq!(cells[0], '\u{2800}', "left edge should be blank");
assert_eq!(*cells.last().unwrap(), '⣿', "right edge should be full");
}
#[test]
fn scroll_is_continuous_across_sample_rollover() {
// frame N at frac=1.0 must equal frame N+1 (data shifted by
// one sample) at frac=0.0 everywhere except the right edge,
// where the new sample is revealed.
let old: Vec<u64> = (0..60).map(|i| (i * 13) % 100).collect();
let mut new = old[1..].to_vec();
new.push(77);
let render_plain = |s: &[u64], frac: f64| -> Vec<String> {
render(s, 30, 2, 100.0, frac, 60, |_| ratatui::style::Color::Reset)
.into_iter()
.map(|l| l.spans.iter().map(|sp| sp.content.clone()).collect())
.collect()
};
let before = render_plain(&old, 1.0);
let after = render_plain(&new, 0.0);
for (b, a) in before.iter().zip(after.iter()) {
// Ignore the last 3 cells: the column smoother's 2-dot
// reach plus the revealed sample touch only those.
let cut = b.chars().count() - 3;
let b_body: String = b.chars().take(cut).collect();
let a_body: String = a.chars().take(cut).collect();
assert_eq!(b_body, a_body);
}
}
#[test]
fn trend_glyph_directions() {
assert_eq!(trend_glyph(&[0, 100, 200, 300, 400, 500]), "");
assert_eq!(trend_glyph(&[500, 400, 300, 200, 100, 0]), "");
assert_eq!(trend_glyph(&[300, 300, 300, 300, 300, 300]), "");
}
#[test]
fn wave_header_rate_slots_keep_a_constant_width() {
let rates = [0.0, 730.0, 1_020.0, 12_345.0, 2_500_000.0];
for rate in rates {
assert_eq!(format_header_rate(rate).chars().count(), HEADER_RATE_WIDTH);
assert_eq!(
format_peak_rate(rate).chars().count(),
"peak ".len() + HEADER_RATE_WIDTH
);
}
}
}
+46
View File
@@ -0,0 +1,46 @@
//! Filter input line shown above the status bar whenever the user
//! has either entered filter mode or has a persistent filter active.
//! A single borderless row: accent " / " prompt, the query, and a
//! muted right-side hint with the relevant keys.
use ratatui::{
Frame,
layout::Rect,
text::{Line, Span},
widgets::Paragraph,
};
use crate::ui::{UIState, theme};
/// Height of the filter line in rows.
pub(crate) const FILTER_INPUT_HEIGHT: u16 = 1;
pub(in crate::ui) fn draw_filter_input(f: &mut Frame, ui_state: &UIState, area: Rect) {
let query = if ui_state.filter_mode {
// Show cursor when in filter mode
let mut display_query = ui_state.filter_query.clone();
if ui_state.filter_cursor_position <= display_query.len() {
display_query.insert(ui_state.filter_cursor_position, '|');
}
display_query
} else {
ui_state.filter_query.clone()
};
let hint = if ui_state.filter_mode {
"↑↓ navigate · Enter confirm · Esc cancel "
} else {
"filter active · Esc clears "
};
let line = Line::from(vec![
Span::styled(" / ", theme::bold_fg(theme::accent())),
Span::raw(query),
]);
let hint_line = Line::from(Span::styled(hint, theme::fg(theme::muted()))).right_aligned();
// Hint first, query second: when the terminal is too narrow for both,
// the query (rendered later) wins the overlap.
f.render_widget(Paragraph::new(hint_line), area);
f.render_widget(Paragraph::new(line), area);
}
+94
View File
@@ -0,0 +1,94 @@
//! Startup splash shown while packet capture initializes: a breathing
//! accent glow, a spinning braille spinner, and an animated wave in
//! the same gradient family as the traffic graphs.
use std::time::Duration;
use ratatui::{
Frame,
layout::{Constraint, Direction, Layout, Rect},
style::Style,
text::{Line, Span},
widgets::Paragraph,
};
use crate::ui::{panel_block, theme, widgets::braille_graph};
/// Braille spinner frames, advanced every [`FRAME_MS`] of splash time.
const SPINNER: [char; 8] = ['⣾', '⣽', '⣻', '⢿', '⡿', '⣟', '⣯', '⣷'];
/// Splash animation frame length in milliseconds. Callers quantize
/// `elapsed` to this bucket so repeated draws within one frame are
/// byte-identical (and the first frame is deterministic for tests).
pub(in crate::ui) const FRAME_MS: u64 = 120;
pub(in crate::ui) fn draw_loading_screen(f: &mut Frame, elapsed: Duration) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage(40),
Constraint::Length(9),
Constraint::Percentage(40),
])
.split(f.area());
let panel = chunks[1];
let secs = elapsed.as_secs_f64();
// Breathing glow: brightness swings through the accent gradient on
// a ~2s cycle (same trick as flow's pulsing logo dot).
let breath = 0.5 + 0.5 * (secs * std::f64::consts::TAU / 2.0).sin();
let glow = theme::accent_wave(0.35 + 0.55 * breath);
let spinner = SPINNER[(elapsed.as_millis() as u64 / FRAME_MS) as usize % SPINNER.len()];
let loading_text = vec![
Line::from(""),
Line::from(vec![
Span::styled(format!("{spinner} "), theme::bold_fg(glow)),
Span::styled("Loading network connections...", Style::default()),
]),
Line::from(""),
Line::from(vec![Span::styled(
"This may take a few seconds",
theme::fg(theme::muted()),
)]),
];
let loading_paragraph = Paragraph::new(loading_text)
.alignment(ratatui::layout::Alignment::Center)
.block(panel_block("RustNet Monitor"));
f.render_widget(loading_paragraph, panel);
// Animated swell centered under the text, scrolling left, rendered
// with the same braille gradient engine as the traffic graphs.
// Purely decorative: a synthetic sine under a raised-sine envelope,
// so a few gentle crests rise in the middle and fade out toward
// the edges instead of a wall of waves spanning the panel.
if panel.width > 8 && panel.height >= 9 {
let wave_width = (panel.width - 4).min(40);
let wave_area = Rect::new(
panel.x + (panel.width - wave_width) / 2,
panel.y + panel.height - 3,
wave_width,
2,
);
let phase = secs * 6.0;
let dots = wave_area.width as usize * 2;
let samples: Vec<u64> = (0..dots)
.map(|i| {
let envelope = (std::f64::consts::PI * i as f64 / (dots - 1) as f64).sin();
(55.0 * envelope * (1.0 + (i as f64 * 0.22 + phase).sin())) as u64
})
.collect();
let lines = braille_graph::render(
&samples,
wave_area.width as usize,
2,
130.0,
0.0,
dots,
|intensity| theme::accent_wave((0.35 + 0.45 * breath) * intensity),
);
f.render_widget(Paragraph::new(lines), wave_area);
}
}
+10
View File
@@ -0,0 +1,10 @@
//! Reusable chrome widgets that wrap the main content area:
//! tabs bar at the top, filter input + status bar at the bottom,
//! and the loading screen shown during startup.
pub(super) mod braille_graph;
pub(super) mod filter_input;
pub(super) mod loading;
pub(super) mod scrollbar;
pub(super) mod status_bar;
pub(super) mod tabs_bar;
+94
View File
@@ -0,0 +1,94 @@
//! Shared vertical scrollbar for panes whose content can overflow:
//! the Overview connection table, the Details info panes, the Help
//! text, and the Interfaces table.
use ratatui::{
Frame,
layout::Rect,
style::{Color, Style},
widgets::{Scrollbar, ScrollbarOrientation, ScrollbarState},
};
use crate::ui::theme;
/// Render a vertical scrollbar on the right edge of `area` when the
/// content overflows the viewport. `position` is the scroll offset of
/// the topmost visible row; `viewport` is the number of rows currently
/// visible. No-op when everything fits. Styled to match the section
/// rules (and NO_COLOR-aware via `theme::fg`).
pub(in crate::ui) fn draw_scrollbar(
f: &mut Frame,
area: Rect,
total_rows: usize,
position: usize,
viewport: usize,
) {
if total_rows <= viewport {
return;
}
// ratatui sizes the thumb against `(content_length - 1) + viewport`, so the
// thumb only reaches the track bottom when `position == content_length - 1`
// (last row scrolled to the *top* of the viewport). Our `position` is a
// scroll offset clamped to `total_rows - viewport` (last row at the *bottom*
// of the viewport), so reporting `total_rows` as the content length leaves
// the thumb short by `viewport - 1` rows. Reporting the number of distinct
// scroll positions instead makes the thumb track the visible window
// `[position, position + viewport)` over `[0, total_rows)` and sit flush at
// the bottom when fully scrolled.
let scroll_positions = total_rows - viewport + 1;
let mut scrollbar_state = ScrollbarState::new(scroll_positions)
.position(position)
.viewport_content_length(viewport);
// Thumb in the terminal's default foreground so it matches the
// content; only the track recedes into the chrome gray. The fg
// must be set explicitly (`Color::Reset`), not left empty: ratatui
// styles are patches, and an empty patch lets the thumb inherit
// whatever color the underlying cells already have (on the Help
// tab the scrollbar rides the panel border, which is gray, and the
// thumb would blend into the track).
let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
.begin_symbol(None)
.end_symbol(None)
.track_style(theme::fg(theme::border()))
.thumb_style(Style::default().fg(Color::Reset));
f.render_stateful_widget(scrollbar, area, &mut scrollbar_state);
}
#[cfg(test)]
mod tests {
/// Render `draw_scrollbar` into a test buffer and report whether
/// any non-space glyph landed in the rightmost column (the scrollbar
/// track/thumb sits on the right border).
fn scrollbar_renders(total_rows: usize, position: usize, viewport: usize) -> bool {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use ratatui::layout::Rect;
let backend = TestBackend::new(20, 12);
let mut terminal = Terminal::new(backend).expect("test terminal");
terminal
.draw(|f| {
super::draw_scrollbar(f, Rect::new(0, 0, 20, 12), total_rows, position, viewport)
})
.expect("draw scrollbar");
let buffer = terminal.backend().buffer();
let right_x = 19;
(0..12).any(|y| buffer[(right_x, y)].symbol() != " ")
}
#[test]
fn scrollbar_hidden_when_content_fits() {
// 5 rows, 10-row viewport: nothing to scroll, no bar drawn.
assert!(!scrollbar_renders(5, 0, 10));
// Exactly fits is also a no-op.
assert!(!scrollbar_renders(10, 0, 10));
}
#[test]
fn scrollbar_shown_when_content_overflows() {
// 100 rows, 10-row viewport: bar must render on the right edge.
assert!(scrollbar_renders(100, 0, 10));
// Still drawn when scrolled into the middle of the list.
assert!(scrollbar_renders(100, 45, 10));
}
}
+76
View File
@@ -0,0 +1,76 @@
//! Bottom status line: shows tab-specific keybinds by default, or
//! transient confirmation prompts ("press q again to quit"),
//! filtered-count messages, and clipboard feedback when relevant.
use ratatui::{Frame, layout::Rect, widgets::Paragraph};
use crate::ui::{UIState, theme};
/// Status bar text per tab. Only Overview exposes connection-list shortcuts
/// (/, a, t, c); other tabs show just what actually works there.
fn default_status_line(selected_tab: usize) -> &'static str {
match selected_tab {
// Overview
0 => {
" 'h' help | 1-5 jump | Tab/[/] cycle | '/' filter | 'a' group | 't' history | 'i' info | 'c' copy"
}
// Details
1 => {
" 'h' help | 1-5 jump | Tab/[/] cycle | j/k prev/next | Ctrl-d/u scroll | 'c' copy remote addr | Esc back"
}
// Interfaces / Help (both scroll with j/k)
2 | 4 => " 'h' help | 1-5 jump | Tab/[/] cycle | j/k scroll | Esc back to Overview",
// Graph
_ => " 'h' help | 1-5 jump | Tab/[/] cycle | Esc back to Overview",
}
}
pub(in crate::ui) fn draw_status_bar(
f: &mut Frame,
ui_state: &UIState,
connection_count: usize,
area: Rect,
) {
let status = if ui_state.quit_confirmation {
" Press 'q' again to quit or any other key to cancel ".to_string()
} else if ui_state.clear_confirmation {
" Press 'x' again to clear all connections or any other key to cancel ".to_string()
} else if let Some((ref msg, ref time)) = ui_state.clipboard_message {
// Show clipboard message for 3 seconds
if time.elapsed().as_secs() < 3 {
format!(" {} ", msg)
} else {
default_status_line(ui_state.selected_tab).to_string()
}
} else if !ui_state.filter_query.is_empty() {
format!(
" 'h' help | 1-5 jump | Tab/[/] cycle | Showing {} filtered connections (Esc to clear) ",
connection_count
)
} else {
default_status_line(ui_state.selected_tab).to_string()
};
let style = if ui_state.quit_confirmation || ui_state.clear_confirmation {
theme::status_bar_confirm()
} else if ui_state.clipboard_message.is_some()
&& ui_state
.clipboard_message
.as_ref()
.unwrap()
.1
.elapsed()
.as_secs()
< 3
{
theme::status_bar_success()
} else {
theme::status_bar_default()
};
let status_bar = Paragraph::new(status)
.style(style)
.alignment(ratatui::layout::Alignment::Left);
f.render_widget(status_bar, area);
}
+98
View File
@@ -0,0 +1,98 @@
//! Top tab bar — a borderless two-row strip: the brand + numbered tab
//! titles on the first row, and an underline rule on the second with a
//! heavy accent segment under the active tab. The heavy ━ vs light ─
//! glyph difference keeps the active tab readable under NO_COLOR.
//! Click regions cover both rows so a click on the underline works too.
use ratatui::{
Frame,
layout::Rect,
text::{Line, Span},
widgets::Paragraph,
};
use crate::ui::{ClickAction, ClickableRegions, UIState, theme};
pub(crate) const TAB_TITLES: [&str; 5] = ["Overview", "Details", "Interfaces", "Graph", "Help"];
/// Total number of tabs (kept in sync with `TAB_TITLES`).
pub(crate) const TAB_COUNT: usize = TAB_TITLES.len();
/// Index of the Help tab. Lets `UIState::jump_to_tab` keep `show_help` in
/// sync without re-checking `TAB_TITLES` at the call site.
pub(crate) const HELP_TAB_INDEX: usize = TAB_COUNT - 1;
/// Height of the tab bar in rows (titles + underline).
pub(crate) const TABS_BAR_HEIGHT: u16 = 2;
const BRAND: &str = " rustnet ";
/// Gap between tab titles, in cells.
const TAB_GAP: u16 = 3;
pub(in crate::ui) fn draw_tabs(
f: &mut Frame,
ui_state: &UIState,
area: Rect,
click_regions: &mut ClickableRegions,
) {
let mut title_spans: Vec<Span> = vec![Span::styled(BRAND, theme::primary())];
let mut underline_spans: Vec<Span> = vec![Span::styled(
"".repeat(BRAND.chars().count()),
theme::fg(theme::border()),
)];
let gap = " ".repeat(TAB_GAP as usize);
let mut x_offset = area.x + BRAND.chars().count() as u16;
for (i, title) in TAB_TITLES.iter().enumerate() {
// Numbered titles: the 1-5 jump shortcut becomes discoverable.
let label = format!("{} {}", i + 1, title);
let label_width = label.chars().count() as u16;
let active = i == ui_state.selected_tab;
title_spans.push(Span::raw(gap.clone()));
if active {
title_spans.push(Span::styled(
format!("{} ", i + 1),
theme::fg(theme::accent()),
));
title_spans.push(Span::styled((*title).to_string(), theme::primary()));
} else {
title_spans.push(Span::styled(label.clone(), theme::fg(theme::muted())));
}
underline_spans.push(Span::styled(
"".repeat(TAB_GAP as usize),
theme::fg(theme::border()),
));
let rule_glyph = if active { "" } else { "" };
let rule_style = if active {
theme::fg(theme::accent())
} else {
theme::fg(theme::border())
};
underline_spans.push(Span::styled(
rule_glyph.repeat(label_width as usize),
rule_style,
));
// Click region spans both rows (title + underline).
let tab_rect = Rect::new(x_offset + TAB_GAP, area.y, label_width, TABS_BAR_HEIGHT);
click_regions.register(tab_rect, ClickAction::SwitchTab(i));
x_offset += TAB_GAP + label_width;
}
// Extend the rule to the right edge of the bar.
let used: u16 = x_offset.saturating_sub(area.x);
if area.width > used {
underline_spans.push(Span::styled(
"".repeat((area.width - used) as usize),
theme::fg(theme::border()),
));
}
let titles = Paragraph::new(Line::from(title_spans));
let underline = Paragraph::new(Line::from(underline_spans));
f.render_widget(titles, Rect::new(area.x, area.y, area.width, 1));
if area.height >= TABS_BAR_HEIGHT {
f.render_widget(underline, Rect::new(area.x, area.y + 1, area.width, 1));
}
}