Compare commits

..
45 Commits
Author SHA1 Message Date
Your Name b0c0754e83 v1.2.51 - Fix NIP-01 non-compliance: limit:0 now correctly returns zero stored events instead of falling through to default_limit 2026-03-04 11:23:58 -04:00
Your Name 3265e3d114 v1.2.50 - Fix NIP-42 relay URL verification and add onauth to all publish flows 2026-03-01 12:20:44 -04:00
Your Name 927659ece1 v1.2.49 - Fix: MEM% bar format (bar then MB), move CPU Core row after CPU Usage 2026-02-25 07:37:50 -04:00
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
Your Name 0dc5b75d7c v1.2.7 - Add configurable SQLite mmap_size (256MB) and cache_size (64MB) PRAGMAs for ~20% CPU reduction from DB read overhead 2026-02-23 09:48:01 -04:00
32 changed files with 3503 additions and 332 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;
}
+118 -6
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>
@@ -119,14 +124,14 @@
<td>Memory Usage</td>
<td id="memory-usage">-</td>
</tr>
<tr>
<td>CPU Core</td>
<td id="cpu-core">-</td>
</tr>
<tr>
<td>CPU Usage</td>
<td id="cpu-usage">-</td>
</tr>
<tr>
<td>CPU Core</td>
<td id="cpu-core">-</td>
</tr>
<tr>
<td>Oldest Event</td>
<td id="oldest-event">-</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>
+672 -104
View File
File diff suppressed because it is too large Load Diff
+95 -1
View File
@@ -3297,8 +3297,18 @@ var NostrTools = (() => {
this.enablePing = opts.enablePing;
}
async ensureRelay(url, params) {
const rawUrl = url;
const debugEnabled = typeof window !== "undefined" && !!window.__SIMPLE_POOL_DEBUG__;
url = normalizeURL(url);
let relay = this.relays.get(url);
if (debugEnabled) {
console.log("🔎 SIMPLE_POOL ensureRelay START", {
rawUrl,
normalizedUrl: url,
hadRelay: !!relay,
relayMapKeysBefore: Array.from(this.relays.keys())
});
}
if (!relay) {
relay = new AbstractRelay(url, {
verifyEvent: this.trustedRelayURLs.has(url) ? alwaysTrue : this.verifyEvent,
@@ -3306,13 +3316,38 @@ var NostrTools = (() => {
enablePing: this.enablePing
});
relay.onclose = () => {
if (debugEnabled) {
console.log("🔎 SIMPLE_POOL ensureRelay onclose", {
normalizedUrl: url,
relayMapKeysBeforeDelete: Array.from(this.relays.keys())
});
}
this.relays.delete(url);
if (debugEnabled) {
console.log("🔎 SIMPLE_POOL ensureRelay onclose complete", {
normalizedUrl: url,
relayMapKeysAfterDelete: Array.from(this.relays.keys())
});
}
};
if (params?.connectionTimeout)
relay.connectionTimeout = params.connectionTimeout;
this.relays.set(url, relay);
if (debugEnabled) {
console.log("🔎 SIMPLE_POOL ensureRelay created relay", {
normalizedUrl: url,
relayMapKeysAfterCreate: Array.from(this.relays.keys())
});
}
}
await relay.connect();
if (debugEnabled) {
console.log("🔎 SIMPLE_POOL ensureRelay connected", {
normalizedUrl: url,
relayConnected: relay.connected,
relayMapKeysAfterConnect: Array.from(this.relays.keys())
});
}
return relay;
}
close(relays) {
@@ -3334,16 +3369,26 @@ var NostrTools = (() => {
}
subscribeMany(relays, filters, params) {
params.onauth = params.onauth || params.doauth;
const debugEnabled = typeof window !== "undefined" && !!window.__SIMPLE_POOL_DEBUG__;
const request = [];
const uniqUrls = [];
for (let i2 = 0; i2 < relays.length; i2++) {
const url = normalizeURL(relays[i2]);
if (uniqUrls.indexOf(url) === -1) {
uniqUrls.push(url);
for (let f2 = 0; f2 < filters.length; f2++) {
request.push({ url, filter: filters[f2] });
}
}
}
if (debugEnabled) {
console.log("🔎 SIMPLE_POOL subscribeMany", {
relaysInput: relays,
uniqUrls,
filtersCount: filters.length,
requestsCount: request.length
});
}
return this.subscribeMap(request, params);
}
subscribeMap(requests, params) {
@@ -3391,14 +3436,31 @@ var NostrTools = (() => {
_knownIds.add(id);
return have;
};
const debugEnabled = typeof window !== "undefined" && !!window.__SIMPLE_POOL_DEBUG__;
const allOpened = Promise.all(
requests.map(async ({ url, filter }, i2) => {
if (debugEnabled) {
console.log("🔎 SIMPLE_POOL subscribeMap request", {
index: i2,
url,
filterKinds: filter?.kinds,
hasAuthorFilter: !!filter?.authors,
hasPTagFilter: !!filter?.["#p"]
});
}
let relay;
try {
relay = await this.ensureRelay(url, {
connectionTimeout: params.maxWait ? Math.max(params.maxWait * 0.8, params.maxWait - 1e3) : void 0
});
} catch (err) {
if (debugEnabled) {
console.log("🔎 SIMPLE_POOL subscribeMap ensureRelay FAILED", {
index: i2,
url,
error: err?.message || String(err)
});
}
handleClose(i2, err?.message || String(err));
return;
}
@@ -3480,18 +3542,50 @@ var NostrTools = (() => {
return events[0] || null;
}
publish(relays, event, options) {
return relays.map(normalizeURL).map(async (url, i2, arr) => {
const debugEnabled = typeof window !== "undefined" && !!window.__SIMPLE_POOL_DEBUG__;
const normalizedRelays = relays.map(normalizeURL);
if (debugEnabled) {
console.log("🔎 SIMPLE_POOL publish START", {
relaysInput: relays,
normalizedRelays,
eventKind: event?.kind,
eventId: event?.id
});
}
return normalizedRelays.map(async (url, i2, arr) => {
if (arr.indexOf(url) !== i2) {
if (debugEnabled) {
console.log("🔎 SIMPLE_POOL publish duplicate URL", { url, index: i2 });
}
return Promise.reject("duplicate url");
}
let r = await this.ensureRelay(url);
if (debugEnabled) {
console.log("🔎 SIMPLE_POOL publish ensured relay", {
url,
relayConnected: r?.connected,
relayMapKeys: Array.from(this.relays.keys())
});
}
return r.publish(event).catch(async (err) => {
if (debugEnabled) {
console.log("🔎 SIMPLE_POOL publish ERROR", {
url,
error: err?.message || String(err)
});
}
if (err instanceof Error && err.message.startsWith("auth-required: ") && options?.onauth) {
await r.auth(options.onauth);
return r.publish(event);
}
throw err;
}).then((reason) => {
if (debugEnabled) {
console.log("🔎 SIMPLE_POOL publish SUCCESS", {
url,
reason
});
}
if (this.trackRelays) {
let set = this.seenOn.get(event.id);
if (!set) {
+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 ""
+1
View File
@@ -0,0 +1 @@
key,value,data_type,description,category,requires_restart,created_at,updated_at
1 key value data_type description category requires_restart created_at updated_at
+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 @@
1946657
2946182
+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;
+173 -51
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));
@@ -951,6 +951,124 @@ 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) {
snprintf(error_msg, error_size, "invalid sqlite_mmap_size '%s' (must be non-negative integer bytes)", value);
return -1;
}
return 0;
}
if (strcmp(key, "sqlite_cache_size_kb") == 0) {
if (!is_valid_positive_integer(value)) {
snprintf(error_msg, error_size, "invalid sqlite_cache_size_kb '%s' (must be positive integer KB)", value);
return -1;
}
return 0;
}
// Boolean fields
if (strcmp(key, "auth_enabled") == 0 ||
@@ -1257,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;
@@ -1321,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");
@@ -1396,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;
@@ -1719,7 +1837,9 @@ int populate_default_config_values(void) {
strcmp(key, "default_limit") == 0 ||
strcmp(key, "max_limit") == 0 ||
strcmp(key, "nip42_challenge_expiration") == 0 ||
strcmp(key, "nip40_expiration_grace_period") == 0) {
strcmp(key, "nip40_expiration_grace_period") == 0 ||
strcmp(key, "sqlite_mmap_size") == 0 ||
strcmp(key, "sqlite_cache_size_kb") == 0) {
data_type = "integer";
} else if (strcmp(key, "auth_enabled") == 0 ||
strcmp(key, "nip40_expiration_enabled") == 0 ||
@@ -1939,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");
@@ -1964,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) {
@@ -2054,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
@@ -2429,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);
@@ -2447,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;
@@ -2557,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));
@@ -2671,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));
}
@@ -2765,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");
@@ -2790,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");
@@ -2812,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);
@@ -2943,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) {
@@ -3131,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) {
@@ -3246,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) {
@@ -3342,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) {
@@ -3406,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) {
@@ -3483,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) {
@@ -3544,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) {
@@ -3576,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) {
@@ -3600,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) {
@@ -3658,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) {
@@ -3720,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;
@@ -3803,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) {
@@ -3933,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) {
@@ -4001,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;
@@ -4082,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)) {
@@ -4282,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) {
@@ -4310,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) {
@@ -4339,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) {
@@ -4499,7 +4619,9 @@ int populate_all_config_values_atomic(const char* admin_pubkey, const char* rela
strcmp(key, "default_limit") == 0 ||
strcmp(key, "max_limit") == 0 ||
strcmp(key, "nip42_challenge_expiration") == 0 ||
strcmp(key, "nip40_expiration_grace_period") == 0) {
strcmp(key, "nip40_expiration_grace_period") == 0 ||
strcmp(key, "sqlite_mmap_size") == 0 ||
strcmp(key, "sqlite_cache_size_kb") == 0) {
data_type = "integer";
} else if (strcmp(key, "auth_enabled") == 0 ||
strcmp(key, "nip40_expiration_enabled") == 0 ||
@@ -4743,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;
@@ -4892,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;
@@ -5094,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;
}
+32
View File
@@ -83,6 +83,38 @@ 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", "0"}, // Seconds before idle connection is closed (0 = disabled)
{"idle_ban_enabled", "false"}, // 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
{"sqlite_mmap_size", "268435456"},
// cache_size_kb: SQLite page cache size in KB (negative = KB, positive = pages of 4KB each)
// Default 2000KB is too small for a busy relay; 65536KB (64MB) keeps hot data in memory
{"sqlite_cache_size_kb", "65536"},
// NIP-59 Gift Wrap Timestamp Configuration
{"nip59_timestamp_max_delay_sec", "0"},
+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
+98 -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();
@@ -566,6 +570,40 @@ int init_database(const char* database_path_override) {
DEBUG_LOG("SQLite WAL mode enabled");
}
// Apply SQLite performance tuning PRAGMAs from config
// mmap_size: memory-map the database file to eliminate pread64 syscall overhead
// Default 256MB covers most relay databases; set to 0 to disable
long mmap_size = get_config_int("sqlite_mmap_size", 268435456);
if (mmap_size > 0) {
char mmap_pragma[64];
snprintf(mmap_pragma, sizeof(mmap_pragma), "PRAGMA mmap_size=%ld;", mmap_size);
char* mmap_error = NULL;
rc = sqlite3_exec(g_db, mmap_pragma, NULL, NULL, &mmap_error);
if (rc != SQLITE_OK) {
DEBUG_WARN("Failed to set mmap_size: %s", mmap_error ? mmap_error : "unknown");
if (mmap_error) sqlite3_free(mmap_error);
} else {
DEBUG_LOG("SQLite mmap_size set to %ld bytes", mmap_size);
}
}
// cache_size_kb: page cache size in KB (negative value = KB, positive = number of 4KB pages)
// Default 64MB keeps hot event data in memory and reduces repeated disk reads
int cache_size_kb = get_config_int("sqlite_cache_size_kb", 65536);
if (cache_size_kb != 0) {
char cache_pragma[64];
// Use negative value so SQLite interprets it as KB rather than page count
snprintf(cache_pragma, sizeof(cache_pragma), "PRAGMA cache_size=-%d;", cache_size_kb > 0 ? cache_size_kb : -cache_size_kb);
char* cache_error = NULL;
rc = sqlite3_exec(g_db, cache_pragma, NULL, NULL, &cache_error);
if (rc != SQLITE_OK) {
DEBUG_WARN("Failed to set cache_size: %s", cache_error ? cache_error : "unknown");
if (cache_error) sqlite3_free(cache_error);
} else {
DEBUG_LOG("SQLite cache_size set to %d KB", cache_size_kb);
}
}
DEBUG_TRACE("Exiting init_database() - success");
return 0;
}
@@ -705,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");
@@ -829,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) {
@@ -847,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;
@@ -971,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;
@@ -1206,6 +1260,13 @@ int handle_req_message(const char* sub_id, cJSON* filters, struct lws *wsi, stru
continue;
}
// NIP-01: limit:0 means return zero stored events — skip SQL entirely, just send EOSE
cJSON* limit_check = cJSON_GetObjectItemCaseSensitive(filter, "limit");
if (limit_check && cJSON_IsNumber(limit_check) && (int)cJSON_GetNumberValue(limit_check) == 0) {
DEBUG_LOG("Filter has limit:0 — skipping query, sending EOSE immediately (NIP-01 compliance)");
continue;
}
// Reset bind params for this filter
free_bind_params(bind_params, bind_param_count);
bind_params = NULL;
@@ -1222,7 +1283,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) {
@@ -1250,7 +1311,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
@@ -1290,7 +1351,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
@@ -1380,7 +1441,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) {
@@ -1408,7 +1469,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);
@@ -1416,7 +1477,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);
@@ -1429,7 +1490,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) {
@@ -1518,21 +1579,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);
@@ -1572,7 +1636,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;
@@ -1585,7 +1649,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)");
@@ -1620,7 +1684,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");
@@ -2112,6 +2176,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 6
#define CRELAY_VERSION "v1.2.6"
#define CRELAY_VERSION_PATCH 51
#define CRELAY_VERSION "v1.2.51"
// 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,
+22 -3
View File
@@ -12,7 +12,9 @@
#include <string.h>
#include <stdlib.h>
#include <time.h>
#include <stdio.h>
#include "websockets.h"
#include "ip_ban.h"
// Forward declaration for notice message function
@@ -93,13 +95,22 @@ void handle_nip42_auth_signed_event(struct lws* wsi, struct per_session_data* ps
// Verify authentication using existing request_validator function
// Note: nostr_nip42_verify_auth_event doesn't extract pubkey, we need to do that separately
char relay_url[RELAY_URL_MAX_LENGTH];
const char* configured_relay_url = get_config_value("relay_url");
if (configured_relay_url && configured_relay_url[0] != '\0') {
snprintf(relay_url, sizeof(relay_url), "%s", configured_relay_url);
} else {
int relay_port = (g_relay_port > 0) ? g_relay_port : get_config_int("relay_port", DEFAULT_PORT);
snprintf(relay_url, sizeof(relay_url), "ws://127.0.0.1:%d", relay_port);
}
int result = nostr_nip42_verify_auth_event(auth_event, challenge_copy,
"ws://localhost:8888", 600); // 10 minutes tolerance
relay_url, 600); // 10 minutes tolerance
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 +136,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");
+378 -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,86 @@ extern struct lws_context *ws_context;
// Global subscription manager
struct subscription_manager g_subscription_manager;
// Actual relay port bound by libwebsockets at runtime (after fallback/retry logic)
int g_relay_port = DEFAULT_PORT;
// 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 +276,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 +410,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 +469,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 +638,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 +778,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 +815,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 +881,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 +908,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 +925,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 +1117,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 +1156,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 +1191,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 +1497,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 +1522,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 +1602,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 +1667,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 +1694,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 +1711,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 +1903,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 +1944,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 +1972,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 +2247,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 +2263,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 +2449,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
@@ -2283,6 +2612,9 @@ int start_websocket_relay(int port_override, int strict_port) {
return -1;
}
// Persist the actual bound port for components that need the runtime relay URL
g_relay_port = actual_port;
char startup_msg[256];
if (actual_port != configured_port) {
snprintf(startup_msg, sizeof(startup_msg),
@@ -2321,11 +2653,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 +2695,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 +2731,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 +2770,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 +2896,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 +2924,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 +2968,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 +3070,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 +3098,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 +3106,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);
+15
View File
@@ -89,8 +89,19 @@ 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);
// Actual relay port bound by libwebsockets at runtime (after fallback/retry logic)
extern int g_relay_port;
// NIP-11 HTTP session data structure for managing buffer lifetime
struct nip11_session_data {
int type; // 0 for NIP-11
@@ -107,6 +118,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);
+272
View File
@@ -0,0 +1,272 @@
#!/usr/bin/env node
import WebSocket from 'ws';
import {
finalizeEvent,
generateSecretKey,
getPublicKey,
nip19,
nip42,
} from 'nostr-tools';
const RELAY_URL = process.env.RELAY_URL || 'ws://127.0.0.1:7777';
const EVENT_COUNT = Number(process.env.EVENT_COUNT || 5);
const DELAY_MS = Number(process.env.DELAY_MS || 2000);
const RECIPIENT_PUBKEY_ENV = process.env.RECIPIENT_PUBKEY || '';
const SECRET_INPUT = process.env.NOSTR_SECRET_KEY || '';
const OVERALL_TIMEOUT_MS = Number(process.env.OVERALL_TIMEOUT_MS || 120000);
function now() {
return new Date().toISOString();
}
function log(msg, extra = null) {
if (extra !== null) {
console.log(`[${now()}] ${msg}`, extra);
} else {
console.log(`[${now()}] ${msg}`);
}
}
function normalizeHexSecret(secretInput) {
if (!secretInput) return null;
const raw = secretInput.trim();
if (/^[0-9a-fA-F]{64}$/.test(raw)) {
return raw.toLowerCase();
}
if (raw.startsWith('nsec1')) {
const decoded = nip19.decode(raw);
if (decoded.type !== 'nsec') {
throw new Error('NOSTR_SECRET_KEY is nsec-like but did not decode as nsec');
}
if (typeof decoded.data === 'string') {
return decoded.data.toLowerCase();
}
if (decoded.data instanceof Uint8Array) {
return Buffer.from(decoded.data).toString('hex').toLowerCase();
}
throw new Error('Unsupported nsec decoded payload type');
}
throw new Error('NOSTR_SECRET_KEY must be 64-char hex or nsec1...');
}
const hexSecret = normalizeHexSecret(SECRET_INPUT) || Buffer.from(generateSecretKey()).toString('hex');
const secretKey = Buffer.from(hexSecret, 'hex');
const senderPubkey = getPublicKey(secretKey);
const recipientPubkey = RECIPIENT_PUBKEY_ENV || senderPubkey;
let ws;
let currentChallenge = null;
let authenticated = false;
let authAttempts = 0;
let sentAccepted = 0;
let nextSeq = 1;
let pendingEvent = null;
let waitingForAuth = false;
let finished = false;
let overallTimer = null;
function makeKind4Event(seq) {
const eventTemplate = {
kind: 4,
created_at: Math.floor(Date.now() / 1000),
tags: [['p', recipientPubkey]],
content: `kind4 disconnect probe seq=${seq} ts=${Date.now()}`,
pubkey: senderPubkey,
};
return finalizeEvent(eventTemplate, secretKey);
}
function sendJson(arr) {
const payload = JSON.stringify(arr);
ws.send(payload);
}
function sendAuth(challenge) {
const authTemplate = nip42.makeAuthEvent(RELAY_URL, challenge);
const signedAuth = finalizeEvent(authTemplate, secretKey);
authAttempts += 1;
log(`Sending AUTH attempt #${authAttempts} with challenge ${challenge}`);
sendJson(['AUTH', signedAuth]);
}
function scheduleNext() {
if (nextSeq > EVENT_COUNT) {
log(`All ${EVENT_COUNT} kind-4 events were accepted without disconnect`);
cleanupAndExit(0);
return;
}
setTimeout(() => {
if (!finished) {
publishNext();
}
}, DELAY_MS);
}
function publishNext() {
if (waitingForAuth) {
log('Still waiting for auth completion, postponing publish');
return;
}
const ev = makeKind4Event(nextSeq);
pendingEvent = ev;
log(`Publishing kind-4 seq=${nextSeq} id=${ev.id.slice(0, 12)}...`);
sendJson(['EVENT', ev]);
}
function retryPendingAfterAuth() {
if (!pendingEvent) return;
waitingForAuth = false;
log(`Retrying pending kind-4 id=${pendingEvent.id.slice(0, 12)}... after auth`);
sendJson(['EVENT', pendingEvent]);
}
function handleAuthRequiredSignal(source, msg) {
waitingForAuth = true;
log(`Relay signaled auth-required via ${source}: ${msg}`);
if (currentChallenge) {
sendAuth(currentChallenge);
} else {
log('No AUTH challenge received yet; waiting for relay AUTH challenge message');
}
}
function cleanupAndExit(code) {
if (finished) return;
finished = true;
if (overallTimer) clearTimeout(overallTimer);
const summary = {
relay: RELAY_URL,
sentAccepted,
expected: EVENT_COUNT,
authAttempts,
authenticated,
senderPubkey,
recipientPubkey,
};
if (code === 0) {
log('PASS: Connection remained stable through repeated kind-4 publish cycle', summary);
} else {
log('FAIL: Relay disconnected or test did not complete successfully', summary);
}
try {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.close();
}
} catch (_) {}
process.exit(code);
}
function main() {
log('Starting kind-4 disconnect probe', {
relay: RELAY_URL,
eventCount: EVENT_COUNT,
delayMs: DELAY_MS,
senderPubkey,
recipientPubkey,
});
ws = new WebSocket(RELAY_URL);
overallTimer = setTimeout(() => {
log(`Overall timeout reached (${OVERALL_TIMEOUT_MS}ms)`);
cleanupAndExit(1);
}, OVERALL_TIMEOUT_MS);
ws.on('open', () => {
log('WebSocket connected');
publishNext();
});
ws.on('message', (raw) => {
const text = raw.toString();
let msg;
try {
msg = JSON.parse(text);
} catch {
log(`Non-JSON relay message: ${text}`);
return;
}
const t = msg?.[0];
if (t === 'AUTH') {
currentChallenge = msg?.[1] || null;
log(`Received AUTH challenge: ${currentChallenge}`);
if (currentChallenge) sendAuth(currentChallenge);
return;
}
if (t === 'NOTICE') {
const notice = String(msg?.[1] || '');
log(`NOTICE: ${notice}`);
if (notice.toLowerCase().includes('auth-required')) {
handleAuthRequiredSignal('NOTICE', notice);
return;
}
if (notice.toLowerCase().includes('authentication successful')) {
authenticated = true;
log('Relay confirmed NIP-42 authentication success');
retryPendingAfterAuth();
}
return;
}
if (t === 'OK') {
const eventId = msg?.[1];
const ok = !!msg?.[2];
const reason = String(msg?.[3] || '');
log(`OK for ${String(eventId).slice(0, 12)}... accepted=${ok} reason=${reason}`);
if (!ok && reason.toLowerCase().includes('auth-required')) {
handleAuthRequiredSignal('OK', reason);
return;
}
if (pendingEvent && eventId === pendingEvent.id) {
if (ok) {
sentAccepted += 1;
nextSeq += 1;
pendingEvent = null;
waitingForAuth = false;
scheduleNext();
} else {
log(`Pending event rejected: ${reason}`);
cleanupAndExit(1);
}
}
return;
}
log('Relay message', msg);
});
ws.on('close', (code, reasonBuf) => {
const reason = reasonBuf?.toString?.() || '';
log(`WebSocket closed code=${code} reason=${reason}`);
if (!finished) {
if (sentAccepted >= EVENT_COUNT) {
cleanupAndExit(0);
} else {
cleanupAndExit(1);
}
}
});
ws.on('error', (err) => {
log(`WebSocket error: ${err.message}`);
cleanupAndExit(1);
});
}
main();
+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 ===