Skip to content

Commit a3465c7

Browse files
authored
Report closed ports as closed on Windows (#498)
Windows retries a SYN answered with a RST for about 2 s before connect() fails with WSAECONNREFUSED. The scanner's default timeout is 500 ms, so every closed port timed out first and read as filtered. Connect through a socket with SYN retransmissions off (SIO_TCP_INITIAL_RTO, MaxSynRetransmissions = TCP_INITIAL_RTO_NO_SYN_RETRANSMISSIONS), which makes the first RST final. Without retransmissions Windows gives up after the initial RTO (1 s by default), so set that to the caller's timeout too. The closed-port test accepted filtered, which is how this went unnoticed; it now requires closed within 500 ms and fails without the ioctl.
1 parent 927e137 commit a3465c7

6 files changed

Lines changed: 110 additions & 16 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,11 @@ its heading and collects entries; the date and the link go on with the tag.
9393
`ff:ff:ff:ff:ff:ff`, and discovery listed it as a host that ignored ping
9494
(and a sweep then scanned it). The network and broadcast addresses, and broadcast and
9595
multicast MACs, are no longer reported.
96+
- **Closed ports showed as filtered on Windows.** Windows waits about two
97+
seconds before reporting a refused connection, and the scan stops waiting
98+
after half a second, so a port that was plainly closed came back as
99+
filtered. The scan now reports it as closed straight away. A scan of 1,024
100+
ports on a LAN machine went from 1,022 filtered to 1,022 closed.
96101
- **The macOS app could be refused as broken on Apple Silicon.** Its only
97102
signature was the one Apple's linker puts on every arm64 program, which
98103
claims the app's files are sealed when nothing sealed them. macOS's own

‎crates/netscli-core/Cargo.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ pnet_datalink = "0.35"
4848

4949
[target.'cfg(windows)'.dependencies]
5050
ipconfig = "0.3"
51-
windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_NetworkManagement_IpHelper", "Win32_NetworkManagement_Ndis", "Win32_Networking_WinSock"] }
51+
windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_NetworkManagement_IpHelper", "Win32_NetworkManagement_Ndis", "Win32_Networking_WinSock", "Win32_System_IO"] }
5252

5353
[features]
5454
# Lean by default: scan, discover, DNS, ARP, OUI lookup, stats.

‎crates/netscli-core/src/common.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
mod connect;
12
mod constants;
23
mod network;
34
mod ports;
@@ -8,6 +9,7 @@ mod ports;
89
mod system_tools;
910
mod terminal;
1011

12+
pub(crate) use connect::connect as tcp_connect;
1113
pub use constants::{
1214
DEFAULT_CONCURRENCY, DEFAULT_DNS_TIMEOUT_MS, DEFAULT_PING_TIMEOUT_MS, DEFAULT_PORTS,
1315
DEFAULT_SCAN_TIMEOUT_MS, DEFAULT_SUBNET, MAX_MDNS_TIMEOUT_MS, MAX_PING_COUNT,
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
//! TCP connect for the port scanner, which must tell "refused" from "no
2+
//! answer".
3+
//!
4+
//! Windows retries a SYN that was answered with a RST, so a connect to a
5+
//! closed port only fails with `ConnectionRefused` after about 2 s (measured
6+
//! 2026-10-02 on Windows 11: 2025-2047 ms to a LAN host, 2043 ms to
7+
//! loopback). The port scanner gives up at 500 ms by default, so every closed
8+
//! port read as filtered. Turning SYN retransmissions off for the socket
9+
//! makes the first RST final: the same connects failed in 0-5 ms.
10+
//!
11+
//! With no retransmissions Windows abandons the connect after the initial
12+
//! retransmission timeout, 1 s by default, whatever timeout the caller asked
13+
//! for. So the initial timeout is set to the caller's timeout as well.
14+
15+
use std::io;
16+
use std::net::SocketAddr;
17+
use std::time::Duration;
18+
19+
use tokio::net::{TcpSocket, TcpStream};
20+
21+
/// Connect to `addr`, giving up after `timeout_ms`. A timeout is reported as
22+
/// `ErrorKind::TimedOut`, the same kind the OS uses for an unanswered SYN.
23+
pub(crate) async fn connect(addr: SocketAddr, timeout_ms: u64) -> io::Result<TcpStream> {
24+
let socket = if addr.is_ipv4() {
25+
TcpSocket::new_v4()?
26+
} else {
27+
TcpSocket::new_v6()?
28+
};
29+
#[cfg(windows)]
30+
windows::final_rst(&socket, timeout_ms);
31+
match tokio::time::timeout(Duration::from_millis(timeout_ms), socket.connect(addr)).await {
32+
Ok(result) => result,
33+
Err(_) => Err(io::Error::new(io::ErrorKind::TimedOut, "connect timed out")),
34+
}
35+
}
36+
37+
#[cfg(windows)]
38+
mod windows {
39+
use std::os::windows::io::AsRawSocket;
40+
41+
use tokio::net::TcpSocket;
42+
use windows_sys::Win32::Networking::WinSock::{
43+
WSAIoctl, SIO_TCP_INITIAL_RTO, SOCKET, TCP_INITIAL_RTO_NO_SYN_RETRANSMISSIONS,
44+
TCP_INITIAL_RTO_PARAMETERS,
45+
};
46+
47+
/// Turn off SYN retransmissions and set the initial retransmission
48+
/// timeout to `timeout_ms`. Best effort: if the ioctl is refused the
49+
/// connect still works, it is only slow to report a closed port.
50+
pub(super) fn final_rst(socket: &TcpSocket, timeout_ms: u64) {
51+
let params = TCP_INITIAL_RTO_PARAMETERS {
52+
Rtt: rtt(timeout_ms),
53+
// The C header defines this as (USHORT)-2 and assigns it to the
54+
// UCHAR field, which keeps the low byte.
55+
MaxSynRetransmissions: TCP_INITIAL_RTO_NO_SYN_RETRANSMISSIONS as u8,
56+
};
57+
let mut returned = 0u32;
58+
// SAFETY: a valid socket handle, an input buffer of the declared size,
59+
// no output buffer, and no overlapped I/O.
60+
unsafe {
61+
WSAIoctl(
62+
socket.as_raw_socket() as SOCKET,
63+
SIO_TCP_INITIAL_RTO,
64+
&params as *const _ as *const core::ffi::c_void,
65+
std::mem::size_of::<TCP_INITIAL_RTO_PARAMETERS>() as u32,
66+
std::ptr::null_mut(),
67+
0,
68+
&mut returned,
69+
std::ptr::null_mut(),
70+
None,
71+
);
72+
}
73+
}
74+
75+
/// 65535 means "unspecified" to Windows, so the largest usable value is
76+
/// 65534 ms. 0 is not a timeout either.
77+
pub(super) fn rtt(timeout_ms: u64) -> u16 {
78+
timeout_ms.clamp(1, 65_534) as u16
79+
}
80+
81+
#[cfg(test)]
82+
mod tests {
83+
use super::rtt;
84+
85+
#[test]
86+
fn rtt_stays_inside_the_values_windows_accepts() {
87+
assert_eq!(rtt(0), 1);
88+
assert_eq!(rtt(500), 500);
89+
assert_eq!(rtt(65_535), 65_534);
90+
assert_eq!(rtt(u64::MAX), 65_534);
91+
}
92+
}
93+
}

‎crates/netscli-core/src/scan/tcp.rs‎

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,9 @@ use std::sync::{
44
atomic::{AtomicUsize, Ordering},
55
Arc,
66
};
7-
use std::time::{Duration, Instant};
7+
use std::time::Instant;
88
use tokio::net::TcpStream;
99
use tokio::sync::Semaphore;
10-
use tokio::time::timeout;
1110

1211
use super::probes::{as_text, ask, first_banner_line, probe_http, probe_tls, read_greeting, Quiet};
1312
use super::services::{classify_connect_error, guess_service, is_http_port, is_tls_port};
@@ -128,10 +127,8 @@ impl PortScanner {
128127
let addr = SocketAddr::new(target, port);
129128
let service = Self::guess_service(port);
130129
let started = Instant::now();
131-
let result = timeout(Duration::from_millis(timeout_ms), TcpStream::connect(addr)).await;
132-
133-
match result {
134-
Ok(Ok(stream)) => {
130+
match crate::common::tcp_connect(addr, timeout_ms).await {
131+
Ok(stream) => {
135132
let latency_ms = started.elapsed().as_millis() as u64;
136133
let mut result = PortResult::new(port, PortStatus::Open, service.clone())
137134
.with_latency(latency_ms);
@@ -145,7 +142,7 @@ impl PortScanner {
145142
}
146143
result
147144
}
148-
Ok(Err(e)) => match classify_connect_error(e.kind()) {
145+
Err(e) => match classify_connect_error(e.kind()) {
149146
PortStatus::Closed => PortResult::new(port, PortStatus::Closed, service)
150147
.with_latency(started.elapsed().as_millis() as u64),
151148
PortStatus::Filtered => PortResult::new(port, PortStatus::Filtered, service),
@@ -155,7 +152,6 @@ impl PortScanner {
155152
.with_error(e.to_string())
156153
}
157154
},
158-
Err(_) => PortResult::new(port, PortStatus::Filtered, service),
159155
}
160156
}
161157

‎crates/netscli-core/src/scan/tests.rs‎

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -107,16 +107,14 @@ async fn test_scan_closed_local_port_records_latency() {
107107
.await
108108
.expect("valid port list");
109109

110+
// Nothing listens, so the OS answers with a RST and the port is closed,
111+
// well inside the timeout. Windows used to retry the SYN for about 2 s
112+
// first, so this read as filtered there.
110113
assert_eq!(results.len(), 1);
111114
let result = results.remove(0);
112115
assert!(!result.open);
113-
assert!(matches!(
114-
result.status,
115-
PortStatus::Closed | PortStatus::Filtered | PortStatus::Error
116-
));
117-
if matches!(result.status, PortStatus::Closed) {
118-
assert!(result.latency_ms.is_some());
119-
}
116+
assert_eq!(result.status, PortStatus::Closed);
117+
assert!(result.latency_ms.is_some_and(|ms| ms < 500));
120118
}
121119

122120
#[tokio::test]

0 commit comments

Comments
 (0)