diff --git a/Mailcow-Ban-Check.sh b/Mailcow-Ban-Check.sh new file mode 100644 index 0000000..5349100 --- /dev/null +++ b/Mailcow-Ban-Check.sh @@ -0,0 +1,463 @@ +#!/usr/bin/env bash + +set -x +set -o pipefail + +# Configuration +BANLIST_URL="https://email.example.com/f2b-banlist?id=xxxxxxxxxxxxxxxxxxxxx" +DISCORD_WEBHOOK_URL="https://api.fluxer.app/webhooks/xxxxxxxxxxxxxxxxx/xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" + +DB_PATH="${DB_PATH:-"$SCRIPT_DIR/banlist.db"}" +LOG_FILE="${LOG_FILE:-"$SCRIPT_DIR/banlist_monitor.log"}" +TEMP_DIR="${TMPDIR:-/tmp}/banlist_monitor.$$" +TIMEOUT="${TIMEOUT:-10}" +LOCK_FILE="${LOCK_FILE:-/tmp/banlist_monitor.lock}" + +mkdir -p "$TEMP_DIR" +trap 'rm -rf -- "$TEMP_DIR"' EXIT INT TERM + +# Logging function +log() { + local level=$1 + shift + + local message="$*" + local timestamp + + timestamp=$(date '+%Y-%m-%d %H:%M:%S') + printf '[%s] [%s] %s\n' "$timestamp" "$level" "$message" >> "$LOG_FILE" +} + +# Escape a value for use inside a SQLite string literal +sql_escape() { + local value=$1 + value=${value//\'/\'\'} + printf '%s' "$value" +} + +# Initialize database +init_database() { + if [[ ! -f "$DB_PATH" ]]; then + sqlite3 "$DB_PATH" <<'SQL' +CREATE TABLE IF NOT EXISTS banned_ips ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ip_address TEXT UNIQUE NOT NULL, + banned_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + status TEXT DEFAULT 'active' +); + +CREATE TABLE IF NOT EXISTS ban_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ip_address TEXT NOT NULL, + action TEXT NOT NULL, + timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + details TEXT +); + +CREATE INDEX IF NOT EXISTS idx_ip_status + ON banned_ips(ip_address, status); + +CREATE INDEX IF NOT EXISTS idx_history_ip + ON ban_history(ip_address); +SQL + + log "INFO" "Database initialized" + else + # Ensure older databases receive any missing objects. + sqlite3 "$DB_PATH" <<'SQL' +CREATE TABLE IF NOT EXISTS banned_ips ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ip_address TEXT UNIQUE NOT NULL, + banned_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + status TEXT DEFAULT 'active' +); + +CREATE TABLE IF NOT EXISTS ban_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ip_address TEXT NOT NULL, + action TEXT NOT NULL, + timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + details TEXT +); + +CREATE INDEX IF NOT EXISTS idx_ip_status + ON banned_ips(ip_address, status); + +CREATE INDEX IF NOT EXISTS idx_history_ip + ON ban_history(ip_address); +SQL + fi +} + +# Fetch ban list from URL +fetch_banlist() { + local output_file="$TEMP_DIR/banlist_current.txt" + local cleaned_file="$TEMP_DIR/banlist_current.cleaned.txt" + local downloaded_file="$TEMP_DIR/banlist_download.txt" + + if ! curl \ + --fail \ + --silent \ + --show-error \ + --location \ + --connect-timeout "$TIMEOUT" \ + --max-time "$TIMEOUT" \ + "$BANLIST_URL" \ + -o "$downloaded_file" \ + 2>>"$LOG_FILE"; then + + log "ERROR" "Failed to fetch ban list" + return 1 + fi + + awk ' + { + gsub(/^[[:space:]]+|[[:space:]]+$/, "", $0) + + # Accept IPv4/IPv6 addresses with optional CIDR notation. + if ($0 != "" && $0 !~ /[^0-9A-Fa-f:.\/]/) { + print $0 + } + } + ' "$downloaded_file" | sort -u > "$cleaned_file" + + if [[ ! -s "$cleaned_file" && -s "$downloaded_file" ]]; then + log "ERROR" "Downloaded ban list contained no valid IP addresses" + return 1 + fi + + mv -- "$cleaned_file" "$output_file" + + local count + count=$(wc -l < "$output_file") + + log "INFO" "Fetched $count banned IPs" + printf '%s\n' "$output_file" +} + +# Get currently tracked active IPs from database +get_tracked_ips() { + local output_file="$TEMP_DIR/ips_tracked.txt" + + sqlite3 "$DB_PATH" \ + "SELECT ip_address + FROM banned_ips + WHERE status = 'active' + ORDER BY ip_address;" > "$output_file" + + printf '%s\n' "$output_file" +} + +# Add IP to database +add_ip() { + local original_ip=$1 + local original_action=${2:-banned} + local original_details=${3:-New IP banned} + + local ip + local action + local details + + ip=$(sql_escape "$original_ip") + action=$(sql_escape "$original_action") + details=$(sql_escape "$original_details") + + sqlite3 "$DB_PATH" </dev/null \ + 2>>"$LOG_FILE"; then + + log "INFO" "Discord notification sent for $action with $count IPs" + else + log "ERROR" "Failed to send Discord notification" + return 1 + fi +} + +# Find IPs present in the current list but not the tracked list +find_new_ips() { + local current_file=$1 + local tracked_file=$2 + + comm -23 "$current_file" "$tracked_file" +} + +# Find tracked IPs no longer present in the current list +find_removed_ips() { + local current_file=$1 + local tracked_file=$2 + + comm -13 "$current_file" "$tracked_file" +} + +# Main monitoring function +check_for_changes() { + log "INFO" "Starting ban list check" + + local current_file + local tracked_file + local new_bans + local unbanned + + if ! current_file=$(fetch_banlist); then + log "ERROR" "Failed to fetch ban list, skipping check" + return 1 + fi + + if ! tracked_file=$(get_tracked_ips); then + log "ERROR" "Failed to read tracked IPs" + return 1 + fi + + # Find newly banned IPs + new_bans=$(find_new_ips "$current_file" "$tracked_file") + + if [[ -n "$new_bans" ]]; then + log "INFO" "Found $(printf '%s\n' "$new_bans" | awk 'NF { count++ } END { print count + 0 }') new bans" + + while IFS= read -r ip; do + [[ -n "$ip" ]] || continue + add_ip "$ip" "banned" "New IP banned" + done <<< "$new_bans" + + send_discord_notification "banned" "$new_bans" + fi + + # Find unbanned IPs + unbanned=$(find_removed_ips "$current_file" "$tracked_file") + + if [[ -n "$unbanned" ]]; then + log "INFO" "Found $(printf '%s\n' "$unbanned" | awk 'NF { count++ } END { print count + 0 }') unbans" + + while IFS= read -r ip; do + [[ -n "$ip" ]] || continue + remove_ip "$ip" + done <<< "$unbanned" + + send_discord_notification "unbanned" "$unbanned" + fi + + if [[ -z "$new_bans" && -z "$unbanned" ]]; then + log "INFO" "No changes detected" + fi +} + +# Get statistics +get_statistics() { + local active + local inactive + local total + + active=$(sqlite3 "$DB_PATH" \ + "SELECT COUNT(*) FROM banned_ips WHERE status = 'active';") + + inactive=$(sqlite3 "$DB_PATH" \ + "SELECT COUNT(*) FROM banned_ips WHERE status = 'inactive';") + + total=$((active + inactive)) + + log "INFO" \ + "Ban list statistics - Active: $active, Inactive: $inactive, Total: $total" + + printf 'Active: %s\nInactive: %s\nTotal: %s\n' \ + "$active" "$inactive" "$total" +} + +# View recent history +view_history() { + echo "=== Recent Ban History ===" + + sqlite3 -header -column "$DB_PATH" \ + "SELECT ip_address, action, timestamp + FROM ban_history + ORDER BY timestamp DESC + LIMIT 20;" +} + +# View active banned IPs +view_active_bans() { + echo "=== Currently Active Banned IPs ===" + + sqlite3 "$DB_PATH" \ + "SELECT ip_address, banned_at + FROM banned_ips + WHERE status = 'active' + ORDER BY banned_at DESC;" +} + +# Check required commands +check_dependencies() { + local cmd + + for cmd in curl sqlite3 jq flock awk sort comm date wc; do + if ! command -v "$cmd" >/dev/null 2>&1; then + echo "ERROR: $cmd is required but not installed" >&2 + exit 1 + fi + done +} + +# Run the monitor +main() { + check_dependencies + + # Prevent overlapping cron/systemd runs. + exec 9>"$LOCK_FILE" + + if ! flock -n 9; then + echo "Another instance is already running" + exit 0 + fi + + init_database + check_for_changes + get_statistics +} + +# Handle command-line arguments +case "${1:-}" in + history) + check_dependencies + init_database + view_history + ;; + + stats) + check_dependencies + init_database + get_statistics + ;; + + active) + check_dependencies + init_database + view_active_bans + ;; + + "") + main + ;; + + *) + echo "Usage: $0 [history|stats|active]" >&2 + exit 1 + ;; +esac diff --git a/README.md b/README.md index e9ea651..48f7fb5 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,44 @@ # Mailcow-Ban-Check -This Bash script monitors a remote IP ban list, compares it with previously tracked IPs in SQLite, records changes, and sends notifications to a Discord-compatible webhook \ No newline at end of file +This Bash script monitors a remote IP ban list, compares it with previously tracked IPs in SQLite, records changes, and sends notifications to a Discord-compatible webhook + +# Still in development +# Not for Production + +## !!! Important: the URLs placed into this script will contain credentials/access tokens or API Keys. Treat this script as sensitive. These credentials will be stored in a more secure way in a future update !!! + +A Bash-based monitoring tool that tracks changes to a remote IP ban list. + +The script downloads a ban list, compares it with previously tracked addresses in a local SQLite database, records new bans and removals, and sends notifications to a Discord-compatible webhook. + +### Features + +- Downloads a remote ban list over HTTPS +- Supports IPv4, IPv6, and CIDR notation +- Removes duplicates and surrounding whitespace +- Tracks active and inactive IP addresses +- Maintains a ban history +- Sends notifications for: + - Newly banned IPs + - IPs removed from the ban list +- Prevents overlapping executions with `flock` +- Supports cron and systemd timer execution +- Provides commands for viewing history, statistics, and active bans + +### Requirements + +The following commands must be installed: + +- Bash +- `curl` +- `sqlite3` +- `jq` +- `flock` +- `awk` +- `sort` +- `comm` +- `date` +- `wc` + + +## !!! Important: the URLs placed into this script will contain credentials/access tokens or API Keys. Treat this script as sensitive. These credentials will be stored in a more secure way in a future update !!!