Bash Scripting Series — Level 5

Bash Scripting Series — Level 5: TCP Port Monitoring and Change Detection

Introduction

In Level 4, we learned how to identify running processes using pgrep and display their Process IDs (PIDs). In Level 5, we will use Bash to monitor a different aspect of a Linux system: its listening TCP ports.

A listening port can indicate that an application or service is waiting for incoming connections. Monitoring these ports helps system administrators identify changes in the services running on a machine.

For example, if you start a web server, it may begin listening on port 8000. If the server stops, that listening endpoint should disappear from the system's socket listing.

In this lesson, we will build a script that records listening TCP endpoints, saves timestamped scan results, compares each new scan with the previous one, and reports additions or removals.

We will also improve the original script's error handling and make its results easier to compare.

1. The Original Script

#!/bin/bash

# Script to scan open TCP ports and detect changes

# Create a timestamped output filename
TIMESTAMP=$(date +"%Y%m%d_%H%M%S")
OUTPUT_DIR="$HOME/portscanner"
OUTPUT_FILE="$OUTPUT_DIR/open_ports_$TIMESTAMP.txt"

# Create the output directory if necessary
if [ ! -d "$OUTPUT_DIR" ]; then
    mkdir "$OUTPUT_DIR"
    echo "The directory '$OUTPUT_DIR' has been created"
else
    echo "The requested directory '$OUTPUT_DIR' already exists"
fi

# Save listening TCP socket information
ss -tln > "$OUTPUT_FILE"

echo "Current scan saved to: $OUTPUT_FILE"

# Find a previous scan, excluding the current one
PREVIOUS_FILE=""
for file in "$OUTPUT_DIR"/open_ports_*.txt; do
    if [ "$file" != "$OUTPUT_FILE" ]; then
        PREVIOUS_FILE="$file"
        break
    fi
done

# Find the most recently modified previous scan
if [ -n "$PREVIOUS_FILE" ]; then
    for file in "$OUTPUT_DIR"/open_ports_*.txt; do
        if [ "$file" != "$OUTPUT_FILE" ] &&
           [ "$file" -nt "$PREVIOUS_FILE" ]; then
            PREVIOUS_FILE="$file"
        fi
    done

    echo
    echo "Comparing with previous scan: $PREVIOUS_FILE"
    echo "Changes (if any):"

    # Compare the previous and current results
    if diff -q "$PREVIOUS_FILE" "$OUTPUT_FILE" > /dev/null; then
        echo "No changes detected."
    else
        diff --old-line-format='Removed: %L' \
             --new-line-format='Added: %L' \
             --unchanged-line-format='' \
             "$PREVIOUS_FILE" "$OUTPUT_FILE"
    fi

else
    echo "No previous scan found to compare."
fi

The script performs five main tasks:

  1. Creates a filename containing the current date and time.

  2. Saves the current listening TCP socket information.

  3. Finds the most recently modified previous scan.

  4. Compares the two result files.

  5. Reports whether entries have been added or removed.

The main concepts introduced in this lesson are file timestamps, Bash loops, command substitution, socket inspection, file comparison, and error handling.

2. Understanding Listening TCP Ports

Before analyzing the script, we need to clarify what it actually monitors.

The command used in the original script is:

ss -tln

ss is a Linux utility used to inspect socket information, including network connections and listening sockets.

Its options are:

OptionMeaning
-tSelect TCP sockets.
-lShow listening sockets.
-nDisplay numerical addresses and port numbers instead of resolving names.

For example, its output might resemble:

State  Recv-Q  Send-Q  Local Address:Port  Peer Address:Port
LISTEN 0       4096    127.0.0.1:8000      0.0.0.0:*
LISTEN 0       128     0.0.0.0:22          0.0.0.0:*

The values above are illustrative.

The Local Address:Port column identifies the local endpoint on which the service is listening. In this example, a service listens on port 8000, while another listens on port 22.

Important distinction: This script checks listening TCP sockets on the local machine. It does not scan another computer across the network, test UDP ports, or prove that a port is reachable from the internet. Firewall rules, network routing, and other restrictions may affect external accessibility.

3. Generating a Timestamped Filename

The script starts with:

TIMESTAMP=$(date +"%Y%m%d_%H%M%S")

The date command returns the current date and time. The $(...) syntax captures its output and stores it in the TIMESTAMP variable.

The format string contains these components:

FormatMeaning
%YFour-digit year
%mTwo-digit month
%dTwo-digit day
%HHour in 24-hour format
%MMinutes
%SSeconds

For example:

20261011_143025

This represents October 11, 2026, at 14:30:25.

Next, the script defines the output location:

OUTPUT_DIR="$HOME/portscanner"
OUTPUT_FILE="$OUTPUT_DIR/open_ports_$TIMESTAMP.txt"

$HOME expands to the current user's home directory. On a typical Linux system, the results might be saved in:

/home/kali/portscanner/

A generated filename might be:

open_ports_20261011_143025.txt

By creating a different filename for each scan, the script preserves historical results rather than continually overwriting the same file.

One limitation is that the timestamp has second-level precision. Two runs during the same second could produce the same filename and overwrite an earlier result. We will address this in the improved script.

4. Creating the Output Directory

The original script checks whether the output directory exists:

if [ ! -d "$OUTPUT_DIR" ]; then
    mkdir "$OUTPUT_DIR"
    echo "The directory '$OUTPUT_DIR' has been created"
else
    echo "The requested directory '$OUTPUT_DIR' already exists"
fi

The condition [ ! -d "$OUTPUT_DIR" ] is true when the path does not identify an existing directory.

If the directory is missing, mkdir attempts to create it. Otherwise, the script reports that the directory already exists.

However, the original code does not verify whether mkdir succeeded. If the directory cannot be created, the script continues and attempts to save the scan anyway.

A simpler and more reliable approach is:

if mkdir -p -- "$OUTPUT_DIR"; then
    echo "Backup and scan directory is ready: $OUTPUT_DIR"
else
    echo "Error: Could not create the output directory."
    exit 1
fi

The -p option creates missing parent directories as needed and avoids errors when the destination already exists as a directory. The -- marks the end of command-line options.

Checking the command's exit status lets the script stop if the output directory cannot be prepared.

5. Saving the Current Scan

The original scan is saved with:

ss -tln > "$OUTPUT_FILE"

The > operator redirects standard output into the specified file.

If the file does not exist, the shell creates it. If it already exists, its contents are overwritten.

For example, the output might be saved to:

/home/kali/portscanner/open_ports_20261011_143025.txt

The following message confirms the intended destination:

echo "Current scan saved to: $OUTPUT_FILE"

However, displaying that message does not prove that the scan succeeded. The original script does not check the exit status of ss.

Another limitation is that it stores the entire ss listing, including headers and queue values, rather than only the listening endpoints. Changes in output ordering or connection-related details can make two scans appear different even when the actual listening endpoints have not changed.

We will improve this by extracting the local endpoints and sorting them before saving them.

6. Finding the Previous Scan

The script needs two files to detect changes: the current result and the previous result.

It begins with an empty variable:

PREVIOUS_FILE=""

It then loops over files matching this pattern:

"$OUTPUT_DIR"/open_ports_*.txt

The * is a wildcard that matches any sequence of characters. Therefore, the pattern matches files whose names begin with open_ports_ and end with .txt.

The loop is:

for file in "$OUTPUT_DIR"/open_ports_*.txt; do
    if [ "$file" != "$OUTPUT_FILE" ]; then
        PREVIOUS_FILE="$file"
        break
    fi
done

Let's examine the important pieces.

for file in ...

The loop processes the filenames matching the pattern, assigning the current filename to the variable file on each iteration.

[ "$file" != "$OUTPUT_FILE" ]

The != operator checks whether the candidate filename differs from the current output filename.

PREVIOUS_FILE="$file"

The script remembers the candidate file for comparison.

break

The break command exits the loop immediately. This initial loop finds a candidate; it does not guarantee that the candidate is the newest scan.

Finding the Most Recently Modified File

The script then uses a second loop:

for file in "$OUTPUT_DIR"/open_ports_*.txt; do
    if [ "$file" != "$OUTPUT_FILE" ] &&
       [ "$file" -nt "$PREVIOUS_FILE" ]; then
        PREVIOUS_FILE="$file"
    fi
done

The -nt file test means newer than, based on filesystem modification times.

If a candidate file was modified more recently than the current PREVIOUS_FILE, its name replaces the existing value.

Once the loop finishes, the script has selected the most recently modified previous scan.

This is different from simply selecting the last filename alphabetically: the script uses the files' modification times.

7. Comparing Files with diff

Once the previous scan has been identified, the script compares it with the current result.

First, it runs:

if diff -q "$PREVIOUS_FILE" "$OUTPUT_FILE" > /dev/null; then
    echo "No changes detected."

diff compares two files line by line.

The -q option asks it to report only whether the files differ rather than displaying the full differences. Redirecting its output to /dev/null suppresses the comparison message because the script only needs the exit status here.

When both files are identical, diff returns 0, and the script displays:

No changes detected.

If the files differ, the script runs:

diff --old-line-format='Removed: %L' \
     --new-line-format='Added: %L' \
     --unchanged-line-format='' \
     "$PREVIOUS_FILE" "$OUTPUT_FILE"

These formatting options customize the output of GNU diff:

  • --old-line-format='Removed: %L' displays lines present in the previous file but absent from the new file.

  • --new-line-format='Added: %L' displays lines present in the new file but absent from the previous file.

  • --unchanged-line-format='' suppresses unchanged lines.

For example, suppose the previous result contains:

127.0.0.1:22
127.0.0.1:8000

And the current result contains:

127.0.0.1:22
127.0.0.1:8080

The change report would resemble:

Removed: 127.0.0.1:8000
Added: 127.0.0.1:8080

This makes the comparison much easier to understand than printing two complete scan files.

Note that diff normally returns status 1 when differences exist. That is expected and does not, by itself, mean that the command failed. Other non-zero statuses can indicate an actual comparison error, which a robust script should handle separately.

8. An Improved Version of the Script

The following version addresses the main limitations of the original.

It creates the destination safely, avoids overwriting scans created during the same second, extracts and sorts the local TCP endpoints, selects the previous scan before creating the new one, and handles comparison errors explicitly.

#!/bin/bash

# Detect changes in local listening TCP endpoints

set -o pipefail

OUTPUT_DIR="$HOME/portscanner"
TIMESTAMP=$(date +"%Y%m%d_%H%M%S")

# Ensure the output directory exists
if ! mkdir -p -- "$OUTPUT_DIR"; then
    echo "Error: Could not create output directory."
    exit 1
fi

# Find the most recently modified existing scan
shopt -s nullglob
SCAN_FILES=("$OUTPUT_DIR"/open_ports_*.txt)
PREVIOUS_FILE=""

for file in "${SCAN_FILES[@]}"; do
    if [[ -z "$PREVIOUS_FILE" || "$file" -nt "$PREVIOUS_FILE" ]]; then
        PREVIOUS_FILE="$file"
    fi
done

# Create a unique filename, even if run repeatedly in one second
OUTPUT_FILE="$OUTPUT_DIR/open_ports_${TIMESTAMP}.txt"
SUFFIX=1

while [[ -e "$OUTPUT_FILE" ]]; do
    OUTPUT_FILE="$OUTPUT_DIR/open_ports_${TIMESTAMP}_${SUFFIX}.txt"
    ((SUFFIX++))
done

# Record local listening TCP endpoints only
if ! ss -H -ltn | awk '{print $4}' | sort -u > "$OUTPUT_FILE"; then
    echo "Error: Could not collect listening TCP endpoints."
    rm -f -- "$OUTPUT_FILE"
    exit 1
fi

echo "Current scan saved to: $OUTPUT_FILE"

# Compare with the most recent previous scan
if [[ -n "$PREVIOUS_FILE" ]]; then
    echo
    echo "Comparing with previous scan: $PREVIOUS_FILE"

    if diff -q -- "$PREVIOUS_FILE" "$OUTPUT_FILE" > /dev/null; then
        echo "No changes detected."
    else
        DIFF_STATUS=$?

        if [[ "$DIFF_STATUS" -eq 1 ]]; then
            echo "Changes detected:"
            diff --old-line-format='Removed: %L' \
                 --new-line-format='Added: %L' \
                 --unchanged-line-format='' \
                 -- "$PREVIOUS_FILE" "$OUTPUT_FILE"
        else
            echo "Error: Could not compare the scan files."
            exit 1
        fi
    fi
else
    echo "No previous scan found to compare."
fi

What Changed?

1. The previous scan is selected before the new file is created.

This avoids needing to exclude the current file from the list of historical scans.

2. nullglob handles empty file lists.

shopt -s nullglob

When a wildcard matches no files, Bash normally leaves the unmatched pattern unchanged. With nullglob, an unmatched pattern expands to nothing.

This makes the scan-file array reliable even when the directory contains no previous results.

3. Duplicate filenames are avoided.

The while loop checks whether the intended filename already exists. If it does, the script adds a numeric suffix rather than overwriting an earlier scan.

4. The output is normalized.

ss -H -ltn | awk '{print $4}' | sort -u

Here, -H suppresses the header, awk extracts the local endpoint column, and sort -u sorts and removes duplicate lines.

The result is a more stable list of listening TCP endpoints, which reduces unnecessary differences caused by output order or changes to other ss columns.

The script enables pipefail so that a failure in the ss command is not hidden by a successful command later in the pipeline.

5. Comparison errors are handled separately.

The script treats diff status 0 as identical files and status 1 as differences. Other statuses are treated as errors rather than being reported as ordinary port changes.

This distinction is important in automation: an actual failure should not be mistaken for a successful scan.

9. How to Run and Test the Script

Step 1: Create the File

Open a terminal and run:

nano port_monitor.sh

Paste the improved script into the editor. Save with Ctrl + O, press Enter, and exit using Ctrl + X.

Step 2: Make It Executable

chmod +x port_monitor.sh

Step 3: Run the First Scan

./port_monitor.sh

The script creates the portscanner directory inside your home directory and saves the first result. Since no previous scan exists, there is nothing to compare.

Step 4: Check the Results

List your saved scans:

ls -lt "$HOME/portscanner"

Display the contents of the newest scan:

cat "$(ls -t "$HOME"/portscanner/open_ports_*.txt | head -n 1)"

The scan should contain listening local TCP endpoints, one per line.

If the saved file is empty, that may simply mean there were no matching listening TCP sockets visible to ss at the time of the scan.

Step 5: Generate a Change

We can use a local web server to introduce a controlled change.

First, run the script once to establish a baseline.

Then, open a second terminal and start a Python HTTP server:

python3 -m http.server 8000 --bind 127.0.0.1

Keep this process running. It should listen on 127.0.0.1:8000.

Run the monitoring script again in the first terminal:

./port_monitor.sh

Provided the server started successfully, the report should include a line similar to:

Added: 127.0.0.1:8000

Now return to the terminal running the HTTP server and press Ctrl + C to stop it.

Run the monitoring script one more time:

./port_monitor.sh

Provided no other process is listening on that endpoint, you should see a removal similar to:

Removed: 127.0.0.1:8000

This demonstrates how the script detects a change in listening TCP endpoints between consecutive scans.

10. When and Where Is This Useful?

The same technique can be used for several system administration and defensive security tasks.

System administration: Detect newly started services or applications that open listening TCP ports.

Troubleshooting: Compare socket listings before and after starting, stopping, or reconfiguring a service.

Change monitoring: Maintain a simple historical record of listening TCP endpoints and identify unexpected changes.

Security labs: Observe how applications such as development web servers or test services affect the local system.

Automation: Run the script periodically and use its saved results as input for later reporting or alerting.

However, a changed endpoint is not automatically suspicious. Software updates, legitimate applications, and configuration changes can all cause differences. The report identifies what changed; the administrator must determine whether that change is expected.

Also, the current version compares snapshots only when you run it. It does not continuously monitor connections or send alerts automatically.

11. Practical Exercises

Exercise 1 — Baseline: Run the script twice without changing any services. Check whether the results remain identical.

Exercise 2 — Add a listener: Start the Python HTTP server on port 8000, scan again, and observe the newly added endpoint.

Exercise 3 — Remove a listener: Stop the server, run another scan, and observe the removal.

Exercise 4 — Historical records: Run the script several times and inspect the files using ls -lt.

Exercise 5 — Logging: Modify the script so that each scan records its timestamp, output filename, and whether changes were detected in a separate log file.

Exercise 6 — UDP monitoring: Research the ss options required to list listening UDP sockets. Adapt the script to monitor TCP and UDP separately.

Exercise 7 — Automated monitoring: Research how to schedule a script with cron and how to send a notification when the monitored endpoints change.

Conclusion

In Level 5, we developed a Bash script that records local listening TCP endpoints and detects additions or removals by comparing successive results.

Along the way, we explored timestamped filenames, directory management, Bash arrays, loops, file modification times, pipelines, awk, sort, and diff.

The central lesson is that monitoring becomes much more useful when you preserve historical results and compare them against a new baseline.

Next step: Expand the script to keep a change log and send a notification when a new listening endpoint appears. This will build on the same foundations while introducing logging, scheduling, and automated alerts.

Comments

Popular posts from this blog

Information disclosure in version control history

Linux AAA

Source code disclosure via backup files