Compare commits

...
12 Commits
Author SHA1 Message Date
Your Name c96736fa6a v1.2.27 - Permanent ban escalation: ban_count never resets, IPs with history always get 24h ban on next failure 2026-02-23 18:18:55 -04:00
Your Name f8ec4ae924 v1.2.26 - Persistent IP ban table: save/load to ip_bans DB, auth success tracking, lifetime stats per IP 2026-02-23 18:16:14 -04:00
Your Name fe7304ac7f v1.2.25 - Add NIP-42 AUTH support to web UI: onauth callback in subscribeMany and publish calls 2026-02-23 18:03:29 -04:00
Your Name e5d39c984b v1.2.24 - Fix: run ip_ban maintenance unconditionally every 60s regardless of max_connection_seconds setting 2026-02-23 17:57:20 -04:00
Your Name 5e45f21e35 v1.2.23 - ip_ban: retain ban_count for 24 hours after last ban expiry, then fully clean entry 2026-02-23 17:16:02 -04:00
Your Name 5321a238b8 v1.2.22 - Fix ip_ban cleanup: preserve ban_count on cleanup so exponential backoff persists across ban expiry 2026-02-23 17:12:15 -04:00
Your Name 083bc14972 v1.2.21 - Fix IP ban check: use pss->client_ip (proxy-aware) instead of raw socket IP to match failure recording 2026-02-23 16:26:23 -04:00
Your Name 11aaccba9b v1.2.20 - Add IP auth failure ban system: track failures per IP, ban after threshold with exponential backoff, periodic log stats 2026-02-23 16:02:07 -04:00
Your Name bd1bbd763d v1.2.19 - Add IP auth failure ban system: track failures per IP, ban after threshold with exponential backoff, periodic log stats 2026-02-23 16:00:53 -04:00
Your Name 2bd7aa5a10 v1.2.18 - Proactive auth timeout via lws_set_timeout: close idle unauthenticated connections after nip42_auth_timeout_sec even without REQ 2026-02-23 15:37:45 -04:00
Your Name 361912ec85 v1.2.17 - Add nip42_auth_timeout_sec (default 10s): close unauthenticated connections after timeout to prevent connection accumulation 2026-02-23 15:22:18 -04:00
Your Name 0de491382e v1.2.16 - Fix auth rules UI: change label/placeholder/errors from nsec to npub (public key, not private key) 2026-02-23 14:36:19 -04:00
14 changed files with 675 additions and 18 deletions
+1 -1
View File
@@ -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 \
+1 -1
View File
@@ -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
+2 -2
View File
@@ -288,8 +288,8 @@ AUTH RULES MANAGEMENT
<!-- Auth Rule Input Section -->
<div id="authRuleInputSections" style="display: block;">
<div class="input-group">
<label for="authRulePubkey">Pubkey (nsec or hex):</label>
<input type="text" id="authRulePubkey" placeholder="nsec1... or 64-character hex pubkey">
<label for="authRulePubkey">Public Key (npub or hex):</label>
<input type="text" id="authRulePubkey" placeholder="npub1... or 64-character hex pubkey">
</div>
<div id="whitelistWarning" class="warning-box" style="display: none;">
<strong>⚠️ WARNING:</strong> Adding whitelist rules changes relay behavior to whitelist-only
+33 -4
View File
@@ -1338,6 +1338,23 @@ async function subscribeToConfiguration() {
console.log('No configuration events were received');
}
},
async onauth(challenge) {
// NIP-42 authentication: relay sent AUTH challenge, sign and respond
console.log('🔐 NIP-42 AUTH challenge received:', challenge);
try {
if (!window.nostr) {
console.error('NIP-42 auth required but no nostr extension available');
return;
}
const url = relayConnectionUrl.value.trim();
const authEvent = window.NostrTools.nip42.makeAuthEvent(url, challenge);
const signedAuthEvent = await window.nostr.signEvent(authEvent);
console.log('✅ NIP-42 AUTH event signed, responding to challenge');
return signedAuthEvent;
} catch (authError) {
console.error('NIP-42 AUTH failed:', authError.message);
}
},
onclose(reason) {
console.log('Subscription closed:', reason);
// Reset subscription state to allow re-subscription
@@ -3168,7 +3185,7 @@ function addBlacklistRule() {
// Convert nsec or npub to hex if needed
const hexPubkey = nsecToHex(inputValue);
if (!hexPubkey) {
log('Invalid pubkey format. Please enter nsec1..., npub1..., or 64-character hex', 'ERROR');
log('Invalid public key format. Please enter npub1... or 64-character hex pubkey', 'ERROR');
return;
}
@@ -3220,7 +3237,7 @@ function addWhitelistRule() {
// Convert nsec or npub to hex if needed
const hexPubkey = nsecToHex(inputValue);
if (!hexPubkey) {
log('Invalid pubkey format. Please enter nsec1..., npub1..., or 64-character hex', 'ERROR');
log('Invalid public key format. Please enter npub1... or 64-character hex pubkey', 'ERROR');
return;
}
@@ -5467,9 +5484,21 @@ async function sendAdminCommand(commandArray) {
throw new Error('Event signing failed');
}
// Publish via SimplePool with detailed error diagnostics
// Publish via SimplePool with NIP-42 auth support
const url = relayConnectionUrl.value.trim();
const publishPromises = relayPool.publish([url], signedEvent);
const publishPromises = relayPool.publish([url], signedEvent, {
async onauth(challenge) {
console.log('🔐 NIP-42 AUTH challenge during publish:', challenge);
try {
const authEvent = window.NostrTools.nip42.makeAuthEvent(url, challenge);
const signedAuthEvent = await window.nostr.signEvent(authEvent);
console.log('✅ NIP-42 AUTH signed for publish');
return signedAuthEvent;
} catch (authError) {
console.error('NIP-42 AUTH failed during publish:', authError.message);
}
}
});
// Use Promise.allSettled to capture per-relay outcomes
const results = await Promise.allSettled(publishPromises);
+1 -1
View File
@@ -1 +1 @@
1950899
2039374
+27
View File
@@ -952,6 +952,33 @@ 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) {
snprintf(error_msg, error_size, "invalid nip42_auth_timeout_sec '%s' (must be non-negative integer)", value);
return -1;
}
return 0;
}
// SQLite performance tuning
if (strcmp(key, "sqlite_mmap_size") == 0) {
if (!is_valid_positive_integer(value) && strcmp(value, "0") != 0) {
+12
View File
@@ -83,6 +83,18 @@ 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
{"nip42_auth_timeout_sec", "10"},
// SQLite Performance Tuning
// mmap_size: bytes of database file to memory-map (0 = disabled, 268435456 = 256MB recommended)
// Eliminates pread64 syscall overhead for database reads — significant CPU savings under load
File diff suppressed because one or more lines are too long
+435
View File
@@ -0,0 +1,435 @@
#define _GNU_SOURCE
#include "ip_ban.h"
#include "debug.h"
#include "config.h"
#include <string.h>
#include <stdlib.h>
#include <pthread.h>
// ============================================================
// 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
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];
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;
}
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;
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;
}
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;
}
// 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;
entry->first_failure = 0;
}
pthread_mutex_unlock(&g_ban_mutex);
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;
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);
}
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 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];
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) {
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 && !entry->has_authed_successfully &&
entry->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;
if (last_ban_expired_long_ago) {
entry->banned_until = 0;
// 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 &&
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;
}
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 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++;
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 banned, %d tracked, %d trusted (ever authed)",
banned_count, tracked_count, trusted_count);
}
}
+61
View File
@@ -0,0 +1,61 @@
#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 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)
// 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 <time.h>
#include <sqlite3.h>
// 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, 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);
// 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.
// 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);
#endif // IP_BAN_H
+8
View File
@@ -162,6 +162,10 @@ 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 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)
void init_pow_config();
@@ -2165,6 +2169,10 @@ int main(int argc, char* argv[]) {
// Cleanup orphaned subscriptions from previous runs
cleanup_all_subscriptions_on_startup();
// 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
+2 -2
View File
@@ -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 15
#define CRELAY_VERSION "v1.2.15"
#define CRELAY_VERSION_PATCH 27
#define CRELAY_VERSION "v1.2.27"
// Relay metadata (authoritative source for NIP-11 information)
#define RELAY_NAME "C-Relay"
+7
View File
@@ -13,6 +13,7 @@
#include <stdlib.h>
#include <time.h>
#include "websockets.h"
#include "ip_ban.h"
// Forward declaration for notice message function
@@ -126,6 +127,12 @@ void handle_nip42_auth_signed_event(struct lws* wsi, struct per_session_data* ps
pss->challenge_expires = 0;
pss->auth_challenge_sent = 0;
pthread_mutex_unlock(&pss->session_lock);
// 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 {
+81 -3
View File
@@ -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
@@ -545,6 +546,31 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso
memset(pss->active_challenge, 0, sizeof(pss->active_challenge));
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).
if (ip_ban_is_banned(pss->client_ip)) {
DEBUG_LOG("Rejecting banned IP %s at connection establishment", pss->client_ip);
return -1; // Close connection immediately — no challenge, no processing
}
// Set libwebsockets auth timeout: if NIP-42 auth is required and the client
// doesn't authenticate within nip42_auth_timeout_sec seconds, lws will close
// the connection automatically — even if the client never sends a message.
// This prevents idle unauthenticated connections from accumulating.
if (pss->nip42_auth_required_events || pss->nip42_auth_required_subscriptions) {
int auth_timeout = get_config_int("nip42_auth_timeout_sec", 10);
if (auth_timeout > 0) {
lws_set_timeout(wsi, PENDING_TIMEOUT_AWAITING_PING, auth_timeout);
DEBUG_TRACE("Auth timeout set: %d seconds for unauthenticated connection from %s",
auth_timeout, pss->client_ip);
}
}
DEBUG_TRACE("WebSocket connection initialization complete");
break;
@@ -1011,6 +1037,21 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso
} else {
send_notice_message(wsi, pss, "NIP-42 authentication required for subscriptions");
DEBUG_WARN("REQ rejected: NIP-42 authentication required");
// Auth timeout: close connection if challenge was sent but client
// hasn't authenticated within nip42_auth_timeout_sec seconds
int auth_timeout = get_config_int("nip42_auth_timeout_sec", 10);
if (auth_timeout > 0 && pss->connection_established > 0) {
time_t connection_age = time(NULL) - pss->connection_established;
if (connection_age >= auth_timeout) {
DEBUG_LOG("Closing unauthenticated connection from %s after %ld seconds (timeout=%d)",
pss->client_ip, connection_age, auth_timeout);
lws_close_reason(wsi, LWS_CLOSE_STATUS_POLICY_VIOLATION,
(unsigned char*)"Authentication timeout", 22);
cJSON_Delete(json);
return -1;
}
}
}
cJSON_Delete(json);
// Note: complete_message points to reassembly_buffer, which is managed separately
@@ -1768,6 +1809,22 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso
} else {
send_notice_message(wsi, pss, "NIP-42 authentication required for subscriptions");
DEBUG_WARN("REQ rejected: NIP-42 authentication required");
// Auth timeout: close connection if challenge was sent but client
// hasn't authenticated within nip42_auth_timeout_sec seconds
int auth_timeout = get_config_int("nip42_auth_timeout_sec", 10);
if (auth_timeout > 0 && pss->connection_established > 0) {
time_t connection_age = time(NULL) - pss->connection_established;
if (connection_age >= auth_timeout) {
DEBUG_LOG("Closing unauthenticated connection from %s after %ld seconds (timeout=%d)",
pss->client_ip, connection_age, auth_timeout);
lws_close_reason(wsi, LWS_CLOSE_STATUS_POLICY_VIOLATION,
(unsigned char*)"Authentication timeout", 22);
cJSON_Delete(json);
free(message);
return -1;
}
}
}
cJSON_Delete(json);
free(message);
@@ -2060,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];
@@ -2226,6 +2292,11 @@ static void check_connection_age(int max_connection_seconds) {
// Cleanup
free(checked_wsis);
// Periodic IP ban maintenance: cleanup expired entries, log stats, save to DB
ip_ban_cleanup();
extern sqlite3* g_db;
ip_ban_log_stats(g_db);
}
// WebSocket protocol definition
@@ -2422,11 +2493,18 @@ int start_websocket_relay(int port_override, int strict_port) {
}
}
// Check connection age limits (every 60 seconds)
// Check connection age limits and run IP ban maintenance (every 60 seconds)
int max_connection_seconds = get_config_int("max_connection_seconds", 86400);
if (max_connection_seconds > 0 && (current_time - last_connection_age_check >= 60)) {
if (current_time - last_connection_age_check >= 60) {
last_connection_age_check = current_time;
check_connection_age(max_connection_seconds);
if (max_connection_seconds > 0) {
check_connection_age(max_connection_seconds);
} else {
// Even when connection age limit is disabled, run IP ban maintenance
ip_ban_cleanup();
extern sqlite3* g_db;
ip_ban_log_stats(g_db);
}
}
}