Files
Mailcow-Ban-Check/Mailcow-Ban-Check.sh

464 lines
11 KiB
Bash

#!/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" <<SQL
INSERT OR IGNORE INTO banned_ips (ip_address, status)
VALUES ('$ip', 'active');
UPDATE banned_ips
SET status = 'active'
WHERE ip_address = '$ip';
INSERT INTO ban_history (ip_address, action, details)
VALUES ('$ip', '$action', '$details');
SQL
log "INFO" "Added IP to database: $original_ip"
}
# Mark IP as unbanned
remove_ip() {
local original_ip=$1
local ip
ip=$(sql_escape "$original_ip")
sqlite3 "$DB_PATH" <<SQL
UPDATE banned_ips
SET status = 'inactive'
WHERE ip_address = '$ip';
INSERT INTO ban_history (ip_address, action, details)
VALUES ('$ip', 'unbanned', 'IP removed from ban list');
SQL
log "INFO" "Marked IP as unbanned: $original_ip"
}
# Format IPs for the notification
format_ips_for_discord() {
local ips_string=$1
printf '%s\n' "$ips_string" |
awk 'NF { printf "`%s`\n", $0 }'
}
# Send a notification with all changed IPs
send_discord_notification() {
local action=$1
local ips_string=$2
if [[ -z "$DISCORD_WEBHOOK_URL" ]]; then
log "WARN" "Discord webhook URL not configured"
return 0
fi
if [[ -z "$ips_string" ]]; then
return 0
fi
local color
local title
local count
local timestamp
local description
local payload
count=$(printf '%s\n' "$ips_string" | awk 'NF { count++ } END { print count + 0 }')
timestamp=$(date -u '+%Y-%m-%dT%H:%M:%SZ')
description=$(format_ips_for_discord "$ips_string")
if [[ "$action" == "banned" ]]; then
color=16711680
title="🔴 IPs Banned ($count)"
else
color=65280
title="🟢 IPs Unbanned ($count)"
fi
payload=$(
jq -n \
--arg title "$title" \
--arg description "$description" \
--arg timestamp "$timestamp" \
--argjson color "$color" \
--argjson count "$count" \
'{
embeds: [
{
title: $title,
description: $description,
color: $color,
fields: [
{
name: "Total IPs",
value: ($count | tostring),
inline: true
},
{
name: "Timestamp",
value: $timestamp,
inline: true
}
],
footer: {
text: "F2B Ban List Monitor"
}
}
]
}'
)
if curl \
--fail \
--silent \
--show-error \
--location \
--max-time "$TIMEOUT" \
-X POST \
-H 'Content-Type: application/json' \
--data "$payload" \
"$DISCORD_WEBHOOK_URL" \
>/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