Skip to main content

gossan_engine/
scan.rs

1//! Core SYN scan engine implementing the Gossan [`Scanner`] trait.
2//!
3//! Orchestrates the full scan pipeline:
4//! 1. Resolve targets to IPs
5//! 2. Build SYN packet template
6//! 3. Schedule probes via Blackrock permutation
7//! 4. TX thread: stamp and send packets at configured rate
8//! 5. RX thread: receive SYN-ACKs, verify stateless cookies
9//! 6. Emit discovered services as `Target::Service`
10
11use std::collections::HashMap;
12use std::net::{IpAddr, Ipv4Addr, UdpSocket};
13use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
14use std::sync::{Arc, RwLock};
15use std::time::{Duration, Instant};
16
17use futures::StreamExt;
18use gossan_classify::BannerClassifier;
19use tokio::io::{AsyncReadExt, AsyncWriteExt};
20use tokio::net::TcpStream;
21
22use async_trait::async_trait;
23use gossan_core::{
24    Config, HostTarget, PortMode, Protocol, ScanInput, Scanner, ServiceTarget, Target,
25};
26use netforge::engine::{tcp_flags, RxPacket};
27use netforge::packet;
28use netforge::seq::SeqEncoder;
29
30use crate::rate::RateLimiter;
31use crate::schedule::BlackrockPermutation;
32
33/// Raw SYN scanner using netforge packet engine.
34///
35/// Performs stateless SYN scanning with randomized probe ordering,
36/// configurable rate limiting, and OS fingerprinting from SYN-ACK responses.
37pub struct EngineScanner {
38    /// Secret for stateless cookie generation.
39    encoder: SeqEncoder,
40}
41
42impl EngineScanner {
43    /// Create a new engine scanner.
44    #[must_use]
45    pub fn new() -> Self {
46        Self {
47            encoder: SeqEncoder::new(),
48        }
49    }
50}
51
52impl Default for EngineScanner {
53    fn default() -> Self {
54        Self::new()
55    }
56}
57
58/// OS fingerprint heuristic from TTL and window size.
59fn identify_os(ttl: u8, window: u16) -> Option<&'static str> {
60    match ttl {
61        62..=64 => Some("Linux/Unix"),
62        126..=128 => Some("Windows"),
63        254..=255 => Some("Cisco/Network Device"),
64        _ => match window {
65            // Some BSD variants use TTL=48 or TTL=50
66            _ if ttl >= 48 && ttl <= 50 => Some("BSD"),
67            _ => None,
68        },
69    }
70}
71
72/// Response collected from RX thread.
73struct SynAckResponse {
74    ip: Ipv4Addr,
75    port: u16,
76    ttl: u8,
77    window: u16,
78}
79
80/// Per-/24 RST-burst backoff table. Shared between the RX thread (writer)
81/// and TX threads (readers). When a /24 sends RSTs over a sustained rate
82/// the RX thread inserts (slash24 → backoff_until). TX threads consult
83/// this map before queuing a probe and skip subnets that are actively
84/// rejecting our scan — which both saves the network and prevents the
85/// remote firewall from learning to drop us harder.
86///
87/// The slash24 key is the /24 prefix packed as `u32::from_be_bytes([a,
88/// b, c, 0])` — the high 24 bits are the prefix, low 8 are zero.
89#[derive(Clone, Default)]
90pub(crate) struct Slash24Backoff {
91    inner: Arc<RwLock<HashMap<u32, Instant>>>,
92    /// Total probes the TX side declined to send because the destination
93    /// /24 was in active backoff. Surfaced as an info log at scan end so
94    /// operators can see how aggressive the remote network was.
95    pub(crate) skipped: Arc<AtomicU64>,
96}
97
98impl Slash24Backoff {
99    fn new() -> Self {
100        Self::default()
101    }
102
103    /// Insert a /24 into backoff for `duration` from now. Concurrent
104    /// inserts from the RX thread keep the latest expiry.
105    fn block(&self, slash24: u32, duration: Duration) {
106        let until = Instant::now() + duration;
107        // RwLock write-lock; uncontended hot path because the only
108        // writer is the RX thread once per second.
109        if let Ok(mut g) = self.inner.write() {
110            // Don't shrink an existing longer backoff window.
111            let entry = g.entry(slash24).or_insert(until);
112            if *entry < until {
113                *entry = until;
114            }
115        }
116    }
117
118    /// True if the /24 is currently blocked. Returns false when the
119    /// stored expiry is in the past (lazy cleanup happens in `prune`).
120    #[inline]
121    fn is_blocked(&self, slash24: u32) -> bool {
122        let g = match self.inner.read() {
123            Ok(g) => g,
124            Err(_) => return false, // poisoned lock = open by default
125        };
126        match g.get(&slash24) {
127            Some(until) => *until > Instant::now(),
128            None => false,
129        }
130    }
131
132    /// Drop expired entries. Called from the RX thread once per window
133    /// flush so the map stays small under long scans.
134    fn prune(&self) {
135        let now = Instant::now();
136        if let Ok(mut g) = self.inner.write() {
137            g.retain(|_, until| *until > now);
138        }
139    }
140}
141
142#[inline]
143fn slash24_of(ip: Ipv4Addr) -> u32 {
144    let o = ip.octets();
145    u32::from_be_bytes([o[0], o[1], o[2], 0])
146}
147
148/// Discover the primary outgoing local IPv4 address.
149fn get_local_ip(config: &Config) -> anyhow::Result<Ipv4Addr> {
150    let target = config
151        .resolvers
152        .first()
153        .map(std::string::ToString::to_string)
154        .unwrap_or_else(|| "8.8.8.8".to_string());
155
156    let socket = UdpSocket::bind("0.0.0.0:0")?;
157    socket.connect(format!("{target}:53"))?;
158    if let IpAddr::V4(addr) = socket.local_addr()?.ip() {
159        Ok(addr)
160    } else {
161        anyhow::bail!("could not determine local IPv4 route")
162    }
163}
164
165/// Resolve port mode to a concrete list of ports.
166fn resolve_ports(mode: &PortMode) -> Vec<u16> {
167    match mode {
168        PortMode::Default => vec![
169            80, 443, 22, 21, 23, 25, 53, 110, 143, 3306, 5432, 8080, 8443, 6379, 27017, 9200, 3000,
170            5000, 8000, 9000,
171        ],
172        PortMode::Top100 => vec![
173            80, 443, 22, 21, 25, 53, 110, 143, 993, 995, 8080, 8443, 3306, 5432, 3389, 5900, 1723,
174            8000, 8888, 9090, 1433, 389, 636, 161, 162, 123, 69, 514, 5060, 5061, 2049, 111, 135,
175            139, 445, 1521, 1080, 3128, 8081, 9000, 9200, 9300, 6379, 27017, 11211, 5672, 15672,
176            4369, 25672, 6443, 2379, 2380, 10250, 10255, 4194, 8001, 8002, 8003, 8004, 8005, 8006,
177            8007, 8008, 8009, 8010, 8181, 8282, 8383, 8484, 8585, 8686, 8787, 8888, 9999, 7070,
178            7071, 7072, 7443, 4443, 4040, 5000, 5001, 5002, 5003, 5004, 5005, 5006, 5007, 5008,
179            5009, 5010, 6000, 6001, 6002, 6003, 6004, 6005, 6006, 6007, 6008, 6009, 6010,
180        ],
181        PortMode::Top1000 => {
182            // Nmap top 1000 — using a representative subset
183            (1..=1024)
184                .chain(
185                    [
186                        1433, 1521, 2049, 2379, 3000, 3128, 3306, 3389, 4443, 5000, 5432, 5672,
187                        5900, 6379, 6443, 7070, 8000, 8080, 8443, 8888, 9000, 9090, 9200, 9300,
188                        10250, 11211, 15672, 27017,
189                    ]
190                    .iter()
191                    .copied(),
192                )
193                .collect()
194        }
195        PortMode::Full => (1..=65535).collect(),
196        PortMode::Custom(ports) => ports.clone(),
197    }
198}
199
200#[async_trait]
201impl Scanner for EngineScanner {
202    fn name(&self) -> &'static str {
203        "engine"
204    }
205
206    fn tags(&self) -> &[&'static str] {
207        &["active", "network", "portscan", "raw", "engine"]
208    }
209
210    fn accepts(&self, target: &Target) -> bool {
211        matches!(target, Target::Host(_) | Target::Domain(_))
212    }
213
214    async fn run(&self, input: ScanInput, config: &Config) -> anyhow::Result<()> {
215        let source_ip = get_local_ip(config)?;
216        let source_port = 49152 + (std::process::id() as u16 % 16383);
217        let ports = resolve_ports(&config.port_mode);
218
219        // Resolve all targets to IPv4 addresses
220        let mut target_ips: Vec<(Ipv4Addr, Target)> = Vec::new();
221
222        // Drain incoming targets
223        let mut incoming = Vec::new();
224        {
225            let mut rx = input.target_rx.lock().await;
226            while let Ok(t) = rx.try_recv() {
227                incoming.push(t);
228            }
229        }
230
231        for t in &incoming {
232            match t {
233                Target::Host(h) => {
234                    if let IpAddr::V4(ipv4) = h.ip {
235                        target_ips.push((ipv4, t.clone()));
236                    }
237                }
238                Target::Domain(d) => {
239                    if let Ok(addrs) = input.resolver.lookup_ip(format!("{}.", d.domain)).await {
240                        for addr in addrs {
241                            if let IpAddr::V4(ipv4) = addr {
242                                target_ips.push((ipv4, t.clone()));
243                                break;
244                            }
245                        }
246                    }
247                }
248                _ => {}
249            }
250        }
251
252        if target_ips.is_empty() {
253            tracing::warn!("no targets resolved to IPv4 addresses");
254            return Ok(());
255        }
256
257        tracing::info!(
258            targets = target_ips.len(),
259            ports = ports.len(),
260            total_probes = target_ips.len() * ports.len(),
261            rate_pps = config.rate_limit,
262            "starting SYN scan via engine"
263        );
264
265        // Build packet template
266        let template = packet::build_syn_template(source_ip, source_port);
267
268        // Set up result channel
269        let (res_tx, res_rx) = crossbeam_channel::bounded(500_000);
270
271        // RX socket — single shared raw socket for receive (all SYN-ACKs
272        // come back to whichever fd kernel hands them to since we only
273        // bind by source IP, not port). The TX side opens its own raw
274        // socket per thread inside the parallel-TX block below.
275        let engine_config_rx = netforge::EngineConfig {
276            source_ip,
277            source_port_start: source_port,
278            source_port_end: source_port + 1,
279            rate_pps: config.rate_limit as u64,
280            ..Default::default()
281        };
282        let rx_engine = netforge::engine::auto_select(engine_config_rx)?;
283
284        // RX thread: collect SYN-ACKs. The dst_port filter is widened
285        // to the per-thread source-port range so SYN-ACKs replying to
286        // any TX thread are accepted (each TX thread uses a unique
287        // ephemeral source port; see GOSSAN_TX_THREADS comment below).
288        let stop_flag = Arc::new(AtomicBool::new(false));
289        let rx_stop = Arc::clone(&stop_flag);
290        let rx_encoder = SeqEncoder::with_cookie(self.encoder.cookie().clone());
291        let rx_source_port_base = source_port;
292
293        // Backoff table shared with all TX threads. RX writes; TX reads.
294        let backoff = Slash24Backoff::new();
295        let rx_backoff = backoff.clone();
296
297        let rx_handle = std::thread::spawn(move || {
298            let mut rx_buf = vec![
299                RxPacket {
300                    packet: netforge::RawPacket::empty(),
301                    src_ip: Ipv4Addr::UNSPECIFIED,
302                    src_port: 0,
303                    dst_port: 0,
304                    tcp_flags: 0,
305                    ack_num: 0,
306                    seq_num: 0,
307                    ttl: 0,
308                    window: 0,
309                    payload: Vec::new(),
310                };
311                256
312            ];
313
314            // Adaptive RST-burst detection: count RST packets per /24
315            // subnet over a 1-second sliding window. When a single /24
316            // exceeds RST_BURST_THRESHOLD per second, log a warning so
317            // the operator knows that subnet is actively rejecting our
318            // probes — a signal masscan does not surface at all.
319            const RST_BURST_THRESHOLD: u32 = 100;
320            let mut rst_count_per_24: HashMap<u32, u32> = HashMap::new();
321            let mut last_rst_window = std::time::Instant::now();
322
323            while !rx_stop.load(Ordering::Relaxed) {
324                let count = rx_engine.rx_batch(&mut rx_buf).unwrap_or(0);
325                for i in 0..count {
326                    let pkt = &rx_buf[i];
327
328                    // Track RSTs by /24 subnet for adaptive backoff.
329                    if pkt.tcp_flags & tcp_flags::RST != 0
330                        && pkt.dst_port >= rx_source_port_base
331                        && pkt.dst_port < rx_source_port_base + 8
332                    {
333                        let octets = pkt.src_ip.octets();
334                        let slash24 = u32::from_be_bytes([octets[0], octets[1], octets[2], 0]);
335                        *rst_count_per_24.entry(slash24).or_insert(0) += 1;
336                    }
337
338                    // Filter: only SYN-ACKs whose dst_port falls inside
339                    // the TX-thread port range [base, base+8). 8 is the
340                    // max TX thread count we cap to.
341                    if pkt.dst_port < rx_source_port_base || pkt.dst_port >= rx_source_port_base + 8
342                    {
343                        continue;
344                    }
345                    if pkt.tcp_flags & tcp_flags::SYN_ACK != tcp_flags::SYN_ACK {
346                        continue;
347                    }
348
349                    // Verify stateless cookie. The cookie includes the
350                    // dst_port so verify with the actual port the SYN
351                    // was sent from (= pkt.dst_port from RX perspective).
352                    if rx_encoder.verify_synack(pkt.ack_num, pkt.src_ip, pkt.src_port, pkt.dst_port)
353                    {
354                        let _ = res_tx.try_send(SynAckResponse {
355                            ip: pkt.src_ip,
356                            port: pkt.src_port,
357                            ttl: pkt.ttl,
358                            window: pkt.window,
359                        });
360                    }
361                }
362
363                // RST window flush: once a second, log any /24 over
364                // the burst threshold AND mark it for TX-side backoff.
365                // The TX threads consult `rx_backoff` before queuing
366                // each probe and will skip blocked /24s for the
367                // duration below — masscan does not do this and gets
368                // throttled harder by upstream firewalls as a result.
369                let now = std::time::Instant::now();
370                if now.duration_since(last_rst_window).as_secs() >= 1 {
371                    const BACKOFF_DURATION: Duration = Duration::from_secs(30);
372                    for (slash24, n) in &rst_count_per_24 {
373                        if *n >= RST_BURST_THRESHOLD {
374                            let octets = slash24.to_be_bytes();
375                            tracing::warn!(
376                                subnet = format!("{}.{}.{}.0/24", octets[0], octets[1], octets[2]),
377                                rst_per_sec = n,
378                                backoff_s = BACKOFF_DURATION.as_secs(),
379                                "engine: RST burst detected — entering backoff"
380                            );
381                            rx_backoff.block(*slash24, BACKOFF_DURATION);
382                        }
383                    }
384                    rx_backoff.prune();
385                    rst_count_per_24.clear();
386                    last_rst_window = now;
387                }
388
389                if count == 0 {
390                    std::thread::sleep(std::time::Duration::from_micros(100));
391                }
392            }
393        });
394
395        // ── Parallel TX ────────────────────────────────────────────────
396        // Multiple TX threads each own:
397        //   - A separate raw socket (separate netforge engine handle).
398        //     Linux's raw-socket egress doesn't lock per-fd, so multiple
399        //     fds give linear speedup until the NIC is saturated.
400        //   - An exclusive stride of the global probe schedule. Thread
401        //     N processes global indices [N, N+num_threads, N+2*num_threads, ...].
402        //   - Its own rate limiter sized to (total_rate / num_threads)
403        //     so the aggregate rate matches user config.
404        //   - Its own batch buffer (1.5 MB) and SeqEncoder bound to the
405        //     shared cookie so RX can verify SYN-ACKs from any TX thread.
406        //
407        // Hot-loop choices that matter:
408        //   - Pre-allocate batch ONCE with TX_BATCH template clones; reuse.
409        //   - stamp_syn fully overwrites the per-probe bytes — no
410        //     copy_from_slice needed each iteration.
411        //   - Per-batch rate-limit consume (one spin-wait per batch
412        //     instead of per probe).
413        //   - 1024 pkts/batch matches kernel mmsghdr cap.
414        const TX_BATCH: usize = 1024;
415        let num_ips = target_ips.len() as u64;
416        let num_ports = ports.len() as u64;
417        let total_probes = num_ips.saturating_mul(num_ports);
418        let schedule_seed: u64 = fastrand::u64(..);
419
420        // Number of TX threads — capped at 8 because beyond ~4 we hit
421        // kernel softirq / ring contention on most NICs and the next
422        // win is moving to AF_XDP (the next backend). Honour an env
423        // override for ops to dial up/down without recompiling.
424        let num_tx_threads: usize = std::env::var("GOSSAN_TX_THREADS")
425            .ok()
426            .and_then(|s| s.parse().ok())
427            .unwrap_or_else(|| {
428                std::thread::available_parallelism()
429                    .map(|n| n.get().min(8).max(1))
430                    .unwrap_or(2)
431            });
432
433        let scan_start = std::time::Instant::now();
434        tracing::info!(
435            tx_threads = num_tx_threads,
436            total_probes,
437            rate_pps = config.rate_limit,
438            "engine: parallel TX dispatching"
439        );
440
441        let total_sent_atomic = Arc::new(std::sync::atomic::AtomicU64::new(0));
442        let cookie = self.encoder.cookie().clone();
443        // Per-thread rate (rounded up so the aggregate matches even
444        // when total_rate doesn't divide evenly). 0 = unlimited.
445        let per_thread_rate: u64 = if config.rate_limit == 0 {
446            0
447        } else {
448            ((config.rate_limit as u64) + num_tx_threads as u64 - 1) / num_tx_threads as u64
449        };
450
451        // Pack target_ips into a Vec<Ipv4Addr> for cheap shared-by-Arc
452        // access in worker threads (Target is large; we only need the IP).
453        let ip_slice: Arc<Vec<Ipv4Addr>> = Arc::new(target_ips.iter().map(|(ip, _)| *ip).collect());
454        let ports_slice: Arc<Vec<u16>> = Arc::new(ports.clone());
455
456        let adaptive_rate_enabled = config.adaptive_rate;
457        let icmp_backoff = crate::icmp_backoff::IcmpBackoff::new();
458        let mut tx_handles = Vec::with_capacity(num_tx_threads);
459        for thread_id in 0..num_tx_threads {
460            let cookie_for_thread = cookie.clone();
461            let ip_slice = Arc::clone(&ip_slice);
462            let ports_slice = Arc::clone(&ports_slice);
463            let template_for_thread = template.clone();
464            let total_sent_atomic = Arc::clone(&total_sent_atomic);
465            let tx_backoff = backoff.clone();
466            let tx_icmp_backoff = icmp_backoff.clone();
467            let engine_config = netforge::EngineConfig {
468                source_ip,
469                // Each TX thread gets its own ephemeral source-port slot
470                // so kernel-side flow tracking doesn't conflate them.
471                source_port_start: source_port + thread_id as u16,
472                source_port_end: source_port + thread_id as u16 + 1,
473                rate_pps: per_thread_rate,
474                ..Default::default()
475            };
476
477            tx_handles.push(std::thread::spawn(move || -> u64 {
478                // Pin this TX thread to a dedicated CPU core for cache
479                // locality. Linux only; other platforms silently no-op.
480                // At 90+ Mpps, every L2 miss costs measurable throughput.
481                // Failure is non-fatal — we just lose the affinity speedup.
482                #[cfg(target_os = "linux")]
483                unsafe {
484                    let cpu_count = std::thread::available_parallelism()
485                        .map(|n| n.get())
486                        .unwrap_or(1);
487                    let target_cpu = thread_id % cpu_count;
488                    let mut cpuset: libc::cpu_set_t = std::mem::zeroed();
489                    libc::CPU_SET(target_cpu, &mut cpuset);
490                    let _ =
491                        libc::sched_setaffinity(0, std::mem::size_of::<libc::cpu_set_t>(), &cpuset);
492                }
493
494                // Per-thread engine + rate limiter + encoder + batch.
495                let tx_engine = match netforge::engine::auto_select(engine_config) {
496                    Ok(e) => e,
497                    Err(e) => {
498                        tracing::error!(thread_id, error = %e, "TX engine init failed");
499                        return 0;
500                    }
501                };
502                let encoder = SeqEncoder::with_cookie(cookie_for_thread);
503                let mut rate_limiter = RateLimiter::new(per_thread_rate, TX_BATCH as u64);
504                let unlimited = rate_limiter.is_unlimited();
505                // AIMD interlock — only armed when explicitly requested.
506                // The ceiling is the per-thread share of the configured rate;
507                // AdaptiveLoop seeds itself at half-rate per `AdaptiveRate::new`.
508                let mut adaptive_loop: Option<crate::rate::AdaptiveLoop> =
509                    if adaptive_rate_enabled && !unlimited {
510                        Some(crate::rate::AdaptiveLoop::new(per_thread_rate))
511                    } else {
512                        None
513                    };
514                // Tick cadence: every TICK_BATCHES batches we re-poll
515                // engine stats and re-target the limiter. Cheap.
516                const TICK_BATCHES: u32 = 8;
517                let mut batches_since_tick: u32 = 0;
518
519                let mut batch: Vec<netforge::RawPacket> =
520                    (0..TX_BATCH).map(|_| template_for_thread.clone()).collect();
521                let mut batch_len: usize = 0;
522                let mut local_sent: u64 = 0;
523
524                // Stride iteration over the SAME deterministic schedule.
525                // Thread N handles global_idx ∈ {N, N+T, N+2T, ...}. The
526                // permutation is constructed identically in every thread
527                // (deterministic from `schedule_seed`); each thread only
528                // calls .shuffle() on indices it owns.
529                let permutation = BlackrockPermutation::new(total_probes.max(1), schedule_seed);
530                let stride = num_tx_threads as u64;
531                let mut global_idx: u64 = thread_id as u64;
532
533                while global_idx < total_probes {
534                    let permuted = permutation.shuffle(global_idx);
535                    let ip_idx = permuted / num_ports;
536                    let port_idx = permuted % num_ports;
537                    let target_ip = ip_slice[ip_idx as usize];
538                    let port = ports_slice[port_idx as usize];
539
540                    // Adaptive backoff consumer. If the RX side flagged
541                    // this /24 as actively rejecting probes, skip the
542                    // whole probe rather than burn TX budget on it.
543                    // The skip is silent — the warn is emitted once
544                    // per second from the RX thread when the burst is
545                    // first detected.
546                    let s24 = slash24_of(target_ip);
547                    if tx_backoff.is_blocked(s24) {
548                        tx_backoff.skipped.fetch_add(1, Ordering::Relaxed);
549                        global_idx += stride;
550                        continue;
551                    }
552                    // Mirror check on the ICMP-unreachable backoff. The
553                    // source side (netforge ICMP RX) is open work; the
554                    // consumer plug-in is live so a future signal route
555                    // does not require a scan.rs edit.
556                    if tx_icmp_backoff.is_blocked(s24) {
557                        global_idx += stride;
558                        continue;
559                    }
560
561                    let slot = &mut batch[batch_len];
562                    // Each TX thread uses its own source port so the
563                    // RX side can disambiguate which thread a SYN-ACK
564                    // is replying to. Cookie stamping uses the same port.
565                    let my_source_port = source_port + thread_id as u16;
566                    let seq = encoder.encode(target_ip, port, my_source_port, 0);
567                    packet::stamp_syn(slot, target_ip, port, seq);
568                    batch_len += 1;
569
570                    if batch_len == TX_BATCH {
571                        if !unlimited {
572                            let mut remaining = TX_BATCH as u64;
573                            while remaining > 0 {
574                                let got = rate_limiter.try_consume_batch(remaining);
575                                if got == 0 {
576                                    std::hint::spin_loop();
577                                    continue;
578                                }
579                                remaining -= got;
580                            }
581                        }
582                        let sent = tx_engine.tx_batch(&batch[..batch_len]).unwrap_or(0);
583                        local_sent += sent as u64;
584                        batch_len = 0;
585
586                        if let Some(al) = adaptive_loop.as_mut() {
587                            batches_since_tick += 1;
588                            if batches_since_tick >= TICK_BATCHES {
589                                batches_since_tick = 0;
590                                let s = tx_engine.stats();
591                                al.tick(s.tx_packets, s.tx_drops);
592                                al.apply(&mut rate_limiter);
593                            }
594                        }
595                    }
596
597                    global_idx += stride;
598                }
599
600                // Flush partial batch.
601                if batch_len > 0 {
602                    if !unlimited {
603                        let mut remaining = batch_len as u64;
604                        while remaining > 0 {
605                            let got = rate_limiter.try_consume_batch(remaining);
606                            if got == 0 {
607                                std::hint::spin_loop();
608                                continue;
609                            }
610                            remaining -= got;
611                        }
612                    }
613                    let sent = tx_engine.tx_batch(&batch[..batch_len]).unwrap_or(0);
614                    local_sent += sent as u64;
615                }
616
617                total_sent_atomic.fetch_add(local_sent, std::sync::atomic::Ordering::Relaxed);
618                local_sent
619            }));
620        }
621
622        // Live throughput logger — runs on the orchestrator while
623        // workers fan out. One info line per second showing aggregate pps.
624        let log_atomic = Arc::clone(&total_sent_atomic);
625        let log_stop = Arc::new(AtomicBool::new(false));
626        let log_stop_handle = Arc::clone(&log_stop);
627        let log_handle = std::thread::spawn(move || {
628            let mut last_log = std::time::Instant::now();
629            let mut last_sent: u64 = 0;
630            while !log_stop_handle.load(Ordering::Relaxed) {
631                std::thread::sleep(std::time::Duration::from_secs(1));
632                let now = std::time::Instant::now();
633                let cur_sent = log_atomic.load(std::sync::atomic::Ordering::Relaxed);
634                let dt = now.duration_since(last_log).as_secs_f64();
635                let pps = ((cur_sent - last_sent) as f64 / dt) as u64;
636                tracing::info!(pps = pps, sent = cur_sent, "engine TX");
637                last_log = now;
638                last_sent = cur_sent;
639            }
640        });
641
642        // Wait for workers.
643        for h in tx_handles {
644            let _ = h.join();
645        }
646        log_stop.store(true, Ordering::Relaxed);
647        let _ = log_handle.join();
648        let total_sent = total_sent_atomic.load(std::sync::atomic::Ordering::Relaxed);
649        let _ = scan_start;
650
651        // tx_drops is no longer easily aggregated — each TX thread had its
652        // own engine and we joined them already. Total sent comes from the
653        // shared atomic; drops would need a separate atomic if we wanted
654        // them. For now report wall-time pps from the scan-start clock.
655        let elapsed_s = scan_start.elapsed().as_secs_f64().max(0.000_001);
656        let skipped = backoff.skipped.load(Ordering::Relaxed);
657        tracing::info!(
658            sent = total_sent,
659            tx_threads = num_tx_threads,
660            elapsed_s,
661            pps = (total_sent as f64 / elapsed_s) as u64,
662            backoff_skipped = skipped,
663            "SYN probes sent. Waiting for responses..."
664        );
665        if skipped > 0 {
666            tracing::info!(
667                backoff_skipped = skipped,
668                "engine: skipped probes against /24 subnets in active RST backoff"
669            );
670        }
671
672        // Wait for stragglers
673        tokio::time::sleep(config.timeout()).await;
674        stop_flag.store(true, Ordering::Relaxed);
675        let _ = rx_handle.join();
676
677        // Collect results
678        let mut found: HashMap<(Ipv4Addr, u16), SynAckResponse> = HashMap::new();
679        while let Ok(resp) = res_rx.try_recv() {
680            found.insert((resp.ip, resp.port), resp);
681        }
682
683        tracing::info!(open_ports = found.len(), "scan complete");
684
685        // ── Banner grab + service classification ─────────────────────────
686        // For each open port discovered by the SYN scan, do a quick
687        // TCP connect-and-read to grab a ~512-byte banner, then run
688        // gossan-classify rules to identify the service. This is the
689        // masscan-parity item — masscan has `--banners` for the same
690        // thing. Concurrency caps prevent banner grab from undoing the
691        // scan-time win; with 500-way concurrency the grab phase
692        // typically finishes in seconds even for thousands of ports.
693        let classifier = Arc::new(BannerClassifier::new());
694        // Convert &'static str OS hint to owned String so the closure
695        // below isn't HRTB-bound by an unwanted 'static lifetime.
696        let mut grab_jobs: Vec<(Ipv4Addr, u16, Option<String>, Option<String>)> =
697            Vec::with_capacity(found.len());
698        for (ip, t) in &target_ips {
699            let domain = match t {
700                Target::Domain(d) => Some(d.domain.clone()),
701                Target::Host(h) => h.domain.clone(),
702                _ => None,
703            };
704            for &port in &ports {
705                if let Some(info) = found.get(&(*ip, port)) {
706                    let os = identify_os(info.ttl, info.window).map(|s| s.to_string());
707                    grab_jobs.push((*ip, port, domain.clone(), os));
708                }
709            }
710        }
711
712        if !grab_jobs.is_empty() {
713            let banner_grab_start = std::time::Instant::now();
714            tracing::info!(
715                open_ports = grab_jobs.len(),
716                "engine: starting banner grab + classification"
717            );
718            const GRAB_TIMEOUT: Duration = Duration::from_secs(2);
719            const GRAB_CONCURRENCY: usize = 500;
720
721            let results = futures::stream::iter(grab_jobs)
722                .map(|(ip, port, domain, os)| {
723                    let classifier = Arc::clone(&classifier);
724                    async move {
725                        let banner = grab_banner(ip, port, GRAB_TIMEOUT).await;
726                        let classification = banner
727                            .as_deref()
728                            .and_then(|b| classifier.classify_top(b))
729                            .map(|m| {
730                                format!("{}/{}", m.service, m.version.unwrap_or_else(|| "?".into()))
731                            });
732                        (ip, port, domain, os, banner, classification)
733                    }
734                })
735                .buffer_unordered(GRAB_CONCURRENCY)
736                .collect::<Vec<_>>()
737                .await;
738
739            tracing::info!(
740                grabbed = results.len(),
741                elapsed_s = banner_grab_start.elapsed().as_secs_f64(),
742                "engine: banner grab complete"
743            );
744
745            for (ip, port, domain, os, banner, classification) in results {
746                let tls = port == 443 || port == 8443;
747                let mut tags: Vec<String> = Vec::new();
748                if let Some(o) = os {
749                    tags.push(format!("[OS: {o}]"));
750                }
751                if let Some(c) = &classification {
752                    tags.push(format!("[SVC: {c}]"));
753                }
754                let banner_str = if !tags.is_empty() || banner.is_some() {
755                    let mut s = tags.join(" ");
756                    if let Some(b) = &banner {
757                        if !s.is_empty() {
758                            s.push(' ');
759                        }
760                        // Truncate raw banner to keep ServiceTarget compact.
761                        let b_trim = b.trim();
762                        let cap = 200;
763                        if b_trim.len() > cap {
764                            s.push_str(&b_trim[..cap]);
765                            s.push_str("…");
766                        } else {
767                            s.push_str(b_trim);
768                        }
769                    }
770                    Some(s)
771                } else {
772                    None
773                };
774
775                let svc = ServiceTarget {
776                    host: HostTarget {
777                        ip: IpAddr::V4(ip),
778                        domain,
779                    },
780                    port,
781                    protocol: Protocol::Tcp,
782                    banner: banner_str,
783                    tls,
784                };
785                input.emit_target(Target::Service(svc));
786            }
787        }
788
789        Ok(())
790    }
791}
792
793/// Lightweight banner grabber: TCP-connect, send a generic probe, read
794/// up to 512 bytes, return as UTF-8-lossy. None on connect / read
795/// failure or empty response. The probe is "GET / HTTP/1.0\r\n\r\n" for
796/// likely-web ports and a no-op (read-only) for everything else — many
797/// services (SSH, FTP, SMTP, IRC, Redis without AUTH) emit a banner on
798/// connect, so we just need to wait briefly for the server to speak.
799async fn grab_banner(ip: Ipv4Addr, port: u16, timeout: Duration) -> Option<String> {
800    let connect_fut = TcpStream::connect((ip, port));
801    let mut stream = match tokio::time::timeout(timeout, connect_fut).await {
802        Ok(Ok(s)) => s,
803        _ => return None,
804    };
805    // For HTTP-ish ports, kick the server with a GET so it actually
806    // responds. For most other ports the server speaks first.
807    if matches!(port, 80 | 8080 | 8000 | 8888 | 443 | 8443 | 9000) {
808        let _ = stream
809            .write_all(b"GET / HTTP/1.0\r\nHost: localhost\r\nUser-Agent: gossan\r\n\r\n")
810            .await;
811    }
812    let mut buf = [0u8; 512];
813    let read_fut = stream.read(&mut buf);
814    let n = match tokio::time::timeout(timeout, read_fut).await {
815        Ok(Ok(n)) if n > 0 => n,
816        _ => return None,
817    };
818    Some(String::from_utf8_lossy(&buf[..n]).into_owned())
819}
820
821#[cfg(test)]
822mod tests {
823    use super::*;
824    use gossan_core::{DiscoverySource, DomainTarget};
825
826    #[test]
827    fn scanner_metadata() {
828        let scanner = EngineScanner::new();
829        assert_eq!(scanner.name(), "engine");
830        assert!(scanner.tags().contains(&"raw"));
831        assert!(scanner.tags().contains(&"engine"));
832    }
833
834    #[test]
835    fn accepts_hosts_and_domains() {
836        let scanner = EngineScanner::new();
837        assert!(scanner.accepts(&Target::Domain(DomainTarget {
838            domain: "example.com".into(),
839            source: DiscoverySource::Seed,
840        })));
841        assert!(scanner.accepts(&Target::Host(HostTarget {
842            ip: IpAddr::V4(Ipv4Addr::LOCALHOST),
843            domain: None,
844        })));
845    }
846
847    #[test]
848    fn rejects_non_host_targets() {
849        let scanner = EngineScanner::new();
850        // Service targets are accepted, but Web targets are not
851        let svc = Target::Service(ServiceTarget {
852            host: HostTarget {
853                ip: IpAddr::V4(Ipv4Addr::LOCALHOST),
854                domain: None,
855            },
856            port: 80,
857            protocol: Protocol::Tcp,
858            banner: None,
859            tls: false,
860        });
861        assert!(!scanner.accepts(&svc));
862    }
863
864    #[test]
865    fn os_fingerprint_linux() {
866        assert_eq!(identify_os(64, 29200), Some("Linux/Unix"));
867        assert_eq!(identify_os(63, 14600), Some("Linux/Unix"));
868    }
869
870    #[test]
871    fn os_fingerprint_windows() {
872        assert_eq!(identify_os(128, 65535), Some("Windows"));
873        assert_eq!(identify_os(127, 8192), Some("Windows"));
874    }
875
876    #[test]
877    fn os_fingerprint_cisco() {
878        assert_eq!(identify_os(255, 4128), Some("Cisco/Network Device"));
879    }
880
881    #[test]
882    fn os_fingerprint_unknown() {
883        assert_eq!(identify_os(100, 0), None);
884    }
885
886    #[test]
887    fn resolve_ports_default() {
888        let ports = resolve_ports(&PortMode::Default);
889        assert!(ports.contains(&80));
890        assert!(ports.contains(&443));
891        assert!(ports.contains(&22));
892        assert!(!ports.is_empty());
893    }
894
895    #[test]
896    fn resolve_ports_full() {
897        let ports = resolve_ports(&PortMode::Full);
898        assert_eq!(ports.len(), 65535);
899        assert_eq!(*ports.first().unwrap_or(&0), 1);
900        assert_eq!(*ports.last().unwrap_or(&0), 65535);
901    }
902
903    #[test]
904    fn resolve_ports_custom() {
905        let ports = resolve_ports(&PortMode::Custom(vec![80, 443, 8080]));
906        assert_eq!(ports, vec![80, 443, 8080]);
907    }
908
909    #[test]
910    fn slash24_of_strips_low_octet() {
911        let a: Ipv4Addr = "10.20.30.40".parse().unwrap();
912        let b: Ipv4Addr = "10.20.30.41".parse().unwrap();
913        let c: Ipv4Addr = "10.20.31.40".parse().unwrap();
914        assert_eq!(slash24_of(a), slash24_of(b));
915        assert_ne!(slash24_of(a), slash24_of(c));
916    }
917
918    #[test]
919    fn slash24_backoff_blocks_then_expires() {
920        let bo = Slash24Backoff::new();
921        let s = slash24_of("203.0.113.7".parse().unwrap());
922        assert!(!bo.is_blocked(s), "untouched subnet must not be blocked");
923        bo.block(s, Duration::from_millis(50));
924        assert!(
925            bo.is_blocked(s),
926            "subnet must be blocked immediately after insert"
927        );
928        std::thread::sleep(Duration::from_millis(80));
929        assert!(
930            !bo.is_blocked(s),
931            "subnet must auto-expire once backoff window elapses"
932        );
933    }
934
935    #[test]
936    fn slash24_backoff_does_not_shrink_existing_window() {
937        let bo = Slash24Backoff::new();
938        let s = slash24_of("198.51.100.1".parse().unwrap());
939        bo.block(s, Duration::from_secs(60));
940        bo.block(s, Duration::from_millis(10));
941        // The shorter window must NOT replace the longer one.
942        std::thread::sleep(Duration::from_millis(40));
943        assert!(
944            bo.is_blocked(s),
945            "longer window must survive a shorter overwrite"
946        );
947    }
948
949    #[test]
950    fn slash24_backoff_prune_removes_expired_only() {
951        let bo = Slash24Backoff::new();
952        // Distinct /24s. `slash24_of` zeros the low octet, so 192.0.2.10
953        // and 192.0.2.20 collide on the same key — use a different /24
954        // for `dead` to actually exercise prune's per-key behavior.
955        let live = slash24_of("192.0.2.10".parse().unwrap());
956        let dead = slash24_of("192.0.3.20".parse().unwrap());
957        assert_ne!(live, dead, "test must use distinct /24 keys");
958        bo.block(live, Duration::from_secs(60));
959        bo.block(dead, Duration::from_millis(5));
960        std::thread::sleep(Duration::from_millis(40));
961        bo.prune();
962        assert!(bo.is_blocked(live));
963        // Map entry for `dead` is gone, so is_blocked returns false.
964        assert!(!bo.is_blocked(dead));
965    }
966
967    #[test]
968    fn slash24_backoff_skipped_counter_starts_at_zero() {
969        let bo = Slash24Backoff::new();
970        assert_eq!(bo.skipped.load(Ordering::Relaxed), 0);
971    }
972
973    #[test]
974    fn slash24_backoff_clones_share_state() {
975        let bo = Slash24Backoff::new();
976        let bo2 = bo.clone();
977        let s = slash24_of("10.0.0.1".parse().unwrap());
978        bo.block(s, Duration::from_secs(60));
979        assert!(
980            bo2.is_blocked(s),
981            "clone must observe writes through original"
982        );
983        bo2.skipped.fetch_add(7, Ordering::Relaxed);
984        assert_eq!(bo.skipped.load(Ordering::Relaxed), 7);
985    }
986}