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."
fiThe script performs five main tasks:
Creates a filename containing the current date and time.
Saves the current listening TCP socket information.
Finds the most recently modified previous scan.
Compares the two result files.
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 -tlnss is a Linux utility used to inspect socket information, including network connections and listening sockets.
Its options are:
| Option | Meaning |
|---|---|
-t | Select TCP sockets. |
-l | Show listening sockets. |
-n | Display 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:
| Format | Meaning |
|---|---|
%Y | Four-digit year |
%m | Two-digit month |
%d | Two-digit day |
%H | Hour in 24-hour format |
%M | Minutes |
%S | Seconds |
For example:
20261011_143025This 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.txtBy 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"
fiThe 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
fiThe -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.txtThe 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_*.txtThe * 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
doneLet'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
doneThe -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:8000And the current result contains:
127.0.0.1:22
127.0.0.1:8080The change report would resemble:
Removed: 127.0.0.1:8000
Added: 127.0.0.1:8080This 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."
fiWhat 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 nullglobWhen 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 -uHere, -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.shPaste 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.shStep 3: Run the First Scan
./port_monitor.shThe 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.1Keep this process running. It should listen on 127.0.0.1:8000.
Run the monitoring script again in the first terminal:
./port_monitor.shProvided the server started successfully, the report should include a line similar to:
Added: 127.0.0.1:8000Now return to the terminal running the HTTP server and press Ctrl + C to stop it.
Run the monitoring script one more time:
./port_monitor.shProvided no other process is listening on that endpoint, you should see a removal similar to:
Removed: 127.0.0.1:8000This 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