Skip to content

Commit e53c367

Browse files
author
Simon Morley
committed
fix: fail hard on BPF init failure, no silent connect fallback
Engine::new() on Linux now returns Result<Self, String> instead of silently falling back to ConnectOnly when BPF is unavailable. The connect fallback produces fundamentally wrong results for CDN-fronted hosts — Cloudflare accepts TCP handshakes on filtered ports (21, 22, 143, etc.) making them appear "open" when only 80/443/8080/8443 are. Changes: - Engine::new() returns Err with diagnostic message on BPF failure - Engine::new_connect_only() added for explicit non-Linux/test use - CLI callers updated to propagate the Result - Stale XDP/TC cleanup: system command fallback (ip link / tc qdisc) when libbpf detach/destroy fails after a crash - 8 new tests: Engine::new Result handling, new_connect_only, cleanup command builders Empirical evidence (polkaspots.com via Cloudflare): connect fallback: 10 open, 4 filtered, TTL 0, WIN 0 XDP SYN scanner: 4 open, 10 filtered, TTL 57, WIN 65535
1 parent 7a7e88d commit e53c367

3 files changed

Lines changed: 271 additions & 80 deletions

File tree

src/cli/mod.rs

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -150,10 +150,7 @@ pub async fn run_scan(
150150
timeout_ms: u32,
151151
interface: Option<String>,
152152
) -> Result<ScanResult, String> {
153-
let engine = Engine::new(ScanEngineConfig {
154-
interface: interface.clone(),
155-
passthrough: false,
156-
});
153+
let engine = create_engine(interface.clone())?;
157154

158155
let request = ScanRequest {
159156
request_id: Uuid::new_v4(),
@@ -175,6 +172,26 @@ pub async fn run_scan(
175172

176173
use crate::ScanResult;
177174

175+
/// Create an Engine, handling the platform-specific return type.
176+
///
177+
/// On Linux, `Engine::new()` returns `Result` (fails hard if BPF unavailable).
178+
/// On non-Linux, returns the engine directly (connect-only for dev).
179+
#[cfg(target_os = "linux")]
180+
fn create_engine(interface: Option<String>) -> Result<Engine, String> {
181+
Engine::new(ScanEngineConfig {
182+
interface,
183+
passthrough: false,
184+
})
185+
}
186+
187+
#[cfg(not(target_os = "linux"))]
188+
fn create_engine(interface: Option<String>) -> Result<Engine, String> {
189+
Ok(Engine::new(ScanEngineConfig {
190+
interface,
191+
passthrough: false,
192+
}))
193+
}
194+
178195
// ─────────────────────────────────────────────────────────────────────────────
179196
// Timing subcommand
180197
// ─────────────────────────────────────────────────────────────────────────────
@@ -192,10 +209,7 @@ pub async fn run_time(
192209
) -> Result<(), String> {
193210
let (_, hostname) = resolve_target(target)?;
194211

195-
let engine = Engine::new(ScanEngineConfig {
196-
interface,
197-
passthrough: false,
198-
});
212+
let engine = create_engine(interface)?;
199213

200214
let request = TimingRequest {
201215
request_id: Uuid::new_v4(),

src/engine.rs

Lines changed: 117 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -76,59 +76,85 @@ pub fn resolve_target(target: &str) -> Result<(Ipv4Addr, Option<String>), String
7676
// ─────────────────────────────────────────────────────────────────────────────
7777

7878
impl 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

Comments
 (0)