diff --git a/CHANGELOG.md b/CHANGELOG.md index 27d04eeb..77b74f5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -93,6 +93,11 @@ its heading and collects entries; the date and the link go on with the tag. `ff:ff:ff:ff:ff:ff`, and discovery listed it as a host that ignored ping (and a sweep then scanned it). The network and broadcast addresses, and broadcast and multicast MACs, are no longer reported. +- **Closed ports showed as filtered on Windows.** Windows waits about two + seconds before reporting a refused connection, and the scan stops waiting + after half a second, so a port that was plainly closed came back as + filtered. The scan now reports it as closed straight away. A scan of 1,024 + ports on a LAN machine went from 1,022 filtered to 1,022 closed. - **The macOS app could be refused as broken on Apple Silicon.** Its only signature was the one Apple's linker puts on every arm64 program, which claims the app's files are sealed when nothing sealed them. macOS's own diff --git a/crates/netscli-core/Cargo.toml b/crates/netscli-core/Cargo.toml index 785cb66e..e193b3b6 100644 --- a/crates/netscli-core/Cargo.toml +++ b/crates/netscli-core/Cargo.toml @@ -48,7 +48,7 @@ pnet_datalink = "0.35" [target.'cfg(windows)'.dependencies] ipconfig = "0.3" -windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_NetworkManagement_IpHelper", "Win32_NetworkManagement_Ndis", "Win32_Networking_WinSock"] } +windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_NetworkManagement_IpHelper", "Win32_NetworkManagement_Ndis", "Win32_Networking_WinSock", "Win32_System_IO"] } [features] # Lean by default: scan, discover, DNS, ARP, OUI lookup, stats. diff --git a/crates/netscli-core/src/common.rs b/crates/netscli-core/src/common.rs index 6e0255b9..1a20e3ae 100644 --- a/crates/netscli-core/src/common.rs +++ b/crates/netscli-core/src/common.rs @@ -1,3 +1,4 @@ +mod connect; mod constants; mod network; mod ports; @@ -8,6 +9,7 @@ mod ports; mod system_tools; mod terminal; +pub(crate) use connect::connect as tcp_connect; pub use constants::{ DEFAULT_CONCURRENCY, DEFAULT_DNS_TIMEOUT_MS, DEFAULT_PING_TIMEOUT_MS, DEFAULT_PORTS, DEFAULT_SCAN_TIMEOUT_MS, DEFAULT_SUBNET, MAX_MDNS_TIMEOUT_MS, MAX_PING_COUNT, diff --git a/crates/netscli-core/src/common/connect.rs b/crates/netscli-core/src/common/connect.rs new file mode 100644 index 00000000..ec7abfbb --- /dev/null +++ b/crates/netscli-core/src/common/connect.rs @@ -0,0 +1,93 @@ +//! TCP connect for the port scanner, which must tell "refused" from "no +//! answer". +//! +//! Windows retries a SYN that was answered with a RST, so a connect to a +//! closed port only fails with `ConnectionRefused` after about 2 s (measured +//! 2026-10-02 on Windows 11: 2025-2047 ms to a LAN host, 2043 ms to +//! loopback). The port scanner gives up at 500 ms by default, so every closed +//! port read as filtered. Turning SYN retransmissions off for the socket +//! makes the first RST final: the same connects failed in 0-5 ms. +//! +//! With no retransmissions Windows abandons the connect after the initial +//! retransmission timeout, 1 s by default, whatever timeout the caller asked +//! for. So the initial timeout is set to the caller's timeout as well. + +use std::io; +use std::net::SocketAddr; +use std::time::Duration; + +use tokio::net::{TcpSocket, TcpStream}; + +/// Connect to `addr`, giving up after `timeout_ms`. A timeout is reported as +/// `ErrorKind::TimedOut`, the same kind the OS uses for an unanswered SYN. +pub(crate) async fn connect(addr: SocketAddr, timeout_ms: u64) -> io::Result { + let socket = if addr.is_ipv4() { + TcpSocket::new_v4()? + } else { + TcpSocket::new_v6()? + }; + #[cfg(windows)] + windows::final_rst(&socket, timeout_ms); + match tokio::time::timeout(Duration::from_millis(timeout_ms), socket.connect(addr)).await { + Ok(result) => result, + Err(_) => Err(io::Error::new(io::ErrorKind::TimedOut, "connect timed out")), + } +} + +#[cfg(windows)] +mod windows { + use std::os::windows::io::AsRawSocket; + + use tokio::net::TcpSocket; + use windows_sys::Win32::Networking::WinSock::{ + WSAIoctl, SIO_TCP_INITIAL_RTO, SOCKET, TCP_INITIAL_RTO_NO_SYN_RETRANSMISSIONS, + TCP_INITIAL_RTO_PARAMETERS, + }; + + /// Turn off SYN retransmissions and set the initial retransmission + /// timeout to `timeout_ms`. Best effort: if the ioctl is refused the + /// connect still works, it is only slow to report a closed port. + pub(super) fn final_rst(socket: &TcpSocket, timeout_ms: u64) { + let params = TCP_INITIAL_RTO_PARAMETERS { + Rtt: rtt(timeout_ms), + // The C header defines this as (USHORT)-2 and assigns it to the + // UCHAR field, which keeps the low byte. + MaxSynRetransmissions: TCP_INITIAL_RTO_NO_SYN_RETRANSMISSIONS as u8, + }; + let mut returned = 0u32; + // SAFETY: a valid socket handle, an input buffer of the declared size, + // no output buffer, and no overlapped I/O. + unsafe { + WSAIoctl( + socket.as_raw_socket() as SOCKET, + SIO_TCP_INITIAL_RTO, + ¶ms as *const _ as *const core::ffi::c_void, + std::mem::size_of::() as u32, + std::ptr::null_mut(), + 0, + &mut returned, + std::ptr::null_mut(), + None, + ); + } + } + + /// 65535 means "unspecified" to Windows, so the largest usable value is + /// 65534 ms. 0 is not a timeout either. + pub(super) fn rtt(timeout_ms: u64) -> u16 { + timeout_ms.clamp(1, 65_534) as u16 + } + + #[cfg(test)] + mod tests { + use super::rtt; + + #[test] + fn rtt_stays_inside_the_values_windows_accepts() { + assert_eq!(rtt(0), 1); + assert_eq!(rtt(500), 500); + assert_eq!(rtt(65_535), 65_534); + assert_eq!(rtt(u64::MAX), 65_534); + } + } +} diff --git a/crates/netscli-core/src/scan/tcp.rs b/crates/netscli-core/src/scan/tcp.rs index 6c1be314..818f465c 100644 --- a/crates/netscli-core/src/scan/tcp.rs +++ b/crates/netscli-core/src/scan/tcp.rs @@ -4,10 +4,9 @@ use std::sync::{ atomic::{AtomicUsize, Ordering}, Arc, }; -use std::time::{Duration, Instant}; +use std::time::Instant; use tokio::net::TcpStream; use tokio::sync::Semaphore; -use tokio::time::timeout; use super::probes::{as_text, ask, first_banner_line, probe_http, probe_tls, read_greeting, Quiet}; use super::services::{classify_connect_error, guess_service, is_http_port, is_tls_port}; @@ -128,10 +127,8 @@ impl PortScanner { let addr = SocketAddr::new(target, port); let service = Self::guess_service(port); let started = Instant::now(); - let result = timeout(Duration::from_millis(timeout_ms), TcpStream::connect(addr)).await; - - match result { - Ok(Ok(stream)) => { + match crate::common::tcp_connect(addr, timeout_ms).await { + Ok(stream) => { let latency_ms = started.elapsed().as_millis() as u64; let mut result = PortResult::new(port, PortStatus::Open, service.clone()) .with_latency(latency_ms); @@ -145,7 +142,7 @@ impl PortScanner { } result } - Ok(Err(e)) => match classify_connect_error(e.kind()) { + Err(e) => match classify_connect_error(e.kind()) { PortStatus::Closed => PortResult::new(port, PortStatus::Closed, service) .with_latency(started.elapsed().as_millis() as u64), PortStatus::Filtered => PortResult::new(port, PortStatus::Filtered, service), @@ -155,7 +152,6 @@ impl PortScanner { .with_error(e.to_string()) } }, - Err(_) => PortResult::new(port, PortStatus::Filtered, service), } } diff --git a/crates/netscli-core/src/scan/tests.rs b/crates/netscli-core/src/scan/tests.rs index f21bd74a..59911560 100644 --- a/crates/netscli-core/src/scan/tests.rs +++ b/crates/netscli-core/src/scan/tests.rs @@ -107,16 +107,14 @@ async fn test_scan_closed_local_port_records_latency() { .await .expect("valid port list"); + // Nothing listens, so the OS answers with a RST and the port is closed, + // well inside the timeout. Windows used to retry the SYN for about 2 s + // first, so this read as filtered there. assert_eq!(results.len(), 1); let result = results.remove(0); assert!(!result.open); - assert!(matches!( - result.status, - PortStatus::Closed | PortStatus::Filtered | PortStatus::Error - )); - if matches!(result.status, PortStatus::Closed) { - assert!(result.latency_ms.is_some()); - } + assert_eq!(result.status, PortStatus::Closed); + assert!(result.latency_ms.is_some_and(|ms| ms < 500)); } #[tokio::test]