Port scanning is one of those problems that looks trivial until you try to do it fast. Connect to a port, check if it's open, move on. But when you need to check 65,535 ports and each connection attempt might hang for seconds waiting for a timeout, naive sequential scanning becomes unbearable. The answer is concurrency — but unbounded concurrency creates its own problems.
In this article I'll walk through building portscan-rs, a fast concurrent TCP port scanner in Rust. The interesting parts aren't the networking — it's the backpressure pattern using tokio semaphores, the injectable connector trait that makes the scan engine fully testable without touching the network, and the timeout handling that distinguishes "closed" from "filtered" ports.
Important: This tool is for authorized use only. Only scan hosts you own or have explicit written permission to test. The focus here is on async Rust patterns, not penetration testing.
The problem with unbounded concurrency
The tempting first approach is to spawn a task for every port:
// Don't do this
for port in 1..=65535 {
tokio::spawn(async move {
try_connect(host, port).await;
});
}
This creates 65,535 tasks immediately. Each one opens a TCP connection. Your OS has a file descriptor limit (often 1024 by default on Linux). You'll hit it, get EMFILE errors, and the scan will produce garbage results. Even if you raise the limit, flooding the network stack with tens of thousands of simultaneous SYN packets can saturate your NIC or trigger firewall rate limiting.
What we need is backpressure — a mechanism that lets us limit how many connections are in-flight at any moment, while still keeping all available slots busy.
Semaphore-bounded concurrency
A semaphore is exactly the right primitive. It holds N permits. Before starting a connection, a task acquires a permit. When the connection completes (or times out), the permit is released. If all permits are taken, the next task waits until one frees up.
Here's the core scan function:
pub async fn scan<C>(
host: &str,
ports: &[u16],
concurrency: usize,
connector: std::sync::Arc<C>,
) -> Vec<PortResult>
where
C: Connector + 'static,
{
use tokio::sync::Semaphore;
let sem = std::sync::Arc::new(Semaphore::new(concurrency.max(1)));
let mut handles = Vec::with_capacity(ports.len());
for &port in ports {
let permit = sem.clone().acquire_owned().await
.expect("semaphore closed");
let host_owned = host.to_string();
let conn = connector.clone();
let handle = tokio::spawn(async move {
let _permit = permit; // released on drop
let state = conn.connect(host_owned, port).await;
PortResult { port, state }
});
handles.push(handle);
}
let mut out = Vec::with_capacity(handles.len());
for h in handles {
if let Ok(r) = h.await {
out.push(r);
}
}
out.sort_by_key(|r| r.port);
out
}
The key insight is where the acquire_owned() call sits. It's before tokio::spawn, not inside the spawned task. This means the loop itself blocks when all permits are taken — we won't even create the next task until a slot opens up. This keeps memory usage proportional to concurrency, not to the total port count.
The permit is moved into the spawned future as _permit. When the future completes, _permit is dropped, which releases the permit back to the semaphore. The underscore prefix tells Rust "I know this variable isn't read, but I need it alive for its Drop side effect."
Why acquire_owned instead of acquire?
The acquire() method returns a SemaphorePermit<'a> that borrows the semaphore. Since our spawned task needs to be 'static (it might outlive the current stack frame), we need acquire_owned(), which returns an OwnedSemaphorePermit that holds an Arc reference to the semaphore. This is a common pattern in tokio code that combines semaphores with tokio::spawn.
Timeout handling: closed vs. filtered
Not all unreachable ports behave the same way. A closed port actively refuses the connection — the OS sends back a TCP RST packet, and connect() returns an error immediately. A filtered port might be behind a firewall that silently drops the SYN packet, so connect() just hangs until our timeout expires.
Distinguishing these two states is useful for the user:
pub struct TokioConnector {
pub timeout: std::time::Duration,
}
impl Connector for TokioConnector {
fn connect(&self, host: String, port: u16) -> ConnectFuture {
let t = self.timeout;
Box::pin(async move {
let addr = format!("{host}:{port}");
match tokio::time::timeout(t, tokio::net::TcpStream::connect(&addr)).await {
Ok(Ok(_)) => PortState::Open, // connection succeeded
Ok(Err(_)) => PortState::Closed, // connection refused
Err(_) => PortState::Filtered, // timeout elapsed
}
})
}
}
The tokio::time::timeout wrapper gives us a clean three-way result:
-
Ok(Ok(_))— the inner future completed and the connection succeeded. Port is open. -
Ok(Err(_))— the inner future completed but with an error (typically "connection refused"). Port is closed. -
Err(_)— the timeout fired before the inner future completed. Port is filtered (or very slow).
This is a pattern you'll use in any networked Rust application. The key realization is that timeout wraps the future, not the result — it races a timer against the entire async operation.
Trait-based dependency injection for testability
The scan engine needs to make TCP connections. But in tests, we don't want to spin up real network services for every edge case. The solution is an injectable Connector trait:
pub type ConnectFuture = Pin<Box<dyn Future<Output = PortState> + Send>>;
pub trait Connector: Send + Sync {
fn connect(&self, host: String, port: u16) -> ConnectFuture;
}
The real implementation uses TcpStream::connect. Tests can inject a stub:
pub struct StubConnector {
pub open_ports: Vec<u16>,
pub filtered_ports: Vec<u16>,
}
impl Connector for StubConnector {
fn connect(&self, _host: String, port: u16) -> ConnectFuture {
let open = self.open_ports.contains(&port);
let filtered = self.filtered_ports.contains(&port);
Box::pin(async move {
if open { PortState::Open }
else if filtered { PortState::Filtered }
else { PortState::Closed }
})
}
}
This lets us test the scan engine's concurrency logic, result aggregation, and sorting without any network I/O:
#[tokio::test]
async fn scan_with_stub_returns_open_ports() {
let stub = Arc::new(StubConnector {
open_ports: vec![22, 80, 443],
filtered_ports: vec![8080],
});
let results = scan("fake-host", &[22, 80, 81, 443, 8080], 4, stub).await;
let open: Vec<u16> = results.iter()
.filter(|r| r.state == PortState::Open)
.map(|r| r.port)
.collect();
assert_eq!(open, vec![22, 80, 443]);
}
But we don't stop there. We also have tests that use real TCP:
#[tokio::test]
async fn real_tcp_listener_is_detected_as_open() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let _accept = tokio::spawn(async move {
loop { if listener.accept().await.is_err() { break; } }
});
let conn = Arc::new(TokioConnector {
timeout: Duration::from_millis(500),
});
let results = scan(&addr.ip().to_string(), &[addr.port()], 4, conn).await;
assert_eq!(results[0].state, PortState::Open);
}
The trick here is bind("127.0.0.1:0") — port 0 tells the OS to assign any free port. We then extract the actual port from local_addr(). This avoids hardcoding port numbers that might conflict with other services or tests running in parallel.
Port specification parsing
Users need flexible ways to specify which ports to scan. We support three formats:
- Single ports:
80 - Comma-separated lists:
22,80,443 - Ranges:
1-1024 - Mixed:
22,80-82,3306,5432
The parser is a pure function that returns Result<Vec<u16>, ParsePortsError>:
pub fn parse_ports(spec: &str) -> Result<Vec<u16>, ParsePortsError> {
let mut out: Vec<u16> = Vec::new();
for raw in spec.trim().split(',') {
let item = raw.trim();
if let Some((lo_s, hi_s)) = item.split_once('-') {
let lo: u16 = lo_s.trim().parse()
.map_err(|_| ParsePortsError(format!("bad range start: {lo_s}")))?;
let hi: u16 = hi_s.trim().parse()
.map_err(|_| ParsePortsError(format!("bad range end: {hi_s}")))?;
if lo == 0 || hi == 0 {
return Err(ParsePortsError("port 0 is not scannable".into()));
}
if lo > hi {
return Err(ParsePortsError(format!("reverse range {lo}-{hi}")));
}
for p in lo..=hi { out.push(p); }
} else {
let p: u16 = item.parse()
.map_err(|_| ParsePortsError(format!("bad port number: {item}")))?;
if p == 0 {
return Err(ParsePortsError("port 0 is not scannable".into()));
}
out.push(p);
}
}
out.sort_unstable();
out.dedup();
Ok(out)
}
A few design decisions worth noting:
- Port 0 is rejected. It's not meaningful for TCP scanning (it means "OS picks a free port" in bind contexts).
- Reverse ranges are rejected rather than silently swapped. "90-80" is likely a typo, not an intentional backwards range.
-
Duplicates are merged.
22,80,22scans port 22 once, not twice. - Whitespace is tolerated. Users shouldn't have to worry about spaces after commas.
This is a pure function with no side effects, which makes it easy to test extensively. We have 10 tests covering the parser alone — valid inputs, edge cases, and error conditions.
The "top ports" shortcut
Scanning all 65,535 ports is thorough but slow. Most of the time, users care about well-known services. We ship a curated list of ~100 common TCP service ports (HTTP, SSH, databases, mail servers, caches, etc.) that the user can access with --top N:
const TOP_PORTS: &[u16] = &[
80, 443, 22, 21, 25, 53, 110, 143, 465, 587, 993, 995,
8080, 8443, 8000, 8888, 3000, 5000,
3306, 5432, 6379, 27017, // databases
// ... ~100 entries total
];
pub fn top_ports(n: usize) -> Vec<u16> {
let slice = &TOP_PORTS[..n.min(TOP_PORTS.len())];
let mut v: Vec<u16> = slice.to_vec();
v.sort_unstable();
v.dedup();
v
}
The list is ordered roughly by frequency/importance, so --top 10 gives you the most common services while --top 100 covers a broad survey. If the user specifies neither --ports nor --top, we default to the top 100.
The thin CLI shell
The main.rs file is deliberately thin — about 100 lines. It uses clap's derive macro for argument parsing, resolves the port list, builds the tokio runtime manually (rather than using #[tokio::main]), runs the scan, and prints colored output:
fn main() -> ExitCode {
let cli = Cli::parse();
let pal = Palette::new(color::should_use_color(cli.no_color));
let ports = if let Some(ref spec) = cli.ports {
parse_ports(spec)?
} else if let Some(n) = cli.top {
top_ports(n)
} else {
top_ports(100)
};
let connector = Arc::new(TokioConnector { timeout: cli.timeout });
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.unwrap();
let results = rt.block_on(scan(&cli.host, &ports, cli.concurrency, connector));
// ... print results ...
ExitCode::SUCCESS
}
Building the runtime manually rather than using the #[tokio::main] attribute gives us control over the return type (ExitCode instead of Result) and avoids the macro hiding important setup.
Color handling without dependencies
Rather than pulling in a color crate, we hand-roll a minimal ANSI palette. The Palette struct is constructed once at startup with a boolean for whether color is enabled:
pub struct Palette { enabled: bool }
impl Palette {
fn pick(&self, code: &'static str) -> &'static str {
if self.enabled { code } else { "" }
}
pub fn open(&self) -> &'static str { self.pick("\x1b[32m") }
pub fn reset(&self) -> &'static str { self.pick("\x1b[0m") }
// ...
}
Call sites never branch on color — they just call pal.open() and get either an ANSI escape or an empty string. Color is disabled when stdout is not a terminal (piping), when NO_COLOR is set, or when --no-color is passed.
Testing strategy
The project has 27 tests across three layers:
Unit tests in lib.rs (20 tests): Port parsing (10 tests covering valid specs, ranges, errors, deduplication), top-ports behavior, result formatting, stub-based scanning, and real TCP listener detection.
Color module tests (2 tests in lib): Palette enabled/disabled behavior.
CLI integration tests (7 tests): Using
assert_cmdto run the compiled binary and verify help text, version output, and error exit codes for invalid inputs.
The test pyramid is intentionally bottom-heavy: most logic lives in pure functions that are cheap to test. The async scan engine is tested both with a stub (for logic correctness) and with a real TcpListener (for integration confidence). The CLI surface tests ensure argument parsing and error handling work end-to-end.
Performance characteristics
On localhost, scanning 1024 ports with 200 concurrent connections and a 200ms timeout completes in about 10ms — closed ports return RST immediately, so the timeout rarely fires. Scanning a remote host where filtered ports cause timeouts is bounded by (ports / concurrency) * timeout. For 65,535 ports at concurrency 500 and timeout 1s, that's a worst case of about 131 seconds.
The semaphore keeps memory usage constant regardless of port count. Whether you scan 100 ports or 65,535, only concurrency tasks are alive at any moment. Each task is small — just a TcpStream::connect future and a PortResult to return.
What I'd add next
- Service detection: After finding open ports, probe them for protocol banners (HTTP, SSH, etc.)
- UDP scanning: Fundamentally different from TCP — no connection handshake, relies on ICMP unreachable responses
- JSON output: For scripting and piping into other tools
- IPv6 support: The connection code already handles it, but the CLI would need validation
Source code
The full source is available on GitHub. Two dependencies: clap for argument parsing, tokio for async runtime. Everything else — color output, port parsing, the top-ports table — is hand-rolled.
This is entry #191 in a series where I build 200 small tools and libraries. Each one focuses on a specific technique or pattern worth understanding.

Top comments (0)