pub mod blackrock;
pub mod cookie;
pub mod packet;
pub mod transport;
use std::collections::{HashMap, HashSet};
use std::net::{Ipv4Addr, SocketAddrV4};
use std::time::Duration;
use blackrock::Blackrock;
use cookie::SynCookie;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Outcome {
Open(SocketAddrV4),
Closed(SocketAddrV4),
}
#[derive(Debug, Clone, PartialEq)]
pub struct Probe {
pub dst: SocketAddrV4,
pub seq: u32,
pub bytes: Vec<u8>,
}
pub trait SynTransport {
fn send(&mut self, dst: SocketAddrV4, ip_tcp: &[u8]) -> std::io::Result<()>;
fn try_recv(&mut self) -> std::io::Result<Option<Vec<u8>>>;
}
pub struct StatelessScanner {
src: SocketAddrV4,
ips: Vec<Ipv4Addr>,
ports: Vec<u16>,
cookie: SynCookie,
perm: Blackrock,
cursor: u64,
total: u64,
ttl: u8,
window: u16,
ip_id: u16,
seen: HashSet<SocketAddrV4>,
}
impl StatelessScanner {
#[must_use]
pub fn new(
src: SocketAddrV4,
ips: Vec<Ipv4Addr>,
ports: Vec<u16>,
cookie: SynCookie,
seed: u64,
) -> Self {
let total = (ips.len() as u64).saturating_mul(ports.len() as u64);
let perm = Blackrock::new(total, seed);
Self {
src,
ips,
ports,
cookie,
perm,
cursor: 0,
total,
ttl: 64,
window: 1024,
ip_id: 1,
seen: HashSet::new(),
}
}
#[must_use]
pub fn total(&self) -> u64 {
self.total
}
fn decode(&self, index: u64) -> SocketAddrV4 {
let np = self.ports.len() as u64;
let ip = self.ips[(index / np) as usize];
let port = self.ports[(index % np) as usize];
SocketAddrV4::new(ip, port)
}
pub fn next_probe(&mut self) -> Option<Probe> {
if self.cursor >= self.total {
return None;
}
let idx = self.perm.shuffle(self.cursor);
self.cursor += 1;
let dst = self.decode(idx);
let seq = self.cookie.seq(self.src, dst);
self.ip_id = self.ip_id.wrapping_add(1);
let bytes = packet::build_syn(self.src, dst, seq, self.ttl, self.window, self.ip_id);
Some(Probe { dst, seq, bytes })
}
#[must_use]
pub fn classify(&self, pkt: &[u8]) -> Option<Outcome> {
let r = packet::parse_tcp_reply(pkt)?;
if r.to.ip() != self.src.ip() || r.to.port() != self.src.port() {
return None;
}
if !self.cookie.validate(self.src, r.from, r.ackno) {
return None;
}
if r.is_open() {
Some(Outcome::Open(r.from))
} else if r.is_closed() {
Some(Outcome::Closed(r.from))
} else {
None
}
}
pub fn record(&mut self, outcome: Outcome) -> Option<Outcome> {
let target = match outcome {
Outcome::Open(a) | Outcome::Closed(a) => a,
};
if self.seen.insert(target) {
Some(outcome)
} else {
None
}
}
}
#[must_use]
pub fn pace(sent: u64, elapsed: Duration, pps: u64) -> Duration {
if pps == 0 {
return Duration::ZERO;
}
let earliest = Duration::from_secs_f64(sent as f64 / pps as f64);
earliest.checked_sub(elapsed).unwrap_or(Duration::ZERO)
}
pub struct MockTransport {
cookie: SynCookie,
open: Vec<SocketAddrV4>,
closed: Vec<SocketAddrV4>,
inbox: std::collections::VecDeque<Vec<u8>>,
pub sent: HashMap<SocketAddrV4, u32>,
}
impl MockTransport {
#[must_use]
pub fn new(cookie: SynCookie, open: Vec<SocketAddrV4>, closed: Vec<SocketAddrV4>) -> Self {
Self {
cookie,
open,
closed,
inbox: std::collections::VecDeque::new(),
sent: HashMap::new(),
}
}
}
impl SynTransport for MockTransport {
fn send(&mut self, dst: SocketAddrV4, ip_tcp: &[u8]) -> std::io::Result<()> {
let our = match packet::parse_tcp_reply(ip_tcp) {
Some(p) => p,
None => {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"MockTransport received invalid SYN packet",
));
}
};
self.sent
.insert(dst, our.ackno );
let our_src = our.to; let _ = our_src;
let isn = self.cookie.seq(our.from, dst);
let reply_ack = isn.wrapping_add(1);
let mut reply = packet::build_syn(dst, our.from, 0xABCD_1234, 64, 512, 9);
reply[20 + 8..20 + 12].copy_from_slice(&reply_ack.to_be_bytes());
if self.open.contains(&dst) {
reply[20 + 13] = 0x12; self.inbox.push_back(reply);
} else if self.closed.contains(&dst) {
reply[20 + 13] = 0x04; self.inbox.push_back(reply);
}
Ok(())
}
fn try_recv(&mut self) -> std::io::Result<Option<Vec<u8>>> {
Ok(self.inbox.pop_front())
}
}
pub fn run_to_completion<T: SynTransport>(
scanner: &mut StatelessScanner,
transport: &mut T,
) -> std::io::Result<Vec<Outcome>> {
let mut out = Vec::new();
while let Some(p) = scanner.next_probe() {
transport.send(p.dst, &p.bytes)?;
while let Some(pkt) = transport.try_recv()? {
if let Some(o) = scanner.classify(&pkt) {
if let Some(o) = scanner.record(o) {
out.push(o);
}
}
}
}
while let Some(pkt) = transport.try_recv()? {
if let Some(o) = scanner.classify(&pkt) {
if let Some(o) = scanner.record(o) {
out.push(o);
}
}
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
fn ip(a: [u8; 4]) -> Ipv4Addr {
Ipv4Addr::from(a)
}
fn sa(a: [u8; 4], p: u16) -> SocketAddrV4 {
SocketAddrV4::new(ip(a), p)
}
fn scanner(seed: u64, cookie: &SynCookie) -> StatelessScanner {
StatelessScanner::new(
sa([10, 0, 0, 9], 40000),
vec![ip([93, 184, 216, 34]), ip([1, 1, 1, 1])],
vec![80, 443, 22, 8080],
cookie.clone(),
seed,
)
}
#[test]
fn emits_every_target_exactly_once_in_permuted_order() {
let c = SynCookie::with_key([1; 16]);
let mut s = scanner(123, &c);
assert_eq!(s.total(), 8);
let mut seen = std::collections::HashSet::new();
let mut order = Vec::new();
while let Some(p) = s.next_probe() {
assert!(seen.insert(p.dst), "duplicate probe {}", p.dst);
order.push(p.dst);
}
assert_eq!(seen.len(), 8, "must cover the whole ip×port space once");
let sequential: Vec<SocketAddrV4> = {
let mut s2 = scanner(123, &c);
std::iter::from_fn(|| s2.next_probe().map(|p| p.dst)).collect()
};
assert_eq!(order, sequential, "deterministic for a fixed seed");
}
#[test]
fn end_to_end_finds_exactly_the_open_ports() {
let c = SynCookie::with_key([9; 16]);
let open = vec![sa([93, 184, 216, 34], 443), sa([1, 1, 1, 1], 22)];
let closed = vec![sa([93, 184, 216, 34], 80)];
let mut s = scanner(777, &c);
let mut t = MockTransport::new(c.clone(), open.clone(), closed.clone());
let mut outcomes = run_to_completion(&mut s, &mut t).unwrap();
outcomes.sort_by_key(|o| match o {
Outcome::Open(a) | Outcome::Closed(a) => (*a.ip(), a.port()),
});
let opens: Vec<_> = outcomes
.iter()
.filter_map(|o| match o {
Outcome::Open(a) => Some(*a),
Outcome::Closed(_) => None,
})
.collect();
let closes: Vec<_> = outcomes
.iter()
.filter_map(|o| match o {
Outcome::Closed(a) => Some(*a),
Outcome::Open(_) => None,
})
.collect();
assert_eq!(opens.len(), 2, "exactly the two open ports: {opens:?}");
assert!(opens.contains(&sa([93, 184, 216, 34], 443)));
assert!(opens.contains(&sa([1, 1, 1, 1], 22)));
assert_eq!(closes, vec![sa([93, 184, 216, 34], 80)]);
}
#[test]
fn spoofed_reply_with_wrong_cookie_is_rejected() {
let real = SynCookie::with_key([1; 16]);
let mut s = scanner(5, &real);
let p = s.next_probe().unwrap();
let mut forged = packet::build_syn(p.dst, sa([10, 0, 0, 9], 40000), 1, 64, 512, 1);
forged[20 + 13] = 0x12; forged[20 + 8..20 + 12].copy_from_slice(&0xdead_beefu32.to_be_bytes());
assert_eq!(s.classify(&forged), None, "forged cookie must be rejected");
let mut good = packet::build_syn(p.dst, sa([10, 0, 0, 9], 40000), 1, 64, 512, 1);
good[20 + 13] = 0x12;
good[20 + 8..20 + 12].copy_from_slice(&p.seq.wrapping_add(1).to_be_bytes());
assert_eq!(s.classify(&good), Some(Outcome::Open(p.dst)));
}
#[test]
fn reply_addressed_to_a_different_host_is_ignored() {
let c = SynCookie::with_key([2; 16]);
let s = scanner(1, &c);
let mut pkt =
packet::build_syn(sa([1, 1, 1, 1], 443), sa([8, 8, 8, 8], 40000), 1, 64, 1, 1);
pkt[20 + 13] = 0x12;
assert_eq!(s.classify(&pkt), None);
}
#[test]
fn pace_enforces_average_rate() {
assert_eq!(pace(0, Duration::from_secs(0), 1000), Duration::ZERO);
let w = pace(1000, Duration::from_millis(500), 1000);
assert!(
(w.as_millis() as i64 - 500).abs() <= 2,
"expected ~500ms, got {w:?}"
);
assert_eq!(pace(1000, Duration::from_secs(2), 1000), Duration::ZERO);
assert_eq!(pace(5, Duration::from_secs(1), 0), Duration::ZERO); }
fn synack_for(p: &Probe) -> Vec<u8> {
let mut pk = packet::build_syn(p.dst, sa([10, 0, 0, 9], 40000), 1, 64, 512, 1);
pk[20 + 13] = 0x12; pk[20 + 8..20 + 12].copy_from_slice(&p.seq.wrapping_add(1).to_be_bytes());
pk
}
#[test]
fn retransmitted_synack_is_reported_once() {
let c = SynCookie::with_key([3; 16]);
let mut s = scanner(11, &c);
let p = s.next_probe().unwrap();
let synack = synack_for(&p);
let mut emitted = Vec::new();
for _ in 0..5 {
assert_eq!(s.classify(&synack), Some(Outcome::Open(p.dst)));
if let Some(o) = s.classify(&synack) {
if let Some(o) = s.record(o) {
emitted.push(o);
}
}
}
assert_eq!(
emitted,
vec![Outcome::Open(p.dst)],
"5 retransmitted SYN/ACKs must yield exactly one Open, got {emitted:?}"
);
}
#[test]
fn result_gate_is_per_target_not_global() {
let c = SynCookie::with_key([4; 16]);
let mut s = scanner(12, &c);
let p1 = s.next_probe().unwrap();
let p2 = s.next_probe().unwrap();
assert_ne!(p1.dst, p2.dst);
let a = synack_for(&p1);
let b = synack_for(&p2);
let mut emitted = Vec::new();
for pkt in [&a, &a, &b, &a, &b, &b] {
if let Some(o) = s.classify(pkt) {
if let Some(o) = s.record(o) {
emitted.push(o);
}
}
}
assert_eq!(
emitted.len(),
2,
"two distinct opens must report once each: {emitted:?}"
);
assert!(emitted.contains(&Outcome::Open(p1.dst)));
assert!(emitted.contains(&Outcome::Open(p2.dst)));
}
struct RetxTransport {
cookie: SynCookie,
open: Vec<SocketAddrV4>,
inbox: std::collections::VecDeque<Vec<u8>>,
copies: usize,
}
impl SynTransport for RetxTransport {
fn send(&mut self, dst: SocketAddrV4, ip_tcp: &[u8]) -> std::io::Result<()> {
let our = packet::parse_tcp_reply(ip_tcp).expect("valid SYN");
if self.open.contains(&dst) {
let isn = self.cookie.seq(our.from, dst);
let mut reply = packet::build_syn(dst, our.from, 0xABCD_1234, 64, 512, 9);
reply[20 + 8..20 + 12].copy_from_slice(&isn.wrapping_add(1).to_be_bytes());
reply[20 + 13] = 0x12; for _ in 0..self.copies {
self.inbox.push_back(reply.clone());
}
}
Ok(())
}
fn try_recv(&mut self) -> std::io::Result<Option<Vec<u8>>> {
Ok(self.inbox.pop_front())
}
}
#[test]
fn driver_dedups_retransmitted_opens_end_to_end() {
let c = SynCookie::with_key([8; 16]);
let open = vec![sa([93, 184, 216, 34], 443), sa([1, 1, 1, 1], 22)];
let mut s = scanner(99, &c);
let mut t = RetxTransport {
cookie: c.clone(),
open: open.clone(),
inbox: std::collections::VecDeque::new(),
copies: 5,
};
let outs = run_to_completion(&mut s, &mut t).unwrap();
let mut opens: Vec<_> = outs
.iter()
.filter_map(|o| match o {
Outcome::Open(a) => Some(*a),
Outcome::Closed(_) => None,
})
.collect();
opens.sort_by_key(|a| (*a.ip(), a.port()));
assert_eq!(
opens,
{
let mut e = open.clone();
e.sort_by_key(|a| (*a.ip(), a.port()));
e
},
"each open port must appear exactly once despite 5 retransmits: {outs:?}"
);
}
#[test]
fn mock_transport_rejects_garbage_instead_of_panicking() {
let c = SynCookie::with_key([1; 16]);
let mut t = MockTransport::new(c, vec![], vec![]);
let result = t.send(
SocketAddrV4::new(Ipv4Addr::new(1, 1, 1, 1), 80),
b"this is not a valid ip+tcp packet",
);
assert!(
result.is_err(),
"MockTransport must return Err for garbage, not panic"
);
}
#[test]
fn empty_ports_produces_no_probes() {
let c = SynCookie::with_key([2; 16]);
let mut s = StatelessScanner::new(
SocketAddrV4::new(Ipv4Addr::new(10, 0, 0, 1), 40000),
vec![Ipv4Addr::new(1, 1, 1, 1)],
vec![],
c,
42,
);
assert_eq!(s.total(), 0);
assert_eq!(s.next_probe(), None);
}
}