Skip to main content

gossan_engine/
probe.rs

1//! Runtime probe for the available packet I/O backend.
2//!
3//! `gossan-engine` always falls back to a slower backend when the
4//! preferred one is unavailable, but the user often needs to *know*
5//! which backend will run before they kick off a scan. This module
6//! exposes a deterministic, side-effect-free probe that surfaces:
7//!
8//! * compiled-in feature set (xdp, sendmmsg, pnet)
9//! * Linux kernel version (for the AF_XDP `>= 5.10` gate)
10//! * effective capabilities (CAP_BPF, CAP_NET_RAW)
11//! * libbpf availability
12//!
13//! Surface this from the CLI as `gossan probe-engine`. Surface it
14//! from a test harness to assert that benchmarks actually ran on the
15//! backend they claim to compare against masscan.
16
17use std::fmt;
18
19/// Concrete backend selected by [`netforge::engine::auto_select`] at runtime.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum Backend {
22    /// AF_XDP zero-copy via `xsk-rs` (Linux 5.10+, CAP_BPF).
23    Xdp,
24    /// Batched `sendmmsg(2)` raw sockets (Linux, CAP_NET_RAW).
25    Sendmmsg,
26    /// libpnet datalink (portable, slower).
27    Pnet,
28}
29
30impl fmt::Display for Backend {
31    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
32        f.write_str(match self {
33            Backend::Xdp => "xdp",
34            Backend::Sendmmsg => "sendmmsg",
35            Backend::Pnet => "pnet",
36        })
37    }
38}
39
40/// Result of probing the runtime for backend availability.
41///
42/// Each field is independently testable so a CLI / test can show a
43/// clear go / no-go matrix.
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct ProbeReport {
46    /// XDP feature compiled into this build.
47    pub xdp_compiled: bool,
48    /// sendmmsg feature compiled into this build.
49    pub sendmmsg_compiled: bool,
50    /// pnet feature compiled into this build.
51    pub pnet_compiled: bool,
52    /// Detected kernel version, if Linux. (major, minor) — patch is dropped.
53    pub kernel: Option<(u32, u32)>,
54    /// Process holds CAP_NET_RAW (best-effort: euid==0 is the proxy).
55    pub cap_net_raw: bool,
56    /// Best-effort probe for libbpf availability (file present).
57    pub libbpf_present: bool,
58    /// Backend [`netforge::engine::auto_select`] would pick *now*.
59    pub selected: Backend,
60}
61
62impl ProbeReport {
63    /// Render a multi-line table for human consumption (CLI output).
64    pub fn render_table(&self) -> String {
65        let yes = "✓";
66        let no = "✗";
67        let yn = |b: bool| if b { yes } else { no };
68        let kernel = self
69            .kernel
70            .map(|(maj, min)| format!("{maj}.{min}"))
71            .unwrap_or_else(|| "n/a".to_string());
72        format!(
73            concat!(
74                "engine probe:\n",
75                "  selected backend: {selected}\n",
76                "  kernel:           {kernel}\n",
77                "  CAP_NET_RAW:      {cnr}\n",
78                "  libbpf present:   {bpf}\n",
79                "  features:         xdp={xdp} sendmmsg={smm} pnet={pnet}\n",
80            ),
81            selected = self.selected,
82            kernel = kernel,
83            cnr = yn(self.cap_net_raw),
84            bpf = yn(self.libbpf_present),
85            xdp = yn(self.xdp_compiled),
86            smm = yn(self.sendmmsg_compiled),
87            pnet = yn(self.pnet_compiled),
88        )
89    }
90}
91
92fn detect_kernel() -> Option<(u32, u32)> {
93    if !cfg!(target_os = "linux") {
94        return None;
95    }
96    let raw = std::fs::read_to_string("/proc/sys/kernel/osrelease").ok()?;
97    let head = raw.trim().split('-').next()?;
98    let mut parts = head.split('.');
99    let maj: u32 = parts.next()?.parse().ok()?;
100    let min: u32 = parts.next()?.parse().ok()?;
101    Some((maj, min))
102}
103
104fn detect_cap_net_raw() -> bool {
105    // Best-effort proxy: euid==0 implies CAP_NET_RAW unless explicitly
106    // dropped. A more precise probe would parse /proc/self/status's
107    // CapEff bitmap, but euid==0 is the dominant production case.
108    #[cfg(target_os = "linux")]
109    unsafe {
110        libc::geteuid() == 0
111    }
112    #[cfg(not(target_os = "linux"))]
113    {
114        false
115    }
116}
117
118fn detect_libbpf() -> bool {
119    // libbpf is dynamically linked from xsk-rs. The library is named
120    // libbpf.so.1 on most distros and libbpf.so.0 on older ones.
121    for path in [
122        "/usr/lib/x86_64-linux-gnu/libbpf.so.1",
123        "/usr/lib/x86_64-linux-gnu/libbpf.so.0",
124        "/usr/lib64/libbpf.so.1",
125        "/usr/lib64/libbpf.so.0",
126    ] {
127        if std::path::Path::new(path).exists() {
128            return true;
129        }
130    }
131    false
132}
133
134/// Capture the engine selection logic in pure data: which backend
135/// would `auto_select` pick on this build, on this host, right now?
136///
137/// This intentionally mirrors `netforge::engine::auto_select`'s order:
138/// XDP > sendmmsg > pnet. We do NOT actually open a raw socket — that
139/// would change behavior for callers (e.g. depleting a syscall slot).
140pub fn probe() -> ProbeReport {
141    let xdp_compiled = cfg!(feature = "xdp");
142    // netforge ships sendmmsg + pnet by default on linux.
143    let sendmmsg_compiled = cfg!(target_os = "linux");
144    let pnet_compiled = true;
145
146    let kernel = detect_kernel();
147    let cap_net_raw = detect_cap_net_raw();
148    let libbpf_present = detect_libbpf();
149
150    // Selection mirror — must stay in lockstep with netforge auto_select.
151    let xdp_runnable = xdp_compiled
152        && cap_net_raw
153        && libbpf_present
154        && kernel.map_or(false, |(maj, min)| maj > 5 || (maj == 5 && min >= 10));
155    let sendmmsg_runnable = sendmmsg_compiled && cap_net_raw;
156
157    let selected = if xdp_runnable {
158        Backend::Xdp
159    } else if sendmmsg_runnable {
160        Backend::Sendmmsg
161    } else {
162        Backend::Pnet
163    };
164
165    ProbeReport {
166        xdp_compiled,
167        sendmmsg_compiled,
168        pnet_compiled,
169        kernel,
170        cap_net_raw,
171        libbpf_present,
172        selected,
173    }
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179
180    #[test]
181    fn probe_does_not_panic() {
182        let _ = probe();
183    }
184
185    #[test]
186    fn render_table_contains_all_rows() {
187        let r = probe();
188        let s = r.render_table();
189        for needle in [
190            "selected backend",
191            "kernel",
192            "CAP_NET_RAW",
193            "libbpf present",
194            "features",
195        ] {
196            assert!(s.contains(needle), "table missing row `{needle}`: {s}");
197        }
198    }
199
200    #[test]
201    fn unprivileged_never_picks_xdp() {
202        // We can't actually drop CAP_BPF inside the test runner; this
203        // case is the production reality (CI is non-root). XDP must
204        // require both compile-in AND CAP_NET_RAW.
205        let r = probe();
206        if !r.cap_net_raw {
207            assert_ne!(r.selected, Backend::Xdp);
208        }
209    }
210
211    #[test]
212    fn pnet_is_the_universal_fallback() {
213        let r = probe();
214        if !r.cap_net_raw {
215            assert_eq!(r.selected, Backend::Pnet);
216        }
217    }
218
219    #[test]
220    fn selected_backend_displays_lowercase() {
221        assert_eq!(format!("{}", Backend::Xdp), "xdp");
222        assert_eq!(format!("{}", Backend::Sendmmsg), "sendmmsg");
223        assert_eq!(format!("{}", Backend::Pnet), "pnet");
224    }
225
226    #[test]
227    fn kernel_parsing_handles_release_strings() {
228        // We can't override /proc/sys/kernel/osrelease, so we exercise
229        // the public surface. The probe must yield Some on Linux and
230        // None elsewhere.
231        let r = probe();
232        if cfg!(target_os = "linux") {
233            assert!(r.kernel.is_some(), "kernel must parse on linux");
234        } else {
235            assert!(r.kernel.is_none(), "kernel must be None off linux");
236        }
237    }
238}