refactor AbuseIPDB integration with improved concurrency and error handling

This refactors the AbuseIPDB integration for Fail2Ban with major improvements:
- Introduced separate lock files (LOCK_INIT, LOCK_BAN, LOCK_DONE) to better handle concurrent
  initialization and prevent race conditions during restarts.
- LOCK_BAN → serializes ban reports to the API (during actionban).
- LOCK_DONE → can signal completion or be used for future synchronization (like restart-safe exits).
- LOCK_INIT with flock in actionstart to prevent concurrent
  initialization, ensuring SQLite and log file integrity during parallel
  Fail2Ban restarts or multiple jail startups.
- Enhanced argument validation for both actionstart and actionban to prevent silent failures.
- Improved database initialization checks, ensuring proper creation of directories and log files.
- Added persistent SQLite pragmas for performance optimization under concurrent access.
- Refined error handling and logging for API interactions, including better detection of
  rate-limiting (HTTP 429) and invalid responses.
- Implemented consistent whitespace trimming and sanitization on IP addresses and bantime inputs.
- Improved modularity with dedicated helper functions, reducing code duplication and improving
  maintainability.
- Ensured background execution with better log redirection and failure tracking.
- Verify local DB insertions, aborting the process on failure to prevent
  incomplete or invalid state.
- Roll back local DB entries if AbuseIPDB reporting fails, ensuring no
  orphaned records remain.
- Replace basic info logs with clear status and error messages to improve
  traceability and debugging.
- Maintain high integrity between the local database and AbuseIPDB by
  only proceeding when all previous steps succeed.
- Shift from a "continue regardless" flow to a controlled stop on any
  critical error, ensuring system reliability.

Previously, the script assumed success of key steps, risking stale database
entries, silent API call failures, and duplicate reports after Fail2Ban
restarts. These changes improve reliability, prevent data corruption under
high concurrency, and ensure accurate synchronization between local db and
AbuseIPDB API.
pull/3948/head
Hasan ÇALIŞIR 2025-03-05 20:04:46 +03:00 committed by GitHub
parent 4a1e854080
commit 00bb41b864
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
1 changed files with 221 additions and 122 deletions

View File

@ -68,6 +68,7 @@ CATEGORIES="$4"
BANTIME="$5"
RESTORED="$6"
BYPASS_FAIL2BAN="${7:-0}"
if [[ "$1" == "--actionstart" ]]; then
SQLITE_DB="${2:-/var/lib/fail2ban/abuseipdb/fail2ban_abuseipdb}"
LOG_FILE="${3:-/var/log/abuseipdb/abuseipdb.log}"
@ -76,35 +77,26 @@ else
LOG_FILE="${9:-/var/log/abuseipdb/abuseipdb.log}"
fi
# Log messages
log_message() {
local message="$1"
echo "$(date +"%Y-%m-%d %H:%M:%S") - ${message}" >> "${LOG_FILE}"
}
# Lock files for 'actionstart' status
LOCK_BAN="/tmp/abuseipdb_actionstart.lock"
LOCK_DONE="/tmp/abuseipdb_actionstart.done"
LOCK_INIT="/tmp/abuseipdb_actionstart_init.lock"
LOCK_BAN="/tmp/abuseipdb_actionstart_ban.lock"
LOCK_DONE="/tmp/abuseipdb_actionstart_done.lock"
# Remove lock file
remove_lock() {
[[ -f "${LOCK_BAN}" ]] && rm -f "${LOCK_BAN}"
}
# Create lock file
create_lock() {
[[ ! -f "${LOCK_BAN}" ]] && touch "${LOCK_BAN}"
}
# Pre-defined SQLite PRAGMAS
SQLITE_PRAGMAS="
PRAGMA journal_mode=WAL;
PRAGMA synchronous=NORMAL;
PRAGMA temp_store=MEMORY;
PRAGMA locking_mode=NORMAL;
PRAGMA cache_size=-256000;
PRAGMA busy_timeout=10000;
"
SQLITE_NON_PERSISTENT_PRAGMAS="PRAGMA synchronous=NORMAL; \
PRAGMA locking_mode=NORMAL; \
PRAGMA busy_timeout=10000;"
#######################################
# HELPERS: (END)
@ -124,53 +116,72 @@ SQLITE_PRAGMAS="
# on initial start to prevent latency.
# - Listens for exit codes to control
# further 'actionban' events via the
# 'lock' mechanism.
# - Check 'abuseipdb.local' for
# integration details.
# 'LOCK_BAN' mechanism.
# - Use 'LOCK_INIT' and 'LOCK_DONE' to
# manage concurrent calls on restarts.
########################################
if [[ "$1" == "--actionstart" ]]; then
if [[ ! -f "${LOCK_DONE}" ]]; then
trap 'if [[ $? -ne 0 ]]; then create_lock; else remove_lock; fi' EXIT
SQLITE_DIR=$(dirname "${SQLITE_DB}")
if [[ ! -d "${SQLITE_DIR}" ]]; then
mkdir -p "${SQLITE_DIR}" || exit 1
fi
if [[ ! -f "${LOG_FILE}" ]]; then
touch "${LOG_FILE}" || exit 1
fi
for dep in curl jq sqlite3; do
if ! command -v "${dep}" &>/dev/null; then
log_message "ERROR: ${dep} is not installed. Please install ${dep}"
exit 1
fi
done
if [[ ! -f "${SQLITE_DB}" ]]; then
sqlite3 "${SQLITE_DB}" "
${SQLITE_PRAGMAS}
CREATE TABLE IF NOT EXISTS banned_ips (ip TEXT PRIMARY KEY, bantime INTEGER);
CREATE INDEX IF NOT EXISTS idx_ip ON banned_ips(ip);
"
fi
result=$(sqlite3 "file:${SQLITE_DB}?mode=ro" "SELECT name FROM sqlite_master WHERE type='table' AND name='banned_ips';")
if ! [[ -n "${result}" ]]; then
log_message "ERROR: AbuseIPDB database initialization failed."
rm -f "${SQLITE_DB:?}"
exit 1
else
log_message "SUCCESS: The AbuseIPDB database is now ready for connection."
fi
touch "${LOCK_DONE}" || exit 1
(
flock -n 200 || {
[[ -f "${LOG_FILE}" ]] && log_message "WARNING: Another initialization is already running. Exiting."
exit 0
else
}
if [[ -f "${LOCK_DONE}" ]]; then
log_message "INFO: Initialization already completed. Skipping further checks."
exit 0
fi
trap 'if [[ $? -ne 0 ]]; then create_lock; else remove_lock; fi' EXIT
SQLITE_DIR=$(dirname "${SQLITE_DB}")
if [[ ! -d "${SQLITE_DIR}" ]]; then
mkdir -p "${SQLITE_DIR}" || exit 1
fi
LOG_DIR=$(dirname "${LOG_FILE}")
if [[ ! -d "${LOG_DIR}" ]]; then
mkdir -p "${LOG_DIR}" || exit 1
fi
if [[ ! -f "${LOG_FILE}" ]]; then
touch "${LOG_FILE}" || exit 1
fi
for dep in curl jq sqlite3; do
if ! command -v "${dep}" &>/dev/null; then
log_message "ERROR: ${dep} is not installed. Please install ${dep}"
exit 1
fi
done
if [[ ! -f "${SQLITE_DB}" ]]; then
log_message "INFO: AbuseIPDB database not found. Initializing..."
sqlite3 "${SQLITE_DB}" "
PRAGMA journal_mode=WAL;
CREATE TABLE IF NOT EXISTS banned_ips (
ip TEXT PRIMARY KEY,
bantime INTEGER
);
CREATE INDEX IF NOT EXISTS idx_ip ON banned_ips(ip);
" &>/dev/null
log_message "INFO: AbuseIPDB database is initialized!"
fi
table=$(sqlite3 "${SQLITE_DB}" "SELECT name FROM sqlite_master WHERE type='table' AND name='banned_ips';")
if ! [[ -n "${table}" ]]; then
log_message "ERROR: AbuseIPDB database initialization failed."
exit 1
fi
touch "${LOCK_DONE}" || exit 1
log_message "SUCCESS: All (actionstart) checks completed!"
exit 0
) 200>"${LOCK_INIT}"
exit 0
fi
#######################################
@ -182,17 +193,7 @@ fi
#######################################
#######################################
# 1) Prevent 'actionban' if
# 'actionstart' fails.
#
# If 'actionstart' fails, block
# 'actionban' to prevent issues from
# missing dependencies or permission
# errors.
#######################################
#######################################
# 2) Fail2Ban restart handling &
# 1) Fail2Ban restart handling &
# duplicate report prevention.
#
# - If 'BYPASS_FAIL2BAN' is disabled,
@ -203,8 +204,18 @@ fi
# - If enabled, Fail2Ban is bypassed,
# and the script independently
# decides which IPs to report based
# on the AbuseIPDB db, even after
# restarts.
# on the local AbuseIPDB SQLite db,
# even after restarts.
#######################################
#######################################
# 2) Prevent 'actionban' if
# 'actionstart' fails.
#
# - If 'actionstart' fails, block
# 'actionban' to prevent issues from
# missing dependencies or permission
# errors.
#######################################
#######################################
@ -221,16 +232,16 @@ fi
# EARLY CHECKS: (START)
#######################################
if [[ "${BYPASS_FAIL2BAN}" -eq 0 && "${RESTORED}" -eq 1 ]]; then
log_message "INFO: (RESTART) IP ${IP} was already reported in the previous Fail2Ban session."
exit 0
fi
if [[ -f "${LOCK_BAN}" ]]; then
[[ -f "${LOG_FILE}" ]] && log_message "ERROR: Initialization failed! (actionstart). Reporting for IP ${IP} is blocked."
exit 1
fi
if [[ "${BYPASS_FAIL2BAN}" -eq 0 && "${RESTORED}" -eq 1 ]]; then
log_message "INFO: IP ${IP} already reported."
exit 0
fi
if [[ -z "${APIKEY}" || -z "${COMMENT}" || -z "${IP}" || -z "${CATEGORIES}" || -z "${BANTIME}" ]]; then
log_message "ERROR: Missing core argument(s)."
exit 1
@ -246,82 +257,133 @@ fi
check_ip_in_abuseipdb() {
local response http_status body total_reports delimiter="HTTP_STATUS:"
response=$(curl -s -w "${delimiter}%{http_code}" -G "https://api.abuseipdb.com/api/v2/check" \
if ! response=$(curl -sS -w "${delimiter}%{http_code}" -G "https://api.abuseipdb.com/api/v2/check" \
--data-urlencode "ipAddress=${IP}" \
-H "Key: ${APIKEY}" \
-H "Accept: application/json" 2>&1)
if [[ $? -ne 0 ]]; then
log_message "ERROR: API failure. Response: ${response}"
return 1
-H "Accept: application/json" 2>&1); then
log_message "ERROR: curl failed. Response: ${response}"
return 2
fi
http_status=$(echo "${response}" | tr -d '\n' | sed -e "s/.*${delimiter}//")
body=$(echo "${response}" | sed -e "s/${delimiter}[0-9]*//")
http_status="${response##*${delimiter}}"
body="${response%"${delimiter}${http_status}"}"
if [[ "${http_status}" =~ ^[0-9]+$ ]]; then
if [[ ! "${http_status}" =~ ^[0-9]+$ ]]; then
log_message "ERROR: Invalid HTTP status in Response: ${response}"
return 2
fi
if [[ "${http_status}" -ne 200 ]]; then
if [[ "${http_status}" -eq 429 ]]; then
log_message "ERROR: Rate limited (HTTP 429). Response: ${body}"
return 1
fi
if [[ "${http_status}" -ne 200 ]]; then
else
log_message "ERROR: HTTP ${http_status}. Response: ${body}"
return 1
fi
else
log_message "ERROR: API failure. Response: ${response}"
return 1
return 2
fi
total_reports=$(echo "${body}" | jq '.data.totalReports')
if [[ "${total_reports}" -gt 0 ]]; then
total_reports=$(jq -r '.data.totalReports // 0' <<< "${body}")
if (( total_reports > 0 )); then
return 0
else
return 1
fi
return 1
}
convert_bantime() {
local bantime=$1
local time_value
local time_unit
if [[ "${bantime}" =~ ^[0-9]+$ ]]; then
echo "${bantime}"
return 0
fi
time_value="${bantime%"${bantime##*[0-9]}"}"
time_unit="${bantime#${time_value}}"
[[ -z "$time_unit" ]] && time_unit="s"
case "$time_unit" in
s) return $time_value ;;
m) return $((time_value * 60)) ;;
h) return $((time_value * 3600)) ;;
d) return $((time_value * 86400)) ;;
w) return $((time_value * 604800)) ;;
y) return $((time_value * 31536000)) ;;
*) return $time_value ;;
esac
}
report_ip_to_abuseipdb() {
local response
response=$(curl --fail -s 'https://api.abuseipdb.com/api/v2/report' \
local response http_status body delimiter="HTTP_STATUS:"
if ! response=$(curl -sS -w "${delimiter}%{http_code}" "https://api.abuseipdb.com/api/v2/report" \
-H 'Accept: application/json' \
-H "Key: ${APIKEY}" \
--data-urlencode "comment=${COMMENT}" \
--data-urlencode "ip=${IP}" \
--data "categories=${CATEGORIES}" 2>&1)
if [[ $? -ne 0 ]]; then
log_message "ERROR: API failure. Response: ${response} for IP: ${IP}"
else
log_message "SUCCESS: Reported IP ${IP} to AbuseIPDB."
--data "categories=${CATEGORIES}" 2>&1); then
log_message "ERROR: curl failed. Response: ${response}"
return 1
fi
http_status="${response##*${delimiter}}"
body="${response%"${delimiter}${http_status}"}"
if [[ ! "${http_status}" =~ ^[0-9]+$ ]]; then
log_message "ERROR: Invalid HTTP status in response: ${response}"
return 1
fi
if [[ "${http_status}" -ne 200 ]]; then
if [[ "${http_status}" -eq 429 ]]; then
log_message "ERROR: Rate limited (HTTP 429). Response: ${body}"
else
log_message "ERROR: HTTP ${http_status}. Response: ${body}"
fi
return 1
fi
log_message "SUCCESS: Reported IP ${IP} to AbuseIPDB."
return 0
}
check_ip_in_db() {
local ip=$1 result
result=$(sqlite3 "file:${SQLITE_DB}?mode=ro" "
${SQLITE_PRAGMAS}
SELECT 1 FROM banned_ips WHERE ip = '${ip}' LIMIT 1;"
)
ip="${ip%"${ip##*[![:space:]]}"}"
ip="${ip#"${ip%%[^[:space:]]*}"}"
ip="${ip//\'/}"
ip="${ip//\"/}"
if [[ $? -ne 0 ]]; then
log_message "ERROR: AbuseIPDB database query failed while checking IP ${ip}. Response: ${result}"
return 1
fi
sqlite3 "${SQLITE_DB}" "${SQLITE_NON_PERSISTENT_PRAGMAS}" &>/dev/null
result=$(sqlite3 "${SQLITE_DB}" "SELECT EXISTS(SELECT 1 FROM banned_ips WHERE ip = '${ip}');")
if [[ -n "${result}" ]]; then
if [[ "${result}" -eq 1 ]]; then
return 0
else
elif [[ "${result}" -eq 0 ]]; then
return 1
else
return 2
fi
}
insert_ip_to_db() {
local ip=$1
local bantime=$2
local ip=$1 bantime=$2
bantime=$(convert_bantime "${bantime}")
bantime="${bantime%"${bantime##*[![:space:]]}"}"
bantime="${bantime#"${bantime%%[^[:space:]]*}"}"
bantime="${bantime//\'/}"
bantime="${bantime//\"/}"
ip="${ip%"${ip##*[![:space:]]}"}"
ip="${ip#"${ip%%[^[:space:]]*}"}"
ip="${ip//\'/}"
ip="${ip//\"/}"
sqlite3 "${SQLITE_DB}" "${SQLITE_NON_PERSISTENT_PRAGMAS}" &>/dev/null
sqlite3 "${SQLITE_DB}" "
${SQLITE_PRAGMAS}
BEGIN IMMEDIATE;
INSERT INTO banned_ips (ip, bantime)
VALUES ('${ip}', ${bantime})
@ -329,9 +391,31 @@ insert_ip_to_db() {
COMMIT;
"
# TO-DO: Better handle SQLite INSERT ops. exit statuses
# $? -ne 0 | I think not the best approach here.
if [[ $? -ne 0 ]]; then
log_message "ERROR: Failed to insert or update IP ${ip} in the AbuseIPDB database."
return 1
fi
return 0
}
delete_ip_from_db() {
local ip=$1
ip="${ip%"${ip##*[![:space:]]}"}"
ip="${ip#"${ip%%[^[:space:]]*}"}"
ip="${ip//\'/}"
ip="${ip//\"/}"
sqlite3 "${SQLITE_DB}" "${SQLITE_NON_PERSISTENT_PRAGMAS}" &>/dev/null
sqlite3 "${SQLITE_DB}" "
BEGIN IMMEDIATE;
DELETE FROM banned_ips WHERE ip='${ip}';
COMMIT;
"
# TO-DO: Do we need to listen exit status DELETE
# I don't think so for now.
log_message "INFO: IP ${ip} deleted from the AbuseIPDB SQLite database."
}
#######################################
@ -352,18 +436,33 @@ insert_ip_to_db() {
log_message "INFO: IP ${IP} has already been reported and remains on AbuseIPDB."
shouldBanIP=0
else
log_message "INFO: IP ${IP} has already been reported but is no longer listed on AbuseIPDB."
shouldBanIP=1
status=$?
if [[ "${status}" -eq 1 ]]; then
log_message "INFO: IP ${IP} has already been reported but is no longer listed on AbuseIPDB. Resubmitting..."
else
log_message "ERROR: Failed to check IP ${IP} in the AbuseIPDB API. Skipping report."
exit 1
fi
fi
else
shouldBanIP=1
status=$?
if [[ "${status}" -eq 2 ]]; then
log_message "ERROR: Failed to check IP ${IP} in the local database. Skipping report."
exit 1
fi
fi
if [[ "${shouldBanIP}" -eq 1 ]]; then
if [[ "${is_found_local}" -eq 0 ]]; then
insert_ip_to_db $IP $BANTIME
if ! insert_ip_to_db $IP $BANTIME; then
log_message "ERROR: Failed to insert IP ${IP} into the local database. Skipping report."
exit 1
fi
fi
if ! report_ip_to_abuseipdb; then
delete_ip_from_db $IP
fi
report_ip_to_abuseipdb
fi
) >> "${LOG_FILE}" 2>&1 &