@@ -76,59 +76,85 @@ pub fn resolve_target(target: &str) -> Result<(Ipv4Addr, Option<String>), String
7676// ─────────────────────────────────────────────────────────────────────────────
7777
7878impl Engine {
79- /// Create a new scanning engine.
79+ /// Create a new BPF-backed scanning engine.
8080 ///
81- /// Attempts to initialize BPF timing backend. Falls back to `ConnectOnly`
82- /// when BPF is unavailable (non-Linux, missing CAP_BPF, etc.).
81+ /// On Linux, initializes BPF timing backend and **fails hard** if BPF is
82+ /// unavailable (missing CAP_BPF, stale XDP programs, etc.). The connect-scan
83+ /// fallback is intentionally removed — it produces incorrect results for
84+ /// CDN-fronted hosts where Cloudflare accepts TCP handshakes on filtered ports.
85+ ///
86+ /// On non-Linux, returns `ConnectOnly` (for development only).
87+ ///
88+ /// Use [`Engine::new_connect_only`] when you explicitly want TCP connect
89+ /// scanning (tests, non-Linux dev environments).
90+ #[ cfg( not( target_os = "linux" ) ) ]
8391 pub fn new ( config : ScanEngineConfig ) -> Self {
92+ let iface = config. interface . unwrap_or_else ( || "lo0" . to_string ( ) ) ;
93+ tracing:: warn!( "BPF unavailable on non-Linux — connect-scan fallback active" ) ;
94+ Engine :: ConnectOnly { interface : iface }
95+ }
96+
97+ /// Create a new BPF-backed scanning engine.
98+ ///
99+ /// On Linux, initializes BPF timing backend and **fails hard** if BPF is
100+ /// unavailable (missing CAP_BPF, stale XDP programs, etc.). The connect-scan
101+ /// fallback is intentionally removed — it produces incorrect results for
102+ /// CDN-fronted hosts where Cloudflare accepts TCP handshakes on filtered ports.
103+ ///
104+ /// # Errors
105+ ///
106+ /// Returns `Err` with a diagnostic message if:
107+ /// - BPF timing backend cannot be initialized (permissions, stale programs)
108+ /// - SYN scanner creation fails (no IPv4 on interface, raw socket error)
109+ #[ cfg( target_os = "linux" ) ]
110+ pub fn new ( config : ScanEngineConfig ) -> Result < Self , String > {
84111 if config. passthrough {
85112 tracing:: info!( "passthrough mode: using raw socket TX, no AF_XDP redirect" ) ;
86113 }
87114
88- #[ cfg( not( target_os = "linux" ) ) ]
89- {
90- let iface = config. interface . unwrap_or_else ( || "lo0" . to_string ( ) ) ;
91- tracing:: warn!( "BPF unavailable on non-Linux — connect-scan fallback active" ) ;
92- return Engine :: ConnectOnly { interface : iface } ;
93- }
115+ let ( backend, collector) = crate :: timing:: detect_timing_backend ( & config. interface )
116+ . map_err ( |e| {
117+ format ! (
118+ "BPF timing backend unavailable: {e}. \
119+ Limpet requires BPF (CAP_BPF + CAP_NET_ADMIN) for accurate SYN scanning. \
120+ If stale programs are attached, try: \
121+ sudo ip link set dev <iface> xdpgeneric off && \
122+ sudo tc qdisc del dev <iface> clsact"
123+ )
124+ } ) ?;
125+
126+ let iface = collector. interface ( ) . to_string ( ) ;
127+ tracing:: info!(
128+ backend = %backend,
129+ interface = %iface,
130+ passthrough = config. passthrough,
131+ "BPF timing backend initialised"
132+ ) ;
94133
95- #[ cfg( target_os = "linux" ) ]
96- {
97- match crate :: timing:: detect_timing_backend ( & config. interface ) {
98- Ok ( ( backend, collector) ) => {
99- let iface = collector. interface ( ) . to_string ( ) ;
100- tracing:: info!(
101- backend = %backend,
102- interface = %iface,
103- passthrough = config. passthrough,
104- "BPF timing backend initialised"
105- ) ;
106- let bpf = Arc :: new ( Mutex :: new ( collector) ) ;
107- match Self :: create_scanner ( & iface, & bpf, config. passthrough ) {
108- Some ( scanner) => Engine :: Bpf ( ScanEngine {
109- collector : bpf,
110- scanner,
111- interface : iface,
112- backend,
113- passthrough : config. passthrough ,
114- } ) ,
115- None => {
116- tracing:: warn!(
117- "scanner creation failed — connect-scan fallback active"
118- ) ;
119- Engine :: ConnectOnly { interface : iface }
120- }
121- }
122- }
123- Err ( e) => {
124- tracing:: warn!(
125- error = %e,
126- "BPF timing backend unavailable — connect-scan fallback active"
127- ) ;
128- let iface = config. interface . unwrap_or_else ( || "eth0" . to_string ( ) ) ;
129- Engine :: ConnectOnly { interface : iface }
130- }
131- }
134+ let bpf = Arc :: new ( Mutex :: new ( collector) ) ;
135+ let scanner = Self :: create_scanner ( & iface, & bpf, config. passthrough ) . ok_or_else ( || {
136+ format ! (
137+ "SYN scanner creation failed on interface '{iface}'. \
138+ Check that the interface has an IPv4 address and raw socket permissions."
139+ )
140+ } ) ?;
141+
142+ Ok ( Engine :: Bpf ( ScanEngine {
143+ collector : bpf,
144+ scanner,
145+ interface : iface,
146+ backend,
147+ passthrough : config. passthrough ,
148+ } ) )
149+ }
150+
151+ /// Create a connect-only engine explicitly.
152+ ///
153+ /// For tests and non-Linux development only. Does **not** use BPF — results
154+ /// will be inaccurate for CDN-fronted hosts.
155+ pub fn new_connect_only ( interface : & str ) -> Self {
156+ Engine :: ConnectOnly {
157+ interface : interface. to_string ( ) ,
132158 }
133159 }
134160
@@ -689,22 +715,57 @@ mod tests {
689715 assert_eq ! ( result. ports. len( ) , 3 , "max_ports=3 should truncate to 3 ports" ) ;
690716 }
691717
692- // ── Engine::new fallback ───────────────────── ──────────────────────────
718+ // ── Engine::new — fail hard on BPF failure ──────────────────────────
693719
720+ #[ cfg( target_os = "linux" ) ]
694721 #[ test]
695- fn test_engine_new_returns_some_variant ( ) {
696- // On non-Linux or unprivileged, returns ConnectOnly .
697- // On Linux with BPF, returns Bpf.
722+ fn test_engine_new_returns_result ( ) {
723+ // On unprivileged Linux, Engine::new() returns Err (BPF unavailable) .
724+ // On privileged Linux with BPF, returns Ok( Bpf) .
698725 // Either way, it must not panic.
726+ let result = Engine :: new ( ScanEngineConfig {
727+ interface : None ,
728+ passthrough : true ,
729+ } ) ;
730+ match result {
731+ Ok ( engine) => {
732+ let backend = engine. backend_str ( ) ;
733+ assert ! (
734+ backend == "xdp" || backend == "xdp-hybrid" ,
735+ "BPF engine must be xdp or xdp-hybrid, got '{backend}'"
736+ ) ;
737+ }
738+ Err ( e) => {
739+ assert ! (
740+ !e. is_empty( ) ,
741+ "error message must not be empty"
742+ ) ;
743+ }
744+ }
745+ }
746+
747+ #[ cfg( not( target_os = "linux" ) ) ]
748+ #[ test]
749+ fn test_engine_new_returns_connect_only_on_non_linux ( ) {
699750 let engine = Engine :: new ( ScanEngineConfig {
700751 interface : None ,
701752 passthrough : true ,
702753 } ) ;
703- // Just verify it doesn't panic and has a valid backend
704- let backend = engine. backend_str ( ) ;
705- assert ! (
706- backend == "connect" || backend == "xdp" || backend == "xdp-hybrid" ,
707- "backend must be one of connect/xdp/xdp-hybrid, got '{backend}'"
708- ) ;
754+ assert_eq ! ( engine. backend_str( ) , "connect" ) ;
755+ }
756+
757+ // ── Engine::new_connect_only ──────────────────────────────────────────
758+
759+ #[ test]
760+ fn test_engine_new_connect_only_returns_connect_backend ( ) {
761+ let engine = Engine :: new_connect_only ( "eth0" ) ;
762+ assert_eq ! ( engine. backend_str( ) , "connect" ) ;
763+ assert_eq ! ( engine. interface( ) , "eth0" ) ;
764+ }
765+
766+ #[ test]
767+ fn test_engine_new_connect_only_custom_interface ( ) {
768+ let engine = Engine :: new_connect_only ( "wlan0" ) ;
769+ assert_eq ! ( engine. interface( ) , "wlan0" ) ;
709770 }
710771}
0 commit comments