#!/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