22 KiB
Plan: Ban Idle and Early-Disconnect Connections
Problem Statement
The relay needs to defend against spam connections that:
- Connect via WebSocket and sit idle doing nothing (the original problem)
- Connect and immediately disconnect without subscribing or posting (the new requirement)
Both patterns indicate bot/scanner behavior that should result in IP bans.
Goal
Implement a system that:
- Bans IPs that connect but never send a REQ or EVENT (regardless of how the connection ends)
- Works independent of NIP-42 authentication (no auth required)
- Allows legitimate users to connect and use the relay normally
- Uses existing IP ban infrastructure
Architecture Overview
flowchart TD
A[Client connects] --> B{IP banned?}
B -->|Yes| C[Reject immediately]
B -->|No| D[Set idle timeout timer]
D --> E{Client sends REQ or EVENT?}
E -->|Yes| F[Mark session ACTIVE<br>Cancel idle timer]
E -->|No| G{Connection closes?}
G --> H{Session was ACTIVE?}
H -->|Yes| I[Clean close - no ban]
H -->|No| J["ip_ban_record_failure()<br>→ may trigger ban"]
F --> K[Normal operation]
K --> L[Connection closes]
L --> I
Key Insight
The distinction between "idle timeout" and "early disconnect" is minimal:
- Idle timeout: Connection closed by server because client never became ACTIVE
- Early disconnect: Connection closed by client because client never became ACTIVE
Both result in the same check: if (!session_was_active) ban_ip()
Implementation Plan
1. Add Session Activity Tracking
File: src/websockets.h
Add to struct per_session_data:
// Session activity tracking for idle connection banning
int session_active; // 1 if client sent REQ or EVENT, 0 otherwise
int idle_timeout_sec; // Timeout value for this session (copied from config)
2. Set Idle Timeout on All Connections
File: src/websockets.c - LWS_CALLBACK_ESTABLISHED
Currently (lines 561-572):
// Only set timeout if auth is required
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);
}
}
Change to:
// Initialize session activity tracking
pss->session_active = 0;
pss->idle_timeout_sec = get_config_int("idle_connection_timeout_sec", 30);
// Set idle timeout for ALL connections (not just auth-required)
// This catches bots that connect and do nothing
if (pss->idle_timeout_sec > 0) {
lws_set_timeout(wsi, PENDING_TIMEOUT_AWAITING_PING, pss->idle_timeout_sec);
DEBUG_TRACE("Idle timeout set: %d seconds for connection from %s",
pss->idle_timeout_sec, pss->client_ip);
}
// Also set auth timeout if auth is required (separate concern)
if (pss->nip42_auth_required_events || pss->nip42_auth_required_subscriptions) {
int auth_timeout = get_config_int("nip42_auth_timeout_sec", 10);
// Use the shorter of the two timeouts
int effective_timeout = (pss->idle_timeout_sec > 0 && pss->idle_timeout_sec < auth_timeout)
? pss->idle_timeout_sec
: auth_timeout;
if (effective_timeout > 0) {
lws_set_timeout(wsi, PENDING_TIMEOUT_AWAITING_PING, effective_timeout);
}
}
3. Mark Session as Active on Valid Activity
File: src/websockets.c - LWS_CALLBACK_RECEIVE
When processing REQ message (around line 1030):
// Before handling REQ, mark session as active
pthread_mutex_lock(&pss->session_lock);
pss->session_active = 1;
pthread_mutex_unlock(&pss->session_lock);
// Cancel idle timeout - this is legitimate usage
lws_set_timeout(wsi, NO_PENDING_TIMEOUT, 0);
When processing EVENT message (around line 1380):
// Before handling EVENT, mark session as active
pthread_mutex_lock(&pss->session_lock);
pss->session_active = 1;
pthread_mutex_unlock(&pss->session_lock);
// Cancel idle timeout - this is legitimate usage
lws_set_timeout(wsi, NO_PENDING_TIMEOUT, 0);
Note: We should also consider AUTH as "activity" since the client is engaging with the protocol:
// In handle_nip42_auth_signed_event (nip042.c)
// After successful auth, also mark session active
pthread_mutex_lock(&pss->session_lock);
pss->session_active = 1;
pthread_mutex_unlock(&pss->session_lock);
3.5. Separate Rate Limiting for Idle Connections
Per user feedback, idle/early-disconnect failures have separate rate limiting from auth failures. This requires:
New Config Keys:
| Config Key | Type | Default | Range | Description |
|---|---|---|---|---|
idle_ban_threshold |
int | 3 | 1-100 | Idle failures before ban |
idle_ban_window_sec |
int | 60 | 10-3600 | Window for counting idle failures |
idle_ban_duration_sec |
int | 300 | 60-86400 | Initial idle ban duration |
Add to ip_ban_entry_t in src/ip_ban.c:
typedef struct {
int state;
char ip[46];
// Auth failure tracking (existing)
int failure_count;
time_t first_failure;
time_t banned_until;
int ban_count;
// 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;
time_t last_success_at;
int total_connections;
int total_failures;
int total_successes;
time_t first_seen;
} ip_ban_entry_t;
New function in src/ip_ban.c:
// 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;
int threshold = get_config_int("idle_ban_threshold", 3);
int window_sec = get_config_int("idle_ban_window_sec", 60);
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);
}
Update ip_ban_is_banned() to check BOTH ban types:
int ip_ban_is_banned(const char* ip) {
if (!ip || !g_initialized) 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;
}
Update persistence:
ip_ban_load_from_db(): Load idle_* fields from new columnsip_ban_save_to_db(): Save idle_* fields to new columns- Add migration SQL to create new columns if they don't exist
Add to src/ip_ban.h:
// Record an idle/early-disconnect failure for an IP
void ip_ban_record_idle_failure(const char* ip);
4. Record Failure on Inactive Connection Close
File: src/websockets.c - LWS_CALLBACK_CLOSED
Currently (lines 2121-2128):
// Record auth failure if connection closed while unauthenticated and auth was required
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);
}
Change to:
// Record failure if connection closed without ever becoming active
// This catches:
// 1. Idle connections that timed out (never sent REQ/EVENT/AUTH)
// 2. Early disconnects (client closed before sending REQ/EVENT/AUTH)
if (!pss->session_active && strlen(pss->client_ip) > 0) {
// Use separate idle failure recording (has its own threshold/duration)
ip_ban_record_idle_failure(pss->client_ip);
DEBUG_LOG("Recording idle/early-disconnect failure for IP %s (connected %ld seconds)",
pss->client_ip,
time(NULL) - pss->connection_established);
}
// Legacy: record auth failure if auth was required but not completed
else 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);
}
5. Add Configuration Keys
File: src/config.c - Add validation for new keys
In the config validation section, add:
// Idle connection ban settings
if (strcmp(key, "idle_connection_timeout_sec") == 0) {
if (!is_valid_positive_integer(value)) {
snprintf(error_msg, error_size, "invalid idle_connection_timeout_sec '%s' (must be positive integer)", value);
return -1;
}
int timeout = atoi(value);
if (timeout < 5 || timeout > 300) {
snprintf(error_msg, error_size, "idle_connection_timeout_sec must be between 5 and 300 seconds");
return -1;
}
return 0;
}
if (strcmp(key, "idle_ban_enabled") == 0) {
if (!is_valid_boolean(value)) {
snprintf(error_msg, error_size, "invalid boolean value '%s' for idle_ban_enabled", value);
return -1;
}
return 0;
}
if (strcmp(key, "idle_ban_threshold") == 0) {
if (!is_valid_positive_integer(value)) {
snprintf(error_msg, error_size, "invalid idle_ban_threshold '%s' (must be positive integer)", value);
return -1;
}
int threshold = atoi(value);
if (threshold < 1 || threshold > 100) {
snprintf(error_msg, error_size, "idle_ban_threshold must be between 1 and 100");
return -1;
}
return 0;
}
if (strcmp(key, "idle_ban_window_sec") == 0) {
if (!is_valid_positive_integer(value)) {
snprintf(error_msg, error_size, "invalid idle_ban_window_sec '%s' (must be positive integer)", value);
return -1;
}
int window = atoi(value);
if (window < 10 || window > 3600) {
snprintf(error_msg, error_size, "idle_ban_window_sec must be between 10 and 3600");
return -1;
}
return 0;
}
if (strcmp(key, "idle_ban_duration_sec") == 0) {
if (!is_valid_positive_integer(value)) {
snprintf(error_msg, error_size, "invalid idle_ban_duration_sec '%s' (must be positive integer)", value);
return -1;
}
int duration = atoi(value);
if (duration < 60 || duration > 86400) {
snprintf(error_msg, error_size, "idle_ban_duration_sec must be between 60 and 86400");
return -1;
}
return 0;
}
Also update the config introspection/documentation functions to describe these keys.
6. Add API Documentation
File: src/api.c - Add to config metadata
{"idle_connection_timeout_sec", "int", 5, 300},
{"idle_ban_enabled", "bool", 0, 1},
{"idle_ban_threshold", "int", 1, 100},
{"idle_ban_window_sec", "int", 10, 3600},
{"idle_ban_duration_sec", "int", 60, 86400},
Add descriptions in the config query handler:
} else if (strcmp(key, "idle_connection_timeout_sec") == 0) {
description = "Seconds before an idle connection (no REQ/EVENT) is closed and IP flagged. 0 to disable.";
} else if (strcmp(key, "idle_ban_enabled") == 0) {
description = "Whether to ban IPs that repeatedly connect without sending REQ or EVENT.";
} else if (strcmp(key, "idle_ban_threshold") == 0) {
description = "Number of idle/early-disconnect failures before banning an IP.";
} else if (strcmp(key, "idle_ban_window_sec") == 0) {
description = "Time window in seconds for counting idle failures.";
} else if (strcmp(key, "idle_ban_duration_sec") == 0) {
description = "Initial ban duration in seconds for idle failures (doubles each time).";
}
7. Update ip_ban Persistence and Cleanup
File: src/ip_ban.c - Update ip_ban_load_from_db()
Add migration SQL to create new columns if they don't exist, then load them:
ALTER TABLE ip_bans ADD COLUMN idle_failure_count INTEGER NOT NULL DEFAULT 0;
ALTER TABLE ip_bans ADD COLUMN idle_ban_count INTEGER NOT NULL DEFAULT 0;
ALTER TABLE ip_bans ADD COLUMN idle_banned_until INTEGER NOT NULL DEFAULT 0;
ALTER TABLE ip_bans ADD COLUMN idle_first_failure INTEGER NOT NULL DEFAULT 0;
File: src/ip_ban.c - Update ip_ban_save_to_db()
Add the idle_* fields to the INSERT/REPLACE statement.
File: src/ip_ban.c - Update ip_ban_cleanup()
Add cleanup logic for idle ban entries (same pattern as auth ban cleanup).
File: src/ip_ban.c - Update ip_ban_log_stats()
Add idle ban count to the periodic log summary:
DEBUG_WARN("IP BAN SUMMARY: %d auth-banned, %d idle-banned, %d tracked, %d trusted",
auth_banned_count, idle_banned_count, tracked_count, trusted_count);
Configuration Reference
Idle Connection Settings
| Config Key | Type | Default | Range | Description |
|---|---|---|---|---|
idle_connection_timeout_sec |
int | 30 | 5-300 | Seconds to wait for REQ/EVENT before closing connection. 0 = disable idle timeout. |
idle_ban_enabled |
bool | true | true/false | Whether to ban IPs with repeated idle/early-disconnect failures. |
idle_ban_threshold |
int | 3 | 1-100 | Idle failures before ban. |
idle_ban_window_sec |
int | 60 | 10-3600 | Window for counting idle failures. |
idle_ban_duration_sec |
int | 300 | 60-86400 | Initial idle ban duration. |
Auth Failure Settings (Existing)
| Config Key | Type | Default | Range | Description |
|---|---|---|---|---|
auth_fail_ban_enabled |
bool | true | true/false | Whether to ban IPs with failed auth. |
auth_fail_ban_threshold |
int | 3 | 1-100 | Auth failures before ban. |
auth_fail_window_sec |
int | 60 | 10-3600 | Window for counting auth failures. |
auth_fail_ban_duration_sec |
int | 300 | 60-86400 | Initial auth ban duration. |
Behavior Matrix
| Scenario | idle_timeout_sec | idle_ban_enabled | Result |
|---|---|---|---|
| Connect → do nothing → timeout | >0 | true | Connection closed, IP failure recorded, may be banned |
| Connect → do nothing → timeout | >0 | false | Connection closed, no ban |
| Connect → immediate disconnect | any | true | IP failure recorded, may be banned |
| Connect → immediate disconnect | any | false | No ban |
| Connect → send REQ | any | any | Session active, no ban |
| Connect → send EVENT | any | any | Session active, no ban |
| Connect → send AUTH (NIP-42) | any | any | Session active, no ban |
Testing Strategy
- Idle timeout test: Connect via wscat, wait 30 seconds, verify disconnect and ban table entry
- Early disconnect test: Connect via wscat, immediately Ctrl-C, verify ban table entry
- Active session test: Connect, send REQ, disconnect immediately, verify NO ban
- Config disable test: Set
idle_ban_enabled=false, repeat test 1, verify no ban - Timeout config test: Set
idle_connection_timeout_sec=5, verify faster disconnect
Files to Modify
| File | Change Type |
|---|---|
src/websockets.h |
Add session_active and idle_timeout_sec fields to per_session_data |
src/websockets.c ~565 |
Set idle timeout on ALL connections in LWS_CALLBACK_ESTABLISHED |
src/websockets.c ~1030 |
Mark session_active=1 and cancel idle timer on REQ |
src/websockets.c ~1380 |
Mark session_active=1 and cancel idle timer on EVENT |
src/websockets.c ~2121 |
Call ip_ban_record_idle_failure() for inactive sessions in LWS_CALLBACK_CLOSED |
src/nip042.c ~130 |
Mark session_active=1 on successful AUTH |
src/ip_ban.h |
Add ip_ban_record_idle_failure() declaration |
src/ip_ban.c |
Add idle_* fields to ip_ban_entry_t, new ip_ban_record_idle_failure() function, update ip_ban_is_banned() to check both ban types, update persistence and cleanup |
src/config.c ~987+ |
Add validation for 5 new idle_* config keys |
src/api.c ~664 |
Add config metadata entries for idle_* keys |
Deployment on Existing Server
Config Keys: No Manual SQL Required
All config keys use get_config_int() and get_config_bool() which return hardcoded defaults when a key doesn't exist in the config table. The new code will work immediately with these defaults:
idle_connection_timeout_sec→ defaults to 30idle_ban_enabled→ defaults to trueidle_ban_threshold→ defaults to 3idle_ban_window_sec→ defaults to 60idle_ban_duration_sec→ defaults to 300
If you want to override any defaults, you can insert them into the config table. From the same directory as the .db file:
# Find your database file
DB_FILE=$(ls *.db | head -1)
# Optional: Insert custom values (only if you want non-default settings)
sqlite3 "$DB_FILE" "INSERT OR REPLACE INTO config (key, value) VALUES ('idle_connection_timeout_sec', '30');"
sqlite3 "$DB_FILE" "INSERT OR REPLACE INTO config (key, value) VALUES ('idle_ban_enabled', 'true');"
sqlite3 "$DB_FILE" "INSERT OR REPLACE INTO config (key, value) VALUES ('idle_ban_threshold', '3');"
sqlite3 "$DB_FILE" "INSERT OR REPLACE INTO config (key, value) VALUES ('idle_ban_window_sec', '60');"
sqlite3 "$DB_FILE" "INSERT OR REPLACE INTO config (key, value) VALUES ('idle_ban_duration_sec', '300');"
Or change them via the admin API/web UI after deployment.
ip_bans Table: Automatic Migration
The ip_bans table needs 4 new columns for idle tracking. The updated ip_ban_load_from_db() will run ALTER TABLE statements automatically on startup. These are safe because SQLite's ALTER TABLE ADD COLUMN is a no-op if the column already exists (we'll use error-tolerant execution).
If you prefer to run the migration manually before deploying:
DB_FILE=$(ls *.db | head -1)
sqlite3 "$DB_FILE" <<'SQL'
ALTER TABLE ip_bans ADD COLUMN idle_failure_count INTEGER NOT NULL DEFAULT 0;
ALTER TABLE ip_bans ADD COLUMN idle_ban_count INTEGER NOT NULL DEFAULT 0;
ALTER TABLE ip_bans ADD COLUMN idle_banned_until INTEGER NOT NULL DEFAULT 0;
ALTER TABLE ip_bans ADD COLUMN idle_first_failure INTEGER NOT NULL DEFAULT 0;
SQL
Note: SQLite will error on ALTER TABLE ADD COLUMN if the column already exists, but the code will handle this gracefully (ignore the error). Running it manually is optional — the relay will do it on startup.
Deployment Steps
- Build and deploy with
deploy_lt.shas usual - The relay starts,
ip_ban_load_from_db()auto-migrates theip_banstable - Config keys use defaults immediately — no manual SQL needed
- Optionally tune settings via admin API or direct SQL
Backward Compatibility
- Default
idle_connection_timeout_sec=30provides immediate protection - Default
idle_ban_enabled=truemaintains current spam protection behavior - Setting
idle_connection_timeout_sec=0restores old behavior (no idle timeout) - Setting
idle_ban_enabled=falseallows connections without banning (for debugging) - Existing auth-based banning continues to work independently with its own thresholds
- Database migration adds new columns with defaults — existing ban data preserved
Summary
This plan implements a unified "session activity" tracking system with separate rate limiting for idle vs auth failures:
- Catches idle connections via
lws_set_timeout()on ALL connections (not just auth-required) - Catches early disconnects via the same
!session_activecheck on close - Respects legitimate users who actually use the relay (REQ/EVENT/AUTH marks session active)
- Separate idle ban tracking — idle failures have their own threshold, window, and duration independent of auth failures
- Extends existing ban infrastructure — adds idle_* fields to
ip_ban_entry_t, unifiedip_ban_is_banned()checks both ban types - Fully configurable — 5 new config keys for idle banning, all tunable via admin events
The key insight is that there's no meaningful difference between "idle timeout" and "early disconnect" — both indicate a client that connected but never actually used the relay. The same !session_active check handles both cases, and the separate rate limiting ensures idle bots don't interfere with auth failure tracking for legitimate clients who fail NIP-42.