Skip to content
AoceanPPublic

About

Multithreaded TCP port scanner in Java 21: maps open ports across a subnet, grabs service banners, and tells closed ports from firewalled ones. Built to learn how nmap works.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

35 Commits

Folders and files

Repository files navigation

portscout

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.org allows light scanning for testing.

Overview

What

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.

The recon view after scanning this PC: the port map, the progress line, and cards for SMB, AMQP and four web servers

Problem

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.2 shows 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:

  1. 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.
  2. 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.

Solution

  • 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 HEAD to 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 ::1 or 2001: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 N starts 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-probe only 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.

Why I built it

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

Part of a project series

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.

Setup

1. Install the requirements

  • 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.

2. Get the code

git clone https://github.com/AoceanP/portscout.git
cd portscout

3. Build and run the tests

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.

4. Scan your own machine

Windows macOS / Linux
.\gradlew.bat run --args="127.0.0.1" ./gradlew run --args="127.0.0.1"

5. Optional: install it as a command

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.

Troubleshooting

  • gradlew is not recognized (PowerShell): type .\gradlew.bat, with the .\ in front.
  • JAVA_HOME is not set or Unsupported 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.

Usage

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? and smb? 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).

Recon view

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.

How it works

  • 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 HEAD and 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 -c probes exist at once and the scan never exhausts sockets.
  • Rate: before each probe starts, Pacer waits 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.

Privacy and data

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.

Design

Program flow

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.

portscout program flow

UML class diagram

portscout UML class diagram

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.

Benchmarks

Measured on Windows 11 with Java 25, default 500 ms timeout.

A host that does not answer (worst case)

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.

Your own machine (best case)

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.

Roadmap

  • 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: --rate and --no-probe
  • IPv6 targets (single addresses)
  • Stretch: map versions to known vulnerabilities and flag outdated ones

License

MIT, Copyright (c) 2026 AoceanP. See LICENSE.

Edge cases

Every case below is covered by a test (in EdgeCaseTest unless another class is named) or, for the command line, was checked by hand.

Ports

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)

Targets

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)

Probes and banners

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)

Scanner and output

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 line (checked by hand)

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

About

Multithreaded TCP port scanner in Java 21: maps open ports across a subnet, grabs service banners, and tells closed ports from firewalled ones. Built to learn how nmap works.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages