A multithreaded TCP port scanner in Java to understand how tools like nmap work: it maps open ports across a subnet, identifies services from their banners, and tells closed ports apart from firewalled ones.
Only scan hosts you own or have written permission to scan.
scanme.nmap.orgallows light scanning for testing.
A command-line tool that takes an IPv4 address or subnet (e.g. 192.168.1.0/24), or an IPv6 address, and a list of ports, tries to connect to each one, and reports which ports are open, closed or filtered, and what is running behind each open port: the service, the software and its version (ssh · OpenSSH 9.6p1, http · nginx 1.24.0).
With --watch it draws the scan live in the terminal as pixel art: you and the target, probes going out along the wire, a map of the ports filling in, and a card with an icon for every service found.
Every device on a network listens on numbered ports: 22 is usually SSH, 80 and 443 are websites, 3389 is Remote Desktop. Network and security teams constantly need to know which ports a machine exposes and what is running behind them:
- Network engineering: does the firewall really block what it should? What is on this subnet? Why can't anyone reach this service?
- Security: every open port is a possible way in, and a banner such as
SSH-2.0-OpenSSH_7.2shows the exact version to check against known vulnerabilities. - Operations: find forgotten services, like an old FTP server nobody switched off.
Doing this by hand is slow and unclear:
- Slow: checking ports one at a time means waiting out a full timeout for every port that does not answer. On a firewalled host, 1,024 ports at 500 ms each is over 8 minutes.
- Unclear: a failed connection alone does not say whether nothing is listening or a firewall is hiding the port, and those mean very different things.
- TCP connect scan that reads the packet behaviour: a completed handshake is OPEN, a reset (RST) is CLOSED, silence until the timeout is FILTERED. That turns "it failed" into "nothing there" vs "a firewall is dropping it".
- Banner grabbing to identify the service: read the greeting that SSH, FTP and SMTP send, and send an HTTP
HEADto services that stay silent. - Service and version detection from what came back: the SSH greeting, the HTTP
Server:header, FTP and mail greetings, the binary MySQL/MariaDB greeting, VNC, Redis and AMQP. When the service says nothing, the usual service for that port is shown as a guess, marked with?, because anything can listen on any port. - Java 21 virtual threads, capped by a semaphore, so probes wait on the network in parallel: 1,024 ports at 200 at a time takes about 1,024 ÷ 200 × 0.5 s ≈ 3 seconds in the worst case instead of 8 minutes (measured: 93x faster on a silent host, see Benchmarks), without exhausting sockets or flooding the target.
- CIDR parsing to scan a whole subnet in one command, with safety limits (nothing bigger than a /16, at most 1,000,000 probes).
- IPv6 targets: single addresses such as
::1or2001:db8::1, parsed as literals (never a DNS lookup) and written the standard short way (RFC 5952). IPv6 subnets are refused: even the usual smallest one, a /64, holds about 18 quintillion addresses, so sweeping it would never finish. - Polite scanning on request:
--rate Nstarts at most N probes per second. Concurrency only limits how many probes are open at once; against a host that answers instantly, 200 at once can still mean thousands of connection attempts per second, enough to upset a small device.--no-probeonly completes the handshake and closes, sending nothing to the service. Use it for fragile devices (old printers, industrial controllers) that misbehave when sent unexpected data. Services are then only guessed from the port number.
nmap already does this well. portscout exists to understand how it works underneath, the networking, security and Java skills these jobs are built on:
| Concept | Where it lives |
|---|---|
| TCP three-way handshake (SYN, SYN-ACK, RST) | PortProbe: open / closed / filtered |
| Subnetting and CIDR math | TargetParser: network and broadcast addresses |
| Service and version identification | PortProbe.grabReply, Service.identify |
| Drawing in a terminal: half blocks, 24-bit colour, redrawing in place | ReconView |
| Firewall behaviour (drop vs refuse) | FILTERED vs CLOSED |
| Concurrency in Java 21 | Scanner: virtual threads capped by a semaphore |
| Rate limiting without bursts | Pacer: evenly spaced starts, no catch-up after a pause |
| IPv6 address text (RFC 5952) | TargetParser.parseIpv6, TargetParser.compress |
| Testing and documentation | 82 JUnit tests, Javadoc, UML, flowchart and the edge cases below |
Each project looks at the same network from a different side:
| Project | Language | View | Question it answers |
|---|---|---|---|
| portscout (this one) | Java 21 | the attacker's view | What ports are open, and what is running behind them? |
| wiresight | C++20 | the network's view | What is actually on the wire? |
| logonwatch | C# / .NET 10 | the defender's view | Is someone trying to break in, and did they get in? |
| tracekeep | Python | the evidence's view | Can the logs still be trusted afterwards? |
They work together: run wiresight on the loopback adapter while portscout scans, and every probe becomes visible. A closed port answers SYN with RST, an open one completes the SYN, SYN-ACK, ACK handshake, and you can even see portscout's 19-byte banner probe. The full capture is in wiresight's README.
The common thread: patient, automated attackers. Automated attackers, including AI-driven ones, are cheap, fast and patient, so each tool answers them in its own layer. portscout shows that a rate limit makes a scan polite, not invisible. wiresight catches fast and slow scans and sweeps, and caps its own memory against SYN floods. logonwatch catches slow password guessing and spraying spread over a day. tracekeep's protocol makes every connection prove the key and solve a puzzle that gets harder after each failure. The series overview has the whole story.
- JDK 21 or newer. Check with
java -version. If it is missing or older, install Eclipse Temurin and reopen your terminal. - Git, to download the code.
You do not need to install Gradle: the included gradlew wrapper downloads the right version on first use.
git clone https://github.com/AoceanP/portscout.git
cd portscout
| Windows (PowerShell or cmd) | macOS / Linux |
|---|---|
.\gradlew.bat build |
./gradlew build |
The first run downloads Gradle and takes a minute. It ends with BUILD SUCCESSFUL once every test passes.
| Windows | macOS / Linux |
|---|---|
.\gradlew.bat run --args="127.0.0.1" |
./gradlew run --args="127.0.0.1" |
| Windows | macOS / Linux |
|---|---|
.\gradlew.bat installDist |
./gradlew installDist |
build\install\portscout\bin\portscout.bat 127.0.0.1 |
build/install/portscout/bin/portscout 127.0.0.1 |
Add that bin folder to your PATH to run portscout from anywhere.
gradlewis not recognized (PowerShell): type.\gradlew.bat, with the.\in front.JAVA_HOME is not setorUnsupported class file major version: your Java is missing or older than 21; see step 1.- Every port shows FILTERED on a remote host: a firewall is dropping the probes, or the timeout is too short for a slow link; try
-t 2000.
portscout <target> [options]
| Option | Meaning | Default |
|---|---|---|
target |
one IPv4 address, a CIDR block up to /16 (e.g. 192.168.1.0/24), or one IPv6 address (e.g. ::1) |
required |
-p, --ports |
ports and ranges, e.g. 22,80,443,8000-8100 |
1-1024 |
-t, --timeout |
connect and read timeout in ms | 500 |
-c, --concurrency |
probes running at once | 200 |
-r, --rate |
start at most N probes per second | no limit |
--no-probe |
only connect: send nothing to services, read no banners | off |
--json |
JSON output, including closed and filtered ports | off |
--all |
also list closed and filtered ports in the table | off |
-w, --watch |
show the recon view while scanning, then the table | off |
Example, a scan of a Windows PC:
> portscout 127.0.0.1 -p 1-1024,5357,5672,8080,15672
HOST PORT STATE SERVICE SOFTWARE MS BANNER
127.0.0.1 135 OPEN msrpc? 1009
127.0.0.1 445 OPEN smb? 501
127.0.0.1 5357 OPEN http Microsoft-HTTPAPI 2.0 502 HTTP/1.1 503 Service Unavailable
127.0.0.1 5672 OPEN amqp 503 AMQP
127.0.0.1 8080 OPEN http 509 HTTP/1.0 404 Not Found
127.0.0.1 15672 OPEN http Cowboy 503 HTTP/1.1 200 OK
6 open of 1028 probed
scanned 1 host(s) x 1028 port(s) in 1054 ms
- 135 and 445 (RPC and SMB file sharing) are open on every Windows PC; a home router keeps them hidden from the internet. Neither says anything, so
msrpc?andsmb?are guesses from the port number. - 5357 is Windows' own web server, which names itself in its
Server:header. - 5672 and 15672 are a RabbitMQ message broker running in Docker: the first answers in its own protocol (AMQP), the second is its management website, served by Cowboy.
- 8080 is a web server that does not name itself, so only "http" is known.
In JSON, each result also has service, product, version and identified_by (banner, port or none).
portscout 127.0.0.1 --watch
| Part | What it shows |
|---|---|
| The wire | probes going out to the target (amber) and answers coming back, green when an open port was just found |
| The server's lights | power, amber while being probed, green once an open port is found, brown if a port never answered |
| The progress line | probes done, and the open, closed and filtered counts as they come in |
| The port map | one pixel per port, in scan order: dark while waiting, green open, dark red closed, brown filtered. A big scan shows several probes per pixel, and the most interesting one wins (open first) |
| Services found | a card for each open port, with an icon for its kind (key: remote shell, globe: web, lock: encrypted, folder: file sharing, envelope: mail or messaging, cylinder: database, screen: remote desktop), its name and the software, or why the software is unknown |
The full table is printed below the view when the scan ends. The view needs a terminal with 24-bit colour and at least 80 x 28 characters: Windows Terminal (the default on Windows 11), and any common macOS or Linux terminal. Every character it draws exists in code page 437, the Windows console default.
Scanning many ports of Docker containers on Windows: Docker Desktop forwards each container port through one process, which slows down when hundreds of connections arrive at once. A web server in a container can then miss the banner timeout and show only http?. Scanning just those ports, or using -c 50 or -r 100, gives it time to answer.
A slow scan and wiresight: wiresight's fast alarm fires at 20 different ports from one address within 10 seconds, and a scan at -r 1 stays under it. That is why wiresight also has a 15-minute window, like the slow-attack rules in logonwatch: run against -r 1, it reports SLOW PORT SCAN ... 100 ports in 96 s instead. A rate limit makes a scan polite, not invisible.
- TCP connect scan: open = handshake completes, closed = RST, filtered = no reply before the timeout.
- Banner grabbing: read the greeting that SSH, FTP and SMTP send first; for silent services send an HTTP
HEADand read the status line. Only the first printable line is kept, because banners are untrusted remote data. - Concurrency: a semaphore permit is taken before each virtual thread starts, so at most
-cprobes exist at once and the scan never exhausts sockets. - Rate: before each probe starts,
Pacerwaits until one interval (1 s ÷ N) has passed since the previous start. After a pause the next probe goes at once but the one after still waits, so the rate is never exceeded in a burst. - Safety limits: blocks bigger than a /16 and scans over 1,000,000 probes are refused.
Scan results can reveal weak spots in a network, so portscout keeps nothing:
- No storage: no database, no log files, no history. Results are printed once to the terminal and exist only in memory until the program exits.
- No network calls other than the probes you ask for: no telemetry, no update checks, no lookups.
- No dependencies at runtime: only the Java standard library, so no third-party code can collect or send anything.
If you save output yourself (for example portscout 192.168.1.0/24 --json > scan.json), treat that file as sensitive and don't commit it.
The main thread parses the input and hands one task per host and port to a pool of virtual threads. Each task runs a TCP connect scan and, for open ports, grabs the banner.
Both diagrams are drawn in draw.io: portscout-flow.drawio and portscout-uml.drawio. Open them in draw.io or at app.diagrams.net to edit, then export to PNG with the same name.
Measured on Windows 11 with Java 25, default 500 ms timeout.
192.0.2.1 is a reserved address that never replies, so every probe waits out the full timeout, exactly like a host behind a firewall that drops packets. 100 ports:
Concurrency (-c) |
Time | Speed-up |
|---|---|---|
| 1 | 50,176 ms | 1x |
| 10 | 5,043 ms | 10x |
| 50 | 1,040 ms | 48x |
| 100 | 538 ms | 93x |
The time follows ports × timeout ÷ concurrency almost exactly (100 × 0.5 s ÷ 10 = 5 s), because each probe spends nearly all its time waiting, not computing. Virtual threads make that waiting cheap enough to run hundreds at once.
127.0.0.1, ports 1 to 1024. Closed ports answer with a reset in about 2 ms, so even one at a time is quick:
Concurrency (-c) |
Time |
|---|---|
| 1 | 3,831 ms |
| 50 | 1,053 ms |
| 500 | 1,050 ms |
Past 50 there is no gain: the scan can never finish faster than its slowest probe, here port 135, which waits about 1 s for a banner that never comes.
- PortParser
- TargetParser (CIDR)
- PortProbe (connect scan + banners)
- Scanner (virtual threads + semaphore)
- Benchmarks
- Compare against
nmap -sT - Stretch: UDP probes for DNS (53) and NTP (123)
- Identify the service, software and version behind each open port
- Recon view: a live pixel-art picture of the scan
- Polite scanning:
--rateand--no-probe - IPv6 targets (single addresses)
- Stretch: map versions to known vulnerabilities and flag outdated ones
MIT, Copyright (c) 2026 AoceanP. See LICENSE.
Every case below is covered by a test (in EdgeCaseTest unless another class is named) or, for the command line, was checked by hand.
| Input | Result | Why |
|---|---|---|
1, 65535, 443-443 |
accepted | the lowest and highest TCP ports, and a one-port range, are all valid |
0, 65536 |
refused | port 0 is reserved; ports are 16-bit (PortParserTest) |
080 |
port 80 | read as decimal, never octal |
-5 |
refused | looks like a range with no start, so it must not quietly scan 1 to 5 |
100-50 |
refused | a backwards range is a typo (PortParserTest) |
80, 80- 1-2-3 |
refused | typos fail loudly instead of being ignored (PortParserTest) |
80 , 22,80 |
22, 80 |
spaces ignored, duplicates removed, sorted (PortParserTest) |
| Input | Result | Why |
|---|---|---|
10.0.0.0/16 |
65,534 hosts | the biggest block allowed; /15 and larger are refused so nobody scans a huge network by accident |
192.168.1.77/24 |
same as 192.168.1.0/24 |
host bits are masked off (TargetParserTest) |
10.0.0.0/31, /32 |
every address | point-to-point and single-host blocks have no network or broadcast address, RFC 3021 (TargetParserTest) |
255.255.255.0/30 |
.1 and .2 |
above 127.255.255.255 a Java int is negative, so the math is done unsigned (TargetParserTest) |
010.0.0.008 |
10.0.0.8 |
some C libraries read 010 as octal 8, a known way to slip past address filters; portscout always reads decimal |
10.0.0.1 |
10.0.0.1 |
surrounding spaces from copy and paste are trimmed |
0.0.0.0, 255.255.255.255 |
accepted | valid IPv4 text; what a connect does is up to the operating system |
2001:DB8:0:0:0:0:0:1, [::1] |
2001:db8::1, ::1 |
IPv6 is written the short standard way, so one host is always written one way (PoliteScanTest) |
::ffff:192.0.2.1 |
192.0.2.1 |
an IPv4-mapped address is really IPv4 (PoliteScanTest) |
2001:db8::/64 |
refused | a /64 holds about 18 quintillion addresses (PoliteScanTest) |
fe80::1%eth0, 192.0.2.1:80, example.com:80 |
refused | only plain address literals, so nothing can turn into a DNS lookup (PoliteScanTest) |
10.+1.0.1, 10..0.1, 256.1.1.1, example |
refused | Integer.parseInt accepts +1, so octets are checked as digits only (TargetParserTest) |
| Situation | Result | Why |
|---|---|---|
| Service greets first (SSH, FTP) | OPEN with banner | read straight away (PortProbeTest) |
| Service waits for a request (HTTP) | OPEN with status line | after one silent read timeout, a HEAD request is sent (PortProbeTest) |
| Service accepts and hangs up at once | OPEN, empty banner | the port is open even if nothing is said |
Service never answers, even to HEAD |
OPEN, empty banner, about 2 timeouts | every read has a timeout, so a probe can never hang |
| Banner over 256 bytes | cut to 256 | a chatty or hostile service cannot flood the report |
| Banner with control characters | trimmed at the edges, . inside |
banners are untrusted remote data (PortProbeTest) |
| Banner that is only a line break | empty | whitespace is not a banner |
Banner with " or \ |
escaped in JSON | a server cannot break or inject into the JSON output (ReportTest) |
| Nothing listening | CLOSED | the host answers the SYN with a RST (PortProbeTest) |
No reply at all (192.0.2.1) |
FILTERED | a reserved, never-routed address behaves like a firewall that drops packets (PortProbeTest) |
--no-probe on a silent open port |
OPEN, nothing sent | the test server counts the bytes it received: 0, against a HEAD request without the option (PoliteScanTest) |
--rate 100, 20 instantly closed ports |
at least 190 ms | 20 evenly spaced starts, even though every answer is instant (PoliteScanTest) |
| A pause in the middle of a rate-limited scan | no burst afterwards | the pacer books from now, not from the missed slots (PoliteScanTest) |
An open port on ::1 |
OPEN with banner | end to end over IPv6; skipped on a machine without IPv6 (PoliteScanTest) |
| Situation | Result | Why |
|---|---|---|
-c 1 |
runs one probe at a time | fully sequential still finishes |
-c 10000 with 2 ports |
2 results | the cap only limits; it never adds threads |
-c 0 |
refused | at least one probe must be allowed to run (PortProbeTest) |
| More than 1,000,000 probes | refused before any socket opens | e.g. a /16 with 16 ports; keeps memory bounded |
Hosts .10, .9, .1 |
sorted .1, .9, .10 |
sorted as numbers, not text |
| Empty scan | 0 open of 0 probed / [] |
"nothing found" prints cleanly |
| Command | Result |
|---|---|
portscout or portscout -h |
prints usage |
portscout 127.0.0.1 -p |
error: -p needs a value, exit code 2 |
portscout 127.0.0.1 -t 0 |
error: timeout and concurrency must be at least 1, exit code 2 |
portscout 127.0.0.1 --rate 0 |
error: --rate must be at least 1, got 0, exit code 2 |
portscout 127.0.0.1 -t abc |
error: -t needs a whole number, got 'abc', exit code 2 |
portscout 10.0.0.0/8 |
error: /8 is too big to scan; use /16 or smaller, exit code 2 |
portscout 127.0.0.1 --bogus |
error: unknown option --bogus, exit code 2 |


