#define _GNU_SOURCE #include "ip_ban.h" #include "debug.h" #include "config.h" #include #include #include // ============================================================ // IP Auth Failure Ban System // // Fixed-size open-addressing hash table. No malloc after init. // Thread-safe via a single mutex (low contention — only called // at connection open/close, not in the hot event path). // // State is persisted to the ip_bans SQLite table every 5 minutes // and loaded at startup so bans survive relay restarts. // ============================================================ #define IP_BAN_EMPTY 0 #define IP_BAN_ACTIVE 1 typedef struct { int state; // IP_BAN_EMPTY or IP_BAN_ACTIVE char ip[46]; // IPv4 or IPv6 string // Auth failure tracking (existing) int failure_count; // failures in current window time_t first_failure; // start of current failure window time_t banned_until; // 0 = not banned int ban_count; // escalation level (for exponential backoff) // NEW: Idle failure tracking (separate) int idle_failure_count; time_t idle_first_failure; time_t idle_banned_until; int idle_ban_count; // Other existing fields int has_authed_successfully; // 1 if this IP has ever authenticated time_t last_success_at; // timestamp of last successful auth int total_connections; // lifetime connection count int total_failures; // lifetime auth failure count int total_successes; // lifetime successful auth count time_t first_seen; // when this IP was first seen } ip_ban_entry_t; static ip_ban_entry_t g_ban_table[IP_BAN_TABLE_SIZE]; static pthread_mutex_t g_ban_mutex = PTHREAD_MUTEX_INITIALIZER; static int g_initialized = 0; // Simple FNV-1a hash for IP strings static unsigned int ip_hash(const char* ip) { unsigned int hash = 2166136261u; while (*ip) { hash ^= (unsigned char)*ip++; hash *= 16777619u; } return hash % IP_BAN_TABLE_SIZE; } // Find slot for IP (open addressing with linear probing) static int find_slot(const char* ip) { unsigned int start = ip_hash(ip); for (unsigned int i = 0; i < IP_BAN_TABLE_SIZE; i++) { unsigned int idx = (start + i) % IP_BAN_TABLE_SIZE; if (g_ban_table[idx].state == IP_BAN_EMPTY) { return (int)idx; } if (strcmp(g_ban_table[idx].ip, ip) == 0) { return (int)idx; } } return -1; } // Get or create an entry for an IP. Returns NULL if table is full. // Caller must hold g_ban_mutex. static ip_ban_entry_t* get_or_create_entry(const char* ip) { int idx = find_slot(ip); if (idx < 0) { DEBUG_WARN("IP ban table full, cannot track %s", ip); return NULL; } ip_ban_entry_t* entry = &g_ban_table[idx]; if (entry->state == IP_BAN_EMPTY) { entry->state = IP_BAN_ACTIVE; strncpy(entry->ip, ip, sizeof(entry->ip) - 1); entry->ip[sizeof(entry->ip) - 1] = '\0'; entry->first_seen = time(NULL); } return entry; } void ip_ban_init(void) { pthread_mutex_lock(&g_ban_mutex); memset(g_ban_table, 0, sizeof(g_ban_table)); g_initialized = 1; pthread_mutex_unlock(&g_ban_mutex); DEBUG_LOG("IP ban table initialized (%d slots)", IP_BAN_TABLE_SIZE); } void ip_ban_load_from_db(sqlite3* db) { if (!db || !g_initialized) return; // Create table if it doesn't exist (handles existing databases) const char* create_sql = "CREATE TABLE IF NOT EXISTS ip_bans (" " ip TEXT PRIMARY KEY," " failure_count INTEGER NOT NULL DEFAULT 0," " ban_count INTEGER NOT NULL DEFAULT 0," " banned_until INTEGER NOT NULL DEFAULT 0," " first_failure INTEGER NOT NULL DEFAULT 0," " has_authed_successfully INTEGER NOT NULL DEFAULT 0," " last_success_at INTEGER NOT NULL DEFAULT 0," " total_connections INTEGER NOT NULL DEFAULT 0," " total_failures INTEGER NOT NULL DEFAULT 0," " total_successes INTEGER NOT NULL DEFAULT 0," " first_seen INTEGER NOT NULL DEFAULT 0," " updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))" ");"; char* err = NULL; if (sqlite3_exec(db, create_sql, NULL, NULL, &err) != SQLITE_OK) { DEBUG_ERROR("Failed to create ip_bans table: %s", err ? err : "unknown"); if (err) sqlite3_free(err); return; } // Migration: Add idle_* columns if they don't exist (ignore errors if already exists) sqlite3_exec(db, "ALTER TABLE ip_bans ADD COLUMN idle_failure_count INTEGER NOT NULL DEFAULT 0", NULL, NULL, NULL); sqlite3_exec(db, "ALTER TABLE ip_bans ADD COLUMN idle_ban_count INTEGER NOT NULL DEFAULT 0", NULL, NULL, NULL); sqlite3_exec(db, "ALTER TABLE ip_bans ADD COLUMN idle_banned_until INTEGER NOT NULL DEFAULT 0", NULL, NULL, NULL); sqlite3_exec(db, "ALTER TABLE ip_bans ADD COLUMN idle_first_failure INTEGER NOT NULL DEFAULT 0", NULL, NULL, NULL); const char* sql = "SELECT ip, failure_count, ban_count, banned_until, first_failure," " has_authed_successfully, last_success_at, total_connections," " total_failures, total_successes, first_seen," " idle_failure_count, idle_ban_count, idle_banned_until, idle_first_failure" " FROM ip_bans"; sqlite3_stmt* stmt; if (sqlite3_prepare_v2(db, sql, -1, &stmt, NULL) != SQLITE_OK) { DEBUG_ERROR("Failed to prepare ip_bans load query"); return; } int loaded = 0; pthread_mutex_lock(&g_ban_mutex); while (sqlite3_step(stmt) == SQLITE_ROW) { const char* ip = (const char*)sqlite3_column_text(stmt, 0); if (!ip) continue; int idx = find_slot(ip); if (idx < 0) continue; ip_ban_entry_t* entry = &g_ban_table[idx]; entry->state = IP_BAN_ACTIVE; strncpy(entry->ip, ip, sizeof(entry->ip) - 1); entry->ip[sizeof(entry->ip) - 1] = '\0'; entry->failure_count = sqlite3_column_int(stmt, 1); entry->ban_count = sqlite3_column_int(stmt, 2); entry->banned_until = (time_t)sqlite3_column_int64(stmt, 3); entry->first_failure = (time_t)sqlite3_column_int64(stmt, 4); entry->has_authed_successfully = sqlite3_column_int(stmt, 5); entry->last_success_at = (time_t)sqlite3_column_int64(stmt, 6); entry->total_connections = sqlite3_column_int(stmt, 7); entry->total_failures = sqlite3_column_int(stmt, 8); entry->total_successes = sqlite3_column_int(stmt, 9); entry->first_seen = (time_t)sqlite3_column_int64(stmt, 10); // Load idle tracking fields (default to 0 if columns don't exist yet) entry->idle_failure_count = sqlite3_column_int(stmt, 11); entry->idle_ban_count = sqlite3_column_int(stmt, 12); entry->idle_banned_until = (time_t)sqlite3_column_int64(stmt, 13); entry->idle_first_failure = (time_t)sqlite3_column_int64(stmt, 14); loaded++; } pthread_mutex_unlock(&g_ban_mutex); sqlite3_finalize(stmt); // Count how many are still actively banned time_t now = time(NULL); int still_banned = 0; pthread_mutex_lock(&g_ban_mutex); for (int i = 0; i < IP_BAN_TABLE_SIZE; i++) { if (g_ban_table[i].state == IP_BAN_ACTIVE && g_ban_table[i].banned_until > now) { still_banned++; } } pthread_mutex_unlock(&g_ban_mutex); DEBUG_WARN("IP ban table loaded: %d IPs restored (%d still banned)", loaded, still_banned); } void ip_ban_save_to_db(sqlite3* db) { if (!db || !g_initialized) return; const char* upsert_sql = "INSERT OR REPLACE INTO ip_bans" " (ip, failure_count, ban_count, banned_until, first_failure," " has_authed_successfully, last_success_at, total_connections," " total_failures, total_successes, first_seen, updated_at," " idle_failure_count, idle_ban_count, idle_banned_until, idle_first_failure)" " VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, strftime('%s', 'now'), ?, ?, ?, ?)"; sqlite3_stmt* stmt; if (sqlite3_prepare_v2(db, upsert_sql, -1, &stmt, NULL) != SQLITE_OK) { DEBUG_ERROR("Failed to prepare ip_bans save query"); return; } sqlite3_exec(db, "BEGIN TRANSACTION", NULL, NULL, NULL); int saved = 0; pthread_mutex_lock(&g_ban_mutex); for (int i = 0; i < IP_BAN_TABLE_SIZE; i++) { ip_ban_entry_t* entry = &g_ban_table[i]; if (entry->state != IP_BAN_ACTIVE) continue; sqlite3_reset(stmt); sqlite3_bind_text(stmt, 1, entry->ip, -1, SQLITE_STATIC); sqlite3_bind_int(stmt, 2, entry->failure_count); sqlite3_bind_int(stmt, 3, entry->ban_count); sqlite3_bind_int64(stmt, 4, (sqlite3_int64)entry->banned_until); sqlite3_bind_int64(stmt, 5, (sqlite3_int64)entry->first_failure); sqlite3_bind_int(stmt, 6, entry->has_authed_successfully); sqlite3_bind_int64(stmt, 7, (sqlite3_int64)entry->last_success_at); sqlite3_bind_int(stmt, 8, entry->total_connections); sqlite3_bind_int(stmt, 9, entry->total_failures); sqlite3_bind_int(stmt, 10, entry->total_successes); sqlite3_bind_int64(stmt, 11, (sqlite3_int64)entry->first_seen); // Idle tracking fields sqlite3_bind_int(stmt, 12, entry->idle_failure_count); sqlite3_bind_int(stmt, 13, entry->idle_ban_count); sqlite3_bind_int64(stmt, 14, (sqlite3_int64)entry->idle_banned_until); sqlite3_bind_int64(stmt, 15, (sqlite3_int64)entry->idle_first_failure); if (sqlite3_step(stmt) != SQLITE_DONE) { DEBUG_WARN("Failed to save ip_ban entry for %s", entry->ip); } else { saved++; } } pthread_mutex_unlock(&g_ban_mutex); sqlite3_finalize(stmt); sqlite3_exec(db, "COMMIT", NULL, NULL, NULL); DEBUG_TRACE("IP ban table saved: %d entries written to DB", saved); } // Check if an IP is in the idle_ban_whitelist config (comma-separated list) static int ip_is_whitelisted(const char* ip) { const char* whitelist = get_config_value("idle_ban_whitelist"); if (!whitelist || whitelist[0] == '\0') return 0; // Make a mutable copy to tokenize char buf[1024]; strncpy(buf, whitelist, sizeof(buf) - 1); buf[sizeof(buf) - 1] = '\0'; char* token = strtok(buf, ","); while (token) { // Trim leading/trailing spaces while (*token == ' ') token++; char* end = token + strlen(token) - 1; while (end > token && *end == ' ') { *end = '\0'; end--; } if (strcmp(token, ip) == 0) return 1; token = strtok(NULL, ","); } return 0; } int ip_ban_is_banned(const char* ip) { if (!ip || !g_initialized) return 0; // Whitelisted IPs are never banned if (ip_is_whitelisted(ip)) return 0; pthread_mutex_lock(&g_ban_mutex); int idx = find_slot(ip); if (idx < 0 || g_ban_table[idx].state == IP_BAN_EMPTY) { pthread_mutex_unlock(&g_ban_mutex); return 0; } ip_ban_entry_t* entry = &g_ban_table[idx]; time_t now = time(NULL); int banned = 0; // Check auth ban (if enabled) if (get_config_bool("auth_fail_ban_enabled", 1) && entry->banned_until > 0 && now < entry->banned_until) { banned = 1; } // Check idle ban (if enabled) if (get_config_bool("idle_ban_enabled", 1) && entry->idle_banned_until > 0 && now < entry->idle_banned_until) { banned = 1; } // Clear expired bans if (!banned) { if (entry->banned_until > 0 && now >= entry->banned_until) { entry->banned_until = 0; entry->failure_count = 0; entry->first_failure = 0; } if (entry->idle_banned_until > 0 && now >= entry->idle_banned_until) { entry->idle_banned_until = 0; entry->idle_failure_count = 0; entry->idle_first_failure = 0; } } pthread_mutex_unlock(&g_ban_mutex); return banned; } void ip_ban_record_connection(const char* ip) { if (!ip || !g_initialized) return; pthread_mutex_lock(&g_ban_mutex); ip_ban_entry_t* entry = get_or_create_entry(ip); if (entry) { entry->total_connections++; } pthread_mutex_unlock(&g_ban_mutex); } void ip_ban_record_failure(const char* ip) { if (!ip || !g_initialized) return; if (!get_config_bool("auth_fail_ban_enabled", 1)) return; int threshold = get_config_int("auth_fail_ban_threshold", 3); int window_sec = get_config_int("auth_fail_window_sec", 60); int ban_duration = get_config_int("auth_fail_ban_duration_sec", 300); pthread_mutex_lock(&g_ban_mutex); ip_ban_entry_t* entry = get_or_create_entry(ip); if (!entry) { pthread_mutex_unlock(&g_ban_mutex); return; } time_t now = time(NULL); // Reset window if expired if (entry->first_failure > 0 && (now - entry->first_failure) > window_sec) { entry->failure_count = 0; entry->first_failure = now; } if (entry->first_failure == 0) { entry->first_failure = now; } entry->failure_count++; entry->total_failures++; DEBUG_TRACE("IP %s auth failure count: %d/%d", ip, entry->failure_count, threshold); if (entry->failure_count >= threshold) { int duration = ban_duration; for (int i = 0; i < entry->ban_count && duration < 86400; i++) { duration *= 2; } if (duration > 86400) duration = 86400; entry->banned_until = now + duration; entry->ban_count++; entry->failure_count = 0; entry->first_failure = 0; DEBUG_WARN("IP %s banned for %d seconds (ban #%d) after %d auth failures", ip, duration, entry->ban_count, threshold); } pthread_mutex_unlock(&g_ban_mutex); } // Record an idle/early-disconnect failure for an IP void ip_ban_record_idle_failure(const char* ip) { if (!ip || !g_initialized) return; if (!get_config_bool("idle_ban_enabled", 1)) return; if (ip_is_whitelisted(ip)) return; // Never record idle failures for whitelisted IPs int threshold = get_config_int("idle_ban_threshold", 1); int window_sec = get_config_int("idle_ban_window_sec", 30); int ban_duration = get_config_int("idle_ban_duration_sec", 300); pthread_mutex_lock(&g_ban_mutex); ip_ban_entry_t* entry = get_or_create_entry(ip); if (!entry) { pthread_mutex_unlock(&g_ban_mutex); return; } time_t now = time(NULL); // Reset window if expired if (entry->idle_first_failure > 0 && (now - entry->idle_first_failure) > window_sec) { entry->idle_failure_count = 0; entry->idle_first_failure = now; } if (entry->idle_first_failure == 0) { entry->idle_first_failure = now; } entry->idle_failure_count++; entry->total_failures++; DEBUG_TRACE("IP %s idle failure count: %d/%d", ip, entry->idle_failure_count, threshold); if (entry->idle_failure_count >= threshold) { int duration = ban_duration; for (int i = 0; i < entry->idle_ban_count && duration < 86400; i++) { duration *= 2; } if (duration > 86400) duration = 86400; entry->idle_banned_until = now + duration; entry->idle_ban_count++; entry->idle_failure_count = 0; entry->idle_first_failure = 0; DEBUG_WARN("IP %s banned for %d seconds (idle ban #%d) after %d idle failures", ip, duration, entry->idle_ban_count, threshold); } pthread_mutex_unlock(&g_ban_mutex); } void ip_ban_record_success(const char* ip) { if (!ip || !g_initialized) return; pthread_mutex_lock(&g_ban_mutex); ip_ban_entry_t* entry = get_or_create_entry(ip); if (entry) { entry->failure_count = 0; entry->first_failure = 0; entry->has_authed_successfully = 1; entry->last_success_at = time(NULL); entry->total_successes++; DEBUG_TRACE("IP %s authenticated successfully — failure count cleared", ip); } pthread_mutex_unlock(&g_ban_mutex); } void ip_ban_cleanup(void) { if (!g_initialized) return; pthread_mutex_lock(&g_ban_mutex); time_t now = time(NULL); int window_sec = get_config_int("auth_fail_window_sec", 60); int idle_window_sec = get_config_int("idle_ban_window_sec", 60); int cleaned = 0; for (int i = 0; i < IP_BAN_TABLE_SIZE; i++) { if (g_ban_table[i].state != IP_BAN_ACTIVE) continue; ip_ban_entry_t* entry = &g_ban_table[i]; // Check auth failure window expiration int auth_ban_expired = (entry->banned_until == 0 || now >= entry->banned_until); int auth_window_expired = (entry->first_failure == 0 || (now - entry->first_failure) > window_sec * 10); // Check idle failure window expiration int idle_ban_expired = (entry->idle_banned_until == 0 || now >= entry->idle_banned_until); int idle_window_expired = (entry->idle_first_failure == 0 || (now - entry->idle_first_failure) > idle_window_sec * 10); if (auth_ban_expired && auth_window_expired && entry->failure_count == 0 && idle_ban_expired && idle_window_expired && entry->idle_failure_count == 0) { int retain_sec = 86400; // 24 hours int last_auth_ban_expired_long_ago = (entry->banned_until == 0 || (now - entry->banned_until) > retain_sec); int last_idle_ban_expired_long_ago = (entry->idle_banned_until == 0 || (now - entry->idle_banned_until) > retain_sec); if (last_auth_ban_expired_long_ago && last_idle_ban_expired_long_ago && !entry->has_authed_successfully && entry->ban_count == 0 && entry->idle_ban_count == 0 && entry->total_connections <= 1) { // Fully clean — never banned, never authenticated, only seen once memset(entry, 0, sizeof(ip_ban_entry_t)); cleaned++; } else { // Keep entry permanently — preserve ban_count for escalation. // An IP that has been banned before will always get at least a 24-hour ban // if it fails auth again, regardless of how long it has been away. entry->failure_count = 0; entry->first_failure = 0; entry->idle_failure_count = 0; entry->idle_first_failure = 0; if (last_auth_ban_expired_long_ago) { entry->banned_until = 0; // ban_count intentionally NOT reset — permanent escalation } if (last_idle_ban_expired_long_ago) { entry->idle_banned_until = 0; // idle_ban_count intentionally NOT reset — permanent escalation } } } } if (cleaned > 0) { DEBUG_TRACE("IP ban cleanup: freed %d stale entries", cleaned); } pthread_mutex_unlock(&g_ban_mutex); } int ip_ban_get_banned_count(void) { if (!g_initialized) return 0; pthread_mutex_lock(&g_ban_mutex); time_t now = time(NULL); int count = 0; for (int i = 0; i < IP_BAN_TABLE_SIZE; i++) { if (g_ban_table[i].state != IP_BAN_ACTIVE) continue; // Count if either auth banned or idle banned if (g_ban_table[i].banned_until > now || g_ban_table[i].idle_banned_until > now) { count++; } } pthread_mutex_unlock(&g_ban_mutex); return count; } int ip_ban_get_tracked_count(void) { if (!g_initialized) return 0; pthread_mutex_lock(&g_ban_mutex); int count = 0; for (int i = 0; i < IP_BAN_TABLE_SIZE; i++) { if (g_ban_table[i].state == IP_BAN_ACTIVE) count++; } pthread_mutex_unlock(&g_ban_mutex); return count; } void ip_ban_log_stats(sqlite3* db) { if (!g_initialized) return; static time_t last_log = 0; time_t now = time(NULL); if (now - last_log < 300) return; last_log = now; // Save to DB every 5 minutes if (db) { ip_ban_save_to_db(db); } pthread_mutex_lock(&g_ban_mutex); int auth_banned_count = 0; int idle_banned_count = 0; int tracked_count = 0; int trusted_count = 0; for (int i = 0; i < IP_BAN_TABLE_SIZE; i++) { if (g_ban_table[i].state != IP_BAN_ACTIVE) continue; tracked_count++; if (g_ban_table[i].banned_until > now) auth_banned_count++; if (g_ban_table[i].idle_banned_until > now) idle_banned_count++; if (g_ban_table[i].has_authed_successfully) trusted_count++; } pthread_mutex_unlock(&g_ban_mutex); int total_banned = auth_banned_count + idle_banned_count; if (total_banned > 0 || tracked_count > 0) { DEBUG_WARN("IP BAN SUMMARY: %d auth-banned, %d idle-banned, %d tracked, %d trusted (ever authed)", auth_banned_count, idle_banned_count, tracked_count, trusted_count); } }