diff --git a/src/ip_ban.c b/src/ip_ban.c index 90acc65..77960c5 100644 --- a/src/ip_ban.c +++ b/src/ip_ban.c @@ -12,18 +12,27 @@ // 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 // slot is unused -#define IP_BAN_ACTIVE 1 // slot has an entry +#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 - int failure_count; // total failures in current window - time_t first_failure; // start of current failure window - time_t banned_until; // 0 = not banned; >0 = banned until this time - int ban_count; // how many times this IP has been banned (for backoff) + int state; // IP_BAN_EMPTY or IP_BAN_ACTIVE + char ip[46]; // IPv4 or IPv6 string + 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) + 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]; @@ -41,19 +50,36 @@ static unsigned int ip_hash(const char* ip) { } // Find slot for IP (open addressing with linear probing) -// Returns index of existing entry or first empty slot, -1 if table full 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; // empty slot — can insert here + return (int)idx; } if (strcmp(g_ban_table[idx].ip, ip) == 0) { - return (int)idx; // found existing entry + return (int)idx; } } - return -1; // table full (shouldn't happen with 4096 slots) + 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) { @@ -64,18 +90,147 @@ void ip_ban_init(void) { 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; + } + + 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" + " 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); + 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)" + " 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); + + 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); +} + int ip_ban_is_banned(const char* ip) { if (!ip || !g_initialized) return 0; - - // Check if feature is enabled if (!get_config_bool("auth_fail_ban_enabled", 1)) 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; // no entry — not banned + return 0; } ip_ban_entry_t* entry = &g_ban_table[idx]; @@ -84,10 +239,10 @@ int ip_ban_is_banned(const char* ip) { if (entry->banned_until > 0 && now < entry->banned_until) { pthread_mutex_unlock(&g_ban_mutex); DEBUG_TRACE("IP %s is banned for %ld more seconds", ip, entry->banned_until - now); - return 1; // still banned + return 1; } - // Ban expired — clear it but keep the entry for failure tracking + // Ban expired — clear it but keep the entry if (entry->banned_until > 0 && now >= entry->banned_until) { entry->banned_until = 0; entry->failure_count = 0; @@ -98,6 +253,17 @@ int ip_ban_is_banned(const char* ip) { return 0; } +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; @@ -107,41 +273,29 @@ void ip_ban_record_failure(const char* ip) { int ban_duration = get_config_int("auth_fail_ban_duration_sec", 300); pthread_mutex_lock(&g_ban_mutex); - - int idx = find_slot(ip); - if (idx < 0) { - // Table full — can't track this IP, just log and return - DEBUG_WARN("IP ban table full, cannot track %s", ip); + ip_ban_entry_t* entry = get_or_create_entry(ip); + if (!entry) { pthread_mutex_unlock(&g_ban_mutex); return; } - ip_ban_entry_t* entry = &g_ban_table[idx]; time_t now = time(NULL); - if (entry->state == IP_BAN_EMPTY) { - // New entry - entry->state = IP_BAN_ACTIVE; - strncpy(entry->ip, ip, sizeof(entry->ip) - 1); - entry->ip[sizeof(entry->ip) - 1] = '\0'; - entry->failure_count = 0; - entry->first_failure = now; - entry->banned_until = 0; - entry->ban_count = 0; - } - - // Reset window if it's expired + // 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) { - // Apply exponential backoff: ban_duration * 2^ban_count, capped at 24 hours int duration = ban_duration; for (int i = 0; i < entry->ban_count && duration < 86400; i++) { duration *= 2; @@ -150,7 +304,7 @@ void ip_ban_record_failure(const char* ip) { entry->banned_until = now + duration; entry->ban_count++; - entry->failure_count = 0; // reset counter after ban + entry->failure_count = 0; entry->first_failure = 0; DEBUG_WARN("IP %s banned for %d seconds (ban #%d) after %d auth failures", @@ -164,16 +318,15 @@ void ip_ban_record_success(const char* ip) { if (!ip || !g_initialized) return; pthread_mutex_lock(&g_ban_mutex); - - int idx = find_slot(ip); - if (idx >= 0 && g_ban_table[idx].state == IP_BAN_ACTIVE) { - // Clear failure count on successful auth — reward good behavior - g_ban_table[idx].failure_count = 0; - g_ban_table[idx].first_failure = 0; - // Note: we keep ban_count so backoff persists across sessions + 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); } @@ -181,37 +334,35 @@ 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 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]; - // Clear entries where ban has expired AND failure window has expired AND no recent activity int ban_expired = (entry->banned_until == 0 || now >= entry->banned_until); int window_expired = (entry->first_failure == 0 || (now - entry->first_failure) > window_sec * 10); if (ban_expired && window_expired && entry->failure_count == 0) { - // Retain ban_count for 24 hours after the last ban expired. - // This ensures exponential backoff persists if the IP returns within 24 hours. - // After 24 hours of inactivity, fully clean the entry. int retain_sec = 86400; // 24 hours int last_ban_expired_long_ago = (entry->banned_until == 0 || (now - entry->banned_until) > retain_sec); - if (last_ban_expired_long_ago) { - // Fully clean — IP has been gone for 24+ hours, start fresh if it returns + if (last_ban_expired_long_ago && !entry->has_authed_successfully) { + // Fully clean — no ban history and never authenticated memset(entry, 0, sizeof(ip_ban_entry_t)); cleaned++; } else { - // Keep entry alive but reset transient fields — preserve ban_count + // Keep entry — either has ban history or has authenticated before entry->failure_count = 0; entry->first_failure = 0; - // banned_until and ban_count preserved intentionally + if (last_ban_expired_long_ago) { + entry->banned_until = 0; + // Reset ban_count after 24 hours of inactivity + entry->ban_count = 0; + } } } } @@ -219,13 +370,11 @@ void ip_ban_cleanup(void) { 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; @@ -241,7 +390,6 @@ int ip_ban_get_banned_count(void) { 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++) { @@ -251,41 +399,35 @@ int ip_ban_get_tracked_count(void) { return count; } -// Emit a periodic log summary of banned IPs. -// Call from the connection age checker (runs every ~30s). -// Only logs when there are active bans or when the interval has elapsed. -void ip_ban_log_stats(void) { +void ip_ban_log_stats(sqlite3* db) { if (!g_initialized) return; static time_t last_log = 0; time_t now = time(NULL); - // Log every 5 minutes if (now - last_log < 300) return; last_log = now; - pthread_mutex_lock(&g_ban_mutex); + // Save to DB every 5 minutes + if (db) { + ip_ban_save_to_db(db); + } + pthread_mutex_lock(&g_ban_mutex); int 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) { - banned_count++; - DEBUG_WARN("IP BAN: %s banned for %ld more seconds (ban #%d, failures: %d)", - g_ban_table[i].ip, - g_ban_table[i].banned_until - now, - g_ban_table[i].ban_count, - g_ban_table[i].failure_count); - } + if (g_ban_table[i].banned_until > now) banned_count++; + if (g_ban_table[i].has_authed_successfully) trusted_count++; } - pthread_mutex_unlock(&g_ban_mutex); if (banned_count > 0 || tracked_count > 0) { - DEBUG_WARN("IP BAN SUMMARY: %d IPs currently banned, %d IPs tracked", - banned_count, tracked_count); + DEBUG_WARN("IP BAN SUMMARY: %d banned, %d tracked, %d trusted (ever authed)", + banned_count, tracked_count, trusted_count); } } diff --git a/src/ip_ban.h b/src/ip_ban.h index 3963f94..8a4d120 100644 --- a/src/ip_ban.h +++ b/src/ip_ban.h @@ -5,7 +5,8 @@ // // Tracks auth failures per IP address and temporarily bans IPs that repeatedly // fail NIP-42 authentication. Uses an in-memory fixed-size hash table — no -// database writes, no root required, no persistent state. +// database writes on the hot path. State is persisted to the ip_bans table +// every 5 minutes and loaded at startup. // // Config keys (all read from the config table at runtime): // auth_fail_ban_enabled bool default: true (0 = disabled) @@ -14,13 +15,22 @@ // auth_fail_ban_duration_sec int default: 300 (initial ban duration, doubles each time) #include +#include // Maximum number of IPs tracked simultaneously (fixed-size, no malloc) #define IP_BAN_TABLE_SIZE 4096 -// Initialize the IP ban table (call once at startup) +// Initialize the IP ban table (call once at startup, before loading from DB) void ip_ban_init(void); +// Load ban state from the ip_bans database table. +// Call after ip_ban_init() and after the database is open. +void ip_ban_load_from_db(sqlite3* db); + +// Save current ban state to the ip_bans database table. +// Called every 5 minutes from the maintenance timer. +void ip_ban_save_to_db(sqlite3* db); + // Check if an IP is currently banned. // Returns 1 if banned (connection should be rejected), 0 if allowed. int ip_ban_is_banned(const char* ip); @@ -31,18 +41,21 @@ int ip_ban_is_banned(const char* ip); void ip_ban_record_failure(const char* ip); // Record a successful auth for an IP. -// Clears any failure count for this IP (reward good behavior). +// Sets has_authed_successfully=1 and clears failure count. void ip_ban_record_success(const char* ip); +// Record a new connection from an IP (increment total_connections). +void ip_ban_record_connection(const char* ip); + // Periodic cleanup: expire old entries (call from the connection age checker). void ip_ban_cleanup(void); +// Emit a periodic WARN-level log summary of banned IPs (every 5 minutes). +// Also saves state to DB. +void ip_ban_log_stats(sqlite3* db); + // Get stats for logging/monitoring int ip_ban_get_banned_count(void); int ip_ban_get_tracked_count(void); -// Emit a periodic WARN-level log summary of banned IPs (every 5 minutes). -// Call from the connection age checker timer. -void ip_ban_log_stats(void); - #endif // IP_BAN_H diff --git a/src/main.c b/src/main.c index ce3c395..a4b6c25 100644 --- a/src/main.c +++ b/src/main.c @@ -162,8 +162,9 @@ int handle_nip11_http_request(struct lws* wsi, const char* accept_header); // Forward declaration for WebSocket relay server int start_websocket_relay(int port_override, int strict_port); -// Forward declaration for IP ban system +// Forward declarations for IP ban system void ip_ban_init(void); +void ip_ban_load_from_db(sqlite3* db); // Forward declarations for NIP-13 PoW handling (now in nip013.c) @@ -2168,8 +2169,9 @@ int main(int argc, char* argv[]) { // Cleanup orphaned subscriptions from previous runs cleanup_all_subscriptions_on_startup(); - // Initialize IP ban table before starting the relay + // Initialize IP ban table and load persisted state from database ip_ban_init(); + ip_ban_load_from_db(g_db); // Start WebSocket Nostr relay server (port from CLI override or configuration) int result = start_websocket_relay(cli_options.port_override, cli_options.strict_port); // Use CLI port override if specified, otherwise config diff --git a/src/main.h b/src/main.h index a26e114..cffe177 100644 --- a/src/main.h +++ b/src/main.h @@ -13,8 +13,8 @@ // Using CRELAY_ prefix to avoid conflicts with nostr_core_lib VERSION macros #define CRELAY_VERSION_MAJOR 1 #define CRELAY_VERSION_MINOR 2 -#define CRELAY_VERSION_PATCH 25 -#define CRELAY_VERSION "v1.2.25" +#define CRELAY_VERSION_PATCH 26 +#define CRELAY_VERSION "v1.2.26" // Relay metadata (authoritative source for NIP-11 information) #define RELAY_NAME "C-Relay" diff --git a/src/nip042.c b/src/nip042.c index 5041651..02f0f60 100644 --- a/src/nip042.c +++ b/src/nip042.c @@ -13,6 +13,7 @@ #include #include #include "websockets.h" +#include "ip_ban.h" // Forward declaration for notice message function @@ -129,6 +130,9 @@ void handle_nip42_auth_signed_event(struct lws* wsi, struct per_session_data* ps // Cancel the auth timeout — client has authenticated, keep connection open lws_set_timeout(wsi, NO_PENDING_TIMEOUT, 0); + + // Record successful auth for this IP — marks it as trusted + ip_ban_record_success(pss->client_ip); send_notice_message(wsi, pss, "NIP-42 authentication successful"); } else { diff --git a/src/websockets.c b/src/websockets.c index f12036d..9944ce6 100644 --- a/src/websockets.c +++ b/src/websockets.c @@ -547,6 +547,9 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso pss->challenge_created = 0; pss->challenge_expires = 0; + // Record connection for stats tracking + ip_ban_record_connection(pss->client_ip); + // Check IP ban using the resolved client IP (which may be from X-Forwarded-For). // This must happen AFTER pss->client_ip is populated so the same IP string // is used for both ban recording (at CLOSED) and ban checking (here). @@ -2290,9 +2293,10 @@ static void check_connection_age(int max_connection_seconds) { // Cleanup free(checked_wsis); - // Periodic IP ban maintenance: cleanup expired entries and log stats + // Periodic IP ban maintenance: cleanup expired entries, log stats, save to DB ip_ban_cleanup(); - ip_ban_log_stats(); + extern sqlite3* g_db; + ip_ban_log_stats(g_db); } // WebSocket protocol definition @@ -2498,7 +2502,8 @@ int start_websocket_relay(int port_override, int strict_port) { } else { // Even when connection age limit is disabled, run IP ban maintenance ip_ban_cleanup(); - ip_ban_log_stats(); + extern sqlite3* g_db; + ip_ban_log_stats(g_db); } } }