Compare commits

...
41 Commits
Author SHA1 Message Date
Your Name b6ff4150b4 v1.2.48 - Add CPU% and MEM% ASCII bar graphs to API page stats 2026-02-25 07:33:06 -04:00
Your Name 63bc526163 v1.2.47 - Fix build: use getter function for g_connection_count (was static, can't extern) 2026-02-25 07:24:17 -04:00
Your Name a1f712236a v1.2.46 - Fix: move extern g_connection_count to file scope in api.c 2026-02-25 07:22:14 -04:00
Your Name 06e6c17b7b v1.2.45 - Add WebSocket Connections to system status: api.c sends active_connections, HTML+JS display it 2026-02-25 07:18:42 -04:00
Your Name 0751a7c55c v1.2.44 - IP Bans: show idle+auth bans in stats/status, fix filter buttons, add idle ban columns to query 2026-02-25 07:12:07 -04:00
Your Name f1728932a9 v1.2.43 - IP Bans page: fix filter buttons, add whitelist management UI, compact table rows 2026-02-25 07:08:20 -04:00
Your Name ef8bdef2a8 v1.2.42 - IP Bans page: fix filter buttons, add whitelist management UI, compact table rows 2026-02-25 07:08:12 -04:00
Your Name c11a8ba292 v1.2.41 - Fix IP Bans page: route ip_bans SQL responses to handleIpBansResponse 2026-02-25 06:59:22 -04:00
Your Name 0f124fe575 v1.2.40 - IP Bans page: show whitelisted IPs with star icon, hide Unban button for whitelisted IPs 2026-02-25 06:54:35 -04:00
Your Name 6b20452fab v1.2.39 - Fix IP Bans page: convert rows+columns SQL response format to objects for display 2026-02-25 06:53:50 -04:00
Your Name 10c19bc243 v1.2.38 - Add idle_ban_whitelist config: comma-separated IPs that are never idle-banned 2026-02-25 06:48:41 -04:00
Your Name 3d7aa2196f v1.2.37 - Fix: executeSqlQueryRaw uses relayPool/sendAdminCommand instead of undefined pool variable 2026-02-25 06:43:29 -04:00
Your Name a416c3f275 v1.2.36 - Fix: don't reset relay connection state when external relays time out in subscription onclose 2026-02-25 06:33:25 -04:00
Your Name bba9baabc3 v1.2.35 - Fix: only record idle ban failures for WebSocket connections, not HTTP requests (NIP-11/embedded files) 2026-02-24 17:41:59 -04:00
Your Name 31187c4c4f v1.2.34 - Fix: mark HTTP requests (NIP-11, embedded files) as session_active to prevent idle ban on legitimate HTTP clients 2026-02-24 17:07:33 -04:00
Your Name 55f862b879 v1.2.33 - Set default debug_level to 3 (INFO) 2026-02-24 16:06:19 -04:00
Your Name 76c9b3fcf0 v1.2.32 - Set idle_ban_threshold default to 1 and idle_ban_window_sec default to 30 2026-02-24 15:45:40 -04:00
Your Name 929bd09164 v1.2.31 - Fix idle connection timeout: use global connection list + periodic timer instead of lws_set_timeout which was not firing 2026-02-24 15:44:11 -04:00
Your Name d17f1dd8d5 v1.2.30 - Temporarily bypass admin verification on API page (idle timeout fix pending) 2026-02-24 14:55:39 -04:00
Your Name 207a949835 v1.2.29 - Added idle connection ban system - bans IPs that connect but never send REQ/EVENT (idle timeout or early disconnect) with separate rate limiting from auth failures 2026-02-24 14:18:27 -04:00
Your Name d08f4e4221 v1.2.28 - Add debug_level config setting: live update every 60s without restart, default 0 2026-02-24 08:56:32 -04:00
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
Your Name 3148bbbee7 v1.2.15 - Admin verification: show 'Waiting for response' with status updates, 60s timeout, no premature access denied 2026-02-23 14:26:02 -04:00
Your Name 3965ba04d8 v1.2.14 - Handle late admin verification response: grant access even if timeout already fired and access-denied overlay was shown 2026-02-23 14:24:55 -04:00
Your Name b96af938bd v1.2.13 - Add WoT status to NIP-11 response: restricted_writes, auth_required, and web_of_trust object when WoT enabled 2026-02-23 14:22:08 -04:00
Your Name 89c8248013 v1.2.12 - Increase admin verification timeout from 5s to 30s; continue waiting after publish timeout under heavy load 2026-02-23 14:16:56 -04:00
Your Name d8f477c6cf v1.2.11 - Early duplicate check before secp256k1 signature verification — skip crypto for events already in DB 2026-02-23 14:01:23 -04:00
Your Name 040eeadb13 v1.2.10 - Zero-copy message queue: eliminate memcpy in broadcast and REQ paths via queue_message_take_ownership() 2026-02-23 13:45:56 -04:00
Your Name 83f8b0ab88 v1.2.9 - Replace cJSON_GetObjectItem with CaseSensitive variant (178 calls) — eliminates 14% CPU from tolower+linear scan in hot path 2026-02-23 13:25:50 -04:00
Your Name b82f7eaaf3 v1.2.8 - Add debug build support: _debug suffix in build_static.sh, deploy_lt_debug.sh with perf profiling workflow 2026-02-23 13:07:53 -04:00
29 changed files with 2782 additions and 266 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
+13
View File
@@ -1486,3 +1486,16 @@ body.dark-mode .admin-verification-content {
font-size: 12px;
}
/* ================================
IP BANS TABLE - compact rows
================================ */
#ip-bans-table td, #ip-bans-table th {
padding: 4px 8px;
line-height: 1.3;
font-size: 13px;
}
#ip-bans-table button {
padding: 2px 8px;
font-size: 12px;
}
+114 -2
View File
@@ -16,6 +16,7 @@
<li><button class="nav-item" data-page="subscriptions">Subscriptions</button></li>
<li><button class="nav-item" data-page="configuration">Configuration</button></li>
<li><button class="nav-item" data-page="authorization">Authorization</button></li>
<li><button class="nav-item" data-page="ip-bans">IP BANS</button></li>
<li><button class="nav-item" data-page="relay-events">Relay Events</button></li>
<li><button class="nav-item" data-page="dm">DM</button></li>
<li><button class="nav-item" data-page="database">Database Query</button></li>
@@ -111,6 +112,10 @@
<td>Process ID</td>
<td id="process-id">-</td>
</tr>
<tr>
<td>WebSocket Connections</td>
<td id="websocket-connections">-</td>
</tr>
<tr>
<td>Active Subscriptions</td>
<td id="active-subscriptions">-</td>
@@ -288,8 +293,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
@@ -382,6 +387,109 @@ WEB OF TRUST
</div>
</div>
<!-- IP BANS Section -->
<div class="section" id="ipBansSection" style="display: none;">
<div class="section-header">
IP BAN MANAGEMENT
</div>
<!-- Statistics Cards -->
<div class="input-group">
<div class="config-table-container">
<table class="config-table" id="ip-bans-stats-table">
<thead>
<tr>
<th>Total IPs Tracked</th>
<th>Currently Banned</th>
<th>Total Bans Issued</th>
</tr>
</thead>
<tbody>
<tr>
<td id="ip-bans-total">-</td>
<td id="ip-bans-active">-</td>
<td id="ip-bans-issued">-</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Add Ban Form -->
<div class="input-group">
<h3>Manually Ban IP Address</h3>
<div class="form-group">
<label for="ban-ip-input">IP Address:</label>
<input type="text" id="ban-ip-input" placeholder="192.168.1.100">
</div>
<div class="form-group">
<label for="ban-duration-select">Ban Duration:</label>
<select id="ban-duration-select">
<option value="3600">1 Hour</option>
<option value="86400" selected>24 Hours</option>
<option value="604800">7 Days</option>
<option value="2592000">30 Days</option>
<option value="31536000">1 Year</option>
<option value="999999999">Permanent</option>
</select>
</div>
<div class="inline-buttons">
<button type="button" id="add-ban-btn">BAN IP</button>
</div>
<div id="add-ban-status" class="status-message"></div>
</div>
<!-- Whitelist Management -->
<div class="input-group">
<h3>IP Whitelist (Never Banned)</h3>
<p style="font-size:13px;opacity:0.8;">IPs in this list are never idle-banned. Comma-separated.</p>
<div class="form-group">
<label for="whitelist-ip-input">Add IP to Whitelist:</label>
<input type="text" id="whitelist-ip-input" placeholder="103.81.231.220">
</div>
<div class="inline-buttons">
<button type="button" id="add-whitelist-btn">ADD TO WHITELIST</button>
</div>
<div id="whitelist-status" class="status-message"></div>
<div id="whitelist-current" style="margin-top:8px;font-size:13px;"></div>
</div>
<!-- Filter Controls -->
<div class="input-group">
<div class="inline-buttons">
<button type="button" id="ip-ban-filter-all" class="active">All IPs</button>
<button type="button" id="ip-ban-filter-banned">Currently Banned</button>
<button type="button" id="ip-ban-filter-expired">Expired</button>
<button type="button" id="refresh-ip-bans-btn">REFRESH</button>
</div>
</div>
<!-- IP Bans List -->
<div class="input-group">
<label>Banned IP Addresses:</label>
<div class="config-table-container">
<table class="config-table" id="ip-bans-table">
<thead>
<tr>
<th>IP Address</th>
<th>Status</th>
<th>Banned Until</th>
<th>Failures</th>
<th>Authed Successfully</th>
<th>Connection Attempts</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="ip-bans-tbody">
<tr>
<td colspan="7" style="text-align: center;">Click REFRESH to load IP bans</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- RELAY EVENTS Section -->
<div class="section" id="relayEventsSection" style="display: none;">
<div class="section-header">
@@ -467,6 +575,10 @@ WEB OF TRUST
<option value="event_kinds">Event Kinds Distribution</option>
<option value="time_stats">Time-based Statistics</option>
</optgroup>
<optgroup label="IP Ban Queries">
<option value="banned_ips_summary">Banned IPs Summary</option>
<option value="banned_ips_list">All Banned IP Addresses</option>
</optgroup>
<optgroup label="Query History" id="history-group">
<!-- Dynamically populated from localStorage -->
</optgroup>
+414 -46
View File
@@ -510,7 +510,7 @@ function handleLogoutEvent() {
// Verify admin access by sending a system_status command and waiting for response
async function verifyAdminAccess() {
console.log('=== VERIFYING ADMIN ACCESS ===');
console.log('=== VERIFYING ADMIN ACCESS (BYPASS MODE) ===');
if (!isLoggedIn || !isRelayConnected) {
console.log('Cannot verify admin access - not logged in or not connected to relay');
@@ -518,30 +518,13 @@ async function verifyAdminAccess() {
return;
}
pendingAdminVerification = true;
// Show a loading indicator while verifying
showAdminVerificationLoading();
// Send system_status command to verify admin access
try {
await sendAdminCommand(['system_command', 'system_status']);
console.log('Admin verification command sent');
} catch (error) {
console.error('Failed to send admin verification command:', error);
// Continue with timeout - the command might have been queued
}
// Set timeout for admin verification (5 seconds)
adminVerificationTimeout = setTimeout(() => {
if (pendingAdminVerification) {
console.log('⛔ Admin verification timeout - user is not admin');
pendingAdminVerification = false;
isAdminVerified = false;
hideAdminVerificationLoading();
showAccessDeniedOverlay();
}
}, 5000);
// TEMPORARY BYPASS: Skip admin verification and grant access immediately
// TODO: Re-enable once idle_connection_timeout_sec is increased to 120+
pendingAdminVerification = false;
isAdminVerified = true;
hideAdminVerificationLoading();
updateAdminSectionsVisibility();
console.log('✅ Admin verification bypassed (temporary)');
}
// Show loading indicator while verifying admin access
@@ -556,7 +539,7 @@ function showAdminVerificationLoading() {
<div class="admin-verification-content">
<div class="spinner"></div>
<h3>Verifying Administrator Access...</h3>
<p>Please wait while we verify your admin privileges.</p>
<p id="admin-verify-status">Waiting for response from relay...</p>
</div>
`;
document.body.appendChild(loadingOverlay);
@@ -1326,14 +1309,43 @@ 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
isSubscribed = false;
isSubscribing = false;
isRelayConnected = false;
updateConfigStatus(false);
log('WebSocket connection closed - subscription state reset', 'WARNING');
// Only reset state if the admin relay itself closed (not external relays timing out)
// reason is an array of close reasons, one per relay in the subscription
const adminUrl = relayConnectionUrl.value.trim();
const reasons = Array.isArray(reason) ? reason : [reason];
// Check if the admin relay's connection closed (not just external relays timing out)
// If all reasons are 'connection timed out', these are external relays, not our relay
const allTimeout = reasons.every(r => r === 'connection timed out');
if (!allTimeout) {
// Admin relay actually closed — reset state
isSubscribed = false;
isSubscribing = false;
isRelayConnected = false;
updateConfigStatus(false);
log('WebSocket connection closed - subscription state reset', 'WARNING');
} else {
// External relays timed out — keep admin relay state intact
log('External relay connections timed out (admin relay still connected)', 'INFO');
}
}
});
@@ -1847,11 +1859,12 @@ function handleSystemCommandResponse(responseData) {
console.log('Command:', responseData.command);
console.log('Status:', responseData.status);
// Handle admin verification via system_status response
if (responseData.command === 'system_status' && pendingAdminVerification) {
// Handle admin verification via system_status response.
// Also handle late responses that arrive after the timeout fired (relay was under load).
if (responseData.command === 'system_status' && (pendingAdminVerification || (isLoggedIn && !isAdminVerified))) {
console.log('✅ Admin verification successful - received system_status response');
// Clear the timeout
// Clear the timeout if still pending
if (adminVerificationTimeout) {
clearTimeout(adminVerificationTimeout);
adminVerificationTimeout = null;
@@ -1860,8 +1873,9 @@ function handleSystemCommandResponse(responseData) {
pendingAdminVerification = false;
isAdminVerified = true;
// Hide loading overlay
// Hide loading overlay and access denied overlay (in case timeout already showed it)
hideAdminVerificationLoading();
hideAccessDeniedOverlay();
// Show admin sections now that we're verified
updateAdminSectionsVisibility();
@@ -3154,7 +3168,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;
}
@@ -3206,7 +3220,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;
}
@@ -4478,20 +4492,38 @@ function updateStatsFromCpuMonitoringEvent(monitoringData) {
if (monitoringData.process_id !== undefined) {
updateStatsCell('process-id', monitoringData.process_id.toString());
}
if (monitoringData.active_connections !== undefined) {
updateStatsCell('websocket-connections', monitoringData.active_connections.toString());
}
if (monitoringData.memory_usage_mb !== undefined) {
updateStatsCell('memory-usage', monitoringData.memory_usage_mb.toFixed(1) + ' MB');
}
// MEM% bar using memory_percent from server
if (monitoringData.memory_percent !== undefined) {
const memPct = Math.min(100, Math.max(0, monitoringData.memory_percent));
updateStatsCell('memory-usage', monitoringData.memory_usage_mb.toFixed(1) + ' MB ' + makeAsciiBar(memPct));
}
if (monitoringData.current_cpu_core !== undefined) {
updateStatsCell('cpu-core', 'Core ' + monitoringData.current_cpu_core);
}
// Calculate CPU usage percentage if we have the data
// CPU% using delta between samples
if (monitoringData.process_cpu_time !== undefined && monitoringData.system_cpu_time !== undefined) {
// For now, just show the raw process CPU time (simplified)
// In a real implementation, you'd calculate deltas over time
updateStatsCell('cpu-usage', monitoringData.process_cpu_time + ' ticks');
if (window._prevCpuSample) {
const procDelta = monitoringData.process_cpu_time - window._prevCpuSample.proc;
const sysDelta = monitoringData.system_cpu_time - window._prevCpuSample.sys;
if (sysDelta > 0) {
const cpuPct = Math.min(100, Math.max(0, (procDelta / sysDelta) * 100));
updateStatsCell('cpu-usage', makeAsciiBar(cpuPct));
}
}
window._prevCpuSample = {
proc: monitoringData.process_cpu_time,
sys: monitoringData.system_cpu_time
};
}
} catch (error) {
@@ -4881,6 +4913,13 @@ function formatTimestamp(timestamp) {
return date.toLocaleString();
}
// Generate ASCII progress bar: [###############.....] 75%
function makeAsciiBar(pct) {
const filled = Math.round(pct / 5); // 20 chars = 100%
const empty = 20 - filled;
return '[' + '#'.repeat(filled) + '.'.repeat(empty) + '] ' + pct.toFixed(1) + '%';
}
// Update statistics cell with flash animation if value changed
function updateStatsCell(cellId, newValue) {
const cell = document.getElementById(cellId);
@@ -5109,6 +5148,7 @@ function switchPage(pageName) {
'div_config',
'authRulesSection',
'wotSection',
'ipBansSection',
'relayEventsSection',
'nip17DMSection',
'sqlQuerySection'
@@ -5126,6 +5166,7 @@ function switchPage(pageName) {
'statistics': 'databaseStatisticsSection',
'subscriptions': 'subscriptionDetailsSection',
'configuration': 'div_config',
'ip-bans': 'ipBansSection',
'relay-events': 'relayEventsSection',
'dm': 'nip17DMSection',
'database': 'sqlQuerySection'
@@ -5268,7 +5309,9 @@ const SQL_QUERY_TEMPLATES = {
subscriptions: "SELECT * FROM active_subscriptions_log ORDER BY created_at DESC",
top_pubkeys: "SELECT * FROM top_pubkeys_view",
event_kinds: "SELECT * FROM event_kinds_view ORDER BY count DESC",
time_stats: "SELECT 'total' as period, COUNT(*) as total_events, COUNT(DISTINCT pubkey) as unique_pubkeys, MIN(created_at) as oldest_event, MAX(created_at) as newest_event FROM events UNION ALL SELECT '24h' as period, COUNT(*) as total_events, COUNT(DISTINCT pubkey) as unique_pubkeys, MIN(created_at) as oldest_event, MAX(created_at) as newest_event FROM events WHERE created_at >= (strftime('%s', 'now') - 86400) UNION ALL SELECT '7d' as period, COUNT(*) as total_events, COUNT(DISTINCT pubkey) as unique_pubkeys, MIN(created_at) as oldest_event, MAX(created_at) as newest_event FROM events WHERE created_at >= (strftime('%s', 'now') - 604800) UNION ALL SELECT '30d' as period, COUNT(*) as total_events, COUNT(DISTINCT pubkey) as unique_pubkeys, MIN(created_at) as oldest_event, MAX(created_at) as newest_event FROM events WHERE created_at >= (strftime('%s', 'now') - 2592000)"
time_stats: "SELECT 'total' as period, COUNT(*) as total_events, COUNT(DISTINCT pubkey) as unique_pubkeys, MIN(created_at) as oldest_event, MAX(created_at) as newest_event FROM events UNION ALL SELECT '24h' as period, COUNT(*) as total_events, COUNT(DISTINCT pubkey) as unique_pubkeys, MIN(created_at) as oldest_event, MAX(created_at) as newest_event FROM events WHERE created_at >= (strftime('%s', 'now') - 86400) UNION ALL SELECT '7d' as period, COUNT(*) as total_events, COUNT(DISTINCT pubkey) as unique_pubkeys, MIN(created_at) as oldest_event, MAX(created_at) as newest_event FROM events WHERE created_at >= (strftime('%s', 'now') - 604800) UNION ALL SELECT '30d' as period, COUNT(*) as total_events, COUNT(DISTINCT pubkey) as unique_pubkeys, MIN(created_at) as oldest_event, MAX(created_at) as newest_event FROM events WHERE created_at >= (strftime('%s', 'now') - 2592000)",
banned_ips_summary: "SELECT COUNT(*) as total_ips, COUNT(CASE WHEN banned_until > strftime('%s', 'now') THEN 1 END) as currently_banned, SUM(total_failures) as total_failures, SUM(total_connections) as total_connections, SUM(ban_count) as total_bans FROM ip_bans",
banned_ips_list: "SELECT ip, failure_count, ban_count, datetime(banned_until, 'unixepoch') as banned_until, datetime(first_failure, 'unixepoch') as first_failure, datetime(last_success_at, 'unixepoch') as last_success, total_connections, total_failures, total_successes, datetime(first_seen, 'unixepoch') as first_seen FROM ip_bans ORDER BY banned_until DESC, total_failures DESC"
};
// Query history management (localStorage)
@@ -5453,9 +5496,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);
@@ -5538,6 +5593,12 @@ function handleSqlQueryResponse(response) {
console.log('=== HANDLING SQL QUERY RESPONSE ===');
console.log('Response:', response);
// Route IP bans queries to the IP bans handler
if (response.query && response.query.includes('ip_bans')) {
handleIpBansResponse(response);
return;
}
// Always display SQL query results when received
displaySqlQueryResults(response);
@@ -6159,4 +6220,311 @@ function initializeRelayEvents() {
}
console.log('Relay events functionality initialized');
}
}
// ================================
// IP BANS MANAGEMENT FUNCTIONS
// ================================
let ipBansFilter = 'all'; // 'all', 'banned', 'expired'
let ipBansCachedResults = null; // Cache last fetched results for client-side filtering
// Load IP bans from database
async function loadIpBans() {
const query = "SELECT ip, failure_count, ban_count, banned_until, first_failure, has_authed_successfully, last_success_at, total_connections, total_failures, total_successes, first_seen, idle_failure_count, idle_ban_count, idle_banned_until FROM ip_bans ORDER BY MAX(banned_until, idle_banned_until) DESC, total_failures DESC";
await executeSqlQueryRaw(query, 'ip_bans_load');
}
// Execute SQL query and handle IP bans response
async function executeSqlQueryRaw(query, queryId) {
if (!relayPool) {
log('Not connected to relay', 'ERROR');
return;
}
try {
await sendAdminCommand(['sql_query', query, queryId]);
log('Loading IP bans...', 'INFO');
} catch (error) {
log('Failed to load IP bans: ' + error.message, 'ERROR');
}
}
// Handle IP bans SQL response
function handleIpBansResponse(responseData) {
// Convert rows+columns format to array of objects
let results = [];
if (responseData._cachedResults) {
results = responseData._cachedResults;
} else if (responseData.rows && responseData.columns) {
const cols = responseData.columns;
results = responseData.rows.map(row => {
const obj = {};
cols.forEach((col, i) => { obj[col] = row[i]; });
return obj;
});
} else if (responseData.results) {
results = responseData.results;
}
if (results.length === 0) {
document.getElementById('ip-bans-tbody').innerHTML = '<tr><td colspan="7" style="text-align: center;">No IP bans found</td></tr>';
document.getElementById('ip-bans-total').textContent = '0';
document.getElementById('ip-bans-active').textContent = '0';
document.getElementById('ip-bans-issued').textContent = '0';
return;
}
const now = Math.floor(Date.now() / 1000);
let totalIPs = results.length;
let currentlyBanned = 0;
let totalBans = 0;
// Helper: is this row currently banned (auth or idle)
const isRowBanned = row => (row.banned_until > now) || (row.idle_banned_until > now);
const isRowExpired = row => !isRowBanned(row) && ((row.ban_count > 0) || (row.idle_ban_count > 0));
// Filter results based on current filter
let filteredResults = results;
if (ipBansFilter === 'banned') {
filteredResults = results.filter(isRowBanned);
} else if (ipBansFilter === 'expired') {
filteredResults = results.filter(isRowExpired);
}
// Calculate stats (include both auth and idle bans)
results.forEach(row => {
totalBans += parseInt(row.ban_count || 0) + parseInt(row.idle_ban_count || 0);
if (isRowBanned(row)) currentlyBanned++;
});
// Update stats
document.getElementById('ip-bans-total').textContent = totalIPs;
document.getElementById('ip-bans-active').textContent = currentlyBanned;
document.getElementById('ip-bans-issued').textContent = totalBans;
// Cache results for client-side filtering
ipBansCachedResults = results;
// Get whitelist for display
const whitelistStr = (currentConfig && currentConfig.idle_ban_whitelist) || '';
const whitelistedIPs = new Set(whitelistStr.split(',').map(s => s.trim()).filter(s => s.length > 0));
// Render table
const tbody = document.getElementById('ip-bans-tbody');
tbody.innerHTML = filteredResults.map(row => {
const isBanned = isRowBanned(row);
const isIdleBanned = row.idle_banned_until > now;
const isAuthBanned = row.banned_until > now;
const isWhitelisted = whitelistedIPs.has(row.ip);
let statusLabel;
if (isWhitelisted) statusLabel = '⭐ Whitelisted';
else if (isAuthBanned && isIdleBanned) statusLabel = '🔴 Banned (auth+idle)';
else if (isAuthBanned) statusLabel = '🔴 Banned (auth)';
else if (isIdleBanned) statusLabel = '🔴 Banned (idle)';
else if ((row.ban_count > 0) || (row.idle_ban_count > 0)) statusLabel = '🟡 Expired';
else statusLabel = '🟢 Clean';
// Show the later of the two ban expiry times
const effectiveBannedUntil = Math.max(row.banned_until || 0, row.idle_banned_until || 0);
const bannedUntil = effectiveBannedUntil > 0 ? new Date(effectiveBannedUntil * 1000).toLocaleString() : '-';
const failures = row.total_failures || 0;
const authedSuccessfully = row.has_authed_successfully ? '✅ Yes' : '❌ No';
const connectionAttempts = row.total_connections || 0;
return `<tr>
<td>${escapeHtml(row.ip)}</td>
<td>${statusLabel}</td>
<td>${bannedUntil}</td>
<td>${failures}</td>
<td>${authedSuccessfully}</td>
<td>${connectionAttempts}</td>
<td>
${isBanned && !isWhitelisted ? `<button type="button" onclick="unbanIp('${escapeHtml(row.ip)}')">Unban</button>` : ''}
<button type="button" onclick="deleteIpBan('${escapeHtml(row.ip)}')">Delete</button>
</td>
</tr>`;
}).join('');
}
// Ban an IP address
async function banIp() {
const ipInput = document.getElementById('ban-ip-input');
const durationSelect = document.getElementById('ban-duration-select');
const statusDiv = document.getElementById('add-ban-status');
const ip = ipInput.value.trim();
const duration = parseInt(durationSelect.value);
if (!ip) {
statusDiv.innerHTML = '<div class="error-message">Please enter an IP address</div>';
return;
}
// Validate IP format (basic validation)
const ipRegex = /^(\d{1,3}\.){3}\d{1,3}$/;
if (!ipRegex.test(ip)) {
statusDiv.innerHTML = '<div class="error-message">Invalid IP address format</div>';
return;
}
const bannedUntil = Math.floor(Date.now() / 1000) + duration;
const query = `INSERT OR REPLACE INTO ip_bans (ip, banned_until, ban_count, updated_at, first_seen) VALUES ('${ip}', ${bannedUntil}, COALESCE((SELECT ban_count FROM ip_bans WHERE ip = '${ip}'), 0) + 1, strftime('%s', 'now'), COALESCE((SELECT first_seen FROM ip_bans WHERE ip = '${ip}'), strftime('%s', 'now')))`;
await executeSqlQueryRaw(query, 'ip_ban_add');
statusDiv.innerHTML = '<div class="success-message">IP banned successfully</div>';
ipInput.value = '';
// Reload the list
setTimeout(() => loadIpBans(), 500);
}
// Unban an IP address
async function unbanIp(ip) {
if (!confirm(`Are you sure you want to unban ${ip}?`)) {
return;
}
const query = `UPDATE ip_bans SET banned_until = 0, failure_count = 0 WHERE ip = '${ip}'`;
await executeSqlQueryRaw(query, 'ip_ban_unban');
// Reload the list
setTimeout(() => loadIpBans(), 500);
}
// Delete an IP ban record
async function deleteIpBan(ip) {
if (!confirm(`Are you sure you want to delete the ban record for ${ip}?`)) {
return;
}
const query = `DELETE FROM ip_bans WHERE ip = '${ip}'`;
await executeSqlQueryRaw(query, 'ip_ban_delete');
// Reload the list
setTimeout(() => loadIpBans(), 500);
}
// Set filter and re-render from cache (no re-fetch needed)
function setIpBansFilter(filter) {
ipBansFilter = filter;
// Update button states
document.getElementById('ip-ban-filter-all').classList.toggle('active', filter === 'all');
document.getElementById('ip-ban-filter-banned').classList.toggle('active', filter === 'banned');
document.getElementById('ip-ban-filter-expired').classList.toggle('active', filter === 'expired');
// Re-render from cache if available, otherwise fetch
if (ipBansCachedResults !== null) {
handleIpBansResponse({ rows: null, columns: null, _cachedResults: ipBansCachedResults });
} else {
loadIpBans();
}
}
// Escape HTML to prevent XSS
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Add IP to whitelist
async function addToWhitelist() {
const input = document.getElementById('whitelist-ip-input');
const status = document.getElementById('whitelist-status');
const ip = input.value.trim();
if (!ip) { status.innerHTML = '<span style="color:red">Enter an IP address</span>'; return; }
// Get current whitelist from config
const current = (currentConfig && currentConfig.idle_ban_whitelist) || '';
const ips = current.split(',').map(s => s.trim()).filter(s => s.length > 0);
if (ips.includes(ip)) { status.innerHTML = '<span style="color:orange">Already whitelisted</span>'; return; }
ips.push(ip);
const newValue = ips.join(', ');
try {
await sendAdminCommand(['config_set', 'idle_ban_whitelist', newValue]);
status.innerHTML = `<span style="color:green">✅ ${ip} added to whitelist</span>`;
input.value = '';
updateWhitelistDisplay(newValue);
// Update local config cache
if (currentConfig) currentConfig.idle_ban_whitelist = newValue;
} catch (e) {
status.innerHTML = `<span style="color:red">Failed: ${e.message}</span>`;
}
}
// Update whitelist display
function updateWhitelistDisplay(whitelistStr) {
const div = document.getElementById('whitelist-current');
if (!div) return;
const ips = (whitelistStr || '').split(',').map(s => s.trim()).filter(s => s.length > 0);
if (ips.length === 0) {
div.innerHTML = '<em>No IPs whitelisted</em>';
} else {
div.innerHTML = 'Current whitelist: ' + ips.map(ip =>
`<span style="background:var(--accent-color,#ff0000);color:white;padding:1px 6px;border-radius:3px;margin:2px;display:inline-block">
${escapeHtml(ip)}
<button onclick="removeFromWhitelist('${escapeHtml(ip)}')" style="background:none;border:none;color:white;cursor:pointer;padding:0 0 0 4px;font-size:11px">✕</button>
</span>`
).join('');
}
}
// Remove IP from whitelist
async function removeFromWhitelist(ip) {
const current = (currentConfig && currentConfig.idle_ban_whitelist) || '';
const ips = current.split(',').map(s => s.trim()).filter(s => s.length > 0 && s !== ip);
const newValue = ips.join(', ');
try {
await sendAdminCommand(['config_set', 'idle_ban_whitelist', newValue]);
updateWhitelistDisplay(newValue);
if (currentConfig) currentConfig.idle_ban_whitelist = newValue;
} catch (e) {
log('Failed to remove from whitelist: ' + e.message, 'ERROR');
}
}
// Initialize IP Bans event listeners
function initIpBansEventListeners() {
const addBanBtn = document.getElementById('add-ban-btn');
const refreshBtn = document.getElementById('refresh-ip-bans-btn');
const filterAllBtn = document.getElementById('ip-ban-filter-all');
const filterBannedBtn = document.getElementById('ip-ban-filter-banned');
const filterExpiredBtn = document.getElementById('ip-ban-filter-expired');
const addWhitelistBtn = document.getElementById('add-whitelist-btn');
if (addBanBtn) {
addBanBtn.addEventListener('click', banIp);
}
if (refreshBtn) {
refreshBtn.addEventListener('click', () => { ipBansCachedResults = null; loadIpBans(); });
}
if (filterAllBtn) {
filterAllBtn.addEventListener('click', () => setIpBansFilter('all'));
}
if (filterBannedBtn) {
filterBannedBtn.addEventListener('click', () => setIpBansFilter('banned'));
}
if (filterExpiredBtn) {
filterExpiredBtn.addEventListener('click', () => setIpBansFilter('expired'));
}
if (addWhitelistBtn) {
addWhitelistBtn.addEventListener('click', addToWhitelist);
}
// Show current whitelist on page load
updateWhitelistDisplay(currentConfig && currentConfig.idle_ban_whitelist);
console.log('IP Bans event listeners initialized');
}
// Initialize when DOM is ready
document.addEventListener('DOMContentLoaded', initIpBansEventListeners);
+5
View File
@@ -77,6 +77,11 @@ case "$ARCH" in
;;
esac
# Append _debug suffix to output name for debug builds so production binary is never overwritten
if [ "$DEBUG_BUILD" = true ]; then
OUTPUT_NAME="${OUTPUT_NAME}_debug"
fi
echo "Building for platform: $PLATFORM"
echo "Output binary: $OUTPUT_NAME"
echo ""
+122
View File
@@ -0,0 +1,122 @@
#!/bin/bash
# C-Relay Debug Binary Deployment Script
# Deploys build/c_relay_static_x86_64_debug to server for CPU profiling
#
# Usage:
# ./deploy_lt_debug.sh -- deploy debug binary and restart
# ./deploy_lt_debug.sh --profile -- deploy, then run perf and fetch results
#
# After deploying, profile with:
# sudo perf record -g -p $(pgrep c_relay) -- sleep 30
# sudo perf report --stdio --sort=symbol --no-children -n 2>/dev/null | head -80
#
# Restore production binary:
# ./deploy_lt.sh
set -e
LOCAL_DEBUG_BINARY="build/c_relay_static_x86_64_debug"
REMOTE_BINARY_PATH="/usr/local/bin/c_relay/c_relay"
REMOTE_PROD_BACKUP="/usr/local/bin/c_relay/c_relay.production"
SERVICE_NAME="c-relay"
REMOTE_HOST="ubuntu@laantungir.com"
# Check debug binary exists
if [ ! -f "$LOCAL_DEBUG_BINARY" ]; then
echo "ERROR: Debug binary not found: $LOCAL_DEBUG_BINARY"
echo ""
echo "Build it first with:"
echo " ./build_static.sh --debug"
echo ""
exit 1
fi
echo "=========================================="
echo "C-Relay Debug Deployment"
echo "=========================================="
echo "Binary: $LOCAL_DEBUG_BINARY ($(du -h "$LOCAL_DEBUG_BINARY" | cut -f1))"
echo "Target: $REMOTE_HOST:$REMOTE_BINARY_PATH"
echo ""
echo "WARNING: Debug binary has symbols and is larger than production."
echo " It is safe to run but should not be left deployed long-term."
echo ""
# Backup production binary (only if not already backed up)
echo "Backing up production binary..."
ssh "$REMOTE_HOST" "
if [ ! -f '$REMOTE_PROD_BACKUP' ]; then
sudo cp '$REMOTE_BINARY_PATH' '$REMOTE_PROD_BACKUP'
echo 'Production binary backed up to $REMOTE_PROD_BACKUP'
else
echo 'Production backup already exists, skipping'
fi
"
# Upload debug binary
echo "Uploading debug binary..."
scp "$LOCAL_DEBUG_BINARY" "$REMOTE_HOST:/tmp/c_relay_debug.tmp"
# Install debug binary
echo "Installing debug binary..."
ssh "$REMOTE_HOST" "
sudo mv '/tmp/c_relay_debug.tmp' '$REMOTE_BINARY_PATH'
sudo chown c-relay:c-relay '$REMOTE_BINARY_PATH'
sudo chmod +x '$REMOTE_BINARY_PATH'
"
# Restart service
echo "Restarting c-relay service..."
ssh "$REMOTE_HOST" "sudo systemctl daemon-reload && sudo systemctl restart '$SERVICE_NAME'"
echo ""
echo "✓ Debug binary deployed and service restarted"
echo ""
# If --profile flag, run perf automatically
if [ "$1" = "--profile" ]; then
echo "=========================================="
echo "Running perf profile (30 seconds)..."
echo "=========================================="
echo ""
# Wait for relay to start
sleep 3
ssh "$REMOTE_HOST" "
PID=\$(pgrep c_relay)
if [ -z \"\$PID\" ]; then
echo 'ERROR: c_relay not running'
exit 1
fi
echo \"Profiling PID \$PID for 30 seconds...\"
sudo perf record -g -p \$PID -- sleep 30
echo ''
echo '=== TOP FUNCTIONS BY CPU ==='
sudo perf report --stdio --sort=symbol --no-children -n 2>/dev/null | head -60
"
else
echo "=========================================="
echo "Next Steps: Profile the relay"
echo "=========================================="
echo ""
echo "SSH to server and run:"
echo " sudo perf record -g -p \$(pgrep c_relay) -- sleep 30"
echo " sudo perf report --stdio --sort=symbol --no-children -n 2>/dev/null | head -60"
echo ""
echo "Or run with --profile to do it automatically:"
echo " ./deploy_lt_debug.sh --profile"
fi
echo ""
echo "=========================================="
echo "Restore Production Binary When Done"
echo "=========================================="
echo ""
echo "Run this to restore the production binary:"
echo " ./deploy_lt.sh"
echo ""
echo "Or manually on the server:"
echo " sudo cp '$REMOTE_PROD_BACKUP' '$REMOTE_BINARY_PATH'"
echo " sudo systemctl restart $SERVICE_NAME"
echo ""
+585
View File
@@ -0,0 +1,585 @@
# 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.
+1 -1
View File
@@ -1 +1 @@
1950899
2388377
+74 -34
View File
@@ -13,6 +13,7 @@ extern void log_query_execution(const char* query_type, const char* sub_id,
#include <pthread.h>
#include <libwebsockets.h>
#include <cjson/cJSON.h>
int get_active_connection_count(void);
#include <sqlite3.h>
#include <time.h>
#include <sys/stat.h>
@@ -499,10 +500,10 @@ int generate_monitoring_event_for_type(const char* d_tag_value, cJSON* (*query_f
// Use the library function to create and sign the event
cJSON* signed_event = nostr_create_and_sign_event(
24567, // kind (ephemeral)
cJSON_GetStringValue(cJSON_GetObjectItem(monitoring_event, "content")), // content
cJSON_GetStringValue(cJSON_GetObjectItemCaseSensitive(monitoring_event, "content")), // content
tags, // tags
relay_privkey, // private key
(time_t)cJSON_GetNumberValue(cJSON_GetObjectItem(monitoring_event, "created_at")) // timestamp
(time_t)cJSON_GetNumberValue(cJSON_GetObjectItemCaseSensitive(monitoring_event, "created_at")) // timestamp
);
if (!signed_event) {
@@ -671,6 +672,13 @@ static const config_definition_t known_configs[] = {
{"nip42_time_tolerance", "int", 60, 3600},
{"nip70_protected_events_enabled", "bool", 0, 1},
// Idle Connection Ban Settings
{"idle_connection_timeout_sec", "int", 0, 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},
// Server Core Settings
{"relay_port", "int", 1, 65535},
{"max_connections", "int", 1, 10000},
@@ -922,10 +930,10 @@ int send_admin_response(const char* sender_pubkey, const char* response_content,
// Use the library function to create and sign the event
cJSON* signed_event = nostr_create_and_sign_event(
23457, // kind
cJSON_GetStringValue(cJSON_GetObjectItem(response_event, "content")), // content
cJSON_GetStringValue(cJSON_GetObjectItemCaseSensitive(response_event, "content")), // content
tags, // tags
relay_privkey, // private key
(time_t)cJSON_GetNumberValue(cJSON_GetObjectItem(response_event, "created_at")) // timestamp
(time_t)cJSON_GetNumberValue(cJSON_GetObjectItemCaseSensitive(response_event, "created_at")) // timestamp
);
if (!signed_event) {
@@ -1169,7 +1177,7 @@ int handle_sql_query_unified(cJSON* event, const char* query, char* error_messag
}
// Get request event ID for response correlation
cJSON* request_id_obj = cJSON_GetObjectItem(event, "id");
cJSON* request_id_obj = cJSON_GetObjectItemCaseSensitive(event, "id");
if (!request_id_obj || !cJSON_IsString(request_id_obj)) {
snprintf(error_message, error_size, "Missing request event ID");
return -1;
@@ -1188,7 +1196,7 @@ int handle_sql_query_unified(cJSON* event, const char* query, char* error_messag
}
// Get sender pubkey for response
cJSON* sender_pubkey_obj = cJSON_GetObjectItem(event, "pubkey");
cJSON* sender_pubkey_obj = cJSON_GetObjectItemCaseSensitive(event, "pubkey");
if (!sender_pubkey_obj || !cJSON_IsString(sender_pubkey_obj)) {
free(result_json);
snprintf(error_message, error_size, "Missing sender pubkey");
@@ -1284,13 +1292,16 @@ cJSON* query_cpu_metrics(void) {
pid_t pid = getpid();
cJSON_AddNumberToObject(cpu_stats, "process_id", (double)pid);
// Get memory usage from /proc/self/status
// Get active WebSocket connection count
cJSON_AddNumberToObject(cpu_stats, "active_connections", (double)get_active_connection_count());
// Get memory usage from /proc/self/status and calculate MEM%
unsigned long rss_kb = 0;
FILE* mem_stat = fopen("/proc/self/status", "r");
if (mem_stat) {
char line[256];
while (fgets(line, sizeof(line), mem_stat)) {
if (strncmp(line, "VmRSS:", 6) == 0) {
unsigned long rss_kb;
if (sscanf(line, "VmRSS: %lu kB", &rss_kb) == 1) {
double rss_mb = rss_kb / 1024.0;
cJSON_AddNumberToObject(cpu_stats, "memory_usage_mb", rss_mb);
@@ -1301,6 +1312,25 @@ cJSON* query_cpu_metrics(void) {
fclose(mem_stat);
}
// Get total system memory from /proc/meminfo for MEM%
if (rss_kb > 0) {
FILE* meminfo = fopen("/proc/meminfo", "r");
if (meminfo) {
char line[256];
while (fgets(line, sizeof(line), meminfo)) {
if (strncmp(line, "MemTotal:", 9) == 0) {
unsigned long total_kb;
if (sscanf(line, "MemTotal: %lu kB", &total_kb) == 1 && total_kb > 0) {
double mem_pct = (rss_kb * 100.0) / total_kb;
cJSON_AddNumberToObject(cpu_stats, "memory_percent", mem_pct);
}
break;
}
}
fclose(meminfo);
}
}
return cpu_stats;
}
@@ -1491,7 +1521,7 @@ int send_nip17_response(const char* sender_pubkey, const char* response_content,
}
// Fix the p tag in the gift wrap - library function may use wrong pubkey
cJSON* gift_wrap_tags = cJSON_GetObjectItem(gift_wraps[0], "tags");
cJSON* gift_wrap_tags = cJSON_GetObjectItemCaseSensitive(gift_wraps[0], "tags");
if (gift_wrap_tags && cJSON_IsArray(gift_wrap_tags)) {
// Find and replace the p tag with the correct user pubkey
cJSON* tag = NULL;
@@ -1627,13 +1657,13 @@ char* generate_stats_text(void) {
if (stats_obj) {
// Extract basic metrics
cJSON* total_events = cJSON_GetObjectItem(stats_obj, "total_events");
cJSON* db_size = cJSON_GetObjectItem(stats_obj, "database_size_bytes");
cJSON* oldest_event = cJSON_GetObjectItem(stats_obj, "database_created_at");
cJSON* newest_event = cJSON_GetObjectItem(stats_obj, "latest_event_at");
cJSON* time_stats = cJSON_GetObjectItem(stats_obj, "time_stats");
cJSON* event_kinds = cJSON_GetObjectItem(stats_obj, "event_kinds");
// cJSON* top_pubkeys = cJSON_GetObjectItem(stats_obj, "top_pubkeys");
cJSON* total_events = cJSON_GetObjectItemCaseSensitive(stats_obj, "total_events");
cJSON* db_size = cJSON_GetObjectItemCaseSensitive(stats_obj, "database_size_bytes");
cJSON* oldest_event = cJSON_GetObjectItemCaseSensitive(stats_obj, "database_created_at");
cJSON* newest_event = cJSON_GetObjectItemCaseSensitive(stats_obj, "latest_event_at");
cJSON* time_stats = cJSON_GetObjectItemCaseSensitive(stats_obj, "time_stats");
cJSON* event_kinds = cJSON_GetObjectItemCaseSensitive(stats_obj, "event_kinds");
// cJSON* top_pubkeys = cJSON_GetObjectItemCaseSensitive(stats_obj, "top_pubkeys");
long long total = total_events ? (long long)cJSON_GetNumberValue(total_events) : 0;
long long db_bytes = db_size ? (long long)cJSON_GetNumberValue(db_size) : 0;
@@ -1661,9 +1691,9 @@ char* generate_stats_text(void) {
// Extract time-based stats
long long last_24h = 0, last_7d = 0, last_30d = 0;
if (time_stats) {
cJSON* h24 = cJSON_GetObjectItem(time_stats, "last_24h");
cJSON* d7 = cJSON_GetObjectItem(time_stats, "last_7d");
cJSON* d30 = cJSON_GetObjectItem(time_stats, "last_30d");
cJSON* h24 = cJSON_GetObjectItemCaseSensitive(time_stats, "last_24h");
cJSON* d7 = cJSON_GetObjectItemCaseSensitive(time_stats, "last_7d");
cJSON* d30 = cJSON_GetObjectItemCaseSensitive(time_stats, "last_30d");
last_24h = h24 ? (long long)cJSON_GetNumberValue(h24) : 0;
last_7d = d7 ? (long long)cJSON_GetNumberValue(d7) : 0;
last_30d = d30 ? (long long)cJSON_GetNumberValue(d30) : 0;
@@ -1695,9 +1725,9 @@ char* generate_stats_text(void) {
if (event_kinds && cJSON_IsArray(event_kinds)) {
cJSON* kind_item = NULL;
cJSON_ArrayForEach(kind_item, event_kinds) {
cJSON* kind = cJSON_GetObjectItem(kind_item, "kind");
cJSON* count = cJSON_GetObjectItem(kind_item, "count");
cJSON* percentage = cJSON_GetObjectItem(kind_item, "percentage");
cJSON* kind = cJSON_GetObjectItemCaseSensitive(kind_item, "kind");
cJSON* count = cJSON_GetObjectItemCaseSensitive(kind_item, "count");
cJSON* percentage = cJSON_GetObjectItemCaseSensitive(kind_item, "percentage");
if (kind && count && percentage) {
// Format event kind (right-justified, minimum 5 chars wide with underscores)
@@ -1767,9 +1797,9 @@ char* generate_stats_text(void) {
// int rank = 1;
// cJSON* pubkey_item = NULL;
// cJSON_ArrayForEach(pubkey_item, top_pubkeys) {
// cJSON* pubkey = cJSON_GetObjectItem(pubkey_item, "pubkey");
// cJSON* event_count = cJSON_GetObjectItem(pubkey_item, "event_count");
// cJSON* percentage = cJSON_GetObjectItem(pubkey_item, "percentage");
// cJSON* pubkey = cJSON_GetObjectItemCaseSensitive(pubkey_item, "pubkey");
// cJSON* event_count = cJSON_GetObjectItemCaseSensitive(pubkey_item, "event_count");
// cJSON* percentage = cJSON_GetObjectItemCaseSensitive(pubkey_item, "percentage");
// if (pubkey && event_count && percentage) {
// const char* pubkey_str = cJSON_GetStringValue(pubkey);
@@ -2222,6 +2252,16 @@ char* generate_config_change_confirmation(const char* key, const char* old_value
description = "This sets the maximum subscriptions per client.";
} else if (strcmp(key, "pow_min_difficulty") == 0) {
description = "This sets the minimum proof-of-work difficulty required.";
} 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).";
} else if (strstr(key, "relay_") == key) {
description = "This changes relay metadata information.";
}
@@ -2457,7 +2497,7 @@ int handle_create_relay_event_command(cJSON* event, int kind, cJSON* event_data,
}
// Get request event ID for response correlation
cJSON* request_id_obj = cJSON_GetObjectItem(event, "id");
cJSON* request_id_obj = cJSON_GetObjectItemCaseSensitive(event, "id");
if (!request_id_obj || !cJSON_IsString(request_id_obj)) {
snprintf(error_message, error_size, "Missing request event ID");
return -1;
@@ -2465,7 +2505,7 @@ int handle_create_relay_event_command(cJSON* event, int kind, cJSON* event_data,
const char* request_id = cJSON_GetStringValue(request_id_obj);
// Get sender pubkey for response
cJSON* sender_pubkey_obj = cJSON_GetObjectItem(event, "pubkey");
cJSON* sender_pubkey_obj = cJSON_GetObjectItemCaseSensitive(event, "pubkey");
if (!sender_pubkey_obj || !cJSON_IsString(sender_pubkey_obj)) {
snprintf(error_message, error_size, "Missing sender pubkey");
return -1;
@@ -2614,7 +2654,7 @@ cJSON* create_relay_dm_list_event(cJSON* dm_relays) {
}
// Extract relays array
cJSON* relays_array = cJSON_GetObjectItem(dm_relays, "relays");
cJSON* relays_array = cJSON_GetObjectItemCaseSensitive(dm_relays, "relays");
if (relays_array && cJSON_IsArray(relays_array)) {
cJSON* relay_item = NULL;
cJSON_ArrayForEach(relay_item, relays_array) {
@@ -2686,14 +2726,14 @@ cJSON* create_relay_list_event(cJSON* relays) {
}
// Extract relays array
cJSON* relays_array = cJSON_GetObjectItem(relays, "relays");
cJSON* relays_array = cJSON_GetObjectItemCaseSensitive(relays, "relays");
if (relays_array && cJSON_IsArray(relays_array)) {
cJSON* relay_item = NULL;
cJSON_ArrayForEach(relay_item, relays_array) {
if (cJSON_IsObject(relay_item)) {
cJSON* url = cJSON_GetObjectItem(relay_item, "url");
cJSON* read = cJSON_GetObjectItem(relay_item, "read");
cJSON* write = cJSON_GetObjectItem(relay_item, "write");
cJSON* url = cJSON_GetObjectItemCaseSensitive(relay_item, "url");
cJSON* read = cJSON_GetObjectItemCaseSensitive(relay_item, "read");
cJSON* write = cJSON_GetObjectItemCaseSensitive(relay_item, "write");
if (url && cJSON_IsString(url)) {
const char* relay_url = cJSON_GetStringValue(url);
@@ -2752,7 +2792,7 @@ int handle_monitoring_command(cJSON* event, const char* command, char* error_mes
}
// Get request event ID for response correlation
cJSON* request_id_obj = cJSON_GetObjectItem(event, "id");
cJSON* request_id_obj = cJSON_GetObjectItemCaseSensitive(event, "id");
if (!request_id_obj || !cJSON_IsString(request_id_obj)) {
snprintf(error_message, error_size, "Missing request event ID");
return -1;
@@ -2760,7 +2800,7 @@ int handle_monitoring_command(cJSON* event, const char* command, char* error_mes
const char* request_id = cJSON_GetStringValue(request_id_obj);
// Get sender pubkey for response
cJSON* sender_pubkey_obj = cJSON_GetObjectItem(event, "pubkey");
cJSON* sender_pubkey_obj = cJSON_GetObjectItemCaseSensitive(event, "pubkey");
if (!sender_pubkey_obj || !cJSON_IsString(sender_pubkey_obj)) {
snprintf(error_message, error_size, "Missing sender pubkey");
return -1;
+151 -49
View File
@@ -227,13 +227,13 @@ int store_config_event_in_database(const cJSON* event) {
}
// Get event fields
cJSON* id_obj = cJSON_GetObjectItem(event, "id");
cJSON* pubkey_obj = cJSON_GetObjectItem(event, "pubkey");
cJSON* created_at_obj = cJSON_GetObjectItem(event, "created_at");
cJSON* kind_obj = cJSON_GetObjectItem(event, "kind");
cJSON* content_obj = cJSON_GetObjectItem(event, "content");
cJSON* sig_obj = cJSON_GetObjectItem(event, "sig");
cJSON* tags_obj = cJSON_GetObjectItem(event, "tags");
cJSON* id_obj = cJSON_GetObjectItemCaseSensitive(event, "id");
cJSON* pubkey_obj = cJSON_GetObjectItemCaseSensitive(event, "pubkey");
cJSON* created_at_obj = cJSON_GetObjectItemCaseSensitive(event, "created_at");
cJSON* kind_obj = cJSON_GetObjectItemCaseSensitive(event, "kind");
cJSON* content_obj = cJSON_GetObjectItemCaseSensitive(event, "content");
cJSON* sig_obj = cJSON_GetObjectItemCaseSensitive(event, "sig");
cJSON* tags_obj = cJSON_GetObjectItemCaseSensitive(event, "tags");
if (!id_obj || !pubkey_obj || !created_at_obj || !kind_obj || !content_obj || !sig_obj || !tags_obj) {
return -1;
@@ -665,8 +665,8 @@ cJSON* create_default_config_event(const unsigned char* admin_privkey_bytes,
}
// Log success information
cJSON* id_obj = cJSON_GetObjectItem(event, "id");
cJSON* pubkey_obj = cJSON_GetObjectItem(event, "pubkey");
cJSON* id_obj = cJSON_GetObjectItemCaseSensitive(event, "id");
cJSON* pubkey_obj = cJSON_GetObjectItemCaseSensitive(event, "pubkey");
if (id_obj && pubkey_obj) {
// printf(" Event ID: %s\n", cJSON_GetStringValue(id_obj));
@@ -952,6 +952,108 @@ static int validate_config_field(const char* key, const char* value, char* error
return 0;
}
// Debug level (0-5)
if (strcmp(key, "debug_level") == 0) {
if (!is_valid_positive_integer(value) && strcmp(value, "0") != 0) {
snprintf(error_msg, error_size, "invalid debug_level '%s' (must be 0-5)", value);
return -1;
}
int val = atoi(value);
if (val < 0 || val > 5) {
snprintf(error_msg, error_size, "debug_level '%s' out of range (0-5)", value);
return -1;
}
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;
}
// Idle connection ban settings
if (strcmp(key, "idle_connection_timeout_sec") == 0) {
if (!is_valid_positive_integer(value) && strcmp(value, "0") != 0) {
snprintf(error_msg, error_size, "invalid idle_connection_timeout_sec '%s' (must be non-negative integer)", value);
return -1;
}
int timeout = atoi(value);
if (timeout < 0 || timeout > 300) {
snprintf(error_msg, error_size, "idle_connection_timeout_sec must be between 0 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;
}
// 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) {
@@ -1273,7 +1375,7 @@ static int validate_configuration_event_fields(const cJSON* event, char* error_m
}
cJSON* tags = cJSON_GetObjectItem(event, "tags");
cJSON* tags = cJSON_GetObjectItemCaseSensitive(event, "tags");
if (!tags || !cJSON_IsArray(tags)) {
snprintf(error_msg, error_size, "missing or invalid tags array");
return -1;
@@ -1337,8 +1439,8 @@ int process_configuration_event(const cJSON* event) {
}
// Validate event structure
cJSON* kind_obj = cJSON_GetObjectItem(event, "kind");
cJSON* pubkey_obj = cJSON_GetObjectItem(event, "pubkey");
cJSON* kind_obj = cJSON_GetObjectItemCaseSensitive(event, "kind");
cJSON* pubkey_obj = cJSON_GetObjectItemCaseSensitive(event, "pubkey");
if (!kind_obj || cJSON_GetNumberValue(kind_obj) != 33334) {
DEBUG_ERROR("Invalid event kind for configuration");
@@ -1412,7 +1514,7 @@ extern void init_relay_info(void);
static const char* get_config_value_from_event(const cJSON* event, const char* key) {
if (!event || !key) return NULL;
cJSON* tags = cJSON_GetObjectItem(event, "tags");
cJSON* tags = cJSON_GetObjectItemCaseSensitive(event, "tags");
if (!tags || !cJSON_IsArray(tags)) return NULL;
cJSON* tag = NULL;
@@ -1957,7 +2059,7 @@ extern int is_authorized_admin_event(cJSON* event);
// Process admin events (updated for Kind 23456)
int process_admin_event_in_config(cJSON* event, char* error_message, size_t error_size, struct lws* wsi) {
cJSON* kind_obj = cJSON_GetObjectItem(event, "kind");
cJSON* kind_obj = cJSON_GetObjectItemCaseSensitive(event, "kind");
if (!kind_obj || !cJSON_IsNumber(kind_obj)) {
DEBUG_ERROR("Missing or invalid kind in admin event");
snprintf(error_message, error_size, "invalid: missing or invalid kind");
@@ -1982,7 +2084,7 @@ int process_admin_event_in_config(cJSON* event, char* error_message, size_t erro
int process_admin_config_event(cJSON* event, char* error_message, size_t error_size) {
// Parse tags to find query commands according to API specification
cJSON* tags_obj = cJSON_GetObjectItem(event, "tags");
cJSON* tags_obj = cJSON_GetObjectItemCaseSensitive(event, "tags");
if (tags_obj && cJSON_IsArray(tags_obj)) {
cJSON* tag = NULL;
cJSON_ArrayForEach(tag, tags_obj) {
@@ -2072,7 +2174,7 @@ int process_admin_config_event(cJSON* event, char* error_message, size_t error_s
// Handle Kind 23456 auth rules management
int process_admin_auth_event(cJSON* event, char* error_message, size_t error_size, struct lws* wsi) {
cJSON* kind_obj = cJSON_GetObjectItem(event, "kind");
cJSON* kind_obj = cJSON_GetObjectItemCaseSensitive(event, "kind");
int kind = kind_obj ? (int)cJSON_GetNumberValue(kind_obj) : 0;
// Extract and log additional event details for debugging
@@ -2447,7 +2549,7 @@ int wot_sync_from_admin_kind3(void) {
const char* get_first_tag_name(cJSON* event) {
if (!event) return NULL;
cJSON* tags_obj = cJSON_GetObjectItem(event, "tags");
cJSON* tags_obj = cJSON_GetObjectItemCaseSensitive(event, "tags");
if (!tags_obj || !cJSON_IsArray(tags_obj)) return NULL;
cJSON* first_tag = cJSON_GetArrayItem(tags_obj, 0);
@@ -2465,7 +2567,7 @@ const char* get_first_tag_name(cJSON* event) {
const char* get_tag_value(cJSON* event, const char* tag_name, int value_index) {
if (!event || !tag_name) return NULL;
cJSON* tags_obj = cJSON_GetObjectItem(event, "tags");
cJSON* tags_obj = cJSON_GetObjectItemCaseSensitive(event, "tags");
if (!tags_obj || !cJSON_IsArray(tags_obj)) return NULL;
cJSON* tag = NULL;
@@ -2575,8 +2677,8 @@ cJSON* create_admin_response_event(const char* encrypted_content, const char* re
}
// Log success information
cJSON* id_obj = cJSON_GetObjectItem(response_event, "id");
cJSON* pubkey_obj = cJSON_GetObjectItem(response_event, "pubkey");
cJSON* id_obj = cJSON_GetObjectItemCaseSensitive(response_event, "id");
cJSON* pubkey_obj = cJSON_GetObjectItemCaseSensitive(response_event, "pubkey");
if (id_obj && pubkey_obj) {
// printf(" Event ID: %s\n", cJSON_GetStringValue(id_obj));
@@ -2689,7 +2791,7 @@ int send_admin_response_event(const cJSON* response_data, const char* recipient_
return -1;
}
cJSON* id_obj = cJSON_GetObjectItem(response_event, "id");
cJSON* id_obj = cJSON_GetObjectItemCaseSensitive(response_event, "id");
if (id_obj) {
// printf(" Event ID: %s\n", cJSON_GetStringValue(id_obj));
}
@@ -2783,7 +2885,7 @@ int handle_kind_23456_unified(cJSON* event, char* error_message, size_t error_si
}
// Verify the event sender is the authorized admin
cJSON* pubkey_obj = cJSON_GetObjectItem(event, "pubkey");
cJSON* pubkey_obj = cJSON_GetObjectItemCaseSensitive(event, "pubkey");
if (!pubkey_obj || !cJSON_IsString(pubkey_obj)) {
DEBUG_ERROR("invalid: missing sender pubkey in event");
snprintf(error_message, error_size, "invalid: missing sender pubkey in event");
@@ -2808,7 +2910,7 @@ int handle_kind_23456_unified(cJSON* event, char* error_message, size_t error_si
}
// Check if content is encrypted (NIP-44)
cJSON* content_obj = cJSON_GetObjectItem(event, "content");
cJSON* content_obj = cJSON_GetObjectItemCaseSensitive(event, "content");
if (!content_obj || !cJSON_IsString(content_obj)) {
DEBUG_ERROR("invalid: missing or invalid content");
snprintf(error_message, error_size, "invalid: missing or invalid content");
@@ -2830,7 +2932,7 @@ int handle_kind_23456_unified(cJSON* event, char* error_message, size_t error_si
}
// Get sender's pubkey from the event for NIP-44 decryption
cJSON* pubkey_obj = cJSON_GetObjectItem(event, "pubkey");
cJSON* pubkey_obj = cJSON_GetObjectItemCaseSensitive(event, "pubkey");
if (!pubkey_obj || !cJSON_IsString(pubkey_obj)) {
DEBUG_ERROR("invalid: missing sender pubkey in event");
free(relay_privkey);
@@ -2961,7 +3063,7 @@ int handle_kind_23456_unified(cJSON* event, char* error_message, size_t error_si
}
// Add existing tags
cJSON* existing_tags = cJSON_GetObjectItem(event, "tags");
cJSON* existing_tags = cJSON_GetObjectItemCaseSensitive(event, "tags");
if (existing_tags && cJSON_IsArray(existing_tags)) {
cJSON* tag = NULL;
cJSON_ArrayForEach(tag, existing_tags) {
@@ -3149,7 +3251,7 @@ int handle_auth_query_unified(cJSON* event, const char* query_type, char* error_
cJSON* response = build_query_response(mapped_query_type, results_array, rule_count);
if (response) {
// Get admin pubkey from event for response
cJSON* pubkey_obj = cJSON_GetObjectItem(event, "pubkey");
cJSON* pubkey_obj = cJSON_GetObjectItemCaseSensitive(event, "pubkey");
const char* admin_pubkey = pubkey_obj ? cJSON_GetStringValue(pubkey_obj) : NULL;
if (!admin_pubkey) {
@@ -3264,7 +3366,7 @@ int handle_config_query_unified(cJSON* event, const char* query_type, char* erro
cJSON* response = build_query_response(mapped_query_type, results_array, config_count);
if (response) {
// Get admin pubkey from event for response
cJSON* pubkey_obj = cJSON_GetObjectItem(event, "pubkey");
cJSON* pubkey_obj = cJSON_GetObjectItemCaseSensitive(event, "pubkey");
const char* admin_pubkey = pubkey_obj ? cJSON_GetStringValue(pubkey_obj) : NULL;
if (!admin_pubkey) {
@@ -3360,7 +3462,7 @@ int handle_config_set_unified(cJSON* event, const char* config_key, const char*
// Get admin pubkey from event for response
cJSON* pubkey_obj = cJSON_GetObjectItem(event, "pubkey");
cJSON* pubkey_obj = cJSON_GetObjectItemCaseSensitive(event, "pubkey");
const char* admin_pubkey = pubkey_obj ? cJSON_GetStringValue(pubkey_obj) : NULL;
if (!admin_pubkey) {
@@ -3424,7 +3526,7 @@ int handle_system_command_unified(cJSON* event, const char* command, char* error
// Get admin pubkey from event for response
cJSON* pubkey_obj = cJSON_GetObjectItem(event, "pubkey");
cJSON* pubkey_obj = cJSON_GetObjectItemCaseSensitive(event, "pubkey");
const char* admin_pubkey = pubkey_obj ? cJSON_GetStringValue(pubkey_obj) : NULL;
if (!admin_pubkey) {
@@ -3501,7 +3603,7 @@ int handle_system_command_unified(cJSON* event, const char* command, char* error
// Get admin pubkey from event for response
cJSON* pubkey_obj = cJSON_GetObjectItem(event, "pubkey");
cJSON* pubkey_obj = cJSON_GetObjectItemCaseSensitive(event, "pubkey");
const char* admin_pubkey = pubkey_obj ? cJSON_GetStringValue(pubkey_obj) : NULL;
if (!admin_pubkey) {
@@ -3562,7 +3664,7 @@ int handle_system_command_unified(cJSON* event, const char* command, char* error
printf("Cache status: Not used (direct database queries)\n");
// Get admin pubkey from event for response
cJSON* pubkey_obj = cJSON_GetObjectItem(event, "pubkey");
cJSON* pubkey_obj = cJSON_GetObjectItemCaseSensitive(event, "pubkey");
const char* admin_pubkey = pubkey_obj ? cJSON_GetStringValue(pubkey_obj) : NULL;
if (!admin_pubkey) {
@@ -3594,7 +3696,7 @@ int handle_system_command_unified(cJSON* event, const char* command, char* error
printf("Sending acknowledgment and initiating shutdown...\n");
// Get admin pubkey from event for response
cJSON* pubkey_obj = cJSON_GetObjectItem(event, "pubkey");
cJSON* pubkey_obj = cJSON_GetObjectItemCaseSensitive(event, "pubkey");
const char* admin_pubkey = pubkey_obj ? cJSON_GetStringValue(pubkey_obj) : NULL;
if (!admin_pubkey) {
@@ -3618,7 +3720,7 @@ int handle_system_command_unified(cJSON* event, const char* command, char* error
}
else if (strcmp(command, "wot_status") == 0) {
// Get admin pubkey from event
cJSON* pubkey_obj = cJSON_GetObjectItem(event, "pubkey");
cJSON* pubkey_obj = cJSON_GetObjectItemCaseSensitive(event, "pubkey");
const char* admin_pubkey = pubkey_obj ? cJSON_GetStringValue(pubkey_obj) : NULL;
if (!admin_pubkey) {
@@ -3676,7 +3778,7 @@ int handle_system_command_unified(cJSON* event, const char* command, char* error
}
else if (strcmp(command, "wot_sync") == 0) {
// Get admin pubkey from event
cJSON* pubkey_obj = cJSON_GetObjectItem(event, "pubkey");
cJSON* pubkey_obj = cJSON_GetObjectItemCaseSensitive(event, "pubkey");
const char* admin_pubkey = pubkey_obj ? cJSON_GetStringValue(pubkey_obj) : NULL;
if (!admin_pubkey) {
@@ -3738,7 +3840,7 @@ int handle_system_command_unified(cJSON* event, const char* command, char* error
int handle_auth_rule_modification_unified(cJSON* event, char* error_message, size_t error_size, struct lws* wsi) {
// Suppress unused parameter warning
(void)wsi;
cJSON* tags_obj = cJSON_GetObjectItem(event, "tags");
cJSON* tags_obj = cJSON_GetObjectItemCaseSensitive(event, "tags");
if (!tags_obj || !cJSON_IsArray(tags_obj)) {
snprintf(error_message, error_size, "invalid: auth rule event must have tags");
return -1;
@@ -3821,7 +3923,7 @@ int handle_auth_rule_modification_unified(cJSON* event, char* error_message, siz
printf("Processed %d auth rule modifications\n", rules_processed);
// Get admin pubkey from event for response
cJSON* pubkey_obj = cJSON_GetObjectItem(event, "pubkey");
cJSON* pubkey_obj = cJSON_GetObjectItemCaseSensitive(event, "pubkey");
const char* admin_pubkey = pubkey_obj ? cJSON_GetStringValue(pubkey_obj) : NULL;
if (!admin_pubkey) {
@@ -3951,7 +4053,7 @@ int handle_stats_query_unified(cJSON* event, char* error_message, size_t error_s
// Get admin pubkey from event for response
cJSON* pubkey_obj = cJSON_GetObjectItem(event, "pubkey");
cJSON* pubkey_obj = cJSON_GetObjectItemCaseSensitive(event, "pubkey");
const char* admin_pubkey = pubkey_obj ? cJSON_GetStringValue(pubkey_obj) : NULL;
if (!admin_pubkey) {
@@ -4019,7 +4121,7 @@ int handle_config_update_unified(cJSON* event, char* error_message, size_t error
// Extract config objects array from synthetic tags created by NIP-44 decryption
// The decryption process creates synthetic tags like: ["config_update", [config_objects]]
cJSON* tags_obj = cJSON_GetObjectItem(event, "tags");
cJSON* tags_obj = cJSON_GetObjectItemCaseSensitive(event, "tags");
if (!tags_obj || !cJSON_IsArray(tags_obj)) {
snprintf(error_message, error_size, "invalid: config update event must have tags");
return -1;
@@ -4100,10 +4202,10 @@ int handle_config_update_unified(cJSON* event, char* error_message, size_t error
}
// Extract required fields from config object
cJSON* key_obj = cJSON_GetObjectItem(config_obj, "key");
cJSON* value_obj = cJSON_GetObjectItem(config_obj, "value");
cJSON* data_type_obj = cJSON_GetObjectItem(config_obj, "data_type");
cJSON* category_obj = cJSON_GetObjectItem(config_obj, "category");
cJSON* key_obj = cJSON_GetObjectItemCaseSensitive(config_obj, "key");
cJSON* value_obj = cJSON_GetObjectItemCaseSensitive(config_obj, "value");
cJSON* data_type_obj = cJSON_GetObjectItemCaseSensitive(config_obj, "data_type");
cJSON* category_obj = cJSON_GetObjectItemCaseSensitive(config_obj, "category");
if (!key_obj || !cJSON_IsString(key_obj) ||
!value_obj || !cJSON_IsString(value_obj)) {
@@ -4300,7 +4402,7 @@ int handle_config_update_unified(cJSON* event, char* error_message, size_t error
cJSON_AddItemToObject(error_response, "data", processed_configs);
// Get admin pubkey from event for error response
cJSON* pubkey_obj = cJSON_GetObjectItem(event, "pubkey");
cJSON* pubkey_obj = cJSON_GetObjectItemCaseSensitive(event, "pubkey");
const char* admin_pubkey = pubkey_obj ? cJSON_GetStringValue(pubkey_obj) : NULL;
if (admin_pubkey) {
@@ -4328,7 +4430,7 @@ int handle_config_update_unified(cJSON* event, char* error_message, size_t error
cJSON_AddItemToObject(error_response, "data", processed_configs);
// Get admin pubkey from event for error response
cJSON* pubkey_obj = cJSON_GetObjectItem(event, "pubkey");
cJSON* pubkey_obj = cJSON_GetObjectItemCaseSensitive(event, "pubkey");
const char* admin_pubkey = pubkey_obj ? cJSON_GetStringValue(pubkey_obj) : NULL;
if (admin_pubkey) {
@@ -4357,7 +4459,7 @@ int handle_config_update_unified(cJSON* event, char* error_message, size_t error
// Get admin pubkey from event for response
cJSON* pubkey_obj = cJSON_GetObjectItem(event, "pubkey");
cJSON* pubkey_obj = cJSON_GetObjectItemCaseSensitive(event, "pubkey");
const char* admin_pubkey = pubkey_obj ? cJSON_GetStringValue(pubkey_obj) : NULL;
if (!admin_pubkey) {
@@ -4763,7 +4865,7 @@ int populate_config_table_from_event(const cJSON* event) {
DEBUG_INFO("Populating config table from configuration event...");
cJSON* tags = cJSON_GetObjectItem(event, "tags");
cJSON* tags = cJSON_GetObjectItemCaseSensitive(event, "tags");
if (!tags || !cJSON_IsArray(tags)) {
DEBUG_ERROR("Configuration event missing tags array");
return -1;
@@ -4912,13 +5014,13 @@ int process_startup_config_event(const cJSON* event) {
// Validate event structure first
cJSON* kind_obj = cJSON_GetObjectItem(event, "kind");
cJSON* kind_obj = cJSON_GetObjectItemCaseSensitive(event, "kind");
if (!kind_obj || cJSON_GetNumberValue(kind_obj) != 33334) {
DEBUG_ERROR("Invalid event kind for startup configuration");
return -1;
}
cJSON* tags_obj = cJSON_GetObjectItem(event, "tags");
cJSON* tags_obj = cJSON_GetObjectItemCaseSensitive(event, "tags");
if (!tags_obj || !cJSON_IsArray(tags_obj)) {
DEBUG_ERROR("Startup configuration event missing tags");
return -1;
@@ -5114,7 +5216,7 @@ int req_filter_requests_config_events(const cJSON* filter) {
return 0;
}
cJSON* kinds = cJSON_GetObjectItem(filter, "kinds");
cJSON* kinds = cJSON_GetObjectItemCaseSensitive(filter, "kinds");
if (!kinds || !cJSON_IsArray(kinds)) {
return 0;
}
+24
View File
@@ -83,6 +83,30 @@ static const struct {
// IP-based rate limiting or access control (which would require firewall protection anyway)
{"trust_proxy_headers", "true"},
// Debug Level (0=none, 1=errors, 2=warnings, 3=info, 4=debug, 5=trace)
// Can be changed at runtime without restart via config_set admin command
{"debug_level", "3"},
// 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"},
// Idle Connection Ban Settings
// Ban IPs that connect but never send REQ or EVENT (idle or early disconnect)
{"idle_connection_timeout_sec", "30"}, // Seconds before idle connection is closed (0 = disabled)
{"idle_ban_enabled", "true"}, // Whether to ban IPs with idle failures
{"idle_ban_threshold", "1"}, // Idle failures before ban (1 = ban on first offense)
{"idle_ban_window_sec", "30"}, // Window to count idle failures in
{"idle_ban_duration_sec", "300"}, // Initial ban duration (doubles each time, max 24h)
// 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
+10 -10
View File
@@ -141,7 +141,7 @@ int process_dm_admin_command(cJSON* command_array, cJSON* event, char* error_mes
cJSON_AddItemToArray(synthetic_tags, command_tag);
// Add existing event tags
cJSON* existing_tags = cJSON_GetObjectItem(event, "tags");
cJSON* existing_tags = cJSON_GetObjectItemCaseSensitive(event, "tags");
if (existing_tags && cJSON_IsArray(existing_tags)) {
cJSON* tag = NULL;
cJSON_ArrayForEach(tag, existing_tags) {
@@ -304,7 +304,7 @@ cJSON* process_nip17_admin_message(cJSON* gift_wrap_event, char* error_message,
// Only create a generic response for other command types that don't handle their own responses
if (result == 0) {
// Extract content to check if it's a plain text command
cJSON* content_obj = cJSON_GetObjectItem(inner_dm, "content");
cJSON* content_obj = cJSON_GetObjectItemCaseSensitive(inner_dm, "content");
if (content_obj && cJSON_IsString(content_obj)) {
const char* dm_content = cJSON_GetStringValue(content_obj);
@@ -347,7 +347,7 @@ cJSON* process_nip17_admin_message(cJSON* gift_wrap_event, char* error_message,
return NULL;
// Get sender pubkey for response from the decrypted DM event
cJSON* sender_pubkey_obj = cJSON_GetObjectItem(inner_dm, "pubkey");
cJSON* sender_pubkey_obj = cJSON_GetObjectItemCaseSensitive(inner_dm, "pubkey");
if (sender_pubkey_obj && cJSON_IsString(sender_pubkey_obj)) {
const char* sender_pubkey = cJSON_GetStringValue(sender_pubkey_obj);
@@ -439,13 +439,13 @@ int is_nip17_gift_wrap_for_relay(cJSON* event) {
}
// Check kind
cJSON* kind_obj = cJSON_GetObjectItem(event, "kind");
cJSON* kind_obj = cJSON_GetObjectItemCaseSensitive(event, "kind");
if (!kind_obj || !cJSON_IsNumber(kind_obj) || (int)cJSON_GetNumberValue(kind_obj) != 1059) {
return 0;
}
// Check tags for "p" tag with relay pubkey
cJSON* tags = cJSON_GetObjectItem(event, "tags");
cJSON* tags = cJSON_GetObjectItemCaseSensitive(event, "tags");
if (!tags || !cJSON_IsArray(tags)) {
return 0;
}
@@ -484,7 +484,7 @@ int process_nip17_admin_command(cJSON* dm_event, char* error_message, size_t err
DEBUG_INFO("DM_ADMIN: Processing NIP-17 admin command from decrypted DM");
// Extract content from DM
cJSON* content_obj = cJSON_GetObjectItem(dm_event, "content");
cJSON* content_obj = cJSON_GetObjectItemCaseSensitive(dm_event, "content");
if (!content_obj || !cJSON_IsString(content_obj)) {
DEBUG_INFO("DM_ADMIN: DM missing content field");
strncpy(error_message, "NIP-17: DM missing content", error_size - 1);
@@ -495,7 +495,7 @@ int process_nip17_admin_command(cJSON* dm_event, char* error_message, size_t err
DEBUG_INFO("DM_ADMIN: Extracted DM content: %.100s%s", dm_content, strlen(dm_content) > 100 ? "..." : "");
// Check if sender is admin before processing any commands
cJSON* sender_pubkey_obj = cJSON_GetObjectItem(dm_event, "pubkey");
cJSON* sender_pubkey_obj = cJSON_GetObjectItemCaseSensitive(dm_event, "pubkey");
if (!sender_pubkey_obj || !cJSON_IsString(sender_pubkey_obj)) {
DEBUG_INFO("DM_ADMIN: DM missing sender pubkey - treating as user DM");
return 0; // Not an error, just treat as user DM
@@ -804,7 +804,7 @@ int process_nip17_admin_command(cJSON* dm_event, char* error_message, size_t err
if (cJSON_IsString(first_item) && strcmp(cJSON_GetStringValue(first_item), "stats") == 0) {
DEBUG_INFO("DM_ADMIN: Processing JSON stats command");
// Get sender pubkey for response
cJSON* sender_pubkey_obj = cJSON_GetObjectItem(dm_event, "pubkey");
cJSON* sender_pubkey_obj = cJSON_GetObjectItemCaseSensitive(dm_event, "pubkey");
if (!sender_pubkey_obj || !cJSON_IsString(sender_pubkey_obj)) {
cJSON_Delete(command_array);
DEBUG_INFO("DM_ADMIN: DM missing sender pubkey for stats command");
@@ -846,13 +846,13 @@ int process_nip17_admin_command(cJSON* dm_event, char* error_message, size_t err
cJSON_AddStringToObject(synthetic_event, "content", dm_content);
// Copy pubkey from DM
cJSON* pubkey_obj = cJSON_GetObjectItem(dm_event, "pubkey");
cJSON* pubkey_obj = cJSON_GetObjectItemCaseSensitive(dm_event, "pubkey");
if (pubkey_obj && cJSON_IsString(pubkey_obj)) {
cJSON_AddStringToObject(synthetic_event, "pubkey", cJSON_GetStringValue(pubkey_obj));
}
// Copy tags from DM
cJSON* tags = cJSON_GetObjectItem(dm_event, "tags");
cJSON* tags = cJSON_GetObjectItemCaseSensitive(dm_event, "tags");
if (tags) {
cJSON_AddItemToObject(synthetic_event, "tags", cJSON_Duplicate(tags, 1));
}
File diff suppressed because one or more lines are too long
+575
View File
@@ -0,0 +1,575 @@
#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
// Auth failure tracking (existing)
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)
// 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; // 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;
}
// Migration: Add idle_* columns if they don't exist (ignore errors if already exists)
sqlite3_exec(db, "ALTER TABLE ip_bans ADD COLUMN idle_failure_count INTEGER NOT NULL DEFAULT 0", NULL, NULL, NULL);
sqlite3_exec(db, "ALTER TABLE ip_bans ADD COLUMN idle_ban_count INTEGER NOT NULL DEFAULT 0", NULL, NULL, NULL);
sqlite3_exec(db, "ALTER TABLE ip_bans ADD COLUMN idle_banned_until INTEGER NOT NULL DEFAULT 0", NULL, NULL, NULL);
sqlite3_exec(db, "ALTER TABLE ip_bans ADD COLUMN idle_first_failure INTEGER NOT NULL DEFAULT 0", NULL, NULL, NULL);
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,"
" idle_failure_count, idle_ban_count, idle_banned_until, idle_first_failure"
" 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);
// Load idle tracking fields (default to 0 if columns don't exist yet)
entry->idle_failure_count = sqlite3_column_int(stmt, 11);
entry->idle_ban_count = sqlite3_column_int(stmt, 12);
entry->idle_banned_until = (time_t)sqlite3_column_int64(stmt, 13);
entry->idle_first_failure = (time_t)sqlite3_column_int64(stmt, 14);
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,"
" idle_failure_count, idle_ban_count, idle_banned_until, idle_first_failure)"
" 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);
// Idle tracking fields
sqlite3_bind_int(stmt, 12, entry->idle_failure_count);
sqlite3_bind_int(stmt, 13, entry->idle_ban_count);
sqlite3_bind_int64(stmt, 14, (sqlite3_int64)entry->idle_banned_until);
sqlite3_bind_int64(stmt, 15, (sqlite3_int64)entry->idle_first_failure);
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);
}
// Check if an IP is in the idle_ban_whitelist config (comma-separated list)
static int ip_is_whitelisted(const char* ip) {
const char* whitelist = get_config_value("idle_ban_whitelist");
if (!whitelist || whitelist[0] == '\0') return 0;
// Make a mutable copy to tokenize
char buf[1024];
strncpy(buf, whitelist, sizeof(buf) - 1);
buf[sizeof(buf) - 1] = '\0';
char* token = strtok(buf, ",");
while (token) {
// Trim leading/trailing spaces
while (*token == ' ') token++;
char* end = token + strlen(token) - 1;
while (end > token && *end == ' ') { *end = '\0'; end--; }
if (strcmp(token, ip) == 0) return 1;
token = strtok(NULL, ",");
}
return 0;
}
int ip_ban_is_banned(const char* ip) {
if (!ip || !g_initialized) return 0;
// Whitelisted IPs are never banned
if (ip_is_whitelisted(ip)) 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;
}
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);
}
// 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;
if (ip_is_whitelisted(ip)) return; // Never record idle failures for whitelisted IPs
int threshold = get_config_int("idle_ban_threshold", 1);
int window_sec = get_config_int("idle_ban_window_sec", 30);
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);
}
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 idle_window_sec = get_config_int("idle_ban_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];
// Check auth failure window expiration
int auth_ban_expired = (entry->banned_until == 0 || now >= entry->banned_until);
int auth_window_expired = (entry->first_failure == 0 || (now - entry->first_failure) > window_sec * 10);
// Check idle failure window expiration
int idle_ban_expired = (entry->idle_banned_until == 0 || now >= entry->idle_banned_until);
int idle_window_expired = (entry->idle_first_failure == 0 || (now - entry->idle_first_failure) > idle_window_sec * 10);
if (auth_ban_expired && auth_window_expired && entry->failure_count == 0 &&
idle_ban_expired && idle_window_expired && entry->idle_failure_count == 0) {
int retain_sec = 86400; // 24 hours
int last_auth_ban_expired_long_ago = (entry->banned_until == 0 ||
(now - entry->banned_until) > retain_sec);
int last_idle_ban_expired_long_ago = (entry->idle_banned_until == 0 ||
(now - entry->idle_banned_until) > retain_sec);
if (last_auth_ban_expired_long_ago && last_idle_ban_expired_long_ago &&
!entry->has_authed_successfully &&
entry->ban_count == 0 && entry->idle_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;
entry->idle_failure_count = 0;
entry->idle_first_failure = 0;
if (last_auth_ban_expired_long_ago) {
entry->banned_until = 0;
// ban_count intentionally NOT reset — permanent escalation
}
if (last_idle_ban_expired_long_ago) {
entry->idle_banned_until = 0;
// idle_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) continue;
// Count if either auth banned or idle banned
if (g_ban_table[i].banned_until > now ||
g_ban_table[i].idle_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 auth_banned_count = 0;
int idle_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) auth_banned_count++;
if (g_ban_table[i].idle_banned_until > now) idle_banned_count++;
if (g_ban_table[i].has_authed_successfully) trusted_count++;
}
pthread_mutex_unlock(&g_ban_mutex);
int total_banned = auth_banned_count + idle_banned_count;
if (total_banned > 0 || tracked_count > 0) {
DEBUG_WARN("IP BAN SUMMARY: %d auth-banned, %d idle-banned, %d tracked, %d trusted (ever authed)",
auth_banned_count, idle_banned_count, tracked_count, trusted_count);
}
}
+66
View File
@@ -0,0 +1,66 @@
#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 an idle/early-disconnect failure for an IP.
// Called when a connection closes without ever sending REQ or EVENT.
// May trigger a ban if the threshold is exceeded.
void ip_ban_record_idle_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
+57 -30
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();
@@ -739,13 +743,13 @@ int store_event(cJSON* event) {
}
// Extract event fields
cJSON* id = cJSON_GetObjectItem(event, "id");
cJSON* pubkey = cJSON_GetObjectItem(event, "pubkey");
cJSON* created_at = cJSON_GetObjectItem(event, "created_at");
cJSON* kind = cJSON_GetObjectItem(event, "kind");
cJSON* content = cJSON_GetObjectItem(event, "content");
cJSON* sig = cJSON_GetObjectItem(event, "sig");
cJSON* tags = cJSON_GetObjectItem(event, "tags");
cJSON* id = cJSON_GetObjectItemCaseSensitive(event, "id");
cJSON* pubkey = cJSON_GetObjectItemCaseSensitive(event, "pubkey");
cJSON* created_at = cJSON_GetObjectItemCaseSensitive(event, "created_at");
cJSON* kind = cJSON_GetObjectItemCaseSensitive(event, "kind");
cJSON* content = cJSON_GetObjectItemCaseSensitive(event, "content");
cJSON* sig = cJSON_GetObjectItemCaseSensitive(event, "sig");
cJSON* tags = cJSON_GetObjectItemCaseSensitive(event, "tags");
if (!id || !pubkey || !created_at || !kind || !content || !sig) {
DEBUG_ERROR("Invalid event - missing required fields");
@@ -863,8 +867,8 @@ int store_event(cJSON* event) {
store_event_tags(cJSON_GetStringValue(id), tags);
// Check if this is a kind 3 event from the admin — trigger WoT sync
cJSON* kind_obj = cJSON_GetObjectItem(event, "kind");
cJSON* pubkey_obj = cJSON_GetObjectItem(event, "pubkey");
cJSON* kind_obj = cJSON_GetObjectItemCaseSensitive(event, "kind");
cJSON* pubkey_obj = cJSON_GetObjectItemCaseSensitive(event, "pubkey");
if (kind_obj && pubkey_obj && cJSON_GetNumberValue(kind_obj) == 3) {
int wot_level = get_config_int("wot_enabled", 0);
if (wot_level > 0) {
@@ -881,6 +885,22 @@ int store_event(cJSON* event) {
return 0;
}
// Fast duplicate check: returns 1 if event ID already exists in DB, 0 if not.
// Uses the primary key index — single B-tree lookup, ~10μs.
// Call this BEFORE signature verification to skip expensive crypto on duplicates.
int event_id_exists_in_db(const char* event_id) {
if (!g_db || !event_id || strlen(event_id) != 64) return 0;
sqlite3_stmt* stmt;
const char* sql = "SELECT 1 FROM events WHERE id=? LIMIT 1";
if (sqlite3_prepare_v2(g_db, sql, -1, &stmt, NULL) != SQLITE_OK) return 0;
sqlite3_bind_text(stmt, 1, event_id, 64, SQLITE_STATIC);
int exists = (sqlite3_step(stmt) == SQLITE_ROW) ? 1 : 0;
sqlite3_finalize(stmt);
return exists;
}
// Populate event_tags from existing events (run once at startup)
int populate_event_tags_from_existing(void) {
if (!g_db) return -1;
@@ -1005,7 +1025,7 @@ static int is_only_kind_99999_request(cJSON* filters) {
return 0;
}
cJSON* kinds = cJSON_GetObjectItem(filter, "kinds");
cJSON* kinds = cJSON_GetObjectItemCaseSensitive(filter, "kinds");
if (!kinds || !cJSON_IsArray(kinds)) {
// Filter has no kinds or kinds is not an array - not a pure 99999 request
return 0;
@@ -1256,7 +1276,7 @@ int handle_req_message(const char* sub_id, cJSON* filters, struct lws *wsi, stru
// after retrieving events to ensure compatibility with all SQLite versions
// Handle kinds filter
cJSON* kinds = cJSON_GetObjectItem(filter, "kinds");
cJSON* kinds = cJSON_GetObjectItemCaseSensitive(filter, "kinds");
if (kinds && cJSON_IsArray(kinds)) {
int kind_count = cJSON_GetArraySize(kinds);
if (kind_count > 0) {
@@ -1284,7 +1304,7 @@ int handle_req_message(const char* sub_id, cJSON* filters, struct lws *wsi, stru
}
// Handle authors filter
cJSON* authors = cJSON_GetObjectItem(filter, "authors");
cJSON* authors = cJSON_GetObjectItemCaseSensitive(filter, "authors");
if (authors && cJSON_IsArray(authors)) {
int author_count = 0;
// Count valid authors
@@ -1324,7 +1344,7 @@ int handle_req_message(const char* sub_id, cJSON* filters, struct lws *wsi, stru
}
// Handle ids filter
cJSON* ids = cJSON_GetObjectItem(filter, "ids");
cJSON* ids = cJSON_GetObjectItemCaseSensitive(filter, "ids");
if (ids && cJSON_IsArray(ids)) {
int id_count = 0;
// Count valid ids
@@ -1414,7 +1434,7 @@ int handle_req_message(const char* sub_id, cJSON* filters, struct lws *wsi, stru
}
// Handle search filter (NIP-50)
cJSON* search = cJSON_GetObjectItem(filter, "search");
cJSON* search = cJSON_GetObjectItemCaseSensitive(filter, "search");
if (search && cJSON_IsString(search)) {
const char* search_term = cJSON_GetStringValue(search);
if (search_term && strlen(search_term) > 0) {
@@ -1442,7 +1462,7 @@ int handle_req_message(const char* sub_id, cJSON* filters, struct lws *wsi, stru
}
// Handle since filter
cJSON* since = cJSON_GetObjectItem(filter, "since");
cJSON* since = cJSON_GetObjectItemCaseSensitive(filter, "since");
if (since && cJSON_IsNumber(since)) {
snprintf(sql_ptr, remaining, " AND created_at >= %ld", (long)cJSON_GetNumberValue(since));
sql_ptr += strlen(sql_ptr);
@@ -1450,7 +1470,7 @@ int handle_req_message(const char* sub_id, cJSON* filters, struct lws *wsi, stru
}
// Handle until filter
cJSON* until = cJSON_GetObjectItem(filter, "until");
cJSON* until = cJSON_GetObjectItemCaseSensitive(filter, "until");
if (until && cJSON_IsNumber(until)) {
snprintf(sql_ptr, remaining, " AND created_at <= %ld", (long)cJSON_GetNumberValue(until));
sql_ptr += strlen(sql_ptr);
@@ -1463,7 +1483,7 @@ int handle_req_message(const char* sub_id, cJSON* filters, struct lws *wsi, stru
remaining = sizeof(sql) - strlen(sql);
// Handle limit filter
cJSON* limit = cJSON_GetObjectItem(filter, "limit");
cJSON* limit = cJSON_GetObjectItemCaseSensitive(filter, "limit");
if (limit && cJSON_IsNumber(limit)) {
int limit_val = (int)cJSON_GetNumberValue(limit);
if (limit_val > 0 && limit_val <= 5000) {
@@ -1552,21 +1572,24 @@ int handle_req_message(const char* sub_id, cJSON* filters, struct lws *wsi, stru
}
}
// Build EVENT message using string concatenation (much faster than cJSON operations)
// Build EVENT message using zero-copy path: allocate with LWS_PRE prefix,
// write directly, transfer ownership to queue — no memcpy.
// Format: ["EVENT","<sub_id>",<event_json>]
size_t sub_id_len = strlen(sub_id);
size_t event_json_len = strlen(event_json_str);
size_t msg_len = 10 + sub_id_len + 3 + event_json_len + 1; // ["EVENT",""] + sub_id + "," + event_json + ]
char* msg_str = malloc(msg_len + 1);
if (msg_str) {
snprintf(msg_str, msg_len + 1, "[\"EVENT\",\"%s\",%s]", sub_id, event_json_str);
// Use proper message queue system instead of direct lws_write
if (queue_message(wsi, pss, msg_str, strlen(msg_str), LWS_WRITE_TEXT) != 0) {
size_t msg_len = 10 + sub_id_len + 3 + event_json_len + 1;
unsigned char* buf = malloc(LWS_PRE + msg_len + 1);
if (buf) {
char* msg_ptr = (char*)(buf + LWS_PRE);
snprintf(msg_ptr, msg_len + 1, "[\"EVENT\",\"%s\",%s]", sub_id, event_json_str);
size_t actual_len = strlen(msg_ptr);
// queue_message_take_ownership takes buf ownership — no memcpy, no free needed here
if (queue_message_take_ownership(wsi, pss, buf, actual_len, LWS_WRITE_TEXT) != 0) {
DEBUG_ERROR("Failed to queue EVENT message for sub=%s", sub_id);
// buf already freed by queue_message_take_ownership on failure
}
free(msg_str);
}
cJSON_Delete(event);
@@ -1606,7 +1629,7 @@ int is_authorized_admin_event(cJSON* event, char* error_buffer, size_t error_buf
// Step 1: Verify event kind is admin type
cJSON *kind_json = cJSON_GetObjectItem(event, "kind");
cJSON *kind_json = cJSON_GetObjectItemCaseSensitive(event, "kind");
if (!kind_json || !cJSON_IsNumber(kind_json)) {
snprintf(error_buffer, error_buffer_size, "Missing or invalid event kind");
return -1;
@@ -1619,7 +1642,7 @@ int is_authorized_admin_event(cJSON* event, char* error_buffer, size_t error_buf
}
// Step 2: Check if event targets this relay (look for 'p' tag with our relay pubkey)
cJSON *tags = cJSON_GetObjectItem(event, "tags");
cJSON *tags = cJSON_GetObjectItemCaseSensitive(event, "tags");
if (!tags || !cJSON_IsArray(tags)) {
// No tags array - treat as regular event for different relay
snprintf(error_buffer, error_buffer_size, "Admin event not targeting this relay (no tags)");
@@ -1654,7 +1677,7 @@ int is_authorized_admin_event(cJSON* event, char* error_buffer, size_t error_buf
}
// Step 3: Verify admin signature authorization
cJSON *pubkey_json = cJSON_GetObjectItem(event, "pubkey");
cJSON *pubkey_json = cJSON_GetObjectItemCaseSensitive(event, "pubkey");
if (!pubkey_json || !cJSON_IsString(pubkey_json)) {
DEBUG_WARN("Unauthorized admin event attempt: missing or invalid pubkey");
snprintf(error_buffer, error_buffer_size, "Unauthorized admin event attempt: missing pubkey");
@@ -2146,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 7
#define CRELAY_VERSION "v1.2.7"
#define CRELAY_VERSION_PATCH 48
#define CRELAY_VERSION "v1.2.48"
// Relay metadata (authoritative source for NIP-11 information)
#define RELAY_NAME "C-Relay"
+6 -6
View File
@@ -33,12 +33,12 @@ int handle_deletion_request(cJSON* event, char* error_message, size_t error_size
}
// Extract event details
cJSON* kind_obj = cJSON_GetObjectItem(event, "kind");
cJSON* pubkey_obj = cJSON_GetObjectItem(event, "pubkey");
cJSON* created_at_obj = cJSON_GetObjectItem(event, "created_at");
cJSON* tags_obj = cJSON_GetObjectItem(event, "tags");
cJSON* content_obj = cJSON_GetObjectItem(event, "content");
cJSON* event_id_obj = cJSON_GetObjectItem(event, "id");
cJSON* kind_obj = cJSON_GetObjectItemCaseSensitive(event, "kind");
cJSON* pubkey_obj = cJSON_GetObjectItemCaseSensitive(event, "pubkey");
cJSON* created_at_obj = cJSON_GetObjectItemCaseSensitive(event, "created_at");
cJSON* tags_obj = cJSON_GetObjectItemCaseSensitive(event, "tags");
cJSON* content_obj = cJSON_GetObjectItemCaseSensitive(event, "content");
cJSON* event_id_obj = cJSON_GetObjectItemCaseSensitive(event, "id");
if (!kind_obj || !pubkey_obj || !created_at_obj || !tags_obj || !event_id_obj) {
snprintf(error_message, error_size, "invalid: incomplete deletion request");
+23 -2
View File
@@ -118,6 +118,8 @@ cJSON* generate_relay_info_json() {
int default_limit = get_config_int("default_limit", 500);
int min_pow_difficulty = get_config_int("pow_min_difficulty", 0);
int admin_enabled = get_config_bool("admin_enabled", 0);
int wot_enabled = get_config_int("wot_enabled", 0);
int auth_enabled = get_config_bool("auth_enabled", 0);
// Add basic relay information
if (relay_name && strlen(relay_name) > 0) {
@@ -209,6 +211,9 @@ cJSON* generate_relay_info_json() {
}
// Add server limitations
// restricted_writes is true when WoT or auth is enabled (only trusted pubkeys can write)
int restricted_writes = (wot_enabled > 0 || auth_enabled) ? 1 : 0;
cJSON* limitation = cJSON_CreateObject();
if (limitation) {
cJSON_AddNumberToObject(limitation, "max_message_length", max_message_length);
@@ -218,15 +223,31 @@ cJSON* generate_relay_info_json() {
cJSON_AddNumberToObject(limitation, "max_event_tags", max_event_tags);
cJSON_AddNumberToObject(limitation, "max_content_length", max_content_length);
cJSON_AddNumberToObject(limitation, "min_pow_difficulty", min_pow_difficulty);
cJSON_AddBoolToObject(limitation, "auth_required", admin_enabled ? cJSON_True : cJSON_False);
cJSON_AddBoolToObject(limitation, "auth_required", auth_enabled ? cJSON_True : cJSON_False);
cJSON_AddBoolToObject(limitation, "payment_required", cJSON_False);
cJSON_AddBoolToObject(limitation, "restricted_writes", cJSON_False);
cJSON_AddBoolToObject(limitation, "restricted_writes", restricted_writes ? cJSON_True : cJSON_False);
cJSON_AddNumberToObject(limitation, "created_at_lower_limit", 0);
cJSON_AddNumberToObject(limitation, "created_at_upper_limit", 2147483647);
cJSON_AddNumberToObject(limitation, "default_limit", default_limit);
cJSON_AddItemToObject(info, "limitation", limitation);
}
// Add Web of Trust information when enabled
// This informs clients that the relay operates on a trust network
if (wot_enabled > 0) {
cJSON* wot_info = cJSON_CreateObject();
if (wot_info) {
cJSON_AddNumberToObject(wot_info, "level", wot_enabled);
const char* level_desc = (wot_enabled == 1)
? "write-only: only followed pubkeys can publish events"
: "full: only followed pubkeys can publish and subscribe";
cJSON_AddStringToObject(wot_info, "description", level_desc);
cJSON_AddStringToObject(wot_info, "policy",
"Events from pubkeys not in the relay operator's Web of Trust are rejected.");
cJSON_AddItemToObject(info, "web_of_trust", wot_info);
}
}
// Add retention policies (empty array for now)
cJSON* retention = cJSON_CreateArray();
if (retention) {
+1 -1
View File
@@ -55,7 +55,7 @@ int validate_event_pow(cJSON* event, char* error_message, size_t error_size) {
// If min_pow_difficulty is 0, only validate events that have nonce tags
// This allows events without PoW when difficulty requirement is 0
if (min_pow_difficulty == 0) {
cJSON* tags = cJSON_GetObjectItem(event, "tags");
cJSON* tags = cJSON_GetObjectItemCaseSensitive(event, "tags");
int has_nonce_tag = 0;
if (tags && cJSON_IsArray(tags)) {
+2 -2
View File
@@ -114,7 +114,7 @@ int is_event_expired(cJSON* event, time_t current_time) {
return 0; // Invalid event, not expired
}
cJSON* tags = cJSON_GetObjectItem(event, "tags");
cJSON* tags = cJSON_GetObjectItemCaseSensitive(event, "tags");
long expiration_ts = extract_expiration_timestamp(tags);
if (expiration_ts == 0) {
@@ -140,7 +140,7 @@ int validate_event_expiration(cJSON* event, char* error_message, size_t error_si
time_t current_time = time(NULL);
if (is_event_expired(event, current_time)) {
if (g_expiration_config.strict_mode) {
cJSON* tags = cJSON_GetObjectItem(event, "tags");
cJSON* tags = cJSON_GetObjectItemCaseSensitive(event, "tags");
long expiration_ts = extract_expiration_timestamp(tags);
snprintf(error_message, error_size,
+11 -2
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
@@ -99,7 +100,7 @@ void handle_nip42_auth_signed_event(struct lws* wsi, struct per_session_data* ps
char authenticated_pubkey[65] = {0};
if (result == 0) {
// Extract pubkey from the auth event
cJSON* pubkey_json = cJSON_GetObjectItem(auth_event, "pubkey");
cJSON* pubkey_json = cJSON_GetObjectItemCaseSensitive(auth_event, "pubkey");
if (pubkey_json && cJSON_IsString(pubkey_json)) {
const char* pubkey_str = cJSON_GetStringValue(pubkey_json);
if (pubkey_str && strlen(pubkey_str) == 64) {
@@ -125,8 +126,16 @@ void handle_nip42_auth_signed_event(struct lws* wsi, struct per_session_data* ps
memset(pss->active_challenge, 0, sizeof(pss->active_challenge));
pss->challenge_expires = 0;
pss->auth_challenge_sent = 0;
// Mark session as active - client sent AUTH
pss->session_active = 1;
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 {
// Authentication failed
+10 -10
View File
@@ -252,13 +252,13 @@ int nostr_validate_unified_request(const char* json_string, size_t json_length)
}
// 4. Validate basic event structure
cJSON *id = cJSON_GetObjectItem(event, "id");
cJSON *pubkey = cJSON_GetObjectItem(event, "pubkey");
cJSON *created_at = cJSON_GetObjectItem(event, "created_at");
cJSON *kind = cJSON_GetObjectItem(event, "kind");
cJSON *tags = cJSON_GetObjectItem(event, "tags");
cJSON *content = cJSON_GetObjectItem(event, "content");
cJSON *sig = cJSON_GetObjectItem(event, "sig");
cJSON *id = cJSON_GetObjectItemCaseSensitive(event, "id");
cJSON *pubkey = cJSON_GetObjectItemCaseSensitive(event, "pubkey");
cJSON *created_at = cJSON_GetObjectItemCaseSensitive(event, "created_at");
cJSON *kind = cJSON_GetObjectItemCaseSensitive(event, "kind");
cJSON *tags = cJSON_GetObjectItemCaseSensitive(event, "tags");
cJSON *content = cJSON_GetObjectItemCaseSensitive(event, "content");
cJSON *sig = cJSON_GetObjectItemCaseSensitive(event, "sig");
if (!id || !cJSON_IsString(id) ||
!pubkey || !cJSON_IsString(pubkey) ||
@@ -380,7 +380,7 @@ int nostr_validate_unified_request(const char* json_string, size_t json_length)
// Always check expiration tags if present (following NIP-40 specification)
cJSON *expiration_tag = NULL;
cJSON *tags_array = cJSON_GetObjectItem(event, "tags");
cJSON *tags_array = cJSON_GetObjectItemCaseSensitive(event, "tags");
if (tags_array && cJSON_IsArray(tags_array)) {
cJSON *tag = NULL;
@@ -818,7 +818,7 @@ int nostr_nip42_verify_auth_event(cJSON *event, const char *challenge_id,
}
// Check if event has the required tags for NIP-42
cJSON *tags = cJSON_GetObjectItem(event, "tags");
cJSON *tags = cJSON_GetObjectItemCaseSensitive(event, "tags");
if (!tags || !cJSON_IsArray(tags)) {
return NOSTR_ERROR_NIP42_AUTH_EVENT_INVALID;
}
@@ -896,7 +896,7 @@ int nostr_nip42_verify_auth_event(cJSON *event, const char *challenge_id,
}
// Check created_at timestamp for reasonable bounds
cJSON *created_at_json = cJSON_GetObjectItem(event, "created_at");
cJSON *created_at_json = cJSON_GetObjectItemCaseSensitive(event, "created_at");
if (created_at_json && cJSON_IsNumber(created_at_json)) {
time_t created_at = (time_t)cJSON_GetNumberValue(created_at_json);
if (abs((int)(now - created_at)) > time_tolerance_seconds) {
+36 -34
View File
@@ -192,32 +192,32 @@ subscription_filter_t* create_subscription_filter(cJSON* filter_json) {
}
// Copy filter criteria
cJSON* kinds = cJSON_GetObjectItem(filter_json, "kinds");
cJSON* kinds = cJSON_GetObjectItemCaseSensitive(filter_json, "kinds");
if (kinds && cJSON_IsArray(kinds)) {
filter->kinds = cJSON_Duplicate(kinds, 1);
}
cJSON* authors = cJSON_GetObjectItem(filter_json, "authors");
cJSON* authors = cJSON_GetObjectItemCaseSensitive(filter_json, "authors");
if (authors && cJSON_IsArray(authors)) {
filter->authors = cJSON_Duplicate(authors, 1);
}
cJSON* ids = cJSON_GetObjectItem(filter_json, "ids");
cJSON* ids = cJSON_GetObjectItemCaseSensitive(filter_json, "ids");
if (ids && cJSON_IsArray(ids)) {
filter->ids = cJSON_Duplicate(ids, 1);
}
cJSON* since = cJSON_GetObjectItem(filter_json, "since");
cJSON* since = cJSON_GetObjectItemCaseSensitive(filter_json, "since");
if (since && cJSON_IsNumber(since)) {
filter->since = (long)cJSON_GetNumberValue(since);
}
cJSON* until = cJSON_GetObjectItem(filter_json, "until");
cJSON* until = cJSON_GetObjectItemCaseSensitive(filter_json, "until");
if (until && cJSON_IsNumber(until)) {
filter->until = (long)cJSON_GetNumberValue(until);
}
cJSON* limit = cJSON_GetObjectItem(filter_json, "limit");
cJSON* limit = cJSON_GetObjectItemCaseSensitive(filter_json, "limit");
if (limit && cJSON_IsNumber(limit)) {
filter->limit = (int)cJSON_GetNumberValue(limit);
}
@@ -532,9 +532,9 @@ int event_matches_filter(cJSON* event, subscription_filter_t* filter) {
}
// Debug: Log event details being tested
cJSON* event_kind_obj = cJSON_GetObjectItem(event, "kind");
cJSON* event_id_obj = cJSON_GetObjectItem(event, "id");
cJSON* event_created_at_obj = cJSON_GetObjectItem(event, "created_at");
cJSON* event_kind_obj = cJSON_GetObjectItemCaseSensitive(event, "kind");
cJSON* event_id_obj = cJSON_GetObjectItemCaseSensitive(event, "id");
cJSON* event_created_at_obj = cJSON_GetObjectItemCaseSensitive(event, "created_at");
DEBUG_TRACE("FILTER_MATCH: Testing event kind=%d id=%.8s created_at=%ld",
event_kind_obj ? (int)cJSON_GetNumberValue(event_kind_obj) : -1,
@@ -545,7 +545,7 @@ int event_matches_filter(cJSON* event, subscription_filter_t* filter) {
if (filter->kinds && cJSON_IsArray(filter->kinds)) {
DEBUG_TRACE("FILTER_MATCH: Checking kinds filter with %d kinds", cJSON_GetArraySize(filter->kinds));
cJSON* event_kind = cJSON_GetObjectItem(event, "kind");
cJSON* event_kind = cJSON_GetObjectItemCaseSensitive(event, "kind");
if (!event_kind || !cJSON_IsNumber(event_kind)) {
DEBUG_WARN("FILTER_MATCH: Event has no valid kind field");
return 0;
@@ -577,7 +577,7 @@ int event_matches_filter(cJSON* event, subscription_filter_t* filter) {
// Check authors filter
if (filter->authors && cJSON_IsArray(filter->authors)) {
cJSON* event_pubkey = cJSON_GetObjectItem(event, "pubkey");
cJSON* event_pubkey = cJSON_GetObjectItemCaseSensitive(event, "pubkey");
if (!event_pubkey || !cJSON_IsString(event_pubkey)) {
return 0;
}
@@ -604,7 +604,7 @@ int event_matches_filter(cJSON* event, subscription_filter_t* filter) {
// Check IDs filter
if (filter->ids && cJSON_IsArray(filter->ids)) {
cJSON* event_id = cJSON_GetObjectItem(event, "id");
cJSON* event_id = cJSON_GetObjectItemCaseSensitive(event, "id");
if (!event_id || !cJSON_IsString(event_id)) {
return 0;
}
@@ -631,7 +631,7 @@ int event_matches_filter(cJSON* event, subscription_filter_t* filter) {
// Check since filter
if (filter->since > 0) {
cJSON* event_created_at = cJSON_GetObjectItem(event, "created_at");
cJSON* event_created_at = cJSON_GetObjectItemCaseSensitive(event, "created_at");
if (!event_created_at || !cJSON_IsNumber(event_created_at)) {
DEBUG_WARN("FILTER_MATCH: Event has no valid created_at field");
return 0;
@@ -650,7 +650,7 @@ int event_matches_filter(cJSON* event, subscription_filter_t* filter) {
// Check until filter
if (filter->until > 0) {
cJSON* event_created_at = cJSON_GetObjectItem(event, "created_at");
cJSON* event_created_at = cJSON_GetObjectItemCaseSensitive(event, "created_at");
if (!event_created_at || !cJSON_IsNumber(event_created_at)) {
return 0;
}
@@ -663,7 +663,7 @@ int event_matches_filter(cJSON* event, subscription_filter_t* filter) {
// Check tag filters (e.g., #e, #p tags)
if (filter->tag_filters && cJSON_IsObject(filter->tag_filters)) {
cJSON* event_tags = cJSON_GetObjectItem(event, "tags");
cJSON* event_tags = cJSON_GetObjectItemCaseSensitive(event, "tags");
if (!event_tags || !cJSON_IsArray(event_tags)) {
return 0; // Event has no tags but filter requires tags
}
@@ -777,9 +777,9 @@ int broadcast_event_to_subscriptions(cJSON* event) {
int broadcasts = 0;
// Log event details
cJSON* event_kind = cJSON_GetObjectItem(event, "kind");
cJSON* event_id = cJSON_GetObjectItem(event, "id");
cJSON* event_created_at = cJSON_GetObjectItem(event, "created_at");
cJSON* event_kind = cJSON_GetObjectItemCaseSensitive(event, "kind");
cJSON* event_id = cJSON_GetObjectItemCaseSensitive(event, "id");
cJSON* event_created_at = cJSON_GetObjectItemCaseSensitive(event, "created_at");
DEBUG_TRACE("BROADCAST: Event kind=%d id=%.8s created_at=%ld",
event_kind ? (int)cJSON_GetNumberValue(event_kind) : -1,
@@ -876,12 +876,11 @@ int broadcast_event_to_subscriptions(cJSON* event) {
// Serialize event once per subscription using pre-serialized event_json if available,
// otherwise fall back to cJSON serialization.
// Format: ["EVENT","<sub_id>",<event_json>]
cJSON* event_id_obj = cJSON_GetObjectItem(event, "id");
const char* event_json_str = NULL;
char* event_json_allocated = NULL;
// Try to get pre-serialized event_json field first (fast path)
cJSON* event_json_field = cJSON_GetObjectItem(event, "event_json");
cJSON* event_json_field = cJSON_GetObjectItemCaseSensitive(event, "event_json");
if (event_json_field && cJSON_IsString(event_json_field)) {
event_json_str = cJSON_GetStringValue(event_json_field);
} else {
@@ -895,16 +894,19 @@ int broadcast_event_to_subscriptions(cJSON* event) {
size_t event_json_len = strlen(event_json_str);
// ["EVENT","<sub_id>",<event_json>]
size_t msg_len = 10 + sub_id_len + 3 + event_json_len + 1;
char* msg_str = malloc(msg_len + 1);
if (msg_str) {
snprintf(msg_str, msg_len + 1, "[\"EVENT\",\"%s\",%s]", current_temp->id, event_json_str);
size_t actual_len = strlen(msg_str);
// Zero-copy: allocate with LWS_PRE prefix, write directly, transfer ownership to queue
unsigned char* buf = malloc(LWS_PRE + msg_len + 1);
if (buf) {
char* msg_ptr = (char*)(buf + LWS_PRE);
snprintf(msg_ptr, msg_len + 1, "[\"EVENT\",\"%s\",%s]", current_temp->id, event_json_str);
size_t actual_len = strlen(msg_ptr);
DEBUG_TRACE("WS_FRAME_SEND: type=EVENT sub=%s len=%zu", current_temp->id, actual_len);
// Queue message for proper libwebsockets pattern
struct per_session_data* pss = (struct per_session_data*)lws_wsi_user(current_temp->wsi);
if (queue_message(current_temp->wsi, pss, msg_str, actual_len, LWS_WRITE_TEXT) == 0) {
// queue_message_take_ownership takes buf ownership — no memcpy, no free needed here
if (queue_message_take_ownership(current_temp->wsi, pss, buf, actual_len, LWS_WRITE_TEXT) == 0) {
broadcasts++;
// Update events sent counter for this subscription
@@ -922,8 +924,8 @@ int broadcast_event_to_subscriptions(cJSON* event) {
pthread_mutex_unlock(&g_subscription_manager.subscriptions_lock);
} else {
DEBUG_ERROR("Failed to queue EVENT message for sub=%s", current_temp->id);
// buf already freed by queue_message_take_ownership on failure
}
free(msg_str);
}
}
if (event_json_allocated) {
@@ -1473,7 +1475,7 @@ int validate_filter_values(cJSON* filter_json, char* error_message, size_t error
int has_kind_99999 = 0; // Track if we encounter kind 99999 (NDK ping)
// Validate kinds array
cJSON* kinds = cJSON_GetObjectItem(filter_json, "kinds");
cJSON* kinds = cJSON_GetObjectItemCaseSensitive(filter_json, "kinds");
if (kinds) {
if (!cJSON_IsArray(kinds)) {
snprintf(error_message, error_size, "kinds must be an array");
@@ -1516,7 +1518,7 @@ int validate_filter_values(cJSON* filter_json, char* error_message, size_t error
}
// Validate authors array
cJSON* authors = cJSON_GetObjectItem(filter_json, "authors");
cJSON* authors = cJSON_GetObjectItemCaseSensitive(filter_json, "authors");
if (authors) {
if (!cJSON_IsArray(authors)) {
snprintf(error_message, error_size, "authors must be an array");
@@ -1556,7 +1558,7 @@ int validate_filter_values(cJSON* filter_json, char* error_message, size_t error
}
// Validate ids array
cJSON* ids = cJSON_GetObjectItem(filter_json, "ids");
cJSON* ids = cJSON_GetObjectItemCaseSensitive(filter_json, "ids");
if (ids) {
if (!cJSON_IsArray(ids)) {
snprintf(error_message, error_size, "ids must be an array");
@@ -1598,7 +1600,7 @@ int validate_filter_values(cJSON* filter_json, char* error_message, size_t error
// Validate since/until timestamps
long since_val = 0, until_val = 0;
cJSON* since = cJSON_GetObjectItem(filter_json, "since");
cJSON* since = cJSON_GetObjectItemCaseSensitive(filter_json, "since");
if (since) {
if (!cJSON_IsNumber(since)) {
snprintf(error_message, error_size, "since must be a number");
@@ -1607,7 +1609,7 @@ int validate_filter_values(cJSON* filter_json, char* error_message, size_t error
since_val = (long)cJSON_GetNumberValue(since);
}
cJSON* until = cJSON_GetObjectItem(filter_json, "until");
cJSON* until = cJSON_GetObjectItemCaseSensitive(filter_json, "until");
if (until) {
if (!cJSON_IsNumber(until)) {
snprintf(error_message, error_size, "until must be a number");
@@ -1621,7 +1623,7 @@ int validate_filter_values(cJSON* filter_json, char* error_message, size_t error
}
// Validate limit
cJSON* limit = cJSON_GetObjectItem(filter_json, "limit");
cJSON* limit = cJSON_GetObjectItemCaseSensitive(filter_json, "limit");
if (limit) {
if (!cJSON_IsNumber(limit)) {
snprintf(error_message, error_size, "limit must be a number");
@@ -1635,7 +1637,7 @@ int validate_filter_values(cJSON* filter_json, char* error_message, size_t error
}
// Validate search term
cJSON* search = cJSON_GetObjectItem(filter_json, "search");
cJSON* search = cJSON_GetObjectItemCaseSensitive(filter_json, "search");
if (search) {
if (!cJSON_IsString(search)) {
snprintf(error_message, error_size, "search must be a string");
+372 -27
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
@@ -64,6 +65,7 @@ int remove_subscription_from_manager(const char* sub_id, struct lws* wsi);
// Forward declarations for event handling
int handle_event_message(cJSON* event, char* error_message, size_t error_size);
int nostr_validate_unified_request(const char* json_string, size_t json_length);
int event_id_exists_in_db(const char* event_id);
// Forward declarations for admin event processing
int process_admin_event_in_config(cJSON* event, char* error_message, size_t error_size, struct lws* wsi);
@@ -114,6 +116,83 @@ extern struct lws_context *ws_context;
// Global subscription manager
struct subscription_manager g_subscription_manager;
// Global connection list for idle connection tracking
// Tracks ALL WebSocket connections (not just subscribed ones)
// so the periodic timer can find and close idle connections
#define MAX_TRACKED_CONNECTIONS 4096
typedef struct {
struct lws* wsi;
struct per_session_data* pss;
} tracked_connection_t;
static tracked_connection_t g_connections[MAX_TRACKED_CONNECTIONS];
static int g_connection_count = 0;
static pthread_mutex_t g_connections_lock = PTHREAD_MUTEX_INITIALIZER;
int get_active_connection_count(void) { return g_connection_count; }
static void connection_list_add(struct lws* wsi, struct per_session_data* pss) {
pthread_mutex_lock(&g_connections_lock);
for (int i = 0; i < MAX_TRACKED_CONNECTIONS; i++) {
if (g_connections[i].wsi == NULL) {
g_connections[i].wsi = wsi;
g_connections[i].pss = pss;
g_connection_count++;
break;
}
}
pthread_mutex_unlock(&g_connections_lock);
}
static void connection_list_remove(struct lws* wsi) {
pthread_mutex_lock(&g_connections_lock);
for (int i = 0; i < MAX_TRACKED_CONNECTIONS; i++) {
if (g_connections[i].wsi == wsi) {
g_connections[i].wsi = NULL;
g_connections[i].pss = NULL;
g_connection_count--;
break;
}
}
pthread_mutex_unlock(&g_connections_lock);
}
// Check all tracked connections for idle timeout and close them
// Called from the periodic maintenance timer (every 60 seconds)
static void check_idle_connections(int idle_timeout_sec) {
if (idle_timeout_sec <= 0) return;
time_t now = time(NULL);
// Collect WSIs to close outside the lock to avoid deadlock
struct lws* to_close[MAX_TRACKED_CONNECTIONS];
int close_count = 0;
pthread_mutex_lock(&g_connections_lock);
for (int i = 0; i < MAX_TRACKED_CONNECTIONS; i++) {
if (g_connections[i].wsi == NULL || g_connections[i].pss == NULL) continue;
struct per_session_data* pss = g_connections[i].pss;
if (pss->session_active) continue; // Already active — skip
if (pss->connection_established <= 0) continue;
time_t age = now - pss->connection_established;
if (age >= idle_timeout_sec) {
to_close[close_count++] = g_connections[i].wsi;
}
}
pthread_mutex_unlock(&g_connections_lock);
for (int i = 0; i < close_count; i++) {
// Get pss again safely — it may have been freed if connection closed between lock release and here
struct per_session_data* pss = (struct per_session_data*)lws_wsi_user(to_close[i]);
if (!pss) continue;
DEBUG_LOG("Closing idle connection from %s (no REQ/EVENT after %d seconds)",
pss->client_ip, idle_timeout_sec);
lws_close_reason(to_close[i], LWS_CLOSE_STATUS_POLICY_VIOLATION,
(unsigned char*)"Idle connection timeout", 23);
}
}
// Message queue functions for proper libwebsockets pattern
@@ -194,6 +273,66 @@ int queue_message(struct lws* wsi, struct per_session_data* pss, const char* mes
return 0;
}
/**
* Zero-copy variant of queue_message. The caller allocates a buffer of
* (LWS_PRE + length) bytes, writes the message at (buf + LWS_PRE), then
* passes ownership to the queue. The queue will free buf when done.
* No memcpy is performed eliminates one copy per queued message.
*
* @param wsi WebSocket instance
* @param pss Per-session data containing message queue
* @param buf Pre-allocated buffer of size (LWS_PRE + length); ownership transferred
* @param length Length of message (NOT including LWS_PRE)
* @param type LWS_WRITE_* type
* @return 0 on success, -1 on error (buf is freed on error)
*/
int queue_message_take_ownership(struct lws* wsi, struct per_session_data* pss, unsigned char* buf, size_t length, enum lws_write_protocol type) {
if (!wsi || !pss || !buf || length == 0) {
DEBUG_ERROR("queue_message_take_ownership: invalid parameters");
free(buf);
return -1;
}
// Drop message if queue is full
if (pss->message_queue_count >= MAX_MESSAGE_QUEUE_SIZE) {
DEBUG_WARN("queue_message_take_ownership: queue full (%d), dropping message",
pss->message_queue_count);
free(buf);
return -1;
}
struct message_queue_node* node = malloc(sizeof(struct message_queue_node));
if (!node) {
DEBUG_ERROR("queue_message_take_ownership: failed to allocate queue node");
free(buf);
return -1;
}
node->data = buf; // buf already has LWS_PRE prefix — no copy needed
node->length = length;
node->type = type;
node->next = NULL;
pthread_mutex_lock(&pss->session_lock);
if (!pss->message_queue_head) {
pss->message_queue_head = node;
pss->message_queue_tail = node;
} else {
pss->message_queue_tail->next = node;
pss->message_queue_tail = node;
}
pss->message_queue_count++;
pthread_mutex_unlock(&pss->session_lock);
if (!pss->writeable_requested) {
pss->writeable_requested = 1;
lws_callback_on_writable(wsi);
}
DEBUG_TRACE("Queued message (zero-copy): len=%zu, queue_count=%d", length, pss->message_queue_count);
return 0;
}
/**
* Process message queue when the socket becomes writeable.
* This function is called from LWS_CALLBACK_SERVER_WRITEABLE.
@@ -268,6 +407,12 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso
switch (reason) {
case LWS_CALLBACK_HTTP:
// Handle HTTP requests
// Mark session as active so HTTP requests don't trigger idle ban
if (pss) {
pthread_mutex_lock(&pss->session_lock);
pss->session_active = 1;
pthread_mutex_unlock(&pss->session_lock);
}
{
char *requested_uri = (char *)in;
@@ -321,6 +466,12 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso
int is_nip11_request = (strstr(accept_header, "application/nostr+json") != NULL);
if (is_nip11_request) {
// Mark session as active so HTTP requests don't trigger idle ban
if (pss) {
pthread_mutex_lock(&pss->session_lock);
pss->session_active = 1;
pthread_mutex_unlock(&pss->session_lock);
}
// Handle NIP-11 request
if (handle_nip11_http_request(wsi, accept_header) == 0) {
return 0; // Successfully handled
@@ -484,6 +635,50 @@ 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;
// Mark as WebSocket connection (not HTTP)
pss->is_websocket = 1;
// Register in global connection list for idle tracking
connection_list_add(wsi, pss);
// 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
}
// 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);
DEBUG_TRACE("Auth timeout set: %d seconds for unauthenticated connection from %s",
effective_timeout, pss->client_ip);
}
}
DEBUG_TRACE("WebSocket connection initialization complete");
break;
@@ -580,6 +775,14 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso
const char* msg_type = cJSON_GetStringValue(type);
if (strcmp(msg_type, "EVENT") == 0) {
// Mark session as active - client sent EVENT
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);
// Handle EVENT message
cJSON* event = cJSON_GetArrayItem(json, 1);
if (event && cJSON_IsObject(event)) {
@@ -609,6 +812,27 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso
return 0;
}
// Early duplicate check: skip expensive crypto for events already in DB.
// The event id is a 64-char hex string — look it up via primary key index (~10μs).
// This must happen AFTER we have the id string but BEFORE signature verification.
cJSON* id_obj_dup = cJSON_GetObjectItemCaseSensitive(event, "id");
if (id_obj_dup && cJSON_IsString(id_obj_dup)) {
const char* event_id_str = cJSON_GetStringValue(id_obj_dup);
if (event_id_str && event_id_exists_in_db(event_id_str)) {
// Already have this event — send OK true and skip crypto
DEBUG_TRACE("Duplicate event %s — skipping signature verification", event_id_str);
char ok_msg[128];
snprintf(ok_msg, sizeof(ok_msg),
"[\"OK\",\"%s\",true,\"duplicate: already have this event\"]",
event_id_str);
size_t ok_len = strlen(ok_msg);
queue_message(wsi, pss, ok_msg, ok_len, LWS_WRITE_TEXT);
free(event_json_str);
cJSON_Delete(json);
return 0;
}
}
// Call unified validator with JSON string
size_t event_json_len = strlen(event_json_str);
int validation_result = nostr_validate_unified_request(event_json_str, event_json_len);
@@ -654,7 +878,7 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso
if (result == 0) {
// Check if event has protected tag ["-"]
int is_protected_event = 0;
cJSON* tags = cJSON_GetObjectItem(event, "tags");
cJSON* tags = cJSON_GetObjectItemCaseSensitive(event, "tags");
if (tags && cJSON_IsArray(tags)) {
cJSON* tag = NULL;
cJSON_ArrayForEach(tag, tags) {
@@ -681,7 +905,7 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso
DEBUG_WARN("Protected event rejected: protected events not enabled");
} else {
// Protected events enabled - check authentication
cJSON* pubkey_obj = cJSON_GetObjectItem(event, "pubkey");
cJSON* pubkey_obj = cJSON_GetObjectItemCaseSensitive(event, "pubkey");
const char* event_pubkey = pubkey_obj ? cJSON_GetStringValue(pubkey_obj) : NULL;
if (!pss || !pss->authenticated ||
@@ -698,7 +922,7 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso
// Check for admin events (kind 23456) and intercept them
if (result == 0) {
cJSON* kind_obj = cJSON_GetObjectItem(event, "kind");
cJSON* kind_obj = cJSON_GetObjectItemCaseSensitive(event, "kind");
if (kind_obj && cJSON_IsNumber(kind_obj)) {
int event_kind = (int)cJSON_GetNumberValue(kind_obj);
@@ -890,7 +1114,7 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso
}
// Send OK response
cJSON* event_id = cJSON_GetObjectItem(event, "id");
cJSON* event_id = cJSON_GetObjectItemCaseSensitive(event, "id");
if (event_id && cJSON_IsString(event_id)) {
cJSON* response = cJSON_CreateArray();
cJSON_AddItemToArray(response, cJSON_CreateString("OK"));
@@ -929,6 +1153,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
@@ -949,7 +1188,15 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso
return 0;
}
}
// Mark session as active - client sent REQ
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);
DEBUG_TRACE("REQ message passed authentication check");
// Handle REQ message
@@ -1247,15 +1494,23 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso
const char* msg_type = cJSON_GetStringValue(type);
if (strcmp(msg_type, "EVENT") == 0) {
// Mark session as active - client sent EVENT
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);
// Extract event for kind-specific NIP-42 authentication check
cJSON* event_obj = cJSON_GetArrayItem(json, 1);
if (event_obj && cJSON_IsObject(event_obj)) {
// Extract event kind for kind-specific NIP-42 authentication check
cJSON* kind_obj = cJSON_GetObjectItem(event_obj, "kind");
cJSON* kind_obj = cJSON_GetObjectItemCaseSensitive(event_obj, "kind");
int event_kind = kind_obj && cJSON_IsNumber(kind_obj) ? (int)cJSON_GetNumberValue(kind_obj) : -1;
// Extract pubkey for debugging
cJSON* pubkey_obj = cJSON_GetObjectItem(event_obj, "pubkey");
cJSON* pubkey_obj = cJSON_GetObjectItemCaseSensitive(event_obj, "pubkey");
const char* event_pubkey = pubkey_obj ? cJSON_GetStringValue(pubkey_obj) : "unknown";
// Check if NIP-42 authentication is required for this event kind or globally
@@ -1264,7 +1519,7 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso
// Special case: allow kind 14 DMs addressed to relay to bypass auth (admin commands)
int bypass_auth = 0;
if (event_kind == 14 && event_obj && cJSON_IsObject(event_obj)) {
cJSON* tags = cJSON_GetObjectItem(event_obj, "tags");
cJSON* tags = cJSON_GetObjectItemCaseSensitive(event_obj, "tags");
if (tags && cJSON_IsArray(tags)) {
const char* relay_pubkey = get_config_value("relay_pubkey");
if (relay_pubkey) {
@@ -1344,6 +1599,25 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso
return 0;
}
// Early duplicate check: skip expensive crypto for events already in DB.
cJSON* id_obj_dup2 = cJSON_GetObjectItemCaseSensitive(event, "id");
if (id_obj_dup2 && cJSON_IsString(id_obj_dup2)) {
const char* event_id_str2 = cJSON_GetStringValue(id_obj_dup2);
if (event_id_str2 && event_id_exists_in_db(event_id_str2)) {
DEBUG_TRACE("Duplicate event %s — skipping signature verification", event_id_str2);
char ok_msg2[128];
snprintf(ok_msg2, sizeof(ok_msg2),
"[\"OK\",\"%s\",true,\"duplicate: already have this event\"]",
event_id_str2);
size_t ok_len2 = strlen(ok_msg2);
queue_message(wsi, pss, ok_msg2, ok_len2, LWS_WRITE_TEXT);
free(event_json_str);
cJSON_Delete(json);
free(message);
return 0;
}
}
// Call unified validator with JSON string
size_t event_json_len = strlen(event_json_str);
int validation_result = nostr_validate_unified_request(event_json_str, event_json_len);
@@ -1390,7 +1664,7 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso
if (result == 0) {
// Check if event has protected tag ["-"]
int is_protected_event = 0;
cJSON* tags = cJSON_GetObjectItem(event, "tags");
cJSON* tags = cJSON_GetObjectItemCaseSensitive(event, "tags");
if (tags && cJSON_IsArray(tags)) {
cJSON* tag = NULL;
cJSON_ArrayForEach(tag, tags) {
@@ -1417,7 +1691,7 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso
DEBUG_WARN("Protected event rejected: protected events not enabled");
} else {
// Protected events enabled - check authentication
cJSON* pubkey_obj = cJSON_GetObjectItem(event, "pubkey");
cJSON* pubkey_obj = cJSON_GetObjectItemCaseSensitive(event, "pubkey");
const char* event_pubkey = pubkey_obj ? cJSON_GetStringValue(pubkey_obj) : NULL;
if (!pss || !pss->authenticated ||
@@ -1434,7 +1708,7 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso
// Check for admin events (kind 23456) and intercept them
if (result == 0) {
cJSON* kind_obj = cJSON_GetObjectItem(event, "kind");
cJSON* kind_obj = cJSON_GetObjectItemCaseSensitive(event, "kind");
if (kind_obj && cJSON_IsNumber(kind_obj)) {
int event_kind = (int)cJSON_GetNumberValue(kind_obj);
@@ -1626,7 +1900,7 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso
}
// Send OK response
cJSON* event_id = cJSON_GetObjectItem(event, "id");
cJSON* event_id = cJSON_GetObjectItemCaseSensitive(event, "id");
if (event_id && cJSON_IsString(event_id)) {
cJSON* response = cJSON_CreateArray();
cJSON_AddItemToArray(response, cJSON_CreateString("OK"));
@@ -1667,6 +1941,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);
@@ -1679,9 +1969,17 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso
cJSON* sub_id = cJSON_GetArrayItem(json, 1);
if (sub_id && cJSON_IsString(sub_id)) {
const char* subscription_id = cJSON_GetStringValue(sub_id);
const char* subscription_id = cJSON_GetStringValue(sub_id);
DEBUG_TRACE("Processing REQ message for subscription %s", subscription_id);
// Mark session as active - client sent REQ
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);
DEBUG_TRACE("Processing REQ message for subscription %s", subscription_id);
// Validate subscription ID before processing
if (!subscription_id) {
@@ -1946,7 +2244,10 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso
case LWS_CALLBACK_CLOSED:
DEBUG_TRACE("WebSocket connection closed");
// Remove from global connection list (must happen before pss cleanup)
connection_list_remove(wsi);
// Enhanced closure logging with detailed diagnostics
if (pss) {
// Calculate connection duration
@@ -1959,6 +2260,26 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso
if (g_shutdown_flag || !g_server_running) {
reason = "server_shutdown";
}
// 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)
// Only record for WebSocket connections, not HTTP requests (NIP-11, embedded files)
if (!pss->session_active && pss->is_websocket && 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);
}
// Format authentication status
char auth_status[80];
@@ -2125,6 +2446,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
@@ -2321,11 +2647,30 @@ 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);
// Live debug level update: read from config table so it can be changed
// without restarting the relay (via config_set admin command or direct SQL)
int config_debug_level = get_config_int("debug_level", -1);
if (config_debug_level >= 0 && config_debug_level != g_debug_level) {
DEBUG_WARN("Debug level changed: %d -> %d", g_debug_level, config_debug_level);
g_debug_level = config_debug_level;
}
// Check and close idle connections (no REQ/EVENT sent within timeout)
int idle_timeout_sec = get_config_int("idle_connection_timeout_sec", 30);
check_idle_connections(idle_timeout_sec);
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);
}
}
}
@@ -2344,7 +2689,7 @@ int process_dm_stats_command(cJSON* dm_event, char* error_message, size_t error_
}
// Check if DM is addressed to relay
cJSON* tags = cJSON_GetObjectItem(dm_event, "tags");
cJSON* tags = cJSON_GetObjectItemCaseSensitive(dm_event, "tags");
if (!tags || !cJSON_IsArray(tags)) {
strncpy(error_message, "DM missing or invalid tags", error_size - 1);
return -1;
@@ -2380,7 +2725,7 @@ int process_dm_stats_command(cJSON* dm_event, char* error_message, size_t error_
}
// Get sender pubkey
cJSON* pubkey_obj = cJSON_GetObjectItem(dm_event, "pubkey");
cJSON* pubkey_obj = cJSON_GetObjectItemCaseSensitive(dm_event, "pubkey");
if (!pubkey_obj || !cJSON_IsString(pubkey_obj)) {
strncpy(error_message, "DM missing sender pubkey", error_size - 1);
return -1;
@@ -2419,7 +2764,7 @@ int process_dm_stats_command(cJSON* dm_event, char* error_message, size_t error_
}
// Get encrypted content
cJSON* content_obj = cJSON_GetObjectItem(dm_event, "content");
cJSON* content_obj = cJSON_GetObjectItemCaseSensitive(dm_event, "content");
if (!content_obj || !cJSON_IsString(content_obj)) {
strncpy(error_message, "DM missing content", error_size - 1);
return -1;
@@ -2545,7 +2890,7 @@ int handle_count_message(const char* sub_id, cJSON* filters, struct lws *wsi, st
// after retrieving events to ensure compatibility with all SQLite versions
// Handle kinds filter
cJSON* kinds = cJSON_GetObjectItem(filter, "kinds");
cJSON* kinds = cJSON_GetObjectItemCaseSensitive(filter, "kinds");
if (kinds && cJSON_IsArray(kinds)) {
int kind_count = cJSON_GetArraySize(kinds);
if (kind_count > 0) {
@@ -2573,7 +2918,7 @@ int handle_count_message(const char* sub_id, cJSON* filters, struct lws *wsi, st
}
// Handle authors filter
cJSON* authors = cJSON_GetObjectItem(filter, "authors");
cJSON* authors = cJSON_GetObjectItemCaseSensitive(filter, "authors");
if (authors && cJSON_IsArray(authors)) {
int author_count = 0;
// Count valid authors
@@ -2617,7 +2962,7 @@ int handle_count_message(const char* sub_id, cJSON* filters, struct lws *wsi, st
}
// Handle ids filter
cJSON* ids = cJSON_GetObjectItem(filter, "ids");
cJSON* ids = cJSON_GetObjectItemCaseSensitive(filter, "ids");
if (ids && cJSON_IsArray(ids)) {
int id_count = 0;
// Count valid ids
@@ -2719,7 +3064,7 @@ int handle_count_message(const char* sub_id, cJSON* filters, struct lws *wsi, st
}
// Handle search filter (NIP-50)
cJSON* search = cJSON_GetObjectItem(filter, "search");
cJSON* search = cJSON_GetObjectItemCaseSensitive(filter, "search");
if (search && cJSON_IsString(search)) {
const char* search_term = cJSON_GetStringValue(search);
if (search_term && strlen(search_term) > 0) {
@@ -2747,7 +3092,7 @@ int handle_count_message(const char* sub_id, cJSON* filters, struct lws *wsi, st
}
// Handle since filter
cJSON* since = cJSON_GetObjectItem(filter, "since");
cJSON* since = cJSON_GetObjectItemCaseSensitive(filter, "since");
if (since && cJSON_IsNumber(since)) {
snprintf(sql_ptr, remaining, " AND created_at >= %ld", (long)cJSON_GetNumberValue(since));
sql_ptr += strlen(sql_ptr);
@@ -2755,7 +3100,7 @@ int handle_count_message(const char* sub_id, cJSON* filters, struct lws *wsi, st
}
// Handle until filter
cJSON* until = cJSON_GetObjectItem(filter, "until");
cJSON* until = cJSON_GetObjectItemCaseSensitive(filter, "until");
if (until && cJSON_IsNumber(until)) {
snprintf(sql_ptr, remaining, " AND created_at <= %ld", (long)cJSON_GetNumberValue(until));
sql_ptr += strlen(sql_ptr);
+12
View File
@@ -89,8 +89,16 @@ struct per_session_data {
int db_queries_executed; // Total SELECT queries executed by this connection
int db_rows_returned; // Total rows returned across all queries
time_t query_tracking_start; // When connection was established (for rate calculation)
// 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)
int is_websocket; // 1 if this is a WebSocket connection, 0 for HTTP
};
// Get current active WebSocket connection count
int get_active_connection_count(void);
// NIP-11 HTTP session data structure for managing buffer lifetime
struct nip11_session_data {
int type; // 0 for NIP-11
@@ -107,6 +115,10 @@ int start_websocket_relay(int port_override, int strict_port);
int queue_message(struct lws* wsi, struct per_session_data* pss, const char* message, size_t length, enum lws_write_protocol type);
int process_message_queue(struct lws* wsi, struct per_session_data* pss);
// Zero-copy variant: caller allocates (LWS_PRE + length) bytes, writes message at buf+LWS_PRE,
// then passes ownership to the queue. The queue will free buf when done. No memcpy performed.
int queue_message_take_ownership(struct lws* wsi, struct per_session_data* pss, unsigned char* buf, size_t length, enum lws_write_protocol type);
// Auth rules checking function from request_validator.c
int check_database_auth_rules(const char *pubkey, const char *operation, const char *resource_hash);
+88
View File
@@ -0,0 +1,88 @@
=== NIP-42 Authentication Test Started ===
2026-02-24 09:45:30 - Starting NIP-42 authentication tests
[INFO] === Starting NIP-42 Authentication Tests ===
[INFO] Checking dependencies...
[SUCCESS] Dependencies check complete
[INFO] Test 1: Checking NIP-42 support in relay info
[SUCCESS] NIP-42 is advertised in supported NIPs
2026-02-24 09:45:30 - Supported NIPs: 1,2,4,9,11,12,13,15,16,20,22,33,40,42,50,70
[INFO] Test 2: Testing AUTH challenge generation
[WARNING] Could not extract admin private key from relay.log - using manual test approach
[INFO] Manual test: Connect to relay and send an event without auth to trigger challenge
[INFO] Test 3: Testing complete NIP-42 authentication flow
[INFO] Generated test keypair: test_pubkey
[INFO] Attempting to publish event without authentication...
[INFO] Publishing test event to relay...
2026-02-24 09:45:31 - Event publish result: connecting to ws://localhost:8888... ok.
{"kind":1,"id":"74a0b723797569b2483d47457d2f6e2378aed6ccd4cca0f553038e0431ade0e8","pubkey":"7a2f213c220c46a194c5730e5bb2fe9b27304f0c27226f380086d93ff10bf7da","created_at":1771940731,"tags":[],"content":"NIP-42 test event - should require auth","sig":"319466df2167cd7f33c83d41b09bdd06f23c961380fd205a0a5dc5a923f11e45b6d158d79e251239bef364430c5d45e6a9e43fe93fd629864a88de00e6766a44"}
publishing to ws://localhost:8888... success.
[SUCCESS] Relay requested authentication as expected
[INFO] Test 4: Testing WebSocket AUTH message handling
[INFO] Testing WebSocket connection and AUTH message...
[INFO] Sending test message via WebSocket...
2026-02-24 09:45:31 - WebSocket response:
[INFO] No AUTH challenge in WebSocket response
[INFO] Test 5: Testing NIP-42 configuration options
[INFO] Retrieving current relay configuration...
[WARNING] Could not retrieve configuration events
[INFO] Test 6: Testing NIP-42 performance and stability
[INFO] Testing multiple authentication attempts...
2026-02-24 09:45:33 - Attempt 1: .264126491s - connecting to ws://localhost:8888... ok.
{"kind":1,"id":"9cb8f626ced97e8fc406bd2d2358074fe33542d983d11a7f039a26919ded6039","pubkey":"7c098dbaeeaca6fb27798625835dfc5c13fb31096eed76c6d77269a2a08cd22b","created_at":1771940733,"tags":[],"content":"Performance test event 1","sig":"87025c881004a7936589785bbba6171542135348e4f798c490c033eb462c9a0322882cfe1a2b742e3644b6c69ee4fee76feb3cffecd6d64c096fa8038bcd84c4"}
publishing to ws://localhost:8888... success.
2026-02-24 09:45:33 - Attempt 2: .262980094s - connecting to ws://localhost:8888... ok.
{"kind":1,"id":"bb1b3da4cccbcdc6be73646e29ecf060b68d8cb9340f8b23da0f167ffac0831d","pubkey":"7c098dbaeeaca6fb27798625835dfc5c13fb31096eed76c6d77269a2a08cd22b","created_at":1771940733,"tags":[],"content":"Performance test event 2","sig":"1349f7af97edb9f507081782cfb2d055eb810ec96056b97692920060091ccf39b8d1ba8590d25e1b3c92f4b785f6362ac0d734373d29d4159e5778f8982b086f"}
publishing to ws://localhost:8888... success.
2026-02-24 09:45:34 - Attempt 3: .296311100s - connecting to ws://localhost:8888... ok.
{"kind":1,"id":"3b171de7c00e918ad0bcbedea4c07c69f59a9d20c31b0de957488c919fa5c116","pubkey":"7c098dbaeeaca6fb27798625835dfc5c13fb31096eed76c6d77269a2a08cd22b","created_at":1771940734,"tags":[],"content":"Performance test event 3","sig":"a1699f12064d4da7ba15519eb7666cd2801f333ee837df70ce1298ac97477e900d095dce8fed0bade8f3e3e2e2b68e22800d10ba213a1de6cd1537112d6a6c2a"}
publishing to ws://localhost:8888... success.
2026-02-24 09:45:35 - Attempt 4: .300140904s - connecting to ws://localhost:8888... ok.
{"kind":1,"id":"f3587b9955204284ec65f2b171d1fa3f330e0bbfc2d95fe7ad0550a63a6aabe6","pubkey":"7c098dbaeeaca6fb27798625835dfc5c13fb31096eed76c6d77269a2a08cd22b","created_at":1771940734,"tags":[],"content":"Performance test event 4","sig":"fb78cd3bce49097eea1c67c0a1ee92bf4cdf5bf6396ce20519481d0e4d09b03dca05c70da2ddf5ba51d23aa3253b21ec3b57bc41d000f274bb1123a4c3d64c45"}
publishing to ws://localhost:8888... success.
2026-02-24 09:45:35 - Attempt 5: .373738147s - connecting to ws://localhost:8888... ok.
{"kind":1,"id":"e1fdf2d0579f0e2b446bdd9dbe5f5e53913b7d328d6f527ac7142636979c1ff2","pubkey":"7c098dbaeeaca6fb27798625835dfc5c13fb31096eed76c6d77269a2a08cd22b","created_at":1771940735,"tags":[],"content":"Performance test event 5","sig":"a11a11991434511737092d8cf2806a700a207c81c08e341f8d98c8b3369845df35adb9247ff094378e6712d68e5b2657d628e809b17bc9f838f6f5d9c21ce96c"}
publishing to ws://localhost:8888... success.
[SUCCESS] Performance test completed: 5/5 successful responses
[INFO] Test 7: Testing kind-specific NIP-42 authentication requirements
[INFO] Generated test keypair for kind-specific tests: test_pubkey
[INFO] Testing kind 1 event (regular note) - should work without authentication...
2026-02-24 09:45:36 - Kind 1 event result: connecting to ws://localhost:8888... ok.
{"kind":1,"id":"85430aa1bb17e0aa7f5f05568448e35570a0d8446a79600f033e2683b17f8902","pubkey":"1cee941c80f1fa0037047151cf577503a1243cbef748f572b9fd6062a36807db","created_at":1771940736,"tags":[],"content":"Regular note - should not require auth","sig":"118a2dec1256aca39b22b9027c8f56e0b8f60bae1d95f38408ac9d9a6eb6590c48e4a37bf561e29f993b435ceecc1ff2218fcc9dec3e2dcf305cd456ac6a0ee4"}
publishing to ws://localhost:8888... success.
[SUCCESS] Kind 1 event accepted without authentication (correct behavior)
[INFO] Testing kind 4 event (direct message) - should require authentication...
2026-02-24 09:45:47 - Kind 4 event result: connecting to ws://localhost:8888... ok.
{"kind":4,"id":"32f20767dd83d6f1ee5e70f72c33c7caa8a5a03acbfbb4de5899f6b27b850be4","pubkey":"1cee941c80f1fa0037047151cf577503a1243cbef748f572b9fd6062a36807db","created_at":1771940737,"tags":[["p,test_pubkey"]],"content":"This is a direct message - should require auth","sig":"cee96adf8c266120d98579734134cca652eeefa5f090d119b794a964bae4d2568e3aa39d648e5a897bb7f8d65be19f338291af3ffe87accdbf502f5eaae453ab"}
publishing to ws://localhost:8888...
[SUCCESS] Kind 4 event requested authentication (correct behavior for DMs)
[INFO] Testing kind 14 event (chat message) - should require authentication...
2026-02-24 09:45:57 - Kind 14 event result: connecting to ws://localhost:8888... ok.
{"kind":14,"id":"0a6d37f32fc10dae793b61d8f3afb9d28bdf12d59cf25d73eb90461d8efaa117","pubkey":"1cee941c80f1fa0037047151cf577503a1243cbef748f572b9fd6062a36807db","created_at":1771940747,"tags":[["p,test_pubkey"]],"content":"Chat message - should require auth","sig":"5337ded22499a8c361445d6c660f297e636cc5adf2735a85bb7d629e5771e0c47176b2afde02f4af9645198d441fb0aedd78931d0200d8b5ed8f721e6460d5c9"}
publishing to ws://localhost:8888...
[SUCCESS] Kind 14 event requested authentication (correct behavior for DMs)
[INFO] Testing other event kinds - should work without authentication...
2026-02-24 09:45:58 - Kind 0 event result: connecting to ws://localhost:8888... ok.
{"kind":0,"id":"5836e2da48eb458c8faca084afc3827ee6e481574d0295dc7bfdc50d9495891a","pubkey":"1cee941c80f1fa0037047151cf577503a1243cbef748f572b9fd6062a36807db","created_at":1771940757,"tags":[],"content":"Test event kind 0 - should not require auth","sig":"e454d5990a157b6211b655a9219ad494c58d49a59d61c4e1fb0aa91b96e5ff2368efc57e2acd0a24c7198b1181c37d0ab535f608289ec83afdc0b68e076def7c"}
publishing to ws://localhost:8888... success.
[SUCCESS] Kind 0 event accepted without authentication (correct)
2026-02-24 09:45:58 - Kind 3 event result: connecting to ws://localhost:8888... ok.
{"kind":3,"id":"d11a038044f9ea859338c6ab879c4628aece258f2cad0ed7ca88808b35ec899c","pubkey":"1cee941c80f1fa0037047151cf577503a1243cbef748f572b9fd6062a36807db","created_at":1771940758,"tags":[],"content":"Test event kind 3 - should not require auth","sig":"9fbf410cd609f9f7a6bf3118ecfe554776bb647d5ed988c0d4df06f83c1283c8baeaa0b62da900934cc8f617c73572fbce9f7232c77bb87942e4471228b06c73"}
publishing to ws://localhost:8888... success.
[SUCCESS] Kind 3 event accepted without authentication (correct)
2026-02-24 09:45:59 - Kind 7 event result: connecting to ws://localhost:8888... ok.
{"kind":7,"id":"b819482f287f6370fac7804c2cdcdac8340eafd86799b24af0a11f74c6190fed","pubkey":"1cee941c80f1fa0037047151cf577503a1243cbef748f572b9fd6062a36807db","created_at":1771940758,"tags":[],"content":"Test event kind 7 - should not require auth","sig":"f197c7c072af111dd92220c489472473f5b4fb30e908501e4d2d0037f1a0064960550a4e51af4168572a192295d870f6a6c49473308a25dacbc0deb703003547"}
publishing to ws://localhost:8888... success.
[SUCCESS] Kind 7 event accepted without authentication (correct)
[INFO] Kind-specific authentication test completed
[INFO] === NIP-42 Test Results Summary ===
[SUCCESS] Dependencies: PASS
[SUCCESS] NIP-42 Support: PASS
[SUCCESS] Auth Challenge: PASS
[SUCCESS] Auth Flow: PASS
[SUCCESS] WebSocket AUTH: PASS
[SUCCESS] Configuration: PASS
[SUCCESS] Performance: PASS
[SUCCESS] Kind-Specific Auth: PASS
[SUCCESS] All NIP-42 tests completed successfully!
[SUCCESS] NIP-42 authentication implementation is working correctly
[INFO] === NIP-42 Authentication Tests Complete ===