diff --git a/Dockerfile.alpine-musl b/Dockerfile.alpine-musl index 6f189da..62725ee 100644 --- a/Dockerfile.alpine-musl +++ b/Dockerfile.alpine-musl @@ -121,7 +121,7 @@ RUN if [ "$DEBUG_BUILD" = "true" ]; then \ -Inostr_core_lib/cjson -Inostr_core_lib/nostr_websocket \ src/main.c src/config.c src/dm_admin.c src/request_validator.c \ src/nip009.c src/nip011.c src/nip013.c src/nip040.c src/nip042.c \ - src/websockets.c src/subscriptions.c src/api.c src/embedded_web_content.c \ + src/websockets.c src/subscriptions.c src/api.c src/embedded_web_content.c src/ip_ban.c \ -o /build/c_relay_static \ c_utils_lib/libc_utils.a \ nostr_core_lib/libnostr_core_x64.a \ diff --git a/Makefile b/Makefile index cc2be5f..8132f46 100644 --- a/Makefile +++ b/Makefile @@ -9,7 +9,7 @@ LIBS = -lsqlite3 -lwebsockets -lz -ldl -lpthread -lm -L/usr/local/lib -lsecp256k BUILD_DIR = build # Source files -MAIN_SRC = src/main.c src/config.c src/dm_admin.c src/request_validator.c src/nip009.c src/nip011.c src/nip013.c src/nip040.c src/nip042.c src/websockets.c src/subscriptions.c src/api.c src/embedded_web_content.c +MAIN_SRC = src/main.c src/config.c src/dm_admin.c src/request_validator.c src/nip009.c src/nip011.c src/nip013.c src/nip040.c src/nip042.c src/websockets.c src/subscriptions.c src/api.c src/embedded_web_content.c src/ip_ban.c NOSTR_CORE_LIB = nostr_core_lib/libnostr_core_x64.a C_UTILS_LIB = c_utils_lib/libc_utils.a diff --git a/src/config.c b/src/config.c index 0d03adb..1ca8170 100644 --- a/src/config.c +++ b/src/config.c @@ -952,6 +952,24 @@ static int validate_config_field(const char* key, const char* value, char* error return 0; } + // IP auth failure ban settings + if (strcmp(key, "auth_fail_ban_enabled") == 0) { + if (!is_valid_boolean(value)) { + snprintf(error_msg, error_size, "invalid boolean value '%s' for auth_fail_ban_enabled", value); + return -1; + } + return 0; + } + if (strcmp(key, "auth_fail_ban_threshold") == 0 || + strcmp(key, "auth_fail_window_sec") == 0 || + strcmp(key, "auth_fail_ban_duration_sec") == 0) { + if (!is_valid_positive_integer(value)) { + snprintf(error_msg, error_size, "invalid value '%s' for %s (must be positive integer)", value, key); + return -1; + } + return 0; + } + // NIP-42 auth timeout if (strcmp(key, "nip42_auth_timeout_sec") == 0) { if (!is_valid_positive_integer(value) && strcmp(value, "0") != 0) { diff --git a/src/default_config_event.h b/src/default_config_event.h index c9c812b..0f5c373 100644 --- a/src/default_config_event.h +++ b/src/default_config_event.h @@ -83,6 +83,13 @@ static const struct { // IP-based rate limiting or access control (which would require firewall protection anyway) {"trust_proxy_headers", "true"}, + // IP Auth Failure Ban Settings + // Ban IPs that repeatedly fail NIP-42 authentication + {"auth_fail_ban_enabled", "true"}, + {"auth_fail_ban_threshold", "3"}, // failures before ban + {"auth_fail_window_sec", "60"}, // window to count failures in + {"auth_fail_ban_duration_sec", "300"}, // initial ban duration (doubles each time, max 24h) + // NIP-42 Authentication Timeout // Seconds after connection before unauthenticated clients are disconnected (0 = disabled) // Prevents unauthenticated connections from accumulating under heavy load diff --git a/src/ip_ban.c b/src/ip_ban.c new file mode 100644 index 0000000..fd6ced1 --- /dev/null +++ b/src/ip_ban.c @@ -0,0 +1,276 @@ +#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). +// ============================================================ + +#define IP_BAN_EMPTY 0 // slot is unused +#define IP_BAN_ACTIVE 1 // slot has an entry + +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) +} 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) +// 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 + } + if (strcmp(g_ban_table[idx].ip, ip) == 0) { + return (int)idx; // found existing entry + } + } + return -1; // table full (shouldn't happen with 4096 slots) +} + +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); +} + +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 + } + + ip_ban_entry_t* entry = &g_ban_table[idx]; + time_t now = time(NULL); + + 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 + } + + // Ban expired — clear it but keep the entry for failure tracking + if (entry->banned_until > 0 && now >= entry->banned_until) { + entry->banned_until = 0; + entry->failure_count = 0; + entry->first_failure = 0; + } + + pthread_mutex_unlock(&g_ban_mutex); + return 0; +} + +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); + + 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); + 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 + if (entry->first_failure > 0 && (now - entry->first_failure) > window_sec) { + entry->failure_count = 0; + entry->first_failure = now; + } + + entry->failure_count++; + + 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; + } + if (duration > 86400) duration = 86400; + + entry->banned_until = now + duration; + entry->ban_count++; + entry->failure_count = 0; // reset counter after ban + 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); +} + +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 + 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 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) { + memset(entry, 0, sizeof(ip_ban_entry_t)); + cleaned++; + } + } + + 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 && + g_ban_table[i].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; +} + +// 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) { + 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); + + int banned_count = 0; + int tracked_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); + } + } + + 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); + } +} diff --git a/src/ip_ban.h b/src/ip_ban.h new file mode 100644 index 0000000..3963f94 --- /dev/null +++ b/src/ip_ban.h @@ -0,0 +1,48 @@ +#ifndef IP_BAN_H +#define IP_BAN_H + +// IP Auth Failure Ban System +// +// 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. +// +// Config keys (all read from the config table at runtime): +// auth_fail_ban_enabled bool default: true (0 = disabled) +// auth_fail_ban_threshold int default: 3 (failures before ban) +// auth_fail_window_sec int default: 60 (window to count failures) +// auth_fail_ban_duration_sec int default: 300 (initial ban duration, doubles each time) + +#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) +void ip_ban_init(void); + +// 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); + +// Record an auth failure for an IP. +// Called when a connection is closed due to auth timeout. +// May trigger a ban if the threshold is exceeded. +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). +void ip_ban_record_success(const char* ip); + +// Periodic cleanup: expire old entries (call from the connection age checker). +void ip_ban_cleanup(void); + +// 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 bc8e687..ce3c395 100644 --- a/src/main.c +++ b/src/main.c @@ -162,6 +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 +void ip_ban_init(void); + // Forward declarations for NIP-13 PoW handling (now in nip013.c) void init_pow_config(); @@ -2165,6 +2168,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 + ip_ban_init(); + // 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 83cb01d..4da4ee7 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 18 -#define CRELAY_VERSION "v1.2.18" +#define CRELAY_VERSION_PATCH 19 +#define CRELAY_VERSION "v1.2.19" // Relay metadata (authoritative source for NIP-11 information) #define RELAY_NAME "C-Relay" diff --git a/src/websockets.c b/src/websockets.c index 2f6103f..75ef6c9 100644 --- a/src/websockets.c +++ b/src/websockets.c @@ -30,6 +30,7 @@ #include "embedded_web_content.h" // Embedded web content #include "api.h" // API for embedded files #include "dm_admin.h" // DM admin functions including NIP-17 +#include "ip_ban.h" // IP auth failure ban system // Forward declarations for logging functions @@ -462,6 +463,17 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso case LWS_CALLBACK_ESTABLISHED: DEBUG_TRACE("WebSocket connection established"); + + // Check IP ban before doing any work — reject banned IPs immediately + { + char check_ip[CLIENT_IP_MAX_LENGTH] = {0}; + const char* fwd = lws_get_peer_simple(wsi, check_ip, sizeof(check_ip)); + (void)fwd; + if (ip_ban_is_banned(check_ip)) { + DEBUG_LOG("Rejecting banned IP %s at connection establishment", check_ip); + return -1; // Close connection immediately + } + } memset(pss, 0, sizeof(*pss)); pthread_mutex_init(&pss->session_lock, NULL); @@ -2105,6 +2117,15 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso if (g_shutdown_flag || !g_server_running) { reason = "server_shutdown"; } + + // Record auth failure if connection closed while unauthenticated and auth was required. + // This covers both the lws_set_timeout path (idle bots) and the reactive REQ path. + if (!pss->authenticated && + (pss->nip42_auth_required_events || pss->nip42_auth_required_subscriptions) && + pss->auth_challenge_sent && + strlen(pss->client_ip) > 0) { + ip_ban_record_failure(pss->client_ip); + } // Format authentication status char auth_status[80]; @@ -2271,6 +2292,10 @@ static void check_connection_age(int max_connection_seconds) { // Cleanup free(checked_wsis); + + // Periodic IP ban maintenance: cleanup expired entries and log stats + ip_ban_cleanup(); + ip_ban_log_stats(); } // WebSocket protocol definition