When an autonomous system you care about announces a new prefix — or pulls one — that's the kind of thing you usually find out about by accident. A firewall rule starts misbehaving, a customer reports a routing change, an alert fires on something unrelated and you trace it backwards. There's no good reason for that. The data is public, the deltas are easy to compute, and a daily email is enough to catch most of what matters.

So I wrote a small bash script that does exactly that.


What it does

Once a day, the script:

  1. Fetches the current list of announced CIDRs for each AS number you care about, from asn.ipinfo.app/api/text/list/AS<number>.
  2. Compares it against the previous run's list, stored on disk under as_monitor_data/.
  3. If anything has changed, sends a single HTML email — green rows for prefixes that were added, red rows for prefixes that were removed.
  4. Stays silent on days when nothing has changed.

That last point matters. The whole point of a monitor like this is that you trust it to be quiet when there's nothing to see. If it pings you every day, you start ignoring it within a week, and the one day it has something useful to say it goes straight to the trash.


Configuration

A handful of variables at the top of the script:

SMTP_SERVER="mail.example.com"
SMTP_PORT="25"               # or 587 for TLS
SMTP_USE_TLS="false"         # "true" turns on --ssl-reqd
SMTP_USER=""                 # leave blank for an open relay
SMTP_PASS=""
EMAIL_FROM="[email protected]"
EMAIL_TO="[email protected]"
AS_NUMBERS="398493 15169"    # space-separated

It supports both authenticated SMTP-with-TLS (the typical port 587 setup) and an open relay on port 25 — handy if you have a local mailserver on the LAN that doesn't care about auth from internal hosts. If SMTP_USER/SMTP_PASS are unset, the script skips the auth flag entirely.

The dependencies are minimal — curl, plus the standard coreutils (sort, comm, wc). The asn.ipinfo.app text/list endpoint is plain text, one CIDR per line, so no JSON parser is needed.


Run it

chmod +x as_monitor.sh
./as_monitor.sh

The first run is "everything new" — there's no previous file to compare against, so every prefix shows up as added. From the second run onwards you only see the deltas. Drop it in cron:

0 9 * * * /path/to/as_monitor.sh

Daily at 9 AM. Adjust to taste — once a day is honestly plenty for prefix-level monitoring; ASN announcements don't churn that fast.


The script

#!/bin/bash

# AS IP Monitor Script
# Monitors IP prefix changes for multiple AS numbers and sends email reports

# Configuration
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DATA_DIR="${SCRIPT_DIR}/as_monitor_data"
SMTP_SERVER="smtp.example.com"
SMTP_PORT="587"
SMTP_USE_TLS="true"  # Set to "false" for non-secure port 25 relays
SMTP_USER="[email protected]"
SMTP_PASS="your-password"
EMAIL_FROM="[email protected]"
EMAIL_TO="[email protected]"
EMAIL_SUBJECT="AS IP Monitor - Daily Report"

# AS numbers to monitor (space-separated)
AS_NUMBERS="398493 15169 8075"

# API base URL
API_BASE="https://asn.ipinfo.app/api/text/list"

# Create data directory if it doesn't exist
mkdir -p "$DATA_DIR"

# Function to fetch current IPs for an AS
fetch_as_ips() {
    local asn=$1
    local output_file=$2

    # Fetch IP prefixes from the API
    curl -s "${API_BASE}/AS${asn}" | sort > "$output_file"

    # Check if fetch was successful
    if [ ! -s "$output_file" ]; then
        echo "Warning: Failed to fetch data for AS${asn}" >&2
        return 1
    fi

    return 0
}

# Function to compare IP lists and generate changes
compare_lists() {
    local old_file=$1
    local new_file=$2
    local added_file=$3
    local removed_file=$4

    # Find added IPs (in new but not in old)
    if [ -f "$old_file" ]; then
        comm -13 "$old_file" "$new_file" > "$added_file"
        comm -23 "$old_file" "$new_file" > "$removed_file"
    else
        # First run - all IPs are "added"
        cp "$new_file" "$added_file"
        touch "$removed_file"
    fi
}

# Function to generate HTML report for a single AS
generate_as_report() {
    local asn=$1
    local added_file=$2
    local removed_file=$3

    local added_count=$(wc -l < "$added_file")
    local removed_count=$(wc -l < "$removed_file")

    # Skip if no changes
    if [ "$added_count" -eq 0 ] && [ "$removed_count" -eq 0 ]; then
        return 0
    fi

    echo "<h2>AS${asn} Changes</h2>"
    echo "<p><strong>Added:</strong> ${added_count} | <strong>Removed:</strong> ${removed_count}</p>"

    if [ "$added_count" -gt 0 ] || [ "$removed_count" -gt 0 ]; then
        echo "<table border='1' cellpadding='8' cellspacing='0' style='border-collapse: collapse; width: 100%; margin-bottom: 20px;'>"
        echo "<thead style='background-color: #f0f0f0;'>"
        echo "<tr><th>Status</th><th>IP Prefix</th></tr>"
        echo "</thead><tbody>"

        # Process removed IPs
        while read -r prefix; do
            if [ -n "$prefix" ]; then
                echo "<tr style='background-color: #ffcccc;'>"
                echo "<td><strong style='color: #cc0000;'>REMOVED</strong></td>"
                echo "<td>${prefix}</td>"
                echo "</tr>"
            fi
        done < "$removed_file"

        # Process added IPs
        while read -r prefix; do
            if [ -n "$prefix" ]; then
                echo "<tr style='background-color: #ccffcc;'>"
                echo "<td><strong style='color: #00cc00;'>ADDED</strong></td>"
                echo "<td>${prefix}</td>"
                echo "</tr>"
            fi
        done < "$added_file"

        echo "</tbody></table>"
    fi
}

# Function to send email via SMTP
send_email() {
    local subject=$1
    local html_body=$2
    local total_changes=$3

    # Only send email if there are changes
    if [ "$total_changes" -eq 0 ]; then
        echo "No changes detected. Email not sent."
        return 0
    fi

    # Create email with headers
    local email_file="${DATA_DIR}/email_$(date +%s).tmp"

    cat > "$email_file" <<EOF
From: ${EMAIL_FROM}
To: ${EMAIL_TO}
Subject: ${subject}
MIME-Version: 1.0
Content-Type: text/html; charset=UTF-8

<!DOCTYPE html>
<html>
<head>
    <style>
        body { font-family: Arial, sans-serif; margin: 20px; }
        h1 { color: #333; }
        h2 { color: #666; margin-top: 30px; }
        table { font-size: 14px; }
        .summary { background-color: #f9f9f9; padding: 15px; border-left: 4px solid #007bff; margin-bottom: 20px; }
    </style>
</head>
<body>
    <h1>AS IP Monitor Report</h1>
    <div class="summary">
        <p><strong>Report Date:</strong> $(date '+%Y-%m-%d %H:%M:%S')</p>
        <p><strong>Total Changes:</strong> ${total_changes}</p>
    </div>
    ${html_body}
    <hr>
    <p style='color: #999; font-size: 12px;'>This is an automated report from AS IP Monitor</p>
</body>
</html>
EOF

    # Send via curl with SMTP
    local curl_opts=(
        --url "smtp://${SMTP_SERVER}:${SMTP_PORT}"
        --mail-from "${EMAIL_FROM}"
        --mail-rcpt "${EMAIL_TO}"
        --upload-file "$email_file"
    )

    # Add TLS/authentication only if configured
    if [ "$SMTP_USE_TLS" = "true" ]; then
        curl_opts+=(--ssl-reqd)
    fi

    # Add authentication if user/pass are provided
    if [ -n "$SMTP_USER" ] && [ -n "$SMTP_PASS" ]; then
        curl_opts+=(--user "${SMTP_USER}:${SMTP_PASS}")
    fi

    curl "${curl_opts[@]}" 2>&1

    local result=$?
    rm -f "$email_file"

    if [ $result -eq 0 ]; then
        echo "Email sent successfully!"
    else
        echo "Failed to send email. Check SMTP settings."
    fi

    return $result
}

# Main execution
main() {
    echo "=== AS IP Monitor - $(date) ==="
    echo "Monitoring AS numbers: ${AS_NUMBERS}"
    echo ""

    local html_report=""
    local total_changes=0

    for asn in $AS_NUMBERS; do
        echo "Processing AS${asn}..."

        local current_file="${DATA_DIR}/as${asn}_current.txt"
        local previous_file="${DATA_DIR}/as${asn}_previous.txt"
        local added_file="${DATA_DIR}/as${asn}_added.txt"
        local removed_file="${DATA_DIR}/as${asn}_removed.txt"

        # Fetch current data
        if ! fetch_as_ips "$asn" "$current_file"; then
            echo "Skipping AS${asn} due to fetch error"
            continue
        fi

        # Compare with previous data
        compare_lists "$previous_file" "$current_file" "$added_file" "$removed_file"

        # Generate HTML report section
        local as_html=$(generate_as_report "$asn" "$added_file" "$removed_file")

        if [ -n "$as_html" ]; then
            html_report="${html_report}${as_html}"
            local added_count=$(wc -l < "$added_file")
            local removed_count=$(wc -l < "$removed_file")
            total_changes=$((total_changes + added_count + removed_count))
        fi

        # Update previous file for next run
        cp "$current_file" "$previous_file"

        echo "AS${asn}: $(wc -l < "$added_file") added, $(wc -l < "$removed_file") removed"
        echo ""
    done

    # Send email report
    if [ -n "$html_report" ]; then
        echo "Sending email report with ${total_changes} total changes..."
        send_email "$EMAIL_SUBJECT" "$html_report" "$total_changes"
    else
        echo "No changes detected across all monitored AS numbers."
    fi

    echo "=== Monitoring complete ==="
}

# Run main function
main

Why I bother

Most of the AS numbers I'm watching are ones I care about for either firewall reasons (hosting providers I block, networks I allowlist) or operational reasons (my own and friends' networks). When one of those rolls out a new /24 or pulls a /22 it'd be nice to know that day rather than the third time something downstream gets weird.

It's not glamorous. It's a bash script and a cron entry. But that's the right shape for a problem this small — anything more elaborate would be over-engineering, and it'd quietly stop working in six months when I forgot which container it was running in.

asn.ipinfo.app makes this easy: one URL per ASN, plain text, parseable with comm. If you've got similar monitoring needs and a Linux box anywhere on your network, this script is short enough to read top-to-bottom in a minute and customise to taste.

https://asn.ipinfo.app/api/text/list/AS<number>

That's the whole interface. Pick your ASNs, point the script at them, walk away.