Skip to main content

rustnet_host/
lib.rs

1//! # rustnet-host
2//!
3//! Per-connection **process attribution** for
4//! [RustNet](https://github.com/domcyrus/rustnet): given a [`Connection`], find
5//! the owning process (pid + name). Each platform uses its best available
6//! strategy behind one [`ProcessLookup`] trait, selected by
7//! [`create_process_lookup`]:
8//!
9//! - **Linux** — eBPF socket tracking (with the `ebpf` feature) and a procfs
10//!   fallback.
11//! - **macOS** — PKTAP packet metadata when available (capture provides it
12//!   directly, so lookup is a no-op), otherwise `lsof`.
13//! - **Windows** — the IP Helper API (`GetExtendedTcpTable`/`...UdpTable`).
14//! - **FreeBSD** — `sockstat`.
15//!
16//! When a platform can't use its optimal method, [`ProcessLookup::get_degradation_reason`]
17//! reports why via [`DegradationReason`] (e.g. missing `CAP_BPF`, no root for
18//! PKTAP), which front-ends can surface to the user.
19//!
20//! This crate depends only on `rustnet-core` (for [`Connection`]/[`Protocol`]).
21//! It does not depend on `rustnet-capture`; on macOS the application injects
22//! whether PKTAP is active (via `report_pktap_degradation`) rather than this
23//! crate querying capture. It has no UI or capture-loop dependency, so headless
24//! tools can attribute processes the same way the `rustnet` TUI does.
25
26use anyhow::Result;
27use rustnet_core::network::types::{Connection, Protocol};
28use std::borrow::Cow;
29use std::collections::HashMap;
30use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
31
32/// Reasons why process detection may be degraded from optimal
33#[derive(Debug, Clone, PartialEq, Default)]
34pub enum DegradationReason {
35    /// No degradation - optimal method available
36    #[default]
37    None,
38    // Linux eBPF reasons
39    /// Missing CAP_BPF capability (Linux 5.8+)
40    #[cfg(target_os = "linux")]
41    MissingCapBpf,
42    /// Missing CAP_PERFMON capability (Linux 5.8+)
43    #[cfg(target_os = "linux")]
44    MissingCapPerfmon,
45    /// Missing both CAP_BPF and CAP_PERFMON (and no CAP_SYS_ADMIN fallback)
46    #[cfg(target_os = "linux")]
47    MissingBpfCapabilities,
48    /// eBPF feature not compiled in
49    #[cfg(all(target_os = "linux", not(feature = "ebpf")))]
50    EbpfFeatureDisabled,
51    /// Kernel doesn't support required eBPF features (e.g. ENOSYS from bpf(2))
52    #[cfg(target_os = "linux")]
53    KernelUnsupported,
54    /// BPF syscall denied despite caps - typically AppArmor, kernel lockdown,
55    /// or unprivileged_bpf_disabled interactions
56    #[cfg(target_os = "linux")]
57    BpfPermissionDenied,
58    /// Failed to attach a kprobe (e.g. symbol missing from kernel). The
59    /// String carries the symbol name where known.
60    #[cfg(target_os = "linux")]
61    KprobeAttachFailed(String),
62    /// Kernel BTF unavailable / CO-RE relocation failed (no /sys/kernel/btf/vmlinux)
63    #[cfg(target_os = "linux")]
64    BtfUnavailable,
65    /// Generic eBPF load failure carrying the truncated libbpf error text
66    #[cfg(target_os = "linux")]
67    EbpfLoadFailed(String),
68    /// Binary lives on a filesystem mounted with `nosuid`, which makes the
69    /// kernel silently ignore file capabilities set via `setcap`. Common when
70    /// the binary is under `/home`, `/tmp`, or a removable mount.
71    #[cfg(target_os = "linux")]
72    BinaryOnNosuidMount,
73    // macOS PKTAP reasons
74    /// No root privileges for PKTAP
75    #[cfg(target_os = "macos")]
76    MissingRootPrivileges,
77    /// Cannot access BPF devices (/dev/bpf*)
78    #[cfg(target_os = "macos")]
79    NoBpfDeviceAccess,
80    /// BPF filter specified (incompatible with PKTAP)
81    #[cfg(target_os = "macos")]
82    BpfFilterIncompatible,
83    /// Specific interface requested (PKTAP only works with pktap pseudo-device)
84    #[cfg(target_os = "macos")]
85    InterfaceSpecified,
86}
87
88impl DegradationReason {
89    /// Get human-readable description of what's needed
90    pub fn description(&self) -> Cow<'_, str> {
91        match self {
92            Self::None => Cow::Borrowed(""),
93            #[cfg(target_os = "linux")]
94            Self::MissingCapBpf => Cow::Borrowed("needs CAP_BPF"),
95            #[cfg(target_os = "linux")]
96            Self::MissingCapPerfmon => Cow::Borrowed("needs CAP_PERFMON"),
97            #[cfg(target_os = "linux")]
98            Self::MissingBpfCapabilities => Cow::Borrowed("needs CAP_BPF+CAP_PERFMON"),
99            #[cfg(all(target_os = "linux", not(feature = "ebpf")))]
100            Self::EbpfFeatureDisabled => Cow::Borrowed("eBPF feature disabled"),
101            #[cfg(target_os = "linux")]
102            Self::KernelUnsupported => Cow::Borrowed("kernel unsupported"),
103            #[cfg(target_os = "linux")]
104            Self::BpfPermissionDenied => Cow::Borrowed(
105                "BPF denied (check perf_event_paranoid / AppArmor / unprivileged_bpf_disabled)",
106            ),
107            #[cfg(target_os = "linux")]
108            Self::KprobeAttachFailed(sym) => {
109                if sym.is_empty() {
110                    Cow::Borrowed("kprobe attach failed")
111                } else {
112                    Cow::Owned(format!("kprobe attach failed: {sym}"))
113                }
114            }
115            #[cfg(target_os = "linux")]
116            Self::BtfUnavailable => Cow::Borrowed("kernel BTF unavailable"),
117            #[cfg(target_os = "linux")]
118            Self::EbpfLoadFailed(s) => Cow::Owned(format!("eBPF load failed: {s}")),
119            #[cfg(target_os = "linux")]
120            Self::BinaryOnNosuidMount => {
121                Cow::Borrowed("file caps ignored: binary on a nosuid mount")
122            }
123            #[cfg(target_os = "macos")]
124            Self::MissingRootPrivileges => Cow::Borrowed("needs root"),
125            #[cfg(target_os = "macos")]
126            Self::NoBpfDeviceAccess => Cow::Borrowed("no BPF device access"),
127            #[cfg(target_os = "macos")]
128            Self::BpfFilterIncompatible => Cow::Borrowed("BPF filter incompatible"),
129            #[cfg(target_os = "macos")]
130            Self::InterfaceSpecified => Cow::Borrowed("interface specified"),
131        }
132    }
133
134    /// Get the name of the unavailable feature
135    pub fn unavailable_feature(&self) -> Option<&str> {
136        match self {
137            Self::None => None,
138            #[cfg(target_os = "linux")]
139            Self::MissingCapBpf
140            | Self::MissingCapPerfmon
141            | Self::MissingBpfCapabilities
142            | Self::KernelUnsupported
143            | Self::BpfPermissionDenied
144            | Self::KprobeAttachFailed(_)
145            | Self::BtfUnavailable
146            | Self::EbpfLoadFailed(_)
147            | Self::BinaryOnNosuidMount => Some("eBPF"),
148            #[cfg(all(target_os = "linux", not(feature = "ebpf")))]
149            Self::EbpfFeatureDisabled => Some("eBPF"),
150            #[cfg(target_os = "macos")]
151            Self::MissingRootPrivileges
152            | Self::NoBpfDeviceAccess
153            | Self::BpfFilterIncompatible
154            | Self::InterfaceSpecified => Some("PKTAP"),
155        }
156    }
157}
158
159// Platform-specific modules (one cfg per platform instead of many)
160#[cfg(target_os = "freebsd")]
161mod freebsd;
162#[cfg(target_os = "linux")]
163mod linux;
164#[cfg(target_os = "macos")]
165mod macos;
166#[cfg(target_os = "windows")]
167mod windows;
168
169// Re-export the per-platform process-lookup factory.
170#[cfg(target_os = "freebsd")]
171pub use freebsd::create_process_lookup;
172#[cfg(target_os = "linux")]
173pub use linux::create_process_lookup;
174#[cfg(target_os = "macos")]
175pub use macos::{create_process_lookup, report_pktap_degradation};
176#[cfg(target_os = "windows")]
177pub use windows::create_process_lookup;
178
179/// Trait for platform-specific process lookup
180pub trait ProcessLookup: Send + Sync {
181    /// Look up process information for a connection
182    /// Returns (pid, process_name) if found
183    fn get_process_for_connection(&self, conn: &Connection) -> Option<(u32, String)>;
184
185    /// Refresh internal caches if any (best-effort)
186    fn refresh(&self) -> Result<()> {
187        Ok(()) // Default no-op
188    }
189
190    /// Get the detection method name for display purposes
191    fn get_detection_method(&self) -> &str;
192
193    /// Get the reason why process detection is degraded (if any)
194    /// Returns DegradationReason::None if using optimal detection method
195    fn get_degradation_reason(&self) -> DegradationReason {
196        DegradationReason::None // Default: no degradation
197    }
198
199    /// Fallback lookup that relaxes the connection key to handle sockets stored with
200    /// wildcard addresses in OS-level tables.
201    ///
202    /// Three shapes that actually appear in OS socket tables:
203    ///   1. (lip:lport, 0:0)      — listening on a specific local IP
204    ///   2. (0:lport,  rip:rport) — INADDR_ANY socket connected to a known remote
205    ///   3. (0:lport,  0:0)       — listening on INADDR_ANY
206    ///
207    /// If two candidates resolve to different processes the result is ambiguous and
208    /// `None` is returned to avoid mis-attribution.
209    fn fallback_lookup(
210        map: &HashMap<ConnectionKey, (u32, String)>,
211        key: &ConnectionKey,
212    ) -> Option<(u32, String)>
213    where
214        Self: Sized,
215    {
216        // Only TCP and UDP sockets appear in OS network tables with wildcard
217        // addresses. Other protocols (ICMP, IGMP, ARP) have no entries to fall back to.
218        if !matches!(key.protocol, Protocol::Tcp | Protocol::Udp) {
219            return None;
220        }
221
222        let zero = |addr: SocketAddr| -> IpAddr {
223            match addr {
224                SocketAddr::V4(_) => IpAddr::V4(Ipv4Addr::UNSPECIFIED),
225                SocketAddr::V6(_) => IpAddr::V6(Ipv6Addr::UNSPECIFIED),
226            }
227        };
228
229        let lip = key.local_addr.ip();
230        let lport = key.local_addr.port();
231        let rip = key.remote_addr.ip();
232        let rport = key.remote_addr.port();
233        let zlip = zero(key.local_addr);
234        let zrip = zero(key.remote_addr);
235
236        let candidates: [(IpAddr, u16, IpAddr, u16); 3] = [
237            (lip, lport, zrip, 0),     // 1. listening on specific local IP
238            (zlip, lport, rip, rport), // 2. INADDR_ANY with known remote
239            (zlip, lport, zrip, 0),    // 3. INADDR_ANY listener
240        ];
241
242        // Collect all matches across every candidate. If two candidates resolve to
243        // different processes the result is ambiguous — return nothing to avoid
244        // attributing traffic to the wrong process.
245        let mut found: Option<(u32, String)> = None;
246        for (l_ip, l_port, r_ip, r_port) in candidates {
247            let candidate = ConnectionKey {
248                protocol: key.protocol,
249                local_addr: SocketAddr::new(l_ip, l_port),
250                remote_addr: SocketAddr::new(r_ip, r_port),
251            };
252            if let Some(entry) = map.get(&candidate) {
253                match &found {
254                    None => found = Some(entry.clone()),
255                    Some(existing) if existing == entry => {} // same result, no conflict
256                    Some(_) => return None,                   // two different processes → ambiguous
257                }
258            }
259        }
260        found
261    }
262}
263
264/// Connection identifier for lookups
265#[derive(Debug, Clone, Hash, PartialEq, Eq)]
266pub struct ConnectionKey {
267    pub protocol: Protocol,
268    pub local_addr: SocketAddr,
269    pub remote_addr: SocketAddr,
270}
271
272impl ConnectionKey {
273    pub fn from_connection(conn: &Connection) -> Self {
274        Self {
275            protocol: conn.protocol,
276            local_addr: conn.local_addr,
277            remote_addr: conn.remote_addr,
278        }
279    }
280}