1use 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
33pub struct EngineScanner {
38 encoder: SeqEncoder,
40}
41
42impl EngineScanner {
43 #[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
58fn 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 _ if ttl >= 48 && ttl <= 50 => Some("BSD"),
67 _ => None,
68 },
69 }
70}
71
72struct SynAckResponse {
74 ip: Ipv4Addr,
75 port: u16,
76 ttl: u8,
77 window: u16,
78}
79
80#[derive(Clone, Default)]
90pub(crate) struct Slash24Backoff {
91 inner: Arc<RwLock<HashMap<u32, Instant>>>,
92 pub(crate) skipped: Arc<AtomicU64>,
96}
97
98impl Slash24Backoff {
99 fn new() -> Self {
100 Self::default()
101 }
102
103 fn block(&self, slash24: u32, duration: Duration) {
106 let until = Instant::now() + duration;
107 if let Ok(mut g) = self.inner.write() {
110 let entry = g.entry(slash24).or_insert(until);
112 if *entry < until {
113 *entry = until;
114 }
115 }
116 }
117
118 #[inline]
121 fn is_blocked(&self, slash24: u32) -> bool {
122 let g = match self.inner.read() {
123 Ok(g) => g,
124 Err(_) => return false, };
126 match g.get(&slash24) {
127 Some(until) => *until > Instant::now(),
128 None => false,
129 }
130 }
131
132 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
148fn 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
165fn 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 (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 let mut target_ips: Vec<(Ipv4Addr, Target)> = Vec::new();
221
222 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 let template = packet::build_syn_template(source_ip, source_port);
267
268 let (res_tx, res_rx) = crossbeam_channel::bounded(500_000);
270
271 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 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 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 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 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 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 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 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 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 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 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 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 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 #[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 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 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 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 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 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 if tx_icmp_backoff.is_blocked(s24) {
557 global_idx += stride;
558 continue;
559 }
560
561 let slot = &mut batch[batch_len];
562 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 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 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 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 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 tokio::time::sleep(config.timeout()).await;
674 stop_flag.store(true, Ordering::Relaxed);
675 let _ = rx_handle.join();
676
677 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 let classifier = Arc::new(BannerClassifier::new());
694 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 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
793async 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 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 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 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 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 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}