diff --git a/README.md b/README.md index 2f8a95d..cbd6cad 100644 --- a/README.md +++ b/README.md @@ -106,10 +106,13 @@ TNT_PUBLIC_HOST=chat.m1ng.space tnt # Max total connections (default 64) TNT_MAX_CONNECTIONS=100 tnt -# Max connections per IP (default 5) +# Max concurrent sessions per IP (default 5) TNT_MAX_CONN_PER_IP=10 tnt -# Disable rate limiting (testing only) +# Max new connection attempts per IP in 60 seconds (default 10) +TNT_MAX_CONN_RATE_PER_IP=30 tnt + +# Disable connection-rate and auth-failure blocking (testing only) TNT_RATE_LIMIT=0 tnt ``` @@ -124,11 +127,24 @@ TNT_SSH_LOG_LEVEL=3 tnt TNT_ACCESS_TOKEN="strong-password-123" \ TNT_BIND_ADDR=0.0.0.0 \ TNT_MAX_CONNECTIONS=200 \ -TNT_MAX_CONN_PER_IP=3 \ +TNT_MAX_CONN_PER_IP=30 \ +TNT_MAX_CONN_RATE_PER_IP=60 \ TNT_SSH_LOG_LEVEL=1 \ tnt -p 2222 ``` +### SSH Exec Interface + +TNT also exposes a small non-interactive SSH surface for scripts: + +```sh +ssh -p 2222 chat.m1ng.space health +ssh -p 2222 chat.m1ng.space stats --json +ssh -p 2222 chat.m1ng.space users +ssh -p 2222 chat.m1ng.space "tail -n 20" +ssh -p 2222 operator@chat.m1ng.space post "service notice" +``` + ## Development ### Building @@ -151,6 +167,7 @@ cd tests ./test_basic.sh # basic functionality ./test_security_features.sh # security features ./test_anonymous_access.sh # anonymous access +./test_connection_limits.sh # per-IP concurrency and rate limits ./test_stress.sh # stress test ``` @@ -217,6 +234,7 @@ TNT_BIND_ADDR=0.0.0.0 TNT_STATE_DIR=/var/lib/tnt TNT_MAX_CONNECTIONS=200 TNT_MAX_CONN_PER_IP=30 +TNT_MAX_CONN_RATE_PER_IP=60 TNT_RATE_LIMIT=1 TNT_SSH_LOG_LEVEL=0 TNT_PUBLIC_HOST=chat.m1ng.space diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index b00ed8e..ce64473 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -51,6 +51,7 @@ TNT_BIND_ADDR=0.0.0.0 TNT_STATE_DIR=/var/lib/tnt TNT_MAX_CONNECTIONS=200 TNT_MAX_CONN_PER_IP=30 +TNT_MAX_CONN_RATE_PER_IP=60 TNT_RATE_LIMIT=1 TNT_SSH_LOG_LEVEL=0 TNT_PUBLIC_HOST=chat.m1ng.space @@ -81,6 +82,13 @@ sudo systemctl restart tnt The service uses `StateDirectory=tnt`, so systemd creates `/var/lib/tnt` automatically. Use `TNT_STATE_DIR` or `tnt -d DIR` when running outside systemd to avoid depending on the current working directory. +Recommended interpretation: + +- `TNT_MAX_CONNECTIONS`: global connection ceiling +- `TNT_MAX_CONN_PER_IP`: concurrent sessions allowed from one IP +- `TNT_MAX_CONN_RATE_PER_IP`: new connection attempts allowed per IP per 60 seconds +- `TNT_RATE_LIMIT=0`: disables rate-based blocking and auth-failure IP blocking, but not the explicit capacity limits + ## Firewall ```bash diff --git a/docs/SECURITY_QUICKREF.md b/docs/SECURITY_QUICKREF.md index 37adb87..8837ad2 100644 --- a/docs/SECURITY_QUICKREF.md +++ b/docs/SECURITY_QUICKREF.md @@ -26,7 +26,8 @@ Connect: `sshpass -p "YourSecretPassword" ssh -p 2222 localhost` | `TNT_SSH_LOG_LEVEL` | `1` | SSH logging (0-4) | `TNT_SSH_LOG_LEVEL=3` | | `TNT_RATE_LIMIT` | `1` | Rate limiting on/off | `TNT_RATE_LIMIT=0` | | `TNT_MAX_CONNECTIONS` | `64` | Total connection limit | `TNT_MAX_CONNECTIONS=100` | -| `TNT_MAX_CONN_PER_IP` | `5` | Per-IP limit | `TNT_MAX_CONN_PER_IP=3` | +| `TNT_MAX_CONN_PER_IP` | `5` | Concurrent sessions per IP | `TNT_MAX_CONN_PER_IP=3` | +| `TNT_MAX_CONN_RATE_PER_IP` | `10` | New connections per IP per 60s | `TNT_MAX_CONN_RATE_PER_IP=20` | --- @@ -75,7 +76,8 @@ TNT_MAX_CONN_PER_IP=2 \ ## Rate Limiting ### Defaults -- **Connection Rate:** 10 connections per IP per 60 seconds +- **Concurrent Sessions:** 5 per IP +- **Connection Rate:** 10 new connections per IP per 60 seconds - **Auth Failures:** 5 failures → 5 minute IP block - **Window:** 60 second rolling window @@ -110,6 +112,12 @@ TNT_MAX_CONN_PER_IP=3 ./tnt ``` Each IP can have max 3 concurrent connections. +### Per-IP Rate Limit +```bash +TNT_MAX_CONN_RATE_PER_IP=20 ./tnt +``` +Each IP can open at most 20 new connections per 60 seconds before being temporarily blocked. + ### Combined Example ```bash TNT_MAX_CONNECTIONS=100 TNT_MAX_CONN_PER_IP=10 ./tnt diff --git a/include/ssh_server.h b/include/ssh_server.h index c4fe565..baa222e 100644 --- a/include/ssh_server.h +++ b/include/ssh_server.h @@ -3,6 +3,7 @@ #include "common.h" #include "chat_room.h" +#include #include #include @@ -12,6 +13,7 @@ typedef struct client { ssh_session session; /* SSH session */ ssh_channel channel; /* SSH channel */ char username[MAX_USERNAME_LEN]; + char client_ip[INET6_ADDRSTRLEN]; int width; int height; client_mode_t mode; diff --git a/src/ssh_server.c b/src/ssh_server.c index 163845a..5e1a4f9 100644 --- a/src/ssh_server.c +++ b/src/ssh_server.c @@ -35,6 +35,7 @@ typedef struct { typedef struct { ssh_session session; + char client_ip[INET6_ADDRSTRLEN]; } accepted_session_t; /* Rate limiting and connection tracking */ @@ -46,7 +47,8 @@ typedef struct { typedef struct { char ip[INET6_ADDRSTRLEN]; time_t window_start; - int connection_count; + int recent_connection_count; + int active_connections; int auth_failure_count; bool is_blocked; time_t block_until; @@ -61,6 +63,7 @@ static time_t g_server_start_time = 0; /* Configuration from environment variables */ static int g_max_connections = 64; static int g_max_conn_per_ip = 5; +static int g_max_conn_rate_per_ip = 10; static int g_rate_limit_enabled = 1; static char g_access_token[256] = ""; @@ -118,11 +121,18 @@ static void init_rate_limit_config(void) { if ((env = getenv("TNT_MAX_CONN_PER_IP")) != NULL) { int val = atoi(env); - if (val > 0 && val <= 100) { + if (val > 0 && val <= 1024) { g_max_conn_per_ip = val; } } + if ((env = getenv("TNT_MAX_CONN_RATE_PER_IP")) != NULL) { + int val = atoi(env); + if (val > 0 && val <= 1024) { + g_max_conn_rate_per_ip = val; + } + } + if ((env = getenv("TNT_RATE_LIMIT")) != NULL) { g_rate_limit_enabled = atoi(env); } @@ -147,7 +157,8 @@ static ip_rate_limit_t* get_rate_limit_entry(const char *ip) { if (g_rate_limits[i].ip[0] == '\0') { strncpy(g_rate_limits[i].ip, ip, sizeof(g_rate_limits[i].ip) - 1); g_rate_limits[i].window_start = time(NULL); - g_rate_limits[i].connection_count = 0; + g_rate_limits[i].recent_connection_count = 0; + g_rate_limits[i].active_connections = 0; g_rate_limits[i].auth_failure_count = 0; g_rate_limits[i].is_blocked = false; g_rate_limits[i].block_until = 0; @@ -155,67 +166,83 @@ static ip_rate_limit_t* get_rate_limit_entry(const char *ip) { } } - /* Find oldest entry to replace */ - int oldest_idx = 0; - time_t oldest_time = g_rate_limits[0].window_start; - for (int i = 1; i < MAX_TRACKED_IPS; i++) { - if (g_rate_limits[i].window_start < oldest_time) { + /* Reuse the oldest inactive entry first so active IP accounting stays intact. */ + int oldest_idx = -1; + time_t oldest_time = 0; + for (int i = 0; i < MAX_TRACKED_IPS; i++) { + if (g_rate_limits[i].active_connections != 0) { + continue; + } + if (oldest_idx < 0 || g_rate_limits[i].window_start < oldest_time) { oldest_time = g_rate_limits[i].window_start; oldest_idx = i; } } + if (oldest_idx < 0) { + oldest_idx = 0; + oldest_time = g_rate_limits[0].window_start; + for (int i = 1; i < MAX_TRACKED_IPS; i++) { + if (g_rate_limits[i].window_start < oldest_time) { + oldest_time = g_rate_limits[i].window_start; + oldest_idx = i; + } + } + } + /* Reset and reuse */ strncpy(g_rate_limits[oldest_idx].ip, ip, sizeof(g_rate_limits[oldest_idx].ip) - 1); g_rate_limits[oldest_idx].ip[sizeof(g_rate_limits[oldest_idx].ip) - 1] = '\0'; g_rate_limits[oldest_idx].window_start = time(NULL); - g_rate_limits[oldest_idx].connection_count = 0; + g_rate_limits[oldest_idx].recent_connection_count = 0; + g_rate_limits[oldest_idx].active_connections = 0; g_rate_limits[oldest_idx].auth_failure_count = 0; g_rate_limits[oldest_idx].is_blocked = false; g_rate_limits[oldest_idx].block_until = 0; return &g_rate_limits[oldest_idx]; } -/* Check rate limit for an IP */ -static bool check_rate_limit(const char *ip) { - if (!g_rate_limit_enabled) { - return true; - } - +/* Check rate and concurrency limits for an IP */ +static bool check_ip_connection_policy(const char *ip) { time_t now = time(NULL); pthread_mutex_lock(&g_rate_limit_lock); ip_rate_limit_t *entry = get_rate_limit_entry(ip); - /* Check if blocked */ - if (entry->is_blocked && now < entry->block_until) { + if (entry->active_connections >= g_max_conn_per_ip) { + pthread_mutex_unlock(&g_rate_limit_lock); + fprintf(stderr, "Concurrent IP limit reached for %s\n", ip); + return false; + } + + if (g_rate_limit_enabled && entry->is_blocked && now < entry->block_until) { pthread_mutex_unlock(&g_rate_limit_lock); fprintf(stderr, "Blocked IP %s (blocked until %ld)\n", ip, (long)entry->block_until); return false; } - /* Unblock if block duration passed */ - if (entry->is_blocked && now >= entry->block_until) { + if (g_rate_limit_enabled && entry->is_blocked && now >= entry->block_until) { entry->is_blocked = false; entry->auth_failure_count = 0; } - /* Reset window if expired */ - if (now - entry->window_start >= RATE_LIMIT_WINDOW) { - entry->window_start = now; - entry->connection_count = 0; - } - - /* Check connection rate */ - entry->connection_count++; - if (entry->connection_count > g_max_conn_per_ip) { - entry->is_blocked = true; - entry->block_until = now + BLOCK_DURATION; - pthread_mutex_unlock(&g_rate_limit_lock); - fprintf(stderr, "Rate limit exceeded for IP %s\n", ip); - return false; + if (g_rate_limit_enabled) { + if (now - entry->window_start >= RATE_LIMIT_WINDOW) { + entry->window_start = now; + entry->recent_connection_count = 0; + } + + entry->recent_connection_count++; + if (entry->recent_connection_count > g_max_conn_rate_per_ip) { + entry->is_blocked = true; + entry->block_until = now + BLOCK_DURATION; + pthread_mutex_unlock(&g_rate_limit_lock); + fprintf(stderr, "Rate limit exceeded for IP %s\n", ip); + return false; + } } + entry->active_connections++; pthread_mutex_unlock(&g_rate_limit_lock); return true; } @@ -224,6 +251,10 @@ static bool check_rate_limit(const char *ip) { static void record_auth_failure(const char *ip) { time_t now = time(NULL); + if (!g_rate_limit_enabled) { + return; + } + pthread_mutex_lock(&g_rate_limit_lock); ip_rate_limit_t *entry = get_rate_limit_entry(ip); @@ -237,6 +268,19 @@ static void record_auth_failure(const char *ip) { pthread_mutex_unlock(&g_rate_limit_lock); } +static void release_ip_connection(const char *ip) { + if (!ip || ip[0] == '\0') { + return; + } + + pthread_mutex_lock(&g_rate_limit_lock); + ip_rate_limit_t *entry = get_rate_limit_entry(ip); + if (entry->active_connections > 0) { + entry->active_connections--; + } + pthread_mutex_unlock(&g_rate_limit_lock); +} + /* Check and increment total connection count */ static bool check_and_increment_connections(void) { pthread_mutex_lock(&g_conn_count_lock); @@ -1447,6 +1491,8 @@ cleanup: room_broadcast(g_room, &leave_msg); } + release_ip_connection(client->client_ip); + /* Release the main reference - client will be freed when all refs are gone */ client_release(client); @@ -1566,6 +1612,9 @@ static void cleanup_failed_session(ssh_session session, session_context_t *ctx) ssh_free(session); } + if (ctx) { + release_ip_connection(ctx->client_ip); + } destroy_session_context(ctx); decrement_connections(); } @@ -1768,23 +1817,32 @@ static void *bootstrap_client_session(void *arg) { client_t *client = NULL; bool timed_out = false; time_t start_time; + char accepted_ip[INET6_ADDRSTRLEN] = ""; if (!accepted) { return NULL; } session = accepted->session; + if (accepted->client_ip[0] != '\0') { + snprintf(accepted_ip, sizeof(accepted_ip), "%s", accepted->client_ip); + } free(accepted); ctx = calloc(1, sizeof(session_context_t)); if (!ctx) { + release_ip_connection(accepted_ip); ssh_disconnect(session); ssh_free(session); decrement_connections(); return NULL; } - get_client_ip(session, ctx->client_ip, sizeof(ctx->client_ip)); + if (accepted_ip[0] != '\0') { + snprintf(ctx->client_ip, sizeof(ctx->client_ip), "%s", accepted_ip); + } else { + get_client_ip(session, ctx->client_ip, sizeof(ctx->client_ip)); + } ctx->pty_width = 80; ctx->pty_height = 24; ctx->exec_command[0] = '\0'; @@ -1881,6 +1939,10 @@ static void *bootstrap_client_session(void *arg) { sizeof(client->ssh_login) - 1); client->ssh_login[sizeof(client->ssh_login) - 1] = '\0'; } + if (ctx->client_ip[0] != '\0') { + snprintf(client->client_ip, sizeof(client->client_ip), "%s", + ctx->client_ip); + } if (ctx->exec_command[0] != '\0') { strncpy(client->exec_command, ctx->exec_command, sizeof(client->exec_command) - 1); @@ -1991,13 +2053,6 @@ int ssh_server_start(int unused) { get_client_ip(session, client_ip, sizeof(client_ip)); - /* Check rate limit */ - if (!check_rate_limit(client_ip)) { - ssh_disconnect(session); - ssh_free(session); - continue; - } - /* Check total connection limit */ if (!check_and_increment_connections()) { fprintf(stderr, "Max connections reached, rejecting %s\n", client_ip); @@ -2005,8 +2060,17 @@ int ssh_server_start(int unused) { ssh_free(session); continue; } + + if (!check_ip_connection_policy(client_ip)) { + decrement_connections(); + ssh_disconnect(session); + ssh_free(session); + continue; + } + accepted = calloc(1, sizeof(*accepted)); if (!accepted) { + release_ip_connection(client_ip); decrement_connections(); ssh_disconnect(session); ssh_free(session); @@ -2014,10 +2078,13 @@ int ssh_server_start(int unused) { } accepted->session = session; + snprintf(accepted->client_ip, sizeof(accepted->client_ip), "%s", + client_ip); if (pthread_create(&thread, &attr, bootstrap_client_session, accepted) != 0) { fprintf(stderr, "Thread creation failed: %s\n", strerror(errno)); free(accepted); + release_ip_connection(client_ip); decrement_connections(); ssh_disconnect(session); ssh_free(session); diff --git a/tests/test_connection_limits.sh b/tests/test_connection_limits.sh new file mode 100755 index 0000000..fa1deec --- /dev/null +++ b/tests/test_connection_limits.sh @@ -0,0 +1,135 @@ +#!/bin/sh +# Connection limit regression tests for TNT + +PORT=${PORT:-2222} +BIN="../tnt" +PASS=0 +FAIL=0 +STATE_DIR=$(mktemp -d "${TMPDIR:-/tmp}/tnt-limit-test.XXXXXX") +SERVER_PID="" +WATCHER_PID="" + +cleanup() { + if [ -n "$WATCHER_PID" ]; then + kill "$WATCHER_PID" 2>/dev/null || true + wait "$WATCHER_PID" 2>/dev/null || true + fi + if [ -n "$SERVER_PID" ]; then + kill "$SERVER_PID" 2>/dev/null || true + wait "$SERVER_PID" 2>/dev/null || true + fi + rm -rf "$STATE_DIR" +} + +trap cleanup EXIT + +if [ ! -f "$BIN" ]; then + echo "Error: Binary $BIN not found. Run make first." + exit 1 +fi + +SSH_OPTS="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -p $PORT" + +wait_for_health() { + for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15; do + if [ -n "$SERVER_PID" ] && ! kill -0 "$SERVER_PID" 2>/dev/null; then + return 1 + fi + OUT=$(ssh $SSH_OPTS localhost health 2>/dev/null || true) + [ "$OUT" = "ok" ] && return 0 + sleep 1 + done + return 1 +} + +echo "=== TNT Connection Limit Tests ===" + +TNT_RATE_LIMIT=0 TNT_MAX_CONN_PER_IP=1 "$BIN" -p "$PORT" -d "$STATE_DIR" \ + >"$STATE_DIR/concurrent.log" 2>&1 & +SERVER_PID=$! + +if wait_for_health; then + echo "✓ server started for concurrent limit test" + PASS=$((PASS + 1)) +else + echo "✗ server failed to start for concurrent limit test" + exit 1 +fi + +WATCHER_READY="$STATE_DIR/watcher.ready" +cat >"$STATE_DIR/watcher.expect" <"$STATE_DIR/watcher.log" 2>&1 & +WATCHER_PID=$! + +for _ in 1 2 3 4 5 6 7 8 9 10; do + [ -f "$WATCHER_READY" ] && break + sleep 1 +done + +if [ ! -f "$WATCHER_READY" ]; then + echo "✗ watcher session did not become ready" + sed -n '1,120p' "$STATE_DIR/watcher.log" + exit 1 +fi + +if ssh $SSH_OPTS localhost health >/dev/null 2>&1; then + echo "✗ concurrent per-IP limit was not enforced" + FAIL=$((FAIL + 1)) +else + echo "✓ concurrent per-IP limit rejects a second session" + PASS=$((PASS + 1)) +fi + +wait "$WATCHER_PID" 2>/dev/null || true +WATCHER_PID="" +kill "$SERVER_PID" 2>/dev/null || true +wait "$SERVER_PID" 2>/dev/null || true +SERVER_PID="" + +RATE_PORT=$((PORT + 1)) +SSH_RATE_OPTS="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -p $RATE_PORT" + +TNT_MAX_CONN_PER_IP=10 TNT_MAX_CONN_RATE_PER_IP=2 "$BIN" -p "$RATE_PORT" -d "$STATE_DIR" \ + >"$STATE_DIR/rate.log" 2>&1 & +SERVER_PID=$! + +sleep 2 +if kill -0 "$SERVER_PID" 2>/dev/null; then + echo "✓ server started for rate limit test" + PASS=$((PASS + 1)) +else + echo "✗ server failed to start for rate limit test" + sed -n '1,120p' "$STATE_DIR/rate.log" + exit 1 +fi + +R1=$(ssh $SSH_RATE_OPTS localhost health 2>/dev/null || true) +R2=$(ssh $SSH_RATE_OPTS localhost health 2>/dev/null || true) +if ssh $SSH_RATE_OPTS localhost health >/dev/null 2>&1; then + echo "✗ per-IP connection-rate limit was not enforced" + FAIL=$((FAIL + 1)) +else + if [ "$R1" = "ok" ] && [ "$R2" = "ok" ]; then + echo "✓ per-IP connection-rate limit blocks after the configured burst" + PASS=$((PASS + 1)) + else + echo "✗ per-IP connection-rate limit setup failed unexpectedly" + FAIL=$((FAIL + 1)) + fi +fi + +echo "" +echo "PASSED: $PASS" +echo "FAILED: $FAIL" +[ "$FAIL" -eq 0 ] && echo "All tests passed" || echo "Some tests failed" +exit "$FAIL" diff --git a/tests/test_security_features.sh b/tests/test_security_features.sh index 7f2cb39..b0bb8c3 100755 --- a/tests/test_security_features.sh +++ b/tests/test_security_features.sh @@ -97,6 +97,10 @@ TNT_ACCESS_TOKEN="test123" $TIMEOUT_CMD 3 $BIN 2>&1 | grep -q "TNT chat server" TNT_MAX_CONNECTIONS=10 $TIMEOUT_CMD 3 $BIN 2>&1 | grep -q "TNT chat server" && \ pass "TNT_MAX_CONNECTIONS configuration accepted" || fail "TNT_MAX_CONNECTIONS not working" +# Test per-IP connection rate configuration +TNT_MAX_CONN_RATE_PER_IP=20 $TIMEOUT_CMD 3 $BIN 2>&1 | grep -q "TNT chat server" && \ + pass "TNT_MAX_CONN_RATE_PER_IP configuration accepted" || fail "TNT_MAX_CONN_RATE_PER_IP not working" + # Test rate limit toggle TNT_RATE_LIMIT=0 $TIMEOUT_CMD 3 $BIN 2>&1 | grep -q "TNT chat server" && \ pass "TNT_RATE_LIMIT configuration accepted" || fail "TNT_RATE_LIMIT not working" diff --git a/tests/test_stress.sh b/tests/test_stress.sh index 2b0f1e5..49ad5b9 100755 --- a/tests/test_stress.sh +++ b/tests/test_stress.sh @@ -19,7 +19,7 @@ if command -v gtimeout >/dev/null 2>&1; then fi echo "Starting TNT server on port $PORT..." -TNT_RATE_LIMIT=0 $BIN -p $PORT & +TNT_RATE_LIMIT=0 TNT_MAX_CONN_PER_IP=$CLIENTS $BIN -p $PORT & SERVER_PID=$! sleep 2