A Polite Port Scanner in 400 Lines of Tokio
nmapis a great tool. It is also the wrong tool for the question I ask my laptop about fifteen times a day: "did postgres come up on 5432, or did docker bump it to 5433 again?" So I wrote a tiny one. Localhost by default, concurrent via tokio, honest about what it can and can't see. Here is the design.
🔗 GitHub: https://github.com/sen-ltd/port-scanner
The daily question
Every week I seem to re-discover the same fact: the gap between "I want to scan my own machine for a listener" and "I want nmap" is enormous. nmap does SYN scans (needs root), OS fingerprinting, service version detection, NSE scripting, six kinds of timing templates. All excellent. None of which help me answer "is redis up on 6379 yet or do I need to keep waiting on docker-compose?"
The actual question I want to ask is: "for these N ports, is something listening? Yes / no / I can't tell." On my own machine, maybe across 1024 ports, as fast as tokio can shove connect() calls at the kernel.
That's not a 30000-line project. It's about 400 lines of Rust with two dependencies, and it turns out to be a really nice little tour of tokio patterns I reach for constantly: JoinSet, Semaphore, timeout. If you've been meaning to build something small with tokio to get a feel for it, port scanning is a perfect excuse. Let me walk through the interesting parts.
The scope discipline: localhost by default
Before we get to code — one thing to settle. Port scanners have a public-image problem. Scanning a machine you don't own can range from "mildly rude" to "felony, depending on jurisdiction", and I didn't want to ship a tool whose default behavior even flirts with that edge.
So the scope is explicit:
pub fn gate(host: &str, allow_remote: bool) -> Result<(), String> {
match classify(host) {
HostKind::Local => Ok(()),
HostKind::Remote if allow_remote => Ok(()),
HostKind::Remote => Err(format!(
"refusing to scan remote host {host:?} without --allow-remote.\n\
\n\
This tool scans 127.0.0.1 by default. Scanning another machine \
without the owner's consent can be illegal in your jurisdiction.\n\
If you own {host:?} or have permission, re-run with --allow-remote."
)),
}
}
The gate runs before we even parse the port spec. Any non-loopback target — IPv4, IPv6, hostname — fails with exit code 2 and a human-readable message unless the caller passed --allow-remote. The classification is deliberately syntactic: localhost, 127.0.0.1, ::1, 0.0.0.0, and :: are local. Everything else is remote. We do not resolve DNS before deciding, because if corp-dev.example.com happens to point at 127.0.0.1 today, I don't want the user's accidental typo to be the thing that saves them.
One subtlety: private RFC1918 addresses like 192.168.1.1 and 10.0.0.5 are classified as remote. It would be tempting to let them through — "come on, it's the LAN, surely that's fine" — but I don't know whose LAN the user is on. The cost of making --allow-remote opt-in for the home router case is one extra flag. The cost of making it implicit is someone accidentally scanning a hotel network. Easy call.
This is "intentional friction." Adding a command-line flag to do the thing you meant to do is not a security feature in any deep sense — anyone who wants to scan 8.8.8.8 can type --allow-remote without breaking a sweat. What it is is a nudge: "hey, just so we're clear, you know what you're asking for, right?" That's enough.
The port spec parser
Users want to say things like 80,443, 1-1024, or 80,443,8080-8090. This is the kind of parser that looks trivial until you write the tests.
pub fn parse(spec: &str) -> Result<Vec<u16>, ParseError> {
if spec.is_empty() {
return Err(ParseError::Empty);
}
let mut out: BTreeSet<u16> = BTreeSet::new();
for item in spec.split(',') {
if item.is_empty() {
return Err(ParseError::EmptyItem);
}
if item.chars().any(|c| c.is_whitespace()) {
return Err(ParseError::TrailingGarbage(item.to_string()));
}
match item.split_once('-') {
None => {
let n = parse_port(item)?;
out.insert(n);
}
Some((a, b)) => {
let lo = parse_port(a)?;
let hi = parse_port(b)?;
if lo > hi {
return Err(ParseError::InvalidRange { lo, hi });
}
for p in lo..=hi {
out.insert(p);
}
}
}
}
Ok(out.into_iter().collect())
}
Two design points worth calling out:
BTreeSet for dedup-plus-sort. The user can say 443,80,80,1-5 and we want to emit 1,2,3,4,5,80,443 — sorted, no duplicates, deterministic. BTreeSet gives us that for free, and the into_iter().collect() at the end preserves the sorted order. I started with a Vec plus sort_unstable() plus dedup() and it was uglier for no reason.
Rejecting whitespace inside items. parse("80, 443") returns Err. This feels fussy, but the alternative is allowing parse("80, 443, 1024") and then explaining to a user why parse("80\t443") works but parse("80 443") doesn't. Same convention as nmap's -p flag: quote it, no spaces. The error message is explicit: trailing garbage in port spec: " 443".
Port 0 is rejected specifically. On most OSes it's the "any port" sentinel for bind() and there's no meaningful "scan port zero" case. Eleven tests, all named. My favorite:
#[test]
fn full_range() {
// 1-1024 is the classic "well-known ports" scan.
let v = parse("1-1024").unwrap();
assert_eq!(v.len(), 1024);
assert_eq!(v[0], 1);
assert_eq!(v[1023], 1024);
}
The scanner: JoinSet + bounded Semaphore
This is the tokio bit. The core of the scanner is this:
pub async fn scan(
host: &str,
ports: &[u16],
concurrency: usize,
per_port_timeout: Duration,
) -> Vec<PortResult> {
let sem = Arc::new(Semaphore::new(concurrency.max(1)));
let host: Arc<str> = Arc::from(host);
let mut set: JoinSet<PortResult> = JoinSet::new();
for &port in ports {
let sem = sem.clone();
let host = host.clone();
set.spawn(async move {
let _permit = sem.acquire_owned().await.expect("semaphore closed");
probe_one(&host, port, per_port_timeout).await
});
}
let mut results = Vec::with_capacity(ports.len());
while let Some(joined) = set.join_next().await {
if let Ok(r) = joined { results.push(r); }
}
results.sort_by_key(|r| r.port);
results
}
Three patterns here. Worth understanding each one because you'll use them together over and over once you internalize them.
JoinSet is for "spawn N tasks, collect their results as they finish." It's strictly better than Vec<JoinHandle> for this use case because join_next() yields the first one to complete rather than waiting for them in submission order. If port 80 finishes in 1 ms and port 22 takes 900 ms, join_next() hands us port 80 immediately. We don't need that property for summing — we're collecting everything anyway — but it's the right default, and if you ever wanted to stream results to stdout as they come in, you'd already have it.
Semaphore is the concurrency cap. This is the actually-important knob. Without it, scan("127.0.0.1", &(1..=1024).collect::<Vec<_>>(), _, _) would spawn 1024 tasks instantly, each one calling connect(). Two things go wrong:
- You blow past the default
ulimit -n(1024 file descriptors on macOS) and suddenly your "scanner" is returning EMFILE errors. - The kernel's ephemeral port range — the range it uses as source ports for outgoing connections — is finite. On Linux that's typically ~28,000 ports. If you're scanning a lot and doing it in a loop, you will exhaust them and
connect()will start failing withEADDRNOTAVAIL.
A Semaphore with 200 permits (the default) caps us at 200 outstanding connect()s at any one time. Tasks wait on acquire_owned() inside the spawn so the semaphore is the only gate — spawning 1024 tasks is cheap, it's the syscalls we want to throttle. The .expect("semaphore closed") is safe because we hold the Arc for the entire loop.
tokio::time::timeout wraps the fallible operation. The per-port probe:
async fn probe_one(host: &str, port: u16, deadline: Duration) -> PortResult {
let started = Instant::now();
let target = format!("{host}:{port}");
let result = timeout(deadline, TcpStream::connect(&target)).await;
let latency = started.elapsed();
let status = match result {
Ok(Ok(_stream)) => Status::Open, // handshake completed
Ok(Err(_)) => Status::Closed, // RST, refused
Err(_) => Status::Filtered, // silence
};
PortResult { port, status, latency }
}
Note the nested Result: timeout() returns Result<T, Elapsed> where T is the inner return type. So a successful connect() is Ok(Ok(stream)), a failed one is Ok(Err(io::Error)), and a timeout is Err(Elapsed). The three arms map cleanly onto the three-way status.
Open, closed, filtered — what we actually measure
That three-way split deserves its own section. It's not just pedantry — it's what TCP literally tells us.
Open —
connect()returnedOk. The other side completed the three-way handshake: we sent SYN, they sent SYN+ACK, we sent ACK. The kernel on the target machine forwarded the connection up to a listening socket. Something is listening.Closed —
connect()returnedErrfast (usuallyECONNREFUSED). The target's kernel answered our SYN with a RST packet, which it does when nothing is listening on that port but the machine is otherwise up and routable. Nothing is listening, but the host is alive.Filtered — the timeout fired before anything happened. No SYN+ACK, no RST, no ICMP unreachable, just silence. On a local machine this is unusual; it almost always means "open and busy" is not a real distinction and we got a weirdly slow answer. On a remote host it usually means a firewall is dropping the packet rather than rejecting it, which is what most modern firewalls do to avoid giving an attacker useful information. We can't tell the difference between "closed" and "dropped" when the response is nothing.
nmap uses exactly these three labels for its -sT connect scan and for exactly this reason. A stateful firewall that drops-on-policy is indistinguishable from a closed port from the scanner's perspective, because both produce "no response at all." Calling that state filtered is honest: we're telling the user "I didn't hear back, and I don't know why, and the timeout might mean a dozen different things."
Testing against real listeners
The scanner tests are some of the more satisfying tests I've written in a while, because tokio gives you everything you need to make them deterministic:
#[tokio::test]
async fn open_port_is_detected() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let results = scan("127.0.0.1", &[port], 10, Duration::from_millis(500)).await;
assert_eq!(results[0].status, Status::Open);
}
Bind to 127.0.0.1:0 — the kernel hands you a free ephemeral port. You now have a guaranteed-open port number. Hand it to scan() and assert Open. No mocks, no test doubles, no wiremock-style nonsense; you're testing against a real TCP stack.
For the "closed" case, the trick is to bind and immediately drop the listener:
let (listener, port) = bound_listener().await;
drop(listener);
The port was yours; now it isn't. Anything that tries to connect to it should get RST (closed) within microseconds on loopback. There's a tiny TOCTOU window where another process could grab that port before the test runs, but in practice on a dev machine this is fine, and the test is strictly better than "hardcode port 1 and hope."
33 unit tests + 12 integration tests via the built binary. The integration layer uses assert_cmd-style invocation (env!("CARGO_BIN_EXE_port-scanner")) and tests the exit-code contract end-to-end: 0 on any open, 1 on none open, 2 on bad args or safety gate.
Tradeoffs, honestly
This is not a replacement for nmap. Some things I deliberately did not build:
-
No SYN scan. SYN scans send a SYN, read the SYN+ACK, then send RST without completing the handshake. They're faster and stealthier, but they need a raw socket, which needs
CAP_NET_RAWon Linux or root on macOS. A localhost-first tool that asks for root to scan the machine you're already logged into is silly. - No OS fingerprinting. That's a pile of heuristics on TCP window sizes and option ordering and it is not a weekend project.
-
No service banner grab. After
connect()succeeds we could read a few bytes and guess at what's there. I chose not to: it adds another pass, another timeout, another set of edge cases, and for my daily use case I already know what's supposed to be on 5432 — I just need to know if it's up. - No UDP. UDP "scanning" by TCP-connect is nonsense; UDP needs its own strategy with its own timing assumptions.
-
--allow-remoteis intentional friction. A small ethical speed bump that costs nothing to type when you have a legitimate reason.
Two deps (clap + tokio), 400 lines, multi-stage Alpine Docker image at 9.6 MB. Everything strips and LTOs away.
Try it in 30 seconds
git clone https://github.com/sen-ltd/port-scanner
cd port-scanner
docker build -t port-scanner .
docker run --rm port-scanner 127.0.0.1 22,80,443,5432,6379,8080
Or if you have Rust installed:
cargo install --path .
port-scanner 127.0.0.1 1-1024 --open-only
It's the kind of tool that takes an afternoon to build and then disappears into your daily workflow. My favorite kind.
Closing
Entry #164 in a 100+ portfolio series by SEN LLC. If you liked this walk-through there are more in the series — the common thread is "small, single-purpose, honest about scope." Feedback welcome.

Top comments (0)