chore: import upstream snapshot with attribution
This commit is contained in:
+3070
File diff suppressed because it is too large
Load Diff
+227
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod pcapng;
|
||||
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)"));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
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
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
---
|
||||
source: src/ui/mod.rs
|
||||
assertion_line: 652
|
||||
expression: output
|
||||
---
|
||||
/ | ↑↓ navigate · Enter confirm · Esc cancel
|
||||
+6
@@ -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 │
|
||||
│ │
|
||||
│ ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣠⣶⣿⣿⣿⣿⣷⣤⡀⠀⠀⠀⠀⠀⢀⣠⣤⣤⣤⣄⡀⠀⠀⠀⠀⠀ │
|
||||
│ ⣀⣠⣴⣾⣿⣿⣿⣷⣦⣄⣀⣀⣠⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣦⣀⣀⣠⣴⣿⣿⣿⣿⣿⣿⣿⣶⣤⣀⣀⠀ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
+6
@@ -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
|
||||
+6
@@ -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
|
||||
────────────━━━━━━━━━━──────────────────────────────────────────────────────────
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user