# 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

```mermaid
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`:

```c
// 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):
```c
// 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:
```c
// 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):
```c
// 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):
```c
// 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:
```c
// 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`:**

```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`:**

```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:**

```c
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`:**

```c
// 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):
```c
// 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:
```c
// 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:

```c
// 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**

```c
{"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:
```c
} 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:
```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;
```

**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:
```c
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()`](src/config.c:300) and [`get_config_bool()`](src/config.c:313) 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:

```bash
# 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()`](src/ip_ban.c:93) 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:

```bash
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.
