Files
c-relay-pg/plans/idle_connection_ban_plan.md

22 KiB

Plan: Ban Idle and Early-Disconnect Connections

Problem Statement

The relay needs to defend against spam connections that:

  1. Connect via WebSocket and sit idle doing nothing (the original problem)
  2. 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 columns
  • ip_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

  1. Idle timeout test: Connect via wscat, wait 30 seconds, verify disconnect and ban table entry
  2. Early disconnect test: Connect via wscat, immediately Ctrl-C, verify ban table entry
  3. Active session test: Connect, send REQ, disconnect immediately, verify NO ban
  4. Config disable test: Set idle_ban_enabled=false, repeat test 1, verify no ban
  5. 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 30
  • idle_ban_enabled → defaults to true
  • idle_ban_threshold → defaults to 3
  • idle_ban_window_sec → defaults to 60
  • idle_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

  1. Build and deploy with deploy_lt.sh as usual
  2. The relay starts, ip_ban_load_from_db() auto-migrates the ip_bans table
  3. Config keys use defaults immediately — no manual SQL needed
  4. Optionally tune settings via admin API or direct SQL

Backward Compatibility

  • Default idle_connection_timeout_sec=30 provides immediate protection
  • Default idle_ban_enabled=true maintains current spam protection behavior
  • Setting idle_connection_timeout_sec=0 restores old behavior (no idle timeout)
  • Setting idle_ban_enabled=false allows 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:

  1. Catches idle connections via lws_set_timeout() on ALL connections (not just auth-required)
  2. Catches early disconnects via the same !session_active check on close
  3. Respects legitimate users who actually use the relay (REQ/EVENT/AUTH marks session active)
  4. Separate idle ban tracking — idle failures have their own threshold, window, and duration independent of auth failures
  5. Extends existing ban infrastructure — adds idle_* fields to ip_ban_entry_t, unified ip_ban_is_banned() checks both ban types
  6. 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.