Build a Live Port Scanner in Bash

Build a concurrent port scanner with a live ANSI dashboard in pure Bash.

Introduction

30 Second Summary

Every service running on a computer listens behind a numbered door called a port. Figuring out which doors are open is one of the first things a systems administrator or security professional does when investigating a machine.

In this project, you will build a live port scanner entirely in pure Bash. Your scanner distributes work across parallel background workers and streams color-coded results into a fullscreen terminal dashboard in real time.

What You'll Build

You point the scanner at any host, hit Enter, and a fullscreen terminal dashboard lights up with parallel workers racing through ports and streaming color-coded results in real time.

By the end of this project, you'll have:

  • A concurrent port scanner that fans work across a pool of parallel background workers through named pipes, scanning hundreds of ports in seconds instead of minutes.
  • A fullscreen terminal dashboard built with raw ANSI escape codes that renders color-coded results live, complete with service banners showing what is running on each open port.
  • A cross-platform script that detects the OS and branches its scanning logic for Linux/WSL and macOS, with graceful cleanup that restores your terminal no matter how the scan ends.
  • An optional Secret Mission to push your skills even further.

Are there any prerequisites?

This project builds on Bash scripting fundamentals like variables, functions, loops, and case statements. If you have completed the Build a File Organizer with Bash project or have equivalent experience, you are ready to go.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters to you.

Set Up Your Bash Environment

The port scanner you're building relies on Bash 5.x features like associative arrays, coprocesses, and automatic file descriptor allocation. Older shells lack these features entirely, and your script would silently fail or throw confusing errors.

In this step, you'll confirm the right version of Bash is active, verify that socket connections work from the shell, and create the project's starting file.

In this step, get ready to:
  • Confirm Bash 5.x is installed and running.
  • Verify /dev/tcp socket support (or the nc fallback on macOS).
  • Create an executable scanner.sh file in your project directory.
Open your terminal environment

The setup looks different depending on your operating system. Pick your platform below to get the right terminal open.

macOS

  • Press Cmd+Space to open Spotlight.
  • Type Terminal and press Enter to open it.

The default Bash on macOS is version 3.2, which is too old for this project. You need Bash 5.x installed through Homebrew.

  • Install the latest Bash through Homebrew by running:
brew install bash
  • Start the Homebrew Bash shell by running:
/opt/homebrew/bin/bash

Using an Intel Mac?

If you see "No such file or directory", you have an Intel Mac. Run /usr/local/bin/bash instead.

  • Run all commands for the rest of this project inside this Homebrew Bash session.

Windows

  • Press the Windows key to open search.
  • Type Ubuntu and press Enter to open your WSL terminal.

Don't see Ubuntu?

If WSL is not installed, open PowerShell as administrator and run the following command. Then restart your computer, launch Ubuntu from the Start menu, and set up your username and password.

wsl --install

Help me install WSL on Windows.

Linux

  • Press the Super key (or your distro's equivalent) to open search.
  • Type Terminal and press Enter to open it.

Most Linux distributions ship Bash 5.x out of the box.

Verify your Bash version

Now confirm you're running Bash 5.x. Features like coproc, declare -A, and automatic file descriptor allocation don't exist in older versions.

  • Check your Bash version by running:
bash --version

Look at the first line of output. It should start with GNU bash, version 5.

✔️ I see version 5 or higher

Your output starts with something like GNU bash, version 5.2.21 or GNU bash, version 5.3.12. You're all set to continue.

ⓧ I see an older version

You're most likely running the system Bash 3.2 on macOS, or an outdated package on Linux.

  • On macOS, make sure you started the Homebrew Bash session from the macOS tab above. Run /opt/homebrew/bin/bash (Apple Silicon) or /usr/local/bin/bash (Intel), then re-run bash --version.
  • On Linux, update Bash through your package manager. For example:
sudo apt update && sudo apt install bash

Help me get Bash 5 running on my system.

ⓧ Command not found

Bash is not installed or not on your PATH.

  • On macOS, install via Homebrew by running:
brew install bash
  • On Linux (Debian/Ubuntu), install via your package manager by running:
sudo apt install bash
  • On Windows WSL, Bash comes pre-installed with Ubuntu. Try reinstalling WSL by running wsl --install in PowerShell as administrator.

Help me install Bash on my system.

Test /dev/tcp socket support

The scanner uses Bash's built-in /dev/tcp redirections to probe ports without any external tools. This quick test confirms the feature is active in your shell.

  • Test /dev/tcp support by running:
(echo >/dev/tcp/127.0.0.1/22) 2>/dev/null && echo "open" || echo "closed"

What does this command do?

This attempts to open a TCP connection to port 22 (SSH) on your own machine. If the connection succeeds, it prints open. If the port is closed or refused, it prints closed.

Either result confirms that /dev/tcp is working. If the feature were not supported, you would see a "No such file or directory" error instead.

You should see either open or closed printed. Both confirm /dev/tcp is working.

Seeing 'No such file' on macOS?

If your shell does not support /dev/tcp, the project falls back to nc (netcat) on macOS. Confirm nc works by running:

nc -zw1 127.0.0.1 22 2>/dev/null; echo $?

Seeing 0 (port open) or 1 (port closed) means nc is available. The scanner will detect macOS automatically and use nc as its fallback.

My /dev/tcp test isn't working. Help me troubleshoot.

Create the project directory and script file

With your shell confirmed, it's time to create the project's home and its first file.

  • Create the project directory and move into it by running:
mkdir -p ~/portscan
cd ~/portscan
  • Confirm you're in the right place by running:
pwd

You should see /home/your-username/portscan (Linux/WSL) or /Users/your-username/portscan (macOS).

  • Open a new file called scanner.sh in the nano editor by running:
nano scanner.sh

Nano opens a blank file inside your terminal. You'll type the script's first line directly in this editor.

  • Type the following on the first line:
#!/usr/bin/env bash

What is a shebang line?

The #! at the top of a script tells your system which program should run the file. #!/usr/bin/env bash looks up the bash binary in your PATH.

This means it automatically uses the Homebrew Bash on macOS or the system Bash on Linux, whichever you set up.

  • Press Ctrl+O to save. Nano asks you to confirm the filename at the bottom of the screen.
  • Press Enter to confirm the filename.
  • Press Ctrl+X to exit nano and return to your terminal.
  • Make the script executable by running:
chmod +x scanner.sh

Before you run the script, what do you think will happen?

  • Test your script by running:
./scanner.sh

The script exits silently with no output and no errors. That blank exit confirms the shebang is working and the file is executable.

Seeing 'Permission denied' or 'bad interpreter'?

"Permission denied" means chmod +x was not applied. Re-run chmod +x scanner.sh and try again.

"bad interpreter" means the shebang line has a typo. Open the file with nano scanner.sh and check that the first line is exactly #!/usr/bin/env bash.

My scanner.sh won't run. Help me debug it.

✔️ Awesome, I've got everything!

Make sure you've saved scanner.sh and you're still in the ~/portscan directory.

ⓧ I'd like to double check the full code

Your scanner.sh should contain exactly this at this point:

#!/usr/bin/env bash

That single shebang line is the complete content of the file right now.

Your environment is locked in and the project file is ready. Next, you'll write a working port scanner that probes ports one at a time.

Build a Sequential Port Scanner

Your Bash environment is ready and scanner.sh is waiting with nothing but a shebang line. Before you can build a concurrent worker pool, you need a scanner that actually probes ports and proves the networking primitives work.

In this step, you'll build a simple sequential scanner that checks ports one at a time. It works correctly, but it will reveal a performance bottleneck that sets up everything you build next.

In this step, get ready to:
  • Add argument parsing and OS detection to your script.
  • Write a port scanning function with cross-platform support.
  • Run the scanner and experience the performance bottleneck firsthand.
Add argument parsing and OS detection

A port scanner needs to know three things: what host to target, which ports to scan, and which operating system it's running on. The OS matters because Linux and macOS use different tools to probe a port.

  • Open scanner.sh in nano by running:
nano scanner.sh
  • Find the shebang line #!/usr/bin/env bash at the top. Add all of the following code below it:
set -euo pipefail

# --- Argument parsing ---
TARGET="${1:-127.0.0.1}"
START_PORT="${2:-1}"
END_PORT="${3:-1024}"
MAX_WORKERS="${4:-10}"

if ! [[ "$START_PORT" =~ ^[0-9]+$ ]] || ! [[ "$END_PORT" =~ ^[0-9]+$ ]]; then
    echo "Usage: ./scanner.sh <target> <start_port> <end_port> [max_workers]"
    exit 1
fi

# --- OS Detection ---
OS="$(uname -s)"
case "$OS" in
    Linux)  SCAN_MODE="devtcp" ;;
    Darwin) SCAN_MODE="nc" ;;
    *)      echo "Unsupported OS: $OS"; exit 1 ;;
esac

echo "Detected OS: $OS (mode: $SCAN_MODE)"

What does this code do?

  • set -euo pipefail turns on strict mode. The script exits on any error (-e), treats unset variables as errors (-u), and catches failures in pipes (pipefail).
  • The ${1:-127.0.0.1} syntax sets a default value if the argument is missing. TARGET defaults to localhost, START_PORT to 1, and END_PORT to 1024.
  • MAX_WORKERS is parsed now but used later when you add the concurrent worker pool.
  • The if block validates that the port arguments are numbers. If someone passes letters, the script prints a usage message and exits.
  • uname -s returns the kernel name. The case statement sets SCAN_MODE to devtcp on Linux (using Bash's built-in /dev/tcp) or nc on macOS (using netcat as a fallback).
  • Save the file with Ctrl+O, press Enter, then exit with Ctrl+X.
  • Confirm the OS detection is working by running:
./scanner.sh

You should see output like Detected OS: Linux (mode: devtcp) or Detected OS: Darwin (mode: nc) depending on your operating system.

Seeing a syntax error?

  • Check that the opening if ! [[ has double brackets and a space after [[. Missing the space or using single brackets causes a parse error.
  • Make sure every ;; in the case statement has two semicolons, not one. A single semicolon causes unexpected behavior.
  • If you see Unsupported OS, your system reports an OS name that isn't Linux or Darwin. Run uname -s to see what it reports.

Help me debug my scanner.sh argument parsing and OS detection

Write the scan_port function

The scanner needs a function that can probe a single port and report whether it is open or closed. This function uses the SCAN_MODE variable you just set up to pick the right tool for each OS.

  • Open scanner.sh in nano again by running:
nano scanner.sh
  • Scroll to the bottom of the file. Add the scan_port function below the echo "Detected OS: $OS (mode: $SCAN_MODE)" line:
# --- Port scanning function ---
scan_port() {
    local host="$1"
    local port="$2"
    if [[ "$SCAN_MODE" == "devtcp" ]]; then
        timeout 1 bash -c "echo >/dev/tcp/$host/$port" 2>/dev/null
    else
        nc -zw1 "$host" "$port" 2>/dev/null
    fi
}

What does this code do?

  • scan_port takes a host and port number as arguments. It returns 0 (success) if the port is open and 1 (failure) if it is closed.
  • On Linux, timeout 1 bash -c "echo >/dev/tcp/$host/$port" attempts to open a TCP connection. The timeout 1 wrapper kills the attempt after 1 second so closed ports don't hang forever.
  • On macOS, nc -zw1 uses netcat in zero-I/O scan mode with a 1-second timeout. This achieves the same result without relying on /dev/tcp.
  • 2>/dev/null silences error messages so closed ports don't clutter the output.

The scan_port function is called by the scan loop you'll add next.

  • Still in nano, add the sequential scan loop below the scan_port function:
# --- Sequential scan loop ---
for (( port = START_PORT; port <= END_PORT; port++ )); do
    if scan_port "$TARGET" "$port"; then
        echo "[OPEN]   $port"
    else
        echo "[CLOSED] $port"
    fi
done

What does this code do?

  • The for (( ... )) C-style loop counts from START_PORT to END_PORT, checking one port per iteration.
  • Each port is passed to scan_port. If the function returns 0 (open), the loop prints [OPEN]. Otherwise it prints [CLOSED].
  • This is completely sequential. The loop waits for each port probe to finish (up to 1 second) before moving to the next one.
  • Save with Ctrl+O, press Enter, then exit with Ctrl+X.

Seeing a syntax error near the for loop?

  • The C-style for loop requires double parentheses: for (( ... )). Single parentheses cause a syntax error.
  • Make sure there is a blank line between the closing } of scan_port and the for loop. Missing it can cause Bash to misparse the function boundary.
  • Check that the done keyword is on its own line, matching the for. A missing done produces an "unexpected end of file" error.

Help me fix a syntax error in my scan_port function or sequential loop

Run the sequential scanner on a small range

Each port probe waits up to 1 second for a response before giving up. Before you run the scanner, think about how long you expect 30 ports to take at that rate.

  • Run the scanner against localhost on ports 1 through 30 by running:
./scanner.sh 127.0.0.1 1 30

Watch the results appear one at a time. Each [CLOSED] line takes about a second to print because the probe has to wait for the timeout to expire.

  • Now try a larger range to feel the full weight of the problem by running:
./scanner.sh 127.0.0.1 1 100

You can press Ctrl+C to stop the scan early once you've felt the slowness. You don't need to wait for all 100 ports to finish.

Why is this so slow?

The sequential loop checks one port, waits up to 1 second, then moves to the next. 100 ports takes up to 100 seconds. Scanning the default range of 1024 ports would take over 15 minutes.

This is the exact problem the next step solves. You'll replace this sequential loop with a pool of concurrent background workers that scan multiple ports at the same time, cutting the time from minutes to seconds.

Script exits immediately with no output?

  • If you see the usage message, check that you're passing three numeric arguments (./scanner.sh 127.0.0.1 1 30). Letters or special characters in the port arguments trigger the validation exit.
  • If you see a "permission denied" error, make sure scanner.sh is still executable. Run chmod +x scanner.sh to fix it.
  • If the script hangs on the first port without printing anything, check that scan_port is defined before the for loop that calls it. Bash functions must be declared before they are used.

Help me debug why my sequential port scanner is not producing output

✔️ Awesome, I've got everything!

Great. Double check you've saved scanner.sh and that running ./scanner.sh 127.0.0.1 1 10 prints results one at a time.

ⓧ I'd like to double check the full code

Your scanner.sh should contain these sections in order. Scroll up and confirm each one is present:

  • The shebang line #!/usr/bin/env bash followed by set -euo pipefail.
  • Argument parsing: TARGET, START_PORT, END_PORT, MAX_WORKERS with defaults, followed by the numeric validation if block.
  • OS detection: a case statement on uname -s setting SCAN_MODE to devtcp or nc, then the echo confirmation line.
  • The scan_port function with the devtcp/nc branching inside it.
  • The sequential for loop that calls scan_port and prints [OPEN] or [CLOSED] for each port.

Your sequential scanner works. It proves the networking primitives are solid and the cross-platform branching picks the right tool. Next up, you'll replace that painfully slow loop with a pool of concurrent workers that scan ports in parallel through named pipes.

Add a FIFO-Based Worker Pool

Your sequential scanner works, but it crawls. Each port probe blocks for up to one second, so scanning 200 ports takes over three minutes of waiting.

In this step, you will replace the sequential loop with a pool of concurrent background workers coordinated through named pipes (FIFOs). A token FIFO acts as a counting semaphore that caps concurrency at exactly N workers, so you get speed without overwhelming the system.

In this step, get ready to:
  • Create three named pipes and a cleanup trap.
  • Build a worker function with token-based concurrency control.
  • Wire up the dispatcher and results reader to run the scan in parallel.
Create three named pipes and a cleanup trap

The sequential loop served its purpose as proof-of-concept, but it needs to go. You will replace it with three FIFOs that form a message bus between a dispatcher and a pool of workers.

  • In scanner.sh, find and delete this line (right after the OS detection case block):
echo "Detected OS: $OS (mode: $SCAN_MODE)"
  • Scroll to the bottom of the file and delete the entire sequential scan loop, from the for (( port = START_PORT line through the closing done:
# --- Sequential scan loop ---
for (( port = START_PORT; port <= END_PORT; port++ )); do
    if scan_port "$TARGET" "$port"; then
        echo "[OPEN]   $port"
    else
        echo "[CLOSED] $port"
    fi
done
  • Now add the following code between the OS detection esac line and the scan_port function by copying it into scanner.sh:
# --- FIFO paths ---
FIFO_DIR="/tmp/portscan_$$"
mkdir -p "$FIFO_DIR"
JOB_FIFO="$FIFO_DIR/jobs"
RESULT_FIFO="$FIFO_DIR/results"
TOKEN_FIFO="$FIFO_DIR/tokens"
mkfifo "$JOB_FIFO" "$RESULT_FIFO" "$TOKEN_FIFO"

# --- State ---
SCANNED=0
TOTAL_PORTS=$(( END_PORT - START_PORT + 1 ))

# --- Cleanup ---
cleanup() {
    # Kill all background jobs
    jobs -p 2>/dev/null | xargs -r kill 2>/dev/null || true
    wait 2>/dev/null || true

    # Close file descriptors
    exec 3>&- 2>/dev/null || true
    exec 4<&- 2>/dev/null || true

    # Remove FIFOs
    rm -rf "$FIFO_DIR" 2>/dev/null || true
}
trap cleanup EXIT INT TERM

What does this code do?

  • FIFO_DIR uses $$ (the current process ID) to give each scan run its own unique directory, so multiple scans never collide.
  • mkfifo creates three named pipes. JOB_FIFO carries port numbers to workers. RESULT_FIFO carries scan results back. TOKEN_FIFO holds concurrency tokens.
  • The cleanup function kills background workers, closes file descriptors, and removes the FIFO directory. trap cleanup EXIT INT TERM guarantees cleanup runs on normal exit, Ctrl+C, and kill signals.

What is a named pipe (FIFO)?

A named pipe is a special file on disk that connects two processes. One process writes into the pipe, and another reads from it. Unlike a regular file, data flows through in real time and disappears once read.

Named pipes persist as filesystem entries until you remove them, which is why the cleanup function includes rm -rf.

  • Save scanner.sh with Ctrl+O, Enter, Ctrl+X.
  • Test the FIFO infrastructure and cleanup by running:
./scanner.sh 127.0.0.1 1 5

The script creates the FIFOs, defines the cleanup trap, and exits. The trap fires on exit and removes everything.

  • Confirm the FIFOs were cleaned up by running:
ls /tmp/portscan_* 2>/dev/null; echo "Exit code: $?"

You should see an exit code of 2 (no files found). This proves your trap handler fired and removed the FIFOs on exit.

Seeing a "Permission denied" or syntax error?

  • Make sure you deleted the entire sequential for loop, including the closing done. A leftover done without a matching for causes a syntax error.
  • Confirm the new code sits between the esac line and the scan_port() function. Misplacing it inside the case block breaks the script.
  • If you see files still present in /tmp/portscan_*, a previous run may have crashed before cleanup. Remove them manually with rm -rf /tmp/portscan_* and try again.

Why isn't my scanner.sh running after I added the FIFO code?

Build the worker loop and token-based concurrency

You could spawn one background job per port, but 1024 simultaneous connections would overwhelm your system. The token FIFO solves this by acting as a counting semaphore.

Why a token FIFO instead of unlimited background jobs?

You preload the token FIFO with N tokens (one per allowed worker). Before a worker scans a port, it reads a token from the FIFO. If all tokens are consumed, the read blocks until another worker finishes and returns its token.

This caps concurrency at exactly N with zero busy-waiting. No external tools needed.

  • Open scanner.sh in nano and add the following code after the scan_port function (at the bottom of the file):
# --- Preload token FIFO ---
for (( i = 0; i < MAX_WORKERS; i++ )); do
    echo "t" > "$TOKEN_FIFO" &
done

# --- Worker function ---
worker() {
    while read -r port; do
        # Acquire token (blocks until available)
        read -r _ < "$TOKEN_FIFO"

        local result="closed"
        if scan_port "$TARGET" "$port"; then
            result="open"
        fi
        echo "$port:$result" > "$RESULT_FIFO"

        # Return token
        echo "t" > "$TOKEN_FIFO" &
    done
}

What does this code do?

  • The preload loop writes 10 tokens (MAX_WORKERS) into the token FIFO. Each echo "t" runs in the background (&) because writing to a FIFO blocks until something reads from the other end.
  • The worker function reads port numbers from stdin (which will be connected to the job FIFO). Before probing a port, it reads a token. If no tokens are available, it blocks right there until one is returned.
  • After scanning, the worker writes port:result (like 22:open or 80:closed) to the results FIFO and returns its token for the next worker to pick up.
  • Save scanner.sh with Ctrl+O, Enter, Ctrl+X.
Feed jobs and collect results

The workers and tokens are defined, but nothing connects them yet. You need a dispatcher that feeds port numbers into the job FIFO, and a reader that collects results as they arrive.

  • Open scanner.sh in nano and add the following code at the very end of the file (after the worker function):
# --- Open FDs ---
exec 3>"$JOB_FIFO"
exec 4<"$RESULT_FIFO"

# --- Launch workers ---
for (( i = 0; i < MAX_WORKERS; i++ )); do
    worker < "$JOB_FIFO" &
done

# --- Results reader (background) ---
{
    while (( SCANNED < TOTAL_PORTS )); do
        if read -t 2 -r line <&4; then
            port="${line%%:*}"
            status="${line#*:}"
            echo "[$status] $port"
            (( SCANNED++ ))
        fi
    done
} &
READER_PID=$!

# --- Dispatch jobs ---
for (( port = START_PORT; port <= END_PORT; port++ )); do
    echo "$port" >&3
done
exec 3>&-

# --- Wait for completion ---
wait "$READER_PID" 2>/dev/null || true

What does this code do?

  • exec 3>"$JOB_FIFO" opens file descriptor 3 for writing to the job FIFO. exec 4<"$RESULT_FIFO" opens FD 4 for reading results. Using numbered FDs lets you send and receive data without reopening the pipes each time.
  • The launch loop starts 10 worker processes in the background, each reading from the job FIFO. They sit idle until port numbers arrive.
  • The results reader runs in the background and parses each port:status line from FD 4 as results arrive. ${line%%:*} strips everything after the colon (the port). ${line#*:} strips everything before it (the status).
  • The dispatch loop writes each port number into FD 3. After all ports are dispatched, exec 3>&- closes the write end, signalling EOF to workers so they stop reading.
  • Save scanner.sh with Ctrl+O, Enter, Ctrl+X.

Before you run this, think back to the sequential scanner. Scanning 100 ports took about 100 seconds. How fast do you think 200 ports will complete with 10 concurrent workers?

  • Run the concurrent scanner by executing:
./scanner.sh 127.0.0.1 1 200

Results stream in rapidly from multiple workers. A 200-port scan that would have taken over three minutes sequentially now completes in seconds.

Want to see the FIFOs in action?

Open a second terminal while a scan is running. Run ls /tmp/portscan_* and you will see the three FIFOs (jobs, results, tokens) actively in use. After the scan finishes, they vanish because the trap handler cleaned them up.

Script hangs or produces no output?

  • If the script hangs forever, leftover FIFOs from a previous failed run may be blocking. Press Ctrl+C, run rm -rf /tmp/portscan_*, and try again.
  • Check that the token preload loop appears before the worker function. If tokens are not preloaded, every worker blocks on its first token read and nothing ever scans.
  • Verify exec 3>"$JOB_FIFO" appears before the worker launch loop. Workers reading from the job FIFO will block if the write end is not open yet.

My FIFO-based port scanner hangs or gives no output. Help me figure out what's wrong.

✔️ Awesome, I've got everything!

Great. Double check you have saved scanner.sh.

ⓧ I'd like to double check the full code

Your scanner.sh is too long to show in one block, so confirm these sections appear in order by scrolling through your file:

  • Shebang (#!/usr/bin/env bash) and set -euo pipefail.
  • Argument parsing block (TARGET, START_PORT, END_PORT, MAX_WORKERS) with validation.
  • OS detection via uname -s with a case block. No echo line after it.
  • FIFO paths block (FIFO_DIR, JOB_FIFO, RESULT_FIFO, TOKEN_FIFO) with mkfifo.
  • State variables (SCANNED and TOTAL_PORTS).
  • cleanup() function and trap cleanup EXIT INT TERM.
  • scan_port() function (unchanged from Step 2).
  • Token preload loop writing to TOKEN_FIFO.
  • worker() function.
  • exec 3> and exec 4< FD opening, worker launch loop, results reader, dispatch loop, and wait.

Make sure the old sequential for loop and the echo "Detected OS..." line are both deleted.

Your scanner is blazing fast now, but the results are a jumbled mess scrolling past the screen. Next up, you will build a fullscreen ANSI dashboard that organizes everything into a live-updating display.

Add a Fullscreen ANSI Dashboard

Your FIFO worker pool is lightning fast. A 200-port scan that used to take minutes now finishes in seconds.

But the speed creates a new problem: results fly past the terminal faster than you can read them. You need a persistent, live-updating display that stays in place while results stream in.

In this step, get ready to:
  • Set up ANSI color codes and switch to the alternate screen buffer.
  • Build a render function that draws a formatted dashboard with headers and a status bar.
  • Wire the results reader to update the dashboard in real time.
Switch to the alternate screen buffer and set up the display

Programs like vim and htop take over your whole terminal and then restore it when you quit. They do this using the alternate screen buffer, an ANSI terminal feature that saves your current scrollback and switches to a blank canvas.

Your dashboard will do the same thing. First, you need ANSI color variables and some new state tracking.

  • In scanner.sh, find the # --- State --- section. Replace the entire state section (the two lines SCANNED=0 and TOTAL_PORTS=...) and add a new ANSI codes section above it, so the result looks like this:
# --- ANSI codes ---
ESC=$'\e'
BOLD="${ESC}[1m"
DIM="${ESC}[2m"
RED="${ESC}[31m"
GREEN="${ESC}[32m"
CYAN="${ESC}[36m"
YELLOW="${ESC}[33m"
RESET="${ESC}[0m"
CLEAR_LINE="${ESC}[K"

# --- State ---
declare -A RESULTS
declare -A BANNERS
SCANNED=0
OPEN_COUNT=0
TOTAL_PORTS=$(( END_PORT - START_PORT + 1 ))
START_TIME=$(date +%s)

What does this code do?

The ANSI variables store escape sequences for terminal formatting. ESC is the raw escape character, and each color variable appends a control code the terminal interprets as a style change.

CLEAR_LINE erases from the cursor to the end of the line, preventing ghost characters from previous renders. declare -A RESULTS creates an associative array to store each port's scan result, and BANNERS will hold banner and state data for open ports in a later step.

Now you need to update the cleanup function so it restores the terminal when the script exits.

  • In scanner.sh, scroll to the cleanup() function and find the closing lines:
    rm -rf "$FIFO_DIR" 2>/dev/null || true
}
  • Add two printf lines between the rm -rf line and the closing }, so the end of the function looks like this:
    rm -rf "$FIFO_DIR" 2>/dev/null || true
    printf '\e[?25h'
    printf '\e[?1049l'
}

What do these escape codes do?

\e[?25h shows the cursor again (it gets hidden during the dashboard). \e[?1049l exits the alternate screen buffer and restores the original terminal content. Together, they guarantee the terminal is usable after the scanner exits, even on Ctrl+C.

  • Save scanner.sh with Ctrl+O, Enter, Ctrl+X.
  • Run the scanner on a small range to confirm it still works:
./scanner.sh 127.0.0.1 1 10

You should still see plain text output like before. The dashboard will replace this once you add the render function.

Seeing a syntax error about declare -A?

Associative arrays require Bash 4.0 or higher. Run bash --version and confirm you see version 5. On macOS, make sure you are running the Homebrew Bash, not the system Bash.

Help me debug a declare -A error in my port scanner script.

Build the render function

The render function is the core of the dashboard. Each time it runs, it homes the cursor to the top-left corner and redraws every line in place. This creates a flicker-free live display without clearing the whole screen.

  • Open scanner.sh in nano and find the scan_port() function. Add the following code below it, after its closing }. This is the first part of the render function, which draws the header:
# --- Render dashboard ---
render() {
    local cols
    cols=$(tput cols 2>/dev/null || echo 80)
    local now=$(date +%s)
    local elapsed=$(( now - START_TIME ))

    printf '\e[H'

    # Header
    printf "${BOLD}${CYAN}  PORT SCANNER${RESET}${CLEAR_LINE}\n"
    printf "  Target: ${BOLD}%s${RESET}  Ports: %d-%d  Workers: %d  OS: %s${CLEAR_LINE}\n" \
        "$TARGET" "$START_PORT" "$END_PORT" "$MAX_WORKERS" "$OS"
    printf "  ${DIM}%s${RESET}${CLEAR_LINE}\n" "$(printf '%*s' "$((cols - 4))" '' | tr ' ' '-')"

    # Column headers
    printf "  ${BOLD}%-8s %-10s %-14s %-40s${RESET}${CLEAR_LINE}\n" \
        "PORT" "STATUS" "TCP STATE" "BANNER"
    printf "  ${DIM}%s${RESET}${CLEAR_LINE}\n" "$(printf '%*s' "$((cols - 4))" '' | tr ' ' '-')"

What does this code do?

tput cols queries the terminal width so the divider lines fill the screen. printf '\e[H' homes the cursor to row 1, column 1 without clearing the screen. Each line ends with CLEAR_LINE to erase leftover characters from previous renders.

  • Directly below the column headers code you just added (still inside the function, no closing } yet), add the results loop that draws open ports in green:
    # Results - show open ports
    local displayed=0
    local max_display=$(( $(tput lines 2>/dev/null || echo 24) - 10 ))
    for port in $(echo "${!RESULTS[@]}" | tr ' ' '\n' | sort -n); do
        if [[ "${RESULTS[$port]}" == "open" ]]; then
            local state="${BANNERS[${port}_state]:-...}"
            local banner="${BANNERS[${port}_banner]:-}"
            printf "  ${GREEN}${BOLD}%-8s %-10s${RESET} ${YELLOW}%-14s${RESET} ${CYAN}%-40s${RESET}${CLEAR_LINE}\n" \
                "$port" "OPEN" "$state" "${banner:0:40}"
            (( displayed++ ))
            if (( displayed >= max_display )); then
                printf "  ${DIM}... and more${RESET}${CLEAR_LINE}\n"
                break
            fi
        fi
    done

What does this code do?

The loop iterates over every key in the RESULTS associative array, sorted numerically. Only open ports are printed. tput lines checks the terminal height so the loop stops before overflowing the screen.

The BANNERS lookups reference data you will populate in the next step (banner grabbing). For now they show placeholder values.

  • Below the results loop, still inside the function, add the code that fills empty lines and draws the status bar, then close the function with }:
    # Fill empty lines
    local current_line=$(( 6 + displayed ))
    local status_line=$(( $(tput lines 2>/dev/null || echo 24) - 2 ))
    while (( current_line < status_line )); do
        printf "${CLEAR_LINE}\n"
        (( current_line++ ))
    done

    # Status bar
    local pct=0
    if (( TOTAL_PORTS > 0 )); then
        pct=$(( SCANNED * 100 / TOTAL_PORTS ))
    fi
    printf "  ${DIM}%s${RESET}${CLEAR_LINE}\n" "$(printf '%*s' "$((cols - 4))" '' | tr ' ' '-')"
    printf "  Scanned: ${BOLD}%d/%d${RESET} (%d%%)  Open: ${GREEN}${BOLD}%d${RESET}  Elapsed: %ds${CLEAR_LINE}\n" \
        "$SCANNED" "$TOTAL_PORTS" "$pct" "$OPEN_COUNT" "$elapsed"
}

What does this code do?

The fill loop prints blank lines between the last result and the status bar, keeping the status bar pinned to the bottom of the terminal. Without it, the bar would jump around as ports are discovered.

The status bar shows how many ports have been scanned, the percentage complete, total open ports, and elapsed time. This gives the learner a sense of progress during longer scans.

The render function is complete. Now you need to activate the alternate screen buffer so the dashboard has a clean canvas to draw on.

  • Find the # --- Preload token FIFO --- comment. Add the following block directly above it:
# --- Enter alternate screen ---
printf '\e[?1049h'
printf '\e[?25l'
printf '\e[2J'
render

What do these three escape codes do?

  • \e[?1049h saves the current terminal content and switches to a blank alternate buffer.
  • \e[?25l hides the cursor so it does not flicker across the screen during redraws.
  • \e[2J clears the alternate screen. Then the initial render call draws the empty dashboard frame before any results arrive.
  • Save scanner.sh with Ctrl+O, Enter, Ctrl+X.
  • Run the scanner to test the alternate screen by running:
./scanner.sh 127.0.0.1 1 10

Your terminal should switch to a blank dashboard screen with the header and column labels drawn. Results will still print as plain text below it because the results reader has not been updated yet. Press Ctrl+C and your original terminal content should restore cleanly.

Terminal not restoring after Ctrl+C?

Check that your cleanup() function contains printf '\e[?25h' and printf '\e[?1049l' after the rm -rf line. If your terminal is stuck in a broken state, type reset and press Enter to restore it manually.

Help me fix my terminal restore after Ctrl+C in my Bash port scanner.

Wire the render loop to the results reader

The dashboard frame is drawn but results still print as raw text. The current results reader uses echo to dump each result. You need to swap that out so it populates the RESULTS associative array and calls render after every update.

  • In scanner.sh, find the # --- Results reader (background) --- section. Replace the entire block (from the opening { through READER_PID=$!) with this updated version:
# --- Results reader (background) ---
{
    while (( SCANNED < TOTAL_PORTS )); do
        if read -t 2 -r line <&4; then
            port="${line%%:*}"
            status="${line#*:}"
            RESULTS["$port"]="$status"
            (( SCANNED++ ))
            if [[ "$status" == "open" ]]; then
                (( OPEN_COUNT++ ))
            fi
            render
        fi
    done
} &
READER_PID=$!

What changed?

Instead of printing raw text with echo, each result is stored in the RESULTS associative array keyed by port number. OPEN_COUNT tracks how many open ports have been found. After each update, render redraws the entire dashboard in place so the learner sees results appear in the grid in real time.

One last piece. When the scan finishes, the script should show a final render and wait for the learner to press a key before exiting.

  • Find the # --- Wait for completion --- section. It currently has a single wait line. Add the following code below it, so the end of the file looks like this:
# --- Wait for completion ---
wait "$READER_PID" 2>/dev/null || true

render
printf '\e[?25h'
printf '\e[%d;1H' "$(tput lines 2>/dev/null || echo 24)"
printf "\n  ${GREEN}${BOLD}Scan complete.${RESET} Press any key to exit.\n"
read -rsn1

What does this code do?

After the reader finishes, one final render call paints the completed dashboard. The cursor is made visible again and moved to the bottom of the screen. read -rsn1 waits for a single keypress before exiting, giving the learner time to read the final results.

  • Save scanner.sh with Ctrl+O, Enter, Ctrl+X.

Before you run this, what do you expect to see? Plain scrolling text or a fullscreen dashboard?

  • Run the scanner on 200 ports to see the dashboard in action:
./scanner.sh 127.0.0.1 1 200

Your terminal switches to a fullscreen dashboard. Open ports appear in green as they are discovered. The status bar at the bottom shows scanned/total count, percentage, open port count, and elapsed time, all updating in real time.

  • Wait for the scan to complete, then press any key to exit. Your original terminal content should restore cleanly.
  • Press Ctrl+C during a second run to confirm the cleanup trap restores the terminal mid-scan.

Dashboard looks garbled or columns misaligned?

Make sure your terminal window is at least 80 columns wide. Run tput cols to check. If the output is under 80, widen your terminal window and run the scanner again.

Also check that each printf line in your render() function ends with ${CLEAR_LINE} before the \n. Missing clear-line codes leave ghost characters from previous renders.

Help me fix my ANSI dashboard rendering in my Bash port scanner.

✔️ Awesome, I've got everything!

Great. Double check you have saved scanner.sh.

ⓧ I'd like to double check the full code

The file is long now, so scroll through it in nano and confirm each section is present in this order:

  • Shebang, set -euo pipefail, argument parsing, OS detection.
  • FIFO paths section with mkfifo.
  • ANSI codes section (ESC, BOLD, DIM, RED, GREEN, CYAN, YELLOW, RESET, CLEAR_LINE).
  • State section with declare -A RESULTS, declare -A BANNERS, OPEN_COUNT, and START_TIME.
  • Cleanup function with terminal restore (printf '\e[?25h' and printf '\e[?1049l') at the end.
  • scan_port() function (unchanged from before).
  • render() function with header, column headers, results loop, fill lines, and status bar.
  • Alternate screen entry (printf '\e[?1049h', hide cursor, clear, initial render).
  • Token preloading, worker function, FD opens, worker launch (unchanged).
  • Updated results reader populating RESULTS array and calling render.
  • Dispatcher, wait, final render, and read -rsn1 at the end.

Your scanner now has a fullscreen live dashboard. Open ports pop in green, progress tracks in real time, and the terminal restores cleanly. Next up, you will add coprocess banner grabbing to identify the services running behind those open ports.

Add Coprocess Banner Grabbing

Your dashboard is lightning fast, but open ports are still just numbers. Knowing what service is running behind a port turns a scan from raw data into actionable intelligence.

Many services announce themselves by sending a text "banner" the moment a client connects. In this step, you'll use a coprocess to connect to each open port and capture its banner. On Linux, you'll also cross-reference results against the kernel's live socket table in /proc/net/tcp to show each port's TCP state.

In this step, get ready to:
  • Write a grab_banner function using coproc to capture service banners.
  • Add /proc/net/tcp parsing functions to show TCP states on Linux.
  • Wire banner grabbing and TCP state checks into the results reader.
Write the banner grabber using coproc

When a client connects to a service like SSH or HTTP, the service often sends a greeting string before the client says anything. That greeting is called a banner. Capturing it tells you exactly what software is listening.

What is coproc?

The coproc keyword launches a subprocess with its stdin and stdout automatically connected to the parent shell via file descriptors. Bash exposes NAME[0] for reading output and NAME_PID for the process ID.

This makes it perfect for one-shot connections: spawn a coprocess that connects to a port, read whatever the service sends, then clean up.

  • In scanner.sh, find the closing } of the scan_port function.
  • Add the grab_banner function below it by copying this code:
# --- Banner grabbing ---
grab_banner() {
    local host="$1"
    local port="$2"
    local banner=""
    if [[ "$SCAN_MODE" == "devtcp" ]]; then
        coproc BGRAB { timeout 2 bash -c "exec 5<>/dev/tcp/$host/$port; read -t 1 -u 5 line; echo \"\$line\"" 2>/dev/null; }
        read -t 2 -u "${BGRAB[0]}" banner 2>/dev/null || true
        wait "$BGRAB_PID" 2>/dev/null || true
    else
        banner=$(echo "" | nc -w1 "$host" "$port" 2>/dev/null | head -1) || true
    fi
    echo "${banner:0:50}"
}

What does this code do?

  • On Linux, coproc BGRAB { ... } spawns a subprocess that opens a bidirectional connection to the port via /dev/tcp, reads one line with a 1-second timeout, and prints it.
  • The parent reads the coprocess output from file descriptor ${BGRAB[0]} and waits for the coprocess to exit with wait "$BGRAB_PID".
  • On macOS, the function falls back to piping through nc -w1 and grabbing the first line of output.
  • The banner is truncated to 50 characters to keep the dashboard clean.
  • Save scanner.sh with Ctrl+O, Enter.
Add /proc/net/tcp cross-referencing

On Linux, the kernel exposes every active TCP socket in /proc/net/tcp. The catch is that addresses, ports, and connection states are all hex-encoded. You need decoder functions to translate them into human-readable values.

  • Below the grab_banner function you just added, paste the hex decoder functions:
# --- /proc/net/tcp parsing (Linux only) ---
decode_hex_ip() {
    local hex="$1"
    printf '%d.%d.%d.%d' \
        "0x${hex:6:2}" "0x${hex:4:2}" "0x${hex:2:2}" "0x${hex:0:2}"
}

decode_hex_port() {
    printf '%d' "0x$1"
}

decode_tcp_state() {
    case "$1" in
        01) echo "ESTABLISHED" ;; 02) echo "SYN_SENT" ;;
        03) echo "SYN_RECV" ;;    04) echo "FIN_WAIT1" ;;
        05) echo "FIN_WAIT2" ;;   06) echo "TIME_WAIT" ;;
        07) echo "CLOSE" ;;       08) echo "CLOSE_WAIT" ;;
        09) echo "LAST_ACK" ;;    0A) echo "LISTEN" ;;
        0B) echo "CLOSING" ;;     *)  echo "UNKNOWN" ;;
    esac
}

What do these decoders do?

  • decode_hex_ip converts an 8-character hex string to dotted-decimal IPv4. The bytes are reversed because Linux stores them in little-endian order on x86. For example, 0100007F becomes 127.0.0.1.
  • decode_hex_port converts a 4-character hex port to decimal. Port 80 appears as 0050 in the file.
  • decode_tcp_state maps hex state codes (01 through 0B) to names like ESTABLISHED, LISTEN, and TIME_WAIT.
  • Save scanner.sh with Ctrl+O, Enter.
  • Run a quick scan to confirm the new function definitions don't cause any errors:
./scanner.sh 127.0.0.1 1 10
  • The dashboard should appear and scan 10 ports, then show "Scan complete." at the bottom. Press any key to exit.

Seeing a syntax error on launch?

  • Check that every ;; in the decode_tcp_state case statement is present. A missing double-semicolon causes a parse error.
  • Make sure esac closes the case block and } closes each function.
  • Verify the backslash continuation in decode_hex_ip has no trailing spaces after the \ on the printf line.

Why is my scanner crashing with a syntax error after adding the decoder functions?

Now add the function that uses those decoders to look up a port's TCP state.

  • Below the decode_tcp_state function, add the check_proc_net_tcp function:
check_proc_net_tcp() {
    local port="$1"
    local hex_port
    hex_port=$(printf '%04X' "$port")
    if [[ -f /proc/net/tcp ]]; then
        while IFS=' ' read -r _ local_addr _ state _rest; do
            local lport="${local_addr##*:}"
            if [[ "$lport" == "$hex_port" ]]; then
                decode_tcp_state "$state"
                return
            fi
        done < <(tail -n +2 /proc/net/tcp 2>/dev/null)
    fi
    echo "N/A"
}

What does this function do?

  • It converts the target port number to a 4-digit hex string with printf '%04X' so it can match the format used in /proc/net/tcp.
  • It reads each line from the kernel's socket table (skipping the header with tail -n +2), extracts the local port field, and checks for a match.
  • When it finds a match, it passes the hex state code to decode_tcp_state and returns a human-readable name like LISTEN or ESTABLISHED.
  • If /proc/net/tcp does not exist (macOS), the function returns "N/A".
  • Save scanner.sh with Ctrl+O, Enter.
Wire banner and state into the results reader

Your render function already has Banner and TCP State columns from Step 4. It reads from the BANNERS associative array, but nothing populates that array yet. The final piece is updating the results reader to call grab_banner and check_proc_net_tcp whenever an open port is discovered.

  • Scroll down to the background results reader section (the block inside { ... } & above READER_PID=$!).
  • Find this block inside the reader:
            if [[ "$status" == "open" ]]; then
                (( OPEN_COUNT++ ))
            fi
            render
  • Replace that entire block with the following code, which spawns a background subshell to grab the banner and check the TCP state for each open port:
            if [[ "$status" == "open" ]]; then
                (( OPEN_COUNT++ ))
                # Grab banner and TCP state in background
                {
                    banner=$(grab_banner "$TARGET" "$port")
                    BANNERS["${port}_banner"]="$banner"
                    if [[ "$OS" == "Linux" ]]; then
                        state=$(check_proc_net_tcp "$port")
                    else
                        state="N/A"
                    fi
                    BANNERS["${port}_state"]="$state"
                    render
                } &
            fi

            render

What changed?

  • When a port comes back as open, a background subshell ({ ... } &) calls grab_banner and stores the result in BANNERS["${port}_banner"].
  • On Linux, it also calls check_proc_net_tcp to look up the port's TCP state from the kernel. On macOS, it stores "N/A" since /proc/net/tcp does not exist.
  • The subshell calls render after populating BANNERS so the dashboard updates as soon as each banner arrives.

Why run banner grabbing in the background?

Banner grabbing requires connecting to a port and waiting up to 2 seconds for a response. If the results reader did this synchronously, every open port would stall the entire scan for 2 seconds. Running it in a background subshell keeps the reader free to process the next result immediately.

  • Save scanner.sh with Ctrl+O, Enter, then exit nano with Ctrl+X.

Before you run the scanner, think about what you expect to see. Do you think every open port will show a banner, or just some of them? What about the TCP State column?

  • Run the scanner on a wider range to see banners in action:
./scanner.sh 127.0.0.1 1 1024

Open ports now show service banners in cyan next to their port number. A port running SSH might display something like SSH-2.0-OpenSSH_9.6. Not every open port sends a banner. Some services wait for the client to speak first, so those will show a blank banner column.

On Linux, the TCP State column shows values like LISTEN or ESTABLISHED pulled from the kernel's socket table. On macOS, this column shows "N/A". Press any key to exit when the scan completes.

Banners not appearing for any port?

  • Make sure the replacement code is inside the if read -t 2 -r line <&4; then block, not outside it. The old if [[ "$status" == "open" ]] block should be fully replaced, not duplicated.
  • Check that the grab_banner function is defined above the results reader section, not below it. Bash reads scripts top-to-bottom, so functions must be defined before they are called.
  • If you see banners for some ports but not others, that is normal. Many services do not send a greeting unless the client sends data first.

My port scanner dashboard is not showing any banners next to open ports. Help me debug.

✔️ Awesome, I've got everything!

Double check you've saved scanner.sh and that your dashboard displays banners and TCP states for open ports.

ⓧ I'd like to double check the full code

The full file is long, so scroll through your scanner.sh and confirm these sections are present, in order:

  • Shebang, set -euo pipefail, and argument parsing.
  • OS detection (uname -s and SCAN_MODE).
  • FIFO paths, mkfifo, and ANSI color variables.
  • State variables (declare -A RESULTS, declare -A BANNERS, counters).
  • cleanup function with trap cleanup EXIT INT TERM.
  • scan_port function.
  • grab_banner function (new in this step).
  • decode_hex_ip, decode_hex_port, decode_tcp_state, and check_proc_net_tcp (new in this step).
  • render function (unchanged from Step 4).
  • Alternate screen entry, token preloading, worker function, FD opens, worker launches.
  • Results reader with grab_banner and check_proc_net_tcp calls inside the open-port block (updated in this step).
  • Dispatcher loop, wait, and final render with "Scan complete" message.

Your scanner now identifies what is running on each open port, and on Linux it cross-references the kernel's own socket table. You've built a complete, production-style port scanner entirely in pure Bash. Next up, push your skills further with an optional Secret Mission that digs deeper into /proc/net/tcp parsing.

Secret mission

Decode /proc/net/tcp Hex Addresses

Your scanner already checks port numbers against /proc/net/tcp to get a TCP state. But the kernel's socket table holds much more: full hex-encoded IPv4 addresses for both local and remote endpoints, hex port numbers, and state codes for every active connection. Can you decode it all and render a live Connections section on the dashboard?

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs.

Resources you used:

  • The ~/portscan project directory containing scanner.sh.
  • Named pipe (FIFO) files in /tmp/portscan_*. These are cleaned up automatically by the trap handler when the script exits.

Keep everything running

No action needed. Choose this if you plan to keep experimenting with the scanner.

The script and FIFOs only exist while the scanner is running. Once you press Ctrl+C or the scan completes, the trap handler removes all named pipes and restores your terminal automatically.

Pause - I'll come back to this later

Nothing to pause. The scanner is a local script with no background services or running processes between sessions.

Your scanner.sh file stays in ~/portscan and is ready to run whenever you come back.

Delete - I don't want to use this again

Remove the project directory and all its contents to start fresh.

  • Delete the project directory by running this command in your terminal:
rm -rf ~/portscan
  • Confirm the directory is gone by running:
ls ~/portscan

You should see a "No such file or directory" message, confirming the project has been fully removed.

Nice Work!

Nice Work!

Nice work! You just built a multi-process port scanner with a live terminal dashboard, entirely in pure Bash.

You've learned how to:

  • Built a cross-platform port scanner that detects the OS with uname and branches scanning logic between Bash /dev/tcp redirections on Linux/WSL and nc on macOS.
  • Architected a FIFO-based worker pool with a token FIFO acting as a counting semaphore, capping concurrent workers without busy-waiting or any external tools.
  • Rendered a fullscreen ANSI dashboard using the alternate screen buffer, cursor positioning, and color codes that streams scan results in real time, with coproc-powered banner grabbing identifying services on open ports.
  • Completed an optional Secret Mission to push your skills even further.

Ready to quiz yourself?